From 04cd738f7f9b021cc42ebea8e045d4a40ca05710 Mon Sep 17 00:00:00 2001 From: Tom van Dinther <39470469+tvandinther@users.noreply.github.com> Date: Sun, 7 Jul 2024 17:02:31 +0200 Subject: [PATCH] Add support for prepared statements with positional arguments (#22) * Add positional argument tests * Implement passing implementation of positional arguments for #15 * Change order of methods * Refactor prepared statement into new class * WIP * remove prints * add items to gitignore * Add null check to row column type population If there are no returned rows, we can not parse it to find the column types. This check fixes a bug where the process would crash if there were no rows as it was using a null pointer to ask for column types. * make os for build runners consistent with test runners * Add documentation for positional arguments --- .github/workflows/build-dependencies.yaml | 2 +- .gitignore | 6 +- Demo/Program.cs | 28 ++++-- .../PositionalArgumentTests.cs | 89 ++++++++++++++++++ Libsql.Client.Tests/RemoteTests.cs | 1 - Libsql.Client/DatabaseWrapper.cs | 50 +++++----- Libsql.Client/Libsql.Client.csproj | 2 +- Libsql.Client/Rows.cs | 10 +- Libsql.Client/Statement.cs | 94 +++++++++++++++++++ README.md | 10 +- generate-bindings.sh | 2 +- 11 files changed, 255 insertions(+), 39 deletions(-) create mode 100644 Libsql.Client.Tests/PositionalArgumentTests.cs create mode 100644 Libsql.Client/Statement.cs diff --git a/.github/workflows/build-dependencies.yaml b/.github/workflows/build-dependencies.yaml index 2f12a31..9e8c1f4 100644 --- a/.github/workflows/build-dependencies.yaml +++ b/.github/workflows/build-dependencies.yaml @@ -12,7 +12,7 @@ jobs: runs-on: ${{ (inputs.multi-platform-build && matrix.os) || 'ubuntu-latest' }} strategy: matrix: - os: [ubuntu-latest, windows-latest, macos-latest] + os: [ubuntu-22.04, windows-2022, macos-13] steps: - uses: actions/checkout@v3 diff --git a/.gitignore b/.gitignore index e5e3b03..fef1d19 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,13 @@ bin/ obj/ .idea/ +.mono/ *.DotSettings.user # Rust files target/ bindings/ -Cargo.lock \ No newline at end of file +Cargo.lock + +*.db-shm +*.db-wal \ No newline at end of file diff --git a/Demo/Program.cs b/Demo/Program.cs index bfd8c14..3ac19fb 100644 --- a/Demo/Program.cs +++ b/Demo/Program.cs @@ -1,24 +1,30 @@ using Libsql.Client; +// Create a database client using the static factory method var dbClient = await DatabaseClient.Create(opts => { opts.Url = ":memory:"; }); + +// Execute SQL statements directly var rs = await dbClient.Execute("CREATE TABLE IF NOT EXISTS `users` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` TEXT NOT NULL, `height` REAL, `data` BLOB)"); + +// Read the results by using the IResultSet interface var rs1 = await dbClient.Execute("INSERT INTO `users` (`name`, `height`, `data`) VALUES ('John Doe', 182.6, X'a4c7b8e21d3f50a6b9d2e8f7c1349a0b5c6d7e218349b6d012c71e8f9a093fed'), ('Jane Doe', 0.5, X'00')"); Console.WriteLine($"Inserted {rs1.RowsAffected} rows"); Console.WriteLine($"Last inserted id: {rs1.LastInsertRowId}"); var rs2 = await dbClient.Execute("SELECT `id`, `name`, `height`, `data` FROM `users`"); +PrintTable(rs2); + -Console.WriteLine(); -Console.WriteLine(string.Join(", ", rs2.Columns)); -Console.WriteLine("------------------------"); -Console.WriteLine(string.Join("\n", rs2.Rows.Select(row => string.Join(", ", row.Select(x => x.ToString()))))); -Console.WriteLine(string.Join("\n", rs2.Rows.Select(row => string.Join(", ", row.Select(x => x.ToString()))))); +// Using positional arguments +var searchString = "hn"; +var rs3 = await dbClient.Execute("SELECT `id`, `name`, `height`, `data` FROM `users` WHERE `name` LIKE concat('%', ?, '%')", searchString); +PrintTable(rs3); -var user = ToUser(rs2.Rows.First()); +// Map rows to User records using type declaration pattern matching var users = rs2.Rows.Select(ToUser); User ToUser(IEnumerable row) @@ -37,4 +43,12 @@ User ToUser(IEnumerable row) throw new ArgumentException(); } -record User(int Id, string Name, double? Height, byte[] Data); \ No newline at end of file +void PrintTable(IResultSet rs) +{ + Console.WriteLine(); + Console.WriteLine(string.Join(", ", rs.Columns)); + Console.WriteLine("------------------------"); + Console.WriteLine(string.Join("\n", rs.Rows.Select(row => string.Join(", ", row.Select(x => x.ToString()))))); +} + +record User(int Id, string Name, double? Height, byte[] Data); diff --git a/Libsql.Client.Tests/PositionalArgumentTests.cs b/Libsql.Client.Tests/PositionalArgumentTests.cs new file mode 100644 index 0000000..a2fe5f1 --- /dev/null +++ b/Libsql.Client.Tests/PositionalArgumentTests.cs @@ -0,0 +1,89 @@ +namespace Libsql.Client.Tests; + +public class PositionalArgumentTests +{ + private readonly IDatabaseClient _db = DatabaseClient.Create().Result; + + [Fact] + public async Task SingleParameter() + { + var rs = await _db.Execute("SELECT ?", 1); + var row = rs.Rows.First(); + var value = row.First(); + var integer = Assert.IsType(value); + + Assert.Equal(1, integer.Value); + } + + [Fact] + public async Task MultipleParameters() + { + var rs = await _db.Execute("SELECT ?, ?, ?", 1.0, "2", 3); + var row = rs.Rows.First(); + var integer = Assert.IsType(row.Skip(2).First()); + + Assert.Equal(3, integer.Value); + } + + [Fact] + public async Task BindIntParameter() + { + var rs = await _db.Execute("SELECT ?", 1); + var row = rs.Rows.First(); + var value = row.First(); + var integer = Assert.IsType(value); + + Assert.Equal(1, integer.Value); + } + + [Fact] + public async Task BindRealParameter() + { + var rs = await _db.Execute("SELECT ?", 1.0); + var row = rs.Rows.First(); + var value = row.First(); + var real = Assert.IsType(value); + + Assert.Equal(1.0, real.Value); + } + + [Fact] + public async Task BindStringParameter() + { + var rs = await _db.Execute("SELECT ?", "hello"); + var row = rs.Rows.First(); + var value = row.First(); + var text = Assert.IsType(value); + + Assert.Equal("hello", text.Value); + } + + [Fact] + public async Task BindSingleNullParameter() + { + var rs = await _db.Execute("SELECT ?", null); + var row = rs.Rows.First(); + var value = row.First(); + Assert.IsType(value); + } + + [Fact] + public async Task BindMultipleParametersWithANull() + { + var rs = await _db.Execute("SELECT ?, ?, ?", 1, null, 3); + var row = rs.Rows.First(); + var value = row.Skip(1).First(); + Assert.IsType(value); + } + + [Fact] + public async Task BindBlobParameter() + { + var rs = await _db.Execute("SELECT ?", new byte[] { 1, 2, 3 }); + var row = rs.Rows.First(); + var value = row.First(); + var blob = Assert.IsType(value); + + Assert.Equal(new byte[] { 1, 2, 3 }, blob.Value); + } +} diff --git a/Libsql.Client.Tests/RemoteTests.cs b/Libsql.Client.Tests/RemoteTests.cs index 4636046..f11ec99 100644 --- a/Libsql.Client.Tests/RemoteTests.cs +++ b/Libsql.Client.Tests/RemoteTests.cs @@ -26,7 +26,6 @@ public async Task CanConnectAndQueryRemoteDatabase() var count = rs.Rows.First().First(); var value = Assert.IsType(count); - Console.WriteLine(value.Value); Assert.Equal(3503, value.Value); } } diff --git a/Libsql.Client/DatabaseWrapper.cs b/Libsql.Client/DatabaseWrapper.cs index 849211d..8430872 100644 --- a/Libsql.Client/DatabaseWrapper.cs +++ b/Libsql.Client/DatabaseWrapper.cs @@ -137,32 +137,38 @@ public async Task Execute(string sql) { return await Task.Run(() => { - unsafe - { - var error = new Error(); - var rows = new libsql_rows_t(); - int exitCode; - - fixed (byte* sqlPtr = Encoding.UTF8.GetBytes(sql)) - { - exitCode = Bindings.libsql_execute(_connection, sqlPtr, &rows, &error.Ptr); - } - - error.ThrowIfNonZero(exitCode, "Failed to execute query"); - - return new ResultSet( - Bindings.libsql_last_insert_rowid(_connection), - Bindings.libsql_changes(_connection), - rows.GetColumnNames(), - new Rows(rows) - ); - } + var statement = new Statement(_connection, sql); + return ExecuteStatement(statement); }); } - public Task Execute(string sql, params object[] args) + public async Task Execute(string sql, params object[] args) { - throw new NotImplementedException(); + return await Task.Run(() => { + var statement = new Statement(_connection, sql); + statement.Bind(args); + + return ExecuteStatement(statement); + }); + } + + private unsafe IResultSet ExecuteStatement(Statement statement) + { + var error = new Error(); + var rows = new libsql_rows_t(); + int exitCode; + + exitCode = Bindings.libsql_execute_stmt(statement.Stmt, &rows, &error.Ptr); + statement.Dispose(); + + error.ThrowIfNonZero(exitCode, "Failed to execute statement"); + + return new ResultSet( + Bindings.libsql_last_insert_rowid(_connection), + Bindings.libsql_changes(_connection), + rows.GetColumnNames(), + new Rows(rows) + ); } public async Task Sync() diff --git a/Libsql.Client/Libsql.Client.csproj b/Libsql.Client/Libsql.Client.csproj index 53077b3..88b728d 100644 --- a/Libsql.Client/Libsql.Client.csproj +++ b/Libsql.Client/Libsql.Client.csproj @@ -4,7 +4,7 @@ Libsql.Client Tom van Dinther A client library for Libsql. - 0.4.0 + 0.5.0 Copyright (c) Tom van Dinther 2023 https://github.com/tvandinther/libsql-client-dotnet https://raw.githubusercontent.com/tvandinther/libsql-client-dotnet/master/LICENSE diff --git a/Libsql.Client/Rows.cs b/Libsql.Client/Rows.cs index 7584ddf..c26fb53 100644 --- a/Libsql.Client/Rows.cs +++ b/Libsql.Client/Rows.cs @@ -55,19 +55,23 @@ public RowsEnumerator(libsql_rows_t libsqlRowsT, ref RowEnumeratorData enumerato private unsafe void PopulateColumnTypes(int columnCount) { // Must fetch the cursor before we can read the column types - var row = GetRow(); + var firstRow = GetRow(); + + // Do not populate column types if there is no first row + if ((int) firstRow.ptr == 0) return; for (var i = 0; i < columnCount; i++) { int columnType; var error = new Error(); - var errorCode = Bindings.libsql_column_type(_libsqlRowsT, i, &columnType, &error.Ptr); + Debug.Assert(firstRow.ptr != null, "firstRow is null. Can not find column type on a null pointer."); + var errorCode = Bindings.libsql_column_type(_libsqlRowsT, firstRow, i, &columnType, &error.Ptr); error.ThrowIfNonZero(errorCode, "Failed to get column type"); _enumeratorData.ColumnTypes[i] = (ValueType)columnType; } // Parse the first row so that it is cached now that the cursor has moved on to the next row - ParseRow(row); + ParseRow(firstRow); } public bool MoveNext() diff --git a/Libsql.Client/Statement.cs b/Libsql.Client/Statement.cs new file mode 100644 index 0000000..a261f89 --- /dev/null +++ b/Libsql.Client/Statement.cs @@ -0,0 +1,94 @@ +using System; +using System.Text; + +namespace Libsql.Client +{ + internal class Statement + { + public libsql_stmt_t Stmt; + private libsql_connection_t _connection; + + public unsafe Statement(libsql_connection_t connection, string sql) + { + _connection = connection; + Stmt = new libsql_stmt_t(); + var error = new Error(); + int exitCode; + + fixed (byte* sqlPtr = Encoding.UTF8.GetBytes(sql)) + { + fixed (libsql_stmt_t* statementPtr = &Stmt) + { + exitCode = Bindings.libsql_prepare(_connection, sqlPtr, statementPtr, &error.Ptr); + } + } + + error.ThrowIfNonZero(exitCode, $"Failed to prepare statement for: {sql}"); + } + + public unsafe void Bind(object[] values) + { + var error = new Error(); + int exitCode; + + if (values is null) + { + exitCode = Bindings.libsql_bind_null(Stmt, 1, &error.Ptr); + error.ThrowIfNonZero(exitCode, "Failed to bind null parameter"); + } + else + { + for (var i = 0; i < values.Length; i++) + { + var arg = values[i]; + + + if (arg is int val) { + exitCode = Bindings.libsql_bind_int(Stmt, i + 1, val, &error.Ptr); + } + else if (arg is double d) { + exitCode = Bindings.libsql_bind_float(Stmt, i + 1, d, &error.Ptr); + } + else if (arg is string s) { + fixed (byte* sPtr = Encoding.UTF8.GetBytes(s)) + { + exitCode = Bindings.libsql_bind_string(Stmt, i + 1, sPtr, &error.Ptr); + } + } + else if (arg is byte[] b) { + fixed (byte* bPtr = b) + { + exitCode = Bindings.libsql_bind_blob(Stmt, i + 1, bPtr, b.Length, &error.Ptr); + } + } + else if (arg is null) + { + exitCode = Bindings.libsql_bind_null(Stmt, i + 1, &error.Ptr); + } + else + { + throw new ArgumentException($"Unsupported argument type: {arg.GetType()}"); + } + + error.ThrowIfNonZero(exitCode, $"Failed to bind parameter. Type: {(arg is null ? "null" : arg.GetType().ToString())} Value: {arg}"); + } + } + } + + private void ReleaseUnmanagedResources() + { + Bindings.libsql_free_stmt(Stmt); + } + + public void Dispose() + { + ReleaseUnmanagedResources(); + GC.SuppressFinalize(this); + } + + ~Statement() + { + ReleaseUnmanagedResources(); + } + } +} diff --git a/README.md b/README.md index d1d3cfc..e973069 100644 --- a/README.md +++ b/README.md @@ -38,14 +38,20 @@ var dbClient = DatabaseClient.Create(opts => { ### Executing SQL Statements +Using direct queries ```csharp await dbClient.Execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, height REAL)"); ``` +Using positional arguments +```csharp +await dbClient.Execute("SELECT name FROM users WHERE id = ?", userId); +``` + ### Querying the Database ```csharp -User ToUser(Value[] row) +User ToUser(IEnumerable row) { var rowArray = row.ToArray(); @@ -98,7 +104,7 @@ The full test suite is run only on a Linux x64 platform. Most of the test suite - [ ] An embeded replica can be synced. - The database can execute SQL statements: - [x] Non-parameterised. - - [ ] Parameterised with positional arguments. + - [x] Parameterised with positional arguments. - [ ] Parameterised with named arguments. - [ ] Prepared statements. - [ ] Batched statements. diff --git a/generate-bindings.sh b/generate-bindings.sh index bcb2bcc..1dd38d5 100755 --- a/generate-bindings.sh +++ b/generate-bindings.sh @@ -42,7 +42,7 @@ fi # git sparse-checkout set bindings/c bindings/wasm libsql libsql-sys # ----- git fetch --quiet origin -git checkout ac6bf3a +git checkout bdb526e # ----- # git fetch --depth 1 origin tag v${LIBSQL_VERSION} # git reset --hard tags/v${LIBSQL_VERSION}