diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml new file mode 100644 index 0000000..c5d57d1 --- /dev/null +++ b/.github/workflows/release-cli.yml @@ -0,0 +1,49 @@ +name: Release Sheetly.CLI (dotnet-sheetly) + +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..5d962d4 --- /dev/null +++ b/.github/workflows/release-core.yml @@ -0,0 +1,49 @@ +name: Release Sheetly.Core + +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..6e2455e --- /dev/null +++ b/.github/workflows/release-di.yml @@ -0,0 +1,49 @@ +name: Release Sheetly.DependencyInjection + +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-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 diff --git a/.github/workflows/release-google.yml b/.github/workflows/release-google.yml new file mode 100644 index 0000000..6162abe --- /dev/null +++ b/.github/workflows/release-google.yml @@ -0,0 +1,49 @@ +name: Release Sheetly.Google + +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 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/README.md b/README.md index d33dfea..ff7ddac 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ [![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) @@ -9,7 +11,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 @@ -32,7 +34,8 @@ 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"); } protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -54,7 +57,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 +68,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 +96,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 @@ -115,12 +125,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 @@ -133,7 +147,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 @@ -142,6 +158,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("your-spreadsheet-id", "credentials.json"); +} +``` + +### 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 @@ -179,7 +219,8 @@ 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"); } protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -228,14 +269,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,8 +314,19 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) ### **ASP.NET Core Integration** ```csharp +// Parameterless constructor (classic) +builder.Services.AddSheetsContext(options => + options.UseGoogleSheets("spreadsheet-id", "credentials.json")); + +// 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")); + options.UseGoogleSheets("spreadsheet-id", "credentials.json")); ``` ### **AsNoTracking** @@ -294,6 +345,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("spreadsheet-id", "credentials.json"); +``` + +### **CancellationToken Support** + +```csharp +var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); +await context.SaveChangesAsync(cts.Token); +``` + --- ## ๐Ÿ—๏ธ Architecture @@ -301,7 +390,8 @@ var any = await context.Products.AnyAsync(p => p.Price > 0); ``` 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 ``` @@ -310,7 +400,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 | |---|---| 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/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index bf189c1..33a8d40 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -1,104 +1,66 @@ -# ๐ŸŽ‰ 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 - -### Core Features - -- **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 +- **`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: -### Code-First Migrations +```csharp +// Google Sheets +options.UseGoogleSheets("spreadsheetId", "credentials.json"); -- 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 - -### Constraint Validation - -Validates locally before any Google Sheets API calls: +// Local Excel file +options.UseExcel("path/to/file.xlsx"); +``` -- 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 +- All CLI commands (`migrations add`, `database update`, `database drop`, `scaffold`) work identically for both providers -### CLI Tool +### Schema-Based Auto-Increment ID -```bash -dotnet tool install -g dotnet-sheetly +- **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 -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`) -- Advanced LINQ support (`OrderBy`, `Select`, `Skip`, `Take`) -- Query result caching - -### v1.2.0 -- Scaffold improvements -- Batch operation optimization -- Read-only view support - ---- - -**Created by** [Muqimjon Mamadaliyev](https://github.com/muqimjon) ยท MIT License +- **Navigation property auto-resolution** โ€” `product.Category = new Category { Name = "Books" }` automatically resolves and assigns `CategoryId` diff --git a/samples/Sheetly.Sample/AppDbContext.cs b/samples/Sheetly.Sample/AppDbContext.cs index 258a736..15a15a5 100644 --- a/samples/Sheetly.Sample/AppDbContext.cs +++ b/samples/Sheetly.Sample/AppDbContext.cs @@ -1,6 +1,6 @@ -๏ปฟusing Microsoft.Extensions.Configuration; -using Sheetly.Core; +๏ปฟusing Sheetly.Core; using Sheetly.Core.Configuration; +using Sheetly.Excel; using Sheetly.Google; using Sheetly.Sample.Models; @@ -13,17 +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"); - - if (string.IsNullOrEmpty(connectionString)) - throw new Exception("Connection string 'DefaultConnection' not found."); - - options.UseGoogleSheets(connectionString); + //options.UseExcel("C:\\Users\\muqim\\OneDrive\\Ishchi stol\\sheetly-test.xlsx"); + options.UseGoogleSheets("1bNZnlJJ81VLbM5VeWoy9uCq4Ynz2bkAXaJlFJAYy_Sc", "credentials.json"); } protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -46,9 +37,9 @@ 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); }); } } \ 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.Sample/Migrations/20260228114226_InitialMigrate.cs b/samples/Sheetly.Sample/Migrations/20260228114226_InitialMigrate.cs new file mode 100644 index 0000000..6f78133 --- /dev/null +++ b/samples/Sheetly.Sample/Migrations/20260228114226_InitialMigrate.cs @@ -0,0 +1,32 @@ +using Sheetly.Core.Migrations; +using Sheetly.Core.Migrations.Operations; + +namespace Sheetly.Sample.Migrations; + +[Migration("20260228114226_InitialMigrate")] +public partial class InitialMigrate : Migration +{ + public override void Up(MigrationBuilder builder) + { + builder.CreateTable("Categories", table => table + .Column("Id", c => c.IsPrimaryKey().IsUnique()) + .Column("Name", c => c.IsRequired().HasMaxLength(100)) + ); + + builder.CreateTable("Products", table => table + .Column("Id", c => c.IsPrimaryKey().IsUnique()) + .Column("Title", c => c.IsRequired().HasMaxLength(200)) + .Column("Price", c => c.IsRequired()) + .Column("Description", c => c.HasMaxLength(500)) + .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.Sample/Migrations/AppDbModelSnapshot.cs b/samples/Sheetly.Sample/Migrations/AppDbModelSnapshot.cs index 8b27cb2..c0c73e2 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-28T11:42:26.8750456Z") + }; - // 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 = true + }, + 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 = true + }, + 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..dc686e2 100644 --- a/samples/Sheetly.Sample/Program.cs +++ b/samples/Sheetly.Sample/Program.cs @@ -1,26 +1,34 @@ 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(); +await using var context = new AppDbContext(); +await context.InitializeAsync(); -try +context.Products.Add(new Product { - // Run comprehensive test suite - await ComprehensiveTests.RunAllTests(); - - 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}"); -} + 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(); +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("๐Ÿ“ฆ Products:"); +var products = await context.Products.ToListAsync(); +foreach (var p in products) + Console.WriteLine($" [{p.Id}] {p.Title} - ${p.Price}"); 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..9e26dfe 100644 --- a/samples/Sheetly.Sample/appsettings.json +++ b/samples/Sheetly.Sample/appsettings.json @@ -1,5 +1 @@ -{ - "ConnectionStrings": { - "DefaultConnection": "Provider=GoogleSheets;CredentialsPath=credentials.json;SpreadsheetId=1bNZnlJJ81VLbM5VeWoy9uCq4Ynz2bkAXaJlFJAYy_Sc" - } -} \ No newline at end of file +{} \ No newline at end of file diff --git a/src/Sheetly.CLI/Commands/AddCommand.cs b/src/Sheetly.CLI/Commands/AddCommand.cs index 73877da..f34fd48 100644 --- a/src/Sheetly.CLI/Commands/AddCommand.cs +++ b/src/Sheetly.CLI/Commands/AddCommand.cs @@ -1,10 +1,5 @@ using Sheetly.CLI.Helpers; -using Sheetly.Core; -using Sheetly.Core.Migration; -using Sheetly.Core.Migrations; -using Sheetly.Core.Migrations.Design; using System.CommandLine; -using System.Reflection; namespace Sheetly.CLI.Commands; @@ -46,84 +41,23 @@ private async Task ExecuteAsync(string? name, bool noBuild, string? projectPath, try { - var assembly = Assembly.LoadFrom(Path.GetFullPath(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"; - var modelBuilder = new ModelBuilder(); - 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()); - - 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) - { - // Instantiate snapshot (constructor populates Entities) - previousSnapshot = Activator.CreateInstance(snapshotType) as MigrationSnapshot; - } - - 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", "")); - - string snapshotFileName = $"{contextType.Name.Replace("Context", "")}ModelSnapshot.cs"; - string snapshotFilePath = Path.Combine(finalPath, snapshotFileName); - await File.WriteAllTextAsync(snapshotFilePath, snapshotCode, ct); - - Console.WriteLine($"โœ… Migration created: '{csharpFileName}'"); - Console.WriteLine($"โœ… Model snapshot updated: '{snapshotFileName}'"); - Console.WriteLine($" Operations: {operations.Count}"); - - foreach (var op in operations) - { - Console.WriteLine($" - {op.OperationType}"); - } + var (assembly, loadContext) = CliHelper.LoadAssemblyIsolated(dllPath); + var coreAsm = CliHelper.GetCoreAssembly(assembly, loadContext); + var contextType = CliHelper.FindContextType(assembly); + + var json = CliHelper.InvokeDesignTime(coreAsm, "AddMigration", contextType, name, outputDir); + var doc = CliHelper.ParseResult(json); + if (doc is null) return; + + var root = doc.RootElement; + Console.WriteLine($"โœ… Migration created: '{root.GetProperty("migrationFile").GetString()}'"); + Console.WriteLine($"โœ… Model snapshot updated: '{root.GetProperty("snapshotFile").GetString()}'"); + + 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 fce769e..6aef3a7 100644 --- a/src/Sheetly.CLI/Commands/DropCommand.cs +++ b/src/Sheetly.CLI/Commands/DropCommand.cs @@ -1,8 +1,5 @@ -๏ปฟusing Sheetly.CLI.Helpers; -using Sheetly.Core; -using Sheetly.Google; +using Sheetly.CLI.Helpers; using System.CommandLine; -using System.Reflection; namespace Sheetly.CLI.Commands; @@ -10,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) { @@ -28,27 +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 = Assembly.LoadFrom(Path.GetFullPath(dllPath)); - var contextType = assembly.GetExportedTypes().FirstOrDefault(t => CliHelper.IsSubclassOfSheetsContext(t)) - ?? throw new Exception("SheetsContext not found."); + var (assembly, loadContext) = CliHelper.LoadAssemblyIsolated(dllPath); + 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)); - var method = typeof(GoogleSheetsFactory).GetMethods() - .FirstOrDefault(m => m.Name == "CreateContextAsync" && m.GetParameters().Length == 1) - ?.MakeGenericMethod(contextType); + var json = CliHelper.InvokeDesignTime(coreAsm, "DropDatabaseAsync", contextType, connStr); + var doc = CliHelper.ParseResult(json); + if (doc is null) return; - var task = (Task)method!.Invoke(null, [connStr])!; - await task; - - var context = (SheetsContext)((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/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 7f74355..e72cd31 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; @@ -28,228 +20,18 @@ private async Task ExecuteAsync(string? projectPath, CancellationToken ct) try { - var assembly = Assembly.LoadFrom(Path.GetFullPath(dllPath)); - var contextType = assembly.GetExportedTypes().FirstOrDefault(t => CliHelper.IsSubclassOfSheetsContext(t)) - ?? throw new Exception("SheetsContext not found."); + var (assembly, loadContext) = CliHelper.LoadAssemblyIsolated(dllPath); + var coreAsm = CliHelper.GetCoreAssembly(assembly, loadContext); + var contextType = CliHelper.FindContextType(assembly); - string contextProjectDir = CliHelper.FindProjectRootFromDll(contextType.Assembly.Location); - string migrationsDir = Path.Combine(contextProjectDir, "Migrations"); + var json = CliHelper.InvokeDesignTime(coreAsm, "RemoveMigration", contextType); + var doc = CliHelper.ParseResult(json); + if (doc is null) return; - if (!Directory.Exists(migrationsDir)) - { - Console.WriteLine("โš ๏ธ Migrations directory not found."); - return; - } - - var migrationTypes = assembly.GetExportedTypes() - .Where(t => t.GetCustomAttribute() != null) - .OrderByDescending(t => t.GetCustomAttribute()!.Id) - .ToList(); - - if (migrationTypes.Count == 0) - { - Console.WriteLine("โš ๏ธ No migrations to remove."); - return; - } - - var lastMigrationType = migrationTypes[0]; - string migrationId = lastMigrationType.GetCustomAttribute()!.Id; - - 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"; - - 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 - ?? throw new Exception("Failed to instantiate ModelSnapshot."); - - var lastMigration = Activator.CreateInstance(lastMigrationType) as Migration - ?? throw new Exception("Failed to instantiate migration."); - - var downBuilder = new Sheetly.Core.Migrations.MigrationBuilder(); - lastMigration.Down(downBuilder); - - var revertedSnapshot = RevertSnapshot(currentSnapshot, downBuilder.GetOperations()); - - var generator = new ModelSnapshotGenerator(); - string snapshotCode = generator.GenerateModelSnapshot(revertedSnapshot, targetNamespace, contextName); - string snapshotFilePath = Path.Combine(migrationsDir, $"{snapshotClassName}.cs"); - - 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 ed36aa7..0d683fb 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; @@ -32,14 +31,13 @@ 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."); 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) @@ -63,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 e38760c..c68d98d 100644 --- a/src/Sheetly.CLI/Commands/ScaffoldCommand.cs +++ b/src/Sheetly.CLI/Commands/ScaffoldCommand.cs @@ -1,10 +1,5 @@ -๏ปฟusing Sheetly.CLI.Helpers; -using Sheetly.Core; -using Sheetly.Core.Migration; -using Sheetly.Google; +using Sheetly.CLI.Helpers; using System.CommandLine; -using System.Reflection; -using System.Text.Json; namespace Sheetly.CLI.Commands; @@ -12,52 +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 = Assembly.LoadFrom(Path.GetFullPath(dllPath)); - var contextType = assembly.GetExportedTypes().FirstOrDefault(t => CliHelper.IsSubclassOfSheetsContext(t)) - ?? throw new Exception("SheetsContext not found."); + var (assembly, loadContext) = CliHelper.LoadAssemblyIsolated(dllPath); + 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)); - var method = typeof(GoogleSheetsFactory).GetMethods().First(m => m.Name == "CreateContextAsync").MakeGenericMethod(contextType); - var task = (Task)method.Invoke(null, [connStr])!; - await task; - var context = (SheetsContext)((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 is null) return; - var rows = await context.Provider.GetAllRowsAsync("__SheetlyHistory__"); - 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 36df47c..1daa7ef 100644 --- a/src/Sheetly.CLI/Commands/ScriptCommand.cs +++ b/src/Sheetly.CLI/Commands/ScriptCommand.cs @@ -1,53 +1,38 @@ -๏ปฟusing Sheetly.CLI.Helpers; -using Sheetly.Core.Migration; +using Sheetly.CLI.Helpers; 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 = Assembly.LoadFrom(Path.GetFullPath(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") && t.IsSubclassOf(typeof(MigrationSnapshot))); + var json = CliHelper.InvokeDesignTime(coreAsm, "GetSchemaScript", contextType); + var doc = CliHelper.ParseResult(json); + if (doc is null) return; - if (snapshotType == null) - { - Console.WriteLine("โš ๏ธ Snapshot not found. Run 'migrations add' first."); - return; - } - - var snapshot = (MigrationSnapshot)Activator.CreateInstance(snapshotType)!; - - 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 9e8935a..dc006cd 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; @@ -33,103 +27,35 @@ private async Task ExecuteAsync(bool noBuild, string? projectPath, CancellationT try { - var assembly = Assembly.LoadFrom(Path.GetFullPath(dllPath)); - var contextType = assembly.GetExportedTypes().FirstOrDefault(t => CliHelper.IsSubclassOfSheetsContext(t)) - ?? throw new Exception("SheetsContext not found."); + var (assembly, loadContext) = CliHelper.LoadAssemblyIsolated(dllPath); + var coreAsm = CliHelper.GetCoreAssembly(assembly, loadContext); + var contextType = CliHelper.FindContextType(assembly); - 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."); + string? connStr = CliHelper.GetConnectionString(CliHelper.FindProjectRootFromDll(dllPath)); - // 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(); + Console.WriteLine("โณ Applying pending migrations..."); + var json = CliHelper.InvokeDesignTime(coreAsm, "UpdateDatabaseAsync", contextType, connStr); + var doc = CliHelper.ParseResult(json); + if (doc is null) return; - var migrationService = new GoogleMigrationService(provider); + var root = doc.RootElement; + int total = root.GetProperty("total").GetInt32(); - var appliedMigrations = await migrationService.GetAppliedMigrationsAsync(); - - 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(); - - if (migrationTypes.Count == 0) - { - Console.WriteLine("โš ๏ธ No migrations found in the project."); - return; - } - - var pendingMigrations = migrationTypes - .Where(x => !appliedMigrations.Contains(x.Attribute!.Id)) - .ToList(); - - if (pendingMigrations.Count == 0) + if (total == 0) { Console.WriteLine("โœ… Database is up to date."); return; } - Console.WriteLine($"๐Ÿš€ Found {pendingMigrations.Count} pending migration(s)."); - - var snapshotType = assembly.GetTypes() - .FirstOrDefault(t => t.Name.EndsWith("ModelSnapshot") && t.IsSubclassOf(typeof(MigrationSnapshot))); - MigrationSnapshot? currentSnapshot = snapshotType != null - ? (MigrationSnapshot?)Activator.CreateInstance(snapshotType) - : null; - - foreach (var pm in pendingMigrations) - { - var migrationId = pm.Attribute!.Id; - Console.Write($"Applying {migrationId}... "); - - var migration = (Migration)Activator.CreateInstance(pm.Type)!; - var builder = new Core.Migrations.MigrationBuilder(); - migration.Up(builder); - var operations = builder.GetOperations(); - - 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) { 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 abe8f1a..6825b48 100644 --- a/src/Sheetly.CLI/Helpers/CliHelper.cs +++ b/src/Sheetly.CLI/Helpers/CliHelper.cs @@ -1,15 +1,80 @@ -๏ปฟusing Microsoft.Extensions.Configuration; -using Sheetly.Core.Migration; +using Microsoft.Extensions.Configuration; using System.Reflection; -using System.Text; +using System.Text.Json; namespace Sheetly.CLI.Helpers; public static class CliHelper { + 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) + { + 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); + } + + /// + /// 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. + /// + internal static string InvokeDesignTime(Assembly coreAsm, string methodName, params object?[] args) + { + 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)); + + var result = method.Invoke(null, args); + + if (result is Task task) + { + task.GetAwaiter().GetResult(); + return (string)((dynamic)task).Result; + } + + return (string)result!; + } + + /// + /// Finds SheetsContext subclass from the loaded assembly using string-based type check. + /// + internal static Type FindContextType(Assembly assembly) + { + return assembly.GetExportedTypes().FirstOrDefault(t => IsSubclassOfSheetsContext(t)) + ?? throw new Exception("SheetsContext not found in the project."); + } + + /// + /// Parses a JSON result string from DesignTimeOperations and prints error if unsuccessful. + /// Returns the parsed JsonDocument, or null on failure. + /// + internal static JsonDocument? ParseResult(string json) + { + var doc = JsonDocument.Parse(json); + if (doc.RootElement.GetProperty("success").GetBoolean()) + return doc; + + 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)) + while (type is not null && type != typeof(object)) { if (type.FullName == "Sheetly.Core.SheetsContext") return true; type = type.BaseType; @@ -17,11 +82,21 @@ 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; var csproj = Directory.GetFiles(Directory.GetCurrentDirectory(), "*.csproj").FirstOrDefault(); - if (csproj == null) return string.Empty; + if (csproj is null) return string.Empty; if (!noBuild) { @@ -49,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)!; } @@ -64,42 +139,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/ProjectAssemblyLoadContext.cs b/src/Sheetly.CLI/Helpers/ProjectAssemblyLoadContext.cs new file mode 100644 index 0000000..8ba4fbf --- /dev/null +++ b/src/Sheetly.CLI/Helpers/ProjectAssemblyLoadContext.cs @@ -0,0 +1,24 @@ +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) + { + var path = _resolver.ResolveAssemblyToPath(assemblyName); + 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()); diff --git a/src/Sheetly.CLI/Sheetly.CLI.csproj b/src/Sheetly.CLI/Sheetly.CLI.csproj index 4df3a06..2d40622 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 @@ -30,11 +30,6 @@ - - - - - diff --git a/src/Sheetly.Core/Abstractions/ISheetsProvider.cs b/src/Sheetly.Core/Abstractions/ISheetsProvider.cs index 53fb1ec..4c00c79 100644 --- a/src/Sheetly.Core/Abstractions/ISheetsProvider.cs +++ b/src/Sheetly.Core/Abstractions/ISheetsProvider.cs @@ -7,7 +7,14 @@ 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 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/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/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..9b1e2a5 100644 --- a/src/Sheetly.Core/Infrastructure/DatabaseFacade.cs +++ b/src/Sheetly.Core/Infrastructure/DatabaseFacade.cs @@ -6,44 +6,29 @@ 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 == 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) .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; @@ -55,21 +40,21 @@ 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 == 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) .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(); @@ -77,12 +62,12 @@ public async Task> GetPendingMigrationsAsync() public async Task DropDatabaseAsync() { - await _provider.DropDatabaseAsync(); + await provider.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 +77,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 +87,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/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 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/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 new file mode 100644 index 0000000..fda60c7 --- /dev/null +++ b/src/Sheetly.Core/Migrations/Design/DesignTimeOperations.cs @@ -0,0 +1,514 @@ +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 is not 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 is not 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 is 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 is 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 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"); + 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); + } + + await Task.CompletedTask; + 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 is 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 is not 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 is not null) + { + 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 is not 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 +} 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.New.cs b/src/Sheetly.Core/Migrations/MigrationBuilder.cs similarity index 96% rename from src/Sheetly.Core/Migrations/MigrationBuilder.New.cs rename to src/Sheetly.Core/Migrations/MigrationBuilder.cs index 04d99a0..c38de08 100644 --- a/src/Sheetly.Core/Migrations/MigrationBuilder.New.cs +++ b/src/Sheetly.Core/Migrations/MigrationBuilder.cs @@ -31,7 +31,7 @@ public MigrationBuilder AddColumn(string table, string name, Action(columns) }; - if (configure != null) + if (configure is not null) { var builder = new IndexBuilder(operation); configure(builder); @@ -97,7 +97,7 @@ public MigrationBuilder DropCheckConstraint(string name, string table) private static bool IsNullableType(Type type) { - return !type.IsValueType || Nullable.GetUnderlyingType(type) != null; + return !type.IsValueType || Nullable.GetUnderlyingType(type) is not null; } } @@ -122,7 +122,7 @@ public TableBuilder Column(string name, Action? configure = nu IsNullable = IsNullableType(typeof(T)) }; - if (configure != null) + if (configure is not null) { var columnBuilder = new ColumnBuilder(operation); configure(columnBuilder); @@ -134,7 +134,7 @@ public TableBuilder Column(string name, Action? configure = nu private static bool IsNullableType(Type type) { - return !type.IsValueType || Nullable.GetUnderlyingType(type) != null; + return !type.IsValueType || Nullable.GetUnderlyingType(type) is not null; } } 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/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/Migrations/SnapshotBuilder.cs b/src/Sheetly.Core/Migrations/SnapshotBuilder.cs index 895cd87..4ef5949 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, MinLength = propConfig?.MinLength, MinValue = propConfig?.MinValue, @@ -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")) @@ -118,6 +112,15 @@ private static bool IsPrimaryKey(PropertyInfo prop) prop.Name.Equals(prop.DeclaringType?.Name + "Id", StringComparison.OrdinalIgnoreCase); } + private static bool IsNumericType(Type type) + { + var underlying = Nullable.GetUnderlyingType(type) ?? type; + return underlying == typeof(int) || underlying == typeof(long) || + underlying == typeof(short) || underlying == typeof(byte) || + underlying == typeof(uint) || underlying == typeof(ulong) || + underlying == typeof(ushort) || underlying == typeof(sbyte); + } + private static string GetSimpleTypeName(Type type) { var underlyingType = Nullable.GetUnderlyingType(type) ?? type; @@ -148,7 +151,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/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.Core/SheetsContext.cs b/src/Sheetly.Core/SheetsContext.cs index d9ee563..42e0501 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!; @@ -19,18 +19,29 @@ public abstract class SheetsContext : IDisposable private MigrationSnapshot? _currentSnapshot; private ConstraintValidator? _validator; + private readonly SheetsOptions? _constructorOptions; + + protected SheetsContext() { } + + protected SheetsContext(SheetsOptions options) + { + _constructorOptions = options; + } + protected virtual void OnModelCreating(ModelBuilder modelBuilder) { } 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 = new SheetsOptions(); - OnConfiguring(options); + var options = _constructorOptions ?? new SheetsOptions(); + if (_constructorOptions is 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; } @@ -84,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; // No snapshot class yet โ€” new project + 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) { @@ -111,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); } @@ -125,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); @@ -148,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) { @@ -159,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), @@ -167,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; @@ -176,8 +183,19 @@ private void InitializeSets(ISheetsProvider provider, MigrationSnapshot snapshot } } - public async Task SaveChangesAsync() + public async Task SaveChangesAsync(CancellationToken cancellationToken = default) { + if (Provider is null) + throw new InvalidOperationException( + "Context not initialized. Call InitializeAsync() first."); + + 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(); @@ -195,8 +213,7 @@ public async Task SaveChangesAsync() allDeletedEntities.AddRange(deleted); } - // Validate locally BEFORE any API calls (constraint checks, FK format, etc.) - if (_validator != null && allPendingEntities.Count > 0) + if (_validator is not null && allPendingEntities.Count > 0) { var result = new ValidationResult(); @@ -209,7 +226,7 @@ public async Task SaveChangesAsync() 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 { @@ -227,20 +244,21 @@ public async Task SaveChangesAsync() throw new ValidationException(result); } - // Remote FK validation โ€” one API call per referenced table if (allPendingEntities.Count > 0) await ValidateForeignKeyReferencesAsync(allPendingEntities); if (allDeletedEntities.Count > 0) await ValidateForeignKeyConstraintsOnDelete(allDeletedEntities); + cancellationToken.ThrowIfCancellationRequested(); + int total = 0; foreach (var set in sets.Values) { 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; @@ -254,7 +272,7 @@ public async Task SaveChangesAsync() /// 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>(); @@ -263,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)) @@ -294,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); @@ -337,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) { @@ -411,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; @@ -445,6 +463,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); diff --git a/src/Sheetly.Core/SheetsSet.cs b/src/Sheetly.Core/SheetsSet.cs index 822758b..0f1cdb0 100644 --- a/src/Sheetly.Core/SheetsSet.cs +++ b/src/Sheetly.Core/SheetsSet.cs @@ -2,7 +2,9 @@ using Sheetly.Core.Mapping; using Sheetly.Core.Migration; using System.Collections; +using System.Linq.Expressions; using System.Reflection; +using System.Text.Json; namespace Sheetly.Core; @@ -10,11 +12,10 @@ namespace Sheetly.Core; { private readonly Dictionary _trackedEntities = []; private readonly Dictionary _entityRowIndexes = []; + private readonly Dictionary _snapshots = []; private readonly List _includes = []; private bool _asNoTracking = false; - private const string SchemaTable = "__SheetlySchema__"; - public SheetsSet AsNoTracking() { _asNoTracking = true; @@ -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() => @@ -39,6 +52,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); @@ -55,7 +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 + _entityRowIndexes[entity] = i + 1; + _snapshots[entity] = JsonSerializer.Serialize(entity); } } @@ -76,36 +108,48 @@ 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 all = await ToListAsync(); - var pkProp = typeof(T).GetProperty(pkColumn.PropertyName); - if (pkProp == null) return default; + var keyStr = keyValue.ToString()!; - return all.FirstOrDefault(e => + var rowIndex = await provider.FindRowIndexByKeyAsync(schema.TableName, keyStr); + if (rowIndex < 0) return default; + + var rowData = await provider.GetRowByIndexAsync(schema.TableName, rowIndex); + if (rowData is null) return default; + + var headerRow = await provider.GetRowByIndexAsync(schema.TableName, 1); + if (headerRow is null) return default; + var headers = headerRow.Select(h => h?.ToString() ?? string.Empty).ToList(); + + 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) { 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) @@ -115,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; @@ -124,10 +168,8 @@ 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; + if (relatedSchema is null) continue; } var actualTableName = relatedSchema.TableName; @@ -161,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(); @@ -187,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); } @@ -231,80 +273,33 @@ 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) + if (pkColumn is not null && pkColumn.IsAutoIncrement) { - if (pkColumn != null) + 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) { - var prop = typeof(T).GetProperty(pkColumn.PropertyName); - prop?.SetValue(item.Key, Convert.ChangeType(nextId++, prop.PropertyType)); + pkProp?.SetValue(item.Key, Convert.ChangeType(nextId, pkProp.PropertyType)); + batchRows.Add(EntityMapper.MapToRow(item.Key, schema)); + nextId++; } - await provider.AppendRowAsync(schema.TableName, EntityMapper.MapToRow(item.Key, schema)); - changes++; + await provider.AppendRowsAsync(schema.TableName, batchRows); + } + else + { + var batchRows = toAdd.Select(item => EntityMapper.MapToRow(item.Key, schema)).ToList(); + await provider.AppendRowsAsync(schema.TableName, (IList>)batchRows); } + changes += toAdd.Count; } _trackedEntities.Clear(); _entityRowIndexes.Clear(); + _snapshots.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.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..d6a50fd 100644 --- a/src/Sheetly.Core/Validation/Rules/PrimaryKeyValidator.cs +++ b/src/Sheetly.Core/Validation/Rules/PrimaryKeyValidator.cs @@ -9,26 +9,38 @@ 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 (pkColumn.IsAutoIncrement) { - // This is a new entity - PK will be auto-generated - 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; + } } - // Check for duplicates in tracked entities if (context.ExistingPrimaryKeys.Contains(value)) { result.AddError(new ValidationError(pkColumn.PropertyName, @@ -38,7 +50,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 fc056b4..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; @@ -6,24 +6,38 @@ 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(); + var ctorWithOptions = typeof(TContext) + .GetConstructor([typeof(SheetsContextOptions)]); - if (options.Provider != null) + TContext context; + if (ctorWithOptions is not null) { - context.InitializeAsync(options.Provider).GetAwaiter().GetResult(); + context = (TContext)ctorWithOptions.Invoke([options]); + context.InitializeAsync().GetAwaiter().GetResult(); } else { - context.InitializeAsync().GetAwaiter().GetResult(); + context = (TContext)Activator.CreateInstance(typeof(TContext), nonPublic: true)!; + if (options.Provider is not null) + context.InitializeAsync(options.Provider).GetAwaiter().GetResult(); + else + context.InitializeAsync().GetAwaiter().GetResult(); } return context; @@ -31,4 +45,4 @@ public static IServiceCollection AddSheetsContext( return services; } -} \ No newline at end of file +} 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.Excel/ExcelMigrationService.cs b/src/Sheetly.Excel/ExcelMigrationService.cs new file mode 100644 index 0000000..7ab63af --- /dev/null +++ b/src/Sheetly.Excel/ExcelMigrationService.cs @@ -0,0 +1,309 @@ +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 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 is not 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) + { + var version = typeof(ISheetsProvider).Assembly.GetName().Version?.ToString(3) ?? "1.0.0"; + await provider.AppendRowAsync(HistoryTable, + [migrationId, DateTime.UtcNow.ToString("O"), version]); + } + + 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..e34ed6d --- /dev/null +++ b/src/Sheetly.Excel/ExcelSheetProvider.cs @@ -0,0 +1,349 @@ +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(string filePath) : ISheetsProvider, IAsyncDisposable +{ + private readonly string _filePath = Path.GetFullPath(filePath); + private XLWorkbook? _workbook; + + 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 is 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 is 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 is 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 async Task GetAndIncrementIdAsync(string tableName, int count = 1) + { + var schemaRows = await GetAllRowsAsync("__SheetlySchema__"); + for (int i = 1; i < schemaRows.Count; i++) + { + var row = schemaRows[i]; + 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) + { + 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; + } + + 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 is 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) + { + return Task.CompletedTask; + } + + public Task SetCheckboxAsync(string sheetName, int startRow, int endRow, int columnId) + { + return Task.CompletedTask; + } + + public void Dispose() + { + _workbook?.Dispose(); + _workbook = null; + } + + public ValueTask DisposeAsync() + { + Dispose(); + return ValueTask.CompletedTask; + } + + private void EnsureWorkbook() + { + if (_workbook is 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 is null ? 2 : lastUsed.RowNumber() + 1; + } + + private static long GetMaxIdFromSheet(IXLWorksheet ws) + { + long max = 0; + var rangeUsed = ws.RangeUsed(); + if (rangeUsed is null) return max; + + int lastRow = rangeUsed.LastRow().RowNumber(); + for (int r = 2; r <= lastRow; r++) + { + var val = ws.Cell(r, 1).GetValue(); + if (long.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 + + + + + + + + + + + + + + + + diff --git a/src/Sheetly.Google/GoogleMigrationService.cs b/src/Sheetly.Google/GoogleMigrationService.cs index 15857da..6f1299d 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 ?? "" ]); } @@ -194,7 +191,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)) { @@ -205,15 +205,13 @@ 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) { - // 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); } @@ -227,19 +225,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; @@ -249,7 +246,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 155048a..aa59ba3 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,13 +11,21 @@ namespace Sheetly.Google; public class GoogleSheetProvider : ISheetsProvider { - private readonly SheetsService _service; + private readonly SheetsService[] _services; private readonly string _spreadsheetId; + private int _serviceIndex = -1; - // โ”€โ”€ Retry configuration โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + private Dictionary _sheetCache = []; 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[(Interlocked.Increment(ref _serviceIndex) & 0x7FFFFFFF) % _services.Length]; + /// /// Executes a Google API request with automatic exponential-backoff retry /// on 429 (TooManyRequests) and 503 (ServiceUnavailable) responses. @@ -37,21 +45,17 @@ 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(); } - public GoogleSheetProvider(string credentialsPath, string spreadsheetId) + public GoogleSheetProvider(string spreadsheetId, string credentialsPath) { _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); + _services = LoadServicesFromJson(new StreamReader(stream).ReadToEnd()); } public GoogleSheetProvider(IConfigurationSection section, string spreadsheetId) @@ -59,77 +63,146 @@ 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('[')) + { + 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]; + } + + 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() { - await ExecuteWithRetryAsync(_service.Spreadsheets.Get(_spreadsheetId)); + var ss = await ExecuteWithRetryAsync(NextService.Spreadsheets.Get(_spreadsheetId)); + _sheetCache = ss.Sheets + .Where(s => s.Properties?.Title is not 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(); + 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(); } - // 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); } } } - private SheetsService CreateService(GoogleCredential credential) - { - return new SheetsService(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) + { + 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 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; + if (cell == keyValue) + return i + 1; + } + return -1; + } + 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); + } + + public async Task GetAndIncrementIdAsync(string tableName, int count = 1) + { + var schemaRows = await GetAllRowsAsync("__SheetlySchema__"); + for (int i = 1; i < schemaRows.Count; i++) + { + var row = schemaRows[i]; + if (row.Count <= 7) continue; + if (row[1]?.ToString() != tableName) continue; + if (!bool.TryParse(row[7]?.ToString(), out var isPk) || !isPk) continue; + + int spreadsheetRow = i + 1; + var rawId = await GetValueAsync("__SheetlySchema__", $"AC{spreadsheetRow}"); + long.TryParse(rawId?.ToString(), out long 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; + await UpdateValueAsync("__SheetlySchema__", $"AC{spreadsheetRow}", currentId + count); + return nextId; + } + return 1; + } + public async Task AppendRowsAsync(string sheetName, IList> rows) + { + var vr = new ValueRange { Values = rows }; + var request = NextService.Spreadsheets.Values.Append(vr, _spreadsheetId, $"'{sheetName}'!A1"); request.ValueInputOption = SpreadsheetsResource.ValuesResource.AppendRequest.ValueInputOptionEnum.USERENTERED; await ExecuteWithRetryAsync(request); } @@ -139,7 +212,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); } @@ -155,15 +228,12 @@ public async Task DeleteRowAsync(string sheetName, int rowIndex) } }; await ExecuteWithRetryAsync( - _service.Spreadsheets.BatchUpdate( + NextService.Spreadsheets.BatchUpdate( 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) { @@ -177,7 +247,7 @@ public async Task CreateSheetAsync(string sheetName, IList headers) GridProperties = new GridProperties { FrozenRowCount = 1, - ColumnCount = headers.Count // Explicitly set column count + ColumnCount = headers.Count } } } @@ -189,9 +259,11 @@ 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; + _sheetCache[sheetName] = (int)(sheetId ?? 0); + var headerRows = new List { new() { @@ -231,29 +303,30 @@ public async Task CreateSheetAsync(string sheetName, IList headers) } }; - await ExecuteWithRetryAsync(_service.Spreadsheets.BatchUpdate( + await ExecuteWithRetryAsync(NextService.Spreadsheets.BatchUpdate( new BatchUpdateSpreadsheetRequest { Requests = [updateCellsRequest] }, _spreadsheetId)); } 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(_service.Spreadsheets.BatchUpdate( + await ExecuteWithRetryAsync(NextService.Spreadsheets.BatchUpdate( new BatchUpdateSpreadsheetRequest { Requests = [request] }, _spreadsheetId)); + _sheetCache.Remove(sheetName); } 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); } @@ -261,14 +334,14 @@ 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(); } 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 @@ -277,7 +350,7 @@ public async Task HideSheetAsync(string sheetName) Fields = "hidden" } }; - await ExecuteWithRetryAsync(_service.Spreadsheets.BatchUpdate( + await ExecuteWithRetryAsync(NextService.Spreadsheets.BatchUpdate( new BatchUpdateSpreadsheetRequest { Requests = [request] }, _spreadsheetId)); } @@ -292,7 +365,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)); } @@ -307,17 +380,22 @@ 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)); } - 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(); + 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.) @@ -333,4 +411,4 @@ private static string GetColumnLetter(int columnNumber) } return result; } -} \ No newline at end of file +} 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/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 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/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/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/Helpers/InMemorySheetsProvider.cs b/tests/Sheetly.Core.Tests/Integration/Helpers/InMemorySheetsProvider.cs index 649b7f9..d0645f7 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,13 +66,22 @@ 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()); } 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++) + if (rows[i].Count > 0 && rows[i][0]?.ToString() == keyValue) + return Task.FromResult(i + 1); + return Task.FromResult(-1); + } + public Task AppendRowAsync(string sheetName, IList row) { if (_sheets.TryGetValue(sheetName, out var rows)) @@ -87,6 +89,43 @@ 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 async Task GetAndIncrementIdAsync(string tableName, int count = 1) + { + var schemaRows = await GetAllRowsAsync("__SheetlySchema__"); + for (int i = 1; i < schemaRows.Count; i++) + { + var row = schemaRows[i]; + 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) + { + 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; + } + public Task UpdateRowAsync(string sheetName, int rowIndex, IList row) { if (_sheets.TryGetValue(sheetName, out var rows)) @@ -109,8 +148,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; @@ -140,34 +177,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; @@ -176,7 +199,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/Models/TestModels.cs b/tests/Sheetly.Core.Tests/Integration/Models/TestModels.cs index af36cba..5d8ec1f 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; } @@ -26,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/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); 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()); + } +} 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; } = ""; + } }