diff --git a/.editorconfig b/.editorconfig index 59e13c5..b107b2a 100644 --- a/.editorconfig +++ b/.editorconfig @@ -24,5 +24,17 @@ indent_size = 2 [*.json] indent_size = 2 +# C# settings [*.cs] -csharp_style_namespace_declarations = file_scoped:warning +csharp_style_namespace_declarations = file_scoped:error +csharp_new_line_before_open_brace = types,methods,properties,anonymous_methods,control_blocks,anonymous_types,object_collection_array_initializers,lambdas + +# Verify settings +[*.{received,verified}.{json,txt,xml}] +charset = "utf-8-bom" +end_of_line = lf +indent_size = unset +indent_style = unset +insert_final_newline = false +tab_width = unset +trim_trailing_whitespace = false \ No newline at end of file diff --git a/.gitattributes b/.gitattributes index b1060b7..ec10f3b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,66 +1,3 @@ -# Auto detect text files and perform LF normalization -* text=auto - -*.cs text diff=csharp -*.cshtml text diff=html -*.csx text diff=csharp -*.sln text eol=crlf -*.csproj text eol=crlf - -############################################################################### -# Set default behavior to automatically normalize line endings. -############################################################################### -* text=auto - -############################################################################### -# Set the merge driver for project and solution files -# -# Merging from the command prompt will add diff markers to the files if there -# are conflicts (Merging from VS is not affected by the settings below, in VS -# the diff markers are never inserted). Diff markers may cause the following -# file extensions to fail to load in VS. An alternative would be to treat -# these files as binary and thus will always conflict and require user -# intervention with every merge. To do so, just comment the entries below and -# uncomment the group further below -############################################################################### - -*.sln text eol=crlf -*.csproj text eol=crlf -*.vbproj text eol=crlf -*.vcxproj text eol=crlf -*.vcproj text eol=crlf -*.dbproj text eol=crlf -*.fsproj text eol=crlf -*.lsproj text eol=crlf -*.wixproj text eol=crlf -*.modelproj text eol=crlf -*.sqlproj text eol=crlf -*.wwaproj text eol=crlf - -*.xproj text eol=crlf -*.props text eol=crlf -*.filters text eol=crlf -*.vcxitems text eol=crlf - - -#*.sln merge=binary -#*.csproj merge=binary -#*.vbproj merge=binary -#*.vcxproj merge=binary -#*.vcproj merge=binary -#*.dbproj merge=binary -#*.fsproj merge=binary -#*.lsproj merge=binary -#*.wixproj merge=binary -#*.modelproj merge=binary -#*.sqlproj merge=binary -#*.wwaproj merge=binary - -#*.xproj merge=binary -#*.props merge=binary -#*.filters merge=binary -#*.vcxitems merge=binary - # Common settings that generally should always be used with your language specific settings # Auto detect text files and perform LF normalization @@ -144,3 +81,87 @@ .gitattributes export-ignore .gitignore export-ignore .gitkeep export-ignore + +# Auto detect text files and perform LF normalization +* text=auto + +*.cs text diff=csharp +*.cshtml text diff=html +*.csx text diff=csharp +*.sln text eol=crlf +*.csproj text eol=crlf + +# Apply override to all files in the directory +*.md linguist-detectable + +############################################################################### +# Set default behavior to automatically normalize line endings. +############################################################################### +* text=auto + +############################################################################### +# Set the merge driver for project and solution files +# +# Merging from the command prompt will add diff markers to the files if there +# are conflicts (Merging from VS is not affected by the settings below, in VS +# the diff markers are never inserted). Diff markers may cause the following +# file extensions to fail to load in VS. An alternative would be to treat +# these files as binary and thus will always conflict and require user +# intervention with every merge. To do so, just comment the entries below and +# uncomment the group further below +############################################################################### + +*.sln text eol=crlf +*.csproj text eol=crlf +*.vbproj text eol=crlf +*.vcxproj text eol=crlf +*.vcproj text eol=crlf +*.dbproj text eol=crlf +*.fsproj text eol=crlf +*.lsproj text eol=crlf +*.wixproj text eol=crlf +*.modelproj text eol=crlf +*.sqlproj text eol=crlf +*.wwaproj text eol=crlf + +*.xproj text eol=crlf +*.props text eol=crlf +*.filters text eol=crlf +*.vcxitems text eol=crlf + + +#*.sln merge=binary +#*.csproj merge=binary +#*.vbproj merge=binary +#*.vcxproj merge=binary +#*.vcproj merge=binary +#*.dbproj merge=binary +#*.fsproj merge=binary +#*.lsproj merge=binary +#*.wixproj merge=binary +#*.modelproj merge=binary +#*.sqlproj merge=binary +#*.wwaproj merge=binary + +#*.xproj merge=binary +#*.props merge=binary +#*.filters merge=binary +#*.vcxitems merge=binary + +# Basic .gitattributes for a PowerShell repo. + +# Source files +# ============ +*.ps1 text eol=crlf +*.ps1x text eol=crlf +*.psm1 text eol=crlf +*.psd1 text eol=crlf +*.ps1xml text eol=crlf +*.pssc text eol=crlf +*.psrc text eol=crlf +*.cdxml text eol=crlf + +# Verify +*.verified.txt text eol=lf working-tree-encoding=UTF-8 +*.verified.xml text eol=lf working-tree-encoding=UTF-8 +*.verified.json text eol=lf working-tree-encoding=UTF-8 \ No newline at end of file diff --git a/.github/workflows/_.yaml b/.github/workflows/_.yaml new file mode 100644 index 0000000..966da86 --- /dev/null +++ b/.github/workflows/_.yaml @@ -0,0 +1,51 @@ +name: CI/CD +env: { DOTNET_NOLOGO: true } +on: + workflow_call: + inputs: + test: { type: boolean, default: false, description: Run tests. } + publish: { type: boolean, default: false, description: Publish to nuget. Will run all tests too. } + secrets: + nuget-key: +jobs: + pipeline: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Read .NET Version + shell: pwsh + id: dotnet-version + run: | + $version = (Get-Content .\global.json -Raw | ConvertFrom-Json).sdk.version.TrimEnd('0') + 'x' + "version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: { dotnet-version: "${{ steps.dotnet-version.outputs.version }}" } + + - name: Build + working-directory: src + shell: pwsh + run: dotnet build NoSQLite -c Release + + - name: Test + if: inputs.test || inputs.publish + working-directory: test + run: dotnet test --project NoSQLite.Test -c Release + + - name: Pack + if: inputs.publish + working-directory: src + run: dotnet pack NoSQLite --no-restore --no-build + + - name: Push + if: inputs.publish + working-directory: artifacts/src/package/release + env: + SOURCE_URL: https://api.nuget.org/v3/index.json + NUGET_AUTH_TOKEN: ${{ secrets.nuget-key }} + run: dotnet nuget push *.nupkg --skip-duplicate -s ${{ env.SOURCE_URL }} -k ${{ env.NUGET_AUTH_TOKEN }} diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..ace39a3 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,18 @@ +name: build +env: { DOTNET_NOLOGO: true } +on: + pull_request: + branches: + - main + paths: + - src/** + - test/** + types: + - opened + - ready_for_review + - review_requested +jobs: + pipeline: + uses: panoukos41/NoSQLite/.github/workflows/_.yaml@main + with: + test: true \ No newline at end of file diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..9210e25 --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,19 @@ +name: publish +env: { DOTNET_NOLOGO: true } +on: + workflow_dispatch: + push: + branches: + - main + tags: + - v[0-9]+.[0-9]+.[0-9]+ # Only matches vX.X.X where X is a number + paths: + - src/** + - test/** +jobs: + pipeline: + uses: panoukos41/NoSQLite/.github/workflows/_.yaml@main + with: + publish: true + secrets: + nuget-key: ${{ secrets.NUGET_API_KEY }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml deleted file mode 100644 index 357b3ff..0000000 --- a/.github/workflows/release.yaml +++ /dev/null @@ -1,56 +0,0 @@ -name: Release - -env: - DOTNET_NOLOGO: true - -on: - workflow_dispatch: - push: - branches: [main] - paths: - - "src/**/*.cs" - - "src/**/*.csproj" - -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v3 - with: { fetch-depth: 0 } - - - name: Setup .NET - uses: actions/setup-dotnet@v2 - with: { dotnet-version: 7.0.x, include-prerelease: true } - - - name: Setup NerdBank.GitVersioning - uses: dotnet/nbgv@master - id: nbgv - with: { setAllVars: true } - - - name: Build - shell: pwsh - run: ./ci/build.ps1 - - - name: Test - shell: pwsh - run: ./ci/test.ps1 - - - name: Pack - shell: pwsh - run: ./ci/pack.ps1 - - - name: Publish on NuGet - env: - SOURCE_URL: https://api.nuget.org/v3/index.json - NUGET_AUTH_TOKEN: ${{ secrets.NUGET_API_KEY }} - run: | - dotnet nuget push ./nuget/*.nupkg --skip-duplicate -s ${{ env.SOURCE_URL }} -k ${{ env.NUGET_AUTH_TOKEN }} - - - name: Create Github Release - uses: actions/create-release@latest - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: v${{ env.NBGV_SemVer2 }} - release_name: ${{ env.NBGV_SemVer2 }} diff --git a/.gitignore b/.gitignore index d79dc33..2093343 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. ## -## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore # User-specific files *.rsuser @@ -49,6 +49,10 @@ Generated\ Files/ TestResult.xml nunit-*.xml +# Verify +*.received.* +*.received/ + # Build Results of an ATL Project [Dd]ebugPS/ [Rr]eleasePS/ @@ -206,6 +210,9 @@ PublishScripts/ *.nuget.props *.nuget.targets +# Nuget personal access tokens and Credentials +# nuget.config + # Microsoft Azure Build Output csx/ *.build.csdef @@ -294,17 +301,6 @@ node_modules/ # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) *.vbw -# Visual Studio 6 auto-generated project file (contains which files were open etc.) -*.vbp - -# Visual Studio 6 workspace and project file (working project files containing files to include in project) -*.dsw -*.dsp - -# Visual Studio 6 technical files -*.ncb -*.aps - # Visual Studio LightSwitch build output **/*.HTMLClient/GeneratedArtifacts **/*.DesktopClient/GeneratedArtifacts @@ -361,9 +357,6 @@ ASALocalRun/ # Local History for Visual Studio .localhistory/ -# Visual Studio History (VSHistory) files -.vshistory/ - # BeatPulse healthcheck temp database healthchecksdb @@ -395,6 +388,7 @@ FodyWeavers.xsd *.msp # JetBrains Rider +.idea/ *.sln.iml .vscode/* @@ -402,24 +396,10 @@ FodyWeavers.xsd !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json -!.vscode/*.code-snippets +*.code-workspace # Local History for Visual Studio Code .history/ -# Built Visual Studio Code Extensions -*.vsix - -# Igonre db related files eg: sqlite. -*.db -*.db-shm -*.db-wal -*.db3 -*.db3-shm -*.db3-wal -*.sqlite -*.sqlite-shm -*.sqlite-wal -*.sqlite3 -*.sqlite3-shm -*.sqlite3-wal +# Custom +*.min.* \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props index 30bb69b..794ecc0 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,55 +1,55 @@ + - 11 + latest enable true + true Debug - IDE0130;CA1416 + $(NoWarn);CS8509;IDE0039;IDE0130;IDE0290;IDE0060;RZ10012;IDE0052;BL0007;NU5128 + $(WarningsAsErrors);RZ2012; - - $([MSBuild]::GetDirectoryNameOfFileAbove($(MSBuildThisFileDirectory), 'Directory.Build.props')) - $(SourceDir)\artifacts - $(ArtifactsDir)\$(MSBuildProjectName)\$(Configuration) + $([System.IO.Path]::Combine( + $(MSBuildThisFileDirectory), + "artifacts", + $([MSBuild]::MakeRelative($(MSBuildThisFileDirectory),$(MSBuildProjectDirectory)).Replace("$(MSBuildProjectName)", '').TrimEnd('/')) + )) + true + true + false + false + - $(Artifacts)\bin - $(Artifacts)\obj - $(SourceDir)nuget - - $(BaseOutputPath) + + + Panagiotis Athanasiou + Copyright (c) $([System.DateTimeOffset]::UtcNow.ToString("yyyy")) $(Authors) + https://github.com/panoukos41/NoSQLite + git + MIT + A thin wrapper above sqlite to use it as a nosql database. + sqlite + - Panos Athanasiou - Copyright (c) 2022 Panos Athanasiou - MIT - https://github.com/panoukos41/NoSQLite - - A thin wrapper above sqlite to use it as a nosql database. - $(DefaultPackageDescription) - panoukos41 - sqlite - $(PackageProjectUrl) + $(RepositoryUrl) + $(RepositoryLicense) + $(RepositoryDescription) + $(RepositoryTags) $(RepositoryUrl)/releases - git + README.md - true - - true - - - - $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb + true + true + snupkg - - - + + + + - - - - - diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..2225120 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,26 @@ + + + + true + + + + + + + + + + + + + + + + + + + + + + diff --git a/GitVersion.yaml b/GitVersion.yaml new file mode 100644 index 0000000..6e3b19e --- /dev/null +++ b/GitVersion.yaml @@ -0,0 +1,8 @@ +workflow: GitHubFlow/v1 +branches: + main: + label: beta + increment: Patch + feature: + label: alpha + increment: Minor diff --git a/LICENSE.md b/LICENSE.md index 0a60099..d5364a5 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,7 +1,7 @@ MIT License -Copyright (c) 2021 Panos Athanasiou +Copyright (c) 2025 Panagiotis Athanasiou Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/NoSQLite.sln b/NoSQLite.sln deleted file mode 100644 index d390a33..0000000 --- a/NoSQLite.sln +++ /dev/null @@ -1,76 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.4.32912.340 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{1CB9024C-F693-466A-A960-806B871E6DFD}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "root", "root", "{4CB6779A-2488-4EF5-8FB3-1EB90B63A311}" - ProjectSection(SolutionItems) = preProject - .gitattributes = .gitattributes - .gitignore = .gitignore - Directory.Build.props = Directory.Build.props - global.json = global.json - LICENSE.md = LICENSE.md - README.md = README.md - version.json = version.json - EndProjectSection -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NoSQLite", "src\NoSQLite\NoSQLite.csproj", "{4CB52AF4-51A8-45D9-8BFD-F921189063A0}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{C3F14F03-2367-47B9-BF89-4D028C29074A}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConsoleApp", "samples\ConsoleApp\ConsoleApp.csproj", "{053B5452-DBE9-421A-80AC-E415F11CBAEC}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ci", "ci", "{7C0251EF-9751-4494-ADF5-B6E226FFEFBC}" - ProjectSection(SolutionItems) = preProject - ci\build.ps1 = ci\build.ps1 - ci\pack.ps1 = ci\pack.ps1 - ci\test.ps1 = ci\test.ps1 - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{0743F8BC-C025-42F2-943C-08BB38399EDF}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{A450CC69-06DB-4506-B76A-83BF556D959F}" - ProjectSection(SolutionItems) = preProject - .github\workflows\release.yaml = .github\workflows\release.yaml - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{581BD499-7F2B-4255-B8E6-95F442B9C187}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NoSQLite.Test", "test\NoSQLite.Test\NoSQLite.Test.csproj", "{DD8B54C8-499F-4A46-BBDD-8F641940BF85}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {4CB52AF4-51A8-45D9-8BFD-F921189063A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4CB52AF4-51A8-45D9-8BFD-F921189063A0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4CB52AF4-51A8-45D9-8BFD-F921189063A0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4CB52AF4-51A8-45D9-8BFD-F921189063A0}.Release|Any CPU.Build.0 = Release|Any CPU - {053B5452-DBE9-421A-80AC-E415F11CBAEC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {053B5452-DBE9-421A-80AC-E415F11CBAEC}.Debug|Any CPU.Build.0 = Debug|Any CPU - {053B5452-DBE9-421A-80AC-E415F11CBAEC}.Release|Any CPU.ActiveCfg = Release|Any CPU - {053B5452-DBE9-421A-80AC-E415F11CBAEC}.Release|Any CPU.Build.0 = Release|Any CPU - {DD8B54C8-499F-4A46-BBDD-8F641940BF85}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {DD8B54C8-499F-4A46-BBDD-8F641940BF85}.Debug|Any CPU.Build.0 = Debug|Any CPU - {DD8B54C8-499F-4A46-BBDD-8F641940BF85}.Release|Any CPU.ActiveCfg = Release|Any CPU - {DD8B54C8-499F-4A46-BBDD-8F641940BF85}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {4CB52AF4-51A8-45D9-8BFD-F921189063A0} = {1CB9024C-F693-466A-A960-806B871E6DFD} - {053B5452-DBE9-421A-80AC-E415F11CBAEC} = {C3F14F03-2367-47B9-BF89-4D028C29074A} - {7C0251EF-9751-4494-ADF5-B6E226FFEFBC} = {4CB6779A-2488-4EF5-8FB3-1EB90B63A311} - {0743F8BC-C025-42F2-943C-08BB38399EDF} = {4CB6779A-2488-4EF5-8FB3-1EB90B63A311} - {A450CC69-06DB-4506-B76A-83BF556D959F} = {0743F8BC-C025-42F2-943C-08BB38399EDF} - {DD8B54C8-499F-4A46-BBDD-8F641940BF85} = {581BD499-7F2B-4255-B8E6-95F442B9C187} - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {600A3307-F265-4E32-B09A-73C7FC05B2D1} - EndGlobalSection -EndGlobal diff --git a/NoSQLite.slnx b/NoSQLite.slnx new file mode 100644 index 0000000..75ca580 --- /dev/null +++ b/NoSQLite.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/README.md b/README.md index 262e249..a3955ff 100644 --- a/README.md +++ b/README.md @@ -1,81 +1,79 @@ -## NoSQLite: NoSQL on top of SQLite +# NoSQLite -[![Release](https://github.com/panoukos41/NoSQLite/actions/workflows/release.yaml/badge.svg)](https://github.com/panoukos41/NoSQLite/actions/workflows/release.yaml) -[![NuGet](https://buildstats.info/nuget/P41.NoSQLite?includePreReleases=true)](https://www.nuget.org/packages/P41.NoSQLite) -[![MIT License](https://img.shields.io/apm/l/atomic-design-ui.svg?)](https://github.com/panoukos41/NoSQLite/blob/main/LICENSE.md) +[![Build Action](https://github.com/panoukos41/NoSQLite/actions/workflows/build.yaml/badge.svg)](https://github.com/panoukos41/NoSQLite/actions/workflows/build.yaml) +[![Publish Action](https://github.com/panoukos41/NoSQLite/actions/workflows/publish.yaml/badge.svg)](https://github.com/panoukos41/NoSQLite/actions/workflows/publish.yaml) +[![Downloads](https://img.shields.io/nuget/dt/P41.NoSQLite.contracts?style=flat)](https://www.nuget.org/packages/P41.NoSQLite/) -A thin wrapper above sqlite using the [`JSON1`](https://www.sqlite.org/json1.html) apis turn it into a [`NOSQL`](https://en.wikipedia.org/wiki/NoSQL) database. +[![License](https://img.shields.io/github/license/panoukos41/NoSQLite?style=flat)](./LICENSE) +[![.NET 8](https://img.shields.io/badge/.NET%208-%23512bd4?style=flat)](https://dotnet.microsoft.com) +[![.NET 9](https://img.shields.io/badge/.NET%209-%23512bd4?style=flat)](https://dotnet.microsoft.com) +[![.NET 10](https://img.shields.io/badge/.NET%2010-%23512bd4?style=flat)](https://dotnet.microsoft.com) -This library references and uses [`SQLitePCLRaw.bundle_e_sqlite3`](https://www.nuget.org/packages/SQLitePCLRaw.bundle_e_sqlite3) version `2.1.2` and later witch ensures that the [`JSON1 APIS`](https://www.sqlite.org/json1.html) are present. +A C# library built using [`SQLitePCL.raw`](https://github.com/ericsink/SQLitePCL.raw) to use [SQLite](https://sqlite.org) as a [NoSQL](https://en.wikipedia.org/wiki/NoSQL) database. -The library executes `Batteries.Init();` for you when a connection is first initialized. +The library aims to provide simple low level methods that are used to create your own data access layers. For now the library uses a single `connection` class which creates/uses `tables` that have a single column called `documents`. In reality it should work for other tables with more columns as long as they contain one column called `documents` but no tests have been made or run for this use case. + +> [!IMPORTANT] +> To use the library you must ensure you are using an SQLite that contains the [`JSON1`](https://www.sqlite.org/json1.html) extension. As of version 3.38.0 (2022-02-22) the JSON functions and operators are built into SQLite by default. ## Getting Started -Create an instance of `NoSQLiteConnection`. -```csharp -var connection = new NoSQLiteConnection( - "path to database file", // Required - json_options) // Optional JsonSerializerOptions -``` - -The connection configures the `PRAGMA journal_mode` to be [`WAL`](https://www.sqlite.org/wal.html) - -The connection manages an `sqlite3` object when initialized. *You should always dispose it when you are done so that the databases flashes `WAL` files* but you can also ignore it ¯\ (ツ)/¯. - -## Tables - -You get a table using `connection.GetTable()` you can optionaly provide a table name or leave it as it is to get the default `documents` table. -> Tables are created if they do not exist. - -Tables are managed by their connections so you don't have to worry about disposing. If you request a table multiple times *(eg: the same name)* you will always get the same `Instance`. - -## Document Management - -Example class that will be used below: -```csharp -public class Person -{ - public string Name { get; set; } - public string Surname { get; set; } - public string? Description { get; set; } -} -``` -```csharp -var connection = new NoSQLiteConnection("path to database file", "json options or null"); -``` -```csharp -var docs = connection.GetTable(); -``` - -### Create/Update documents. - -Creating or Updating a document happens from the same `Insert` method, keep in mind that this always replaces the document with the new one. -```csharp -var panos = new Person -{ - Name = "panoukos", - Surname = "41", - Description = "C# dev" -} - -docs.Insert("panos", panos); // If it exists it is now replaced/updated. -``` - -### Get documents. -```csharp -var doc = docs.Get("panos"); // Get the document or null. -``` - -#### Delete documents. -```csharp -docs.Remove("panos"); // Will remove the document. -docs.Remove("panos"); // Will still succeed even if the document doesn't exist. -``` +Install some version of [`SQLitePCL.raw`](https://github.com/ericsink/SQLitePCL.raw) to create your `sqlite3` object. + +> [!TIP] +> You can look at the [test project](./test/NoSQLite.Test/) for more pragmatic usage. The test project uses [SQLitePCLRaw.bundle_e_sqlite3](https://www.nuget.org/packages/SQLitePCLRaw.bundle_e_sqlite3) nuget pacakge to use sqlite. + +### Connection + +Using your `sqlite3` db create a new instances of the `NoSQLiteConnection` and optionally pass a `JsonSerializerOptions` object. + +> [!CAUTION] +> Once you use the connection be very careful on the consequences of switching your `JsonSerializerOptions` object. + +> [!Note] +> Disposing `NoSQLiteConnection` will not close your `sqlite3` db or do anything with it. It will just cleanup it's associated table and statement instances. + +### Tables + +You get a table using `connection.GetTable({TableName})` + +> [!NOTE] +> Tables are created if they do not exist. If you request a table multiple times you will always get the same table instance. + +At the table level the following methods are supported: +| Method | Description | +|- |- | +| Count/CountLong | Returns the number of rows in the table. | +| All | Returns all rows in the table. Deserialized to `T` | +| Clear | Clears the table. | +| Exists | Check if a document exists. | +| Find | Returns the document if it exists or throws. | +| Add | Adds a document. | +| Update | Updates a document (replace). | +| Delete | Deletes a document. | +| IndexExists | Check if an index exists. | +| CreateIndex | Creates an index if it does not exists using `"{TableName}_{IndexName}"` (can also set unique flag). | +| DeleteIndex | Deletes an index if it does exist. | + +### Documents + +At the document level the following methods are supported: +| Method | Description | +|- |- | +| FindProperty | Finds a document by key and returns a property value. | +| Insert | Inserts a property value into a document by key. Overwrite `NO`, Create `YES`. | +| Replace | Replaces a property value in a document by key. Overwrite `YES`, Create `NO`. | +| Set | Sets a property value in a document by key. Overwrite `YES`, Create `YES`. | + +### Examples + +- For connection creation look at the [TestBase Before and After methods of the _setup.cs file](./test/NoSQLite.Test/_setup.cs). +- For CRUD examples look at the [CRUD method of the Table.cs file](./test/NoSQLite.Test/Table.cs). +- For INDEX examples look at the [Index and Index_Unique methods of the Table.cs file](./test/NoSQLite.Test/Table.cs). ## Build -To build this project [.NET 7](https://dotnet.microsoft.com/en-us/download/dotnet/7.0) is needed. +To build this project [.NET 10](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) is needed. ## Contribute diff --git a/ci/build.ps1 b/ci/build.ps1 deleted file mode 100644 index b1e7115..0000000 --- a/ci/build.ps1 +++ /dev/null @@ -1,18 +0,0 @@ -[CmdletBinding()] -param ( - [Parameter(Position = 1)] - [ValidateSet("Debug", "Release")] - [string] $configuration = "Release" -) - -$projects = @( - "./src/NoSQLite" - "./test/NoSQLite.Test" -) - -foreach ($project in $projects) { - dotnet ` - build $project ` - -c $configuration ` - -v minimal -} diff --git a/ci/pack.ps1 b/ci/pack.ps1 deleted file mode 100644 index 494d04d..0000000 --- a/ci/pack.ps1 +++ /dev/null @@ -1,17 +0,0 @@ -[CmdletBinding()] - -$projects = @( - "./src/NoSQLite" -) - -$output = "./nuget" - -foreach ($project in $projects) { - dotnet ` - pack $project ` - -c `Release ` - --no-restore ` - --no-build ` - -v minimal ` - -o $output -} diff --git a/ci/test.ps1 b/ci/test.ps1 deleted file mode 100644 index e13e2ea..0000000 --- a/ci/test.ps1 +++ /dev/null @@ -1,18 +0,0 @@ -[CmdletBinding()] -param ( - [Parameter(Position = 0)] - [ValidateSet("Debug", "Release")] - [string] $configuration = "Release" -) - -$projects = @( - "./test/NoSQLite.Test" -) - -foreach ($project in $projects) { - dotnet ` - test $project ` - -c $configuration ` - --no-restore ` - --no-build -} diff --git a/global.json b/global.json index 4925416..e2031ed 100644 --- a/global.json +++ b/global.json @@ -1,10 +1,10 @@ { - "msbuild-sdks": { - "MSBuild.Sdk.Extras": "3.0.44" - }, "sdk": { - "version": "7.0.0", + "version": "10.0.0", "rollForward": "latestMinor", "allowPrerelease": true + }, + "test": { + "runner": "Microsoft.Testing.Platform" } } \ No newline at end of file diff --git a/samples/ConsoleApp/Benchy.cs b/samples/ConsoleApp/Benchy.cs deleted file mode 100644 index 738a887..0000000 --- a/samples/ConsoleApp/Benchy.cs +++ /dev/null @@ -1,57 +0,0 @@ -using BenchmarkDotNet.Attributes; -using NoSQLite; - -namespace ConsoleApp; - -[MemoryDiagnoser(true)] -public class Benchy -{ - public NoSQLiteConnection Connection { get; set; } = null!; - public NoSQLiteTable WriteTable { get; set; } = null!; - public NoSQLiteTable ReadTable { get; set; } = null!; - - public Person Person { get; set; } = null!; - public string[] PeopleIds { get; set; } = null!; - public Dictionary People { get; set; } = null!; - - [GlobalSetup] - public void Setup() - { - Connection = new(Path.Combine(Environment.CurrentDirectory, "benchmark.sqlite3")); - WriteTable = Connection.GetTable("Write"); - ReadTable = Connection.GetTable("Read"); - - Person = new Person - { - Name = "singe_write", - Description = "A" - }; - - PeopleIds = (0..100).Select(static num => num.ToString()).ToArray(); - - People = PeopleIds.Select(num => new Person - { - Name = num.ToString(), - Description = "Yay", - Surname = "no" - }) - .ToDictionary(static x => x.Name); - - ReadTable.InsertMany(People); - } - - [GlobalCleanup] - public void Cleanup() => Connection.Dispose(); - - [Benchmark] - public Person Read_Singe() => ReadTable.Find("0")!; - - [Benchmark] - public List Read_Many() => ReadTable.FindMany(PeopleIds).ToList(); - - [Benchmark] - public void Write_Singe() => WriteTable.Insert(Person.Name, Person); - - [Benchmark] - public void Write_Many() => WriteTable.InsertMany(People); -} diff --git a/samples/ConsoleApp/CRUD.cs b/samples/ConsoleApp/CRUD.cs deleted file mode 100644 index 59334ee..0000000 --- a/samples/ConsoleApp/CRUD.cs +++ /dev/null @@ -1,69 +0,0 @@ -using NoSQLite; - -namespace ConsoleApp; - -public static class CRUD -{ - public static void Objects(NoSQLiteConnection connection) - { - var db = connection.GetTable(); - // objects - var panos = new Person - { - Name = "panos", - Surname = "athanasiou" - }; - - var john = new Person - { - Name = "john", - Surname = "mandis", - Description = "good friendo!" - }; - - db.Insert("0", panos); - db.Insert("1", john); - - db.InsertMany(new Dictionary - { - ["0"] = panos, - ["1"] = john - }); - - var exists = db.Exists("0"); - - var panos2 = db.Find("0"); - var john2 = db.Find("1"); - - var people = db.FindMany(new[] { "0", "1" }).ToArray(); - - db.Remove("0"); - db.RemoveMany(new[] { "0", "1" }); - - db.Insert("0", panos); - db.Insert("1", john); - } - - public static void Lists(NoSQLiteConnection connection) - { - var db = connection.GetTable(); - - var people = new[] - { - new Person{ Name = "person1", }, - new Person{ Name = "person2", }, - new Person{ Name = "person3", }, - new Person{ Name = "person4", }, - }; - - db.Insert("people", people); - - var exists = db.Exists("people"); - - var people2 = db.Find("people"); - - db.Remove("people"); - - db.Insert("people", people); - } -} diff --git a/samples/ConsoleApp/ConsoleApp.csproj b/samples/ConsoleApp/ConsoleApp.csproj deleted file mode 100644 index 33f31d5..0000000 --- a/samples/ConsoleApp/ConsoleApp.csproj +++ /dev/null @@ -1,19 +0,0 @@ - - - - Exe - net6.0 - CS1591;NU1603; - false - false - - - - - - - - - - - diff --git a/samples/ConsoleApp/Data/Person.cs b/samples/ConsoleApp/Data/Person.cs deleted file mode 100644 index 9dba6f5..0000000 --- a/samples/ConsoleApp/Data/Person.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace ConsoleApp.Data; - -public class Person -{ - public string Name { get; set; } - - public string Surname { get; set; } - - public string? Description { get; set; } -} diff --git a/samples/ConsoleApp/Extensions/RangeExtensions.cs b/samples/ConsoleApp/Extensions/RangeExtensions.cs deleted file mode 100644 index 602f5dd..0000000 --- a/samples/ConsoleApp/Extensions/RangeExtensions.cs +++ /dev/null @@ -1,36 +0,0 @@ -namespace System; - -public static class RangeExtensions -{ - public static RangeEnumerator GetEnumerator(this Range range) => new(range); - - public static IEnumerable Select(this Range range, Func selector) - { - foreach (var num in range) - { - yield return selector(num); - } - } -} - -public struct RangeEnumerator -{ - private int _current; - private readonly int _end; - - public RangeEnumerator(Range range) - { - if (range.End.IsFromEnd) throw new NotSupportedException("From end ranges not supported"); - - _current = range.Start.Value - 1; - _end = range.End.Value; - } - - public int Current => _current; - - public bool MoveNext() - { - _current += 1; - return _current <= _end; - } -} diff --git a/samples/ConsoleApp/INDEX.cs b/samples/ConsoleApp/INDEX.cs deleted file mode 100644 index b12b052..0000000 --- a/samples/ConsoleApp/INDEX.cs +++ /dev/null @@ -1,23 +0,0 @@ -using NoSQLite; - -namespace ConsoleApp; - -public static class INDEX -{ - public static void Execute(NoSQLiteConnection connection) - { - var db = connection.GetTable(); - - var indexName = "name_index"; - - var exists = db.IndexExists(indexName); - - db.CreateIndex(indexName, "name"); - - exists = db.IndexExists(indexName); - - db.DeleteIndex(indexName); - - exists = db.IndexExists(indexName); - } -} diff --git a/samples/ConsoleApp/Program.cs b/samples/ConsoleApp/Program.cs deleted file mode 100644 index 49b51f3..0000000 --- a/samples/ConsoleApp/Program.cs +++ /dev/null @@ -1,46 +0,0 @@ -global using ConsoleApp.Data; -using BenchmarkDotNet.Configs; -using BenchmarkDotNet.Running; -using ConsoleApp; -using NoSQLite; -using System.Diagnostics; -using System.Text.Json; -using System.Text.Json.Serialization; - - -//BenchmarkRunner.Run(); - -//return; - -var stopwatch = Stopwatch.StartNew(); - -var connection = new NoSQLiteConnection( - Path.Combine(Environment.CurrentDirectory, "console.sqlite3"), - new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - }); - -stopwatch.Stop(); -Console.WriteLine($"Open elapsed time: {stopwatch.ElapsedMilliseconds}"); - -Console.WriteLine($""" - Path: {connection.Path} - Version: {connection.Version} - """); - -stopwatch.Restart(); - -CRUD.Objects(connection); -CRUD.Lists(connection); -INDEX.Execute(connection); - -stopwatch.Stop(); -Console.WriteLine($"Operation time: {stopwatch.ElapsedMilliseconds}"); - -stopwatch.Restart(); -connection.Dispose(); - -stopwatch.Stop(); -Console.WriteLine($"Close elapsed time: {stopwatch.ElapsedMilliseconds}"); diff --git a/samples/ConsoleApp/Properties/launchSettings.json b/samples/ConsoleApp/Properties/launchSettings.json deleted file mode 100644 index 521dd32..0000000 --- a/samples/ConsoleApp/Properties/launchSettings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "profiles": { - "ConsoleApp": { - "commandName": "Project", - "commandLineArgs": "", - "workingDirectory": "." - } - } -} \ No newline at end of file diff --git a/src/NoSQLite/NoSQLite.csproj b/src/NoSQLite/NoSQLite.csproj index 528bc28..73b6dcd 100644 --- a/src/NoSQLite/NoSQLite.csproj +++ b/src/NoSQLite/NoSQLite.csproj @@ -1,13 +1,16 @@  - net6.0 - P41.NoSQLite - NoSQLite + net8.0;net9.0;net10.0 + True - + + + + + diff --git a/src/NoSQLite/NoSQLiteConnection.cs b/src/NoSQLite/NoSQLiteConnection.cs index c85912d..d03d9b1 100644 --- a/src/NoSQLite/NoSQLiteConnection.cs +++ b/src/NoSQLite/NoSQLiteConnection.cs @@ -1,64 +1,53 @@ -using SQLitePCL; -using System.Buffers; -using System.Text.Json; +using System.Buffers; +using System.Collections.Concurrent; namespace NoSQLite; -using static SQLitePCL.raw; - /// -/// +/// Represents a connection to a NoSQLite database, providing methods to manage tables and documents. /// [Preserve(AllMembers = true)] -public sealed class NoSQLiteConnection : IDisposable +public sealed partial class NoSQLiteConnection : IDisposable { - static NoSQLiteConnection() - { - Batteries.Init(); - } - - internal readonly Dictionary tables; - internal readonly sqlite3 db; - internal bool open = true; + private readonly Lazy tableExistsStmt; /// - /// Initialize a new instance of class. + /// The collection of tables managed by this connection. /// - /// The path pointing to the database file or the path it will create the file at. - /// The options that will be used to serialize/deserialize the json objects. - public NoSQLiteConnection(string databasePath, JsonSerializerOptions? jsonOptions = null) - { - ArgumentNullException.ThrowIfNull(databasePath, nameof(databasePath)); - - var result = sqlite3_open(databasePath, out db); - db.CheckResult(result, $"Could not open or create database file: {Path}"); - - SetJournalMode(); + internal readonly ConcurrentDictionary tables; - Version = sqlite3_libversion().utf8_to_string(); - Name = System.IO.Path.GetFileName(databasePath); - Path = databasePath; - JsonOptions = jsonOptions; - tables = new(); - } - - private void SetJournalMode() => - sqlite3_exec(db, "PRAGMA journal_mode=WAL;"); + /// + /// The underlying SQLite database handle. + /// + internal readonly sqlite3 db; /// - /// All the tables that belong to this Connection. + /// Indicates whether the connection is open. /// - public IReadOnlyCollection Tables => tables.Values; + internal bool open = true; /// - /// The database name. + /// Initializes a new instance of the class. /// - public string Name { get; } + /// The sqlite3 database. + /// The options used to serialize/deserialize JSON objects, or null for defaults. + public NoSQLiteConnection(sqlite3 db, JsonSerializerOptions? jsonOptions = null) + { + this.db = db; + tables = []; + Version = sqlite3_libversion().utf8_to_string(); + JsonOptions = jsonOptions; + + tableExistsStmt = new(() => new SQLiteStmt(this.db, JsonOptions, """ + SELECT name FROM "sqlite_master" + WHERE type='table' AND name = ?; + """u8)); + } /// - /// Gets the database path used by this connection. + /// Gets the JSON serializer options used to serialize/deserialize the documents. /// - public string Path { get; } + public JsonSerializerOptions? JsonOptions { get; } /// /// Gets the SQLite library version. @@ -66,84 +55,86 @@ private void SetJournalMode() => public string Version { get; } /// - /// Gets or Sets the JSON serializer options used to serialzie/deserialize the documents. + /// Gets all the tables that belong to this connection. /// - public JsonSerializerOptions? JsonOptions { get; set; } + public IEnumerable Tables => tables.Values; /// - /// todo: Summary + /// Gets a table with the specified name, or the default "documents" table if is null. + /// If the table does not exist in the connection's cache, a new is created. /// - /// The name of the table to create/use or null to use the default documents table. - /// - public NoSQLiteTable GetTable(string? table = null) + /// The name of the table to use and create if it does not exist. + /// The instance for the specified table. + public NoSQLiteTable GetTable(string table) { - const string docs = "documents"; - return tables.TryGetValue(table ?? docs, out var t) ? t : new(table ?? docs, this); + return tables.GetOrAdd(table, static (table, connection) => new(table, connection), this); } /// - /// Create a document table if it does not exist with the specified name. + /// Checks whether a table with the specified name exists in the database. /// - /// - public void CreateTable(string table) + /// The name of the table to check. + /// true if the table exists; otherwise, false. + public bool TableExists(string table) { - var result = sqlite3_exec(db, $""" - CREATE TABLE IF NOT EXISTS '{table}' ( - "id" TEXT NOT NULL UNIQUE, - "json" TEXT NOT NULL, - PRIMARY KEY("id") - ); - """); - - db.CheckResult(result, $"Could not create '{table}' database table"); + var stmt = tableExistsStmt.Value; + return stmt.Execute(b => b.Text(1, table), static r => r.Result is SQLITE_ROW, shouldThrow: false); } /// - /// Deletes all rows from a table. + /// Creates a document table with the specified name if it does not already exist. /// - public void Clear(string table) + /// The name of the table to create. + /// Thrown if the table cannot be created. + public void CreateTable(string table) { - sqlite3_exec(db, $"""DELETE FROM "{table}";"""); + using var stmt = new SQLiteStmt(db, JsonOptions, $""" + CREATE TABLE IF NOT EXISTS "{table}" ( + "documents" JSON NOT NULL + ); + """); + stmt.Execute(b => b.Text(1, table)); } /// - /// Drops the table and then create it again. This will delete all indexes views etc. + /// Drops a table with the specified name if it exists. This will delete all indexes, views, etc. /// - /// See for more info. - public void DropAndCreate(string table) + /// The name of the table to drop. + /// Thrown if the table cannot be dropped. + public void DropTable(string table) { - sqlite3_exec(db, $"""DROP TABLE IF EXISTS "{table}";"""); - CreateTable(table); + using var stmt = new SQLiteStmt(db, JsonOptions, $""" + DROP TABLE IF EXISTS "{table}"; + """); + stmt.Execute(b => b.Text(1, table)); } /// - /// Execute wal_checkpoint. + /// Releases all resources used by the . /// - /// See for more info. - public void Checkpoint() - { - sqlite3_wal_checkpoint(db, Name); - } - - /// /// - /// This will close and dispose the underlying database connection. + /// Disposes all managed instances and prepared statements. /// public void Dispose() { if (!open) return; open = false; - var length = tables.Count; var buffer = ArrayPool.Shared.Rent(length); - tables.Values.CopyTo(buffer, 0); + try + { + tables.Values.CopyTo(buffer, 0); + tables.Clear(); - for (int i = 0; i < length; i++) buffer[i].Dispose(); + for (int i = 0; i < length; i++) buffer[i].Dispose(); - ArrayPool.Shared.Return(buffer, true); + if (tableExistsStmt.IsValueCreated) tableExistsStmt.Value.Dispose(); + } - sqlite3_close_v2(db); - db.Dispose(); + finally + { + ArrayPool.Shared.Return(buffer, true); + } } } diff --git a/src/NoSQLite/NoSQLiteException.cs b/src/NoSQLite/NoSQLiteException.cs index 0c3799e..e3595b1 100644 --- a/src/NoSQLite/NoSQLiteException.cs +++ b/src/NoSQLite/NoSQLiteException.cs @@ -1,14 +1,59 @@ -namespace NoSQLite; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; -/// +namespace NoSQLite; + +/// +/// Represents errors that occur during NoSQLite database operations. +/// public sealed class NoSQLiteException : Exception { + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The message that describes the error. internal NoSQLiteException(string message) : base(message) { } - internal NoSQLiteException(string message, Exception inner) - : base(message, inner) + /// + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The message that describes the error. + /// The exception that is the cause of the current exception. + internal NoSQLiteException(string message, Exception inner) : base(message, inner) + { + } + + /// + /// Throws a with the specified message if the condition is true. + /// + /// The condition to evaluate. If true, the exception is thrown. + /// The interpolated message to include in the exception if thrown. + /// Thrown when is true. + internal static void If(bool condition, [InterpolatedStringHandlerArgument("condition")] ref ConditionInterpolation message) + { + if (condition) + { + throw new NoSQLiteException(message.ToString()); + } + } + + /// + /// Throws a with a message indicating that a document for the specified key was not found, + /// if the provided is null. + /// + /// The type of the item being checked. + /// The type of the key used to identify the item. + /// The item to check for existence. If null, the exception is thrown. + /// The key associated with the item. + /// Thrown when is null. + internal static void KeyNotFound([NotNull] T? item, TKey key) { + if (item is null) + { + var msg = $"Could not find document for key {key}"; + throw new NoSQLiteException(msg, new KeyNotFoundException(msg)); + } } } diff --git a/src/NoSQLite/NoSQLiteTable.cs b/src/NoSQLite/NoSQLiteTable.cs index d1b5f61..283fe7e 100644 --- a/src/NoSQLite/NoSQLiteTable.cs +++ b/src/NoSQLite/NoSQLiteTable.cs @@ -1,530 +1,440 @@ -using SQLitePCL; -using System.Text.Json; +using System.Buffers; namespace NoSQLite; -using static SQLitePCL.raw; - /// -/// todo: Summary +/// Represents a table in a NoSQLite database, providing methods to manage documents and indexes. /// [Preserve(AllMembers = true)] -public sealed class NoSQLiteTable : IDisposable +public sealed class NoSQLiteTable { - private readonly List disposables = new(6); + private readonly List statements = []; private readonly sqlite3 db; internal NoSQLiteTable(string table, NoSQLiteConnection connection) { - ArgumentNullException.ThrowIfNull(connection, nameof(connection)); - ArgumentNullException.ThrowIfNull(table, nameof(table)); + ArgumentNullException.ThrowIfNull(connection); + ArgumentException.ThrowIfNullOrEmpty(table); Table = table; Connection = connection; - JsonOptions = connection.JsonOptions; db = connection.db; connection.CreateTable(table); - connection.tables.Add(table, this); - - #region Lazy statment initialization - - countStmt = new(() => - { - var stmt = new SQLiteStmt(db, $""" - SELECT count(*) FROM "{Table}"; - """); - - disposables.Add(stmt); - return stmt; - }); - - existsStmt = new(() => - { - var stmt = new SQLiteStmt(db, $""" - SELECT count(*) FROM "{Table}" - WHERE id is ?; - """); - - disposables.Add(stmt); - return stmt; - }); - - allStmt = new(() => - { - var stmt = new SQLiteStmt(db, $""" - SELECT json FROM "{Table}"; - """); - disposables.Add(stmt); - return stmt; - }); - - findStmt = new(() => - { - var stmt = new SQLiteStmt(db, $""" - SELECT json - FROM "{Table}" - WHERE id is ?; - """); - - disposables.Add(stmt); - return stmt; - }); - - insertStmt = new(() => - { - var stmt = new SQLiteStmt(db, $""" - REPLACE INTO "{Table}" - VALUES (?, json(?)); - """); - - disposables.Add(stmt); - return stmt; - }); - - removeStmt = new(() => - { - var stmt = new SQLiteStmt(db, $""" - DELETE FROM "{Table}" - WHERE id is ?; - """); - - disposables.Add(stmt); - return stmt; - }); - #endregion } /// - /// The connection that created and manages this table. + /// Gets the associated with this table. /// - /// If the connection is disposed this will also be disposed. public NoSQLiteConnection Connection { get; } /// - /// The name of the table this connection will use. + /// Gets the name of the table. /// public string Table { get; } /// - /// Gets or Sets the JSON serializer options used to serialzie/deserialize the documents. - /// - public JsonSerializerOptions? JsonOptions { get; set; } - - /// - /// Deletes all rows from a table. + /// Gets the used for JSON serialization and deserialization. /// - public void Clear() => Connection.Clear(Table); + public JsonSerializerOptions? JsonOptions => Connection.JsonOptions; - /// - /// Drops the table and then Creates it again. This will delete all indexes views etc. - /// - /// See for more info. - public void DropAndCreate() => Connection.DropAndCreate(Table); + internal SQLiteStmt NewStmt(string sql) => new(db, JsonOptions, sql, statements); - #region Basic + internal SQLiteStmt NewStmt(ReadOnlySpan sql) => new(db, JsonOptions, sql, statements); - private readonly Lazy countStmt; + private SQLiteStmt CountStmt => field ??= NewStmt($""" + SELECT count(*) FROM "{Table}" + """); + /// + /// Gets the number of documents in the table. + /// + /// The count of documents as an . public int Count() { - var stmt = countStmt.Value; - lock (countStmt) - { - stmt.Step(); - - var count = stmt.ColumnInt(0); - stmt.Reset(); - return count; - } + return CountStmt.Execute(null, static r => r.Int(0)); } + private SQLiteStmt LongCountStmt => field ??= NewStmt($""" + SELECT count(*) FROM "{Table}" + """); + + /// + /// Gets the number of documents in the table as a . + /// + /// The count of documents as a . public long LongCount() { - var stmt = countStmt.Value; - lock (countStmt) - { - stmt.Step(); - - var count = stmt.ColumnLong(0); - stmt.Reset(); - return count; - } + return LongCountStmt.Execute(null, static r => r.Long(0)); } - private readonly Lazy existsStmt; + private SQLiteStmt AllStmt => field ??= NewStmt($""" + SELECT "documents" FROM "{Table}" + """); /// - /// Check wheter a document exists or not. + /// Gets all documents in the table as an array of type . /// - /// The id to search for. - /// True when the id exists otherwise false. - public bool Exists(string id) + /// The type to deserialize each document to. + /// An array of all documents in the table. + public T[] All() { - var stmt = existsStmt.Value; - lock (existsStmt) - { - stmt.BindText(1, id); - stmt.Step(); - - var value = stmt.ColumnInt(0); - stmt.Reset(); - return value != 0; - } + return AllStmt.ExecuteMany(null, static r => r.Deserialize(0)!); } - private readonly Lazy allStmt; + private SQLiteStmt ClearStmt => field ??= NewStmt($""" + DELETE FROM "{Table}" + """); - public IEnumerable All() + /// + /// Removes all documents from the table. + /// + public void Clear() { - var stmt = allStmt.Value; - lock (allStmt) - { - start: - var result = stmt.Step(); - - if (result is not SQLITE_ROW) - { - stmt.Reset(); - yield break; - } - - var value = stmt.ColumnDeserialize(0, JsonOptions); - yield return value!; - goto start; - } + ClearStmt.Execute(null); } - public IEnumerable AllBytes() - { - var stmt = allStmt.Value; - lock (allStmt) - { - start: - var result = stmt.Step(); - - if (result is not SQLITE_ROW) - { - stmt.Reset(); - yield break; - } - - var bytes = stmt.ColumnBlob(0).ToArray(); - yield return bytes; - goto start; - } - } - - #endregion - - #region Find - - private readonly Lazy findStmt; + private SQLiteStmt ExistsStmt => field ??= NewStmt($""" + SELECT count(*) FROM "{Table}" + WHERE "documents"->('$.' || ?) = ?; + """); /// - /// Get an object for the provided id or null. + /// Determines whether a document with the specified key exists in the table. /// - /// The type the object will deserialize to. - /// The id to search for. - /// An instance of or null. - public T? Find(string id) + /// The document type. + /// The key type. + /// An expression selecting the key property. + /// The key value to search for. + /// if a document with the specified key exists; otherwise, . + public bool Exists(Expression> selector, TKey key) { - var stmt = findStmt.Value; - lock (findStmt) - { - stmt.BindText(1, id); - var result = stmt.Step(); + var propertyPath = selector.GetPropertyPath(JsonOptions); + var jsonKey = JsonSerializer.SerializeToUtf8Bytes(key, JsonOptions); - if (result is not SQLITE_ROW) + return ExistsStmt.Execute( + b => { - stmt.Reset(); - return default; - } - - var value = stmt.ColumnDeserialize(0, JsonOptions); - stmt.Reset(); - return value; - } + b.Text(1, propertyPath); + b.Text(2, jsonKey); + }, + static b => b.Int(0) is not 0 + ); } + private SQLiteStmt FindStmt => field ??= NewStmt($""" + SELECT "documents" + FROM "{Table}" + WHERE "documents"->('$.' || ?) = ? + """); + /// - /// Get an object for each one of the provided ids. + /// Finds and returns a document by key. /// - /// The type the objects will deserialize to. - /// The ids to search for. - /// True to ignore missing ids - /// A list of objects. - /// When is true and a key is not found. - public IEnumerable FindMany(IEnumerable ids, bool throwIfNotFound = true) + /// The document type. + /// The key type. + /// An expression selecting the key property. + /// The key value to search for. + /// The document matching the specified key. + /// Thrown if the key is not found. + public T Find(Expression> selector, TKey key) { - foreach (var id in ids) - { - var doc = Find(id); + var propertyPath = selector.GetPropertyPath(JsonOptions); + var jsonKey = JsonSerializer.SerializeToUtf8Bytes(key, JsonOptions); - if (doc is null) + var found = FindStmt.Execute( + b => { - Throw.KeyNotFound(throwIfNotFound, $"Could not locate the Id '{id}'"); - continue; - } - yield return doc; - } + b.Text(1, propertyPath); + b.Text(2, jsonKey); + }, + static r => r.Deserialize(0) + ); + + NoSQLiteException.KeyNotFound(found, key); + return found; } - /// - /// Get Id - pairs. - /// If an object doesn't exist it will have a null value. - /// - /// The type the objects will deserialize to. - /// The ids to search for. - /// An enumerable of Id - object pairs. - /// Duplicate keys are ignored. - public IDictionary FindPairs(IEnumerable ids) - { - var dictionary = new Dictionary(); - foreach (var id in ids) - { - if (dictionary.ContainsKey(id)) continue; - - dictionary.Add(id, Find(id)); - } - return dictionary; - } + private SQLiteStmt FindPropertyStmt => field ??= NewStmt($""" + SELECT "documents"->('$.' || ?) + FROM "{Table}" + WHERE "documents"->('$.' || ?) = ? + """); /// - /// Get a byte array for the provided id or null. + /// Finds and returns a property value from a document by key. /// - /// The id to search for. - /// A byte array or null. - public byte[]? FindBytes(string id) + /// The document type. + /// The key type. + /// The property type. + /// An expression selecting the key property. + /// An expression selecting the property to retrieve. + /// The key value to search for. + /// The property value, or null if not found. + public TProperty? FindProperty(Expression> keySelector, Expression> propertySelector, TKey key) { - var stmt = findStmt.Value; - lock (findStmt) - { - stmt.BindText(1, id); - var result = stmt.Step(); + var keyPropertyPath = keySelector.GetPropertyPath(JsonOptions); + var propertyPath = propertySelector.GetPropertyPath(JsonOptions); + var jsonKey = JsonSerializer.SerializeToUtf8Bytes(key, JsonOptions); - if (result is not SQLITE_ROW) + return FindPropertyStmt.Execute( + b => { - stmt.Reset(); - return default; - } - - var bytes = stmt.ColumnBlob(0).ToArray(); - stmt.Reset(); - return bytes; - } + b.Text(1, propertyPath); + b.Text(2, keyPropertyPath); + b.Text(3, jsonKey); + }, + static r => r.Deserialize(0) + ); } + private SQLiteStmt AddStmt => field ??= NewStmt($""" + INSERT INTO "{Table}"("documents") VALUES (json(?)) + """); + /// - /// Get a byte array for each one of the provided ids. + /// Adds a new document to the table. /// - /// The ids to search for. - /// True to ignore missing ids - /// A list of byte arrays. - /// When is true and a key is not found. - public IEnumerable FindBytesMany(IEnumerable ids, bool throwIfNotFound = true) + /// The document type. + /// The document to add. + public void Add(T obj) { - foreach (var id in ids) - { - var bytes = FindBytes(id); + var document = JsonSerializer.SerializeToUtf8Bytes(obj, JsonOptions); - if (bytes is null) - { - Throw.KeyNotFound(throwIfNotFound, $"Could not locate the Id '{id}'"); - continue; - } - yield return bytes; - } + AddStmt.Execute(b => b.Blob(1, document)); } + private SQLiteStmt UpdateStmt => field ??= NewStmt($""" + UPDATE "{Table}" + SET "documents" = json(?) + WHERE "documents"->('$.' || ?) = ?; + """); + /// - /// Get Id - byte array pairs. - /// If an object doesn't exist it will have a null value - /// for the corresponding Id in the dictionary. + /// Updates an existing document in the table. /// - /// The ids to search for. - /// A dictionary of Id - object pairs. - /// Duplicate keys are just put on the key again. - public IDictionary FindBytesPairs(IEnumerable ids) + /// The document type. + /// The key type. + /// The updated document. + /// An expression selecting the key property. + public void Update(T document, Expression> selector) { - var pairs = new Dictionary(); - foreach (var id in ids) + var propertyPath = selector.GetPropertyPath(JsonOptions); + var key = selector.Compile().Invoke(document); + + UpdateStmt.Execute(b => { - var bytes = FindBytes(id); - pairs[id] = bytes; - } - return pairs; + b.JsonBlob(1, document); + b.Text(2, propertyPath); + b.JsonText(3, key); + }); } - #endregion - - #region Insert + private SQLiteStmt DeleteStmt => field ??= NewStmt($""" + DELETE FROM "{Table}" + WHERE "documents"->('$.' || ?) = ?; + """); - private readonly Lazy insertStmt; - - public void Insert(string id, T obj) + /// + /// Deletes a document from the table by key. + /// + /// The document type. + /// The key type. + /// An expression selecting the key property. + /// The key value of the document to remove. + public void Delete(Expression> selector, TKey key) { - InsertBytes(id, JsonSerializer.SerializeToUtf8Bytes(obj, JsonOptions)); - } + var propertyPath = selector.GetPropertyPath(JsonOptions); - public void InsertMany(IDictionary keyValuePairs) - { - using var transaction = new SQLiteTransaction(db); - foreach (var (id, obj) in keyValuePairs) + DeleteStmt.Execute(b => { - Insert(id, obj); - } + b.Text(1, propertyPath); + b.JsonText(2, key); + }); } - public void InsertBytes(string id, byte[] obj) - { - var stmt = insertStmt.Value; - - lock (insertStmt) - { - stmt.BindText(1, id); - stmt.BindText(2, obj); - var result = stmt.Step(); - - db.CheckResult(result, $"Could not Insert ({id})"); - stmt.Reset(); - } - } + // https://sqlite.org/json1.html#jins + private SQLiteStmt InsertStmt => field ??= NewStmt($""" + UPDATE "{Table}" + SET "documents" = json_insert("documents", ('$.' || ?), json(?)) + WHERE "documents"->('$.' || ?) = ? + """); - public void InsertBytesMany(IDictionary keyValuePairs) + /// + /// Inserts a property value into a document by key. + /// + /// + /// Overwrite if already exists? NO (including values, only the key matters).
+ /// Create if does not exist? YES + ///
+ /// The document type. + /// The key type. + /// The property type. + /// An expression selecting the key property. + /// An expression selecting the property to insert. + /// The key value of the document. + /// The property value to insert. + public void Insert(Expression> keySelector, Expression> propertySelector, TKey key, TProperty? value) { - using var transaction = new SQLiteTransaction(db); - foreach (var (id, obj) in keyValuePairs) + var keyPropertyPath = keySelector.GetPropertyPath(JsonOptions); + var propertyPath = propertySelector.GetPropertyPath(JsonOptions); + + // cant know if it actaully inserted or not + InsertStmt.Execute(b => { - InsertBytes(id, obj); - } + b.Text(1, propertyPath); + b.JsonBlob(2, value); + b.Text(3, keyPropertyPath); + b.JsonText(4, key); + }); } - #endregion - - #region Remove - - private readonly Lazy removeStmt; + // https://sqlite.org/json1.html#jins + private SQLiteStmt ReplaceStmt => field ??= NewStmt($""" + UPDATE "{Table}" + SET "documents" = json_replace("documents", ('$.' || ?), json(?)) + WHERE "documents"->('$.' || ?) = ? + """); /// - /// Deletes the specified id from the database. + /// Replaces a property value in a document by key. /// - /// The id to delete. - public void Remove(string id) + /// + /// Overwrite if already exists? YES ( values won't remove the key).
+ /// Create if does not exist? NO + ///
+ /// The document type. + /// The key type. + /// The property type. + /// An expression selecting the key property. + /// An expression selecting the property to replace. + /// The key value of the document. + /// The property value to replace. + public void Replace(Expression> keySelector, Expression> propertySelector, TKey key, TProperty? value) { - var stmt = removeStmt.Value; + var keyPropertyPath = keySelector.GetPropertyPath(JsonOptions); + var propertyPath = propertySelector.GetPropertyPath(JsonOptions); - lock (removeStmt) + // cant know if it actaully replaced or not + ReplaceStmt.Execute(b => { - stmt.BindText(1, id); - stmt.Step(); - stmt.Reset(); - } + b.Text(1, propertyPath); + b.JsonBlob(2, value); + b.Text(3, keyPropertyPath); + b.JsonText(4, key); + }); } + // https://sqlite.org/json1.html#jins + private SQLiteStmt SetStmt => field ??= NewStmt($""" + UPDATE "{Table}" + SET "documents" = json_set("documents", ('$.' || ?), json(?)) + WHERE "documents"->('$.' || ?) = ? + """); + /// - /// Deletes the specified ids from the database. + /// Sets a property value in a document by key. /// - /// The ids to delete. - public void RemoveMany(IEnumerable ids) + /// + /// Overwrite if already exists? YES
+ /// Create if does not exist? YES + ///
+ /// The document type. + /// The key type. + /// The property type. + /// An expression selecting the key property. + /// An expression selecting the property to set. + /// The key value of the document. + /// The property value to set. + public void Set(Expression> keySelector, Expression> propertySelector, TKey key, TProperty? value) { - using var transaction = new SQLiteTransaction(db); - foreach (var id in ids) + var keyPropertyPath = keySelector.GetPropertyPath(JsonOptions); + var propertyPath = propertySelector.GetPropertyPath(JsonOptions); + + // will set no matter what so failure will throw + SetStmt.Execute(b => { - Remove(id); - } + b.Text(1, propertyPath); + b.JsonBlob(2, value); + b.Text(3, keyPropertyPath); + b.JsonText(4, key); + }); } - #endregion - - #region Indexing - /// - /// Check whether an index exists or not. + /// Determines whether an index with the specified name exists for this table. /// - /// The index to search for. - /// True when the index exists. + /// The name of the index to check. + /// if the index exists; otherwise, . public bool IndexExists(string indexName) { - string sql = $""" - SELECT count(*) FROM sqlite_master - WHERE type='index' and name="{Table}_{indexName}"; - """; + using var stmt = new SQLiteStmt(db, JsonOptions, """ + SELECT name FROM "sqlite_master" + WHERE type='index' AND name = ?; + """u8); - using var stmt = new SQLiteStmt(db, sql); - stmt.Step(); + var index = $"{Table}_{indexName}"; - var value = stmt.ColumnInt(0); - return value != 0; + return stmt.Execute(b => b.Text(1, index), static r => r.Result is SQLITE_ROW, shouldThrow: false); } /// - /// Create an index on a json parameter using the json_extract(json, '$.param') opperator. - /// Parameter can include nested json values eg: assets.house.location + /// Creates an index on the specified property of the documents in the table. /// - /// The name of the index. If this name exists the index won't be created. - /// The json parameter to create the index for. - /// - /// Parameter names are case sensitive.
- /// Index name on sqlite will always be created as _ - ///
- public bool CreateIndex(string indexName, string parameter) + /// The document type. + /// The key type. + /// An expression selecting the property to index. + /// The name of the index to create. + /// Whether the index should enforce uniqueness. + public void CreateIndex(Expression> selector, string indexName, bool unique = false) { - string sql = $""" - CREATE INDEX "{Table}_{indexName}" - ON "{Table}"(json_extract("json", '$.{parameter}')); - """; + var propertyPath = selector.GetPropertyPath(JsonOptions); - return sqlite3_exec(db, sql) == SQLITE_OK; - } + // can't use parameter (?) here so we have to create a new statement every time. + using var stmt = new SQLiteStmt(db, JsonOptions, $""" + CREATE{(unique ? " UNIQUE" : "")} INDEX IF NOT EXISTS "{Table}_{indexName}" + ON "{Table}" ("documents"->'$.{propertyPath}') + """); - /// - /// Combination of and - /// - /// The name of the index. - /// The json parameter to update the index for. - /// Index on sqlite is always _ - public void RecreateIndex(string indexName, string parameter) - { - DeleteIndex(indexName); - CreateIndex(indexName, parameter); + stmt.Execute(null); } /// - /// Delete an index if it exists. + /// Deletes an index with the specified name from this table. /// - /// The name of the index. - /// Index on sqlite is always _ + /// The name of the index to delete. + /// if the index was deleted; otherwise, . public bool DeleteIndex(string indexName) { - string sql = $""" + using var stmt = new SQLiteStmt(db, JsonOptions, $""" DROP INDEX "{Table}_{indexName}" - """; + """); - return sqlite3_exec(db, sql) == SQLITE_OK; + return stmt.Execute(null, static r => true); } - #endregion - - #region Query - - // todo: Implement query capabilities. - - #endregion - - #region View - - // todo: Implement view usage. - - #endregion - - /// - public void Dispose() + /// + /// Releases all resources used by the . + /// + /// + /// Disposes all prepared statements. + /// + internal void Dispose() { - Connection.tables.Remove(Table); - if (disposables.Count <= 0) return; + if (statements.Count <= 0) return; + + var length = statements.Count; + var buffer = ArrayPool.Shared.Rent(length); + try + { + statements.CopyTo(buffer, 0); + statements.Clear(); - foreach (var d in disposables) d.Dispose(); - disposables.Clear(); + for (int i = 0; i < length; i++) buffer[i].Dispose(); + } + finally + { + ArrayPool.Shared.Return(buffer, true); + } } } diff --git a/src/NoSQLite/SQLiteStmt.cs b/src/NoSQLite/SQLiteStmt.cs index 97c6b9f..cfec3cf 100644 --- a/src/NoSQLite/SQLiteStmt.cs +++ b/src/NoSQLite/SQLiteStmt.cs @@ -1,47 +1,339 @@ -using SQLitePCL; -using System.Text.Json; - -namespace NoSQLite; - -using static SQLitePCL.raw; +namespace NoSQLite; +/// +/// Represents a prepared SQLite statement, providing methods for execution with parameter binding and result retrieval. +/// +[Preserve(AllMembers = true)] internal sealed class SQLiteStmt : IDisposable { +#if NET9_0_OR_GREATER + private readonly Lock locker = new(); +#else + private readonly object locker = new(); +#endif + private readonly sqlite3 db; private readonly sqlite3_stmt stmt; + private readonly JsonSerializerOptions? jsonOptions; /// - /// Initialize a new . + /// Initializes a new instance of the class using a SQL string. + /// Prepares the SQL statement for execution and optionally adds this instance to a disposables list. /// - /// The database that runs the statements. - /// The sql to execute. - public SQLiteStmt(sqlite3 db, string sql) + /// The SQLite database connection to use for this statement. + /// Optional JSON serializer options for deserialization operations. + /// The SQL statement to prepare and execute as a string. + /// An optional list to which this statement will be added for disposal management. + public SQLiteStmt(sqlite3 db, JsonSerializerOptions? jsonOptions, string sql, List? disposables = null) { this.db = db; + this.jsonOptions = jsonOptions; sqlite3_prepare_v2(db, sql, out stmt); + disposables?.Add(this); + } + + /// + /// Initializes a new instance of the class using a SQL byte span. + /// Prepares the SQL statement for execution and optionally adds this instance to a disposables list. + /// + /// The SQLite database connection to use for this statement. + /// Optional JSON serializer options for deserialization operations. + /// The SQL statement to prepare and execute as a . + /// An optional list to which this statement will be added for disposal management. + public SQLiteStmt(sqlite3 db, JsonSerializerOptions? jsonOptions, ReadOnlySpan sql, List? disposables = null) + { + this.db = db; + this.jsonOptions = jsonOptions; + sqlite3_prepare_v2(db, sql, out stmt); + disposables?.Add(this); + } + + /// + /// Executes the SQL statement by stepping through it. + /// + /// An optional delegate that binds parameters to the statement before execution. + /// Indicates whether exceptions should be thrown on errors. + /// The statement is locked during execution to ensure thread safety. The statement is reset after execution. + public void Execute(SQLiteWriterFunc? bind, bool shouldThrow = true) + { + lock (locker) + { + using var step = new SQLiteStep(this, bind, static read => null, shouldThrow); + } + } + + /// + /// Executes the SQL statement and returns a result using the provided reader function. + /// + /// The type of the result returned by the reader function. + /// An optional delegate that binds parameters to the statement before execution. + /// A delegate that processes the and returns a result of type . + /// Indicates whether exceptions should be thrown on errors. + /// The result produced by the delegate after executing the statement. + /// The statement is locked during execution to ensure thread safety. The statement is reset after execution. + public TResult Execute(SQLiteWriterFunc? bind, SQLiteReaderFunc read, bool shouldThrow = true) + { + lock (locker) + { + using var step = new SQLiteStep(this, bind, read, shouldThrow); + return step.Result; + } + } + + /// + /// Executes the SQL statement and returns an array of results using the provided reader function. + /// + /// The type of the result returned by the reader function. + /// An optional delegate that binds parameters to the statement before execution. + /// A delegate that processes the and returns a result of type . + /// Indicates whether exceptions should be thrown on errors. + /// + /// An array of results produced by the delegate for each row returned by the statement. + /// + /// + /// The statement is locked during execution to ensure thread safety. The statement is reset after execution. + /// + public TResult[] ExecuteMany(SQLiteWriterFunc? bind, SQLiteReaderFunc read, bool shouldThrow = true) + { + lock (locker) + { + using var steps = new SQLiteSteps(this, bind, read, shouldThrow); + var results = new List(); + while (steps.MoveNext()) + { + results.Add(steps.Current); + } + return [.. results]; + } + } + + /// + /// Finalizes this statement and releases associated resources. + /// + public void Dispose() + { + sqlite3_finalize(stmt); + } + + /// + /// Implicitly converts a to its underlying database connection. + /// + /// The instance. + public static implicit operator sqlite3(SQLiteStmt stmt) => stmt.db; + + /// + /// Implicitly converts a to its underlying statement handle. + /// + /// The instance. + public static implicit operator sqlite3_stmt(SQLiteStmt stmt) => stmt.stmt; + + /// + /// Implicitly converts a to its associated . + /// + /// The instance. + public static implicit operator JsonSerializerOptions?(SQLiteStmt stmt) => stmt.jsonOptions; + + /// + /// Represents a single step execution of a SQLite statement, providing result retrieval and automatic reset. + /// + /// The type of the result returned by the reader function. + private readonly ref struct SQLiteStep : IDisposable + { + /// + /// The parent instance. + /// + private readonly SQLiteStmt stmt; + + /// + /// Gets the result produced by the reader function after statement execution. + /// + public TResult Result { get; } + + /// + /// Initializes a new instance of the struct. + /// + /// The parent instance. + /// An optional delegate that binds parameters to the statement before execution. + /// A delegate that processes the and returns a result of type . + /// Indicates whether exceptions should be thrown on errors. + public SQLiteStep(SQLiteStmt stmt, SQLiteWriterFunc? bind, SQLiteReaderFunc read, bool shouldThrow) + { + this.stmt = stmt; + bind?.Invoke(new(stmt)); + var result = shouldThrow ? sqlite3_step(stmt).CheckResult(stmt.db, $"") : sqlite3_step(stmt); + Result = read(new(stmt, result)); + if (bind is { }) + { + sqlite3_clear_bindings(stmt); + } + } + + /// + /// Resets the statement after execution. + /// + public void Dispose() + { + sqlite3_reset(stmt); + } + } + + /// + /// Enumerates multiple result rows from an SQLite statement, providing result retrieval and automatic reset. + /// + /// The type of the result returned by the reader function. + private ref struct SQLiteSteps : IEnumerator, IDisposable + { + private readonly SQLiteStmt stmt; + private readonly SQLiteReaderFunc read; + private readonly bool shouldThrow; + + public TResult Current { get; private set; } = default!; + + /// + /// Initializes a new instance of the struct. + /// + /// The parent instance. + /// An optional delegate that binds parameters to the statement before execution. + /// A delegate that processes the and returns a result of type . + /// Indicates whether exceptions should be thrown on errors. + public SQLiteSteps(SQLiteStmt stmt, SQLiteWriterFunc? bind, SQLiteReaderFunc read, bool shouldThrow) + { + this.stmt = stmt; + this.read = read; + this.shouldThrow = shouldThrow; + bind?.Invoke(new(stmt)); + } + + public bool MoveNext() + { + var result = shouldThrow ? sqlite3_step(stmt).CheckResult(stmt.db, $"") : sqlite3_step(stmt); + if (result is not SQLITE_ROW) + { + return false; + } + Current = read(new(stmt, result)); + return true; + } + + public readonly void Reset() + { + throw new NotSupportedException(); + } + + public readonly void Dispose() + { + sqlite3_reset(stmt); + sqlite3_clear_bindings(stmt); + } + + /// + readonly object IEnumerator.Current => Current!; } +} + +#if NET9_0_OR_GREATER +internal readonly ref struct SQLiteParameterBinder +#else +internal readonly struct SQLiteParameterBinder +#endif +{ + private readonly SQLiteStmt stmt; + + public SQLiteParameterBinder(SQLiteStmt stmt) + { + this.stmt = stmt; + } + + /// + /// Binds a blob value to the specified parameter index in the statement. + /// + /// Index starts from 1. + /// The parameter index to bind to, starting from 1. + /// The blob value to bind as a . + public void Blob(int index, ReadOnlySpan value) => sqlite3_bind_blob(stmt, index, value); + + /// + /// Bind text to a parameter. + /// + /// Index starts from 1. + /// The parameter index to bind to, starting from 1. + /// The value to bind. + public void Text(int index, string value) => sqlite3_bind_text(stmt, index, value); - #region Bind + /// + /// Bind text to a parameter. + /// + /// Index starts from 1. + /// The parameter index to bind to, starting from 1. + /// The value to bind. + public void Text(int index, ReadOnlySpan value) => sqlite3_bind_text(stmt, index, value); /// - /// Bind text to a paramter. + /// Bind an object to a parameter as json using . /// + /// The type of the value. /// Index starts from 1. /// The parameter index to bind to, starting from 1. /// The value to bind. - public void BindText(int index, string value) => sqlite3_bind_text(stmt, index, value); + public void JsonBlob(int index, T value) + { + var json = JsonSerializer.SerializeToUtf8Bytes(value, stmt); + Blob(index, json); + } /// - /// Bind text to a paramter. + /// Bind an object to a parameter as json using >. /// + /// The type of the value. /// Index starts from 1. /// The parameter index to bind to, starting from 1. /// The value to bind. - public void BindText(int index, ReadOnlySpan value) => sqlite3_bind_text(stmt, index, value); + public void JsonText(int index, T value) + { + var json = JsonSerializer.SerializeToUtf8Bytes(value, stmt); + Text(index, json); + } + + /// + /// Bind an integer value to a parameter. + /// + /// Index starts from 1. + /// The parameter index to bind to, starting from 1. + /// The integer value to bind. + public void Int(int index, int value) => sqlite3_bind_int(stmt, index, value); + + /// + /// Clear all bound parameters for the current statement. + /// + /// + /// This method clears all parameter bindings, allowing the statement to be reused with new values. + /// + public void Clear() => sqlite3_clear_bindings(stmt); +} + +#if NET9_0_OR_GREATER +internal delegate void SQLiteWriterFunc(TWriter writer) where TWriter : allows ref struct; +#else +internal delegate void SQLiteWriterFunc(TWriter writer); +#endif - #endregion +#if NET9_0_OR_GREATER +internal readonly ref struct SQLiteResultReader +#else +internal readonly struct SQLiteResultReader +#endif +{ + private readonly SQLiteStmt stmt; - #region Column + public int Result { get; } + + public SQLiteResultReader(SQLiteStmt stmt, int result) + { + this.stmt = stmt; + Result = result; + } + + public int Count() => sqlite3_column_count(stmt); /// /// Get the value of a number column as . @@ -49,7 +341,7 @@ public SQLiteStmt(sqlite3 db, string sql) /// Index starts from 0. /// The column to read the value from, starting from 0. /// The column value as . - public int ColumnInt(int index) => sqlite3_column_int(stmt, index); + public int Int(int index) => sqlite3_column_int(stmt, index); /// /// Get the value of a number column as . @@ -57,7 +349,7 @@ public SQLiteStmt(sqlite3 db, string sql) /// Index starts from 0. /// The column to read the value from, starting from 0. /// The column value as . - public long ColumnLong(int index) => sqlite3_column_int64(stmt, index); + public long Long(int index) => sqlite3_column_int64(stmt, index); /// /// Get the value of a text column as . @@ -65,60 +357,70 @@ public SQLiteStmt(sqlite3 db, string sql) /// Index starts from 0. /// The column to read the value from, starting from 0. /// The column value as . - public string ColumnText(int index) => sqlite3_column_text(stmt, index).utf8_to_string(); + public string Text(int index) => sqlite3_column_text(stmt, index).utf8_to_string(); /// - /// Get the value of a text/blobl column as a series of . + /// Get the value of a text/blob column as a series of . /// /// Index starts from 0. /// The column to read the value from, starting from 0. /// The column value as a series of . - public ReadOnlySpan ColumnBlob(int index) => sqlite3_column_blob(stmt, index); + public ReadOnlySpan Blob(int index) => sqlite3_column_blob(stmt, index); /// - /// Get the value of a text column deserialized to . + /// Get the value of a text/blob column and deserialize it to the specified type . /// - /// Index starts from 0. - /// The type to deserialize from. + /// The type to deserialize the column value to. /// The column to read the value from, starting from 0. - /// The serializer options to use. - /// An instance of or null. - /// - /// - public T? ColumnDeserialize(int index, JsonSerializerOptions? jsonOptions = null) + /// + /// The column value deserialized as , or null if the value is null. + /// + /// Thrown if the JSON is invalid. + /// Thrown if the type is not supported. + public T? Deserialize(int index) { - var bytes = ColumnBlob(index); - var value = JsonSerializer.Deserialize(bytes, jsonOptions); + var bytes = Blob(index); + var value = bytes.IsEmpty ? default : JsonSerializer.Deserialize(bytes, stmt); return value; } - #endregion - - /// - /// Execute the SQL statement. Depending on the SQL this - /// can be executed multiple times returning different results - /// for different sql statements. - /// - public int Step() => sqlite3_step(stmt); - - /// - /// Reset this back to the start - /// as if it was just created. - /// - /// This will lose all progress before reset was called. - public int Reset() => sqlite3_reset(stmt); - /// - /// Get the error message the database has currently emmited. + /// Get the value of a text/blob column as a . /// - /// The error message from the database. - public string Error() => sqlite3_errmsg(db).utf8_to_string(); + /// Index starts from 0. + /// The column to read the value from, starting from 0. + /// + /// The column value deserialized as a , or null if the value is null. + /// + /// Thrown if the JSON is invalid. + /// Thrown if the type is not supported. + public JsonDocument? DeserializeDocument(int index) + { + var bytes = Blob(index); + var value = bytes.IsEmpty ? default : JsonSerializer.Deserialize(bytes, stmt); + return value; + } /// - /// Finilize this statement. + /// Get the value of a text/blob column as a . /// - public void Dispose() + /// Index starts from 0. + /// The column to read the value from, starting from 0. + /// + /// The column value deserialized as a , or null if the value is null. + /// + /// Thrown if the JSON is invalid. + /// Thrown if the type is not supported. + public JsonElement? DeserializeElement(int index) { - sqlite3_finalize(stmt); + var bytes = Blob(index); + var value = bytes.IsEmpty ? default : JsonSerializer.Deserialize(bytes, stmt); + return value; } } + +#if NET9_0_OR_GREATER +internal delegate TResult SQLiteReaderFunc(TReader reader) where TReader : allows ref struct; +#else +internal delegate TResult SQLiteReaderFunc(TReader reader); +#endif diff --git a/src/NoSQLite/SQLiteTransaction.cs b/src/NoSQLite/SQLiteTransaction.cs index b3b7926..7fef055 100644 --- a/src/NoSQLite/SQLiteTransaction.cs +++ b/src/NoSQLite/SQLiteTransaction.cs @@ -1,8 +1,4 @@ -using SQLitePCL; - -namespace NoSQLite; - -using static SQLitePCL.raw; +namespace NoSQLite; internal ref struct SQLiteTransaction { diff --git a/src/NoSQLite/Utilities.cs b/src/NoSQLite/Utilities.cs index 08bc29d..1dc9aad 100644 --- a/src/NoSQLite/Utilities.cs +++ b/src/NoSQLite/Utilities.cs @@ -1,31 +1,122 @@ -using SQLitePCL; -using System.Runtime.CompilerServices; +using System.Runtime.CompilerServices; namespace NoSQLite; -using static SQLitePCL.raw; - -internal static class Throw +/// +/// Provides extension methods for SQLite result checking. +/// +internal static class Extensions { - public static void KeyNotFound(bool condition, [InterpolatedStringHandlerArgument("condition")] ref ConditionInterpolation message) + /// + /// Checks the SQLite result code and throws a if the result indicates an error. + /// + /// The SQLite database handle. + /// The SQLite result code to check. + /// The interpolated message to include in the exception if thrown. + /// The original if no error is detected. + /// Thrown when the result code indicates an error. + public static int CheckResult(this sqlite3 db, int result, [InterpolatedStringHandlerArgument("result")] ref SQLiteCodeInterpolation message) { - if (condition) + if (message.ShouldThrow) { - throw new KeyNotFoundException(message.ToString()); + throw new NoSQLiteException($"{message.ToString()}. SQLite info, code: {result}, message: {sqlite3_errmsg(db).utf8_to_string()}"); } + return result; } -} -internal static class Extensions -{ - public static void CheckResult(this sqlite3 db, int result, [InterpolatedStringHandlerArgument("result")] ref SQLiteCodeInterpolation message) + /// + /// Checks the SQLite result code and throws a if the result indicates an error. + /// + /// The SQLite result code to check. + /// The SQLite database handle. + /// The interpolated message to include in the exception if thrown. + /// The original if no error is detected. + /// Thrown when the result code indicates an error. + public static int CheckResult(this int result, sqlite3 db, [InterpolatedStringHandlerArgument("result")] ref SQLiteCodeInterpolation message) { if (message.ShouldThrow) { throw new NoSQLiteException($"{message.ToString()}. SQLite info, code: {result}, message: {sqlite3_errmsg(db).utf8_to_string()}"); } + return result; + } + + /// + /// Checks the SQLite result code and throws a if the result indicates an error. + /// + /// The SQLite result code to check. + /// The instance associated with the operation. + /// The interpolated message to include in the exception if thrown. + /// The original if no error is detected. + /// Thrown when the result code indicates an error. + public static int CheckResult(this int result, NoSQLiteConnection connection, [InterpolatedStringHandlerArgument("result")] ref SQLiteCodeInterpolation message) + { + if (message.ShouldThrow) + { + throw new NoSQLiteException($"{message.ToString()}. SQLite info, code: {result}, message: {sqlite3_errmsg(connection.db).utf8_to_string()}"); + } + return result; + } + + /// + /// Extracts the property name from a lambda expression representing a property accessor. + /// + /// The type containing the property. + /// The type of the property. + /// An expression representing a property accessor, e.g., x => x.Property. + /// The name of the property accessed in the expression. + /// Thrown when the expression does not represent a property access. + public static string GetPropertyName(this Expression> expression, JsonSerializerOptions? jsonOptions) + { + if (expression.Body is MemberExpression memberExpression) + { + return jsonOptions?.PropertyNamingPolicy?.ConvertName(memberExpression.Member.Name) ?? memberExpression.Member.Name; + } + + if (expression.Body is UnaryExpression unaryExpression && unaryExpression.Operand is MemberExpression operand) + { + return jsonOptions?.PropertyNamingPolicy?.ConvertName(operand.Member.Name) ?? operand.Member.Name; + } + + throw new ArgumentException("Invalid expression. Expected a property access expression.", nameof(expression)); } + /// + /// Extracts the full property path from a lambda expression representing a property accessor. + /// + /// The type containing the property. + /// The type of the property. + /// An expression representing a property accessor, e.g., x => x.Nested.Property. + /// The full path of the property accessed in the expression, e.g., "Nested.Property". + /// Thrown when the expression does not represent a property access. + public static string GetPropertyPath(this Expression> expression, JsonSerializerOptions? jsonOptions) + { + static string BuildPath(Expression? expr) + { + if (expr is MemberExpression memberExpression) + { + var parentPath = BuildPath(memberExpression.Expression); + return string.IsNullOrEmpty(parentPath) + ? memberExpression.Member.Name + : $"{parentPath}.{memberExpression.Member.Name}"; + } + + if (expr is UnaryExpression unaryExpression) + { + return BuildPath(unaryExpression.Operand); + } + + return string.Empty; + } + + var path = BuildPath(expression.Body); + if (string.IsNullOrEmpty(path)) + { + throw new ArgumentException("Invalid expression. Expected a property access expression.", nameof(expression)); + } + + return jsonOptions?.PropertyNamingPolicy?.ConvertName(path) ?? path; + } } [InterpolatedStringHandler] @@ -34,22 +125,20 @@ internal readonly ref struct SQLiteCodeInterpolation private readonly DefaultInterpolatedStringHandler _innerHandler; /// - /// Inidicates that the code is invalid and an exception - /// should be throwen with the message that has been created. + /// Indicates that the result code is invalid and an exception + /// should be thrown with the message that has been created. /// public bool ShouldThrow { get; } public SQLiteCodeInterpolation( int literalLength, int formattedCount, - int code, + int result, out bool shouldAppend) { // Here we determine which sqlite codes are OK. - // This is used in conjuction with the . - if (code == SQLITE_DONE || - code == SQLITE_OK || - code == SQLITE_ROW) + // This is used in conjunction with the "NoSQLiteException". + if (result is SQLITE_DONE or SQLITE_OK or SQLITE_ROW) { shouldAppend = false; ShouldThrow = false; @@ -70,7 +159,6 @@ public SQLiteCodeInterpolation( public string ToStringAndClear() => _innerHandler.ToStringAndClear(); } - [InterpolatedStringHandler] internal readonly ref struct ConditionInterpolation { diff --git a/src/NoSQLite/_usings.cs b/src/NoSQLite/_usings.cs new file mode 100644 index 0000000..537a873 --- /dev/null +++ b/src/NoSQLite/_usings.cs @@ -0,0 +1,5 @@ +global using SQLitePCL; +global using System.Collections; +global using System.Text.Json; +global using System.Linq.Expressions; +global using static SQLitePCL.raw; diff --git a/src/NoSQLite/sqlite3_mixins.cs b/src/NoSQLite/sqlite3_mixins.cs new file mode 100644 index 0000000..ba7d7ba --- /dev/null +++ b/src/NoSQLite/sqlite3_mixins.cs @@ -0,0 +1,68 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NoSQLite; + +/// +/// Provides extension methods for the sqlite3 type to simplify database creation and disposal. +/// +[SuppressMessage("Style", "IDE1006:Naming Styles", Justification = "Matching the lib name.")] +public static class sqlite3_mixins +{ + extension(sqlite3) + { + /// + /// Creates or opens an SQLite database at the specified path. + /// Optionally enables Write-Ahead Logging (WAL) mode. + /// + /// The file path to the SQLite database. + /// If , enables WAL journal mode. Default is . + /// An instance of representing the opened database. + /// Thrown if is or empty. + /// Thrown if the database could not be opened. + public static sqlite3 Create(string databasePath, bool useWal = true) + { + ArgumentException.ThrowIfNullOrEmpty(databasePath); + + var result = sqlite3_open(databasePath, out var db); + db.CheckResult(result, $"Could not open or create database file: {databasePath}"); + + if (useWal) + { + sqlite3_exec(db, "PRAGMA journal_mode=WAL;"); + } + return db; + } + } + + extension(sqlite3 db) + { + /// + /// Executes a write-ahead log (WAL) checkpoint for the database. + /// + /// See for more info. + public void Checkpoint(string name) + { + sqlite3_wal_checkpoint(db, name); + } + + /// + /// Executes a write-ahead log (WAL) checkpoint for the database. + /// + /// See for more info. + public (int LogSize, int FramesCheckPointed) CheckpointV2(string name, int eMode) + { + sqlite3_wal_checkpoint_v2(db, name, eMode, out var logSize, out var framesCheckPointed); + return (logSize, framesCheckPointed); + } + + /// + /// Closes and disposes the specified sqlite3 database connection. + /// + /// The sqlite3 database connection to dispose. + public void CloseAndDispose() + { + sqlite3_close_v2(db); + db.Dispose(); + } + } +} diff --git a/test/NoSQLite.Test/Abstractions/ModuleInitializer.cs b/test/NoSQLite.Test/Abstractions/ModuleInitializer.cs deleted file mode 100644 index a7281bf..0000000 --- a/test/NoSQLite.Test/Abstractions/ModuleInitializer.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Runtime.CompilerServices; - -namespace NoSQLite.Test.Collections; - -public static class ModuleInitializer -{ - [ModuleInitializer] - public static void Initialize() - { - Bogus.Randomizer.Seed = new Random(420690001); - } -} diff --git a/test/NoSQLite.Test/Abstractions/TestBase.cs b/test/NoSQLite.Test/Abstractions/TestBase.cs deleted file mode 100644 index 5c58085..0000000 --- a/test/NoSQLite.Test/Abstractions/TestBase.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Runtime.CompilerServices; - -namespace NoSQLite.Test.Abstractions; - -public abstract class TestBase : IClassFixture> -{ - protected TestFixture Fixture { get; } - - public TestBase(TestFixture fixture) - { - Fixture = fixture; - } - - public NoSQLiteTable GetTable([CallerMemberName] string? caller = null) - { - return Fixture.Connection.GetTable($"{caller}_{Guid.NewGuid()}"); - } - - public NoSQLiteTable GetTable(IDictionary initPairs, [CallerMemberName] string? caller = null) - { - var table = GetTable(caller); - table.InsertMany(initPairs); - return table; - } -} diff --git a/test/NoSQLite.Test/Abstractions/TestFixture.cs b/test/NoSQLite.Test/Abstractions/TestFixture.cs deleted file mode 100644 index d605e8c..0000000 --- a/test/NoSQLite.Test/Abstractions/TestFixture.cs +++ /dev/null @@ -1,37 +0,0 @@ -namespace NoSQLite.Test.Abstractions; - -public sealed class TestFixture : IAsyncLifetime -{ - public string Path { get; private set; } = null!; - - public NoSQLiteConnection Connection { get; private set; } = null!; - - public bool Delete { get; set; } = true; - - public Task InitializeAsync() - { - Path = System.IO.Path.Combine(Environment.CurrentDirectory, $"{typeof(TTestClass).Name}.sqlite3"); - - if (File.Exists(Path)) - { - File.Delete(Path); - } - - Connection = new NoSQLiteConnection(Path); - - Assert.True(File.Exists(Connection.Path)); - return Task.CompletedTask; - } - - public Task DisposeAsync() - { - Connection.Dispose(); - Assert.Equal(0, Connection.Tables.Count); - - if (Delete) - { - File.Delete(Path); - } - return Task.CompletedTask; - } -} diff --git a/test/NoSQLite.Test/CRUD.cs b/test/NoSQLite.Test/CRUD.cs deleted file mode 100644 index d445023..0000000 --- a/test/NoSQLite.Test/CRUD.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System.Text.Json; - -namespace NoSQLite.Test; - -public class CRUD : TestBase -{ - private readonly Dictionary seed; - - public CRUD(TestFixture fixture) : base(fixture) - { - seed = new PersonFaker().Generate(10).ToDictionary(x => x.Email); - } - - [Fact] - public void Insert() - { - var table = GetTable(); - - table.Insert("0", seed.First()); - } - - [Fact] - public void InsertMany() - { - var table = GetTable(); - var peopleBytes = seed.ToDictionary(x => x.Key, pair => JsonSerializer.SerializeToUtf8Bytes(pair.Value)); - - table.InsertMany(seed); - table.InsertBytesMany(peopleBytes); - } - - [Fact] - public void Count() - { - var table = GetTable(seed); - - var count = table.Count(); - var longCount = table.LongCount(); - - count.Should().Be(seed.Count); - longCount.Should().Be(seed.Count); - } - - [Fact] - public void All() - { - var table = GetTable(seed); - - var people = table.All(); - var peopleBytes = table.AllBytes(); - - people.Should().BeEquivalentTo(seed.Values); - peopleBytes.Select(p => JsonSerializer.Deserialize(p)).Should().BeEquivalentTo(seed.Values); - } - - [Fact] - public void Exists() - { - var table = GetTable(seed); - var person = seed.First(); - - var exists = table.Exists(person.Key); - - exists.Should().BeTrue(); - } - - [Fact] - public void Find() - { - var table = GetTable(seed); - var pair = seed.First(); - var person = pair.Value; - - var found = table.Find(pair.Key); - var foundBytes = table.FindBytes(pair.Key); - - found.Should().Be(person); - JsonSerializer.Deserialize(foundBytes).Should().Be(person); - } - - [Fact] - public void FindMany() - { - var table = GetTable(seed); - - var people = table.FindMany(seed.Keys); - var peopleBytes = table.FindBytesMany(seed.Keys); - - people.Should().BeEquivalentTo(seed.Values); - peopleBytes.Select(p => JsonSerializer.Deserialize(p)).Should().BeEquivalentTo(seed.Values); - } - - [Fact] - public void FindPairs() - { - var table = GetTable(seed); - - var people = table.FindPairs(seed.Keys); - var peopleBytes = table.FindBytesPairs(seed.Keys); - - people.Should().BeEquivalentTo(seed); - peopleBytes.ToDictionary(p => p.Key, p => JsonSerializer.Deserialize(p.Value)).Should().BeEquivalentTo(seed); - } -} diff --git a/test/NoSQLite.Test/Connection.cs b/test/NoSQLite.Test/Connection.cs new file mode 100644 index 0000000..727815a --- /dev/null +++ b/test/NoSQLite.Test/Connection.cs @@ -0,0 +1,23 @@ +namespace NoSQLite.Test; + +public sealed class Connection : TestBase +{ + [Test] + [Arguments("MyDocuments")] + [Arguments("123 My Documents")] + [Arguments("!@# -- __ -- // --")] + [Arguments(" ")] + [Arguments("")] + public async Task Create_And_Drop_Table(string tableName) + { + await That(Connection.TableExists(tableName)).IsFalse(); + + Connection.CreateTable(tableName); + + await That(Connection.TableExists(tableName)).IsTrue(); + + Connection.DropTable(tableName); + + await That(Connection.TableExists(tableName)).IsFalse(); + } +} diff --git a/test/NoSQLite.Test/Data/Person.cs b/test/NoSQLite.Test/Data/Person.cs deleted file mode 100644 index ddd9a6c..0000000 --- a/test/NoSQLite.Test/Data/Person.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Bogus; - -namespace NoSQLite.Test.Data; - -public sealed record Person -{ - public string Email { get; set; } - - public string Name { get; set; } - - public string Surname { get; set; } - - public string Phone { get; set; } - - public DateOnly Birthdate { get; set; } -} - -public sealed class PersonFaker : Faker -{ - public PersonFaker() - { - RuleFor(x => x.Email, f => f.Person.Email); - RuleFor(x => x.Name, f => f.Person.FirstName); - RuleFor(x => x.Surname, f => f.Person.LastName); - RuleFor(x => x.Phone, f => f.Person.Phone); - RuleFor(x => x.Birthdate, f => f.Date.PastDateOnly(20)); - } -} \ No newline at end of file diff --git a/test/NoSQLite.Test/Data/Animal.cs b/test/NoSQLite.Test/Data/TestAnimal.cs similarity index 82% rename from test/NoSQLite.Test/Data/Animal.cs rename to test/NoSQLite.Test/Data/TestAnimal.cs index 96481e1..4c0fa55 100644 --- a/test/NoSQLite.Test/Data/Animal.cs +++ b/test/NoSQLite.Test/Data/TestAnimal.cs @@ -6,6 +6,6 @@ namespace NoSQLite.Test.Data; -public sealed record Animal +public sealed record TestAnimal { } diff --git a/test/NoSQLite.Test/Data/Faction.cs b/test/NoSQLite.Test/Data/TestFaction.cs similarity index 82% rename from test/NoSQLite.Test/Data/Faction.cs rename to test/NoSQLite.Test/Data/TestFaction.cs index 12eba24..d2bafa2 100644 --- a/test/NoSQLite.Test/Data/Faction.cs +++ b/test/NoSQLite.Test/Data/TestFaction.cs @@ -6,6 +6,6 @@ namespace NoSQLite.Test.Data; -public sealed record Faction +public sealed record TestFaction { } diff --git a/test/NoSQLite.Test/Data/TestPerson.cs b/test/NoSQLite.Test/Data/TestPerson.cs new file mode 100644 index 0000000..28e9c3e --- /dev/null +++ b/test/NoSQLite.Test/Data/TestPerson.cs @@ -0,0 +1,35 @@ +namespace NoSQLite.Test.Data; + +public sealed record TestPerson +{ + public int Id { get; set; } + + public string Email { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + + public string Surname { get; set; } = string.Empty; + + public string Phone { get; set; } = string.Empty; + + public DateOnly Birthdate { get; set; } + + public bool Sane { get; set; } = true; + + public string? Nonce { get; set; } + + public string? Nonce2 { get; set; } +} + +public sealed class PersonFaker : Faker +{ + public PersonFaker() + { + RuleFor(x => x.Id, f => f.IndexFaker); + RuleFor(x => x.Email, f => f.Person.Email); + RuleFor(x => x.Name, f => f.Person.FirstName); + RuleFor(x => x.Surname, f => f.Person.LastName); + RuleFor(x => x.Phone, f => f.Person.Phone); + RuleFor(x => x.Birthdate, f => f.Date.PastDateOnly(20)); + } +} \ No newline at end of file diff --git a/test/NoSQLite.Test/Data/Vehicle.cs b/test/NoSQLite.Test/Data/TestVehicle.cs similarity index 53% rename from test/NoSQLite.Test/Data/Vehicle.cs rename to test/NoSQLite.Test/Data/TestVehicle.cs index 526a374..77be285 100644 --- a/test/NoSQLite.Test/Data/Vehicle.cs +++ b/test/NoSQLite.Test/Data/TestVehicle.cs @@ -1,5 +1,5 @@ namespace NoSQLite.Test.Data; -public sealed record Vehicle +public sealed record TestVehicle { } diff --git a/test/NoSQLite.Test/Indexing.cs b/test/NoSQLite.Test/Indexing.cs deleted file mode 100644 index a5f2461..0000000 --- a/test/NoSQLite.Test/Indexing.cs +++ /dev/null @@ -1,60 +0,0 @@ -namespace NoSQLite.Test; - -public sealed class Indexing : TestBase -{ - public Indexing(TestFixture fixture) : base(fixture) - { - } - - [Fact] - public void Exists() - { - var table = GetTable(); - - var create = table.CreateIndex("test", "email"); - create.Should().BeTrue(); - - var exists = table.IndexExists("test"); - var notExists = table.IndexExists("test_1"); - - exists.Should().BeTrue(); - notExists.Should().BeFalse(); - } - - [Fact] - public void Create() - { - var table = GetTable(); - - var notExists = table.CreateIndex("test", "email"); - var exists = table.CreateIndex("test", "email"); - - notExists.Should().BeTrue(); - exists.Should().BeFalse(); - } - - [Fact] - public void Recreate() - { - var table = GetTable(); - - table.RecreateIndex("test", "email"); - - table.IndexExists("test").Should().BeTrue(); - } - - [Fact] - public void Delete() - { - var table = GetTable(); - - var create = table.CreateIndex("test", "email"); - create.Should().BeTrue(); - - var exists = table.DeleteIndex("test"); - var notExists = table.DeleteIndex("test"); - - exists.Should().BeTrue(); - notExists.Should().BeFalse(); - } -} diff --git a/test/NoSQLite.Test/NoSQLite.Test.csproj b/test/NoSQLite.Test/NoSQLite.Test.csproj index 2d31bd1..82733f3 100644 --- a/test/NoSQLite.Test/NoSQLite.Test.csproj +++ b/test/NoSQLite.Test/NoSQLite.Test.csproj @@ -1,17 +1,14 @@  - net7.0 + net8.0;net9.0;net10.0 false - - - - - - + + + diff --git a/test/NoSQLite.Test/Table.cs b/test/NoSQLite.Test/Table.cs new file mode 100644 index 0000000..12d5ff2 --- /dev/null +++ b/test/NoSQLite.Test/Table.cs @@ -0,0 +1,177 @@ +namespace NoSQLite.Test; + +[MethodDataSource(nameof(Arguments))] +public sealed class Table : TestBase +{ + public static IEnumerable> Arguments() => + [ + () => null, + () => JsonSerializerOptions.Default, +#if NET9_0_OR_GREATER + () => new(JsonSerializerOptions.Web) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }, +#else + () => new(JsonSerializerOptions.Default) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }, +#endif + ]; + + public Table(JsonSerializerOptions? jsonOptions) : base(jsonOptions) + { + } + + [Test] + public async Task CRUD() + { + var people = new PersonFaker().Generate(10); + var table = Connection.GetTable("crud"); + + // Insert + foreach (var person in people) + { + table.Add(person); + } + + // Count, LongCount, All + await That(table.Count()).IsEqualTo(10); + await That(table.LongCount()).IsEqualTo(10); + await That(table.All().Length).IsEqualTo(10); + + // Exists + var id = 5; + await That(table.Exists(p => p.Id, id)).IsTrue(); + await That(table.Exists(p => p.Id, 15)).IsFalse(); + + // Find + await That(() => table.Find(p => p.Id, 15)).Throws().WithInnerException().And.IsTypeOf(); + var person5 = await That(table.Find(p => p.Id, id)).IsNotNull(); + + // Update + person5!.Name = "test"; + table.Update(person5, p => p.Id); + + // Select + await That(table.FindProperty(p => p.Id, p => p.Name, id)).IsEqualTo("test"); + + // Insert/Replace/Set + // Check conditions for where the value doesnt exist works only when options are set to not writting null + if (Connection.JsonOptions?.DefaultIgnoreCondition is JsonIgnoreCondition.WhenWritingNull) + { + // should not replace because it doesnt exist + table.Replace(p => p.Id, p => p.Nonce, id, "r"); + await That(table.FindProperty(p => p.Id, p => p.Nonce, id)).IsEqualTo(null); + + // should insert because it doesnt exist + table.Insert(p => p.Id, p => p.Nonce, id, "inserted"); + await That(table.FindProperty(p => p.Id, p => p.Nonce, id)).IsEqualTo("inserted"); + + // should replace beacuse it exists + table.Replace(p => p.Id, p => p.Nonce, id, "replaced"); + await That(table.FindProperty(p => p.Id, p => p.Nonce, id)).IsEqualTo("replaced"); + + // should set the value even if it deosnt exist + table.Set(p => p.Id, p => p.Nonce2, id, "aaaaa"); + await That(table.FindProperty(p => p.Id, p => p.Nonce2, id)).IsEqualTo("aaaaa"); + } + + // Replace + table.Replace(p => p.Id, p => p.Sane, id, false); + await That(table.FindProperty(p => p.Id, p => p.Sane, id)).IsFalse(); + + // Set (should set regardles of existances or not) + table.Set(p => p.Id, p => p.Name, id, "test from set"); + await That(table.FindProperty(p => p.Id, p => p.Name, id)).IsEqualTo("test from set"); + + // Delete, (Assert) Exists, Count, LongCount, All + table.Delete(p => p.Id, id); + await That(table.Exists(p => p.Id, id)).IsFalse(); + await That(table.Count()).IsEqualTo(9); + await That(table.LongCount()).IsEqualTo(9); + await That(table.All().Length).IsEqualTo(9); + + // Clear, (Assert) Count, LongCount, All + table.Clear(); + await That(table.Count()).IsEqualTo(0); + await That(table.LongCount()).IsEqualTo(0); + await That(table.All().Length).IsEqualTo(0); + } + + [Test] + [Arguments("MyIndex")] + [Arguments("123 My Index")] + [Arguments("!@# -- __ -- // --")] + [Arguments(" ")] + [Arguments("")] + public async Task Index(string indexName) + { + var tableName = "index"; + var table = Connection.GetTable(tableName); + var propertyPath = Extensions.GetPropertyPath(p => p.Id, Connection.JsonOptions); + + await That(table.IndexExists(indexName)).IsFalse(); + + table.CreateIndex(p => p.Id, indexName); + + await That(table.IndexExists(indexName)).IsTrue(); + + // test plan index + using var planStmt = table.NewStmt($""" + EXPLAIN QUERY PLAN + SELECT * + FROM "{tableName}" + WHERE "documents"->'$.{propertyPath}' = '10'; + """); + + var result = planStmt.Execute(null, r => r.Text(3)); + await That(result).IsEqualTo($"SEARCH {tableName} USING INDEX {tableName}_{indexName} (=?)"); + + table.DeleteIndex(indexName); + + await That(table.IndexExists(indexName)).IsFalse(); + } + + [Test] + public async Task Index_Unique() + { + var indexName = "id"; + var tableName = "unique"; + var table = Connection.GetTable(tableName); + var propertyPath = Extensions.GetPropertyPath(p => p.Id, Connection.JsonOptions); + + await That(table.IndexExists(indexName)).IsFalse(); + + table.CreateIndex(p => p.Id, indexName, unique: true); + + await That(table.IndexExists(indexName)).IsTrue(); + + // test plan index + using var planStmt = table.NewStmt($""" + EXPLAIN QUERY PLAN + SELECT * + FROM "{tableName}" + WHERE "documents"->'$.{propertyPath}' = '10'; + """); + + var result = planStmt.Execute(null, r => r.Text(3)); + await That(result).IsEqualTo($"SEARCH {tableName} USING INDEX {tableName}_{indexName} (=?)"); + + // insert two times + var personFaker = new PersonFaker(); + var person = personFaker.Generate(); + await That(() => table.Add(person)).ThrowsNothing(); + + // second time throws + await That(() => table.Add(person)).Throws(); + + // count remains only one person + await That(table.Count()).IsEqualTo(1); + + // delete index + table.DeleteIndex(indexName); + await That(table.IndexExists(indexName)).IsFalse(); + + // insert doesnt throw + await That(() => table.Add(person)).ThrowsNothing(); + + // count goes up to two people + await That(table.Count()).IsEqualTo(2); + } +} diff --git a/test/NoSQLite.Test/Transactions.cs b/test/NoSQLite.Test/Transactions.cs new file mode 100644 index 0000000..8d3329b --- /dev/null +++ b/test/NoSQLite.Test/Transactions.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace NoSQLite.Test; + +internal class Transactions +{ +} diff --git a/test/NoSQLite.Test/_setup.cs b/test/NoSQLite.Test/_setup.cs new file mode 100644 index 0000000..98a6a54 --- /dev/null +++ b/test/NoSQLite.Test/_setup.cs @@ -0,0 +1,74 @@ +using SQLitePCL; +using System.Runtime.CompilerServices; + +namespace NoSQLite.Test; + +public static class ModuleInitializer +{ + [ModuleInitializer] + public static void Initialize() + { + Randomizer.Seed = new Random(420690001); + } +} + +public abstract class TestBase +{ + protected string DbPath { get; private set; } = null!; + + protected sqlite3 Db { get; private set; } = null!; + + protected NoSQLiteConnection Connection { get; private set; } = null!; + + protected JsonSerializerOptions? JsonOptions { get; } + + private bool Delete { get; } = true; + + protected TestBase(JsonSerializerOptions? jsonOptions = null) + { + JsonOptions = jsonOptions; + } + + [Before(HookType.Test)] + public async Task BeforeAsync() + { + var dir = Path.Combine(Environment.CurrentDirectory, "databases"); + var now = TimeProvider.System.GetTimestamp(); + DbPath = Path.Combine(dir, $"{GetType().Name}_{now}.sqlite3"); + + if (!Directory.Exists(dir)) + { + Directory.CreateDirectory(dir); + } + if (Delete && File.Exists(DbPath)) + { + File.Delete(DbPath); + } + + Batteries_V2.Init(); + Db = sqlite3.Create(DbPath, useWal: true); + Connection = new NoSQLiteConnection(Db, JsonOptions); + + await That(File.Exists(DbPath)).IsTrue(); + } + + public NoSQLiteTable GetTable([CallerMemberName] string? caller = null) + { + return Connection.GetTable($"{caller}"); + } + + [After(HookType.Test)] + public async Task AfterAsync() + { + Connection.Dispose(); + Db.CloseAndDispose(); + await That(Connection.Tables.Count).IsEqualTo(0); + await That(File.Exists($"{DbPath}-shm")).IsFalse(); + await That(File.Exists($"{DbPath}-wal")).IsFalse(); + + if (Delete) + { + File.Delete(DbPath); + } + } +} diff --git a/test/NoSQLite.Test/_usings.cs b/test/NoSQLite.Test/_usings.cs index 9667401..3d97e9c 100644 --- a/test/NoSQLite.Test/_usings.cs +++ b/test/NoSQLite.Test/_usings.cs @@ -1,4 +1,6 @@ -global using FluentAssertions; -global using NoSQLite.Test.Abstractions; +global using System.Text.Json; +global using System.Text.Json.Serialization; global using NoSQLite.Test.Data; -global using Xunit; +global using Bogus; +global using TUnit; +global using static TUnit.Assertions.Assert; diff --git a/version.json b/version.json deleted file mode 100644 index 0d07bfd..0000000 --- a/version.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "0.3-beta.{height}", - "pathFilters": [ - ":/src" - ], - "publicReleaseRefSpec": [ - "^refs/heads/main$" - ], - "nugetPackageVersion": { - "semVer": 2 - }, - "cloudBuild": { - "setVersionVariables": true, - "buildNumber": { - "enabled": false - } - } -} \ No newline at end of file