Skip to content

Commit

Permalink
Add support for prepared statements with positional arguments (#22)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
tvandinther committed Jul 7, 2024
1 parent 203f127 commit 04cd738
Show file tree
Hide file tree
Showing 11 changed files with 255 additions and 39 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/build-dependencies.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
bin/
obj/
.idea/
.mono/
*.DotSettings.user

# Rust files
target/
bindings/
Cargo.lock
Cargo.lock

*.db-shm
*.db-wal
28 changes: 21 additions & 7 deletions Demo/Program.cs
Original file line number Diff line number Diff line change
@@ -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<Value> row)
Expand All @@ -37,4 +43,12 @@ User ToUser(IEnumerable<Value> row)
throw new ArgumentException();
}

record User(int Id, string Name, double? Height, byte[] Data);
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);
89 changes: 89 additions & 0 deletions Libsql.Client.Tests/PositionalArgumentTests.cs
Original file line number Diff line number Diff line change
@@ -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<Integer>(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<Integer>(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<Integer>(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<Real>(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<Text>(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<Null>(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<Null>(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<Blob>(value);

Assert.Equal(new byte[] { 1, 2, 3 }, blob.Value);
}
}
1 change: 0 additions & 1 deletion Libsql.Client.Tests/RemoteTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ public async Task CanConnectAndQueryRemoteDatabase()

var count = rs.Rows.First().First();
var value = Assert.IsType<Integer>(count);
Console.WriteLine(value.Value);
Assert.Equal(3503, value.Value);
}
}
50 changes: 28 additions & 22 deletions Libsql.Client/DatabaseWrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -137,32 +137,38 @@ public async Task<IResultSet> 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<IResultSet> Execute(string sql, params object[] args)
public async Task<IResultSet> 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()
Expand Down
2 changes: 1 addition & 1 deletion Libsql.Client/Libsql.Client.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<Title>Libsql.Client</Title>
<Authors>Tom van Dinther</Authors>
<Description>A client library for Libsql.</Description>
<PackageVersion>0.4.0</PackageVersion>
<PackageVersion>0.5.0</PackageVersion>
<Copyright>Copyright (c) Tom van Dinther 2023</Copyright>
<PackageProjectUrl>https://github.com/tvandinther/libsql-client-dotnet</PackageProjectUrl>
<PackageLicense>https://raw.githubusercontent.com/tvandinther/libsql-client-dotnet/master/LICENSE</PackageLicense>
Expand Down
10 changes: 7 additions & 3 deletions Libsql.Client/Rows.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
94 changes: 94 additions & 0 deletions Libsql.Client/Statement.cs
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Value> row)
{
var rowArray = row.ToArray();

Expand Down Expand Up @@ -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.
Expand Down
Loading

0 comments on commit 04cd738

Please sign in to comment.