Skip to content

Commit

Permalink
Feature/extend orm client (#8)
Browse files Browse the repository at this point in the history
* Extend ORM client to handle parameterized queries and adapt for dependency injection

* Adding unit tests and fixing a formatting error in the QueryParameter classes

* Extracting IRqliteClient to a separate file

* Removing nullable return type from HttpClientExtensions.SendTyped

---------

Co-authored-by: Jonatan Olofsson <[email protected]>
  • Loading branch information
jonkan81 and SjoVJonatan authored Oct 28, 2024
1 parent 1c7e5c6 commit d7b5009
Show file tree
Hide file tree
Showing 8 changed files with 219 additions and 33 deletions.
38 changes: 38 additions & 0 deletions RqliteDotnet.Test/RqliteClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,25 @@ public async Task QueryWithGenerics_Works()
Assert.AreEqual(1, queryresults[0].Id);
Assert.AreEqual("john", queryresults[0].Name);
}

[Test]
public async Task ParametrizedQueryWithGenerics_Works()
{
var client = HttpClientMock.GetParamQueryMock();

var rqClient = new RqliteOrmClient("http://localhost:6000", client);
var queryresults = await rqClient.QueryParams<NamedQueryParameter, FooResultDto>("select * from foo where Name = :name",
new NamedQueryParameter()
{
Name = "name",
ParamType = QueryParamType.String,
Value = "john"
});

Assert.AreEqual(1, queryresults.Count);
Assert.AreEqual(1, queryresults[0].Id);
Assert.AreEqual("john", queryresults[0].Name);
}

[Test]
public async Task BasicExecute_Works()
Expand All @@ -47,6 +66,25 @@ public async Task BasicExecute_Works()
Assert.AreEqual(1, result.Results[0].RowsAffected);
Assert.AreEqual(2, result.Results[0].LastInsertId);
}

[Test]
public async Task ParametrizedExecute_Works()
{
var client = HttpClientMock.GetExecuteMock();

var rqClient = new RqliteClient("http://localhost:6000", client);
var result = await rqClient.ExecuteParams(new []{
("update foo set name = :newName where name = :oldName"
, new [] {
new NamedQueryParameter() {Name = "newName", Value = "doe", ParamType = QueryParamType.String}
, new NamedQueryParameter {Name = "oldName", Value = "john", ParamType = QueryParamType.String}
}
)}, DbFlag.Transaction);

Assert.AreEqual(1, result.Results.Count);
Assert.AreEqual(1, result.Results[0].RowsAffected);
Assert.AreEqual(2, result.Results[0].LastInsertId);
}

[Test]
public async Task BasicQueryParam_Works()
Expand Down
3 changes: 2 additions & 1 deletion RqliteDotnet/DbFlag.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
namespace RqliteDotnet;

[Flags]
public enum DbFlag
{
Timings = 1,
Transaction
Transaction = 2
}
11 changes: 10 additions & 1 deletion RqliteDotnet/Dto/NamedQueryParameter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ public class NamedQueryParameter : QueryParameter

public override string ToParamString()
{
return $"\"{Name}\":" + (ParamType == QueryParamType.Number ? Value.ToString() : $"\"{Value}\"");
return $"\"{Name}\":" + PrintValue();
}

private string PrintValue()
{
if (Value == null)
{
return "null";
}
return (ParamType == QueryParamType.Number ? FormattableString.Invariant($"{Value}") : $"\"{Value}\"");
}
}
2 changes: 1 addition & 1 deletion RqliteDotnet/Dto/QueryParameter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ public class QueryParameter

public virtual string ToParamString()
{
return (ParamType == QueryParamType.Number ? Value.ToString() : $"\"{Value}\"")!;
return (ParamType == QueryParamType.Number ? FormattableString.Invariant($"{Value}") : $"\"{Value}\"")!;
}
}

Expand Down
5 changes: 4 additions & 1 deletion RqliteDotnet/HttpClientExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ public static async Task<T> SendTyped<T>(this HttpClient client, HttpRequestMess
var response = await client.SendAsync(request);
var content = await response.Content.ReadAsStringAsync();

response.EnsureSuccessStatusCode();

var result = JsonSerializer.Deserialize<T>(content, new JsonSerializerOptions() { PropertyNameCaseInsensitive = true });
return result;

return result!;
}
}
48 changes: 48 additions & 0 deletions RqliteDotnet/IRqliteClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using RqliteDotnet.Dto;

namespace RqliteDotnet;

public interface IRqliteClient
{
/// <summary>
/// Ping Rqlite instance
/// </summary>
/// <returns>String containining Rqlite version</returns>
Task<string> Ping();

/// <summary>
/// Query DB and return result
/// </summary>
/// <param name="query"></param>
Task<QueryResults> Query(string query);

/// <summary>
/// Execute command and return result
/// </summary>
Task<ExecuteResults> Execute(string command);

/// <summary>
/// Execute one or several commands and return result
/// </summary>
/// <param name="commands">Commands to execute</param>
/// <param name="flags">Command flags, e.g. whether to use transaction</param>
/// <returns></returns>
Task<ExecuteResults> Execute(IEnumerable<string> commands, DbFlag? flags);

/// <summary>
/// Execute one or several commands and return result
/// </summary>
/// <param name="commands">Commands to execute</param>
/// <param name="flags">Command flags, e.g. whether to use transaction</param>
/// <returns></returns>
Task<ExecuteResults> ExecuteParams<T>(IEnumerable<(string, T[])> commands, DbFlag? flags) where T : QueryParameter;

/// <summary>
/// Query DB using parametrized statement
/// </summary>
/// <param name="query"></param>
/// <param name="qps"></param>
/// <typeparam name="T"></typeparam>
/// <returns></returns>
Task<QueryResults> QueryParams<T>(string query, params T[] qps) where T: QueryParameter;
}
82 changes: 60 additions & 22 deletions RqliteDotnet/RqliteClient.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
using System.Data;
using System.Net;
using RqliteDotnet.Dto;
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using RqliteDotnet.Dto;
using System.Threading.Tasks;

namespace RqliteDotnet;

public class RqliteClient


public class RqliteClient : IRqliteClient
{
private readonly HttpClient _httpClient;

Expand All @@ -15,6 +21,11 @@ public RqliteClient(string uri, HttpClient? client = null)
_httpClient = client ?? new HttpClient(){ BaseAddress = new Uri(uri) };
}

public RqliteClient(HttpClient client)
{
_httpClient = client ?? throw new ArgumentNullException(nameof(client));
}

/// <summary>
/// Ping Rqlite instance
/// </summary>
Expand All @@ -25,14 +36,14 @@ public async Task<string> Ping()

return x.Headers.GetValues("X-Rqlite-Version").FirstOrDefault()!;
}

/// <summary>
/// Query DB and return result
/// </summary>
/// <param name="query"></param>
public async Task<QueryResults> Query(string query)
{
var data = "&q="+Uri.EscapeDataString(query);
var data = "&q=" + Uri.EscapeDataString(query);
var baseUrl = "/db/query?timings";

var r = await _httpClient.GetAsync($"{baseUrl}&{data}");
Expand All @@ -53,7 +64,7 @@ public async Task<ExecuteResults> Execute(string command)
var result = await _httpClient.SendTyped<ExecuteResults>(request);
return result;
}

/// <summary>
/// Execute one or several commands and return result
/// </summary>
Expand All @@ -71,33 +82,55 @@ public async Task<ExecuteResults> Execute(IEnumerable<string> commands, DbFlag?
var result = await _httpClient.SendTyped<ExecuteResults>(request);
return result;
}


/// <summary>
/// Execute one or several commands and return result
/// </summary>
/// <param name="commands">Commands to execute</param>
/// <param name="flags">Command flags, e.g. whether to use transaction</param>
/// <returns></returns>
public async Task<ExecuteResults> ExecuteParams<T>(IEnumerable<(string, T[])> commands, DbFlag? flags) where T : QueryParameter
{
var parameters = GetParameters(flags);
var request = new HttpRequestMessage(HttpMethod.Post, $"/db/execute{parameters}");
var compiled = commands.Select(c => $"{BuildQuery(c.Item1, c.Item2)}");
var s = string.Join(",", compiled);

request.Content = new StringContent($"[{s}]", Encoding.UTF8, "application/json");
var result = await _httpClient.SendTyped<ExecuteResults>(request);
return result;
}

/// <summary>
/// Query DB using parametrized statement
/// </summary>
/// <param name="query"></param>
/// <param name="qps"></param>
/// <typeparam name="T"></typeparam>
/// <returns></returns>
public async Task<QueryResults> QueryParams<T>(string query, params T[] qps) where T: QueryParameter
public async Task<QueryResults> QueryParams<T>(string query, params T[] qps) where T : QueryParameter
{
var request = new HttpRequestMessage(HttpMethod.Post, "/db/query?timings");
var sb = new StringBuilder(typeof(T) == typeof(NamedQueryParameter) ?
$"[[\"{query}\",{{" :
$"[[\"{query}\",");
var q = BuildQuery(query, qps);

request.Content = new StringContent($"[{q}]", Encoding.UTF8, "application/json");
var result = await _httpClient.SendTyped<QueryResults>(request);

return result;
}

private static string BuildQuery<T>(string query, T[] qps) where T : QueryParameter
{
var sb = new StringBuilder(typeof(T) == typeof(NamedQueryParameter) ? $"[\"{query}\",{{" : $"[\"{query}\",");

foreach (var qp in qps)
{
sb.Append(qp.ToParamString()+",");
sb.Append(qp.ToParamString() + ",");
}

sb.Length -= 1;
sb.Append(typeof(T) == typeof(NamedQueryParameter) ? "}]]" : "]]");

request.Content = new StringContent(sb.ToString(), Encoding.UTF8, "application/json");
var result = await _httpClient.SendTyped<QueryResults>(request);

return result;
sb.Append(typeof(T) == typeof(NamedQueryParameter) ? "}]" : "]");
return sb.ToString();
}

private string GetParameters(DbFlag? flags)
Expand All @@ -121,12 +154,17 @@ private string GetParameters(DbFlag? flags)

protected object GetValue(string valType, JsonElement el)
{
object? x = valType switch
if (el.ValueKind == JsonValueKind.Null)
{
return null;

Check warning on line 159 in RqliteDotnet/RqliteClient.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference return.
}
object x = valType switch

Check warning on line 161 in RqliteDotnet/RqliteClient.cs

View workflow job for this annotation

GitHub Actions / build

Converting null literal or possible null value to non-nullable type.
{
"text" => el.GetString(),
"integer" or "numeric" => el.GetInt32(),
"integer" or "numeric" or "int" => el.GetInt32(),
"real" => el.GetDouble(),
_ => throw new ArgumentException("Unsupported type")
"bigint" => el.GetInt64(),
_ => throw new ArgumentException($"Unsupported type {valType}")
};

return x;

Check warning on line 170 in RqliteDotnet/RqliteClient.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference return.
Expand Down
63 changes: 56 additions & 7 deletions RqliteDotnet/RqliteOrmClient.cs
Original file line number Diff line number Diff line change
@@ -1,40 +1,89 @@
using System.Data;
using RqliteDotnet.Dto;

namespace RqliteDotnet;

public class RqliteOrmClient : RqliteClient
public interface IRqliteOrmClient : IRqliteClient
{
/// <summary>
/// Query Rqlite DB and return result as an instance of T
/// </summary>
/// <param name="query">Query to execute</param>
/// <typeparam name="T">Type of result object</typeparam>
/// <returns></returns>
Task<List<T>> Query<T>(string query) where T: new();

Task<List<U>> QueryParams<T, U>(string query, params T[] qps)
where T: QueryParameter
where U : new();
}

public class RqliteOrmClient : RqliteClient, IRqliteOrmClient
{
public RqliteOrmClient(HttpClient client) : base(client) { }

public RqliteOrmClient(string uri, HttpClient? client = null) : base(uri, client) {}

/// <summary>
/// Query Rqlite DB and return result as an instance of T
/// </summary>
/// <param name="query">Query to execute</param>
/// <typeparam name="T">Type of result object</typeparam>
/// <returns></returns>
public async Task<List<T>> Query<T>(string query) where T: new()
public async Task<List<T>> Query<T>(string query) where T : new()
{
var response = await Query(query);
if (response.Results!.Count > 1)
throw new DataException("Query returned more than 1 result. At the moment only 1 result supported");
var res = response.Results[0];

if (!string.IsNullOrEmpty(res.Error))
throw new InvalidOperationException(res.Error);
var list = new List<T>();

for (int i = 0; i < res.Values.Count; i++)
for (int i = 0; i < res.Values?.Count; i++)
{
var dto = new T();

foreach (var prop in typeof(T).GetProperties())
{
var index = res.Columns.FindIndex(c => c.ToLower() == prop.Name.ToLower());

Check warning on line 50 in RqliteDotnet/RqliteOrmClient.cs

View workflow job for this annotation

GitHub Actions / build

Dereference of a possibly null reference.
var val = GetValue(res.Types[index], res.Values[i][index]);

Check warning on line 51 in RqliteDotnet/RqliteOrmClient.cs

View workflow job for this annotation

GitHub Actions / build

Dereference of a possibly null reference.

prop.SetValue(dto, val);
}


list.Add(dto);
}

return list;
}

public async Task<List<U>> QueryParams<T, U>(string query, params T[] qps)
where T : QueryParameter
where U : new()
{
var response = await QueryParams(query, qps);
if (response.Results!.Count > 1)
throw new DataException("Query returned more than 1 result. At the moment only 1 result supported");
var res = response.Results[0];

if (!string.IsNullOrEmpty(res.Error))
throw new InvalidOperationException(res.Error);
var list = new List<U>();

for (int i = 0; i < res.Values?.Count; i++)
{
var dto = new U();

foreach (var prop in typeof(U).GetProperties())
{
var index = res.Columns.FindIndex(c => c.ToLower() == prop.Name.ToLower());

Check warning on line 81 in RqliteDotnet/RqliteOrmClient.cs

View workflow job for this annotation

GitHub Actions / build

Dereference of a possibly null reference.
var val = GetValue(res.Types[index], res.Values[i][index]);

Check warning on line 82 in RqliteDotnet/RqliteOrmClient.cs

View workflow job for this annotation

GitHub Actions / build

Dereference of a possibly null reference.

prop.SetValue(dto, val);
}

list.Add(dto);
}

Expand Down

0 comments on commit d7b5009

Please sign in to comment.