diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml index c00dcdf..a5e088d 100644 --- a/.github/workflows/publish-nuget.yml +++ b/.github/workflows/publish-nuget.yml @@ -17,8 +17,21 @@ env: DOTNET_NOLOGO: true jobs: + core-validation: + uses: ./.github/workflows/validate-dotnet.yml + permissions: + contents: read + + browser-validation: + uses: ./.github/workflows/validate-browsers.yml + permissions: + contents: read + publish: name: Build and publish package + needs: + - core-validation + - browser-validation runs-on: ubuntu-latest timeout-minutes: 20 permissions: @@ -56,11 +69,8 @@ jobs: echo "package_version=${package_version}" >> "${GITHUB_OUTPUT}" echo "Publishing Magic.IndexedDb ${package_version} from ${GITHUB_SHA}." - - name: Restore - run: dotnet restore Magic.IndexedDb.UnitTests/Magic.IndexedDb.UnitTests.csproj - - - name: Run unit tests - run: dotnet test Magic.IndexedDb.UnitTests/Magic.IndexedDb.UnitTests.csproj --configuration Release --no-restore + - name: Restore package project + run: dotnet restore Magic.IndexedDb/Magic.IndexedDb.csproj - name: Pack run: >- diff --git a/.github/workflows/validate-browsers.yml b/.github/workflows/validate-browsers.yml new file mode 100644 index 0000000..5caec5d --- /dev/null +++ b/.github/workflows/validate-browsers.yml @@ -0,0 +1,147 @@ +name: Browser validation + +on: + workflow_call: + pull_request: + push: + branches: + - master + workflow_dispatch: + +concurrency: + group: browser-validation-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_NOLOGO: true + +jobs: + browser-validation: + name: ${{ matrix.name }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + include: + - name: Chrome integration + os: ubuntu-latest + browser: chromium + - name: Firefox integration + os: ubuntu-latest + browser: firefox + - name: Linux WebKit integration + os: ubuntu-latest + browser: webkit + - name: macOS WebKit integration + os: macos-15 + browser: webkit + runs-on: ${{ matrix.os }} + + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Set up .NET 10 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 + with: + dotnet-version: 10.0.x + + - name: Restore browser tests + run: dotnet restore E2eTest/E2eTest.csproj + + - name: Build browser tests + run: dotnet build E2eTest/E2eTest.csproj --configuration Release --no-restore + + - name: Install browser and Linux dependencies + if: ${{ runner.os == 'Linux' }} + shell: pwsh + run: E2eTest/bin/Release/net10.0/playwright.ps1 install --with-deps ${{ matrix.browser }} + + - name: Install browser on macOS + if: ${{ runner.os == 'macOS' }} + shell: pwsh + run: E2eTest/bin/Release/net10.0/playwright.ps1 install ${{ matrix.browser }} + + - name: Run browser integration tests + if: ${{ runner.os == 'Linux' }} + run: >- + dotnet test E2eTest/E2eTest.csproj + --configuration Release + --no-build + --no-restore + --logger "trx;LogFileName=${{ matrix.browser }}-${{ runner.os }}.trx" + --results-directory artifacts/test-results + -- + Playwright.BrowserName=${{ matrix.browser }} + Playwright.LaunchOptions.Headless=true + + - name: Run macOS WebKit open and registration tests + if: ${{ runner.os == 'macOS' }} + run: >- + dotnet test E2eTest/E2eTest.csproj + --configuration Release + --no-build + --no-restore + --filter "FullyQualifiedName~E2eTest.OpenTest" + --logger "trx;LogFileName=webkit-macOS-open.trx" + --results-directory artifacts/test-results + -- + Playwright.BrowserName=webkit + Playwright.LaunchOptions.Headless=true + + - name: Run macOS WebKit CRUD and streaming tests + if: ${{ runner.os == 'macOS' }} + run: >- + dotnet test E2eTest/E2eTest.csproj + --configuration Release + --no-build + --no-restore + --filter "FullyQualifiedName~E2eTest.SingleRecordBasicTest" + --logger "trx;LogFileName=webkit-macOS-crud-streaming.trx" + --results-directory artifacts/test-results + -- + Playwright.BrowserName=webkit + Playwright.LaunchOptions.Headless=true + + - name: Run macOS WebKit query tests + if: ${{ runner.os == 'macOS' }} + run: >- + dotnet test E2eTest/E2eTest.csproj + --configuration Release + --no-build + --no-restore + --filter "FullyQualifiedName~E2eTest.WhereTest" + --logger "trx;LogFileName=webkit-macOS-query.trx" + --results-directory artifacts/test-results + -- + Playwright.BrowserName=webkit + Playwright.LaunchOptions.Headless=true + + - name: Run macOS WebKit cursor tests + if: ${{ runner.os == 'macOS' }} + run: >- + dotnet test E2eTest/E2eTest.csproj + --configuration Release + --no-build + --no-restore + --filter "FullyQualifiedName~E2eTest.CursorTest" + --logger "trx;LogFileName=webkit-macOS-cursor.trx" + --results-directory artifacts/test-results + -- + Playwright.BrowserName=webkit + Playwright.LaunchOptions.Headless=true + + - name: Upload browser test results + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: browser-${{ matrix.browser }}-${{ runner.os }}-${{ github.run_attempt }} + path: artifacts/test-results/*.trx + if-no-files-found: warn + overwrite: true diff --git a/.github/workflows/validate-dotnet.yml b/.github/workflows/validate-dotnet.yml new file mode 100644 index 0000000..027f6a8 --- /dev/null +++ b/.github/workflows/validate-dotnet.yml @@ -0,0 +1,78 @@ +name: Core validation + +on: + workflow_call: + pull_request: + push: + branches: + - master + workflow_dispatch: + +concurrency: + group: core-validation-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_NOLOGO: true + +jobs: + core-validation: + name: Core validation + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Set up .NET 10 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 + with: + dotnet-version: 10.0.x + + - name: Restore unit tests + run: dotnet restore Magic.IndexedDb.UnitTests/Magic.IndexedDb.UnitTests.csproj + + - name: Run unit and contract tests + run: >- + dotnet test Magic.IndexedDb.UnitTests/Magic.IndexedDb.UnitTests.csproj + --configuration Release + --no-restore + --logger "trx;LogFileName=core-validation.trx" + --results-directory artifacts/test-results + + - name: Pack the NuGet artifact + run: >- + dotnet pack Magic.IndexedDb/Magic.IndexedDb.csproj + --configuration Release + --no-restore + --output artifacts/package + -p:ContinuousIntegrationBuild=true + -p:GeneratePackageOnBuild=false + + - name: Verify package contents + shell: bash + run: | + package="$(find artifacts/package -maxdepth 1 -name '*.nupkg' -print -quit)" + test -n "${package}" + unzip -Z1 "${package}" | grep -Fx README.md + unzip -Z1 "${package}" | grep -Fx LICENSE.txt + unzip -Z1 "${package}" | grep -Fx wizardHatIcon.png + unzip -Z1 "${package}" | grep -F 'staticwebassets/' + + - name: Upload test results and package + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: core-validation-${{ github.run_attempt }} + path: | + artifacts/test-results/*.trx + artifacts/package/*.nupkg + if-no-files-found: warn + overwrite: true diff --git a/E2eTest/Extensions/PageExtensions.cs b/E2eTest/Extensions/PageExtensions.cs index a9e3d88..a00bb8e 100644 --- a/E2eTest/Extensions/PageExtensions.cs +++ b/E2eTest/Extensions/PageExtensions.cs @@ -1,5 +1,4 @@ -using E2eTest.Entities; -using Microsoft.Playwright; +using Microsoft.Playwright; namespace E2eTest.Extensions; internal static class PageExtensions @@ -14,7 +13,5 @@ public static async ValueTask DeleteDatabaseAsync(this IPage page, string databa request.onblocked = () => reject(new Error(`Deletion of ${database} was blocked.`)); }) """, database); - var databases = await page.EvaluateAsync("indexedDB.databases()"); - Assert.IsFalse(databases!.Any(x => x.Name == database)); } } diff --git a/E2eTest/OpenTest.cs b/E2eTest/OpenTest.cs index b3b0984..bbe73b0 100644 --- a/E2eTest/OpenTest.cs +++ b/E2eTest/OpenTest.cs @@ -1,5 +1,4 @@ -using E2eTest.Entities; -using E2eTest.Extensions; +using E2eTest.Extensions; using TestBase.Models; using E2eTestWebApp.TestPages; using Magic.IndexedDb; @@ -19,10 +18,21 @@ public async Task DirectOpenTest() var result = await this.RunTestPageMethodAsync(p => p.DirectOpen); Assert.AreEqual("OK", result); - var databases = await page.EvaluateAsync("indexedDB.databases()"); + var version = await page.EvaluateAsync(""" + database => new Promise((resolve, reject) => { + const request = indexedDB.open(database); + request.onsuccess = () => { + const version = request.result.version; + request.result.close(); + resolve(version); + }; + request.onerror = () => reject(request.error); + request.onblocked = () => reject(new Error(`Opening ${database} was blocked.`)); + }) + """, "Employee"); // The actual version will be 10: // https://dexie.org/docs/Dexie/Dexie.version() - Assert.IsTrue(databases!.Any(x => x.Name == "Employee" && x.Version == 10)); + Assert.AreEqual(10, version); } diff --git a/E2eTest/Program.cs b/E2eTest/Program.cs index c9dd55b..940c9b2 100644 --- a/E2eTest/Program.cs +++ b/E2eTest/Program.cs @@ -1,4 +1,5 @@ -using System.Diagnostics; +using System.Collections.Concurrent; +using System.Diagnostics; namespace E2eTest; @@ -19,24 +20,36 @@ public static async Task InitializeAsync(TestContext context) return; using var currentProcess = Process.GetCurrentProcess(); - var dotnetRunArguments = "--no-build --project ../../../../E2eTestWebApp"; + var appDll = Path.Combine(AppContext.BaseDirectory, "E2eTestWebApp.dll"); + var appContentRoot = Path.GetFullPath( + Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "E2eTestWebApp")); var webAppArguments = $"--E2eTest {currentProcess.Id}"; + var output = new ConcurrentQueue(); + var errors = new ConcurrentQueue(); var server = new Process { StartInfo = new ProcessStartInfo() { FileName = "dotnet", - Arguments = $"run {dotnetRunArguments} -- {webAppArguments}", + Arguments = $"\"{appDll}\" {webAppArguments} --urls http://127.0.0.1:0 --contentRoot \"{appContentRoot}\"", + WorkingDirectory = appContentRoot, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true } }; + server.ErrorDataReceived += (_, eventArgs) => + { + if (eventArgs.Data is not null) + errors.Enqueue(eventArgs.Data); + }; + server.StartInfo.Environment["ASPNETCORE_ENVIRONMENT"] = "Development"; try { if (!server.Start()) throw new Exception("Failed to start E2eTestWebApp. Process.Start returns false."); + server.BeginErrorReadLine(); } catch { @@ -46,7 +59,6 @@ public static async Task InitializeAsync(TestContext context) try { - var lines = new List(); for (; ; ) { var line = await server.StandardOutput.ReadLineAsync(); @@ -55,15 +67,18 @@ public static async Task InitializeAsync(TestContext context) throw new Exception( $"Failed to start E2eTestWebApp. The output stream ended accidentally.{Environment.NewLine}" + $"The previous message is:{Environment.NewLine}" + - string.Join(Environment.NewLine, lines)); + string.Join(Environment.NewLine, output) + Environment.NewLine + + $"Standard error:{Environment.NewLine}" + + string.Join(Environment.NewLine, errors)); } - lines.Add(line); + EnqueueRecent(output, line); line = line.TrimStart(); if (line.StartsWith("Now listening on: http://")) { BaseUrl = line.Substring("Now listening on: ".Length).TrimEnd(); Program.server = server; + _ = DrainOutputAsync(server.StandardOutput, output); return; } } @@ -77,6 +92,26 @@ public static async Task InitializeAsync(TestContext context) } } + private static async Task DrainOutputAsync(StreamReader reader, ConcurrentQueue output) + { + try + { + while (await reader.ReadLineAsync() is { } line) + EnqueueRecent(output, line); + } + catch (Exception exception) when (exception is ObjectDisposedException or IOException) + { + // Assembly cleanup owns the process and its redirected streams. + } + } + + private static void EnqueueRecent(ConcurrentQueue output, string line) + { + output.Enqueue(line); + while (output.Count > 200) + output.TryDequeue(out _); + } + [AssemblyCleanup] public static void Cleanup() { diff --git a/E2eTest/SingleRecordBasicTest.cs b/E2eTest/SingleRecordBasicTest.cs index fed4970..3b2931e 100644 --- a/E2eTest/SingleRecordBasicTest.cs +++ b/E2eTest/SingleRecordBasicTest.cs @@ -68,4 +68,52 @@ public async Task NamedEnumWhereTest() var result = await this.RunTestPageMethodAsync(p => p.NamedEnumWhere); Assert.AreEqual("OK", result); } + + [TestMethod] + public async Task RangeCrudTest() => + Assert.AreEqual("OK", await this.RunTestPageMethodAsync(page => page.RangeCrud)); + + [TestMethod] + public async Task ClearAndPopulatedCountTest() => + Assert.AreEqual("OK", await this.RunTestPageMethodAsync(page => page.ClearAndPopulatedCount)); + + [TestMethod] + public async Task UniqueConstraintFailureIsRecoverableTest() => + Assert.AreEqual("OK", await this.RunTestPageMethodAsync(page => page.UniqueConstraintFailureIsRecoverable)); + + [TestMethod] + public async Task DatabaseLifecycleTest() => + Assert.AreEqual("OK", await this.RunTestPageMethodAsync(page => page.DatabaseLifecycle)); + + [TestMethod] + public async Task MultipleDatabaseIsolationTest() => + Assert.AreEqual("OK", await this.RunTestPageMethodAsync(page => page.MultipleDatabaseIsolation)); + + [TestMethod] + public async Task ExactMaterializedOrderingTest() => + Assert.AreEqual("OK", await this.RunTestPageMethodAsync(page => page.ExactMaterializedOrdering)); + + [TestMethod] + public async Task InMemoryWhereAfterPaginationTest() => + Assert.AreEqual("OK", await this.RunTestPageMethodAsync(page => page.InMemoryWhereAfterPagination)); + + [TestMethod] + public async Task CompoundKeyCrudAndQueryTest() => + Assert.AreEqual("OK", await this.RunTestPageMethodAsync(page => page.CompoundKeyCrudAndQuery)); + + [TestMethod] + public async Task LargeUnicodeStreamTest() => + Assert.AreEqual("OK", await this.RunTestPageMethodAsync(page => page.LargeUnicodeStream)); + + [TestMethod] + public async Task StreamCancellationAndRecoveryTest() => + Assert.AreEqual("OK", await this.RunTestPageMethodAsync(page => page.StreamCancellationAndRecovery)); + + [TestMethod] + public async Task ConcurrentStreamsRemainIsolatedTest() => + Assert.AreEqual("OK", await this.RunTestPageMethodAsync(page => page.ConcurrentStreamsRemainIsolated)); + + [TestMethod] + public async Task StorageEstimateTest() => + Assert.AreEqual("OK", await this.RunTestPageMethodAsync(page => page.StorageEstimate)); } diff --git a/E2eTestWebApp/Program.cs b/E2eTestWebApp/Program.cs index 1197ba5..df878eb 100644 --- a/E2eTestWebApp/Program.cs +++ b/E2eTestWebApp/Program.cs @@ -16,7 +16,8 @@ public static async Task Main(string[] args) var builder = WebApplication.CreateBuilder(args); _ = builder.Services.AddRazorComponents().AddInteractiveServerComponents(); - _ = builder.Services.AddMagicBlazorDB(BlazorInteropMode.WASM, true); + // Deliberately tiny in tests so every streamed entity exercises multi-chunk transport. + _ = builder.Services.AddMagicBlazorDB(64, true); _ = builder.Services.AddSingleton(new DbStore() { Name = "OpenTest.RegisteredOpen2", diff --git a/E2eTestWebApp/TestPages/CursorTestPage.cs b/E2eTestWebApp/TestPages/CursorTestPage.cs index f7fd95b..253c9e2 100644 --- a/E2eTestWebApp/TestPages/CursorTestPage.cs +++ b/E2eTestWebApp/TestPages/CursorTestPage.cs @@ -50,14 +50,14 @@ public async Task TestWhere48() { public async Task TestWhere54() { var result = RunTest("Take & With Index Test", await (await SetupData()).Cursor(x => x.Name.StartsWith("J")) .OrderBy(x => x.Name).Take(2).ToListAsync(), - PersonData.persons.Where(x => x.Name.StartsWith("J")).OrderBy(x => x.Name).ThenBy(x => x._Id).Take(2)); + PersonData.persons.Where(x => x.Name.StartsWith("J")).OrderBy(x => x.Name).ThenBy(x => x._Id).Take(2), ordered: true); return result.Success ? "OK" : result.Message; } public async Task TestWhere55() { var result = RunTest("TakeLast & With Index Test", await (await SetupData()).Cursor(x => x.Name.StartsWith("J")) .OrderBy(x => x.Name).TakeLast(2).ToListAsync(), - PersonData.persons.Where(x => x.Name.StartsWith("J")).OrderBy(x => x.Name).ThenBy(x => x._Id).TakeLast(2)); + PersonData.persons.Where(x => x.Name.StartsWith("J")).OrderBy(x => x.Name).ThenBy(x => x._Id).TakeLast(2), ordered: true); return result.Success ? "OK" : result.Message; } @@ -81,7 +81,7 @@ public async Task TestWhere69() { .Take(3) .Skip(2) .ToListAsync(), - PersonData.persons.Where(x => x._Age > 30).OrderBy(x => x._Age).ThenBy(x => x._Id).Skip(2).Take(3)); + PersonData.persons.Where(x => x._Age > 30).OrderBy(x => x._Age).ThenBy(x => x._Id).Skip(2).Take(3), ordered: true); return result.Success ? "OK" : result.Message; } @@ -114,7 +114,7 @@ public async Task TestWhere72() { .Take(3) .Skip(2) .ToListAsync(), - PersonData.persons.Where(x => x.Name.StartsWith("J")).OrderBy(x => x._Id).ThenBy(x => x._Id).Skip(2).Take(3)); + PersonData.persons.Where(x => x.Name.StartsWith("J")).OrderBy(x => x._Id).ThenBy(x => x._Id).Skip(2).Take(3), ordered: true); return result.Success ? "OK" : result.Message; } @@ -125,7 +125,7 @@ public async Task TestWhere73() { .Take(3) .Skip(2) .ToListAsync(), - PersonData.persons.Where(x => x.Name.StartsWith("J")).OrderByDescending(x => x._Id).ThenByDescending(x => x._Id).Skip(2).Take(3)); + PersonData.persons.Where(x => x.Name.StartsWith("J")).OrderByDescending(x => x._Id).ThenByDescending(x => x._Id).Skip(2).Take(3), ordered: true); return result.Success ? "OK" : result.Message; } @@ -135,7 +135,7 @@ public async Task TestWhere74() { .OrderByDescending(x => x._Age) .TakeLast(2) .ToListAsync(), - PersonData.persons.Where(x => x._Age < 60).OrderByDescending(x => x._Age).ThenByDescending(x => x._Id).TakeLast(2)); + PersonData.persons.Where(x => x._Age < 60).OrderByDescending(x => x._Age).ThenByDescending(x => x._Id).TakeLast(2), ordered: true); return result.Success ? "OK" : result.Message; //await Task.Delay(10000); @@ -154,7 +154,7 @@ public async Task TestWhere91() { .OrderBy(x => x._Id) .TakeLast(2) .ToListAsync(), - PersonData.persons.Where(x => x.TestInt > 2).OrderBy(x => x._Id).ThenBy(x => x._Id).TakeLast(2)); + PersonData.persons.Where(x => x.TestInt > 2).OrderBy(x => x._Id).ThenBy(x => x._Id).TakeLast(2), ordered: true); return result.Success ? "OK" : result.Message; } @@ -165,7 +165,7 @@ public async Task TestWhere92() { .Take(3) .Skip(1) .ToListAsync(), - PersonData.persons.Where(x => x.TestInt > 2 && x.TestInt == 9).OrderBy(x => x._Id).ThenBy(x => x._Id).Skip(1).Take(3)); + PersonData.persons.Where(x => x.TestInt > 2 && x.TestInt == 9).OrderBy(x => x._Id).ThenBy(x => x._Id).Skip(1).Take(3), ordered: true); return result.Success ? "OK" : result.Message; @@ -178,7 +178,7 @@ public async Task TestWhere93() { .Take(3) .Skip(1) .ToListAsync(), - PersonData.persons.Where(x => x._Age > 30 && x.TestInt == 9).OrderBy(x => x._Age).ThenBy(x => x._Id).Skip(1).Take(3)); + PersonData.persons.Where(x => x._Age > 30 && x.TestInt == 9).OrderBy(x => x._Age).ThenBy(x => x._Id).Skip(1).Take(3), ordered: true); return result.Success ? "OK" : result.Message; } -} \ No newline at end of file +} diff --git a/E2eTestWebApp/TestPages/SingleRecordBasicTestPage.cs b/E2eTestWebApp/TestPages/SingleRecordBasicTestPage.cs index d538ce9..eac2250 100644 --- a/E2eTestWebApp/TestPages/SingleRecordBasicTestPage.cs +++ b/E2eTestWebApp/TestPages/SingleRecordBasicTestPage.cs @@ -141,4 +141,239 @@ await db.AddRangeAsync([ ? "OK" : "Incorrect"; } + + public async Task RangeCrud() + { + var db = await magic.Query(); + Person[] records = + [ + new() { _Id = 1, Name = "one", _Age = 10 }, + new() { _Id = 2, Name = "two", _Age = 20 }, + new() { _Id = 3, Name = "three", _Age = 30 } + ]; + await db.AddRangeAsync(records); + + records[0]._Age = 11; + records[2]._Age = 33; + var updated = await db.UpdateRangeAsync([records[0], records[2]]); + var afterUpdate = await db.OrderBy(person => person._Id).ToListAsync(); + var deleted = await db.DeleteRangeAsync([records[0], records[1]]); + var remaining = await db.ToListAsync(); + + return updated == 2 && deleted == 2 && + afterUpdate.Select(person => person._Age).SequenceEqual([11, 20, 33]) && + remaining.Count == 1 && remaining[0]._Id == 3 + ? "OK" + : "Incorrect"; + } + + public async Task ClearAndPopulatedCount() + { + var db = await magic.Query(); + await db.AddRangeAsync([ + new Person { _Id = 1, Name = "one" }, + new Person { _Id = 2, Name = "two" } + ]); + var before = await db.CountAsync(); + await db.ClearTable(); + var after = await db.CountAsync(); + + return before == 2 && after == 0 && (await db.ToListAsync()).Count == 0 + ? "OK" + : "Incorrect"; + } + + public async Task UniqueConstraintFailureIsRecoverable() + { + var db = await magic.Query(); + var unique = Guid.NewGuid(); + await db.AddAsync(new Person { _Id = 1, Name = "first", GUIY = unique }); + + var rejected = false; + try + { + await db.AddAsync(new Person { _Id = 2, Name = "duplicate", GUIY = unique }); + } + catch + { + rejected = true; + } + + await db.AddAsync(new Person { _Id = 3, Name = "after-error" }); + var rows = await db.OrderBy(person => person._Id).ToListAsync(); + return rejected && rows.Select(person => person._Id).SequenceEqual([1, 3]) + ? "OK" + : "Incorrect"; + } + + public async Task DatabaseLifecycle() + { + var database = await magic.Database(TestBase.Repository.IndexDbContext.Animal); + var existsInitially = await database.DoesExistAsync(); + var openInitially = await database.IsOpenAsync(); + await database.CloseAsync(); + var closed = !await database.IsOpenAsync(); + var persistedAfterClose = await database.DoesExistAsync(); + await database.OpenAsync(); + var reopened = await database.IsOpenAsync(); + await database.DeleteAsync(); + var deleted = !await database.DoesExistAsync(); + + return existsInitially && openInitially && closed && persistedAfterClose && reopened && deleted + ? "OK" + : "Incorrect"; + } + + public async Task MultipleDatabaseIsolation() + { + var client = await magic.Query(); + var employee = await magic.Query(person => person.Databases.Employee); + await client.AddAsync(new Person { _Id = 1, Name = "client" }); + await employee.AddAsync(new Person { _Id = 2, Name = "employee" }); + + var clientRows = await client.ToListAsync(); + var employeeRows = await employee.ToListAsync(); + return clientRows.Count == 1 && clientRows[0].Name == "client" && + employeeRows.Count == 1 && employeeRows[0].Name == "employee" + ? "OK" + : "Incorrect"; + } + + public async Task ExactMaterializedOrdering() + { + var db = await magic.Query(); + await db.AddRangeAsync([ + new Person { _Id = 1, Name = "oldest", _Age = 50 }, + new Person { _Id = 2, Name = "youngest", _Age = 20 }, + new Person { _Id = 3, Name = "middle", _Age = 30 } + ]); + + var ascending = await db.OrderBy(person => person._Age).ToListAsync(); + var descending = await db.OrderByDescending(person => person._Age).ToListAsync(); + var ascendingIds = ascending.Select(person => person._Id).ToArray(); + var descendingIds = descending.Select(person => person._Id).ToArray(); + return ascendingIds.SequenceEqual([2, 3, 1]) && descendingIds.SequenceEqual([1, 3, 2]) + ? "OK" + : $"Ascending: {string.Join(',', ascendingIds)}; descending: {string.Join(',', descendingIds)}"; + } + + public async Task InMemoryWhereAfterPagination() + { + var db = await magic.Query(); + await db.AddRangeAsync(Enumerable.Range(1, 10) + .Select(value => new Person { _Id = value, Name = $"person-{value}", _Age = value })); + + var rows = await db.OrderBy(person => person._Id) + .Take(6) + .WhereAsync(person => person._Id % 2 == 0); + + return rows.Select(person => person._Id).SequenceEqual([2, 4, 6]) + ? "OK" + : "Incorrect"; + } + + public async Task CompoundKeyCrudAndQuery() + { + var db = await magic.Query(); + CompositeRecord[] rows = + [ + new() { Tenant = "alpha", Sequence = 1, Category = "work", Value = "one" }, + new() { Tenant = "alpha", Sequence = 2, Category = "work", Value = "two" }, + new() { Tenant = "beta", Sequence = 1, Category = "home", Value = "three" } + ]; + await db.AddRangeAsync(rows); + + var matches = await db.Where(row => row.Tenant == "alpha" && row.Category == "work").ToListAsync(); + rows[1].Value = "updated"; + var updated = await db.UpdateAsync(rows[1]); + await db.DeleteAsync(rows[0]); + var remaining = await db.OrderBy(row => row.Sequence).ToListAsync(); + + return matches.Count == 2 && updated == 1 && remaining.Count == 2 && + remaining.Any(row => row.Tenant == "alpha" && row.Sequence == 2 && row.Value == "updated") && + remaining.All(row => row.Tenant != "alpha" || row.Sequence != 1) + ? "OK" + : "Incorrect"; + } + + public async Task LargeUnicodeStream() + { + var db = await magic.Query(); + const string payload = "🧙‍♂️\n雪\t\\quoted\""; + await db.AddRangeAsync(Enumerable.Range(1, 64) + .Select(value => new Person + { + _Id = value, + Name = $"person-{value}", + Secret = string.Concat(Enumerable.Repeat(payload, 16)) + })); + + var streamed = new Dictionary(); + await foreach (var person in db.AsAsyncEnumerable()) + streamed.Add(person._Id, person.Secret); + + return streamed.Count == 64 && streamed.Values.All(value => value == string.Concat(Enumerable.Repeat(payload, 16))) + ? "OK" + : "Incorrect"; + } + + public async Task StreamCancellationAndRecovery() + { + var db = await magic.Query(); + await db.AddRangeAsync(Enumerable.Range(1, 50) + .Select(value => new Person { _Id = value, Name = $"person-{value}" })); + + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var canceled = false; + try + { + await foreach (var _ in db.AsAsyncEnumerable(cancellation.Token)) + { + } + } + catch (OperationCanceledException) + { + canceled = true; + } + + return canceled && await db.CountAsync() == 50 ? "OK" : "Incorrect"; + } + + public async Task ConcurrentStreamsRemainIsolated() + { + var db = await magic.Query(); + await db.AddRangeAsync(Enumerable.Range(1, 40) + .Select(value => new Person + { + _Id = value, + Name = $"person-{value}", + TestInt = value % 2 + })); + + static async Task> ReadIds(IAsyncEnumerable stream) + { + var values = new List(); + await foreach (var item in stream) + values.Add(item._Id); + return values; + } + + var evensTask = ReadIds(db.Where(person => person.TestInt == 0).AsAsyncEnumerable()); + var oddsTask = ReadIds(db.Where(person => person.TestInt == 1).AsAsyncEnumerable()); + await Task.WhenAll(evensTask, oddsTask); + + return evensTask.Result.Count == 20 && oddsTask.Result.Count == 20 && + evensTask.Result.All(id => id % 2 == 0) && oddsTask.Result.All(id => id % 2 == 1) + ? "OK" + : "Incorrect"; + } + + public async Task StorageEstimate() + { + var estimate = await magic.GetStorageEstimateAsync(); + return estimate.Quota >= 0 && estimate.Usage >= 0 && estimate.Quota >= estimate.Usage + ? "OK" + : "Incorrect"; + } } diff --git a/E2eTestWebApp/TestPages/TestPageBase.razor.cs b/E2eTestWebApp/TestPages/TestPageBase.razor.cs index b46113a..935a819 100644 --- a/E2eTestWebApp/TestPages/TestPageBase.razor.cs +++ b/E2eTestWebApp/TestPages/TestPageBase.razor.cs @@ -41,8 +41,10 @@ private void Clear() } public TestResponse RunTest(string testName, - IEnumerable indexDbResults, IEnumerable correctResults) where T : class + IEnumerable indexDbResults, + IEnumerable correctResults, + bool ordered = false) where T : class { - return TestValidator.ValidateLists(correctResults, indexDbResults); + return TestValidator.ValidateLists(correctResults, indexDbResults, ordered); } } diff --git a/E2eTestWebApp/TestPages/WhereTestPage.cs b/E2eTestWebApp/TestPages/WhereTestPage.cs index d1beb03..b15177f 100644 --- a/E2eTestWebApp/TestPages/WhereTestPage.cs +++ b/E2eTestWebApp/TestPages/WhereTestPage.cs @@ -340,13 +340,13 @@ public async Task TestWhere49() { public async Task TestWhere50() { var result = RunTest("Ordering Test", await (await SetupData()).OrderBy(x => x._Age).ToListAsync(), - PersonData.persons.OrderBy(x => x._Age).ThenBy(x => x._Id)); + PersonData.persons.OrderBy(x => x._Age).ThenBy(x => x._Id), ordered: true); return result.Success ? "OK" : result.Message; } public async Task TestWhere51() { var result = RunTest("Order Descending Test", await (await SetupData()).OrderByDescending(x => x._Age).ToListAsync(), - PersonData.persons.OrderByDescending(x => x._Age).ThenByDescending(x => x._Id)); + PersonData.persons.OrderByDescending(x => x._Age).ThenBy(x => x._Id), ordered: true); return result.Success ? "OK" : result.Message; var asdfsdfdsfsdf = await (await SetupData()).OrderBy(x => x._Age).Skip(3).ToListAsync(); @@ -354,13 +354,13 @@ public async Task TestWhere51() { public async Task TestWhere52() { var result = RunTest("Skip Test", await (await SetupData()).OrderBy(x => x._Age).Skip(3).ToListAsync(), - PersonData.persons.OrderBy(x => x._Age).ThenBy(x => x._Id).Skip(3)); + PersonData.persons.OrderBy(x => x._Age).ThenBy(x => x._Id).Skip(3), ordered: true); return result.Success ? "OK" : result.Message; } public async Task TestWhere53() { var result = RunTest("Take Test", await (await SetupData()).OrderBy(x => x._Age).Take(2).ToListAsync(), - PersonData.persons.OrderBy(x => x._Age).ThenBy(x => x._Id).Take(2)); + PersonData.persons.OrderBy(x => x._Age).ThenBy(x => x._Id).Take(2), ordered: true); return result.Success ? "OK" : result.Message; } @@ -371,7 +371,7 @@ public async Task TestWhere56() { * Take last is special operation that changes order, * but this altered version replicates the LINQ to SQL desired result */ - PersonData.persons.OrderBy(x => x.Name).ThenBy(x => x._Id).Take(2)); + PersonData.persons.OrderBy(x => x.Name).ThenBy(x => x._Id).Take(2), ordered: true); return result.Success ? "OK" : result.Message; } @@ -382,7 +382,7 @@ public async Task TestWhere57() { * Take last is special operation that changes order, * but this altered version replicates the LINQ to SQL desired result */ - PersonData.persons.OrderBy(x => x.Name).ThenBy(x => x._Id).TakeLast(2)); + PersonData.persons.OrderBy(x => x.Name).ThenBy(x => x._Id).TakeLast(2), ordered: true); return result.Success ? "OK" : result.Message; var totalPersons = await (await SetupData()).CountAsync(); @@ -430,7 +430,7 @@ public async Task TestWhere64() { public async Task TestWhere66() { var result = RunTest("TakeLast Cursor Test", await (await SetupData()).OrderBy(x => x._Age).TakeLast(2).ToListAsync(), - PersonData.persons.OrderBy(x => x._Age).ThenBy(x => x._Id).TakeLast(2)); + PersonData.persons.OrderBy(x => x._Age).ThenBy(x => x._Id).TakeLast(2), ordered: true); return result.Success ? "OK" : result.Message; } @@ -538,7 +538,7 @@ public async Task TestWhere80() { .Take(5) .Skip(3) .ToListAsync(), - PersonData.persons.OrderBy(x => x._Age).ThenBy(x => x._Id).Skip(3).Take(5)); + PersonData.persons.OrderBy(x => x._Age).ThenBy(x => x._Id).Skip(3).Take(5), ordered: true); return result.Success ? "OK" : result.Message; } @@ -547,7 +547,7 @@ public async Task TestWhere81() { await (await SetupData()).OrderByDescending(x => x._Age) .TakeLast(5) .ToListAsync(), - PersonData.persons.OrderByDescending(x => x._Age).ThenByDescending(x => x._Id).TakeLast(5)); + PersonData.persons.OrderByDescending(x => x._Age).ThenByDescending(x => x._Id).TakeLast(5), ordered: true); return result.Success ? "OK" : result.Message; } @@ -614,4 +614,4 @@ public async Task TestWhere90() { []); return result.Success ? "OK" : result.Message; } -} \ No newline at end of file +} diff --git a/Magic.IndexedDb.UnitTests/ChunkProcessorTests.cs b/Magic.IndexedDb.UnitTests/ChunkProcessorTests.cs index d08161c..8ea8048 100644 --- a/Magic.IndexedDb.UnitTests/ChunkProcessorTests.cs +++ b/Magic.IndexedDb.UnitTests/ChunkProcessorTests.cs @@ -28,4 +28,67 @@ public void Chunks_AreReassembledAndDrainedBeforeCompletion() MagicJsChunkProcessor.RemoveInstance(instanceId); } } + + [TestMethod] + public void CompletionMarker_WaitsForEveryCompletedItemToDrain() + { + var instanceId = Guid.NewGuid().ToString("N"); + MagicJsChunkProcessor.RegisterInstance(instanceId); + + try + { + MagicJsChunkProcessor.AddChunk(instanceId, "last", 2, "third", 0, 1); + MagicJsChunkProcessor.AddChunk(instanceId, "STREAM_COMPLETE", -1, "", 0, 1); + MagicJsChunkProcessor.AddChunk(instanceId, "first", 0, "first", 0, 1); + MagicJsChunkProcessor.AddChunk(instanceId, "middle", 1, "second", 0, 1); + + CollectionAssert.AreEqual( + new[] { "first", "second", "third", "STREAM_COMPLETE" }, + Enumerable.Range(0, 4) + .Select(_ => MagicJsChunkProcessor.GetCompletedItem(instanceId)) + .ToArray()); + } + finally + { + MagicJsChunkProcessor.RemoveInstance(instanceId); + } + } + + [TestMethod] + public void ConcurrentStreamInstances_RemainIsolated() + { + var first = Guid.NewGuid().ToString("N"); + var second = Guid.NewGuid().ToString("N"); + MagicJsChunkProcessor.RegisterInstance(first); + MagicJsChunkProcessor.RegisterInstance(second); + + try + { + MagicJsChunkProcessor.AddChunk(first, "item", 0, "one", 0, 1); + MagicJsChunkProcessor.AddChunk(second, "item", 0, "two", 0, 1); + + Assert.AreEqual("one", MagicJsChunkProcessor.GetCompletedItem(first)); + Assert.AreEqual("two", MagicJsChunkProcessor.GetCompletedItem(second)); + Assert.IsNull(MagicJsChunkProcessor.GetCompletedItem(first)); + Assert.IsNull(MagicJsChunkProcessor.GetCompletedItem(second)); + } + finally + { + MagicJsChunkProcessor.RemoveInstance(first); + MagicJsChunkProcessor.RemoveInstance(second); + } + } + + [TestMethod] + public void RemovedInstance_DoesNotLeakPreviouslyCompletedItems() + { + var instanceId = Guid.NewGuid().ToString("N"); + MagicJsChunkProcessor.RegisterInstance(instanceId); + MagicJsChunkProcessor.AddChunk(instanceId, "item", 0, "secret", 0, 1); + + MagicJsChunkProcessor.RemoveInstance(instanceId); + + Assert.IsNull(MagicJsChunkProcessor.GetCompletedItem(instanceId)); + MagicJsChunkProcessor.RemoveInstance(instanceId); + } } diff --git a/Magic.IndexedDb.UnitTests/ExpressionContractMatrixTests.cs b/Magic.IndexedDb.UnitTests/ExpressionContractMatrixTests.cs new file mode 100644 index 0000000..e21fb5a --- /dev/null +++ b/Magic.IndexedDb.UnitTests/ExpressionContractMatrixTests.cs @@ -0,0 +1,172 @@ +using System.Linq.Expressions; +using Magic.IndexedDb.LinqTranslation.Extensions; +using Magic.IndexedDb.LinqTranslation.Models; +using Magic.IndexedDb.SchemaAnnotations; + +namespace Magic.IndexedDb.UnitTests; + +[TestClass] +public sealed class ExpressionContractMatrixTests +{ + [TestMethod] + public void NumericComparisonMatrix_PreservesOperationAndOperandDirection() + { + (Expression> Predicate, string Operation, object Value)[] cases = + [ + (record => record.Age == 18, "Equal", 18), + (record => record.Age != 18, "NotEqual", 18), + (record => record.Age > 18, "GreaterThan", 18), + (record => record.Age >= 18, "GreaterThanOrEqual", 18), + (record => record.Age < 18, "LessThan", 18), + (record => record.Age <= 18, "LessThanOrEqual", 18), + (record => 18 < record.Age, "GreaterThan", 18), + (record => 18 <= record.Age, "GreaterThanOrEqual", 18), + (record => 18 > record.Age, "LessThan", 18), + (record => 18 >= record.Age, "LessThanOrEqual", 18) + ]; + + foreach (var (predicate, operation, value) in cases) + { + var condition = Condition(predicate); + Assert.AreEqual("persisted_age", condition.property, predicate.ToString()); + Assert.AreEqual(operation, condition.operation, predicate.ToString()); + Assert.AreEqual(value, condition.value, predicate.ToString()); + } + } + + [TestMethod] + public void CapturedValues_AreEvaluatedWithoutChangingThePredicateShape() + { + var minimum = 21; + + var condition = Condition(record => record.Age >= minimum); + + Assert.AreEqual("GreaterThanOrEqual", condition.operation); + Assert.AreEqual(21, condition.value); + } + + [TestMethod] + public void BooleanMember_IsTranslatedAsEqualityWithTrue() + { + var condition = Condition(record => record.Enabled); + + Assert.AreEqual(nameof(QueryRecord.Enabled), condition.property); + Assert.AreEqual("Equal", condition.operation); + Assert.AreEqual(true, condition.value); + } + + [TestMethod] + public void StringMethodMatrix_PreservesOperationAndCaseSensitivity() + { + (Expression> Predicate, string Operation, bool CaseSensitive)[] cases = + [ + (record => record.Name.Contains("ab"), "Contains", true), + (record => record.Name.Contains("ab", StringComparison.OrdinalIgnoreCase), "Contains", false), + (record => record.Name.StartsWith("ab", StringComparison.Ordinal), "StartsWith", true), + (record => record.Name.EndsWith("ab", StringComparison.OrdinalIgnoreCase), "EndsWith", false), + (record => !record.Name.Contains("ab"), "NotContains", true), + (record => !record.Name.StartsWith("ab"), "NotStartsWith", true), + (record => !record.Name.EndsWith("ab"), "NotEndsWith", true) + ]; + + foreach (var (predicate, operation, caseSensitive) in cases) + { + var condition = Condition(predicate); + Assert.AreEqual(operation, condition.operation, predicate.ToString()); + Assert.AreEqual(caseSensitive, condition.caseSensitive, predicate.ToString()); + Assert.AreEqual("ab", condition.value, predicate.ToString()); + } + } + + [TestMethod] + public void LengthComparisonMatrix_UsesLengthOperations() + { + (Expression> Predicate, string Operation)[] cases = + [ + (record => record.Name.Length == 3, "LengthEqual"), + (record => record.Name.Length != 3, "NotLengthEqual"), + (record => record.Name.Length > 3, "LengthGreaterThan"), + (record => record.Name.Length >= 3, "LengthGreaterThanOrEqual"), + (record => record.Name.Length < 3, "LengthLessThan"), + (record => record.Name.Length <= 3, "LengthLessThanOrEqual") + ]; + + foreach (var (predicate, operation) in cases) + Assert.AreEqual(operation, Condition(predicate).operation, predicate.ToString()); + } + + [TestMethod] + public void DateComponentMatrix_UsesComponentOperations() + { + (Expression> Predicate, string Operation, object Value)[] cases = + [ + (record => record.When.Year == 2030, "YearEqual", 2030), + (record => record.When.Month != 2, "NotMonthEqual", 2), + (record => record.When.Day > 10, "DayGreaterThan", 10), + (record => record.When.DayOfYear <= 100, "DayOfYearLessThanOrEqual", 100), + (record => record.When.DayOfWeek == DayOfWeek.Monday, "DayOfWeekEqual", 1) + ]; + + foreach (var (predicate, operation, value) in cases) + { + var condition = Condition(predicate); + Assert.AreEqual(operation, condition.operation, predicate.ToString()); + Assert.AreEqual(value, condition.value, predicate.ToString()); + } + } + + [TestMethod] + public void CollectionMembership_PreservesEveryAlternative() + { + int[] values = [1, 3, 5]; + + var node = new UniversalExpressionBuilder( + record => values.Contains(record.Age)).Build(); + + Assert.AreEqual(FilterNodeType.Logical, node.NodeType); + Assert.AreEqual(FilterLogicalOperator.Or, node.Operator); + CollectionAssert.AreEqual( + values, + node.Children!.Select(child => (int)child.Condition!.Value.value!).ToArray()); + Assert.IsTrue(node.Children!.All(child => child.Condition!.Value.operation == "Equal")); + } + + [TestMethod] + public void NegatedLogicalExpression_AppliesDeMorgansLaw() + { + var node = new UniversalExpressionBuilder( + record => !(record.Age > 18 || record.Name == "admin")).Build(); + + Assert.AreEqual(FilterLogicalOperator.And, node.Operator); + CollectionAssert.AreEqual( + new[] { "LessThanOrEqual", "NotEquals" }, + node.Children!.Select(child => child.Condition!.Value.operation).ToArray()); + } + + [TestMethod] + public void UnsupportedArithmeticExpression_FailsWithActionableContext() + { + var exception = Assert.ThrowsExactly(() => + new UniversalExpressionBuilder( + record => record.Age + 1 > 20).Build()); + + StringAssert.Contains(exception.Message, "Unsupported binary expression"); + } + + private static Magic.IndexedDb.Models.UniversalOperations.FilterCondition Condition( + Expression> predicate) + { + var node = new UniversalExpressionBuilder(predicate).Build(); + Assert.IsTrue(node.Condition.HasValue, predicate.ToString()); + return node.Condition.Value; + } + + private sealed class QueryRecord + { + [MagicName("persisted_age")] + public int Age { get; set; } + public string Name { get; set; } = string.Empty; + public bool Enabled { get; set; } + public DateTime When { get; set; } + } +} diff --git a/Magic.IndexedDb.UnitTests/Magic.IndexedDb.UnitTests.csproj b/Magic.IndexedDb.UnitTests/Magic.IndexedDb.UnitTests.csproj index baab0d4..50786d8 100644 --- a/Magic.IndexedDb.UnitTests/Magic.IndexedDb.UnitTests.csproj +++ b/Magic.IndexedDb.UnitTests/Magic.IndexedDb.UnitTests.csproj @@ -13,6 +13,7 @@ + diff --git a/Magic.IndexedDb.UnitTests/PublicApiBaseline.txt b/Magic.IndexedDb.UnitTests/PublicApiBaseline.txt new file mode 100644 index 0000000..9340f60 --- /dev/null +++ b/Magic.IndexedDb.UnitTests/PublicApiBaseline.txt @@ -0,0 +1,308 @@ +enum Magic.IndexedDb.BlazorInteropMode : System.IComparable, System.IConvertible, System.IFormattable, System.ISpanFormattable + field System.Int64 value__ + field static Magic.IndexedDb.BlazorInteropMode SignalR = 31744 + field static Magic.IndexedDb.BlazorInteropMode WASM = 15728640 +class Magic.IndexedDb.DbMigration + ctor Magic.IndexedDb.DbMigration() + property System.Collections.Generic.List Instructions { get; set; } + property System.String FromVersion { get; set; } + property System.String ToVersion { get; set; } +class Magic.IndexedDb.DbMigrationInstruction + ctor Magic.IndexedDb.DbMigrationInstruction() + property System.String Action { get; set; } + property System.String Details { get; set; } + property System.String StoreName { get; set; } +class Magic.IndexedDb.DbStore + ctor Magic.IndexedDb.DbStore() + property System.Collections.Generic.List DbMigrations { get; set; } + property System.Collections.Generic.List StoreSchemas { get; set; } + property System.Int32 Version { get; set; } + property System.String Name { get; set; } +static class Magic.IndexedDb.Extensions.MagicJsChunkProcessor + method static System.String GetCompletedItem(System.String instanceId) + method static System.Void AddChunk(System.String instanceId, System.String chunkInstanceId, System.Int32 yieldOrderIndex, System.String chunk, System.Int32 chunkIndex, System.Int32 totalChunks) + method static System.Void RegisterInstance(System.String instanceId) + method static System.Void RemoveInstance(System.String instanceId) +static class Magic.IndexedDb.Helpers.AttributeHelpers + method static System.Collections.Generic.List GetPrimaryKeys(T item) + method static System.Type[] GetPrimaryKeyTypes() + method static System.Void ValidatePrimaryKey(System.Object[] keys) +static class Magic.IndexedDb.Helpers.ExpandoToTypeConverter +static class Magic.IndexedDb.Helpers.ExpressionFlattener + method static System.Linq.Expressions.Expression> FlattenAndOptimize(System.Linq.Expressions.Expression> expr) +static class Magic.IndexedDb.Helpers.MagicSerializationHelper + method static System.Object[] SerializeObjects(Magic.IndexedDb.Interfaces.ITypedArgument[] objs, Magic.IndexedDb.Models.MagicJsonSerializationSettings settings = null) + method static System.String SerializeObject(T value, Magic.IndexedDb.Models.MagicJsonSerializationSettings settings = null) + method static System.String[] SerializeObjectsToString(Magic.IndexedDb.Interfaces.ITypedArgument[] objs, Magic.IndexedDb.Models.MagicJsonSerializationSettings settings = null) + method static System.Text.Json.JsonElement SerializeObjectToJsonElement(T value, Magic.IndexedDb.Models.MagicJsonSerializationSettings settings = null) + method static System.Threading.Tasks.Task SerializeObjectToStreamAsync(System.IO.Stream stream, T value, Magic.IndexedDb.Models.MagicJsonSerializationSettings settings = null) + method static System.Threading.Tasks.Task SerializeObjectToStreamAsync(System.IO.StreamWriter writer, T value, Magic.IndexedDb.Models.MagicJsonSerializationSettings settings = null) + method static System.Void PopulateObject(T source, T target) + method static T DeserializeObject(System.String json, Magic.IndexedDb.Models.MagicJsonSerializationSettings settings = null) +static class Magic.IndexedDb.Helpers.MagicValidator + method static System.Void ValidateTables(System.Collections.Generic.List magicTableClasses = null) +class Magic.IndexedDb.Helpers.PrimaryKeys + ctor Magic.IndexedDb.Helpers.PrimaryKeys() + property System.Object Value { get; set; } + property System.String JsName { get; set; } +static class Magic.IndexedDb.Helpers.PropertyMappingCache + method static Magic.IndexedDb.Helpers.SearchPropEntry GetTypeOfTProperties(System.Type type) + method static Magic.IndexedDb.Models.MagicPropertyEntry GetPropertyByCsharpName(Magic.IndexedDb.Helpers.SearchPropEntry propCachee, System.String csharpName) + method static Magic.IndexedDb.Models.MagicPropertyEntry GetPropertyEntry(System.Reflection.PropertyInfo property, System.Type type) + method static Magic.IndexedDb.Models.MagicPropertyEntry GetPropertyEntry(System.String propertyName, System.Type type) + method static Magic.IndexedDb.Models.MagicPropertyEntry GetPropertyEntry(System.Reflection.PropertyInfo property) + method static Magic.IndexedDb.Models.MagicPropertyEntry GetPropertyEntry(System.String propertyName) + method static System.Boolean IsComplexType(System.Type type) + method static System.Boolean IsSimpleType(System.Type type) + method static System.Collections.Generic.IEnumerable GetAllNestedComplexTypes(System.Collections.Generic.IEnumerable properties) + method static System.Collections.Generic.List GetPrimaryKeysOfType(System.Type type) + method static System.String GetCsharpPropertyName(Magic.IndexedDb.Helpers.SearchPropEntry propCachee, System.String jsPropertyName) + method static System.String GetCsharpPropertyName(System.String jsPropertyName, System.Type type) + method static System.String GetCsharpPropertyName(System.String jsPropertyName) + method static System.String GetJsPropertyName(System.Reflection.PropertyInfo prop, System.Type type) + method static System.String GetJsPropertyName(System.String csharpPropertyName, System.Type type) + method static System.String GetJsPropertyName(System.Reflection.PropertyInfo prop) + method static System.String GetJsPropertyName(System.String csharpPropertyName) +static class Magic.IndexedDb.Helpers.SchemaHelper + method static Magic.IndexedDb.StoreSchema GetStoreSchema(System.Type type) + method static System.Boolean HasMagicTableInterface(System.Type type) + method static System.Boolean ImplementsIMagicRepository(System.Type type) + method static System.Boolean ImplementsIMagicTable(System.Type type) + method static System.Collections.Generic.List GetAllIndexedDbSets() + method static System.Collections.Generic.List GetAllSchemas(System.String databaseName = null) + method static System.Collections.Generic.List GetAllMagicRepositories() + method static System.Collections.Generic.List GetAllMagicTables() + method static System.String GetDefaultDatabaseName() + method static System.String GetTableName() +struct Magic.IndexedDb.Helpers.SearchPropEntry + ctor Magic.IndexedDb.Helpers.SearchPropEntry(System.Type type, System.Collections.Generic.Dictionary _propertyEntries, System.Reflection.ConstructorInfo[] constructors) + property System.Boolean EnforcePascalCase { get; } + property System.Boolean HasConstructorParameters { get; } + property System.Collections.Generic.Dictionary propertyEntries { get; } + property System.Collections.Generic.Dictionary ConstructorParameterMappings { get; } + property System.Collections.Generic.Dictionary jsNameToCsName { get; } + property System.Func InstanceCreator { get; } + property System.Reflection.ConstructorInfo Constructor { get; } + property System.String EffectiveTableName { get; } +interface Magic.IndexedDb.IMagicCompoundIndex + property System.Reflection.PropertyInfo[] PropertyInfos { get; } + property System.String[] ColumnNamesInCompoundIndex { get; } +interface Magic.IndexedDb.IMagicCompoundKey + property System.Boolean AutoIncrement { get; } + property System.Reflection.PropertyInfo[] PropertyInfos { get; } + property System.String[] ColumnNamesInCompoundKey { get; } +interface Magic.IndexedDb.IMagicCursor : Magic.IndexedDb.IMagicExecute + method Magic.IndexedDb.IMagicCursor Cursor(System.Linq.Expressions.Expression> predicate) + method Magic.IndexedDb.IMagicCursorPaginationTake Take(System.Int32 amount) + method Magic.IndexedDb.IMagicCursorPaginationTake TakeLast(System.Int32 amount) + method Magic.IndexedDb.IMagicCursorSkip Skip(System.Int32 amount) + method Magic.IndexedDb.IMagicCursorStage OrderBy(System.Linq.Expressions.Expression> predicate) + method Magic.IndexedDb.IMagicCursorStage OrderByDescending(System.Linq.Expressions.Expression> predicate) + method Magic.IndexedDb.IMagicCursorStage StableOrdering() + method System.Threading.Tasks.Task FirstOrDefaultAsync() + method System.Threading.Tasks.Task LastOrDefaultAsync() +interface Magic.IndexedDb.IMagicCursorFinal : Magic.IndexedDb.IMagicExecute + method System.Threading.Tasks.Task FirstOrDefaultAsync() + method System.Threading.Tasks.Task LastOrDefaultAsync() +interface Magic.IndexedDb.IMagicCursorPaginationTake : Magic.IndexedDb.IMagicExecute + method Magic.IndexedDb.IMagicCursorSkip Skip(System.Int32 amount) +interface Magic.IndexedDb.IMagicCursorSkip : Magic.IndexedDb.IMagicExecute + method System.Threading.Tasks.Task FirstOrDefaultAsync() + method System.Threading.Tasks.Task LastOrDefaultAsync() +interface Magic.IndexedDb.IMagicCursorStage : Magic.IndexedDb.IMagicExecute + method Magic.IndexedDb.IMagicCursorPaginationTake Take(System.Int32 amount) + method Magic.IndexedDb.IMagicCursorPaginationTake TakeLast(System.Int32 amount) + method Magic.IndexedDb.IMagicCursorSkip Skip(System.Int32 amount) + method System.Threading.Tasks.Task FirstOrDefaultAsync() + method System.Threading.Tasks.Task LastOrDefaultAsync() +interface Magic.IndexedDb.IMagicExecute + method System.Collections.Generic.IAsyncEnumerable AsAsyncEnumerable(System.Threading.CancellationToken cancellationToken = null) + method System.Threading.Tasks.Task> ToListAsync() +interface Magic.IndexedDb.IMagicIndexedDb + method System.Threading.Tasks.Task GetStorageEstimateAsync(System.Threading.CancellationToken cancellationToken = null) + method System.Threading.Tasks.ValueTask> Query() + method System.Threading.Tasks.ValueTask> Query(System.Func dbSetSelector) + method System.Threading.Tasks.ValueTask Database(Magic.IndexedDb.IndexedDbSet indexedDbSet) +interface Magic.IndexedDb.IMagicQuery : Magic.IndexedDb.IMagicExecute + method Magic.IndexedDb.IMagicCursor Cursor(System.Linq.Expressions.Expression> predicate) + method Magic.IndexedDb.IMagicQueryFinal Skip(System.Int32 amount) + method Magic.IndexedDb.IMagicQueryFinal TakeLast(System.Int32 amount) + method Magic.IndexedDb.IMagicQueryOrderableTable OrderBy(System.Linq.Expressions.Expression> predicate) + method Magic.IndexedDb.IMagicQueryOrderableTable OrderByDescending(System.Linq.Expressions.Expression> predicate) + method Magic.IndexedDb.IMagicQueryPaginationTake Take(System.Int32 amount) + method Magic.IndexedDb.IMagicQueryStaging Where(System.Linq.Expressions.Expression> predicate) + method System.Threading.Tasks.Task AddAsync(T record, System.Threading.CancellationToken cancellationToken = null) + method System.Threading.Tasks.Task AddRangeAsync(System.Collections.Generic.IEnumerable records, System.Threading.CancellationToken cancellationToken = null) + method System.Threading.Tasks.Task ClearTable() + method System.Threading.Tasks.Task DeleteAsync(T item, System.Threading.CancellationToken cancellationToken = null) + method System.Threading.Tasks.Task CountAsync() + method System.Threading.Tasks.Task DeleteRangeAsync(System.Collections.Generic.IEnumerable items, System.Threading.CancellationToken cancellationToken = null) + method System.Threading.Tasks.Task UpdateAsync(T item, System.Threading.CancellationToken cancellationToken = null) + method System.Threading.Tasks.Task UpdateRangeAsync(System.Collections.Generic.IEnumerable items, System.Threading.CancellationToken cancellationToken = null) + method System.Threading.Tasks.Task FirstOrDefaultAsync() + method System.Threading.Tasks.Task FirstOrDefaultAsync(System.Linq.Expressions.Expression> predicate) + method System.Threading.Tasks.Task LastOrDefaultAsync() + method System.Threading.Tasks.Task LastOrDefaultAsync(System.Linq.Expressions.Expression> predicate) + property System.String DatabaseName { get; } + property System.String SchemaName { get; } +interface Magic.IndexedDb.IMagicQueryFinal : Magic.IndexedDb.IMagicExecute + method System.Threading.Tasks.Task> WhereAsync(System.Linq.Expressions.Expression> predicate) +interface Magic.IndexedDb.IMagicQueryOrderable : Magic.IndexedDb.IMagicExecute + method Magic.IndexedDb.IMagicQueryFinal Skip(System.Int32 amount) + method Magic.IndexedDb.IMagicQueryFinal TakeLast(System.Int32 amount) + method Magic.IndexedDb.IMagicQueryPaginationTake Take(System.Int32 amount) + method System.Threading.Tasks.Task> WhereAsync(System.Linq.Expressions.Expression> predicate) + method System.Threading.Tasks.Task FirstOrDefaultAsync() + method System.Threading.Tasks.Task LastOrDefaultAsync() +interface Magic.IndexedDb.IMagicQueryOrderableTable : Magic.IndexedDb.IMagicExecute, Magic.IndexedDb.IMagicQueryOrderable + method System.Threading.Tasks.Task FirstOrDefaultAsync(System.Linq.Expressions.Expression> predicate) + method System.Threading.Tasks.Task LastOrDefaultAsync(System.Linq.Expressions.Expression> predicate) +interface Magic.IndexedDb.IMagicQueryPaginationTake : Magic.IndexedDb.IMagicExecute + method Magic.IndexedDb.IMagicQueryFinal Skip(System.Int32 amount) + method System.Threading.Tasks.Task> WhereAsync(System.Linq.Expressions.Expression> predicate) +interface Magic.IndexedDb.IMagicQueryStaging : Magic.IndexedDb.IMagicExecute + method Magic.IndexedDb.IMagicQueryFinal Skip(System.Int32 amount) + method Magic.IndexedDb.IMagicQueryFinal TakeLast(System.Int32 amount) + method Magic.IndexedDb.IMagicQueryPaginationTake Take(System.Int32 amount) + method Magic.IndexedDb.IMagicQueryStaging Where(System.Linq.Expressions.Expression> predicate) + method System.Threading.Tasks.Task FirstOrDefaultAsync() + method System.Threading.Tasks.Task LastOrDefaultAsync() +interface Magic.IndexedDb.IMagicTable : Magic.IndexedDb.Interfaces.IMagicTableBase + property TDbSets Databases { get; } +interface Magic.IndexedDb.IMagicUtilities +class Magic.IndexedDb.IndexedDbSet + ctor Magic.IndexedDb.IndexedDbSet(System.String databaseName) + property System.String DatabaseName { get; } +interface Magic.IndexedDb.Interfaces.IColumnNamed + property System.String ColumnName { get; } +interface Magic.IndexedDb.Interfaces.IMagicRepository +interface Magic.IndexedDb.Interfaces.IMagicTableBase + method Magic.IndexedDb.IMagicCompoundKey GetKeys() + method Magic.IndexedDb.IndexedDbSet GetDefaultDatabase() + method System.Collections.Generic.List GetCompoundIndexes() + method System.String GetTableName() +interface Magic.IndexedDb.Interfaces.ITypedArgument + method System.String Serialize() + method System.String SerializeToJsonString(Magic.IndexedDb.Models.MagicJsonSerializationSettings settings = null) + method System.Text.Json.JsonElement SerializeToJsonElement(Magic.IndexedDb.Models.MagicJsonSerializationSettings settings = null) +class Magic.IndexedDb.LinqTranslation.Extensions.UniversalExpressionBuilder + ctor Magic.IndexedDb.LinqTranslation.Extensions.UniversalExpressionBuilder(System.Linq.Expressions.Expression> predicate) + method Magic.IndexedDb.LinqTranslation.Models.FilterNode Build() +interface Magic.IndexedDb.LinqTranslation.Interfaces.IMagicDatabaseScoped + method System.Threading.Tasks.Task CloseAsync() + method System.Threading.Tasks.Task DeleteAsync() + method System.Threading.Tasks.Task OpenAsync() + method System.Threading.Tasks.Task DoesExistAsync() + method System.Threading.Tasks.Task IsOpenAsync() +enum Magic.IndexedDb.LinqTranslation.Models.FilterLogicalOperator : System.IComparable, System.IConvertible, System.IFormattable, System.ISpanFormattable + field System.Int32 value__ + field static Magic.IndexedDb.LinqTranslation.Models.FilterLogicalOperator And = 0 + field static Magic.IndexedDb.LinqTranslation.Models.FilterLogicalOperator Or = 1 +class Magic.IndexedDb.LinqTranslation.Models.FilterNode + ctor Magic.IndexedDb.LinqTranslation.Models.FilterNode() + property Magic.IndexedDb.LinqTranslation.Models.FilterLogicalOperator Operator { get; set; } + property Magic.IndexedDb.LinqTranslation.Models.FilterNodeType NodeType { get; set; } + property System.Collections.Generic.List Children { get; set; } + property System.Nullable Condition { get; set; } +enum Magic.IndexedDb.LinqTranslation.Models.FilterNodeType : System.IComparable, System.IConvertible, System.IFormattable, System.ISpanFormattable + field System.Int32 value__ + field static Magic.IndexedDb.LinqTranslation.Models.FilterNodeType Condition = 1 + field static Magic.IndexedDb.LinqTranslation.Models.FilterNodeType Logical = 0 +class Magic.IndexedDb.MagicTableTool + ctor Magic.IndexedDb.MagicTableTool() +class Magic.IndexedDb.MagicUniqueIndexAttribute : Magic.IndexedDb.Interfaces.IColumnNamed, System.Attribute + ctor Magic.IndexedDb.MagicUniqueIndexAttribute(System.String columnName = null) + property System.String ColumnName { get; } +class Magic.IndexedDb.Models.IndexFilterValue + ctor Magic.IndexedDb.Models.IndexFilterValue(System.String indexName, System.Object filterValue) + property System.Object FilterValue { get; set; } + property System.String IndexName { get; set; } +class Magic.IndexedDb.Models.MagicConstructorException : System.InvalidOperationException, System.Runtime.Serialization.ISerializable + ctor Magic.IndexedDb.Models.MagicConstructorException(System.String message) +class Magic.IndexedDb.Models.MagicException : System.Exception, System.Runtime.Serialization.ISerializable + ctor Magic.IndexedDb.Models.MagicException(System.String message, System.Exception inner = null) +class Magic.IndexedDb.Models.MagicJsonSerializationSettings + ctor Magic.IndexedDb.Models.MagicJsonSerializationSettings() + method System.Text.Json.JsonSerializerOptions GetOptionsWithResolver() + property System.Boolean UseCamelCase { get; set; } + property System.Text.Json.JsonSerializerOptions Options { get; set; } +struct Magic.IndexedDb.Models.MagicPropertyEntry + ctor Magic.IndexedDb.Models.MagicPropertyEntry(System.Reflection.PropertyInfo property, Magic.IndexedDb.Interfaces.IColumnNamed columnNamedAttribute, System.Boolean indexed, System.Boolean uniqueIndex, System.Boolean primaryKey, System.Boolean notMapped, System.Boolean overrideNeverCamel = false) + property System.Action Setter { get; } + property System.Boolean Indexed { get; set; } + property System.Boolean IsComplexType { get; } + property System.Boolean NeverCamelCase { get; } + property System.Boolean NotMapped { get; set; } + property System.Boolean OverrideNeverCamel { get; } + property System.Boolean PrimaryKey { get; set; } + property System.Boolean UniqueIndex { get; set; } + property System.Func Getter { get; } + property System.Object DefaultValue { get; } + property System.Reflection.PropertyInfo Property { get; set; } + property System.String CsharpPropertyName { get; } + property System.String JsPropertyName { get; } +class Magic.IndexedDb.Models.PredicateVisitor : System.Linq.Expressions.ExpressionVisitor + ctor Magic.IndexedDb.Models.PredicateVisitor() +class Magic.IndexedDb.Models.QuotaUsage : System.IEquatable + ctor Magic.IndexedDb.Models.QuotaUsage(System.Int64 Quota, System.Int64 Usage) + method Magic.IndexedDb.Models.QuotaUsage $() + method System.Boolean Equals(Magic.IndexedDb.Models.QuotaUsage other) + method System.Boolean Equals(System.Object obj) + method System.Int32 GetHashCode() + method System.String ToString() + method System.Void Deconstruct(out System.Int64 Quota, out System.Int64 Usage) + property System.Double QuotaInMegabytes { get; } + property System.Double UsageInMegabytes { get; } + property System.Int64 Quota { get; set; } + property System.Int64 Usage { get; set; } + property System.ValueTuple InMegabytes { get; } +class Magic.IndexedDb.Models.StoredMagicQuery + ctor Magic.IndexedDb.Models.StoredMagicQuery() + property System.Int32 intValue { get; set; } + property System.String additionFunction { get; set; } + property System.String property { get; set; } +class Magic.IndexedDb.Models.TypedArgument : Magic.IndexedDb.Interfaces.ITypedArgument + ctor Magic.IndexedDb.Models.TypedArgument(T value) + method System.String Serialize() + method System.String SerializeToJsonString(Magic.IndexedDb.Models.MagicJsonSerializationSettings settings = null) + method System.Text.Json.JsonElement SerializeToJsonElement(Magic.IndexedDb.Models.MagicJsonSerializationSettings settings = null) + property T Value { get; } +struct Magic.IndexedDb.Models.UniversalOperations.FilterCondition + ctor Magic.IndexedDb.Models.UniversalOperations.FilterCondition(System.String _property, System.String _operation, System.Object _value, System.Boolean _isString = false, System.Boolean _caseSensitive = false) + property System.Boolean caseSensitive { get; set; } + property System.Boolean isString { get; set; } + property System.Object value { get; set; } + property System.String operation { get; set; } + property System.String property { get; set; } +class Magic.IndexedDb.SchemaAnnotations.MagicConstructorAttribute : System.Attribute + ctor Magic.IndexedDb.SchemaAnnotations.MagicConstructorAttribute() +class Magic.IndexedDb.SchemaAnnotations.MagicIndexAttribute : Magic.IndexedDb.Interfaces.IColumnNamed, System.Attribute + ctor Magic.IndexedDb.SchemaAnnotations.MagicIndexAttribute(System.String columnName = null) + property System.String ColumnName { get; } +class Magic.IndexedDb.SchemaAnnotations.MagicNameAttribute : Magic.IndexedDb.Interfaces.IColumnNamed, System.Attribute + ctor Magic.IndexedDb.SchemaAnnotations.MagicNameAttribute(System.String columnName) + property System.String ColumnName { get; } +class Magic.IndexedDb.SchemaAnnotations.MagicNotMappedAttribute : System.Attribute + ctor Magic.IndexedDb.SchemaAnnotations.MagicNotMappedAttribute() +static class Magic.IndexedDb.ServiceCollectionExtensions + method static Microsoft.Extensions.DependencyInjection.IServiceCollection AddMagicBlazorDB(Microsoft.Extensions.DependencyInjection.IServiceCollection services, Magic.IndexedDb.BlazorInteropMode interoptMode, System.Boolean isDebug) + method static Microsoft.Extensions.DependencyInjection.IServiceCollection AddMagicBlazorDB(Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Int64 jsMessageSizeBytes, System.Boolean isDebug) +class Magic.IndexedDb.StoreRecord + ctor Magic.IndexedDb.StoreRecord() + property System.String DbName { get; set; } + property System.String StoreName { get; set; } + property T Record { get; set; } +class Magic.IndexedDb.StoreSchema + ctor Magic.IndexedDb.StoreSchema() + property System.Boolean PrimaryKeyAuto { get; set; } + property System.Collections.Generic.List> ColumnNamesInCompoundIndex { get; set; } + property System.Collections.Generic.List ColumnNamesInCompoundKey { get; set; } + property System.Collections.Generic.List Indexes { get; set; } + property System.Collections.Generic.List UniqueIndexes { get; set; } + property System.Int32 Version { get; set; } + property System.String TableName { get; set; } +class Magic.IndexedDb.UpdateRecord : Magic.IndexedDb.StoreRecord + ctor Magic.IndexedDb.UpdateRecord() + property System.Collections.Generic.List Key { get; set; } +class Magic.IndexedDb._Imports + ctor Magic.IndexedDb._Imports() diff --git a/Magic.IndexedDb.UnitTests/PublicApiContractTests.cs b/Magic.IndexedDb.UnitTests/PublicApiContractTests.cs index dc5e965..1cb0d2b 100644 --- a/Magic.IndexedDb.UnitTests/PublicApiContractTests.cs +++ b/Magic.IndexedDb.UnitTests/PublicApiContractTests.cs @@ -1,3 +1,6 @@ +using System.Globalization; +using System.Reflection; +using System.Runtime.CompilerServices; using Magic.IndexedDb.Interfaces; namespace Magic.IndexedDb.UnitTests; @@ -14,4 +17,143 @@ public void TypedArgumentSerializationMembers_RemainPublic() new[] { "Serialize", "SerializeToJsonElement", "SerializeToJsonString" }, methods.ToArray()); } + + [TestMethod] + public void PublicApi_MatchesReviewedBaseline() + { + var baselinePath = GetBaselinePath(); + var actual = BuildPublicApiSnapshot(); + + if (Environment.GetEnvironmentVariable("UPDATE_PUBLIC_API_BASELINE") == "1") + { + File.WriteAllText(baselinePath, actual + Environment.NewLine); + return; + } + + var expected = File.ReadAllText(baselinePath).TrimEnd(); + if (string.Equals(expected, actual, StringComparison.Ordinal)) + return; + + var expectedLines = expected.Split('\n'); + var actualLines = actual.Split('\n'); + var difference = Enumerable.Range(0, Math.Max(expectedLines.Length, actualLines.Length)) + .First(index => index >= expectedLines.Length || + index >= actualLines.Length || + expectedLines[index] != actualLines[index]); + + Assert.Fail( + $"The public API changed at line {difference + 1}.{Environment.NewLine}" + + $"Expected: {(difference < expectedLines.Length ? expectedLines[difference] : "")}{Environment.NewLine}" + + $"Actual: {(difference < actualLines.Length ? actualLines[difference] : "")}{Environment.NewLine}" + + "Review the compatibility impact, then regenerate intentionally with " + + "UPDATE_PUBLIC_API_BASELINE=1 dotnet test --filter PublicApi_MatchesReviewedBaseline."); + } + + private static string GetBaselinePath([CallerFilePath] string sourceFile = "") => + Path.Combine(Path.GetDirectoryName(sourceFile)!, "PublicApiBaseline.txt"); + + private static string BuildPublicApiSnapshot() + { + var lines = new List(); + foreach (var type in typeof(IMagicIndexedDb).Assembly.GetExportedTypes() + .OrderBy(type => TypeName(type), StringComparer.Ordinal)) + { + lines.Add(DescribeType(type)); + + var members = new List(); + members.AddRange(type.GetConstructors(DeclaredPublic) + .Select(constructor => $" ctor {TypeName(type)}({Parameters(constructor.GetParameters())})")); + members.AddRange(type.GetFields(DeclaredPublic) + .Select(field => $" field {(field.IsStatic ? "static " : "")}" + + $"{(field.IsInitOnly ? "readonly " : "")}{TypeName(field.FieldType)} {field.Name}" + + (field.IsLiteral ? $" = {FormatValue(field.GetRawConstantValue())}" : string.Empty))); + members.AddRange(type.GetProperties(DeclaredPublic) + .Select(property => + { + var index = property.GetIndexParameters(); + var name = index.Length == 0 + ? property.Name + : $"this[{Parameters(index)}]"; + var accessors = string.Join(" ", new[] + { + property.GetMethod?.IsPublic == true ? "get;" : null, + property.SetMethod?.IsPublic == true ? "set;" : null + }.Where(value => value is not null)); + return $" property {TypeName(property.PropertyType)} {name} {{ {accessors} }}"; + })); + members.AddRange(type.GetEvents(DeclaredPublic) + .Select(@event => $" event {TypeName(@event.EventHandlerType!)} {@event.Name}")); + members.AddRange(type.GetMethods(DeclaredPublic) + .Where(method => !method.IsSpecialName) + .Select(method => + { + var generic = method.IsGenericMethodDefinition + ? $"<{string.Join(", ", method.GetGenericArguments().Select(argument => argument.Name))}>" + : string.Empty; + return $" method {(method.IsStatic ? "static " : string.Empty)}" + + $"{TypeName(method.ReturnType)} {method.Name}{generic}({Parameters(method.GetParameters())})"; + })); + + lines.AddRange(members.OrderBy(member => member, StringComparer.Ordinal)); + } + + return string.Join('\n', lines); + } + + private const BindingFlags DeclaredPublic = + BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; + + private static string DescribeType(Type type) + { + var kind = type.IsInterface ? "interface" : + type.IsEnum ? "enum" : + type.IsValueType ? "struct" : + type.IsAbstract && type.IsSealed ? "static class" : + "class"; + var bases = new List(); + if (type.BaseType is not null && type.BaseType != typeof(object) && !type.IsEnum && !type.IsValueType) + bases.Add(TypeName(type.BaseType)); + bases.AddRange(type.GetInterfaces().Select(TypeName)); + return $"{kind} {TypeName(type)}" + + (bases.Count == 0 + ? string.Empty + : $" : {string.Join(", ", bases.Distinct().Order(StringComparer.Ordinal))}"); + } + + private static string Parameters(IEnumerable parameters) => + string.Join(", ", parameters.Select(parameter => + { + var modifier = parameter.IsOut ? "out " : + parameter.ParameterType.IsByRef ? "ref " : string.Empty; + var optional = parameter.HasDefaultValue + ? $" = {FormatValue(parameter.DefaultValue)}" + : string.Empty; + return $"{modifier}{TypeName(parameter.ParameterType)} {parameter.Name}{optional}"; + })); + + private static string TypeName(Type type) + { + if (type.IsByRef) + return TypeName(type.GetElementType()!); + if (type.IsArray) + return $"{TypeName(type.GetElementType()!)}[]"; + if (type.IsGenericParameter) + return type.Name; + if (!type.IsGenericType) + return type.FullName?.Replace('+', '.') ?? type.Name; + + var definitionName = (type.GetGenericTypeDefinition().FullName ?? type.Name) + .Split('`')[0] + .Replace('+', '.'); + return $"{definitionName}<{string.Join(", ", type.GetGenericArguments().Select(TypeName))}>"; + } + + private static string FormatValue(object? value) => value switch + { + null => "null", + string text => $"\"{text.Replace("\\", "\\\\").Replace("\"", "\\\"")}\"", + char character => $"'{character}'", + bool boolean => boolean ? "true" : "false", + _ => Convert.ToString(value, CultureInfo.InvariantCulture) ?? "null" + }; } diff --git a/Magic.IndexedDb.UnitTests/SchemaContractTests.cs b/Magic.IndexedDb.UnitTests/SchemaContractTests.cs new file mode 100644 index 0000000..915873d --- /dev/null +++ b/Magic.IndexedDb.UnitTests/SchemaContractTests.cs @@ -0,0 +1,93 @@ +using Magic.IndexedDb.Helpers; +using Magic.IndexedDb.SchemaAnnotations; +using TestBase.Models; +using TestBase.Repository; + +namespace Magic.IndexedDb.UnitTests; + +[TestClass] +public sealed class SchemaContractTests +{ + [TestMethod] + public void PersonSchema_PreservesMappedKeysAndIndexes() + { + var schema = SchemaHelper.GetStoreSchema(typeof(Person)); + + Assert.AreEqual("Person", schema.TableName); + Assert.IsTrue(schema.PrimaryKeyAuto); + CollectionAssert.AreEqual(new[] { "_id" }, schema.ColumnNamesInCompoundKey); + CollectionAssert.AreEquivalent( + new[] { "_id", nameof(Person.Name), "TestInt", nameof(Person.TestIntStable2) }, + schema.Indexes); + CollectionAssert.AreEqual(new[] { "guid" }, schema.UniqueIndexes); + Assert.AreEqual(1, schema.ColumnNamesInCompoundIndex.Count); + CollectionAssert.AreEqual( + new[] { nameof(Person.TestIntStable2), nameof(Person.Name) }, + schema.ColumnNamesInCompoundIndex[0]); + } + + [TestMethod] + public void CompoundSchema_PreservesKeyAndIndexOrdering() + { + var schema = SchemaHelper.GetStoreSchema(typeof(CompositeRecord)); + + Assert.IsFalse(schema.PrimaryKeyAuto); + CollectionAssert.AreEqual( + new[] { nameof(CompositeRecord.Tenant), nameof(CompositeRecord.Sequence) }, + schema.ColumnNamesInCompoundKey); + CollectionAssert.AreEqual( + new[] { nameof(CompositeRecord.Tenant), nameof(CompositeRecord.Category) }, + schema.ColumnNamesInCompoundIndex.Single()); + } + + [TestMethod] + public void Validator_AcceptsKnownProductionLikeSchemas() + { + MagicValidator.ValidateTables([typeof(Person), typeof(ContractRecord), typeof(CompositeRecord)]); + } + + [TestMethod] + public void Validator_RejectsAutoIncrementOnNonNumericKey() + { + var exception = Assert.ThrowsExactly(() => + MagicValidator.ValidateTables([typeof(InvalidAutoIncrementRecord)])); + + StringAssert.Contains(exception.Message, "auto-increment"); + StringAssert.Contains(exception.Message, nameof(InvalidAutoIncrementRecord.Key)); + } + + [TestMethod] + public void Validator_RejectsConflictingMagicAttributes() + { + var exception = Assert.ThrowsExactly(() => + MagicValidator.ValidateTables([typeof(ConflictingAttributeRecord)])); + + StringAssert.Contains(exception.Message, "multiple Magic attributes"); + StringAssert.Contains(exception.Message, nameof(ConflictingAttributeRecord.Value)); + } + + private sealed class InvalidAutoIncrementRecord : MagicTableTool, IMagicTable + { + public string Key { get; set; } = string.Empty; + public IMagicCompoundKey GetKeys() => CreatePrimaryKey(record => record.Key, true); + public List GetCompoundIndexes() => []; + public string GetTableName() => nameof(InvalidAutoIncrementRecord); + public IndexedDbSet GetDefaultDatabase() => IndexDbContext.Client; + public Person.DbSets Databases { get; } = new(); + } + + private sealed class ConflictingAttributeRecord : MagicTableTool, IMagicTable + { + public int Key { get; set; } + + [MagicIndex] + [MagicName("value")] + public string Value { get; set; } = string.Empty; + + public IMagicCompoundKey GetKeys() => CreatePrimaryKey(record => record.Key, false); + public List GetCompoundIndexes() => []; + public string GetTableName() => nameof(ConflictingAttributeRecord); + public IndexedDbSet GetDefaultDatabase() => IndexDbContext.Client; + public Person.DbSets Databases { get; } = new(); + } +} diff --git a/Magic.IndexedDb.UnitTests/SerializationContractTests.cs b/Magic.IndexedDb.UnitTests/SerializationContractTests.cs index 4036256..a733a78 100644 --- a/Magic.IndexedDb.UnitTests/SerializationContractTests.cs +++ b/Magic.IndexedDb.UnitTests/SerializationContractTests.cs @@ -185,6 +185,49 @@ public void ConfiguredSimpleTypeConverter_IsHonoredInBothDirections() Assert.AreEqual(FixedDateConverter.Value, actual.Date); } + [TestMethod] + public void BrowserRelevantScalarTypes_RoundTripWithoutPrecisionOrIdentityLoss() + { + var expected = new ScalarValues + { + Identifier = Guid.NewGuid(), + Signed = long.MinValue + 17, + Unsigned = ulong.MaxValue - 17, + Money = 7922816251426433759354395.0335m, + Moment = new DateTimeOffset(2040, 2, 29, 12, 34, 56, TimeSpan.FromHours(-5)) + }; + + var actual = MagicSerializationHelper.DeserializeObject( + MagicSerializationHelper.SerializeObject(expected)); + + Assert.IsNotNull(actual); + Assert.AreEqual(expected.Identifier, actual.Identifier); + Assert.AreEqual(expected.Signed, actual.Signed); + Assert.AreEqual(expected.Unsigned, actual.Unsigned); + Assert.AreEqual(expected.Money, actual.Money); + Assert.AreEqual(expected.Moment, actual.Moment); + } + + [TestMethod] + public void NullAndEmptyCollectionShapes_RemainDistinct() + { + var expected = new NullableShapes + { + Missing = null, + Empty = [], + Values = [null, "", "value"] + }; + + var actual = MagicSerializationHelper.DeserializeObject( + MagicSerializationHelper.SerializeObject(expected)); + + Assert.IsNotNull(actual); + Assert.IsNull(actual.Missing); + Assert.IsNotNull(actual.Empty); + Assert.AreEqual(0, actual.Empty.Count); + CollectionAssert.AreEqual(expected.Values, actual.Values); + } + private sealed class EscapedValue { public string Text { get; set; } = string.Empty; @@ -238,6 +281,22 @@ private sealed class DateValue public DateTime Date { get; set; } } + private sealed class ScalarValues + { + public Guid Identifier { get; set; } + public long Signed { get; set; } + public ulong Unsigned { get; set; } + public decimal Money { get; set; } + public DateTimeOffset Moment { get; set; } + } + + private sealed class NullableShapes + { + public List? Missing { get; set; } + public List Empty { get; set; } = []; + public List Values { get; set; } = []; + } + private sealed class FixedDateConverter : JsonConverter { public static DateTime Value { get; } = new(2001, 2, 3, 4, 5, 6, DateTimeKind.Utc); diff --git a/Magic.IndexedDb.UnitTests/TestValidatorTests.cs b/Magic.IndexedDb.UnitTests/TestValidatorTests.cs new file mode 100644 index 0000000..bc223a1 --- /dev/null +++ b/Magic.IndexedDb.UnitTests/TestValidatorTests.cs @@ -0,0 +1,38 @@ +using TestBase.Helpers; +using TestBase.Models; + +namespace Magic.IndexedDb.UnitTests; + +[TestClass] +public sealed class TestValidatorTests +{ + [TestMethod] + public void OrderedComparison_RejectsTheRightRowsInTheWrongOrder() + { + Person[] expected = + [ + new() { _Id = 1, Name = "first" }, + new() { _Id = 2, Name = "second" } + ]; + Person[] reversed = [expected[1], expected[0]]; + + var ordered = TestValidator.ValidateLists(expected, reversed, ordered: true); + var unordered = TestValidator.ValidateLists(expected, reversed); + + Assert.IsFalse(ordered.Success); + StringAssert.Contains(ordered.Message, "Position 0"); + Assert.IsTrue(unordered.Success, unordered.Message); + } + + [TestMethod] + public void Comparison_ReportsPropertyDifferencesForMatchingKeys() + { + Person[] expected = [new() { _Id = 1, Name = "expected" }]; + Person[] actual = [new() { _Id = 1, Name = "actual" }]; + + var result = TestValidator.ValidateLists(expected, actual); + + Assert.IsFalse(result.Success); + StringAssert.Contains(result.Message, nameof(Person.Name)); + } +} diff --git a/Magic.IndexedDb/wwwroot/magicDB.js b/Magic.IndexedDb/wwwroot/magicDB.js index 30b9e14..7a0f325 100644 --- a/Magic.IndexedDb/wwwroot/magicDB.js +++ b/Magic.IndexedDb/wwwroot/magicDB.js @@ -435,15 +435,18 @@ export async function bulkUpdateItem(items) { async function getKeyArrayForDelete(dbName, storeName, keyData) { const keyInfo = await getPrimaryKey(dbName, storeName); + const getName = key => key.jsName ?? key.JsName; + const getValue = key => key.value ?? key.Value; if (!keyInfo.isCompound) { - return keyData.find(k => k.JsName === keyInfo.keys[0])?.Value; + const key = keyData.find(candidate => getName(candidate) === keyInfo.keys[0]); + return key === undefined ? undefined : getValue(key); } return keyInfo.keys.map(pk => { - const part = keyData.find(k => k.JsName === pk); + const part = keyData.find(candidate => getName(candidate) === pk); if (!part) throw new Error(`Missing key part: ${pk}`); - return part.Value; + return getValue(part); }); } @@ -493,8 +496,12 @@ export async function toArray(dbName, storeName) { const table = await getTable(dbName, storeName); return await table.toArray(); } -export function getStorageEstimate() { - return navigator.storage.estimate(); +export async function getStorageEstimate() { + if (navigator.storage?.estimate) { + return await navigator.storage.estimate(); + } + + return { quota: 0, usage: 0 }; } async function getTable(dbName, storeName) { diff --git a/Magic.IndexedDb/wwwroot/magicLinqToIndexedDb.js b/Magic.IndexedDb/wwwroot/magicLinqToIndexedDb.js index bb4093c..cbc88f6 100644 --- a/Magic.IndexedDb/wwwroot/magicLinqToIndexedDb.js +++ b/Magic.IndexedDb/wwwroot/magicLinqToIndexedDb.js @@ -266,7 +266,7 @@ function runIndexedQuery(table, indexedConditions, queryAdditions = []) { throw new Error(`Unsupported indexed query operation: ${firstCondition.operation}`); } } else { - throw new Error("Invalid indexed conditionmissing `properties` or `property`."); + throw new Error("Invalid indexed condition--missing `properties` or `property`."); } // === Apply Query Additions (take, skip, first, etc.) === @@ -284,7 +284,8 @@ function runIndexedQuery(table, indexedConditions, queryAdditions = []) { query = query.limit(addition.intValue); break; case QUERY_ADDITIONS.TAKE_LAST: - query = query.reverse().limit(addition.intValue); + // Read the tail efficiently, then restore the caller's requested order. + query = query.reverse().limit(addition.intValue).reverse(); break; case QUERY_ADDITIONS.FIRST: return query.first(); @@ -340,7 +341,7 @@ function optimizeIndexedQueries(indexedQueries, compoundIndexQueries) { optimizedSingleIndexes.push(...fallbackSingleIndexes); if (optimizedSingleIndexes.length === 0 && optimizedCompoundIndexes.length === 0) { - throw new Error("OptimizeIndexedQueries failedNo indexed queries were produced! Investigate input conditions."); + throw new Error("OptimizeIndexedQueries failed--No indexed queries were produced! Investigate input conditions."); } debugLog("Final Optimized Queries", { optimizedSingleIndexes, optimizedCompoundIndexes }); @@ -454,4 +455,3 @@ function optimizeCompoundIndexedOnlyQueries(compoundIndexQueries) { return { optimizedCompoundIndexes, fallbackSingleIndexes }; } - diff --git a/Magic.IndexedDb/wwwroot/utilities/cursorEngine.js b/Magic.IndexedDb/wwwroot/utilities/cursorEngine.js index e41b9af..6cc99f5 100644 --- a/Magic.IndexedDb/wwwroot/utilities/cursorEngine.js +++ b/Magic.IndexedDb/wwwroot/utilities/cursorEngine.js @@ -20,7 +20,15 @@ export async function runCursorQuery(db, table, conditions, queryAdditions, yiel debugLog("Running Cursor Query with Conditions", { structuredPredicateTree, queryAdditions }); const requiresMetaProcessing = queryAdditions.some(a => - [QUERY_ADDITIONS.TAKE, QUERY_ADDITIONS.SKIP, QUERY_ADDITIONS.FIRST, QUERY_ADDITIONS.LAST, QUERY_ADDITIONS.TAKE_LAST].includes(a.additionFunction) + [ + QUERY_ADDITIONS.ORDER_BY, + QUERY_ADDITIONS.ORDER_BY_DESCENDING, + QUERY_ADDITIONS.TAKE, + QUERY_ADDITIONS.SKIP, + QUERY_ADDITIONS.FIRST, + QUERY_ADDITIONS.LAST, + QUERY_ADDITIONS.TAKE_LAST + ].includes(a.additionFunction) ); if (requiresMetaProcessing) { @@ -196,7 +204,7 @@ function optimizeSingleCondition(condition) { const optimized = { ...condition }; // Lowercase normalization for string values if not case-sensitive - if (!condition.caseSensitive && typeof condition.value === "string") { + if (condition.isString && !condition.caseSensitive && typeof condition.value === "string") { optimized.value = condition.value.toLowerCase(); } @@ -248,7 +256,7 @@ async function runMetaDataCursorQuery(db, table, conditions, queryAdditions, yie let magicOrder = 0; if (conditions?.nodeType === "logical" && !conditions.children) { - // No conditions grab everything + // No conditions -- grab everything debugLog("Detected no-op predicate. All records will be evaluated."); } else { collectPropertiesFromTree(conditions, requiredProperties); @@ -306,6 +314,20 @@ async function runMetaDataCursorQuery(db, table, conditions, queryAdditions, yie return primaryKeyList.slice(0, resultIndex); } +function normalizeDate(value) { + if (value === null || value === undefined) { + return new Date(Number.NaN); + } + + // IndexedDB values can cross a browser-realm boundary. Firefox does not + // consistently recognize those Date objects with the local instanceof. + if (Object.prototype.toString.call(value) === "[object Date]") { + return new Date(value.getTime()); + } + + return new Date(value); +} + function getComparisonFunction(operation) { const operations = { [QUERY_OPERATIONS.EQUAL]: (recordValue, queryValue) => recordValue === queryValue, @@ -353,63 +375,63 @@ function getComparisonFunction(operation) { // ------ MONTH OPERATIONS ------ [QUERY_OPERATIONS.MONTH_EQUAL]: (recordValue, queryValue) => { - if (!(recordValue instanceof Date)) recordValue = new Date(recordValue); + recordValue = normalizeDate(recordValue); return !isNaN(recordValue) && (recordValue.getMonth() + 1) === queryValue; }, [QUERY_OPERATIONS.NOT_MONTH_EQUAL]: (recordValue, queryValue) => { - if (!(recordValue instanceof Date)) recordValue = new Date(recordValue); + recordValue = normalizeDate(recordValue); return !isNaN(recordValue) && (recordValue.getMonth() + 1) !== queryValue; }, [QUERY_OPERATIONS.MONTH_GREATER_THAN]: (recordValue, queryValue) => { - if (!(recordValue instanceof Date)) recordValue = new Date(recordValue); + recordValue = normalizeDate(recordValue); return !isNaN(recordValue) && (recordValue.getMonth() + 1) > queryValue; }, [QUERY_OPERATIONS.MONTH_GREATER_THAN_OR_EQUAL]: (recordValue, queryValue) => { - if (!(recordValue instanceof Date)) recordValue = new Date(recordValue); + recordValue = normalizeDate(recordValue); return !isNaN(recordValue) && (recordValue.getMonth() + 1) >= queryValue; }, [QUERY_OPERATIONS.MONTH_LESS_THAN]: (recordValue, queryValue) => { - if (!(recordValue instanceof Date)) recordValue = new Date(recordValue); + recordValue = normalizeDate(recordValue); return !isNaN(recordValue) && (recordValue.getMonth() + 1) < queryValue; }, [QUERY_OPERATIONS.MONTH_LESS_THAN_OR_EQUAL]: (recordValue, queryValue) => { - if (!(recordValue instanceof Date)) recordValue = new Date(recordValue); + recordValue = normalizeDate(recordValue); return !isNaN(recordValue) && (recordValue.getMonth() + 1) <= queryValue; }, // ------ DAY OPERATIONS ------ [QUERY_OPERATIONS.DAY_EQUAL]: (recordValue, queryValue) => { - if (!(recordValue instanceof Date)) recordValue = new Date(recordValue); + recordValue = normalizeDate(recordValue); return !isNaN(recordValue) && recordValue.getDate() === queryValue; }, [QUERY_OPERATIONS.NOT_DAY_EQUAL]: (recordValue, queryValue) => { - if (!(recordValue instanceof Date)) recordValue = new Date(recordValue); + recordValue = normalizeDate(recordValue); return !isNaN(recordValue) && recordValue.getDate() !== queryValue; }, [QUERY_OPERATIONS.DAY_GREATER_THAN]: (recordValue, queryValue) => { - if (!(recordValue instanceof Date)) recordValue = new Date(recordValue); + recordValue = normalizeDate(recordValue); return !isNaN(recordValue) && recordValue.getDate() > queryValue; }, [QUERY_OPERATIONS.DAY_GREATER_THAN_OR_EQUAL]: (recordValue, queryValue) => { - if (!(recordValue instanceof Date)) recordValue = new Date(recordValue); + recordValue = normalizeDate(recordValue); return !isNaN(recordValue) && recordValue.getDate() >= queryValue; }, [QUERY_OPERATIONS.DAY_LESS_THAN]: (recordValue, queryValue) => { - if (!(recordValue instanceof Date)) recordValue = new Date(recordValue); + recordValue = normalizeDate(recordValue); return !isNaN(recordValue) && recordValue.getDate() < queryValue; }, [QUERY_OPERATIONS.DAY_LESS_THAN_OR_EQUAL]: (recordValue, queryValue) => { - if (!(recordValue instanceof Date)) recordValue = new Date(recordValue); + recordValue = normalizeDate(recordValue); return !isNaN(recordValue) && recordValue.getDate() <= queryValue; }, @@ -417,37 +439,37 @@ function getComparisonFunction(operation) { // ------ DAY OF WEEK OPERATIONS ------ [QUERY_OPERATIONS.DAY_OF_WEEK_EQUAL]: (recordValue, queryValue) => { if (recordValue === null || recordValue === undefined) return false; - if (!(recordValue instanceof Date)) recordValue = new Date(recordValue); + recordValue = normalizeDate(recordValue); return !isNaN(recordValue) && recordValue.getDay() === queryValue; }, [QUERY_OPERATIONS.NOT_DAY_OF_WEEK_EQUAL]: (recordValue, queryValue) => { if (recordValue === null || recordValue === undefined) return false; - if (!(recordValue instanceof Date)) recordValue = new Date(recordValue); + recordValue = normalizeDate(recordValue); return !isNaN(recordValue) && recordValue.getDay() !== queryValue; }, [QUERY_OPERATIONS.DAY_OF_WEEK_GREATER_THAN]: (recordValue, queryValue) => { if (recordValue === null || recordValue === undefined) return false; - if (!(recordValue instanceof Date)) recordValue = new Date(recordValue); + recordValue = normalizeDate(recordValue); return !isNaN(recordValue) && recordValue.getDay() > queryValue; }, [QUERY_OPERATIONS.DAY_OF_WEEK_GREATER_THAN_OR_EQUAL]: (recordValue, queryValue) => { if (recordValue === null || recordValue === undefined) return false; - if (!(recordValue instanceof Date)) recordValue = new Date(recordValue); + recordValue = normalizeDate(recordValue); return !isNaN(recordValue) && recordValue.getDay() >= queryValue; }, [QUERY_OPERATIONS.DAY_OF_WEEK_LESS_THAN]: (recordValue, queryValue) => { if (recordValue === null || recordValue === undefined) return false; - if (!(recordValue instanceof Date)) recordValue = new Date(recordValue); + recordValue = normalizeDate(recordValue); return !isNaN(recordValue) && recordValue.getDay() < queryValue; }, [QUERY_OPERATIONS.DAY_OF_WEEK_LESS_THAN_OR_EQUAL]: (recordValue, queryValue) => { if (recordValue === null || recordValue === undefined) return false; - if (!(recordValue instanceof Date)) recordValue = new Date(recordValue); + recordValue = normalizeDate(recordValue); return !isNaN(recordValue) && recordValue.getDay() <= queryValue; }, @@ -455,44 +477,44 @@ function getComparisonFunction(operation) { // ------ YEAR OPERATIONS ------ [QUERY_OPERATIONS.YEAR_EQUAL]: (recordValue, queryValue) => { if (recordValue === null || recordValue === undefined) return false; - if (!(recordValue instanceof Date)) recordValue = new Date(recordValue); + recordValue = normalizeDate(recordValue); return !isNaN(recordValue) && recordValue.getFullYear() === queryValue; }, [QUERY_OPERATIONS.NOT_YEAR_EQUAL]: (recordValue, queryValue) => { if (recordValue === null || recordValue === undefined) return false; - if (!(recordValue instanceof Date)) recordValue = new Date(recordValue); + recordValue = normalizeDate(recordValue); return !isNaN(recordValue) && recordValue.getFullYear() !== queryValue; }, [QUERY_OPERATIONS.YEAR_GREATER_THAN]: (recordValue, queryValue) => { if (recordValue === null || recordValue === undefined) return false; - if (!(recordValue instanceof Date)) recordValue = new Date(recordValue); + recordValue = normalizeDate(recordValue); return !isNaN(recordValue) && recordValue.getFullYear() > queryValue; }, [QUERY_OPERATIONS.YEAR_GREATER_THAN_OR_EQUAL]: (recordValue, queryValue) => { if (recordValue === null || recordValue === undefined) return false; - if (!(recordValue instanceof Date)) recordValue = new Date(recordValue); + recordValue = normalizeDate(recordValue); return !isNaN(recordValue) && recordValue.getFullYear() >= queryValue; }, [QUERY_OPERATIONS.YEAR_LESS_THAN]: (recordValue, queryValue) => { if (recordValue === null || recordValue === undefined) return false; - if (!(recordValue instanceof Date)) recordValue = new Date(recordValue); + recordValue = normalizeDate(recordValue); return !isNaN(recordValue) && recordValue.getFullYear() < queryValue; }, [QUERY_OPERATIONS.YEAR_LESS_THAN_OR_EQUAL]: (recordValue, queryValue) => { if (recordValue === null || recordValue === undefined) return false; - if (!(recordValue instanceof Date)) recordValue = new Date(recordValue); + recordValue = normalizeDate(recordValue); return !isNaN(recordValue) && recordValue.getFullYear() <= queryValue; }, // ------ DAY OF YEAR OPERATIONS ------ [QUERY_OPERATIONS.DAY_OF_YEAR_EQUAL]: (recordValue, queryValue) => { if (recordValue === null || recordValue === undefined) return false; - if (!(recordValue instanceof Date)) recordValue = new Date(recordValue); + recordValue = normalizeDate(recordValue); if (isNaN(recordValue)) return false; const start = new Date(recordValue.getFullYear(), 0, 0); const diff = recordValue - start + ((start.getTimezoneOffset() - recordValue.getTimezoneOffset()) * 60000); @@ -502,7 +524,7 @@ function getComparisonFunction(operation) { [QUERY_OPERATIONS.NOT_DAY_OF_YEAR_EQUAL]: (recordValue, queryValue) => { if (recordValue === null || recordValue === undefined) return false; - if (!(recordValue instanceof Date)) recordValue = new Date(recordValue); + recordValue = normalizeDate(recordValue); if (isNaN(recordValue)) return false; const start = new Date(recordValue.getFullYear(), 0, 0); const diff = recordValue - start + ((start.getTimezoneOffset() - recordValue.getTimezoneOffset()) * 60000); @@ -512,7 +534,7 @@ function getComparisonFunction(operation) { [QUERY_OPERATIONS.DAY_OF_YEAR_GREATER_THAN]: (recordValue, queryValue) => { if (recordValue === null || recordValue === undefined) return false; - if (!(recordValue instanceof Date)) recordValue = new Date(recordValue); + recordValue = normalizeDate(recordValue); if (isNaN(recordValue)) return false; const start = new Date(recordValue.getFullYear(), 0, 0); const diff = recordValue - start + ((start.getTimezoneOffset() - recordValue.getTimezoneOffset()) * 60000); @@ -522,7 +544,7 @@ function getComparisonFunction(operation) { [QUERY_OPERATIONS.DAY_OF_YEAR_GREATER_THAN_OR_EQUAL]: (recordValue, queryValue) => { if (recordValue === null || recordValue === undefined) return false; - if (!(recordValue instanceof Date)) recordValue = new Date(recordValue); + recordValue = normalizeDate(recordValue); if (isNaN(recordValue)) return false; const start = new Date(recordValue.getFullYear(), 0, 0); const diff = recordValue - start + ((start.getTimezoneOffset() - recordValue.getTimezoneOffset()) * 60000); @@ -532,7 +554,7 @@ function getComparisonFunction(operation) { [QUERY_OPERATIONS.DAY_OF_YEAR_LESS_THAN]: (recordValue, queryValue) => { if (recordValue === null || recordValue === undefined) return false; - if (!(recordValue instanceof Date)) recordValue = new Date(recordValue); + recordValue = normalizeDate(recordValue); if (isNaN(recordValue)) return false; const start = new Date(recordValue.getFullYear(), 0, 0); const diff = recordValue - start + ((start.getTimezoneOffset() - recordValue.getTimezoneOffset()) * 60000); @@ -542,7 +564,7 @@ function getComparisonFunction(operation) { [QUERY_OPERATIONS.DAY_OF_YEAR_LESS_THAN_OR_EQUAL]: (recordValue, queryValue) => { if (recordValue === null || recordValue === undefined) return false; - if (!(recordValue instanceof Date)) recordValue = new Date(recordValue); + recordValue = normalizeDate(recordValue); if (isNaN(recordValue)) return false; const start = new Date(recordValue.getFullYear(), 0, 0); const diff = recordValue - start + ((start.getTimezoneOffset() - recordValue.getTimezoneOffset()) * 60000); @@ -615,7 +637,7 @@ function getComparisonFunction(operation) { function applyCondition(record, condition) { let recordValue = record[condition.property]; - if (!condition.caseSensitive && typeof recordValue === "string") { + if (condition.isString && !condition.caseSensitive && typeof recordValue === "string") { recordValue = recordValue.toLowerCase(); } @@ -654,14 +676,28 @@ async function fetchRecordsByPrimaryKeys(db, table, primaryKeys, compoundKeys, b : batch.map(pk => Array.isArray(pk) ? pk[0] : pk); }; + const keyForRecord = record => isCompoundKey + ? compoundKeys.map(key => record[key]) + : record[compoundKeys[0]]; + + const requestedOrder = new Map( + normalizeBatch(primaryKeys).map((key, index) => [JSON.stringify(key), index]) + ); + + const restoreRequestedOrder = records => records.sort((left, right) => + requestedOrder.get(JSON.stringify(keyForRecord(left))) - + requestedOrder.get(JSON.stringify(keyForRecord(right))) + ); + // **Tier 1: Small datasets (< 1500) Single Fetch** if (primaryKeys.length < 1500) { - return await db.transaction('r', table, async () => { + const records = await db.transaction('r', table, async () => { let formattedBatch = normalizeBatch(primaryKeys); return table.where(isCompoundKey ? compoundKeys : compoundKeys[0]) .anyOf(formattedBatch) .toArray(); }); + return restoreRequestedOrder(records); } // **Tier 2: Medium Datasets (< Large Threshold) Fire All Batches In Parallel** @@ -679,11 +715,11 @@ async function fetchRecordsByPrimaryKeys(db, table, primaryKeys, compoundKeys, b } }); let batchResults = await Promise.all(batchPromises); - return batchResults.flat(); + return restoreRequestedOrder(batchResults.flat()); } // **Tier 3: Massive Datasets - Controlled Concurrency, Shrinking `anyOf()` for faster lookups** - return await db.transaction('r', table, async () => { + const records = await db.transaction('r', table, async () => { let remainingKeys = [...primaryKeys]; let foundKeys = new Set(); let results = []; @@ -748,6 +784,7 @@ async function fetchRecordsByPrimaryKeys(db, table, primaryKeys, compoundKeys, b await Promise.all(activePromises); return results; }); + return restoreRequestedOrder(records); } @@ -770,8 +807,6 @@ function applyCursorQueryAdditions( }); let additions = [...queryAdditions]; // Avoid modifying original - let needsReverse = false; - // Step 0: Always apply detectedIndexOrderProperties first if (detectedIndexOrderProperties?.length > 0) { primaryKeyList.sort((a, b) => { @@ -829,7 +864,6 @@ function applyCursorQueryAdditions( break; case QUERY_ADDITIONS.TAKE_LAST: - needsReverse = true; primaryKeyList = primaryKeyList.slice(-addition.intValue); break; @@ -849,10 +883,6 @@ function applyCursorQueryAdditions( } } - if (needsReverse) { - primaryKeyList.reverse(); - } - debugLog("Final Ordered Primary Key List", primaryKeyList); return primaryKeyList.map(item => diff --git a/Magic.IndexedDb/wwwroot/utilities/partitionLinqQueries.js b/Magic.IndexedDb/wwwroot/utilities/partitionLinqQueries.js index a88cd3a..77b7274 100644 --- a/Magic.IndexedDb/wwwroot/utilities/partitionLinqQueries.js +++ b/Magic.IndexedDb/wwwroot/utilities/partitionLinqQueries.js @@ -145,4 +145,4 @@ function detectCompoundQuery(andConditions, indexCache) { debugLog("No matching compound index found"); return null; -} \ No newline at end of file +} diff --git a/README.md b/README.md index bf2890b..f5f96d1 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,8 @@ See [schema evolution and migrations](https://github.com/magiccodingman/Magic.In Issues and pull requests are welcome. Changes to expression translation, serialization, schema handling, or the JavaScript query engine should include focused unit tests and browser end-to-end coverage where applicable. +See [testing and continuous integration](https://github.com/magiccodingman/Magic.IndexedDb/blob/master/docs/contributing/testing.md) for local commands, coverage expectations, and required CI checks. + ## 🏆 Contributors Hall of Fame 🏆 Thank you to all contributors, whether large or small! This section is for the people who have put significant work, care, and energy into the project. diff --git a/TestBase/Helpers/TestHelper.cs b/TestBase/Helpers/TestHelper.cs index 8c1638c..a1cd8af 100644 --- a/TestBase/Helpers/TestHelper.cs +++ b/TestBase/Helpers/TestHelper.cs @@ -9,7 +9,10 @@ namespace TestBase.Helpers; public static class TestValidator { - public static TestResponse ValidateLists(IEnumerable correctResults, IEnumerable testResults) + public static TestResponse ValidateLists( + IEnumerable correctResults, + IEnumerable testResults, + bool ordered = false) where T : class { if (correctResults == null || testResults == null) return new TestResponse { Success = false, Message = "Error: One or both input lists are null." }; @@ -32,41 +35,44 @@ public static TestResponse ValidateLists(IEnumerable correctResults, IEnum if (!primaryKeys.Any()) return new TestResponse { Success = false, Message = "Error: No primary keys found for type." }; + var properties = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(prop => !Attribute.IsDefined(prop, typeof(MagicNotMappedAttribute))) + .ToArray(); var failureDetails = new List(); - // 🔍 Convert correct results into a dictionary for fast lookup by **property name** - var correctDictionary = correctList.ToDictionary( - item => primaryKeys.ToDictionary( - pk => pk.Property.DeclaringType.FullName + "." + pk.Property.Name, // **Use fully qualified property name** - pk => pk.Property.GetValue(item) - ), - item => item - ); - - foreach (var actualItem in testList) + for (var index = 0; index < testList.Count; index++) { + var actualItem = testList[index]; // Extract key properties from the actual item var actualKeyValues = primaryKeys .Select(pk => (Property: pk.Property.Name, Value: pk.Property.GetValue(actualItem))) .ToList(); - // Find matching item in correctList using key properties - var expectedItem = correctList.FirstOrDefault(correctItem => - primaryKeys.All(pk => - Equals(pk.Property.GetValue(correctItem), pk.Property.GetValue(actualItem)) - ) - ); + var expectedItem = ordered + ? correctList[index] + : correctList.FirstOrDefault(correctItem => + primaryKeys.All(pk => + Equals(pk.Property.GetValue(correctItem), pk.Property.GetValue(actualItem)) + )); - if (expectedItem == null) + if (expectedItem is null) { failureDetails.Add($"❌ No matching item found for Primary Key [{FormatKey(actualKeyValues)}]."); continue; } - // **Deeply compare object properties** - var properties = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance) - .Where(prop => !Attribute.IsDefined(prop, typeof(MagicNotMappedAttribute))) - .ToArray(); + if (!primaryKeys.All(pk => + Equals(pk.Property.GetValue(expectedItem), pk.Property.GetValue(actualItem)))) + { + var expectedKeyValues = primaryKeys + .Select(pk => (Property: pk.Property.Name, Value: pk.Property.GetValue(expectedItem))) + .ToList(); + var prefix = ordered ? $"Position {index}: " : string.Empty; + failureDetails.Add( + $"❌ {prefix}Expected Primary Key [{FormatKey(expectedKeyValues)}], " + + $"but received [{FormatKey(actualKeyValues)}]."); + continue; + } var propertyDifferences = CompareObjects(expectedItem, actualItem, properties, $"Item[{FormatKey(actualKeyValues)}]"); @@ -81,7 +87,7 @@ public static TestResponse ValidateLists(IEnumerable correctResults, IEnum : new TestResponse { Success = true }; } - private static string FormatKey(List<(string Property, object Value)> keyValues) + private static string FormatKey(List<(string Property, object? Value)> keyValues) { if (keyValues == null || keyValues.Count == 0) return "NULL"; @@ -154,4 +160,4 @@ private static bool IsAnonymousType(Type type) && (type.Name.StartsWith("<>") || type.Name.StartsWith("VB$")) && type.Namespace == null; } -} \ No newline at end of file +} diff --git a/TestBase/Models/CompositeRecord.cs b/TestBase/Models/CompositeRecord.cs new file mode 100644 index 0000000..7095dbe --- /dev/null +++ b/TestBase/Models/CompositeRecord.cs @@ -0,0 +1,29 @@ +using Magic.IndexedDb; +using Magic.IndexedDb.SchemaAnnotations; +using TestBase.Repository; + +namespace TestBase.Models; + +public sealed class CompositeRecord : MagicTableTool, IMagicTable +{ + public string Tenant { get; set; } = string.Empty; + public int Sequence { get; set; } + + [MagicIndex] + public string Category { get; set; } = string.Empty; + + public string Value { get; set; } = string.Empty; + + public IMagicCompoundKey GetKeys() => CreateCompoundKey( + record => record.Tenant, + record => record.Sequence); + + public List GetCompoundIndexes() => + [ + CreateCompoundIndex(record => record.Tenant, record => record.Category) + ]; + + public string GetTableName() => "CompositeRecord"; + public IndexedDbSet GetDefaultDatabase() => IndexDbContext.Client; + public Person.DbSets Databases { get; } = new(); +} diff --git a/docs/README.md b/docs/README.md index 624a14b..fb4350b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -41,6 +41,10 @@ Like LINQ to SQL, LINQ to IndexedDB has provider-specific rules. Reading the ord - [How the query engine works](architecture/query-engine.md) - [Universal predicate language](architecture/universal-predicate-language.md) +## Contributing + +- [Testing and continuous integration](contributing/testing.md) + ## Upgrading and legacy versions - [.NET 10 upgrade notes](upgrading/dotnet-10.md) diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md new file mode 100644 index 0000000..1ada523 --- /dev/null +++ b/docs/contributing/testing.md @@ -0,0 +1,60 @@ +# Testing Magic IndexedDB + +Magic IndexedDB crosses C#, JavaScript interop, query planning, serialization, streaming, IndexedDB, and browser-specific behavior. The test system therefore has two required layers: deterministic .NET contract tests and browser integration tests against the actual Blazor application. + +## Run the .NET tests + +```bash +dotnet test Magic.IndexedDb.UnitTests/Magic.IndexedDb.UnitTests.csproj --configuration Release +``` + +These tests cover expression translation, schema generation and validation, serialization boundaries, chunked-stream bookkeeping, result validation, and a snapshot of the public .NET API. The API snapshot catches accidental additions, removals, and signature changes. + +If a public API change is deliberate, review the complete diff first and then regenerate the snapshot explicitly: + +```bash +UPDATE_PUBLIC_API_BASELINE=1 dotnet test Magic.IndexedDb.UnitTests/Magic.IndexedDb.UnitTests.csproj --configuration Release --filter PublicApiMatchesApprovedBaseline +``` + +Commit the reviewed `Magic.IndexedDb.UnitTests/PublicApiBaseline.txt` change with the implementation. + +## Run the browser tests + +Build the tests before installing Playwright so its generated installer is available: + +```bash +dotnet build E2eTest/E2eTest.csproj --configuration Release +pwsh E2eTest/bin/Release/net10.0/playwright.ps1 install --with-deps chromium firefox webkit +``` + +Run one browser at a time: + +```bash +dotnet test E2eTest/E2eTest.csproj --configuration Release --no-build -- Playwright.BrowserName=chromium Playwright.LaunchOptions.Headless=true +dotnet test E2eTest/E2eTest.csproj --configuration Release --no-build -- Playwright.BrowserName=firefox Playwright.LaunchOptions.Headless=true +dotnet test E2eTest/E2eTest.csproj --configuration Release --no-build -- Playwright.BrowserName=webkit Playwright.LaunchOptions.Headless=true +``` + +The suite validates CRUD and range operations, database lifecycle and isolation, query expressions, pagination and exact materialized ordering, compound keys, enum and dictionary serialization, constrained multi-chunk streaming, cancellation, concurrent streams, and quota access. + +## Continuous integration + +Every pull request runs: + +- `Core validation` +- `Chrome integration` +- `Firefox integration` +- `Linux WebKit integration` +- `macOS WebKit integration` + +Pushes to `master` run the same workflows again. A push to `release` starts `publish-nuget.yml`, which calls both validation workflows and does not build or publish the NuGet package until every validation job succeeds. This makes the release run a final independent gate even when the same commit passed on `master`. + +The macOS WebKit job is valuable coverage for Apple's browser engine, but Playwright's WebKit build is not the branded Safari application and is not an iPhone or iPad device. Real Safari and iOS device coverage requires a separate device service or owned Apple test hardware; it should be added when credentials and a stable device-testing provider are available. + +## Test design rules + +- Test public behavior and compatibility contracts, not incidental implementation details. +- Compare ordered results positionally whenever ordering or pagination is under test. +- Keep unit tests deterministic and use browser tests for behavior that depends on JavaScript, IndexedDB, Blazor interop, or streaming. +- Add a regression test before or with every bug fix. +- Treat a public API snapshot update as an intentional compatibility decision, never routine test maintenance.