diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml new file mode 100644 index 0000000..a5e088d --- /dev/null +++ b/.github/workflows/publish-nuget.yml @@ -0,0 +1,109 @@ +name: Publish NuGet + +on: + push: + branches: + - release + +concurrency: + group: publish-nuget-release + cancel-in-progress: false + +permissions: + contents: read + +env: + DOTNET_CLI_TELEMETRY_OPTOUT: true + 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: + contents: read + id-token: write + + steps: + - name: Check out release commit + 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: Calculate package version + id: version + shell: bash + run: | + current_version="$(sed -nE 's:.*([^<]+).*:\1:p' Magic.IndexedDb/Magic.IndexedDb.csproj)" + + if [[ ! "${current_version}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + echo "Expected a stable major.minor.patch Version in Magic.IndexedDb.csproj, found '${current_version}'." >&2 + exit 1 + fi + + major="${BASH_REMATCH[1]}" + minor="${BASH_REMATCH[2]}" + patch="${BASH_REMATCH[3]}" + next_patch="$((10#${patch} + GITHUB_RUN_NUMBER))" + package_version="${major}.${minor}.${next_patch}" + + echo "package_version=${package_version}" >> "${GITHUB_OUTPUT}" + echo "Publishing Magic.IndexedDb ${package_version} from ${GITHUB_SHA}." + + - name: Restore package project + run: dotnet restore Magic.IndexedDb/Magic.IndexedDb.csproj + + - name: Pack + run: >- + dotnet pack Magic.IndexedDb/Magic.IndexedDb.csproj + --configuration Release + --no-restore + --output artifacts + -p:Version=${{ steps.version.outputs.package_version }} + -p:PackageVersion=${{ steps.version.outputs.package_version }} + -p:ContinuousIntegrationBuild=true + + - name: Upload package artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: Magic.IndexedDb-${{ steps.version.outputs.package_version }} + path: artifacts/*.nupkg + if-no-files-found: error + overwrite: true + + - name: Request temporary NuGet API key + id: login + uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1 + with: + user: ${{ secrets.NUGET_USER }} + + - name: Publish to NuGet.org + run: >- + dotnet nuget push artifacts/*.nupkg + --api-key "${{ steps.login.outputs.NUGET_API_KEY }}" + --source https://api.nuget.org/v3/index.json + --skip-duplicate + + - name: Write release summary + run: | + echo "## Published Magic.IndexedDb ${{ steps.version.outputs.package_version }}" >> "${GITHUB_STEP_SUMMARY}" + echo "Source commit: \`${GITHUB_SHA}\`" >> "${GITHUB_STEP_SUMMARY}" 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 03b0650..3b2931e 100644 --- a/E2eTest/SingleRecordBasicTest.cs +++ b/E2eTest/SingleRecordBasicTest.cs @@ -47,4 +47,73 @@ public async Task YieldAllTest() var result = await this.RunTestPageMethodAsync(p => p.YieldAll); Assert.AreEqual("OK", result); } + + [TestMethod] + public async Task DictionaryPropertyRoundTripTest() + { + var result = await this.RunTestPageMethodAsync(p => p.DictionaryPropertyRoundTrip); + Assert.AreEqual("OK", result); + } + + [TestMethod] + public async Task NumericEnumWhereTest() + { + var result = await this.RunTestPageMethodAsync(p => p.NumericEnumWhere); + Assert.AreEqual("OK", result); + } + + [TestMethod] + 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 bac7fb6..eac2250 100644 --- a/E2eTestWebApp/TestPages/SingleRecordBasicTestPage.cs +++ b/E2eTestWebApp/TestPages/SingleRecordBasicTestPage.cs @@ -82,4 +82,298 @@ await db.AddRangeAsync([ return yielded.Count == 3 ? "OK" : "Incorrect"; } + + public async Task DictionaryPropertyRoundTrip() + { + var db = await magic.Query(); + await db.AddAsync(new ContractRecord + { + Name = "Dictionary", + Metadata = new Dictionary + { + ["count"] = 2, + ["enabled"] = false, + ["label"] = "value" + } + }); + + var record = (await db.ToListAsync()).Single(); + return record.Metadata.Count == 3 && + ((JsonElement)record.Metadata["count"]!).GetInt32() == 2 && + !((JsonElement)record.Metadata["enabled"]!).GetBoolean() && + ((JsonElement)record.Metadata["label"]!).GetString() == "value" + ? "OK" + : "Incorrect"; + } + + public async Task NumericEnumWhere() + { + var db = await magic.Query(); + await db.AddRangeAsync([ + new ContractRecord { Name = "Readable", NumericAccess = ContractRecord.NumericStatus.Read }, + new ContractRecord { Name = "Writable", NumericAccess = ContractRecord.NumericStatus.Write } + ]); + + var matches = await db + .Where(record => record.NumericAccess == ContractRecord.NumericStatus.Write) + .ToListAsync(); + + return matches.Count == 1 && matches[0].Name == "Writable" + ? "OK" + : "Incorrect"; + } + + public async Task NamedEnumWhere() + { + var db = await magic.Query(); + await db.AddRangeAsync([ + new ContractRecord { Name = "Inactive", NamedAccess = ContractRecord.NamedStatus.Inactive }, + new ContractRecord { Name = "Active", NamedAccess = ContractRecord.NamedStatus.Active } + ]); + + var matches = await db + .Where(record => record.NamedAccess == ContractRecord.NamedStatus.Active) + .ToListAsync(); + + return matches.Count == 1 && + matches[0].Name == "Active" && + matches[0].NamedAccess == ContractRecord.NamedStatus.Active + ? "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/ExpressionBuilderTests.cs b/Magic.IndexedDb.UnitTests/ExpressionBuilderTests.cs index e3a8bce..ffeadbd 100644 --- a/Magic.IndexedDb.UnitTests/ExpressionBuilderTests.cs +++ b/Magic.IndexedDb.UnitTests/ExpressionBuilderTests.cs @@ -1,20 +1,93 @@ using Magic.IndexedDb.LinqTranslation.Extensions; +using Magic.IndexedDb.Helpers; +using Magic.IndexedDb.Models; +using System.Text.Json; +using System.Text.Json.Serialization; namespace Magic.IndexedDb.UnitTests; [TestClass] public sealed class ExpressionBuilderTests { + [TestMethod] + public void PlainEnumEquality_IsRecognizedAndSerializedNumerically() + { + var node = new UniversalExpressionBuilder( + record => record.Status == RecordStatus.Active).Build(); + + AssertNumericEnumCondition(node); + } + [TestMethod] public void ExplicitEnumConversions_AreRecognizedAsPropertyComparisons() { var node = new UniversalExpressionBuilder( record => (int)record.Status == (int)RecordStatus.Active).Build(); + AssertNumericEnumCondition(node); + } + + [TestMethod] + public void NullableAndReversedEnumEquality_AreRecognized() + { + var nullable = new UniversalExpressionBuilder( + record => record.Status == RecordStatus.Active).Build(); + var reversed = new UniversalExpressionBuilder( + record => RecordStatus.Active == record.Status).Build(); + + Assert.AreEqual(RecordStatus.Active, nullable.Condition!.Value.value); + Assert.AreEqual(RecordStatus.Active, reversed.Condition!.Value.value); + } + + [TestMethod] + public void StringEnumConverter_UsesTheSameRepresentationForRecordsAndFilters() + { + var settings = new MagicJsonSerializationSettings { UseCamelCase = true }; + var node = new UniversalExpressionBuilder( + record => record.Status == NamedStatus.Active).Build(); + + var recordJson = MagicSerializationHelper.SerializeObject( + new NamedEnumRecord { Status = NamedStatus.Active }, settings); + var filterJson = MagicSerializationHelper.SerializeObject(node, settings); + + Assert.AreEqual("Active", JsonDocument.Parse(recordJson).RootElement + .GetProperty("status").GetString()); + Assert.AreEqual("Active", JsonDocument.Parse(filterJson).RootElement + .GetProperty("condition").GetProperty("value").GetString()); + } + + [TestMethod] + public void StringBackedEnums_RejectRangeComparisons() + { + var exception = Assert.ThrowsExactly(() => + new UniversalExpressionBuilder( + record => (int)record.Status > 0).Build()); + + StringAssert.Contains(exception.Message, "persisted as a JSON string"); + } + + [TestMethod] + public void NonEnumMemberConversions_RemainUnsupported() + { + var exception = Assert.ThrowsExactly(() => + new UniversalExpressionBuilder( + record => (int)record.Amount == 1).Build()); + + StringAssert.Contains(exception.Message, "Unsupported binary expression"); + } + + private static void AssertNumericEnumCondition( + Magic.IndexedDb.LinqTranslation.Models.FilterNode node) + { Assert.IsTrue(node.Condition.HasValue); Assert.AreEqual(nameof(EnumRecord.Status), node.Condition.Value.property); Assert.AreEqual("Equal", node.Condition.Value.operation); - Assert.AreEqual(1, node.Condition.Value.value); + Assert.AreEqual(RecordStatus.Active, node.Condition.Value.value); + + var settings = new MagicJsonSerializationSettings { UseCamelCase = true }; + var json = MagicSerializationHelper.SerializeObject(node, settings); + Assert.AreEqual(1, JsonDocument.Parse(json).RootElement + .GetProperty("condition").GetProperty("value").GetInt32()); } private sealed class EnumRecord @@ -22,9 +95,31 @@ private sealed class EnumRecord public RecordStatus Status { get; set; } } + private sealed class NullableEnumRecord + { + public RecordStatus? Status { get; set; } + } + + private sealed class NamedEnumRecord + { + public NamedStatus Status { get; set; } + } + + private sealed class NumericRecord + { + public decimal Amount { get; set; } + } + private enum RecordStatus { Inactive = 0, Active = 1 } + + [JsonConverter(typeof(JsonStringEnumConverter))] + private enum NamedStatus + { + Inactive = 0, + Active = 1 + } } 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 6b77518..a733a78 100644 --- a/Magic.IndexedDb.UnitTests/SerializationContractTests.cs +++ b/Magic.IndexedDb.UnitTests/SerializationContractTests.cs @@ -88,6 +88,73 @@ public void Dictionaries_RoundTripKeysAndValues() Assert.IsFalse(((JsonElement)actual["enabled"]!).GetBoolean()); } + [TestMethod] + public void DictionaryProperties_RoundTripInsideEntityCollections() + { + DictionaryContainer[] expected = + [ + new() + { + Id = 7, + Metadata = new Dictionary + { + ["count"] = 2, + ["enabled"] = false, + ["label"] = "value", + ["missing"] = null + } + } + ]; + + var json = MagicSerializationHelper.SerializeObject>(expected); + var actual = MagicSerializationHelper + .DeserializeObject>(json)? + .Single(); + + Assert.IsNotNull(actual); + Assert.AreEqual(7, actual.Id); + Assert.AreEqual(4, actual.Metadata.Count); + Assert.AreEqual(2, ((JsonElement)actual.Metadata["count"]!).GetInt32()); + Assert.IsFalse(((JsonElement)actual.Metadata["enabled"]!).GetBoolean()); + Assert.AreEqual("value", ((JsonElement)actual.Metadata["label"]!).GetString()); + Assert.IsNull(actual.Metadata["missing"]); + } + + [TestMethod] + public void Dictionaries_RoundTripInsideNestedCollections() + { + var expected = new DictionaryCollections + { + Values = + [ + new Dictionary { ["one"] = 1 }, + new Dictionary { ["two"] = 2 } + ] + }; + + var json = MagicSerializationHelper.SerializeObject(expected); + var actual = MagicSerializationHelper.DeserializeObject(json); + + Assert.IsNotNull(actual); + Assert.AreEqual(1, actual.Values[0]["one"]); + Assert.AreEqual(2, actual.Values[1]["two"]); + } + + [TestMethod] + public void ReadOnlyDictionaryProperties_RoundTripAsJsonObjects() + { + var expected = new ReadOnlyDictionaryContainer + { + Values = new Dictionary { ["answer"] = 42 } + }; + + var json = MagicSerializationHelper.SerializeObject(expected); + var actual = MagicSerializationHelper.DeserializeObject(json); + + Assert.IsNotNull(actual); + Assert.AreEqual(42, actual.Values["answer"]); + } + [TestMethod] public void ConfiguredEnumConverter_IsHonoredInBothDirections() { @@ -118,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; @@ -149,11 +259,44 @@ private sealed class EnumValue public LargeStatus Status { get; set; } } + private sealed class DictionaryContainer + { + public int Id { get; set; } + public Dictionary Metadata { get; set; } = []; + } + + private sealed class DictionaryCollections + { + public List> Values { get; set; } = []; + } + + private sealed class ReadOnlyDictionaryContainer + { + public IReadOnlyDictionary Values { get; set; } = + new Dictionary(); + } + 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/LinqTranslation/Extensions/UniversalExpressionBuilder.cs b/Magic.IndexedDb/LinqTranslation/Extensions/UniversalExpressionBuilder.cs index 8fc5da2..a9850d0 100644 --- a/Magic.IndexedDb/LinqTranslation/Extensions/UniversalExpressionBuilder.cs +++ b/Magic.IndexedDb/LinqTranslation/Extensions/UniversalExpressionBuilder.cs @@ -3,12 +3,15 @@ using Magic.IndexedDb.Models; using Magic.IndexedDb.Models.UniversalOperations; using System.Collections; +using System.Collections.Concurrent; using System.Linq.Expressions; +using System.Text.Json; namespace Magic.IndexedDb.LinqTranslation.Extensions; public class UniversalExpressionBuilder { + private static readonly ConcurrentDictionary StringBackedEnumCache = new(); private readonly Expression> _predicate; public UniversalExpressionBuilder(Expression> predicate) @@ -260,14 +263,13 @@ private FilterNode BuildComparisonLeaf(BinaryExpression bin, string? forceOperat return specialNode; } - var leftExpression = UnwrapConvert(bin.Left); - var rightExpression = UnwrapConvert(bin.Right); + var left = GetComparableParameterMember(bin.Left); + var right = GetComparableParameterMember(bin.Right); - if (IsParameterMember(leftExpression) && !IsParameterMember(rightExpression)) + if (left != null && right == null) { - var left = leftExpression as MemberExpression; - var right = ToConst(rightExpression); - var cond = BuildConditionFromMemberAndConstant(left, right, operation); + var constant = ToConst(bin.Right); + var cond = BuildConditionFromMemberAndConstant(left, constant, operation); return new FilterNode { @@ -275,12 +277,11 @@ private FilterNode BuildComparisonLeaf(BinaryExpression bin, string? forceOperat Condition = cond }; } - else if (!IsParameterMember(leftExpression) && IsParameterMember(rightExpression)) + else if (left == null && right != null) { operation = InvertBinary(operation); - var left = rightExpression as MemberExpression; - var right = ToConst(leftExpression); - var cond = BuildConditionFromMemberAndConstant(left, right, operation); + var constant = ToConst(bin.Left); + var cond = BuildConditionFromMemberAndConstant(right, constant, operation); return new FilterNode { @@ -686,7 +687,7 @@ private FilterCondition BuildConditionFromMemberAndConstant( // If you absolutely need a JSON representation, do: // object? val = constExpr.Value != null ? JsonValue.Create(constExpr.Value) : null; // Otherwise, you can just store the raw object in FilterCondition.value: - object? val = constExpr.Value; + object? val = NormalizeEnumComparisonValue(propInfo, constExpr.Value, operation); // e.g. "name", "age" string universalProp = PropertyMappingCache.GetJsPropertyName(propInfo); @@ -703,6 +704,35 @@ private FilterCondition BuildConditionFromMemberAndConstant( ); } + private static object? NormalizeEnumComparisonValue( + System.Reflection.PropertyInfo property, + object? value, + string operation) + { + if (value == null) + return null; + + Type enumType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType; + if (!enumType.IsEnum) + return value; + + object enumValue = value.GetType() == enumType + ? value + : Enum.ToObject(enumType, value); + + bool isStringBacked = StringBackedEnumCache.GetOrAdd(enumType, _ => + JsonSerializer.SerializeToElement(enumValue, enumType).ValueKind == JsonValueKind.String); + if (isStringBacked && + operation is not "Equal" and not "NotEqual") + { + throw new InvalidOperationException( + $"Enum property '{property.Name}' is persisted as a JSON string. " + + "Only equality and inequality comparisons are supported for string-backed enums."); + } + + return enumValue; + } + private static bool SupportedMethodNameForNegation(string name) => name is "Contains" or "StartsWith" or "EndsWith" or "Equals"; @@ -759,6 +789,38 @@ private static bool IsParameterMember(Expression expr) member.Expression is ParameterExpression; } + private static MemberExpression? GetComparableParameterMember(Expression expression) + { + if (expression is MemberExpression directMember && IsParameterMember(directMember)) + return directMember; + + if (expression is not UnaryExpression + { + NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked, + Operand: MemberExpression convertedMember + } conversion || + !IsParameterMember(convertedMember)) + { + return null; + } + + Type memberType = Nullable.GetUnderlyingType(convertedMember.Type) ?? convertedMember.Type; + Type conversionType = Nullable.GetUnderlyingType(conversion.Type) ?? conversion.Type; + + return memberType.IsEnum && IsIntegralType(conversionType) + ? convertedMember + : null; + } + + private static bool IsIntegralType(Type type) + { + return Type.GetTypeCode(type) is + TypeCode.SByte or TypeCode.Byte or + TypeCode.Int16 or TypeCode.UInt16 or + TypeCode.Int32 or TypeCode.UInt32 or + TypeCode.Int64 or TypeCode.UInt64; + } + private static ConstantExpression ToConst(Expression expr) { expr = StripConvert(expr); // <-- handle Convert wrappers diff --git a/Magic.IndexedDb/Models/MagicContractResolver.cs b/Magic.IndexedDb/Models/MagicContractResolver.cs index d82372c..c31150b 100644 --- a/Magic.IndexedDb/Models/MagicContractResolver.cs +++ b/Magic.IndexedDb/Models/MagicContractResolver.cs @@ -210,23 +210,27 @@ private bool IsSimpleJsonNull(JsonElement element) /// Reads and assigns a property value, detecting collections, simple types, and complex objects. /// private object? ReadPropertyValue(ref Utf8JsonReader reader, MagicPropertyEntry mpe, JsonSerializerOptions options) + { + return ReadValue(ref reader, mpe.Property.PropertyType, options); + } + + private object? ReadValue(ref Utf8JsonReader reader, Type type, JsonSerializerOptions options) { if (reader.TokenType == JsonTokenType.Null) return null; - Type propertyType = mpe.Property.PropertyType; + // A dictionary is both a JSON object and an IEnumerable. Its object shape + // must win before the collection path at every nesting level. + if (IsDictionaryType(type)) + return DeserializePassthrough(ref reader, type, options); - if (typeof(IEnumerable).IsAssignableFrom(propertyType) && propertyType != typeof(string)) - { - return ReadIEnumerable(ref reader, propertyType, options); - } + if (typeof(IEnumerable).IsAssignableFrom(type) && type != typeof(string)) + return ReadIEnumerable(ref reader, type, options); - if (mpe.IsComplexType) - { - return ReadComplexObject(ref reader, propertyType, options); - } + if (PropertyMappingCache.IsComplexType(type)) + return ReadComplexObject(ref reader, type, options); - return ReadSimpleType(ref reader, propertyType, options); + return ReadSimpleType(ref reader, type, options); } /// @@ -264,23 +268,7 @@ private bool IsSimpleJsonNull(JsonElement element) if (reader.TokenType == JsonTokenType.EndArray) break; - object? item; - - if (typeof(IEnumerable).IsAssignableFrom(itemType) && itemType != typeof(string)) - { - item = ReadIEnumerable(ref reader, itemType, options); - } - // 🔥 If it's a complex type, we need to deserialize it recursively - else if (PropertyMappingCache.IsComplexType(itemType)) - { - item = ReadComplexObject(ref reader, itemType, options); - } - else - { - item = ReadSimpleType(ref reader, itemType, options); - } - - list.Add(item); + list.Add(ReadValue(ref reader, itemType, options)); } // Convert to array if original type was an array @@ -516,9 +504,19 @@ private JsonSerializerOptions GetPassthroughOptions(Type type, JsonSerializerOpt private static bool IsDictionaryType(Type type) { + if (typeof(IDictionary).IsAssignableFrom(type)) + return true; + return type.GetInterfaces() .Prepend(type) - .Any(candidate => candidate.IsGenericType && - candidate.GetGenericTypeDefinition() == typeof(IDictionary<,>)); + .Any(candidate => + { + if (!candidate.IsGenericType) + return false; + + Type definition = candidate.GetGenericTypeDefinition(); + return definition == typeof(IDictionary<,>) || + definition == typeof(IReadOnlyDictionary<,>); + }); } } 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 c4d230f..f5f96d1 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,20 @@ # Magic IndexedDB -Magic IndexedDB is a LINQ-to-IndexedDB query engine and typed browser database library for Blazor. It lets .NET applications query IndexedDB with C# expression trees while preserving the performance characteristics of a browser-native database. +[![NuGet version](https://img.shields.io/nuget/v/Magic.IndexedDb.svg?logo=nuget&label=NuGet)](https://www.nuget.org/packages/Magic.IndexedDb/) +[![NuGet downloads](https://img.shields.io/nuget/dt/Magic.IndexedDb.svg?logo=nuget&label=downloads)](https://www.nuget.org/packages/Magic.IndexedDb/) + +Magic IndexedDB is a C#-first LINQ-to-IndexedDB query engine and typed browser database library for Blazor. It lets .NET applications query IndexedDB with C# expression trees while preserving the performance characteristics of a browser-native database. Instead of treating LINQ as an in-memory filter over an already-loaded collection, Magic IndexedDB translates supported predicates into an IndexedDB-aware query plan. It uses single-field and compound indexes where possible, partitions complex AND/OR expressions, and uses an optimized cursor engine for operations that IndexedDB cannot execute through an index. -[Documentation](docs/README.md) · [NuGet](https://www.nuget.org/packages/Magic.IndexedDb/) · [.NET 10 upgrade notes](docs/upgrading/dotnet-10.md) · [Issues](https://github.com/magiccodingman/Magic.IndexedDb/issues) +Beneath the current C# API is a language-neutral predicate and schema model. The C# wrapper is the first implementation, but the translation boundary is designed so other languages and frameworks can build wrappers that target the same browser query planner instead of recreating its indexing, cursor, and optimization logic. + +[Documentation](https://github.com/magiccodingman/Magic.IndexedDb/blob/master/docs/README.md) · [NuGet](https://www.nuget.org/packages/Magic.IndexedDb/) · [.NET 10 upgrade notes](https://github.com/magiccodingman/Magic.IndexedDb/blob/master/docs/upgrading/dotnet-10.md) · [Issues](https://github.com/magiccodingman/Magic.IndexedDb/issues) ## Why use Magic IndexedDB? - **Write browser database queries in C#.** Use strongly typed predicates instead of maintaining a separate JavaScript data-access layer. +- **Build on a universal query model.** Additional language wrappers can translate their native query intent into the same predicate tree and browser execution engine. - **Keep filtering close to the data.** Compatible equality, range, membership, ordering, and compound-key operations are planned around IndexedDB indexes. - **Express real application logic.** Nested `&&` and `||` predicates are translated, partitioned, optimized, and de-duplicated by primary key. - **Choose the execution strategy deliberately.** `Where(...)` preserves opportunities for index optimization; `Cursor(...)` explicitly selects cursor evaluation when a scan is appropriate. @@ -18,6 +24,18 @@ Instead of treating LINQ as an in-memory filter over an already-loaded collectio Magic IndexedDB is a strong fit for offline-first Blazor applications, progressive web apps, local browser caches, disconnected workflows, and client-side datasets that need more than simple key/value access. +## C# first, universal by design + +Magic IndexedDB deliberately separates the language-facing wrapper from the engine that plans and executes browser queries: + +1. A language wrapper translates native query expressions and schema definitions. +2. The universal layer represents predicates, logical groups, operations, query additions, and persisted schema names in a language-neutral form. +3. The browser engine partitions and optimizes that intent across primary keys, indexes, compound indexes, and cursor execution. + +Today, the supported public wrapper is the C# and Blazor API. A future TypeScript, JavaScript, Python, or other language wrapper could produce the same universal intent and reuse the same IndexedDB engine rather than starting over. Building a wrapper still requires semantic translation, schema mapping, validation, and transport compatibility; the internal JavaScript protocol is not yet presented as an independently versioned public SDK. + +See the [universal predicate language](https://github.com/magiccodingman/Magic.IndexedDb/blob/master/docs/architecture/universal-predicate-language.md) and [query engine architecture](https://github.com/magiccodingman/Magic.IndexedDb/blob/master/docs/architecture/query-engine.md) for the wrapper contract and execution model. + ## How it works 1. The Blazor wrapper reads a supported C# expression tree. @@ -31,7 +49,7 @@ This provides a LINQ-oriented programming model without pretending IndexedDB is ## Requirements -Magic IndexedDB remains on its version 2 release line. The current codebase targets .NET 10; applications that must remain on .NET 8 should use an earlier compatible package release. +The current codebase targets .NET 10. The current package supports Blazor WebAssembly and Blazor applications using JavaScript interop over SignalR. Browser storage behavior and quota remain controlled by the user's browser. @@ -100,7 +118,7 @@ await foreach (Person person in people } ``` -Continue with [installation and configuration](docs/getting-started/installation.md), [schema setup](docs/getting-started/schema.md), and the [first complete workflow](docs/getting-started/first-application.md). +Continue with [installation and configuration](https://github.com/magiccodingman/Magic.IndexedDb/blob/master/docs/getting-started/installation.md), [schema setup](https://github.com/magiccodingman/Magic.IndexedDb/blob/master/docs/getting-started/schema.md), and the [first complete workflow](https://github.com/magiccodingman/Magic.IndexedDb/blob/master/docs/getting-started/first-application.md). ## Query behavior worth knowing @@ -111,40 +129,43 @@ Continue with [installation and configuration](docs/getting-started/installation - `AsAsyncEnumerable()` prioritizes progressive delivery and does not promise final arrival order across query branches. - `CountAsync()` on the root query counts the whole table; it is not currently a filtered-count operator. -The [`Where` versus `Cursor`](docs/guides/where-vs-cursor.md) and [ordering and pagination](docs/guides/ordering-and-pagination.md) guides explain these contracts in detail. +The [`Where` versus `Cursor`](https://github.com/magiccodingman/Magic.IndexedDb/blob/master/docs/guides/where-vs-cursor.md) and [ordering and pagination](https://github.com/magiccodingman/Magic.IndexedDb/blob/master/docs/guides/ordering-and-pagination.md) guides explain these contracts in detail. ## Documentation -The maintained documentation lives entirely in [`docs/`](docs/README.md): +The maintained documentation lives entirely in [`docs/`](https://github.com/magiccodingman/Magic.IndexedDb/blob/master/docs/README.md): -- [Installation](docs/getting-started/installation.md) -- [Schema setup](docs/getting-started/schema.md) -- [First application workflow](docs/getting-started/first-application.md) -- [Querying guide](docs/guides/querying.md) -- [`Where` versus `Cursor`](docs/guides/where-vs-cursor.md) -- [Ordering and pagination](docs/guides/ordering-and-pagination.md) -- [Streaming results](docs/guides/streaming.md) -- [Database management](docs/guides/database-management.md) -- [Schema evolution](docs/guides/schema-evolution.md) -- [Public API reference](docs/reference/public-api.md) -- [Query expression reference](docs/reference/query-expressions.md) -- [Query engine architecture](docs/architecture/query-engine.md) +- [Installation](https://github.com/magiccodingman/Magic.IndexedDb/blob/master/docs/getting-started/installation.md) +- [Schema setup](https://github.com/magiccodingman/Magic.IndexedDb/blob/master/docs/getting-started/schema.md) +- [First application workflow](https://github.com/magiccodingman/Magic.IndexedDb/blob/master/docs/getting-started/first-application.md) +- [Querying guide](https://github.com/magiccodingman/Magic.IndexedDb/blob/master/docs/guides/querying.md) +- [`Where` versus `Cursor`](https://github.com/magiccodingman/Magic.IndexedDb/blob/master/docs/guides/where-vs-cursor.md) +- [Ordering and pagination](https://github.com/magiccodingman/Magic.IndexedDb/blob/master/docs/guides/ordering-and-pagination.md) +- [Streaming results](https://github.com/magiccodingman/Magic.IndexedDb/blob/master/docs/guides/streaming.md) +- [Database management](https://github.com/magiccodingman/Magic.IndexedDb/blob/master/docs/guides/database-management.md) +- [Schema evolution](https://github.com/magiccodingman/Magic.IndexedDb/blob/master/docs/guides/schema-evolution.md) +- [Public API reference](https://github.com/magiccodingman/Magic.IndexedDb/blob/master/docs/reference/public-api.md) +- [Query expression reference](https://github.com/magiccodingman/Magic.IndexedDb/blob/master/docs/reference/query-expressions.md) +- [Query engine architecture](https://github.com/magiccodingman/Magic.IndexedDb/blob/master/docs/architecture/query-engine.md) -Version 1 documentation remains available in the [legacy archive](MagicIndexDbWiki/Version-1.0-Legacy.md). +Version 1 documentation remains available in the [legacy archive](https://github.com/magiccodingman/Magic.IndexedDb/blob/master/MagicIndexDbWiki/Version-1.0-Legacy.md). ## Schema evolution The automated migration protocol is still under construction. Magic IndexedDB does not automatically migrate existing browser data when a C# model changes. Plan and test persisted-name, index, primary-key, and required-property changes against data produced by the previously released application. -See [schema evolution and migrations](docs/guides/schema-evolution.md) before changing a deployed schema. +See [schema evolution and migrations](https://github.com/magiccodingman/Magic.IndexedDb/blob/master/docs/guides/schema-evolution.md) before changing a deployed schema. ## Contributing 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. -## Contributors Hall of Fame +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. -Thank you to every contributor, including these developers whose sustained work has had a significant impact on the project: +[@yueyinqiu](https://github.com/yueyinqiu) — I built this project in about two weeks in 2023, told nobody about it, then walked away and forgot about it. It was not until 2024 that I realized there were pull requests and tickets from other people. Yue provided significant contributions during that time and worked closely with me as we completed version 1 together. This project might have died without you, my friend, and you made it fun for me to come back and see it through. Together we finished version 1 and laid the foundation for version 2. -- [@yueyinqiu](https://github.com/yueyinqiu) helped complete version 1 and kept the project moving during its earliest maintenance period. -- [@Ard2025](https://github.com/Ard2025) has contributed extensive bug fixes, cleanup, refactoring, and design discussions throughout version 2 and beyond. +[@Ard2025](https://github.com/Ard2025) — Dude, you came out of left field in 2025 and became a powerhouse contributor! I swear you were a pest control exterminator in a past life because you just cannot stop killing bugs. You have also worked closely with me through valuable brainstorming sessions, major cleanup, refactoring, and much more since the version 2 alpha launch. Seriously, thank you—this project thrives because you are here. 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/TestBase/Models/ContractRecord.cs b/TestBase/Models/ContractRecord.cs new file mode 100644 index 0000000..fac3c8c --- /dev/null +++ b/TestBase/Models/ContractRecord.cs @@ -0,0 +1,45 @@ +using System.Text.Json.Serialization; +using Magic.IndexedDb; +using Magic.IndexedDb.SchemaAnnotations; +using TestBase.Repository; + +namespace TestBase.Models; + +public class ContractRecord : MagicTableTool, IMagicTable +{ + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public Dictionary Metadata { get; set; } = []; + + [MagicIndex] + public NumericStatus NumericAccess { get; set; } + + [MagicIndex] + public NamedStatus NamedAccess { get; set; } + + public List GetCompoundIndexes() => []; + + public IMagicCompoundKey GetKeys() => CreatePrimaryKey(x => x.Id, true); + + public string GetTableName() => "ContractRecord"; + + public IndexedDbSet GetDefaultDatabase() => IndexDbContext.Client; + + public Person.DbSets Databases { get; } = new(); + + public enum NumericStatus + { + None = 0, + Read = 1, + Write = 2 + } + + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum NamedStatus + { + Inactive = 0, + Active = 1 + } +} 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. diff --git a/docs/getting-started/schema.md b/docs/getting-started/schema.md index b311ef1..4e40623 100644 --- a/docs/getting-started/schema.md +++ b/docs/getting-started/schema.md @@ -124,7 +124,7 @@ Compound indexes let the optimizer satisfy compatible multi-field predicates wit ## Nested data and collections -Stored models may contain nested objects, arrays, lists, sets, dictionaries, and nested collections. The current release preserves configured JSON converters, escaped strings, Unicode text, `MagicName` mappings inside nested objects, and supported concrete collection shapes when values are materialized. +Stored models may contain nested objects, arrays, lists, sets, dictionaries, and nested collections. Dictionaries remain JSON objects when used as entity properties or collection elements; they are not treated as arrays merely because they implement `IEnumerable`. The current release also preserves escaped strings, Unicode text, `MagicName` mappings inside nested objects, and supported concrete collection shapes when values are materialized. Indexes and primary keys still need to describe values IndexedDB can use as keys. Do not assume an arbitrary nested object is indexable merely because it can be serialized. diff --git a/docs/guides/schema-evolution.md b/docs/guides/schema-evolution.md index 813d622..9bc0d7e 100644 --- a/docs/guides/schema-evolution.md +++ b/docs/guides/schema-evolution.md @@ -14,11 +14,14 @@ Treat these as persisted-data changes: - Adding or removing an index or compound index - Changing a primary key or its auto-increment behavior - Changing the serialized type or meaning of a property +- Changing an enum between numeric and named-string storage - Adding required data that older records do not contain - Changing constructor requirements for materialization Test every such change against a copy of realistic data created by the previously released application. +String-backed enum names avoid ordinal changes when members are reordered, but enabling a string-enum converter does not rewrite existing numeric records. A database containing both `1` and `"Active"` requires an explicit migration strategy; a filter for one representation does not automatically query both. + ## Keep persisted names stable Use `[MagicName]` to decouple a C# property name from its stored name: diff --git a/docs/reference/query-expressions.md b/docs/reference/query-expressions.md index 7bc1e12..9bae6c8 100644 --- a/docs/reference/query-expressions.md +++ b/docs/reference/query-expressions.md @@ -78,7 +78,25 @@ await people.Where(person => (long)person.Access >= (long)Permissions.CanRead).ToListAsync(); ``` -The current release recognizes explicit enum conversions used in comparisons. JSON enum converters configured through Magic's serialization settings are also honored during serialization. +Enums are stored and queried as their numeric values by default. The translator recognizes the compiler conversions used by ordinary enum equality and explicit integral casts without treating unrelated property casts as equivalent queries. + +To persist names instead, place a `System.Text.Json` string-enum converter on the enum type: + +```csharp +using System.Text.Json.Serialization; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum Permissions +{ + None, + CanRead, + CanWrite +} +``` + +Magic then uses the same string representation for stored records and translated equality filters. String-backed enum queries support equality and inequality; numeric range casts are rejected because their ordering does not describe the persisted strings. + +Changing an existing property from numeric enum storage to named storage changes its persisted representation. Existing numeric records are not automatically rewritten, so plan a migration or rebuild disposable data before switching. ## Logical composition diff --git a/docs/upgrading/dotnet-10.md b/docs/upgrading/dotnet-10.md index e48a65b..e78acf5 100644 --- a/docs/upgrading/dotnet-10.md +++ b/docs/upgrading/dotnet-10.md @@ -50,8 +50,8 @@ Read [schema attributes and constructors](../reference/schema-attributes.md) for ## Serialization and streaming corrections - Backslashes, quotes, newlines, tabs, control characters, and Unicode strings remain valid JSON and round-trip unchanged. -- Nested collections, arrays, `HashSet`, and dictionaries restore supported requested collection shapes. -- Configured `System.Text.Json` converters, including enum converters and enums wider than `Int32`, are honored. +- Nested collections, arrays, `HashSet`, and dictionaries restore supported requested collection shapes, including dictionaries nested inside entities and collections. +- Enum-type `System.Text.Json` string converters are honored consistently by stored records and equality filters; numeric enum storage remains the default. - JavaScript arguments use a versioned raw-JSON envelope internally. JavaScript retains the earlier envelope reader; consumer call syntax does not change. - `0`, `false`, an empty string, and `null` are returned as their real values rather than being replaced with an empty object. - `AsAsyncEnumerable()` drains chunks while JavaScript is producing them. JavaScript failures propagate to .NET, and interop stream/reference objects are disposed. @@ -71,6 +71,7 @@ builder.Services.AddMagicBlazorDB( - Assembly scanning tolerates partially loadable assemblies by using the types that did load. - Explicit enum conversions in comparison expressions are recognized. +- Unrelated member conversions remain unsupported rather than being translated with changed semantics. - Closing all cached connections internally closes the actual Dexie instances. - Multi-database creation passes each complete store definition to database creation. - The bundled Dexie source map is valid BOM-free JSON. @@ -83,6 +84,6 @@ Materialized queries apply their requested ordering. Progressive `AsAsyncEnumera ## Verification -The repository includes a .NET 10 unit-test project covering constructor precedence, immutable and hybrid models, public serialization API preservation, escaped strings, dictionaries, nested collections, collection shapes, configured enum converters, explicit enum query casts, and the earlier JavaScript envelope. +The repository includes a .NET 10 unit-test project covering constructor precedence, immutable and hybrid models, public serialization API preservation, escaped strings, entity and collection dictionaries, nested collections, collection shapes, numeric and named enums, safe enum query casts, and the earlier JavaScript envelope. -Browser end-to-end coverage also exercises escaped and nested records, falsey zero counts, and yield streaming. +Browser end-to-end coverage also exercises escaped and nested records, dictionary properties, numeric and named enum filters, falsey zero counts, and yield streaming.