diff --git a/client/trailbase-dotnet/src/Client.cs b/client/trailbase-dotnet/src/Client.cs index a9e9e28e..a794f11d 100644 --- a/client/trailbase-dotnet/src/Client.cs +++ b/client/trailbase-dotnet/src/Client.cs @@ -221,11 +221,11 @@ internal class ThinClient { } var query = (Dictionary p) => { - var queryString = System.Web.HttpUtility.ParseQueryString(string.Empty); - foreach (var e in p) { - queryString.Add(e.Key, e.Value); - } - return queryString.ToString(); + // NOTE: System.Web.HttpUtility encode '[' and ']' as "%5b" and "%5d", while we + // need the capital letter version. Use System.Net.WebUtility.UrlEncode instead. + var encode = System.Net.WebUtility.UrlEncode; + return string.Join("&", + p.Select(kvp => $"{encode(kvp.Key)}={encode(kvp.Value)}")); }; var httpRequestMessage = new HttpRequestMessage { diff --git a/client/trailbase-dotnet/src/RecordApi.cs b/client/trailbase-dotnet/src/RecordApi.cs index 6930cac3..b4fd597f 100644 --- a/client/trailbase-dotnet/src/RecordApi.cs +++ b/client/trailbase-dotnet/src/RecordApi.cs @@ -206,6 +206,63 @@ public class ErrorEvent : Event { internal partial class SerializeResponseRecordIdContext : JsonSerializerContext { } +/// Comparison operation for column filters, e.g. col != 'val'. +public enum CompareOp { + /// Equal operator: '='. + Equal, + /// Not equal operator: '<>'. + NotEqual, + /// Less than operator: '<'. + LessThan, + /// Less than equal operator: '<='. + LessThanEqual, + /// Greater than operator: '>'. + GreaterThan, + /// Greater than equal operator: '>='. + GreaterThanEqual, + /// Like string operator: 'LIKE'. + Like, + /// Regular expression operator: 'REGEXP'. + Regexp, +} + +/// Abstract base class for filters. +public abstract class FilterBase { } + +/// Column filters. +sealed public class Filter : FilterBase { + internal String column; + internal CompareOp? op; + internal String value; + + /// Constructs column filter by column name, value and comparison operation + public Filter(String column, String value, CompareOp? op = null) { + this.column = column; + this.op = op; + this.value = value; + } +} + +/// Logical "and" for column filters. +sealed public class And : FilterBase { + internal List filters; + + /// Constructs logical "and". + public And(List filters) { + this.filters = filters; + } +} + +/// Logical "or" for column filters. +sealed public class Or : FilterBase { + internal List filters; + + /// Constructs logical "or". + public Or(List filters) { + this.filters = filters; + } +} + /// Main API to interact with Records. public class RecordApi { static readonly string _recordApi = "api/records/v1"; @@ -330,7 +387,7 @@ public class RecordApi { public async Task> List( Pagination? pagination = null, List? order = null, - List? filters = null, + List? filters = null, List? expand = null, bool count = false ) { @@ -351,7 +408,7 @@ public class RecordApi { JsonTypeInfo> jsonTypeInfo, Pagination? pagination = null, List? order = null, - List? filters = null, + List? filters = null, List? expand = null, bool count = false ) { @@ -370,7 +427,7 @@ public class RecordApi { public async Task> List( Pagination? pagination = null, List? order = null, - List? filters = null, + List? filters = null, List? expand = null, bool count = false ) { @@ -382,7 +439,7 @@ public class RecordApi { private async Task ListImpl( Pagination? pagination, List? order, - List? filters, + List? filters, List? expand, bool count ) { @@ -416,18 +473,52 @@ public class RecordApi { param.Add("expand", String.Join(",", expand.ToArray())); } - if (filters != null) { - foreach (var filter in filters) { - var split = filter.Split('=', 2); - if (split.Length < 2) { - throw new Exception($"Filter '{filter}' does not match: 'name[op]=value'"); - } - var nameOp = split[0]; - var value = split[1]; - param.Add(nameOp, value); + String op(CompareOp op) { + return op switch { + CompareOp.Equal => "$eq", + CompareOp.NotEqual => "$eq", + CompareOp.LessThan => "$lt", + CompareOp.LessThanEqual => "$lte", + CompareOp.GreaterThan => "$gt", + CompareOp.GreaterThanEqual => "$gte", + CompareOp.Like => "$like", + CompareOp.Regexp => "$re", + _ => "??", + }; + } + + void traverseFilters(String path, FilterBase filter) { + switch (filter) { + case Filter f: + if (f.op != null) { + var o = op((CompareOp)f.op); + param.Add($"{path}[{f.column}][{o}]", f.value); + } + else { + param.Add($"{path}[{f.column}]", f.value); + } + break; + case And f: + var i = 0; + foreach (var fil in f.filters) { + traverseFilters($"{path}[$and][{i++}]", fil); + } + break; + case Or f: + var j = 0; + foreach (var fil in f.filters) { + traverseFilters($"{path}[$or][{j++}]", fil); + } + break; + default: + break; } } + foreach (var filter in filters ?? []) { + traverseFilters("filter", filter); + } + var response = await client.Fetch( $"{RecordApi._recordApi}/{name}", HttpMethod.Get, diff --git a/client/trailbase-dotnet/test/ClientTest.cs b/client/trailbase-dotnet/test/ClientTest.cs index e03884cc..5adf2817 100644 --- a/client/trailbase-dotnet/test/ClientTest.cs +++ b/client/trailbase-dotnet/test/ClientTest.cs @@ -163,11 +163,13 @@ public class ClientTest : IClassFixture { ListResponse response = await api.List( null, null, - [$"text_not_null={messages[0]}"], + [new Filter(column: "text_not_null", value: $"{messages[0]}")], null, false )!; + Console.WriteLine("FFFFIIII"); Assert.Single(response.records); + Console.WriteLine("FFFFIIII AFTER"); Assert.Null(response.total_count); Assert.Equal(messages[0], response.records[0].text_not_null); } @@ -175,7 +177,7 @@ public class ClientTest : IClassFixture { { var responseAsc = await api.List( order: ["+text_not_null"], - filters: [$"text_not_null[like]=% =?&{suffix}"], + filters: [new Filter(column: "text_not_null", value: $"% =?&{suffix}", op: CompareOp.Like)], count: true )!; Assert.Equal(2, responseAsc.total_count); @@ -184,7 +186,7 @@ public class ClientTest : IClassFixture { var responseDesc = await api.List( order: ["-text_not_null"], - filters: [$"text_not_null[like]=%{suffix}"] + filters: [new Filter("text_not_null", $"%{suffix}", op: CompareOp.Like)] )!; var recordsDesc = responseDesc.records; Assert.Equal(messages.Count, recordsDesc.Count); @@ -217,7 +219,7 @@ public class ClientTest : IClassFixture { var id = ids[0]; await api.Delete(id); - var response = await api.List(filters: [$"text_not_null[like]=%{suffix}"])!; + var response = await api.List(filters: [new Filter("text_not_null", $"%{suffix}", op: CompareOp.Like)])!; Assert.Single(response.records); } @@ -249,7 +251,7 @@ public class ClientTest : IClassFixture { { ListResponse response = await api.List( SerializeSimpleStrictContext.Default.ListResponseSimpleStrict, - filters: [$"text_not_null={messages[0]}"] + filters: [new Filter("text_not_null", $"{messages[0]}")] )!; Assert.Single(response.records); Assert.Equal(messages[0], response.records[0].text_not_null); @@ -259,7 +261,7 @@ public class ClientTest : IClassFixture { var responseAsc = await api.List( SerializeSimpleStrictContext.Default.ListResponseSimpleStrict, order: ["+text_not_null"], - filters: [$"text_not_null[like]=% =?&{suffix}"] + filters: [new Filter("text_not_null", $"% =?&{suffix}", op: CompareOp.Like)] )!; var recordsAsc = responseAsc.records; Assert.Equal(messages.Count, recordsAsc.Count); @@ -268,7 +270,7 @@ public class ClientTest : IClassFixture { var responseDesc = await api.List( SerializeSimpleStrictContext.Default.ListResponseSimpleStrict, order: ["-text_not_null"], - filters: [$"text_not_null[like]=%{suffix}"] + filters: [new Filter("text_not_null", $"%{suffix}", op: CompareOp.Like)] )!; var recordsDesc = responseDesc.records; Assert.Equal(messages.Count, recordsDesc.Count); @@ -307,7 +309,7 @@ public class ClientTest : IClassFixture { var response = await api.List( SerializeSimpleStrictContext.Default.ListResponseSimpleStrict, - filters: [$"text_not_null[like]=%{suffix}"] + filters: [new Filter("text_not_null", $"%{suffix}", op: CompareOp.Like)] )!; Assert.Single(response.records);