Skip to content

Commit 04cd738

Browse files
committed
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
1 parent 203f127 commit 04cd738

File tree

11 files changed

+255
-39
lines changed

11 files changed

+255
-39
lines changed

.github/workflows/build-dependencies.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ jobs:
1212
runs-on: ${{ (inputs.multi-platform-build && matrix.os) || 'ubuntu-latest' }}
1313
strategy:
1414
matrix:
15-
os: [ubuntu-latest, windows-latest, macos-latest]
15+
os: [ubuntu-22.04, windows-2022, macos-13]
1616
steps:
1717
- uses: actions/checkout@v3
1818

.gitignore

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
bin/
22
obj/
33
.idea/
4+
.mono/
45
*.DotSettings.user
56

67
# Rust files
78
target/
89
bindings/
9-
Cargo.lock
10+
Cargo.lock
11+
12+
*.db-shm
13+
*.db-wal

Demo/Program.cs

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,30 @@
11
using Libsql.Client;
22

3+
// Create a database client using the static factory method
34
var dbClient = await DatabaseClient.Create(opts => {
45
opts.Url = ":memory:";
56
});
67

8+
9+
// Execute SQL statements directly
710
var rs = await dbClient.Execute("CREATE TABLE IF NOT EXISTS `users` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` TEXT NOT NULL, `height` REAL, `data` BLOB)");
811

12+
13+
// Read the results by using the IResultSet interface
914
var rs1 = await dbClient.Execute("INSERT INTO `users` (`name`, `height`, `data`) VALUES ('John Doe', 182.6, X'a4c7b8e21d3f50a6b9d2e8f7c1349a0b5c6d7e218349b6d012c71e8f9a093fed'), ('Jane Doe', 0.5, X'00')");
1015
Console.WriteLine($"Inserted {rs1.RowsAffected} rows");
1116
Console.WriteLine($"Last inserted id: {rs1.LastInsertRowId}");
1217
var rs2 = await dbClient.Execute("SELECT `id`, `name`, `height`, `data` FROM `users`");
18+
PrintTable(rs2);
19+
1320

14-
Console.WriteLine();
15-
Console.WriteLine(string.Join(", ", rs2.Columns));
16-
Console.WriteLine("------------------------");
17-
Console.WriteLine(string.Join("\n", rs2.Rows.Select(row => string.Join(", ", row.Select(x => x.ToString())))));
18-
Console.WriteLine(string.Join("\n", rs2.Rows.Select(row => string.Join(", ", row.Select(x => x.ToString())))));
21+
// Using positional arguments
22+
var searchString = "hn";
23+
var rs3 = await dbClient.Execute("SELECT `id`, `name`, `height`, `data` FROM `users` WHERE `name` LIKE concat('%', ?, '%')", searchString);
24+
PrintTable(rs3);
1925

20-
var user = ToUser(rs2.Rows.First());
2126

27+
// Map rows to User records using type declaration pattern matching
2228
var users = rs2.Rows.Select(ToUser);
2329

2430
User ToUser(IEnumerable<Value> row)
@@ -37,4 +43,12 @@ User ToUser(IEnumerable<Value> row)
3743
throw new ArgumentException();
3844
}
3945

40-
record User(int Id, string Name, double? Height, byte[] Data);
46+
void PrintTable(IResultSet rs)
47+
{
48+
Console.WriteLine();
49+
Console.WriteLine(string.Join(", ", rs.Columns));
50+
Console.WriteLine("------------------------");
51+
Console.WriteLine(string.Join("\n", rs.Rows.Select(row => string.Join(", ", row.Select(x => x.ToString())))));
52+
}
53+
54+
record User(int Id, string Name, double? Height, byte[] Data);
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
namespace Libsql.Client.Tests;
2+
3+
public class PositionalArgumentTests
4+
{
5+
private readonly IDatabaseClient _db = DatabaseClient.Create().Result;
6+
7+
[Fact]
8+
public async Task SingleParameter()
9+
{
10+
var rs = await _db.Execute("SELECT ?", 1);
11+
var row = rs.Rows.First();
12+
var value = row.First();
13+
var integer = Assert.IsType<Integer>(value);
14+
15+
Assert.Equal(1, integer.Value);
16+
}
17+
18+
[Fact]
19+
public async Task MultipleParameters()
20+
{
21+
var rs = await _db.Execute("SELECT ?, ?, ?", 1.0, "2", 3);
22+
var row = rs.Rows.First();
23+
var integer = Assert.IsType<Integer>(row.Skip(2).First());
24+
25+
Assert.Equal(3, integer.Value);
26+
}
27+
28+
[Fact]
29+
public async Task BindIntParameter()
30+
{
31+
var rs = await _db.Execute("SELECT ?", 1);
32+
var row = rs.Rows.First();
33+
var value = row.First();
34+
var integer = Assert.IsType<Integer>(value);
35+
36+
Assert.Equal(1, integer.Value);
37+
}
38+
39+
[Fact]
40+
public async Task BindRealParameter()
41+
{
42+
var rs = await _db.Execute("SELECT ?", 1.0);
43+
var row = rs.Rows.First();
44+
var value = row.First();
45+
var real = Assert.IsType<Real>(value);
46+
47+
Assert.Equal(1.0, real.Value);
48+
}
49+
50+
[Fact]
51+
public async Task BindStringParameter()
52+
{
53+
var rs = await _db.Execute("SELECT ?", "hello");
54+
var row = rs.Rows.First();
55+
var value = row.First();
56+
var text = Assert.IsType<Text>(value);
57+
58+
Assert.Equal("hello", text.Value);
59+
}
60+
61+
[Fact]
62+
public async Task BindSingleNullParameter()
63+
{
64+
var rs = await _db.Execute("SELECT ?", null);
65+
var row = rs.Rows.First();
66+
var value = row.First();
67+
Assert.IsType<Null>(value);
68+
}
69+
70+
[Fact]
71+
public async Task BindMultipleParametersWithANull()
72+
{
73+
var rs = await _db.Execute("SELECT ?, ?, ?", 1, null, 3);
74+
var row = rs.Rows.First();
75+
var value = row.Skip(1).First();
76+
Assert.IsType<Null>(value);
77+
}
78+
79+
[Fact]
80+
public async Task BindBlobParameter()
81+
{
82+
var rs = await _db.Execute("SELECT ?", new byte[] { 1, 2, 3 });
83+
var row = rs.Rows.First();
84+
var value = row.First();
85+
var blob = Assert.IsType<Blob>(value);
86+
87+
Assert.Equal(new byte[] { 1, 2, 3 }, blob.Value);
88+
}
89+
}

Libsql.Client.Tests/RemoteTests.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ public async Task CanConnectAndQueryRemoteDatabase()
2626

2727
var count = rs.Rows.First().First();
2828
var value = Assert.IsType<Integer>(count);
29-
Console.WriteLine(value.Value);
3029
Assert.Equal(3503, value.Value);
3130
}
3231
}

Libsql.Client/DatabaseWrapper.cs

Lines changed: 28 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -137,32 +137,38 @@ public async Task<IResultSet> Execute(string sql)
137137
{
138138
return await Task.Run(() =>
139139
{
140-
unsafe
141-
{
142-
var error = new Error();
143-
var rows = new libsql_rows_t();
144-
int exitCode;
145-
146-
fixed (byte* sqlPtr = Encoding.UTF8.GetBytes(sql))
147-
{
148-
exitCode = Bindings.libsql_execute(_connection, sqlPtr, &rows, &error.Ptr);
149-
}
150-
151-
error.ThrowIfNonZero(exitCode, "Failed to execute query");
152-
153-
return new ResultSet(
154-
Bindings.libsql_last_insert_rowid(_connection),
155-
Bindings.libsql_changes(_connection),
156-
rows.GetColumnNames(),
157-
new Rows(rows)
158-
);
159-
}
140+
var statement = new Statement(_connection, sql);
141+
return ExecuteStatement(statement);
160142
});
161143
}
162144

163-
public Task<IResultSet> Execute(string sql, params object[] args)
145+
public async Task<IResultSet> Execute(string sql, params object[] args)
164146
{
165-
throw new NotImplementedException();
147+
return await Task.Run(() => {
148+
var statement = new Statement(_connection, sql);
149+
statement.Bind(args);
150+
151+
return ExecuteStatement(statement);
152+
});
153+
}
154+
155+
private unsafe IResultSet ExecuteStatement(Statement statement)
156+
{
157+
var error = new Error();
158+
var rows = new libsql_rows_t();
159+
int exitCode;
160+
161+
exitCode = Bindings.libsql_execute_stmt(statement.Stmt, &rows, &error.Ptr);
162+
statement.Dispose();
163+
164+
error.ThrowIfNonZero(exitCode, "Failed to execute statement");
165+
166+
return new ResultSet(
167+
Bindings.libsql_last_insert_rowid(_connection),
168+
Bindings.libsql_changes(_connection),
169+
rows.GetColumnNames(),
170+
new Rows(rows)
171+
);
166172
}
167173

168174
public async Task Sync()

Libsql.Client/Libsql.Client.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<Title>Libsql.Client</Title>
55
<Authors>Tom van Dinther</Authors>
66
<Description>A client library for Libsql.</Description>
7-
<PackageVersion>0.4.0</PackageVersion>
7+
<PackageVersion>0.5.0</PackageVersion>
88
<Copyright>Copyright (c) Tom van Dinther 2023</Copyright>
99
<PackageProjectUrl>https://github.com/tvandinther/libsql-client-dotnet</PackageProjectUrl>
1010
<PackageLicense>https://raw.githubusercontent.com/tvandinther/libsql-client-dotnet/master/LICENSE</PackageLicense>

Libsql.Client/Rows.cs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,19 +55,23 @@ public RowsEnumerator(libsql_rows_t libsqlRowsT, ref RowEnumeratorData enumerato
5555

5656
private unsafe void PopulateColumnTypes(int columnCount) {
5757
// Must fetch the cursor before we can read the column types
58-
var row = GetRow();
58+
var firstRow = GetRow();
59+
60+
// Do not populate column types if there is no first row
61+
if ((int) firstRow.ptr == 0) return;
5962

6063
for (var i = 0; i < columnCount; i++)
6164
{
6265
int columnType;
6366
var error = new Error();
64-
var errorCode = Bindings.libsql_column_type(_libsqlRowsT, i, &columnType, &error.Ptr);
67+
Debug.Assert(firstRow.ptr != null, "firstRow is null. Can not find column type on a null pointer.");
68+
var errorCode = Bindings.libsql_column_type(_libsqlRowsT, firstRow, i, &columnType, &error.Ptr);
6569
error.ThrowIfNonZero(errorCode, "Failed to get column type");
6670
_enumeratorData.ColumnTypes[i] = (ValueType)columnType;
6771
}
6872

6973
// Parse the first row so that it is cached now that the cursor has moved on to the next row
70-
ParseRow(row);
74+
ParseRow(firstRow);
7175
}
7276

7377
public bool MoveNext()

Libsql.Client/Statement.cs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
using System;
2+
using System.Text;
3+
4+
namespace Libsql.Client
5+
{
6+
internal class Statement
7+
{
8+
public libsql_stmt_t Stmt;
9+
private libsql_connection_t _connection;
10+
11+
public unsafe Statement(libsql_connection_t connection, string sql)
12+
{
13+
_connection = connection;
14+
Stmt = new libsql_stmt_t();
15+
var error = new Error();
16+
int exitCode;
17+
18+
fixed (byte* sqlPtr = Encoding.UTF8.GetBytes(sql))
19+
{
20+
fixed (libsql_stmt_t* statementPtr = &Stmt)
21+
{
22+
exitCode = Bindings.libsql_prepare(_connection, sqlPtr, statementPtr, &error.Ptr);
23+
}
24+
}
25+
26+
error.ThrowIfNonZero(exitCode, $"Failed to prepare statement for: {sql}");
27+
}
28+
29+
public unsafe void Bind(object[] values)
30+
{
31+
var error = new Error();
32+
int exitCode;
33+
34+
if (values is null)
35+
{
36+
exitCode = Bindings.libsql_bind_null(Stmt, 1, &error.Ptr);
37+
error.ThrowIfNonZero(exitCode, "Failed to bind null parameter");
38+
}
39+
else
40+
{
41+
for (var i = 0; i < values.Length; i++)
42+
{
43+
var arg = values[i];
44+
45+
46+
if (arg is int val) {
47+
exitCode = Bindings.libsql_bind_int(Stmt, i + 1, val, &error.Ptr);
48+
}
49+
else if (arg is double d) {
50+
exitCode = Bindings.libsql_bind_float(Stmt, i + 1, d, &error.Ptr);
51+
}
52+
else if (arg is string s) {
53+
fixed (byte* sPtr = Encoding.UTF8.GetBytes(s))
54+
{
55+
exitCode = Bindings.libsql_bind_string(Stmt, i + 1, sPtr, &error.Ptr);
56+
}
57+
}
58+
else if (arg is byte[] b) {
59+
fixed (byte* bPtr = b)
60+
{
61+
exitCode = Bindings.libsql_bind_blob(Stmt, i + 1, bPtr, b.Length, &error.Ptr);
62+
}
63+
}
64+
else if (arg is null)
65+
{
66+
exitCode = Bindings.libsql_bind_null(Stmt, i + 1, &error.Ptr);
67+
}
68+
else
69+
{
70+
throw new ArgumentException($"Unsupported argument type: {arg.GetType()}");
71+
}
72+
73+
error.ThrowIfNonZero(exitCode, $"Failed to bind parameter. Type: {(arg is null ? "null" : arg.GetType().ToString())} Value: {arg}");
74+
}
75+
}
76+
}
77+
78+
private void ReleaseUnmanagedResources()
79+
{
80+
Bindings.libsql_free_stmt(Stmt);
81+
}
82+
83+
public void Dispose()
84+
{
85+
ReleaseUnmanagedResources();
86+
GC.SuppressFinalize(this);
87+
}
88+
89+
~Statement()
90+
{
91+
ReleaseUnmanagedResources();
92+
}
93+
}
94+
}

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,20 @@ var dbClient = DatabaseClient.Create(opts => {
3838

3939
### Executing SQL Statements
4040

41+
Using direct queries
4142
```csharp
4243
await dbClient.Execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, height REAL)");
4344
```
4445

46+
Using positional arguments
47+
```csharp
48+
await dbClient.Execute("SELECT name FROM users WHERE id = ?", userId);
49+
```
50+
4551
### Querying the Database
4652

4753
```csharp
48-
User ToUser(Value[] row)
54+
User ToUser(IEnumerable<Value> row)
4955
{
5056
var rowArray = row.ToArray();
5157

@@ -98,7 +104,7 @@ The full test suite is run only on a Linux x64 platform. Most of the test suite
98104
- [ ] An embeded replica can be synced.
99105
- The database can execute SQL statements:
100106
- [x] Non-parameterised.
101-
- [ ] Parameterised with positional arguments.
107+
- [x] Parameterised with positional arguments.
102108
- [ ] Parameterised with named arguments.
103109
- [ ] Prepared statements.
104110
- [ ] Batched statements.

0 commit comments

Comments
 (0)