From fbd5f75a883e358504b408f5dced6af9f6bd47a1 Mon Sep 17 00:00:00 2001 From: muqimjon Date: Sat, 28 Feb 2026 00:27:01 +0500 Subject: [PATCH 01/36] fix: hide __SheetlyMigrationsHistory__ sheet on creation Both system sheets (__SheetlySchema__ and __SheetlyMigrationsHistory__) are now hidden from view after creation, as documented in README. --- src/Sheetly.Google/GoogleMigrationService.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Sheetly.Google/GoogleMigrationService.cs b/src/Sheetly.Google/GoogleMigrationService.cs index 15857da..e82b6bf 100644 --- a/src/Sheetly.Google/GoogleMigrationService.cs +++ b/src/Sheetly.Google/GoogleMigrationService.cs @@ -194,7 +194,10 @@ await provider.AppendRowAsync(SchemaTable, private async Task EnsureSystemTablesExistAsync() { if (!await provider.SheetExistsAsync(HistoryTable)) + { await provider.CreateSheetAsync(HistoryTable, ["MigrationId", "AppliedAt", "ProductVersion"]); + await provider.HideSheetAsync(HistoryTable); + } if (!await provider.SheetExistsAsync(SchemaTable)) { From 1585037964e8f98a462163d46daf21914f22141e Mon Sep 17 00:00:00 2001 From: muqimjon Date: Sat, 28 Feb 2026 00:29:34 +0500 Subject: [PATCH 02/36] perf: add in-memory sheet metadata cache to eliminate redundant API calls - Populate _sheetCache in InitializeAsync from single Spreadsheets.Get response - SheetExistsAsync now returns from cache (0 API calls, was 1 per check) - GetSheetIdInternal now returns from cache (0 API calls, was 1 per call) - CreateSheetAsync updates cache after creation - DeleteSheetAsync removes from cache after deletion - DropDatabaseAsync uses cache instead of extra Spreadsheets.Get --- src/Sheetly.Google/GoogleSheetProvider.cs | 52 ++++++++++++----------- 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/src/Sheetly.Google/GoogleSheetProvider.cs b/src/Sheetly.Google/GoogleSheetProvider.cs index 155048a..04ecfc3 100644 --- a/src/Sheetly.Google/GoogleSheetProvider.cs +++ b/src/Sheetly.Google/GoogleSheetProvider.cs @@ -14,6 +14,11 @@ public class GoogleSheetProvider : ISheetsProvider private readonly SheetsService _service; private readonly string _spreadsheetId; + // ── Sheet metadata cache ──────────────────────────────────────────────── + // Populated on InitializeAsync(); updated on CreateSheet/DeleteSheet. + // Eliminates per-operation Spreadsheets.Get() API calls. + private Dictionary _sheetCache = []; // name → sheetId + // ── Retry configuration ─────────────────────────────────────────────────── private const int MaxRetries = 5; private static readonly TimeSpan InitialRetryDelay = TimeSpan.FromSeconds(2); @@ -67,36 +72,33 @@ public GoogleSheetProvider(IConfigurationSection section, string spreadsheetId) public async Task InitializeAsync() { - await ExecuteWithRetryAsync(_service.Spreadsheets.Get(_spreadsheetId)); + var ss = await ExecuteWithRetryAsync(_service.Spreadsheets.Get(_spreadsheetId)); + _sheetCache = ss.Sheets + .Where(s => s.Properties?.Title != null) + .ToDictionary(s => s.Properties.Title!, s => (int)(s.Properties.SheetId ?? 0)); } public async Task DropDatabaseAsync() { - var ss = await ExecuteWithRetryAsync(_service.Spreadsheets.Get(_spreadsheetId)); - var sheetsList = ss.Sheets.ToList(); + // Work from cache — avoids extra Spreadsheets.Get() calls + var sheetNames = _sheetCache.Keys.ToList(); - // Get list of app-related sheets (migration tables and user tables) - var appSheets = sheetsList - .Where(s => s.Properties.Title.StartsWith("__Sheetly") || - !s.Properties.Title.Equals("Sheet1", StringComparison.OrdinalIgnoreCase)) + var appSheets = sheetNames + .Where(t => t.StartsWith("__Sheetly") || + !t.Equals("Sheet1", StringComparison.OrdinalIgnoreCase)) .ToList(); // If all sheets are app sheets, keep one default sheet - if (appSheets.Count == sheetsList.Count && sheetsList.Count > 0) + if (appSheets.Count == sheetNames.Count && sheetNames.Count > 0) { - // Create a default sheet first await CreateSheetAsync("Sheet1", new List()); - sheetsList = (await ExecuteWithRetryAsync(_service.Spreadsheets.Get(_spreadsheetId))).Sheets.ToList(); + sheetNames = _sheetCache.Keys.ToList(); // refresh from updated cache } - // Delete only app sheets, preserving default/empty sheets - foreach (var sheet in sheetsList) + foreach (var title in sheetNames) { - var title = sheet.Properties.Title; - - // Delete if it's a Sheetly system sheet or not a default sheet if (title.StartsWith("__Sheetly") || - (!title.Equals("Sheet1", StringComparison.OrdinalIgnoreCase) && sheetsList.Count > 1)) + (!title.Equals("Sheet1", StringComparison.OrdinalIgnoreCase) && sheetNames.Count > 1)) { await DeleteSheetAsync(title); } @@ -159,11 +161,8 @@ await ExecuteWithRetryAsync( new BatchUpdateSpreadsheetRequest { Requests = [deleteRequest] }, _spreadsheetId)); } - public async Task SheetExistsAsync(string sheetName) - { - var ss = await ExecuteWithRetryAsync(_service.Spreadsheets.Get(_spreadsheetId)); - return ss.Sheets.Any(s => s.Properties.Title == sheetName); - } + public Task SheetExistsAsync(string sheetName) + => Task.FromResult(_sheetCache.ContainsKey(sheetName)); public async Task CreateSheetAsync(string sheetName, IList headers) { @@ -192,6 +191,9 @@ public async Task CreateSheetAsync(string sheetName, IList headers) _service.Spreadsheets.BatchUpdate(batchRequest, _spreadsheetId)); var sheetId = response.Replies[0].AddSheet.Properties.SheetId; + // Update cache so subsequent SheetExistsAsync/GetSheetIdInternal are free + _sheetCache[sheetName] = (int)(sheetId ?? 0); + var headerRows = new List { new() { @@ -242,6 +244,7 @@ public async Task DeleteSheetAsync(string sheetName) var request = new Request { DeleteSheet = new DeleteSheetRequest { SheetId = sheetId } }; await ExecuteWithRetryAsync(_service.Spreadsheets.BatchUpdate( new BatchUpdateSpreadsheetRequest { Requests = [request] }, _spreadsheetId)); + _sheetCache.Remove(sheetName); } public async Task ClearSheetAsync(string sheetName) @@ -311,10 +314,11 @@ await ExecuteWithRetryAsync(_service.Spreadsheets.BatchUpdate( new BatchUpdateSpreadsheetRequest { Requests = [request] }, _spreadsheetId)); } - private async Task GetSheetIdInternal(string sheetName) + private Task GetSheetIdInternal(string sheetName) { - var ss = await ExecuteWithRetryAsync(_service.Spreadsheets.Get(_spreadsheetId)); - return ss.Sheets.FirstOrDefault(s => s.Properties.Title == sheetName)?.Properties.SheetId; + if (_sheetCache.TryGetValue(sheetName, out var id)) + return Task.FromResult(id); + return Task.FromResult(null); } public void Dispose() => _service?.Dispose(); From 2b06ab66cedea46e01b2d159878e8e31d0a285ef Mon Sep 17 00:00:00 2001 From: muqimjon Date: Sat, 28 Feb 2026 00:32:59 +0500 Subject: [PATCH 03/36] perf: replace 5-call ID management with formula-based auto-increment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ISheetsProvider.AppendRowAndGetIdAsync(sheetName, row): Task - GoogleSheetProvider: set ID cell to =IFERROR(MAX(INDIRECT(...))+1,1), parse row number from AppendRow response, read back computed value - InMemorySheetsProvider: compute MAX(Id)+1 in-memory for test parity - SheetsSet: use AppendRowAndGetIdAsync for PK tables (2 API calls vs 5) - Remove GetAndIncrementIdFromCentralSchema — no longer needed - ID is now computed atomically by Sheets formula, safer for concurrent writes --- .../Abstractions/ISheetsProvider.cs | 1 + src/Sheetly.Core/SheetsSet.cs | 69 +++---------------- src/Sheetly.Google/GoogleSheetProvider.cs | 40 +++++++++++ .../Helpers/InMemorySheetsProvider.cs | 21 ++++++ 4 files changed, 70 insertions(+), 61 deletions(-) diff --git a/src/Sheetly.Core/Abstractions/ISheetsProvider.cs b/src/Sheetly.Core/Abstractions/ISheetsProvider.cs index 53fb1ec..7240f84 100644 --- a/src/Sheetly.Core/Abstractions/ISheetsProvider.cs +++ b/src/Sheetly.Core/Abstractions/ISheetsProvider.cs @@ -8,6 +8,7 @@ public interface ISheetsProvider : IDisposable Task>> GetAllRowsAsync(string sheetName); Task?> GetRowByIndexAsync(string sheetName, int rowIndex); Task AppendRowAsync(string sheetName, IList row); + Task AppendRowAndGetIdAsync(string sheetName, IList row); Task UpdateRowAsync(string sheetName, int rowIndex, IList row); Task DeleteRowAsync(string sheetName, int rowIndex); diff --git a/src/Sheetly.Core/SheetsSet.cs b/src/Sheetly.Core/SheetsSet.cs index 822758b..6dccca6 100644 --- a/src/Sheetly.Core/SheetsSet.cs +++ b/src/Sheetly.Core/SheetsSet.cs @@ -13,8 +13,6 @@ namespace Sheetly.Core; private readonly List _includes = []; private bool _asNoTracking = false; - private const string SchemaTable = "__SheetlySchema__"; - public SheetsSet AsNoTracking() { _asNoTracking = true; @@ -231,16 +229,21 @@ internal async Task SaveChangesInternalAsync() if (toAdd.Count > 0) { var pkColumn = schema.Columns.FirstOrDefault(c => c.IsPrimaryKey); - int nextId = pkColumn != null ? await GetAndIncrementIdFromCentralSchema(schema.TableName, toAdd.Count) : 0; foreach (var item in toAdd) { + int assignedId; if (pkColumn != null) { + // Formula-based atomic ID: IFERROR(MAX(A2:A)+1,1) — 2 API calls vs old 5 + assignedId = await provider.AppendRowAndGetIdAsync(schema.TableName, EntityMapper.MapToRow(item.Key, schema)); var prop = typeof(T).GetProperty(pkColumn.PropertyName); - prop?.SetValue(item.Key, Convert.ChangeType(nextId++, prop.PropertyType)); + prop?.SetValue(item.Key, Convert.ChangeType(assignedId, prop.PropertyType)); + } + else + { + await provider.AppendRowAsync(schema.TableName, EntityMapper.MapToRow(item.Key, schema)); } - await provider.AppendRowAsync(schema.TableName, EntityMapper.MapToRow(item.Key, schema)); changes++; } } @@ -249,62 +252,6 @@ internal async Task SaveChangesInternalAsync() _entityRowIndexes.Clear(); return changes; } - - private async Task GetAndIncrementIdFromCentralSchema(string tableName, int count) - { - if (!await provider.SheetExistsAsync(SchemaTable)) - throw new Exception("__SheetlySchema__ table not found."); - - var rows = await provider.GetAllRowsAsync(SchemaTable); - int schemaIdValue = 0; - var pkPropertyName = schema.Columns.First(c => c.IsPrimaryKey).PropertyName; - - int schemaRowIndex = -1; - for (int i = 1; i < rows.Count; i++) - { - if (rows[i].Count > 2 && - rows[i][1]?.ToString() == tableName && - rows[i][2]?.ToString() == pkPropertyName) - { - schemaRowIndex = i; - if (rows[i].Count > 28) - _ = int.TryParse(rows[i][28]?.ToString(), out schemaIdValue); - break; - } - } - - // Also check actual data sheet for MAX(ID) - handles restart scenarios - int maxIdInSheet = 0; - if (await provider.SheetExistsAsync(tableName)) - { - var dataRows = await provider.GetAllRowsAsync(tableName); - if (dataRows.Count > 1) // Has data beyond header - { - // Find ID column index (first column is typically ID) - for (int i = 1; i < dataRows.Count; i++) - { - if (dataRows[i].Count > 0 && int.TryParse(dataRows[i][0]?.ToString(), out int id)) - { - if (id > maxIdInSheet) - maxIdInSheet = id; - } - } - } - } - - int currentId = Math.Max(schemaIdValue, maxIdInSheet); - - int nextId = currentId + 1; - - int newSchemaValue = nextId + count - 1; - - if (schemaRowIndex >= 0) - { - await provider.UpdateValueAsync(SchemaTable, $"AC{schemaRowIndex + 1}", newSchemaValue); - } - - return nextId; - } } public enum EntityState diff --git a/src/Sheetly.Google/GoogleSheetProvider.cs b/src/Sheetly.Google/GoogleSheetProvider.cs index 04ecfc3..231e941 100644 --- a/src/Sheetly.Google/GoogleSheetProvider.cs +++ b/src/Sheetly.Google/GoogleSheetProvider.cs @@ -136,6 +136,46 @@ public async Task AppendRowAsync(string sheetName, IList row) await ExecuteWithRetryAsync(request); } + /// + /// Appends a row where the first cell is a formula =IFERROR(MAX(INDIRECT("'Table'!A2:A"))+1,1). + /// Returns the computed integer ID after reading the cell back. + /// Reduces ID management from 5 API calls to 2 (append + read). + /// + public async Task AppendRowAndGetIdAsync(string sheetName, IList row) + { + // Replace the first element with a MAX+1 formula so Sheets computes the next ID atomically + var rowWithFormula = new List(row) + { + [0] = $"=IFERROR(MAX(INDIRECT(\"'{sheetName}'!A2:A\"))+1,1)" + }; + + var vr = new ValueRange { Values = new List> { rowWithFormula } }; + var request = _service.Spreadsheets.Values.Append(vr, _spreadsheetId, $"'{sheetName}'!A1"); + request.ValueInputOption = SpreadsheetsResource.ValuesResource.AppendRequest.ValueInputOptionEnum.USERENTERED; + var response = await ExecuteWithRetryAsync(request); + + // Extract the row number from the updated range (e.g. "'Products'!A5:E5" → 5) + var updatedRange = response.Updates?.UpdatedRange ?? string.Empty; + var rowNumber = ExtractRowNumberFromRange(updatedRange); + + // Read back the computed ID value + var idValue = await GetValueAsync(sheetName, $"A{rowNumber}"); + return idValue != null && int.TryParse(idValue.ToString(), out var id) ? id : rowNumber - 1; + } + + /// Parses the row number from a Sheets range string like "'Table'!A5:E5" or "A5:E5". + private static int ExtractRowNumberFromRange(string range) + { + // Strip sheet prefix if present + var colonIdx = range.IndexOf('!'); + var cellPart = colonIdx >= 0 ? range[(colonIdx + 1)..] : range; + // cellPart looks like "A5:E5" — take the start cell + var startCell = cellPart.Split(':')[0]; + // Strip column letters + var digits = new string(startCell.SkipWhile(c => !char.IsDigit(c)).ToArray()); + return int.TryParse(digits, out var row) ? row : 2; + } + public async Task UpdateRowAsync(string sheetName, int rowIndex, IList row) { var endCol = GetColumnLetter(row.Count); diff --git a/tests/Sheetly.Core.Tests/Integration/Helpers/InMemorySheetsProvider.cs b/tests/Sheetly.Core.Tests/Integration/Helpers/InMemorySheetsProvider.cs index 649b7f9..e2dfea5 100644 --- a/tests/Sheetly.Core.Tests/Integration/Helpers/InMemorySheetsProvider.cs +++ b/tests/Sheetly.Core.Tests/Integration/Helpers/InMemorySheetsProvider.cs @@ -87,6 +87,27 @@ public Task AppendRowAsync(string sheetName, IList row) return Task.CompletedTask; } + public Task AppendRowAndGetIdAsync(string sheetName, IList row) + { + if (!_sheets.TryGetValue(sheetName, out var rows)) + return Task.FromResult(1); + + // Compute MAX(Id) + 1 in-memory (mirrors the Sheets formula) + int maxId = 0; + for (int i = 1; i < rows.Count; i++) // skip header at index 0 + { + if (rows[i].Count > 0 && int.TryParse(rows[i][0]?.ToString(), out var id) && id > maxId) + maxId = id; + } + int nextId = maxId + 1; + + var newRow = row.ToList(); + if (newRow.Count > 0) + newRow[0] = nextId; + rows.Add(newRow); + return Task.FromResult(nextId); + } + public Task UpdateRowAsync(string sheetName, int rowIndex, IList row) { if (_sheets.TryGetValue(sheetName, out var rows)) From 1c0cf3835710621ce042d18f8f1f2ec882fa9396 Mon Sep 17 00:00:00 2001 From: muqimjon Date: Sat, 28 Feb 2026 00:34:27 +0500 Subject: [PATCH 04/36] perf: batch append rows in single API call instead of N calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ISheetsProvider.AppendRowsAsync (1 API call for N rows) - Add ISheetsProvider.GetMaxIdAsync (read MAX column-A value, 1 API call) - GoogleSheetProvider: implement both with Values.Append batch and Values.Get - InMemorySheetsProvider: implement both with in-memory logic - SheetsSet SaveChanges: for PK tables read MAX once then AppendRowsAsync (2 API calls for any batch size, was 2×N with individual appends) - SheetsSet SaveChanges: for non-PK tables also uses AppendRowsAsync --- .../Abstractions/ISheetsProvider.cs | 2 ++ src/Sheetly.Core/SheetsSet.cs | 29 ++++++++++-------- src/Sheetly.Google/GoogleSheetProvider.cs | 30 +++++++++++++++++++ .../Helpers/InMemorySheetsProvider.cs | 18 +++++++++++ 4 files changed, 67 insertions(+), 12 deletions(-) diff --git a/src/Sheetly.Core/Abstractions/ISheetsProvider.cs b/src/Sheetly.Core/Abstractions/ISheetsProvider.cs index 7240f84..d30e6b9 100644 --- a/src/Sheetly.Core/Abstractions/ISheetsProvider.cs +++ b/src/Sheetly.Core/Abstractions/ISheetsProvider.cs @@ -8,7 +8,9 @@ public interface ISheetsProvider : IDisposable Task>> GetAllRowsAsync(string sheetName); Task?> GetRowByIndexAsync(string sheetName, int rowIndex); Task AppendRowAsync(string sheetName, IList row); + Task AppendRowsAsync(string sheetName, IList> rows); Task AppendRowAndGetIdAsync(string sheetName, IList row); + Task GetMaxIdAsync(string sheetName); Task UpdateRowAsync(string sheetName, int rowIndex, IList row); Task DeleteRowAsync(string sheetName, int rowIndex); diff --git a/src/Sheetly.Core/SheetsSet.cs b/src/Sheetly.Core/SheetsSet.cs index 6dccca6..9e0db02 100644 --- a/src/Sheetly.Core/SheetsSet.cs +++ b/src/Sheetly.Core/SheetsSet.cs @@ -230,22 +230,27 @@ internal async Task SaveChangesInternalAsync() { var pkColumn = schema.Columns.FirstOrDefault(c => c.IsPrimaryKey); - foreach (var item in toAdd) + if (pkColumn != null) { - int assignedId; - if (pkColumn != null) + // Batch path: read MAX(id) once, assign IDs locally, append all rows in 1 API call + int nextId = await provider.GetMaxIdAsync(schema.TableName) + 1; + var batchRows = new List>(toAdd.Count); + var pkProp = typeof(T).GetProperty(pkColumn.PropertyName); + foreach (var item in toAdd) { - // Formula-based atomic ID: IFERROR(MAX(A2:A)+1,1) — 2 API calls vs old 5 - assignedId = await provider.AppendRowAndGetIdAsync(schema.TableName, EntityMapper.MapToRow(item.Key, schema)); - var prop = typeof(T).GetProperty(pkColumn.PropertyName); - prop?.SetValue(item.Key, Convert.ChangeType(assignedId, prop.PropertyType)); + pkProp?.SetValue(item.Key, Convert.ChangeType(nextId, pkProp.PropertyType)); + batchRows.Add(EntityMapper.MapToRow(item.Key, schema)); + nextId++; } - else - { - await provider.AppendRowAsync(schema.TableName, EntityMapper.MapToRow(item.Key, schema)); - } - changes++; + await provider.AppendRowsAsync(schema.TableName, batchRows); + } + else + { + // No PK — batch append without ID assignment + var batchRows = toAdd.Select(item => EntityMapper.MapToRow(item.Key, schema)).ToList(); + await provider.AppendRowsAsync(schema.TableName, (IList>)batchRows); } + changes += toAdd.Count; } _trackedEntities.Clear(); diff --git a/src/Sheetly.Google/GoogleSheetProvider.cs b/src/Sheetly.Google/GoogleSheetProvider.cs index 231e941..e6ddc26 100644 --- a/src/Sheetly.Google/GoogleSheetProvider.cs +++ b/src/Sheetly.Google/GoogleSheetProvider.cs @@ -176,6 +176,36 @@ private static int ExtractRowNumberFromRange(string range) return int.TryParse(digits, out var row) ? row : 2; } + /// + /// Appends multiple rows in a single API call (batch). Use when IDs are already assigned. + /// 1 API call regardless of row count. + /// + public async Task AppendRowsAsync(string sheetName, IList> rows) + { + var vr = new ValueRange { Values = rows }; + var request = _service.Spreadsheets.Values.Append(vr, _spreadsheetId, $"'{sheetName}'!A1"); + request.ValueInputOption = SpreadsheetsResource.ValuesResource.AppendRequest.ValueInputOptionEnum.USERENTERED; + await ExecuteWithRetryAsync(request); + } + + /// + /// Returns the current maximum integer value in column A (excluding header). + /// Uses VALUES_UNRENDERED to get the raw number even when formulas are present. + /// 1 API call. + /// + public async Task GetMaxIdAsync(string sheetName) + { + var request = _service.Spreadsheets.Values.Get(_spreadsheetId, $"'{sheetName}'!A2:A"); + request.ValueRenderOption = SpreadsheetsResource.ValuesResource.GetRequest.ValueRenderOptionEnum.UNFORMATTEDVALUE; + var response = await ExecuteWithRetryAsync(request); + int max = 0; + if (response.Values != null) + foreach (var row in response.Values) + if (row.Count > 0 && int.TryParse(row[0]?.ToString(), out var id) && id > max) + max = id; + return max; + } + public async Task UpdateRowAsync(string sheetName, int rowIndex, IList row) { var endCol = GetColumnLetter(row.Count); diff --git a/tests/Sheetly.Core.Tests/Integration/Helpers/InMemorySheetsProvider.cs b/tests/Sheetly.Core.Tests/Integration/Helpers/InMemorySheetsProvider.cs index e2dfea5..f0588ef 100644 --- a/tests/Sheetly.Core.Tests/Integration/Helpers/InMemorySheetsProvider.cs +++ b/tests/Sheetly.Core.Tests/Integration/Helpers/InMemorySheetsProvider.cs @@ -87,6 +87,24 @@ public Task AppendRowAsync(string sheetName, IList row) return Task.CompletedTask; } + public Task AppendRowsAsync(string sheetName, IList> rows) + { + if (_sheets.TryGetValue(sheetName, out var sheet)) + foreach (var row in rows) + sheet.Add(row.ToList()); + return Task.CompletedTask; + } + + public Task GetMaxIdAsync(string sheetName) + { + int max = 0; + if (_sheets.TryGetValue(sheetName, out var rows)) + for (int i = 1; i < rows.Count; i++) // skip header at index 0 + if (rows[i].Count > 0 && int.TryParse(rows[i][0]?.ToString(), out var id) && id > max) + max = id; + return Task.FromResult(max); + } + public Task AppendRowAndGetIdAsync(string sheetName, IList row) { if (!_sheets.TryGetValue(sheetName, out var rows)) From c9473938d6f297fb0221a34c003c8604c87cd5ba Mon Sep 17 00:00:00 2001 From: muqimjon Date: Sat, 28 Feb 2026 00:35:49 +0500 Subject: [PATCH 05/36] feat: add automatic change tracking via JSON snapshot comparison MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SheetsSet: add _snapshots dictionary keyed by entity reference - ToListAsync: serialize each tracked entity as JSON snapshot on load - DetectChanges: compare current JSON with original; promote Unchanged entities to Modified when any property has changed - SheetsContext.SaveChangesAsync: call DetectChanges on all sets before validating/saving — mirrors EF Core ChangeTracker.DetectChanges() - Clear _snapshots in SaveChangesInternalAsync alongside other state - Users no longer need to call context.Set.Update(entity) explicitly --- src/Sheetly.Core/SheetsContext.cs | 8 ++++++++ src/Sheetly.Core/SheetsSet.cs | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/Sheetly.Core/SheetsContext.cs b/src/Sheetly.Core/SheetsContext.cs index d9ee563..c7f8a1a 100644 --- a/src/Sheetly.Core/SheetsContext.cs +++ b/src/Sheetly.Core/SheetsContext.cs @@ -178,6 +178,14 @@ private void InitializeSets(ISheetsProvider provider, MigrationSnapshot snapshot public async Task SaveChangesAsync() { + // Auto change detection: promote Unchanged → Modified for mutated entities + foreach (var set in sets.Values) + { + set.GetType() + .GetMethod("DetectChanges", BindingFlags.NonPublic | BindingFlags.Instance) + ?.Invoke(set, null); + } + var allPendingEntities = new List(); var allDeletedEntities = new List(); diff --git a/src/Sheetly.Core/SheetsSet.cs b/src/Sheetly.Core/SheetsSet.cs index 9e0db02..8741401 100644 --- a/src/Sheetly.Core/SheetsSet.cs +++ b/src/Sheetly.Core/SheetsSet.cs @@ -3,6 +3,7 @@ using Sheetly.Core.Migration; using System.Collections; using System.Reflection; +using System.Text.Json; namespace Sheetly.Core; @@ -10,6 +11,7 @@ namespace Sheetly.Core; { private readonly Dictionary _trackedEntities = []; private readonly Dictionary _entityRowIndexes = []; + private readonly Dictionary _snapshots = []; private readonly List _includes = []; private bool _asNoTracking = false; @@ -37,6 +39,24 @@ internal IEnumerable GetPendingEntities() => internal IEnumerable GetDeletedEntities() => _trackedEntities.Where(x => x.Value == EntityState.Deleted).Select(x => (object)x.Key); + /// + /// Compares each Unchanged tracked entity against its original snapshot. + /// Automatically promotes entities whose properties have changed to Modified state, + /// mirroring EF Core's ChangeTracker.DetectChanges() behaviour. + /// + internal void DetectChanges() + { + foreach (var entry in _trackedEntities.ToList()) + { + if (entry.Value != EntityState.Unchanged) continue; + if (!_snapshots.TryGetValue(entry.Key, out var original)) continue; + + var current = JsonSerializer.Serialize(entry.Key); + if (current != original) + _trackedEntities[entry.Key] = EntityState.Modified; + } + } + public async Task> ToListAsync() { var rows = await provider.GetAllRowsAsync(schema.TableName); @@ -54,6 +74,7 @@ public async Task> ToListAsync() { _trackedEntities[entity] = EntityState.Unchanged; _entityRowIndexes[entity] = i + 1; // A1 notation: row 1=header, row 2=first data + _snapshots[entity] = JsonSerializer.Serialize(entity); // snapshot for auto change detection } } @@ -255,6 +276,7 @@ internal async Task SaveChangesInternalAsync() _trackedEntities.Clear(); _entityRowIndexes.Clear(); + _snapshots.Clear(); return changes; } } From 0a2c7bf4342288769046702f88d099f64d3b74b5 Mon Sep 17 00:00:00 2001 From: muqimjon Date: Sat, 28 Feb 2026 00:37:07 +0500 Subject: [PATCH 06/36] perf: optimize FindAsync to scan only PK column instead of all rows - Add ISheetsProvider.FindRowIndexByKeyAsync: reads column A only to locate row index by key value (avoids fetching all row data) - GoogleSheetProvider: implement with Values.Get on A:A column range - InMemorySheetsProvider: implement with in-memory column-A scan - SheetsSet.FindAsync: use FindRowIndexByKeyAsync + GetRowByIndexAsync (3 API calls: key-column + header + row, vs 1 call but all rows) - FindAsync also stores snapshot for auto change tracking --- .../Abstractions/ISheetsProvider.cs | 5 +++ src/Sheetly.Core/SheetsSet.cs | 32 ++++++++++++++----- src/Sheetly.Google/GoogleSheetProvider.cs | 17 ++++++++++ .../Helpers/InMemorySheetsProvider.cs | 9 ++++++ 4 files changed, 55 insertions(+), 8 deletions(-) diff --git a/src/Sheetly.Core/Abstractions/ISheetsProvider.cs b/src/Sheetly.Core/Abstractions/ISheetsProvider.cs index d30e6b9..fb2c087 100644 --- a/src/Sheetly.Core/Abstractions/ISheetsProvider.cs +++ b/src/Sheetly.Core/Abstractions/ISheetsProvider.cs @@ -7,6 +7,11 @@ public interface ISheetsProvider : IDisposable Task>> GetAllRowsAsync(string sheetName); Task?> GetRowByIndexAsync(string sheetName, int rowIndex); + /// + /// Reads only column A to find the 1-based row index of a matching key value. + /// Returns -1 if not found. Uses 2 API calls total (key column + full row). + /// + Task FindRowIndexByKeyAsync(string sheetName, string keyValue); Task AppendRowAsync(string sheetName, IList row); Task AppendRowsAsync(string sheetName, IList> rows); Task AppendRowAndGetIdAsync(string sheetName, IList row); diff --git a/src/Sheetly.Core/SheetsSet.cs b/src/Sheetly.Core/SheetsSet.cs index 8741401..e068b74 100644 --- a/src/Sheetly.Core/SheetsSet.cs +++ b/src/Sheetly.Core/SheetsSet.cs @@ -103,16 +103,32 @@ public async Task> Where(Func predicate) var pkColumn = schema.Columns.FirstOrDefault(c => c.IsPrimaryKey); if (pkColumn == null) return default; - var all = await ToListAsync(); - var pkProp = typeof(T).GetProperty(pkColumn.PropertyName); - if (pkProp == null) return default; + var keyStr = keyValue.ToString()!; + + // Optimization: scan only the PK column to find the row, then fetch just that row. + // 2 API calls (key-column scan + single row) vs GetAllRowsAsync (1 call but all data). + // For large datasets this is significantly faster. + var rowIndex = await provider.FindRowIndexByKeyAsync(schema.TableName, keyStr); + if (rowIndex < 0) return default; + + var rowData = await provider.GetRowByIndexAsync(schema.TableName, rowIndex); + if (rowData == null) return default; + + // Need the header row for column name mapping + var headerRow = await provider.GetRowByIndexAsync(schema.TableName, 1); + if (headerRow == null) return default; + var headers = headerRow.Select(h => h?.ToString() ?? string.Empty).ToList(); - return all.FirstOrDefault(e => + var entity = EntityMapper.MapFromRow(rowData, headers, schema); + + if (!_asNoTracking && !_trackedEntities.ContainsKey(entity)) { - var val = pkProp.GetValue(e); - if (val == null) return false; - return val.ToString() == keyValue.ToString(); - }); + _trackedEntities[entity] = EntityState.Unchanged; + _entityRowIndexes[entity] = rowIndex; + _snapshots[entity] = JsonSerializer.Serialize(entity); + } + + return entity; } public async Task CountAsync(Func? predicate = null) diff --git a/src/Sheetly.Google/GoogleSheetProvider.cs b/src/Sheetly.Google/GoogleSheetProvider.cs index e6ddc26..5639c8f 100644 --- a/src/Sheetly.Google/GoogleSheetProvider.cs +++ b/src/Sheetly.Google/GoogleSheetProvider.cs @@ -128,6 +128,23 @@ public async Task>> GetAllRowsAsync(string sheetName) return response.Values?.FirstOrDefault(); } + public async Task FindRowIndexByKeyAsync(string sheetName, string keyValue) + { + // Fetch only column A (the PK column) — much less data than GetAllRowsAsync + var request = _service.Spreadsheets.Values.Get(_spreadsheetId, $"'{sheetName}'!A:A"); + request.ValueRenderOption = SpreadsheetsResource.ValuesResource.GetRequest.ValueRenderOptionEnum.UNFORMATTEDVALUE; + var response = await ExecuteWithRetryAsync(request); + + if (response.Values == null) return -1; + for (int i = 1; i < response.Values.Count; i++) // skip header (index 0 = row 1) + { + var cell = response.Values[i].Count > 0 ? response.Values[i][0]?.ToString() : null; + if (cell == keyValue) + return i + 1; // 1-based row index + } + return -1; + } + public async Task AppendRowAsync(string sheetName, IList row) { var vr = new ValueRange { Values = new List> { row } }; diff --git a/tests/Sheetly.Core.Tests/Integration/Helpers/InMemorySheetsProvider.cs b/tests/Sheetly.Core.Tests/Integration/Helpers/InMemorySheetsProvider.cs index f0588ef..f7e6c7e 100644 --- a/tests/Sheetly.Core.Tests/Integration/Helpers/InMemorySheetsProvider.cs +++ b/tests/Sheetly.Core.Tests/Integration/Helpers/InMemorySheetsProvider.cs @@ -80,6 +80,15 @@ public Task>> GetAllRowsAsync(string sheetName) return Task.FromResult?>(null); } + public Task FindRowIndexByKeyAsync(string sheetName, string keyValue) + { + if (_sheets.TryGetValue(sheetName, out var rows)) + for (int i = 1; i < rows.Count; i++) // skip header at index 0 + if (rows[i].Count > 0 && rows[i][0]?.ToString() == keyValue) + return Task.FromResult(i + 1); // 1-based + return Task.FromResult(-1); + } + public Task AppendRowAsync(string sheetName, IList row) { if (_sheets.TryGetValue(sheetName, out var rows)) From 408ef4e5c4f32d97c8f8a9034ae7161141ead771 Mon Sep 17 00:00:00 2001 From: muqimjon Date: Sat, 28 Feb 2026 00:38:48 +0500 Subject: [PATCH 07/36] feat: support multiple credentials for round-robin API quota rotation - GoogleSheetProvider: replace single SheetsService with SheetsService[] - LoadServicesFromJson: auto-detect {} (single) vs [{},{}] (array) format - Round-robin via Interlocked.Increment + modulo on _serviceIndex - With N accounts: effective limit scales to N x 60 req/min writes - Dispose: properly disposes all services - credentials.json can now hold [{creds1},{creds2},...] for up to 5x quota --- src/Sheetly.Google/GoogleSheetProvider.cs | 105 +++++++++++++++------- 1 file changed, 73 insertions(+), 32 deletions(-) diff --git a/src/Sheetly.Google/GoogleSheetProvider.cs b/src/Sheetly.Google/GoogleSheetProvider.cs index 5639c8f..8d344ce 100644 --- a/src/Sheetly.Google/GoogleSheetProvider.cs +++ b/src/Sheetly.Google/GoogleSheetProvider.cs @@ -1,4 +1,4 @@ -using Google.Apis.Auth.OAuth2; +using Google.Apis.Auth.OAuth2; using Google.Apis.Requests; using Google.Apis.Services; using Google.Apis.Sheets.v4; @@ -11,8 +11,9 @@ namespace Sheetly.Google; public class GoogleSheetProvider : ISheetsProvider { - private readonly SheetsService _service; + private readonly SheetsService[] _services; private readonly string _spreadsheetId; + private int _serviceIndex = -1; // ── Sheet metadata cache ──────────────────────────────────────────────── // Populated on InitializeAsync(); updated on CreateSheet/DeleteSheet. @@ -23,6 +24,13 @@ public class GoogleSheetProvider : ISheetsProvider private const int MaxRetries = 5; private static readonly TimeSpan InitialRetryDelay = TimeSpan.FromSeconds(2); + /// + /// Returns the next service in round-robin order. With N accounts, effective + /// write limit is N × 60 req/min instead of 60 req/min for a single account. + /// + private SheetsService NextService => + _services[Math.Abs(Interlocked.Increment(ref _serviceIndex) % _services.Length)]; + /// /// Executes a Google API request with automatic exponential-backoff retry /// on 429 (TooManyRequests) and 503 (ServiceUnavailable) responses. @@ -53,10 +61,8 @@ public GoogleSheetProvider(string credentialsPath, string spreadsheetId) { _spreadsheetId = spreadsheetId; using var stream = new FileStream(credentialsPath, FileMode.Open, FileAccess.Read); -#pragma warning disable CS0618 - var credential = GoogleCredential.FromStream(stream).CreateScoped(SheetsService.Scope.Spreadsheets); -#pragma warning restore CS0618 - _service = CreateService(credential); + var json = new StreamReader(stream).ReadToEnd(); + _services = LoadServicesFromJson(json); } public GoogleSheetProvider(IConfigurationSection section, string spreadsheetId) @@ -64,15 +70,48 @@ public GoogleSheetProvider(IConfigurationSection section, string spreadsheetId) _spreadsheetId = spreadsheetId; var dict = section.GetChildren().ToDictionary(c => c.Key, c => c.Value); var json = JsonSerializer.Serialize(dict); + _services = [CreateServiceFromJson(json)]; + } + + /// + /// Parses credentials JSON as either a single object {} or an array [{},{}]. + /// Each element becomes a separate , enabling round-robin + /// rotation to multiply the effective API quota. + /// + private static SheetsService[] LoadServicesFromJson(string json) + { + var trimmed = json.TrimStart(); + if (trimmed.StartsWith('[')) + { + // Array format: [{...}, {...}, ...] + using var doc = JsonDocument.Parse(json); + var services = new List(); + foreach (var element in doc.RootElement.EnumerateArray()) + services.Add(CreateServiceFromJson(element.GetRawText())); + if (services.Count == 0) + throw new InvalidOperationException("credentials.json array is empty."); + return [.. services]; + } + + // Single object format: {...} + return [CreateServiceFromJson(json)]; + } + + private static SheetsService CreateServiceFromJson(string json) + { #pragma warning disable CS0618 var credential = GoogleCredential.FromJson(json).CreateScoped(SheetsService.Scope.Spreadsheets); #pragma warning restore CS0618 - _service = CreateService(credential); + return new SheetsService(new BaseClientService.Initializer + { + HttpClientInitializer = credential, + ApplicationName = "Sheetly" + }); } public async Task InitializeAsync() { - var ss = await ExecuteWithRetryAsync(_service.Spreadsheets.Get(_spreadsheetId)); + var ss = await ExecuteWithRetryAsync(NextService.Spreadsheets.Get(_spreadsheetId)); _sheetCache = ss.Sheets .Where(s => s.Properties?.Title != null) .ToDictionary(s => s.Properties.Title!, s => (int)(s.Properties.SheetId ?? 0)); @@ -105,33 +144,31 @@ public async Task DropDatabaseAsync() } } - private SheetsService CreateService(GoogleCredential credential) - { - return new SheetsService(new BaseClientService.Initializer + private SheetsService CreateService(GoogleCredential credential) => + new(new BaseClientService.Initializer { HttpClientInitializer = credential, ApplicationName = "Sheetly" }); - } public async Task>> GetAllRowsAsync(string sheetName) { var response = await ExecuteWithRetryAsync( - _service.Spreadsheets.Values.Get(_spreadsheetId, $"'{sheetName}'")); + NextService.Spreadsheets.Values.Get(_spreadsheetId, $"'{sheetName}'")); return response.Values?.ToList() ?? []; } public async Task?> GetRowByIndexAsync(string sheetName, int rowIndex) { var response = await ExecuteWithRetryAsync( - _service.Spreadsheets.Values.Get(_spreadsheetId, $"'{sheetName}'!{rowIndex}:{rowIndex}")); + NextService.Spreadsheets.Values.Get(_spreadsheetId, $"'{sheetName}'!{rowIndex}:{rowIndex}")); return response.Values?.FirstOrDefault(); } public async Task FindRowIndexByKeyAsync(string sheetName, string keyValue) { // Fetch only column A (the PK column) — much less data than GetAllRowsAsync - var request = _service.Spreadsheets.Values.Get(_spreadsheetId, $"'{sheetName}'!A:A"); + var request = NextService.Spreadsheets.Values.Get(_spreadsheetId, $"'{sheetName}'!A:A"); request.ValueRenderOption = SpreadsheetsResource.ValuesResource.GetRequest.ValueRenderOptionEnum.UNFORMATTEDVALUE; var response = await ExecuteWithRetryAsync(request); @@ -148,7 +185,7 @@ public async Task FindRowIndexByKeyAsync(string sheetName, string keyValue) public async Task AppendRowAsync(string sheetName, IList row) { var vr = new ValueRange { Values = new List> { row } }; - var request = _service.Spreadsheets.Values.Append(vr, _spreadsheetId, $"'{sheetName}'!A1"); + var request = NextService.Spreadsheets.Values.Append(vr, _spreadsheetId, $"'{sheetName}'!A1"); request.ValueInputOption = SpreadsheetsResource.ValuesResource.AppendRequest.ValueInputOptionEnum.USERENTERED; await ExecuteWithRetryAsync(request); } @@ -167,7 +204,7 @@ public async Task AppendRowAndGetIdAsync(string sheetName, IList ro }; var vr = new ValueRange { Values = new List> { rowWithFormula } }; - var request = _service.Spreadsheets.Values.Append(vr, _spreadsheetId, $"'{sheetName}'!A1"); + var request = NextService.Spreadsheets.Values.Append(vr, _spreadsheetId, $"'{sheetName}'!A1"); request.ValueInputOption = SpreadsheetsResource.ValuesResource.AppendRequest.ValueInputOptionEnum.USERENTERED; var response = await ExecuteWithRetryAsync(request); @@ -200,7 +237,7 @@ private static int ExtractRowNumberFromRange(string range) public async Task AppendRowsAsync(string sheetName, IList> rows) { var vr = new ValueRange { Values = rows }; - var request = _service.Spreadsheets.Values.Append(vr, _spreadsheetId, $"'{sheetName}'!A1"); + var request = NextService.Spreadsheets.Values.Append(vr, _spreadsheetId, $"'{sheetName}'!A1"); request.ValueInputOption = SpreadsheetsResource.ValuesResource.AppendRequest.ValueInputOptionEnum.USERENTERED; await ExecuteWithRetryAsync(request); } @@ -212,7 +249,7 @@ public async Task AppendRowsAsync(string sheetName, IList> rows) /// public async Task GetMaxIdAsync(string sheetName) { - var request = _service.Spreadsheets.Values.Get(_spreadsheetId, $"'{sheetName}'!A2:A"); + var request = NextService.Spreadsheets.Values.Get(_spreadsheetId, $"'{sheetName}'!A2:A"); request.ValueRenderOption = SpreadsheetsResource.ValuesResource.GetRequest.ValueRenderOptionEnum.UNFORMATTEDVALUE; var response = await ExecuteWithRetryAsync(request); int max = 0; @@ -228,7 +265,7 @@ public async Task UpdateRowAsync(string sheetName, int rowIndex, IList r var endCol = GetColumnLetter(row.Count); var range = $"'{sheetName}'!A{rowIndex}:{endCol}{rowIndex}"; var valueRange = new ValueRange { Values = new List> { row } }; - var request = _service.Spreadsheets.Values.Update(valueRange, _spreadsheetId, range); + var request = NextService.Spreadsheets.Values.Update(valueRange, _spreadsheetId, range); request.ValueInputOption = SpreadsheetsResource.ValuesResource.UpdateRequest.ValueInputOptionEnum.USERENTERED; await ExecuteWithRetryAsync(request); } @@ -244,7 +281,7 @@ public async Task DeleteRowAsync(string sheetName, int rowIndex) } }; await ExecuteWithRetryAsync( - _service.Spreadsheets.BatchUpdate( + NextService.Spreadsheets.BatchUpdate( new BatchUpdateSpreadsheetRequest { Requests = [deleteRequest] }, _spreadsheetId)); } @@ -275,7 +312,7 @@ public async Task CreateSheetAsync(string sheetName, IList headers) }; var response = await ExecuteWithRetryAsync( - _service.Spreadsheets.BatchUpdate(batchRequest, _spreadsheetId)); + NextService.Spreadsheets.BatchUpdate(batchRequest, _spreadsheetId)); var sheetId = response.Replies[0].AddSheet.Properties.SheetId; // Update cache so subsequent SheetExistsAsync/GetSheetIdInternal are free @@ -320,7 +357,7 @@ public async Task CreateSheetAsync(string sheetName, IList headers) } }; - await ExecuteWithRetryAsync(_service.Spreadsheets.BatchUpdate( + await ExecuteWithRetryAsync(NextService.Spreadsheets.BatchUpdate( new BatchUpdateSpreadsheetRequest { Requests = [updateCellsRequest] }, _spreadsheetId)); } @@ -329,7 +366,7 @@ public async Task DeleteSheetAsync(string sheetName) var sheetId = await GetSheetIdInternal(sheetName); if (sheetId == null) return; var request = new Request { DeleteSheet = new DeleteSheetRequest { SheetId = sheetId } }; - await ExecuteWithRetryAsync(_service.Spreadsheets.BatchUpdate( + await ExecuteWithRetryAsync(NextService.Spreadsheets.BatchUpdate( new BatchUpdateSpreadsheetRequest { Requests = [request] }, _spreadsheetId)); _sheetCache.Remove(sheetName); } @@ -337,13 +374,13 @@ await ExecuteWithRetryAsync(_service.Spreadsheets.BatchUpdate( public async Task ClearSheetAsync(string sheetName) { await ExecuteWithRetryAsync( - _service.Spreadsheets.Values.Clear(new ClearValuesRequest(), _spreadsheetId, $"'{sheetName}'!A2:ZZ")); + NextService.Spreadsheets.Values.Clear(new ClearValuesRequest(), _spreadsheetId, $"'{sheetName}'!A2:ZZ")); } public async Task UpdateValueAsync(string sheetName, string range, object value) { var vr = new ValueRange { Values = [[value]] }; - var req = _service.Spreadsheets.Values.Update(vr, _spreadsheetId, $"'{sheetName}'!{range}"); + var req = NextService.Spreadsheets.Values.Update(vr, _spreadsheetId, $"'{sheetName}'!{range}"); req.ValueInputOption = SpreadsheetsResource.ValuesResource.UpdateRequest.ValueInputOptionEnum.USERENTERED; await ExecuteWithRetryAsync(req); } @@ -351,7 +388,7 @@ public async Task UpdateValueAsync(string sheetName, string range, object value) public async Task GetValueAsync(string sheetName, string range) { var response = await ExecuteWithRetryAsync( - _service.Spreadsheets.Values.Get(_spreadsheetId, $"'{sheetName}'!{range}")); + NextService.Spreadsheets.Values.Get(_spreadsheetId, $"'{sheetName}'!{range}")); return response.Values?.FirstOrDefault()?.FirstOrDefault(); } @@ -367,7 +404,7 @@ public async Task HideSheetAsync(string sheetName) Fields = "hidden" } }; - await ExecuteWithRetryAsync(_service.Spreadsheets.BatchUpdate( + await ExecuteWithRetryAsync(NextService.Spreadsheets.BatchUpdate( new BatchUpdateSpreadsheetRequest { Requests = [request] }, _spreadsheetId)); } @@ -382,7 +419,7 @@ public async Task AddDataValidationAsync(string sheetName, int columnIndex, stri Rule = new DataValidationRule { Condition = new BooleanCondition { Type = "NOT_BLANK" }, InputMessage = message, Strict = true } } }; - await ExecuteWithRetryAsync(_service.Spreadsheets.BatchUpdate( + await ExecuteWithRetryAsync(NextService.Spreadsheets.BatchUpdate( new BatchUpdateSpreadsheetRequest { Requests = [request] }, _spreadsheetId)); } @@ -397,7 +434,7 @@ public async Task SetCheckboxAsync(string sheetName, int startRow, int endRow, i Rule = new DataValidationRule { Condition = new BooleanCondition { Type = "BOOLEAN" }, ShowCustomUi = true } } }; - await ExecuteWithRetryAsync(_service.Spreadsheets.BatchUpdate( + await ExecuteWithRetryAsync(NextService.Spreadsheets.BatchUpdate( new BatchUpdateSpreadsheetRequest { Requests = [request] }, _spreadsheetId)); } @@ -408,7 +445,11 @@ await ExecuteWithRetryAsync(_service.Spreadsheets.BatchUpdate( return Task.FromResult(null); } - public void Dispose() => _service?.Dispose(); + public void Dispose() + { + foreach (var svc in _services) + svc?.Dispose(); + } /// /// Converts 1-based column count to column letter (1=A, 26=Z, 27=AA, etc.) @@ -424,4 +465,4 @@ private static string GetColumnLetter(int columnNumber) } return result; } -} \ No newline at end of file +} From d51b8bc4795ffd7c45d29d44da820ec6b56af5e3 Mon Sep 17 00:00:00 2001 From: muqimjon Date: Sat, 28 Feb 2026 00:39:26 +0500 Subject: [PATCH 08/36] feat: add expression-based Include overload for type-safe navigation loading - SheetsSet.Include(Expression>): extracts property name from lambda at compile time (no magic strings) - Mirrors EF Core syntax: context.Orders.Include(o => o.Customer) - String-based Include(string) overload retained for backward compatibility --- src/Sheetly.Core/SheetsSet.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/Sheetly.Core/SheetsSet.cs b/src/Sheetly.Core/SheetsSet.cs index e068b74..bc573c7 100644 --- a/src/Sheetly.Core/SheetsSet.cs +++ b/src/Sheetly.Core/SheetsSet.cs @@ -2,6 +2,7 @@ using Sheetly.Core.Mapping; using Sheetly.Core.Migration; using System.Collections; +using System.Linq.Expressions; using System.Reflection; using System.Text.Json; @@ -27,6 +28,18 @@ public SheetsSet Include(string propertyName) return this; } + /// + /// Strongly-typed navigation include, mirroring EF Core's expression-based overload: + /// context.Orders.Include(o => o.Customer) + /// The property name is extracted at compile time — no magic strings needed. + /// + public SheetsSet Include(Expression> navigationExpression) + { + if (navigationExpression.Body is MemberExpression member) + _includes.Add(member.Member.Name); + return this; + } + public void Add(T entity) => _trackedEntities[entity] = EntityState.Added; internal IEnumerable GetPendingEntities() => From 6cf393b1f08230ba9a40a6dd02b5f450f6c3392f Mon Sep 17 00:00:00 2001 From: muqimjon Date: Sat, 28 Feb 2026 00:40:20 +0500 Subject: [PATCH 09/36] feat: implement IAsyncDisposable and add CancellationToken to SaveChangesAsync - SheetsContext: implement IAsyncDisposable.DisposeAsync() alongside IDisposable (delegates to IAsyncDisposable provider if available, else sync Dispose) - SheetsContext.SaveChangesAsync: accept optional CancellationToken parameter ThrowIfCancellationRequested after local validation, before API calls - Mirrors EF Core's DbContext.SaveChangesAsync(CancellationToken) signature --- src/Sheetly.Core/SheetsContext.cs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/Sheetly.Core/SheetsContext.cs b/src/Sheetly.Core/SheetsContext.cs index c7f8a1a..8573ed5 100644 --- a/src/Sheetly.Core/SheetsContext.cs +++ b/src/Sheetly.Core/SheetsContext.cs @@ -10,7 +10,7 @@ namespace Sheetly.Core; -public abstract class SheetsContext : IDisposable +public abstract class SheetsContext : IDisposable, IAsyncDisposable { public ISheetsProvider Provider { get; private set; } = default!; public DatabaseFacade Database { get; private set; } = default!; @@ -176,7 +176,7 @@ private void InitializeSets(ISheetsProvider provider, MigrationSnapshot snapshot } } - public async Task SaveChangesAsync() + public async Task SaveChangesAsync(CancellationToken cancellationToken = default) { // Auto change detection: promote Unchanged → Modified for mutated entities foreach (var set in sets.Values) @@ -242,6 +242,8 @@ public async Task SaveChangesAsync() if (allDeletedEntities.Count > 0) await ValidateForeignKeyConstraintsOnDelete(allDeletedEntities); + cancellationToken.ThrowIfCancellationRequested(); + int total = 0; foreach (var set in sets.Values) { @@ -453,6 +455,15 @@ protected virtual void Dispose(bool disposing) Provider?.Dispose(); } + public async ValueTask DisposeAsync() + { + if (Provider is IAsyncDisposable asyncDisposable) + await asyncDisposable.DisposeAsync(); + else + Provider?.Dispose(); + GC.SuppressFinalize(this); + } + ~SheetsContext() { Dispose(false); From 2fb39f21cd7bd9b6bbeaacb94262bb9b1bd8acde Mon Sep 17 00:00:00 2001 From: muqimjon Date: Sat, 28 Feb 2026 00:41:33 +0500 Subject: [PATCH 10/36] feat: add SheetsContextOptions constructor pattern (EF Core style) - Add SheetsContextOptions : SheetsOptions typed options class - SheetsContext: add protected ctor(SheetsOptions) storing injected options - InitializeAsync: prefers constructor options over OnConfiguring override - ServiceCollectionExtensions.AddSheetsContext: detect ctor with options and inject SheetsContextOptions (EF Core DI style); fall back to parameterless ctor for backward-compatible OnConfiguring contexts - Enables: public AppContext(SheetsContextOptions opts) : base(opts) --- .../Configuration/SheetsContextOptions.cs | 16 ++++++++++ src/Sheetly.Core/SheetsContext.cs | 25 ++++++++++++++-- .../Extensions/ServiceCollectionExtensions.cs | 30 ++++++++++++++----- 3 files changed, 61 insertions(+), 10 deletions(-) create mode 100644 src/Sheetly.Core/Configuration/SheetsContextOptions.cs diff --git a/src/Sheetly.Core/Configuration/SheetsContextOptions.cs b/src/Sheetly.Core/Configuration/SheetsContextOptions.cs new file mode 100644 index 0000000..5d2a0cf --- /dev/null +++ b/src/Sheetly.Core/Configuration/SheetsContextOptions.cs @@ -0,0 +1,16 @@ +namespace Sheetly.Core.Configuration; + +/// +/// Typed options for a specific instance. +/// Mirrors EF Core's DbContextOptions<TContext> pattern, enabling +/// constructor-based dependency injection: +/// +/// public class AppContext : SheetsContext +/// { +/// public AppContext(SheetsContextOptions<AppContext> options) : base(options) { } +/// } +/// +/// +public class SheetsContextOptions : SheetsOptions where TContext : class +{ +} diff --git a/src/Sheetly.Core/SheetsContext.cs b/src/Sheetly.Core/SheetsContext.cs index 8573ed5..d0533ca 100644 --- a/src/Sheetly.Core/SheetsContext.cs +++ b/src/Sheetly.Core/SheetsContext.cs @@ -19,6 +19,22 @@ public abstract class SheetsContext : IDisposable, IAsyncDisposable private MigrationSnapshot? _currentSnapshot; private ConstraintValidator? _validator; + // Stores options provided via constructor so InitializeAsync can use them + // without requiring an OnConfiguring override. + private readonly SheetsOptions? _constructorOptions; + + /// Parameterless constructor — provider configured via OnConfiguring(). + protected SheetsContext() { } + + /// + /// Options constructor — mirrors EF Core's DbContext(DbContextOptions) pattern. + /// Use with for DI-friendly contexts. + /// + protected SheetsContext(SheetsOptions options) + { + _constructorOptions = options; + } + protected virtual void OnModelCreating(ModelBuilder modelBuilder) { } protected virtual void OnConfiguring(SheetsOptions options) { } @@ -27,10 +43,13 @@ public virtual async Task InitializeAsync(ISheetsProvider? provider = null, IMig { if (provider == null) { - var options = new SheetsOptions(); - OnConfiguring(options); + // Prefer constructor-injected options over OnConfiguring override + var options = _constructorOptions ?? new SheetsOptions(); + if (_constructorOptions == null) + OnConfiguring(options); + provider = options.Provider ?? throw new InvalidOperationException( - "ISheetsProvider not configured. Call UseGoogleSheets in OnConfiguring."); + "ISheetsProvider not configured. Call UseGoogleSheets in OnConfiguring or pass SheetsContextOptions via constructor."); migrationService ??= options.MigrationService; } diff --git a/src/Sheetly.DependencyInjection/Extensions/ServiceCollectionExtensions.cs b/src/Sheetly.DependencyInjection/Extensions/ServiceCollectionExtensions.cs index fc056b4..f3cd4f6 100644 --- a/src/Sheetly.DependencyInjection/Extensions/ServiceCollectionExtensions.cs +++ b/src/Sheetly.DependencyInjection/Extensions/ServiceCollectionExtensions.cs @@ -6,24 +6,40 @@ namespace Sheetly.DependencyInjection.Extensions; public static class ServiceCollectionExtensions { + /// + /// Registers a as a scoped service. + /// Configures via action on . + /// If has a constructor accepting + /// , it is used (EF Core style). + /// Otherwise, falls back to the parameterless constructor with InitializeAsync. + /// public static IServiceCollection AddSheetsContext( this IServiceCollection services, - Action? configure = null) where TContext : SheetsContext, new() + Action>? configure = null) where TContext : SheetsContext { services.AddScoped(sp => { - var options = new SheetsOptions(); + var options = new SheetsContextOptions(); configure?.Invoke(options); - var context = new TContext(); + // Try constructor injection (EF Core-style) first + var ctorWithOptions = typeof(TContext) + .GetConstructor([typeof(SheetsContextOptions)]); - if (options.Provider != null) + TContext context; + if (ctorWithOptions != null) { - context.InitializeAsync(options.Provider).GetAwaiter().GetResult(); + context = (TContext)ctorWithOptions.Invoke([options]); + context.InitializeAsync().GetAwaiter().GetResult(); } else { - context.InitializeAsync().GetAwaiter().GetResult(); + // Fallback: parameterless constructor + provider passed to InitializeAsync + context = (TContext)Activator.CreateInstance(typeof(TContext), nonPublic: true)!; + if (options.Provider != null) + context.InitializeAsync(options.Provider).GetAwaiter().GetResult(); + else + context.InitializeAsync().GetAwaiter().GetResult(); } return context; @@ -31,4 +47,4 @@ public static IServiceCollection AddSheetsContext( return services; } -} \ No newline at end of file +} From 91eb05efdf808432b85569d09987d7b7a3b0e208 Mon Sep 17 00:00:00 2001 From: muqimjon Date: Sat, 28 Feb 2026 00:43:09 +0500 Subject: [PATCH 11/36] ci: add per-package release workflows triggered by version tags - release-core.yml : triggered on tags matching core-v* - release-google.yml : triggered on tags matching google-v* - release-di.yml : triggered on tags matching di-v* - release-cli.yml : triggered on tags matching cli-v* Each workflow: builds solution, runs tests, packs the single project, pushes to NuGet, creates GitHub Release with the package as artifact. Usage: git tag core-v1.1.0 && git push origin core-v1.1.0 --- .github/workflows/release-cli.yml | 50 ++++++++++++++++++++++++++++ .github/workflows/release-core.yml | 50 ++++++++++++++++++++++++++++ .github/workflows/release-di.yml | 50 ++++++++++++++++++++++++++++ .github/workflows/release-google.yml | 50 ++++++++++++++++++++++++++++ 4 files changed, 200 insertions(+) create mode 100644 .github/workflows/release-cli.yml create mode 100644 .github/workflows/release-core.yml create mode 100644 .github/workflows/release-di.yml create mode 100644 .github/workflows/release-google.yml diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml new file mode 100644 index 0000000..7872c28 --- /dev/null +++ b/.github/workflows/release-cli.yml @@ -0,0 +1,50 @@ +name: Release Sheetly.CLI (dotnet-sheetly) + +# Triggered by tags like: cli-v1.1.0 +on: + push: + tags: + - 'cli-v*' + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.x' + + - name: Extract version from tag + id: version + run: | + TAG="${{ github.ref_name }}" + VERSION="${TAG#cli-v}" + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Releasing dotnet-sheetly (CLI) v$VERSION" + + - name: Build + run: dotnet build Sheetly.sln -c Release --no-incremental + + - name: Test + run: dotnet test tests/Sheetly.Core.Tests/ -c Release --no-build --verbosity normal + + - name: Pack dotnet-sheetly CLI tool + run: dotnet pack src/Sheetly.CLI/Sheetly.CLI.csproj -c Release --no-build -o ./nupkg + + - name: Push to NuGet + run: | + dotnet nuget push nupkg/dotnet-sheetly.${{ steps.version.outputs.version }}.nupkg \ + -k ${{ secrets.NUGET_API_KEY }} -s https://api.nuget.org/v3/index.json --skip-duplicate + + - name: Create GitHub Release + uses: ncipollo/release-action@v1 + with: + tag: ${{ github.ref_name }} + name: "dotnet-sheetly (CLI) v${{ steps.version.outputs.version }}" + artifacts: nupkg/dotnet-sheetly.${{ steps.version.outputs.version }}.nupkg + token: ${{ secrets.GITHUB_TOKEN }} + skipIfReleaseExists: true diff --git a/.github/workflows/release-core.yml b/.github/workflows/release-core.yml new file mode 100644 index 0000000..68cf431 --- /dev/null +++ b/.github/workflows/release-core.yml @@ -0,0 +1,50 @@ +name: Release Sheetly.Core + +# Triggered by tags like: core-v1.1.0 +on: + push: + tags: + - 'core-v*' + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.x' + + - name: Extract version from tag + id: version + run: | + TAG="${{ github.ref_name }}" + VERSION="${TAG#core-v}" + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Releasing Sheetly.Core v$VERSION" + + - name: Build + run: dotnet build Sheetly.sln -c Release --no-incremental + + - name: Test + run: dotnet test tests/Sheetly.Core.Tests/ -c Release --no-build --verbosity normal + + - name: Pack Sheetly.Core + run: dotnet pack src/Sheetly.Core/Sheetly.Core.csproj -c Release --no-build -o ./nupkg + + - name: Push to NuGet + run: | + dotnet nuget push nupkg/Sheetly.Core.${{ steps.version.outputs.version }}.nupkg \ + -k ${{ secrets.NUGET_API_KEY }} -s https://api.nuget.org/v3/index.json --skip-duplicate + + - name: Create GitHub Release + uses: ncipollo/release-action@v1 + with: + tag: ${{ github.ref_name }} + name: "Sheetly.Core v${{ steps.version.outputs.version }}" + artifacts: nupkg/Sheetly.Core.${{ steps.version.outputs.version }}.nupkg + token: ${{ secrets.GITHUB_TOKEN }} + skipIfReleaseExists: true diff --git a/.github/workflows/release-di.yml b/.github/workflows/release-di.yml new file mode 100644 index 0000000..11926fd --- /dev/null +++ b/.github/workflows/release-di.yml @@ -0,0 +1,50 @@ +name: Release Sheetly.DependencyInjection + +# Triggered by tags like: di-v1.1.0 +on: + push: + tags: + - 'di-v*' + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.x' + + - name: Extract version from tag + id: version + run: | + TAG="${{ github.ref_name }}" + VERSION="${TAG#di-v}" + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Releasing Sheetly.DependencyInjection v$VERSION" + + - name: Build + run: dotnet build Sheetly.sln -c Release --no-incremental + + - name: Test + run: dotnet test tests/Sheetly.Core.Tests/ -c Release --no-build --verbosity normal + + - name: Pack Sheetly.DependencyInjection + run: dotnet pack src/Sheetly.DependencyInjection/Sheetly.DependencyInjection.csproj -c Release --no-build -o ./nupkg + + - name: Push to NuGet + run: | + dotnet nuget push nupkg/Sheetly.DependencyInjection.${{ steps.version.outputs.version }}.nupkg \ + -k ${{ secrets.NUGET_API_KEY }} -s https://api.nuget.org/v3/index.json --skip-duplicate + + - name: Create GitHub Release + uses: ncipollo/release-action@v1 + with: + tag: ${{ github.ref_name }} + name: "Sheetly.DependencyInjection v${{ steps.version.outputs.version }}" + artifacts: nupkg/Sheetly.DependencyInjection.${{ steps.version.outputs.version }}.nupkg + token: ${{ secrets.GITHUB_TOKEN }} + skipIfReleaseExists: true diff --git a/.github/workflows/release-google.yml b/.github/workflows/release-google.yml new file mode 100644 index 0000000..671f5ce --- /dev/null +++ b/.github/workflows/release-google.yml @@ -0,0 +1,50 @@ +name: Release Sheetly.Google + +# Triggered by tags like: google-v1.1.0 +on: + push: + tags: + - 'google-v*' + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.x' + + - name: Extract version from tag + id: version + run: | + TAG="${{ github.ref_name }}" + VERSION="${TAG#google-v}" + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Releasing Sheetly.Google v$VERSION" + + - name: Build + run: dotnet build Sheetly.sln -c Release --no-incremental + + - name: Test + run: dotnet test tests/Sheetly.Core.Tests/ -c Release --no-build --verbosity normal + + - name: Pack Sheetly.Google + run: dotnet pack src/Sheetly.Google/Sheetly.Google.csproj -c Release --no-build -o ./nupkg + + - name: Push to NuGet + run: | + dotnet nuget push nupkg/Sheetly.Google.${{ steps.version.outputs.version }}.nupkg \ + -k ${{ secrets.NUGET_API_KEY }} -s https://api.nuget.org/v3/index.json --skip-duplicate + + - name: Create GitHub Release + uses: ncipollo/release-action@v1 + with: + tag: ${{ github.ref_name }} + name: "Sheetly.Google v${{ steps.version.outputs.version }}" + artifacts: nupkg/Sheetly.Google.${{ steps.version.outputs.version }}.nupkg + token: ${{ secrets.GITHUB_TOKEN }} + skipIfReleaseExists: true From 4fd5e7e3385fac39053a10b24169e692da802e0d Mon Sep 17 00:00:00 2001 From: muqimjon Date: Sat, 28 Feb 2026 01:08:44 +0500 Subject: [PATCH 12/36] refactor: remove inline comments and dead code Remove code-level comments throughout codebase, keeping only brief class/method-level XML documentation. Remove unused CreateService(GoogleCredential) method from GoogleSheetProvider. --- .../Migration/MigrationSnapshot.cs | 19 ++-------- .../Operations/AddColumnOperation.cs | 10 ------ src/Sheetly.Core/SheetsContext.cs | 18 ++-------- src/Sheetly.Core/SheetsSet.cs | 12 ++----- .../Extensions/ServiceCollectionExtensions.cs | 2 -- src/Sheetly.Google/GoogleSheetProvider.cs | 28 +++------------ .../Integration/CrudTests.cs | 12 +------ .../Helpers/InMemorySheetsProvider.cs | 36 ++++--------------- .../Integration/IdGenerationTests.cs | 1 - .../Integration/QueryTests.cs | 17 +-------- 10 files changed, 20 insertions(+), 135 deletions(-) diff --git a/src/Sheetly.Core/Migration/MigrationSnapshot.cs b/src/Sheetly.Core/Migration/MigrationSnapshot.cs index fa2f548..fa235f0 100644 --- a/src/Sheetly.Core/Migration/MigrationSnapshot.cs +++ b/src/Sheetly.Core/Migration/MigrationSnapshot.cs @@ -5,17 +5,14 @@ /// public class ColumnSchema { - // Basic properties public string Name { get; set; } = string.Empty; public string PropertyName { get; set; } = string.Empty; public string DataType { get; set; } = string.Empty; - public string? ClrType { get; set; } // Full CLR type name (e.g., "System.Int32") + public string? ClrType { get; set; } - // Nullability public bool IsNullable { get; set; } = true; public bool IsRequired { get; set; } = false; - // Key constraints public bool IsPrimaryKey { get; set; } public bool IsForeignKey { get; set; } public string? ForeignKeyTable { get; set; } @@ -23,45 +20,36 @@ public class ColumnSchema public ForeignKeyAction OnDelete { get; set; } = ForeignKeyAction.NoAction; public ForeignKeyAction OnUpdate { get; set; } = ForeignKeyAction.NoAction; - // Unique and Index public bool IsUnique { get; set; } public string? IndexName { get; set; } public bool IsClustered { get; set; } - // Value constraints public int? MaxLength { get; set; } public int? MinLength { get; set; } public object? DefaultValue { get; set; } public string? DefaultValueSql { get; set; } - // Numeric constraints public decimal? MinValue { get; set; } public decimal? MaxValue { get; set; } public int? Precision { get; set; } public int? Scale { get; set; } - // Check constraints public string? CheckConstraint { get; set; } public string? CheckConstraintName { get; set; } - // Computed columns public bool IsComputed { get; set; } public string? ComputedColumnSql { get; set; } public bool? IsStored { get; set; } - // Concurrency public bool IsConcurrencyToken { get; set; } public bool IsRowVersion { get; set; } - // Auto-increment public bool IsAutoIncrement { get; set; } public long? IdentitySeed { get; set; } public long? IdentityIncrement { get; set; } - // Validation rules (JSON format for complex validations) public string? ValidationRules { get; set; } - // Additional metadata public string? Comment { get; set; } public string? Collation { get; set; } } @@ -91,9 +79,8 @@ public class EntitySchema public List Indexes { get; set; } = []; public List CheckConstraints { get; set; } = []; - // Table-level options public string? Comment { get; set; } - public string? Schema { get; set; } // For database schema (e.g., "dbo") + public string? Schema { get; set; } public Dictionary AdditionalOptions { get; set; } = []; } @@ -106,7 +93,7 @@ public class IndexSchema public List Columns { get; set; } = []; public bool IsUnique { get; set; } public bool IsClustered { get; set; } - public string? Filter { get; set; } // For filtered indexes + public string? Filter { get; set; } } /// diff --git a/src/Sheetly.Core/Migrations/Operations/AddColumnOperation.cs b/src/Sheetly.Core/Migrations/Operations/AddColumnOperation.cs index 77b5c82..653e5a3 100644 --- a/src/Sheetly.Core/Migrations/Operations/AddColumnOperation.cs +++ b/src/Sheetly.Core/Migrations/Operations/AddColumnOperation.cs @@ -10,11 +10,9 @@ public class AddColumnOperation : MigrationOperation public string Name { get; set; } = string.Empty; public Type ClrType { get; set; } = typeof(string); - // Nullability public bool IsNullable { get; set; } = true; public bool IsRequired { get; set; } - // Keys public bool IsPrimaryKey { get; set; } public bool IsForeignKey => !string.IsNullOrEmpty(ForeignKeyTable); public string? ForeignKeyTable { get; set; } @@ -22,38 +20,30 @@ public class AddColumnOperation : MigrationOperation public ForeignKeyAction OnDelete { get; set; } = ForeignKeyAction.NoAction; public ForeignKeyAction OnUpdate { get; set; } = ForeignKeyAction.NoAction; - // Unique and Index public bool IsUnique { get; set; } public string? IndexName { get; set; } - // Value constraints public int? MaxLength { get; set; } public int? MinLength { get; set; } public object? DefaultValue { get; set; } public string? DefaultValueSql { get; set; } - // Numeric constraints public decimal? MinValue { get; set; } public decimal? MaxValue { get; set; } public int? Precision { get; set; } public int? Scale { get; set; } - // Check constraint public string? CheckConstraint { get; set; } - // Computed column public bool IsComputed { get; set; } public string? ComputedColumnSql { get; set; } public bool? IsStored { get; set; } - // Concurrency public bool IsConcurrencyToken { get; set; } public bool IsRowVersion { get; set; } - // Auto-increment public bool IsAutoIncrement { get; set; } - // Additional metadata public string? Comment { get; set; } public string? ClassName { get; set; } } diff --git a/src/Sheetly.Core/SheetsContext.cs b/src/Sheetly.Core/SheetsContext.cs index d0533ca..c96e5f8 100644 --- a/src/Sheetly.Core/SheetsContext.cs +++ b/src/Sheetly.Core/SheetsContext.cs @@ -19,17 +19,10 @@ public abstract class SheetsContext : IDisposable, IAsyncDisposable private MigrationSnapshot? _currentSnapshot; private ConstraintValidator? _validator; - // Stores options provided via constructor so InitializeAsync can use them - // without requiring an OnConfiguring override. private readonly SheetsOptions? _constructorOptions; - /// Parameterless constructor — provider configured via OnConfiguring(). protected SheetsContext() { } - /// - /// Options constructor — mirrors EF Core's DbContext(DbContextOptions) pattern. - /// Use with for DI-friendly contexts. - /// protected SheetsContext(SheetsOptions options) { _constructorOptions = options; @@ -43,7 +36,6 @@ public virtual async Task InitializeAsync(ISheetsProvider? provider = null, IMig { if (provider == null) { - // Prefer constructor-injected options over OnConfiguring override var options = _constructorOptions ?? new SheetsOptions(); if (_constructorOptions == null) OnConfiguring(options); @@ -108,7 +100,7 @@ private void CheckModelSnapshotSync() var snapshotType = GetType().Assembly.GetTypes() .FirstOrDefault(t => t.Name.EndsWith("ModelSnapshot") && t.IsSubclassOf(typeof(MigrationSnapshot))); - if (snapshotType == null) return; // No snapshot class yet — new project + if (snapshotType == null) return; var storedSnapshot = (MigrationSnapshot?)Activator.CreateInstance(snapshotType); if (storedSnapshot == null) return; @@ -144,10 +136,7 @@ private async Task> GetAppliedMigrationsFromRemoteAsync() const string HistoryTable = "__SheetlyMigrationsHistory__"; if (!await Provider.SheetExistsAsync(HistoryTable)) - { - // History table doesn't exist - database is new return new List(); - } var rows = await Provider.GetAllRowsAsync(HistoryTable); @@ -167,7 +156,6 @@ private void InitializeSets(ISheetsProvider provider, MigrationSnapshot snapshot { var entityType = prop.PropertyType.GetGenericArguments()[0]; - // Try to find schema by entity type name matching any table EntitySchema? schema = null; foreach (var kvp in snapshot.Entities) { @@ -197,7 +185,6 @@ private void InitializeSets(ISheetsProvider provider, MigrationSnapshot snapshot public async Task SaveChangesAsync(CancellationToken cancellationToken = default) { - // Auto change detection: promote Unchanged → Modified for mutated entities foreach (var set in sets.Values) { set.GetType() @@ -222,7 +209,7 @@ public async Task SaveChangesAsync(CancellationToken cancellationToken = de allDeletedEntities.AddRange(deleted); } - // Validate locally BEFORE any API calls (constraint checks, FK format, etc.) + // Validate locally before any API calls if (_validator != null && allPendingEntities.Count > 0) { var result = new ValidationResult(); @@ -254,7 +241,6 @@ public async Task SaveChangesAsync(CancellationToken cancellationToken = de throw new ValidationException(result); } - // Remote FK validation — one API call per referenced table if (allPendingEntities.Count > 0) await ValidateForeignKeyReferencesAsync(allPendingEntities); diff --git a/src/Sheetly.Core/SheetsSet.cs b/src/Sheetly.Core/SheetsSet.cs index bc573c7..46231e6 100644 --- a/src/Sheetly.Core/SheetsSet.cs +++ b/src/Sheetly.Core/SheetsSet.cs @@ -86,8 +86,8 @@ public async Task> ToListAsync() if (!_asNoTracking && !_trackedEntities.ContainsKey(entity)) { _trackedEntities[entity] = EntityState.Unchanged; - _entityRowIndexes[entity] = i + 1; // A1 notation: row 1=header, row 2=first data - _snapshots[entity] = JsonSerializer.Serialize(entity); // snapshot for auto change detection + _entityRowIndexes[entity] = i + 1; + _snapshots[entity] = JsonSerializer.Serialize(entity); } } @@ -118,16 +118,12 @@ public async Task> Where(Func predicate) var keyStr = keyValue.ToString()!; - // Optimization: scan only the PK column to find the row, then fetch just that row. - // 2 API calls (key-column scan + single row) vs GetAllRowsAsync (1 call but all data). - // For large datasets this is significantly faster. var rowIndex = await provider.FindRowIndexByKeyAsync(schema.TableName, keyStr); if (rowIndex < 0) return default; var rowData = await provider.GetRowByIndexAsync(schema.TableName, rowIndex); if (rowData == null) return default; - // Need the header row for column name mapping var headerRow = await provider.GetRowByIndexAsync(schema.TableName, 1); if (headerRow == null) return default; var headers = headerRow.Select(h => h?.ToString() ?? string.Empty).ToList(); @@ -172,8 +168,6 @@ private async Task ProcessIncludes(List entities) EntitySchema? relatedSchema; if (!allSchemas.TryGetValue(relatedTableName, out relatedSchema)) { - // Fallback: match by ClassName when table name uses a different convention - // (e.g. fluent API HasSheetName("Products") vs. EntityMapper returning "Product") relatedSchema = allSchemas.Values.FirstOrDefault(s => s.ClassName == targetType.Name); if (relatedSchema == null) continue; } @@ -282,7 +276,6 @@ internal async Task SaveChangesInternalAsync() if (pkColumn != null) { - // Batch path: read MAX(id) once, assign IDs locally, append all rows in 1 API call int nextId = await provider.GetMaxIdAsync(schema.TableName) + 1; var batchRows = new List>(toAdd.Count); var pkProp = typeof(T).GetProperty(pkColumn.PropertyName); @@ -296,7 +289,6 @@ internal async Task SaveChangesInternalAsync() } else { - // No PK — batch append without ID assignment var batchRows = toAdd.Select(item => EntityMapper.MapToRow(item.Key, schema)).ToList(); await provider.AppendRowsAsync(schema.TableName, (IList>)batchRows); } diff --git a/src/Sheetly.DependencyInjection/Extensions/ServiceCollectionExtensions.cs b/src/Sheetly.DependencyInjection/Extensions/ServiceCollectionExtensions.cs index f3cd4f6..7266787 100644 --- a/src/Sheetly.DependencyInjection/Extensions/ServiceCollectionExtensions.cs +++ b/src/Sheetly.DependencyInjection/Extensions/ServiceCollectionExtensions.cs @@ -22,7 +22,6 @@ public static IServiceCollection AddSheetsContext( var options = new SheetsContextOptions(); configure?.Invoke(options); - // Try constructor injection (EF Core-style) first var ctorWithOptions = typeof(TContext) .GetConstructor([typeof(SheetsContextOptions)]); @@ -34,7 +33,6 @@ public static IServiceCollection AddSheetsContext( } else { - // Fallback: parameterless constructor + provider passed to InitializeAsync context = (TContext)Activator.CreateInstance(typeof(TContext), nonPublic: true)!; if (options.Provider != null) context.InitializeAsync(options.Provider).GetAwaiter().GetResult(); diff --git a/src/Sheetly.Google/GoogleSheetProvider.cs b/src/Sheetly.Google/GoogleSheetProvider.cs index 8d344ce..6647dcb 100644 --- a/src/Sheetly.Google/GoogleSheetProvider.cs +++ b/src/Sheetly.Google/GoogleSheetProvider.cs @@ -15,12 +15,7 @@ public class GoogleSheetProvider : ISheetsProvider private readonly string _spreadsheetId; private int _serviceIndex = -1; - // ── Sheet metadata cache ──────────────────────────────────────────────── - // Populated on InitializeAsync(); updated on CreateSheet/DeleteSheet. - // Eliminates per-operation Spreadsheets.Get() API calls. - private Dictionary _sheetCache = []; // name → sheetId - - // ── Retry configuration ─────────────────────────────────────────────────── + private Dictionary _sheetCache = []; private const int MaxRetries = 5; private static readonly TimeSpan InitialRetryDelay = TimeSpan.FromSeconds(2); @@ -61,8 +56,7 @@ public GoogleSheetProvider(string credentialsPath, string spreadsheetId) { _spreadsheetId = spreadsheetId; using var stream = new FileStream(credentialsPath, FileMode.Open, FileAccess.Read); - var json = new StreamReader(stream).ReadToEnd(); - _services = LoadServicesFromJson(json); + _services = LoadServicesFromJson(new StreamReader(stream).ReadToEnd()); } public GoogleSheetProvider(IConfigurationSection section, string spreadsheetId) @@ -83,7 +77,6 @@ private static SheetsService[] LoadServicesFromJson(string json) var trimmed = json.TrimStart(); if (trimmed.StartsWith('[')) { - // Array format: [{...}, {...}, ...] using var doc = JsonDocument.Parse(json); var services = new List(); foreach (var element in doc.RootElement.EnumerateArray()) @@ -93,7 +86,6 @@ private static SheetsService[] LoadServicesFromJson(string json) return [.. services]; } - // Single object format: {...} return [CreateServiceFromJson(json)]; } @@ -119,7 +111,6 @@ public async Task InitializeAsync() public async Task DropDatabaseAsync() { - // Work from cache — avoids extra Spreadsheets.Get() calls var sheetNames = _sheetCache.Keys.ToList(); var appSheets = sheetNames @@ -127,11 +118,10 @@ public async Task DropDatabaseAsync() !t.Equals("Sheet1", StringComparison.OrdinalIgnoreCase)) .ToList(); - // If all sheets are app sheets, keep one default sheet if (appSheets.Count == sheetNames.Count && sheetNames.Count > 0) { await CreateSheetAsync("Sheet1", new List()); - sheetNames = _sheetCache.Keys.ToList(); // refresh from updated cache + sheetNames = _sheetCache.Keys.ToList(); } foreach (var title in sheetNames) @@ -144,13 +134,6 @@ public async Task DropDatabaseAsync() } } - private SheetsService CreateService(GoogleCredential credential) => - new(new BaseClientService.Initializer - { - HttpClientInitializer = credential, - ApplicationName = "Sheetly" - }); - public async Task>> GetAllRowsAsync(string sheetName) { var response = await ExecuteWithRetryAsync( @@ -167,17 +150,16 @@ public async Task>> GetAllRowsAsync(string sheetName) public async Task FindRowIndexByKeyAsync(string sheetName, string keyValue) { - // Fetch only column A (the PK column) — much less data than GetAllRowsAsync var request = NextService.Spreadsheets.Values.Get(_spreadsheetId, $"'{sheetName}'!A:A"); request.ValueRenderOption = SpreadsheetsResource.ValuesResource.GetRequest.ValueRenderOptionEnum.UNFORMATTEDVALUE; var response = await ExecuteWithRetryAsync(request); if (response.Values == null) return -1; - for (int i = 1; i < response.Values.Count; i++) // skip header (index 0 = row 1) + for (int i = 1; i < response.Values.Count; i++) { var cell = response.Values[i].Count > 0 ? response.Values[i][0]?.ToString() : null; if (cell == keyValue) - return i + 1; // 1-based row index + return i + 1; } return -1; } diff --git a/tests/Sheetly.Core.Tests/Integration/CrudTests.cs b/tests/Sheetly.Core.Tests/Integration/CrudTests.cs index 86118df..ac100f4 100644 --- a/tests/Sheetly.Core.Tests/Integration/CrudTests.cs +++ b/tests/Sheetly.Core.Tests/Integration/CrudTests.cs @@ -8,8 +8,6 @@ namespace Sheetly.Core.Tests.Integration; /// public class CrudTests { - // ── CREATE ──────────────────────────────────────────────────────────────── - [Fact] public async Task Add_SingleEntity_AssignsPositiveId() { @@ -37,7 +35,7 @@ public async Task Add_MultipleEntities_AssignsUniqueIds() await ctx.SaveChangesAsync(); var ids = new[] { c1.Id, c2.Id, c3.Id }; - Assert.Equal(ids.Distinct().Count(), ids.Length); // all unique + Assert.Equal(ids.Distinct().Count(), ids.Length); Assert.All(ids, id => Assert.True(id > 0)); } @@ -55,8 +53,6 @@ public async Task Add_MultipleEntities_IdsAreSequential() Assert.Equal(c1.Id + 1, c2.Id); } - // ── READ ────────────────────────────────────────────────────────────────── - [Fact] public async Task ToListAsync_EmptySheet_ReturnsEmptyList() { @@ -115,8 +111,6 @@ public async Task ToListAsync_FieldsRoundtripCorrectly() Assert.Equal(category.Id, p.CategoryId); } - // ── UPDATE ──────────────────────────────────────────────────────────────── - [Fact] public async Task Update_ChangesArePersistedOnNextRead() { @@ -161,8 +155,6 @@ public async Task Update_Product_DecimalPriceRoundtrips() Assert.Equal(599.99m, updated.First(x => x.Id == product.Id).Price); } - // ── DELETE ──────────────────────────────────────────────────────────────── - [Fact] public async Task Remove_EntityIsGoneAfterSave() { @@ -219,8 +211,6 @@ public async Task Remove_MultipleEntities_AllDeleted() Assert.Empty(await ctx.Categories.ToListAsync()); } - // ── SaveChanges return value ────────────────────────────────────────────── - [Fact] public async Task SaveChangesAsync_ReturnsCorrectChangeCount() { diff --git a/tests/Sheetly.Core.Tests/Integration/Helpers/InMemorySheetsProvider.cs b/tests/Sheetly.Core.Tests/Integration/Helpers/InMemorySheetsProvider.cs index f7e6c7e..872c7f8 100644 --- a/tests/Sheetly.Core.Tests/Integration/Helpers/InMemorySheetsProvider.cs +++ b/tests/Sheetly.Core.Tests/Integration/Helpers/InMemorySheetsProvider.cs @@ -10,12 +10,9 @@ namespace Sheetly.Core.Tests.Integration.Helpers; /// public sealed class InMemorySheetsProvider : ISheetsProvider { - // sheetName → list of rows (index 0 = header, index 1+ = data rows) private readonly Dictionary>> _sheets = new(StringComparer.OrdinalIgnoreCase); - // ── Lifecycle ──────────────────────────────────────────────────────────── - public Task InitializeAsync() => Task.CompletedTask; public Task DropDatabaseAsync() @@ -26,8 +23,6 @@ public Task DropDatabaseAsync() public void Dispose() { } - // ── Sheet management ───────────────────────────────────────────────────── - public Task SheetExistsAsync(string sheetName) => Task.FromResult(_sheets.ContainsKey(sheetName)); @@ -60,8 +55,6 @@ public Task ClearSheetAsync(string sheetName) public Task HideSheetAsync(string sheetName) => Task.CompletedTask; - // ── Row CRUD (1-based rowIndex: 1 = header, 2 = first data row) ────────── - public Task>> GetAllRowsAsync(string sheetName) { if (_sheets.TryGetValue(sheetName, out var rows)) @@ -73,7 +66,7 @@ public Task>> GetAllRowsAsync(string sheetName) { if (_sheets.TryGetValue(sheetName, out var rows)) { - int idx = rowIndex - 1; // convert to 0-based + int idx = rowIndex - 1; if (idx >= 0 && idx < rows.Count) return Task.FromResult?>(rows[idx].ToList()); } @@ -83,9 +76,9 @@ public Task>> GetAllRowsAsync(string sheetName) public Task FindRowIndexByKeyAsync(string sheetName, string keyValue) { if (_sheets.TryGetValue(sheetName, out var rows)) - for (int i = 1; i < rows.Count; i++) // skip header at index 0 + for (int i = 1; i < rows.Count; i++) if (rows[i].Count > 0 && rows[i][0]?.ToString() == keyValue) - return Task.FromResult(i + 1); // 1-based + return Task.FromResult(i + 1); return Task.FromResult(-1); } @@ -108,7 +101,7 @@ public Task GetMaxIdAsync(string sheetName) { int max = 0; if (_sheets.TryGetValue(sheetName, out var rows)) - for (int i = 1; i < rows.Count; i++) // skip header at index 0 + for (int i = 1; i < rows.Count; i++) if (rows[i].Count > 0 && int.TryParse(rows[i][0]?.ToString(), out var id) && id > max) max = id; return Task.FromResult(max); @@ -119,9 +112,8 @@ public Task AppendRowAndGetIdAsync(string sheetName, IList row) if (!_sheets.TryGetValue(sheetName, out var rows)) return Task.FromResult(1); - // Compute MAX(Id) + 1 in-memory (mirrors the Sheets formula) int maxId = 0; - for (int i = 1; i < rows.Count; i++) // skip header at index 0 + for (int i = 1; i < rows.Count; i++) { if (rows[i].Count > 0 && int.TryParse(rows[i][0]?.ToString(), out var id) && id > maxId) maxId = id; @@ -157,8 +149,6 @@ public Task DeleteRowAsync(string sheetName, int rowIndex) return Task.CompletedTask; } - // ── Cell value (A1 notation, e.g. "AC2") ──────────────────────────────── - public Task UpdateValueAsync(string sheetName, string cellAddress, object value) { if (!_sheets.TryGetValue(sheetName, out var rows)) return Task.CompletedTask; @@ -188,34 +178,20 @@ public Task UpdateValueAsync(string sheetName, string cellAddress, object value) return Task.FromResult(rowData[col]); } - // ── Stubs (not needed for CRUD tests) ─────────────────────────────────── - public Task AddDataValidationAsync(string sheetName, int columnIndex, string message) => Task.CompletedTask; public Task SetCheckboxAsync(string sheetName, int startRow, int endRow, int columnId) => Task.CompletedTask; - // ── Test helpers ───────────────────────────────────────────────────────── - - /// - /// Returns a snapshot of all rows in the given sheet (for assertions). - /// Returns empty list if sheet does not exist. - /// public List> GetSheetSnapshot(string sheetName) => _sheets.TryGetValue(sheetName, out var rows) ? rows.Select(r => (IList)r.ToList()).ToList() : new List>(); - /// - /// Returns the total number of data rows (excluding header) in the sheet. - /// public int DataRowCount(string sheetName) => _sheets.TryGetValue(sheetName, out var rows) ? Math.Max(0, rows.Count - 1) : 0; - // ── Cell address parsing ───────────────────────────────────────────────── - - /// Converts column letters like "AC" to 0-based index (A=0, B=1, …, AC=28). private static int ParseColumnIndex(string cellAddress) { int i = 0; @@ -224,7 +200,7 @@ private static int ParseColumnIndex(string cellAddress) int index = 0; foreach (char c in letters) index = index * 26 + (c - 'A' + 1); - return index - 1; // 0-based + return index - 1; } private static int ParseRowNumber(string cellAddress) diff --git a/tests/Sheetly.Core.Tests/Integration/IdGenerationTests.cs b/tests/Sheetly.Core.Tests/Integration/IdGenerationTests.cs index 48b361a..0ed5c27 100644 --- a/tests/Sheetly.Core.Tests/Integration/IdGenerationTests.cs +++ b/tests/Sheetly.Core.Tests/Integration/IdGenerationTests.cs @@ -110,7 +110,6 @@ public async Task ProductAndCategory_HaveIndependentIdCounters() ctx.Products.Add(product); await ctx.SaveChangesAsync(); - // Both start at 1 but from separate counters — both being 1 is valid Assert.True(category.Id > 0); Assert.True(product.Id > 0); } diff --git a/tests/Sheetly.Core.Tests/Integration/QueryTests.cs b/tests/Sheetly.Core.Tests/Integration/QueryTests.cs index 359cc08..3e843d6 100644 --- a/tests/Sheetly.Core.Tests/Integration/QueryTests.cs +++ b/tests/Sheetly.Core.Tests/Integration/QueryTests.cs @@ -8,8 +8,6 @@ namespace Sheetly.Core.Tests.Integration; /// public class QueryTests { - // ── FindAsync ───────────────────────────────────────────────────────────── - [Fact] public async Task FindAsync_ExistingId_ReturnsEntity() { @@ -36,8 +34,6 @@ public async Task FindAsync_NonExistingId_ReturnsNull() Assert.Null(found); } - // ── FirstOrDefaultAsync ─────────────────────────────────────────────────── - [Fact] public async Task FirstOrDefaultAsync_NoPredicate_ReturnsFirstEntity() { @@ -90,8 +86,6 @@ public async Task FirstOrDefaultAsync_EmptySheet_ReturnsNull() Assert.Null(result); } - // ── Where ───────────────────────────────────────────────────────────────── - [Fact] public async Task Where_FiltersByPredicate() { @@ -115,7 +109,7 @@ public async Task Where_FiltersByPredicate() // Get products with Price > 20 var expensive = await ctx.Products.Where(p => p.Price > 20m); - Assert.Equal(3, expensive.Count); // 30, 40, 50 + Assert.Equal(3, expensive.Count); Assert.All(expensive, p => Assert.True(p.Price > 20m)); } @@ -132,8 +126,6 @@ public async Task Where_NoMatch_ReturnsEmptyList() Assert.Empty(result); } - // ── CountAsync ──────────────────────────────────────────────────────────── - [Fact] public async Task CountAsync_NoPredicate_ReturnsTotal() { @@ -173,8 +165,6 @@ public async Task CountAsync_EmptySheet_ReturnsZero() Assert.Equal(0, await ctx.Categories.CountAsync()); } - // ── AnyAsync ────────────────────────────────────────────────────────────── - [Fact] public async Task AnyAsync_WithData_ReturnsTrue() { @@ -216,8 +206,6 @@ public async Task AnyAsync_WithNonMatchingPredicate_ReturnsFalse() Assert.False(await ctx.Categories.AnyAsync(c => c.Name == "Missing")); } - // ── AsNoTracking ────────────────────────────────────────────────────────── - [Fact] public async Task AsNoTracking_DoesNotCauseDoubleSaveOnSubsequentSave() { @@ -234,8 +222,6 @@ public async Task AsNoTracking_DoesNotCauseDoubleSaveOnSubsequentSave() Assert.Equal(0, changes); } - // ── Include (eager loading) ─────────────────────────────────────────────── - [Fact] public async Task Include_LoadsRelatedCollection() { @@ -268,7 +254,6 @@ public async Task Include_CategoryWithNoProducts_EmptyCollection() var categories = await ctx.Categories.Include("Products").ToListAsync(); - // Products list should be null or empty (no products inserted) var cat = categories.Single(); var productCount = cat.Products?.Count ?? 0; Assert.Equal(0, productCount); From 387b69dca8b9372494ae42fb018cced8a446567a Mon Sep 17 00:00:00 2001 From: muqimjon Date: Sat, 28 Feb 2026 01:09:34 +0500 Subject: [PATCH 13/36] refactor: add guard clause to SaveChangesAsync Throw InvalidOperationException if context is not initialized, preventing confusing NullReferenceException at runtime. --- src/Sheetly.Core/SheetsContext.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Sheetly.Core/SheetsContext.cs b/src/Sheetly.Core/SheetsContext.cs index c96e5f8..c4c0822 100644 --- a/src/Sheetly.Core/SheetsContext.cs +++ b/src/Sheetly.Core/SheetsContext.cs @@ -185,6 +185,10 @@ private void InitializeSets(ISheetsProvider provider, MigrationSnapshot snapshot public async Task SaveChangesAsync(CancellationToken cancellationToken = default) { + if (Provider == null) + throw new InvalidOperationException( + "Context not initialized. Call InitializeAsync() first."); + foreach (var set in sets.Values) { set.GetType() From d0380ce472793cc12026e113a6773e414d139ab5 Mon Sep 17 00:00:00 2001 From: muqimjon Date: Sat, 28 Feb 2026 01:11:14 +0500 Subject: [PATCH 14/36] test: add tests for change tracking and expression Include Add ChangeTrackingTests (auto-detect modified, unchanged, AsNoTracking) and ExpressionIncludeTests (collection, reference, string vs expression). --- .../Integration/ChangeTrackingTests.cs | 85 +++++++++++++++++++ .../Integration/ExpressionIncludeTests.cs | 67 +++++++++++++++ .../Integration/Models/TestModels.cs | 2 - 3 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 tests/Sheetly.Core.Tests/Integration/ChangeTrackingTests.cs create mode 100644 tests/Sheetly.Core.Tests/Integration/ExpressionIncludeTests.cs diff --git a/tests/Sheetly.Core.Tests/Integration/ChangeTrackingTests.cs b/tests/Sheetly.Core.Tests/Integration/ChangeTrackingTests.cs new file mode 100644 index 0000000..cd939d6 --- /dev/null +++ b/tests/Sheetly.Core.Tests/Integration/ChangeTrackingTests.cs @@ -0,0 +1,85 @@ +using Sheetly.Core.Tests.Integration.Models; + +namespace Sheetly.Core.Tests.Integration; + +/// +/// Tests for automatic change tracking via JSON snapshots. +/// +public class ChangeTrackingTests +{ + [Fact] + public async Task AutoDetect_ModifiedEntity_SavesWithoutExplicitUpdate() + { + var (ctx, _) = await TestContextFactory.CreateAsync(); + + ctx.Categories.Add(new Category { Name = "Original" }); + await ctx.SaveChangesAsync(); + + var all = await ctx.Categories.ToListAsync(); + var cat = all.First(); + cat.Name = "Modified"; + + int changes = await ctx.SaveChangesAsync(); + + Assert.Equal(1, changes); + + var refreshed = await ctx.Categories.ToListAsync(); + Assert.Equal("Modified", refreshed.First().Name); + } + + [Fact] + public async Task AutoDetect_UnchangedEntity_DoesNotSave() + { + var (ctx, _) = await TestContextFactory.CreateAsync(); + + ctx.Categories.Add(new Category { Name = "Stable" }); + await ctx.SaveChangesAsync(); + + _ = await ctx.Categories.ToListAsync(); + + int changes = await ctx.SaveChangesAsync(); + + Assert.Equal(0, changes); + } + + [Fact] + public async Task AutoDetect_MultipleModified_SavesAll() + { + var (ctx, _) = await TestContextFactory.CreateAsync(); + + ctx.Categories.Add(new Category { Name = "Alpha" }); + ctx.Categories.Add(new Category { Name = "Bravo" }); + await ctx.SaveChangesAsync(); + + var all = await ctx.Categories.ToListAsync(); + all[0].Name = "Alpha-Updated"; + all[1].Name = "Bravo-Updated"; + + int changes = await ctx.SaveChangesAsync(); + + Assert.Equal(2, changes); + + var refreshed = await ctx.Categories.ToListAsync(); + Assert.Contains(refreshed, c => c.Name == "Alpha-Updated"); + Assert.Contains(refreshed, c => c.Name == "Bravo-Updated"); + } + + [Fact] + public async Task AsNoTracking_ModifiedEntity_DoesNotAutoSave() + { + var (ctx, _) = await TestContextFactory.CreateAsync(); + + ctx.Categories.Add(new Category { Name = "Untracked" }); + await ctx.SaveChangesAsync(); + + var all = await ctx.Categories.AsNoTracking().ToListAsync(); + all.First().Name = "ShouldNotSave"; + + int changes = await ctx.SaveChangesAsync(); + + Assert.Equal(0, changes); + + var refreshed = await ctx.Categories.ToListAsync(); + Assert.Equal("Untracked", refreshed.First().Name); + } +} diff --git a/tests/Sheetly.Core.Tests/Integration/ExpressionIncludeTests.cs b/tests/Sheetly.Core.Tests/Integration/ExpressionIncludeTests.cs new file mode 100644 index 0000000..e7b74fc --- /dev/null +++ b/tests/Sheetly.Core.Tests/Integration/ExpressionIncludeTests.cs @@ -0,0 +1,67 @@ +using Sheetly.Core.Tests.Integration.Models; + +namespace Sheetly.Core.Tests.Integration; + +/// +/// Tests for expression-based Include overload. +/// +public class ExpressionIncludeTests +{ + [Fact] + public async Task ExpressionInclude_LoadsRelatedCollection() + { + var (ctx, _) = await TestContextFactory.CreateAsync(); + + var category = new Category { Name = "Tech" }; + ctx.Categories.Add(category); + await ctx.SaveChangesAsync(); + + ctx.Products.Add(new Product { Title = "Laptop", Price = 999m, CategoryId = category.Id }); + ctx.Products.Add(new Product { Title = "Mouse", Price = 29m, CategoryId = category.Id }); + await ctx.SaveChangesAsync(); + + var categories = await ctx.Categories.Include(c => c.Products).ToListAsync(); + var tech = categories.First(c => c.Id == category.Id); + + Assert.NotNull(tech.Products); + Assert.Equal(2, tech.Products.Count); + } + + [Fact] + public async Task ExpressionInclude_LoadsReferenceNavigation() + { + var (ctx, _) = await TestContextFactory.CreateAsync(); + + var category = new Category { Name = "Books" }; + ctx.Categories.Add(category); + await ctx.SaveChangesAsync(); + + ctx.Products.Add(new Product { Title = "Novel", Price = 15m, CategoryId = category.Id }); + await ctx.SaveChangesAsync(); + + var products = await ctx.Products.Include(p => p.Category).ToListAsync(); + + Assert.NotNull(products.First().Category); + Assert.Equal("Books", products.First().Category.Name); + } + + [Fact] + public async Task StringAndExpressionInclude_ProduceSameResult() + { + var (ctx, _) = await TestContextFactory.CreateAsync(); + + var category = new Category { Name = "Music" }; + ctx.Categories.Add(category); + await ctx.SaveChangesAsync(); + + ctx.Products.Add(new Product { Title = "Guitar", Price = 299m, CategoryId = category.Id }); + await ctx.SaveChangesAsync(); + + var stringResult = await ctx.Categories.Include("Products").ToListAsync(); + var exprResult = await ctx.Categories.Include(c => c.Products).ToListAsync(); + + Assert.Equal( + stringResult.First().Products?.Count ?? 0, + exprResult.First().Products?.Count ?? 0); + } +} diff --git a/tests/Sheetly.Core.Tests/Integration/Models/TestModels.cs b/tests/Sheetly.Core.Tests/Integration/Models/TestModels.cs index af36cba..68a260d 100644 --- a/tests/Sheetly.Core.Tests/Integration/Models/TestModels.cs +++ b/tests/Sheetly.Core.Tests/Integration/Models/TestModels.cs @@ -1,7 +1,5 @@ namespace Sheetly.Core.Tests.Integration.Models; -// ── Domain models used across all integration tests ───────────────────────── - public class Category { public int Id { get; set; } From b6404a9d7873c70838b6949cae63e0a3441ca909 Mon Sep 17 00:00:00 2001 From: muqimjon Date: Sat, 28 Feb 2026 01:12:26 +0500 Subject: [PATCH 15/36] docs: update README with new features Document auto change tracking, expression-based Include, multiple credentials rotation, batch operations, optimized FindAsync, CancellationToken support, SheetsContextOptions constructor pattern, IAsyncDisposable, and in-memory sheet metadata cache. --- README.md | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 61 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index d33dfea..452bd59 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ context.Products.Add(new Product { Name = "Laptop", Price = 1200 }); await context.SaveChangesAsync(); var products = await context.Products - .Include("Category") + .Include(p => p.Category) .ToListAsync(); ``` @@ -65,9 +65,12 @@ var products = await context.Products ### 🎯 **EF Core-Style API** - `SheetsContext` and `SheetsSet` — familiar patterns - `Add()`, `Update()`, `Remove()`, `SaveChangesAsync()` -- `Include()` for eager loading +- **Automatic change tracking** — modify entities and call `SaveChangesAsync()` without explicit `Update()` +- `Include()` with **string** and **expression-based** overloads (`Include(p => p.Category)`) - `AsNoTracking()` for read-only queries - `FindAsync()`, `FirstOrDefaultAsync()`, `Where()`, `CountAsync()`, `AnyAsync()` +- `CancellationToken` support on `SaveChangesAsync()` +- `IAsyncDisposable` — use `await using` for automatic cleanup ### 🔄 **Code-First Migrations** - C# migration files with Up/Down methods @@ -90,10 +93,14 @@ dotnet sheetly database update - Column mapping (`HasColumnName()`) - Local validation before API calls -### 🛡️ **Schema Tracking** +### 🛡️ **Schema Tracking & Performance** - Hidden **\_\_SheetlySchema\_\_** sheet stores all metadata - Hidden **\_\_SheetlyMigrationsHistory\_\_** tracks applied migrations +- **Batch operations** — adding N entities uses a single API call +- **In-memory sheet metadata cache** — `SheetExistsAsync` costs 0 API calls after init +- **Optimized `FindAsync`** — scans only the PK column instead of full data - Automatic retry with exponential backoff on rate limits +- **Multiple credentials rotation** — distribute API quota across service accounts ### 🧰 **Professional CLI** ```bash @@ -228,14 +235,13 @@ context.Products.Add(product); await context.SaveChangesAsync(); // Query with Include -var products = await context.Products.Include("Category").ToListAsync(); +var products = await context.Products.Include(p => p.Category).ToListAsync(); foreach (var p in products) Console.WriteLine($"{p.Title} - ${p.Price} - {p.Category.Name}"); -// Update +// Update (auto change tracking — no explicit Update() needed) product.Price = 1100; -context.Products.Update(product); await context.SaveChangesAsync(); // Delete @@ -274,6 +280,17 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) ### **ASP.NET Core Integration** ```csharp +// Parameterless constructor (classic) +builder.Services.AddSheetsContext(options => + options.UseGoogleSheets("credentials.json", "spreadsheet-id")); + +// Options constructor (EF Core-style) +public class MyAppContext : SheetsContext +{ + public MyAppContext(SheetsContextOptions options) : base(options) { } + public SheetsSet Products { get; set; } +} + builder.Services.AddSheetsContext(options => options.UseGoogleSheets("credentials.json", "spreadsheet-id")); ``` @@ -294,6 +311,44 @@ var count = await context.Products.CountAsync(); var any = await context.Products.AnyAsync(p => p.Price > 0); ``` +### **Expression-Based Include** + +```csharp +// Type-safe — compile-time validation +var products = await context.Products.Include(p => p.Category).ToListAsync(); +var categories = await context.Categories.Include(c => c.Products).ToListAsync(); + +// String-based still supported +var products2 = await context.Products.Include("Category").ToListAsync(); +``` + +### **Automatic Change Tracking** + +```csharp +var products = await context.Products.ToListAsync(); +products.First().Price = 999; + +// No need for context.Products.Update(product) — changes are auto-detected +await context.SaveChangesAsync(); +``` + +### **Multiple Credentials (API Quota Rotation)** + +```csharp +// credentials.json can be a single object or an array: +// [{ "type": "service_account", ... }, { "type": "service_account", ... }] +// Each API call rotates to the next credential (round-robin) +// Effective limit: N accounts × 60 req/min = N×60 req/min +options.UseGoogleSheets("credentials.json", "spreadsheet-id"); +``` + +### **CancellationToken Support** + +```csharp +var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); +await context.SaveChangesAsync(cts.Token); +``` + --- ## 🏗️ Architecture From 75569b3e4372c42998530ee539e270296ca2fbfe Mon Sep 17 00:00:00 2001 From: muqimjon Date: Sat, 28 Feb 2026 01:13:33 +0500 Subject: [PATCH 16/36] chore: bump version to 1.1.0 New features added since v1.0.3: - Automatic change tracking - Expression-based Include - Multiple credentials rotation - Batch append operations - Optimized FindAsync - CancellationToken support - IAsyncDisposable - SheetsContextOptions constructor pattern - In-memory sheet metadata cache - Per-package release workflows --- src/Sheetly.CLI/Sheetly.CLI.csproj | 2 +- src/Sheetly.Core/Sheetly.Core.csproj | 2 +- .../Sheetly.DependencyInjection.csproj | 2 +- src/Sheetly.Google/Sheetly.Google.csproj | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Sheetly.CLI/Sheetly.CLI.csproj b/src/Sheetly.CLI/Sheetly.CLI.csproj index 4df3a06..6d1ab7a 100644 --- a/src/Sheetly.CLI/Sheetly.CLI.csproj +++ b/src/Sheetly.CLI/Sheetly.CLI.csproj @@ -9,7 +9,7 @@ dotnet-sheetly dotnet-sheetly - 1.0.3 + 1.1.0 Dotnetolog Muqimjon Mamadaliyev Copyright (c) 2025–2026 Muqimjon Mamadaliyev diff --git a/src/Sheetly.Core/Sheetly.Core.csproj b/src/Sheetly.Core/Sheetly.Core.csproj index 6157206..ddc748a 100644 --- a/src/Sheetly.Core/Sheetly.Core.csproj +++ b/src/Sheetly.Core/Sheetly.Core.csproj @@ -5,7 +5,7 @@ enable Sheetly.Core - 1.0.3 + 1.1.0 Dotnetolog Muqimjon Mamadaliyev Copyright (c) 2025–2026 Muqimjon Mamadaliyev diff --git a/src/Sheetly.DependencyInjection/Sheetly.DependencyInjection.csproj b/src/Sheetly.DependencyInjection/Sheetly.DependencyInjection.csproj index b0f335c..515f8b4 100644 --- a/src/Sheetly.DependencyInjection/Sheetly.DependencyInjection.csproj +++ b/src/Sheetly.DependencyInjection/Sheetly.DependencyInjection.csproj @@ -5,7 +5,7 @@ enable Sheetly.DependencyInjection - 1.0.3 + 1.1.0 Dotnetolog Muqimjon Mamadaliyev Copyright (c) 2025–2026 Muqimjon Mamadaliyev diff --git a/src/Sheetly.Google/Sheetly.Google.csproj b/src/Sheetly.Google/Sheetly.Google.csproj index 87a14df..06222f8 100644 --- a/src/Sheetly.Google/Sheetly.Google.csproj +++ b/src/Sheetly.Google/Sheetly.Google.csproj @@ -5,7 +5,7 @@ enable Sheetly.Google - 1.0.3 + 1.1.0 Dotnetolog Muqimjon Mamadaliyev Copyright (c) 2025–2026 Muqimjon Mamadaliyev From 8ad3d8fab47190d2b243ecf1e366fc69da7cb911 Mon Sep 17 00:00:00 2001 From: muqimjon Date: Sat, 28 Feb 2026 02:38:15 +0500 Subject: [PATCH 17/36] feat: add Sheetly.Excel provider with ClosedXML Implement ExcelSheetProvider (ISheetsProvider) for local .xlsx files. Add ExcelMigrationService with full DropColumn support. Add UseExcel() extension method on SheetsOptions. --- Sheetly.sln | 15 + src/Sheetly.Excel/ExcelMigrationService.cs | 308 +++++++++++++++ src/Sheetly.Excel/ExcelSheetProvider.cs | 356 ++++++++++++++++++ .../ExcelSheetsOptionsExtensions.cs | 18 + src/Sheetly.Excel/Sheetly.Excel.csproj | 38 ++ 5 files changed, 735 insertions(+) create mode 100644 src/Sheetly.Excel/ExcelMigrationService.cs create mode 100644 src/Sheetly.Excel/ExcelSheetProvider.cs create mode 100644 src/Sheetly.Excel/ExcelSheetsOptionsExtensions.cs create mode 100644 src/Sheetly.Excel/Sheetly.Excel.csproj diff --git a/Sheetly.sln b/Sheetly.sln index e5ec17b..b3af6e9 100644 --- a/Sheetly.sln +++ b/Sheetly.sln @@ -21,6 +21,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sheetly.Core.Tests", "tests\Sheetly.Core.Tests\Sheetly.Core.Tests.csproj", "{F7D83F5A-F558-4DD7-B025-C14A57B73164}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sheetly.Excel", "src\Sheetly.Excel\Sheetly.Excel.csproj", "{7B2AF4C6-ADBF-44BF-BE19-8C39CA06D63B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -103,6 +105,18 @@ Global {F7D83F5A-F558-4DD7-B025-C14A57B73164}.Release|x64.Build.0 = Release|Any CPU {F7D83F5A-F558-4DD7-B025-C14A57B73164}.Release|x86.ActiveCfg = Release|Any CPU {F7D83F5A-F558-4DD7-B025-C14A57B73164}.Release|x86.Build.0 = Release|Any CPU + {7B2AF4C6-ADBF-44BF-BE19-8C39CA06D63B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7B2AF4C6-ADBF-44BF-BE19-8C39CA06D63B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7B2AF4C6-ADBF-44BF-BE19-8C39CA06D63B}.Debug|x64.ActiveCfg = Debug|Any CPU + {7B2AF4C6-ADBF-44BF-BE19-8C39CA06D63B}.Debug|x64.Build.0 = Debug|Any CPU + {7B2AF4C6-ADBF-44BF-BE19-8C39CA06D63B}.Debug|x86.ActiveCfg = Debug|Any CPU + {7B2AF4C6-ADBF-44BF-BE19-8C39CA06D63B}.Debug|x86.Build.0 = Debug|Any CPU + {7B2AF4C6-ADBF-44BF-BE19-8C39CA06D63B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7B2AF4C6-ADBF-44BF-BE19-8C39CA06D63B}.Release|Any CPU.Build.0 = Release|Any CPU + {7B2AF4C6-ADBF-44BF-BE19-8C39CA06D63B}.Release|x64.ActiveCfg = Release|Any CPU + {7B2AF4C6-ADBF-44BF-BE19-8C39CA06D63B}.Release|x64.Build.0 = Release|Any CPU + {7B2AF4C6-ADBF-44BF-BE19-8C39CA06D63B}.Release|x86.ActiveCfg = Release|Any CPU + {7B2AF4C6-ADBF-44BF-BE19-8C39CA06D63B}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -114,6 +128,7 @@ Global {B21444F8-726C-40F7-946D-3EE13B808442} = {EDE96271-BDBB-4A48-B4A3-C890C939E193} {87D2D7B9-819E-4F2A-B511-75A8CAC4DBDB} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {F7D83F5A-F558-4DD7-B025-C14A57B73164} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {7B2AF4C6-ADBF-44BF-BE19-8C39CA06D63B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {2901C6BF-30A0-42C6-97E2-7193CF638399} diff --git a/src/Sheetly.Excel/ExcelMigrationService.cs b/src/Sheetly.Excel/ExcelMigrationService.cs new file mode 100644 index 0000000..bb3940f --- /dev/null +++ b/src/Sheetly.Excel/ExcelMigrationService.cs @@ -0,0 +1,308 @@ +using Sheetly.Core.Abstractions; +using Sheetly.Core.Migrations.Operations; + +namespace Sheetly.Excel; + +/// +/// IMigrationService implementation for local Excel files. +/// Reuses the same __SheetlySchema__ / __SheetlyMigrationsHistory__ pattern as GoogleMigrationService. +/// +public class ExcelMigrationService(ISheetsProvider provider) : IMigrationService +{ + private const string HistoryTable = "__SheetlyMigrationsHistory__"; + private const string SchemaTable = "__SheetlySchema__"; + + private static readonly string[] SchemaTableHeaders = + [ + "ClassName", + "TableName", + "PropertyName", + "ColumnName", + "DataType", + "IsNullable", + "IsRequired", + "IsPrimaryKey", + "IsForeignKey", + "ForeignKeyTable", + "ForeignKeyColumn", + "OnDelete", + "OnUpdate", + "IsUnique", + "IndexName", + "MaxLength", + "MinLength", + "Precision", + "Scale", + "MinValue", + "MaxValue", + "DefaultValue", + "DefaultValueSql", + "CheckConstraint", + "IsComputed", + "ComputedSql", + "IsConcurrencyToken", + "IsAutoIncrement", + "CurrentIdValue", + "Comment" + ]; + + public async Task> GetAppliedMigrationsAsync() + { + if (!await provider.SheetExistsAsync(HistoryTable)) return []; + + var rows = await provider.GetAllRowsAsync(HistoryTable); + return rows.Skip(1) + .Where(r => r.Count > 0) + .Select(r => r[0]?.ToString() ?? "") + .Where(id => !string.IsNullOrEmpty(id)) + .ToList(); + } + + public async Task ApplyMigrationAsync(List operations, string migrationId) + { + await EnsureSystemTablesExistAsync(); + + foreach (var operation in operations) + await ExecuteOperationAsync(operation); + + await RecordMigrationAsync(migrationId); + } + + private async Task ExecuteOperationAsync(MigrationOperation operation) + { + switch (operation) + { + case CreateTableOperation createTable: + await CreateTableAsync(createTable); + break; + case DropTableOperation dropTable: + await DropTableAsync(dropTable); + break; + case AddColumnOperation addColumn: + await AddColumnAsync(addColumn); + break; + case DropColumnOperation dropColumn: + await DropColumnAsync(dropColumn); + break; + case AlterColumnOperation alterColumn: + await AlterColumnAsync(alterColumn); + break; + case CreateIndexOperation createIndex: + await CreateIndexAsync(createIndex); + break; + case DropIndexOperation dropIndex: + await DropIndexAsync(dropIndex); + break; + case AddCheckConstraintOperation: + case DropCheckConstraintOperation: + break; + default: + Console.WriteLine($"Warning: Operation {operation.OperationType} is not yet supported by Excel provider."); + break; + } + } + + private async Task CreateTableAsync(CreateTableOperation op) + { + var headers = op.Columns.Select(c => c.Name).ToList(); + await provider.CreateSheetAsync(op.Name, headers); + + foreach (var col in op.Columns) + { + col.Table = op.Name; + await AddColumnToSchemaAsync(col, op.ClassName); + } + } + + private async Task DropTableAsync(DropTableOperation op) + { + if (await provider.SheetExistsAsync(op.Name)) + await provider.DeleteSheetAsync(op.Name); + + var rows = await provider.GetAllRowsAsync(SchemaTable); + var newRows = new List> { rows[0] }; + for (int i = 1; i < rows.Count; i++) + { + if (rows[i].Count > 1 && rows[i][1]?.ToString() == op.Name) continue; + newRows.Add(rows[i]); + } + + await provider.ClearSheetAsync(SchemaTable); + foreach (var row in newRows) + await provider.AppendRowAsync(SchemaTable, row); + } + + private async Task AddColumnAsync(AddColumnOperation op) + { + var rows = await provider.GetRowByIndexAsync(op.Table, 1); + var headers = rows?.Select(x => x?.ToString() ?? "").ToList() ?? []; + + if (!headers.Contains(op.Name)) + { + var newHeaders = new List(headers.Cast()) { op.Name }; + await provider.UpdateRowAsync(op.Table, 1, newHeaders); + } + + await AddColumnToSchemaAsync(op, op.ClassName); + } + + private async Task DropColumnAsync(DropColumnOperation op) + { + var rows = await provider.GetAllRowsAsync(op.Table); + if (rows.Count == 0) return; + + var headers = rows[0].Select(h => h?.ToString() ?? "").ToList(); + var colIndex = headers.IndexOf(op.Name); + if (colIndex < 0) return; + + var newRows = rows.Select(row => + (IList)row.Where((_, i) => i != colIndex).ToList()).ToList(); + + await provider.ClearSheetAsync(op.Table); + foreach (var row in newRows) + await provider.AppendRowAsync(op.Table, row); + + await RemoveFromSchemaTableAsync(op.Table, op.Name); + } + + private async Task AlterColumnAsync(AlterColumnOperation op) + { + var rows = await provider.GetAllRowsAsync(SchemaTable); + for (int i = 1; i < rows.Count; i++) + { + if (rows[i].Count > 2 && + rows[i][1]?.ToString() == op.Table && + rows[i][2]?.ToString() == op.Name) + { + var updatedRow = rows[i].ToList(); + while (updatedRow.Count < SchemaTableHeaders.Length) + updatedRow.Add(""); + + if (op.ClrType != null) updatedRow[4] = op.ClrType.Name; + if (op.IsNullable.HasValue) + { + updatedRow[5] = op.IsNullable.Value.ToString(); + updatedRow[6] = (!op.IsNullable.Value).ToString(); + } + if (op.MaxLength.HasValue) updatedRow[15] = op.MaxLength.Value.ToString(); + if (op.DefaultValue != null) updatedRow[21] = op.DefaultValue.ToString() ?? ""; + + await provider.UpdateRowAsync(SchemaTable, i + 1, updatedRow); + break; + } + } + } + + private async Task CreateIndexAsync(CreateIndexOperation op) + { + var rows = await provider.GetAllRowsAsync(SchemaTable); + for (int i = 1; i < rows.Count; i++) + { + if (rows[i].Count > 2 && + rows[i][1]?.ToString() == op.Table && + op.Columns.Contains(rows[i][2]?.ToString() ?? "")) + { + var updatedRow = rows[i].ToList(); + while (updatedRow.Count < SchemaTableHeaders.Length) + updatedRow.Add(""); + + updatedRow[14] = op.Name; + updatedRow[13] = op.IsUnique.ToString(); + await provider.UpdateRowAsync(SchemaTable, i + 1, updatedRow); + } + } + } + + private async Task DropIndexAsync(DropIndexOperation op) + { + var rows = await provider.GetAllRowsAsync(SchemaTable); + for (int i = 1; i < rows.Count; i++) + { + if (rows[i].Count > 14 && + rows[i][1]?.ToString() == op.Table && + rows[i][14]?.ToString() == op.Name) + { + var updatedRow = rows[i].ToList(); + updatedRow[14] = ""; + await provider.UpdateRowAsync(SchemaTable, i + 1, updatedRow); + } + } + } + + private async Task AddColumnToSchemaAsync(AddColumnOperation col, string? className = null) + { + await provider.AppendRowAsync(SchemaTable, + [ + className ?? "", + col.Table, + col.Name, + col.Name, + col.ClrType.Name, + col.IsNullable.ToString(), + col.IsRequired.ToString(), + col.IsPrimaryKey.ToString(), + (!string.IsNullOrEmpty(col.ForeignKeyTable)).ToString(), + col.ForeignKeyTable ?? "", + !string.IsNullOrEmpty(col.ForeignKeyTable) ? col.ForeignKeyColumn : "", + col.OnDelete.ToString(), + col.OnUpdate.ToString(), + col.IsUnique.ToString(), + col.IndexName ?? "", + col.MaxLength?.ToString() ?? "", + col.MinLength?.ToString() ?? "", + col.Precision?.ToString() ?? "", + col.Scale?.ToString() ?? "", + col.MinValue?.ToString() ?? "", + col.MaxValue?.ToString() ?? "", + col.DefaultValue?.ToString() ?? "", + col.DefaultValueSql ?? "", + col.CheckConstraint ?? "", + col.IsComputed.ToString(), + col.ComputedColumnSql ?? "", + col.IsConcurrencyToken.ToString(), + col.IsAutoIncrement.ToString(), + col.IsPrimaryKey ? "0" : "", + col.Comment ?? "" + ]); + } + + private async Task EnsureSystemTablesExistAsync() + { + if (!await provider.SheetExistsAsync(HistoryTable)) + { + await provider.CreateSheetAsync(HistoryTable, ["MigrationId", "AppliedAt", "ProductVersion"]); + await provider.HideSheetAsync(HistoryTable); + } + + if (!await provider.SheetExistsAsync(SchemaTable)) + { + await provider.CreateSheetAsync(SchemaTable, SchemaTableHeaders); + await provider.HideSheetAsync(SchemaTable); + } + } + + private async Task RecordMigrationAsync(string migrationId) + { + await provider.AppendRowAsync(HistoryTable, + [migrationId, DateTime.UtcNow.ToString("O"), "1.0.0"]); + } + + private async Task RemoveFromSchemaTableAsync(string tableName, string columnName) + { + var rows = await provider.GetAllRowsAsync(SchemaTable); + var newRows = new List> { rows[0] }; + + for (int i = 1; i < rows.Count; i++) + { + if (rows[i].Count > 2 && + rows[i][1]?.ToString() == tableName && + rows[i][2]?.ToString() == columnName) + continue; + newRows.Add(rows[i]); + } + + await provider.ClearSheetAsync(SchemaTable); + foreach (var row in newRows) + await provider.AppendRowAsync(SchemaTable, row); + } +} diff --git a/src/Sheetly.Excel/ExcelSheetProvider.cs b/src/Sheetly.Excel/ExcelSheetProvider.cs new file mode 100644 index 0000000..3aa5032 --- /dev/null +++ b/src/Sheetly.Excel/ExcelSheetProvider.cs @@ -0,0 +1,356 @@ +using ClosedXML.Excel; +using Sheetly.Core.Abstractions; + +namespace Sheetly.Excel; + +/// +/// ISheetsProvider implementation backed by a local .xlsx file via ClosedXML. +/// All operations are synchronous file I/O wrapped in Task for API compatibility. +/// +public sealed class ExcelSheetProvider : ISheetsProvider, IAsyncDisposable +{ + private readonly string _filePath; + private XLWorkbook? _workbook; + + public ExcelSheetProvider(string filePath) + { + _filePath = Path.GetFullPath(filePath); + } + + public Task InitializeAsync() + { + _workbook = File.Exists(_filePath) + ? new XLWorkbook(_filePath) + : new XLWorkbook(); + return Task.CompletedTask; + } + + public Task DropDatabaseAsync() + { + EnsureWorkbook(); + var names = _workbook!.Worksheets.Select(ws => ws.Name).ToList(); + + foreach (var name in names) + { + if (name.StartsWith("__Sheetly") || + !name.Equals("Sheet1", StringComparison.OrdinalIgnoreCase)) + { + if (_workbook.Worksheets.Count > 1) + _workbook.Worksheets.Delete(name); + } + } + + Save(); + return Task.CompletedTask; + } + + public Task>> GetAllRowsAsync(string sheetName) + { + EnsureWorkbook(); + if (!_workbook!.TryGetWorksheet(sheetName, out var ws)) + return Task.FromResult(new List>()); + + var result = new List>(); + var rangeUsed = ws.RangeUsed(); + if (rangeUsed == null) + return Task.FromResult(result); + + int lastCol = rangeUsed.LastColumn().ColumnNumber(); + foreach (var row in rangeUsed.Rows()) + { + var cells = new List(); + for (int c = 1; c <= lastCol; c++) + cells.Add(row.Cell(c).GetValue()); + result.Add(cells); + } + + return Task.FromResult(result); + } + + public Task?> GetRowByIndexAsync(string sheetName, int rowIndex) + { + EnsureWorkbook(); + if (!_workbook!.TryGetWorksheet(sheetName, out var ws)) + return Task.FromResult?>(null); + + var rangeUsed = ws.RangeUsed(); + if (rangeUsed == null || rowIndex < 1 || rowIndex > rangeUsed.LastRow().RowNumber()) + return Task.FromResult?>(null); + + int lastCol = rangeUsed.LastColumn().ColumnNumber(); + var cells = new List(); + for (int c = 1; c <= lastCol; c++) + cells.Add(ws.Cell(rowIndex, c).GetValue()); + + return Task.FromResult?>(cells); + } + + public Task FindRowIndexByKeyAsync(string sheetName, string keyValue) + { + EnsureWorkbook(); + if (!_workbook!.TryGetWorksheet(sheetName, out var ws)) + return Task.FromResult(-1); + + var rangeUsed = ws.RangeUsed(); + if (rangeUsed == null) + return Task.FromResult(-1); + + int lastRow = rangeUsed.LastRow().RowNumber(); + for (int r = 2; r <= lastRow; r++) + { + if (ws.Cell(r, 1).GetValue() == keyValue) + return Task.FromResult(r); + } + + return Task.FromResult(-1); + } + + public Task AppendRowAsync(string sheetName, IList row) + { + EnsureWorkbook(); + var ws = GetWorksheet(sheetName); + int nextRow = GetNextEmptyRow(ws); + + for (int i = 0; i < row.Count; i++) + ws.Cell(nextRow, i + 1).Value = row[i]?.ToString() ?? ""; + + Save(); + return Task.CompletedTask; + } + + public Task AppendRowsAsync(string sheetName, IList> rows) + { + if (rows.Count == 0) return Task.CompletedTask; + + EnsureWorkbook(); + var ws = GetWorksheet(sheetName); + int nextRow = GetNextEmptyRow(ws); + + foreach (var row in rows) + { + for (int i = 0; i < row.Count; i++) + ws.Cell(nextRow, i + 1).Value = row[i]?.ToString() ?? ""; + nextRow++; + } + + Save(); + return Task.CompletedTask; + } + + public Task AppendRowAndGetIdAsync(string sheetName, IList row) + { + EnsureWorkbook(); + var ws = GetWorksheet(sheetName); + + int maxId = GetMaxIdFromSheet(ws); + int nextId = maxId + 1; + + var newRow = row.ToList(); + if (newRow.Count > 0) + newRow[0] = nextId; + + int nextRowNum = GetNextEmptyRow(ws); + for (int i = 0; i < newRow.Count; i++) + ws.Cell(nextRowNum, i + 1).Value = newRow[i]?.ToString() ?? ""; + + Save(); + return Task.FromResult(nextId); + } + + public Task GetMaxIdAsync(string sheetName) + { + EnsureWorkbook(); + if (!_workbook!.TryGetWorksheet(sheetName, out var ws)) + return Task.FromResult(0); + + return Task.FromResult(GetMaxIdFromSheet(ws)); + } + + public Task UpdateRowAsync(string sheetName, int rowIndex, IList row) + { + EnsureWorkbook(); + var ws = GetWorksheet(sheetName); + + for (int i = 0; i < row.Count; i++) + ws.Cell(rowIndex, i + 1).Value = row[i]?.ToString() ?? ""; + + Save(); + return Task.CompletedTask; + } + + public Task DeleteRowAsync(string sheetName, int rowIndex) + { + EnsureWorkbook(); + var ws = GetWorksheet(sheetName); + ws.Row(rowIndex).Delete(); + Save(); + return Task.CompletedTask; + } + + public Task SheetExistsAsync(string sheetName) + { + EnsureWorkbook(); + return Task.FromResult(_workbook!.TryGetWorksheet(sheetName, out _)); + } + + public Task CreateSheetAsync(string sheetName, IList headers) + { + EnsureWorkbook(); + if (_workbook!.TryGetWorksheet(sheetName, out _)) + return Task.CompletedTask; + + var ws = _workbook.Worksheets.Add(sheetName); + for (int i = 0; i < headers.Count; i++) + { + var cell = ws.Cell(1, i + 1); + cell.Value = headers[i]; + cell.Style.Font.Bold = true; + cell.Style.Fill.BackgroundColor = XLColor.FromArgb(26, 26, 26); + cell.Style.Font.FontColor = XLColor.White; + cell.Style.Font.FontSize = 12; + cell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; + cell.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; + } + + ws.SheetView.FreezeRows(1); + Save(); + return Task.CompletedTask; + } + + public Task DeleteSheetAsync(string sheetName) + { + EnsureWorkbook(); + if (_workbook!.TryGetWorksheet(sheetName, out _)) + { + _workbook.Worksheets.Delete(sheetName); + Save(); + } + return Task.CompletedTask; + } + + public Task ClearSheetAsync(string sheetName) + { + EnsureWorkbook(); + if (!_workbook!.TryGetWorksheet(sheetName, out var ws)) + return Task.CompletedTask; + + var rangeUsed = ws.RangeUsed(); + if (rangeUsed == null || rangeUsed.LastRow().RowNumber() < 2) + return Task.CompletedTask; + + int lastRow = rangeUsed.LastRow().RowNumber(); + int lastCol = rangeUsed.LastColumn().ColumnNumber(); + ws.Range(2, 1, lastRow, lastCol).Clear(); + + Save(); + return Task.CompletedTask; + } + + public Task HideSheetAsync(string sheetName) + { + EnsureWorkbook(); + if (_workbook!.TryGetWorksheet(sheetName, out var ws)) + { + ws.Hide(); + Save(); + } + return Task.CompletedTask; + } + + public Task UpdateValueAsync(string sheetName, string range, object value) + { + EnsureWorkbook(); + var ws = GetWorksheet(sheetName); + var (row, col) = ParseCellAddress(range); + ws.Cell(row, col).Value = value?.ToString() ?? ""; + Save(); + return Task.CompletedTask; + } + + public Task GetValueAsync(string sheetName, string range) + { + EnsureWorkbook(); + if (!_workbook!.TryGetWorksheet(sheetName, out var ws)) + return Task.FromResult(null); + + var (row, col) = ParseCellAddress(range); + return Task.FromResult(ws.Cell(row, col).GetValue()); + } + + public Task AddDataValidationAsync(string sheetName, int columnIndex, string message) + { + // Excel data validation is metadata-only; no runtime enforcement like Google Sheets + return Task.CompletedTask; + } + + public Task SetCheckboxAsync(string sheetName, int startRow, int endRow, int columnId) + { + // ClosedXML doesn't support checkbox data validation natively + return Task.CompletedTask; + } + + public void Dispose() + { + _workbook?.Dispose(); + _workbook = null; + } + + public ValueTask DisposeAsync() + { + Dispose(); + return ValueTask.CompletedTask; + } + + private void EnsureWorkbook() + { + if (_workbook == null) + throw new InvalidOperationException( + "Workbook not initialized. Call InitializeAsync() first."); + } + + private IXLWorksheet GetWorksheet(string sheetName) + { + if (!_workbook!.TryGetWorksheet(sheetName, out var ws)) + throw new InvalidOperationException($"Worksheet '{sheetName}' not found."); + return ws; + } + + private void Save() + { + _workbook!.SaveAs(_filePath); + } + + private static int GetNextEmptyRow(IXLWorksheet ws) + { + var lastUsed = ws.LastRowUsed(); + return lastUsed == null ? 2 : lastUsed.RowNumber() + 1; + } + + private static int GetMaxIdFromSheet(IXLWorksheet ws) + { + int max = 0; + var rangeUsed = ws.RangeUsed(); + if (rangeUsed == null) return max; + + int lastRow = rangeUsed.LastRow().RowNumber(); + for (int r = 2; r <= lastRow; r++) + { + var val = ws.Cell(r, 1).GetValue(); + if (int.TryParse(val, out var id) && id > max) + max = id; + } + return max; + } + + private static (int row, int col) ParseCellAddress(string cellAddress) + { + int i = 0; + while (i < cellAddress.Length && char.IsLetter(cellAddress[i])) i++; + var letters = cellAddress[..i].ToUpperInvariant(); + int col = 0; + foreach (char c in letters) + col = col * 26 + (c - 'A' + 1); + int row = int.Parse(cellAddress[i..]); + return (row, col); + } +} diff --git a/src/Sheetly.Excel/ExcelSheetsOptionsExtensions.cs b/src/Sheetly.Excel/ExcelSheetsOptionsExtensions.cs new file mode 100644 index 0000000..89a0e4c --- /dev/null +++ b/src/Sheetly.Excel/ExcelSheetsOptionsExtensions.cs @@ -0,0 +1,18 @@ +using Sheetly.Core.Configuration; + +namespace Sheetly.Excel; + +public static class ExcelSheetsOptionsExtensions +{ + /// + /// Configures Sheetly to use a local Excel (.xlsx) file as the backing store. + /// + public static SheetsOptions UseExcel(this SheetsOptions options, string filePath) + { + options.ConnectionString = $"Provider=Excel;FilePath={filePath}"; + var provider = new ExcelSheetProvider(filePath); + options.Provider = provider; + options.MigrationService = new ExcelMigrationService(provider); + return options; + } +} diff --git a/src/Sheetly.Excel/Sheetly.Excel.csproj b/src/Sheetly.Excel/Sheetly.Excel.csproj new file mode 100644 index 0000000..b74f59e --- /dev/null +++ b/src/Sheetly.Excel/Sheetly.Excel.csproj @@ -0,0 +1,38 @@ + + + + net10.0 + enable + enable + + Sheetly.Excel + 1.1.0 + Dotnetolog + Muqimjon Mamadaliyev + Copyright (c) 2025–2026 Muqimjon Mamadaliyev + Excel (.xlsx) provider for Sheetly ORM. Enables Entity Framework Core-like access to local Excel files with migrations and constraints. + excel;xlsx;spreadsheet;sheetly;orm;provider + MIT + https://github.com/muqimjon/sheetly + git + https://github.com/muqimjon/sheetly + README.md + icon.png + + true + + + + + + + + + + + + + + + + From f9e0981623de96a1d19cd956029832538a30b253 Mon Sep 17 00:00:00 2001 From: muqimjon Date: Sat, 28 Feb 2026 02:38:38 +0500 Subject: [PATCH 18/36] ci: add release workflow for Sheetly.Excel package --- .github/workflows/release-excel.yml | 49 +++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .github/workflows/release-excel.yml diff --git a/.github/workflows/release-excel.yml b/.github/workflows/release-excel.yml new file mode 100644 index 0000000..dc05ab7 --- /dev/null +++ b/.github/workflows/release-excel.yml @@ -0,0 +1,49 @@ +name: Release Sheetly.Excel + +on: + push: + tags: + - 'excel-v*' + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.x' + + - name: Extract version from tag + id: version + run: | + TAG="${{ github.ref_name }}" + VERSION="${TAG#excel-v}" + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Releasing Sheetly.Excel v$VERSION" + + - name: Build + run: dotnet build Sheetly.sln -c Release --no-incremental + + - name: Test + run: dotnet test tests/Sheetly.Core.Tests/ -c Release --no-build --verbosity normal + + - name: Pack Sheetly.Excel + run: dotnet pack src/Sheetly.Excel/Sheetly.Excel.csproj -c Release --no-build -o ./nupkg + + - name: Push to NuGet + run: | + dotnet nuget push nupkg/Sheetly.Excel.${{ steps.version.outputs.version }}.nupkg \ + -k ${{ secrets.NUGET_API_KEY }} -s https://api.nuget.org/v3/index.json --skip-duplicate + + - name: Create GitHub Release + uses: ncipollo/release-action@v1 + with: + tag: ${{ github.ref_name }} + name: "Sheetly.Excel v${{ steps.version.outputs.version }}" + artifacts: nupkg/Sheetly.Excel.${{ steps.version.outputs.version }}.nupkg + token: ${{ secrets.GITHUB_TOKEN }} + skipIfReleaseExists: true From 9dbf289e5c8585fee7e493875660691b49aa8b0c Mon Sep 17 00:00:00 2001 From: muqimjon Date: Sat, 28 Feb 2026 02:39:58 +0500 Subject: [PATCH 19/36] refactor: remove dead MigrationBuilder.BuildFromContext duplicate SnapshotBuilder.BuildFromContext is the canonical implementation used by both SheetsContext and CLI. The static MigrationBuilder in the Migration namespace was an earlier version with zero callers. --- .../Migration/MigrationBuilder.cs | 155 ------------------ 1 file changed, 155 deletions(-) delete mode 100644 src/Sheetly.Core/Migration/MigrationBuilder.cs diff --git a/src/Sheetly.Core/Migration/MigrationBuilder.cs b/src/Sheetly.Core/Migration/MigrationBuilder.cs deleted file mode 100644 index 5e34d13..0000000 --- a/src/Sheetly.Core/Migration/MigrationBuilder.cs +++ /dev/null @@ -1,155 +0,0 @@ -using Sheetly.Core.Mapping; -using System.Collections; -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; - -namespace Sheetly.Core.Migration; - -public static class MigrationBuilder -{ - public static MigrationSnapshot BuildFromContext(Type contextType, ModelBuilder modelBuilder) - { - var snapshot = new MigrationSnapshot(); - var fluentMetadata = modelBuilder.GetMetadata(); - - var sets = contextType.GetProperties(BindingFlags.Public | BindingFlags.Instance) - .Where(p => p.PropertyType.IsGenericType && p.PropertyType.GetGenericTypeDefinition() == typeof(SheetsSet<>)); - - foreach (var set in sets) - { - var entityType = set.PropertyType.GetGenericArguments()[0]; - fluentMetadata.TryGetValue(entityType, out var metadata); - - var tableName = metadata?.SheetName - ?? entityType.GetCustomAttribute()?.Name - ?? EntityMapper.GetTableName(entityType); - - var schema = new EntitySchema - { - TableName = tableName, - ClassName = entityType.Name, - Namespace = entityType.Namespace ?? string.Empty - }; - - var properties = entityType.GetProperties(BindingFlags.Public | BindingFlags.Instance); - - foreach (var prop in properties) - { - if (IsNavigationProperty(prop)) continue; - - PropertyBuilder? fluentProp = null; - metadata?.Properties.TryGetValue(prop.Name, out fluentProp); - - var column = new ColumnSchema - { - Name = fluentProp?.ColumnName - ?? prop.GetCustomAttribute()?.Name - ?? EntityMapper.GetColumnName(prop), - - PropertyName = prop.Name, - DataType = GetSimpleTypeName(prop.PropertyType), - - IsPrimaryKey = (metadata?.PrimaryKey == prop.Name) - || prop.GetCustomAttribute() != null - || EntityMapper.IsPrimaryKey(prop), - - IsNullable = fluentProp != null - ? !fluentProp.IsRequiredValue - : (prop.GetCustomAttribute() == null && IsPropertyNullable(prop)), - - MaxLength = prop.GetCustomAttribute()?.Length - }; - - if (prop.Name.EndsWith("Id", StringComparison.OrdinalIgnoreCase) && !column.IsPrimaryKey) - { - var relatedName = prop.Name.Substring(0, prop.Name.Length - 2); - - var navProp = properties.FirstOrDefault(p => - p.Name.Equals(relatedName, StringComparison.OrdinalIgnoreCase)); - - if (navProp != null && IsNavigationProperty(navProp)) - { - column.IsForeignKey = true; - var relatedType = navProp.PropertyType; - - if (typeof(IEnumerable).IsAssignableFrom(relatedType) && relatedType.IsGenericType) - { - relatedType = relatedType.GetGenericArguments()[0]; - } - - fluentMetadata.TryGetValue(relatedType, out var relatedMetadata); - column.ForeignKeyTable = relatedMetadata?.SheetName - ?? relatedType.GetCustomAttribute()?.Name - ?? EntityMapper.GetTableName(relatedType); - - schema.Relationships.Add(new RelationshipSchema - { - FromProperty = prop.Name, - ToTable = column.ForeignKeyTable, - Type = DetectRelationshipType(entityType, relatedType) - }); - } - } - schema.Columns.Add(column); - } - snapshot.Entities[tableName] = schema; - } - - snapshot.ModelHash = CalculateHash(snapshot.Entities); - return snapshot; - } - - private static string GetSimpleTypeName(Type type) - { - var underlyingType = Nullable.GetUnderlyingType(type) ?? type; - return underlyingType.Name; - } - - private static bool IsNavigationProperty(PropertyInfo prop) - { - var type = prop.PropertyType; - if (type == typeof(string)) return false; - - - var underlyingType = Nullable.GetUnderlyingType(type) ?? type; - if (underlyingType.IsPrimitive || - underlyingType.IsEnum || - underlyingType == typeof(decimal) || - underlyingType == typeof(DateTime) || - underlyingType == typeof(DateTimeOffset) || - underlyingType == typeof(TimeSpan) || - underlyingType == typeof(Guid)) - { - return false; - } - - if (typeof(IEnumerable).IsAssignableFrom(type)) return true; - - return type.IsClass && !type.FullName!.StartsWith("System."); - } - - private static bool IsPropertyNullable(PropertyInfo prop) => - Nullable.GetUnderlyingType(prop.PropertyType) != null || !prop.PropertyType.IsValueType; - - private static RelationshipType DetectRelationshipType(Type parent, Type related) - { - var hasCollection = related.GetProperties().Any(p => - typeof(IEnumerable).IsAssignableFrom(p.PropertyType) && - p.PropertyType.IsGenericType && - p.PropertyType.GetGenericArguments()[0] == parent); - - return hasCollection ? RelationshipType.ManyToOne : RelationshipType.OneToOne; - } - - private static string CalculateHash(Dictionary entities) - { - JsonSerializerOptions options = new() { WriteIndented = false }; - var json = JsonSerializer.Serialize(entities, options); - var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(json)); - return Convert.ToBase64String(bytes); - } -} \ No newline at end of file From 422f080a8c723f6993fc18251defc9c4e215894f Mon Sep 17 00:00:00 2001 From: muqimjon Date: Sat, 28 Feb 2026 02:40:46 +0500 Subject: [PATCH 20/36] fix: prevent OverflowException in round-robin credential rotation Math.Abs(int.MinValue) throws when _serviceIndex wraps past int.MaxValue. Use bitmask (& 0x7FFFFFFF) instead to safely clear the sign bit. --- src/Sheetly.Google/GoogleSheetProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Sheetly.Google/GoogleSheetProvider.cs b/src/Sheetly.Google/GoogleSheetProvider.cs index 6647dcb..7fe4ceb 100644 --- a/src/Sheetly.Google/GoogleSheetProvider.cs +++ b/src/Sheetly.Google/GoogleSheetProvider.cs @@ -24,7 +24,7 @@ public class GoogleSheetProvider : ISheetsProvider /// write limit is N × 60 req/min instead of 60 req/min for a single account. /// private SheetsService NextService => - _services[Math.Abs(Interlocked.Increment(ref _serviceIndex) % _services.Length)]; + _services[(Interlocked.Increment(ref _serviceIndex) & 0x7FFFFFFF) % _services.Length]; /// /// Executes a Google API request with automatic exponential-backoff retry From deefb43db75211c2b5726ffa8fe075d616b798f4 Mon Sep 17 00:00:00 2001 From: muqimjon Date: Sat, 28 Feb 2026 02:41:05 +0500 Subject: [PATCH 21/36] refactor: remove redundant comments from release workflows --- .github/workflows/release-cli.yml | 1 - .github/workflows/release-core.yml | 1 - .github/workflows/release-di.yml | 1 - .github/workflows/release-google.yml | 1 - 4 files changed, 4 deletions(-) diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index 7872c28..c5d57d1 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -1,6 +1,5 @@ name: Release Sheetly.CLI (dotnet-sheetly) -# Triggered by tags like: cli-v1.1.0 on: push: tags: diff --git a/.github/workflows/release-core.yml b/.github/workflows/release-core.yml index 68cf431..5d962d4 100644 --- a/.github/workflows/release-core.yml +++ b/.github/workflows/release-core.yml @@ -1,6 +1,5 @@ name: Release Sheetly.Core -# Triggered by tags like: core-v1.1.0 on: push: tags: diff --git a/.github/workflows/release-di.yml b/.github/workflows/release-di.yml index 11926fd..6e2455e 100644 --- a/.github/workflows/release-di.yml +++ b/.github/workflows/release-di.yml @@ -1,6 +1,5 @@ name: Release Sheetly.DependencyInjection -# Triggered by tags like: di-v1.1.0 on: push: tags: diff --git a/.github/workflows/release-google.yml b/.github/workflows/release-google.yml index 671f5ce..6162abe 100644 --- a/.github/workflows/release-google.yml +++ b/.github/workflows/release-google.yml @@ -1,6 +1,5 @@ name: Release Sheetly.Google -# Triggered by tags like: google-v1.1.0 on: push: tags: From 19b4882e21755750848f0af7cc282dcbc8bd1885 Mon Sep 17 00:00:00 2001 From: muqimjon Date: Sat, 28 Feb 2026 02:46:43 +0500 Subject: [PATCH 22/36] docs: add Excel provider to README --- README.md | 44 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 452bd59..ceca360 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![Sheetly.Core](https://img.shields.io/nuget/v/Sheetly.Core.svg?label=Sheetly.Core&color=2f7f73)](https://www.nuget.org/packages/Sheetly.Core/) [![Sheetly.Google](https://img.shields.io/nuget/v/Sheetly.Google.svg?label=Sheetly.Google&color=2f7f73)](https://www.nuget.org/packages/Sheetly.Google/) +[![Sheetly.Excel](https://img.shields.io/nuget/v/Sheetly.Excel.svg?label=Sheetly.Excel&color=2f7f73)](https://www.nuget.org/packages/Sheetly.Excel/) [![dotnet-sheetly](https://img.shields.io/nuget/v/dotnet-sheetly.svg?label=dotnet-sheetly&color=2f7f73)](https://www.nuget.org/packages/dotnet-sheetly/) [![License-MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) @@ -9,7 +10,7 @@ ## 🌟 Why Sheetly? -Sheetly brings the **Entity Framework Core developer experience** to Google Sheets. If you know EF Core, you already know Sheetly. +Sheetly brings the **Entity Framework Core developer experience** to Google Sheets and Excel. If you know EF Core, you already know Sheetly. ```csharp public class Product @@ -33,6 +34,7 @@ public class AppContext : SheetsContext protected override void OnConfiguring(SheetsOptions options) { options.UseGoogleSheets("credentials.json", "your-spreadsheet-id"); + // or: options.UseExcel("data.xlsx"); } protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -122,12 +124,16 @@ dotnet sheetly scaffold |---|---| | [`Sheetly.Core`](https://www.nuget.org/packages/Sheetly.Core/) | Core abstractions, migrations, validation | | [`Sheetly.Google`](https://www.nuget.org/packages/Sheetly.Google/) | Google Sheets API provider | +| [`Sheetly.Excel`](https://www.nuget.org/packages/Sheetly.Excel/) | Local Excel (.xlsx) file provider | | [`dotnet-sheetly`](https://www.nuget.org/packages/dotnet-sheetly/) | CLI tool for migrations | | [`Sheetly.DependencyInjection`](https://www.nuget.org/packages/Sheetly.DependencyInjection/) | ASP.NET Core DI integration | ```bash dotnet add package Sheetly.Core -dotnet add package Sheetly.Google + +# Pick your provider: +dotnet add package Sheetly.Google # Google Sheets (online) +dotnet add package Sheetly.Excel # Excel .xlsx (local) # For ASP.NET Core apps dotnet add package Sheetly.DependencyInjection @@ -140,7 +146,9 @@ dotnet tool install -g dotnet-sheetly ## 🚀 Quick Start -### 1. **Setup Google Sheets API** +### Option A: **Google Sheets** (Online) + +#### 1. Setup Google Sheets API 1. Go to [Google Cloud Console](https://console.cloud.google.com/) 2. Create a new project @@ -149,6 +157,30 @@ dotnet tool install -g dotnet-sheetly 5. Download `credentials.json` 6. Share your spreadsheet with the service account email +#### 2. Configure + +```csharp +protected override void OnConfiguring(SheetsOptions options) +{ + options.UseGoogleSheets("credentials.json", "your-spreadsheet-id"); +} +``` + +### Option B: **Excel** (Local .xlsx) + +```bash +dotnet add package Sheetly.Excel +``` + +```csharp +protected override void OnConfiguring(SheetsOptions options) +{ + options.UseExcel("C:/data/myapp.xlsx"); +} +``` + +No API keys, no internet — all data stays on disk. + ### 2. **Create Your Models** ```csharp @@ -187,6 +219,7 @@ public class MyAppContext : SheetsContext protected override void OnConfiguring(SheetsOptions options) { options.UseGoogleSheets("credentials.json", "your-spreadsheet-id"); + // or: options.UseExcel("mydata.xlsx"); } protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -356,7 +389,8 @@ await context.SaveChangesAsync(cts.Token); ``` Sheetly/ ├── Sheetly.Core # Core: context, sets, migrations, validation -├── Sheetly.Google # Google Sheets API provider +├── Sheetly.Google # Google Sheets API provider (online) +├── Sheetly.Excel # Excel .xlsx provider (local) ├── Sheetly.DependencyInjection # ASP.NET Core DI extensions └── dotnet-sheetly (CLI) # Command-line migration tool ``` @@ -365,7 +399,7 @@ Sheetly/ ## 📊 How It Works -Sheetly creates **hidden sheets** in your Google Spreadsheet: +Sheetly creates **hidden sheets** in your spreadsheet (Google Sheets or local .xlsx): | Sheet | Purpose | |---|---| From b26ba1e840f5cd4b032040fd85cfbeda2c0bffc5 Mon Sep 17 00:00:00 2001 From: muqimjon Date: Sat, 28 Feb 2026 03:14:13 +0500 Subject: [PATCH 23/36] feat: add Sheetly.Test console sample project Product/Category models, ExcelAppContext and GoogleAppContext. Interactive menu to test Excel and Google Sheets providers. Also add Sheetly.DependencyInjection badge to README. --- README.md | 1 + Sheetly.sln | 15 ++ .../Sheetly.Test/Contexts/ExcelAppContext.cs | 34 +++++ .../Sheetly.Test/Contexts/GoogleAppContext.cs | 39 ++++++ samples/Sheetly.Test/Models/Category.cs | 14 ++ samples/Sheetly.Test/Models/Product.cs | 19 +++ samples/Sheetly.Test/Program.cs | 129 ++++++++++++++++++ samples/Sheetly.Test/Sheetly.Test.csproj | 16 +++ 8 files changed, 267 insertions(+) create mode 100644 samples/Sheetly.Test/Contexts/ExcelAppContext.cs create mode 100644 samples/Sheetly.Test/Contexts/GoogleAppContext.cs create mode 100644 samples/Sheetly.Test/Models/Category.cs create mode 100644 samples/Sheetly.Test/Models/Product.cs create mode 100644 samples/Sheetly.Test/Program.cs create mode 100644 samples/Sheetly.Test/Sheetly.Test.csproj diff --git a/README.md b/README.md index ceca360..9d8ae0a 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [![Sheetly.Core](https://img.shields.io/nuget/v/Sheetly.Core.svg?label=Sheetly.Core&color=2f7f73)](https://www.nuget.org/packages/Sheetly.Core/) [![Sheetly.Google](https://img.shields.io/nuget/v/Sheetly.Google.svg?label=Sheetly.Google&color=2f7f73)](https://www.nuget.org/packages/Sheetly.Google/) [![Sheetly.Excel](https://img.shields.io/nuget/v/Sheetly.Excel.svg?label=Sheetly.Excel&color=2f7f73)](https://www.nuget.org/packages/Sheetly.Excel/) +[![Sheetly.DependencyInjection](https://img.shields.io/nuget/v/Sheetly.DependencyInjection.svg?label=Sheetly.DependencyInjection&color=2f7f73)](https://www.nuget.org/packages/Sheetly.DependencyInjection/) [![dotnet-sheetly](https://img.shields.io/nuget/v/dotnet-sheetly.svg?label=dotnet-sheetly&color=2f7f73)](https://www.nuget.org/packages/dotnet-sheetly/) [![License-MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) diff --git a/Sheetly.sln b/Sheetly.sln index b3af6e9..402d5e7 100644 --- a/Sheetly.sln +++ b/Sheetly.sln @@ -23,6 +23,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sheetly.Core.Tests", "tests EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sheetly.Excel", "src\Sheetly.Excel\Sheetly.Excel.csproj", "{7B2AF4C6-ADBF-44BF-BE19-8C39CA06D63B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sheetly.Test", "samples\Sheetly.Test\Sheetly.Test.csproj", "{49208693-C863-4812-8211-1CDA042B9804}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -117,6 +119,18 @@ Global {7B2AF4C6-ADBF-44BF-BE19-8C39CA06D63B}.Release|x64.Build.0 = Release|Any CPU {7B2AF4C6-ADBF-44BF-BE19-8C39CA06D63B}.Release|x86.ActiveCfg = Release|Any CPU {7B2AF4C6-ADBF-44BF-BE19-8C39CA06D63B}.Release|x86.Build.0 = Release|Any CPU + {49208693-C863-4812-8211-1CDA042B9804}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {49208693-C863-4812-8211-1CDA042B9804}.Debug|Any CPU.Build.0 = Debug|Any CPU + {49208693-C863-4812-8211-1CDA042B9804}.Debug|x64.ActiveCfg = Debug|Any CPU + {49208693-C863-4812-8211-1CDA042B9804}.Debug|x64.Build.0 = Debug|Any CPU + {49208693-C863-4812-8211-1CDA042B9804}.Debug|x86.ActiveCfg = Debug|Any CPU + {49208693-C863-4812-8211-1CDA042B9804}.Debug|x86.Build.0 = Debug|Any CPU + {49208693-C863-4812-8211-1CDA042B9804}.Release|Any CPU.ActiveCfg = Release|Any CPU + {49208693-C863-4812-8211-1CDA042B9804}.Release|Any CPU.Build.0 = Release|Any CPU + {49208693-C863-4812-8211-1CDA042B9804}.Release|x64.ActiveCfg = Release|Any CPU + {49208693-C863-4812-8211-1CDA042B9804}.Release|x64.Build.0 = Release|Any CPU + {49208693-C863-4812-8211-1CDA042B9804}.Release|x86.ActiveCfg = Release|Any CPU + {49208693-C863-4812-8211-1CDA042B9804}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -129,6 +143,7 @@ Global {87D2D7B9-819E-4F2A-B511-75A8CAC4DBDB} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {F7D83F5A-F558-4DD7-B025-C14A57B73164} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {7B2AF4C6-ADBF-44BF-BE19-8C39CA06D63B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {49208693-C863-4812-8211-1CDA042B9804} = {EDE96271-BDBB-4A48-B4A3-C890C939E193} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {2901C6BF-30A0-42C6-97E2-7193CF638399} diff --git a/samples/Sheetly.Test/Contexts/ExcelAppContext.cs b/samples/Sheetly.Test/Contexts/ExcelAppContext.cs new file mode 100644 index 0000000..d8aeb1f --- /dev/null +++ b/samples/Sheetly.Test/Contexts/ExcelAppContext.cs @@ -0,0 +1,34 @@ +using Sheetly.Core; +using Sheetly.Core.Configuration; +using Sheetly.Excel; +using Sheetly.Test.Models; + +namespace Sheetly.Test.Contexts; + +// Excel provider bilan ishlaydigan context — credentials shart emas, local .xlsx fayl +public class ExcelAppContext : SheetsContext +{ + public SheetsSet Categories { get; set; } = null!; + public SheetsSet Products { get; set; } = null!; + + protected override void OnConfiguring(SheetsOptions options) + { + options.UseExcel("test-data.xlsx"); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(e => + { + e.HasSheetName("Categories"); + e.Property(c => c.Name).HasMaxLength(100).IsRequired(); + }); + + modelBuilder.Entity(e => + { + e.HasSheetName("Products"); + e.Property(p => p.Name).HasMaxLength(200).IsRequired(); + e.Property(p => p.Price).IsRequired(); + }); + } +} diff --git a/samples/Sheetly.Test/Contexts/GoogleAppContext.cs b/samples/Sheetly.Test/Contexts/GoogleAppContext.cs new file mode 100644 index 0000000..ac0f15d --- /dev/null +++ b/samples/Sheetly.Test/Contexts/GoogleAppContext.cs @@ -0,0 +1,39 @@ +using Sheetly.Core; +using Sheetly.Core.Configuration; +using Sheetly.Google; +using Sheetly.Test.Models; + +namespace Sheetly.Test.Contexts; + +// Google Sheets provider bilan ishlaydigan context +// credentials.json va spreadsheet ID kerak +public class GoogleAppContext : SheetsContext +{ + public SheetsSet Categories { get; set; } = null!; + public SheetsSet Products { get; set; } = null!; + + protected override void OnConfiguring(SheetsOptions options) + { + // credentials.json faylini va spreadsheet ID ni o'zgartiring + options.UseGoogleSheets( + credentialsPath: "credentials.json", + spreadsheetId: "YOUR_SPREADSHEET_ID_HERE" + ); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(e => + { + e.HasSheetName("Categories"); + e.Property(c => c.Name).HasMaxLength(100).IsRequired(); + }); + + modelBuilder.Entity(e => + { + e.HasSheetName("Products"); + e.Property(p => p.Name).HasMaxLength(200).IsRequired(); + e.Property(p => p.Price).IsRequired(); + }); + } +} diff --git a/samples/Sheetly.Test/Models/Category.cs b/samples/Sheetly.Test/Models/Category.cs new file mode 100644 index 0000000..abaf7db --- /dev/null +++ b/samples/Sheetly.Test/Models/Category.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace Sheetly.Test.Models; + +public class Category +{ + public int Id { get; set; } + + [Required] + [MaxLength(100)] + public string Name { get; set; } = string.Empty; + + public List Products { get; set; } = []; +} diff --git a/samples/Sheetly.Test/Models/Product.cs b/samples/Sheetly.Test/Models/Product.cs new file mode 100644 index 0000000..ed7277c --- /dev/null +++ b/samples/Sheetly.Test/Models/Product.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; + +namespace Sheetly.Test.Models; + +public class Product +{ + public int Id { get; set; } + + [Required] + [MaxLength(200)] + public string Name { get; set; } = string.Empty; + + public decimal Price { get; set; } + + public string? Description { get; set; } + + public int CategoryId { get; set; } + public Category Category { get; set; } = null!; +} diff --git a/samples/Sheetly.Test/Program.cs b/samples/Sheetly.Test/Program.cs new file mode 100644 index 0000000..c6b54d1 --- /dev/null +++ b/samples/Sheetly.Test/Program.cs @@ -0,0 +1,129 @@ +using Sheetly.Test.Contexts; +using Sheetly.Test.Models; + +Console.WriteLine("=== Sheetly Test ===\n"); +Console.WriteLine("Qaysi provider bilan test qilmoqchisiz?"); +Console.WriteLine(" 1 - Excel (local .xlsx fayl)"); +Console.WriteLine(" 2 - Google Sheets"); +Console.Write("\nTanlov: "); +var choice = Console.ReadLine()?.Trim(); + +if (choice == "2") + await RunGoogleTest(); +else + await RunExcelTest(); + +// ───────────────────────────────────────────────────────────── +// EXCEL TEST +// ───────────────────────────────────────────────────────────── +static async Task RunExcelTest() +{ + Console.WriteLine("\n[Excel] test-data.xlsx fayli yaratilmoqda...\n"); + + await using var context = new ExcelAppContext(); + await context.InitializeAsync(); + await context.Database.MigrateAsync(); + + // ── CREATE ── + Console.WriteLine("--- CREATE ---"); + + var electronics = new Category { Name = "Electronics" }; + var food = new Category { Name = "Food" }; + context.Categories.Add(electronics); + context.Categories.Add(food); + await context.SaveChangesAsync(); + Console.WriteLine($"Category qo'shildi: {electronics.Name} (Id={electronics.Id})"); + Console.WriteLine($"Category qo'shildi: {food.Name} (Id={food.Id})"); + + var laptop = new Product { Name = "Laptop", Price = 1200, CategoryId = electronics.Id }; + var phone = new Product { Name = "Phone", Price = 800, CategoryId = electronics.Id }; + var bread = new Product { Name = "Bread", Price = 2, CategoryId = food.Id }; + context.Products.Add(laptop); + context.Products.Add(phone); + context.Products.Add(bread); + await context.SaveChangesAsync(); + Console.WriteLine($"Product qo'shildi: {laptop.Name} (Id={laptop.Id})"); + Console.WriteLine($"Product qo'shildi: {phone.Name} (Id={phone.Id})"); + Console.WriteLine($"Product qo'shildi: {bread.Name} (Id={bread.Id})"); + + // ── READ ── + Console.WriteLine("\n--- READ ---"); + var products = await context.Products.Include(p => p.Category).ToListAsync(); + foreach (var p in products) + Console.WriteLine($" {p.Id}. {p.Name} — ${p.Price} [{p.Category?.Name ?? "?"}]"); + + // ── UPDATE (auto change tracking) ── + Console.WriteLine("\n--- UPDATE ---"); + laptop.Price = 999; + await context.SaveChangesAsync(); + Console.WriteLine($"Laptop narxi o'zgartirildi: $999"); + + // ── FIND ── + Console.WriteLine("\n--- FIND ---"); + var found = await context.Products.FindAsync(laptop.Id); + Console.WriteLine($"FindAsync({laptop.Id}) → {found?.Name} ${found?.Price}"); + + // ── WHERE ── + Console.WriteLine("\n--- WHERE ---"); + var expensive = await context.Products.Where(p => p.Price > 100); + foreach (var p in expensive) + Console.WriteLine($" > $100: {p.Name}"); + + // ── DELETE ── + Console.WriteLine("\n--- DELETE ---"); + context.Products.Remove(bread); + await context.SaveChangesAsync(); + Console.WriteLine($"O'chirildi: {bread.Name}"); + + var remaining = await context.Products.ToListAsync(); + Console.WriteLine($"Qolgan productlar soni: {remaining.Count}"); + + Console.WriteLine("\n✅ Excel test muvaffaqiyatli yakunlandi!"); + Console.WriteLine(" test-data.xlsx faylini Excel da ochib ko'ring."); +} + +// ───────────────────────────────────────────────────────────── +// GOOGLE SHEETS TEST +// ───────────────────────────────────────────────────────────── +static async Task RunGoogleTest() +{ + Console.WriteLine("\n[Google Sheets] credentials.json va spreadsheet ID kerak."); + Console.WriteLine("GoogleAppContext.cs faylida YOUR_SPREADSHEET_ID_HERE ni o'zgartiring.\n"); + + await using var context = new GoogleAppContext(); + await context.InitializeAsync(); + await context.Database.MigrateAsync(); + + // ── CREATE ── + Console.WriteLine("--- CREATE ---"); + + var category = new Category { Name = "Tech" }; + context.Categories.Add(category); + await context.SaveChangesAsync(); + Console.WriteLine($"Category qo'shildi: {category.Name} (Id={category.Id})"); + + var product = new Product { Name = "Keyboard", Price = 75, CategoryId = category.Id }; + context.Products.Add(product); + await context.SaveChangesAsync(); + Console.WriteLine($"Product qo'shildi: {product.Name} (Id={product.Id})"); + + // ── READ ── + Console.WriteLine("\n--- READ ---"); + var products = await context.Products.Include(p => p.Category).ToListAsync(); + foreach (var p in products) + Console.WriteLine($" {p.Id}. {p.Name} — ${p.Price} [{p.Category?.Name ?? "?"}]"); + + // ── UPDATE ── + Console.WriteLine("\n--- UPDATE ---"); + product.Price = 65; + await context.SaveChangesAsync(); + Console.WriteLine($"{product.Name} narxi o'zgartirildi: $65"); + + // ── DELETE ── + Console.WriteLine("\n--- DELETE ---"); + context.Products.Remove(product); + await context.SaveChangesAsync(); + Console.WriteLine($"O'chirildi: {product.Name}"); + + Console.WriteLine("\n✅ Google Sheets test muvaffaqiyatli yakunlandi!"); +} diff --git a/samples/Sheetly.Test/Sheetly.Test.csproj b/samples/Sheetly.Test/Sheetly.Test.csproj new file mode 100644 index 0000000..a03fc2e --- /dev/null +++ b/samples/Sheetly.Test/Sheetly.Test.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + From 4fecee4cca12cedac2854a52b733114aa8ba22b4 Mon Sep 17 00:00:00 2001 From: muqimjon Date: Sat, 28 Feb 2026 03:43:13 +0500 Subject: [PATCH 24/36] fix: resolve CLI assembly MVID mismatch with ProjectAssemblyLoadContext Use AssemblyLoadContext + AssemblyDependencyResolver to load the target project DLL in full isolation. All Sheetly.Core types (ModelBuilder, SnapshotBuilder, MigrationBuilder) are now loaded from the project's own bin directory, eliminating MVID conflicts between the CLI's embedded copy and the project's freshly compiled copy. Key changes: - Add ProjectAssemblyLoadContext (mirrors dotnet-ef isolation pattern) - Add TypeJsonConverter for System.Type cross-context serialization - CliHelper: LoadAssemblyIsolated, GetCoreAssembly, GetGoogleAssembly, BridgeMigrationOperations, BridgeFromJson, VersionMismatchMessage - AddCommand, UpdateCommand, RemoveCommand: use isolated ModelBuilder + SnapshotBuilder + MigrationBuilder via JSON bridge - ScriptCommand, RollbackCommand: isolated snapshot loading - DropCommand, ScaffoldCommand: isolated Sheetly.Google factory so contextType satisfies T:SheetsContext constraint CLI is now version-agnostic: once installed, it works with any future version of Sheetly packages without requiring reinstallation. --- .../Sheetly.Test/Contexts/GoogleAppContext.cs | 4 +- .../20260227224251_InitialMigrate.cs | 33 +++++ .../Migrations/ExcelAppModelSnapshot.cs | 139 ++++++++++++++++++ samples/Sheetly.Test/Sheetly.Test.csproj | 6 + src/Sheetly.CLI/Commands/AddCommand.cs | 29 +++- src/Sheetly.CLI/Commands/DropCommand.cs | 13 +- src/Sheetly.CLI/Commands/RemoveCommand.cs | 31 ++-- src/Sheetly.CLI/Commands/RollbackCommand.cs | 2 +- src/Sheetly.CLI/Commands/ScaffoldCommand.cs | 21 ++- src/Sheetly.CLI/Commands/ScriptCommand.cs | 8 +- src/Sheetly.CLI/Commands/UpdateCommand.cs | 46 +++--- src/Sheetly.CLI/Helpers/CliHelper.cs | 111 ++++++++++++++ .../Helpers/ProjectAssemblyLoadContext.cs | 25 ++++ src/Sheetly.CLI/Helpers/TypeJsonConverter.cs | 24 +++ 14 files changed, 440 insertions(+), 52 deletions(-) create mode 100644 samples/Sheetly.Test/Migrations/20260227224251_InitialMigrate.cs create mode 100644 samples/Sheetly.Test/Migrations/ExcelAppModelSnapshot.cs create mode 100644 src/Sheetly.CLI/Helpers/ProjectAssemblyLoadContext.cs create mode 100644 src/Sheetly.CLI/Helpers/TypeJsonConverter.cs diff --git a/samples/Sheetly.Test/Contexts/GoogleAppContext.cs b/samples/Sheetly.Test/Contexts/GoogleAppContext.cs index ac0f15d..788a4cc 100644 --- a/samples/Sheetly.Test/Contexts/GoogleAppContext.cs +++ b/samples/Sheetly.Test/Contexts/GoogleAppContext.cs @@ -17,8 +17,8 @@ protected override void OnConfiguring(SheetsOptions options) // credentials.json faylini va spreadsheet ID ni o'zgartiring options.UseGoogleSheets( credentialsPath: "credentials.json", - spreadsheetId: "YOUR_SPREADSHEET_ID_HERE" - ); + spreadsheetId: "1bNZnlJJ81VLbM5VeWoy9uCq4Ynz2bkAXaJlFJAYy_Sc" + ); } protected override void OnModelCreating(ModelBuilder modelBuilder) diff --git a/samples/Sheetly.Test/Migrations/20260227224251_InitialMigrate.cs b/samples/Sheetly.Test/Migrations/20260227224251_InitialMigrate.cs new file mode 100644 index 0000000..b8e626a --- /dev/null +++ b/samples/Sheetly.Test/Migrations/20260227224251_InitialMigrate.cs @@ -0,0 +1,33 @@ +using Sheetly.Core.Migrations; +using Sheetly.Core.Migrations.Operations; + +namespace Sheetly.Test.Contexts.Migrations; + +[Migration("20260227224251_InitialMigrate")] +public partial class InitialMigrate : Migration +{ + public override void Up(MigrationBuilder builder) + { + // ClassName: Category + builder.CreateTable("Categories", table => table + .Column("Id", c => c.IsPrimaryKey().IsUnique()) + .Column("Name", c => c.IsRequired().HasMaxLength(100)) + ); + + // ClassName: Product + builder.CreateTable("Products", table => table + .Column("Id", c => c.IsPrimaryKey().IsUnique()) + .Column("Name", c => c.IsRequired().HasMaxLength(200)) + .Column("Price", c => c.IsRequired()) + .Column("Description") + .Column("CategoryId", c => c.IsRequired().IsForeignKey("Categories")) + ); + + } + + public override void Down(MigrationBuilder builder) + { + builder.DropTable("Products"); + builder.DropTable("Categories"); + } +} diff --git a/samples/Sheetly.Test/Migrations/ExcelAppModelSnapshot.cs b/samples/Sheetly.Test/Migrations/ExcelAppModelSnapshot.cs new file mode 100644 index 0000000..f8034f5 --- /dev/null +++ b/samples/Sheetly.Test/Migrations/ExcelAppModelSnapshot.cs @@ -0,0 +1,139 @@ +using System; +using Sheetly.Core.Migration; + +namespace Sheetly.Test.Contexts.Migrations; + +public partial class ExcelAppModelSnapshot : MigrationSnapshot +{ + public ExcelAppModelSnapshot() + { + var snapshot = BuildModel(); + this.Entities = snapshot.Entities; + this.ModelHash = snapshot.ModelHash; + this.Version = snapshot.Version; + this.LastUpdated = snapshot.LastUpdated; + } + + public static MigrationSnapshot BuildModel() + { + var snapshot = new MigrationSnapshot + { + ModelHash = "erfMXU/RWc/dJ2XYy4Tck5Nw4rMDkzpVEiJA1z5xQro=", + Version = "1.0.0", + LastUpdated = DateTime.Parse("2026-02-27T22:42:51.5688269Z") + }; + + // Category + snapshot.Entities["Categories"] = new EntitySchema + { + TableName = "Categories", + ClassName = "Category", + Namespace = "Sheetly.Test.Models", + Columns = new List + { + new ColumnSchema + { + Name = "Id", + PropertyName = "Id", + DataType = "Int32", + IsPrimaryKey = true, + IsAutoIncrement = true, + IsForeignKey = false, + ForeignKeyColumn = "Id", + IsNullable = false, + IsRequired = false + }, + new ColumnSchema + { + Name = "Name", + PropertyName = "Name", + DataType = "String", + IsPrimaryKey = false, + IsAutoIncrement = false, + IsForeignKey = false, + ForeignKeyColumn = "Id", + IsNullable = false, + IsRequired = true, + MaxLength = 100 + } + }, + Relationships = new List() + }; + + // Product + snapshot.Entities["Products"] = new EntitySchema + { + TableName = "Products", + ClassName = "Product", + Namespace = "Sheetly.Test.Models", + Columns = new List + { + new ColumnSchema + { + Name = "Id", + PropertyName = "Id", + DataType = "Int32", + IsPrimaryKey = true, + IsAutoIncrement = true, + IsForeignKey = false, + ForeignKeyColumn = "Id", + IsNullable = false, + IsRequired = false + }, + new ColumnSchema + { + Name = "Name", + PropertyName = "Name", + DataType = "String", + IsPrimaryKey = false, + IsAutoIncrement = false, + IsForeignKey = false, + ForeignKeyColumn = "Id", + IsNullable = false, + IsRequired = true, + MaxLength = 200 + }, + new ColumnSchema + { + Name = "Price", + PropertyName = "Price", + DataType = "Decimal", + IsPrimaryKey = false, + IsAutoIncrement = false, + IsForeignKey = false, + ForeignKeyColumn = "Id", + IsNullable = false, + IsRequired = true + }, + new ColumnSchema + { + Name = "Description", + PropertyName = "Description", + DataType = "String", + IsPrimaryKey = false, + IsAutoIncrement = false, + IsForeignKey = false, + ForeignKeyColumn = "Id", + IsNullable = true, + IsRequired = false + }, + new ColumnSchema + { + Name = "CategoryId", + PropertyName = "CategoryId", + DataType = "Int32", + IsPrimaryKey = false, + IsAutoIncrement = false, + IsForeignKey = true, + ForeignKeyTable = "Categories", + ForeignKeyColumn = "Id", + IsNullable = false, + IsRequired = false + } + }, + Relationships = new List() + }; + + return snapshot; + } +} diff --git a/samples/Sheetly.Test/Sheetly.Test.csproj b/samples/Sheetly.Test/Sheetly.Test.csproj index a03fc2e..73fb9d1 100644 --- a/samples/Sheetly.Test/Sheetly.Test.csproj +++ b/samples/Sheetly.Test/Sheetly.Test.csproj @@ -13,4 +13,10 @@ + + + PreserveNewest + + + diff --git a/src/Sheetly.CLI/Commands/AddCommand.cs b/src/Sheetly.CLI/Commands/AddCommand.cs index 73877da..0a63fa5 100644 --- a/src/Sheetly.CLI/Commands/AddCommand.cs +++ b/src/Sheetly.CLI/Commands/AddCommand.cs @@ -1,5 +1,4 @@ using Sheetly.CLI.Helpers; -using Sheetly.Core; using Sheetly.Core.Migration; using Sheetly.Core.Migrations; using Sheetly.Core.Migrations.Design; @@ -46,7 +45,7 @@ private async Task ExecuteAsync(string? name, bool noBuild, string? projectPath, try { - var assembly = Assembly.LoadFrom(Path.GetFullPath(dllPath)); + var (assembly, loadContext) = CliHelper.LoadAssemblyIsolated(dllPath); var contextType = assembly.GetExportedTypes().FirstOrDefault(t => CliHelper.IsSubclassOfSheetsContext(t)) ?? throw new Exception("SheetsContext not found."); @@ -54,12 +53,28 @@ private async Task ExecuteAsync(string? name, bool noBuild, string? projectPath, string contextProjectDir = CliHelper.FindProjectRootFromDll(contextType.Assembly.Location); outputDir ??= "Migrations"; - var modelBuilder = new ModelBuilder(); + + // Use isolated Sheetly.Core to avoid MVID mismatch across load contexts. + // CLI ships its own copy of Sheetly.Core; the project has a freshly built copy. + // Both have the same version string but different MVIDs — cross-context casting fails. + // Fix: invoke ModelBuilder and SnapshotBuilder from the isolated context, then JSON-bridge the result. + var coreAsm = CliHelper.GetCoreAssembly(assembly, loadContext); + var mbType = coreAsm.GetType("Sheetly.Core.ModelBuilder") + ?? throw new Exception(CliHelper.VersionMismatchMessage(coreAsm, "Sheetly.Core.ModelBuilder")); + var modelBuilder = Activator.CreateInstance(mbType)!; + var onModelCreatingMethod = contextType.GetMethod("OnModelCreating", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); onModelCreatingMethod?.Invoke(context, [modelBuilder]); - // Build current snapshot — must include fluent API metadata to match SheetsContext.InitializeAsync - var currentSnapshot = SnapshotBuilder.BuildFromContext(contextType, modelBuilder.GetMetadata()); + // Build current snapshot inside the isolated context, then bridge to CLI via JSON + var sbType = coreAsm.GetType("Sheetly.Core.Migrations.SnapshotBuilder") + ?? throw new Exception(CliHelper.VersionMismatchMessage(coreAsm, "Sheetly.Core.Migrations.SnapshotBuilder")); + var buildMethod = sbType.GetMethod("BuildFromContext", BindingFlags.Public | BindingFlags.Static) + ?? throw new Exception(CliHelper.VersionMismatchMessage(coreAsm, "SnapshotBuilder.BuildFromContext")); + var metadataArg = mbType.GetMethod("GetMetadata")!.Invoke(modelBuilder, null); + var isolatedSnapshot = buildMethod.Invoke(null, [contextType, metadataArg]); + var currentSnapshot = CliHelper.BridgeFromJson(isolatedSnapshot) + ?? throw new Exception("Failed to build migration snapshot."); string finalPath = Path.Combine(contextProjectDir, outputDir); Directory.CreateDirectory(finalPath); @@ -71,8 +86,8 @@ private async Task ExecuteAsync(string? name, bool noBuild, string? projectPath, if (snapshotType != null) { - // Instantiate snapshot (constructor populates Entities) - previousSnapshot = Activator.CreateInstance(snapshotType) as MigrationSnapshot; + var isolatedPrev = Activator.CreateInstance(snapshotType); + previousSnapshot = CliHelper.BridgeFromJson(isolatedPrev); } var modelDiffer = new ModelDiffer(); diff --git a/src/Sheetly.CLI/Commands/DropCommand.cs b/src/Sheetly.CLI/Commands/DropCommand.cs index fce769e..82d4924 100644 --- a/src/Sheetly.CLI/Commands/DropCommand.cs +++ b/src/Sheetly.CLI/Commands/DropCommand.cs @@ -1,6 +1,4 @@ using Sheetly.CLI.Helpers; -using Sheetly.Core; -using Sheetly.Google; using System.CommandLine; using System.Reflection; @@ -33,21 +31,26 @@ private async Task ExecuteAsync(bool force, string? projectPath) try { - var assembly = Assembly.LoadFrom(Path.GetFullPath(dllPath)); + var (assembly, loadContext) = CliHelper.LoadAssemblyIsolated(dllPath); var contextType = assembly.GetExportedTypes().FirstOrDefault(t => CliHelper.IsSubclassOfSheetsContext(t)) ?? throw new Exception("SheetsContext not found."); string? connStr = CliHelper.GetConnectionString(CliHelper.FindProjectRootFromDll(dllPath)) ?? CliHelper.GetConnectionStringFromContext(contextType); - var method = typeof(GoogleSheetsFactory).GetMethods() + // Use isolated Sheetly.Google factory so contextType satisfies its T : SheetsContext constraint + var googleAsm = CliHelper.GetGoogleAssembly(assembly, loadContext) + ?? throw new Exception("Sheetly.Google not found in project references."); + var factoryType = googleAsm.GetType("Sheetly.Google.GoogleSheetsFactory")!; + + var method = factoryType.GetMethods() .FirstOrDefault(m => m.Name == "CreateContextAsync" && m.GetParameters().Length == 1) ?.MakeGenericMethod(contextType); var task = (Task)method!.Invoke(null, [connStr])!; await task; - var context = (SheetsContext)((dynamic)task).Result; + dynamic context = ((dynamic)task).Result; await context.Database.DropDatabaseAsync(); Console.WriteLine("✅ Database dropped successfully."); } diff --git a/src/Sheetly.CLI/Commands/RemoveCommand.cs b/src/Sheetly.CLI/Commands/RemoveCommand.cs index 7f74355..d93250b 100644 --- a/src/Sheetly.CLI/Commands/RemoveCommand.cs +++ b/src/Sheetly.CLI/Commands/RemoveCommand.cs @@ -28,7 +28,7 @@ private async Task ExecuteAsync(string? projectPath, CancellationToken ct) try { - var assembly = Assembly.LoadFrom(Path.GetFullPath(dllPath)); + var (assembly, loadContext) = CliHelper.LoadAssemblyIsolated(dllPath); var contextType = assembly.GetExportedTypes().FirstOrDefault(t => CliHelper.IsSubclassOfSheetsContext(t)) ?? throw new Exception("SheetsContext not found."); @@ -41,9 +41,11 @@ private async Task ExecuteAsync(string? projectPath, CancellationToken ct) return; } + // Cross-context: use string-based attribute access instead of GetCustomAttribute() var migrationTypes = assembly.GetExportedTypes() - .Where(t => t.GetCustomAttribute() != null) - .OrderByDescending(t => t.GetCustomAttribute()!.Id) + .Select(t => new { Type = t, MigrationId = CliHelper.GetMigrationAttributeId(t) }) + .Where(x => x.MigrationId != null) + .OrderByDescending(x => x.MigrationId) .ToList(); if (migrationTypes.Count == 0) @@ -52,8 +54,8 @@ private async Task ExecuteAsync(string? projectPath, CancellationToken ct) return; } - var lastMigrationType = migrationTypes[0]; - string migrationId = lastMigrationType.GetCustomAttribute()!.Id; + var lastMigrationType = migrationTypes[0].Type; + string migrationId = migrationTypes[0].MigrationId!; var migrationFile = Directory.GetFiles(migrationsDir, "*.cs") .FirstOrDefault(f => !f.Contains("ModelSnapshot") && @@ -69,20 +71,27 @@ private async Task ExecuteAsync(string? projectPath, CancellationToken ct) string snapshotClassName = $"{contextName}ModelSnapshot"; string targetNamespace = $"{contextType.Namespace}.Migrations"; + // Cross-context: bridge snapshot and migration operations via JSON var snapshotType = assembly.GetExportedTypes() .FirstOrDefault(t => t.Name == snapshotClassName && t.Namespace == targetNamespace) ?? throw new Exception($"ModelSnapshot class '{snapshotClassName}' not found."); - var currentSnapshot = Activator.CreateInstance(snapshotType) as MigrationSnapshot + var isolatedSnap = Activator.CreateInstance(snapshotType) ?? throw new Exception("Failed to instantiate ModelSnapshot."); + var currentSnapshot = CliHelper.BridgeFromJson(isolatedSnap) + ?? throw new Exception("Failed to bridge ModelSnapshot."); - var lastMigration = Activator.CreateInstance(lastMigrationType) as Migration + // Invoke Down() with isolated MigrationBuilder, then bridge operations via JSON + var coreAsm = CliHelper.GetCoreAssembly(assembly, loadContext); + var isolatedMbType = coreAsm.GetType("Sheetly.Core.Migrations.MigrationBuilder")!; + var lastMigrationObj = Activator.CreateInstance(lastMigrationType) ?? throw new Exception("Failed to instantiate migration."); + var downBuilderObj = Activator.CreateInstance(isolatedMbType)!; + lastMigrationType.GetMethod("Down")!.Invoke(lastMigrationObj, [downBuilderObj]); + var isolatedOps = isolatedMbType.GetMethod("GetOperations")!.Invoke(downBuilderObj, null)!; + var downOps = CliHelper.BridgeMigrationOperations(isolatedOps); - var downBuilder = new Sheetly.Core.Migrations.MigrationBuilder(); - lastMigration.Down(downBuilder); - - var revertedSnapshot = RevertSnapshot(currentSnapshot, downBuilder.GetOperations()); + var revertedSnapshot = RevertSnapshot(currentSnapshot, downOps); var generator = new ModelSnapshotGenerator(); string snapshotCode = generator.GenerateModelSnapshot(revertedSnapshot, targetNamespace, contextName); diff --git a/src/Sheetly.CLI/Commands/RollbackCommand.cs b/src/Sheetly.CLI/Commands/RollbackCommand.cs index ed36aa7..aca4c9e 100644 --- a/src/Sheetly.CLI/Commands/RollbackCommand.cs +++ b/src/Sheetly.CLI/Commands/RollbackCommand.cs @@ -32,7 +32,7 @@ private async Task ExecuteAsync(bool noBuild, string? projectPath, CancellationT try { - var assembly = Assembly.LoadFrom(Path.GetFullPath(dllPath)); + var (assembly, loadContext) = CliHelper.LoadAssemblyIsolated(dllPath); var contextType = assembly.GetExportedTypes().FirstOrDefault(t => CliHelper.IsSubclassOfSheetsContext(t)) ?? throw new Exception("SheetsContext not found."); diff --git a/src/Sheetly.CLI/Commands/ScaffoldCommand.cs b/src/Sheetly.CLI/Commands/ScaffoldCommand.cs index e38760c..484f536 100644 --- a/src/Sheetly.CLI/Commands/ScaffoldCommand.cs +++ b/src/Sheetly.CLI/Commands/ScaffoldCommand.cs @@ -1,7 +1,5 @@ using Sheetly.CLI.Helpers; -using Sheetly.Core; using Sheetly.Core.Migration; -using Sheetly.Google; using System.CommandLine; using System.Reflection; using System.Text.Json; @@ -30,7 +28,7 @@ private async Task ExecuteAsync(string? projectPath, string? outputDir, Cancella try { - var assembly = Assembly.LoadFrom(Path.GetFullPath(dllPath)); + var (assembly, loadContext) = CliHelper.LoadAssemblyIsolated(dllPath); var contextType = assembly.GetExportedTypes().FirstOrDefault(t => CliHelper.IsSubclassOfSheetsContext(t)) ?? throw new Exception("SheetsContext not found."); @@ -38,12 +36,23 @@ private async Task ExecuteAsync(string? projectPath, string? outputDir, Cancella string? connStr = CliHelper.GetConnectionString(contextProjectDir) ?? CliHelper.GetConnectionStringFromContext(contextType); - var method = typeof(GoogleSheetsFactory).GetMethods().First(m => m.Name == "CreateContextAsync").MakeGenericMethod(contextType); + // Use isolated Sheetly.Google factory so contextType satisfies its T : SheetsContext constraint + var googleAsm = CliHelper.GetGoogleAssembly(assembly, loadContext) + ?? throw new Exception("Sheetly.Google not found in project references."); + var factoryType = googleAsm.GetType("Sheetly.Google.GoogleSheetsFactory")!; + var method = factoryType.GetMethods().First(m => m.Name == "CreateContextAsync").MakeGenericMethod(contextType); var task = (Task)method.Invoke(null, [connStr])!; await task; - var context = (SheetsContext)((dynamic)task).Result; + dynamic context = ((dynamic)task).Result; - var rows = await context.Provider.GetAllRowsAsync("__SheetlyHistory__"); + // Provider.GetAllRowsAsync returns Task>> — BCL types survive cross-context cast + var providerProp = contextType.BaseType!.GetProperty("Provider")!; + var provider = providerProp.GetValue(context); + var getRowsTask = (Task)provider!.GetType() + .GetMethod("GetAllRowsAsync", new[] { typeof(string) })! + .Invoke(provider, new object[] { "__SheetlyHistory__" })!; + await getRowsTask; + var rows = (List>)((dynamic)getRowsTask).Result; if (rows.Count <= 1) throw new Exception("Migration history not found."); var snapshotJson = rows.Last()[2].ToString()!; diff --git a/src/Sheetly.CLI/Commands/ScriptCommand.cs b/src/Sheetly.CLI/Commands/ScriptCommand.cs index 36df47c..d71b1a7 100644 --- a/src/Sheetly.CLI/Commands/ScriptCommand.cs +++ b/src/Sheetly.CLI/Commands/ScriptCommand.cs @@ -22,10 +22,11 @@ private async Task ExecuteAsync(string? projectPath) try { - var assembly = Assembly.LoadFrom(Path.GetFullPath(dllPath)); + var (assembly, _) = CliHelper.LoadAssemblyIsolated(dllPath); var snapshotType = assembly.GetTypes() - .FirstOrDefault(t => t.Name.EndsWith("ModelSnapshot") && t.IsSubclassOf(typeof(MigrationSnapshot))); + .FirstOrDefault(t => t.Name.EndsWith("ModelSnapshot") && + CliHelper.IsSubclassOf(t, "Sheetly.Core.Migrations.MigrationSnapshot")); if (snapshotType == null) { @@ -33,7 +34,8 @@ private async Task ExecuteAsync(string? projectPath) return; } - var snapshot = (MigrationSnapshot)Activator.CreateInstance(snapshotType)!; + var isolatedSnap = Activator.CreateInstance(snapshotType)!; + var snapshot = CliHelper.BridgeFromJson(isolatedSnap)!; Console.WriteLine($"--- Sheetly Schema Script (Generated at {DateTime.Now}) ---"); foreach (var entity in snapshot.Entities.Values) diff --git a/src/Sheetly.CLI/Commands/UpdateCommand.cs b/src/Sheetly.CLI/Commands/UpdateCommand.cs index 9e8935a..1bb17fc 100644 --- a/src/Sheetly.CLI/Commands/UpdateCommand.cs +++ b/src/Sheetly.CLI/Commands/UpdateCommand.cs @@ -33,7 +33,7 @@ private async Task ExecuteAsync(bool noBuild, string? projectPath, CancellationT try { - var assembly = Assembly.LoadFrom(Path.GetFullPath(dllPath)); + var (assembly, loadContext) = CliHelper.LoadAssemblyIsolated(dllPath); var contextType = assembly.GetExportedTypes().FirstOrDefault(t => CliHelper.IsSubclassOfSheetsContext(t)) ?? throw new Exception("SheetsContext not found."); @@ -53,12 +53,13 @@ private async Task ExecuteAsync(bool noBuild, string? projectPath, CancellationT var appliedMigrations = await migrationService.GetAppliedMigrationsAsync(); + // Cross-context: IsSubclassOf(typeof(Migration)) fails — use string-based check var migrationTypes = assembly.GetTypes() - .Where(t => t.IsSubclassOf(typeof(Migration)) && !t.IsAbstract) - .Select(t => new { Type = t, Attribute = t.GetCustomAttribute() }) - .Where(x => x.Attribute != null) - .OrderBy(x => x.Attribute!.Id) - .ToList(); + .Where(t => CliHelper.IsSubclassOf(t, "Sheetly.Core.Migration.Migration") && !t.IsAbstract) + .Select(t => new { Type = t, MigrationId = CliHelper.GetMigrationAttributeId(t) }) + .Where(x => x.MigrationId != null) + .OrderBy(x => x.MigrationId) + .ToList(); if (migrationTypes.Count == 0) { @@ -67,8 +68,8 @@ private async Task ExecuteAsync(bool noBuild, string? projectPath, CancellationT } var pendingMigrations = migrationTypes - .Where(x => !appliedMigrations.Contains(x.Attribute!.Id)) - .ToList(); + .Where(x => !appliedMigrations.Contains(x.MigrationId!)) + .ToList(); if (pendingMigrations.Count == 0) { @@ -78,21 +79,32 @@ private async Task ExecuteAsync(bool noBuild, string? projectPath, CancellationT Console.WriteLine($"🚀 Found {pendingMigrations.Count} pending migration(s)."); + // Cross-context: instantiate snapshot and bridge via JSON var snapshotType = assembly.GetTypes() - .FirstOrDefault(t => t.Name.EndsWith("ModelSnapshot") && t.IsSubclassOf(typeof(MigrationSnapshot))); - MigrationSnapshot? currentSnapshot = snapshotType != null - ? (MigrationSnapshot?)Activator.CreateInstance(snapshotType) - : null; + .FirstOrDefault(t => t.Name.EndsWith("ModelSnapshot") && + CliHelper.IsSubclassOf(t, "Sheetly.Core.Migrations.MigrationSnapshot")); + MigrationSnapshot? currentSnapshot = null; + if (snapshotType != null) + { + var isolatedSnap = Activator.CreateInstance(snapshotType); + currentSnapshot = CliHelper.BridgeFromJson(isolatedSnap); + } + + // Load isolated MigrationBuilder once for all pending migrations + var coreAsm = CliHelper.GetCoreAssembly(assembly, loadContext); + var isolatedMbType = coreAsm.GetType("Sheetly.Core.Migrations.MigrationBuilder")!; foreach (var pm in pendingMigrations) { - var migrationId = pm.Attribute!.Id; + var migrationId = pm.MigrationId!; Console.Write($"Applying {migrationId}... "); - var migration = (Migration)Activator.CreateInstance(pm.Type)!; - var builder = new Core.Migrations.MigrationBuilder(); - migration.Up(builder); - var operations = builder.GetOperations(); + // Cross-context: invoke Up() with isolated MigrationBuilder, then bridge operations via JSON + var migrationObj = Activator.CreateInstance(pm.Type)!; + var builder = Activator.CreateInstance(isolatedMbType)!; + pm.Type.GetMethod("Up")!.Invoke(migrationObj, [builder]); + var isolatedOps = isolatedMbType.GetMethod("GetOperations")!.Invoke(builder, null)!; + var operations = CliHelper.BridgeMigrationOperations(isolatedOps); if (currentSnapshot != null) { diff --git a/src/Sheetly.CLI/Helpers/CliHelper.cs b/src/Sheetly.CLI/Helpers/CliHelper.cs index abe8f1a..a217866 100644 --- a/src/Sheetly.CLI/Helpers/CliHelper.cs +++ b/src/Sheetly.CLI/Helpers/CliHelper.cs @@ -1,12 +1,123 @@ using Microsoft.Extensions.Configuration; using Sheetly.Core.Migration; +using Sheetly.Core.Migrations.Operations; using System.Reflection; using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; namespace Sheetly.CLI.Helpers; public static class CliHelper { + // JSON options used when bridging objects across AssemblyLoadContext boundaries. + // TypeJsonConverter handles System.Type fields (e.g. ClrType on operation classes). + private static readonly JsonSerializerOptions _bridgeOptions = new() + { + Converters = { new TypeJsonConverter() } + }; + + /// Loads the project DLL and its dependencies into an isolated context. + internal static (Assembly assembly, ProjectAssemblyLoadContext loadContext) LoadAssemblyIsolated(string dllPath) + { + var fullPath = Path.GetFullPath(dllPath); + var ctx = new ProjectAssemblyLoadContext(fullPath); + return (ctx.LoadFromAssemblyPath(fullPath), ctx); + } + + /// Resolves Sheetly.Core from the project's isolated load context. + internal static Assembly GetCoreAssembly(Assembly userAssembly, ProjectAssemblyLoadContext loadContext) + { + var coreRef = userAssembly.GetReferencedAssemblies() + .FirstOrDefault(a => a.Name == "Sheetly.Core") + ?? throw new Exception("Sheetly.Core not found in assembly references."); + return loadContext.LoadFromAssemblyName(coreRef); + } + + /// Resolves Sheetly.Google from the project's isolated load context (may be null). + internal static Assembly? GetGoogleAssembly(Assembly userAssembly, ProjectAssemblyLoadContext loadContext) + { + var googleRef = userAssembly.GetReferencedAssemblies().FirstOrDefault(a => a.Name == "Sheetly.Google"); + return googleRef != null ? loadContext.LoadFromAssemblyName(googleRef) : null; + } + + /// + /// String-based IsSubclassOf that works across AssemblyLoadContext boundaries + /// (type identity is context-scoped, so reference comparison fails cross-context). + /// + public static bool IsSubclassOf(Type? type, string baseTypeFullName) + { + while (type != null && type != typeof(object)) + { + if (type.FullName == baseTypeFullName) return true; + type = type.BaseType; + } + return false; + } + + /// + /// Produces a human-friendly "update the CLI" message when a reflection lookup fails, + /// indicating that the project's Sheetly.Core version is incompatible with this CLI. + /// + public static string VersionMismatchMessage(Assembly coreAsm, string missingMember) + { + var projectVer = coreAsm.GetName().Version?.ToString() ?? "unknown"; + return $"Incompatible Sheetly.Core version ({projectVer}): member '{missingMember}' not found.\n" + + $"Run: dotnet tool update -g dotnet-sheetly"; + } + + + public static string? GetMigrationAttributeId(Type t) + { + var attr = t.GetCustomAttributes(false).FirstOrDefault(a => a.GetType().Name == "MigrationAttribute"); + return attr?.GetType().GetProperty("Id")?.GetValue(attr) as string; + } + + /// + /// Serializes an object from the isolated context to JSON and deserializes it + /// into a CLI-side type, crossing the AssemblyLoadContext boundary safely. + /// + public static T? BridgeFromJson(object? isolatedObj) where T : class + { + if (isolatedObj == null) return null; + var json = JsonSerializer.Serialize(isolatedObj, isolatedObj.GetType(), _bridgeOptions); + return JsonSerializer.Deserialize(json, _bridgeOptions); + } + + /// + /// Bridges a List<MigrationOperation> across context boundaries. + /// Performs manual dispatch on OperationType because MigrationOperation is abstract. + /// + public static List BridgeMigrationOperations(object? isolatedOps) + { + if (isolatedOps == null) return []; + var json = JsonSerializer.Serialize(isolatedOps, isolatedOps.GetType(), _bridgeOptions); + var elements = JsonSerializer.Deserialize>(json, _bridgeOptions); + if (elements == null) return []; + + var result = new List(); + foreach (var el in elements) + { + var opType = el["OperationType"]?.GetValue(); + MigrationOperation? op = opType switch + { + "CreateTable" => el.Deserialize(_bridgeOptions), + "AddColumn" => el.Deserialize(_bridgeOptions), + "DropColumn" => el.Deserialize(_bridgeOptions), + "DropTable" => el.Deserialize(_bridgeOptions), + "AlterColumn" => el.Deserialize(_bridgeOptions), + "CreateIndex" => el.Deserialize(_bridgeOptions), + "DropIndex" => el.Deserialize(_bridgeOptions), + "AddCheckConstraint" => el.Deserialize(_bridgeOptions), + "DropCheckConstraint"=> el.Deserialize(_bridgeOptions), + _ => null + }; + if (op != null) result.Add(op); + } + return result; + } + + public static bool IsSubclassOfSheetsContext(Type? type) { while (type != null && type != typeof(object)) diff --git a/src/Sheetly.CLI/Helpers/ProjectAssemblyLoadContext.cs b/src/Sheetly.CLI/Helpers/ProjectAssemblyLoadContext.cs new file mode 100644 index 0000000..607c1dd --- /dev/null +++ b/src/Sheetly.CLI/Helpers/ProjectAssemblyLoadContext.cs @@ -0,0 +1,25 @@ +using System.Reflection; +using System.Runtime.Loader; + +namespace Sheetly.CLI.Helpers; + +/// +/// Loads a target project's DLL and all its dependencies in an isolated context, +/// preventing MVID conflicts with assemblies already loaded by the CLI tool itself. +/// +internal sealed class ProjectAssemblyLoadContext : AssemblyLoadContext +{ + private readonly AssemblyDependencyResolver _resolver; + + public ProjectAssemblyLoadContext(string dllPath) : base(isCollectible: true) + { + _resolver = new AssemblyDependencyResolver(dllPath); + } + + protected override Assembly? Load(AssemblyName assemblyName) + { + // Resolve from the project's own bin directory first + var path = _resolver.ResolveAssemblyToPath(assemblyName); + return path != null ? LoadFromAssemblyPath(path) : null; + } +} diff --git a/src/Sheetly.CLI/Helpers/TypeJsonConverter.cs b/src/Sheetly.CLI/Helpers/TypeJsonConverter.cs new file mode 100644 index 0000000..1788bae --- /dev/null +++ b/src/Sheetly.CLI/Helpers/TypeJsonConverter.cs @@ -0,0 +1,24 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Sheetly.CLI.Helpers; + +/// +/// Serializes System.Type as its AssemblyQualifiedName so that MigrationOperation +/// fields like ClrType survive JSON round-tripping across AssemblyLoadContext boundaries. +/// +internal sealed class TypeJsonConverter : JsonConverter +{ + public override Type? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var name = reader.GetString(); + if (string.IsNullOrEmpty(name)) return typeof(string); + // AssemblyQualifiedName resolves for all BCL types + return Type.GetType(name) ?? typeof(string); + } + + public override void Write(Utf8JsonWriter writer, Type? value, JsonSerializerOptions options) + { + writer.WriteStringValue(value?.AssemblyQualifiedName ?? typeof(string).AssemblyQualifiedName); + } +} From c916e5418d89c531916693b9942332fc9419909b Mon Sep 17 00:00:00 2001 From: muqimjon Date: Sat, 28 Feb 2026 04:19:21 +0500 Subject: [PATCH 25/36] refactor: adopt EF Core OperationExecutor pattern for CLI All CLI commands now invoke DesignTimeOperations via reflection. No Sheetly types cross the AssemblyLoadContext boundary - only JSON strings. CLI no longer references Sheetly.Core or Sheetly.Google, making it version-agnostic like dotnet-ef. - Add DesignTimeOperations (6 static methods returning JSON) - Rewrite all 7 CLI commands as thin InvokeDesignTime wrappers - Remove TypeJsonConverter.cs (obsoleted) - Remove Sheetly.Core/Google project references from CLI - Add --no-build option to script/drop/scaffold commands --- ...ate.cs => 20260227231549_InitialCreate.cs} | 4 +- .../Migrations/ExcelAppModelSnapshot.cs | 2 +- src/Sheetly.CLI/Commands/AddCommand.cs | 105 +--- src/Sheetly.CLI/Commands/DropCommand.cs | 31 +- src/Sheetly.CLI/Commands/RemoveCommand.cs | 241 +------- src/Sheetly.CLI/Commands/RollbackCommand.cs | 1 - src/Sheetly.CLI/Commands/ScaffoldCommand.cs | 55 +- src/Sheetly.CLI/Commands/ScriptCommand.cs | 45 +- src/Sheetly.CLI/Commands/UpdateCommand.cs | 112 +--- src/Sheetly.CLI/Helpers/CliHelper.cs | 153 ++---- src/Sheetly.CLI/Helpers/TypeJsonConverter.cs | 24 - src/Sheetly.CLI/Sheetly.CLI.csproj | 5 - .../Migrations/Design/DesignTimeOperations.cs | 519 ++++++++++++++++++ 13 files changed, 634 insertions(+), 663 deletions(-) rename samples/Sheetly.Test/Migrations/{20260227224251_InitialMigrate.cs => 20260227231549_InitialCreate.cs} (91%) delete mode 100644 src/Sheetly.CLI/Helpers/TypeJsonConverter.cs create mode 100644 src/Sheetly.Core/Migrations/Design/DesignTimeOperations.cs diff --git a/samples/Sheetly.Test/Migrations/20260227224251_InitialMigrate.cs b/samples/Sheetly.Test/Migrations/20260227231549_InitialCreate.cs similarity index 91% rename from samples/Sheetly.Test/Migrations/20260227224251_InitialMigrate.cs rename to samples/Sheetly.Test/Migrations/20260227231549_InitialCreate.cs index b8e626a..5718f76 100644 --- a/samples/Sheetly.Test/Migrations/20260227224251_InitialMigrate.cs +++ b/samples/Sheetly.Test/Migrations/20260227231549_InitialCreate.cs @@ -3,8 +3,8 @@ namespace Sheetly.Test.Contexts.Migrations; -[Migration("20260227224251_InitialMigrate")] -public partial class InitialMigrate : Migration +[Migration("20260227231549_InitialCreate")] +public partial class InitialCreate : Migration { public override void Up(MigrationBuilder builder) { diff --git a/samples/Sheetly.Test/Migrations/ExcelAppModelSnapshot.cs b/samples/Sheetly.Test/Migrations/ExcelAppModelSnapshot.cs index f8034f5..eecc170 100644 --- a/samples/Sheetly.Test/Migrations/ExcelAppModelSnapshot.cs +++ b/samples/Sheetly.Test/Migrations/ExcelAppModelSnapshot.cs @@ -20,7 +20,7 @@ public static MigrationSnapshot BuildModel() { ModelHash = "erfMXU/RWc/dJ2XYy4Tck5Nw4rMDkzpVEiJA1z5xQro=", Version = "1.0.0", - LastUpdated = DateTime.Parse("2026-02-27T22:42:51.5688269Z") + LastUpdated = DateTime.Parse("2026-02-27T23:15:49.6662861Z") }; // Category diff --git a/src/Sheetly.CLI/Commands/AddCommand.cs b/src/Sheetly.CLI/Commands/AddCommand.cs index 0a63fa5..2a70c3b 100644 --- a/src/Sheetly.CLI/Commands/AddCommand.cs +++ b/src/Sheetly.CLI/Commands/AddCommand.cs @@ -1,9 +1,5 @@ using Sheetly.CLI.Helpers; -using Sheetly.Core.Migration; -using Sheetly.Core.Migrations; -using Sheetly.Core.Migrations.Design; using System.CommandLine; -using System.Reflection; namespace Sheetly.CLI.Commands; @@ -46,99 +42,22 @@ private async Task ExecuteAsync(string? name, bool noBuild, string? projectPath, try { var (assembly, loadContext) = CliHelper.LoadAssemblyIsolated(dllPath); - var contextType = assembly.GetExportedTypes().FirstOrDefault(t => CliHelper.IsSubclassOfSheetsContext(t)) - ?? throw new Exception("SheetsContext not found."); - - var context = Activator.CreateInstance(contextType)!; - string contextProjectDir = CliHelper.FindProjectRootFromDll(contextType.Assembly.Location); - - outputDir ??= "Migrations"; - - // Use isolated Sheetly.Core to avoid MVID mismatch across load contexts. - // CLI ships its own copy of Sheetly.Core; the project has a freshly built copy. - // Both have the same version string but different MVIDs — cross-context casting fails. - // Fix: invoke ModelBuilder and SnapshotBuilder from the isolated context, then JSON-bridge the result. var coreAsm = CliHelper.GetCoreAssembly(assembly, loadContext); - var mbType = coreAsm.GetType("Sheetly.Core.ModelBuilder") - ?? throw new Exception(CliHelper.VersionMismatchMessage(coreAsm, "Sheetly.Core.ModelBuilder")); - var modelBuilder = Activator.CreateInstance(mbType)!; - - var onModelCreatingMethod = contextType.GetMethod("OnModelCreating", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); - onModelCreatingMethod?.Invoke(context, [modelBuilder]); - - // Build current snapshot inside the isolated context, then bridge to CLI via JSON - var sbType = coreAsm.GetType("Sheetly.Core.Migrations.SnapshotBuilder") - ?? throw new Exception(CliHelper.VersionMismatchMessage(coreAsm, "Sheetly.Core.Migrations.SnapshotBuilder")); - var buildMethod = sbType.GetMethod("BuildFromContext", BindingFlags.Public | BindingFlags.Static) - ?? throw new Exception(CliHelper.VersionMismatchMessage(coreAsm, "SnapshotBuilder.BuildFromContext")); - var metadataArg = mbType.GetMethod("GetMetadata")!.Invoke(modelBuilder, null); - var isolatedSnapshot = buildMethod.Invoke(null, [contextType, metadataArg]); - var currentSnapshot = CliHelper.BridgeFromJson(isolatedSnapshot) - ?? throw new Exception("Failed to build migration snapshot."); - - string finalPath = Path.Combine(contextProjectDir, outputDir); - Directory.CreateDirectory(finalPath); - - MigrationSnapshot? previousSnapshot = null; - string snapshotClassName = $"{contextType.Name.Replace("Context", "")}ModelSnapshot"; - var snapshotType = assembly.GetExportedTypes() - .FirstOrDefault(t => t.Name == snapshotClassName && t.Namespace == $"{contextType.Namespace}.Migrations"); - - if (snapshotType != null) - { - var isolatedPrev = Activator.CreateInstance(snapshotType); - previousSnapshot = CliHelper.BridgeFromJson(isolatedPrev); - } - - var modelDiffer = new ModelDiffer(); - var operations = modelDiffer.GetDifferences(previousSnapshot, currentSnapshot); - - if (operations.Count == 0) - { - Console.WriteLine("⚠️ No changes detected in the model."); - return; - } - - - var existingMigration = Directory.GetFiles(finalPath, "*.cs") - .Where(f => !f.Contains("ModelSnapshot")) - .FirstOrDefault(f => Path.GetFileNameWithoutExtension(f).EndsWith($"_{name}", StringComparison.OrdinalIgnoreCase)); - - if (existingMigration != null) - { - Console.WriteLine($"❌ A migration named '{name}' already exists: '{Path.GetFileName(existingMigration)}'"); - return; - } - - string timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss"); - string migrationId = $"{timestamp}_{name}"; - string targetNamespace = $"{contextType.Namespace}.Migrations"; - - var generator = new CSharpMigrationGenerator(); - string migrationCode = generator.GenerateMigration(name, migrationId, targetNamespace, operations); - - string csharpFileName = $"{migrationId}.cs"; - await File.WriteAllTextAsync(Path.Combine(finalPath, csharpFileName), migrationCode, ct); - - var snapshotGenerator = new ModelSnapshotGenerator(); - string snapshotCode = snapshotGenerator.GenerateModelSnapshot( - currentSnapshot, - targetNamespace, - contextType.Name.Replace("Context", "")); + var contextType = CliHelper.FindContextType(assembly); - string snapshotFileName = $"{contextType.Name.Replace("Context", "")}ModelSnapshot.cs"; - string snapshotFilePath = Path.Combine(finalPath, snapshotFileName); - await File.WriteAllTextAsync(snapshotFilePath, snapshotCode, ct); + var json = CliHelper.InvokeDesignTime(coreAsm, "AddMigration", contextType, name, outputDir); + var doc = CliHelper.ParseResult(json); + if (doc == null) return; - Console.WriteLine($"✅ Migration created: '{csharpFileName}'"); - Console.WriteLine($"✅ Model snapshot updated: '{snapshotFileName}'"); - Console.WriteLine($" Operations: {operations.Count}"); + var root = doc.RootElement; + Console.WriteLine($"✅ Migration created: '{root.GetProperty("migrationFile").GetString()}'"); + Console.WriteLine($"✅ Model snapshot updated: '{root.GetProperty("snapshotFile").GetString()}'"); - foreach (var op in operations) - { - Console.WriteLine($" - {op.OperationType}"); - } + var ops = root.GetProperty("operations"); + Console.WriteLine($" Operations: {ops.GetArrayLength()}"); + foreach (var op in ops.EnumerateArray()) + Console.WriteLine($" - {op.GetString()}"); } catch (Exception ex) { Console.WriteLine($"❌ Error: {ex.Message}"); } } -} \ No newline at end of file +} diff --git a/src/Sheetly.CLI/Commands/DropCommand.cs b/src/Sheetly.CLI/Commands/DropCommand.cs index 82d4924..7dd5b59 100644 --- a/src/Sheetly.CLI/Commands/DropCommand.cs +++ b/src/Sheetly.CLI/Commands/DropCommand.cs @@ -1,6 +1,5 @@ using Sheetly.CLI.Helpers; using System.CommandLine; -using System.Reflection; namespace Sheetly.CLI.Commands; @@ -8,17 +7,20 @@ public class DropCommand : Command { private readonly Option _forceOption = new("--force", ["-f"]); private readonly Option _projectOption = new("--project", ["-p"]); + private readonly Option _noBuildOption = new("--no-build", ["-n"]) { Description = "Do not build project" }; public DropCommand() : base("drop", "Drop the database (clear sheets)") { this.Add(_forceOption); this.Add(_projectOption); + this.Add(_noBuildOption); this.SetAction(async (parseResult, ct) => await ExecuteAsync( parseResult.GetValue(_forceOption), + parseResult.GetValue(_noBuildOption), parseResult.GetValue(_projectOption))); } - private async Task ExecuteAsync(bool force, string? projectPath) + private async Task ExecuteAsync(bool force, bool noBuild, string? projectPath) { if (!force) { @@ -26,32 +28,21 @@ private async Task ExecuteAsync(bool force, string? projectPath) if (Console.ReadLine()?.ToLower() != "y") return; } - string dllPath = CliHelper.FindProjectDll(true, projectPath); + string dllPath = CliHelper.FindProjectDll(noBuild, projectPath); if (string.IsNullOrEmpty(dllPath)) return; try { var (assembly, loadContext) = CliHelper.LoadAssemblyIsolated(dllPath); - var contextType = assembly.GetExportedTypes().FirstOrDefault(t => CliHelper.IsSubclassOfSheetsContext(t)) - ?? throw new Exception("SheetsContext not found."); + var coreAsm = CliHelper.GetCoreAssembly(assembly, loadContext); + var contextType = CliHelper.FindContextType(assembly); - string? connStr = CliHelper.GetConnectionString(CliHelper.FindProjectRootFromDll(dllPath)) - ?? CliHelper.GetConnectionStringFromContext(contextType); + string? connStr = CliHelper.GetConnectionString(CliHelper.FindProjectRootFromDll(dllPath)); - // Use isolated Sheetly.Google factory so contextType satisfies its T : SheetsContext constraint - var googleAsm = CliHelper.GetGoogleAssembly(assembly, loadContext) - ?? throw new Exception("Sheetly.Google not found in project references."); - var factoryType = googleAsm.GetType("Sheetly.Google.GoogleSheetsFactory")!; + var json = CliHelper.InvokeDesignTime(coreAsm, "DropDatabaseAsync", contextType, connStr); + var doc = CliHelper.ParseResult(json); + if (doc == null) return; - var method = factoryType.GetMethods() - .FirstOrDefault(m => m.Name == "CreateContextAsync" && m.GetParameters().Length == 1) - ?.MakeGenericMethod(contextType); - - var task = (Task)method!.Invoke(null, [connStr])!; - await task; - - dynamic context = ((dynamic)task).Result; - await context.Database.DropDatabaseAsync(); Console.WriteLine("✅ Database dropped successfully."); } catch (Exception ex) { Console.WriteLine($"❌ Error: {ex.Message}"); } diff --git a/src/Sheetly.CLI/Commands/RemoveCommand.cs b/src/Sheetly.CLI/Commands/RemoveCommand.cs index d93250b..6b87dc9 100644 --- a/src/Sheetly.CLI/Commands/RemoveCommand.cs +++ b/src/Sheetly.CLI/Commands/RemoveCommand.cs @@ -1,13 +1,5 @@ using Sheetly.CLI.Helpers; -using Sheetly.Core.Migration; -using Sheetly.Core.Migrations; -using Sheetly.Core.Migrations.Design; -using Sheetly.Core.Migrations.Operations; using System.CommandLine; -using System.Reflection; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; namespace Sheetly.CLI.Commands; @@ -29,236 +21,17 @@ private async Task ExecuteAsync(string? projectPath, CancellationToken ct) try { var (assembly, loadContext) = CliHelper.LoadAssemblyIsolated(dllPath); - var contextType = assembly.GetExportedTypes().FirstOrDefault(t => CliHelper.IsSubclassOfSheetsContext(t)) - ?? throw new Exception("SheetsContext not found."); - - string contextProjectDir = CliHelper.FindProjectRootFromDll(contextType.Assembly.Location); - string migrationsDir = Path.Combine(contextProjectDir, "Migrations"); - - if (!Directory.Exists(migrationsDir)) - { - Console.WriteLine("⚠️ Migrations directory not found."); - return; - } - - // Cross-context: use string-based attribute access instead of GetCustomAttribute() - var migrationTypes = assembly.GetExportedTypes() - .Select(t => new { Type = t, MigrationId = CliHelper.GetMigrationAttributeId(t) }) - .Where(x => x.MigrationId != null) - .OrderByDescending(x => x.MigrationId) - .ToList(); - - if (migrationTypes.Count == 0) - { - Console.WriteLine("⚠️ No migrations to remove."); - return; - } - - var lastMigrationType = migrationTypes[0].Type; - string migrationId = migrationTypes[0].MigrationId!; - - var migrationFile = Directory.GetFiles(migrationsDir, "*.cs") - .FirstOrDefault(f => !f.Contains("ModelSnapshot") && - Path.GetFileNameWithoutExtension(f) == migrationId); - - if (migrationFile == null) - { - Console.WriteLine($"⚠️ Migration file for '{migrationId}' not found. It may have already been removed from disk."); - return; - } - - string contextName = contextType.Name.Replace("Context", ""); - string snapshotClassName = $"{contextName}ModelSnapshot"; - string targetNamespace = $"{contextType.Namespace}.Migrations"; - - // Cross-context: bridge snapshot and migration operations via JSON - var snapshotType = assembly.GetExportedTypes() - .FirstOrDefault(t => t.Name == snapshotClassName && t.Namespace == targetNamespace) - ?? throw new Exception($"ModelSnapshot class '{snapshotClassName}' not found."); - - var isolatedSnap = Activator.CreateInstance(snapshotType) - ?? throw new Exception("Failed to instantiate ModelSnapshot."); - var currentSnapshot = CliHelper.BridgeFromJson(isolatedSnap) - ?? throw new Exception("Failed to bridge ModelSnapshot."); - - // Invoke Down() with isolated MigrationBuilder, then bridge operations via JSON var coreAsm = CliHelper.GetCoreAssembly(assembly, loadContext); - var isolatedMbType = coreAsm.GetType("Sheetly.Core.Migrations.MigrationBuilder")!; - var lastMigrationObj = Activator.CreateInstance(lastMigrationType) - ?? throw new Exception("Failed to instantiate migration."); - var downBuilderObj = Activator.CreateInstance(isolatedMbType)!; - lastMigrationType.GetMethod("Down")!.Invoke(lastMigrationObj, [downBuilderObj]); - var isolatedOps = isolatedMbType.GetMethod("GetOperations")!.Invoke(downBuilderObj, null)!; - var downOps = CliHelper.BridgeMigrationOperations(isolatedOps); - - var revertedSnapshot = RevertSnapshot(currentSnapshot, downOps); + var contextType = CliHelper.FindContextType(assembly); - var generator = new ModelSnapshotGenerator(); - string snapshotCode = generator.GenerateModelSnapshot(revertedSnapshot, targetNamespace, contextName); - string snapshotFilePath = Path.Combine(migrationsDir, $"{snapshotClassName}.cs"); + var json = CliHelper.InvokeDesignTime(coreAsm, "RemoveMigration", contextType); + var doc = CliHelper.ParseResult(json); + if (doc == null) return; - File.Delete(migrationFile); - await File.WriteAllTextAsync(snapshotFilePath, snapshotCode, ct); - - Console.WriteLine($"✅ Migration removed: '{Path.GetFileName(migrationFile)}'"); - Console.WriteLine($"✅ Model snapshot reverted: '{snapshotClassName}.cs'"); + var root = doc.RootElement; + Console.WriteLine($"✅ Migration removed: '{root.GetProperty("removedFile").GetString()}'"); + Console.WriteLine($"✅ Model snapshot reverted: '{root.GetProperty("snapshotFile").GetString()}'"); } catch (Exception ex) { Console.WriteLine($"❌ Error: {ex.Message}"); } } - - /// - /// Applies Down operations in reverse to produce the snapshot state before the migration was added. - /// - private static MigrationSnapshot RevertSnapshot(MigrationSnapshot current, List downOps) - { - var entities = current.Entities.ToDictionary(kvp => kvp.Key, kvp => CloneEntity(kvp.Value)); - - foreach (var op in downOps) - { - switch (op) - { - case DropColumnOperation drop: - if (entities.TryGetValue(drop.Table, out var entity)) - entity.Columns.RemoveAll(c => c.Name == drop.Name); - break; - - case AddColumnOperation add: - if (entities.TryGetValue(add.Table, out var entityToAddCol)) - entityToAddCol.Columns.Add(new ColumnSchema - { - Name = add.Name, - PropertyName = add.Name, - DataType = add.ClrType.Name, - IsNullable = add.IsNullable, - IsRequired = add.IsRequired, - IsPrimaryKey = add.IsPrimaryKey, - IsAutoIncrement = add.IsPrimaryKey, - IsForeignKey = add.IsForeignKey, - ForeignKeyTable = add.ForeignKeyTable, - ForeignKeyColumn = add.ForeignKeyColumn, - IsUnique = add.IsUnique, - MaxLength = add.MaxLength, - MinLength = add.MinLength, - DefaultValue = add.DefaultValue, - CheckConstraint = add.CheckConstraint, - IsComputed = add.IsComputed, - ComputedColumnSql = add.ComputedColumnSql, - IsConcurrencyToken = add.IsConcurrencyToken, - Comment = add.Comment - }); - break; - - case DropTableOperation dropTable: - entities.Remove(dropTable.Name); - break; - - case CreateTableOperation createTable: - entities[createTable.Name] = new EntitySchema - { - TableName = createTable.Name, - ClassName = createTable.ClassName ?? createTable.Name, - Columns = createTable.Columns.Select(c => new ColumnSchema - { - Name = c.Name, - PropertyName = c.Name, - DataType = c.ClrType.Name, - IsNullable = c.IsNullable, - IsRequired = c.IsRequired, - IsPrimaryKey = c.IsPrimaryKey, - IsAutoIncrement = c.IsPrimaryKey, - IsForeignKey = c.IsForeignKey, - ForeignKeyTable = c.ForeignKeyTable, - ForeignKeyColumn = c.ForeignKeyColumn - }).ToList(), - Relationships = [] - }; - break; - - case AlterColumnOperation alter: - if (entities.TryGetValue(alter.Table, out var entityToAlter)) - { - var col = entityToAlter.Columns.FirstOrDefault(c => c.Name == alter.Name); - if (col != null) - { - // Down's AlterColumn values are the values to restore - if (alter.ClrType != null) col.DataType = alter.ClrType.Name; - if (alter.IsNullable.HasValue) col.IsNullable = alter.IsNullable.Value; - if (alter.MaxLength.HasValue) col.MaxLength = alter.MaxLength; - if (alter.DefaultValue != null) col.DefaultValue = alter.DefaultValue; - } - } - break; - } - } - - return new MigrationSnapshot - { - Entities = entities, - Version = current.Version, - LastUpdated = DateTime.UtcNow, - ModelHash = CalculateHash(entities) - }; - } - - private static EntitySchema CloneEntity(EntitySchema src) => new() - { - TableName = src.TableName, - ClassName = src.ClassName, - Namespace = src.Namespace, - Columns = src.Columns.Select(c => new ColumnSchema - { - Name = c.Name, - PropertyName = c.PropertyName, - DataType = c.DataType, - IsNullable = c.IsNullable, - IsRequired = c.IsRequired, - IsPrimaryKey = c.IsPrimaryKey, - IsAutoIncrement = c.IsAutoIncrement, - IsForeignKey = c.IsForeignKey, - ForeignKeyTable = c.ForeignKeyTable, - ForeignKeyColumn = c.ForeignKeyColumn, - IsUnique = c.IsUnique, - IndexName = c.IndexName, - MaxLength = c.MaxLength, - MinLength = c.MinLength, - DefaultValue = c.DefaultValue, - DefaultValueSql = c.DefaultValueSql, - MinValue = c.MinValue, - MaxValue = c.MaxValue, - Precision = c.Precision, - Scale = c.Scale, - CheckConstraint = c.CheckConstraint, - IsComputed = c.IsComputed, - ComputedColumnSql = c.ComputedColumnSql, - IsStored = c.IsStored, - IsConcurrencyToken = c.IsConcurrencyToken, - Comment = c.Comment - }).ToList(), - Relationships = src.Relationships.ToList() - }; - - private static string CalculateHash(Dictionary entities) - { - var structural = entities - .OrderBy(e => e.Key) - .ToDictionary( - e => e.Key, - e => new - { - e.Value.TableName, - Columns = e.Value.Columns.Select(c => new - { - c.Name, - c.DataType, - c.IsPrimaryKey, - c.IsAutoIncrement, - c.IsForeignKey, - c.ForeignKeyTable, - c.ForeignKeyColumn - }).ToList() - }); - - var json = JsonSerializer.Serialize(structural, new JsonSerializerOptions { WriteIndented = false }); - return Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(json))); - } } \ No newline at end of file diff --git a/src/Sheetly.CLI/Commands/RollbackCommand.cs b/src/Sheetly.CLI/Commands/RollbackCommand.cs index aca4c9e..e332c1c 100644 --- a/src/Sheetly.CLI/Commands/RollbackCommand.cs +++ b/src/Sheetly.CLI/Commands/RollbackCommand.cs @@ -1,6 +1,5 @@ using Sheetly.CLI.Helpers; using System.CommandLine; -using System.Reflection; namespace Sheetly.CLI.Commands; diff --git a/src/Sheetly.CLI/Commands/ScaffoldCommand.cs b/src/Sheetly.CLI/Commands/ScaffoldCommand.cs index 484f536..d2b58a8 100644 --- a/src/Sheetly.CLI/Commands/ScaffoldCommand.cs +++ b/src/Sheetly.CLI/Commands/ScaffoldCommand.cs @@ -1,8 +1,5 @@ using Sheetly.CLI.Helpers; -using Sheetly.Core.Migration; using System.CommandLine; -using System.Reflection; -using System.Text.Json; namespace Sheetly.CLI.Commands; @@ -10,63 +7,41 @@ public class ScaffoldCommand : Command { private readonly Option _projectOption = new("--project", ["-p"]); private readonly Option _outputDirOption = new("--output-dir", ["-o"]); + private readonly Option _noBuildOption = new("--no-build", ["-n"]) { Description = "Do not build project" }; - public ScaffoldCommand() : base("scaffold", "Scaffold model classes from Google Sheets") + public ScaffoldCommand() : base("scaffold", "Scaffold model classes from remote provider") { this.Add(_projectOption); this.Add(_outputDirOption); + this.Add(_noBuildOption); this.SetAction(async (parseResult, ct) => await ExecuteAsync( + parseResult.GetValue(_noBuildOption), parseResult.GetValue(_projectOption), parseResult.GetValue(_outputDirOption), ct)); } - private async Task ExecuteAsync(string? projectPath, string? outputDir, CancellationToken ct) + private async Task ExecuteAsync(bool noBuild, string? projectPath, string? outputDir, CancellationToken ct) { - string dllPath = CliHelper.FindProjectDll(true, projectPath); + string dllPath = CliHelper.FindProjectDll(noBuild, projectPath); if (string.IsNullOrEmpty(dllPath)) return; try { var (assembly, loadContext) = CliHelper.LoadAssemblyIsolated(dllPath); - var contextType = assembly.GetExportedTypes().FirstOrDefault(t => CliHelper.IsSubclassOfSheetsContext(t)) - ?? throw new Exception("SheetsContext not found."); + var coreAsm = CliHelper.GetCoreAssembly(assembly, loadContext); + var contextType = CliHelper.FindContextType(assembly); - string contextProjectDir = CliHelper.FindProjectRootFromDll(dllPath); - string? connStr = CliHelper.GetConnectionString(contextProjectDir) - ?? CliHelper.GetConnectionStringFromContext(contextType); + string? connStr = CliHelper.GetConnectionString(CliHelper.FindProjectRootFromDll(dllPath)); - // Use isolated Sheetly.Google factory so contextType satisfies its T : SheetsContext constraint - var googleAsm = CliHelper.GetGoogleAssembly(assembly, loadContext) - ?? throw new Exception("Sheetly.Google not found in project references."); - var factoryType = googleAsm.GetType("Sheetly.Google.GoogleSheetsFactory")!; - var method = factoryType.GetMethods().First(m => m.Name == "CreateContextAsync").MakeGenericMethod(contextType); - var task = (Task)method.Invoke(null, [connStr])!; - await task; - dynamic context = ((dynamic)task).Result; + Console.WriteLine("⏳ Scaffolding models from remote provider..."); + var json = CliHelper.InvokeDesignTime(coreAsm, "ScaffoldAsync", contextType, outputDir, connStr); + var doc = CliHelper.ParseResult(json); + if (doc == null) return; - // Provider.GetAllRowsAsync returns Task>> — BCL types survive cross-context cast - var providerProp = contextType.BaseType!.GetProperty("Provider")!; - var provider = providerProp.GetValue(context); - var getRowsTask = (Task)provider!.GetType() - .GetMethod("GetAllRowsAsync", new[] { typeof(string) })! - .Invoke(provider, new object[] { "__SheetlyHistory__" })!; - await getRowsTask; - var rows = (List>)((dynamic)getRowsTask).Result; - if (rows.Count <= 1) throw new Exception("Migration history not found."); + foreach (var f in doc.RootElement.GetProperty("files").EnumerateArray()) + Console.WriteLine($"📄 Created: {f.GetString()}"); - var snapshotJson = rows.Last()[2].ToString()!; - var snapshot = JsonSerializer.Deserialize(snapshotJson)!; - - string finalPath = Path.Combine(contextProjectDir, outputDir ?? "Models/Scaffolded"); - Directory.CreateDirectory(finalPath); - - foreach (var entity in snapshot.Entities.Values) - { - var code = CliHelper.GenerateClassCode(entity); - await File.WriteAllTextAsync(Path.Combine(finalPath, $"{entity.ClassName}.cs"), code, ct); - Console.WriteLine($"📄 Created: {entity.ClassName}.cs"); - } Console.WriteLine("✅ Scaffolding complete."); } catch (Exception ex) { Console.WriteLine($"❌ Error: {ex.Message}"); } diff --git a/src/Sheetly.CLI/Commands/ScriptCommand.cs b/src/Sheetly.CLI/Commands/ScriptCommand.cs index d71b1a7..f77f659 100644 --- a/src/Sheetly.CLI/Commands/ScriptCommand.cs +++ b/src/Sheetly.CLI/Commands/ScriptCommand.cs @@ -1,55 +1,38 @@ using Sheetly.CLI.Helpers; -using Sheetly.Core.Migration; using System.CommandLine; -using System.Reflection; namespace Sheetly.CLI.Commands; public class ScriptCommand : Command { private readonly Option _projectOption = new("--project", ["-p"]) { Description = "Manual path to DLL" }; + private readonly Option _noBuildOption = new("--no-build", ["-n"]) { Description = "Do not build project" }; public ScriptCommand() : base("script", "Generate a schema script from the latest snapshot") { this.Add(_projectOption); - this.SetAction(async (parseResult, ct) => await ExecuteAsync(parseResult.GetValue(_projectOption))); + this.Add(_noBuildOption); + this.SetAction(async (parseResult, ct) => await ExecuteAsync( + parseResult.GetValue(_noBuildOption), + parseResult.GetValue(_projectOption))); } - private async Task ExecuteAsync(string? projectPath) + private async Task ExecuteAsync(bool noBuild, string? projectPath) { - string dllPath = CliHelper.FindProjectDll(true, projectPath); + string dllPath = CliHelper.FindProjectDll(noBuild, projectPath); if (string.IsNullOrEmpty(dllPath)) return; try { - var (assembly, _) = CliHelper.LoadAssemblyIsolated(dllPath); + var (assembly, loadContext) = CliHelper.LoadAssemblyIsolated(dllPath); + var coreAsm = CliHelper.GetCoreAssembly(assembly, loadContext); + var contextType = CliHelper.FindContextType(assembly); - var snapshotType = assembly.GetTypes() - .FirstOrDefault(t => t.Name.EndsWith("ModelSnapshot") && - CliHelper.IsSubclassOf(t, "Sheetly.Core.Migrations.MigrationSnapshot")); + var json = CliHelper.InvokeDesignTime(coreAsm, "GetSchemaScript", contextType); + var doc = CliHelper.ParseResult(json); + if (doc == null) return; - if (snapshotType == null) - { - Console.WriteLine("⚠️ Snapshot not found. Run 'migrations add' first."); - return; - } - - var isolatedSnap = Activator.CreateInstance(snapshotType)!; - var snapshot = CliHelper.BridgeFromJson(isolatedSnap)!; - - Console.WriteLine($"--- Sheetly Schema Script (Generated at {DateTime.Now}) ---"); - foreach (var entity in snapshot.Entities.Values) - { - Console.WriteLine($"Sheet: {entity.TableName}"); - foreach (var col in entity.Columns) - { - string pk = col.IsPrimaryKey ? " [PK]" : ""; - string fk = col.IsForeignKey ? $" [FK → {col.ForeignKeyTable}]" : ""; - string req = col.IsRequired ? " [Required]" : ""; - Console.WriteLine($" - {col.PropertyName} ({col.DataType}){pk}{fk}{req}"); - } - Console.WriteLine(); - } + Console.Write(doc.RootElement.GetProperty("script").GetString()); } catch (Exception ex) { Console.WriteLine($"❌ Error: {ex.Message}"); } } diff --git a/src/Sheetly.CLI/Commands/UpdateCommand.cs b/src/Sheetly.CLI/Commands/UpdateCommand.cs index 1bb17fc..8e101e8 100644 --- a/src/Sheetly.CLI/Commands/UpdateCommand.cs +++ b/src/Sheetly.CLI/Commands/UpdateCommand.cs @@ -1,11 +1,5 @@ using Sheetly.CLI.Helpers; -using Sheetly.Core.Configuration; -using Sheetly.Core.Migration; -using Sheetly.Core.Migrations; -using Sheetly.Core.Migrations.Operations; -using Sheetly.Google; using System.CommandLine; -using System.Reflection; namespace Sheetly.CLI.Commands; @@ -34,109 +28,29 @@ private async Task ExecuteAsync(bool noBuild, string? projectPath, CancellationT try { var (assembly, loadContext) = CliHelper.LoadAssemblyIsolated(dllPath); - var contextType = assembly.GetExportedTypes().FirstOrDefault(t => CliHelper.IsSubclassOfSheetsContext(t)) - ?? throw new Exception("SheetsContext not found."); - - string contextProjectDir = CliHelper.FindProjectRootFromDll(contextType.Assembly.Location); - string? connStr = CliHelper.GetConnectionString(contextProjectDir) - ?? CliHelper.GetConnectionStringFromContext(contextType) - ?? throw new Exception("ConnectionString not found. Configure OnConfiguring() or add appsettings.json."); - - // Create provider directly — bypassing full context init so migration checks don't run - Console.WriteLine("⏳ Connecting to Google Sheets..."); - var connString = SheetsConnectionString.Parse(connStr); - connString.Validate(); - var provider = new GoogleSheetProvider(connString.CredentialsPath, connString.SpreadsheetId); - await provider.InitializeAsync(); - - var migrationService = new GoogleMigrationService(provider); - - var appliedMigrations = await migrationService.GetAppliedMigrationsAsync(); + var coreAsm = CliHelper.GetCoreAssembly(assembly, loadContext); + var contextType = CliHelper.FindContextType(assembly); - // Cross-context: IsSubclassOf(typeof(Migration)) fails — use string-based check - var migrationTypes = assembly.GetTypes() - .Where(t => CliHelper.IsSubclassOf(t, "Sheetly.Core.Migration.Migration") && !t.IsAbstract) - .Select(t => new { Type = t, MigrationId = CliHelper.GetMigrationAttributeId(t) }) - .Where(x => x.MigrationId != null) - .OrderBy(x => x.MigrationId) - .ToList(); + string? connStr = CliHelper.GetConnectionString(CliHelper.FindProjectRootFromDll(dllPath)); - if (migrationTypes.Count == 0) - { - Console.WriteLine("⚠️ No migrations found in the project."); - return; - } + Console.WriteLine("⏳ Applying pending migrations..."); + var json = CliHelper.InvokeDesignTime(coreAsm, "UpdateDatabaseAsync", contextType, connStr); + var doc = CliHelper.ParseResult(json); + if (doc == null) return; - var pendingMigrations = migrationTypes - .Where(x => !appliedMigrations.Contains(x.MigrationId!)) - .ToList(); + var root = doc.RootElement; + int total = root.GetProperty("total").GetInt32(); - if (pendingMigrations.Count == 0) + if (total == 0) { Console.WriteLine("✅ Database is up to date."); return; } - Console.WriteLine($"🚀 Found {pendingMigrations.Count} pending migration(s)."); - - // Cross-context: instantiate snapshot and bridge via JSON - var snapshotType = assembly.GetTypes() - .FirstOrDefault(t => t.Name.EndsWith("ModelSnapshot") && - CliHelper.IsSubclassOf(t, "Sheetly.Core.Migrations.MigrationSnapshot")); - MigrationSnapshot? currentSnapshot = null; - if (snapshotType != null) - { - var isolatedSnap = Activator.CreateInstance(snapshotType); - currentSnapshot = CliHelper.BridgeFromJson(isolatedSnap); - } - - // Load isolated MigrationBuilder once for all pending migrations - var coreAsm = CliHelper.GetCoreAssembly(assembly, loadContext); - var isolatedMbType = coreAsm.GetType("Sheetly.Core.Migrations.MigrationBuilder")!; - - foreach (var pm in pendingMigrations) - { - var migrationId = pm.MigrationId!; - Console.Write($"Applying {migrationId}... "); - - // Cross-context: invoke Up() with isolated MigrationBuilder, then bridge operations via JSON - var migrationObj = Activator.CreateInstance(pm.Type)!; - var builder = Activator.CreateInstance(isolatedMbType)!; - pm.Type.GetMethod("Up")!.Invoke(migrationObj, [builder]); - var isolatedOps = isolatedMbType.GetMethod("GetOperations")!.Invoke(builder, null)!; - var operations = CliHelper.BridgeMigrationOperations(isolatedOps); - - if (currentSnapshot != null) - { - foreach (var op in operations.OfType()) - { - if (!currentSnapshot.Entities.TryGetValue(op.Name, out var entity)) continue; - op.ClassName = entity.ClassName; - foreach (var col in op.Columns) - { - var sc = entity.Columns.FirstOrDefault(c => c.Name == col.Name); - if (sc == null) continue; - col.IsAutoIncrement = sc.IsAutoIncrement; - if (sc.IsPrimaryKey) col.IsUnique = true; - } - } - - foreach (var op in operations.OfType()) - { - if (!currentSnapshot.Entities.TryGetValue(op.Table, out var entity)) continue; - var sc = entity.Columns.FirstOrDefault(c => c.Name == op.Name); - if (sc == null) continue; - op.IsAutoIncrement = sc.IsAutoIncrement; - op.ClassName = entity.ClassName; - if (sc.IsPrimaryKey) op.IsUnique = true; - } - } - - await migrationService.ApplyMigrationAsync(operations, migrationId); - Console.WriteLine("Done."); - } + foreach (var m in root.GetProperty("applied").EnumerateArray()) + Console.WriteLine($" Applied: {m.GetString()}"); - Console.WriteLine("✅ All migrations applied successfully."); + Console.WriteLine($"✅ {total} migration(s) applied successfully."); } catch (Exception ex) { diff --git a/src/Sheetly.CLI/Helpers/CliHelper.cs b/src/Sheetly.CLI/Helpers/CliHelper.cs index a217866..0fa2e16 100644 --- a/src/Sheetly.CLI/Helpers/CliHelper.cs +++ b/src/Sheetly.CLI/Helpers/CliHelper.cs @@ -1,21 +1,12 @@ using Microsoft.Extensions.Configuration; -using Sheetly.Core.Migration; -using Sheetly.Core.Migrations.Operations; using System.Reflection; -using System.Text; using System.Text.Json; -using System.Text.Json.Nodes; namespace Sheetly.CLI.Helpers; public static class CliHelper { - // JSON options used when bridging objects across AssemblyLoadContext boundaries. - // TypeJsonConverter handles System.Type fields (e.g. ClrType on operation classes). - private static readonly JsonSerializerOptions _bridgeOptions = new() - { - Converters = { new TypeJsonConverter() } - }; + private const string DesignTimeType = "Sheetly.Core.Migrations.Design.DesignTimeOperations"; /// Loads the project DLL and its dependencies into an isolated context. internal static (Assembly assembly, ProjectAssemblyLoadContext loadContext) LoadAssemblyIsolated(string dllPath) @@ -34,90 +25,54 @@ internal static Assembly GetCoreAssembly(Assembly userAssembly, ProjectAssemblyL return loadContext.LoadFromAssemblyName(coreRef); } - /// Resolves Sheetly.Google from the project's isolated load context (may be null). - internal static Assembly? GetGoogleAssembly(Assembly userAssembly, ProjectAssemblyLoadContext loadContext) - { - var googleRef = userAssembly.GetReferencedAssemblies().FirstOrDefault(a => a.Name == "Sheetly.Google"); - return googleRef != null ? loadContext.LoadFromAssemblyName(googleRef) : null; - } - /// - /// String-based IsSubclassOf that works across AssemblyLoadContext boundaries - /// (type identity is context-scoped, so reference comparison fails cross-context). + /// Invokes a static method on DesignTimeOperations inside the isolated context. + /// Returns the raw string result (JSON). Only strings cross the boundary. + /// This mirrors EF Core's OperationExecutor pattern. /// - public static bool IsSubclassOf(Type? type, string baseTypeFullName) + internal static string InvokeDesignTime(Assembly coreAsm, string methodName, params object?[] args) { - while (type != null && type != typeof(object)) - { - if (type.FullName == baseTypeFullName) return true; - type = type.BaseType; - } - return false; - } + var designType = coreAsm.GetType(DesignTimeType) + ?? throw new Exception(VersionMismatchMessage(coreAsm, DesignTimeType)); + var method = designType.GetMethod(methodName, BindingFlags.Public | BindingFlags.Static) + ?? throw new Exception(VersionMismatchMessage(coreAsm, methodName)); - /// - /// Produces a human-friendly "update the CLI" message when a reflection lookup fails, - /// indicating that the project's Sheetly.Core version is incompatible with this CLI. - /// - public static string VersionMismatchMessage(Assembly coreAsm, string missingMember) - { - var projectVer = coreAsm.GetName().Version?.ToString() ?? "unknown"; - return $"Incompatible Sheetly.Core version ({projectVer}): member '{missingMember}' not found.\n" + - $"Run: dotnet tool update -g dotnet-sheetly"; - } + var result = method.Invoke(null, args); + // Handle async methods (Task) + if (result is Task task) + { + task.GetAwaiter().GetResult(); + return (string)((dynamic)task).Result; + } - public static string? GetMigrationAttributeId(Type t) - { - var attr = t.GetCustomAttributes(false).FirstOrDefault(a => a.GetType().Name == "MigrationAttribute"); - return attr?.GetType().GetProperty("Id")?.GetValue(attr) as string; + return (string)result!; } /// - /// Serializes an object from the isolated context to JSON and deserializes it - /// into a CLI-side type, crossing the AssemblyLoadContext boundary safely. + /// Finds SheetsContext subclass from the loaded assembly using string-based type check. /// - public static T? BridgeFromJson(object? isolatedObj) where T : class + internal static Type FindContextType(Assembly assembly) { - if (isolatedObj == null) return null; - var json = JsonSerializer.Serialize(isolatedObj, isolatedObj.GetType(), _bridgeOptions); - return JsonSerializer.Deserialize(json, _bridgeOptions); + return assembly.GetExportedTypes().FirstOrDefault(t => IsSubclassOfSheetsContext(t)) + ?? throw new Exception("SheetsContext not found in the project."); } /// - /// Bridges a List<MigrationOperation> across context boundaries. - /// Performs manual dispatch on OperationType because MigrationOperation is abstract. + /// Parses a JSON result string from DesignTimeOperations and prints error if unsuccessful. + /// Returns the parsed JsonDocument, or null on failure. /// - public static List BridgeMigrationOperations(object? isolatedOps) + internal static JsonDocument? ParseResult(string json) { - if (isolatedOps == null) return []; - var json = JsonSerializer.Serialize(isolatedOps, isolatedOps.GetType(), _bridgeOptions); - var elements = JsonSerializer.Deserialize>(json, _bridgeOptions); - if (elements == null) return []; + var doc = JsonDocument.Parse(json); + if (doc.RootElement.GetProperty("success").GetBoolean()) + return doc; - var result = new List(); - foreach (var el in elements) - { - var opType = el["OperationType"]?.GetValue(); - MigrationOperation? op = opType switch - { - "CreateTable" => el.Deserialize(_bridgeOptions), - "AddColumn" => el.Deserialize(_bridgeOptions), - "DropColumn" => el.Deserialize(_bridgeOptions), - "DropTable" => el.Deserialize(_bridgeOptions), - "AlterColumn" => el.Deserialize(_bridgeOptions), - "CreateIndex" => el.Deserialize(_bridgeOptions), - "DropIndex" => el.Deserialize(_bridgeOptions), - "AddCheckConstraint" => el.Deserialize(_bridgeOptions), - "DropCheckConstraint"=> el.Deserialize(_bridgeOptions), - _ => null - }; - if (op != null) result.Add(op); - } - return result; + var error = doc.RootElement.GetProperty("error").GetString(); + Console.WriteLine($"❌ Error: {error}"); + return null; } - public static bool IsSubclassOfSheetsContext(Type? type) { while (type != null && type != typeof(object)) @@ -128,6 +83,16 @@ public static bool IsSubclassOfSheetsContext(Type? type) return false; } + /// + /// Produces a human-friendly "update the CLI" message when a reflection lookup fails. + /// + public static string VersionMismatchMessage(Assembly coreAsm, string missingMember) + { + var projectVer = coreAsm.GetName().Version?.ToString() ?? "unknown"; + return $"Incompatible Sheetly.Core version ({projectVer}): member '{missingMember}' not found.\n" + + $"Run: dotnet tool update -g dotnet-sheetly"; + } + public static string FindProjectDll(bool noBuild, string? manualPath) { if (!string.IsNullOrEmpty(manualPath)) return manualPath; @@ -175,42 +140,4 @@ public static string FindProjectRootFromDll(string dllPath) return config.GetConnectionString("DefaultConnection") ?? config.GetSection("Sheetly")["ConnectionString"]; } - - public static string? GetConnectionStringFromContext(Type contextType) - { - try - { - var context = Activator.CreateInstance(contextType); - var options = new Sheetly.Core.Configuration.SheetsOptions(); - var method = contextType.GetMethod("OnConfiguring", - BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); - method?.Invoke(context, [options]); - return options.ConnectionString; - } - catch { return null; } - } - - public static string GenerateClassCode(EntitySchema entity) - { - var sb = new StringBuilder(); - sb.AppendLine("using System.ComponentModel.DataAnnotations;"); - sb.AppendLine("using System.ComponentModel.DataAnnotations.Schema;"); - sb.AppendLine(); - sb.AppendLine($"namespace {entity.Namespace}.Scaffolded;"); - sb.AppendLine(); - sb.AppendLine($"[Table(\"{entity.TableName}\")]"); - sb.AppendLine($"public class {entity.ClassName}"); - sb.AppendLine("{"); - foreach (var col in entity.Columns) - { - if (col.IsPrimaryKey) sb.AppendLine(" [Key]"); - if (col.IsForeignKey) sb.AppendLine($" [ForeignKey(\"{col.ForeignKeyTable}\")]"); - var type = col.DataType; - if (col.IsNullable && type != "String" && !type.EndsWith("?")) type += "?"; - sb.AppendLine($" public {type} {col.PropertyName} {{ get; set; }}"); - sb.AppendLine(); - } - sb.AppendLine("}"); - return sb.ToString(); - } } \ No newline at end of file diff --git a/src/Sheetly.CLI/Helpers/TypeJsonConverter.cs b/src/Sheetly.CLI/Helpers/TypeJsonConverter.cs deleted file mode 100644 index 1788bae..0000000 --- a/src/Sheetly.CLI/Helpers/TypeJsonConverter.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Sheetly.CLI.Helpers; - -/// -/// Serializes System.Type as its AssemblyQualifiedName so that MigrationOperation -/// fields like ClrType survive JSON round-tripping across AssemblyLoadContext boundaries. -/// -internal sealed class TypeJsonConverter : JsonConverter -{ - public override Type? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - var name = reader.GetString(); - if (string.IsNullOrEmpty(name)) return typeof(string); - // AssemblyQualifiedName resolves for all BCL types - return Type.GetType(name) ?? typeof(string); - } - - public override void Write(Utf8JsonWriter writer, Type? value, JsonSerializerOptions options) - { - writer.WriteStringValue(value?.AssemblyQualifiedName ?? typeof(string).AssemblyQualifiedName); - } -} diff --git a/src/Sheetly.CLI/Sheetly.CLI.csproj b/src/Sheetly.CLI/Sheetly.CLI.csproj index 6d1ab7a..2d40622 100644 --- a/src/Sheetly.CLI/Sheetly.CLI.csproj +++ b/src/Sheetly.CLI/Sheetly.CLI.csproj @@ -30,11 +30,6 @@ - - - - - diff --git a/src/Sheetly.Core/Migrations/Design/DesignTimeOperations.cs b/src/Sheetly.Core/Migrations/Design/DesignTimeOperations.cs new file mode 100644 index 0000000..0944642 --- /dev/null +++ b/src/Sheetly.Core/Migrations/Design/DesignTimeOperations.cs @@ -0,0 +1,519 @@ +using Sheetly.Core.Abstractions; +using Sheetly.Core.Configuration; +using Sheetly.Core.Infrastructure; +using Sheetly.Core.Migration; +using Sheetly.Core.Migrations.Operations; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace Sheetly.Core.Migrations.Design; + +/// +/// Entry point for design-time operations invoked by the CLI tool. +/// All operations execute within the project's own AssemblyLoadContext, +/// so no Sheetly types cross the context boundary — only JSON strings. +/// This mirrors EF Core's OperationExecutor pattern. +/// +public static class DesignTimeOperations +{ + /// + /// Creates a new migration. Writes files to disk. + /// Returns JSON: { "success":true, "migrationFile":"...", "snapshotFile":"...", "operations":["CreateTable",...] } + /// Or: { "success":false, "error":"..." } + /// + public static string AddMigration(Type contextType, string name, string? outputDir) + { + try + { + var context = Activator.CreateInstance(contextType)!; + + outputDir ??= "Migrations"; + var modelBuilder = new ModelBuilder(); + InvokeOnModelCreating(contextType, context, modelBuilder); + + var currentSnapshot = SnapshotBuilder.BuildFromContext(contextType, modelBuilder.GetMetadata()); + + string contextProjectDir = FindProjectRootFromDll(contextType.Assembly.Location); + string finalPath = Path.Combine(contextProjectDir, outputDir); + Directory.CreateDirectory(finalPath); + + MigrationSnapshot? previousSnapshot = LoadExistingSnapshot(contextType); + + var modelDiffer = new ModelDiffer(); + var operations = modelDiffer.GetDifferences(previousSnapshot, currentSnapshot); + + if (operations.Count == 0) + return Error("No changes detected in the model."); + + var existingMigration = Directory.GetFiles(finalPath, "*.cs") + .Where(f => !f.Contains("ModelSnapshot")) + .FirstOrDefault(f => Path.GetFileNameWithoutExtension(f) + .EndsWith($"_{name}", StringComparison.OrdinalIgnoreCase)); + + if (existingMigration != null) + return Error($"A migration named '{name}' already exists: '{Path.GetFileName(existingMigration)}'"); + + string timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss"); + string migrationId = $"{timestamp}_{name}"; + string targetNamespace = $"{contextType.Namespace}.Migrations"; + + var generator = new CSharpMigrationGenerator(); + string migrationCode = generator.GenerateMigration(name, migrationId, targetNamespace, operations); + string csharpFileName = $"{migrationId}.cs"; + File.WriteAllText(Path.Combine(finalPath, csharpFileName), migrationCode); + + string contextName = contextType.Name.Replace("Context", ""); + var snapshotGenerator = new ModelSnapshotGenerator(); + string snapshotCode = snapshotGenerator.GenerateModelSnapshot( + currentSnapshot, targetNamespace, contextName); + string snapshotFileName = $"{contextName}ModelSnapshot.cs"; + File.WriteAllText(Path.Combine(finalPath, snapshotFileName), snapshotCode); + + return JsonSerializer.Serialize(new + { + success = true, + migrationFile = csharpFileName, + snapshotFile = snapshotFileName, + operations = operations.Select(o => o.OperationType).ToArray() + }); + } + catch (Exception ex) + { + return Error(ex.InnerException?.Message ?? ex.Message); + } + } + + /// + /// Removes the last migration. Reverts snapshot and deletes migration file. + /// Returns JSON: { "success":true, "removedFile":"...", "snapshotFile":"..." } + /// + public static string RemoveMigration(Type contextType) + { + try + { + string contextProjectDir = FindProjectRootFromDll(contextType.Assembly.Location); + string migrationsDir = Path.Combine(contextProjectDir, "Migrations"); + + if (!Directory.Exists(migrationsDir)) + return Error("Migrations directory not found."); + + var migrationTypes = contextType.Assembly.GetExportedTypes() + .Select(t => new { Type = t, Attr = t.GetCustomAttribute() }) + .Where(x => x.Attr != null) + .OrderByDescending(x => x.Attr!.Id) + .ToList(); + + if (migrationTypes.Count == 0) + return Error("No migrations to remove."); + + var lastMigrationType = migrationTypes[0].Type; + string migrationId = migrationTypes[0].Attr!.Id; + + var migrationFile = Directory.GetFiles(migrationsDir, "*.cs") + .FirstOrDefault(f => !f.Contains("ModelSnapshot") && + Path.GetFileNameWithoutExtension(f) == migrationId); + + if (migrationFile == null) + return Error($"Migration file for '{migrationId}' not found."); + + string contextName = contextType.Name.Replace("Context", ""); + string snapshotClassName = $"{contextName}ModelSnapshot"; + string targetNamespace = $"{contextType.Namespace}.Migrations"; + + var snapshotType = contextType.Assembly.GetExportedTypes() + .FirstOrDefault(t => t.Name == snapshotClassName && t.Namespace == targetNamespace) + ?? throw new Exception($"ModelSnapshot class '{snapshotClassName}' not found."); + + var currentSnapshot = (MigrationSnapshot?)Activator.CreateInstance(snapshotType) + ?? throw new Exception("Failed to instantiate ModelSnapshot."); + + var lastMigration = (Migration)Activator.CreateInstance(lastMigrationType)!; + var downBuilder = new MigrationBuilder(); + lastMigration.Down(downBuilder); + + var revertedSnapshot = RevertSnapshot(currentSnapshot, downBuilder.GetOperations()); + + var snapshotGenerator = new ModelSnapshotGenerator(); + string snapshotCode = snapshotGenerator.GenerateModelSnapshot(revertedSnapshot, targetNamespace, contextName); + string snapshotFilePath = Path.Combine(migrationsDir, $"{snapshotClassName}.cs"); + + File.Delete(migrationFile); + File.WriteAllText(snapshotFilePath, snapshotCode); + + return JsonSerializer.Serialize(new + { + success = true, + removedFile = Path.GetFileName(migrationFile), + snapshotFile = $"{snapshotClassName}.cs" + }); + } + catch (Exception ex) + { + return Error(ex.InnerException?.Message ?? ex.Message); + } + } + + /// + /// Gets a text schema from the latest snapshot. + /// Returns JSON: { "success":true, "script":"..." } + /// + public static string GetSchemaScript(Type contextType) + { + try + { + var snapshotType = contextType.Assembly.GetTypes() + .FirstOrDefault(t => t.Name.EndsWith("ModelSnapshot") && t.IsSubclassOf(typeof(MigrationSnapshot))); + + if (snapshotType == null) + return Error("Snapshot not found. Run 'migrations add' first."); + + var snapshot = (MigrationSnapshot)Activator.CreateInstance(snapshotType)!; + var sb = new StringBuilder(); + sb.AppendLine($"--- Sheetly Schema Script (Generated at {DateTime.Now}) ---"); + + foreach (var entity in snapshot.Entities.Values) + { + sb.AppendLine($"Sheet: {entity.TableName}"); + foreach (var col in entity.Columns) + { + string pk = col.IsPrimaryKey ? " [PK]" : ""; + string fk = col.IsForeignKey ? $" [FK → {col.ForeignKeyTable}]" : ""; + string req = col.IsRequired ? " [Required]" : ""; + sb.AppendLine($" - {col.PropertyName} ({col.DataType}){pk}{fk}{req}"); + } + sb.AppendLine(); + } + + return JsonSerializer.Serialize(new { success = true, script = sb.ToString() }); + } + catch (Exception ex) + { + return Error(ex.InnerException?.Message ?? ex.Message); + } + } + + /// + /// Applies all pending migrations using the context's configured provider. + /// Returns JSON: { "success":true, "applied":["20240101_Init",...], "total":2 } + /// + public static async Task UpdateDatabaseAsync(Type contextType, string? connectionString = null) + { + try + { + var (provider, migrationService) = CreateProviderFromContext(contextType, connectionString); + await provider.InitializeAsync(); + + var facade = new DatabaseFacade(provider, migrationService, contextType); + var pending = await facade.GetPendingMigrationsAsync(); + + if (pending.Count == 0) + return JsonSerializer.Serialize(new { success = true, applied = Array.Empty(), total = 0, message = "Database is up to date." }); + + await facade.MigrateAsync(); + + return JsonSerializer.Serialize(new { success = true, applied = pending, total = pending.Count }); + } + catch (Exception ex) + { + return Error(ex.InnerException?.Message ?? ex.Message); + } + } + + /// + /// Drops the database (clears all sheets). + /// Returns JSON: { "success":true } + /// + public static async Task DropDatabaseAsync(Type contextType, string? connectionString = null) + { + try + { + var (provider, migrationService) = CreateProviderFromContext(contextType, connectionString); + await provider.InitializeAsync(); + + var facade = new DatabaseFacade(provider, migrationService, contextType); + await facade.DropDatabaseAsync(); + + return JsonSerializer.Serialize(new { success = true }); + } + catch (Exception ex) + { + return Error(ex.InnerException?.Message ?? ex.Message); + } + } + + /// + /// Scaffolds model classes from the remote provider's migration history. + /// Returns JSON: { "success":true, "files":["Product.cs","Category.cs"] } + /// + public static async Task ScaffoldAsync(Type contextType, string? outputDir, string? connectionString = null) + { + try + { + var (provider, _) = CreateProviderFromContext(contextType, connectionString); + await provider.InitializeAsync(); + + var rows = await provider.GetAllRowsAsync("__SheetlyHistory__"); + if (rows.Count <= 1) + return Error("Migration history not found."); + + var snapshotJson = rows.Last()[2].ToString()!; + var snapshot = JsonSerializer.Deserialize(snapshotJson)!; + + string contextProjectDir = FindProjectRootFromDll(contextType.Assembly.Location); + string finalPath = Path.Combine(contextProjectDir, outputDir ?? "Models/Scaffolded"); + Directory.CreateDirectory(finalPath); + + var files = new List(); + foreach (var entity in snapshot.Entities.Values) + { + var code = GenerateClassCode(entity); + var fileName = $"{entity.ClassName}.cs"; + File.WriteAllText(Path.Combine(finalPath, fileName), code); + files.Add(fileName); + } + + return JsonSerializer.Serialize(new { success = true, files }); + } + catch (Exception ex) + { + return Error(ex.InnerException?.Message ?? ex.Message); + } + } + + #region Private helpers + + private static void InvokeOnModelCreating(Type contextType, object context, ModelBuilder modelBuilder) + { + var method = contextType.GetMethod("OnModelCreating", + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + method?.Invoke(context, [modelBuilder]); + } + + private static MigrationSnapshot? LoadExistingSnapshot(Type contextType) + { + string contextName = contextType.Name.Replace("Context", ""); + string snapshotClassName = $"{contextName}ModelSnapshot"; + var snapshotType = contextType.Assembly.GetExportedTypes() + .FirstOrDefault(t => t.Name == snapshotClassName && + t.Namespace == $"{contextType.Namespace}.Migrations"); + if (snapshotType == null) return null; + return Activator.CreateInstance(snapshotType) as MigrationSnapshot; + } + + /// + /// Creates the ISheetsProvider by calling the context's OnConfiguring, + /// just like EF Core creates the DbConnection from the DbContext configuration. + /// Works with any provider (Google Sheets, Excel, or future implementations). + /// + private static (ISheetsProvider provider, IMigrationService? migrationService) CreateProviderFromContext( + Type contextType, string? connectionString) + { + var context = Activator.CreateInstance(contextType)!; + var options = new SheetsOptions(); + + if (!string.IsNullOrEmpty(connectionString)) + options.ConnectionString = connectionString; + + var method = contextType.GetMethod("OnConfiguring", + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + method?.Invoke(context, [options]); + + var provider = options.Provider + ?? throw new InvalidOperationException( + "ISheetsProvider not configured. Call UseGoogleSheets or UseExcel in OnConfiguring."); + + return (provider, options.MigrationService); + } + + private static string FindProjectRootFromDll(string dllPath) + { + var dir = new DirectoryInfo(Path.GetDirectoryName(dllPath)!); + while (dir != null && !dir.GetFiles("*.csproj").Any()) + dir = dir.Parent; + return dir?.FullName ?? Path.GetDirectoryName(dllPath)!; + } + + private static string Error(string message) + => JsonSerializer.Serialize(new { success = false, error = message }); + + private static MigrationSnapshot RevertSnapshot(MigrationSnapshot current, List downOps) + { + var entities = current.Entities + .ToDictionary(kvp => kvp.Key, kvp => CloneEntity(kvp.Value)); + + foreach (var op in downOps) + { + switch (op) + { + case DropColumnOperation drop: + if (entities.TryGetValue(drop.Table, out var eDrop)) + eDrop.Columns.RemoveAll(c => c.Name == drop.Name); + break; + + case AddColumnOperation add: + if (entities.TryGetValue(add.Table, out var eAdd)) + eAdd.Columns.Add(new ColumnSchema + { + Name = add.Name, + PropertyName = add.Name, + DataType = add.ClrType.Name, + IsNullable = add.IsNullable, + IsRequired = add.IsRequired, + IsPrimaryKey = add.IsPrimaryKey, + IsAutoIncrement = add.IsPrimaryKey, + IsForeignKey = add.IsForeignKey, + ForeignKeyTable = add.ForeignKeyTable, + ForeignKeyColumn = add.ForeignKeyColumn, + IsUnique = add.IsUnique, + MaxLength = add.MaxLength, + MinLength = add.MinLength, + DefaultValue = add.DefaultValue, + CheckConstraint = add.CheckConstraint, + IsComputed = add.IsComputed, + ComputedColumnSql = add.ComputedColumnSql, + IsConcurrencyToken = add.IsConcurrencyToken, + Comment = add.Comment + }); + break; + + case DropTableOperation dropTable: + entities.Remove(dropTable.Name); + break; + + case CreateTableOperation createTable: + entities[createTable.Name] = new EntitySchema + { + TableName = createTable.Name, + ClassName = createTable.ClassName ?? createTable.Name, + Columns = createTable.Columns.Select(c => new ColumnSchema + { + Name = c.Name, + PropertyName = c.Name, + DataType = c.ClrType.Name, + IsNullable = c.IsNullable, + IsRequired = c.IsRequired, + IsPrimaryKey = c.IsPrimaryKey, + IsAutoIncrement = c.IsPrimaryKey, + IsForeignKey = c.IsForeignKey, + ForeignKeyTable = c.ForeignKeyTable, + ForeignKeyColumn = c.ForeignKeyColumn + }).ToList(), + Relationships = [] + }; + break; + + case AlterColumnOperation alter: + if (entities.TryGetValue(alter.Table, out var eAlter)) + { + var col = eAlter.Columns.FirstOrDefault(c => c.Name == alter.Name); + if (col != null) + { + if (alter.ClrType != null) col.DataType = alter.ClrType.Name; + if (alter.IsNullable.HasValue) col.IsNullable = alter.IsNullable.Value; + if (alter.MaxLength.HasValue) col.MaxLength = alter.MaxLength; + if (alter.DefaultValue != null) col.DefaultValue = alter.DefaultValue; + } + } + break; + } + } + + return new MigrationSnapshot + { + Entities = entities, + Version = current.Version, + LastUpdated = DateTime.UtcNow, + ModelHash = CalculateHash(entities) + }; + } + + private static EntitySchema CloneEntity(EntitySchema src) => new() + { + TableName = src.TableName, + ClassName = src.ClassName, + Namespace = src.Namespace, + Columns = src.Columns.Select(c => new ColumnSchema + { + Name = c.Name, + PropertyName = c.PropertyName, + DataType = c.DataType, + IsNullable = c.IsNullable, + IsRequired = c.IsRequired, + IsPrimaryKey = c.IsPrimaryKey, + IsAutoIncrement = c.IsAutoIncrement, + IsForeignKey = c.IsForeignKey, + ForeignKeyTable = c.ForeignKeyTable, + ForeignKeyColumn = c.ForeignKeyColumn, + IsUnique = c.IsUnique, + IndexName = c.IndexName, + MaxLength = c.MaxLength, + MinLength = c.MinLength, + DefaultValue = c.DefaultValue, + DefaultValueSql = c.DefaultValueSql, + MinValue = c.MinValue, + MaxValue = c.MaxValue, + Precision = c.Precision, + Scale = c.Scale, + CheckConstraint = c.CheckConstraint, + IsComputed = c.IsComputed, + ComputedColumnSql = c.ComputedColumnSql, + IsStored = c.IsStored, + IsConcurrencyToken = c.IsConcurrencyToken, + Comment = c.Comment + }).ToList(), + Relationships = src.Relationships.ToList() + }; + + private static string CalculateHash(Dictionary entities) + { + var structural = entities + .OrderBy(e => e.Key) + .ToDictionary( + e => e.Key, + e => new + { + e.Value.TableName, + Columns = e.Value.Columns.Select(c => new + { + c.Name, + c.DataType, + c.IsPrimaryKey, + c.IsAutoIncrement, + c.IsForeignKey, + c.ForeignKeyTable, + c.ForeignKeyColumn + }).ToList() + }); + + var json = JsonSerializer.Serialize(structural, new JsonSerializerOptions { WriteIndented = false }); + return Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(json))); + } + + private static string GenerateClassCode(EntitySchema entity) + { + var sb = new StringBuilder(); + sb.AppendLine("using System.ComponentModel.DataAnnotations;"); + sb.AppendLine("using System.ComponentModel.DataAnnotations.Schema;"); + sb.AppendLine(); + sb.AppendLine($"namespace {entity.Namespace}.Scaffolded;"); + sb.AppendLine(); + sb.AppendLine($"[Table(\"{entity.TableName}\")]"); + sb.AppendLine($"public class {entity.ClassName}"); + sb.AppendLine("{"); + foreach (var col in entity.Columns) + { + if (col.IsPrimaryKey) sb.AppendLine(" [Key]"); + if (col.IsForeignKey) sb.AppendLine($" [ForeignKey(\"{col.ForeignKeyTable}\")]"); + var type = col.DataType; + if (col.IsNullable && type != "String" && !type.EndsWith("?")) type += "?"; + sb.AppendLine($" public {type} {col.PropertyName} {{ get; set; }}"); + sb.AppendLine(); + } + sb.AppendLine("}"); + return sb.ToString(); + } + + #endregion +} From c460bbbe42c92329c98668f68f725de3994ea504 Mon Sep 17 00:00:00 2001 From: muqimjon Date: Sat, 28 Feb 2026 12:34:29 +0500 Subject: [PATCH 26/36] fix: scaffold reads snapshot from assembly + remove ClassName comment from generated migrations --- .gitignore | 3 ++ .../Design/CSharpMigrationGenerator.cs | 26 +++------------- .../Migrations/Design/DesignTimeOperations.cs | 31 ++++++++----------- 3 files changed, 21 insertions(+), 39 deletions(-) diff --git a/.gitignore b/.gitignore index cae53aa..5f6920a 100644 --- a/.gitignore +++ b/.gitignore @@ -449,3 +449,6 @@ nupkg/ build.log nupkg/ + +# Sheetly Excel data files +*.xlsx diff --git a/src/Sheetly.Core/Migrations/Design/CSharpMigrationGenerator.cs b/src/Sheetly.Core/Migrations/Design/CSharpMigrationGenerator.cs index 3c72e93..15c3b77 100644 --- a/src/Sheetly.Core/Migrations/Design/CSharpMigrationGenerator.cs +++ b/src/Sheetly.Core/Migrations/Design/CSharpMigrationGenerator.cs @@ -27,28 +27,23 @@ public string GenerateMigration( { var sb = new StringBuilder(); - // Using statements sb.AppendLine("using Sheetly.Core.Migrations;"); sb.AppendLine("using Sheetly.Core.Migrations.Operations;"); sb.AppendLine(); - // Namespace sb.AppendLine($"namespace {targetNamespace};"); sb.AppendLine(); - // Migration attribute sb.AppendLine($"[Migration(\"{migrationId}\")]"); sb.AppendLine($"public partial class {SanitizeClassName(migrationName)} : Migration"); sb.AppendLine("{"); - // Up method sb.AppendLine($"{Indent}public override void Up(MigrationBuilder builder)"); sb.AppendLine($"{Indent}{{"); GenerateOperations(sb, operations, Indent + Indent); sb.AppendLine($"{Indent}}}"); sb.AppendLine(); - // Down method sb.AppendLine($"{Indent}public override void Down(MigrationBuilder builder)"); sb.AppendLine($"{Indent}{{"); GenerateReverseOperations(sb, operations, Indent + Indent); @@ -103,12 +98,6 @@ private void GenerateOperations(StringBuilder sb, List opera private void GenerateCreateTable(StringBuilder sb, CreateTableOperation operation, string indent) { - // Add ClassName as comment for scaffolding support (Sheetly-specific) - if (!string.IsNullOrEmpty(operation.ClassName)) - { - sb.AppendLine($"{indent}// ClassName: {operation.ClassName}"); - } - sb.AppendLine($"{indent}builder.CreateTable(\"{operation.Name}\", table => table"); for (int i = 0; i < operation.Columns.Count; i++) @@ -127,7 +116,6 @@ private void GenerateColumn(StringBuilder sb, AddColumnOperation column, string var typeName = GetTypeName(column.ClrType); var chain = new List(); - // Build fluent chain - order matters for readability if (column.IsPrimaryKey) chain.Add(".IsPrimaryKey()"); else if (!column.IsNullable) @@ -147,7 +135,7 @@ private void GenerateColumn(StringBuilder sb, AddColumnOperation column, string chain.Add($".HasPrecision({column.Precision.Value})"); } - if (column.DefaultValue != null) + if (column.DefaultValue is not null) chain.Add($".HasDefaultValue({FormatValue(column.DefaultValue)})"); if (!string.IsNullOrEmpty(column.CheckConstraint)) @@ -210,7 +198,7 @@ private void GenerateAddColumn(StringBuilder sb, AddColumnOperation column, stri chain.Add($".HasPrecision({column.Precision.Value})"); } - if (column.DefaultValue != null) + if (column.DefaultValue is not null) chain.Add($".HasDefaultValue({FormatValue(column.DefaultValue)})"); if (!string.IsNullOrEmpty(column.CheckConstraint)) @@ -246,7 +234,7 @@ private void GenerateAlterColumn(StringBuilder sb, AlterColumnOperation operatio { var chain = new List(); - if (operation.ClrType != null) + if (operation.ClrType is not null) chain.Add($".HasType<{GetTypeName(operation.ClrType)}>()"); if (operation.IsNullable.HasValue) @@ -255,7 +243,7 @@ private void GenerateAlterColumn(StringBuilder sb, AlterColumnOperation operatio if (operation.MaxLength.HasValue) chain.Add($".HasMaxLength({operation.MaxLength.Value})"); - if (operation.DefaultValue != null) + if (operation.DefaultValue is not null) chain.Add($".HasDefaultValue({FormatValue(operation.DefaultValue)})"); sb.AppendLine($"{indent}builder.AlterColumn(\"{operation.Table}\", \"{operation.Name}\", c => c{string.Join("", chain)});"); @@ -284,7 +272,6 @@ private void GenerateCreateIndex(StringBuilder sb, CreateIndexOperation operatio private void GenerateReverseOperations(StringBuilder sb, List operations, string indent) { - // Generate reverse operations in reverse order var reversed = new List(operations); reversed.Reverse(); @@ -326,7 +313,7 @@ private void GenerateReverseOperations(StringBuilder sb, List 0 && !char.IsLetter(result[0])) result.Insert(0, '_'); @@ -376,7 +361,6 @@ private static string SanitizeClassName(string name) private static string EscapeString(string value) { - // Escape quotes and backslashes for C# string literals return value.Replace("\\", "\\\\").Replace("\"", "\\\""); } } diff --git a/src/Sheetly.Core/Migrations/Design/DesignTimeOperations.cs b/src/Sheetly.Core/Migrations/Design/DesignTimeOperations.cs index 0944642..fda60c7 100644 --- a/src/Sheetly.Core/Migrations/Design/DesignTimeOperations.cs +++ b/src/Sheetly.Core/Migrations/Design/DesignTimeOperations.cs @@ -52,7 +52,7 @@ public static string AddMigration(Type contextType, string name, string? outputD .FirstOrDefault(f => Path.GetFileNameWithoutExtension(f) .EndsWith($"_{name}", StringComparison.OrdinalIgnoreCase)); - if (existingMigration != null) + if (existingMigration is not null) return Error($"A migration named '{name}' already exists: '{Path.GetFileName(existingMigration)}'"); string timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss"); @@ -101,7 +101,7 @@ public static string RemoveMigration(Type contextType) var migrationTypes = contextType.Assembly.GetExportedTypes() .Select(t => new { Type = t, Attr = t.GetCustomAttribute() }) - .Where(x => x.Attr != null) + .Where(x => x.Attr is not null) .OrderByDescending(x => x.Attr!.Id) .ToList(); @@ -115,7 +115,7 @@ public static string RemoveMigration(Type contextType) .FirstOrDefault(f => !f.Contains("ModelSnapshot") && Path.GetFileNameWithoutExtension(f) == migrationId); - if (migrationFile == null) + if (migrationFile is null) return Error($"Migration file for '{migrationId}' not found."); string contextName = contextType.Name.Replace("Context", ""); @@ -166,7 +166,7 @@ public static string GetSchemaScript(Type contextType) var snapshotType = contextType.Assembly.GetTypes() .FirstOrDefault(t => t.Name.EndsWith("ModelSnapshot") && t.IsSubclassOf(typeof(MigrationSnapshot))); - if (snapshotType == null) + if (snapshotType is null) return Error("Snapshot not found. Run 'migrations add' first."); var snapshot = (MigrationSnapshot)Activator.CreateInstance(snapshotType)!; @@ -251,15 +251,9 @@ public static async Task ScaffoldAsync(Type contextType, string? outputD { try { - var (provider, _) = CreateProviderFromContext(contextType, connectionString); - await provider.InitializeAsync(); - - var rows = await provider.GetAllRowsAsync("__SheetlyHistory__"); - if (rows.Count <= 1) - return Error("Migration history not found."); - - var snapshotJson = rows.Last()[2].ToString()!; - var snapshot = JsonSerializer.Deserialize(snapshotJson)!; + var snapshot = LoadExistingSnapshot(contextType); + if (snapshot is null || snapshot.Entities.Count == 0) + return Error("No model snapshot found. Ensure migrations have been created and the project is built."); string contextProjectDir = FindProjectRootFromDll(contextType.Assembly.Location); string finalPath = Path.Combine(contextProjectDir, outputDir ?? "Models/Scaffolded"); @@ -274,6 +268,7 @@ public static async Task ScaffoldAsync(Type contextType, string? outputD files.Add(fileName); } + await Task.CompletedTask; return JsonSerializer.Serialize(new { success = true, files }); } catch (Exception ex) @@ -298,7 +293,7 @@ private static void InvokeOnModelCreating(Type contextType, object context, Mode var snapshotType = contextType.Assembly.GetExportedTypes() .FirstOrDefault(t => t.Name == snapshotClassName && t.Namespace == $"{contextType.Namespace}.Migrations"); - if (snapshotType == null) return null; + if (snapshotType is null) return null; return Activator.CreateInstance(snapshotType) as MigrationSnapshot; } @@ -330,7 +325,7 @@ private static (ISheetsProvider provider, IMigrationService? migrationService) C private static string FindProjectRootFromDll(string dllPath) { var dir = new DirectoryInfo(Path.GetDirectoryName(dllPath)!); - while (dir != null && !dir.GetFiles("*.csproj").Any()) + while (dir is not null && !dir.GetFiles("*.csproj").Any()) dir = dir.Parent; return dir?.FullName ?? Path.GetDirectoryName(dllPath)!; } @@ -408,12 +403,12 @@ private static MigrationSnapshot RevertSnapshot(MigrationSnapshot current, List< if (entities.TryGetValue(alter.Table, out var eAlter)) { var col = eAlter.Columns.FirstOrDefault(c => c.Name == alter.Name); - if (col != null) + if (col is not null) { - if (alter.ClrType != null) col.DataType = alter.ClrType.Name; + if (alter.ClrType is not null) col.DataType = alter.ClrType.Name; if (alter.IsNullable.HasValue) col.IsNullable = alter.IsNullable.Value; if (alter.MaxLength.HasValue) col.MaxLength = alter.MaxLength; - if (alter.DefaultValue != null) col.DefaultValue = alter.DefaultValue; + if (alter.DefaultValue is not null) col.DefaultValue = alter.DefaultValue; } } break; From 597b6fa470aaec674f3e8887cc8ae3713220f622 Mon Sep 17 00:00:00 2001 From: muqimjon Date: Sat, 28 Feb 2026 12:34:40 +0500 Subject: [PATCH 27/36] refactor: remove inline comments, modernize null checks (is null/is not null) --- src/Sheetly.Core/Design/ContextResolver.cs | 8 +- .../Infrastructure/DatabaseFacade.cs | 17 +- src/Sheetly.Core/Mapping/EntityMapper.cs | 12 +- .../Design/ModelSnapshotGenerator.cs | 10 +- .../Migrations/MigrationBuilder.cs | 282 ++++++++++++++++++ src/Sheetly.Core/Migrations/ModelDiffer.cs | 12 +- .../Migrations/SnapshotBuilder.cs | 14 +- src/Sheetly.Core/SheetsContext.cs | 47 ++- src/Sheetly.Core/SheetsSet.cs | 32 +- .../Validation/ConstraintValidator.cs | 2 +- .../Rules/CheckConstraintValidator.cs | 19 +- .../Validation/Rules/DataTypeValidator.cs | 12 +- .../Validation/Rules/ForeignKeyValidator.cs | 15 +- .../Validation/Rules/MaxLengthValidator.cs | 4 +- .../Validation/Rules/MinLengthValidator.cs | 5 +- .../Validation/Rules/NullabilityValidator.cs | 8 +- .../Validation/Rules/PrimaryKeyValidator.cs | 12 +- .../Validation/Rules/RangeValidator.cs | 8 +- .../Validation/Rules/UniqueValidator.cs | 9 +- .../Validation/ValidationResult.cs | 2 +- .../Extensions/ServiceCollectionExtensions.cs | 6 +- src/Sheetly.Excel/ExcelMigrationService.cs | 4 +- src/Sheetly.Excel/ExcelSheetProvider.cs | 16 +- src/Sheetly.Google/GoogleMigrationService.cs | 76 +++-- src/Sheetly.Google/GoogleSheetProvider.cs | 24 +- 25 files changed, 438 insertions(+), 218 deletions(-) create mode 100644 src/Sheetly.Core/Migrations/MigrationBuilder.cs diff --git a/src/Sheetly.Core/Design/ContextResolver.cs b/src/Sheetly.Core/Design/ContextResolver.cs index 46e0b84..34c8c7e 100644 --- a/src/Sheetly.Core/Design/ContextResolver.cs +++ b/src/Sheetly.Core/Design/ContextResolver.cs @@ -1,4 +1,4 @@ -using System.Reflection; +using System.Reflection; namespace Sheetly.Core.Design; @@ -10,7 +10,7 @@ public static SheetsContext CreateContextFromAssembly(Assembly assembly, string[ !t.IsInterface && !t.IsAbstract && t.GetInterfaces().Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IDesignTimeSheetsContextFactory<>))); - if (factoryType != null) + if (factoryType is not null) { var factory = Activator.CreateInstance(factoryType); var method = factoryType.GetMethod("CreateDbContext"); @@ -18,9 +18,9 @@ public static SheetsContext CreateContextFromAssembly(Assembly assembly, string[ } var contextType = assembly.GetTypes().FirstOrDefault(t => - t.BaseType != null && (t.BaseType.Name == "SheetsContext" || t.BaseType.Name.Contains("SheetsContext")) && !t.IsAbstract); + t.BaseType is not null && (t.BaseType.Name == "SheetsContext" || t.BaseType.Name.Contains("SheetsContext")) && !t.IsAbstract); - if (contextType == null) throw new Exception("Project does not contain a class inheriting from SheetsContext."); + if (contextType is null) throw new Exception("Project does not contain a class inheriting from SheetsContext."); return (SheetsContext)Activator.CreateInstance(contextType)!; } diff --git a/src/Sheetly.Core/Infrastructure/DatabaseFacade.cs b/src/Sheetly.Core/Infrastructure/DatabaseFacade.cs index 2b49137..0a4a53d 100644 --- a/src/Sheetly.Core/Infrastructure/DatabaseFacade.cs +++ b/src/Sheetly.Core/Infrastructure/DatabaseFacade.cs @@ -24,7 +24,7 @@ public DatabaseFacade(ISheetsProvider provider, IMigrationService? migrationServ /// public async Task MigrateAsync() { - if (_migrationService == null) + if (_migrationService is null) throw new InvalidOperationException("MigrationService is not configured. Ensure UseGoogleSheets is called in OnConfiguring."); var assembly = _contextType.Assembly; @@ -33,17 +33,16 @@ public async Task MigrateAsync() var migrationTypes = assembly.GetTypes() .Where(t => t.IsSubclassOf(typeof(Migrations.Migration)) && !t.IsAbstract) .Select(t => new { Type = t, Attr = t.GetCustomAttribute() }) - .Where(x => x.Attr != null) + .Where(x => x.Attr is not null) .OrderBy(x => x.Attr!.Id) .ToList(); var pending = migrationTypes.Where(x => !applied.Contains(x.Attr!.Id)).ToList(); if (pending.Count == 0) return; - // Load snapshot for enriching operations with ClassName/IsAutoIncrement var snapshotType = assembly.GetTypes() .FirstOrDefault(t => t.Name.EndsWith("ModelSnapshot") && t.IsSubclassOf(typeof(MigrationSnapshot))); - var snapshot = snapshotType != null + var snapshot = snapshotType is not null ? (MigrationSnapshot?)Activator.CreateInstance(snapshotType) : null; @@ -61,7 +60,7 @@ public async Task MigrateAsync() public async Task> GetPendingMigrationsAsync() { - if (_migrationService == null) return []; + if (_migrationService is null) return []; var assembly = _contextType.Assembly; var applied = await _migrationService.GetAppliedMigrationsAsync(); @@ -69,7 +68,7 @@ public async Task> GetPendingMigrationsAsync() return assembly.GetTypes() .Where(t => t.IsSubclassOf(typeof(Migrations.Migration)) && !t.IsAbstract) .Select(t => t.GetCustomAttribute()?.Id) - .Where(id => id != null && !applied.Contains(id)) + .Where(id => id is not null && !applied.Contains(id)) .Cast() .OrderBy(id => id) .ToList(); @@ -82,7 +81,7 @@ public async Task DropDatabaseAsync() private static void EnrichOperations(List operations, MigrationSnapshot? snapshot) { - if (snapshot == null) return; + if (snapshot is null) return; foreach (var op in operations.OfType()) { @@ -92,7 +91,7 @@ private static void EnrichOperations(List operations, Migrat foreach (var col in op.Columns) { var snapshotCol = entity.Columns.FirstOrDefault(c => c.Name == col.Name); - if (snapshotCol == null) continue; + if (snapshotCol is null) continue; col.IsAutoIncrement = snapshotCol.IsAutoIncrement; if (snapshotCol.IsPrimaryKey) col.IsUnique = true; } @@ -102,7 +101,7 @@ private static void EnrichOperations(List operations, Migrat { if (!snapshot.Entities.TryGetValue(op.Table, out var entity)) continue; var snapshotCol = entity.Columns.FirstOrDefault(c => c.Name == op.Name); - if (snapshotCol == null) continue; + if (snapshotCol is null) continue; op.IsAutoIncrement = snapshotCol.IsAutoIncrement; op.ClassName = entity.ClassName; if (snapshotCol.IsPrimaryKey) op.IsUnique = true; diff --git a/src/Sheetly.Core/Mapping/EntityMapper.cs b/src/Sheetly.Core/Mapping/EntityMapper.cs index 1a09bb9..bb6dedc 100644 --- a/src/Sheetly.Core/Mapping/EntityMapper.cs +++ b/src/Sheetly.Core/Mapping/EntityMapper.cs @@ -1,4 +1,4 @@ -using Sheetly.Core.Attributes; +using Sheetly.Core.Attributes; using Sheetly.Core.Migration; using System.ComponentModel.DataAnnotations; using System.Globalization; @@ -16,8 +16,8 @@ public static string GetColumnName(PropertyInfo prop) public static bool IsPrimaryKey(PropertyInfo prop) { - if (prop.GetCustomAttribute() != null) return true; - if (prop.GetCustomAttribute() != null) return true; + if (prop.GetCustomAttribute() is not null) return true; + if (prop.GetCustomAttribute() is not null) return true; var name = prop.Name.ToLower(); return name == "id" || name == (prop.DeclaringType?.Name.ToLower() + "id"); @@ -38,7 +38,7 @@ public static IList MapToRow(T entity, EntitySchema schema) private static object FormatValueForSheet(object? value) { - if (value == null) return string.Empty; + if (value is null) return string.Empty; if (value is bool b) return b ? "TRUE" : "FALSE"; if (value is DateTime dt) return dt.ToString("O"); if (value is DateTimeOffset dto) return dto.ToString("O"); @@ -53,10 +53,10 @@ private static object FormatValueForSheet(object? value) { var header = actualHeaders[i]; var colSchema = schema.Columns.FirstOrDefault(c => c.Name.Equals(header, StringComparison.OrdinalIgnoreCase)); - if (colSchema != null) + if (colSchema is not null) { var prop = type.GetProperty(colSchema.PropertyName); - if (prop != null && prop.CanWrite && i < row.Count) + if (prop is not null && prop.CanWrite && i < row.Count) { prop.SetValue(entity, ConvertValue(row[i]?.ToString(), prop.PropertyType)); } diff --git a/src/Sheetly.Core/Migrations/Design/ModelSnapshotGenerator.cs b/src/Sheetly.Core/Migrations/Design/ModelSnapshotGenerator.cs index c817b12..2087633 100644 --- a/src/Sheetly.Core/Migrations/Design/ModelSnapshotGenerator.cs +++ b/src/Sheetly.Core/Migrations/Design/ModelSnapshotGenerator.cs @@ -21,20 +21,16 @@ public string GenerateModelSnapshot( { var sb = new StringBuilder(); - // Using statements sb.AppendLine("using System;"); sb.AppendLine("using Sheetly.Core.Migration;"); sb.AppendLine(); - // Namespace sb.AppendLine($"namespace {targetNamespace};"); sb.AppendLine(); - // Class definition with inheritance and constructor sb.AppendLine($"public partial class {contextName}ModelSnapshot : MigrationSnapshot"); sb.AppendLine("{"); - // Constructor sb.AppendLine($"{Indent}public {contextName}ModelSnapshot()"); sb.AppendLine($"{Indent}{{"); sb.AppendLine($"{Indent}{Indent}var snapshot = BuildModel();"); @@ -45,7 +41,6 @@ public string GenerateModelSnapshot( sb.AppendLine($"{Indent}}}"); sb.AppendLine(); - // BuildModel method sb.AppendLine($"{Indent}public static MigrationSnapshot BuildModel()"); sb.AppendLine($"{Indent}{{"); sb.AppendLine($"{Indent}{Indent}var snapshot = new MigrationSnapshot"); @@ -56,7 +51,6 @@ public string GenerateModelSnapshot( sb.AppendLine($"{Indent}{Indent}}};"); sb.AppendLine(); - // Generate entities foreach (var entity in snapshot.Entities.OrderBy(e => e.Key)) { GenerateEntity(sb, entity.Value, Indent + Indent); @@ -89,7 +83,6 @@ private void GenerateEntity(StringBuilder sb, EntitySchema entity, string indent sb.AppendLine($"{indent}{Indent}}},"); - // Relationships if (entity.Relationships.Count > 0) { sb.AppendLine($"{indent}{Indent}Relationships = new List"); @@ -156,7 +149,7 @@ private void GenerateColumn(StringBuilder sb, ColumnSchema column, string indent if (column.MaxValue.HasValue) sb.AppendLine($"{indent}{Indent}MaxValue = {column.MaxValue.Value}m,"); - if (column.DefaultValue != null) + if (column.DefaultValue is not null) sb.AppendLine($"{indent}{Indent}DefaultValue = {FormatValue(column.DefaultValue)},"); if (!string.IsNullOrEmpty(column.CheckConstraint)) @@ -174,7 +167,6 @@ private void GenerateColumn(StringBuilder sb, ColumnSchema column, string indent if (!string.IsNullOrEmpty(column.Comment)) sb.AppendLine($"{indent}{Indent}Comment = \"{EscapeString(column.Comment)}\","); - // Remove trailing comma from last property var lastLine = sb.ToString().TrimEnd(); if (lastLine.EndsWith(",")) { diff --git a/src/Sheetly.Core/Migrations/MigrationBuilder.cs b/src/Sheetly.Core/Migrations/MigrationBuilder.cs new file mode 100644 index 0000000..c38de08 --- /dev/null +++ b/src/Sheetly.Core/Migrations/MigrationBuilder.cs @@ -0,0 +1,282 @@ +using Sheetly.Core.Migrations.Operations; + +namespace Sheetly.Core.Migrations; + +public class MigrationBuilder +{ + private readonly List _operations = new(); + + public MigrationBuilder CreateTable(string name, Action columns) + { + var operation = new CreateTableOperation { Name = name }; + var tableBuilder = new TableBuilder(name, operation.Columns); + columns(tableBuilder); + _operations.Add(operation); + return this; + } + + public MigrationBuilder DropTable(string name) + { + _operations.Add(new DropTableOperation { Name = name }); + return this; + } + + public MigrationBuilder AddColumn(string table, string name, Action? configure = null) + { + var operation = new AddColumnOperation + { + Table = table, + Name = name, + ClrType = typeof(T), + IsNullable = IsNullableType(typeof(T)) + }; + + if (configure is not null) + { + var columnBuilder = new ColumnBuilder(operation); + configure(columnBuilder); + } + + _operations.Add(operation); + return this; + } + + public MigrationBuilder DropColumn(string table, string name) + { + _operations.Add(new DropColumnOperation { Table = table, Name = name }); + return this; + } + + public MigrationBuilder AlterColumn(string table, string name, Action configure) + { + var operation = new AlterColumnOperation { Table = table, Name = name }; + var builder = new AlterColumnBuilder(operation); + configure(builder); + _operations.Add(operation); + return this; + } + + public MigrationBuilder CreateIndex(string name, string table, string[] columns, Action? configure = null) + { + var operation = new CreateIndexOperation + { + Name = name, + Table = table, + Columns = new List(columns) + }; + + if (configure is not null) + { + var builder = new IndexBuilder(operation); + configure(builder); + } + + _operations.Add(operation); + return this; + } + + public MigrationBuilder DropIndex(string name, string table) + { + _operations.Add(new DropIndexOperation { Name = name, Table = table }); + return this; + } + + public MigrationBuilder AddCheckConstraint(string name, string table, string sql) + { + _operations.Add(new AddCheckConstraintOperation { Name = name, Table = table, Sql = sql }); + return this; + } + + public MigrationBuilder DropCheckConstraint(string name, string table) + { + _operations.Add(new DropCheckConstraintOperation { Name = name, Table = table }); + return this; + } + + public List GetOperations() => _operations; + + private static bool IsNullableType(Type type) + { + return !type.IsValueType || Nullable.GetUnderlyingType(type) is not null; + } +} + +public class TableBuilder +{ + private readonly string _tableName; + private readonly List _columns; + + internal TableBuilder(string tableName, List columns) + { + _tableName = tableName; + _columns = columns; + } + + public TableBuilder Column(string name, Action? configure = null) + { + var operation = new AddColumnOperation + { + Table = _tableName, + Name = name, + ClrType = typeof(T), + IsNullable = IsNullableType(typeof(T)) + }; + + if (configure is not null) + { + var columnBuilder = new ColumnBuilder(operation); + configure(columnBuilder); + } + + _columns.Add(operation); + return this; + } + + private static bool IsNullableType(Type type) + { + return !type.IsValueType || Nullable.GetUnderlyingType(type) is not null; + } +} + +public class ColumnBuilder +{ + private readonly AddColumnOperation _operation; + + internal ColumnBuilder(AddColumnOperation operation) + { + _operation = operation; + } + + public ColumnBuilder IsRequired() + { + _operation.IsNullable = false; + return this; + } + + public ColumnBuilder IsPrimaryKey() + { + _operation.IsPrimaryKey = true; + _operation.IsNullable = false; + return this; + } + + public ColumnBuilder HasMaxLength(int length) + { + _operation.MaxLength = length; + return this; + } + + public ColumnBuilder HasDefaultValue(object value) + { + _operation.DefaultValue = value; + return this; + } + + public ColumnBuilder IsForeignKey(string table, string column = "Id") + { + _operation.ForeignKeyTable = table; + _operation.ForeignKeyColumn = column; + return this; + } + + public ColumnBuilder IsUnique() + { + _operation.IsUnique = true; + return this; + } + + public ColumnBuilder HasCheckConstraint(string expression) + { + _operation.CheckConstraint = expression; + return this; + } + + public ColumnBuilder HasPrecision(int precision, int scale = 0) + { + _operation.Precision = precision; + _operation.Scale = scale; + return this; + } + + public ColumnBuilder HasComputedColumnSql(string sql, bool? stored = null) + { + _operation.IsComputed = true; + _operation.ComputedColumnSql = sql; + _operation.IsStored = stored; + return this; + } + + public ColumnBuilder IsConcurrencyToken() + { + _operation.IsConcurrencyToken = true; + return this; + } + + public ColumnBuilder HasComment(string comment) + { + _operation.Comment = comment; + return this; + } +} + +public class AlterColumnBuilder +{ + private readonly AlterColumnOperation _operation; + + internal AlterColumnBuilder(AlterColumnOperation operation) + { + _operation = operation; + } + + public AlterColumnBuilder HasType() + { + _operation.ClrType = typeof(T); + return this; + } + + public AlterColumnBuilder IsNullable(bool nullable = true) + { + _operation.IsNullable = nullable; + return this; + } + + public AlterColumnBuilder HasMaxLength(int length) + { + _operation.MaxLength = length; + return this; + } + + public AlterColumnBuilder HasDefaultValue(object value) + { + _operation.DefaultValue = value; + return this; + } +} + +public class IndexBuilder +{ + private readonly CreateIndexOperation _operation; + + internal IndexBuilder(CreateIndexOperation operation) + { + _operation = operation; + } + + public IndexBuilder IsUnique() + { + _operation.IsUnique = true; + return this; + } + + public IndexBuilder IsClustered() + { + _operation.IsClustered = true; + return this; + } + + public IndexBuilder HasFilter(string filter) + { + _operation.Filter = filter; + return this; + } +} diff --git a/src/Sheetly.Core/Migrations/ModelDiffer.cs b/src/Sheetly.Core/Migrations/ModelDiffer.cs index cfe295e..43a1307 100644 --- a/src/Sheetly.Core/Migrations/ModelDiffer.cs +++ b/src/Sheetly.Core/Migrations/ModelDiffer.cs @@ -12,7 +12,6 @@ public List GetDifferences(MigrationSnapshot? previous, Migr var previousEntities = previous?.Entities ?? new Dictionary(); var currentEntities = current.Entities; - // Find new tables foreach (var (tableName, entity) in currentEntities) { if (!previousEntities.ContainsKey(tableName)) @@ -21,13 +20,11 @@ public List GetDifferences(MigrationSnapshot? previous, Migr } else { - // Find column differences var previousEntity = previousEntities[tableName]; operations.AddRange(GetColumnDifferences(tableName, previousEntity, entity)); } } - // Find dropped tables foreach (var (tableName, _) in previousEntities) { if (!currentEntities.ContainsKey(tableName)) @@ -44,7 +41,7 @@ private static CreateTableOperation CreateTableOperation(EntitySchema entity) var operation = new CreateTableOperation { Name = entity.TableName, - ClassName = entity.ClassName // For scaffolding support + ClassName = entity.ClassName }; foreach (var column in entity.Columns) @@ -56,8 +53,8 @@ private static CreateTableOperation CreateTableOperation(EntitySchema entity) ClrType = GetClrType(column.DataType), IsNullable = column.IsNullable, IsPrimaryKey = column.IsPrimaryKey, - IsUnique = column.IsPrimaryKey || column.IsUnique, // PK is always unique - IsAutoIncrement = column.IsAutoIncrement, // Read from snapshot (set by SnapshotBuilder) + IsUnique = column.IsPrimaryKey || column.IsUnique, + IsAutoIncrement = column.IsAutoIncrement, MaxLength = column.MaxLength, DefaultValue = column.DefaultValue, ForeignKeyTable = column.IsForeignKey ? column.ForeignKeyTable : null @@ -77,7 +74,6 @@ private static IEnumerable GetColumnDifferences( var previousColumns = previous.Columns.ToDictionary(c => c.Name); var currentColumns = current.Columns.ToDictionary(c => c.Name); - // Find new columns foreach (var (columnName, column) in currentColumns) { if (!previousColumns.ContainsKey(columnName)) @@ -96,7 +92,6 @@ private static IEnumerable GetColumnDifferences( } else { - // Check for alterations var prevCol = previousColumns[columnName]; if (HasColumnChanged(prevCol, column)) { @@ -113,7 +108,6 @@ private static IEnumerable GetColumnDifferences( } } - // Find dropped columns foreach (var (columnName, _) in previousColumns) { if (!currentColumns.ContainsKey(columnName)) diff --git a/src/Sheetly.Core/Migrations/SnapshotBuilder.cs b/src/Sheetly.Core/Migrations/SnapshotBuilder.cs index 895cd87..67c9544 100644 --- a/src/Sheetly.Core/Migrations/SnapshotBuilder.cs +++ b/src/Sheetly.Core/Migrations/SnapshotBuilder.cs @@ -22,7 +22,6 @@ public static MigrationSnapshot BuildFromContext(Type contextType, Dictionary()?.Length, @@ -64,17 +61,15 @@ public static MigrationSnapshot BuildFromContext(Type contextType, Dictionary p.Name.Equals(relatedName, StringComparison.OrdinalIgnoreCase)); - if (navProp != null && IsNavigationProperty(navProp)) + if (navProp is not null && IsNavigationProperty(navProp)) { column.IsForeignKey = true; - // Resolve FK table name using fluent API if available EntityMetadata? relatedMetadata = null; modelMetadata?.TryGetValue(navProp.PropertyType, out relatedMetadata); column.ForeignKeyTable = relatedMetadata?.SheetName ?? GetTableName(navProp.PropertyType); @@ -95,9 +90,8 @@ public static MigrationSnapshot BuildFromContext(Type contextType, Dictionary(); - if (tableAttr != null) return tableAttr.Name; + if (tableAttr is not null) return tableAttr.Name; - // Pluralize simple names var name = entityType.Name; if (name.EndsWith("y")) return name[..^1] + "ies"; if (name.EndsWith("s") || name.EndsWith("x") || name.EndsWith("ch") || name.EndsWith("sh")) @@ -148,7 +142,7 @@ private static bool IsNavigationProperty(PropertyInfo prop) private static bool IsPropertyNullable(PropertyInfo prop) { - return Nullable.GetUnderlyingType(prop.PropertyType) != null || !prop.PropertyType.IsValueType; + return Nullable.GetUnderlyingType(prop.PropertyType) is not null || !prop.PropertyType.IsValueType; } /// diff --git a/src/Sheetly.Core/SheetsContext.cs b/src/Sheetly.Core/SheetsContext.cs index c4c0822..42e0501 100644 --- a/src/Sheetly.Core/SheetsContext.cs +++ b/src/Sheetly.Core/SheetsContext.cs @@ -34,10 +34,10 @@ protected virtual void OnConfiguring(SheetsOptions options) { } public virtual async Task InitializeAsync(ISheetsProvider? provider = null, IMigrationService? migrationService = null) { - if (provider == null) + if (provider is null) { var options = _constructorOptions ?? new SheetsOptions(); - if (_constructorOptions == null) + if (_constructorOptions is null) OnConfiguring(options); provider = options.Provider ?? throw new InvalidOperationException( @@ -95,15 +95,15 @@ private async Task CheckMigrationSyncAsync() /// private void CheckModelSnapshotSync() { - if (_currentSnapshot == null) return; + if (_currentSnapshot is null) return; var snapshotType = GetType().Assembly.GetTypes() .FirstOrDefault(t => t.Name.EndsWith("ModelSnapshot") && t.IsSubclassOf(typeof(MigrationSnapshot))); - if (snapshotType == null) return; + if (snapshotType is null) return; var storedSnapshot = (MigrationSnapshot?)Activator.CreateInstance(snapshotType); - if (storedSnapshot == null) return; + if (storedSnapshot is null) return; if (_currentSnapshot.ModelHash != storedSnapshot.ModelHash) { @@ -122,7 +122,7 @@ private List GetLocalMigrations(Assembly assembly) foreach (var migrationType in migrationTypes) { var migrationAttr = migrationType.GetCustomAttribute(); - if (migrationAttr != null) + if (migrationAttr is not null) { migrations.Add(migrationAttr.Id); } @@ -166,7 +166,7 @@ private void InitializeSets(ISheetsProvider provider, MigrationSnapshot snapshot } } - if (schema != null) + if (schema is not null) { var setInstance = Activator.CreateInstance( typeof(SheetsSet<>).MakeGenericType(entityType), @@ -174,7 +174,7 @@ private void InitializeSets(ISheetsProvider provider, MigrationSnapshot snapshot schema, snapshot.Entities); - if (setInstance != null) + if (setInstance is not null) { prop.SetValue(this, setInstance); sets[entityType] = setInstance; @@ -185,7 +185,7 @@ private void InitializeSets(ISheetsProvider provider, MigrationSnapshot snapshot public async Task SaveChangesAsync(CancellationToken cancellationToken = default) { - if (Provider == null) + if (Provider is null) throw new InvalidOperationException( "Context not initialized. Call InitializeAsync() first."); @@ -213,8 +213,7 @@ public async Task SaveChangesAsync(CancellationToken cancellationToken = de allDeletedEntities.AddRange(deleted); } - // Validate locally before any API calls - if (_validator != null && allPendingEntities.Count > 0) + if (_validator is not null && allPendingEntities.Count > 0) { var result = new ValidationResult(); @@ -227,7 +226,7 @@ public async Task SaveChangesAsync(CancellationToken cancellationToken = de if (!(_currentSnapshot?.Entities.TryGetValue(tableName, out schema) == true)) schema = _currentSnapshot?.Entities.Values.FirstOrDefault(e => e.ClassName == entityType.Name); - if (schema != null) + if (schema is not null) { var context = new ValidationContext { @@ -259,7 +258,7 @@ public async Task SaveChangesAsync(CancellationToken cancellationToken = de var method = set.GetType().GetMethod("SaveChangesInternalAsync", BindingFlags.NonPublic | BindingFlags.Instance); - if (method != null) + if (method is not null) { var result = await (Task)method.Invoke(set, null)!; total += result; @@ -273,7 +272,7 @@ public async Task SaveChangesAsync(CancellationToken cancellationToken = de /// private async Task ValidateForeignKeyReferencesAsync(List pendingEntities) { - if (_currentSnapshot?.Entities == null || Provider == null) return; + if (_currentSnapshot?.Entities is null || Provider is null) return; var fkChecks = new Dictionary>(); @@ -282,15 +281,15 @@ private async Task ValidateForeignKeyReferencesAsync(List pendingEntitie var entityType = entity.GetType(); var schema = _currentSnapshot.Entities.Values .FirstOrDefault(e => e.ClassName == entityType.Name); - if (schema == null) continue; + if (schema is null) continue; foreach (var column in schema.Columns.Where(c => c.IsForeignKey && !string.IsNullOrEmpty(c.ForeignKeyTable))) { var prop = entityType.GetProperty(column.PropertyName); - if (prop == null) continue; + if (prop is null) continue; var value = prop.GetValue(entity); - if (value == null || IsDefaultFkValue(value, prop.PropertyType)) continue; + if (value is null || IsDefaultFkValue(value, prop.PropertyType)) continue; var fkTableName = column.ForeignKeyTable!; if (!fkChecks.ContainsKey(fkTableName)) @@ -313,10 +312,10 @@ private async Task ValidateForeignKeyReferencesAsync(List pendingEntitie $"Cannot reference IDs: {string.Join(", ", fkValues)}"); var referencedSchema = _currentSnapshot.Entities.GetValueOrDefault(referencedTable); - if (referencedSchema == null) continue; + if (referencedSchema is null) continue; var pkColumn = referencedSchema.Columns.FirstOrDefault(c => c.IsPrimaryKey); - if (pkColumn == null) continue; + if (pkColumn is null) continue; var headers = rows[0].Select(h => h?.ToString() ?? "").ToList(); int pkColumnIndex = headers.IndexOf(pkColumn.PropertyName); @@ -356,21 +355,21 @@ private static bool IsDefaultFkValue(object value, Type type) /// private async Task ValidateForeignKeyConstraintsOnDelete(List deletedEntities) { - if (_currentSnapshot?.Entities == null || Provider == null) return; + if (_currentSnapshot?.Entities is null || Provider is null) return; foreach (var deletedEntity in deletedEntities) { var entityType = deletedEntity.GetType(); var entitySchema = _currentSnapshot.Entities.Values .FirstOrDefault(e => e.ClassName == entityType.Name); - if (entitySchema == null) continue; + if (entitySchema is null) continue; var pkColumn = entitySchema.Columns.FirstOrDefault(c => c.IsPrimaryKey); - if (pkColumn == null) continue; + if (pkColumn is null) continue; var pkProp = entityType.GetProperty(pkColumn.PropertyName); var pkValue = pkProp?.GetValue(deletedEntity); - if (pkValue == null) continue; + if (pkValue is null) continue; foreach (var otherEntity in _currentSnapshot.Entities.Values) { @@ -430,7 +429,7 @@ private async Task ValidateForeignKeyConstraintsOnDelete(List deletedEnt break; case ForeignKeyAction.SetDefault: - if (fkColumn.DefaultValue != null) + if (fkColumn.DefaultValue is not null) foreach (var rowIndex in referencingRows) await Provider.UpdateValueAsync(otherEntity.TableName, GetCellAddress(fkColumnIndex, rowIndex), fkColumn.DefaultValue); break; diff --git a/src/Sheetly.Core/SheetsSet.cs b/src/Sheetly.Core/SheetsSet.cs index 46231e6..3da8735 100644 --- a/src/Sheetly.Core/SheetsSet.cs +++ b/src/Sheetly.Core/SheetsSet.cs @@ -108,13 +108,13 @@ public async Task> Where(Func predicate) public async Task FirstOrDefaultAsync(Func? predicate = null) { var all = await ToListAsync(); - return predicate != null ? all.FirstOrDefault(predicate) : all.FirstOrDefault(); + return predicate is not null ? all.FirstOrDefault(predicate) : all.FirstOrDefault(); } public async Task FindAsync(object keyValue) { var pkColumn = schema.Columns.FirstOrDefault(c => c.IsPrimaryKey); - if (pkColumn == null) return default; + if (pkColumn is null) return default; var keyStr = keyValue.ToString()!; @@ -122,10 +122,10 @@ public async Task> Where(Func predicate) if (rowIndex < 0) return default; var rowData = await provider.GetRowByIndexAsync(schema.TableName, rowIndex); - if (rowData == null) return default; + if (rowData is null) return default; var headerRow = await provider.GetRowByIndexAsync(schema.TableName, 1); - if (headerRow == null) return default; + if (headerRow is null) return default; var headers = headerRow.Select(h => h?.ToString() ?? string.Empty).ToList(); var entity = EntityMapper.MapFromRow(rowData, headers, schema); @@ -143,13 +143,13 @@ public async Task> Where(Func predicate) public async Task CountAsync(Func? predicate = null) { var all = await ToListAsync(); - return predicate != null ? all.Count(predicate) : all.Count; + return predicate is not null ? all.Count(predicate) : all.Count; } public async Task AnyAsync(Func? predicate = null) { var all = await ToListAsync(); - return predicate != null ? all.Any(predicate) : all.Any(); + return predicate is not null ? all.Any(predicate) : all.Any(); } private async Task ProcessIncludes(List entities) @@ -159,7 +159,7 @@ private async Task ProcessIncludes(List entities) foreach (var includePath in _includes) { var prop = typeof(T).GetProperty(includePath); - if (prop == null) continue; + if (prop is null) continue; bool isCollection = typeof(IEnumerable).IsAssignableFrom(prop.PropertyType) && prop.PropertyType != typeof(string); var targetType = isCollection ? (prop.PropertyType.IsGenericType ? prop.PropertyType.GetGenericArguments()[0] : typeof(object)) : prop.PropertyType; @@ -169,7 +169,7 @@ private async Task ProcessIncludes(List entities) if (!allSchemas.TryGetValue(relatedTableName, out relatedSchema)) { relatedSchema = allSchemas.Values.FirstOrDefault(s => s.ClassName == targetType.Name); - if (relatedSchema == null) continue; + if (relatedSchema is null) continue; } var actualTableName = relatedSchema.TableName; @@ -203,19 +203,19 @@ private async Task ProcessIncludes(List entities) private void MapRelations(List mainEntities, List relatedData, PropertyInfo prop, bool isCollection, EntitySchema relatedSchema, Type targetType) { var pkPropName = schema.Columns.FirstOrDefault(c => c.IsPrimaryKey)?.PropertyName; - var pkProp = pkPropName != null ? typeof(T).GetProperty(pkPropName) : null; + var pkProp = pkPropName is not null ? typeof(T).GetProperty(pkPropName) : null; var relPkPropName = relatedSchema.Columns.FirstOrDefault(c => c.IsPrimaryKey)?.PropertyName; - var relPkProp = relPkPropName != null ? targetType.GetProperty(relPkPropName) : null; + var relPkProp = relPkPropName is not null ? targetType.GetProperty(relPkPropName) : null; foreach (var entity in mainEntities) { if (isCollection) { var fkColumn = relatedSchema.Columns.FirstOrDefault(c => c.IsForeignKey && c.ForeignKeyTable == schema.TableName); - var fkPropOnRelated = fkColumn != null ? targetType.GetProperty(fkColumn.PropertyName) : null; + var fkPropOnRelated = fkColumn is not null ? targetType.GetProperty(fkColumn.PropertyName) : null; - if (fkPropOnRelated != null && pkProp != null) + if (fkPropOnRelated is not null && pkProp is not null) { var myPkValue = pkProp.GetValue(entity); var filtered = relatedData.Where(re => Equals(fkPropOnRelated.GetValue(re), myPkValue)).ToList(); @@ -229,14 +229,14 @@ private void MapRelations(List mainEntities, List relatedData, Proper else { var fkColumn = schema.Columns.FirstOrDefault(c => c.IsForeignKey && c.ForeignKeyTable == relatedSchema.TableName); - var fkProp = fkColumn != null ? typeof(T).GetProperty(fkColumn.PropertyName) : null; + var fkProp = fkColumn is not null ? typeof(T).GetProperty(fkColumn.PropertyName) : null; - if (fkProp != null && relPkProp != null) + if (fkProp is not null && relPkProp is not null) { var fkValue = fkProp.GetValue(entity); var relatedObject = relatedData.FirstOrDefault(re => Equals(relPkProp.GetValue(re), fkValue)); - if (relatedObject != null) + if (relatedObject is not null) { prop.SetValue(entity, relatedObject); } @@ -274,7 +274,7 @@ internal async Task SaveChangesInternalAsync() { var pkColumn = schema.Columns.FirstOrDefault(c => c.IsPrimaryKey); - if (pkColumn != null) + if (pkColumn is not null) { int nextId = await provider.GetMaxIdAsync(schema.TableName) + 1; var batchRows = new List>(toAdd.Count); diff --git a/src/Sheetly.Core/Validation/ConstraintValidator.cs b/src/Sheetly.Core/Validation/ConstraintValidator.cs index 9733f54..c7d59ff 100644 --- a/src/Sheetly.Core/Validation/ConstraintValidator.cs +++ b/src/Sheetly.Core/Validation/ConstraintValidator.cs @@ -86,7 +86,7 @@ public void ValidateAndThrow(IEnumerable entities, IEnumerable all private EntitySchema? GetEntitySchema(string tableName) { - if (_schema == null) return null; + if (_schema is null) return null; _schema.Entities.TryGetValue(tableName, out var schema); return schema; diff --git a/src/Sheetly.Core/Validation/Rules/CheckConstraintValidator.cs b/src/Sheetly.Core/Validation/Rules/CheckConstraintValidator.cs index 9d41e14..3761dcf 100644 --- a/src/Sheetly.Core/Validation/Rules/CheckConstraintValidator.cs +++ b/src/Sheetly.Core/Validation/Rules/CheckConstraintValidator.cs @@ -11,17 +11,16 @@ public ValidationResult Validate(object entity, ValidationContext context) { var result = new ValidationResult(); - if (context.Schema == null || context.EntityType == null) return result; + if (context.Schema is null || context.EntityType is null) return result; foreach (var column in context.Schema.Columns.Where(c => !string.IsNullOrEmpty(c.CheckConstraint))) { var property = context.EntityType.GetProperty(column.PropertyName); - if (property == null) continue; + if (property is null) continue; var value = property.GetValue(entity); - if (value == null) continue; + if (value is null) continue; - // Parse and evaluate the check constraint if (!EvaluateCheckConstraint(column.CheckConstraint!, column.PropertyName, value)) { result.AddError( @@ -41,10 +40,8 @@ private static bool EvaluateCheckConstraint(string constraint, string propertyNa { try { - // Remove extra whitespace constraint = constraint.Trim(); - // Try to parse simple constraints like "PropertyName > 0" if (constraint.Contains(">") || constraint.Contains("<") || constraint.Contains("=")) { string op; @@ -54,19 +51,17 @@ private static bool EvaluateCheckConstraint(string constraint, string propertyNa else if (constraint.Contains(">")) op = ">"; else if (constraint.Contains("<")) op = "<"; else if (constraint.Contains("=")) op = "="; - else return true; // Can't parse, assume valid + else return true; var parts = constraint.Split(new[] { op }, StringSplitOptions.None); - if (parts.Length != 2) return true; // Can't parse + if (parts.Length != 2) return true; var left = parts[0].Trim(); var right = parts[1].Trim(); - // Check if left side is the property name if (!left.Equals(propertyName, StringComparison.OrdinalIgnoreCase)) - return true; // Not about this property + return true; - // Try to convert both sides to decimal for comparison if (!TryConvertToDecimal(value, out decimal leftValue)) return true; if (!TryConvertToDecimal(right, out decimal rightValue)) return true; @@ -82,12 +77,10 @@ private static bool EvaluateCheckConstraint(string constraint, string propertyNa }; } - // If we can't parse it, assume it's valid (avoid false positives) return true; } catch { - // On any parsing error, assume valid return true; } } diff --git a/src/Sheetly.Core/Validation/Rules/DataTypeValidator.cs b/src/Sheetly.Core/Validation/Rules/DataTypeValidator.cs index 606d3d1..31544b2 100644 --- a/src/Sheetly.Core/Validation/Rules/DataTypeValidator.cs +++ b/src/Sheetly.Core/Validation/Rules/DataTypeValidator.cs @@ -9,24 +9,23 @@ public ValidationResult Validate(object entity, ValidationContext context) { var result = new ValidationResult(); - if (context.Schema == null) return result; + if (context.Schema is null) return result; var entityType = entity.GetType(); foreach (var column in context.Schema.Columns) { var property = entityType.GetProperty(column.PropertyName); - if (property == null) continue; + if (property is null) continue; var value = property.GetValue(entity); - if (value == null) continue; + if (value is null) continue; var valueType = value.GetType(); var expectedType = GetExpectedType(column.DataType); - if (expectedType == null) continue; + if (expectedType is null) continue; - // Check type compatibility if (!IsTypeCompatible(valueType, expectedType)) { result.AddError(new ValidationError(column.PropertyName, @@ -65,11 +64,10 @@ private static bool IsTypeCompatible(Type actual, Type expected) if (expected == actual) return true; if (expected.IsAssignableFrom(actual)) return true; - // Numeric type compatibility var numericTypes = new[] { typeof(int), typeof(long), typeof(short), typeof(byte), typeof(decimal), typeof(double), typeof(float) }; if (numericTypes.Contains(expected) && numericTypes.Contains(actual)) { - return true; // Allow numeric conversions + return true; } return false; diff --git a/src/Sheetly.Core/Validation/Rules/ForeignKeyValidator.cs b/src/Sheetly.Core/Validation/Rules/ForeignKeyValidator.cs index 00d4b38..833b627 100644 --- a/src/Sheetly.Core/Validation/Rules/ForeignKeyValidator.cs +++ b/src/Sheetly.Core/Validation/Rules/ForeignKeyValidator.cs @@ -11,7 +11,7 @@ public ValidationResult Validate(object entity, ValidationContext context) { var result = new ValidationResult(); - if (context.Schema == null) return result; + if (context.Schema is null) return result; var entityType = entity.GetType(); @@ -20,12 +20,11 @@ public ValidationResult Validate(object entity, ValidationContext context) if (!column.IsForeignKey || string.IsNullOrEmpty(column.ForeignKeyTable)) continue; var property = entityType.GetProperty(column.PropertyName); - if (property == null) continue; + if (property is null) continue; var value = property.GetValue(entity); - // Null FK is allowed if column is nullable - if (value == null) + if (value is null) { if (!column.IsNullable) { @@ -38,14 +37,12 @@ public ValidationResult Validate(object entity, ValidationContext context) continue; } - // Skip zero/default values for value types (will be set on save) if (IsDefaultValue(value, property.PropertyType)) continue; - // Check against tracked entities using schema-based PK resolution if (context.AllSchemas.TryGetValue(column.ForeignKeyTable, out var referencedSchema)) { var referencedPkColumn = referencedSchema.Columns.FirstOrDefault(c => c.IsPrimaryKey); - if (referencedPkColumn != null) + if (referencedPkColumn is not null) { var found = false; foreach (var tracked in context.TrackedEntities) @@ -53,7 +50,7 @@ public ValidationResult Validate(object entity, ValidationContext context) if (tracked.GetType().Name != referencedSchema.ClassName) continue; var pkProp = tracked.GetType().GetProperty(referencedPkColumn.PropertyName); - if (pkProp == null) continue; + if (pkProp is null) continue; var pkValue = pkProp.GetValue(tracked); if (Equals(value, pkValue)) @@ -63,8 +60,6 @@ public ValidationResult Validate(object entity, ValidationContext context) } } - // Only error if tracked entities of this type exist but none match - // Remote check happens later in SaveChangesAsync if (!found && context.TrackedEntities.Any(e => e.GetType().Name == referencedSchema.ClassName)) { result.AddError(new ValidationError(column.PropertyName, diff --git a/src/Sheetly.Core/Validation/Rules/MaxLengthValidator.cs b/src/Sheetly.Core/Validation/Rules/MaxLengthValidator.cs index 042f36e..17ca78f 100644 --- a/src/Sheetly.Core/Validation/Rules/MaxLengthValidator.cs +++ b/src/Sheetly.Core/Validation/Rules/MaxLengthValidator.cs @@ -9,7 +9,7 @@ public ValidationResult Validate(object entity, ValidationContext context) { var result = new ValidationResult(); - if (context.Schema == null) return result; + if (context.Schema is null) return result; var entityType = entity.GetType(); @@ -18,7 +18,7 @@ public ValidationResult Validate(object entity, ValidationContext context) if (!column.MaxLength.HasValue) continue; var property = entityType.GetProperty(column.PropertyName); - if (property == null) continue; + if (property is null) continue; var value = property.GetValue(entity); diff --git a/src/Sheetly.Core/Validation/Rules/MinLengthValidator.cs b/src/Sheetly.Core/Validation/Rules/MinLengthValidator.cs index ade8d46..67eff5b 100644 --- a/src/Sheetly.Core/Validation/Rules/MinLengthValidator.cs +++ b/src/Sheetly.Core/Validation/Rules/MinLengthValidator.cs @@ -9,7 +9,7 @@ public ValidationResult Validate(object entity, ValidationContext context) { var result = new ValidationResult(); - if (context.Schema == null) return result; + if (context.Schema is null) return result; var entityType = entity.GetType(); @@ -18,11 +18,10 @@ public ValidationResult Validate(object entity, ValidationContext context) if (!column.MinLength.HasValue) continue; var property = entityType.GetProperty(column.PropertyName); - if (property == null) continue; + if (property is null) continue; var value = property.GetValue(entity); - // Only validate non-null strings; null/empty is handled by NullabilityValidator if (value is string str && str.Length < column.MinLength.Value) { result.AddError(new ValidationError(column.PropertyName, diff --git a/src/Sheetly.Core/Validation/Rules/NullabilityValidator.cs b/src/Sheetly.Core/Validation/Rules/NullabilityValidator.cs index ad2b23d..f7948cb 100644 --- a/src/Sheetly.Core/Validation/Rules/NullabilityValidator.cs +++ b/src/Sheetly.Core/Validation/Rules/NullabilityValidator.cs @@ -9,21 +9,21 @@ public ValidationResult Validate(object entity, ValidationContext context) { var result = new ValidationResult(); - if (context.Schema == null) return result; + if (context.Schema is null) return result; var entityType = entity.GetType(); foreach (var column in context.Schema.Columns) { if (column.IsNullable) continue; - if (column.IsPrimaryKey) continue; // PK is handled separately + if (column.IsPrimaryKey) continue; var property = entityType.GetProperty(column.PropertyName); - if (property == null) continue; + if (property is null) continue; var value = property.GetValue(entity); - if (value == null) + if (value is null) { result.AddError(new ValidationError(column.PropertyName, $"'{column.PropertyName}' is required and cannot be null.") diff --git a/src/Sheetly.Core/Validation/Rules/PrimaryKeyValidator.cs b/src/Sheetly.Core/Validation/Rules/PrimaryKeyValidator.cs index a82efc7..e9a0f43 100644 --- a/src/Sheetly.Core/Validation/Rules/PrimaryKeyValidator.cs +++ b/src/Sheetly.Core/Validation/Rules/PrimaryKeyValidator.cs @@ -9,26 +9,23 @@ public ValidationResult Validate(object entity, ValidationContext context) { var result = new ValidationResult(); - if (context.Schema == null) return result; + if (context.Schema is null) return result; var entityType = entity.GetType(); var pkColumn = context.Schema.Columns.FirstOrDefault(c => c.IsPrimaryKey); - if (pkColumn == null) return result; + if (pkColumn is null) return result; var property = entityType.GetProperty(pkColumn.PropertyName); - if (property == null) return result; + if (property is null) return result; var value = property.GetValue(entity); - // Check if value is null or default - if (value == null || IsDefaultValue(value, property.PropertyType)) + if (value is null || IsDefaultValue(value, property.PropertyType)) { - // This is a new entity - PK will be auto-generated return result; } - // Check for duplicates in tracked entities if (context.ExistingPrimaryKeys.Contains(value)) { result.AddError(new ValidationError(pkColumn.PropertyName, @@ -38,7 +35,6 @@ public ValidationResult Validate(object entity, ValidationContext context) }); } - // Check for duplicates among other tracked entities foreach (var other in context.TrackedEntities) { if (ReferenceEquals(entity, other)) continue; diff --git a/src/Sheetly.Core/Validation/Rules/RangeValidator.cs b/src/Sheetly.Core/Validation/Rules/RangeValidator.cs index 1453a2c..744ddc4 100644 --- a/src/Sheetly.Core/Validation/Rules/RangeValidator.cs +++ b/src/Sheetly.Core/Validation/Rules/RangeValidator.cs @@ -9,20 +9,18 @@ public ValidationResult Validate(object entity, ValidationContext context) { var result = new ValidationResult(); - if (context.Schema == null || context.EntityType == null) return result; + if (context.Schema is null || context.EntityType is null) return result; foreach (var column in context.Schema.Columns) { - // Only validate if range constraints are defined if (!column.MinValue.HasValue && !column.MaxValue.HasValue) continue; var property = context.EntityType.GetProperty(column.PropertyName); - if (property == null) continue; + if (property is null) continue; var value = property.GetValue(entity); - if (value == null) continue; // Null values are handled by NullabilityValidator + if (value is null) continue; - // Convert to decimal for comparison if (!TryConvertToDecimal(value, out decimal numericValue)) continue; diff --git a/src/Sheetly.Core/Validation/Rules/UniqueValidator.cs b/src/Sheetly.Core/Validation/Rules/UniqueValidator.cs index 0151ed4..d91dcc8 100644 --- a/src/Sheetly.Core/Validation/Rules/UniqueValidator.cs +++ b/src/Sheetly.Core/Validation/Rules/UniqueValidator.cs @@ -10,22 +10,21 @@ public ValidationResult Validate(object entity, ValidationContext context) { var result = new ValidationResult(); - if (context.Schema == null || context.TrackedEntities == null || context.EntityType == null) + if (context.Schema is null || context.TrackedEntities is null || context.EntityType is null) return result; foreach (var column in context.Schema.Columns.Where(c => c.IsUnique)) { var property = context.EntityType.GetProperty(column.PropertyName); - if (property == null) continue; + if (property is null) continue; var currentValue = property.GetValue(entity); - if (currentValue == null) continue; // Null values are allowed for unique constraints unless Required + if (currentValue is null) continue; - // Check for duplicates in tracked entities var duplicates = context.TrackedEntities .Where(e => e.GetType() == context.EntityType && !ReferenceEquals(e, entity)) .Select(e => property.GetValue(e)) - .Where(v => v != null && v.Equals(currentValue)) + .Where(v => v is not null && v.Equals(currentValue)) .ToList(); if (duplicates.Count > 0) diff --git a/src/Sheetly.Core/Validation/ValidationResult.cs b/src/Sheetly.Core/Validation/ValidationResult.cs index b73192c..e3a62fa 100644 --- a/src/Sheetly.Core/Validation/ValidationResult.cs +++ b/src/Sheetly.Core/Validation/ValidationResult.cs @@ -87,7 +87,7 @@ public ValidationError(string propertyName, string message) } public override string ToString() => - EntityType != null + EntityType is not null ? $"{EntityType}.{PropertyName}: {Message}" : $"{PropertyName}: {Message}"; } diff --git a/src/Sheetly.DependencyInjection/Extensions/ServiceCollectionExtensions.cs b/src/Sheetly.DependencyInjection/Extensions/ServiceCollectionExtensions.cs index 7266787..e07dc8d 100644 --- a/src/Sheetly.DependencyInjection/Extensions/ServiceCollectionExtensions.cs +++ b/src/Sheetly.DependencyInjection/Extensions/ServiceCollectionExtensions.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using Sheetly.Core; using Sheetly.Core.Configuration; @@ -26,7 +26,7 @@ public static IServiceCollection AddSheetsContext( .GetConstructor([typeof(SheetsContextOptions)]); TContext context; - if (ctorWithOptions != null) + if (ctorWithOptions is not null) { context = (TContext)ctorWithOptions.Invoke([options]); context.InitializeAsync().GetAwaiter().GetResult(); @@ -34,7 +34,7 @@ public static IServiceCollection AddSheetsContext( else { context = (TContext)Activator.CreateInstance(typeof(TContext), nonPublic: true)!; - if (options.Provider != null) + if (options.Provider is not null) context.InitializeAsync(options.Provider).GetAwaiter().GetResult(); else context.InitializeAsync().GetAwaiter().GetResult(); diff --git a/src/Sheetly.Excel/ExcelMigrationService.cs b/src/Sheetly.Excel/ExcelMigrationService.cs index bb3940f..9d29c5e 100644 --- a/src/Sheetly.Excel/ExcelMigrationService.cs +++ b/src/Sheetly.Excel/ExcelMigrationService.cs @@ -178,14 +178,14 @@ private async Task AlterColumnAsync(AlterColumnOperation op) while (updatedRow.Count < SchemaTableHeaders.Length) updatedRow.Add(""); - if (op.ClrType != null) updatedRow[4] = op.ClrType.Name; + if (op.ClrType is not null) updatedRow[4] = op.ClrType.Name; if (op.IsNullable.HasValue) { updatedRow[5] = op.IsNullable.Value.ToString(); updatedRow[6] = (!op.IsNullable.Value).ToString(); } if (op.MaxLength.HasValue) updatedRow[15] = op.MaxLength.Value.ToString(); - if (op.DefaultValue != null) updatedRow[21] = op.DefaultValue.ToString() ?? ""; + if (op.DefaultValue is not null) updatedRow[21] = op.DefaultValue.ToString() ?? ""; await provider.UpdateRowAsync(SchemaTable, i + 1, updatedRow); break; diff --git a/src/Sheetly.Excel/ExcelSheetProvider.cs b/src/Sheetly.Excel/ExcelSheetProvider.cs index 3aa5032..af8cab8 100644 --- a/src/Sheetly.Excel/ExcelSheetProvider.cs +++ b/src/Sheetly.Excel/ExcelSheetProvider.cs @@ -52,7 +52,7 @@ public Task>> GetAllRowsAsync(string sheetName) var result = new List>(); var rangeUsed = ws.RangeUsed(); - if (rangeUsed == null) + if (rangeUsed is null) return Task.FromResult(result); int lastCol = rangeUsed.LastColumn().ColumnNumber(); @@ -74,7 +74,7 @@ public Task>> GetAllRowsAsync(string sheetName) return Task.FromResult?>(null); var rangeUsed = ws.RangeUsed(); - if (rangeUsed == null || rowIndex < 1 || rowIndex > rangeUsed.LastRow().RowNumber()) + if (rangeUsed is null || rowIndex < 1 || rowIndex > rangeUsed.LastRow().RowNumber()) return Task.FromResult?>(null); int lastCol = rangeUsed.LastColumn().ColumnNumber(); @@ -92,7 +92,7 @@ public Task FindRowIndexByKeyAsync(string sheetName, string keyValue) return Task.FromResult(-1); var rangeUsed = ws.RangeUsed(); - if (rangeUsed == null) + if (rangeUsed is null) return Task.FromResult(-1); int lastRow = rangeUsed.LastRow().RowNumber(); @@ -235,7 +235,7 @@ public Task ClearSheetAsync(string sheetName) return Task.CompletedTask; var rangeUsed = ws.RangeUsed(); - if (rangeUsed == null || rangeUsed.LastRow().RowNumber() < 2) + if (rangeUsed is null || rangeUsed.LastRow().RowNumber() < 2) return Task.CompletedTask; int lastRow = rangeUsed.LastRow().RowNumber(); @@ -279,13 +279,11 @@ public Task UpdateValueAsync(string sheetName, string range, object value) public Task AddDataValidationAsync(string sheetName, int columnIndex, string message) { - // Excel data validation is metadata-only; no runtime enforcement like Google Sheets return Task.CompletedTask; } public Task SetCheckboxAsync(string sheetName, int startRow, int endRow, int columnId) { - // ClosedXML doesn't support checkbox data validation natively return Task.CompletedTask; } @@ -303,7 +301,7 @@ public ValueTask DisposeAsync() private void EnsureWorkbook() { - if (_workbook == null) + if (_workbook is null) throw new InvalidOperationException( "Workbook not initialized. Call InitializeAsync() first."); } @@ -323,14 +321,14 @@ private void Save() private static int GetNextEmptyRow(IXLWorksheet ws) { var lastUsed = ws.LastRowUsed(); - return lastUsed == null ? 2 : lastUsed.RowNumber() + 1; + return lastUsed is null ? 2 : lastUsed.RowNumber() + 1; } private static int GetMaxIdFromSheet(IXLWorksheet ws) { int max = 0; var rangeUsed = ws.RangeUsed(); - if (rangeUsed == null) return max; + if (rangeUsed is null) return max; int lastRow = rangeUsed.LastRow().RowNumber(); for (int r = 2; r <= lastRow; r++) diff --git a/src/Sheetly.Google/GoogleMigrationService.cs b/src/Sheetly.Google/GoogleMigrationService.cs index e82b6bf..03d33e6 100644 --- a/src/Sheetly.Google/GoogleMigrationService.cs +++ b/src/Sheetly.Google/GoogleMigrationService.cs @@ -1,4 +1,4 @@ -using Sheetly.Core.Abstractions; +using Sheetly.Core.Abstractions; using Sheetly.Core.Migrations.Operations; namespace Sheetly.Google; @@ -13,36 +13,36 @@ public class GoogleMigrationService(ISheetsProvider provider) : IMigrationServic /// private static readonly string[] SchemaTableHeaders = [ - "ClassName", // 0 - Entity class name - "TableName", // 1 - Sheet/Table name - "PropertyName", // 2 - Property/Column name - "ColumnName", // 3 - Actual column name in sheet - "DataType", // 4 - CLR type (Int32, String, etc.) - "IsNullable", // 5 - Is nullable (TRUE/FALSE) - "IsRequired", // 6 - Is required (TRUE/FALSE) - "IsPrimaryKey", // 7 - Is primary key (TRUE/FALSE) - "IsForeignKey", // 8 - Is foreign key (TRUE/FALSE) - "ForeignKeyTable", // 9 - Related table name - "ForeignKeyColumn", // 10 - Related column name - "OnDelete", // 11 - FK delete action - "OnUpdate", // 12 - FK update action - "IsUnique", // 13 - Is unique constraint - "IndexName", // 14 - Index name if part of index - "MaxLength", // 15 - Max string length - "MinLength", // 16 - Min string length - "Precision", // 17 - Decimal precision - "Scale", // 18 - Decimal scale - "MinValue", // 19 - Minimum numeric value - "MaxValue", // 20 - Maximum numeric value - "DefaultValue", // 21 - Default value - "DefaultValueSql", // 22 - Default value SQL expression - "CheckConstraint", // 23 - Check constraint expression - "IsComputed", // 24 - Is computed column - "ComputedSql", // 25 - Computed column SQL - "IsConcurrencyToken", // 26 - Is concurrency token - "IsAutoIncrement", // 27 - Is auto-increment (for PK) - "CurrentIdValue", // 28 - Current ID value (for auto-increment) - "Comment" // 29 - Column comment/description + "ClassName", + "TableName", + "PropertyName", + "ColumnName", + "DataType", + "IsNullable", + "IsRequired", + "IsPrimaryKey", + "IsForeignKey", + "ForeignKeyTable", + "ForeignKeyColumn", + "OnDelete", + "OnUpdate", + "IsUnique", + "IndexName", + "MaxLength", + "MinLength", + "Precision", + "Scale", + "MinValue", + "MaxValue", + "DefaultValue", + "DefaultValueSql", + "CheckConstraint", + "IsComputed", + "ComputedSql", + "IsConcurrencyToken", + "IsAutoIncrement", + "CurrentIdValue", + "Comment" ]; public async Task> GetAppliedMigrationsAsync() @@ -50,8 +50,6 @@ public async Task> GetAppliedMigrationsAsync() if (!await provider.SheetExistsAsync(HistoryTable)) return []; var rows = await provider.GetAllRowsAsync(HistoryTable); - // Assuming first column is MigrationId - // Row 0 is header return rows.Skip(1) .Where(r => r.Count > 0) .Select(r => r[0]?.ToString() ?? "") @@ -125,7 +123,6 @@ private async Task DropTableAsync(DropTableOperation op) if (await provider.SheetExistsAsync(op.Name)) await provider.DeleteSheetAsync(op.Name); - // Rewrite schema table without this table's rows var rows = await provider.GetAllRowsAsync(SchemaTable); var newRows = new List> { rows[0] }; @@ -186,7 +183,7 @@ await provider.AppendRowAsync(SchemaTable, col.ComputedColumnSql ?? "", col.IsConcurrencyToken.ToString(), col.IsAutoIncrement.ToString(), - col.IsPrimaryKey ? "0" : "", // CurrentIdValue (auto-increment PK only) + col.IsPrimaryKey ? "0" : "", col.Comment ?? "" ]); } @@ -214,9 +211,6 @@ await provider.AppendRowAsync(HistoryTable, private async Task DropColumnAsync(DropColumnOperation op) { - // Note: Google Sheets doesn't support dropping columns directly - // We would need to recreate the sheet without that column - // For now, log a warning Console.WriteLine($"Warning: DropColumn '{op.Table}.{op.Name}' requires manual intervention in Google Sheets."); await RemoveFromSchemaTableAsync(op.Table, op.Name); } @@ -230,19 +224,18 @@ private async Task AlterColumnAsync(AlterColumnOperation op) rows[i][1]?.ToString() == op.Table && rows[i][2]?.ToString() == op.Name) { - // Pad row to full schema width to avoid index-out-of-range on sparse rows var updatedRow = rows[i].ToList(); while (updatedRow.Count < SchemaTableHeaders.Length) updatedRow.Add(""); - if (op.ClrType != null) updatedRow[4] = op.ClrType.Name; + if (op.ClrType is not null) updatedRow[4] = op.ClrType.Name; if (op.IsNullable.HasValue) { updatedRow[5] = op.IsNullable.Value.ToString(); updatedRow[6] = (!op.IsNullable.Value).ToString(); } if (op.MaxLength.HasValue) updatedRow[15] = op.MaxLength.Value.ToString(); - if (op.DefaultValue != null) updatedRow[21] = op.DefaultValue.ToString() ?? ""; + if (op.DefaultValue is not null) updatedRow[21] = op.DefaultValue.ToString() ?? ""; await provider.UpdateRowAsync(SchemaTable, i + 1, updatedRow); break; @@ -252,7 +245,6 @@ private async Task AlterColumnAsync(AlterColumnOperation op) private async Task CreateIndexAsync(CreateIndexOperation op) { - // Indexes are metadata-only in Sheets — recorded in schema for scaffold/documentation var rows = await provider.GetAllRowsAsync(SchemaTable); for (int i = 1; i < rows.Count; i++) { diff --git a/src/Sheetly.Google/GoogleSheetProvider.cs b/src/Sheetly.Google/GoogleSheetProvider.cs index 7fe4ceb..444e3e4 100644 --- a/src/Sheetly.Google/GoogleSheetProvider.cs +++ b/src/Sheetly.Google/GoogleSheetProvider.cs @@ -45,10 +45,9 @@ private static async Task ExecuteWithRetryAsync(IClientServiceRequest r ex.HttpStatusCode == System.Net.HttpStatusCode.ServiceUnavailable)) { await Task.Delay(delay); - delay = TimeSpan.FromSeconds(delay.TotalSeconds * 2); // exponential backoff + delay = TimeSpan.FromSeconds(delay.TotalSeconds * 2); } } - // Final attempt — let the exception propagate return await request.ExecuteAsync(); } @@ -105,7 +104,7 @@ public async Task InitializeAsync() { var ss = await ExecuteWithRetryAsync(NextService.Spreadsheets.Get(_spreadsheetId)); _sheetCache = ss.Sheets - .Where(s => s.Properties?.Title != null) + .Where(s => s.Properties?.Title is not null) .ToDictionary(s => s.Properties.Title!, s => (int)(s.Properties.SheetId ?? 0)); } @@ -154,7 +153,7 @@ public async Task FindRowIndexByKeyAsync(string sheetName, string keyValue) request.ValueRenderOption = SpreadsheetsResource.ValuesResource.GetRequest.ValueRenderOptionEnum.UNFORMATTEDVALUE; var response = await ExecuteWithRetryAsync(request); - if (response.Values == null) return -1; + if (response.Values is null) return -1; for (int i = 1; i < response.Values.Count; i++) { var cell = response.Values[i].Count > 0 ? response.Values[i][0]?.ToString() : null; @@ -179,7 +178,6 @@ public async Task AppendRowAsync(string sheetName, IList row) /// public async Task AppendRowAndGetIdAsync(string sheetName, IList row) { - // Replace the first element with a MAX+1 formula so Sheets computes the next ID atomically var rowWithFormula = new List(row) { [0] = $"=IFERROR(MAX(INDIRECT(\"'{sheetName}'!A2:A\"))+1,1)" @@ -190,24 +188,19 @@ public async Task AppendRowAndGetIdAsync(string sheetName, IList ro request.ValueInputOption = SpreadsheetsResource.ValuesResource.AppendRequest.ValueInputOptionEnum.USERENTERED; var response = await ExecuteWithRetryAsync(request); - // Extract the row number from the updated range (e.g. "'Products'!A5:E5" → 5) var updatedRange = response.Updates?.UpdatedRange ?? string.Empty; var rowNumber = ExtractRowNumberFromRange(updatedRange); - // Read back the computed ID value var idValue = await GetValueAsync(sheetName, $"A{rowNumber}"); - return idValue != null && int.TryParse(idValue.ToString(), out var id) ? id : rowNumber - 1; + return idValue is not null && int.TryParse(idValue.ToString(), out var id) ? id : rowNumber - 1; } /// Parses the row number from a Sheets range string like "'Table'!A5:E5" or "A5:E5". private static int ExtractRowNumberFromRange(string range) { - // Strip sheet prefix if present var colonIdx = range.IndexOf('!'); var cellPart = colonIdx >= 0 ? range[(colonIdx + 1)..] : range; - // cellPart looks like "A5:E5" — take the start cell var startCell = cellPart.Split(':')[0]; - // Strip column letters var digits = new string(startCell.SkipWhile(c => !char.IsDigit(c)).ToArray()); return int.TryParse(digits, out var row) ? row : 2; } @@ -235,7 +228,7 @@ public async Task GetMaxIdAsync(string sheetName) request.ValueRenderOption = SpreadsheetsResource.ValuesResource.GetRequest.ValueRenderOptionEnum.UNFORMATTEDVALUE; var response = await ExecuteWithRetryAsync(request); int max = 0; - if (response.Values != null) + if (response.Values is not null) foreach (var row in response.Values) if (row.Count > 0 && int.TryParse(row[0]?.ToString(), out var id) && id > max) max = id; @@ -282,7 +275,7 @@ public async Task CreateSheetAsync(string sheetName, IList headers) GridProperties = new GridProperties { FrozenRowCount = 1, - ColumnCount = headers.Count // Explicitly set column count + ColumnCount = headers.Count } } } @@ -297,7 +290,6 @@ public async Task CreateSheetAsync(string sheetName, IList headers) NextService.Spreadsheets.BatchUpdate(batchRequest, _spreadsheetId)); var sheetId = response.Replies[0].AddSheet.Properties.SheetId; - // Update cache so subsequent SheetExistsAsync/GetSheetIdInternal are free _sheetCache[sheetName] = (int)(sheetId ?? 0); var headerRows = new List @@ -346,7 +338,7 @@ await ExecuteWithRetryAsync(NextService.Spreadsheets.BatchUpdate( public async Task DeleteSheetAsync(string sheetName) { var sheetId = await GetSheetIdInternal(sheetName); - if (sheetId == null) return; + if (sheetId is null) return; var request = new Request { DeleteSheet = new DeleteSheetRequest { SheetId = sheetId } }; await ExecuteWithRetryAsync(NextService.Spreadsheets.BatchUpdate( new BatchUpdateSpreadsheetRequest { Requests = [request] }, _spreadsheetId)); @@ -377,7 +369,7 @@ public async Task UpdateValueAsync(string sheetName, string range, object value) public async Task HideSheetAsync(string sheetName) { var sheetId = await GetSheetIdInternal(sheetName); - if (sheetId == null) return; + if (sheetId is null) return; var request = new Request { UpdateSheetProperties = new UpdateSheetPropertiesRequest From 38f954c34bed313bff825870e5d655ed768fcd44 Mon Sep 17 00:00:00 2001 From: muqimjon Date: Sat, 28 Feb 2026 12:34:48 +0500 Subject: [PATCH 28/36] refactor: clean up CLI code (inline comments, null patterns) --- src/Sheetly.CLI/Commands/AddCommand.cs | 2 +- src/Sheetly.CLI/Commands/DropCommand.cs | 4 ++-- src/Sheetly.CLI/Commands/ListCommand.cs | 1 - src/Sheetly.CLI/Commands/RemoveCommand.cs | 2 +- src/Sheetly.CLI/Commands/RollbackCommand.cs | 4 ---- src/Sheetly.CLI/Commands/ScaffoldCommand.cs | 4 ++-- src/Sheetly.CLI/Commands/ScriptCommand.cs | 4 ++-- src/Sheetly.CLI/Commands/UpdateCommand.cs | 4 ++-- src/Sheetly.CLI/Helpers/CliHelper.cs | 9 ++++----- src/Sheetly.CLI/Helpers/ProjectAssemblyLoadContext.cs | 3 +-- src/Sheetly.CLI/Program.cs | 6 +----- 11 files changed, 16 insertions(+), 27 deletions(-) diff --git a/src/Sheetly.CLI/Commands/AddCommand.cs b/src/Sheetly.CLI/Commands/AddCommand.cs index 2a70c3b..f34fd48 100644 --- a/src/Sheetly.CLI/Commands/AddCommand.cs +++ b/src/Sheetly.CLI/Commands/AddCommand.cs @@ -47,7 +47,7 @@ private async Task ExecuteAsync(string? name, bool noBuild, string? projectPath, var json = CliHelper.InvokeDesignTime(coreAsm, "AddMigration", contextType, name, outputDir); var doc = CliHelper.ParseResult(json); - if (doc == null) return; + if (doc is null) return; var root = doc.RootElement; Console.WriteLine($"✅ Migration created: '{root.GetProperty("migrationFile").GetString()}'"); diff --git a/src/Sheetly.CLI/Commands/DropCommand.cs b/src/Sheetly.CLI/Commands/DropCommand.cs index 7dd5b59..6aef3a7 100644 --- a/src/Sheetly.CLI/Commands/DropCommand.cs +++ b/src/Sheetly.CLI/Commands/DropCommand.cs @@ -1,4 +1,4 @@ -using Sheetly.CLI.Helpers; +using Sheetly.CLI.Helpers; using System.CommandLine; namespace Sheetly.CLI.Commands; @@ -41,7 +41,7 @@ private async Task ExecuteAsync(bool force, bool noBuild, string? projectPath) var json = CliHelper.InvokeDesignTime(coreAsm, "DropDatabaseAsync", contextType, connStr); var doc = CliHelper.ParseResult(json); - if (doc == null) return; + if (doc is null) return; Console.WriteLine("✅ Database dropped successfully."); } diff --git a/src/Sheetly.CLI/Commands/ListCommand.cs b/src/Sheetly.CLI/Commands/ListCommand.cs index b602623..93cc2eb 100644 --- a/src/Sheetly.CLI/Commands/ListCommand.cs +++ b/src/Sheetly.CLI/Commands/ListCommand.cs @@ -34,7 +34,6 @@ private async Task ExecuteAsync(string? projectPath, CancellationToken ct) return; } - // List C# migration files (exclude ModelSnapshot) var migrations = Directory.GetFiles(migrationsDir, "*.cs") .Where(f => !f.Contains("ModelSnapshot")) .OrderBy(f => f) diff --git a/src/Sheetly.CLI/Commands/RemoveCommand.cs b/src/Sheetly.CLI/Commands/RemoveCommand.cs index 6b87dc9..e72cd31 100644 --- a/src/Sheetly.CLI/Commands/RemoveCommand.cs +++ b/src/Sheetly.CLI/Commands/RemoveCommand.cs @@ -26,7 +26,7 @@ private async Task ExecuteAsync(string? projectPath, CancellationToken ct) var json = CliHelper.InvokeDesignTime(coreAsm, "RemoveMigration", contextType); var doc = CliHelper.ParseResult(json); - if (doc == null) return; + if (doc is null) return; var root = doc.RootElement; Console.WriteLine($"✅ Migration removed: '{root.GetProperty("removedFile").GetString()}'"); diff --git a/src/Sheetly.CLI/Commands/RollbackCommand.cs b/src/Sheetly.CLI/Commands/RollbackCommand.cs index e332c1c..0d683fb 100644 --- a/src/Sheetly.CLI/Commands/RollbackCommand.cs +++ b/src/Sheetly.CLI/Commands/RollbackCommand.cs @@ -38,7 +38,6 @@ private async Task ExecuteAsync(bool noBuild, string? projectPath, CancellationT string contextProjectDir = CliHelper.FindProjectRootFromDll(contextType.Assembly.Location); string migrationsDir = Path.Combine(contextProjectDir, "Migrations"); - // Find last C# migration var migrations = Directory.GetFiles(migrationsDir, "*.cs") .Where(f => !f.EndsWith(".Designer.cs")) .OrderByDescending(f => f) @@ -62,18 +61,15 @@ private async Task ExecuteAsync(bool noBuild, string? projectPath, CancellationT return; } - // Delete the migration file File.Delete(lastMigration); Console.WriteLine($"✅ Deleted: {migrationFileName}"); - // If there are previous migrations, restore snapshot from them if (migrations.Count > 1) { Console.WriteLine("💡 Run 'dotnet-sheetly migrations add' again to regenerate snapshot from current model."); } else { - // Delete ModelSnapshot if no more migrations var snapshotFiles = Directory.GetFiles(migrationsDir, "*ModelSnapshot.cs"); foreach (var sf in snapshotFiles) { diff --git a/src/Sheetly.CLI/Commands/ScaffoldCommand.cs b/src/Sheetly.CLI/Commands/ScaffoldCommand.cs index d2b58a8..c68d98d 100644 --- a/src/Sheetly.CLI/Commands/ScaffoldCommand.cs +++ b/src/Sheetly.CLI/Commands/ScaffoldCommand.cs @@ -1,4 +1,4 @@ -using Sheetly.CLI.Helpers; +using Sheetly.CLI.Helpers; using System.CommandLine; namespace Sheetly.CLI.Commands; @@ -37,7 +37,7 @@ private async Task ExecuteAsync(bool noBuild, string? projectPath, string? outpu Console.WriteLine("⏳ Scaffolding models from remote provider..."); var json = CliHelper.InvokeDesignTime(coreAsm, "ScaffoldAsync", contextType, outputDir, connStr); var doc = CliHelper.ParseResult(json); - if (doc == null) return; + if (doc is null) return; foreach (var f in doc.RootElement.GetProperty("files").EnumerateArray()) Console.WriteLine($"📄 Created: {f.GetString()}"); diff --git a/src/Sheetly.CLI/Commands/ScriptCommand.cs b/src/Sheetly.CLI/Commands/ScriptCommand.cs index f77f659..1daa7ef 100644 --- a/src/Sheetly.CLI/Commands/ScriptCommand.cs +++ b/src/Sheetly.CLI/Commands/ScriptCommand.cs @@ -1,4 +1,4 @@ -using Sheetly.CLI.Helpers; +using Sheetly.CLI.Helpers; using System.CommandLine; namespace Sheetly.CLI.Commands; @@ -30,7 +30,7 @@ private async Task ExecuteAsync(bool noBuild, string? projectPath) var json = CliHelper.InvokeDesignTime(coreAsm, "GetSchemaScript", contextType); var doc = CliHelper.ParseResult(json); - if (doc == null) return; + if (doc is null) return; Console.Write(doc.RootElement.GetProperty("script").GetString()); } diff --git a/src/Sheetly.CLI/Commands/UpdateCommand.cs b/src/Sheetly.CLI/Commands/UpdateCommand.cs index 8e101e8..dc006cd 100644 --- a/src/Sheetly.CLI/Commands/UpdateCommand.cs +++ b/src/Sheetly.CLI/Commands/UpdateCommand.cs @@ -36,7 +36,7 @@ private async Task ExecuteAsync(bool noBuild, string? projectPath, CancellationT Console.WriteLine("⏳ Applying pending migrations..."); var json = CliHelper.InvokeDesignTime(coreAsm, "UpdateDatabaseAsync", contextType, connStr); var doc = CliHelper.ParseResult(json); - if (doc == null) return; + if (doc is null) return; var root = doc.RootElement; int total = root.GetProperty("total").GetInt32(); @@ -55,7 +55,7 @@ private async Task ExecuteAsync(bool noBuild, string? projectPath, CancellationT catch (Exception ex) { Console.WriteLine($"❌ Error: {ex.Message}"); - if (ex.InnerException != null) Console.WriteLine($"🔍 Detail: {ex.InnerException.Message}"); + if (ex.InnerException is not null) Console.WriteLine($"🔍 Detail: {ex.InnerException.Message}"); } } } diff --git a/src/Sheetly.CLI/Helpers/CliHelper.cs b/src/Sheetly.CLI/Helpers/CliHelper.cs index 0fa2e16..6825b48 100644 --- a/src/Sheetly.CLI/Helpers/CliHelper.cs +++ b/src/Sheetly.CLI/Helpers/CliHelper.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration; using System.Reflection; using System.Text.Json; @@ -39,7 +39,6 @@ internal static string InvokeDesignTime(Assembly coreAsm, string methodName, par var result = method.Invoke(null, args); - // Handle async methods (Task) if (result is Task task) { task.GetAwaiter().GetResult(); @@ -75,7 +74,7 @@ internal static Type FindContextType(Assembly assembly) public static bool IsSubclassOfSheetsContext(Type? type) { - while (type != null && type != typeof(object)) + while (type is not null && type != typeof(object)) { if (type.FullName == "Sheetly.Core.SheetsContext") return true; type = type.BaseType; @@ -97,7 +96,7 @@ public static string FindProjectDll(bool noBuild, string? manualPath) { if (!string.IsNullOrEmpty(manualPath)) return manualPath; var csproj = Directory.GetFiles(Directory.GetCurrentDirectory(), "*.csproj").FirstOrDefault(); - if (csproj == null) return string.Empty; + if (csproj is null) return string.Empty; if (!noBuild) { @@ -125,7 +124,7 @@ public static string FindProjectDll(bool noBuild, string? manualPath) public static string FindProjectRootFromDll(string dllPath) { var dir = new DirectoryInfo(Path.GetDirectoryName(dllPath)!); - while (dir != null && !dir.GetFiles("*.csproj").Any()) dir = dir.Parent; + while (dir is not null && !dir.GetFiles("*.csproj").Any()) dir = dir.Parent; return dir?.FullName ?? Path.GetDirectoryName(dllPath)!; } diff --git a/src/Sheetly.CLI/Helpers/ProjectAssemblyLoadContext.cs b/src/Sheetly.CLI/Helpers/ProjectAssemblyLoadContext.cs index 607c1dd..8ba4fbf 100644 --- a/src/Sheetly.CLI/Helpers/ProjectAssemblyLoadContext.cs +++ b/src/Sheetly.CLI/Helpers/ProjectAssemblyLoadContext.cs @@ -18,8 +18,7 @@ public ProjectAssemblyLoadContext(string dllPath) : base(isCollectible: true) protected override Assembly? Load(AssemblyName assemblyName) { - // Resolve from the project's own bin directory first var path = _resolver.ResolveAssemblyToPath(assemblyName); - return path != null ? LoadFromAssemblyPath(path) : null; + return path is not null ? LoadFromAssemblyPath(path) : null; } } diff --git a/src/Sheetly.CLI/Program.cs b/src/Sheetly.CLI/Program.cs index f9a5e5a..1251fdd 100644 --- a/src/Sheetly.CLI/Program.cs +++ b/src/Sheetly.CLI/Program.cs @@ -1,4 +1,4 @@ -using Sheetly.CLI.Commands; +using Sheetly.CLI.Commands; using System.CommandLine; using System.Reflection; @@ -7,23 +7,19 @@ .GetCustomAttribute()! .InformationalVersion; -// Root Command RootCommand rootCommand = new("Sheetly CLI - Google Sheets ORM Tool"); var migrationsCommand = new Command("migrations", "Manage migrations"); var databaseCommand = new Command("database", "Manage the database"); -// Migrations subcommands migrationsCommand.Subcommands.Add(new AddCommand()); migrationsCommand.Subcommands.Add(new RemoveCommand()); migrationsCommand.Subcommands.Add(new ListCommand()); migrationsCommand.Subcommands.Add(new ScriptCommand()); -// Database subcommands databaseCommand.Subcommands.Add(new UpdateCommand()); databaseCommand.Subcommands.Add(new DropCommand()); -// Add to root rootCommand.Subcommands.Add(migrationsCommand); rootCommand.Subcommands.Add(databaseCommand); rootCommand.Subcommands.Add(new ScaffoldCommand()); From 0458beb6c8d43cfb8b6dcdc174d63accfc1bdd69 Mon Sep 17 00:00:00 2001 From: muqimjon Date: Sat, 28 Feb 2026 12:34:56 +0500 Subject: [PATCH 29/36] refactor: restructure samples - delete Test, update Sample for Excel+Google --- Sheetly.sln | 15 - samples/Sheetly.Sample/AppDbContext.cs | 27 +- samples/Sheetly.Sample/ComprehensiveTests.cs | 390 ------------------ .../20260222142948_InitialCreate.cs | 33 -- .../20260228072548_InitialCreate.cs} | 13 +- .../Migrations/AppDbModelSnapshot.cs | 281 +++++++------ samples/Sheetly.Sample/Program.cs | 41 +- samples/Sheetly.Sample/Sheetly.Sample.csproj | 1 + .../TestForeignKeyConstraints.cs | 61 --- .../TestValidationConstraints.cs | 259 ------------ samples/Sheetly.Sample/appsettings.json | 2 +- .../Sheetly.Test/Contexts/ExcelAppContext.cs | 34 -- .../Sheetly.Test/Contexts/GoogleAppContext.cs | 39 -- .../Migrations/ExcelAppModelSnapshot.cs | 139 ------- samples/Sheetly.Test/Models/Category.cs | 14 - samples/Sheetly.Test/Models/Product.cs | 19 - samples/Sheetly.Test/Program.cs | 129 ------ samples/Sheetly.Test/Sheetly.Test.csproj | 22 - .../Migrations/MigrationBuilder.New.cs | 282 ------------- 19 files changed, 192 insertions(+), 1609 deletions(-) delete mode 100644 samples/Sheetly.Sample/ComprehensiveTests.cs delete mode 100644 samples/Sheetly.Sample/Migrations/20260222142948_InitialCreate.cs rename samples/{Sheetly.Test/Migrations/20260227231549_InitialCreate.cs => Sheetly.Sample/Migrations/20260228072548_InitialCreate.cs} (68%) delete mode 100644 samples/Sheetly.Sample/TestForeignKeyConstraints.cs delete mode 100644 samples/Sheetly.Sample/TestValidationConstraints.cs delete mode 100644 samples/Sheetly.Test/Contexts/ExcelAppContext.cs delete mode 100644 samples/Sheetly.Test/Contexts/GoogleAppContext.cs delete mode 100644 samples/Sheetly.Test/Migrations/ExcelAppModelSnapshot.cs delete mode 100644 samples/Sheetly.Test/Models/Category.cs delete mode 100644 samples/Sheetly.Test/Models/Product.cs delete mode 100644 samples/Sheetly.Test/Program.cs delete mode 100644 samples/Sheetly.Test/Sheetly.Test.csproj delete mode 100644 src/Sheetly.Core/Migrations/MigrationBuilder.New.cs diff --git a/Sheetly.sln b/Sheetly.sln index 402d5e7..b3af6e9 100644 --- a/Sheetly.sln +++ b/Sheetly.sln @@ -23,8 +23,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sheetly.Core.Tests", "tests EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sheetly.Excel", "src\Sheetly.Excel\Sheetly.Excel.csproj", "{7B2AF4C6-ADBF-44BF-BE19-8C39CA06D63B}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sheetly.Test", "samples\Sheetly.Test\Sheetly.Test.csproj", "{49208693-C863-4812-8211-1CDA042B9804}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -119,18 +117,6 @@ Global {7B2AF4C6-ADBF-44BF-BE19-8C39CA06D63B}.Release|x64.Build.0 = Release|Any CPU {7B2AF4C6-ADBF-44BF-BE19-8C39CA06D63B}.Release|x86.ActiveCfg = Release|Any CPU {7B2AF4C6-ADBF-44BF-BE19-8C39CA06D63B}.Release|x86.Build.0 = Release|Any CPU - {49208693-C863-4812-8211-1CDA042B9804}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {49208693-C863-4812-8211-1CDA042B9804}.Debug|Any CPU.Build.0 = Debug|Any CPU - {49208693-C863-4812-8211-1CDA042B9804}.Debug|x64.ActiveCfg = Debug|Any CPU - {49208693-C863-4812-8211-1CDA042B9804}.Debug|x64.Build.0 = Debug|Any CPU - {49208693-C863-4812-8211-1CDA042B9804}.Debug|x86.ActiveCfg = Debug|Any CPU - {49208693-C863-4812-8211-1CDA042B9804}.Debug|x86.Build.0 = Debug|Any CPU - {49208693-C863-4812-8211-1CDA042B9804}.Release|Any CPU.ActiveCfg = Release|Any CPU - {49208693-C863-4812-8211-1CDA042B9804}.Release|Any CPU.Build.0 = Release|Any CPU - {49208693-C863-4812-8211-1CDA042B9804}.Release|x64.ActiveCfg = Release|Any CPU - {49208693-C863-4812-8211-1CDA042B9804}.Release|x64.Build.0 = Release|Any CPU - {49208693-C863-4812-8211-1CDA042B9804}.Release|x86.ActiveCfg = Release|Any CPU - {49208693-C863-4812-8211-1CDA042B9804}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -143,7 +129,6 @@ Global {87D2D7B9-819E-4F2A-B511-75A8CAC4DBDB} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {F7D83F5A-F558-4DD7-B025-C14A57B73164} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {7B2AF4C6-ADBF-44BF-BE19-8C39CA06D63B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} - {49208693-C863-4812-8211-1CDA042B9804} = {EDE96271-BDBB-4A48-B4A3-C890C939E193} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {2901C6BF-30A0-42C6-97E2-7193CF638399} diff --git a/samples/Sheetly.Sample/AppDbContext.cs b/samples/Sheetly.Sample/AppDbContext.cs index 258a736..7428000 100644 --- a/samples/Sheetly.Sample/AppDbContext.cs +++ b/samples/Sheetly.Sample/AppDbContext.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Configuration; using Sheetly.Core; using Sheetly.Core.Configuration; +using Sheetly.Excel; using Sheetly.Google; using Sheetly.Sample.Models; @@ -18,12 +19,13 @@ protected override void OnConfiguring(SheetsOptions options) .AddJsonFile("appsettings.json") .Build(); - var connectionString = config.GetConnectionString("DefaultConnection"); + var connectionString = config.GetConnectionString("DefaultConnection") + ?? throw new Exception("Connection string 'DefaultConnection' not found."); - if (string.IsNullOrEmpty(connectionString)) - throw new Exception("Connection string 'DefaultConnection' not found."); - - options.UseGoogleSheets(connectionString); + if (connectionString.Contains("Provider=Excel", StringComparison.OrdinalIgnoreCase)) + options.UseExcel(ExtractFilePath(connectionString)); + else + options.UseGoogleSheets(connectionString); } protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -46,9 +48,20 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasMaxLength(200); e.Property(p => p.Price) .IsRequired() - .HasRange(0, 1000000); // Price must be between 0 and 1,000,000 + .HasRange(0, 1000000); e.Property(p => p.Description) - .HasMaxLength(500); // Optional description, max 500 chars + .HasMaxLength(500); }); } + + private static string ExtractFilePath(string connectionString) + { + foreach (var part in connectionString.Split(';')) + { + var kv = part.Split('=', 2); + if (kv.Length == 2 && kv[0].Trim().Equals("FilePath", StringComparison.OrdinalIgnoreCase)) + return kv[1].Trim(); + } + throw new Exception("FilePath not found in Excel connection string."); + } } \ No newline at end of file diff --git a/samples/Sheetly.Sample/ComprehensiveTests.cs b/samples/Sheetly.Sample/ComprehensiveTests.cs deleted file mode 100644 index 558712e..0000000 --- a/samples/Sheetly.Sample/ComprehensiveTests.cs +++ /dev/null @@ -1,390 +0,0 @@ -using Sheetly.Sample.Models; - -namespace Sheetly.Sample; - -public static class ComprehensiveTests -{ - public static async Task RunAllTests() - { - Console.WriteLine("\n" + new string('=', 80)); - Console.WriteLine("🧪 SHEETLY v1.0.0 - COMPREHENSIVE TEST SUITE"); - Console.WriteLine(new string('=', 80)); - Console.WriteLine(); - - var results = new List<(string TestName, bool Passed, string Message)>(); - - // Test 1: Basic CRUD - results.Add(await TestBasicCRUD()); - - // Test 2: ID Uniqueness after restart - results.Add(await TestIDUniquenessAfterRestart()); - - // Test 3: FK Constraint - Restrict - results.Add(await TestFKRestrict()); - - // Test 4: Update operations - results.Add(await TestUpdateOperation()); - - // Test 5: Delete operation - results.Add(await TestDeleteOperation()); - - // Summary - Console.WriteLine("\n" + new string('=', 80)); - Console.WriteLine("📊 TEST SUMMARY"); - Console.WriteLine(new string('=', 80)); - - int passed = 0; - int failed = 0; - - foreach (var result in results) - { - var status = result.Passed ? "✅ PASSED" : "❌ FAILED"; - Console.WriteLine($"{status} | {result.TestName}"); - if (!string.IsNullOrEmpty(result.Message)) - { - Console.WriteLine($" {result.Message}"); - } - - if (result.Passed) passed++; - else failed++; - } - - Console.WriteLine(new string('-', 80)); - Console.WriteLine($"Total: {results.Count} tests | Passed: {passed} | Failed: {failed}"); - Console.WriteLine(new string('=', 80)); - - if (failed == 0) - { - Console.WriteLine("\n🎉 ALL TESTS PASSED! Sheetly is working perfectly!"); - } - else - { - Console.WriteLine($"\n⚠️ {failed} test(s) failed. Please check the details above."); - } - } - - private static async Task<(string, bool, string)> TestBasicCRUD() - { - Console.WriteLine("📋 TEST 1: Basic CRUD Operations"); - Console.WriteLine(new string('-', 80)); - - try - { - using var db = new AppDbContext(); - await db.InitializeAsync(); - - // CREATE - Console.WriteLine(" ➤ Creating Category..."); - var category = new Category { Name = "TestCategory_CRUD" }; - db.Categories.Add(category); - await db.SaveChangesAsync(); - - if (category.Id <= 0) - { - return ("Basic CRUD - CREATE", false, "Category ID was not generated"); - } - Console.WriteLine($" ✓ Category created with ID: {category.Id}"); - - // CREATE Product - Console.WriteLine(" ➤ Creating Product..."); - var product = new Product - { - Title = "TestProduct_CRUD", - Price = 99.99m, - CategoryId = (int)category.Id - }; - db.Products.Add(product); - await db.SaveChangesAsync(); - - if (product.Id <= 0) - { - return ("Basic CRUD - CREATE", false, "Product ID was not generated"); - } - Console.WriteLine($" ✓ Product created with ID: {product.Id}"); - - // READ - Console.WriteLine(" ➤ Reading data..."); - var categories = await db.Categories.ToListAsync(); - var products = await db.Products.ToListAsync(); - - if (!categories.Any(c => c.Id == category.Id)) - { - return ("Basic CRUD - READ", false, "Category not found after save"); - } - - if (!products.Any(p => p.Id == product.Id)) - { - return ("Basic CRUD - READ", false, "Product not found after save"); - } - - Console.WriteLine($" ✓ Found {categories.Count} categories, {products.Count} products"); - Console.WriteLine(); - - return ("Basic CRUD Operations", true, $"Category ID={category.Id}, Product ID={product.Id}"); - } - catch (Exception ex) - { - Console.WriteLine($" ✗ Error: {ex.Message}"); - Console.WriteLine(); - return ("Basic CRUD Operations", false, ex.Message); - } - } - - private static async Task<(string, bool, string)> TestIDUniquenessAfterRestart() - { - Console.WriteLine("📋 TEST 2: ID Uniqueness After Restart"); - Console.WriteLine(new string('-', 80)); - - try - { - // First context - get current max IDs - long maxCategoryId; - int maxProductId; - - using (var db1 = new AppDbContext()) - { - await db1.InitializeAsync(); - var categories = await db1.Categories.ToListAsync(); - var products = await db1.Products.ToListAsync(); - - maxCategoryId = categories.Any() ? categories.Max(c => c.Id) : 0; - maxProductId = products.Any() ? products.Max(p => p.Id) : 0; - - Console.WriteLine($" ➤ Current MAX IDs: Category={maxCategoryId}, Product={maxProductId}"); - } - - // Simulate restart - new context - using (var db2 = new AppDbContext()) - { - await db2.InitializeAsync(); - - Console.WriteLine(" ➤ Creating new records after 'restart'..."); - var newCategory = new Category { Name = "TestCategory_Restart" }; - db2.Categories.Add(newCategory); - await db2.SaveChangesAsync(); - - var newProduct = new Product - { - Title = "TestProduct_Restart", - Price = 150m, - CategoryId = (int)newCategory.Id - }; - db2.Products.Add(newProduct); - await db2.SaveChangesAsync(); - - Console.WriteLine($" ➤ New IDs: Category={newCategory.Id}, Product={newProduct.Id}"); - - // Verify IDs are unique (greater than previous max) - if (newCategory.Id <= maxCategoryId) - { - return ("ID Uniqueness", false, - $"Category ID not unique! Expected >{maxCategoryId}, got {newCategory.Id}"); - } - - if (newProduct.Id <= maxProductId) - { - return ("ID Uniqueness", false, - $"Product ID not unique! Expected >{maxProductId}, got {newProduct.Id}"); - } - - Console.WriteLine($" ✓ IDs are unique and sequential"); - Console.WriteLine(); - - return ("ID Uniqueness After Restart", true, - $"New Category ID={newCategory.Id}, Product ID={newProduct.Id}"); - } - } - catch (Exception ex) - { - Console.WriteLine($" ✗ Error: {ex.Message}"); - Console.WriteLine(); - return ("ID Uniqueness After Restart", false, ex.Message); - } - } - - private static async Task<(string, bool, string)> TestFKRestrict() - { - Console.WriteLine("📋 TEST 3: Foreign Key Constraint (Restrict)"); - Console.WriteLine(new string('-', 80)); - - try - { - using var db = new AppDbContext(); - await db.InitializeAsync(); - - // Create category with product - Console.WriteLine(" ➤ Creating Category with Product..."); - var category = new Category { Name = "TestCategory_FK" }; - db.Categories.Add(category); - await db.SaveChangesAsync(); - - var product = new Product - { - Title = "TestProduct_FK", - Price = 200m, - CategoryId = (int)category.Id - }; - db.Products.Add(product); - await db.SaveChangesAsync(); - - Console.WriteLine($" ✓ Created Category ID={category.Id} with Product ID={product.Id}"); - - // Try to delete category (should fail) - Console.WriteLine(" ➤ Attempting to delete Category with dependent Product..."); - db.Categories.Remove(category); - - try - { - await db.SaveChangesAsync(); - Console.WriteLine(" ✗ Delete succeeded (should have been blocked!)"); - Console.WriteLine(); - return ("FK Constraint Restrict", false, "FK constraint did not prevent delete"); - } - catch (InvalidOperationException ex) - { - if (ex.Message.Contains("Cannot delete")) - { - Console.WriteLine($" ✓ FK constraint blocked delete as expected"); - Console.WriteLine($" Message: {ex.Message.Substring(0, Math.Min(80, ex.Message.Length))}..."); - Console.WriteLine(); - return ("FK Constraint Restrict", true, "FK constraint working correctly"); - } - throw; - } - } - catch (Exception ex) - { - Console.WriteLine($" ✗ Unexpected error: {ex.Message}"); - Console.WriteLine(); - return ("FK Constraint Restrict", false, ex.Message); - } - } - - private static async Task<(string, bool, string)> TestUpdateOperation() - { - Console.WriteLine("📋 TEST 4: Update Operation"); - Console.WriteLine(new string('-', 80)); - - try - { - using var db = new AppDbContext(); - await db.InitializeAsync(); - - // Create product - Console.WriteLine(" ➤ Creating Product..."); - var product = new Product - { - Title = "TestProduct_Update", - Price = 100m, - CategoryId = 1 - }; - db.Products.Add(product); - await db.SaveChangesAsync(); - var originalId = product.Id; - - Console.WriteLine($" ✓ Created Product ID={product.Id}, Price=${product.Price}"); - - // Re-load product to ensure it's tracked (after SaveChanges cleared tracking) - var allProducts = await db.Products.ToListAsync(); - var productToUpdate = allProducts.FirstOrDefault(p => p.Id == originalId); - - if (productToUpdate == null) - { - return ("Update Operation", false, "Product not found before update"); - } - - // Update price - Console.WriteLine(" ➤ Updating price..."); - productToUpdate.Price = 150.50m; - db.Products.Update(productToUpdate); // Mark as modified - await db.SaveChangesAsync(); - - // Verify update (read fresh) - var products = await db.Products.ToListAsync(); - var updatedProduct = products.FirstOrDefault(p => p.Id == originalId); - - if (updatedProduct == null) - { - return ("Update Operation", false, "Product not found after update"); - } - - if (updatedProduct.Price != 150.50m) - { - return ("Update Operation", false, - $"Price not updated correctly. Expected 150.50, got {updatedProduct.Price}"); - } - - Console.WriteLine($" ✓ Price updated successfully to ${updatedProduct.Price}"); - Console.WriteLine(); - - return ("Update Operation", true, $"Updated Product ID={originalId}"); - } - catch (Exception ex) - { - Console.WriteLine($" ✗ Error: {ex.Message}"); - Console.WriteLine(); - return ("Update Operation", false, ex.Message); - } - } - - private static async Task<(string, bool, string)> TestDeleteOperation() - { - Console.WriteLine("📋 TEST 5: Delete Operation"); - Console.WriteLine(new string('-', 80)); - - try - { - using var db = new AppDbContext(); - await db.InitializeAsync(); - - // Create product without dependencies - Console.WriteLine(" ➤ Creating standalone Product..."); - var product = new Product - { - Title = "TestProduct_Delete", - Price = 75m, - CategoryId = 1 - }; - db.Products.Add(product); - await db.SaveChangesAsync(); - var productId = product.Id; - - Console.WriteLine($" ✓ Created Product ID={productId}"); - - // Delete - Console.WriteLine(" ➤ Deleting Product..."); - - // Re-load product to ensure it's tracked (after SaveChanges cleared tracking) - var productsToDelete = await db.Products.ToListAsync(); - var productToDelete = productsToDelete.FirstOrDefault(p => p.Id == productId); - - if (productToDelete == null) - { - return ("Delete Operation", false, "Product not found before delete"); - } - - db.Products.Remove(productToDelete); - await db.SaveChangesAsync(); - - // Verify deletion - var products = await db.Products.ToListAsync(); - var deletedProduct = products.FirstOrDefault(p => p.Id == productId); - - if (deletedProduct != null) - { - return ("Delete Operation", false, "Product still exists after delete"); - } - - Console.WriteLine($" ✓ Product deleted successfully"); - Console.WriteLine(); - - return ("Delete Operation", true, $"Deleted Product ID={productId}"); - } - catch (Exception ex) - { - Console.WriteLine($" ✗ Error: {ex.Message}"); - Console.WriteLine(); - return ("Delete Operation", false, ex.Message); - } - } -} diff --git a/samples/Sheetly.Sample/Migrations/20260222142948_InitialCreate.cs b/samples/Sheetly.Sample/Migrations/20260222142948_InitialCreate.cs deleted file mode 100644 index 6c1c7bf..0000000 --- a/samples/Sheetly.Sample/Migrations/20260222142948_InitialCreate.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Sheetly.Core.Migrations; - -namespace Sheetly.Sample.Migrations; - -[Migration("20260222142948_InitialCreate")] -public partial class InitialCreate : Migration -{ - public override void Up(MigrationBuilder builder) - { - // ClassName: Category - builder.CreateTable("Categories", table => table - .Column("Id", c => c.IsPrimaryKey().IsUnique()) - .Column("Name") - ); - - // ClassName: Product - builder.CreateTable("Products", table => table - .Column("Id", c => c.IsPrimaryKey().IsUnique()) - .Column("Title") - .Column("Price", c => c.IsRequired()) - .Column("Description") - .Column("Stock", c => c.IsRequired()) - .Column("CategoryId", c => c.IsRequired().IsForeignKey("Categories")) - ); - - } - - public override void Down(MigrationBuilder builder) - { - builder.DropTable("Products"); - builder.DropTable("Categories"); - } -} diff --git a/samples/Sheetly.Test/Migrations/20260227231549_InitialCreate.cs b/samples/Sheetly.Sample/Migrations/20260228072548_InitialCreate.cs similarity index 68% rename from samples/Sheetly.Test/Migrations/20260227231549_InitialCreate.cs rename to samples/Sheetly.Sample/Migrations/20260228072548_InitialCreate.cs index 5718f76..486a591 100644 --- a/samples/Sheetly.Test/Migrations/20260227231549_InitialCreate.cs +++ b/samples/Sheetly.Sample/Migrations/20260228072548_InitialCreate.cs @@ -1,25 +1,24 @@ using Sheetly.Core.Migrations; using Sheetly.Core.Migrations.Operations; -namespace Sheetly.Test.Contexts.Migrations; +namespace Sheetly.Sample.Migrations; -[Migration("20260227231549_InitialCreate")] +[Migration("20260228072548_InitialCreate")] public partial class InitialCreate : Migration { public override void Up(MigrationBuilder builder) { - // ClassName: Category builder.CreateTable("Categories", table => table - .Column("Id", c => c.IsPrimaryKey().IsUnique()) + .Column("Id", c => c.IsPrimaryKey().IsUnique()) .Column("Name", c => c.IsRequired().HasMaxLength(100)) ); - // ClassName: Product builder.CreateTable("Products", table => table .Column("Id", c => c.IsPrimaryKey().IsUnique()) - .Column("Name", c => c.IsRequired().HasMaxLength(200)) + .Column("Title", c => c.IsRequired().HasMaxLength(200)) .Column("Price", c => c.IsRequired()) - .Column("Description") + .Column("Description", c => c.HasMaxLength(500)) + .Column("Stock", c => c.IsRequired()) .Column("CategoryId", c => c.IsRequired().IsForeignKey("Categories")) ); diff --git a/samples/Sheetly.Sample/Migrations/AppDbModelSnapshot.cs b/samples/Sheetly.Sample/Migrations/AppDbModelSnapshot.cs index 8b27cb2..d730c94 100644 --- a/samples/Sheetly.Sample/Migrations/AppDbModelSnapshot.cs +++ b/samples/Sheetly.Sample/Migrations/AppDbModelSnapshot.cs @@ -1,148 +1,155 @@ +using System; using Sheetly.Core.Migration; namespace Sheetly.Sample.Migrations; public partial class AppDbModelSnapshot : MigrationSnapshot { - public AppDbModelSnapshot() - { - var snapshot = BuildModel(); - this.Entities = snapshot.Entities; - this.ModelHash = snapshot.ModelHash; - this.Version = snapshot.Version; - this.LastUpdated = snapshot.LastUpdated; - } + public AppDbModelSnapshot() + { + var snapshot = BuildModel(); + this.Entities = snapshot.Entities; + this.ModelHash = snapshot.ModelHash; + this.Version = snapshot.Version; + this.LastUpdated = snapshot.LastUpdated; + } - public static MigrationSnapshot BuildModel() - { - var snapshot = new MigrationSnapshot - { - ModelHash = "bwhvOP7ZTBivUrr39R2aR3SRrkfWllxn7VpK5f1bucg=", - Version = "1.0.0", - LastUpdated = DateTime.Parse("2026-02-22T14:29:48.7373255Z") - }; + public static MigrationSnapshot BuildModel() + { + var snapshot = new MigrationSnapshot + { + ModelHash = "B9emMa1A++cOQMHt5sY3NkJRTAb1yP/Ei7sKWFlwVDw=", + Version = "1.0.0", + LastUpdated = DateTime.Parse("2026-02-28T07:25:48.1042270Z") + }; - // Category - snapshot.Entities["Categories"] = new EntitySchema - { - TableName = "Categories", - ClassName = "Category", - Namespace = "Sheetly.Sample.Models", - Columns = new List - { - new ColumnSchema - { - Name = "Id", - PropertyName = "Id", - DataType = "Int64", - IsPrimaryKey = true, - IsAutoIncrement = true, - IsForeignKey = false, - ForeignKeyColumn = "Id", - IsNullable = false, - IsRequired = false - }, - new ColumnSchema - { - Name = "Name", - PropertyName = "Name", - DataType = "String", - IsPrimaryKey = false, - IsAutoIncrement = false, - IsForeignKey = false, - ForeignKeyColumn = "Id", - IsNullable = true, - IsRequired = false - } - }, - Relationships = new List() - }; + // Category + snapshot.Entities["Categories"] = new EntitySchema + { + TableName = "Categories", + ClassName = "Category", + Namespace = "Sheetly.Sample.Models", + Columns = new List + { + new ColumnSchema + { + Name = "Id", + PropertyName = "Id", + DataType = "Int64", + IsPrimaryKey = true, + IsAutoIncrement = true, + IsForeignKey = false, + ForeignKeyColumn = "Id", + IsNullable = false, + IsRequired = false + }, + new ColumnSchema + { + Name = "Name", + PropertyName = "Name", + DataType = "String", + IsPrimaryKey = false, + IsAutoIncrement = false, + IsForeignKey = false, + ForeignKeyColumn = "Id", + IsNullable = false, + IsRequired = true, + MaxLength = 100, + MinLength = 3 + } + }, + Relationships = new List() + }; - // Product - snapshot.Entities["Products"] = new EntitySchema - { - TableName = "Products", - ClassName = "Product", - Namespace = "Sheetly.Sample.Models", - Columns = new List - { - new ColumnSchema - { - Name = "Id", - PropertyName = "Id", - DataType = "Int32", - IsPrimaryKey = true, - IsAutoIncrement = true, - IsForeignKey = false, - ForeignKeyColumn = "Id", - IsNullable = false, - IsRequired = false - }, - new ColumnSchema - { - Name = "Title", - PropertyName = "Title", - DataType = "String", - IsPrimaryKey = false, - IsAutoIncrement = false, - IsForeignKey = false, - ForeignKeyColumn = "Id", - IsNullable = true, - IsRequired = false - }, - new ColumnSchema - { - Name = "Price", - PropertyName = "Price", - DataType = "Decimal", - IsPrimaryKey = false, - IsAutoIncrement = false, - IsForeignKey = false, - ForeignKeyColumn = "Id", - IsNullable = false, - IsRequired = false - }, - new ColumnSchema - { - Name = "Description", - PropertyName = "Description", - DataType = "String", - IsPrimaryKey = false, - IsAutoIncrement = false, - IsForeignKey = false, - ForeignKeyColumn = "Id", - IsNullable = true, - IsRequired = false - }, - new ColumnSchema - { - Name = "Stock", - PropertyName = "Stock", - DataType = "Int32", - IsPrimaryKey = false, - IsAutoIncrement = false, - IsForeignKey = false, - ForeignKeyColumn = "Id", - IsNullable = false, - IsRequired = false - }, - new ColumnSchema - { - Name = "CategoryId", - PropertyName = "CategoryId", - DataType = "Int32", - IsPrimaryKey = false, - IsAutoIncrement = false, - IsForeignKey = true, - ForeignKeyTable = "Categories", - ForeignKeyColumn = "Id", - IsNullable = false, - IsRequired = false - } - }, - Relationships = new List() - }; + // Product + snapshot.Entities["Products"] = new EntitySchema + { + TableName = "Products", + ClassName = "Product", + Namespace = "Sheetly.Sample.Models", + Columns = new List + { + new ColumnSchema + { + Name = "Id", + PropertyName = "Id", + DataType = "Int32", + IsPrimaryKey = true, + IsAutoIncrement = true, + IsForeignKey = false, + ForeignKeyColumn = "Id", + IsNullable = false, + IsRequired = false + }, + new ColumnSchema + { + Name = "Title", + PropertyName = "Title", + DataType = "String", + IsPrimaryKey = false, + IsAutoIncrement = false, + IsForeignKey = false, + ForeignKeyColumn = "Id", + IsNullable = false, + IsRequired = true, + MaxLength = 200 + }, + new ColumnSchema + { + Name = "Price", + PropertyName = "Price", + DataType = "Decimal", + IsPrimaryKey = false, + IsAutoIncrement = false, + IsForeignKey = false, + ForeignKeyColumn = "Id", + IsNullable = false, + IsRequired = true, + MinValue = 0m, + MaxValue = 1000000m + }, + new ColumnSchema + { + Name = "Description", + PropertyName = "Description", + DataType = "String", + IsPrimaryKey = false, + IsAutoIncrement = false, + IsForeignKey = false, + ForeignKeyColumn = "Id", + IsNullable = true, + IsRequired = false, + MaxLength = 500 + }, + new ColumnSchema + { + Name = "Stock", + PropertyName = "Stock", + DataType = "Int32", + IsPrimaryKey = false, + IsAutoIncrement = false, + IsForeignKey = false, + ForeignKeyColumn = "Id", + IsNullable = false, + IsRequired = false + }, + new ColumnSchema + { + Name = "CategoryId", + PropertyName = "CategoryId", + DataType = "Int32", + IsPrimaryKey = false, + IsAutoIncrement = false, + IsForeignKey = true, + ForeignKeyTable = "Categories", + ForeignKeyColumn = "Id", + IsNullable = false, + IsRequired = false + } + }, + Relationships = new List() + }; - return snapshot; - } + return snapshot; + } } diff --git a/samples/Sheetly.Sample/Program.cs b/samples/Sheetly.Sample/Program.cs index d0f50bb..362bb42 100644 --- a/samples/Sheetly.Sample/Program.cs +++ b/samples/Sheetly.Sample/Program.cs @@ -1,26 +1,25 @@ using Sheetly.Sample; +using Sheetly.Sample.Models; -Console.WriteLine("🚀 SHEETLY v1.0.0 - ENTITY FRAMEWORK FOR GOOGLE SHEETS"); -Console.WriteLine("=" + new string('=', 80)); +Console.WriteLine("🚀 Sheetly Sample Application"); +Console.WriteLine(new string('=', 40)); + +await using var context = new AppDbContext(); +await context.InitializeAsync(); + +Console.WriteLine("✅ Context initialized successfully!"); Console.WriteLine(); -try -{ - // Run comprehensive test suite - await ComprehensiveTests.RunAllTests(); +Console.WriteLine("📋 Categories:"); +var categories = await context.Categories.ToListAsync(); +foreach (var c in categories) + Console.WriteLine($" [{c.Id}] {c.Name}"); - Console.WriteLine("\n✨ Testing complete! Now let's verify data in Google Sheets..."); - Console.WriteLine("\n📊 Please check your Google Sheets and share:"); - Console.WriteLine(" 1. Categories sheet data (all rows)"); - Console.WriteLine(" 2. Products sheet data (all rows)"); - Console.WriteLine(" 3. __SheetlyMigrationsHistory__ sheet"); - Console.WriteLine(" 4. __SheetlySchema__ sheet (should be hidden)"); - Console.WriteLine(); -} -catch (Exception ex) -{ - Console.WriteLine($"\n❌ Fatal Error: {ex.Message}"); - Console.WriteLine($" Type: {ex.GetType().Name}"); - if (ex.InnerException != null) - Console.WriteLine($" Inner: {ex.InnerException.Message}"); -} +Console.WriteLine(); +Console.WriteLine("📦 Products:"); +var products = await context.Products.ToListAsync(); +foreach (var p in products) + Console.WriteLine($" [{p.Id}] {p.Title} - ${p.Price}"); + +Console.WriteLine(); +Console.WriteLine("✨ Done!"); diff --git a/samples/Sheetly.Sample/Sheetly.Sample.csproj b/samples/Sheetly.Sample/Sheetly.Sample.csproj index 863cd78..93a1d3a 100644 --- a/samples/Sheetly.Sample/Sheetly.Sample.csproj +++ b/samples/Sheetly.Sample/Sheetly.Sample.csproj @@ -11,6 +11,7 @@ + diff --git a/samples/Sheetly.Sample/TestForeignKeyConstraints.cs b/samples/Sheetly.Sample/TestForeignKeyConstraints.cs deleted file mode 100644 index 018234b..0000000 --- a/samples/Sheetly.Sample/TestForeignKeyConstraints.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Sheetly.Sample.Models; - -namespace Sheetly.Sample; - -public static class ForeignKeyConstraintTests -{ - public static async Task TestRestrictDelete() - { - Console.WriteLine("\n🧪 TEST: FK Constraint - Restrict Delete"); - Console.WriteLine("=" + new string('=', 50)); - - try - { - using var db = new AppDbContext(); - await db.InitializeAsync(); - - // Create category with products - var category = new Category { Name = "TestCategory" }; - db.Categories.Add(category); - await db.SaveChangesAsync(); - Console.WriteLine($"✅ Created Category ID: {category.Id}"); - - var product = new Product - { - Title = "TestProduct", - Price = 100m, - CategoryId = (int)category.Id - }; - db.Products.Add(product); - await db.SaveChangesAsync(); - Console.WriteLine($"✅ Created Product ID: {product.Id} linked to Category {category.Id}"); - - // Try to delete category (should fail - has dependent products) - Console.WriteLine("\n❌ Attempting to delete Category (has dependent Product)..."); - db.Categories.Remove(category); - - try - { - await db.SaveChangesAsync(); - Console.WriteLine("⚠️ WARNING: Delete succeeded (should have failed!)"); - } - catch (InvalidOperationException ex) - { - Console.WriteLine($"✅ EXPECTED ERROR: {ex.Message}"); - Console.WriteLine("✅ FK Constraint working correctly!"); - } - } - catch (Exception ex) - { - Console.WriteLine($"❌ Unexpected error: {ex.Message}"); - } - } - - public static async Task TestCascadeDelete() - { - Console.WriteLine("\n🧪 TEST: FK Constraint - Cascade Delete"); - Console.WriteLine("=" + new string('=', 50)); - Console.WriteLine("⚠️ NOTE: This test requires OnDelete(ForeignKeyAction.Cascade) in model configuration"); - Console.WriteLine("Currently configured as NoAction - test will fail as expected.\n"); - } -} diff --git a/samples/Sheetly.Sample/TestValidationConstraints.cs b/samples/Sheetly.Sample/TestValidationConstraints.cs deleted file mode 100644 index 19356b6..0000000 --- a/samples/Sheetly.Sample/TestValidationConstraints.cs +++ /dev/null @@ -1,259 +0,0 @@ -using Sheetly.Core.Validation; -using Sheetly.Sample.Models; - -namespace Sheetly.Sample; - -public static class ValidationConstraintTests -{ - public static async Task RunAllTests() - { - Console.WriteLine("\n🧪 VALIDATION CONSTRAINT TESTS"); - Console.WriteLine("=" + new string('=', 70)); - Console.WriteLine("Testing EF Core-like validation features\n"); - - await TestRequiredConstraint(); - await TestMaxLengthConstraint(); - await TestForeignKeyConstraint(); - await TestDataTypeValidation(); - await TestMultipleValidationErrors(); - - Console.WriteLine("\n✅ All Validation Tests Completed!"); - } - - private static async Task TestRequiredConstraint() - { - Console.WriteLine("📋 TEST 1: Required Field Constraint"); - Console.WriteLine("-" + new string('-', 70)); - - try - { - using var db = new AppDbContext(); - await db.InitializeAsync(); - - // Try to add Product without required Title - var product = new Product - { - Title = null!, // Required field - should fail - Price = 100m, - CategoryId = 1 - }; - - db.Products.Add(product); - - try - { - await db.SaveChangesAsync(); - Console.WriteLine("❌ FAILED: Product saved without required Title (should have failed!)"); - Console.WriteLine(" Note: Migration may need to be regenerated to include new constraints"); - } - catch (ValidationException ex) - { - Console.WriteLine($"✅ PASSED: Required constraint caught"); - Console.WriteLine($" Error: {ex.ValidationResult.Errors.FirstOrDefault()?.Message}"); - } - } - catch (Exception ex) - { - Console.WriteLine($"❌ Unexpected error: {ex.Message}"); - } - Console.WriteLine(); - } - - private static async Task TestMaxLengthConstraint() - { - Console.WriteLine("📋 TEST 2: MaxLength Constraint"); - Console.WriteLine("-" + new string('-', 70)); - - try - { - using var db = new AppDbContext(); - await db.InitializeAsync(); - - // Try to add Category with name exceeding max length (if configured) - var category = new Category - { - Name = new string('A', 500) // Very long name - }; - - db.Categories.Add(category); - - try - { - await db.SaveChangesAsync(); - Console.WriteLine("⚠️ No MaxLength constraint configured for Category.Name"); - Console.WriteLine(" (This would fail if MaxLength was set in model configuration)"); - } - catch (ValidationException ex) - { - Console.WriteLine($"✅ PASSED: MaxLength constraint caught"); - Console.WriteLine($" Error: {ex.ValidationResult.Errors.FirstOrDefault()?.Message}"); - } - } - catch (Exception ex) - { - Console.WriteLine($"❌ Unexpected error: {ex.Message}"); - } - Console.WriteLine(); - } - - private static async Task TestForeignKeyConstraint() - { - Console.WriteLine("📋 TEST 3: Foreign Key Constraint"); - Console.WriteLine("-" + new string('-', 70)); - - try - { - using var db = new AppDbContext(); - await db.InitializeAsync(); - - // Try to add Product with non-existent CategoryId - var product = new Product - { - Title = "Test Product", - Price = 100m, - CategoryId = 99999 // Non-existent category - }; - - db.Products.Add(product); - - try - { - await db.SaveChangesAsync(); - Console.WriteLine("⚠️ FK validation needs related IDs loaded"); - Console.WriteLine(" (FK validation works when related data is in memory)"); - } - catch (ValidationException ex) - { - Console.WriteLine($"✅ PASSED: Foreign key constraint caught"); - Console.WriteLine($" Error: {ex.ValidationResult.Errors.FirstOrDefault()?.Message}"); - } - } - catch (Exception ex) - { - Console.WriteLine($"❌ Unexpected error: {ex.Message}"); - } - Console.WriteLine(); - } - - private static async Task TestDataTypeValidation() - { - Console.WriteLine("📋 TEST 4: Data Type Validation"); - Console.WriteLine("-" + new string('-', 70)); - - try - { - using var db = new AppDbContext(); - await db.InitializeAsync(); - - // Try to add Product with negative price (invalid for decimal) - var product = new Product - { - Title = "Test Product", - Price = -50m, // Negative price (might have Range constraint) - CategoryId = 1 - }; - - db.Products.Add(product); - - try - { - await db.SaveChangesAsync(); - Console.WriteLine("⚠️ No Range constraint configured for Price"); - Console.WriteLine(" (This would fail if Range(Min=0) was set in model configuration)"); - } - catch (ValidationException ex) - { - Console.WriteLine($"✅ PASSED: Range constraint caught"); - Console.WriteLine($" Error: {ex.ValidationResult.Errors.FirstOrDefault()?.Message}"); - } - } - catch (Exception ex) - { - Console.WriteLine($"❌ Unexpected error: {ex.Message}"); - } - Console.WriteLine(); - } - - private static async Task TestMultipleValidationErrors() - { - Console.WriteLine("📋 TEST 5: Multiple Validation Errors"); - Console.WriteLine("-" + new string('-', 70)); - - try - { - using var db = new AppDbContext(); - await db.InitializeAsync(); - - // Try to add Product with multiple violations - var product1 = new Product - { - Title = null!, // Required violation - Price = 100m, - CategoryId = 99999 // FK violation - }; - - var product2 = new Product - { - Title = "", // Empty string (might be required) - Price = -10m, // Negative (might have range constraint) - CategoryId = 1 - }; - - db.Products.Add(product1); - db.Products.Add(product2); - - try - { - await db.SaveChangesAsync(); - Console.WriteLine("⚠️ Some constraints may not be configured"); - } - catch (ValidationException ex) - { - Console.WriteLine($"✅ PASSED: Multiple validation errors caught"); - Console.WriteLine($" Total errors: {ex.ValidationResult.Errors.Count}"); - foreach (var error in ex.ValidationResult.Errors) - { - Console.WriteLine($" - {error.PropertyName}: {error.Message}"); - } - } - } - catch (Exception ex) - { - Console.WriteLine($"❌ Unexpected error: {ex.Message}"); - } - Console.WriteLine(); - } -} - -/// -/// Enhanced Product model with validation attributes for testing -/// -public class ValidatedProduct -{ - public int Id { get; set; } - - // [Required] - // [MaxLength(200)] - public string Title { get; set; } = string.Empty; - - // [Range(0, 1000000)] - public decimal Price { get; set; } - - // [ForeignKey("Category")] - public int CategoryId { get; set; } - - public Category? Category { get; set; } -} - -/// -/// Enhanced Category model with validation attributes -/// -public class ValidatedCategory -{ - public long Id { get; set; } - - // [Required] - // [MaxLength(100)] - // [MinLength(3)] - public string Name { get; set; } = string.Empty; -} diff --git a/samples/Sheetly.Sample/appsettings.json b/samples/Sheetly.Sample/appsettings.json index aa31efe..a7c9b3e 100644 --- a/samples/Sheetly.Sample/appsettings.json +++ b/samples/Sheetly.Sample/appsettings.json @@ -1,5 +1,5 @@ { "ConnectionStrings": { - "DefaultConnection": "Provider=GoogleSheets;CredentialsPath=credentials.json;SpreadsheetId=1bNZnlJJ81VLbM5VeWoy9uCq4Ynz2bkAXaJlFJAYy_Sc" + "DefaultConnection": "Provider=Excel;FilePath=data.xlsx" } } \ No newline at end of file diff --git a/samples/Sheetly.Test/Contexts/ExcelAppContext.cs b/samples/Sheetly.Test/Contexts/ExcelAppContext.cs deleted file mode 100644 index d8aeb1f..0000000 --- a/samples/Sheetly.Test/Contexts/ExcelAppContext.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Sheetly.Core; -using Sheetly.Core.Configuration; -using Sheetly.Excel; -using Sheetly.Test.Models; - -namespace Sheetly.Test.Contexts; - -// Excel provider bilan ishlaydigan context — credentials shart emas, local .xlsx fayl -public class ExcelAppContext : SheetsContext -{ - public SheetsSet Categories { get; set; } = null!; - public SheetsSet Products { get; set; } = null!; - - protected override void OnConfiguring(SheetsOptions options) - { - options.UseExcel("test-data.xlsx"); - } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.Entity(e => - { - e.HasSheetName("Categories"); - e.Property(c => c.Name).HasMaxLength(100).IsRequired(); - }); - - modelBuilder.Entity(e => - { - e.HasSheetName("Products"); - e.Property(p => p.Name).HasMaxLength(200).IsRequired(); - e.Property(p => p.Price).IsRequired(); - }); - } -} diff --git a/samples/Sheetly.Test/Contexts/GoogleAppContext.cs b/samples/Sheetly.Test/Contexts/GoogleAppContext.cs deleted file mode 100644 index 788a4cc..0000000 --- a/samples/Sheetly.Test/Contexts/GoogleAppContext.cs +++ /dev/null @@ -1,39 +0,0 @@ -using Sheetly.Core; -using Sheetly.Core.Configuration; -using Sheetly.Google; -using Sheetly.Test.Models; - -namespace Sheetly.Test.Contexts; - -// Google Sheets provider bilan ishlaydigan context -// credentials.json va spreadsheet ID kerak -public class GoogleAppContext : SheetsContext -{ - public SheetsSet Categories { get; set; } = null!; - public SheetsSet Products { get; set; } = null!; - - protected override void OnConfiguring(SheetsOptions options) - { - // credentials.json faylini va spreadsheet ID ni o'zgartiring - options.UseGoogleSheets( - credentialsPath: "credentials.json", - spreadsheetId: "1bNZnlJJ81VLbM5VeWoy9uCq4Ynz2bkAXaJlFJAYy_Sc" - ); - } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.Entity(e => - { - e.HasSheetName("Categories"); - e.Property(c => c.Name).HasMaxLength(100).IsRequired(); - }); - - modelBuilder.Entity(e => - { - e.HasSheetName("Products"); - e.Property(p => p.Name).HasMaxLength(200).IsRequired(); - e.Property(p => p.Price).IsRequired(); - }); - } -} diff --git a/samples/Sheetly.Test/Migrations/ExcelAppModelSnapshot.cs b/samples/Sheetly.Test/Migrations/ExcelAppModelSnapshot.cs deleted file mode 100644 index eecc170..0000000 --- a/samples/Sheetly.Test/Migrations/ExcelAppModelSnapshot.cs +++ /dev/null @@ -1,139 +0,0 @@ -using System; -using Sheetly.Core.Migration; - -namespace Sheetly.Test.Contexts.Migrations; - -public partial class ExcelAppModelSnapshot : MigrationSnapshot -{ - public ExcelAppModelSnapshot() - { - var snapshot = BuildModel(); - this.Entities = snapshot.Entities; - this.ModelHash = snapshot.ModelHash; - this.Version = snapshot.Version; - this.LastUpdated = snapshot.LastUpdated; - } - - public static MigrationSnapshot BuildModel() - { - var snapshot = new MigrationSnapshot - { - ModelHash = "erfMXU/RWc/dJ2XYy4Tck5Nw4rMDkzpVEiJA1z5xQro=", - Version = "1.0.0", - LastUpdated = DateTime.Parse("2026-02-27T23:15:49.6662861Z") - }; - - // Category - snapshot.Entities["Categories"] = new EntitySchema - { - TableName = "Categories", - ClassName = "Category", - Namespace = "Sheetly.Test.Models", - Columns = new List - { - new ColumnSchema - { - Name = "Id", - PropertyName = "Id", - DataType = "Int32", - IsPrimaryKey = true, - IsAutoIncrement = true, - IsForeignKey = false, - ForeignKeyColumn = "Id", - IsNullable = false, - IsRequired = false - }, - new ColumnSchema - { - Name = "Name", - PropertyName = "Name", - DataType = "String", - IsPrimaryKey = false, - IsAutoIncrement = false, - IsForeignKey = false, - ForeignKeyColumn = "Id", - IsNullable = false, - IsRequired = true, - MaxLength = 100 - } - }, - Relationships = new List() - }; - - // Product - snapshot.Entities["Products"] = new EntitySchema - { - TableName = "Products", - ClassName = "Product", - Namespace = "Sheetly.Test.Models", - Columns = new List - { - new ColumnSchema - { - Name = "Id", - PropertyName = "Id", - DataType = "Int32", - IsPrimaryKey = true, - IsAutoIncrement = true, - IsForeignKey = false, - ForeignKeyColumn = "Id", - IsNullable = false, - IsRequired = false - }, - new ColumnSchema - { - Name = "Name", - PropertyName = "Name", - DataType = "String", - IsPrimaryKey = false, - IsAutoIncrement = false, - IsForeignKey = false, - ForeignKeyColumn = "Id", - IsNullable = false, - IsRequired = true, - MaxLength = 200 - }, - new ColumnSchema - { - Name = "Price", - PropertyName = "Price", - DataType = "Decimal", - IsPrimaryKey = false, - IsAutoIncrement = false, - IsForeignKey = false, - ForeignKeyColumn = "Id", - IsNullable = false, - IsRequired = true - }, - new ColumnSchema - { - Name = "Description", - PropertyName = "Description", - DataType = "String", - IsPrimaryKey = false, - IsAutoIncrement = false, - IsForeignKey = false, - ForeignKeyColumn = "Id", - IsNullable = true, - IsRequired = false - }, - new ColumnSchema - { - Name = "CategoryId", - PropertyName = "CategoryId", - DataType = "Int32", - IsPrimaryKey = false, - IsAutoIncrement = false, - IsForeignKey = true, - ForeignKeyTable = "Categories", - ForeignKeyColumn = "Id", - IsNullable = false, - IsRequired = false - } - }, - Relationships = new List() - }; - - return snapshot; - } -} diff --git a/samples/Sheetly.Test/Models/Category.cs b/samples/Sheetly.Test/Models/Category.cs deleted file mode 100644 index abaf7db..0000000 --- a/samples/Sheetly.Test/Models/Category.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace Sheetly.Test.Models; - -public class Category -{ - public int Id { get; set; } - - [Required] - [MaxLength(100)] - public string Name { get; set; } = string.Empty; - - public List Products { get; set; } = []; -} diff --git a/samples/Sheetly.Test/Models/Product.cs b/samples/Sheetly.Test/Models/Product.cs deleted file mode 100644 index ed7277c..0000000 --- a/samples/Sheetly.Test/Models/Product.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace Sheetly.Test.Models; - -public class Product -{ - public int Id { get; set; } - - [Required] - [MaxLength(200)] - public string Name { get; set; } = string.Empty; - - public decimal Price { get; set; } - - public string? Description { get; set; } - - public int CategoryId { get; set; } - public Category Category { get; set; } = null!; -} diff --git a/samples/Sheetly.Test/Program.cs b/samples/Sheetly.Test/Program.cs deleted file mode 100644 index c6b54d1..0000000 --- a/samples/Sheetly.Test/Program.cs +++ /dev/null @@ -1,129 +0,0 @@ -using Sheetly.Test.Contexts; -using Sheetly.Test.Models; - -Console.WriteLine("=== Sheetly Test ===\n"); -Console.WriteLine("Qaysi provider bilan test qilmoqchisiz?"); -Console.WriteLine(" 1 - Excel (local .xlsx fayl)"); -Console.WriteLine(" 2 - Google Sheets"); -Console.Write("\nTanlov: "); -var choice = Console.ReadLine()?.Trim(); - -if (choice == "2") - await RunGoogleTest(); -else - await RunExcelTest(); - -// ───────────────────────────────────────────────────────────── -// EXCEL TEST -// ───────────────────────────────────────────────────────────── -static async Task RunExcelTest() -{ - Console.WriteLine("\n[Excel] test-data.xlsx fayli yaratilmoqda...\n"); - - await using var context = new ExcelAppContext(); - await context.InitializeAsync(); - await context.Database.MigrateAsync(); - - // ── CREATE ── - Console.WriteLine("--- CREATE ---"); - - var electronics = new Category { Name = "Electronics" }; - var food = new Category { Name = "Food" }; - context.Categories.Add(electronics); - context.Categories.Add(food); - await context.SaveChangesAsync(); - Console.WriteLine($"Category qo'shildi: {electronics.Name} (Id={electronics.Id})"); - Console.WriteLine($"Category qo'shildi: {food.Name} (Id={food.Id})"); - - var laptop = new Product { Name = "Laptop", Price = 1200, CategoryId = electronics.Id }; - var phone = new Product { Name = "Phone", Price = 800, CategoryId = electronics.Id }; - var bread = new Product { Name = "Bread", Price = 2, CategoryId = food.Id }; - context.Products.Add(laptop); - context.Products.Add(phone); - context.Products.Add(bread); - await context.SaveChangesAsync(); - Console.WriteLine($"Product qo'shildi: {laptop.Name} (Id={laptop.Id})"); - Console.WriteLine($"Product qo'shildi: {phone.Name} (Id={phone.Id})"); - Console.WriteLine($"Product qo'shildi: {bread.Name} (Id={bread.Id})"); - - // ── READ ── - Console.WriteLine("\n--- READ ---"); - var products = await context.Products.Include(p => p.Category).ToListAsync(); - foreach (var p in products) - Console.WriteLine($" {p.Id}. {p.Name} — ${p.Price} [{p.Category?.Name ?? "?"}]"); - - // ── UPDATE (auto change tracking) ── - Console.WriteLine("\n--- UPDATE ---"); - laptop.Price = 999; - await context.SaveChangesAsync(); - Console.WriteLine($"Laptop narxi o'zgartirildi: $999"); - - // ── FIND ── - Console.WriteLine("\n--- FIND ---"); - var found = await context.Products.FindAsync(laptop.Id); - Console.WriteLine($"FindAsync({laptop.Id}) → {found?.Name} ${found?.Price}"); - - // ── WHERE ── - Console.WriteLine("\n--- WHERE ---"); - var expensive = await context.Products.Where(p => p.Price > 100); - foreach (var p in expensive) - Console.WriteLine($" > $100: {p.Name}"); - - // ── DELETE ── - Console.WriteLine("\n--- DELETE ---"); - context.Products.Remove(bread); - await context.SaveChangesAsync(); - Console.WriteLine($"O'chirildi: {bread.Name}"); - - var remaining = await context.Products.ToListAsync(); - Console.WriteLine($"Qolgan productlar soni: {remaining.Count}"); - - Console.WriteLine("\n✅ Excel test muvaffaqiyatli yakunlandi!"); - Console.WriteLine(" test-data.xlsx faylini Excel da ochib ko'ring."); -} - -// ───────────────────────────────────────────────────────────── -// GOOGLE SHEETS TEST -// ───────────────────────────────────────────────────────────── -static async Task RunGoogleTest() -{ - Console.WriteLine("\n[Google Sheets] credentials.json va spreadsheet ID kerak."); - Console.WriteLine("GoogleAppContext.cs faylida YOUR_SPREADSHEET_ID_HERE ni o'zgartiring.\n"); - - await using var context = new GoogleAppContext(); - await context.InitializeAsync(); - await context.Database.MigrateAsync(); - - // ── CREATE ── - Console.WriteLine("--- CREATE ---"); - - var category = new Category { Name = "Tech" }; - context.Categories.Add(category); - await context.SaveChangesAsync(); - Console.WriteLine($"Category qo'shildi: {category.Name} (Id={category.Id})"); - - var product = new Product { Name = "Keyboard", Price = 75, CategoryId = category.Id }; - context.Products.Add(product); - await context.SaveChangesAsync(); - Console.WriteLine($"Product qo'shildi: {product.Name} (Id={product.Id})"); - - // ── READ ── - Console.WriteLine("\n--- READ ---"); - var products = await context.Products.Include(p => p.Category).ToListAsync(); - foreach (var p in products) - Console.WriteLine($" {p.Id}. {p.Name} — ${p.Price} [{p.Category?.Name ?? "?"}]"); - - // ── UPDATE ── - Console.WriteLine("\n--- UPDATE ---"); - product.Price = 65; - await context.SaveChangesAsync(); - Console.WriteLine($"{product.Name} narxi o'zgartirildi: $65"); - - // ── DELETE ── - Console.WriteLine("\n--- DELETE ---"); - context.Products.Remove(product); - await context.SaveChangesAsync(); - Console.WriteLine($"O'chirildi: {product.Name}"); - - Console.WriteLine("\n✅ Google Sheets test muvaffaqiyatli yakunlandi!"); -} diff --git a/samples/Sheetly.Test/Sheetly.Test.csproj b/samples/Sheetly.Test/Sheetly.Test.csproj deleted file mode 100644 index 73fb9d1..0000000 --- a/samples/Sheetly.Test/Sheetly.Test.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net10.0 - enable - enable - - - - - - - - - - - PreserveNewest - - - - diff --git a/src/Sheetly.Core/Migrations/MigrationBuilder.New.cs b/src/Sheetly.Core/Migrations/MigrationBuilder.New.cs deleted file mode 100644 index 04d99a0..0000000 --- a/src/Sheetly.Core/Migrations/MigrationBuilder.New.cs +++ /dev/null @@ -1,282 +0,0 @@ -using Sheetly.Core.Migrations.Operations; - -namespace Sheetly.Core.Migrations; - -public class MigrationBuilder -{ - private readonly List _operations = new(); - - public MigrationBuilder CreateTable(string name, Action columns) - { - var operation = new CreateTableOperation { Name = name }; - var tableBuilder = new TableBuilder(name, operation.Columns); - columns(tableBuilder); - _operations.Add(operation); - return this; - } - - public MigrationBuilder DropTable(string name) - { - _operations.Add(new DropTableOperation { Name = name }); - return this; - } - - public MigrationBuilder AddColumn(string table, string name, Action? configure = null) - { - var operation = new AddColumnOperation - { - Table = table, - Name = name, - ClrType = typeof(T), - IsNullable = IsNullableType(typeof(T)) - }; - - if (configure != null) - { - var columnBuilder = new ColumnBuilder(operation); - configure(columnBuilder); - } - - _operations.Add(operation); - return this; - } - - public MigrationBuilder DropColumn(string table, string name) - { - _operations.Add(new DropColumnOperation { Table = table, Name = name }); - return this; - } - - public MigrationBuilder AlterColumn(string table, string name, Action configure) - { - var operation = new AlterColumnOperation { Table = table, Name = name }; - var builder = new AlterColumnBuilder(operation); - configure(builder); - _operations.Add(operation); - return this; - } - - public MigrationBuilder CreateIndex(string name, string table, string[] columns, Action? configure = null) - { - var operation = new CreateIndexOperation - { - Name = name, - Table = table, - Columns = new List(columns) - }; - - if (configure != null) - { - var builder = new IndexBuilder(operation); - configure(builder); - } - - _operations.Add(operation); - return this; - } - - public MigrationBuilder DropIndex(string name, string table) - { - _operations.Add(new DropIndexOperation { Name = name, Table = table }); - return this; - } - - public MigrationBuilder AddCheckConstraint(string name, string table, string sql) - { - _operations.Add(new AddCheckConstraintOperation { Name = name, Table = table, Sql = sql }); - return this; - } - - public MigrationBuilder DropCheckConstraint(string name, string table) - { - _operations.Add(new DropCheckConstraintOperation { Name = name, Table = table }); - return this; - } - - public List GetOperations() => _operations; - - private static bool IsNullableType(Type type) - { - return !type.IsValueType || Nullable.GetUnderlyingType(type) != null; - } -} - -public class TableBuilder -{ - private readonly string _tableName; - private readonly List _columns; - - internal TableBuilder(string tableName, List columns) - { - _tableName = tableName; - _columns = columns; - } - - public TableBuilder Column(string name, Action? configure = null) - { - var operation = new AddColumnOperation - { - Table = _tableName, - Name = name, - ClrType = typeof(T), - IsNullable = IsNullableType(typeof(T)) - }; - - if (configure != null) - { - var columnBuilder = new ColumnBuilder(operation); - configure(columnBuilder); - } - - _columns.Add(operation); - return this; - } - - private static bool IsNullableType(Type type) - { - return !type.IsValueType || Nullable.GetUnderlyingType(type) != null; - } -} - -public class ColumnBuilder -{ - private readonly AddColumnOperation _operation; - - internal ColumnBuilder(AddColumnOperation operation) - { - _operation = operation; - } - - public ColumnBuilder IsRequired() - { - _operation.IsNullable = false; - return this; - } - - public ColumnBuilder IsPrimaryKey() - { - _operation.IsPrimaryKey = true; - _operation.IsNullable = false; - return this; - } - - public ColumnBuilder HasMaxLength(int length) - { - _operation.MaxLength = length; - return this; - } - - public ColumnBuilder HasDefaultValue(object value) - { - _operation.DefaultValue = value; - return this; - } - - public ColumnBuilder IsForeignKey(string table, string column = "Id") - { - _operation.ForeignKeyTable = table; - _operation.ForeignKeyColumn = column; - return this; - } - - public ColumnBuilder IsUnique() - { - _operation.IsUnique = true; - return this; - } - - public ColumnBuilder HasCheckConstraint(string expression) - { - _operation.CheckConstraint = expression; - return this; - } - - public ColumnBuilder HasPrecision(int precision, int scale = 0) - { - _operation.Precision = precision; - _operation.Scale = scale; - return this; - } - - public ColumnBuilder HasComputedColumnSql(string sql, bool? stored = null) - { - _operation.IsComputed = true; - _operation.ComputedColumnSql = sql; - _operation.IsStored = stored; - return this; - } - - public ColumnBuilder IsConcurrencyToken() - { - _operation.IsConcurrencyToken = true; - return this; - } - - public ColumnBuilder HasComment(string comment) - { - _operation.Comment = comment; - return this; - } -} - -public class AlterColumnBuilder -{ - private readonly AlterColumnOperation _operation; - - internal AlterColumnBuilder(AlterColumnOperation operation) - { - _operation = operation; - } - - public AlterColumnBuilder HasType() - { - _operation.ClrType = typeof(T); - return this; - } - - public AlterColumnBuilder IsNullable(bool nullable = true) - { - _operation.IsNullable = nullable; - return this; - } - - public AlterColumnBuilder HasMaxLength(int length) - { - _operation.MaxLength = length; - return this; - } - - public AlterColumnBuilder HasDefaultValue(object value) - { - _operation.DefaultValue = value; - return this; - } -} - -public class IndexBuilder -{ - private readonly CreateIndexOperation _operation; - - internal IndexBuilder(CreateIndexOperation operation) - { - _operation = operation; - } - - public IndexBuilder IsUnique() - { - _operation.IsUnique = true; - return this; - } - - public IndexBuilder IsClustered() - { - _operation.IsClustered = true; - return this; - } - - public IndexBuilder HasFilter(string filter) - { - _operation.Filter = filter; - return this; - } -} From e40644a31292592f93a4c9f9573b6301cf3fccb2 Mon Sep 17 00:00:00 2001 From: muqimjon Date: Sat, 28 Feb 2026 13:20:57 +0500 Subject: [PATCH 30/36] fix: swap UseGoogleSheets params to (spreadsheetId, credentialsPath), GetMaxIdAsync long, sample cleanup --- README.md | 12 ++++---- samples/Sheetly.Sample/AppDbContext.cs | 28 ++----------------- ...te.cs => 20260228074424_InitialMigrate.cs} | 4 +-- .../Migrations/AppDbModelSnapshot.cs | 2 +- samples/Sheetly.Sample/Program.cs | 12 ++++++++ samples/Sheetly.Sample/appsettings.json | 6 +--- .../Abstractions/ISheetsProvider.cs | 2 +- src/Sheetly.Core/SheetsSet.cs | 2 +- src/Sheetly.Excel/ExcelSheetProvider.cs | 16 +++++------ src/Sheetly.Google/GoogleSheetProvider.cs | 8 +++--- src/Sheetly.Google/GoogleSheetsFactory.cs | 6 ++-- .../GoogleSheetsOptionsExtensions.cs | 6 ++-- .../Helpers/InMemorySheetsProvider.cs | 6 ++-- 13 files changed, 48 insertions(+), 62 deletions(-) rename samples/Sheetly.Sample/Migrations/{20260228072548_InitialCreate.cs => 20260228074424_InitialMigrate.cs} (91%) diff --git a/README.md b/README.md index 9d8ae0a..ff7ddac 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ public class AppContext : SheetsContext protected override void OnConfiguring(SheetsOptions options) { - options.UseGoogleSheets("credentials.json", "your-spreadsheet-id"); + options.UseGoogleSheets("your-spreadsheet-id", "credentials.json"); // or: options.UseExcel("data.xlsx"); } @@ -163,7 +163,7 @@ dotnet tool install -g dotnet-sheetly ```csharp protected override void OnConfiguring(SheetsOptions options) { - options.UseGoogleSheets("credentials.json", "your-spreadsheet-id"); + options.UseGoogleSheets("your-spreadsheet-id", "credentials.json"); } ``` @@ -219,7 +219,7 @@ public class MyAppContext : SheetsContext protected override void OnConfiguring(SheetsOptions options) { - options.UseGoogleSheets("credentials.json", "your-spreadsheet-id"); + options.UseGoogleSheets("your-spreadsheet-id", "credentials.json"); // or: options.UseExcel("mydata.xlsx"); } @@ -316,7 +316,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) ```csharp // Parameterless constructor (classic) builder.Services.AddSheetsContext(options => - options.UseGoogleSheets("credentials.json", "spreadsheet-id")); + options.UseGoogleSheets("spreadsheet-id", "credentials.json")); // Options constructor (EF Core-style) public class MyAppContext : SheetsContext @@ -326,7 +326,7 @@ public class MyAppContext : SheetsContext } builder.Services.AddSheetsContext(options => - options.UseGoogleSheets("credentials.json", "spreadsheet-id")); + options.UseGoogleSheets("spreadsheet-id", "credentials.json")); ``` ### **AsNoTracking** @@ -373,7 +373,7 @@ await context.SaveChangesAsync(); // [{ "type": "service_account", ... }, { "type": "service_account", ... }] // Each API call rotates to the next credential (round-robin) // Effective limit: N accounts × 60 req/min = N×60 req/min -options.UseGoogleSheets("credentials.json", "spreadsheet-id"); +options.UseGoogleSheets("spreadsheet-id", "credentials.json"); ``` ### **CancellationToken Support** diff --git a/samples/Sheetly.Sample/AppDbContext.cs b/samples/Sheetly.Sample/AppDbContext.cs index 7428000..fed114d 100644 --- a/samples/Sheetly.Sample/AppDbContext.cs +++ b/samples/Sheetly.Sample/AppDbContext.cs @@ -1,5 +1,4 @@ -using Microsoft.Extensions.Configuration; -using Sheetly.Core; +using Sheetly.Core; using Sheetly.Core.Configuration; using Sheetly.Excel; using Sheetly.Google; @@ -14,18 +13,8 @@ public class AppDbContext : SheetsContext protected override void OnConfiguring(SheetsOptions options) { - var config = new ConfigurationBuilder() - .SetBasePath(Directory.GetCurrentDirectory()) - .AddJsonFile("appsettings.json") - .Build(); - - var connectionString = config.GetConnectionString("DefaultConnection") - ?? throw new Exception("Connection string 'DefaultConnection' not found."); - - if (connectionString.Contains("Provider=Excel", StringComparison.OrdinalIgnoreCase)) - options.UseExcel(ExtractFilePath(connectionString)); - else - options.UseGoogleSheets(connectionString); + //options.UseExcel("C:/Users/user/OneDrive/Desk/sheetly-test.xlsx"); + options.UseGoogleSheets("1bNZnlJJ81VLbM5VeWoy9uCq4Ynz2bkAXaJlFJAYy_Sc", "credentials.json"); } protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -53,15 +42,4 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasMaxLength(500); }); } - - private static string ExtractFilePath(string connectionString) - { - foreach (var part in connectionString.Split(';')) - { - var kv = part.Split('=', 2); - if (kv.Length == 2 && kv[0].Trim().Equals("FilePath", StringComparison.OrdinalIgnoreCase)) - return kv[1].Trim(); - } - throw new Exception("FilePath not found in Excel connection string."); - } } \ No newline at end of file diff --git a/samples/Sheetly.Sample/Migrations/20260228072548_InitialCreate.cs b/samples/Sheetly.Sample/Migrations/20260228074424_InitialMigrate.cs similarity index 91% rename from samples/Sheetly.Sample/Migrations/20260228072548_InitialCreate.cs rename to samples/Sheetly.Sample/Migrations/20260228074424_InitialMigrate.cs index 486a591..3e2dae4 100644 --- a/samples/Sheetly.Sample/Migrations/20260228072548_InitialCreate.cs +++ b/samples/Sheetly.Sample/Migrations/20260228074424_InitialMigrate.cs @@ -3,8 +3,8 @@ namespace Sheetly.Sample.Migrations; -[Migration("20260228072548_InitialCreate")] -public partial class InitialCreate : Migration +[Migration("20260228074424_InitialMigrate")] +public partial class InitialMigrate : Migration { public override void Up(MigrationBuilder builder) { diff --git a/samples/Sheetly.Sample/Migrations/AppDbModelSnapshot.cs b/samples/Sheetly.Sample/Migrations/AppDbModelSnapshot.cs index d730c94..711e045 100644 --- a/samples/Sheetly.Sample/Migrations/AppDbModelSnapshot.cs +++ b/samples/Sheetly.Sample/Migrations/AppDbModelSnapshot.cs @@ -20,7 +20,7 @@ public static MigrationSnapshot BuildModel() { ModelHash = "B9emMa1A++cOQMHt5sY3NkJRTAb1yP/Ei7sKWFlwVDw=", Version = "1.0.0", - LastUpdated = DateTime.Parse("2026-02-28T07:25:48.1042270Z") + LastUpdated = DateTime.Parse("2026-02-28T07:44:24.5479128Z") }; // Category diff --git a/samples/Sheetly.Sample/Program.cs b/samples/Sheetly.Sample/Program.cs index 362bb42..4e7fc0a 100644 --- a/samples/Sheetly.Sample/Program.cs +++ b/samples/Sheetly.Sample/Program.cs @@ -10,6 +10,18 @@ Console.WriteLine("✅ Context initialized successfully!"); Console.WriteLine(); +//context.Products.Add(new Product +//{ +// Title = "Sample Product", +// Price = 19.99m, +// Description = "This is a sample product added to the Excel sheet." +//}); + +var firstProduct = await context.Products.FirstOrDefaultAsync(); +if (firstProduct is not null) + context.Products.Remove(firstProduct); + +await context.SaveChangesAsync(); Console.WriteLine("📋 Categories:"); var categories = await context.Categories.ToListAsync(); foreach (var c in categories) diff --git a/samples/Sheetly.Sample/appsettings.json b/samples/Sheetly.Sample/appsettings.json index a7c9b3e..9e26dfe 100644 --- a/samples/Sheetly.Sample/appsettings.json +++ b/samples/Sheetly.Sample/appsettings.json @@ -1,5 +1 @@ -{ - "ConnectionStrings": { - "DefaultConnection": "Provider=Excel;FilePath=data.xlsx" - } -} \ No newline at end of file +{} \ No newline at end of file diff --git a/src/Sheetly.Core/Abstractions/ISheetsProvider.cs b/src/Sheetly.Core/Abstractions/ISheetsProvider.cs index fb2c087..6532878 100644 --- a/src/Sheetly.Core/Abstractions/ISheetsProvider.cs +++ b/src/Sheetly.Core/Abstractions/ISheetsProvider.cs @@ -15,7 +15,7 @@ public interface ISheetsProvider : IDisposable Task AppendRowAsync(string sheetName, IList row); Task AppendRowsAsync(string sheetName, IList> rows); Task AppendRowAndGetIdAsync(string sheetName, IList row); - Task GetMaxIdAsync(string sheetName); + Task GetMaxIdAsync(string sheetName); Task UpdateRowAsync(string sheetName, int rowIndex, IList row); Task DeleteRowAsync(string sheetName, int rowIndex); diff --git a/src/Sheetly.Core/SheetsSet.cs b/src/Sheetly.Core/SheetsSet.cs index 3da8735..2a25846 100644 --- a/src/Sheetly.Core/SheetsSet.cs +++ b/src/Sheetly.Core/SheetsSet.cs @@ -276,7 +276,7 @@ internal async Task SaveChangesInternalAsync() if (pkColumn is not null) { - int nextId = await provider.GetMaxIdAsync(schema.TableName) + 1; + long nextId = await provider.GetMaxIdAsync(schema.TableName) + 1; var batchRows = new List>(toAdd.Count); var pkProp = typeof(T).GetProperty(pkColumn.PropertyName); foreach (var item in toAdd) diff --git a/src/Sheetly.Excel/ExcelSheetProvider.cs b/src/Sheetly.Excel/ExcelSheetProvider.cs index af8cab8..ee79215 100644 --- a/src/Sheetly.Excel/ExcelSheetProvider.cs +++ b/src/Sheetly.Excel/ExcelSheetProvider.cs @@ -142,8 +142,8 @@ public Task AppendRowAndGetIdAsync(string sheetName, IList row) EnsureWorkbook(); var ws = GetWorksheet(sheetName); - int maxId = GetMaxIdFromSheet(ws); - int nextId = maxId + 1; + long maxId = GetMaxIdFromSheet(ws); + long nextId = maxId + 1; var newRow = row.ToList(); if (newRow.Count > 0) @@ -154,14 +154,14 @@ public Task AppendRowAndGetIdAsync(string sheetName, IList row) ws.Cell(nextRowNum, i + 1).Value = newRow[i]?.ToString() ?? ""; Save(); - return Task.FromResult(nextId); + return Task.FromResult((int)nextId); } - public Task GetMaxIdAsync(string sheetName) + public Task GetMaxIdAsync(string sheetName) { EnsureWorkbook(); if (!_workbook!.TryGetWorksheet(sheetName, out var ws)) - return Task.FromResult(0); + return Task.FromResult(0L); return Task.FromResult(GetMaxIdFromSheet(ws)); } @@ -324,9 +324,9 @@ private static int GetNextEmptyRow(IXLWorksheet ws) return lastUsed is null ? 2 : lastUsed.RowNumber() + 1; } - private static int GetMaxIdFromSheet(IXLWorksheet ws) + private static long GetMaxIdFromSheet(IXLWorksheet ws) { - int max = 0; + long max = 0; var rangeUsed = ws.RangeUsed(); if (rangeUsed is null) return max; @@ -334,7 +334,7 @@ private static int GetMaxIdFromSheet(IXLWorksheet ws) for (int r = 2; r <= lastRow; r++) { var val = ws.Cell(r, 1).GetValue(); - if (int.TryParse(val, out var id) && id > max) + if (long.TryParse(val, out var id) && id > max) max = id; } return max; diff --git a/src/Sheetly.Google/GoogleSheetProvider.cs b/src/Sheetly.Google/GoogleSheetProvider.cs index 444e3e4..094c89e 100644 --- a/src/Sheetly.Google/GoogleSheetProvider.cs +++ b/src/Sheetly.Google/GoogleSheetProvider.cs @@ -51,7 +51,7 @@ private static async Task ExecuteWithRetryAsync(IClientServiceRequest r return await request.ExecuteAsync(); } - public GoogleSheetProvider(string credentialsPath, string spreadsheetId) + public GoogleSheetProvider(string spreadsheetId, string credentialsPath) { _spreadsheetId = spreadsheetId; using var stream = new FileStream(credentialsPath, FileMode.Open, FileAccess.Read); @@ -222,15 +222,15 @@ public async Task AppendRowsAsync(string sheetName, IList> rows) /// Uses VALUES_UNRENDERED to get the raw number even when formulas are present. /// 1 API call. /// - public async Task GetMaxIdAsync(string sheetName) + public async Task GetMaxIdAsync(string sheetName) { var request = NextService.Spreadsheets.Values.Get(_spreadsheetId, $"'{sheetName}'!A2:A"); request.ValueRenderOption = SpreadsheetsResource.ValuesResource.GetRequest.ValueRenderOptionEnum.UNFORMATTEDVALUE; var response = await ExecuteWithRetryAsync(request); - int max = 0; + long max = 0; if (response.Values is not null) foreach (var row in response.Values) - if (row.Count > 0 && int.TryParse(row[0]?.ToString(), out var id) && id > max) + if (row.Count > 0 && long.TryParse(row[0]?.ToString(), out var id) && id > max) max = id; return max; } diff --git a/src/Sheetly.Google/GoogleSheetsFactory.cs b/src/Sheetly.Google/GoogleSheetsFactory.cs index 512b5cf..c68772b 100644 --- a/src/Sheetly.Google/GoogleSheetsFactory.cs +++ b/src/Sheetly.Google/GoogleSheetsFactory.cs @@ -10,7 +10,7 @@ public static class GoogleSheetsFactory var connString = SheetsConnectionString.Parse(connectionString); connString.Validate(); - var provider = new GoogleSheetProvider(connString.CredentialsPath, connString.SpreadsheetId); + var provider = new GoogleSheetProvider(connString.SpreadsheetId, connString.CredentialsPath); var migrationService = new GoogleMigrationService(provider); var context = new T(); await context.InitializeAsync(provider, migrationService); @@ -19,8 +19,8 @@ public static class GoogleSheetsFactory } public static async Task CreateContextAsync( - string credentialsPath, - string spreadsheetId + string spreadsheetId, + string credentialsPath ) where T : SheetsContext, new() { var connectionString = $"Provider=GoogleSheets;CredentialsPath={credentialsPath};SpreadsheetId={spreadsheetId}"; diff --git a/src/Sheetly.Google/GoogleSheetsOptionsExtensions.cs b/src/Sheetly.Google/GoogleSheetsOptionsExtensions.cs index 7ea7feb..cd81430 100644 --- a/src/Sheetly.Google/GoogleSheetsOptionsExtensions.cs +++ b/src/Sheetly.Google/GoogleSheetsOptionsExtensions.cs @@ -8,16 +8,16 @@ public static SheetsOptions UseGoogleSheets(this SheetsOptions options, string c { options.ConnectionString = connectionString; var conn = SheetsConnectionString.Parse(connectionString); - var provider = new GoogleSheetProvider(conn.CredentialsPath, conn.SpreadsheetId); + var provider = new GoogleSheetProvider(conn.SpreadsheetId, conn.CredentialsPath); options.Provider = provider; options.MigrationService = new GoogleMigrationService(provider); return options; } - public static SheetsOptions UseGoogleSheets(this SheetsOptions options, string credentialsPath, string spreadsheetId) + public static SheetsOptions UseGoogleSheets(this SheetsOptions options, string spreadsheetId, string credentialsPath) { options.ConnectionString = $"Provider=GoogleSheets;CredentialsPath={credentialsPath};SpreadsheetId={spreadsheetId}"; - var provider = new GoogleSheetProvider(credentialsPath, spreadsheetId); + var provider = new GoogleSheetProvider(spreadsheetId, credentialsPath); options.Provider = provider; options.MigrationService = new GoogleMigrationService(provider); return options; diff --git a/tests/Sheetly.Core.Tests/Integration/Helpers/InMemorySheetsProvider.cs b/tests/Sheetly.Core.Tests/Integration/Helpers/InMemorySheetsProvider.cs index 872c7f8..6743118 100644 --- a/tests/Sheetly.Core.Tests/Integration/Helpers/InMemorySheetsProvider.cs +++ b/tests/Sheetly.Core.Tests/Integration/Helpers/InMemorySheetsProvider.cs @@ -97,12 +97,12 @@ public Task AppendRowsAsync(string sheetName, IList> rows) return Task.CompletedTask; } - public Task GetMaxIdAsync(string sheetName) + public Task GetMaxIdAsync(string sheetName) { - int max = 0; + long max = 0; if (_sheets.TryGetValue(sheetName, out var rows)) for (int i = 1; i < rows.Count; i++) - if (rows[i].Count > 0 && int.TryParse(rows[i][0]?.ToString(), out var id) && id > max) + if (rows[i].Count > 0 && long.TryParse(rows[i][0]?.ToString(), out var id) && id > max) max = id; return Task.FromResult(max); } From 01acb0cf4304efb27d0e43638eda6eac0981bb54 Mon Sep 17 00:00:00 2001 From: muqimjon Date: Sat, 28 Feb 2026 13:43:01 +0500 Subject: [PATCH 31/36] fix: non-numeric PK auto-increment, ProductVersion uses real assembly version - SnapshotBuilder: IsAutoIncrement only true for numeric PK types (int/long/short/byte etc.) - SheetsSet: only auto-assign ID when pkColumn.IsAutoIncrement is true - string PKs (e.g. Username) are now user-assigned, not overwritten with '1','2'... - GoogleMigrationService & ExcelMigrationService: ProductVersion reads Sheetly.Core assembly version dynamically instead of hardcoded '1.0.0' --- src/Sheetly.Core/Migrations/SnapshotBuilder.cs | 11 ++++++++++- src/Sheetly.Core/SheetsSet.cs | 2 +- src/Sheetly.Excel/ExcelMigrationService.cs | 3 ++- src/Sheetly.Google/GoogleMigrationService.cs | 3 ++- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/Sheetly.Core/Migrations/SnapshotBuilder.cs b/src/Sheetly.Core/Migrations/SnapshotBuilder.cs index 67c9544..40eac7e 100644 --- a/src/Sheetly.Core/Migrations/SnapshotBuilder.cs +++ b/src/Sheetly.Core/Migrations/SnapshotBuilder.cs @@ -50,7 +50,7 @@ public static MigrationSnapshot BuildFromContext(Type contextType, Dictionary SaveChangesInternalAsync() { var pkColumn = schema.Columns.FirstOrDefault(c => c.IsPrimaryKey); - if (pkColumn is not null) + if (pkColumn is not null && pkColumn.IsAutoIncrement) { long nextId = await provider.GetMaxIdAsync(schema.TableName) + 1; var batchRows = new List>(toAdd.Count); diff --git a/src/Sheetly.Excel/ExcelMigrationService.cs b/src/Sheetly.Excel/ExcelMigrationService.cs index 9d29c5e..7ab63af 100644 --- a/src/Sheetly.Excel/ExcelMigrationService.cs +++ b/src/Sheetly.Excel/ExcelMigrationService.cs @@ -283,8 +283,9 @@ private async Task EnsureSystemTablesExistAsync() private async Task RecordMigrationAsync(string migrationId) { + var version = typeof(ISheetsProvider).Assembly.GetName().Version?.ToString(3) ?? "1.0.0"; await provider.AppendRowAsync(HistoryTable, - [migrationId, DateTime.UtcNow.ToString("O"), "1.0.0"]); + [migrationId, DateTime.UtcNow.ToString("O"), version]); } private async Task RemoveFromSchemaTableAsync(string tableName, string columnName) diff --git a/src/Sheetly.Google/GoogleMigrationService.cs b/src/Sheetly.Google/GoogleMigrationService.cs index 03d33e6..6f1299d 100644 --- a/src/Sheetly.Google/GoogleMigrationService.cs +++ b/src/Sheetly.Google/GoogleMigrationService.cs @@ -205,8 +205,9 @@ private async Task EnsureSystemTablesExistAsync() private async Task RecordMigrationAsync(string migrationId) { + var version = typeof(ISheetsProvider).Assembly.GetName().Version?.ToString(3) ?? "1.0.0"; await provider.AppendRowAsync(HistoryTable, - [migrationId, DateTime.UtcNow.ToString("O"), "1.0.0"]); + [migrationId, DateTime.UtcNow.ToString("O"), version]); } private async Task DropColumnAsync(DropColumnOperation op) From cc8909a162e7a472fc7f2bc052274803c57b92eb Mon Sep 17 00:00:00 2001 From: muqimjon Date: Sat, 28 Feb 2026 14:11:16 +0500 Subject: [PATCH 32/36] fix: PK required/nullable rules, user-assigned PK validation, string PK tests - SnapshotBuilder: PK columns always IsRequired=true, IsNullable=false regardless of type - PrimaryKeyValidator: empty string user-assigned PK now throws validation error (auto-increment PKs skip validation when value is default - system assigns) - Add 5 SnapshotBuilder unit tests for numeric/string PK schema metadata - Add 5 integration tests (StringPkTests) covering string PK CRUD + validation --- .../Migrations/SnapshotBuilder.cs | 4 +- .../Validation/Rules/PrimaryKeyValidator.cs | 19 ++- .../Integration/Models/TestModels.cs | 7 + .../Integration/StringPkTests.cs | 131 ++++++++++++++++++ .../SnapshotBuilderTests.cs | 49 +++++++ 5 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 tests/Sheetly.Core.Tests/Integration/StringPkTests.cs diff --git a/src/Sheetly.Core/Migrations/SnapshotBuilder.cs b/src/Sheetly.Core/Migrations/SnapshotBuilder.cs index 40eac7e..4ef5949 100644 --- a/src/Sheetly.Core/Migrations/SnapshotBuilder.cs +++ b/src/Sheetly.Core/Migrations/SnapshotBuilder.cs @@ -52,8 +52,8 @@ public static MigrationSnapshot BuildFromContext(Type contextType, Dictionary()?.Length, MinLength = propConfig?.MinLength, MinValue = propConfig?.MinValue, diff --git a/src/Sheetly.Core/Validation/Rules/PrimaryKeyValidator.cs b/src/Sheetly.Core/Validation/Rules/PrimaryKeyValidator.cs index e9a0f43..d6a50fd 100644 --- a/src/Sheetly.Core/Validation/Rules/PrimaryKeyValidator.cs +++ b/src/Sheetly.Core/Validation/Rules/PrimaryKeyValidator.cs @@ -21,9 +21,24 @@ public ValidationResult Validate(object entity, ValidationContext context) var value = property.GetValue(entity); - if (value is null || IsDefaultValue(value, property.PropertyType)) + if (pkColumn.IsAutoIncrement) { - return result; + // Auto-increment PK: skip validation when value is default — system will assign it + if (value is null || IsDefaultValue(value, property.PropertyType)) + return result; + } + else + { + // User-assigned PK: null or empty string is always an error + if (value is null || (value is string s && string.IsNullOrEmpty(s))) + { + result.AddError(new ValidationError(pkColumn.PropertyName, + $"Primary key '{pkColumn.PropertyName}' is required. Non-auto-increment primary keys must have a user-provided value.") + { + EntityType = entityType.Name + }); + return result; + } } if (context.ExistingPrimaryKeys.Contains(value)) diff --git a/tests/Sheetly.Core.Tests/Integration/Models/TestModels.cs b/tests/Sheetly.Core.Tests/Integration/Models/TestModels.cs index 68a260d..5d8ec1f 100644 --- a/tests/Sheetly.Core.Tests/Integration/Models/TestModels.cs +++ b/tests/Sheetly.Core.Tests/Integration/Models/TestModels.cs @@ -24,3 +24,10 @@ public class Tag public int Id { get; set; } public string Label { get; set; } = string.Empty; } + +public class UserAccount +{ + [System.ComponentModel.DataAnnotations.Key] + public string Username { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; +} diff --git a/tests/Sheetly.Core.Tests/Integration/StringPkTests.cs b/tests/Sheetly.Core.Tests/Integration/StringPkTests.cs new file mode 100644 index 0000000..2036e8c --- /dev/null +++ b/tests/Sheetly.Core.Tests/Integration/StringPkTests.cs @@ -0,0 +1,131 @@ +using Sheetly.Core.Tests.Integration.Helpers; +using Sheetly.Core.Tests.Integration.Models; + +namespace Sheetly.Core.Tests.Integration; + +/// +/// Verifies that user-assigned (non-auto-increment) primary keys work correctly: +/// - The user-provided value is stored as-is (not overwritten) +/// - Empty/null PK throws a validation error +/// - Duplicate PK in the same batch throws a validation error +/// +public class StringPkTests +{ + // UserAccount auto-derives table name "UserAccounts" + private const string TableName = "UserAccounts"; + + private static async Task<(StringPkDbContext ctx, InMemorySheetsProvider provider)> CreateAsync() + { + var provider = new InMemorySheetsProvider(); + await provider.CreateSheetAsync(TableName, ["Username", "Email"]); + await provider.CreateSheetAsync("__SheetlySchema__", StringPkContextFactory.SchemaHeaders); + await StringPkContextFactory.AppendSchemaRowAsync(provider, "UserAccount", TableName, "Username"); + + var ctx = new StringPkDbContext(); + await ctx.InitializeAsync(provider); + return (ctx, provider); + } + + [Fact] + public async Task Add_StringPk_ValueIsPreserved() + { + var (ctx, _) = await CreateAsync(); + + var account = new UserAccount { Username = "johndoe", Email = "john@example.com" }; + ctx.Accounts.Add(account); + await ctx.SaveChangesAsync(); + + Assert.Equal("johndoe", account.Username); + } + + [Fact] + public async Task Add_StringPk_StoredCorrectlyInSheet() + { + var (ctx, provider) = await CreateAsync(); + + ctx.Accounts.Add(new UserAccount { Username = "alice", Email = "alice@example.com" }); + await ctx.SaveChangesAsync(); + + var rows = provider.GetSheetSnapshot(TableName); + Assert.Equal(2, rows.Count); // header + 1 data row + Assert.Equal("alice", rows[1][0]?.ToString()); + } + + [Fact] + public async Task Add_EmptyStringPk_ThrowsValidationException() + { + var (ctx, _) = await CreateAsync(); + + ctx.Accounts.Add(new UserAccount { Username = "", Email = "x@example.com" }); + + await Assert.ThrowsAsync( + () => ctx.SaveChangesAsync()); + } + + [Fact] + public async Task Add_DuplicateStringPk_InSameBatch_ThrowsValidationException() + { + var (ctx, _) = await CreateAsync(); + + ctx.Accounts.Add(new UserAccount { Username = "bob", Email = "bob1@example.com" }); + ctx.Accounts.Add(new UserAccount { Username = "bob", Email = "bob2@example.com" }); + + await Assert.ThrowsAsync( + () => ctx.SaveChangesAsync()); + } + + [Fact] + public async Task Add_MultipleStringPk_AllPreserved() + { + var (ctx, _) = await CreateAsync(); + + ctx.Accounts.Add(new UserAccount { Username = "user1", Email = "u1@example.com" }); + ctx.Accounts.Add(new UserAccount { Username = "user2", Email = "u2@example.com" }); + ctx.Accounts.Add(new UserAccount { Username = "user3", Email = "u3@example.com" }); + await ctx.SaveChangesAsync(); + + var all = await ctx.Accounts.ToListAsync(); + var usernames = all.Select(a => a.Username).OrderBy(u => u).ToList(); + Assert.Equal(["user1", "user2", "user3"], usernames); + } +} + +public class StringPkDbContext : SheetsContext +{ + public SheetsSet Accounts { get; set; } = default!; +} + +public static class StringPkContextFactory +{ + public static readonly string[] SchemaHeaders = new string[30] + { + "ClassName", "TableName", "PropertyName", "ColumnName", "DataType", + "IsNullable", "IsRequired", "IsPrimaryKey", "IsForeignKey", "ForeignKeyTable", + "ForeignKeyColumn", "OnDelete", "OnUpdate", "IsUnique", "IndexName", + "MaxLength", "MinLength", "Precision", "Scale", "MinValue", + "MaxValue", "DefaultValue", "DefaultValueSql", "CheckConstraint", "IsComputed", + "ComputedSql", "IsConcurrencyToken", "IsAutoIncrement", "CurrentIdValue", "Comment" + }; + + public static async Task AppendSchemaRowAsync( + InMemorySheetsProvider provider, + string className, + string tableName, + string pkPropertyName) + { + var row = new object[30]; + for (int i = 0; i < row.Length; i++) row[i] = string.Empty; + + row[0] = className; + row[1] = tableName; + row[2] = pkPropertyName; + row[3] = pkPropertyName; + row[4] = "String"; + row[6] = "True"; // IsRequired + row[7] = "True"; // IsPrimaryKey + row[27] = "False"; // IsAutoIncrement — user-assigned PK + row[28] = "0"; + + await provider.AppendRowAsync("__SheetlySchema__", row); + } +} diff --git a/tests/Sheetly.Core.Tests/SnapshotBuilderTests.cs b/tests/Sheetly.Core.Tests/SnapshotBuilderTests.cs index be33f13..1271d14 100644 --- a/tests/Sheetly.Core.Tests/SnapshotBuilderTests.cs +++ b/tests/Sheetly.Core.Tests/SnapshotBuilderTests.cs @@ -98,6 +98,43 @@ public void BuildFromContext_ShouldSetAutoIncrementForPK() Assert.True(pkColumn.IsAutoIncrement); } + [Fact] + public void BuildFromContext_NumericPK_IsRequired() + { + var snapshot = SnapshotBuilder.BuildFromContext(typeof(TestContext)); + var pkColumn = snapshot.Entities["TestUsers"].Columns.First(c => c.IsPrimaryKey); + + Assert.True(pkColumn.IsRequired); + Assert.False(pkColumn.IsNullable); + } + + [Fact] + public void BuildFromContext_StringPK_IsNotAutoIncrement() + { + var snapshot = SnapshotBuilder.BuildFromContext(typeof(TestContextWithStringPk)); + var pkColumn = snapshot.Entities["TestAccounts"].Columns.First(c => c.IsPrimaryKey); + + Assert.False(pkColumn.IsAutoIncrement); + } + + [Fact] + public void BuildFromContext_StringPK_IsRequired() + { + var snapshot = SnapshotBuilder.BuildFromContext(typeof(TestContextWithStringPk)); + var pkColumn = snapshot.Entities["TestAccounts"].Columns.First(c => c.IsPrimaryKey); + + Assert.True(pkColumn.IsRequired); + } + + [Fact] + public void BuildFromContext_StringPK_IsNotNullable() + { + var snapshot = SnapshotBuilder.BuildFromContext(typeof(TestContextWithStringPk)); + var pkColumn = snapshot.Entities["TestAccounts"].Columns.First(c => c.IsPrimaryKey); + + Assert.False(pkColumn.IsNullable); + } + // Test context classes private class TestContext : SheetsContext { @@ -115,6 +152,11 @@ private class TestContextWithAttr : SheetsContext public SheetsSet Products { get; set; } = default!; } + private class TestContextWithStringPk : SheetsContext + { + public SheetsSet Accounts { get; set; } = default!; + } + private class TestUser { public int Id { get; set; } @@ -135,4 +177,11 @@ private class TestProduct public int Id { get; set; } public string Name { get; set; } = ""; } + + private class TestAccount + { + [System.ComponentModel.DataAnnotations.Key] + public string Username { get; set; } = ""; + public string Email { get; set; } = ""; + } } From e8b2827de35c74c5aefc83939a3df7c324517403 Mon Sep 17 00:00:00 2001 From: muqimjon Date: Sat, 28 Feb 2026 17:24:07 +0500 Subject: [PATCH 33/36] feat: schema-based ID generation with batch reservation, release 1.1.0 - ISheetsProvider: replace GetMaxIdAsync with GetAndIncrementIdAsync(tableName, count) - GetAndIncrementIdAsync reads/writes CurrentIdValue in __SheetlySchema__ - Batch insert reserves all IDs in one schema update (reduces race condition window) - Fallback: if CurrentIdValue=0, scans data column A for max (backward compat) - Remove GetMaxIdAsync and AppendRowAndGetIdAsync from interface (internal detail) - Add SchemaIdGenerationTests (5 tests covering schema counter, batch, fallback, concurrency) - Bump all package versions to 1.1.0 --- samples/Sheetly.Sample/AppDbContext.cs | 5 +- ...te.cs => 20260228114226_InitialMigrate.cs} | 2 +- .../Migrations/AppDbModelSnapshot.cs | 6 +- .../Abstractions/ISheetsProvider.cs | 3 +- src/Sheetly.Core/SheetsSet.cs | 2 +- src/Sheetly.Excel/ExcelSheetProvider.cs | 54 +++++----- src/Sheetly.Google/GoogleSheetProvider.cs | 76 +++++--------- .../Helpers/InMemorySheetsProvider.cs | 50 +++++----- .../Integration/SchemaIdGenerationTests.cs | 99 +++++++++++++++++++ 9 files changed, 185 insertions(+), 112 deletions(-) rename samples/Sheetly.Sample/Migrations/{20260228074424_InitialMigrate.cs => 20260228114226_InitialMigrate.cs} (95%) create mode 100644 tests/Sheetly.Core.Tests/Integration/SchemaIdGenerationTests.cs diff --git a/samples/Sheetly.Sample/AppDbContext.cs b/samples/Sheetly.Sample/AppDbContext.cs index fed114d..bb73695 100644 --- a/samples/Sheetly.Sample/AppDbContext.cs +++ b/samples/Sheetly.Sample/AppDbContext.cs @@ -1,7 +1,6 @@ using Sheetly.Core; using Sheetly.Core.Configuration; using Sheetly.Excel; -using Sheetly.Google; using Sheetly.Sample.Models; namespace Sheetly.Sample; @@ -13,8 +12,8 @@ public class AppDbContext : SheetsContext protected override void OnConfiguring(SheetsOptions options) { - //options.UseExcel("C:/Users/user/OneDrive/Desk/sheetly-test.xlsx"); - options.UseGoogleSheets("1bNZnlJJ81VLbM5VeWoy9uCq4Ynz2bkAXaJlFJAYy_Sc", "credentials.json"); + options.UseExcel("C:\\Users\\muqim\\OneDrive\\Ishchi stol\\sheetly-test.xlsx"); + //options.UseGoogleSheets("1bNZnlJJ81VLbM5VeWoy9uCq4Ynz2bkAXaJlFJAYy_Sc", "credentials.json"); } protected override void OnModelCreating(ModelBuilder modelBuilder) diff --git a/samples/Sheetly.Sample/Migrations/20260228074424_InitialMigrate.cs b/samples/Sheetly.Sample/Migrations/20260228114226_InitialMigrate.cs similarity index 95% rename from samples/Sheetly.Sample/Migrations/20260228074424_InitialMigrate.cs rename to samples/Sheetly.Sample/Migrations/20260228114226_InitialMigrate.cs index 3e2dae4..6f78133 100644 --- a/samples/Sheetly.Sample/Migrations/20260228074424_InitialMigrate.cs +++ b/samples/Sheetly.Sample/Migrations/20260228114226_InitialMigrate.cs @@ -3,7 +3,7 @@ namespace Sheetly.Sample.Migrations; -[Migration("20260228074424_InitialMigrate")] +[Migration("20260228114226_InitialMigrate")] public partial class InitialMigrate : Migration { public override void Up(MigrationBuilder builder) diff --git a/samples/Sheetly.Sample/Migrations/AppDbModelSnapshot.cs b/samples/Sheetly.Sample/Migrations/AppDbModelSnapshot.cs index 711e045..c0c73e2 100644 --- a/samples/Sheetly.Sample/Migrations/AppDbModelSnapshot.cs +++ b/samples/Sheetly.Sample/Migrations/AppDbModelSnapshot.cs @@ -20,7 +20,7 @@ public static MigrationSnapshot BuildModel() { ModelHash = "B9emMa1A++cOQMHt5sY3NkJRTAb1yP/Ei7sKWFlwVDw=", Version = "1.0.0", - LastUpdated = DateTime.Parse("2026-02-28T07:44:24.5479128Z") + LastUpdated = DateTime.Parse("2026-02-28T11:42:26.8750456Z") }; // Category @@ -41,7 +41,7 @@ public static MigrationSnapshot BuildModel() IsForeignKey = false, ForeignKeyColumn = "Id", IsNullable = false, - IsRequired = false + IsRequired = true }, new ColumnSchema { @@ -79,7 +79,7 @@ public static MigrationSnapshot BuildModel() IsForeignKey = false, ForeignKeyColumn = "Id", IsNullable = false, - IsRequired = false + IsRequired = true }, new ColumnSchema { diff --git a/src/Sheetly.Core/Abstractions/ISheetsProvider.cs b/src/Sheetly.Core/Abstractions/ISheetsProvider.cs index 6532878..4c00c79 100644 --- a/src/Sheetly.Core/Abstractions/ISheetsProvider.cs +++ b/src/Sheetly.Core/Abstractions/ISheetsProvider.cs @@ -14,8 +14,7 @@ public interface ISheetsProvider : IDisposable Task FindRowIndexByKeyAsync(string sheetName, string keyValue); Task AppendRowAsync(string sheetName, IList row); Task AppendRowsAsync(string sheetName, IList> rows); - Task AppendRowAndGetIdAsync(string sheetName, IList row); - Task GetMaxIdAsync(string sheetName); + Task GetAndIncrementIdAsync(string tableName, int count = 1); Task UpdateRowAsync(string sheetName, int rowIndex, IList row); Task DeleteRowAsync(string sheetName, int rowIndex); diff --git a/src/Sheetly.Core/SheetsSet.cs b/src/Sheetly.Core/SheetsSet.cs index a1fc567..0f1cdb0 100644 --- a/src/Sheetly.Core/SheetsSet.cs +++ b/src/Sheetly.Core/SheetsSet.cs @@ -276,7 +276,7 @@ internal async Task SaveChangesInternalAsync() if (pkColumn is not null && pkColumn.IsAutoIncrement) { - long nextId = await provider.GetMaxIdAsync(schema.TableName) + 1; + long nextId = await provider.GetAndIncrementIdAsync(schema.TableName, toAdd.Count); var batchRows = new List>(toAdd.Count); var pkProp = typeof(T).GetProperty(pkColumn.PropertyName); foreach (var item in toAdd) diff --git a/src/Sheetly.Excel/ExcelSheetProvider.cs b/src/Sheetly.Excel/ExcelSheetProvider.cs index ee79215..b42122c 100644 --- a/src/Sheetly.Excel/ExcelSheetProvider.cs +++ b/src/Sheetly.Excel/ExcelSheetProvider.cs @@ -137,33 +137,35 @@ public Task AppendRowsAsync(string sheetName, IList> rows) return Task.CompletedTask; } - public Task AppendRowAndGetIdAsync(string sheetName, IList row) + public async Task GetAndIncrementIdAsync(string tableName, int count = 1) { - EnsureWorkbook(); - var ws = GetWorksheet(sheetName); - - long maxId = GetMaxIdFromSheet(ws); - long nextId = maxId + 1; - - var newRow = row.ToList(); - if (newRow.Count > 0) - newRow[0] = nextId; - - int nextRowNum = GetNextEmptyRow(ws); - for (int i = 0; i < newRow.Count; i++) - ws.Cell(nextRowNum, i + 1).Value = newRow[i]?.ToString() ?? ""; - - Save(); - return Task.FromResult((int)nextId); - } - - public Task GetMaxIdAsync(string sheetName) - { - EnsureWorkbook(); - if (!_workbook!.TryGetWorksheet(sheetName, out var ws)) - return Task.FromResult(0L); - - return Task.FromResult(GetMaxIdFromSheet(ws)); + var schemaRows = await GetAllRowsAsync("__SheetlySchema__"); + for (int i = 1; i < schemaRows.Count; i++) + { + var row = schemaRows[i]; + if (row.Count > 7 && + row[1]?.ToString() == tableName && + row[7]?.ToString() == "True") + { + long currentId = 0; + if (row.Count > 28) + long.TryParse(row[28]?.ToString(), out currentId); + + if (currentId == 0) + { + var dataRows = await GetAllRowsAsync(tableName); + for (int j = 1; j < dataRows.Count; j++) + if (dataRows[j].Count > 0 && long.TryParse(dataRows[j][0]?.ToString(), out var did) && did > currentId) + currentId = did; + } + + long nextId = currentId + 1; + int spreadsheetRow = i + 1; + await UpdateValueAsync("__SheetlySchema__", $"AC{spreadsheetRow}", currentId + count); + return nextId; + } + } + return 1; } public Task UpdateRowAsync(string sheetName, int rowIndex, IList row) diff --git a/src/Sheetly.Google/GoogleSheetProvider.cs b/src/Sheetly.Google/GoogleSheetProvider.cs index 094c89e..80732e4 100644 --- a/src/Sheetly.Google/GoogleSheetProvider.cs +++ b/src/Sheetly.Google/GoogleSheetProvider.cs @@ -171,44 +171,36 @@ public async Task AppendRowAsync(string sheetName, IList row) await ExecuteWithRetryAsync(request); } - /// - /// Appends a row where the first cell is a formula =IFERROR(MAX(INDIRECT("'Table'!A2:A"))+1,1). - /// Returns the computed integer ID after reading the cell back. - /// Reduces ID management from 5 API calls to 2 (append + read). - /// - public async Task AppendRowAndGetIdAsync(string sheetName, IList row) + public async Task GetAndIncrementIdAsync(string tableName, int count = 1) { - var rowWithFormula = new List(row) + var schemaRows = await GetAllRowsAsync("__SheetlySchema__"); + for (int i = 1; i < schemaRows.Count; i++) { - [0] = $"=IFERROR(MAX(INDIRECT(\"'{sheetName}'!A2:A\"))+1,1)" - }; - - var vr = new ValueRange { Values = new List> { rowWithFormula } }; - var request = NextService.Spreadsheets.Values.Append(vr, _spreadsheetId, $"'{sheetName}'!A1"); - request.ValueInputOption = SpreadsheetsResource.ValuesResource.AppendRequest.ValueInputOptionEnum.USERENTERED; - var response = await ExecuteWithRetryAsync(request); - - var updatedRange = response.Updates?.UpdatedRange ?? string.Empty; - var rowNumber = ExtractRowNumberFromRange(updatedRange); + var row = schemaRows[i]; + if (row.Count > 7 && + row[1]?.ToString() == tableName && + row[7]?.ToString() == "True") + { + long currentId = 0; + if (row.Count > 28) + long.TryParse(row[28]?.ToString(), out currentId); - var idValue = await GetValueAsync(sheetName, $"A{rowNumber}"); - return idValue is not null && int.TryParse(idValue.ToString(), out var id) ? id : rowNumber - 1; - } + if (currentId == 0) + { + var dataRows = await GetAllRowsAsync(tableName); + for (int j = 1; j < dataRows.Count; j++) + if (dataRows[j].Count > 0 && long.TryParse(dataRows[j][0]?.ToString(), out var did) && did > currentId) + currentId = did; + } - /// Parses the row number from a Sheets range string like "'Table'!A5:E5" or "A5:E5". - private static int ExtractRowNumberFromRange(string range) - { - var colonIdx = range.IndexOf('!'); - var cellPart = colonIdx >= 0 ? range[(colonIdx + 1)..] : range; - var startCell = cellPart.Split(':')[0]; - var digits = new string(startCell.SkipWhile(c => !char.IsDigit(c)).ToArray()); - return int.TryParse(digits, out var row) ? row : 2; + long nextId = currentId + 1; + int spreadsheetRow = i + 1; + await UpdateValueAsync("__SheetlySchema__", $"AC{spreadsheetRow}", currentId + count); + return nextId; + } + } + return 1; } - - /// - /// Appends multiple rows in a single API call (batch). Use when IDs are already assigned. - /// 1 API call regardless of row count. - /// public async Task AppendRowsAsync(string sheetName, IList> rows) { var vr = new ValueRange { Values = rows }; @@ -217,24 +209,6 @@ public async Task AppendRowsAsync(string sheetName, IList> rows) await ExecuteWithRetryAsync(request); } - /// - /// Returns the current maximum integer value in column A (excluding header). - /// Uses VALUES_UNRENDERED to get the raw number even when formulas are present. - /// 1 API call. - /// - public async Task GetMaxIdAsync(string sheetName) - { - var request = NextService.Spreadsheets.Values.Get(_spreadsheetId, $"'{sheetName}'!A2:A"); - request.ValueRenderOption = SpreadsheetsResource.ValuesResource.GetRequest.ValueRenderOptionEnum.UNFORMATTEDVALUE; - var response = await ExecuteWithRetryAsync(request); - long max = 0; - if (response.Values is not null) - foreach (var row in response.Values) - if (row.Count > 0 && long.TryParse(row[0]?.ToString(), out var id) && id > max) - max = id; - return max; - } - public async Task UpdateRowAsync(string sheetName, int rowIndex, IList row) { var endCol = GetColumnLetter(row.Count); diff --git a/tests/Sheetly.Core.Tests/Integration/Helpers/InMemorySheetsProvider.cs b/tests/Sheetly.Core.Tests/Integration/Helpers/InMemorySheetsProvider.cs index 6743118..6b8a5c9 100644 --- a/tests/Sheetly.Core.Tests/Integration/Helpers/InMemorySheetsProvider.cs +++ b/tests/Sheetly.Core.Tests/Integration/Helpers/InMemorySheetsProvider.cs @@ -97,34 +97,34 @@ public Task AppendRowsAsync(string sheetName, IList> rows) return Task.CompletedTask; } - public Task GetMaxIdAsync(string sheetName) + public async Task GetAndIncrementIdAsync(string tableName, int count = 1) { - long max = 0; - if (_sheets.TryGetValue(sheetName, out var rows)) - for (int i = 1; i < rows.Count; i++) - if (rows[i].Count > 0 && long.TryParse(rows[i][0]?.ToString(), out var id) && id > max) - max = id; - return Task.FromResult(max); - } - - public Task AppendRowAndGetIdAsync(string sheetName, IList row) - { - if (!_sheets.TryGetValue(sheetName, out var rows)) - return Task.FromResult(1); - - int maxId = 0; - for (int i = 1; i < rows.Count; i++) + var schemaRows = await GetAllRowsAsync("__SheetlySchema__"); + for (int i = 1; i < schemaRows.Count; i++) { - if (rows[i].Count > 0 && int.TryParse(rows[i][0]?.ToString(), out var id) && id > maxId) - maxId = id; + var row = schemaRows[i]; + if (row.Count > 7 && + row[1]?.ToString() == tableName && + row[7]?.ToString() == "True") + { + long currentId = 0; + if (row.Count > 28) + long.TryParse(row[28]?.ToString(), out currentId); + + if (currentId == 0) + { + if (_sheets.TryGetValue(tableName, out var dataRows)) + for (int j = 1; j < dataRows.Count; j++) + if (dataRows[j].Count > 0 && long.TryParse(dataRows[j][0]?.ToString(), out var did) && did > currentId) + currentId = did; + } + + long nextId = currentId + 1; + await UpdateValueAsync("__SheetlySchema__", $"AC{i + 1}", currentId + count); + return nextId; + } } - int nextId = maxId + 1; - - var newRow = row.ToList(); - if (newRow.Count > 0) - newRow[0] = nextId; - rows.Add(newRow); - return Task.FromResult(nextId); + return 1; } public Task UpdateRowAsync(string sheetName, int rowIndex, IList row) diff --git a/tests/Sheetly.Core.Tests/Integration/SchemaIdGenerationTests.cs b/tests/Sheetly.Core.Tests/Integration/SchemaIdGenerationTests.cs new file mode 100644 index 0000000..2e20906 --- /dev/null +++ b/tests/Sheetly.Core.Tests/Integration/SchemaIdGenerationTests.cs @@ -0,0 +1,99 @@ +using Sheetly.Core.Tests.Integration.Models; + +namespace Sheetly.Core.Tests.Integration; + +public class SchemaIdGenerationTests +{ + [Fact] + public async Task SchemaCurrentIdValue_UpdatedAfterInsert() + { + var (ctx, provider) = await TestContextFactory.CreateAsync(); + + ctx.Categories.Add(new Category { Name = "Alpha" }); + await ctx.SaveChangesAsync(); + + var schemaRows = await provider.GetAllRowsAsync("__SheetlySchema__"); + var categoryRow = schemaRows.Skip(1).FirstOrDefault(r => r.Count > 1 && r[1]?.ToString() == "Categories"); + + Assert.NotNull(categoryRow); + Assert.Equal("1", categoryRow![28]?.ToString()); + } + + [Fact] + public async Task BatchInsert_ReservesIdsAtOnce() + { + var (ctx, provider) = await TestContextFactory.CreateAsync(); + + var cats = new[] + { + new Category { Name = "Cat-X" }, + new Category { Name = "Cat-Y" }, + new Category { Name = "Cat-Z" }, + }; + foreach (var c in cats) ctx.Categories.Add(c); + await ctx.SaveChangesAsync(); + + var schemaRows = await provider.GetAllRowsAsync("__SheetlySchema__"); + var categoryRow = schemaRows.Skip(1).FirstOrDefault(r => r.Count > 1 && r[1]?.ToString() == "Categories"); + + Assert.NotNull(categoryRow); + Assert.Equal("3", categoryRow![28]?.ToString()); + Assert.Equal(new[] { 1, 2, 3 }, cats.Select(c => c.Id).ToArray()); + } + + [Fact] + public async Task SchemaFallback_WhenCurrentIdIsZero() + { + var (ctx, provider) = await TestContextFactory.CreateAsync(); + + var dataRow = new object[2]; + dataRow[0] = "5"; + dataRow[1] = "Existing"; + await provider.AppendRowAsync("Categories", dataRow); + + var newCat = new Category { Name = "New" }; + ctx.Categories.Add(newCat); + await ctx.SaveChangesAsync(); + + Assert.Equal(6, newCat.Id); + } + + [Fact] + public async Task NewContext_StartsFromSchemaValue() + { + var (ctx1, provider) = await TestContextFactory.CreateAsync(); + + ctx1.Categories.Add(new Category { Name = "Alpha" }); + ctx1.Categories.Add(new Category { Name = "Beta" }); + await ctx1.SaveChangesAsync(); + + var ctx2 = new TestDbContext(); + await ctx2.InitializeAsync(provider); + + var newCat = new Category { Name = "Gamma" }; + ctx2.Categories.Add(newCat); + await ctx2.SaveChangesAsync(); + + Assert.Equal(3, newCat.Id); + } + + [Fact] + public async Task ConcurrentInserts_NoDuplicateIds() + { + var (ctx1, provider) = await TestContextFactory.CreateAsync(); + + var ctx2 = new TestDbContext(); + await ctx2.InitializeAsync(provider); + + ctx1.Categories.Add(new Category { Name = "First" }); + await ctx1.SaveChangesAsync(); + + ctx2.Categories.Add(new Category { Name = "Second" }); + await ctx2.SaveChangesAsync(); + + var allRows = await provider.GetAllRowsAsync("Categories"); + var ids = allRows.Skip(1).Select(r => r[0]?.ToString()).ToList(); + + Assert.Equal(2, ids.Distinct().Count()); + } +} From 094dcac31cb1c74ea2cb502d154a9e72d87fb904 Mon Sep 17 00:00:00 2001 From: muqimjon Date: Sat, 28 Feb 2026 17:49:46 +0500 Subject: [PATCH 34/36] fix: GetAndIncrementIdAsync - bool.TryParse for IsPrimaryKey, GetValueAsync for direct cell read - Google Sheets USERENTERED mode stores 'True' as boolean TRUE, returned as 'TRUE' Old code: row[7] == 'True' always failed -> function returned 1 every time Fix: bool.TryParse handles 'True', 'TRUE', 'true' all correctly - Google: use GetValueAsync(schema, AC{row}) instead of row[28] for CurrentIdValue This avoids row.Count > 28 assumption (Google API omits trailing empty cells) - Excel/InMemory: same bool.TryParse fix applied for consistency --- samples/Sheetly.Sample/AppDbContext.cs | 5 +-- samples/Sheetly.Sample/Program.cs | 21 +++++------ src/Sheetly.Excel/ExcelSheetProvider.cs | 36 +++++++++---------- src/Sheetly.Google/GoogleSheetProvider.cs | 34 +++++++++--------- .../Helpers/InMemorySheetsProvider.cs | 35 +++++++++--------- 5 files changed, 64 insertions(+), 67 deletions(-) diff --git a/samples/Sheetly.Sample/AppDbContext.cs b/samples/Sheetly.Sample/AppDbContext.cs index bb73695..15a15a5 100644 --- a/samples/Sheetly.Sample/AppDbContext.cs +++ b/samples/Sheetly.Sample/AppDbContext.cs @@ -1,6 +1,7 @@ using Sheetly.Core; using Sheetly.Core.Configuration; using Sheetly.Excel; +using Sheetly.Google; using Sheetly.Sample.Models; namespace Sheetly.Sample; @@ -12,8 +13,8 @@ public class AppDbContext : SheetsContext protected override void OnConfiguring(SheetsOptions options) { - options.UseExcel("C:\\Users\\muqim\\OneDrive\\Ishchi stol\\sheetly-test.xlsx"); - //options.UseGoogleSheets("1bNZnlJJ81VLbM5VeWoy9uCq4Ynz2bkAXaJlFJAYy_Sc", "credentials.json"); + //options.UseExcel("C:\\Users\\muqim\\OneDrive\\Ishchi stol\\sheetly-test.xlsx"); + options.UseGoogleSheets("1bNZnlJJ81VLbM5VeWoy9uCq4Ynz2bkAXaJlFJAYy_Sc", "credentials.json"); } protected override void OnModelCreating(ModelBuilder modelBuilder) diff --git a/samples/Sheetly.Sample/Program.cs b/samples/Sheetly.Sample/Program.cs index 4e7fc0a..4c9ee55 100644 --- a/samples/Sheetly.Sample/Program.cs +++ b/samples/Sheetly.Sample/Program.cs @@ -10,16 +10,17 @@ Console.WriteLine("✅ Context initialized successfully!"); Console.WriteLine(); -//context.Products.Add(new Product -//{ -// Title = "Sample Product", -// Price = 19.99m, -// Description = "This is a sample product added to the Excel sheet." -//}); - -var firstProduct = await context.Products.FirstOrDefaultAsync(); -if (firstProduct is not null) - context.Products.Remove(firstProduct); +context.Products.Add(new Product +{ + Title = "Sample Product", + Price = 19.99m, + Description = "This is a sample product added to the Excel sheet.", + Stock = 100 +}); + +//var firstProduct = await context.Products.FirstOrDefaultAsync(); +//if (firstProduct is not null) +// context.Products.Remove(firstProduct); await context.SaveChangesAsync(); Console.WriteLine("📋 Categories:"); diff --git a/src/Sheetly.Excel/ExcelSheetProvider.cs b/src/Sheetly.Excel/ExcelSheetProvider.cs index b42122c..c8117d1 100644 --- a/src/Sheetly.Excel/ExcelSheetProvider.cs +++ b/src/Sheetly.Excel/ExcelSheetProvider.cs @@ -143,27 +143,25 @@ public async Task GetAndIncrementIdAsync(string tableName, int count = 1) for (int i = 1; i < schemaRows.Count; i++) { var row = schemaRows[i]; - if (row.Count > 7 && - row[1]?.ToString() == tableName && - row[7]?.ToString() == "True") + if (row.Count <= 7) continue; + if (row[1]?.ToString() != tableName) continue; + if (!bool.TryParse(row[7]?.ToString(), out var isPk) || !isPk) continue; + + long currentId = 0; + if (row.Count > 28) + long.TryParse(row[28]?.ToString(), out currentId); + + if (currentId == 0) { - long currentId = 0; - if (row.Count > 28) - long.TryParse(row[28]?.ToString(), out currentId); - - if (currentId == 0) - { - var dataRows = await GetAllRowsAsync(tableName); - for (int j = 1; j < dataRows.Count; j++) - if (dataRows[j].Count > 0 && long.TryParse(dataRows[j][0]?.ToString(), out var did) && did > currentId) - currentId = did; - } - - long nextId = currentId + 1; - int spreadsheetRow = i + 1; - await UpdateValueAsync("__SheetlySchema__", $"AC{spreadsheetRow}", currentId + count); - return nextId; + var dataRows = await GetAllRowsAsync(tableName); + for (int j = 1; j < dataRows.Count; j++) + if (dataRows[j].Count > 0 && long.TryParse(dataRows[j][0]?.ToString(), out var did) && did > currentId) + currentId = did; } + + long nextId = currentId + 1; + await UpdateValueAsync("__SheetlySchema__", $"AC{i + 1}", currentId + count); + return nextId; } return 1; } diff --git a/src/Sheetly.Google/GoogleSheetProvider.cs b/src/Sheetly.Google/GoogleSheetProvider.cs index 80732e4..aa59ba3 100644 --- a/src/Sheetly.Google/GoogleSheetProvider.cs +++ b/src/Sheetly.Google/GoogleSheetProvider.cs @@ -177,27 +177,25 @@ public async Task GetAndIncrementIdAsync(string tableName, int count = 1) for (int i = 1; i < schemaRows.Count; i++) { var row = schemaRows[i]; - if (row.Count > 7 && - row[1]?.ToString() == tableName && - row[7]?.ToString() == "True") - { - long currentId = 0; - if (row.Count > 28) - long.TryParse(row[28]?.ToString(), out currentId); + if (row.Count <= 7) continue; + if (row[1]?.ToString() != tableName) continue; + if (!bool.TryParse(row[7]?.ToString(), out var isPk) || !isPk) continue; - if (currentId == 0) - { - var dataRows = await GetAllRowsAsync(tableName); - for (int j = 1; j < dataRows.Count; j++) - if (dataRows[j].Count > 0 && long.TryParse(dataRows[j][0]?.ToString(), out var did) && did > currentId) - currentId = did; - } + int spreadsheetRow = i + 1; + var rawId = await GetValueAsync("__SheetlySchema__", $"AC{spreadsheetRow}"); + long.TryParse(rawId?.ToString(), out long currentId); - long nextId = currentId + 1; - int spreadsheetRow = i + 1; - await UpdateValueAsync("__SheetlySchema__", $"AC{spreadsheetRow}", currentId + count); - return nextId; + if (currentId == 0) + { + var dataRows = await GetAllRowsAsync(tableName); + for (int j = 1; j < dataRows.Count; j++) + if (dataRows[j].Count > 0 && long.TryParse(dataRows[j][0]?.ToString(), out var did) && did > currentId) + currentId = did; } + + long nextId = currentId + 1; + await UpdateValueAsync("__SheetlySchema__", $"AC{spreadsheetRow}", currentId + count); + return nextId; } return 1; } diff --git a/tests/Sheetly.Core.Tests/Integration/Helpers/InMemorySheetsProvider.cs b/tests/Sheetly.Core.Tests/Integration/Helpers/InMemorySheetsProvider.cs index 6b8a5c9..d0645f7 100644 --- a/tests/Sheetly.Core.Tests/Integration/Helpers/InMemorySheetsProvider.cs +++ b/tests/Sheetly.Core.Tests/Integration/Helpers/InMemorySheetsProvider.cs @@ -103,26 +103,25 @@ public async Task GetAndIncrementIdAsync(string tableName, int count = 1) for (int i = 1; i < schemaRows.Count; i++) { var row = schemaRows[i]; - if (row.Count > 7 && - row[1]?.ToString() == tableName && - row[7]?.ToString() == "True") + if (row.Count <= 7) continue; + if (row[1]?.ToString() != tableName) continue; + if (!bool.TryParse(row[7]?.ToString(), out var isPk) || !isPk) continue; + + long currentId = 0; + if (row.Count > 28) + long.TryParse(row[28]?.ToString(), out currentId); + + if (currentId == 0) { - long currentId = 0; - if (row.Count > 28) - long.TryParse(row[28]?.ToString(), out currentId); - - if (currentId == 0) - { - if (_sheets.TryGetValue(tableName, out var dataRows)) - for (int j = 1; j < dataRows.Count; j++) - if (dataRows[j].Count > 0 && long.TryParse(dataRows[j][0]?.ToString(), out var did) && did > currentId) - currentId = did; - } - - long nextId = currentId + 1; - await UpdateValueAsync("__SheetlySchema__", $"AC{i + 1}", currentId + count); - return nextId; + if (_sheets.TryGetValue(tableName, out var dataRows)) + for (int j = 1; j < dataRows.Count; j++) + if (dataRows[j].Count > 0 && long.TryParse(dataRows[j][0]?.ToString(), out var did) && did > currentId) + currentId = did; } + + long nextId = currentId + 1; + await UpdateValueAsync("__SheetlySchema__", $"AC{i + 1}", currentId + count); + return nextId; } return 1; } From 7a2894663f4db4b6fd66b65abad7df7107023fef Mon Sep 17 00:00:00 2001 From: muqimjon Date: Sat, 28 Feb 2026 18:15:24 +0500 Subject: [PATCH 35/36] refactor: primary constructors + update RELEASE_NOTES for v1.1.0 - DatabaseFacade: converted to C# 12 primary constructor - RELEASE_NOTES.md: added v1.1.0 section, moved v1.0.x to history, updated roadmap --- docs/RELEASE_NOTES.md | 126 +++++++++--------- samples/Sheetly.Sample/Program.cs | 22 ++- .../Infrastructure/DatabaseFacade.cs | 32 ++--- src/Sheetly.Excel/ExcelSheetProvider.cs | 9 +- 4 files changed, 83 insertions(+), 106 deletions(-) diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index bf189c1..ed5f448 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -1,103 +1,103 @@ -# 🎉 Sheetly v1.0.1 — Release Notes +# 🎉 Sheetly v1.1.0 — Release Notes -## Entity Framework Core for Google Sheets +## Entity Framework Core for Spreadsheets -**Release Date:** February 23, 2026 +**Release Date:** March 2026 --- -## 🐛 What's Fixed in v1.0.1 +## ✨ What's New in v1.1.0 -- **CLI banner** — EF Core-style terminal output with teal rocket art -- **`OnConfiguring` detection** — `dotnet sheetly database update` now reads connection settings directly from `OnConfiguring()`, no `appsettings.json` required -- **Build-first behavior** — All CLI commands now build the project before executing (like `dotnet ef`) -- **Version output** — Removed git commit hash from `--version` output -- **Brand logo** — Added official icon to all NuGet packages -- **CI/CD** — GitHub Actions workflows for automatic NuGet publishing - ---- +### Excel Provider -## ✨ What's New +- **`Sheetly.Excel`** — New package for local `.xlsx` files via [ClosedXML](https://github.com/ClosedXML/ClosedXML) +- Switch between Google Sheets and Excel with a single line: -### Core Features +```csharp +// Google Sheets +options.UseGoogleSheets("spreadsheetId", "credentials.json"); -- **SheetsContext & SheetsSet\** — EF Core-style context and entity sets -- **CRUD** — `Add()`, `Update()`, `Remove()`, `SaveChangesAsync()` -- **Queries** — `FindAsync()`, `FirstOrDefaultAsync()`, `Where()`, `CountAsync()`, `AnyAsync()` -- **Include()** — Eager loading for navigation properties -- **AsNoTracking()** — Read-only queries without change tracking +// Local Excel file +options.UseExcel("path/to/file.xlsx"); +``` -### Code-First Migrations +- All CLI commands (`migrations add`, `database update`, `database drop`, `scaffold`) work identically for both providers -- C# migration files with `Up()` / `Down()` methods -- `ModelSnapshot.cs` — C# snapshot (no JSON) -- Automatic change detection via `ModelDiffer` -- Startup sync check — detects pending migrations and model changes +### Schema-Based Auto-Increment ID -### Constraint Validation +- **Concurrent-safe ID generation** — ID counter is stored in `__SheetlySchema__` sheet/worksheet +- On `SaveChangesAsync()`, the counter is fetched, incremented, and written back atomically before data is inserted +- Prevents duplicate IDs when multiple clients insert simultaneously +- If the counter is `0` (first run or legacy data), the provider scans the existing data sheet for the current max ID and continues from there +- **Non-numeric primary keys** (string, Guid) are user-assigned — no auto-increment, required validation is enforced automatically -Validates locally before any Google Sheets API calls: +### Primary Constructor Refactoring -- Primary Keys (auto-detected, auto-increment) -- Foreign Keys (auto-detected from `{Entity}Id` convention) -- Required / Nullable -- MaxLength / MinLength -- Range (MinValue / MaxValue) -- Unique constraints -- Check constraints -- Data type validation +- `DatabaseFacade` refactored to C# 12 primary constructor syntax +- Consistent with `GoogleMigrationService` and `ExcelMigrationService` already using primary constructors -### CLI Tool +### Other Improvements -```bash -dotnet tool install -g dotnet-sheetly +- `ProductVersion` in `__SheetlyMigrationsHistory__` now reflects the actual NuGet assembly version +- Boolean schema columns parsed case-insensitively (`bool.TryParse`) — fixes Google Sheets USERENTERED mode storing `True` as `TRUE` +- Build-first behavior applies to both providers — the user project is built before CLI commands execute +- Inline comments removed from all source files; `is null` / `is not null` null checks enforced throughout -dotnet sheetly migrations add InitialCreate -dotnet sheetly migrations list -dotnet sheetly migrations remove -dotnet sheetly database update -dotnet sheetly database drop -dotnet sheetly scaffold -``` +--- -### Google Sheets Provider +## 📦 Packages -- Automatic retry with exponential backoff on rate limits (429 / 503) -- Hidden `__SheetlySchema__` and `__SheetlyMigrationsHistory__` sheets +| Package | Version | Description | +|---|---|---| +| `Sheetly.Core` | 1.1.0 | Core abstractions, migrations, validation | +| `Sheetly.Google` | 1.1.0 | Google Sheets provider | +| `Sheetly.Excel` | 1.1.0 | Local Excel (.xlsx) provider | +| `dotnet-sheetly` | 1.1.0 | CLI tool (global tool) | +| `Sheetly.DependencyInjection` | 1.1.0 | ASP.NET Core DI integration | --- -## 📦 Packages +## 🐛 What's Fixed in v1.1.0 -| Package | Description | -|---|---| -| `Sheetly.Core` | Core abstractions, migrations, validation | -| `Sheetly.Google` | Google Sheets API provider | -| `dotnet-sheetly` | CLI tool (global tool) | -| `Sheetly.DependencyInjection` | ASP.NET Core DI integration | +- **ID always = 1** — `GetAndIncrementIdAsync` was comparing `"True"` with `"TRUE"` (Google Sheets USERENTERED boolean); fixed with `bool.TryParse` +- **Schema row count assumption** — Replaced `row.Count > 28` check with direct `GetValueAsync` cell read for Google provider to handle trailing empty cells correctly --- ## ⚠️ Known Limitations -- **Google Sheets API rate limits** — 60 reads/min per user (mitigated by auto-retry) +- **Google Sheets API rate limits** — 60 reads/min per user; use multiple `credentials.json` files for higher throughput - **Column drop** — Can't directly remove columns in Sheets; tracked in schema only -- **Transactions** — Not supported (Sheets API limitation) -- **Queries** — In-memory filtering after data load; no server-side query execution +- **Transactions** — Not supported (Sheets/Excel limitation) +- **Queries** — In-memory filtering after full data load; no server-side query execution --- ## 🔮 Roadmap -### v1.1.0 -- Excel provider (`Sheetly.Excel`) +### v1.2.0 + +- **Navigation property auto-resolution** — `product.Category = new Category { Name = "Books" }` automatically resolves and assigns `CategoryId` - Advanced LINQ support (`OrderBy`, `Select`, `Skip`, `Take`) - Query result caching -### v1.2.0 -- Scaffold improvements -- Batch operation optimization -- Read-only view support +--- + +## 📜 Version History + +### v1.0.1 — February 23, 2026 + +- **CLI banner** — EF Core-style terminal output with teal rocket art +- **`OnConfiguring` detection** — `dotnet sheetly database update` reads connection settings directly from `OnConfiguring()`, no `appsettings.json` required +- **Build-first behavior** — All CLI commands build the project before executing (like `dotnet ef`) +- **Version output** — Removed git commit hash from `--version` output +- **Brand logo** — Added official icon to all NuGet packages +- **CI/CD** — GitHub Actions workflows for automatic NuGet publishing + +### v1.0.0 — February 2026 + +- Initial release: `SheetsContext`, `SheetsSet`, CRUD, migrations, CLI tool, Google Sheets provider +- Constraint validation (PK, FK, Required, MaxLength, Range, Unique, Check, DataType) --- diff --git a/samples/Sheetly.Sample/Program.cs b/samples/Sheetly.Sample/Program.cs index 4c9ee55..dc686e2 100644 --- a/samples/Sheetly.Sample/Program.cs +++ b/samples/Sheetly.Sample/Program.cs @@ -1,15 +1,9 @@ using Sheetly.Sample; using Sheetly.Sample.Models; -Console.WriteLine("🚀 Sheetly Sample Application"); -Console.WriteLine(new string('=', 40)); - await using var context = new AppDbContext(); await context.InitializeAsync(); -Console.WriteLine("✅ Context initialized successfully!"); -Console.WriteLine(); - context.Products.Add(new Product { Title = "Sample Product", @@ -18,21 +12,23 @@ Stock = 100 }); -//var firstProduct = await context.Products.FirstOrDefaultAsync(); -//if (firstProduct is not null) -// context.Products.Remove(firstProduct); +var firstProduct = await context.Products.FirstOrDefaultAsync(); +firstProduct?.Description = "Updated description for the first product."; + +if (firstProduct is not null) + context.Products.Remove(firstProduct); await context.SaveChangesAsync(); + + + Console.WriteLine("📋 Categories:"); var categories = await context.Categories.ToListAsync(); foreach (var c in categories) Console.WriteLine($" [{c.Id}] {c.Name}"); -Console.WriteLine(); + Console.WriteLine("📦 Products:"); var products = await context.Products.ToListAsync(); foreach (var p in products) Console.WriteLine($" [{p.Id}] {p.Title} - ${p.Price}"); - -Console.WriteLine(); -Console.WriteLine("✨ Done!"); diff --git a/src/Sheetly.Core/Infrastructure/DatabaseFacade.cs b/src/Sheetly.Core/Infrastructure/DatabaseFacade.cs index 0a4a53d..9b1e2a5 100644 --- a/src/Sheetly.Core/Infrastructure/DatabaseFacade.cs +++ b/src/Sheetly.Core/Infrastructure/DatabaseFacade.cs @@ -6,29 +6,15 @@ namespace Sheetly.Core.Infrastructure; -public class DatabaseFacade +public class DatabaseFacade(ISheetsProvider provider, IMigrationService? migrationService, Type contextType) { - private readonly ISheetsProvider _provider; - private readonly IMigrationService? _migrationService; - private readonly Type _contextType; - - public DatabaseFacade(ISheetsProvider provider, IMigrationService? migrationService, Type contextType) - { - _provider = provider; - _migrationService = migrationService; - _contextType = contextType; - } - - /// - /// Applies all pending migrations. - /// public async Task MigrateAsync() { - if (_migrationService is null) + if (migrationService is null) throw new InvalidOperationException("MigrationService is not configured. Ensure UseGoogleSheets is called in OnConfiguring."); - var assembly = _contextType.Assembly; - var applied = await _migrationService.GetAppliedMigrationsAsync(); + var assembly = contextType.Assembly; + var applied = await migrationService.GetAppliedMigrationsAsync(); var migrationTypes = assembly.GetTypes() .Where(t => t.IsSubclassOf(typeof(Migrations.Migration)) && !t.IsAbstract) @@ -54,16 +40,16 @@ public async Task MigrateAsync() var operations = builder.GetOperations(); EnrichOperations(operations, snapshot); - await _migrationService.ApplyMigrationAsync(operations, m.Attr!.Id); + await migrationService.ApplyMigrationAsync(operations, m.Attr!.Id); } } public async Task> GetPendingMigrationsAsync() { - if (_migrationService is null) return []; + if (migrationService is null) return []; - var assembly = _contextType.Assembly; - var applied = await _migrationService.GetAppliedMigrationsAsync(); + var assembly = contextType.Assembly; + var applied = await migrationService.GetAppliedMigrationsAsync(); return assembly.GetTypes() .Where(t => t.IsSubclassOf(typeof(Migrations.Migration)) && !t.IsAbstract) @@ -76,7 +62,7 @@ public async Task> GetPendingMigrationsAsync() public async Task DropDatabaseAsync() { - await _provider.DropDatabaseAsync(); + await provider.DropDatabaseAsync(); } private static void EnrichOperations(List operations, MigrationSnapshot? snapshot) diff --git a/src/Sheetly.Excel/ExcelSheetProvider.cs b/src/Sheetly.Excel/ExcelSheetProvider.cs index c8117d1..e34ed6d 100644 --- a/src/Sheetly.Excel/ExcelSheetProvider.cs +++ b/src/Sheetly.Excel/ExcelSheetProvider.cs @@ -7,16 +7,11 @@ namespace Sheetly.Excel; /// ISheetsProvider implementation backed by a local .xlsx file via ClosedXML. /// All operations are synchronous file I/O wrapped in Task for API compatibility. /// -public sealed class ExcelSheetProvider : ISheetsProvider, IAsyncDisposable +public sealed class ExcelSheetProvider(string filePath) : ISheetsProvider, IAsyncDisposable { - private readonly string _filePath; + private readonly string _filePath = Path.GetFullPath(filePath); private XLWorkbook? _workbook; - public ExcelSheetProvider(string filePath) - { - _filePath = Path.GetFullPath(filePath); - } - public Task InitializeAsync() { _workbook = File.Exists(_filePath) From 51f56c387bb6b7839b3ee5ebf3bbf69105191bb1 Mon Sep 17 00:00:00 2001 From: muqimjon Date: Sat, 28 Feb 2026 18:49:09 +0500 Subject: [PATCH 36/36] Update Excel provider usage in release notes --- docs/RELEASE_NOTES.md | 38 -------------------------------------- 1 file changed, 38 deletions(-) diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index ed5f448..33a8d40 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -31,18 +31,6 @@ options.UseExcel("path/to/file.xlsx"); - If the counter is `0` (first run or legacy data), the provider scans the existing data sheet for the current max ID and continues from there - **Non-numeric primary keys** (string, Guid) are user-assigned — no auto-increment, required validation is enforced automatically -### Primary Constructor Refactoring - -- `DatabaseFacade` refactored to C# 12 primary constructor syntax -- Consistent with `GoogleMigrationService` and `ExcelMigrationService` already using primary constructors - -### Other Improvements - -- `ProductVersion` in `__SheetlyMigrationsHistory__` now reflects the actual NuGet assembly version -- Boolean schema columns parsed case-insensitively (`bool.TryParse`) — fixes Google Sheets USERENTERED mode storing `True` as `TRUE` -- Build-first behavior applies to both providers — the user project is built before CLI commands execute -- Inline comments removed from all source files; `is null` / `is not null` null checks enforced throughout - --- ## 📦 Packages @@ -75,30 +63,4 @@ options.UseExcel("path/to/file.xlsx"); ## 🔮 Roadmap -### v1.2.0 - - **Navigation property auto-resolution** — `product.Category = new Category { Name = "Books" }` automatically resolves and assigns `CategoryId` -- Advanced LINQ support (`OrderBy`, `Select`, `Skip`, `Take`) -- Query result caching - ---- - -## 📜 Version History - -### v1.0.1 — February 23, 2026 - -- **CLI banner** — EF Core-style terminal output with teal rocket art -- **`OnConfiguring` detection** — `dotnet sheetly database update` reads connection settings directly from `OnConfiguring()`, no `appsettings.json` required -- **Build-first behavior** — All CLI commands build the project before executing (like `dotnet ef`) -- **Version output** — Removed git commit hash from `--version` output -- **Brand logo** — Added official icon to all NuGet packages -- **CI/CD** — GitHub Actions workflows for automatic NuGet publishing - -### v1.0.0 — February 2026 - -- Initial release: `SheetsContext`, `SheetsSet`, CRUD, migrations, CLI tool, Google Sheets provider -- Constraint validation (PK, FK, Required, MaxLength, Range, Unique, Check, DataType) - ---- - -**Created by** [Muqimjon Mamadaliyev](https://github.com/muqimjon) · MIT License