From cd7f99d735816ee03e3df72e1f0e3145f98d154f Mon Sep 17 00:00:00 2001 From: Hermes Ferreira Date: Wed, 10 Jul 2024 23:14:35 +0200 Subject: [PATCH] feat: first alpha version --- .docker/docker-compose.yml | 10 + .editorconfig | 9 + .github/workflows/ci.yml | 101 ++ .gitignore | 879 ++++++++++++++++++ README.md | 53 +- Taskfile.yml | 17 + libsql-http-client-dotnet.sln | 50 + libsql-pkg-icon.png | Bin 0 -> 9825 bytes .../LibSql.Http.Client.Benchmarks.csproj | 19 + .../LibSqlReaderBenchmarks.cs | 27 + src/LibSql.Http.Client.Benchmarks/Program.cs | 4 + .../LibSql.Http.Client.DemoConsoleApp.csproj | 19 + .../Program.cs | 79 ++ .../Buffer/PooledByteBufferWriter.cs | 99 ++ .../Exceptions/LibSqlHttpClientException.cs | 34 + .../Interfaces/ILibSqlHttpClient.cs | 148 +++ .../Interfaces/IResultReader.cs | 59 ++ .../LibSql.Http.Client.csproj | 55 ++ src/LibSql.Http.Client/LibSqlHttpClient.cs | 265 ++++++ .../Request/RequestSerializer.cs | 253 +++++ src/LibSql.Http.Client/Request/Statement.cs | 49 + .../Request/TransactionMode.cs | 24 + .../Response/ExecutionError.cs | 8 + .../Response/ExecutionStats.cs | 18 + .../Response/ResultReader.cs | 409 ++++++++ .../Buffer/PooledByteBufferWriterTests.cs | 51 + .../Data/batch-response-multiple-results.json | 182 ++++ .../Data/batch-response.json | 293 ++++++ test/LibSql.Http.Client.Tests/Data/batch.json | 603 ++++++++++++ ...ute-response-no-error-multiple-result.json | 98 ++ .../execute-response-no-error-no-result.json | 45 + ...ecute-response-no-error-single-result.json | 80 ++ .../Data/execute-response-with-error.json | 36 + .../Data/execute-response.json | 142 +++ .../Data/execute.json | 208 +++++ .../Integration/Assets/product-image.png | Bin 0 -> 4152 bytes .../Integration/Fixture/ProductTestData.cs | 66 ++ .../Integration/InsertCommandsTests.cs | 63 ++ .../IntegrationTestsSerializerContext.cs | 8 + .../Integration/Models/ProductTestModel.cs | 12 + .../Integration/RollbackTests.cs | 47 + .../Integration/SelectCommandsTests.cs | 63 ++ .../Integration/TestWithContainersBase.cs | 52 ++ .../LibSql.Http.Client.Tests.csproj | 62 ++ .../LibSqlHttpClientTestsDefault.cs | 261 ++++++ .../Request/RequestSerializerTests.cs | 150 +++ .../Response/ResultReaderTests.cs | 83 ++ .../Attributes/JsonFileDataAttribute.cs | 51 + .../Shared/MockedJsonHttpRequestHandler.cs | 48 + .../Shared/Models/HranaPipelineRequestBody.cs | 47 + .../Shared/Models/ResultReaderTestScenario.cs | 19 + .../Shared/Models/ResultSetTestModel.cs | 8 + .../Models/SerializationTestScenario.cs | 19 + .../Shared/Models/TestCaseStatement.cs | 34 + .../Models/TestDataJsonSerializerContext.cs | 13 + 55 files changed, 5531 insertions(+), 1 deletion(-) create mode 100644 .docker/docker-compose.yml create mode 100644 .editorconfig create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Taskfile.yml create mode 100644 libsql-http-client-dotnet.sln create mode 100644 libsql-pkg-icon.png create mode 100644 src/LibSql.Http.Client.Benchmarks/LibSql.Http.Client.Benchmarks.csproj create mode 100644 src/LibSql.Http.Client.Benchmarks/LibSqlReaderBenchmarks.cs create mode 100644 src/LibSql.Http.Client.Benchmarks/Program.cs create mode 100644 src/LibSql.Http.Client.DemoConsoleApp/LibSql.Http.Client.DemoConsoleApp.csproj create mode 100644 src/LibSql.Http.Client.DemoConsoleApp/Program.cs create mode 100644 src/LibSql.Http.Client/Buffer/PooledByteBufferWriter.cs create mode 100644 src/LibSql.Http.Client/Exceptions/LibSqlHttpClientException.cs create mode 100644 src/LibSql.Http.Client/Interfaces/ILibSqlHttpClient.cs create mode 100644 src/LibSql.Http.Client/Interfaces/IResultReader.cs create mode 100644 src/LibSql.Http.Client/LibSql.Http.Client.csproj create mode 100644 src/LibSql.Http.Client/LibSqlHttpClient.cs create mode 100644 src/LibSql.Http.Client/Request/RequestSerializer.cs create mode 100644 src/LibSql.Http.Client/Request/Statement.cs create mode 100644 src/LibSql.Http.Client/Request/TransactionMode.cs create mode 100644 src/LibSql.Http.Client/Response/ExecutionError.cs create mode 100644 src/LibSql.Http.Client/Response/ExecutionStats.cs create mode 100644 src/LibSql.Http.Client/Response/ResultReader.cs create mode 100644 test/LibSql.Http.Client.Tests/Buffer/PooledByteBufferWriterTests.cs create mode 100644 test/LibSql.Http.Client.Tests/Data/batch-response-multiple-results.json create mode 100644 test/LibSql.Http.Client.Tests/Data/batch-response.json create mode 100644 test/LibSql.Http.Client.Tests/Data/batch.json create mode 100644 test/LibSql.Http.Client.Tests/Data/execute-response-no-error-multiple-result.json create mode 100644 test/LibSql.Http.Client.Tests/Data/execute-response-no-error-no-result.json create mode 100644 test/LibSql.Http.Client.Tests/Data/execute-response-no-error-single-result.json create mode 100644 test/LibSql.Http.Client.Tests/Data/execute-response-with-error.json create mode 100644 test/LibSql.Http.Client.Tests/Data/execute-response.json create mode 100644 test/LibSql.Http.Client.Tests/Data/execute.json create mode 100644 test/LibSql.Http.Client.Tests/Integration/Assets/product-image.png create mode 100644 test/LibSql.Http.Client.Tests/Integration/Fixture/ProductTestData.cs create mode 100644 test/LibSql.Http.Client.Tests/Integration/InsertCommandsTests.cs create mode 100644 test/LibSql.Http.Client.Tests/Integration/Models/IntegrationTestsSerializerContext.cs create mode 100644 test/LibSql.Http.Client.Tests/Integration/Models/ProductTestModel.cs create mode 100644 test/LibSql.Http.Client.Tests/Integration/RollbackTests.cs create mode 100644 test/LibSql.Http.Client.Tests/Integration/SelectCommandsTests.cs create mode 100644 test/LibSql.Http.Client.Tests/Integration/TestWithContainersBase.cs create mode 100644 test/LibSql.Http.Client.Tests/LibSql.Http.Client.Tests.csproj create mode 100644 test/LibSql.Http.Client.Tests/LibSqlHttpClientTestsDefault.cs create mode 100644 test/LibSql.Http.Client.Tests/Request/RequestSerializerTests.cs create mode 100644 test/LibSql.Http.Client.Tests/Response/ResultReaderTests.cs create mode 100644 test/LibSql.Http.Client.Tests/Shared/Attributes/JsonFileDataAttribute.cs create mode 100644 test/LibSql.Http.Client.Tests/Shared/MockedJsonHttpRequestHandler.cs create mode 100644 test/LibSql.Http.Client.Tests/Shared/Models/HranaPipelineRequestBody.cs create mode 100644 test/LibSql.Http.Client.Tests/Shared/Models/ResultReaderTestScenario.cs create mode 100644 test/LibSql.Http.Client.Tests/Shared/Models/ResultSetTestModel.cs create mode 100644 test/LibSql.Http.Client.Tests/Shared/Models/SerializationTestScenario.cs create mode 100644 test/LibSql.Http.Client.Tests/Shared/Models/TestCaseStatement.cs create mode 100644 test/LibSql.Http.Client.Tests/Shared/Models/TestDataJsonSerializerContext.cs diff --git a/.docker/docker-compose.yml b/.docker/docker-compose.yml new file mode 100644 index 0000000..8bef73f --- /dev/null +++ b/.docker/docker-compose.yml @@ -0,0 +1,10 @@ +services: + libsql-server: + image: ghcr.io/tursodatabase/libsql-server:latest + ports: + - "9080:8080" + volumes: + - libsql-server-data:/var/lib/sqld + +volumes: + libsql-server-data: diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..427c62d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ + +[*] + +# ReSharper properties +resharper_csharp_wrap_after_declaration_lpar = true +resharper_csharp_wrap_after_invocation_lpar = true +resharper_csharp_wrap_arguments_style = chop_if_long +resharper_csharp_wrap_parameters_style = chop_if_long +resharper_place_expr_method_on_single_line = if_owner_is_single_line diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cc1b6da --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,101 @@ +name: Continuous Integration + +on: + workflow_dispatch: + inputs: + run-publish: + description: "Run publish job" + required: false + default: false + type: boolean + push: + pull_request: + branches: + - main + release: + types: + - published + +env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 + DOTNET_NOLOGO: true + +jobs: + build: + runs-on: ubuntu-latest + outputs: + packed-files: ${{ steps.packed-files.outputs.result }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 6.0.x + 7.0.x + 8.0.x + + - name: Restore dependencies + run: dotnet restore + + - name: Build project + run: dotnet build --configuration Release --no-restore + + - name: Run tests + run: dotnet test --configuration Release --collect:"XPlat Code Coverage;Format=opencover,lcov,cobertura" --logger trx --no-build + + - name: Report results + uses: bibipkins/dotnet-test-reporter@v1.4.1 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + comment-title: "Unit Test Results" + results-path: ./test/**/TestResults/*.trx + coverage-path: ./test/**/TestResults/**/coverage.opencover.xml + coverage-threshold: 80 + + - run: dotnet pack --configuration Release --output ${{ github.workspace }}/nuget + + # Publish the NuGet package as an artifact, so they can be used in the following jobs + - uses: actions/upload-artifact@v4 + with: + name: nuget + if-no-files-found: error + retention-days: 7 + path: ${{ github.workspace }}/nuget/*.nupkg + + - uses: actions/github-script@v7 + id: packed-files + with: + script: | + const globber = await glob.create('${{ github.workspace }}/nuget/*.nupkg') + const files = await globber.glob() + return JSON.stringify(files.map(file => file.replace('${{ github.workspace }}/nuget/', ''))) + result-encoding: string + + publish: + if: ${{ github.event_name == 'release' || github.event.inputs.run-publish }} + runs-on: ubuntu-latest + name: Publish ${{ matrix.package }} + needs: build + strategy: + matrix: + package: ${{fromJson(needs.build.outputs.packed-files)}} + steps: + - uses: actions/download-artifact@v4 + with: + name: nuget + path: ${{ github.workspace }}/nuget + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 6.0.x + 7.0.x + 8.0.x + + - name: Publish NuGet package + run: dotnet nuget push ${{ github.workspace }}/nuget/${{matrix.package}} --api-key "${{ secrets.NUGET_API_KEY }}" --source https://api.nuget.org/v3/index.json --skip-duplicate diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2030a80 --- /dev/null +++ b/.gitignore @@ -0,0 +1,879 @@ +# Created by https://www.toptal.com/developers/gitignore/api/dotnetcore,rider,intellij,jetbrains,macos,visualstudiocode,visualstudio,csharp +# Edit at https://www.toptal.com/developers/gitignore?templates=dotnetcore,rider,intellij,jetbrains,macos,visualstudiocode,visualstudio,csharp + +### Csharp ### +## 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 + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# 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 + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml + +### DotnetCore ### +# .NET Core build folders +bin/ +obj/ + +# Common node modules locations +/node_modules +/wwwroot/node_modules + +### Intellij ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Intellij Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +# https://plugins.jetbrains.com/plugin/7973-sonarlint +.idea/**/sonarlint/ + +# SonarQube Plugin +# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator-enh.xml +.idea/**/markdown-navigator/ + +# Cache file creation bug +# See https://youtrack.jetbrains.com/issue/JBR-2257 +.idea/$CACHE_FILE$ + +# CodeStream plugin +# https://plugins.jetbrains.com/plugin/12206-codestream +.idea/codestream.xml + +# Azure Toolkit for IntelliJ plugin +# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij +.idea/**/azureSettings.xml + +.idea + +### JetBrains ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff + +# AWS User-specific + +# Generated files + +# Sensitive or high-churn files + +# Gradle + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake + +# Mongo Explorer plugin + +# File-based project format + +# IntelliJ + +# mpeltonen/sbt-idea plugin + +# JIRA plugin + +# Cursive Clojure plugin + +# SonarLint plugin + +# Crashlytics plugin (for Android Studio and IntelliJ) + +# Editor-based Rest Client + +# Android studio 3.1+ serialized cache file + +### JetBrains Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +# https://plugins.jetbrains.com/plugin/7973-sonarlint + +# SonarQube Plugin +# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin + +# Markdown Navigator plugin +# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced + +# Cache file creation bug +# See https://youtrack.jetbrains.com/issue/JBR-2257 + +# CodeStream plugin +# https://plugins.jetbrains.com/plugin/12206-codestream + +# Azure Toolkit for IntelliJ plugin +# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Rider ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff + +# AWS User-specific + +# Generated files + +# Sensitive or high-churn files + +# Gradle + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake + +# Mongo Explorer plugin + +# File-based project format + +# IntelliJ + +# mpeltonen/sbt-idea plugin + +# JIRA plugin + +# Cursive Clojure plugin + +# SonarLint plugin + +# Crashlytics plugin (for Android Studio and IntelliJ) + +# Editor-based Rest Client + +# Android studio 3.1+ serialized cache file + +### VisualStudioCode ### +!.vscode/*.code-snippets + +# Local History for Visual Studio Code + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +### VisualStudio ### + +# User-specific files + +# User-specific files (MonoDevelop/Xamarin Studio) + +# Mono auto generated files + +# Build results + +# Visual Studio 2015/2017 cache/options directory +# Uncomment if you have tasks that create the project's static files in wwwroot + +# Visual Studio 2017 auto generated files + +# MSTest test Results + +# NUnit + +# Build Results of an ATL Project + +# Benchmark Results + +# .NET Core + +# ASP.NET Scaffolding + +# StyleCop + +# Files built by Visual Studio + +# Chutzpah Test files + +# Visual C++ cache files + +# Visual Studio profiler + +# Visual Studio Trace Files + +# TFS 2012 Local Workspace + +# Guidance Automation Toolkit + +# ReSharper is a .NET coding add-in + +# TeamCity is a build add-in + +# DotCover is a Code Coverage Tool + +# AxoCover is a Code Coverage Tool + +# Coverlet is a free, cross platform Code Coverage Tool + +# Visual Studio code coverage results + +# NCrunch + +# MightyMoose + +# Web workbench (sass) + +# Installshield output folder + +# DocProject is a documentation generator add-in + +# Click-Once directory + +# Publish Web Output +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted + +# NuGet Packages +# NuGet Symbol Packages +# The packages folder can be ignored because of Package Restore +# except build/, which is used as an MSBuild target. +# Uncomment if necessary however generally it will be regenerated when needed +# NuGet v3's project.json files produces more ignorable files + +# Microsoft Azure Build Output + +# Microsoft Azure Emulator + +# Windows Store app package directories and files + +# Visual Studio cache files +# files ending in .cache can be ignored +# but keep track of directories ending in .cache + +# Others + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) + +# RIA/Silverlight projects + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) + +# SQL Server files + +# Business Intelligence projects + +# Microsoft Fakes + +# GhostDoc plugin setting file + +# Node.js Tools for Visual Studio + +# Visual Studio 6 build log + +# Visual Studio 6 workspace options file + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) + +# Visual Studio 6 technical files + +# Visual Studio LightSwitch build output + +# Paket dependency manager + +# FAKE - F# Make + +# CodeRush personal settings + +# Python Tools for Visual Studio (PTVS) + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio + +# Telerik's JustMock configuration file + +# BizTalk build output + +# OpenCover UI analysis results + +# Azure Stream Analytics local run output + +# MSBuild Binary and Structured Log + +# NVidia Nsight GPU debugger configuration file + +# MFractors (Xamarin productivity tool) working folder + +# Local History for Visual Studio + +# Visual Studio History (VSHistory) files + +# BeatPulse healthcheck temp database + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 + +# Ionide (cross platform F# VS Code tools) working folder + +# Fody - auto-generated XML schema + +# VS Code files for those working on multiple tools + +# Local History for Visual Studio Code + +# Windows Installer files from build outputs + +# JetBrains Rider + +### VisualStudio Patch ### +# Additional files built by Visual Studio + +# End of https://www.toptal.com/developers/gitignore/api/dotnetcore,rider,intellij,jetbrains,macos,visualstudiocode,visualstudio,csharp + +.vscode \ No newline at end of file diff --git a/README.md b/README.md index 098d919..38aae2d 100644 --- a/README.md +++ b/README.md @@ -1 +1,52 @@ -# libsql-http-client-dotnet +# LibSql.Http.Client + +An alternative [libSQL](https://github.com/tursodatabase/libsql) .NET client, supporting HTTP protocol, fully trimmable and AOT compatible. + +> [!WARNING] +> This is not an official libSQL client + +## About + +This client is a .NET implementation of HRANA protocol, intented to communicate with libSQL server. + +This lib is inspired by [libsql-stateless-easy](https://github.com/DaBigBlob/libsql-stateless-easy). + +## Requirements + +- .NET 8 (6 and 7 are supported as well) + +## Usage + +The instance of the client expect an instance of HttpClient. + +The most performant way is use a singleton instance of HttpClient. + +Check the offical .NET [HTTP client guidelines](https://learn.microsoft.com/en-us/dotnet/fundamentals/networking/http/httpclient-guidelines) for more information. + +```csharp +var handler = new SocketsHttpHandler +{ + PooledConnectionLifetime = TimeSpan.FromMinutes(15) // Recreate every 15 minutes +}; +var sharedClient = new HttpClient(handler); +var libSqlClient = new LibSqlHttpClient(sharedClient, new Uri("http://localhost:8080")); +``` + +## Features + +- ✅ Single and batch commands +- ✅ Transactions (\*) +- ✅ Positional args +- ✅ Named args via Dictionary +- ✅ Micro ORM like queries and results with minimum overhead +- ❌ Interactive transactions not supported (transactions are done in a single request) + +**\*** Transactions are possible per statement(s) only. So distributed transaction is not possible (yet)! + +## Demo App + +```sh + dotnet run --project src/LibSql.Http.Client.DemoConsoleApp/LibSql.Http.Client.DemoConsoleApp.csproj +``` + +Check the [code](./src/LibSql.Http.Client.DemoConsoleApp/Program.cs). diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..9561c0b --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,17 @@ +--- +version: "3" + +tasks: + restore: + cmds: + - dotnet restore + build: + cmds: + - dotnet build --no-restore + deps: + - restore + default: + cmds: + - dotnet test --collect:"XPlat Code Coverage;Format=opencover,lcov,cobertura" --logger trx --no-build + deps: + - build diff --git a/libsql-http-client-dotnet.sln b/libsql-http-client-dotnet.sln new file mode 100644 index 0000000..deb5a39 --- /dev/null +++ b/libsql-http-client-dotnet.sln @@ -0,0 +1,50 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibSql.Http.Client", "src\LibSql.Http.Client\LibSql.Http.Client.csproj", "{21CBCC9D-5473-4EAA-87E3-AD6F53F20B1F}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{7B8E9954-6C36-4390-854C-8697425A779B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{9F551CAC-A9F0-4E16-BA4F-AB8917455882}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibSql.Http.Client.Tests", "test\LibSql.Http.Client.Tests\LibSql.Http.Client.Tests.csproj", "{5CBA11F0-D0C7-4091-B5A6-5D2DF03F6733}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibSql.Http.Client.Benchmarks", "src\LibSql.Http.Client.Benchmarks\LibSql.Http.Client.Benchmarks.csproj", "{D551ACCE-4968-4B2D-A777-EAEDC3475D4E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibSql.Http.Client.DemoConsoleApp", "src\LibSql.Http.Client.DemoConsoleApp\LibSql.Http.Client.DemoConsoleApp.csproj", "{5F367772-DF1B-4AF0-8B30-03F07C996A4E}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {21CBCC9D-5473-4EAA-87E3-AD6F53F20B1F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {21CBCC9D-5473-4EAA-87E3-AD6F53F20B1F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {21CBCC9D-5473-4EAA-87E3-AD6F53F20B1F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {21CBCC9D-5473-4EAA-87E3-AD6F53F20B1F}.Release|Any CPU.Build.0 = Release|Any CPU + {5CBA11F0-D0C7-4091-B5A6-5D2DF03F6733}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5CBA11F0-D0C7-4091-B5A6-5D2DF03F6733}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5CBA11F0-D0C7-4091-B5A6-5D2DF03F6733}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5CBA11F0-D0C7-4091-B5A6-5D2DF03F6733}.Release|Any CPU.Build.0 = Release|Any CPU + {D551ACCE-4968-4B2D-A777-EAEDC3475D4E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D551ACCE-4968-4B2D-A777-EAEDC3475D4E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D551ACCE-4968-4B2D-A777-EAEDC3475D4E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D551ACCE-4968-4B2D-A777-EAEDC3475D4E}.Release|Any CPU.Build.0 = Release|Any CPU + {5F367772-DF1B-4AF0-8B30-03F07C996A4E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5F367772-DF1B-4AF0-8B30-03F07C996A4E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5F367772-DF1B-4AF0-8B30-03F07C996A4E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5F367772-DF1B-4AF0-8B30-03F07C996A4E}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {21CBCC9D-5473-4EAA-87E3-AD6F53F20B1F} = {7B8E9954-6C36-4390-854C-8697425A779B} + {5CBA11F0-D0C7-4091-B5A6-5D2DF03F6733} = {9F551CAC-A9F0-4E16-BA4F-AB8917455882} + {D551ACCE-4968-4B2D-A777-EAEDC3475D4E} = {7B8E9954-6C36-4390-854C-8697425A779B} + {5F367772-DF1B-4AF0-8B30-03F07C996A4E} = {7B8E9954-6C36-4390-854C-8697425A779B} + EndGlobalSection +EndGlobal diff --git a/libsql-pkg-icon.png b/libsql-pkg-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..e55cd6ceeaa0a46a7bdaba34c5aa9a991284ed8d GIT binary patch literal 9825 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 + + + Exe + net8.0 + enable + enable + false + + + + + + + + + + + diff --git a/src/LibSql.Http.Client.Benchmarks/LibSqlReaderBenchmarks.cs b/src/LibSql.Http.Client.Benchmarks/LibSqlReaderBenchmarks.cs new file mode 100644 index 0000000..e474265 --- /dev/null +++ b/src/LibSql.Http.Client.Benchmarks/LibSqlReaderBenchmarks.cs @@ -0,0 +1,27 @@ +using System.Text; +using System.Text.Json.Serialization; +using BenchmarkDotNet.Attributes; +using LibSql.Http.Client.Response; + +namespace LibSql.Http.Client.Benchmarks; + +[MemoryDiagnoser] +public class LibSqlReaderBenchmarks +{ + private static readonly string Response = + @"{""baton"":null,""base_url"":null,""results"":[{""type"":""ok"",""response"":{""type"":""execute"",""result"":{""cols"":[{""name"":""Id"",""decltype"":""VARCHAR(50)""},{""name"":""Name"",""decltype"":""VARCHAR(255)""},{""name"":""CreatedAt"",""decltype"":""TIMESTAMP""},{""name"":""UpdatedAt"",""decltype"":""TIMESTAMP""}],""rows"":[[{""type"":""text"",""value"":""01j1fecvt1dr0qhastbx9cp8y3""},{""type"":""text"",""value"":""Name 1""},{""type"":""integer"",""value"":""1719836517919""},{""type"":""integer"",""value"":""1719836517919""}],[{""type"":""text"",""value"":""01j1fecvt1dr0qhastbx9cp8y3""},{""type"":""text"",""value"":""Name 1""},{""type"":""integer"",""value"":""1719836517919""},{""type"":""integer"",""value"":""1719836517919""}],[{""type"":""text"",""value"":""01j1fecvt1dr0qhastbx9cp8y3""},{""type"":""text"",""value"":""Name 1""},{""type"":""integer"",""value"":""1719836517919""},{""type"":""integer"",""value"":""1719836517919""}],[{""type"":""text"",""value"":""01j1fecvt1dr0qhastbx9cp8y3""},{""type"":""text"",""value"":""Name 1""},{""type"":""integer"",""value"":""1719836517919""},{""type"":""integer"",""value"":""1719836517919""}],[{""type"":""text"",""value"":""01j1fecvt1dr0qhastbx9cp8y3""},{""type"":""text"",""value"":""Name 1""},{""type"":""integer"",""value"":""1719836517919""},{""type"":""integer"",""value"":""1719836517919""}],[{""type"":""text"",""value"":""01j1fecvt1dr0qhastbx9cp8y3""},{""type"":""text"",""value"":""Name 1""},{""type"":""integer"",""value"":""1719836517919""},{""type"":""integer"",""value"":""1719836517919""}],[{""type"":""text"",""value"":""01j1fecvt1dr0qhastbx9cp8y3""},{""type"":""text"",""value"":""Name 1""},{""type"":""integer"",""value"":""1719836517919""},{""type"":""integer"",""value"":""1719836517919""}],[{""type"":""text"",""value"":""01j1fecvt1dr0qhastbx9cp8y3""},{""type"":""text"",""value"":""Name 1""},{""type"":""integer"",""value"":""1719836517919""},{""type"":""integer"",""value"":""1719836517919""}],[{""type"":""text"",""value"":""01j1fecvt1dr0qhastbx9cp8y3""},{""type"":""text"",""value"":""Name 1""},{""type"":""integer"",""value"":""1719836517919""},{""type"":""integer"",""value"":""1719836517919""}],[{""type"":""text"",""value"":""01j1fecvt1dr0qhastbx9cp8y3""},{""type"":""text"",""value"":""Name 1""},{""type"":""integer"",""value"":""1719836517919""},{""type"":""integer"",""value"":""1719836517919""}],[{""type"":""text"",""value"":""01j1fecvt1dr0qhastbx9cp8y3""},{""type"":""text"",""value"":""Name 1""},{""type"":""integer"",""value"":""1719836517919""},{""type"":""integer"",""value"":""1719836517919""}],[{""type"":""text"",""value"":""01j1fecvt1dr0qhastbx9cp8y3""},{""type"":""text"",""value"":""Name 1""},{""type"":""integer"",""value"":""1719836517919""},{""type"":""integer"",""value"":""1719836517919""}],[{""type"":""text"",""value"":""01j1fecvt1dr0qhastbx9cp8y3""},{""type"":""text"",""value"":""Name 1""},{""type"":""integer"",""value"":""1719836517919""},{""type"":""integer"",""value"":""1719836517919""}],[{""type"":""text"",""value"":""01j1fecvt1dr0qhastbx9cp8y3""},{""type"":""text"",""value"":""Name 1""},{""type"":""integer"",""value"":""1719836517919""},{""type"":""integer"",""value"":""1719836517919""}],[{""type"":""text"",""value"":""01j1fecvt1dr0qhastbx9cp8y3""},{""type"":""text"",""value"":""Name 1""},{""type"":""integer"",""value"":""1719836517919""},{""type"":""integer"",""value"":""1719836517919""}],[{""type"":""text"",""value"":""01j1fecvt1dr0qhastbx9cp8y3""},{""type"":""text"",""value"":""Name 1""},{""type"":""integer"",""value"":""1719836517919""},{""type"":""integer"",""value"":""1719836517919""}],[{""type"":""text"",""value"":""01j1fecvt1dr0qhastbx9cp8y3""},{""type"":""text"",""value"":""Name 1""},{""type"":""integer"",""value"":""1719836517919""},{""type"":""integer"",""value"":""1719836517919""}],[{""type"":""text"",""value"":""01j1fecvt1dr0qhastbx9cp8y3""},{""type"":""text"",""value"":""Name 1""},{""type"":""integer"",""value"":""1719836517919""},{""type"":""integer"",""value"":""1719836517919""}],[{""type"":""text"",""value"":""01j1fecvt1dr0qhastbx9cp8y3""},{""type"":""text"",""value"":""Name 1""},{""type"":""integer"",""value"":""1719836517919""},{""type"":""integer"",""value"":""1719836517919""}],[{""type"":""text"",""value"":""01j1fecvt1dr0qhastbx9cp8y3""},{""type"":""text"",""value"":""Name 1""},{""type"":""integer"",""value"":""1719836517919""},{""type"":""integer"",""value"":""1719836517919""}],[{""type"":""text"",""value"":""01j1fecvt1dr0qhastbx9cp8y3""},{""type"":""text"",""value"":""Name 1""},{""type"":""integer"",""value"":""1719836517919""},{""type"":""integer"",""value"":""1719836517919""}],[{""type"":""text"",""value"":""01j1fecvt1dr0qhastbx9cp8y3""},{""type"":""text"",""value"":""Name 1""},{""type"":""integer"",""value"":""1719836517919""},{""type"":""integer"",""value"":""1719836517919""}],[{""type"":""text"",""value"":""01j1fecvt1dr0qhastbx9cp8y3""},{""type"":""text"",""value"":""Name 1""},{""type"":""integer"",""value"":""1719836517919""},{""type"":""integer"",""value"":""1719836517919""}],[{""type"":""text"",""value"":""01j1fecvt1dr0qhastbx9cp8y3""},{""type"":""text"",""value"":""Name 1""},{""type"":""integer"",""value"":""1719836517919""},{""type"":""integer"",""value"":""1719836517919""}],[{""type"":""text"",""value"":""01j1fecvt1dr0qhastbx9cp8y3""},{""type"":""text"",""value"":""Name 1""},{""type"":""integer"",""value"":""1719836517919""},{""type"":""integer"",""value"":""1719836517919""}],[{""type"":""text"",""value"":""01j1fecvt1dr0qhastbx9cp8y3""},{""type"":""text"",""value"":""Name 1""},{""type"":""integer"",""value"":""1719836517919""},{""type"":""integer"",""value"":""1719836517919""}]],""affected_row_count"":0,""last_insert_rowid"":null,""replication_index"":""53"",""rows_read"":1,""rows_written"":0,""query_duration_ms"":0.098}}},{""type"":""ok"",""response"":{""type"":""close""}}]}"; + + [Benchmark] + public async Task ExecuteLite() + { + var content = new StringContent(Response, Encoding.UTF8, "application/json"); + using var reader = await ResultReader.ParseAsync(content); + + return reader.ReadAt(0, TestSerializerContext.Default.TestResultType); + } +} + +public record TestResultType(string Id, string Name, long CreatedAt, long UpdatedAt); + +[JsonSerializable(typeof(TestResultType))] +public partial class TestSerializerContext : JsonSerializerContext; diff --git a/src/LibSql.Http.Client.Benchmarks/Program.cs b/src/LibSql.Http.Client.Benchmarks/Program.cs new file mode 100644 index 0000000..4b2af42 --- /dev/null +++ b/src/LibSql.Http.Client.Benchmarks/Program.cs @@ -0,0 +1,4 @@ +using BenchmarkDotNet.Running; +using LibSql.Http.Client.Benchmarks; + +BenchmarkRunner.Run(); diff --git a/src/LibSql.Http.Client.DemoConsoleApp/LibSql.Http.Client.DemoConsoleApp.csproj b/src/LibSql.Http.Client.DemoConsoleApp/LibSql.Http.Client.DemoConsoleApp.csproj new file mode 100644 index 0000000..a89b829 --- /dev/null +++ b/src/LibSql.Http.Client.DemoConsoleApp/LibSql.Http.Client.DemoConsoleApp.csproj @@ -0,0 +1,19 @@ + + + + Exe + net8.0 + enable + enable + false + + + + + + + + + + + diff --git a/src/LibSql.Http.Client.DemoConsoleApp/Program.cs b/src/LibSql.Http.Client.DemoConsoleApp/Program.cs new file mode 100644 index 0000000..54c3927 --- /dev/null +++ b/src/LibSql.Http.Client.DemoConsoleApp/Program.cs @@ -0,0 +1,79 @@ +using System.Text.Json.Serialization; +using DotNet.Testcontainers.Builders; +using LibSql.Http.Client; +using LibSql.Http.Client.Request; + +var libSqlServerContainer = new ContainerBuilder() + .WithImage("ghcr.io/tursodatabase/libsql-server:latest") + .WithPortBinding(8080, true) + .WithWaitStrategy( + Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(r => r.ForPath("/health").ForPort(8080))) + .Build(); + +await libSqlServerContainer.StartAsync(); + +var serverUri = new UriBuilder( + Uri.UriSchemeHttp, + libSqlServerContainer.Hostname, + libSqlServerContainer.GetMappedPublicPort(8080)).Uri; + +var handler = new SocketsHttpHandler +{ + PooledConnectionLifetime = TimeSpan.FromMinutes(15) // Recreate every 15 minutes +}; +var sharedClient = new HttpClient(handler); +var libSqlClient = new LibSqlHttpClient(sharedClient, serverUri); + +await libSqlClient.ExecuteAsync( + "CREATE TABLE products (id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL, price REAL NOT NULL, stock INTEGER NOT NULL) WITHOUT ROWID"); + +Console.WriteLine("\u2705 Table 'products' created!"); + +var insertedRows = await libSqlClient.ExecuteMultipleAsync( + [ + "INSERT INTO products (id, name, description, price, stock) VALUES (\"product-123\", \"Laptop\", \"Nice Laptop\", 3200, 10)", + ("INSERT INTO products (id, name, description, price, stock) VALUES (?, ?, ?, ?, ?)", + ["product-456", "Camera", "Nice camera", 2000, 20]), + ("INSERT INTO products (id, name, description, price, stock) VALUES (@id, @name, @description, @price, @stock)", + new Dictionary + { + { "id", "product-789" }, { "name", "TV" }, { "description", "Nice TV" }, { "price", 1000 }, + { "stock", 30 } + })! + ], + TransactionMode.WriteImmediate); + +Console.WriteLine($"{insertedRows} rows inserted!"); + +var count = await libSqlClient.ExecuteScalarAsync("SELECT count(id) FROM products"); + +Console.WriteLine($"There are {count} products registered"); + +var itemsResult = await libSqlClient.QueryAsync( + "SELECT id, name, description, price, stock FROM products", + AppSerializerContext.Default.Product); + +var items = itemsResult.ToList(); + +Console.WriteLine($"Listed {items.Count} items:"); +Console.WriteLine(string.Join(Environment.NewLine, items.Select((it, idx) => $"$\t[{idx}] => {it}"))); + +var byId = await libSqlClient.QuerySingleAsync( + ("SELECT id, name, description, price, stock FROM products WHERE id = ? LIMIT 1", [items[0].Id]), + AppSerializerContext.Default.Product); + +Console.WriteLine($"Hey, we found the item with id '{items[0].Id}':"); + +Console.WriteLine(byId); + +var deletedItemsCount = await libSqlClient.ExecuteAsync("DELETE FROM products"); + +Console.WriteLine($"{deletedItemsCount} items deleted!"); + +public record Product(string Id, string Name, string Description, decimal Price, int Stock); + +[JsonSerializable(typeof(Product))] +[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)] +public partial class AppSerializerContext : JsonSerializerContext +{ +} \ No newline at end of file diff --git a/src/LibSql.Http.Client/Buffer/PooledByteBufferWriter.cs b/src/LibSql.Http.Client/Buffer/PooledByteBufferWriter.cs new file mode 100644 index 0000000..5adf748 --- /dev/null +++ b/src/LibSql.Http.Client/Buffer/PooledByteBufferWriter.cs @@ -0,0 +1,99 @@ +using System.Buffers; + +namespace LibSql.Http.Client.Buffer; + +internal sealed class PooledByteBufferWriter(int initialCapacity) : IBufferWriter, IDisposable +{ + private const int MinimumBufferSize = 256; + + private byte[] _buffer = ArrayPool.Shared.Rent(Math.Max(initialCapacity, MinimumBufferSize)); + + public PooledByteBufferWriter() : this(MinimumBufferSize) + { + } + + public ReadOnlyMemory WrittenMemory => _buffer.AsMemory(0, WrittenCount); + + private int WrittenCount { get; set; } + + private int FreeCapacity => _buffer.Length - WrittenCount; + + public void Advance(int count) + { + if (count < 0) + throw new ArgumentException(null, nameof(count)); + if (WrittenCount > _buffer.Length - count) + ThrowInvalidOperationException_AdvancedTooFar(_buffer.Length); + WrittenCount += count; + } + + public Memory GetMemory(int sizeHint = 0) + { + CheckAndResizeBuffer(sizeHint); + return _buffer.AsMemory(WrittenCount); + } + + public Span GetSpan(int sizeHint = 0) + { + CheckAndResizeBuffer(sizeHint); + return _buffer.AsSpan(WrittenCount); + } + + public void Dispose() + { + Clear(); + ArrayPool.Shared.Return(_buffer); + } + + public ReadOnlySpan AsSpan(long[] marks) => _buffer.AsSpan((int)marks[0], (int)marks[1]); + + private void Clear() + { + _buffer.AsSpan(0, WrittenCount).Clear(); + WrittenCount = 0; + } + + private void CheckAndResizeBuffer(int sizeHint) + { + if (sizeHint < 0) throw new ArgumentException(nameof(sizeHint)); + + sizeHint = Math.Max(sizeHint, 1); + + if (sizeHint <= FreeCapacity) + return; + + var length = _buffer.Length; + + var val1 = Math.Max(sizeHint, length == 0 ? MinimumBufferSize : length); + + var newSize = length + val1; + + if ((uint)newSize > int.MaxValue) + { + var capacity = (uint)(length - FreeCapacity + sizeHint); + if (capacity > Array.MaxLength) + ThrowOutOfMemoryException(capacity); + newSize = Array.MaxLength; + } + + var oldBuffer = _buffer; + + _buffer = ArrayPool.Shared.Rent(newSize); + + var oldBufferAsSpan = oldBuffer.AsSpan(0, WrittenCount); + + oldBufferAsSpan.CopyTo(_buffer); + oldBufferAsSpan.Clear(); + ArrayPool.Shared.Return(oldBuffer); + } + + private static void ThrowInvalidOperationException_AdvancedTooFar(int capacity) + { + throw new InvalidOperationException($"BufferWriterAdvancedTooFar. Capacity:{capacity}"); + } + + private static void ThrowOutOfMemoryException(uint capacity) + { + throw new InvalidOperationException($"Buffer maximum size exceeded. Capacity:{capacity}"); + } +} diff --git a/src/LibSql.Http.Client/Exceptions/LibSqlHttpClientException.cs b/src/LibSql.Http.Client/Exceptions/LibSqlHttpClientException.cs new file mode 100644 index 0000000..4707ecf --- /dev/null +++ b/src/LibSql.Http.Client/Exceptions/LibSqlHttpClientException.cs @@ -0,0 +1,34 @@ +using LibSql.Http.Client.Response; + +namespace LibSql.Http.Client.Exceptions; + +/// +public class LibSqlClientException(string message, Exception? innerException = default) + : Exception(message, innerException) +{ + private const string TabConstant = "\t"; + + /// + /// Create exception using errors from pipeline request + /// + /// Errors from pipeline response + public LibSqlClientException(IReadOnlyCollection executionErrors) : this( + FormatErrorMessage(executionErrors)) + { + } + + private static string FormatErrorMessage(IReadOnlyCollection executionErrors) + { + var joinedMessages = string.Join( + Environment.NewLine, + executionErrors.Select( + (error, index) => + { + var codePart = error.Code is null ? string.Empty : $"({error.Code}) "; + + return $"{TabConstant}[{index}]: {codePart}{error.Message}"; + })); + + return $"[LibSqlPipelineError] The request failed.{Environment.NewLine}{joinedMessages}"; + } +} \ No newline at end of file diff --git a/src/LibSql.Http.Client/Interfaces/ILibSqlHttpClient.cs b/src/LibSql.Http.Client/Interfaces/ILibSqlHttpClient.cs new file mode 100644 index 0000000..e737e93 --- /dev/null +++ b/src/LibSql.Http.Client/Interfaces/ILibSqlHttpClient.cs @@ -0,0 +1,148 @@ +using System.Text.Json.Serialization.Metadata; +using LibSql.Http.Client.Request; + +namespace LibSql.Http.Client.Interfaces; + +/// +/// Provides an interface for executing SQL commands and queries via HTTP, supporting both synchronous and asynchronous +/// operations. +/// +public interface ILibSqlHttpClient +{ + /// + /// Configures the client with credentials for authentication. + /// + /// The base URL of the SQL HTTP service. + /// An optional authentication token for secured access. + /// An instance of the SQL HTTP client configured with specified credentials. + /// The new instance MUST be used + ILibSqlHttpClient WithCredentials(Uri url, string? authToken = null); + + /// + /// Executes a SQL command asynchronously and returns the number of rows affected. + /// + /// Statement to execute + /// The transaction mode, specifying how the command interacts with transactions. + /// A token to monitor for cancellation requests. + /// A task representing the asynchronous operation, with the number of affected rows as the result. + Task ExecuteAsync( + Statement statement, + TransactionMode transactionMode = TransactionMode.None, + CancellationToken cancellationToken = default); + + /// + /// Executes multiple SQL statements asynchronously in a single network call and returns the total number of rows + /// affected. + /// + /// An array of SQL statements to be executed. + /// Specifies the transaction mode for the batch operation. + /// A token to monitor for cancellation requests. + /// A task representing the asynchronous operation, with the total number of affected rows as the result. + Task ExecuteMultipleAsync( + Statement[] statements, + TransactionMode transactionMode = TransactionMode.None, + CancellationToken cancellationToken = default); + + /// + /// Executes a SQL command asynchronously and retrieves the first column of the first row in the result set returned by + /// the query. + /// + /// + /// Specifies the transaction mode. + /// A token to monitor for cancellation requests. + /// A task representing the asynchronous operation, with the result object of the first column in the first row. + Task ExecuteScalarAsync( + Statement statement, + TransactionMode transactionMode = TransactionMode.None, + CancellationToken cancellationToken = default); + + /// + /// Queries the database asynchronously for the first record and maps it to an object of . + /// + /// The type of the result object. + /// + /// Metadata information for JSON serialization and deserialization. + /// Specifies the transaction mode. + /// A token to monitor for cancellation requests. + /// A task representing the asynchronous operation, with the single queried object as the result. + Task QueryFirstAsync( + Statement statement, + JsonTypeInfo jsonTypeInfo, + TransactionMode transactionMode = TransactionMode.None, + CancellationToken cancellationToken = default); + + /// + /// Queries the database asynchronously for the first record or a default value if no records are found, and maps the + /// result to an object of . + /// + /// The type of the result object. + /// + /// Metadata information for JSON serialization and deserialization. + /// Specifies the transaction mode. + /// A token to monitor for cancellation requests. + /// A task representing the asynchronous operation, with the result object of the first record or a default value. + Task QueryFirstOrDefaultAsync( + Statement statement, + JsonTypeInfo jsonTypeInfo, + TransactionMode transactionMode = TransactionMode.None, + CancellationToken cancellationToken = default); + + /// + /// Queries the database asynchronously for a single record and maps the + /// result to an object of . + /// + /// The type of the result object. + /// + /// Metadata information for JSON serialization and deserialization. + /// Specifies the transaction mode. + /// A token to monitor for cancellation requests. + /// A task representing the asynchronous operation, with the result object. + Task QuerySingleAsync( + Statement statement, + JsonTypeInfo jsonTypeInfo, + TransactionMode transactionMode = TransactionMode.None, + CancellationToken cancellationToken = default); + + /// + /// Queries the database asynchronously for a single record or a default value if no records are found and maps the + /// result to an object of . + /// + /// The type of the result object. + /// + /// Metadata information for JSON serialization and deserialization. + /// Specifies the transaction mode. + /// A token to monitor for cancellation requests. + /// A task representing the asynchronous operation, with the result object of the first record or a default value. + Task QuerySingleOrDefaultAsync( + Statement statement, + JsonTypeInfo jsonTypeInfo, + TransactionMode transactionMode = TransactionMode.None, + CancellationToken cancellationToken = default); + + /// + /// Queries the database asynchronously maps the result to a sequence of data of . + /// + /// The type of the result object. + /// + /// Metadata information for JSON serialization and deserialization. + /// Specifies the transaction mode. + /// A token to monitor for cancellation requests. + /// A task representing the asynchronous operation, with the sequence of values. + Task> QueryAsync( + Statement statement, + JsonTypeInfo jsonTypeInfo, + TransactionMode transactionMode = TransactionMode.None, + CancellationToken cancellationToken = default); + + /// + /// Execute multiple queries on the database asynchronously and return a forward-only result reader. + /// + /// Queries to execute + /// Specifies the transaction mode. + /// A token to monitor for cancellation requests. + /// A task representing the asynchronous operation, with the result object of the first record or a default value. + Task QueryMultipleAsync( + Statement[] statements, + TransactionMode transactionMode = TransactionMode.None, + CancellationToken cancellationToken = default); +} diff --git a/src/LibSql.Http.Client/Interfaces/IResultReader.cs b/src/LibSql.Http.Client/Interfaces/IResultReader.cs new file mode 100644 index 0000000..94950e0 --- /dev/null +++ b/src/LibSql.Http.Client/Interfaces/IResultReader.cs @@ -0,0 +1,59 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization.Metadata; +using LibSql.Http.Client.Exceptions; +using LibSql.Http.Client.Response; + +namespace LibSql.Http.Client.Interfaces; + +/// +/// Interface for reading result from a pipeline execution of SQL statements. +/// The reader is a forward-only reader, so it's not possible to go back to a previous result. +/// +/// It's highly recommended dispose the object after process the data to free up memory +public interface IResultReader : IDisposable +{ + /// + /// Number of Result sets + /// + int Count { get; } + + /// + /// Number of rows affected by the execution of the SQL statements if any. + /// + int AffectedRows { get; } + + /// + /// Get the value of the first row of the first column of the result set. + /// + /// + object? GetScalarValue(); + + /// + /// Throw an exception if there are errors in the execution of the SQL statements. + /// + /// + void ThrowIfError(); + + /// + /// Check if there are more results to process + /// + /// + bool HasMoreResults(); + + /// + /// Read the result set as a sequence of data of . + /// + /// Metadata information for JSON deserialization. + /// The type of the result object. + /// + IEnumerable Read(JsonTypeInfo typeInfo); + + /// + /// Read the result set as a sequence of data of . + /// + /// + /// Metadata information for JSON deserialization. + /// The type of the result object. + /// + IEnumerable ReadAt(int index, JsonTypeInfo typeInfo); +} diff --git a/src/LibSql.Http.Client/LibSql.Http.Client.csproj b/src/LibSql.Http.Client/LibSql.Http.Client.csproj new file mode 100644 index 0000000..1e288e4 --- /dev/null +++ b/src/LibSql.Http.Client/LibSql.Http.Client.csproj @@ -0,0 +1,55 @@ + + + + enable + enable + true + true + true + true + default + net6.0;net7.0;net8.0 + Hermogenes Ferreira + A package for interacting with LibSQL database using HTTP API + https://github.com/hermogenes/libsql-http-client-dotnet + libsql-pkg-icon.png + https://github.com/hermogenes/libsql-http-client-dotnet + git + libsql;sqlite + True + true + MIT + README.md + + + + <_Parameter1>LibSql.Http.Client.Tests + + + + + <_Parameter1>LibSql.Http.Client.Benchmarks + + + + + all + runtime; build; native; contentfiles; analyzers + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + diff --git a/src/LibSql.Http.Client/LibSqlHttpClient.cs b/src/LibSql.Http.Client/LibSqlHttpClient.cs new file mode 100644 index 0000000..94f0072 --- /dev/null +++ b/src/LibSql.Http.Client/LibSqlHttpClient.cs @@ -0,0 +1,265 @@ +using System.Net.Http.Headers; +using System.Text.Json.Serialization.Metadata; +using LibSql.Http.Client.Exceptions; +using LibSql.Http.Client.Interfaces; +using LibSql.Http.Client.Request; +using LibSql.Http.Client.Response; + +namespace LibSql.Http.Client; + +/// +public sealed class LibSqlHttpClient : ILibSqlHttpClient +{ + private const string PipelineV3Path = "/v3/pipeline"; + private readonly AuthenticationHeaderValue? _authHeaderValue; + + private readonly HttpClient _httpClient; + private readonly Uri _pipelineUri; + + /// + /// Creates a new instance of . + /// + /// HttpClient instance to use + /// + /// Specify a LibSQL url to use if a singleton http client is being used among different clients or is a + /// multi db app + /// + /// Authorization token + /// The URI is not valid + /// Neither HttpClient BaseAddress nor URL param is set + /// + /// The recommended approach according to HTTP Client guidelines is a singleton HTTP Client instance per app + /// + /// var handler = new SocketsHttpHandler + /// { + /// PooledConnectionLifetime = TimeSpan.FromMinutes(15) // Recreate every 15 minutes + /// }; + /// var sharedClient = new HttpClient(handler); + /// var libSqlClient = new LibSqlHttpClient(sharedClient, "https://db.host.com", "YOUR_AUTH_TOKEN"); + /// + /// or + /// + /// var handler = new SocketsHttpHandler + /// { + /// PooledConnectionLifetime = TimeSpan.FromMinutes(15) // Recreate every 15 minutes + /// }; + /// var sharedClient = new HttpClient(handler) + /// { + /// BaseAddress = new Uri("https://db.host.com/"), + /// DefaultRequestHeaders = { Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_AUTH_TOKEN" )} + /// }; + /// var libSqlClient = new LibSqlHttpClient(sharedClient); + /// + /// or + /// + /// builder.Services.AddHttpClient<ILibSqlHttpClient, LibSqlHttpClient>( + /// client => + /// { + /// client.BaseAddress = new Uri("https://db.host.com/"); + /// client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_AUTH_TOKEN"); + /// }); + /// + /// + public LibSqlHttpClient(HttpClient httpClient, Uri? url = null, string? authToken = null) + { + url ??= httpClient.BaseAddress; + + if (url is null) + throw new ArgumentNullException( + nameof(url), + "URL not set. Please provide a URL either in the constructor or as a parameter or via HttpClient.BaseAddress."); + + _pipelineUri = new Uri(url, PipelineV3Path); + + _httpClient = httpClient; + + if (authToken is not null) + _authHeaderValue = new AuthenticationHeaderValue("Bearer", authToken.Replace("Bearer ", "")); + } + + /// + public ILibSqlHttpClient WithCredentials(Uri url, string? authToken = null) => + new LibSqlHttpClient(_httpClient, url, authToken); + + /// + public Task ExecuteAsync( + Statement statement, + TransactionMode transactionMode = TransactionMode.None, + CancellationToken cancellationToken = default) => + ExecuteMultipleAsync( + [statement], + transactionMode, + cancellationToken); + + /// + public Task ExecuteMultipleAsync( + Statement[] statements, + TransactionMode transactionMode = TransactionMode.None, + CancellationToken cancellationToken = default) => + InternalSendPipelineRequestAsync( + statements, + transactionMode, + reader => reader.AffectedRows, + true, + cancellationToken); + + /// + public Task ExecuteScalarAsync( + Statement statement, + TransactionMode transactionMode = TransactionMode.None, + CancellationToken cancellationToken = default) => + InternalSendPipelineRequestAsync( + [statement], + transactionMode, + reader => reader.GetScalarValue(), + true, + cancellationToken); + + /// + public Task QueryFirstAsync( + Statement statement, + JsonTypeInfo jsonTypeInfo, + TransactionMode transactionMode = TransactionMode.None, + CancellationToken cancellationToken = default) => + InternalQueryAsync( + statement, + jsonTypeInfo, + transactionMode, + result => result.First(), + cancellationToken); + + /// + public Task QueryFirstOrDefaultAsync( + Statement statement, + JsonTypeInfo jsonTypeInfo, + TransactionMode transactionMode = TransactionMode.None, + CancellationToken cancellationToken = default) => + InternalQueryAsync( + statement, + jsonTypeInfo, + transactionMode, + result => result.FirstOrDefault(), + cancellationToken); + + /// + public Task QuerySingleAsync( + Statement statement, + JsonTypeInfo jsonTypeInfo, + TransactionMode transactionMode = TransactionMode.None, + CancellationToken cancellationToken = default) => + InternalQueryAsync( + statement, + jsonTypeInfo, + transactionMode, + result => result.Single(), + cancellationToken); + + /// + public Task QuerySingleOrDefaultAsync( + Statement statement, + JsonTypeInfo jsonTypeInfo, + TransactionMode transactionMode = TransactionMode.None, + CancellationToken cancellationToken = default) => + InternalQueryAsync( + statement, + jsonTypeInfo, + transactionMode, + result => result.SingleOrDefault(), + cancellationToken); + + /// + public Task> QueryAsync( + Statement statement, + JsonTypeInfo jsonTypeInfo, + TransactionMode transactionMode = TransactionMode.None, + CancellationToken cancellationToken = default) => + InternalQueryAsync( + statement, + jsonTypeInfo, + transactionMode, + result => result, + cancellationToken); + + /// + public Task QueryMultipleAsync( + Statement[] statements, + TransactionMode transactionMode, + CancellationToken cancellationToken = default) => + InternalSendPipelineRequestAsync(statements, transactionMode, reader => reader, false, cancellationToken); + + private Task InternalQueryAsync( + Statement statement, + JsonTypeInfo jsonTypeInfo, + TransactionMode transactionMode, + Func, TResult> processorFn, + CancellationToken cancellationToken) => + InternalSendPipelineRequestAsync( + [statement], + transactionMode, + reader => processorFn(reader.Count > 0 ? reader.ReadAt(0, jsonTypeInfo) : Array.Empty()), + true, + cancellationToken); + + private async Task InternalSendPipelineRequestAsync( + Statement[] statements, + TransactionMode transactionMode, + Func processorFn, + bool disposeReader, + CancellationToken cancellationToken) + { + using var content = RequestSerializer.Serialize(statements, transactionMode); + + using var res = await SendRequestAsync(content, cancellationToken); + + var resultsToIgnore = transactionMode is TransactionMode.None + ? new HashSet() + : new HashSet { 0, statements.Length + 1, statements.Length + 2 }; + + var reader = await ResultReader.ParseAsync(res.Content, resultsToIgnore, cancellationToken); + + try + { + reader.ThrowIfError(); + + var result = processorFn(reader); + + return result; + } + finally + { + if (disposeReader) reader.Dispose(); + } + } + + private async Task SendRequestAsync(HttpContent content, CancellationToken cancellationToken) + { + HttpResponseMessage? res = null; + + try + { + var request = new HttpRequestMessage(HttpMethod.Post, _pipelineUri) + { + Content = content + }; + + if (_authHeaderValue is not null) + request.Headers.Authorization = _authHeaderValue; + + res = await _httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + } + catch (Exception e) + { + throw new LibSqlClientException("[LibSqlHttpClient] Error sending pipeline request", e); + } + + if (res.IsSuccessStatusCode) return res; + + var bodyContent = await res.Content.ReadAsStringAsync(cancellationToken); + + throw new LibSqlClientException( + $"[LibSqlHttpClient] Error sending pipeline request. Status Code: {res.StatusCode}, body: {bodyContent}"); + } +} diff --git a/src/LibSql.Http.Client/Request/RequestSerializer.cs b/src/LibSql.Http.Client/Request/RequestSerializer.cs new file mode 100644 index 0000000..3c17187 --- /dev/null +++ b/src/LibSql.Http.Client/Request/RequestSerializer.cs @@ -0,0 +1,253 @@ +using System.Buffers.Text; +using System.Net.Http.Headers; +using System.Text.Encodings.Web; +using System.Text.Json; +using LibSql.Http.Client.Buffer; + +namespace LibSql.Http.Client.Request; + +internal static class RequestSerializer +{ + private static readonly MediaTypeHeaderValue ContentTypeHeaderValue = + MediaTypeHeaderValue.Parse("application/json"); + + private static readonly JsonWriterOptions WriterOptions = new() + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + internal static HttpContent Serialize( + Statement[] statements, + TransactionMode transactionMode, + string? baton = null, + bool isInteractive = false) + { + using var stream = new PooledByteBufferWriter(); + + using var writer = new Utf8JsonWriter(stream, WriterOptions); + + writer.WriteStartObject(); + + if (baton is not null) writer.WriteString("baton"u8, baton); + + writer.WriteStartArray("requests"u8); + + if (statements.Length == 1 && transactionMode is TransactionMode.None) + WriteStatementObject(writer, statements[0].Sql, statements[0].Args, statements[0].NamedArgs, "execute"u8); + else WriteBatchRequest(writer, statements, transactionMode, isInteractive); + + if (!isInteractive) + writer.WriteRawValue("""{"type":"close"}"""u8, true); + + writer.WriteEndArray(); + + writer.WriteEndObject(); + + writer.Flush(); + + return new ReadOnlyMemoryContent(stream.WrittenMemory.ToArray()) + { + Headers = { ContentType = ContentTypeHeaderValue } + }; + } + + private static void WriteBatchRequest( + Utf8JsonWriter writer, + Statement[] statements, + TransactionMode transactionMode, + bool isInteractive) + { + writer.WriteStartObject(); + + writer.WriteString("type"u8, "batch"u8); + + writer.WriteStartObject("batch"u8); + + writer.WriteStartArray("steps"u8); + + var lastStep = transactionMode is TransactionMode.None ? -1 : 0; + + switch (transactionMode) + { + case TransactionMode.WriteImmediate: + writer.WriteRawValue("{\"stmt\":{\"sql\":\"BEGIN IMMEDIATE\"}}"u8, true); + break; + case TransactionMode.Deferred: + writer.WriteRawValue("{\"stmt\":{\"sql\":\"BEGIN DEFERRED\"}}"u8, true); + break; + case TransactionMode.ReadOnly: + writer.WriteRawValue("{\"stmt\":{\"sql\":\"BEGIN TRANSACTION READONLY\"}}"u8, true); + break; + case TransactionMode.None: + default: + break; + } + + foreach (var stmt in statements) + { + WriteStatementObject(writer, stmt.Sql, stmt.Args, stmt.NamedArgs, default, lastStep); + lastStep++; + } + + if (transactionMode != TransactionMode.None && !isInteractive) + { + writer.WriteRawValue( + $"{{\"stmt\":{{\"sql\":\"COMMIT\"}},\"condition\":{{\"type\":\"ok\",\"step\":{lastStep}}}}}", + true); + writer.WriteRawValue( + $"{{\"stmt\":{{\"sql\":\"ROLLBACK\"}},\"condition\":{{\"type\":\"not\",\"cond\":{{\"type\":\"ok\",\"step\":{lastStep + 1}}}}}}}", + true); + } + + + writer.WriteEndArray(); + + writer.WriteEndObject(); + + writer.WriteEndObject(); + } + + + private static void WriteStatementObject( + Utf8JsonWriter writer, + ReadOnlySpan sql, + object?[]? args, + Dictionary? namedArgs, + ReadOnlySpan type = default, + int lastStep = -1) + { + writer.WriteStartObject(); + + if (!type.IsEmpty) writer.WriteString("type"u8, type); + + writer.WriteStartObject("stmt"u8); + writer.WriteString("sql"u8, sql); + + if (args is not null && args.Length > 0) + { + writer.WriteStartArray("args"u8); + foreach (var arg in args) WriteArgObject(writer, arg); + + writer.WriteEndArray(); + } + + if (namedArgs is not null && namedArgs.Count > 0) + { + writer.WriteStartArray("named_args"u8); + foreach (var namedArg in namedArgs) + { + writer.WriteStartObject(); + + writer.WriteString("name"u8, namedArg.Key); + + writer.WritePropertyName("value"u8); + + WriteArgObject(writer, namedArg.Value); + + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + } + + writer.WriteEndObject(); + + if (lastStep > -1) + { + writer.WriteStartObject("condition"u8); + writer.WriteString("type"u8, "ok"u8); + writer.WriteNumber("step"u8, lastStep); + writer.WriteEndObject(); + } + + writer.WriteEndObject(); + + writer.Flush(); + } + + internal static void WriteArgObject(Utf8JsonWriter writer, object? arg) + { + writer.WriteStartObject(); + + switch (arg) + { + case null: + writer.WriteString("type"u8, "null"u8); + break; + case byte[] blobArg: + writer.WriteString("type"u8, "blob"u8); + writer.WriteBase64String("base64"u8, blobArg); + break; + case int intArg: + writer.WriteString("type"u8, "integer"u8); + WriteNumberAsString(writer, "value"u8, intArg, 11); + break; + case uint intArg: + writer.WriteString("type"u8, "integer"u8); + WriteNumberAsString(writer, "value"u8, intArg, 11); + break; + case long intArg: + writer.WriteString("type"u8, "integer"u8); + WriteNumberAsString(writer, "value"u8, intArg, 20); + break; + case ulong intArg: + writer.WriteString("type"u8, "integer"u8); + WriteNumberAsString(writer, "value"u8, intArg, 20); + break; + case short intArg: + writer.WriteString("type"u8, "integer"u8); + WriteNumberAsString(writer, "value"u8, intArg, 6); + break; + case ushort intArg: + writer.WriteString("type"u8, "integer"u8); + WriteNumberAsString(writer, "value"u8, intArg, 6); + break; + case bool bArg: + writer.WriteString("type"u8, "integer"u8); + writer.WriteString("value"u8, bArg ? "1"u8 : "0"u8); + break; + case float nArg: + writer.WriteString("type"u8, "float"u8); + writer.WriteNumber("value"u8, nArg); + break; + case decimal nArg: + writer.WriteString("type"u8, "float"u8); + writer.WriteNumber("value"u8, nArg); + break; + case double nArg: + writer.WriteString("type"u8, "float"u8); + writer.WriteNumber("value"u8, nArg); + break; + case string sArg: + writer.WriteString("type"u8, "text"); + writer.WriteString("value"u8, sArg); + break; + default: + throw new ArgumentException($"Unsupported arg item of type: {arg.GetType()}"); + } + + writer.WriteEndObject(); + } + + private static void WriteNumberAsString( + Utf8JsonWriter writer, + ReadOnlySpan propName, + ulong value, + int byteSize) + { + Span destination = stackalloc byte[byteSize]; + Utf8Formatter.TryFormat(value, destination, out var bytesWritten); + writer.WriteString(propName, destination[..bytesWritten]); + } + + private static void WriteNumberAsString( + Utf8JsonWriter writer, + ReadOnlySpan propName, + long value, + int byteSize) + { + Span destination = stackalloc byte[byteSize]; + Utf8Formatter.TryFormat(value, destination, out var bytesWritten); + writer.WriteString(propName, destination[..bytesWritten]); + } +} diff --git a/src/LibSql.Http.Client/Request/Statement.cs b/src/LibSql.Http.Client/Request/Statement.cs new file mode 100644 index 0000000..1eea724 --- /dev/null +++ b/src/LibSql.Http.Client/Request/Statement.cs @@ -0,0 +1,49 @@ +namespace LibSql.Http.Client.Request; + +/// +/// Statement to execute +/// +/// SQL query +/// Positional args +public class Statement(string sql, object?[]? args = null) +{ + /// + /// SQL Query + /// + public string Sql { get; } = sql; + /// + /// Named Args + /// + public Dictionary? NamedArgs { get; } + /// + /// Positional Args + /// + public object?[]? Args { get; } = args; + + /// + public Statement(string sql, Dictionary namedArgs) : this(sql) + { + NamedArgs = namedArgs; + } + + /// + /// Implicitly create a Statement from a SQL query + /// + /// + /// + public static implicit operator Statement(string sql) => new(sql); + + /// + /// Implicitly create a statement from a (sql, positional args) tuple + /// + /// + /// + public static implicit operator Statement((string, object?[]) stmt) => new(stmt.Item1, stmt.Item2); + + /// + /// Implicitly create a statement from a (sql, named args) tuple + /// + /// + /// + public static implicit operator Statement((string, Dictionary) stmt) => new(stmt.Item1, stmt.Item2); +} \ No newline at end of file diff --git a/src/LibSql.Http.Client/Request/TransactionMode.cs b/src/LibSql.Http.Client/Request/TransactionMode.cs new file mode 100644 index 0000000..215041c --- /dev/null +++ b/src/LibSql.Http.Client/Request/TransactionMode.cs @@ -0,0 +1,24 @@ +namespace LibSql.Http.Client.Request; + +/// +/// Transaction mode for the pipeline request +/// +public enum TransactionMode +{ + /// + /// No transaction required + /// + None = 0, + /// + /// BEGIN IMMEDIATE + /// + WriteImmediate = 1, + /// + /// BEGIN DEFERRED + /// + Deferred = 2, + /// + /// BEGIN TRANSACtION READONLY + /// + ReadOnly = 3 +} \ No newline at end of file diff --git a/src/LibSql.Http.Client/Response/ExecutionError.cs b/src/LibSql.Http.Client/Response/ExecutionError.cs new file mode 100644 index 0000000..7d16caa --- /dev/null +++ b/src/LibSql.Http.Client/Response/ExecutionError.cs @@ -0,0 +1,8 @@ +namespace LibSql.Http.Client.Response; + +/// +/// Execution error +/// +/// +/// +public record ExecutionError(string Message, string? Code = null); \ No newline at end of file diff --git a/src/LibSql.Http.Client/Response/ExecutionStats.cs b/src/LibSql.Http.Client/Response/ExecutionStats.cs new file mode 100644 index 0000000..214d3cd --- /dev/null +++ b/src/LibSql.Http.Client/Response/ExecutionStats.cs @@ -0,0 +1,18 @@ +namespace LibSql.Http.Client.Response; + +/// +/// Record to hold the stats of pipeline executions +/// +/// Number of rows read +/// Number of affected rows +/// Number of rows written +/// Duration +/// +/// +public record ExecutionStats( + int RowsRead = 0, + int AffectedRows = 0, + int RowsWritten = 0, + double QueryDurationInMilliseconds = 0, + string? LastInsertedRowId = null, + string? ReplicationIndex = null); diff --git a/src/LibSql.Http.Client/Response/ResultReader.cs b/src/LibSql.Http.Client/Response/ResultReader.cs new file mode 100644 index 0000000..22714da --- /dev/null +++ b/src/LibSql.Http.Client/Response/ResultReader.cs @@ -0,0 +1,409 @@ +using System.Buffers; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using LibSql.Http.Client.Buffer; +using LibSql.Http.Client.Exceptions; +using LibSql.Http.Client.Interfaces; + +namespace LibSql.Http.Client.Response; + +/// +/// +/// +/// +/// +/// +internal class ResultReader( + PooledByteBufferWriter buffer, + string? baton, + ExecutionError[] errors, + ExecutionStats[] stats, + List> rowsMarkers) : IResultReader +{ + private int _position = -1; + + public ExecutionStats[] Stats { get; } = stats.ToArray(); + + public string? Baton => baton; + + public ExecutionError[] Errors { get; } = errors.ToArray(); + + public int Count => rowsMarkers.Count; + + public int AffectedRows => Stats.Sum(s => s.AffectedRows); + + public void Dispose() + { + buffer.Dispose(); + GC.SuppressFinalize(this); + } + + public void ThrowIfError() + { + if (errors.Length > 0) + throw new LibSqlClientException(errors); + } + + public bool HasMoreResults() + { + _position++; + return _position < Count; + } + + public IEnumerable Read(JsonTypeInfo typeInfo) => ReadAt(_position, typeInfo); + + public IEnumerable ReadAt(int index, JsonTypeInfo typeInfo) + { + var uIndex = index < 0 ? Count + index : index; + + if (uIndex >= Count || uIndex < 0) throw new IndexOutOfRangeException(); + + return rowsMarkers[uIndex].Select( + row => JsonSerializer.Deserialize(buffer.AsSpan(row), typeInfo) ?? + throw new JsonException($"Failed to deserialize row to type {typeof(T).Name}")).ToList(); + } + + public object? GetScalarValue() + { + if (Count < 1 || rowsMarkers[0].Count < 1) return null; + + var row = rowsMarkers[0][0]; + + var reader = new Utf8JsonReader(buffer.AsSpan(row)); + + while (reader.Read()) + { + if (reader.TokenType is not JsonTokenType.PropertyName) continue; + reader.Read(); + + return reader.TokenType switch + { + JsonTokenType.String => reader.GetString(), + JsonTokenType.Number => reader.TryGetInt64(out var i) ? i : reader.GetDouble(), + JsonTokenType.True => true, + JsonTokenType.False => false, + JsonTokenType.Null => null, + _ => throw new InvalidOperationException() + }; + } + + return null; + } + + + public static async Task ParseAsync( + Stream stream, + HashSet? resultsToIgnore = null, + CancellationToken cancellationToken = default) + { + var memoryOwner = ArrayPool.Shared.Rent((int)stream.Length); + + var ms = new PooledByteBufferWriter(); + await using var writer = new Utf8JsonWriter(ms); + + try + { + writer.WriteStartArray(); + + await writer.FlushAsync(cancellationToken); + + var read = await stream.ReadAsync(memoryOwner, cancellationToken); + + var rowsMarks = new List>(); + var errors = new List(); + var stats = new List(); + + var baton = Initialize( + memoryOwner.AsSpan(0, read), + writer, + rowsMarks, + errors, + stats, + resultsToIgnore ?? []); + + writer.WriteEndArray(); + + await writer.FlushAsync(cancellationToken); + + return new ResultReader(ms, baton, errors.ToArray(), stats.ToArray(), rowsMarks); + } + finally + { + ArrayPool.Shared.Return(memoryOwner); + } + } + + public static async Task ParseAsync( + HttpContent content, + HashSet? resultsToIgnore = null, + CancellationToken cancellationToken = default) + { + using var stream = new MemoryStream(); + await content.CopyToAsync(stream, cancellationToken); + stream.Seek(0, SeekOrigin.Begin); + + return await ParseAsync(stream, resultsToIgnore, cancellationToken); + } + + private static string? Initialize( + ReadOnlySpan bytes, + Utf8JsonWriter writer, + List> states, + List errors, + List stats, + HashSet resultsToIgnore) + { + var reader = new Utf8JsonReader(bytes); + + string? baton = null; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.PropertyName && reader.ValueTextEquals("baton"u8) && reader.Read() && + reader.TokenType is JsonTokenType.String) + { + baton = reader.GetString(); + continue; + } + + if (reader.TokenType == JsonTokenType.PropertyName && reader.ValueTextEquals("results"u8)) + { + reader.Read(); + HandleResultsArray(ref reader, writer, states, errors, stats, resultsToIgnore); + } + } + + return baton; + } + + private static void HandleResultsArray( + ref Utf8JsonReader reader, + Utf8JsonWriter writer, + List> marks, + List errors, + List stats, + HashSet resultsToIgnore) + { + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + if (reader.TokenType == JsonTokenType.PropertyName && reader.ValueTextEquals("type"u8)) + { + reader.Read(); + + if (reader.ValueTextEquals("error"u8)) + HandleErrorResult(ref reader, errors); + else if (reader.ValueTextEquals("batch"u8)) + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType != JsonTokenType.PropertyName) continue; + + if (reader.ValueTextEquals("step_results"u8)) + { + var counter = -1; + + reader.Read(); + + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + counter++; + + if (resultsToIgnore.Contains(counter)) + { + reader.Skip(); + continue; + } + + if (reader.TokenType is JsonTokenType.Null) + { + marks.Add([]); + continue; + } + + ReadResults(ref reader, writer, marks, stats); + } + + continue; + } + + if (reader.ValueTextEquals("step_errors"u8) && + reader.Read()) + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + if (reader.TokenType == JsonTokenType.Null) continue; + + HandleErrorResult(ref reader, errors); + } + } + else if (reader.ValueTextEquals("execute"u8)) + while (reader.Read() && reader.TokenType is not JsonTokenType.EndObject) + if (reader.TokenType is JsonTokenType.PropertyName && reader.ValueTextEquals("result"u8)) + ReadResults(ref reader, writer, marks, stats); + } + } + + private static void HandleErrorResult( + ref Utf8JsonReader reader, + List errors) + { + if (reader.TokenType is JsonTokenType.Null) return; + + string? message = null; + string? code = null; + + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + var isPropName = reader.TokenType == JsonTokenType.PropertyName; + + switch (isPropName) + { + case true when reader.ValueTextEquals("message"u8): + reader.Read(); + message = reader.GetString(); + break; + case true when reader.ValueTextEquals("code"u8): + reader.Read(); + code = reader.GetString(); + break; + } + } + + if (message is not null) errors.Add(new ExecutionError(message, code)); + } + + private static void ReadResults( + ref Utf8JsonReader reader, + Utf8JsonWriter writer, + List> marks, + List stats) + { + var cols = new List(); + var rows = new List(); + + var rowsRead = 0; + var affectedRows = 0; + var rowsWritten = 0; + double queryDurationInMilliseconds = 0; + string? lastInsertedRowId = null; + string? replicationIndex = null; + + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType != JsonTokenType.PropertyName) continue; + + if (reader.ValueTextEquals("rows_read"u8) && reader.Read()) + { + rowsRead = reader.GetInt32(); + continue; + } + + if (reader.ValueTextEquals("rows_written"u8) && reader.Read()) + { + rowsWritten = reader.GetInt32(); + continue; + } + + if (reader.ValueTextEquals("affected_row_count"u8) && reader.Read()) + { + affectedRows = reader.GetInt32(); + continue; + } + + if (reader.ValueTextEquals("query_duration_ms"u8) && reader.Read()) + { + queryDurationInMilliseconds = reader.GetDouble(); + continue; + } + + if (reader.ValueTextEquals("last_insert_rowid"u8) && reader.Read()) + { + lastInsertedRowId = reader.GetString(); + continue; + } + + if (reader.ValueTextEquals("replication_index"u8) && reader.Read()) + { + replicationIndex = reader.GetString(); + continue; + } + + if (reader.ValueTextEquals("cols"u8) && reader.Read()) + { + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + if (reader.TokenType is JsonTokenType.PropertyName && reader.ValueTextEquals("name"u8) && + reader.Read()) + cols.Add(reader.ValueSpan.ToArray()); + + continue; + } + + if (reader.ValueTextEquals("rows"u8) && reader.Read()) + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + writer.WriteStartObject(); + + var init = writer.BytesCommitted + writer.BytesPending - 1; + + var colIndex = 0; + + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + string? valueType = null; + + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType is not JsonTokenType.PropertyName) continue; + + if (valueType is null && reader.ValueTextEquals("type"u8) && reader.Read()) + valueType = reader.GetString(); + else if (reader.ValueTextEquals("base64"u8) && reader.Read()) + writer.WriteBase64String(cols[colIndex], reader.GetBytesFromBase64()); + else if (reader.ValueTextEquals("value"u8) && reader.Read()) + switch (valueType) + { + case not null when reader.TokenType == JsonTokenType.Null: + writer.WriteNull(cols[colIndex]); + break; + case "null": + writer.WriteNull(cols[colIndex]); + break; + case "float": + var val = reader.GetDouble(); + writer.WriteNumber(cols[colIndex], val); + break; + case "integer": + if (long.TryParse(reader.GetString(), out var longValue)) + writer.WriteNumber(cols[colIndex], longValue); + else + writer.WriteNull(cols[colIndex]); + + break; + default: + writer.WriteString(cols[colIndex], reader.ValueSpan); + break; + } + } + + colIndex++; + } + + writer.WriteEndObject(); + + var length = writer.BytesCommitted + writer.BytesPending - init; + + rows.Add([init, length]); + } + } + + stats.Add( + new ExecutionStats( + rowsRead, + affectedRows, + rowsWritten, + queryDurationInMilliseconds, + lastInsertedRowId, + replicationIndex)); + + marks.Add(rows); + } +} diff --git a/test/LibSql.Http.Client.Tests/Buffer/PooledByteBufferWriterTests.cs b/test/LibSql.Http.Client.Tests/Buffer/PooledByteBufferWriterTests.cs new file mode 100644 index 0000000..736a258 --- /dev/null +++ b/test/LibSql.Http.Client.Tests/Buffer/PooledByteBufferWriterTests.cs @@ -0,0 +1,51 @@ +using FluentAssertions; +using LibSql.Http.Client.Buffer; + +namespace LibSql.Http.Client.Tests.Buffer; + +public class PooledByteBufferWriterTests +{ + [Fact] + public void ShouldPreventAdvanceToMoreThanActualBufferLength() + { + using var buffer = new PooledByteBufferWriter(); + + var action = () => buffer.Advance(Int32.MaxValue); + + action.Should().ThrowExactly(); + } + + [Fact] + public void ShouldPreventAdvanceNegativeCount() + { + using var buffer = new PooledByteBufferWriter(); + + var action = () => buffer.Advance(-1); + + action.Should().ThrowExactly(); + } + + [Fact] + public void ShouldPreventBiggerThanArrayMaxLength() + { + using var buffer = new PooledByteBufferWriter(); + + var action = () => + { + buffer.GetSpan(Array.MaxLength + 1); + return 1; + }; + + action.Should().ThrowExactly(); + } + + [Fact] + public void ShouldGetSpan() + { + using var buffer = new PooledByteBufferWriter(); + + var span = buffer.GetSpan(257); + + span.Length.Should().BeGreaterThanOrEqualTo(257); + } +} \ No newline at end of file diff --git a/test/LibSql.Http.Client.Tests/Data/batch-response-multiple-results.json b/test/LibSql.Http.Client.Tests/Data/batch-response-multiple-results.json new file mode 100644 index 0000000..ab5219c --- /dev/null +++ b/test/LibSql.Http.Client.Tests/Data/batch-response-multiple-results.json @@ -0,0 +1,182 @@ +{ + "response": { + "baton": "something", + "base_url": null, + "results": [ + { + "type": "ok", + "response": { + "type": "batch", + "result": { + "step_results": [ + { + "cols": [], + "rows": [], + "affected_row_count": 0, + "last_insert_rowid": null, + "replication_index": null, + "rows_read": 0, + "rows_written": 0, + "query_duration_ms": 0 + }, + { + "cols": [ + { + "name": "id", + "decltype": "VARCHAR(50)" + }, + { + "name": "salary", + "decltype": "NUMBER" + }, + { + "name": "order", + "decltype": "INTEGER" + } + ], + "rows": [ + [ + { + "type": "text", + "value": "id-123" + }, + { + "type": "float", + "value": 1000.5 + }, + { + "type": "integer", + "value": "1" + } + ], + [ + { + "type": "text", + "value": "id-456" + }, + { + "type": "float", + "value": 2000.5 + }, + { + "type": "integer", + "value": "2" + } + ] + ], + "affected_row_count": 0, + "last_insert_rowid": null, + "replication_index": "54", + "rows_read": 2, + "rows_written": 0, + "query_duration_ms": 0.1 + }, + { + "cols": [ + { + "name": "id", + "decltype": "VARCHAR(50)" + }, + { + "name": "salary", + "decltype": "NUMBER" + }, + { + "name": "order", + "decltype": "INTEGER" + } + ], + "rows": [ + [ + { + "type": "text", + "value": "id-123" + }, + { + "type": "float", + "value": 1000.5 + }, + { + "type": "integer", + "value": "1" + } + ], + [ + { + "type": "text", + "value": "id-456" + }, + { + "type": "float", + "value": 2000.5 + }, + { + "type": "integer", + "value": "2" + } + ] + ], + "affected_row_count": 0, + "last_insert_rowid": null, + "replication_index": "54", + "rows_read": 2, + "rows_written": 0, + "query_duration_ms": 0.1 + }, + { + "cols": [], + "rows": [], + "affected_row_count": 0, + "last_insert_rowid": null, + "replication_index": null, + "rows_read": 0, + "rows_written": 0, + "query_duration_ms": 0 + }, + null + ], + "step_errors": [ + null, + null, + null, + null, + null + ] + } + } + }, + { + "type": "ok", + "response": { + "type": "close" + } + } + ] + }, + "expected": [ + [ + { + "id": "id-123", + "salary": 1000.5, + "order": 1 + }, + { + "id": "id-456", + "salary": 2000.5, + "order": 2 + } + ], + [ + { + "id": "id-123", + "salary": 1000.5, + "order": 1 + }, + { + "id": "id-456", + "salary": 2000.5, + "order": 2 + } + ] + ] +} \ No newline at end of file diff --git a/test/LibSql.Http.Client.Tests/Data/batch-response.json b/test/LibSql.Http.Client.Tests/Data/batch-response.json new file mode 100644 index 0000000..baec4a5 --- /dev/null +++ b/test/LibSql.Http.Client.Tests/Data/batch-response.json @@ -0,0 +1,293 @@ +[ + [ + { + "name": "Parse Batch response without error", + "response": { + "baton": null, + "base_url": null, + "results": [ + { + "type": "ok", + "response": { + "type": "batch", + "result": { + "step_results": [ + { + "cols": [], + "rows": [], + "affected_row_count": 0, + "last_insert_rowid": null, + "replication_index": null, + "rows_read": 0, + "rows_written": 0, + "query_duration_ms": 0 + }, + { + "cols": [ + { + "name": "id", + "decltype": "VARCHAR(50)" + }, + { + "name": "salary", + "decltype": "NUMBER" + }, + { + "name": "order", + "decltype": "INTEGER" + } + ], + "rows": [ + [ + { + "type": "text", + "value": "id-123" + }, + { + "type": "float", + "value": 1000.5 + }, + { + "type": "integer", + "value": "1" + } + ], + [ + { + "type": "text", + "value": "id-456" + }, + { + "type": "float", + "value": 2000.5 + }, + { + "type": "integer", + "value": "2" + } + ] + ], + "affected_row_count": 0, + "last_insert_rowid": null, + "replication_index": "54", + "rows_read": 2, + "rows_written": 0, + "query_duration_ms": 0.1 + }, + { + "cols": [ + { + "name": "id", + "decltype": "VARCHAR(50)" + }, + { + "name": "salary", + "decltype": "NUMBER" + }, + { + "name": "order", + "decltype": "INTEGER" + } + ], + "rows": [ + [ + { + "type": "text", + "value": "id-123" + }, + { + "type": "float", + "value": 1000.5 + }, + { + "type": "integer", + "value": "1" + } + ], + [ + { + "type": "text", + "value": "id-456" + }, + { + "type": "float", + "value": 2000.5 + }, + { + "type": "integer", + "value": "2" + } + ] + ], + "affected_row_count": 0, + "last_insert_rowid": null, + "replication_index": "54", + "rows_read": 2, + "rows_written": 0, + "query_duration_ms": 0.1 + }, + { + "cols": [], + "rows": [], + "affected_row_count": 0, + "last_insert_rowid": null, + "replication_index": null, + "rows_read": 0, + "rows_written": 0, + "query_duration_ms": 0 + }, + null + ], + "step_errors": [ + null, + null, + null, + null, + null + ] + } + } + }, + { + "type": "ok", + "response": { + "type": "close" + } + } + ] + }, + "commands": [ + false, + true, + true, + false, + false + ], + "errors": [], + "stats": [ + { + "RowsRead": 2, + "AffectedRows": 0, + "RowsWritten": 0, + "LastInsertedRowId": null, + "QueryDurationInMilliseconds": 0.1, + "ReplicationIndex": "54" + }, + { + "RowsRead": 2, + "AffectedRows": 0, + "RowsWritten": 0, + "LastInsertedRowId": null, + "QueryDurationInMilliseconds": 0.1, + "ReplicationIndex": "54" + } + ], + "expected": [ + [], + [ + { + "id": "id-123", + "salary": 1000.5, + "order": 1 + }, + { + "id": "id-456", + "salary": 2000.5, + "order": 2 + } + ], + [ + { + "id": "id-123", + "salary": 1000.5, + "order": 1 + }, + { + "id": "id-456", + "salary": 2000.5, + "order": 2 + } + ], + [], + [] + ] + } + ], + [ + { + "name": "Parse Batch response with error in one step", + "response": { + "baton": null, + "base_url": null, + "results": [ + { + "type": "ok", + "response": { + "type": "batch", + "result": { + "step_results": [ + { + "cols": [], + "rows": [], + "affected_row_count": 0, + "last_insert_rowid": null, + "replication_index": null, + "rows_read": 0, + "rows_written": 0, + "query_duration_ms": 0 + }, + null, + null, + { + "cols": [], + "rows": [], + "affected_row_count": 0, + "last_insert_rowid": null, + "replication_index": null, + "rows_read": 0, + "rows_written": 0, + "query_duration_ms": 0 + } + ], + "step_errors": [ + null, + { + "message": "SQLite error: no such table: wrong_table_name", + "code": "SQLITE_UNKNOWN" + }, + null, + null + ] + } + } + }, + { + "type": "ok", + "response": { + "type": "close" + } + } + ] + }, + "commands": [ + false, + true, + false, + false + ], + "errors": [ + { + "Message": "SQLite error: no such table: wrong_table_name", + "Code": "SQLITE_UNKNOWN" + } + ], + "stats": [], + "expected": [ + [], + [], + [], + [] + ] + } + ] +] diff --git a/test/LibSql.Http.Client.Tests/Data/batch.json b/test/LibSql.Http.Client.Tests/Data/batch.json new file mode 100644 index 0000000..fd4c897 --- /dev/null +++ b/test/LibSql.Http.Client.Tests/Data/batch.json @@ -0,0 +1,603 @@ +[ + [ + { + "name": "Batch statements without transaction", + "transaction": 0, + "statements": [ + { + "sql": "DELETE FROM my_table" + }, + { + "sql": "INSERT INTO my_table (id, name, salary) VALUE (@id, @name, @salary)", + "named_args": [ + { + "name": "id", + "type": "integer", + "value": 1, + "hrana_value": "1" + }, + { + "name": "name", + "type": "text", + "value": "john" + }, + { + "name": "salary", + "type": "float", + "value": 1000.50 + } + ] + }, + { + "sql": "UPDATE my_table SET name = ?, salary = ? WHERE id = ?", + "args": [ + { + "name": "name", + "type": "text", + "value": "john doe" + }, + { + "name": "salary", + "type": "float", + "value": 1000.50 + }, + { + "name": "id", + "type": "integer", + "value": 1, + "hrana_value": "1" + } + ] + } + ], + "request": { + "requests": [ + { + "type": "batch", + "batch": { + "steps": [ + { + "stmt": { + "sql": "DELETE FROM my_table" + } + }, + { + "stmt": { + "sql": "INSERT INTO my_table (id, name, salary) VALUE (@id, @name, @salary)", + "named_args": [ + { + "name": "id", + "value": { + "type": "integer", + "value": "1" + } + }, + { + "name": "name", + "value": { + "type": "text", + "value": "john" + } + }, + { + "name": "salary", + "value": { + "type": "float", + "value": 1000.50 + } + } + ] + }, + "condition": { + "type": "ok", + "step": 0 + } + }, + { + "stmt": { + "sql": "UPDATE my_table SET name = ?, salary = ? WHERE id = ?", + "args": [ + { + "type": "text", + "value": "john doe" + }, + { + "type": "float", + "value": 1000.50 + }, + { + "type": "integer", + "value": "1" + } + ] + }, + "condition": { + "type": "ok", + "step": 1 + } + } + ] + } + }, + { + "type": "close" + } + ] + } + } + ], + [ + { + "name": "Batch statements with IMMEDIATE transaction (non-interactive)", + "transaction": 1, + "is_interactive": false, + "statements": [ + { + "sql": "DELETE FROM my_table" + }, + { + "sql": "INSERT INTO my_table (id, name, salary) VALUE (@id, @name, @salary)", + "named_args": [ + { + "name": "id", + "type": "integer", + "value": 1, + "hrana_value": "1" + }, + { + "name": "name", + "type": "text", + "value": "john" + }, + { + "name": "salary", + "type": "float", + "value": 1000.50 + } + ] + }, + { + "sql": "UPDATE my_table SET name = ?, salary = ? WHERE id = ?", + "args": [ + { + "name": "name", + "type": "text", + "value": "john doe" + }, + { + "name": "salary", + "type": "float", + "value": 1000.50 + }, + { + "name": "id", + "type": "integer", + "value": 1, + "hrana_value": "1" + } + ] + } + ], + "request": { + "requests": [ + { + "type": "batch", + "batch": { + "steps": [ + { + "stmt": { + "sql": "BEGIN IMMEDIATE" + } + }, + { + "stmt": { + "sql": "DELETE FROM my_table" + }, + "condition": { + "type": "ok", + "step": 0 + } + }, + { + "stmt": { + "sql": "INSERT INTO my_table (id, name, salary) VALUE (@id, @name, @salary)", + "named_args": [ + { + "name": "id", + "value": { + "type": "integer", + "value": "1" + } + }, + { + "name": "name", + "value": { + "type": "text", + "value": "john" + } + }, + { + "name": "salary", + "value": { + "type": "float", + "value": 1000.50 + } + } + ] + }, + "condition": { + "type": "ok", + "step": 1 + } + }, + { + "stmt": { + "sql": "UPDATE my_table SET name = ?, salary = ? WHERE id = ?", + "args": [ + { + "type": "text", + "value": "john doe" + }, + { + "type": "float", + "value": 1000.50 + }, + { + "type": "integer", + "value": "1" + } + ] + }, + "condition": { + "type": "ok", + "step": 2 + } + }, + { + "stmt": { + "sql": "COMMIT" + }, + "condition": { + "type": "ok", + "step": 3 + } + }, + { + "stmt": { + "sql": "ROLLBACK" + }, + "condition": { + "type": "not", + "cond": { + "type": "ok", + "step": 4 + } + } + } + ] + } + }, + { + "type": "close" + } + ] + } + } + ], + [ + { + "name": "Batch statements with DEFERRED transaction (non-interactive)", + "transaction": 2, + "is_interactive": false, + "statements": [ + { + "sql": "DELETE FROM my_table" + }, + { + "sql": "INSERT INTO my_table (id, name, salary) VALUE (@id, @name, @salary)", + "named_args": [ + { + "name": "id", + "type": "integer", + "value": 1, + "hrana_value": "1" + }, + { + "name": "name", + "type": "text", + "value": "john" + }, + { + "name": "salary", + "type": "float", + "value": 1000.50 + } + ] + }, + { + "sql": "UPDATE my_table SET name = ?, salary = ? WHERE id = ?", + "args": [ + { + "name": "name", + "type": "text", + "value": "john doe" + }, + { + "name": "salary", + "type": "float", + "value": 1000.50 + }, + { + "name": "id", + "type": "integer", + "value": 1, + "hrana_value": "1" + } + ] + } + ], + "request": { + "requests": [ + { + "type": "batch", + "batch": { + "steps": [ + { + "stmt": { + "sql": "BEGIN DEFERRED" + } + }, + { + "stmt": { + "sql": "DELETE FROM my_table" + }, + "condition": { + "type": "ok", + "step": 0 + } + }, + { + "stmt": { + "sql": "INSERT INTO my_table (id, name, salary) VALUE (@id, @name, @salary)", + "named_args": [ + { + "name": "id", + "value": { + "type": "integer", + "value": "1" + } + }, + { + "name": "name", + "value": { + "type": "text", + "value": "john" + } + }, + { + "name": "salary", + "value": { + "type": "float", + "value": 1000.50 + } + } + ] + }, + "condition": { + "type": "ok", + "step": 1 + } + }, + { + "stmt": { + "sql": "UPDATE my_table SET name = ?, salary = ? WHERE id = ?", + "args": [ + { + "type": "text", + "value": "john doe" + }, + { + "type": "float", + "value": 1000.50 + }, + { + "type": "integer", + "value": "1" + } + ] + }, + "condition": { + "type": "ok", + "step": 2 + } + }, + { + "stmt": { + "sql": "COMMIT" + }, + "condition": { + "type": "ok", + "step": 3 + } + }, + { + "stmt": { + "sql": "ROLLBACK" + }, + "condition": { + "type": "not", + "cond": { + "type": "ok", + "step": 4 + } + } + } + ] + } + }, + { + "type": "close" + } + ] + } + } + ], + [ + { + "name": "Batch statements with READONLY transaction (non-interactive)", + "transaction": 3, + "is_interactive": false, + "statements": [ + { + "sql": "DELETE FROM my_table" + }, + { + "sql": "INSERT INTO my_table (id, name, salary) VALUE (@id, @name, @salary)", + "named_args": [ + { + "name": "id", + "type": "integer", + "value": 1, + "hrana_value": "1" + }, + { + "name": "name", + "type": "text", + "value": "john" + }, + { + "name": "salary", + "type": "float", + "value": 1000.50 + } + ] + }, + { + "sql": "UPDATE my_table SET name = ?, salary = ? WHERE id = ?", + "args": [ + { + "name": "name", + "type": "text", + "value": "john doe" + }, + { + "name": "salary", + "type": "float", + "value": 1000.50 + }, + { + "name": "id", + "type": "integer", + "value": 1, + "hrana_value": "1" + } + ] + } + ], + "request": { + "requests": [ + { + "type": "batch", + "batch": { + "steps": [ + { + "stmt": { + "sql": "BEGIN TRANSACTION READONLY" + } + }, + { + "stmt": { + "sql": "DELETE FROM my_table" + }, + "condition": { + "type": "ok", + "step": 0 + } + }, + { + "stmt": { + "sql": "INSERT INTO my_table (id, name, salary) VALUE (@id, @name, @salary)", + "named_args": [ + { + "name": "id", + "value": { + "type": "integer", + "value": "1" + } + }, + { + "name": "name", + "value": { + "type": "text", + "value": "john" + } + }, + { + "name": "salary", + "value": { + "type": "float", + "value": 1000.50 + } + } + ] + }, + "condition": { + "type": "ok", + "step": 1 + } + }, + { + "stmt": { + "sql": "UPDATE my_table SET name = ?, salary = ? WHERE id = ?", + "args": [ + { + "type": "text", + "value": "john doe" + }, + { + "type": "float", + "value": 1000.50 + }, + { + "type": "integer", + "value": "1" + } + ] + }, + "condition": { + "type": "ok", + "step": 2 + } + }, + { + "stmt": { + "sql": "COMMIT" + }, + "condition": { + "type": "ok", + "step": 3 + } + }, + { + "stmt": { + "sql": "ROLLBACK" + }, + "condition": { + "type": "not", + "cond": { + "type": "ok", + "step": 4 + } + } + } + ] + } + }, + { + "type": "close" + } + ] + } + } + ] +] \ No newline at end of file diff --git a/test/LibSql.Http.Client.Tests/Data/execute-response-no-error-multiple-result.json b/test/LibSql.Http.Client.Tests/Data/execute-response-no-error-multiple-result.json new file mode 100644 index 0000000..d66e3e7 --- /dev/null +++ b/test/LibSql.Http.Client.Tests/Data/execute-response-no-error-multiple-result.json @@ -0,0 +1,98 @@ +{ + "sql": "SELECT * FROM my_table;", + "request": { + "requests": [ + { + "type": "execute", + "stmt": { + "sql": "SELECT * FROM my_table;" + } + }, + { + "type": "close" + } + ] + }, + "response": { + "baton": null, + "base_url": null, + "results": [ + { + "type": "ok", + "response": { + "type": "execute", + "result": { + "cols": [ + { + "name": "id", + "decltype": "VARCHAR(50)" + }, + { + "name": "salary", + "decltype": "NUMBER" + }, + { + "name": "order", + "decltype": "INTEGER" + } + ], + "rows": [ + [ + { + "type": "text", + "value": "id-123" + }, + { + "type": "float", + "value": 1000.5 + }, + { + "type": "integer", + "value": "1" + } + ], + [ + { + "type": "text", + "value": "id-456" + }, + { + "type": "float", + "value": 2000.5 + }, + { + "type": "integer", + "value": "2" + } + ] + ], + "affected_row_count": 0, + "last_insert_rowid": null, + "replication_index": "54", + "rows_read": 2, + "rows_written": 0, + "query_duration_ms": 0.1 + } + } + }, + { + "type": "ok", + "response": { + "type": "close" + } + } + ] + }, + "expected": [ + { + "id": "id-123", + "salary": 1000.5, + "order": 1 + }, + { + "id": "id-456", + "salary": 2000.5, + "order": 2 + } + ] +} diff --git a/test/LibSql.Http.Client.Tests/Data/execute-response-no-error-no-result.json b/test/LibSql.Http.Client.Tests/Data/execute-response-no-error-no-result.json new file mode 100644 index 0000000..e9af22a --- /dev/null +++ b/test/LibSql.Http.Client.Tests/Data/execute-response-no-error-no-result.json @@ -0,0 +1,45 @@ +{ + "sql": "SELECT * FROM my_table;", + "request": { + "requests": [ + { + "type": "execute", + "stmt": { + "sql": "SELECT * FROM my_table;" + } + }, + { + "type": "close" + } + ] + }, + "response": { + "baton": null, + "base_url": null, + "results": [ + { + "type": "ok", + "response": { + "type": "execute", + "result": { + "cols": [], + "rows": [], + "affected_row_count": 0, + "last_insert_rowid": null, + "replication_index": "54", + "rows_read": 0, + "rows_written": 0, + "query_duration_ms": 0.1 + } + } + }, + { + "type": "ok", + "response": { + "type": "close" + } + } + ] + }, + "expected": [] +} diff --git a/test/LibSql.Http.Client.Tests/Data/execute-response-no-error-single-result.json b/test/LibSql.Http.Client.Tests/Data/execute-response-no-error-single-result.json new file mode 100644 index 0000000..a48ae9d --- /dev/null +++ b/test/LibSql.Http.Client.Tests/Data/execute-response-no-error-single-result.json @@ -0,0 +1,80 @@ +{ + "sql": "SELECT * FROM my_table;", + "request": { + "requests": [ + { + "type": "execute", + "stmt": { + "sql": "SELECT * FROM my_table;" + } + }, + { + "type": "close" + } + ] + }, + "response": { + "baton": null, + "base_url": null, + "results": [ + { + "type": "ok", + "response": { + "type": "execute", + "result": { + "cols": [ + { + "name": "id", + "decltype": "VARCHAR(50)" + }, + { + "name": "salary", + "decltype": "NUMBER" + }, + { + "name": "order", + "decltype": "INTEGER" + } + ], + "rows": [ + [ + { + "type": "text", + "value": "id-123" + }, + { + "type": "float", + "value": 1000.5 + }, + { + "type": "integer", + "value": "1" + } + ] + ], + "affected_row_count": 0, + "last_insert_rowid": null, + "replication_index": "54", + "rows_read": 1, + "rows_written": 0, + "query_duration_ms": 0.1 + } + } + }, + { + "type": "ok", + "response": { + "type": "close" + } + } + ] + }, + "expected": [ + { + "id": "id-123", + "salary": 1000.5, + "attributes": "{\"key\":\"prop\"}", + "order": 1 + } + ] +} diff --git a/test/LibSql.Http.Client.Tests/Data/execute-response-with-error.json b/test/LibSql.Http.Client.Tests/Data/execute-response-with-error.json new file mode 100644 index 0000000..33cf950 --- /dev/null +++ b/test/LibSql.Http.Client.Tests/Data/execute-response-with-error.json @@ -0,0 +1,36 @@ +{ + "sql": "SELECT * FROM wrong_table_name;", + "request": { + "requests": [ + { + "type": "execute", + "stmt": { + "sql": "SELECT * FROM wrong_table_name;" + } + }, + { + "type": "close" + } + ] + }, + "response": { + "baton": null, + "base_url": null, + "results": [ + { + "type": "error", + "error": { + "message": "SQLite error: no such table: wrong_table_name", + "code": "SQLITE_UNKNOWN" + } + }, + { + "type": "ok", + "response": { + "type": "close" + } + } + ] + }, + "expected": [] +} diff --git a/test/LibSql.Http.Client.Tests/Data/execute-response.json b/test/LibSql.Http.Client.Tests/Data/execute-response.json new file mode 100644 index 0000000..b0b9e85 --- /dev/null +++ b/test/LibSql.Http.Client.Tests/Data/execute-response.json @@ -0,0 +1,142 @@ +[ + [ + { + "name": "Parse Execute response without error", + "response": { + "baton": null, + "base_url": null, + "results": [ + { + "type": "ok", + "response": { + "type": "execute", + "result": { + "cols": [ + { + "name": "id", + "decltype": "VARCHAR(50)" + }, + { + "name": "salary", + "decltype": "NUMBER" + }, + { + "name": "order", + "decltype": "INTEGER" + } + ], + "rows": [ + [ + { + "type": "text", + "value": "id-123" + }, + { + "type": "float", + "value": 1000.5 + }, + { + "type": "integer", + "value": "1" + } + ], + [ + { + "type": "text", + "value": "id-456" + }, + { + "type": "float", + "value": 2000.5 + }, + { + "type": "integer", + "value": "2" + } + ] + ], + "affected_row_count": 0, + "last_insert_rowid": null, + "replication_index": "54", + "rows_read": 2, + "rows_written": 0, + "query_duration_ms": 0.1 + } + } + }, + { + "type": "ok", + "response": { + "type": "close" + } + } + ] + }, + "commands": [ + true + ], + "errors": [], + "stats": [ + { + "RowsRead": 2, + "AffectedRows": 0, + "RowsWritten": 0, + "LastInsertedRowId": null, + "QueryDurationInMilliseconds": 0.1, + "ReplicationIndex": "54" + } + ], + "expected": [ + [ + { + "id": "id-123", + "salary": 1000.5, + "attributes": "{\"key\":\"prop\"}", + "order": 1 + }, + { + "id": "id-456", + "salary": 2000.5, + "order": 2 + } + ] + ] + } + ], + [ + { + "name": "Parse execute response with error", + "response": { + "baton": null, + "base_url": null, + "results": [ + { + "type": "error", + "error": { + "message": "SQLite error: no such table: wrong_table_name", + "code": "SQLITE_UNKNOWN" + } + }, + { + "type": "ok", + "response": { + "type": "close" + } + } + ] + }, + "commands": [ + true + ], + "errors": [ + { + "Message": "SQLite error: no such table: wrong_table_name", + "Code": "SQLITE_UNKNOWN" + } + ], + "stats": [], + "expected": [ + ] + } + ] +] diff --git a/test/LibSql.Http.Client.Tests/Data/execute.json b/test/LibSql.Http.Client.Tests/Data/execute.json new file mode 100644 index 0000000..8d07849 --- /dev/null +++ b/test/LibSql.Http.Client.Tests/Data/execute.json @@ -0,0 +1,208 @@ +[ + [ + { + "name": "Execute statement with transaction (non-interactive)", + "transaction": 1, + "is_interactive": false, + "statements": [ + { + "sql": "DELETE FROM my_table" + } + ], + "request": { + "requests": [ + { + "type": "batch", + "batch": { + "steps": [ + { + "stmt": { + "sql": "BEGIN IMMEDIATE" + } + }, + { + "stmt": { + "sql": "DELETE FROM my_table" + }, + "condition": { + "type": "ok", + "step": 0 + } + }, + { + "stmt": { + "sql": "COMMIT" + }, + "condition": { + "type": "ok", + "step": 1 + } + }, + { + "stmt": { + "sql": "ROLLBACK" + }, + "condition": { + "type": "not", + "cond": { + "type": "ok", + "step": 2 + } + } + } + ] + } + }, + { + "type": "close" + } + ] + } + } + ], + [ + { + "name": "Execute statement without args", + "transaction": 0, + "statements": [ + { + "sql": "SELECT * FROM my_table" + } + ], + "request": { + "requests": [ + { + "type": "execute", + "stmt": { + "sql": "SELECT * FROM my_table" + } + }, + { + "type": "close" + } + ] + } + } + ], + [ + { + "name": "Execute statement with positional args", + "transaction": 0, + "statements": [ + { + "sql": "SELECT * FROM my_table WHERE id = ? AND name = ? AND salary > ?", + "args": [ + { + "name": "id", + "type": "integer", + "value": 1, + "hrana_value": "1" + }, + { + "name": "name", + "type": "text", + "value": "john" + }, + { + "name": "salary", + "type": "float", + "value": 1000.50 + } + ] + } + ], + "request": { + "requests": [ + { + "type": "execute", + "stmt": { + "sql": "SELECT * FROM my_table WHERE id = ? AND name = ? AND salary > ?", + "args": [ + { + "type": "integer", + "value": "1" + }, + { + "type": "text", + "value": "john" + }, + { + "type": "float", + "value": 1000.50 + } + ] + } + }, + { + "type": "close" + } + ] + } + } + ], + [ + { + "name": "Execute statement with named args", + "transaction": 0, + "statements": [ + { + "sql": "SELECT * FROM my_table WHERE id = @id AND name = @name AND salary > @salary", + "named_args": [ + { + "name": "id", + "type": "integer", + "value": 1, + "hrana_value": "1" + }, + { + "name": "name", + "type": "text", + "value": "john" + }, + { + "name": "salary", + "type": "float", + "value": 1000.50 + } + ] + } + ], + "request": { + "requests": [ + { + "type": "execute", + "stmt": { + "sql": "SELECT * FROM my_table WHERE id = @id AND name = @name AND salary > @salary", + "named_args": [ + { + "name": "id", + "value": { + "type": "integer", + "value": "1" + } + }, + { + "name": "name", + "value": { + "type": "text", + "value": "john" + } + }, + { + "name": "salary", + "value": { + "type": "float", + "value": 1000.50 + } + } + ] + } + }, + { + "type": "close" + } + ] + } + } + ] +] \ No newline at end of file diff --git a/test/LibSql.Http.Client.Tests/Integration/Assets/product-image.png b/test/LibSql.Http.Client.Tests/Integration/Assets/product-image.png new file mode 100644 index 0000000000000000000000000000000000000000..0127af0f7b9fb85b24202501ef0d8f2f08a8f294 GIT binary patch literal 4152 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 Items = + [ + new ProductTestModel("1", "Laptop", "High-performance gaming laptop", 1200.00m, 5, ImageBytes), + new ProductTestModel("2", "Smartphone", "Latest model with advanced features", 999.99m, 10, null), + new ProductTestModel("3", "Headphones", "Noise-cancelling headphones", 250.00m, 15, ImageBytes), + new ProductTestModel("4", "Smartwatch", "Waterproof smartwatch with GPS", 199.99m, 20, null), + new ProductTestModel("5", "Tablet", "Lightweight tablet with 12-inch screen", 450.00m, 8, ImageBytes), + new ProductTestModel("6", "E-reader", "E-reader with adjustable light", 130.00m, 12, null), + new ProductTestModel("7", "Camera", "DSLR camera with 24.1 MP", 600.00m, 7, ImageBytes), + new ProductTestModel("8", "Portable Speaker", "Bluetooth portable speaker", 120.00m, 25, null), + new ProductTestModel("9", "Video Game Console", "Next-gen video game console", 500.00m, 30, ImageBytes), + new ProductTestModel("10", "Wireless Mouse", "Ergonomic wireless mouse", 50.00m, 40, null), + new ProductTestModel("11", "Keyboard", "Mechanical keyboard with backlight", 70.00m, 35, ImageBytes), + new ProductTestModel("12", "External Hard Drive", "2TB external hard drive", 80.00m, 22, null), + new ProductTestModel("13", "USB Flash Drive", "128GB USB 3.0 flash drive", 25.00m, 50, ImageBytes), + new ProductTestModel("14", "Router", "Wi-Fi 6 router", 150.00m, 18, null), + new ProductTestModel("15", "Monitor", "27-inch 4K UHD monitor", 330.00m, 11, ImageBytes), + new ProductTestModel("16", "Graphics Card", "High-end gaming graphics card", 700.00m, 6, ImageBytes), + new ProductTestModel("17", "Processor", "8-core desktop processor", 320.00m, 9, null), + new ProductTestModel("18", "SSD", "1TB NVMe SSD", 100.00m, 14, ImageBytes), + new ProductTestModel("19", "RAM", "16GB DDR4 RAM", 60.00m, 28, null), + new ProductTestModel("20", "Power Supply", "750W modular power supply", 90.00m, 16, ImageBytes), + new ProductTestModel("21", "Gaming Chair", "Ergonomic gaming chair", 200.00m, 13, null), + new ProductTestModel("22", "Desk Lamp", "LED desk lamp with wireless charging", 40.00m, 33, ImageBytes), + new ProductTestModel("23", "Smart Home Hub", "Voice-controlled smart home hub", 130.00m, 19, null) + ]; + + public string InsertSqlWithPositionalArgs => + $"INSERT INTO {TableName} (id, name, description, price, stock, image) VALUES (?, ?, ?, ?, ?, ?)"; + + public string SelectProductsWithoutImageSql => + $"SELECT id, name, description, price, stock, image FROM {TableName} WHERE image IS NULL"; + + public string SelectProductsWithImageSql => + $"SELECT id, name, description, price, stock, image FROM {TableName} WHERE image IS NOT NULL"; + + public string CountSql => + $"SELECT COUNT(id) FROM {TableName}"; + + public string SelectAllSql => + $"SELECT id, name, description, price, stock, image FROM {TableName}"; + + public string SelectLikeNameSql => + $"SELECT id, name, description, price, stock, image FROM {TableName} WHERE name like ? LIMIT 1"; + + public string SelectByIdSql => + $"SELECT id, name, description, price, stock, image FROM {TableName} WHERE id = ? LIMIT 1"; + + public string InsertSqlWithNamedArgs => + $"INSERT INTO {TableName} (id, name, description, price, stock, image) VALUES (@id, @name, @description, @price, @stock, @image)"; + + public string CreateTableSql => + $"CREATE TABLE {TableName} (id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL, price REAL NOT NULL, stock INTEGER NOT NULL, image BLOB) WITHOUT ROWID"; + + public string TableName { get; } = tableName; +} diff --git a/test/LibSql.Http.Client.Tests/Integration/InsertCommandsTests.cs b/test/LibSql.Http.Client.Tests/Integration/InsertCommandsTests.cs new file mode 100644 index 0000000..9425e86 --- /dev/null +++ b/test/LibSql.Http.Client.Tests/Integration/InsertCommandsTests.cs @@ -0,0 +1,63 @@ +using FluentAssertions; +using LibSql.Http.Client.Request; +using LibSql.Http.Client.Tests.Integration.Fixture; +using LibSql.Http.Client.Tests.Integration.Models; + +namespace LibSql.Http.Client.Tests.Integration; + +public class InsertCommandsTests() : TestWithContainersBase("products_insert_commands_scenarios") +{ + [Fact] + public async Task CheckInsertCommandsUsingPositionalArgs() + { + await InitializeContainer(); + + var items = ProductTestData.Items.Where(i => i.Image is null).ToArray(); + + var statements = items.Select( + i => new Statement( + TestData.InsertSqlWithPositionalArgs, + [i.Id, i.Name, i.Description, i.Price, i.Stock, i.Image])).ToArray(); + + var results = await LibSqlClient.ExecuteMultipleAsync(statements, TransactionMode.WriteImmediate); + + results.Should().Be(statements.Length); + + var insertedItems = await LibSqlClient.QueryAsync( + TestData.SelectProductsWithoutImageSql, + IntegrationTestsSerializerContext.Default.ProductTestModel); + + insertedItems.Should().BeEquivalentTo(items); + } + + [Fact] + public async Task CheckInsertCommandsUsingNamedArgs() + { + await InitializeContainer(); + + var items = ProductTestData.Items.Where(i => i.Image is not null).ToArray(); + + var statements = items.Select( + i => new Statement( + TestData.InsertSqlWithNamedArgs, + new Dictionary + { + { "id", i.Id }, + { "name", i.Name }, + { "description", i.Description }, + { "price", i.Price }, + { "stock", i.Stock }, + { "image", i.Image } + })).ToArray(); + + var results = await LibSqlClient.ExecuteMultipleAsync(statements, TransactionMode.WriteImmediate); + + results.Should().Be(statements.Length); + + var insertedItems = await LibSqlClient.QueryAsync( + TestData.SelectProductsWithImageSql, + IntegrationTestsSerializerContext.Default.ProductTestModel); + + insertedItems.Should().BeEquivalentTo(items); + } +} diff --git a/test/LibSql.Http.Client.Tests/Integration/Models/IntegrationTestsSerializerContext.cs b/test/LibSql.Http.Client.Tests/Integration/Models/IntegrationTestsSerializerContext.cs new file mode 100644 index 0000000..2676052 --- /dev/null +++ b/test/LibSql.Http.Client.Tests/Integration/Models/IntegrationTestsSerializerContext.cs @@ -0,0 +1,8 @@ +using System.Text.Json.Serialization; + +namespace LibSql.Http.Client.Tests.Integration.Models; + +[JsonSerializable(typeof(ProductTestModel))] +public partial class IntegrationTestsSerializerContext : JsonSerializerContext +{ +} diff --git a/test/LibSql.Http.Client.Tests/Integration/Models/ProductTestModel.cs b/test/LibSql.Http.Client.Tests/Integration/Models/ProductTestModel.cs new file mode 100644 index 0000000..dff6216 --- /dev/null +++ b/test/LibSql.Http.Client.Tests/Integration/Models/ProductTestModel.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace LibSql.Http.Client.Tests.Integration.Models; + +public record ProductTestModel( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("description")] + string Description, + [property: JsonPropertyName("price")] decimal Price, + [property: JsonPropertyName("stock")] int Stock, + [property: JsonPropertyName("image")] byte[]? Image); diff --git a/test/LibSql.Http.Client.Tests/Integration/RollbackTests.cs b/test/LibSql.Http.Client.Tests/Integration/RollbackTests.cs new file mode 100644 index 0000000..cac0bc7 --- /dev/null +++ b/test/LibSql.Http.Client.Tests/Integration/RollbackTests.cs @@ -0,0 +1,47 @@ +using FluentAssertions; +using LibSql.Http.Client.Exceptions; +using LibSql.Http.Client.Request; +using LibSql.Http.Client.Tests.Integration.Fixture; + +namespace LibSql.Http.Client.Tests.Integration; + +public class RollbackTests() : TestWithContainersBase("products_rollback_scenarios") +{ + [Fact] + public async Task CheckRollbackWhenOneCommandIsInvalid() + { + await InitializeContainer(); + + var randomIndexSuccess = Random.Shared.Next(0, 5); + var randomIndexFail = Random.Shared.Next(5, ProductTestData.Items.Count); + + var expectedItemSuccess = ProductTestData.Items[randomIndexSuccess]; + var expectedItemFail = ProductTestData.Items[randomIndexFail]; + + Statement[] statements = + [ + new Statement( + TestData.InsertSqlWithPositionalArgs, + [ + expectedItemSuccess.Id, expectedItemSuccess.Name, expectedItemSuccess.Description, + expectedItemSuccess.Price, expectedItemSuccess.Stock, expectedItemSuccess.Image + ]), + new Statement( + TestData.InsertSqlWithPositionalArgs, + [ + expectedItemFail.Id, expectedItemFail.Name, null, expectedItemFail.Price, expectedItemFail.Stock, + expectedItemFail.Image + ]) + ]; + + var beforeCount = await LibSqlClient.ExecuteScalarAsync(TestData.CountSql); + + var failingAction = () => LibSqlClient.ExecuteMultipleAsync(statements, TransactionMode.WriteImmediate); + + await failingAction.Should().ThrowExactlyAsync(); + + var afterCount = await LibSqlClient.ExecuteScalarAsync(TestData.CountSql); + + afterCount.Should().Be(beforeCount); + } +} diff --git a/test/LibSql.Http.Client.Tests/Integration/SelectCommandsTests.cs b/test/LibSql.Http.Client.Tests/Integration/SelectCommandsTests.cs new file mode 100644 index 0000000..87093c3 --- /dev/null +++ b/test/LibSql.Http.Client.Tests/Integration/SelectCommandsTests.cs @@ -0,0 +1,63 @@ +using FluentAssertions; +using LibSql.Http.Client.Request; +using LibSql.Http.Client.Tests.Integration.Fixture; +using LibSql.Http.Client.Tests.Integration.Models; + +namespace LibSql.Http.Client.Tests.Integration; + +public class SelectCommandsTests() : TestWithContainersBase("products_select_commands_scenarios") +{ + [Fact] + public async Task CheckSelectAll() + { + await InitializeContainer(); + + var allItems = await LibSqlClient.QueryAsync( + TestData.SelectAllSql, + IntegrationTestsSerializerContext.Default.ProductTestModel); + + allItems.Should().BeEquivalentTo(ProductTestData.Items); + } + + [Fact] + public async Task CheckSelectById() + { + await InitializeContainer(); + + var randomIndex = Random.Shared.Next(0, ProductTestData.Items.Count); + + var expectedItem = ProductTestData.Items[randomIndex]; + + var item = await LibSqlClient.QuerySingleAsync( + new Statement(TestData.SelectByIdSql, [expectedItem.Id]), + IntegrationTestsSerializerContext.Default.ProductTestModel); + + item.Should().BeEquivalentTo(expectedItem); + } + + [Fact] + public async Task CheckSelectByNameLike() + { + await InitializeContainer(); + + var expectedItem = ProductTestData.Items[0]; + + var item = await LibSqlClient.QueryFirstAsync( + new Statement(TestData.SelectLikeNameSql, [$"%{expectedItem.Name}%"]), + IntegrationTestsSerializerContext.Default.ProductTestModel); + + item.Should().BeEquivalentTo(expectedItem); + } + + protected override async Task InitializeContainer() + { + await base.InitializeContainer(); + + var statements = ProductTestData.Items.Select( + i => new Statement( + TestData.InsertSqlWithPositionalArgs, + [i.Id, i.Name, i.Description, i.Price, i.Stock, i.Image])).ToArray(); + + await LibSqlClient.ExecuteMultipleAsync(statements, TransactionMode.WriteImmediate); + } +} diff --git a/test/LibSql.Http.Client.Tests/Integration/TestWithContainersBase.cs b/test/LibSql.Http.Client.Tests/Integration/TestWithContainersBase.cs new file mode 100644 index 0000000..ad9490c --- /dev/null +++ b/test/LibSql.Http.Client.Tests/Integration/TestWithContainersBase.cs @@ -0,0 +1,52 @@ +using DotNet.Testcontainers; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; +using LibSql.Http.Client.Tests.Integration.Fixture; + +namespace LibSql.Http.Client.Tests.Integration; + +public abstract class TestWithContainersBase : IAsyncDisposable +{ + private readonly HttpMessageHandler _handler = new SocketsHttpHandler(); + private readonly IContainer _libSqlContainer; + protected readonly ProductTestData TestData; + private HttpClient _httpClient = new(); + protected LibSqlHttpClient LibSqlClient; + + protected TestWithContainersBase(string tableName) + { + TestData = new ProductTestData(tableName); + ConsoleLogger.Instance.DebugLogLevelEnabled = true; + LibSqlClient = new LibSqlHttpClient(_httpClient, new Uri("https://fake.test")); + _libSqlContainer = + new ContainerBuilder().WithImage("ghcr.io/tursodatabase/libsql-server:latest").WithPortBinding(8080, true) + .WithWaitStrategy( + Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(r => r.ForPath("/health").ForPort(8080))) + .Build(); + } + + public async ValueTask DisposeAsync() + { + await _libSqlContainer.DisposeAsync(); + GC.SuppressFinalize(this); + } + + protected virtual async Task InitializeContainer() + { + if (_libSqlContainer.State is TestcontainersStates.Running) return; + + await _libSqlContainer.StartAsync(); + + _httpClient = new HttpClient(_handler) + { + BaseAddress = new UriBuilder( + Uri.UriSchemeHttp, + _libSqlContainer.Hostname, + _libSqlContainer.GetMappedPublicPort(8080)).Uri + }; + + LibSqlClient = new LibSqlHttpClient(_httpClient); + + await LibSqlClient.ExecuteAsync(TestData.CreateTableSql); + } +} diff --git a/test/LibSql.Http.Client.Tests/LibSql.Http.Client.Tests.csproj b/test/LibSql.Http.Client.Tests/LibSql.Http.Client.Tests.csproj new file mode 100644 index 0000000..e0ccb06 --- /dev/null +++ b/test/LibSql.Http.Client.Tests/LibSql.Http.Client.Tests.csproj @@ -0,0 +1,62 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/test/LibSql.Http.Client.Tests/LibSqlHttpClientTestsDefault.cs b/test/LibSql.Http.Client.Tests/LibSqlHttpClientTestsDefault.cs new file mode 100644 index 0000000..a6f9320 --- /dev/null +++ b/test/LibSql.Http.Client.Tests/LibSqlHttpClientTestsDefault.cs @@ -0,0 +1,261 @@ +using System.Net; +using System.Text.Json; +using FluentAssertions; +using LibSql.Http.Client.Exceptions; +using LibSql.Http.Client.Request; +using LibSql.Http.Client.Tests.Shared; +using LibSql.Http.Client.Tests.Shared.Attributes; +using LibSql.Http.Client.Tests.Shared.Models; + +namespace LibSql.Http.Client.Tests; + +public class LibSqlHttpClientTestsDefault +{ + [Fact] + public void ConstructorShouldFailIfUrlIsNull() + { + var action = () => new LibSqlHttpClient(new HttpClient()); + + action.Should().ThrowExactly(); + } + + [Theory] + [JsonFileData("Data/execute-response-no-error-no-result.json", true)] + public void ShouldAllowUseDifferentCredentials(JsonElement response) + { + var (handler, client) = CreateClient(response); + + client.WithCredentials(new Uri("https://another.libsql.test")).ExecuteAsync("SELECT * FROM table"); + + handler.LastSentRequest!.Uri.Should().Be("https://another.libsql.test/v3/pipeline"); + } + + [Theory] + [JsonFileData("Data/execute-response-with-error.json", true)] + public async Task ShouldThrowExceptionIfResultContainsError( + string sql, + JsonElement request, + JsonElement response) + { + var (handler, client) = CreateClient(response); + + var action = () => client.QueryAsync( + sql, + TestDataJsonSerializerContext.Default.ResultSetTestModel); + + await action.Should().ThrowExactlyAsync(); + handler.LastSentRequest.Should().NotBeNull(); + handler.LastSentRequest!.Body.Should().BeEquivalentTo(JsonSerializer.Serialize(request)); + } + + [Fact] + public async Task ShouldThrowExceptionIfStatusCodeIsNotSuccess() + { + var handler = new MockedJsonHttpHandler(new MockedJsonHttpResponse(HttpStatusCode.Forbidden, new { })); + var client = new LibSqlHttpClient(handler); + + var action = () => client.ExecuteScalarAsync("SELECT * FROM table"); + + await action.Should().ThrowExactlyAsync(); + } + + [Theory] + [JsonFileData("Data/execute-response-no-error-no-result.json", true)] + public async Task ShouldThrowExceptionWhenQuerySingleWithoutResults( + string sql, + JsonElement request, + JsonElement response) + { + var (handler, client) = CreateClient(response); + + var action = () => client.QuerySingleAsync( + sql, + TestDataJsonSerializerContext.Default.ResultSetTestModel); + + await action.Should().ThrowExactlyAsync(); + handler.LastSentRequest.Should().NotBeNull(); + handler.LastSentRequest!.Body.Should().BeEquivalentTo(JsonSerializer.Serialize(request)); + } + + [Theory] + [JsonFileData("Data/execute-response-no-error-no-result.json", true)] + public async Task ShouldThrowExceptionWhenQueryFirstWithoutResults( + string sql, + JsonElement request, + JsonElement response) + { + var (handler, client) = CreateClient(response); + + var action = () => client.QueryFirstAsync( + sql, + TestDataJsonSerializerContext.Default.ResultSetTestModel); + + await action.Should().ThrowExactlyAsync(); + handler.LastSentRequest.Should().NotBeNull(); + handler.LastSentRequest!.Body.Should().BeEquivalentTo(JsonSerializer.Serialize(request)); + } + + [Theory] + [JsonFileData("Data/execute-response-no-error-no-result.json", true)] + public async Task ShouldReturnNullWhenQuerySingleOrDefaultWithoutResults( + string sql, + JsonElement request, + JsonElement response) + { + var (handler, client) = CreateClient(response); + + var result = await client.QuerySingleOrDefaultAsync( + sql, + TestDataJsonSerializerContext.Default.ResultSetTestModel); + + result.Should().BeNull(); + handler.LastSentRequest.Should().NotBeNull(); + handler.LastSentRequest!.Body.Should().BeEquivalentTo(JsonSerializer.Serialize(request)); + } + + [Theory] + [JsonFileData("Data/execute-response-no-error-no-result.json", true)] + public async Task ShouldReturnNullWhenQueryFirstOrDefaultWithoutResults( + string sql, + JsonElement request, + JsonElement response) + { + var (handler, client) = CreateClient(response); + + var result = await client.QueryFirstOrDefaultAsync( + sql, + TestDataJsonSerializerContext.Default.ResultSetTestModel); + + result.Should().BeNull(); + handler.LastSentRequest.Should().NotBeNull(); + handler.LastSentRequest!.Body.Should().BeEquivalentTo(JsonSerializer.Serialize(request)); + } + + [Theory] + [JsonFileData("Data/execute-response-no-error-single-result.json", true)] + public async Task ShouldReturnTheFirstItemWhenQuerySingleWithOneResult( + string sql, + ResultSetTestModel[] expected, + JsonElement request, + JsonElement response) + { + var (handler, client) = CreateClient(response); + + var result = await client.QuerySingleAsync( + sql, + TestDataJsonSerializerContext.Default.ResultSetTestModel); + + result.Should().BeEquivalentTo(expected[0]); + handler.LastSentRequest.Should().NotBeNull(); + handler.LastSentRequest!.Body.Should().BeEquivalentTo(JsonSerializer.Serialize(request)); + } + + [Theory] + [JsonFileData("Data/execute-response-no-error-multiple-result.json", true)] + public async Task ShouldThrowExceptionWhenQuerySingleWitMultipleResults( + string sql, + JsonElement request, + JsonElement response) + { + var (handler, client) = CreateClient(response); + + var action = () => client.QuerySingleAsync( + sql, + TestDataJsonSerializerContext.Default.ResultSetTestModel); + + await action.Should().ThrowExactlyAsync(); + handler.LastSentRequest.Should().NotBeNull(); + handler.LastSentRequest!.Body.Should().BeEquivalentTo(JsonSerializer.Serialize(request)); + } + + [Theory] + [JsonFileData("Data/execute-response-no-error-multiple-result.json", true)] + public async Task ShouldReturnExpectedResults( + string sql, + ResultSetTestModel[] expected, + JsonElement request, + JsonElement response) + { + var (handler, client) = CreateClient(response); + + var result = await client.QueryAsync( + sql, + TestDataJsonSerializerContext.Default.ResultSetTestModel); + + result.Should().BeEquivalentTo(expected); + handler.LastSentRequest.Should().NotBeNull(); + handler.LastSentRequest!.Body.Should().BeEquivalentTo(JsonSerializer.Serialize(request)); + } + + [Theory] + [JsonFileData("Data/execute-response-no-error-no-result.json", true)] + public async Task ShouldReturnNullIfNoResultsWhenExecuteScalar( + string sql, + JsonElement response) + { + var (handler, client) = CreateClient(response); + + var result = await client.ExecuteScalarAsync(sql); + + result.Should().BeNull(); + } + + [Theory] + [JsonFileData("Data/execute-response-no-error-single-result.json", true)] + public async Task ShouldReturnTheFirstValueOfTheFirstItemWhenExecuteScalar( + string sql, + JsonElement request, + JsonElement response) + { + var (handler, client) = CreateClient(response); + + var result = await client.ExecuteScalarAsync(sql); + + result.Should().BeEquivalentTo( + response.GetProperty("results")[0].GetProperty("response").GetProperty("result").GetProperty("rows")[0][0] + .GetProperty("value").Deserialize()); + handler.LastSentRequest.Should().NotBeNull(); + handler.LastSentRequest!.Body.Should().BeEquivalentTo(JsonSerializer.Serialize(request)); + } + + [Theory] + [JsonFileData("Data/execute-response-no-error-single-result.json", true)] + public async Task ShouldReturnAffectedRowsWhenExecute( + string sql, + JsonElement request, + JsonElement response) + { + var (handler, client) = CreateClient(response); + + var result = await client.ExecuteAsync(sql); + + result.Should().Be(0); + handler.LastSentRequest.Should().NotBeNull(); + handler.LastSentRequest!.Body.Should().BeEquivalentTo(JsonSerializer.Serialize(request)); + } + + [Theory] + [JsonFileData("Data/batch-response-multiple-results.json", true)] + public async Task ShouldParseMultipleResults(JsonElement response, ResultSetTestModel[][] expected) + { + var (_, client) = CreateClient(response); + + var result = await client.QueryMultipleAsync(["SELECT * FROM table1", "SELECT * FROM TABLE 2"], TransactionMode.WriteImmediate); + + result.Count.Should().Be(expected.Length); + + for (var i = 0; i < expected.Length; i++) + { + result.HasMoreResults().Should().BeTrue(); + result.Read(TestDataJsonSerializerContext.Default.ResultSetTestModel).ToList().Should().BeEquivalentTo(expected[i]); + } + + result.HasMoreResults().Should().BeFalse(); + } + + private static (MockedJsonHttpHandler, LibSqlHttpClient) CreateClient(JsonElement response) + { + var handler = new MockedJsonHttpHandler(response); + return (handler, new LibSqlHttpClient(handler, null, "token")); + } +} \ No newline at end of file diff --git a/test/LibSql.Http.Client.Tests/Request/RequestSerializerTests.cs b/test/LibSql.Http.Client.Tests/Request/RequestSerializerTests.cs new file mode 100644 index 0000000..5f055ed --- /dev/null +++ b/test/LibSql.Http.Client.Tests/Request/RequestSerializerTests.cs @@ -0,0 +1,150 @@ +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using FluentAssertions; +using LibSql.Http.Client.Request; +using LibSql.Http.Client.Tests.Shared.Attributes; +using LibSql.Http.Client.Tests.Shared.Models; + +namespace LibSql.Http.Client.Tests.Request; + +public class RequestSerializerTests +{ + private readonly JsonSerializerOptions _jsonSerializerOptions = + new(TestDataJsonSerializerContext.Default.HranaPipelineRequestBody.Options) + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + [Theory] + [JsonFileData("Data/execute.json")] + public async Task CheckSingleExecuteSerialization(SerializationTestScenario scenario) + { + var statement = scenario.Statements[0].ToStatement(); + + var output = RequestSerializer.Serialize( + [statement], + scenario.Transaction, + scenario.Baton, + scenario.IsInteractive); + + var strOutput = await output.ReadAsStringAsync(); + + var requestBody = JsonSerializer.Serialize(scenario.Request, _jsonSerializerOptions); + + strOutput.Should().Be(requestBody); + } + + [Theory] + [JsonFileData("Data/batch.json")] + public async Task CheckBatchSerialization(SerializationTestScenario scenario) + { + var output = RequestSerializer.Serialize( + scenario.Statements.Select(s => s.ToStatement()).ToArray(), + scenario.Transaction, + scenario.Baton, + scenario.IsInteractive); + + var strOutput = await output.ReadAsStringAsync(); + + var requestBody = JsonSerializer.Serialize(scenario.Request, _jsonSerializerOptions); + + strOutput.Should().Be(requestBody); + } + + [Fact] + public void ShouldThrowExceptionIfArgValueIsNotSupported() + { + var action = () => + { + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + RequestSerializer.WriteArgObject(writer, new { type = "unsupported" }); + }; + + action.Should().ThrowExactly(); + } + + [Theory] + [InlineData((float)1)] + [InlineData((double)1)] + public void ShouldParseFloatLikeValue(object value) + { + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + RequestSerializer.WriteArgObject(writer, value); + + writer.Flush(); + + var strJson = Encoding.UTF8.GetString(stream.ToArray()); + + strJson.Should().Be($"{{\"type\":\"float\",\"value\":{value}}}"); + } + + [Fact] + public void ShouldParseDecimalAsFloatValue() + { + const decimal value = 1; + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + RequestSerializer.WriteArgObject(writer, value); + + writer.Flush(); + + var strJson = Encoding.UTF8.GetString(stream.ToArray()); + + strJson.Should().Be($"{{\"type\":\"float\",\"value\":{value}}}"); + } + + [Fact] + public void ShouldParseBlobValue() + { + byte[] value = [1, 2, 3]; + const string expected = "AQID"; + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + RequestSerializer.WriteArgObject(writer, value); + + writer.Flush(); + + var strJson = Encoding.UTF8.GetString(stream.ToArray()); + + strJson.Should().Be($"{{\"type\":\"blob\",\"base64\":\"{expected}\"}}"); + } + + [Fact] + public void ShouldParseNullValue() + { + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + RequestSerializer.WriteArgObject(writer, null); + + writer.Flush(); + + var strJson = Encoding.UTF8.GetString(stream.ToArray()); + + strJson.Should().Be("{\"type\":\"null\"}"); + } + + [Theory] + [InlineData(1, 1)] + [InlineData((long)1, 1)] + [InlineData((short)1, 1)] + [InlineData((ulong)1, 1)] + [InlineData((uint)1, 1)] + [InlineData((ushort)1, 1)] + [InlineData(true, 1)] + [InlineData(false, 0)] + public void ShouldParseIntegerLikeValue(object value, object expected) + { + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + RequestSerializer.WriteArgObject(writer, value); + + writer.Flush(); + + var strJson = Encoding.UTF8.GetString(stream.ToArray()); + + strJson.Should().Be($"{{\"type\":\"integer\",\"value\":\"{expected}\"}}"); + } +} diff --git a/test/LibSql.Http.Client.Tests/Response/ResultReaderTests.cs b/test/LibSql.Http.Client.Tests/Response/ResultReaderTests.cs new file mode 100644 index 0000000..e729ad8 --- /dev/null +++ b/test/LibSql.Http.Client.Tests/Response/ResultReaderTests.cs @@ -0,0 +1,83 @@ +using System.Text.Json; +using FluentAssertions; +using LibSql.Http.Client.Response; +using LibSql.Http.Client.Tests.Shared.Attributes; +using LibSql.Http.Client.Tests.Shared.Models; + +namespace LibSql.Http.Client.Tests.Response; + +public class ResultReaderTests +{ + [Theory] + [JsonFileData("Data/execute-response.json")] + public async Task ShouldParseResponse(ResultReaderTestScenario scenario) + { + using var stream = new MemoryStream(); + await using var writer = new Utf8JsonWriter(stream); + scenario.Response.RootElement.WriteTo(writer); + await writer.FlushAsync(); + stream.Seek(0, SeekOrigin.Begin); + using var reader = await ResultReader.ParseAsync(stream); + + reader.Count.Should().Be(scenario.Expected.Length); + + foreach (var t in scenario.Expected) + { + reader.HasMoreResults().Should().BeTrue(); + var items = reader.Read(TestDataJsonSerializerContext.Default.ResultSetTestModel); + items.Should().BeEquivalentTo(t); + } + + reader.Stats.Should().BeEquivalentTo(scenario.Stats); + reader.AffectedRows.Should().Be(scenario.Stats.Sum(s => s.AffectedRows)); + + reader.HasMoreResults().Should().BeFalse(); + reader.Errors.Should().BeEquivalentTo(scenario.Errors); + reader.Baton.Should().Be(null); + } + + [Theory] + [JsonFileData("Data/batch-response.json")] + public async Task ShouldParseBatchResponse(ResultReaderTestScenario scenario) + { + using var stream = new MemoryStream(); + await using var writer = new Utf8JsonWriter(stream); + scenario.Response.RootElement.WriteTo(writer); + await writer.FlushAsync(); + stream.Seek(0, SeekOrigin.Begin); + var reader = await ResultReader.ParseAsync(stream); + + reader.Count.Should().Be(scenario.Expected.Length); + + foreach (var t in scenario.Expected) + { + reader.HasMoreResults().Should().BeTrue(); + var items = reader.Read(TestDataJsonSerializerContext.Default.ResultSetTestModel); + items.Should().BeEquivalentTo(t); + } + + reader.Errors.Should().BeEquivalentTo(scenario.Errors); + reader.Baton.Should().Be(null); + } + + [Theory] + [JsonFileData("Data/execute-response.json")] + public async Task TryReadMoreResultsThanExistsThrowError(ResultReaderTestScenario scenario) + { + using var stream = new MemoryStream(); + await using var writer = new Utf8JsonWriter(stream); + scenario.Response.RootElement.WriteTo(writer); + await writer.FlushAsync(); + stream.Seek(0, SeekOrigin.Begin); + var reader = await ResultReader.ParseAsync(stream); + + var action = () => + reader.ReadAt(reader.Count, TestDataJsonSerializerContext.Default.ResultSetTestModel); + var action2 = () => + reader.ReadAt((reader.Count + 1) * -1, TestDataJsonSerializerContext.Default.ResultSetTestModel); + + reader.Count.Should().Be(scenario.Expected.Length); + action.Should().ThrowExactly(); + action2.Should().ThrowExactly(); + } +} diff --git a/test/LibSql.Http.Client.Tests/Shared/Attributes/JsonFileDataAttribute.cs b/test/LibSql.Http.Client.Tests/Shared/Attributes/JsonFileDataAttribute.cs new file mode 100644 index 0000000..e3b0398 --- /dev/null +++ b/test/LibSql.Http.Client.Tests/Shared/Attributes/JsonFileDataAttribute.cs @@ -0,0 +1,51 @@ +using System.Reflection; +using System.Text.Json; +using Xunit.Sdk; + +namespace LibSql.Http.Client.Tests.Shared.Attributes; + +public class JsonFileDataAttribute(string filePath, bool mapByProps = false) : DataAttribute +{ + public override IEnumerable GetData(MethodInfo testMethod) + { + ArgumentNullException.ThrowIfNull(testMethod); + + var parameters = testMethod.GetParameters(); + + // Get the absolute path to the JSON file + var path = Path.IsPathRooted(filePath) + ? filePath + : Path.GetRelativePath(Directory.GetCurrentDirectory(), filePath); + + if (!File.Exists(path)) throw new ArgumentException($"Could not find file at path: {path}"); + + // Load the file + var fileData = File.ReadAllText(filePath); + + return mapByProps ? GetMappingByProps(fileData, parameters) : GetAsArray(fileData, parameters); + } + + private IEnumerable GetAsArray(string fileData, ParameterInfo[] parameters) + { + var deserialized = JsonSerializer.Deserialize>(fileData); + + return deserialized?.Select( + c => c.Select((el, idx) => idx >= parameters.Length ? el : el.Deserialize(parameters[idx].ParameterType)) + .ToArray()) ?? []; + } + + private IEnumerable GetMappingByProps(string fileData, ParameterInfo[] parameters) + { + var deserialized = JsonSerializer.Deserialize(fileData); + + if (deserialized is null) return []; + + return + [ + parameters.Select( + p => p.Name is not null && deserialized.RootElement.TryGetProperty(p.Name, out var val) + ? val.Deserialize(p.ParameterType) + : null).ToArray() + ]; + } +} diff --git a/test/LibSql.Http.Client.Tests/Shared/MockedJsonHttpRequestHandler.cs b/test/LibSql.Http.Client.Tests/Shared/MockedJsonHttpRequestHandler.cs new file mode 100644 index 0000000..c33a7b6 --- /dev/null +++ b/test/LibSql.Http.Client.Tests/Shared/MockedJsonHttpRequestHandler.cs @@ -0,0 +1,48 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; + +namespace LibSql.Http.Client.Tests.Shared; + +public record MockedJsonHttpRequest(HttpMethod Method, string? Uri, string? Token, string? Body = null); + +public class MockedJsonHttpResponse(HttpStatusCode statusCode, object? body) +{ + public HttpStatusCode StatusCode { get; } = statusCode; + public Task GetContentAsync() => Task.FromResult(JsonContent.Create(body)); + + public static implicit operator MockedJsonHttpResponse(HttpStatusCode statusCode) => new(statusCode, null); + public static implicit operator MockedJsonHttpResponse(JsonElement body) => new(HttpStatusCode.OK, body); +} + +public class MockedJsonHttpHandler(MockedJsonHttpResponse expectedResponse) : HttpMessageHandler +{ + public static readonly Uri DefaultBaseAddress = new("https://libsql.test/"); + private readonly List _sentRequests = []; + + public IReadOnlyList SentRequests => _sentRequests; + + public MockedJsonHttpRequest? LastSentRequest => _sentRequests.LastOrDefault(); + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + _sentRequests.Add( + new MockedJsonHttpRequest( + request.Method, + request.RequestUri?.ToString(), + request.Headers.Authorization?.ToString(), + request.Content is not null ? await request.Content.ReadAsStringAsync(cancellationToken) : null)); + + var content = await expectedResponse.GetContentAsync(); + + return new HttpResponseMessage(expectedResponse.StatusCode) + { + Content = content + }; + } + + public static implicit operator HttpClient(MockedJsonHttpHandler handler) => new(handler) + { BaseAddress = DefaultBaseAddress }; +} diff --git a/test/LibSql.Http.Client.Tests/Shared/Models/HranaPipelineRequestBody.cs b/test/LibSql.Http.Client.Tests/Shared/Models/HranaPipelineRequestBody.cs new file mode 100644 index 0000000..c0dd69f --- /dev/null +++ b/test/LibSql.Http.Client.Tests/Shared/Models/HranaPipelineRequestBody.cs @@ -0,0 +1,47 @@ +using System.Text.Json.Serialization; + +namespace LibSql.Http.Client.Tests.Shared.Models; + +public record HranaNamedArg( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("value")] HranaArgValue Value); + +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(HranaFloatValue), "float")] +[JsonDerivedType(typeof(HranaTextValue), "text")] +[JsonDerivedType(typeof(HranaIntegerValue), "integer")] +public record HranaArgValue; + +public record HranaFloatValue([property: JsonPropertyName("value")] float Value) : HranaArgValue; + +public record HranaTextValue([property: JsonPropertyName("value")] string Value) : HranaArgValue; + +public record HranaIntegerValue([property: JsonPropertyName("value")] string Value) : HranaArgValue; + +public record HranaStatement( + [property: JsonPropertyName("sql")] string Sql, + [property: JsonPropertyName("args")] HranaArgValue?[]? Args = null, + [property: JsonPropertyName("named_args")] + HranaNamedArg[]? NamedArgs = null); + +public record HranaBatchStepCondition( + [property: JsonPropertyName("type")] string Type, + [property: JsonPropertyName("step")] int? Step = null, + [property: JsonPropertyName("cond")] HranaBatchStepCondition? Condition = null); + +public record HranaBatchStep( + [property: JsonPropertyName("stmt")] HranaStatement Statement, + [property: JsonPropertyName("condition")] + HranaBatchStepCondition? Condition = null); + +public record HranaBatchRequest([property: JsonPropertyName("steps")] HranaBatchStep[] Steps); + +public record HranaPipelineRequest( + [property: JsonPropertyName("type")] string Type, + [property: JsonPropertyName("batch")] HranaBatchRequest? Batch = null, + [property: JsonPropertyName("stmt")] HranaStatement? Statement = null); + +public record HranaPipelineRequestBody( + [property: JsonPropertyName("requests")] + HranaPipelineRequest[] Requests, + [property: JsonPropertyName("baton")] string? Baton = null); \ No newline at end of file diff --git a/test/LibSql.Http.Client.Tests/Shared/Models/ResultReaderTestScenario.cs b/test/LibSql.Http.Client.Tests/Shared/Models/ResultReaderTestScenario.cs new file mode 100644 index 0000000..640bb9f --- /dev/null +++ b/test/LibSql.Http.Client.Tests/Shared/Models/ResultReaderTestScenario.cs @@ -0,0 +1,19 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using LibSql.Http.Client.Response; + +namespace LibSql.Http.Client.Tests.Shared.Models; + +public record ResultReaderTestScenario( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("response")] + JsonDocument Response, + [property: JsonPropertyName("expected")] + ResultSetTestModel[]?[] Expected, + [property: JsonPropertyName("commands")] + bool[] Commands, + [property: JsonPropertyName("errors")] List Errors, + [property: JsonPropertyName("stats")] List Stats) +{ + public override string ToString() => Name; +} \ No newline at end of file diff --git a/test/LibSql.Http.Client.Tests/Shared/Models/ResultSetTestModel.cs b/test/LibSql.Http.Client.Tests/Shared/Models/ResultSetTestModel.cs new file mode 100644 index 0000000..ae6dfc5 --- /dev/null +++ b/test/LibSql.Http.Client.Tests/Shared/Models/ResultSetTestModel.cs @@ -0,0 +1,8 @@ +using System.Text.Json.Serialization; + +namespace LibSql.Http.Client.Tests.Shared.Models; + +public record ResultSetTestModel( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("salary")] float Salary, + [property: JsonPropertyName("order")] int Order); \ No newline at end of file diff --git a/test/LibSql.Http.Client.Tests/Shared/Models/SerializationTestScenario.cs b/test/LibSql.Http.Client.Tests/Shared/Models/SerializationTestScenario.cs new file mode 100644 index 0000000..4fc6a39 --- /dev/null +++ b/test/LibSql.Http.Client.Tests/Shared/Models/SerializationTestScenario.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; +using LibSql.Http.Client.Request; + +namespace LibSql.Http.Client.Tests.Shared.Models; + +public record SerializationTestScenario( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("statements")] + TestCaseStatement[] Statements, + [property: JsonPropertyName("transaction")] + TransactionMode Transaction, + [property: JsonPropertyName("request")] + HranaPipelineRequestBody Request, + [property: JsonPropertyName("baton")] string? Baton = null, + [property: JsonPropertyName("is_interactive")] + bool IsInteractive = false) +{ + public override string ToString() => Name; +} \ No newline at end of file diff --git a/test/LibSql.Http.Client.Tests/Shared/Models/TestCaseStatement.cs b/test/LibSql.Http.Client.Tests/Shared/Models/TestCaseStatement.cs new file mode 100644 index 0000000..8bd38a5 --- /dev/null +++ b/test/LibSql.Http.Client.Tests/Shared/Models/TestCaseStatement.cs @@ -0,0 +1,34 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using LibSql.Http.Client.Request; + +namespace LibSql.Http.Client.Tests.Shared.Models; + +public record TestCaseStatementArg( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("type")] string Type, + [property: JsonPropertyName("value")] object? Value, + [property: JsonPropertyName("hrana_value")] object? HranaValue); + +public record TestCaseStatement( + [property: JsonPropertyName("sql")] string Sql, + [property: JsonPropertyName("args")] TestCaseStatementArg[]? Args = null, + [property: JsonPropertyName("named_args")] TestCaseStatementArg[]? NamedArgs = null) +{ + public Statement ToStatement() => NamedArgs is not null + ? new Statement(Sql, NamedArgs.ToDictionary(a => a.Name, a => ParseArgValue(a.Value, a.Type))) + : new Statement(Sql, Args?.Select(a => ParseArgValue(a.Value, a.Type)).ToArray()); + + private static object? ParseArgValue(object? argValue, string type) + { + if (argValue is not JsonElement jsonElement) return argValue; + + return type switch + { + "integer" => jsonElement.GetInt64(), + "float" => jsonElement.GetDouble(), + "text" => jsonElement.GetString(), + _ => throw new ArgumentOutOfRangeException(nameof(type), type, null) + }; + } +} \ No newline at end of file diff --git a/test/LibSql.Http.Client.Tests/Shared/Models/TestDataJsonSerializerContext.cs b/test/LibSql.Http.Client.Tests/Shared/Models/TestDataJsonSerializerContext.cs new file mode 100644 index 0000000..4196d1e --- /dev/null +++ b/test/LibSql.Http.Client.Tests/Shared/Models/TestDataJsonSerializerContext.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace LibSql.Http.Client.Tests.Shared.Models; + +[JsonSerializable(typeof(TestCaseStatement))] +[JsonSerializable(typeof(SerializationTestScenario))] +[JsonSerializable(typeof(HranaPipelineRequestBody))] +[JsonSerializable(typeof(ResultReaderTestScenario))] +[JsonSerializable(typeof(ResultSetTestModel))] +[JsonSourceGenerationOptions( + NumberHandling = JsonNumberHandling.AllowReadingFromString, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +public partial class TestDataJsonSerializerContext : JsonSerializerContext; \ No newline at end of file