diff --git a/.github/workflows/dotnet-publish.yml b/.github/workflows/dotnet-publish.yml new file mode 100644 index 0000000000..929a984b80 --- /dev/null +++ b/.github/workflows/dotnet-publish.yml @@ -0,0 +1,121 @@ +name: Dotnet Publish + +on: + # Manually trigger the workflow + workflow_dispatch: + +env: + working-directory: bindings/dotnet + +jobs: + # Build native libraries for each platform + build-natives: + strategy: + matrix: + include: + - os: ubuntu-latest + make-target: build-rust-linux64 + artifact-name: linux64 + target: x86_64-unknown-linux-gnu + + - os: macos-latest + make-target: build-rust-macos64 + artifact-name: macos64 + target: x86_64-apple-darwin + + - os: macos-latest + make-target: build-rust-macosarm64 + target: aarch64-apple-darwin + artifact-name: macosarm64 + + - os: windows-latest + make-target: build-rust-windows64 + target: x86_64-pc-windows-msvc + artifact-name: windows64 + + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + defaults: + run: + working-directory: ${{ env.working-directory }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Add target + run: rustup target add ${{ matrix.target }} + + - name: Build rust native library + run: make ${{ matrix.make-target }} RUST_RELEASE_OPT='--release' + + - name: Upload rust native library + uses: actions/upload-artifact@v4 + with: + name: native-${{ matrix.artifact-name }} + path: | + ${{ env.working-directory }}/rs_compiled/**/turso_dotnet.dll + ${{ env.working-directory }}/rs_compiled/**/libturso_dotnet.so + ${{ env.working-directory }}/rs_compiled/**/libturso_dotnet.dylib + + retention-days: 1 + + # Create Turso.Raw and Turso nuget packages + publish: + needs: build-natives + runs-on: ubuntu-latest + timeout-minutes: 30 + + defaults: + run: + working-directory: ${{ env.working-directory }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install dotnet sdk 9.0 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: Download all native libraries + uses: actions/download-artifact@v4 + with: + pattern: native-* + path: ${{ env.working-directory }}/rs_compiled + merge-multiple: true + + - name: Build and pack + run: + make pack + + # https://learn.microsoft.com/en-us/nuget/nuget-org/publish-a-package + - name: Publish to nuget + if: false # TODO: Get nuget api key and publish packages + run: | + dotnet nuget push ${{ env.working-directory }}/src/Turso.Raw/bin/Release/Turso.Raw.*.nupkg --api-key ... --source https://api.nuget.org/v3/index.json + + dotnet nuget push ${{ env.working-directory }}/src/Turso/bin/Release/Turso.*.nupkg --api-key ... --source https://api.nuget.org/v3/index.json + + - name: Upload Turso.Raw to artifacts + uses: actions/upload-artifact@v4 + with: + name: Turso.Raw + path: ${{ env.working-directory }}/src/Turso.Raw/bin/Release/Turso.Raw.*.nupkg + retention-days: 7 + + - name: Upload Turso to artifacts + uses: actions/upload-artifact@v4 + with: + name: Turso + path: ${{ env.working-directory }}/src/Turso/bin/Release/Turso.*.nupkg + retention-days: 7 + + + diff --git a/.github/workflows/dotnet-test.yml b/.github/workflows/dotnet-test.yml new file mode 100644 index 0000000000..9f8537d276 --- /dev/null +++ b/.github/workflows/dotnet-test.yml @@ -0,0 +1,38 @@ +name: Dotnet Tests + +on: + push: + branches: + - main + tags: + - v* + pull_request: + branches: + - main + +env: + working-directory: bindings/dotnet + +jobs: + test: + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 30 + + defaults: + run: + working-directory: ${{ env.working-directory }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust(stable) + uses: dtolnay/rust-toolchain@stable + + - name: Install dotnet sdk 9.0 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: Run tests + run: make test diff --git a/Cargo.lock b/Cargo.lock index 4583e45535..b4c58a7051 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4848,6 +4848,13 @@ dependencies = [ "turso_core", ] +[[package]] +name = "turso-dotnet" +version = "0.3.0-pre.4" +dependencies = [ + "turso_core", +] + [[package]] name = "turso-java" version = "0.3.0-pre.4" diff --git a/Cargo.toml b/Cargo.toml index 4460ca6021..52783d984c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "bindings/javascript", "bindings/javascript/sync", "bindings/python", + "bindings/dotnet", "bindings/rust", "cli", "core", diff --git a/bindings/dotnet/.gitignore b/bindings/dotnet/.gitignore new file mode 100644 index 0000000000..a3868f0786 --- /dev/null +++ b/bindings/dotnet/.gitignore @@ -0,0 +1,487 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from `dotnet new gitignore` + +# dotenv files +.env + +# 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 +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# 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 +*.ncb +*.aps + +# 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 +.idea/ + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# Mac bundle stuff +*.dmg +*.app + +# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore +# 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 + +# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp + +src/Turso.Native/* +rs_compiled/* \ No newline at end of file diff --git a/bindings/dotnet/Cargo.toml b/bindings/dotnet/Cargo.toml new file mode 100644 index 0000000000..3fb2ee4a11 --- /dev/null +++ b/bindings/dotnet/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "turso-dotnet" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[lib] +name = "turso_dotnet" +crate-type = ["cdylib"] +path = "rs_src/lib.rs" + +[dependencies] +turso_core = { workspace = true } diff --git a/bindings/dotnet/Makefile b/bindings/dotnet/Makefile new file mode 100644 index 0000000000..5ba8ce1261 --- /dev/null +++ b/bindings/dotnet/Makefile @@ -0,0 +1,59 @@ +ifeq ($(OS),Windows_NT) + OS_TARGET = windows64 +else + UNAME_S := $(shell uname -s) + ifeq ($(UNAME_S),Linux) + OS_TARGET = linux64 + endif + ifeq ($(UNAME_S),Darwin) + UNAME_P := $(shell uname -p) + ifeq ($(UNAME_P),x86_64) + OS_TARGET = macos64 + else + OS_TARGET = macosarm64 + endif + endif +endif + +RUST_RELEASE_OPT ?= +DOTNET_RELEASE_OPT ?= + +all: + echo "make [build|test|benchmark|build-production]" + +build-rust-windows64: + cargo build --target-dir ./rs_compiled --target x86_64-pc-windows-msvc $(RUST_RELEASE_OPT) + +build-rust-linux64: + cargo build --target-dir ./rs_compiled --target x86_64-unknown-linux-gnu $(RUST_RELEASE_OPT) + +build-rust-macos64: + cargo build --target-dir ./rs_compiled --target x86_64-apple-darwin $(RUST_RELEASE_OPT) + +build-rust-macosarm64: + cargo build --target-dir ./rs_compiled --target aarch64-apple-darwin $(RUST_RELEASE_OPT) + +build-rust: + echo "Building for $(OS_TARGET)" + $(MAKE) build-rust-$(OS_TARGET) + +build-dotnet: + dotnet build $(DOTNET_RELEASE_OPT) ./src/Turso/Turso.csproj + +build: build-rust build-dotnet + +build-production: + $(MAKE) build RUST_RELEASE_OPT='--release' DOTNET_RELEASE_OPT='-c Release' + +test: build + dotnet test ./src/Turso.Tests/Turso.Tests.csproj + +benchmark: build-production + dotnet run -c Release --project ./src/Benchmarks/Benchmarks.csproj + +pack: + dotnet build -c Release ./src/Turso.Raw/Turso.Raw.csproj + dotnet build -c Release ./src/Turso.Raw/Turso.Raw.csproj + + dotnet pack -c Release ./src/Turso.Raw/Turso.Raw.csproj + dotnet pack -c Release ./src/Turso/Turso.csproj diff --git a/bindings/dotnet/Readme.md b/bindings/dotnet/Readme.md new file mode 100644 index 0000000000..4a6ecc7a24 --- /dev/null +++ b/bindings/dotnet/Readme.md @@ -0,0 +1,26 @@ +# Turso_dotnet + +Dotnet binding for turso database. + +## Getting Started + +```C# +using Turso; + +using var connection = new TursoConnection("Data Source=:memory:"); +connection.Open(); + +connection.ExecuteNonQuery("CREATE TABLE t(a, b)"); +var rowsAffected = connection.ExecuteNonQuery("INSERT INTO t(a, b) VALUES (1, 2), (3, 4)"); +Console.WriteLine($"RowsAffected: {rowsAffected}"); + +using var command = connection.CreateCommand(); +command.CommandText = "SELECT * FROM t"; +using var reader = command.ExecuteReader(); +while (reader.Read()) +{ + var a = reader.GetInt32(0); + var b = reader.GetInt32(1); + Console.WriteLine($"Value1: {a}, Value2: {b}"); +} +``` \ No newline at end of file diff --git a/bindings/dotnet/Turso.slnx b/bindings/dotnet/Turso.slnx new file mode 100644 index 0000000000..c880622161 --- /dev/null +++ b/bindings/dotnet/Turso.slnx @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bindings/dotnet/rs_src/lib.rs b/bindings/dotnet/rs_src/lib.rs new file mode 100644 index 0000000000..570582ec5b --- /dev/null +++ b/bindings/dotnet/rs_src/lib.rs @@ -0,0 +1,391 @@ +use std::borrow::Cow; +use std::ffi::CStr; +use std::num::NonZero; +use std::os::raw::c_char; +use std::ptr::null; +use std::slice; +use std::sync::Arc; +use turso_core::types::Text; +use turso_core::{self, Connection, Statement, StepResult, Value, IO}; + +type Error = *const i8; + +#[repr(C)] +pub struct Database { + io: Arc, + connection: Arc, +} + +#[repr(C)] +#[derive(Copy, Clone)] +pub enum ValueType { + Empty = 0, + Null = 1, + Integer = 2, + Float = 3, + Text = 4, + Blob = 5, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct Array { + ptr: *const u8, + len: usize, +} + +#[repr(C)] +#[derive(Copy, Clone)] +pub union TursoValueUnion { + int_val: i64, + real_val: f64, + text: Array, + blob: Array, +} + +#[repr(C)] +#[derive(Copy, Clone)] +pub struct TursoValue { + value_type: ValueType, + value: TursoValueUnion, +} + +pub fn allocate(value: T) -> *const T { + Box::into_raw(Box::new(value)) +} + +pub fn allocate_string(str: &str) -> *const c_char { + std::ffi::CString::new(str).unwrap().into_raw() +} + +pub fn to_vec(array: Array) -> Vec { + unsafe { + let slice = slice::from_raw_parts(array.ptr, array.len); + slice.to_vec() + } +} + +pub fn to_value(value: TursoValue) -> Value { + match value.value_type { + ValueType::Empty => Value::Null, + ValueType::Null => Value::Null, + ValueType::Integer => Value::Integer(unsafe { value.value.int_val }), + ValueType::Float => Value::Float(unsafe { value.value.real_val }), + ValueType::Blob => Value::Blob(to_vec(unsafe { value.value.blob })), + ValueType::Text => Value::Text(Text { + value: to_vec(unsafe { value.value.text }), + subtype: turso_core::types::TextSubtype::Text, + }), + } +} + +/// Opens a database at the specified path and returns a pointer to the database. +/// If an error occurred, returns null and writes a pointer to a null-terminated string into `error_ptr`. +/// +/// # Safety +/// +/// - The returned database pointer must be freed with `db_close`. +/// - Any error string written to `error_ptr` must be freed with `free_string`. +/// - `path_ptr` must not be null and must point to a valid null-terminated UTF-8 string. +/// - `error_ptr` must not be null and must point to a valid writable location. +#[no_mangle] +pub unsafe extern "C" fn db_open( + path_ptr: *const c_char, + error_ptr: *mut Error, +) -> *const Database { + let path_cstr: &CStr = unsafe { CStr::from_ptr(path_ptr) }; + let path_str = path_cstr.to_str(); + + let connection_result = + Connection::from_uri(path_str.unwrap(), true, false, false, false, false, false); + match connection_result { + Ok((io, val)) => allocate(Database { + io, + connection: val, + }), + Err(err) => { + unsafe { + *error_ptr = + allocate_string(format!("Error while opening database: {err}").as_str()) + } + null() + } + } +} + +/// Disposes the database pointer. +/// +/// # Safety +/// +/// - `db_ptr` must be a pointer allocated by `db_open`. +/// - Call `db_close` only once per `db_ptr`. +#[no_mangle] +pub unsafe extern "C" fn db_close(db_ptr: *mut Database) { + let _ = unsafe { Box::from_raw(db_ptr) }; +} + +/// Frees a null-terminated string previously allocated by this library. +/// +/// # Safety +/// +/// - `string_ptr` must be a pointer returned by this library (e.g., error messages, column names). +/// - Call `free_string` only once per `string_ptr`. +#[no_mangle] +pub unsafe extern "C" fn free_string(string_ptr: *mut c_char) { + unsafe { drop(std::ffi::CString::from_raw(string_ptr)) }; +} + +/// Prepares an SQL statement and returns a pointer to the prepared statement. +/// If an error occurred, returns null and writes a pointer to a null-terminated string into `error_ptr`. +/// +/// # Safety +/// +/// - `db_ptr` must not be null. +/// - `sql_ptr` must not be null and must point to a valid null-terminated UTF-8 string. +/// - `error_ptr` must not be null and must point to a valid writable location. +/// - When not null, the statement pointer must be freed with `free_statement` and any error string with `free_string`. +#[no_mangle] +pub unsafe extern "C" fn db_prepare_statement( + db_ptr: *mut Database, + sql_ptr: *const c_char, + error_ptr: *mut Error, +) -> *const Statement { + let sql = unsafe { CStr::from_ptr(sql_ptr) }.to_str(); + let db = unsafe { &mut (*db_ptr) }; + + let prepare_result = db.connection.prepare(sql.unwrap()); + match prepare_result { + Ok(statement) => allocate(statement), + Err(e) => { + unsafe { + *error_ptr = allocate_string(format!("Unable to prepare statement: {e}").as_str()) + } + null() + } + } +} + +/// Binds a parameter to the statement by index. +/// +/// # Safety +/// +/// - `statement_ptr` must be a pointer returned by `db_prepare_statement`. +/// - `index` must be >= 1. +/// - `parameter_value` must be a valid pointer to a `TursoValue`. +#[no_mangle] +pub unsafe extern "C" fn bind_parameter( + statement_ptr: *mut Statement, + index: i32, + parameter_value: *const TursoValue, +) { + let statement = unsafe { &mut (*statement_ptr) }; + statement.bind_at( + NonZero::new(index.try_into().unwrap()).unwrap(), + to_value(*parameter_value), + ); +} + +/// Binds a parameter to the statement by name. +/// +/// # Safety +/// +/// - `statement_ptr` must be a pointer returned by `db_prepare_statement`. +/// - `parameter_name` must not be null and must point to a valid null-terminated UTF-8 string. +/// - `parameter_value` must be a valid pointer to a `TursoValue`. +#[no_mangle] +pub unsafe extern "C" fn bind_named_parameter( + statement_ptr: *mut Statement, + parameter_name: *const c_char, + parameter_value: *const TursoValue, +) { + let statement = unsafe { &mut (*statement_ptr) }; + let parameter_name = unsafe { CStr::from_ptr(parameter_name) }.to_str().unwrap(); + + for idx in 1..statement.parameters_count() + 1 { + let non_zero_idx = NonZero::new(idx).unwrap(); + let param = statement.parameters().name(non_zero_idx); + let Some(name) = param else { + continue; + }; + if parameter_name == name { + statement.bind_at(non_zero_idx, to_value(*parameter_value)); + return; + } + } +} + +/// Returns the number of rows changed by the statement. +/// +/// # Safety +/// +/// - `statement_ptr` must not be null. +#[no_mangle] +pub unsafe extern "C" fn db_statement_nchange(statement_ptr: *mut Statement) -> i64 { + let statement = unsafe { &mut (*statement_ptr) }; + statement.n_change() +} + +/// Executes the statement, advancing it by one step. +/// If an error occurred, sets `error_ptr` to a pointer to a null-terminated string. +/// +/// # Safety +/// +/// - `statement_ptr` must not be null. +/// - `error_ptr` must not be null and must point to a location that is valid for writing. +/// - If set, the error string must be freed with `free_string`. +#[no_mangle] +pub unsafe extern "C" fn db_statement_execute_step( + statement_ptr: *mut Statement, + error_ptr: *mut Error, +) -> bool { + let statement = unsafe { &mut (*statement_ptr) }; + + loop { + match statement.step() { + Ok(step_result) => match step_result { + StepResult::Row => { + return true; + } + StepResult::Done => { + return false; + } + StepResult::IO => { + if let Err(err) = statement.run_once() { + unsafe { *error_ptr = allocate_string(err.to_string().as_str()) }; + return false; + } + continue; + } + StepResult::Interrupt => { + unsafe { *error_ptr = allocate_string("Interrupted") }; + return false; + } + StepResult::Busy => { + unsafe { *error_ptr = allocate_string("Database is busy") }; + return false; + } + }, + Err(err) => { + unsafe { *error_ptr = allocate_string(err.to_string().as_str()) }; + return false; + } + } + } +} + +/// Frees the statement pointer. +/// +/// # Safety +/// +/// - `statement_ptr` must not be null. +/// - Call `free_statement` only once per `statement_ptr`. +#[no_mangle] +pub unsafe extern "C" fn free_statement(statement_ptr: *mut Statement) { + let mut statement = unsafe { Box::from_raw(statement_ptr) }; + statement.reset(); +} + +/// Gets the current value from the row at the specified column index. +/// +/// # Safety +/// +/// - `statement_ptr` must not be null. +/// - `col_idx` must be >= 0. +#[no_mangle] +pub unsafe extern "C" fn db_statement_get_value( + statement_ptr: *mut Statement, + col_idx: i32, +) -> TursoValue { + let statement = unsafe { &mut (*statement_ptr) }; + if let Some(row) = statement.row() { + let value = match row.get_value(col_idx.try_into().unwrap()) { + Value::Null => TursoValue { + value_type: ValueType::Null, + value: TursoValueUnion { int_val: 0 }, + }, + Value::Integer(int_val) => TursoValue { + value_type: ValueType::Integer, + value: TursoValueUnion { int_val: *int_val }, + }, + Value::Float(float_value) => TursoValue { + value_type: ValueType::Float, + value: TursoValueUnion { + real_val: *float_value, + }, + }, + Value::Text(text) => { + let array = Array { + ptr: text.value.as_ptr(), + len: text.value.len(), + }; + TursoValue { + value_type: ValueType::Text, + value: TursoValueUnion { text: array }, + } + } + Value::Blob(blob) => { + let bytes = blob.as_ptr(); + let array = Array { + ptr: bytes, + len: blob.len(), + }; + TursoValue { + value_type: ValueType::Blob, + value: TursoValueUnion { blob: array }, + } + } + }; + + return value; + } + + TursoValue { + value_type: ValueType::Empty, + value: TursoValueUnion { int_val: 0 }, + } +} + +/// Gets the number of columns in the current statement. +/// +/// # Safety +/// +/// - `statement_ptr` must not be null. +#[no_mangle] +pub unsafe extern "C" fn db_statement_num_columns(statement_ptr: *mut Statement) -> i32 { + let statement = unsafe { &mut (*statement_ptr) }; + statement.num_columns().try_into().unwrap() +} + +/// Gets the column name for the specified index. +/// The returned string is heap-allocated; free it with `free_string` when no longer needed. +/// +/// # Safety +/// +/// - `statement_ptr` must not be null. +/// - `index` must be >= 0. +#[no_mangle] +pub unsafe extern "C" fn db_statement_column_name( + statement_ptr: *mut Statement, + index: i32, +) -> *const i8 { + let statement = unsafe { &mut (*statement_ptr) }; + let col_name = statement.get_column_name(index.try_into().unwrap()); + match col_name { + Cow::Borrowed(value) => allocate_string(value), + Cow::Owned(value) => allocate_string(value.as_str()), + } +} + +/// Checks whether the statement currently points to a row. +/// +/// # Safety +/// +/// - `statement_ptr` must not be null. +#[no_mangle] +pub unsafe extern "C" fn db_statement_has_rows(statement_ptr: *mut Statement) -> bool { + let statement = unsafe { &mut (*statement_ptr) }; + match statement.row() { + Some(_val) => true, + None => false, + } +} diff --git a/bindings/dotnet/src/Benchmarks/Benchmarks.cs b/bindings/dotnet/src/Benchmarks/Benchmarks.cs new file mode 100644 index 0000000000..0a8a01138d --- /dev/null +++ b/bindings/dotnet/src/Benchmarks/Benchmarks.cs @@ -0,0 +1,66 @@ +using System.Data; +using System.Data.SQLite; +using System.Runtime.CompilerServices; +using BenchmarkDotNet.Attributes; +using Microsoft.Data.Sqlite; +using SQLitePCL; +using Turso; + +namespace Benchmarks; + +[MemoryDiagnoser] +public class Benchmarks +{ + private SQLiteConnection _systemDataSqliteConnection; + private SqliteConnection _microsoftDataSqliteConnection; + private TursoConnection _tursoConnection; + + [GlobalSetup] + public void Setup() + { + _systemDataSqliteConnection = new SQLiteConnection("Data Source=:memory:"); + _systemDataSqliteConnection.Open(); + + _microsoftDataSqliteConnection = new SqliteConnection("Data Source=:memory:"); + _microsoftDataSqliteConnection.Open(); + + _tursoConnection = new TursoConnection("Data Source=:memory:"); + _tursoConnection.Open(); + CreateTable(_systemDataSqliteConnection); + CreateTable(_microsoftDataSqliteConnection); + CreateTable(_tursoConnection); + } + + [Benchmark] + public void TursoSelect() => Select(_tursoConnection); + + [Benchmark] + public void SystemSqliteSelect() => Select(_systemDataSqliteConnection); + + [Benchmark] + public void MicrososftSqliteSelect() => Select(_microsoftDataSqliteConnection); + + private void CreateTable(IDbConnection connection) + { + using var createTableCommand = connection.CreateCommand(); + createTableCommand.CommandText = "CREATE TABLE t(a, b)"; + createTableCommand.ExecuteNonQuery(); + + using var insertCommand = connection.CreateCommand(); + insertCommand.CommandText = @"INSERT INTO t(a, b) VALUES (1, 2), (3, 4);"; + insertCommand.ExecuteNonQuery(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void Select(IDbConnection connection) + { + using var command = connection.CreateCommand(); + command.CommandText = "SELECT * FROM t;"; + using var reader = command.ExecuteReader(); + var sum = 0; + while (reader.Read()) + sum += reader.GetInt32(0); + + GC.KeepAlive(sum); + } +} \ No newline at end of file diff --git a/bindings/dotnet/src/Benchmarks/Benchmarks.csproj b/bindings/dotnet/src/Benchmarks/Benchmarks.csproj new file mode 100644 index 0000000000..2961ebd05d --- /dev/null +++ b/bindings/dotnet/src/Benchmarks/Benchmarks.csproj @@ -0,0 +1,22 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + + + + + + + + + diff --git a/bindings/dotnet/src/Benchmarks/Program.cs b/bindings/dotnet/src/Benchmarks/Program.cs new file mode 100644 index 0000000000..d63b5e6de5 --- /dev/null +++ b/bindings/dotnet/src/Benchmarks/Program.cs @@ -0,0 +1,4 @@ + +using BenchmarkDotNet.Running; + +BenchmarkRunner.Run(); \ No newline at end of file diff --git a/bindings/dotnet/src/Turso.Raw/Data/TursoNativeArray.cs b/bindings/dotnet/src/Turso.Raw/Data/TursoNativeArray.cs new file mode 100644 index 0000000000..2b7d451941 --- /dev/null +++ b/bindings/dotnet/src/Turso.Raw/Data/TursoNativeArray.cs @@ -0,0 +1,10 @@ +using System.Runtime.InteropServices; + +namespace Turso.Raw.Data; + +[StructLayout(LayoutKind.Sequential)] +internal struct TursoNativeArray +{ + public IntPtr Data; + public UInt64 Length; +} \ No newline at end of file diff --git a/bindings/dotnet/src/Turso.Raw/Data/TursoNativeRowValueUnion.cs b/bindings/dotnet/src/Turso.Raw/Data/TursoNativeRowValueUnion.cs new file mode 100644 index 0000000000..0edf110cc5 --- /dev/null +++ b/bindings/dotnet/src/Turso.Raw/Data/TursoNativeRowValueUnion.cs @@ -0,0 +1,19 @@ +using System.Runtime.InteropServices; + +namespace Turso.Raw.Data; + +[StructLayout(LayoutKind.Explicit)] +internal struct TursoNativeRowValueUnion +{ + [FieldOffset(0)] + public Int64 IntValue; + + [FieldOffset(0)] + public Double RealValue; + + [FieldOffset(0)] + public TursoNativeArray StringValue; + + [FieldOffset(0)] + public TursoNativeArray BlobValue; +} \ No newline at end of file diff --git a/bindings/dotnet/src/Turso.Raw/Data/TursoNativeValue.cs b/bindings/dotnet/src/Turso.Raw/Data/TursoNativeValue.cs new file mode 100644 index 0000000000..66e57f6e71 --- /dev/null +++ b/bindings/dotnet/src/Turso.Raw/Data/TursoNativeValue.cs @@ -0,0 +1,14 @@ +using System.Runtime.InteropServices; +using Turso.Raw.Public.Value; + +namespace Turso.Raw.Data; + +[StructLayout(LayoutKind.Explicit)] +internal ref struct TursoNativeValue +{ + [FieldOffset(0)] + public TursoValueType ValueType; + + [FieldOffset(8)] + public TursoNativeRowValueUnion RowValueUnion; +} \ No newline at end of file diff --git a/bindings/dotnet/src/Turso.Raw/Public/Handles/TursoDatabaseHandle.cs b/bindings/dotnet/src/Turso.Raw/Public/Handles/TursoDatabaseHandle.cs new file mode 100644 index 0000000000..f693a1f3ee --- /dev/null +++ b/bindings/dotnet/src/Turso.Raw/Public/Handles/TursoDatabaseHandle.cs @@ -0,0 +1,27 @@ +using System.Runtime.InteropServices; + +namespace Turso.Raw.Public.Handles; + +public class TursoDatabaseHandle() : SafeHandle(IntPtr.Zero, true) +{ + protected override bool ReleaseHandle() + { + TursoInterop.CloseDatabase(handle); + return true; + } + + public void ThrowIfInvalid() + { + if (IsInvalid) + throw new NullReferenceException("database is invalid"); + } + + public static TursoDatabaseHandle FromPtr(IntPtr ptr) + { + var handle = new TursoDatabaseHandle(); + handle.SetHandle(ptr); + return handle; + } + + public override bool IsInvalid => handle == IntPtr.Zero; +} \ No newline at end of file diff --git a/bindings/dotnet/src/Turso.Raw/Public/Handles/TursoStatementHandle.cs b/bindings/dotnet/src/Turso.Raw/Public/Handles/TursoStatementHandle.cs new file mode 100644 index 0000000000..8eda790324 --- /dev/null +++ b/bindings/dotnet/src/Turso.Raw/Public/Handles/TursoStatementHandle.cs @@ -0,0 +1,28 @@ +using System.Runtime.InteropServices; + +namespace Turso.Raw.Public.Handles; + +public class TursoStatementHandle() : SafeHandle(IntPtr.Zero, true) +{ + protected override bool ReleaseHandle() + { + TursoInterop.FreeStatement(handle); + return true; + } + + public void ThrowIfInvalid() + { + if (IsInvalid) + throw new NullReferenceException("statement is invalid"); + } + + public static TursoStatementHandle FromPtr(IntPtr ptr) + { + var handle = new TursoStatementHandle(); + handle.SetHandle(ptr); + return handle; + } + + public override bool IsInvalid => handle == IntPtr.Zero; + +} \ No newline at end of file diff --git a/bindings/dotnet/src/Turso.Raw/Public/TursoBindings.cs b/bindings/dotnet/src/Turso.Raw/Public/TursoBindings.cs new file mode 100644 index 0000000000..a958927143 --- /dev/null +++ b/bindings/dotnet/src/Turso.Raw/Public/TursoBindings.cs @@ -0,0 +1,190 @@ +using System.Runtime.InteropServices; +using System.Text; +using Turso.Raw.Data; +using Turso.Raw.Public.Handles; +using Turso.Raw.Public.Value; + +namespace Turso.Raw.Public; + +public static class TursoBindings +{ + public static TursoDatabaseHandle OpenDatabase(string path) + { + ArgumentNullException.ThrowIfNull(path); + + var dbPtr = TursoInterop.OpenDatabase(path, out var errorPtr); + if (errorPtr != IntPtr.Zero) + ThrowException(errorPtr); + + return TursoDatabaseHandle.FromPtr(dbPtr); + } + + public static TursoStatementHandle PrepareStatement(TursoDatabaseHandle db, string sql) + { + db.ThrowIfInvalid(); + ArgumentNullException.ThrowIfNull(sql); + + var statementPtr = TursoInterop.PrepareStatement(db, sql, out var errorPtr); + if (errorPtr != IntPtr.Zero) + ThrowException(errorPtr); + + return TursoStatementHandle.FromPtr(statementPtr); + } + + public static void BindParameter(TursoStatementHandle statement, int index, TursoValue parameter) + { + statement.ThrowIfInvalid(); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(index); + + var nativeValue = FromValue(parameter, out var handle); + try + { + unsafe + { + var ptr = &nativeValue; + TursoInterop.BindParameter(statement, index, (IntPtr)ptr); + } + } + finally + { + if (handle.HasValue) + handle.Value.Free(); + } + } + + public static void BindNamedParameter(TursoStatementHandle statement, string name, TursoValue parameter) + { + statement.ThrowIfInvalid(); + ArgumentNullException.ThrowIfNull(name); + + var nativeValue = FromValue(parameter, out var handle); + try + { + unsafe + { + var ptr = &nativeValue; + TursoInterop.BindNamedParameter(statement, name, (IntPtr)ptr); + } + } + finally + { + if (handle.HasValue) + handle.Value.Free(); + } + } + + public static bool Read(TursoStatementHandle statement) + { + statement.ThrowIfInvalid(); + + var hasData = TursoInterop.StatementExecuteStep(statement, out var errorPtr); + if (errorPtr != IntPtr.Zero) + ThrowException(errorPtr); + return hasData; + } + + public static TursoValue GetValue(TursoStatementHandle statement, int columnIndex) + { + statement.ThrowIfInvalid(); + ArgumentOutOfRangeException.ThrowIfNegative(columnIndex); + + var rowValue = TursoInterop.GetValueFromStatement(statement, columnIndex); + return rowValue.ValueType switch + { + TursoValueType.Empty => TursoValue.Empty(), + TursoValueType.Null => TursoValue.Null(), + TursoValueType.Integer => TursoValue.Int(rowValue.RowValueUnion.IntValue), + TursoValueType.Real => TursoValue.Real(rowValue.RowValueUnion.RealValue), + TursoValueType.Text => TursoValue.String( + Encoding.UTF8.GetString(ToArray(rowValue.RowValueUnion.StringValue))), + TursoValueType.Blob => TursoValue.Blob(ToArray(rowValue.RowValueUnion.BlobValue)), + _ => throw new ArgumentOutOfRangeException() + }; + } + + public static string GetName(TursoStatementHandle statement, int ordinal) + { + statement.ThrowIfInvalid(); + ArgumentOutOfRangeException.ThrowIfNegative(ordinal); + + var cname = TursoInterop.StatementColumnName(statement, ordinal); + try + { + return Marshal.PtrToStringUTF8(cname) ?? ""; + } + finally + { + TursoInterop.FreeString(cname); + } + } + + public static int GetFieldCount(TursoStatementHandle statement) + { + statement.ThrowIfInvalid(); + + return TursoInterop.StatementNumColumns(statement); + } + + public static int RowsAffected(TursoStatementHandle statement) + { + statement.ThrowIfInvalid(); + + return (int)TursoInterop.StatementRowsAffected(statement); + } + + + public static bool HasRows(TursoStatementHandle statement) + { + statement.ThrowIfInvalid(); + + return TursoInterop.StatementHasRows(statement); + } + + + private static TursoNativeValue FromValue(TursoValue value, out GCHandle? handle) + { + handle = null; + var union = new TursoNativeRowValueUnion(); + if (value.ValueType == TursoValueType.Integer) + union.IntValue = value.IntValue; + if (value.ValueType == TursoValueType.Real) + union.RealValue = value.RealValue; + if (value.ValueType == TursoValueType.Text) + { + var bytes = Encoding.UTF8.GetBytes(value.StringValue); + handle = GCHandle.Alloc(bytes, GCHandleType.Pinned); + union.StringValue = new TursoNativeArray + { Data = handle.Value.AddrOfPinnedObject(), Length = (ulong)bytes.Length }; + } + + if (value.ValueType == TursoValueType.Blob) + { + handle = GCHandle.Alloc(value.BlobValue, GCHandleType.Pinned); + union.BlobValue = new TursoNativeArray + { Data = handle.Value.AddrOfPinnedObject(), Length = (ulong)value.BlobValue.Length }; + } + + return new TursoNativeValue + { + ValueType = value.ValueType, + RowValueUnion = union, + }; + } + + private static byte[] ToArray(TursoNativeArray array) + { + unsafe + { + var data = new Span((void*)array.Data, (int)array.Length); + return data.ToArray(); + } + } + + private static void ThrowException(IntPtr errorPtr) + { + var errorMessage = Marshal.PtrToStringUTF8(errorPtr); + var exception = new TursoException(errorMessage ?? "Internal error"); + TursoInterop.FreeString(errorPtr); + throw exception; + } +} \ No newline at end of file diff --git a/bindings/dotnet/src/Turso.Raw/Public/TursoException.cs b/bindings/dotnet/src/Turso.Raw/Public/TursoException.cs new file mode 100644 index 0000000000..7b9ae8b734 --- /dev/null +++ b/bindings/dotnet/src/Turso.Raw/Public/TursoException.cs @@ -0,0 +1,3 @@ +namespace Turso.Raw.Public; + +public class TursoException(string message) : Exception(message); \ No newline at end of file diff --git a/bindings/dotnet/src/Turso.Raw/Public/Value/TursoValue.cs b/bindings/dotnet/src/Turso.Raw/Public/Value/TursoValue.cs new file mode 100644 index 0000000000..856612ba3d --- /dev/null +++ b/bindings/dotnet/src/Turso.Raw/Public/Value/TursoValue.cs @@ -0,0 +1,17 @@ +namespace Turso.Raw.Public.Value; + +public struct TursoValue +{ + public TursoValueType ValueType; + public long IntValue; + public double RealValue; + public string StringValue; + public byte[] BlobValue; + + public static TursoValue Empty() => new() { ValueType = TursoValueType.Empty }; + public static TursoValue Null() => new() { ValueType = TursoValueType.Null }; + public static TursoValue Int(Int64 value) => new() { ValueType = TursoValueType.Integer, IntValue = value }; + public static TursoValue Real(Double value) => new() { ValueType = TursoValueType.Real, RealValue = value }; + public static TursoValue String(string value) => new() { ValueType = TursoValueType.Text, StringValue = value }; + public static TursoValue Blob(byte[] value) => new() { ValueType = TursoValueType.Blob, BlobValue = value }; +} \ No newline at end of file diff --git a/bindings/dotnet/src/Turso.Raw/Public/Value/TursoValueType.cs b/bindings/dotnet/src/Turso.Raw/Public/Value/TursoValueType.cs new file mode 100644 index 0000000000..84de4f503e --- /dev/null +++ b/bindings/dotnet/src/Turso.Raw/Public/Value/TursoValueType.cs @@ -0,0 +1,11 @@ +namespace Turso.Raw.Public.Value; + +public enum TursoValueType +{ + Empty = 0, + Null = 1, + Integer = 2, + Real = 3, + Text = 4, + Blob = 5, +} \ No newline at end of file diff --git a/bindings/dotnet/src/Turso.Raw/Turso.Raw.csproj b/bindings/dotnet/src/Turso.Raw/Turso.Raw.csproj new file mode 100644 index 0000000000..09e103ace6 --- /dev/null +++ b/bindings/dotnet/src/Turso.Raw/Turso.Raw.csproj @@ -0,0 +1,74 @@ + + + + net9.0 + enable + enable + true + + Turso.Raw + Dotnet bindings for turso database + 0.0.1 + + + + + Always + runtimes\win-x64\native\turso_dotnet.dll + + + Always + runtimes\linux-x64\native\turso_dotnet.so + + + Always + runtimes\osx-x64\native\turso_dotnet.dylib + + + Always + runtimes\osx-arm\native\turso_dotnet.dylib + + + + + Always + runtimes\win-x64\native\turso_dotnet.dll + true + runtimes\win-x64\native\turso_dotnet.dll + + + Always + runtimes\linux-x64\native\turso_dotnet.so + true + runtimes\linux-x64\native\turso_dotnet.so + + + Always + runtimes\osx-x64\native\turso_dotnet.dylib + true + runtimes\osx-x64\native\turso_dotnet.dylib + + + Always + runtimes\osx-arm64\native\turso_dotnet.dylib + true + runtimes\osx-arm64\native\turso_dotnet.dylib + + + diff --git a/bindings/dotnet/src/Turso.Raw/TursoInterop.cs b/bindings/dotnet/src/Turso.Raw/TursoInterop.cs new file mode 100644 index 0000000000..dbf09aedc1 --- /dev/null +++ b/bindings/dotnet/src/Turso.Raw/TursoInterop.cs @@ -0,0 +1,52 @@ +using System.Runtime.InteropServices; +using Turso.Raw.Data; +using Turso.Raw.Public.Handles; + +namespace Turso.Raw; + +internal static class TursoInterop +{ + private const string DllName = "turso_dotnet"; + + [DllImport(DllName, EntryPoint = "db_open", CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr OpenDatabase(string path, out IntPtr errorPtr); + + [DllImport(DllName, EntryPoint = "db_close", CallingConvention = CallingConvention.Cdecl)] + public static extern void CloseDatabase(IntPtr db); + + [DllImport(DllName, EntryPoint = "free_string", CallingConvention = CallingConvention.Cdecl)] + public static extern void FreeString(IntPtr stringPtr); + + [DllImport(DllName, EntryPoint = "db_prepare_statement", CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr PrepareStatement(TursoDatabaseHandle db, string sql, out IntPtr errorPtr); + + [DllImport(DllName, EntryPoint = "free_statement", CallingConvention = CallingConvention.Cdecl)] + public static extern void FreeStatement(IntPtr statement); + + [DllImport(DllName, EntryPoint = "bind_parameter", CallingConvention = CallingConvention.Cdecl)] + public static extern void BindParameter(TursoStatementHandle statement, int index, IntPtr tursoValue); + + [DllImport(DllName, EntryPoint = "bind_named_parameter", CallingConvention = CallingConvention.Cdecl)] + public static extern void BindNamedParameter(TursoStatementHandle statement, string parameterName, IntPtr tursoValue); + + [DllImport(DllName, EntryPoint = "db_statement_execute_step", CallingConvention = CallingConvention.Cdecl)] + [return: MarshalAs(UnmanagedType.I1)] + public static extern bool StatementExecuteStep(TursoStatementHandle statement, out IntPtr errorPtr); + + [DllImport(DllName, EntryPoint = "db_statement_nchange", CallingConvention = CallingConvention.Cdecl)] + public static extern long StatementRowsAffected(TursoStatementHandle statement); + + [DllImport(DllName, EntryPoint = "db_statement_get_value", CallingConvention = CallingConvention.Cdecl)] + public static extern TursoNativeValue GetValueFromStatement(TursoStatementHandle statement, int columnIndex); + + [DllImport(DllName, EntryPoint = "db_statement_num_columns", CallingConvention = CallingConvention.Cdecl)] + public static extern int StatementNumColumns(TursoStatementHandle statement); + + [DllImport(DllName, EntryPoint = "db_statement_column_name", CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr StatementColumnName(TursoStatementHandle statement, int index); + + [DllImport(DllName, EntryPoint = "db_statement_has_rows", CallingConvention = CallingConvention.Cdecl)] + [return: MarshalAs(UnmanagedType.I1)] + public static extern bool StatementHasRows(TursoStatementHandle statement); + +} \ No newline at end of file diff --git a/bindings/dotnet/src/Turso.Tests/Turso.Tests.csproj b/bindings/dotnet/src/Turso.Tests/Turso.Tests.csproj new file mode 100644 index 0000000000..cb830b9a76 --- /dev/null +++ b/bindings/dotnet/src/Turso.Tests/Turso.Tests.csproj @@ -0,0 +1,29 @@ + + + + net9.0 + latest + enable + enable + false + + + + + + + + + + + + + + + + + + + + + diff --git a/bindings/dotnet/src/Turso.Tests/TursoTests.cs b/bindings/dotnet/src/Turso.Tests/TursoTests.cs new file mode 100644 index 0000000000..9e01c605e9 --- /dev/null +++ b/bindings/dotnet/src/Turso.Tests/TursoTests.cs @@ -0,0 +1,260 @@ +using System.Data.Common; +using AwesomeAssertions; +using Turso.Raw.Public; + +namespace Turso.Tests; + +public class TursoTests +{ + [Test] + public void TestSimpleQuery() + { + using var connection = new TursoConnection(); + connection.Open(); + + using var cmd = new TursoCommand(connection, "SELECT * FROM generate_series(1,2,1)"); + + using var reader = cmd.ExecuteReader(); + + reader.Read().Should().BeTrue(); + reader.GetInt32(0).Should().Be(1); + + reader.Read().Should().BeTrue(); + reader.GetInt32(0).Should().Be(2); + + reader.Read().Should().BeFalse(); + } + + [Test] + public void TestPrepareStatement() + { + using var connection = new TursoConnection(); + connection.Open(); + + using var cmd = new TursoCommand(connection, "SELECT * FROM generate_series(?,?,?)"); + cmd.Parameters.Add(1); + cmd.Parameters.Add(2); + cmd.Parameters.Add(1); + cmd.Prepare(); + + using var reader = cmd.ExecuteReader(); + + reader.Read().Should().BeTrue(); + reader.GetInt32(0).Should().Be(1); + + reader.Read().Should().BeTrue(); + reader.GetInt32(0).Should().Be(2); + + reader.Read().Should().BeFalse(); + } + + [TestCase("stringValue", TestName = "TestStringValue")] + [TestCase(new byte[] { 1, 2, 3, 4, 5 }, TestName = "TestBlobValue")] + [TestCase(1, TestName = "TestIntValue")] + [TestCase(2.5, TestName = "TestRealValue")] + public void TestDifferentTypes(object typedValue) + { + using var connection = new TursoConnection(); + connection.Open(); + + using (var create = new TursoCommand(connection, "CREATE TABLE t(v)")) + { + create.ExecuteNonQuery().Should().Be(0); + } + + using (var insert = new TursoCommand(connection, "INSERT INTO t VALUES (?)")) + { + insert.Parameters.Add(typedValue); + insert.ExecuteNonQuery().Should().Be(1); + } + + using var select = new TursoCommand(connection, "SELECT v FROM t"); + using var reader = select.ExecuteReader(); + + reader.Read().Should().BeTrue(); + + switch (typedValue) + { + case string s: + reader.GetString(0).Should().Be(s); + break; + case byte[] bytes: + ((byte[])reader.GetValue(0)).SequenceEqual(bytes).Should().BeTrue(); + break; + case int i: + reader.GetInt32(0).Should().Be(i); + break; + case double d: + reader.GetDouble(0).Should().Be(d); + break; + default: + throw new AssertionException($"Unsupported test type: {typedValue.GetType()}"); + } + + reader.Read().Should().BeFalse(); + } + + + [Test] + public void TestBindNamedParameter() + { + using var connection = new TursoConnection(); + connection.Open(); + + using var cmd = new TursoCommand(connection, "SELECT * FROM generate_series(?,:stop,?1)"); + cmd.Parameters.Add(1); + cmd.Parameters.AddWithValue(":stop", 2); + cmd.Prepare(); + + using var reader = cmd.ExecuteReader(); + + reader.Read().Should().BeTrue(); + reader.GetInt32(0).Should().Be(1); + + reader.Read().Should().BeTrue(); + reader.GetInt32(0).Should().Be(2); + + reader.Read().Should().BeFalse(); + } + + [Test] + public void TestInsertData() + { + using var connection = new TursoConnection(); + connection.Open(); + + using var create = new TursoCommand(connection, "CREATE TABLE t(id INTEGER, name TEXT)"); + create.ExecuteNonQuery().Should().Be(0); + + using var insert = new TursoCommand(connection, "INSERT INTO t(id, name) VALUES (1, 'alice'), (2, 'bob')"); + insert.ExecuteNonQuery().Should().Be(2); + + using var countCmd = new TursoCommand(connection, "SELECT COUNT(*) FROM t"); + using var reader = countCmd.ExecuteReader(); + reader.Read().Should().BeTrue(); + reader.GetInt32(0).Should().Be(2); + reader.Read().Should().BeFalse(); + } + + [Test] + public void TestFetchSpecificColumns() + { + using var connection = new TursoConnection(); + connection.Open(); + + using var create = new TursoCommand(connection, "CREATE TABLE t(id INTEGER, name TEXT, age INTEGER)"); + create.ExecuteNonQuery().Should().Be(0); + + using var insert = new TursoCommand(connection, "INSERT INTO t VALUES (1,'alice',30),(2,'bob',40)"); + insert.ExecuteNonQuery().Should().Be(2); + + using var select = new TursoCommand(connection, "SELECT name, age FROM t WHERE id = 2"); + using var reader = select.ExecuteReader(); + reader.Read().Should().BeTrue(); + reader.GetString(0).Should().Be("bob"); + reader.GetInt32(1).Should().Be(40); + reader.Read().Should().BeFalse(); + } + + [Test] + public void TestQueryError() + { + using var connection = new TursoConnection(); + connection.Open(); + + using var cmd = new TursoCommand(connection, "SELECT * FROM table_that_does_not_exist"); + cmd.Invoking(x => x.ExecuteReader()).Should().Throw() + .WithMessage("Unable to prepare statement: Parse error: no such table: table_that_does_not_exist"); + } + + [Test] + [Ignore("https://github.com/tursodatabase/turso/pull/3591")] + public void TestCommitTransaction() + { + using var connection = new TursoConnection("Data Source=./turso.db"); + connection.Open(); + + using var connection2 = new TursoConnection("Data Source=./turso.db"); + connection2.Open(); + + connection.ExecuteNonQuery("CREATE TABLE IF NOT EXISTS t(id INTEGER)"); + connection.ExecuteNonQuery("DELETE FROM t"); + + using var tx = connection.BeginTransaction(); + + using var insert = new TursoCommand(connection, "INSERT INTO t VALUES (1),(2)"); + insert.ExecuteNonQuery().Should().Be(2); + + using var selectBefore = new TursoCommand(connection2, "SELECT COUNT(*) FROM t"); + using (var readerBefore = selectBefore.ExecuteReader()) + { + readerBefore.Read().Should().BeTrue(); + readerBefore.GetInt32(0).Should().Be(0); + } + + tx.Commit(); + + using var selectAfter = new TursoCommand(connection2, "SELECT COUNT(*) FROM t"); + using var readerAfter = selectAfter.ExecuteReader(); + readerAfter.Read().Should().BeTrue(); + readerAfter.GetInt32(0).Should().Be(2); + } + + [Test] + public void TestRollbackTransaction() + { + using var connection = new TursoConnection(); + connection.Open(); + + + using var create = new TursoCommand(connection, "CREATE TABLE t(id INTEGER)"); + create.ExecuteNonQuery().Should().Be(0); + + using var tx = connection.BeginTransaction(); + using var insert = new TursoCommand(connection, "INSERT INTO t VALUES (1),(2)"); + insert.ExecuteNonQuery().Should().Be(2); + + using var select = new TursoCommand(connection, "SELECT COUNT(*) FROM t"); + using var reader = select.ExecuteReader(); + reader.Read().Should().BeTrue(); + reader.GetInt32(0).Should().Be(2); + + tx.Rollback(); + + using var select2 = new TursoCommand(connection, "SELECT COUNT(*) FROM t"); + using var reader2 = select2.ExecuteReader(); + reader2.Read().Should().BeTrue(); + reader2.GetInt32(0).Should().Be(0); + } + + [Test] + public void TestDataReaderEnumerable() + { + using var connection = new TursoConnection(); + connection.Open(); + + using var create = new TursoCommand(connection, "CREATE TABLE t(id INTEGER, name TEXT, age INTEGER)"); + create.ExecuteNonQuery().Should().Be(0); + + using var insert = new TursoCommand(connection, "INSERT INTO t VALUES (1,'alice',30),(2,'bob',40),(3,'charlie',50)"); + insert.ExecuteNonQuery().Should().Be(3); + + using var select = new TursoCommand(connection, "SELECT id, name, age FROM t ORDER BY id"); + using var reader = select.ExecuteReader(); + + var results = new List<(long id, string name, long age)>(); + + foreach (DbDataRecord record in reader) + { + var id = record.GetInt64(0); + var name = record.GetString(1); + var age = record.GetInt64(2); + results.Add((id, name, age)); + } + + results.Should().HaveCount(3); + results[0].Should().Be((1, "alice", 30)); + results[1].Should().Be((2, "bob", 40)); + results[2].Should().Be((3, "charlie", 50)); + } +} \ No newline at end of file diff --git a/bindings/dotnet/src/Turso/Turso.csproj b/bindings/dotnet/src/Turso/Turso.csproj new file mode 100644 index 0000000000..4b840e4b4e --- /dev/null +++ b/bindings/dotnet/src/Turso/Turso.csproj @@ -0,0 +1,22 @@ + + + + Library + net9.0 + enable + enable + Turso + + Turso + ADO.NET provider for turso bindings + 0.0.1 + + + + + + + + + + diff --git a/bindings/dotnet/src/Turso/TursoCommand.cs b/bindings/dotnet/src/Turso/TursoCommand.cs new file mode 100644 index 0000000000..829c4a8099 --- /dev/null +++ b/bindings/dotnet/src/Turso/TursoCommand.cs @@ -0,0 +1,123 @@ +using System.Data; +using System.Data.Common; +using Turso.Raw.Public; +using Turso.Raw.Public.Handles; + +namespace Turso; + +public class TursoCommand : DbCommand +{ + private TursoConnection _connection; + private TursoParameterCollection _parameterCollection = new(); + + private TursoTransaction? _transaction; + private TursoStatementHandle? _statement; + + public TursoCommand(TursoConnection connection, TursoTransaction? transaction = null) + { + _connection = connection; + _transaction = transaction; + } + + public TursoCommand(TursoConnection connection, string command) + { + _connection = connection; + _transaction = null; + CommandText = command; + } + + + public override string CommandText { get; set; } = ""; + public override int CommandTimeout { get; set; } = 30; + + public override CommandType CommandType + { + get => CommandType.Text; + set => throw new NotSupportedException(); + } + + public override bool DesignTimeVisible { get; set; } + public override UpdateRowSource UpdatedRowSource { get; set; } + + protected override DbConnection? DbConnection + { + get => _connection; + set => _connection = value as TursoConnection ?? throw new ArgumentException(); + } + + protected override DbParameterCollection DbParameterCollection => _parameterCollection; + + public new virtual TursoParameterCollection Parameters => _parameterCollection; + + + protected override DbTransaction? DbTransaction + { + get => _transaction; + set => _transaction = value as TursoTransaction ?? throw new ArgumentException(); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + _statement?.Dispose(); + } + + public override void Cancel() + { + } + + public override int ExecuteNonQuery() + { + var reader = Execute(); + reader.NextResult(); + return reader.RecordsAffected; + } + + public override object? ExecuteScalar() + { + using var reader = Execute(); + return reader.Read() + ? reader.GetValue(0) + : null; + } + + public override void Prepare() + { + _statement = TursoBindings.PrepareStatement(_connection.Turso, CommandText); + for (var i = 0; i < _parameterCollection.Count; i++) + { + var parameter = _parameterCollection[i] as TursoParameter; + if (parameter == null) + throw new ArgumentException("Parameter must be of type TursoParameter"); + + if (!string.IsNullOrEmpty(parameter.ParameterName)) + { + TursoBindings.BindNamedParameter(_statement, parameter.ParameterName, parameter.ToValue()); + } + else + { + TursoBindings.BindParameter(_statement, i + 1, parameter.ToValue()); + } + } + } + + protected override DbParameter CreateDbParameter() + { + return new TursoParameter(); + } + + + protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) + { + return Execute(behavior); + } + + private DbDataReader Execute(CommandBehavior behavior = CommandBehavior.Default) + { + if (_statement is null) + Prepare(); + + var reader = new TursoDataReader(this, _statement); + return reader; + } +} \ No newline at end of file diff --git a/bindings/dotnet/src/Turso/TursoConnection.cs b/bindings/dotnet/src/Turso/TursoConnection.cs new file mode 100644 index 0000000000..8675e5d6e2 --- /dev/null +++ b/bindings/dotnet/src/Turso/TursoConnection.cs @@ -0,0 +1,89 @@ +using System.Data; +using System.Data.Common; +using Turso.Raw.Public; +using Turso.Raw.Public.Handles; + +namespace Turso; + +public class TursoConnection : DbConnection +{ + private TursoDatabaseHandle? _turso = null; + + private TursoConnectionOptions _connectionOptions; + + public override string ConnectionString + { + get => _connectionOptions.GetConnectionString(); + set => _connectionOptions = TursoConnectionOptions.Parse(value); + } + + public override string Database => "main"; + + public override string DataSource => _connectionOptions["Data Source"] ?? ""; + + public override string ServerVersion => throw new NotImplementedException(); + + public override ConnectionState State => _turso is not null ? ConnectionState.Open : ConnectionState.Closed; + + public TursoConnection() : this("") + { + } + + public TursoConnection(string connectionString) + { + _connectionOptions = TursoConnectionOptions.Parse(connectionString); + } + + public override void Open() + { + var filename = _connectionOptions["Data Source"] ?? ":memory:"; + _turso = TursoBindings.OpenDatabase(filename); + } + + public override void Close() + { + _turso?.Dispose(); + _turso = null; + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + _turso?.Dispose(); + } + + protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) + { + if (_turso is null) + { + throw new Exception("Turso database is closed"); + } + + return new TursoTransaction(this, isolationLevel); + } + + protected override DbCommand CreateDbCommand() + { + if (_turso is null) + { + throw new Exception("Turso database is closed"); + } + + return new TursoCommand(this); + } + + public int ExecuteNonQuery(string sql) + { + using var command = CreateCommand(); + command.CommandText = sql; + + return command.ExecuteNonQuery(); + } + + public override void ChangeDatabase(string databaseName) + { + throw new NotSupportedException(); + } + + internal TursoDatabaseHandle Turso => _turso; +} \ No newline at end of file diff --git a/bindings/dotnet/src/Turso/TursoConnectionOptions.cs b/bindings/dotnet/src/Turso/TursoConnectionOptions.cs new file mode 100644 index 0000000000..8907b51d91 --- /dev/null +++ b/bindings/dotnet/src/Turso/TursoConnectionOptions.cs @@ -0,0 +1,76 @@ +namespace Turso; + +public class TursoConnectionOptions +{ + private Dictionary _options = new(); + + + private void AddOption(string keyword, string value) + { + if (!_valid_keywords.Contains(keyword)) + { + throw new InvalidOperationException($"Unsupported keyword: {keyword}"); + } + + _options[keyword] = value; + } + + public string GetConnectionString() + { + var parts = new List(); + foreach (var keyword in _valid_keywords) + { + var option = GetOption(keyword); + if (option is not null) + { + parts.Add($"{keyword}={option}"); + } + } + + return string.Join(";", parts); + } + + private string? GetOption(string keyword) + { + return _options.GetValueOrDefault(keyword); + } + + public string? this[string keyword] + { + get => GetOption(keyword); + set => AddOption(keyword, value ?? ""); + } + + private readonly string[] _valid_keywords = [ + "Data Source", + "Mode", + "Cache", + "Password", + "Foreign Keys", + "Recursive Triggers", + "Default Timeout", + "Pooling", + "Vfs" + ]; + + public static TursoConnectionOptions Parse(string connectionString) + { + var options = new TursoConnectionOptions(); + + + + foreach (var optionPart in connectionString.Split(";")) + { + var separatorIndex = optionPart.IndexOf('='); + if (separatorIndex == -1) + continue; + + var keyword = optionPart.Substring(0, separatorIndex); + var value = optionPart.Substring(separatorIndex + 1); + + options.AddOption(keyword, value); + } + + return options; + } +} diff --git a/bindings/dotnet/src/Turso/TursoDataReader.cs b/bindings/dotnet/src/Turso/TursoDataReader.cs new file mode 100644 index 0000000000..5de590ac8c --- /dev/null +++ b/bindings/dotnet/src/Turso/TursoDataReader.cs @@ -0,0 +1,248 @@ +using System.Collections; +using System.ComponentModel; +using System.Data.Common; +using System.Globalization; +using System.Runtime.CompilerServices; +using Turso.Raw.Public; +using Turso.Raw.Public.Handles; +using Turso.Raw.Public.Value; + +namespace Turso; + +public class TursoDataReader : DbDataReader +{ + private readonly TursoCommand _command; + private readonly TursoStatementHandle _statement; + + public TursoDataReader(TursoCommand command, TursoStatementHandle statement) + { + _command = command; + _statement = statement; + } + + public override bool GetBoolean(int ordinal) + { + return TursoBindings.GetValue(_statement, ordinal).IntValue != 0; + } + + public override byte GetByte(int ordinal) + { + return (byte)TursoBindings.GetValue(_statement, ordinal).IntValue; + } + + public override long GetBytes(int ordinal, long dataOffset, byte[]? buffer, int bufferOffset, int length) + { + return GetArray(ordinal, dataOffset, buffer, bufferOffset, length); + } + + public override char GetChar(int ordinal) + { + var value = TursoBindings.GetValue(_statement, ordinal); + if (value.ValueType == TursoValueType.Text && value.StringValue.Length == 1) + { + return value.StringValue[0]; + } + + return (char)TursoBindings.GetValue(_statement, ordinal).IntValue; + } + + public override long GetChars(int ordinal, long dataOffset, char[]? buffer, int bufferOffset, int length) + { + return GetArray(ordinal, dataOffset, buffer, bufferOffset, length); + } + + public override string GetDataTypeName(int ordinal) + { + var value = TursoBindings.GetValue(_statement, ordinal); + return GetTypeName(value.ValueType); + } + + public override DateTime GetDateTime(int ordinal) + { + var value = TursoBindings.GetValue(_statement, ordinal); + switch (value.ValueType) + { + case TursoValueType.Text: + return DateTime.Parse(GetString(ordinal), CultureInfo.InvariantCulture); + default: + return DateTime.MinValue; + } + } + + public override decimal GetDecimal(int ordinal) + { + return (decimal)TursoBindings.GetValue(_statement, ordinal).RealValue; + } + + public override double GetDouble(int ordinal) + { + return TursoBindings.GetValue(_statement, ordinal).RealValue; + } + + public override Type GetFieldType(int ordinal) + { + var value = TursoBindings.GetValue(_statement, ordinal); + return value.ValueType switch + { + TursoValueType.Integer => typeof(long), + TursoValueType.Real => typeof(double), + TursoValueType.Text => typeof(string), + TursoValueType.Blob => typeof(byte[]), + _ => typeof(object) + }; + } + + public override float GetFloat(int ordinal) + { + return (float)TursoBindings.GetValue(_statement, ordinal).RealValue; + } + + public override Guid GetGuid(int ordinal) + { + return Guid.Parse(TursoBindings.GetValue(_statement, ordinal).StringValue); + } + + public override short GetInt16(int ordinal) + { + return (short)TursoBindings.GetValue(_statement, ordinal).IntValue; + } + + public override int GetInt32(int ordinal) + { + return (int)TursoBindings.GetValue(_statement, ordinal).IntValue; + } + + public override long GetInt64(int ordinal) + { + return TursoBindings.GetValue(_statement, ordinal).IntValue; + } + + public override string GetName(int ordinal) + { + return TursoBindings.GetName(_statement, ordinal); + } + + public override int GetOrdinal(string name) + { + var fields = TursoBindings.GetFieldCount(_statement); + for (var i = 0; i < fields; i++) + { + var columnName = TursoBindings.GetName(_statement, i); + if (columnName == name) + return i; + } + + throw new IndexOutOfRangeException($"column {name} not found"); + } + + public override string GetString(int ordinal) + { + return TursoBindings.GetValue(_statement, ordinal).StringValue; + } + + public override object? GetValue(int ordinal) + { + var value = TursoBindings.GetValue(_statement, ordinal); + return value.ValueType switch + { + TursoValueType.Null or TursoValueType.Empty => null, + TursoValueType.Integer => value.IntValue, + TursoValueType.Real => value.RealValue, + TursoValueType.Text => value.StringValue, + TursoValueType.Blob => value.BlobValue, + _ => throw new ArgumentOutOfRangeException() + }; + } + + public override int GetValues(object[] values) + { + var i = 0; + for (; i < FieldCount; i++) + { + values[i] = GetValue(i)!; + } + + return i; + } + + public override bool IsDBNull(int ordinal) + { + throw new NotImplementedException(); + } + + public override int FieldCount => TursoBindings.GetFieldCount(_statement); + + public override object this[int ordinal] => GetValue(ordinal)!; + + public override object this[string name] + { + get + { + var ordinal = GetOrdinal(name); + return GetValue(ordinal)!; + } + } + + public override int RecordsAffected => TursoBindings.RowsAffected(_statement); + public override bool HasRows => TursoBindings.HasRows(_statement); + public override bool IsClosed => _statement.IsInvalid; + + public override bool NextResult() + { + while (TursoBindings.Read(_statement)) ; + return true; + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + _command.Dispose(); + } + + public override bool Read() + { + return TursoBindings.Read(_statement); + } + + public override int Depth => 0; + + public override IEnumerator GetEnumerator() + { + return new DbEnumerator(this, closeReader: false); + } + + private long GetArray(int ordinal, long dataOffset, T[]? buffer, int bufferOffset, int length) + where T : struct + { + var bytes = TursoBindings.GetValue(_statement, ordinal).BlobValue; + if (buffer is null) + { + return Math.Min(bytes.Length - dataOffset, length); + } + + var position = 0; + for (; position < length; position++) + { + if (bufferOffset + position >= buffer.Length || position + dataOffset >= bytes.Length) + break; + + buffer[bufferOffset + position] = Unsafe.As(ref bytes[position + dataOffset]); + } + + return position; + } + + private static string GetTypeName(TursoValueType valueType) + { + return valueType switch + { + TursoValueType.Empty => "", + TursoValueType.Null => "NULL", + TursoValueType.Integer => "INTEGER", + TursoValueType.Real => "REAL", + TursoValueType.Text => "TEXT", + TursoValueType.Blob => "BLOB", + _ => throw new InvalidEnumArgumentException(nameof(valueType)) + }; + } +} \ No newline at end of file diff --git a/bindings/dotnet/src/Turso/TursoParameter.cs b/bindings/dotnet/src/Turso/TursoParameter.cs new file mode 100644 index 0000000000..528b97443a --- /dev/null +++ b/bindings/dotnet/src/Turso/TursoParameter.cs @@ -0,0 +1,113 @@ +using System.Data; +using System.Data.Common; +using Turso.Raw.Public.Value; + +namespace Turso; + +public class TursoParameter : DbParameter +{ + private static readonly Dictionary TursoTypeMapping = + new() + { + { typeof(bool), TursoValueType.Integer }, + { typeof(byte), TursoValueType.Integer }, + { typeof(byte[]), TursoValueType.Blob }, + { typeof(char), TursoValueType.Text }, + { typeof(DateTime), TursoValueType.Text }, + { typeof(DateTimeOffset), TursoValueType.Text }, + { typeof(DateOnly), TursoValueType.Text }, + { typeof(TimeOnly), TursoValueType.Text }, + { typeof(DBNull), TursoValueType.Null }, + { typeof(decimal), TursoValueType.Text }, + { typeof(double), TursoValueType.Real }, + { typeof(float), TursoValueType.Real }, + { typeof(Guid), TursoValueType.Text }, + { typeof(int), TursoValueType.Integer }, + { typeof(long), TursoValueType.Integer }, + { typeof(sbyte), TursoValueType.Integer }, + { typeof(short), TursoValueType.Integer }, + { typeof(string), TursoValueType.Text }, + { typeof(TimeSpan), TursoValueType.Text }, + { typeof(uint), TursoValueType.Integer }, + { typeof(ulong), TursoValueType.Integer }, + { typeof(ushort), TursoValueType.Integer } + }; + + public TursoParameter() + { + } + + public TursoParameter(object value) + { + Value = value; + } + + public TursoParameter(string parameterName, object value) + { + ParameterName = parameterName; + Value = value; + } + + public TursoParameter(string parameterName, DbType dbType, object value) + { + ParameterName = parameterName; + DbType = dbType; + Value = value; + } + + public override void ResetDbType() + { + DbType = DbType.String; + } + + public override DbType DbType { get; set; } = DbType.String; + + public override ParameterDirection Direction + { + get => ParameterDirection.Input; + set => throw new NotImplementedException(); + } + public override bool IsNullable { get; set; } + public override string? ParameterName { get; set; } + public override string? SourceColumn { get; set; } + public override object? Value { get; set; } + public override bool SourceColumnNullMapping { get; set; } + + public TursoValue ToValue() + { + if (Value is null) + return new TursoValue { ValueType = TursoValueType.Null }; + + var valueType = Value.GetType(); + if (!TursoTypeMapping.TryGetValue(valueType, out var tursoValueType)) + { + throw new ArgumentException($"Parameter type {valueType} is not supported"); + } + + return GetTursoValue(Value, tursoValueType); + } + + public override int Size + { + get => Value is string s + ? s.Length + : Value is byte[] bytes + ? bytes.Length + : 0; + set => throw new NotImplementedException(); + } + + private TursoValue GetTursoValue(object value, TursoValueType tursoValueType) + { + return tursoValueType switch + { + TursoValueType.Empty => new TursoValue() { ValueType = TursoValueType.Empty }, + TursoValueType.Null => new TursoValue() { ValueType = TursoValueType.Null }, + TursoValueType.Integer => new TursoValue() { ValueType = TursoValueType.Integer, IntValue = Convert.ToInt64(value) }, + TursoValueType.Real => new TursoValue() { ValueType = TursoValueType.Real, RealValue = Convert.ToDouble(value) }, + TursoValueType.Text => new TursoValue() { ValueType = TursoValueType.Text, StringValue = value.ToString()! }, + TursoValueType.Blob => new TursoValue() { ValueType = TursoValueType.Blob, BlobValue = (byte[])value }, + _ => throw new ArgumentOutOfRangeException(nameof(tursoValueType), tursoValueType, null) + }; + } +} \ No newline at end of file diff --git a/bindings/dotnet/src/Turso/TursoParameterCollection.cs b/bindings/dotnet/src/Turso/TursoParameterCollection.cs new file mode 100644 index 0000000000..e18b509979 --- /dev/null +++ b/bindings/dotnet/src/Turso/TursoParameterCollection.cs @@ -0,0 +1,122 @@ +using System.Collections; +using System.Data.Common; + +namespace Turso; + +public class TursoParameterCollection : DbParameterCollection +{ + private readonly List _parameters = new(); + public override int Count => _parameters.Count; + + public override object SyncRoot => throw new NotImplementedException(); + + public override int Add(object value) + { + _parameters.Add(value as TursoParameter ?? new TursoParameter(value)); + return _parameters.Count - 1; + } + + public int AddWithValue(string parameterName, object value) + { + _parameters.Add(new TursoParameter(parameterName, value)); + return _parameters.Count - 1; + } + + public override void AddRange(Array values) + { + for (var i = 0; i < values.Length; i++) + { + var value = values.GetValue(i)!; + var parameter = value as TursoParameter ?? new TursoParameter(value); + _parameters.Add(parameter); + } + } + + public override void Clear() + { + _parameters.Clear(); + } + + public override bool Contains(object value) + { + return _parameters.Any(p => value is TursoParameter ? p == value : p.Value == value); + } + + public override bool Contains(string value) + { + return _parameters.Any(p => p.ParameterName == value); + } + + public override void CopyTo(Array array, int index) + { + _parameters.CopyTo((TursoParameter[])array, index); + } + + public override IEnumerator GetEnumerator() + { + return _parameters.GetEnumerator(); + } + + public override int IndexOf(object value) + { + return _parameters.FindIndex(p => value is TursoParameter ? p == value : p.Value == value); + } + + public override int IndexOf(string parameterName) + { + return _parameters.FindIndex(p => p.ParameterName == parameterName); + } + + public override void Insert(int index, object value) + { + _parameters.Insert(index, value as TursoParameter ?? new TursoParameter(value)); + } + + public override void Remove(object value) + { + var index = IndexOf(value); + if (index == -1) + throw new ArgumentException($"Parameter {value} not found"); + _parameters.RemoveAt(index); + } + + public override void RemoveAt(int index) + { + _parameters.RemoveAt(index); + } + + public override void RemoveAt(string parameterName) + { + var index = IndexOf(parameterName); + if (index == -1) + throw new ArgumentException($"Parameter {parameterName} not found"); + + _parameters.RemoveAt(index); + } + + protected override DbParameter GetParameter(int index) + { + return _parameters[index]; + } + + protected override DbParameter GetParameter(string parameterName) + { + return _parameters.Find(p => p.ParameterName == parameterName) + ?? throw new ArgumentException($"Parameter {parameterName} not found"); + } + + protected override void SetParameter(int index, DbParameter value) + { + _parameters[index] = value as TursoParameter + ?? throw new ArgumentException($"Parameter {value} is not a TursoParameter"); + } + + protected override void SetParameter(string parameterName, DbParameter value) + { + var index = IndexOf(parameterName); + if (index == -1) + throw new ArgumentException($"Parameter {parameterName} not found"); + _parameters[index] = value as TursoParameter + ?? throw new ArgumentException($"Parameter {value} is not a TursoParameter"); + } +} \ No newline at end of file diff --git a/bindings/dotnet/src/Turso/TursoTransaction.cs b/bindings/dotnet/src/Turso/TursoTransaction.cs new file mode 100644 index 0000000000..a54829fa9b --- /dev/null +++ b/bindings/dotnet/src/Turso/TursoTransaction.cs @@ -0,0 +1,59 @@ +using System.Data.Common; +using IsolationLevel = System.Data.IsolationLevel; + +namespace Turso; + +public class TursoTransaction : DbTransaction +{ + private TursoConnection _connection; + private IsolationLevel _isolationLevel; + private bool _completed = false; + + public TursoTransaction(TursoConnection connection, IsolationLevel isolationLevel) + { + _connection = connection; + _isolationLevel = isolationLevel; + + if (isolationLevel == IsolationLevel.ReadUncommitted) + connection.ExecuteNonQuery("PRAGMA read_uncommitted = 1;"); + + connection.ExecuteNonQuery("BEGIN"); + } + + protected override void Dispose(bool disposing) + { + if (!_completed) + { + Rollback(); + } + } + + public override IsolationLevel IsolationLevel => _isolationLevel; + + protected override DbConnection? DbConnection => _connection; + + public override void Commit() + { + _connection.ExecuteNonQuery("COMMIT;"); + CompleteTransaction(); + } + + public override void Rollback() + { + try + { + _connection.ExecuteNonQuery("ROLLBACK;"); + } + finally + { + CompleteTransaction(); + } + } + + private void CompleteTransaction() + { + if (_isolationLevel == IsolationLevel.ReadUncommitted) + _connection.ExecuteNonQuery("PRAGMA read_uncommitted = 0;"); + _completed = true; + } +} \ No newline at end of file