diff --git a/Postgrest/Attributes/ColumnAttribute.cs b/Postgrest/Attributes/ColumnAttribute.cs index 0dd1ab6..85238c7 100644 --- a/Postgrest/Attributes/ColumnAttribute.cs +++ b/Postgrest/Attributes/ColumnAttribute.cs @@ -21,22 +21,22 @@ public class ColumnAttribute : Attribute /// /// The name in postgres of this column. /// - public string ColumnName { get; set; } + public string ColumnName { get; } /// - /// Specifies what should be serialied in the event this column's value is NULL + /// Specifies what should be serialized in the event this column's value is NULL /// public NullValueHandling NullValueHandling { get; set; } /// /// If the performed query is an Insert or Upsert, should this value be ignored? /// - public bool IgnoreOnInsert { get; set; } + public bool IgnoreOnInsert { get; } /// /// If the performed query is an Update, should this value be ignored? /// - public bool IgnoreOnUpdate { get; set; } + public bool IgnoreOnUpdate { get; } public ColumnAttribute([CallerMemberName] string? columnName = null, NullValueHandling nullValueHandling = NullValueHandling.Include, bool ignoreOnInsert = false, bool ignoreOnUpdate = false) { diff --git a/Postgrest/Attributes/PrimaryKeyAttribute.cs b/Postgrest/Attributes/PrimaryKeyAttribute.cs index db17300..a65e6e4 100644 --- a/Postgrest/Attributes/PrimaryKeyAttribute.cs +++ b/Postgrest/Attributes/PrimaryKeyAttribute.cs @@ -17,12 +17,12 @@ namespace Postgrest.Attributes [AttributeUsage(AttributeTargets.Property)] public class PrimaryKeyAttribute : Attribute { - public string ColumnName { get; set; } + public string ColumnName { get; } /// /// Would be set to false in the event that the database handles the generation of this property. /// - public bool ShouldInsert { get; set; } + public bool ShouldInsert { get; } public PrimaryKeyAttribute([CallerMemberName] string? columnName = null, bool shouldInsert = false) { diff --git a/Postgrest/Attributes/ReferenceAttribute.cs b/Postgrest/Attributes/ReferenceAttribute.cs index 02cf326..e3e8cab 100644 --- a/Postgrest/Attributes/ReferenceAttribute.cs +++ b/Postgrest/Attributes/ReferenceAttribute.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Runtime.CompilerServices; -using Newtonsoft.Json; using Postgrest.Extensions; using Postgrest.Models; @@ -28,12 +28,12 @@ public class ReferenceAttribute : Attribute /// /// Table name of model /// - public string TableName { get; private set; } + public string TableName { get; } /// /// Columns that exist on the model we will select from. /// - public List Columns { get; } = new List(); + public List Columns { get; } = new(); /// /// If the performed query is an Insert or Upsert, should this value be ignored? (DEFAULT TRUE) @@ -48,14 +48,14 @@ public class ReferenceAttribute : Attribute /// /// If Reference should automatically be included in queries on this reference. (DEFAULT TRUE) /// - public bool IncludeInQuery { get; private set; } + public bool IncludeInQuery { get; } /// /// As to whether the query will filter top-level rows. /// /// See: https://postgrest.org/en/stable/api.html#resource-embedding /// - public bool ShouldFilterTopLevel { get; private set; } + public bool ShouldFilterTopLevel { get; } /// Model referenced /// Should referenced be included in queries? @@ -64,11 +64,12 @@ public class ReferenceAttribute : Attribute /// As to whether the query will filter top-level rows. /// /// - public ReferenceAttribute(Type model, bool includeInQuery = true, bool ignoreOnInsert = true, bool ignoreOnUpdate = true, bool shouldFilterTopLevel = true, [CallerMemberName] string propertyName = "") + public ReferenceAttribute(Type model, bool includeInQuery = true, bool ignoreOnInsert = true, + bool ignoreOnUpdate = true, bool shouldFilterTopLevel = true, [CallerMemberName] string propertyName = "") { if (!IsDerivedFromBaseModel(model)) { - throw new Exception("RefernceAttribute must be used with Postgrest BaseModels."); + throw new Exception("ReferenceAttribute must be used with Postgrest BaseModels."); } Model = model; @@ -102,36 +103,17 @@ public ReferenceAttribute(Type model, bool includeInQuery = true, bool ignoreOnI { Columns.Add(pk.ColumnName); } - else if (item is ReferenceAttribute refAttr) + else if (item is ReferenceAttribute { IncludeInQuery: true } refAttr) { - if (refAttr.IncludeInQuery) - { - if (ShouldFilterTopLevel) - { - Columns.Add($"{refAttr.TableName}!inner({string.Join(",", refAttr.Columns.ToArray())})"); - } - else - { - Columns.Add($"{refAttr.TableName}({string.Join(",", refAttr.Columns.ToArray())})"); - } - } + Columns.Add(ShouldFilterTopLevel + ? $"{refAttr.TableName}!inner({string.Join(",", refAttr.Columns.ToArray())})" + : $"{refAttr.TableName}({string.Join(",", refAttr.Columns.ToArray())})"); } } } } - private bool IsDerivedFromBaseModel(Type type) - { - var isDerived = false; - foreach (var t in type.GetInheritanceHierarchy()) - { - if (t == typeof(BaseModel)) - { - isDerived = true; - break; - } - } - return isDerived; - } + private bool IsDerivedFromBaseModel(Type type) => + type.GetInheritanceHierarchy().Any(t => t == typeof(BaseModel)); } -} +} \ No newline at end of file diff --git a/Postgrest/Client.cs b/Postgrest/Client.cs index d0fb698..262bd07 100644 --- a/Postgrest/Client.cs +++ b/Postgrest/Client.cs @@ -55,7 +55,7 @@ public static JsonSerializerSettings SerializerSettings(ClientOptions? options = /// /// Function that can be set to return dynamic headers. /// - /// Headers specified in the constructor options will ALWAYS take precendece over headers returned by this function. + /// Headers specified in the constructor options will ALWAYS take precedence over headers returned by this function. /// public Func>? GetHeaders { get; set; } @@ -81,9 +81,11 @@ public Client(string baseUrl, ClientOptions? options = null) /// public IPostgrestTable Table() where T : BaseModel, new() { - var table = new Table(BaseUrl, SerializerSettings(Options), Options); - table.GetHeaders = GetHeaders; - + var table = new Table(BaseUrl, SerializerSettings(Options), Options) + { + GetHeaders = GetHeaders + }; + return table; } @@ -111,9 +113,7 @@ public Task Rpc(string procedureName, Dictionary p new Dictionary(Options.Headers), Options); if (GetHeaders != null) - { headers = GetHeaders().MergeLeft(headers); - } // Send request var request = Helpers.MakeRequest(Options, HttpMethod.Post, canonicalUri, serializerSettings, data, headers); diff --git a/Postgrest/Constants.cs b/Postgrest/Constants.cs index f9909e9..4e4e66c 100644 --- a/Postgrest/Constants.cs +++ b/Postgrest/Constants.cs @@ -1,5 +1,4 @@ -using Postgrest.Attributes; -using Supabase.Core.Attributes; +using Supabase.Core.Attributes; namespace Postgrest { diff --git a/Postgrest/Exceptions/FailureHint.cs b/Postgrest/Exceptions/FailureHint.cs new file mode 100644 index 0000000..773b1a9 --- /dev/null +++ b/Postgrest/Exceptions/FailureHint.cs @@ -0,0 +1,39 @@ +using static Postgrest.Exceptions.FailureHint.Reason; + +namespace Postgrest.Exceptions +{ + /// + /// https://postgrest.org/en/v10.2/errors.html?highlight=exception#http-status-codes + /// + public static class FailureHint + { + public enum Reason + { + Unknown, + NotAuthorized, + ForeignKeyViolation, + UniquenessViolation, + Internal, + UndefinedTable, + UndefinedFunction + } + + public static Reason DetectReason(PostgrestException pgex) + { + if (pgex.Content == null) + return Unknown; + + return pgex.StatusCode switch + { + 401 => NotAuthorized, + 403 when pgex.Content.Contains("apikey") => NotAuthorized, + 404 when pgex.Content.Contains("42883") => UndefinedTable, + 404 when pgex.Content.Contains("42P01") => UndefinedFunction, + 409 when pgex.Content.Contains("23503") => ForeignKeyViolation, + 409 when pgex.Content.Contains("23505") => UniquenessViolation, + 500 => Internal, + _ => Unknown + }; + } + } +} diff --git a/Postgrest/Exceptions/PostgrestException.cs b/Postgrest/Exceptions/PostgrestException.cs new file mode 100644 index 0000000..483c906 --- /dev/null +++ b/Postgrest/Exceptions/PostgrestException.cs @@ -0,0 +1,29 @@ +using System; +using System.Net.Http; + +namespace Postgrest.Exceptions +{ + /// + /// Errors from Postgrest are wrapped by this exception + /// + public class PostgrestException : Exception + { + public PostgrestException(string? message) : base(message) { } + public PostgrestException(string? message, Exception? innerException) : base(message, innerException) { } + + public HttpResponseMessage? Response { get; internal set; } + + public string? Content { get; internal set; } + + public int StatusCode { get; internal set; } + + public FailureHint.Reason Reason { get; private set; } + + public void AddReason() + { + Reason = FailureHint.DetectReason(this); + } + + } +} + \ No newline at end of file diff --git a/Postgrest/Extensions/TypeExtensions.cs b/Postgrest/Extensions/TypeExtensions.cs index 0c515da..827ea25 100644 --- a/Postgrest/Extensions/TypeExtensions.cs +++ b/Postgrest/Extensions/TypeExtensions.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Text; namespace Postgrest.Extensions { diff --git a/Postgrest/Extensions/UriExtensions.cs b/Postgrest/Extensions/UriExtensions.cs index b5e0f75..d9a22bd 100644 --- a/Postgrest/Extensions/UriExtensions.cs +++ b/Postgrest/Extensions/UriExtensions.cs @@ -1,6 +1,4 @@ using System; -using System.Collections.Generic; -using System.Text; namespace Postgrest.Extensions { diff --git a/Postgrest/Helpers.cs b/Postgrest/Helpers.cs index 589983c..7327f26 100644 --- a/Postgrest/Helpers.cs +++ b/Postgrest/Helpers.cs @@ -9,170 +9,143 @@ using System.Runtime.CompilerServices; using System.Threading; using Newtonsoft.Json.Linq; -using Postgrest.Extensions; using Postgrest.Models; using Supabase.Core.Extensions; +using Postgrest.Exceptions; [assembly: InternalsVisibleTo("PostgrestTests")] namespace Postgrest { - internal static class Helpers - { - public static T GetPropertyValue(object obj, string propName) => - (T)obj.GetType().GetProperty(propName).GetValue(obj, null); - - public static T GetCustomAttribute(object obj) where T : Attribute => - (T)Attribute.GetCustomAttribute(obj.GetType(), typeof(T)); - - public static T GetCustomAttribute(Type type) where T : Attribute => - (T)Attribute.GetCustomAttribute(type, typeof(T)); - - private static readonly HttpClient Client = new HttpClient(); - - /// - /// Helper to make a request using the defined parameters to an API Endpoint and coerce into a model. - /// - /// - /// - /// - /// - /// - /// - /// - /// - public static async Task> MakeRequest(ClientOptions clientOptions, HttpMethod method, string url, JsonSerializerSettings serializerSettings, object? data = null, Dictionary? headers = null, Func>? getHeaders = null, CancellationToken cancellationToken = default) where T : BaseModel, new() - { - var baseResponse = await MakeRequest(clientOptions, method, url, serializerSettings, data, headers, cancellationToken); - return new ModeledResponse(baseResponse, serializerSettings, getHeaders); - } - - /// - /// Helper to make a request using the defined parameters to an API Endpoint. - /// - /// - /// - /// - /// - /// - /// - /// - public static async Task MakeRequest(ClientOptions clientOptions, HttpMethod method, string url, JsonSerializerSettings serializerSettings, object? data = null, Dictionary? headers = null, CancellationToken cancellationToken = default) - { - var builder = new UriBuilder(url); - var query = HttpUtility.ParseQueryString(builder.Query); - - if (data != null && method == HttpMethod.Get) - { - // Case if it's a Get request the data object is a dictionary - if (data is Dictionary reqParams) - { - foreach (var param in reqParams) - query[param.Key] = param.Value; - } - } - - builder.Query = query.ToString(); - - using var requestMessage = new HttpRequestMessage(method, builder.Uri); - - if (data != null && method != HttpMethod.Get) - { - var stringContent = JsonConvert.SerializeObject(data, serializerSettings); - - if (!string.IsNullOrWhiteSpace(stringContent) && JToken.Parse(stringContent).HasValues) - { - requestMessage.Content = new StringContent(stringContent, - Encoding.UTF8, "application/json"); - } - } - - if (headers != null) - { - foreach (var kvp in headers) - { - requestMessage.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value); - } - } - - var response = await Client.SendAsync(requestMessage, cancellationToken); - var content = await response.Content.ReadAsStringAsync(); - - if (!response.IsSuccessStatusCode) - { - ErrorResponse? obj = null; - - try - { - obj = JsonConvert.DeserializeObject(content); - } - catch (JsonSerializationException) - { - obj = new ErrorResponse(clientOptions, response, content) - { - Message = - "Invalid or Empty response received. Are you trying to update or delete a record that does not exist?" - }; - } - - throw new RequestException(response, obj!); - } - - return new BaseResponse(clientOptions, response, content); - } - - /// - /// Prepares the request with appropriate HTTP headers expected by Postgrest. - /// - /// - /// - /// - /// - /// - /// - public static Dictionary PrepareRequestHeaders(HttpMethod method, Dictionary? headers = null, ClientOptions? options = null, int rangeFrom = int.MinValue, int rangeTo = int.MinValue) - { - options ??= new ClientOptions(); - - headers = headers == null - ? new Dictionary(options.Headers) - : options.Headers.MergeLeft(headers); - - if (!string.IsNullOrEmpty(options.Schema)) - { - headers.Add(method == HttpMethod.Get - ? "Accept-Profile" - : "Content-Profile", options.Schema); - } - - if (rangeFrom != int.MinValue) - { - var formatRangeTo = rangeTo != int.MinValue - ? rangeTo.ToString() - : null; - - headers.Add("Range-Unit", "items"); - headers.Add("Range", $"{rangeFrom}-{formatRangeTo}"); - } - - if (!headers.ContainsKey("X-Client-Info")) - { - headers.Add("X-Client-Info", Supabase.Core.Util.GetAssemblyVersion(typeof(Client))); - } - - return headers; - } - } - - public class RequestException : Exception - { - public HttpResponseMessage Response { get; } - public ErrorResponse Error { get; } - - public RequestException(HttpResponseMessage response, ErrorResponse error) : base(error.Message) - { - Response = response; - Error = error; - } - } + internal static class Helpers + { + private static readonly HttpClient Client = new HttpClient(); + + /// + /// Helper to make a request using the defined parameters to an API Endpoint and coerce into a model. + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static async Task> MakeRequest(ClientOptions clientOptions, HttpMethod method, string url, JsonSerializerSettings serializerSettings, object? data = null, Dictionary? headers = null, Func>? getHeaders = null, CancellationToken cancellationToken = default) where T : BaseModel, new() + { + var baseResponse = await MakeRequest(clientOptions, method, url, serializerSettings, data, headers, cancellationToken); + return new ModeledResponse(baseResponse, serializerSettings, getHeaders); + } + + /// + /// Helper to make a request using the defined parameters to an API Endpoint. + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static async Task MakeRequest(ClientOptions clientOptions, HttpMethod method, string url, JsonSerializerSettings serializerSettings, object? data = null, Dictionary? headers = null, CancellationToken cancellationToken = default) + { + var builder = new UriBuilder(url); + var query = HttpUtility.ParseQueryString(builder.Query); + + if (data != null && method == HttpMethod.Get) + { + // Case if it's a Get request the data object is a dictionary + if (data is Dictionary reqParams) + { + foreach (var param in reqParams) + query[param.Key] = param.Value; + } + } + + builder.Query = query.ToString(); + + using var requestMessage = new HttpRequestMessage(method, builder.Uri); + + if (data != null && method != HttpMethod.Get) + { + var stringContent = JsonConvert.SerializeObject(data, serializerSettings); + + if (!string.IsNullOrWhiteSpace(stringContent) && JToken.Parse(stringContent).HasValues) + { + requestMessage.Content = new StringContent(stringContent, Encoding.UTF8, "application/json"); + } + } + + if (headers != null) + { + foreach (var kvp in headers) + { + requestMessage.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value); + } + } + + var response = await Client.SendAsync(requestMessage, cancellationToken); + var content = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + var e = new PostgrestException("Request Failed") + { + Content = content, + Response = response, + StatusCode = (int)response.StatusCode + }; + e.AddReason(); + throw e; + } + + return new BaseResponse(clientOptions, response, content); + } + + /// + /// Prepares the request with appropriate HTTP headers expected by Postgrest. + /// + /// + /// + /// + /// + /// + /// + public static Dictionary PrepareRequestHeaders(HttpMethod method, Dictionary? headers = null, ClientOptions? options = null, int rangeFrom = int.MinValue, int rangeTo = int.MinValue) + { + options ??= new ClientOptions(); + + headers = headers == null + ? new Dictionary(options.Headers) + : options.Headers.MergeLeft(headers); + + if (!string.IsNullOrEmpty(options.Schema)) + { + headers.Add(method == HttpMethod.Get + ? "Accept-Profile" + : "Content-Profile", options.Schema); + } + + if (rangeFrom != int.MinValue) + { + var formatRangeTo = rangeTo != int.MinValue + ? rangeTo.ToString() + : null; + + headers.Add("Range-Unit", "items"); + headers.Add("Range", $"{rangeFrom}-{formatRangeTo}"); + } + + if (!headers.ContainsKey("X-Client-Info")) + { + headers.Add("X-Client-Info", Supabase.Core.Util.GetAssemblyVersion(typeof(Client))); + } + + return headers; + } + } } \ No newline at end of file diff --git a/Postgrest/IntRange.cs b/Postgrest/IntRange.cs index 34b6394..7fd8fb7 100644 --- a/Postgrest/IntRange.cs +++ b/Postgrest/IntRange.cs @@ -1,10 +1,9 @@ -// https://github.com/dotnet/runtime/blob/419e949d258ecee4c40a460fb09c66d974229623/src/libraries/System.Private.CoreLib/src/System/Index.cs -// https://github.com/dotnet/runtime/blob/419e949d258ecee4c40a460fb09c66d974229623/src/libraries/System.Private.CoreLib/src/System/Range.cs - -using System; +using System; using System.Runtime.CompilerServices; using Postgrest; +// https://github.com/dotnet/runtime/blob/419e949d258ecee4c40a460fb09c66d974229623/src/libraries/System.Private.CoreLib/src/System/Index.cs +// https://github.com/dotnet/runtime/blob/419e949d258ecee4c40a460fb09c66d974229623/src/libraries/System.Private.CoreLib/src/System/Range.cs namespace Postgrest { /// Represent a type can be used to index a collection either from the start or the end. @@ -121,7 +120,7 @@ public int GetOffset(int length) /// Indicates whether the current Index object is equal to another object of the same type. /// An object to compare with this object - public override bool Equals(object? value) => value is Index && _value == ((Index)value)._value; + public override bool Equals(object? value) => value is Index index && _value == index._value; /// Indicates whether the current Index object is equal to another Index object. /// An object to compare with this object diff --git a/Postgrest/Interfaces/IPostgrestQueryFilter.cs b/Postgrest/Interfaces/IPostgrestQueryFilter.cs index 6e8f03a..afb92db 100644 --- a/Postgrest/Interfaces/IPostgrestQueryFilter.cs +++ b/Postgrest/Interfaces/IPostgrestQueryFilter.cs @@ -2,7 +2,7 @@ { public interface IPostgrestQueryFilter { - object Criteria { get; } + object? Criteria { get; } Constants.Operator Op { get; } string? Property { get; } } diff --git a/Postgrest/Linq/SelectExpressionVisitor.cs b/Postgrest/Linq/SelectExpressionVisitor.cs index 658b257..02e2f4d 100644 --- a/Postgrest/Linq/SelectExpressionVisitor.cs +++ b/Postgrest/Linq/SelectExpressionVisitor.cs @@ -1,92 +1,97 @@ using Postgrest.Attributes; using System; using System.Collections.Generic; -using System.Linq; +using System.Diagnostics; using System.Linq.Expressions; -using System.Reflection; -using System.Text; -using static Postgrest.Constants; namespace Postgrest.Linq { - /// - /// Helper class for parsing Select linq queries. - /// - internal class SelectExpressionVisitor : ExpressionVisitor - { - /// - /// The columns that have been selected from this linq expression. - /// - public List Columns { get; private set; } = new List(); + /// + /// Helper class for parsing Select linq queries. + /// + internal class SelectExpressionVisitor : ExpressionVisitor + { + /// + /// The columns that have been selected from this linq expression. + /// + public List Columns { get; } = new(); - /// - /// The root call that will be looped through to populate . - /// - /// Called like: `Table().Select(x => new[] { x.Id, x.Name, x.CreatedAt }).Get()` - /// - /// - /// - protected override Expression VisitNewArray(NewArrayExpression node) - { - foreach (var expression in node.Expressions) - Visit(expression); + /// + /// The root call that will be looped through to populate . + /// + /// Called like: `Table().Select(x => new[] { x.Id, x.Name, x.CreatedAt }).Get()` + /// + /// + /// + protected override Expression VisitNewArray(NewArrayExpression node) + { + foreach (var expression in node.Expressions) + Visit(expression); - return node; - } + return node; + } - /// - /// A Member Node, representing a property on a BaseModel. - /// - /// - /// - protected override Expression VisitMember(MemberExpression node) - { - var column = GetColumnFromMemberExpression(node); + /// + /// A Member Node, representing a property on a BaseModel. + /// + /// + /// + protected override Expression VisitMember(MemberExpression node) + { + var column = GetColumnFromMemberExpression(node); - if (column != null) - Columns.Add(column); + if (column != null) + Columns.Add(column); - return node; - } + return node; + } - /// - /// A Unary Node, delved into to represent a property on a BaseModel. - /// - /// - /// - protected override Expression VisitUnary(UnaryExpression node) - { - if (node.Operand is MemberExpression memberExpression) - { - var column = GetColumnFromMemberExpression(memberExpression); + /// + /// A Unary Node, delved into to represent a property on a BaseModel. + /// + /// + /// + protected override Expression VisitUnary(UnaryExpression node) + { + if (node.Operand is MemberExpression memberExpression) + { + var column = GetColumnFromMemberExpression(memberExpression); - if (column != null) - Columns.Add(column); - } + if (column != null) + Columns.Add(column); + } - return node; - } + return node; + } - /// - /// Gets a column name from property based on it's supplied attributes. - /// - /// - /// - private string? GetColumnFromMemberExpression(MemberExpression node) - { - var type = node.Member.ReflectedType; - var prop = type.GetProperty(node.Member.Name); - var attrs = prop.GetCustomAttributes(true); + /// + /// Gets a column name from property based on it's supplied attributes. + /// + /// + /// + private string? GetColumnFromMemberExpression(MemberExpression node) + { + var type = node.Member.ReflectedType; + var prop = type?.GetProperty(node.Member.Name); + var attrs = prop?.GetCustomAttributes(true); - foreach (var attr in attrs) - { - if (attr is ColumnAttribute columnAttr) - return columnAttr.ColumnName; - else if (attr is PrimaryKeyAttribute primaryKeyAttr) - return primaryKeyAttr.ColumnName; - } + if (attrs == null) + throw new ArgumentException( + $"Unknown argument '{node.Member.Name}' provided, does it have a `Column` or `PrimaryKey` attribute?"); - throw new ArgumentException(string.Format("Unknown argument '{0}' provided, does it have a `Column` or `PrimaryKey` attribute?", node.Member.Name)); - } - } -} + foreach (var attr in attrs) + { + switch (attr) + { + case ColumnAttribute columnAttr: + return columnAttr.ColumnName; + case PrimaryKeyAttribute primaryKeyAttr: + return primaryKeyAttr.ColumnName; + } + } + + throw new ArgumentException( + $"Unknown argument '{node.Member.Name}' provided, does it have a `Column` or `PrimaryKey` attribute?"); + } + } +} \ No newline at end of file diff --git a/Postgrest/Linq/SetExpressionVisitor.cs b/Postgrest/Linq/SetExpressionVisitor.cs index d9dc533..99e3e68 100644 --- a/Postgrest/Linq/SetExpressionVisitor.cs +++ b/Postgrest/Linq/SetExpressionVisitor.cs @@ -1,12 +1,7 @@ -using Newtonsoft.Json.Linq; -using Postgrest.Attributes; +using Postgrest.Attributes; using System; using System.Collections.Generic; -using System.Linq; using System.Linq.Expressions; -using System.Reflection; -using System.Text; -using static Postgrest.Constants; namespace Postgrest.Linq { @@ -37,16 +32,12 @@ internal class SetExpressionVisitor : ExpressionVisitor /// protected override Expression VisitUnary(UnaryExpression node) { - if (node.Operand is MemberExpression memberExpression) - { - var column = GetColumnFromMemberExpression(memberExpression); + if (node.Operand is not MemberExpression memberExpression) return node; + + var column = GetColumnFromMemberExpression(memberExpression); - if (column != null) - { - Column = column; - ExpectedType = memberExpression.Type; - } - } + Column = column; + ExpectedType = memberExpression.Type; return node; } @@ -60,11 +51,8 @@ protected override Expression VisitMember(MemberExpression node) { var column = GetColumnFromMemberExpression(node); - if (column != null) - { - Column = column; - ExpectedType = node.Type; - } + Column = column; + ExpectedType = node.Type; return node; } @@ -115,8 +103,9 @@ private void HandleKeyValuePair(NewExpression node) var valueArgument = Expression.Lambda(right).Compile().DynamicInvoke(); Value = valueArgument; - if (!ExpectedType!.IsAssignableFrom(Value.GetType())) - throw new ArgumentException(string.Format("Expected Value to be of Type: {0}, instead received: {1}.", ExpectedType.Name, Value.GetType().Name)); + if (!ExpectedType!.IsInstanceOfType(Value)) + throw new ArgumentException( + $"Expected Value to be of Type: {ExpectedType.Name}, instead received: {Value.GetType().Name}."); } /// @@ -127,18 +116,26 @@ private void HandleKeyValuePair(NewExpression node) private string GetColumnFromMemberExpression(MemberExpression node) { var type = node.Member.ReflectedType; - var prop = type.GetProperty(node.Member.Name); - var attrs = prop.GetCustomAttributes(true); + var prop = type?.GetProperty(node.Member.Name); + var attrs = prop?.GetCustomAttributes(true); + if (attrs == null) + throw new ArgumentException( + $"Unknown argument '{node.Member.Name}' provided, does it have a Column or PrimaryKey attribute?"); + foreach (var attr in attrs) { - if (attr is ColumnAttribute columnAttr) - return columnAttr.ColumnName; - else if (attr is PrimaryKeyAttribute primaryKeyAttr) - return primaryKeyAttr.ColumnName; + switch (attr) + { + case ColumnAttribute columnAttr: + return columnAttr.ColumnName; + case PrimaryKeyAttribute primaryKeyAttr: + return primaryKeyAttr.ColumnName; + } } - throw new ArgumentException(string.Format("Unknown argument '{0}' provided, does it have a Column or PrimaryKey attribute?", node.Member.Name)); + throw new ArgumentException( + $"Unknown argument '{node.Member.Name}' provided, does it have a Column or PrimaryKey attribute?"); } } } diff --git a/Postgrest/Linq/WhereExpressionVisitor.cs b/Postgrest/Linq/WhereExpressionVisitor.cs index 5464634..1a7301e 100644 --- a/Postgrest/Linq/WhereExpressionVisitor.cs +++ b/Postgrest/Linq/WhereExpressionVisitor.cs @@ -5,8 +5,8 @@ using System.Linq; using System.Linq.Expressions; using System.Reflection; -using System.Text; using static Postgrest.Constants; +// ReSharper disable InvalidXmlDocComment namespace Postgrest.Linq { @@ -54,13 +54,14 @@ protected override Expression VisitBinary(BinaryExpression node) // Otherwise, the base case. - Expression left = Visit(node.Left); - Expression right = Visit(node.Right); + var left = Visit(node.Left); + var right = Visit(node.Right); var column = left is MemberExpression leftMember ? GetColumnFromMemberExpression(leftMember) : null; if (column == null) - throw new ArgumentException(string.Format("Left side of expression: '{0}' is expected to be property with a ColumnAttribute or PrimaryKeyAttribute", node.ToString())); + throw new ArgumentException( + $"Left side of expression: '{node}' is expected to be property with a ColumnAttribute or PrimaryKeyAttribute"); if (right is ConstantExpression rightConstant) { @@ -94,12 +95,14 @@ protected override Expression VisitMethodCall(MethodCallExpression node) var obj = node.Object as MemberExpression; if (obj == null) - throw new ArgumentException(string.Format("Calling context '{0}' is expected to be a member of or derived from `BaseModel`", node.Object)); + throw new ArgumentException( + $"Calling context '{node.Object}' is expected to be a member of or derived from `BaseModel`"); var column = GetColumnFromMemberExpression(obj); if (column == null) - throw new ArgumentException(string.Format("Left side of expression: '{0}' is expected to be property with a ColumnAttribute or PrimaryKeyAttribute", node.ToString())); + throw new ArgumentException( + $"Left side of expression: '{node.ToString()}' is expected to be property with a ColumnAttribute or PrimaryKeyAttribute"); switch (node.Method.Name) { @@ -141,7 +144,6 @@ private void HandleMemberExpression(string column, Operator op, MemberExpression Filter = new QueryFilter(column, op, GetMemberExpressionValue(memberExpression)); } - /// /// A unary expression parser (i.e. => x.Id == 1 <- where both `1` is considered unary) /// @@ -197,15 +199,20 @@ private void HandleNewExpression(string column, Operator op, NewExpression newEx private string GetColumnFromMemberExpression(MemberExpression node) { var type = node.Member.ReflectedType; - var prop = type.GetProperty(node.Member.Name); - var attrs = prop.GetCustomAttributes(true); + var prop = type?.GetProperty(node.Member.Name); + var attrs = prop?.GetCustomAttributes(true); + if (attrs == null) return node.Member.Name; + foreach (var attr in attrs) { - if (attr is ColumnAttribute columnAttr) - return columnAttr.ColumnName; - else if (attr is PrimaryKeyAttribute primaryKeyAttr) - return primaryKeyAttr.ColumnName; + switch (attr) + { + case ColumnAttribute columnAttr: + return columnAttr.ColumnName; + case PrimaryKeyAttribute primaryKeyAttr: + return primaryKeyAttr.ColumnName; + } } return node.Member.Name; @@ -223,12 +230,10 @@ private object GetMemberExpressionValue(MemberExpression member) var obj = Expression.Lambda(member.Expression).Compile().DynamicInvoke(); return field.GetValue(obj); } - else - { - var lambda = Expression.Lambda(member); - var func = lambda.Compile(); - return func.DynamicInvoke(); - } + + var lambda = Expression.Lambda(member); + var func = lambda.Compile(); + return func.DynamicInvoke(); } /// @@ -238,7 +243,7 @@ private object GetMemberExpressionValue(MemberExpression member) /// private Operator GetMappedOperator(Expression node) { - Operator op = Operator.Equals; + var op = Operator.Equals; switch (node.NodeType) { diff --git a/Postgrest/Models/BaseModel.cs b/Postgrest/Models/BaseModel.cs index 514c6d4..3d2e89c 100644 --- a/Postgrest/Models/BaseModel.cs +++ b/Postgrest/Models/BaseModel.cs @@ -1,13 +1,10 @@ using System; using System.Collections.Generic; -using System.Reflection; using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; using Postgrest.Attributes; using Postgrest.Responses; -using Supabase.Core.Extensions; -using Supabase.Core.Interfaces; namespace Postgrest.Models { diff --git a/Postgrest/PostgrestContractResolver.cs b/Postgrest/PostgrestContractResolver.cs index 06652a6..91ba0d9 100644 --- a/Postgrest/PostgrestContractResolver.cs +++ b/Postgrest/PostgrestContractResolver.cs @@ -4,9 +4,10 @@ using System.Reflection; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; +using Postgrest.Attributes; using Postgrest.Converters; -namespace Postgrest.Attributes +namespace Postgrest { /// /// A custom resolver that handles mapping column names and property names as well @@ -14,13 +15,16 @@ namespace Postgrest.Attributes /// public class PostgrestContractResolver : DefaultContractResolver { - public bool IsUpdate { get; private set; } = false; - public bool IsInsert { get; private set; } = false; + public bool IsUpdate { get; private set; } + public bool IsInsert { get; private set; } - public void SetState(bool isInsert = false, bool isUpdate = false) + public bool IsUpsert { get; private set; } + + public void SetState(bool isInsert = false, bool isUpdate = false, bool isUpsert = false) { IsUpdate = isUpdate; IsInsert = isInsert; + IsUpsert = isUpsert; } protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization) @@ -93,7 +97,7 @@ protected override JsonProperty CreateProperty(MemberInfo member, MemberSerializ } prop.PropertyName = primaryKeyAttribute.ColumnName; - prop.ShouldSerialize = instance => primaryKeyAttribute.ShouldInsert; + prop.ShouldSerialize = instance => primaryKeyAttribute.ShouldInsert || (IsUpsert && instance != null); return prop; } } diff --git a/Postgrest/QueryFilter.cs b/Postgrest/QueryFilter.cs index c5a4a52..76e51b9 100644 --- a/Postgrest/QueryFilter.cs +++ b/Postgrest/QueryFilter.cs @@ -9,16 +9,16 @@ namespace Postgrest public class QueryFilter : IPostgrestQueryFilter { /// - /// String value to be subsituted for a null criterion + /// String value to be substituted for a null criterion /// public const string NullVal = "null"; public string? Property { get; private set; } public Operator Op { get; private set; } - public object Criteria { get; private set; } + public object? Criteria { get; private set; } /// - /// Contructor to use single value filtering. + /// Contractor to use single value filtering. /// /// Column name /// Operation: And, Equals, GreaterThan, LessThan, GreaterThanOrEqual, LessThanOrEqual, NotEqual, Is, Adjacent, Not, Like, ILike @@ -46,7 +46,6 @@ public QueryFilter(string property, Operator op, object criteria) default: throw new Exception("Advanced filters require a constructor with more specific arguments"); } - } /// @@ -68,7 +67,8 @@ public QueryFilter(string property, Operator op, List criteria) Criteria = criteria; break; default: - throw new Exception("List constructor must be used with filter that accepts an array of arguments."); + throw new Exception( + "List constructor must be used with filter that accepts an array of arguments."); } } @@ -91,7 +91,8 @@ public QueryFilter(string property, Operator op, Dictionary crit Criteria = criteria; break; default: - throw new Exception("List constructor must be used with filter that accepts an array of arguments."); + throw new Exception( + "List constructor must be used with filter that accepts an array of arguments."); } } @@ -189,16 +190,16 @@ public QueryFilter(Operator op, QueryFilter filter) /// public class FullTextSearchConfig { - [JsonProperty("queryText")] - public string QueryText { get; private set; } + [JsonProperty("queryText")] public string QueryText { get; private set; } - [JsonProperty("config")] - public string Config { get; private set; } = "english"; + [JsonProperty("config")] public string Config { get; private set; } = "english"; - public FullTextSearchConfig(string queryText, string config) + public FullTextSearchConfig(string queryText, string? config) { QueryText = queryText; - Config = config; + + if (!string.IsNullOrEmpty(config)) + Config = config!; } } -} +} \ No newline at end of file diff --git a/Postgrest/QueryOptions.cs b/Postgrest/QueryOptions.cs index ab2bdcc..00f4fcb 100644 --- a/Postgrest/QueryOptions.cs +++ b/Postgrest/QueryOptions.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using Postgrest.Attributes; using Postgrest.Extensions; using Supabase.Core.Attributes; diff --git a/Postgrest/Responses/ErrorResponse.cs b/Postgrest/Responses/ErrorResponse.cs deleted file mode 100644 index a594b0e..0000000 --- a/Postgrest/Responses/ErrorResponse.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Newtonsoft.Json; -using System.Net.Http; - -namespace Postgrest.Responses -{ - /// - /// A representation of Postgrest's API error response. - /// - public class ErrorResponse : BaseResponse - { - public ErrorResponse(ClientOptions clientOptions, HttpResponseMessage? responseMessage, string? content) : base(clientOptions, responseMessage, content) - { } - - [JsonProperty("hint")] - public object? Hint { get; set; } - - [JsonProperty("details")] - public object? Details { get; set; } - - [JsonProperty("code")] - public string? Code { get; set; } - - [JsonProperty("message")] - public string? Message { get; set; } - } -} diff --git a/Postgrest/Responses/ModeledResponse.cs b/Postgrest/Responses/ModeledResponse.cs index c4d0282..f594d03 100644 --- a/Postgrest/Responses/ModeledResponse.cs +++ b/Postgrest/Responses/ModeledResponse.cs @@ -1,12 +1,9 @@ using System; -using System.Collections; using System.Collections.Generic; -using System.Linq; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Postgrest.Extensions; using Postgrest.Models; -using Supabase.Core.Extensions; namespace Postgrest.Responses { @@ -16,13 +13,10 @@ namespace Postgrest.Responses /// public class ModeledResponse : BaseResponse where T : BaseModel, new() { - private JsonSerializerSettings SerializerSettings { get; set; } - - public List Models { get; private set; } = new List(); + public List Models { get; } = new(); public ModeledResponse(BaseResponse baseResponse, JsonSerializerSettings serializerSettings, Func>? getHeaders = null, bool shouldParse = true) : base(baseResponse.ClientOptions, baseResponse.ResponseMessage, baseResponse.Content) { - SerializerSettings = serializerSettings; Content = baseResponse.Content; ResponseMessage = baseResponse.ResponseMessage; @@ -37,14 +31,11 @@ public ModeledResponse(BaseResponse baseResponse, JsonSerializerSettings seriali if (deserialized != null) Models = deserialized; - if (Models != null) + foreach (var model in Models) { - foreach (var model in Models) - { - model.BaseUrl = baseResponse.ResponseMessage!.RequestMessage.RequestUri.GetInstanceUrl().Replace(model.TableName, "").TrimEnd('/'); - model.RequestClientOptions = ClientOptions; - model.GetHeaders = getHeaders; - } + model.BaseUrl = baseResponse.ResponseMessage!.RequestMessage.RequestUri.GetInstanceUrl().Replace(model.TableName, "").TrimEnd('/'); + model.RequestClientOptions = ClientOptions; + model.GetHeaders = getHeaders; } } else if (token is JObject) diff --git a/Postgrest/Table.cs b/Postgrest/Table.cs index cdabdee..c883054 100644 --- a/Postgrest/Table.cs +++ b/Postgrest/Table.cs @@ -9,8 +9,8 @@ using System.Threading.Tasks; using System.Web; using Newtonsoft.Json; -using Newtonsoft.Json.Linq; using Postgrest.Attributes; +using Postgrest.Exceptions; using Postgrest.Extensions; using Postgrest.Interfaces; using Postgrest.Linq; @@ -20,1147 +20,1114 @@ using Supabase.Core.Extensions; using static Postgrest.Constants; +// ReSharper disable InvalidXmlDocComment + namespace Postgrest { - /// - /// Class created from a model derived from `BaseModel` that can generate query requests to a Postgrest Endpoint. - /// - /// Representative of a `USE $TABLE` command. - /// - /// Model derived from `BaseModel`. - public class Table : IPostgrestTable where T : BaseModel, new() - { - public string BaseUrl { get; } - - /// - /// Name of the Table parsed by the Model. - /// - public string TableName { get; } - - /// - /// Function that can be set to return dynamic headers. - /// - /// Headers specified in the constructor will ALWAYS take precendece over headers returned by this function. - /// - public Func>? GetHeaders { get; set; } - - private ClientOptions options; - private JsonSerializerSettings serializerSettings; - - private HttpMethod method = HttpMethod.Get; - - private string? columnQuery; - - private List filters = new List(); - private List orderers = new List(); - private List columns = new List(); - - private Dictionary setData = new Dictionary(); - - private List references = new List(); - - private int rangeFrom = int.MinValue; - private int rangeTo = int.MinValue; - - private int limit = int.MinValue; - private string? limitForeignKey; - - private int offset = int.MinValue; - private string? offsetForeignKey; - - private string? onConflict; - - /// - /// Typically called from the Client `new Client.Table` - /// - /// Api Endpoint (ex: "http://localhost:8000"), no trailing slash required. - /// Optional client configuration. - public Table(string baseUrl, JsonSerializerSettings serializerSettings, ClientOptions? options = null) - { - BaseUrl = baseUrl; - - options ??= new ClientOptions(); - - this.options = options; - - this.serializerSettings = serializerSettings; - - foreach (var property in typeof(T).GetProperties()) - { - var attrs = property.GetCustomAttributes(typeof(ReferenceAttribute), true); - - if (attrs.Length > 0) - references.Add((ReferenceAttribute)attrs.First()); - } - - TableName = FindTableName(); - } - - /// - /// Add a filter to a query request using a predicate to select column. - /// - /// Expects a columns from the Model to be returned - /// Operation to perform. - /// Value to filter with, must be a `string`, `List`, `Dictionary`, `FullTextSearchConfig`, or `Range` - /// - /// - public Table Filter(Expression> predicate, Operator op, object? criterion) - { - var visitor = new SelectExpressionVisitor(); - visitor.Visit(predicate); - - if (visitor.Columns.Count == 0) - throw new ArgumentException("Expected predicate to return a reference to a Model column."); - - if (visitor.Columns.Count > 1) - throw new ArgumentException("Only one column should be returned from the predicate."); - - return Filter(visitor.Columns.First(), op, criterion); - } - - - /// - /// Add a Filter to a query request - /// - /// Column Name in Table. - /// Operation to perform. - /// Value to filter with, must be a `string`, `List`, `Dictionary`, `FullTextSearchConfig`, or `Range` - /// - public Table Filter(string columnName, Operator op, object? criterion) - { - if (criterion == null) - { - switch (op) - { - case Operator.Equals: - case Operator.Is: - filters.Add(new QueryFilter(columnName, Operator.Is, QueryFilter.NullVal)); - break; - case Operator.Not: - case Operator.NotEqual: - filters.Add(new QueryFilter(columnName, Operator.Not, new QueryFilter(columnName, Operator.Is, QueryFilter.NullVal))); - break; - default: - throw new Exception("NOT filters must use the `Equals`, `Is`, `Not` or `NotEqual` operators"); - } - return this; - } - else if (criterion is string stringCriterion) - { - filters.Add(new QueryFilter(columnName, op, stringCriterion)); - return this; - } - else if (criterion is int intCriterion) - { - filters.Add(new QueryFilter(columnName, op, intCriterion)); - return this; - } - else if (criterion is float floatCriterion) - { - filters.Add(new QueryFilter(columnName, op, floatCriterion)); - return this; - } - else if (criterion is List listCriteria) - { - filters.Add(new QueryFilter(columnName, op, listCriteria)); - return this; - } - else if (criterion is Dictionary dictCriteria) - { - filters.Add(new QueryFilter(columnName, op, dictCriteria)); - return this; - } - else if (criterion is IntRange rangeCriteria) - { - filters.Add(new QueryFilter(columnName, op, rangeCriteria)); - return this; - } - else if (criterion is FullTextSearchConfig fullTextSearchCriteria) - { - filters.Add(new QueryFilter(columnName, op, fullTextSearchCriteria)); - return this; - } - - throw new Exception("Unknown criterion type, is it of type `string`, `int`, `float`, `List`, `Dictionary`, `FullTextSearchConfig`, or `Range`?"); - } - - /// - /// Adds a NOT filter to the current query args. - /// - /// - /// - public Table Not(QueryFilter filter) - { - filters.Add(new QueryFilter(Operator.Not, filter)); - return this; - } - - /// - /// Adds a NOT filter to the current query args. - /// - /// Allows queries like: - /// - /// await client.Table().Not("status", Operators.Equal, "OFFLINE").Get(); - /// - /// - /// - /// - /// - /// - public Table Not(string columnName, Operator op, string criterion) => Not(new QueryFilter(columnName, op, criterion)); - - /// - /// Adds a NOT filter to the current query args. - /// Allows queries like: - /// - /// await client.Table().Not("status", Operators.In, new List {"AWAY", "OFFLINE"}).Get(); - /// - /// - /// - /// - /// - /// - public Table Not(string columnName, Operator op, List criteria) => Not(new QueryFilter(columnName, op, criteria)); - - /// - /// Adds a NOT filter to the current query args. - /// - /// - /// - /// - /// - public Table Not(string columnName, Operator op, Dictionary criteria) => Not(new QueryFilter(columnName, op, criteria)); - - /// - /// Adds an AND Filter to the current query args. - /// - /// - /// - public Table And(List filters) - { - this.filters.Add(new QueryFilter(Operator.And, filters)); - return this; - } - - /// - /// Adds a NOT Filter to the current query args. - /// - /// - /// - public Table Or(List filters) - { - this.filters.Add(new QueryFilter(Operator.Or, filters)); - return this; - } - - /// - /// Fills in query parameters based on a given model's primary key(s). - /// - /// A model with a primary key column - /// - public Table Match(T model) - { - foreach (var kvp in model.PrimaryKey) - { - filters.Add(new QueryFilter(kvp.Key.ColumnName, Operator.Equals, kvp.Value)); - } - - return this; - } - - /// - /// Finds all rows whose columns match the specified `query` object. - /// - /// The object to filter with, with column names as keys mapped to their filter values. - /// - public Table Match(Dictionary query) - { - foreach (var param in query) - { - filters.Add(new QueryFilter(param.Key, Operator.Equals, param.Value)); - } - - return this; - } - - /// - /// Adds an ordering to the current query args using a predicate function. - /// - /// NOTE: If multiple orderings are required, chain this function with another call to . - /// - /// - /// >Expects a columns from the Model to be returned - /// - /// - public Table Order(Expression> predicate, Ordering ordering, NullPosition nullPosition = NullPosition.First) - { - var visitor = new SelectExpressionVisitor(); - visitor.Visit(predicate); - - if (visitor.Columns.Count == 0) - throw new ArgumentException("Expected predicate to return a reference to a Model column."); - - if (visitor.Columns.Count > 1) - throw new ArgumentException("Only one column should be returned from the predicate."); - - return Order(visitor.Columns.First(), ordering, nullPosition); - } - - /// - /// Adds an ordering to the current query args. - /// - /// NOTE: If multiple orderings are required, chain this function with another call to . - /// - /// Column Name - /// - /// - /// - public Table Order(string column, Ordering ordering, NullPosition nullPosition = NullPosition.First) - { - orderers.Add(new QueryOrderer(null, column, ordering, nullPosition)); - return this; - } - - /// - /// Adds an ordering to the current query args. - /// - /// NOTE: If multiple orderings are required, chain this function with another call to . - /// - /// - /// - /// - /// - /// - public Table Order(string foreignTable, string column, Ordering ordering, NullPosition nullPosition = NullPosition.First) - { - orderers.Add(new QueryOrderer(foreignTable, column, ordering, nullPosition)); - return this; - } - - /// - /// Sets a FROM range, similar to a `StartAt` query. - /// - /// - /// - public Table Range(int from) - { - rangeFrom = from; - return this; - } - - /// - /// Sets a bounded range to the current query. - /// - /// - /// - /// - public Table Range(int from, int to) - { - rangeFrom = from; - rangeTo = to; - return this; - } - - /// - /// Select columns for query. - /// - /// - /// - public Table Select(string columnQuery) - { - method = HttpMethod.Get; - this.columnQuery = columnQuery; - return this; - } - - /// - /// Select columns using a predicate function. - /// - /// For example: - /// `Table().Select(x => new[] { x.Id, x.Name, x.CreatedAt }).Get();` - /// - /// Expects an array of columns from the Model to be returned. - /// - public Table Select(Expression> predicate) - { - var visitor = new SelectExpressionVisitor(); - visitor.Visit(predicate); - - if (visitor.Columns.Count == 0) - throw new ArgumentException("Unable to find column(s) to select from the given predicate, did you return an array of Model Properties?"); - - return Select(string.Join(",", visitor.Columns)); - } - - /// - /// Filter a query based on a predicate function. - /// - /// Note: Chaining multiple calls will - /// be parsed as an "AND" query. - /// - /// Examples: - /// `Table().Where(x => x.Name == "Top Gun").Get();` - /// `Table().Where(x => x.Name == "Top Gun" || x.Name == "Mad Max").Get();` - /// `Table().Where(x => x.Name.Contains("Gun")).Get();` - /// `Table().Where(x => x.CreatedAt <= new DateTime(2022, 08, 21)).Get();` - /// `Table().Where(x => x.Id > 5 && x.Name.Contains("Max")).Get();` - /// - /// - /// - public Table Where(Expression> predicate) - { - var visitor = new WhereExpressionVisitor(); - visitor.Visit(predicate); - - if (visitor.Filter == null) - throw new ArgumentException("Unable to parse the supplied predicate, did you return a predicate where each left hand of the condition is a Model property?"); - - if (visitor.Filter.Op == Operator.Equals && visitor.Filter.Criteria == null) - filters.Add(new QueryFilter(visitor.Filter.Property!, Operator.Is, QueryFilter.NullVal)); - else if (visitor.Filter.Op == Operator.NotEqual && visitor.Filter.Criteria == null) - filters.Add(new QueryFilter(visitor.Filter.Property!, Operator.Not, new QueryFilter(visitor.Filter.Property!, Operator.Is, QueryFilter.NullVal))); - else - filters.Add(visitor.Filter); - - - return this; - } - - - /// - /// Sets a limit with an optional foreign table reference. - /// - /// - /// - /// - public Table Limit(int limit, string? foreignTableName = null) - { - this.limit = limit; - this.limitForeignKey = foreignTableName; - return this; - } - - /// - /// By specifying the onConflict query parameter, you can make UPSERT work on a column(s) that has a UNIQUE constraint. - /// - /// - /// - public Table OnConflict(string columnName) - { - onConflict = columnName; - return this; - } - - /// - /// Set an onConflict query parameter for UPSERTing on a column that has a UNIQUE constraint using a linq predicate. - /// - /// Expects a column from the model to be returned. - /// - public Table OnConflict(Expression> predicate) - { - var visitor = new SelectExpressionVisitor(); - visitor.Visit(predicate); - - if (visitor.Columns.Count == 0) - throw new ArgumentException("Expected predicate to return a reference to a Model column."); - - if (visitor.Columns.Count > 1) - throw new ArgumentException("Only one column should be returned from the predicate."); - - OnConflict(visitor.Columns.First()); - - return this; - } - - /// - /// By using the columns query parameter it’s possible to specify the payload keys that will be inserted and ignore the rest of the payload. - /// - /// The rest of the JSON keys will be ignored. - /// Using this also has the side-effect of being more efficient for Bulk Insert since PostgREST will not process the JSON and it’ll send it directly to PostgreSQL. - /// - /// See: https://postgrest.org/en/stable/api.html#specifying-columns - /// - /// - /// - public Table Columns(string[] columns) - { - foreach (var column in columns) - this.columns.Add(column); - - return this; - } - - /// - /// By using the columns query parameter it’s possible to specify the payload keys that will be inserted and ignore the rest of the payload. - /// - /// The rest of the JSON keys will be ignored. - /// Using this also has the side-effect of being more efficient for Bulk Insert since PostgREST will not process the JSON and it’ll send it directly to PostgreSQL. - /// - /// See: https://postgrest.org/en/stable/api.html#specifying-columns - /// - /// - /// - public Table Columns(Expression> predicate) - { - var visitor = new SelectExpressionVisitor(); - visitor.Visit(predicate); - - if (visitor.Columns.Count == 0) - throw new ArgumentException("Expected predicate to return an array of references to a Model column."); - - return Columns(visitor.Columns.ToArray()); - } - - /// - /// Sets an offset with an optional foreign table reference. - /// - /// - /// - /// - public Table Offset(int offset, string? foreignTableName = null) - { - this.offset = offset; - this.offsetForeignKey = foreignTableName; - return this; - } - - /// - /// Executes an INSERT query using the defined query params on the current instance. - /// - /// - /// - /// - /// A typed model response from the database. - public Task> Insert(T model, QueryOptions? options = null, CancellationToken cancellationToken = default) => PerformInsert(model, options, cancellationToken); - - /// - /// Executes a BULK INSERT query using the defined query params on the current instance. - /// - /// - /// - /// - /// A typed model response from the database. - public Task> Insert(ICollection models, QueryOptions? options = null, CancellationToken cancellationToken = default) => PerformInsert(models, options, cancellationToken); - - /// - /// Executes an UPSERT query using the defined query params on the current instance. - /// - /// By default the new record is returned. Set QueryOptions.ReturnType to Minimal if you don't need this value. - /// By specifying the QueryOptions.OnConflict parameter, you can make UPSERT work on a column(s) that has a UNIQUE constraint. - /// QueryOptions.DuplicateResolution.IgnoreDuplicates Specifies if duplicate rows should be ignored and not inserted. - /// - /// - /// - /// - /// - public Task> Upsert(T model, QueryOptions? options = null, CancellationToken cancellationToken = default) - { - if (options == null) - { - options = new QueryOptions(); - } - - // Enforce Upsert - options.Upsert = true; - - return PerformInsert(model, options, cancellationToken); - } - - /// - /// Executes an UPSERT query using the defined query params on the current instance. - /// - /// By default the new record is returned. Set QueryOptions.ReturnType to Minimal if you don't need this value. - /// By specifying the QueryOptions.OnConflict parameter, you can make UPSERT work on a column(s) that has a UNIQUE constraint. - /// QueryOptions.DuplicateResolution.IgnoreDuplicates Specifies if duplicate rows should be ignored and not inserted. - /// - /// - /// - /// - /// - public Task> Upsert(ICollection model, QueryOptions? options = null, CancellationToken cancellationToken = default) - { - if (options == null) - { - options = new QueryOptions(); - } - - // Enforce Upsert - options.Upsert = true; - - return PerformInsert(model, options, cancellationToken); - } - - - /// - /// Specifies a key and value to be updated. Should be combined with filters/where clauses. - /// - /// Can be called multiple times to set multiple values. - /// - /// - /// - /// - public Table Set(Expression> keySelector, object? value) - { - var visitor = new SetExpressionVisitor(); - visitor.Visit(keySelector); - - if (visitor.Column == null || visitor.ExpectedType == null) - throw new ArgumentException("Expression should return a KeyValuePair with a key of a Model Property and a value."); - - if (value == null) - { - if (Nullable.GetUnderlyingType(visitor.ExpectedType) == null) - throw new ArgumentException(string.Format("Expected Value to be of Type: {0}, instead received: {1}.", visitor.ExpectedType.Name, null)); - } - else if (!visitor.ExpectedType.IsAssignableFrom(value.GetType())) - { - throw new ArgumentException(string.Format("Expected Value to be of Type: {0}, instead received: {1}.", visitor.ExpectedType.Name, value.GetType().Name)); - } - - setData.Add(visitor.Column, value); - - return this; - } - - /// - /// Specifies a KeyValuePair to be updated. Should be combined with filters/where clauses. - /// - /// Can be called multiple times to set multiple values. - /// - /// - /// - /// - public Table Set(Expression>> keyValuePairExpression) - { - var visitor = new SetExpressionVisitor(); - visitor.Visit(keyValuePairExpression); - - if (visitor.Column == null || visitor.Value == default) - throw new ArgumentException("Expression should return a KeyValuePair with a key of a Model Property and a value."); - - setData.Add(visitor.Column, visitor.Value); - - return this; - } - - /// - /// Calls an Update function after `Set` has been called. - /// - /// - /// - /// - /// - public Task> Update(QueryOptions? options = null, CancellationToken cancellationToken = default) - { - if (options == null) - { - options = new QueryOptions(); - } - - if (setData.Keys.Count == 0) - throw new ArgumentException("No data has been set to update, was `Set` called?"); - - method = new HttpMethod("PATCH"); - - var request = Send(method, setData, options.ToHeaders(), cancellationToken, isUpdate: true); - - Clear(); - - return request; - - } - - /// - /// Executes an UPDATE query using the defined query params on the current instance. - /// - /// - /// - /// A typed response from the database. - public Task> Update(T model, QueryOptions? options = null, CancellationToken cancellationToken = default) - { - if (options == null) - { - options = new QueryOptions(); - } - - method = new HttpMethod("PATCH"); - - Match(model); - - var request = Send(method, model, options.ToHeaders(), cancellationToken, isUpdate: true); - - Clear(); - - return request; - } - - /// - /// Executes a delete request using the defined query params on the current instance. - /// - /// - public Task Delete(QueryOptions? options = null, CancellationToken cancellationToken = default) - { - if (options == null) - { - options = new QueryOptions(); - } - - method = HttpMethod.Delete; - - var request = Send(method, null, options.ToHeaders(), cancellationToken); - - Clear(); - - return request; - } - - /// - /// Executes a delete request using the model's primary key as the filter for the request. - /// - /// - /// - /// - public Task> Delete(T model, QueryOptions? options = null, CancellationToken cancellationToken = default) - { - if (options == null) - { - options = new QueryOptions(); - } - - method = HttpMethod.Delete; - - Match(model); - - var request = Send(method, null, options.ToHeaders(), cancellationToken); - Clear(); - return request; - } - - /// - /// Returns ONLY a count from the specified query. - /// - /// See: https://postgrest.org/en/v7.0.0/api.html?highlight=count - /// - /// - /// - /// - public Task Count(CountType type, CancellationToken cancellationToken = default) - { - var tsc = new TaskCompletionSource(); - - Task.Run(async () => - { - method = HttpMethod.Head; - - var attr = type.GetAttribute(); - - var headers = new Dictionary - { - { "Prefer", $"count={attr?.Mapping}" } - }; - - var request = Send(method, null, headers, cancellationToken); - Clear(); - - try - { - var response = await request; - var countStr = response.ResponseMessage?.Content.Headers.GetValues("Content-Range").FirstOrDefault(); - if (!string.IsNullOrEmpty(countStr) && countStr!.Contains("/")) - { - // Returns X-Y/COUNT [0-3/4] - tsc.SetResult(int.Parse(countStr.Split('/')[1])); - } - tsc.SetException(new Exception("Failed to parse response.")); - } - catch (Exception ex) - { - tsc.SetException(ex); - } - }); - - return tsc.Task; - } - - /// - /// Executes a query that expects to have a single object returned, rather than returning list of models - /// it will return a single model. - /// - /// - /// - public Task Single(CancellationToken cancellationToken = default) - { - var tsc = new TaskCompletionSource(); - - Task.Run(async () => - { - method = HttpMethod.Get; - var headers = new Dictionary - { - { "Accept", "application/vnd.pgrst.object+json" }, - { "Prefer", "return=representation"} - }; - - var request = Send(method, null, headers, cancellationToken); - - Clear(); - - try - { - var result = await request; - tsc.SetResult(result.Models.FirstOrDefault()); - } - catch (RequestException e) - { - // No rows returned - if (e.Response.StatusCode == System.Net.HttpStatusCode.NotAcceptable) - tsc.SetResult(null); - else - tsc.SetException(e); - } - catch (Exception e) - { - tsc.SetException(e); - } - }); - - return tsc.Task; - } - - /// - /// Executes the query using the defined filters on the current instance. - /// - /// - /// - public Task> Get(CancellationToken cancellationToken = default) - { - var request = Send(method, null, null, cancellationToken); - Clear(); - return request; - } - - /// - /// Generates the encoded URL with defined query parameters that will be sent to the Postgrest API. - /// - /// - public string GenerateUrl() - { - var builder = new UriBuilder($"{BaseUrl}/{TableName}"); - var query = HttpUtility.ParseQueryString(builder.Query); - - foreach (var param in options.QueryParams) - { - query.Add(param.Key, param.Value); - } - - if (options.Headers.ContainsKey("apikey")) - { - query.Add("apikey", options.Headers["apikey"]); - } - - if (columns.Count > 0) - { - query["columns"] = string.Join(",", columns); - } - - foreach (var filter in filters) - { - var parsedFilter = PrepareFilter(filter); - query.Add(parsedFilter.Key, parsedFilter.Value); - } - - foreach (var orderer in orderers) - { - var nullPosAttr = orderer.NullPosition.GetAttribute(); - var orderingAttr = orderer.Ordering.GetAttribute(); - if (nullPosAttr is MapToAttribute nullPosAsAttribute && orderingAttr is MapToAttribute orderingAsAttribute) - { - var key = !string.IsNullOrEmpty(orderer.ForeignTable) ? $"{orderer.ForeignTable}.order" : "order"; - query.Add(key, $"{orderer.Column}.{orderingAsAttribute.Mapping}.{nullPosAsAttribute.Mapping}"); - } - } - - if (!string.IsNullOrEmpty(columnQuery)) - { - query["select"] = Regex.Replace(columnQuery, @"\s", ""); - } - - if (references.Count > 0) - { - if (query["select"] == null) - query["select"] = "*"; - - foreach (var reference in references) - { - if (reference.IncludeInQuery) - { - var columns = string.Join(",", reference.Columns.ToArray()); - - if (reference.ShouldFilterTopLevel) - { - query["select"] = query["select"] + $",{reference.TableName}!inner({columns})"; - } - else - { - query["select"] = query["select"] + $",{reference.TableName}({columns})"; - } - } - } - } - - if (!string.IsNullOrEmpty(onConflict)) - { - query["on_conflict"] = onConflict; - } - - if (limit != int.MinValue) - { - var key = limitForeignKey != null ? $"{limitForeignKey}.limit" : "limit"; - query[key] = limit.ToString(); - } - - if (offset != int.MinValue) - { - var key = offsetForeignKey != null ? $"{offsetForeignKey}.offset" : "offset"; - query[key] = offset.ToString(); - } - - builder.Query = query.ToString(); - return builder.Uri.ToString(); - } - - /// - /// Transforms an object into a string mapped list/dictionary using `JsonSerializerSettings`. - /// - /// - /// - internal object? PrepareRequestData(object? data, bool isInsert = false, bool isUpdate = false) - { - if (data == null) return new Dictionary(); - - PostgrestContractResolver? resolver = (PostgrestContractResolver)serializerSettings.ContractResolver!; // Specified in constructor; - - resolver.SetState(isInsert, isUpdate); - - var serialized = JsonConvert.SerializeObject(data, serializerSettings); - - resolver.SetState(); - - // Check if data is a Collection for the Insert Bulk case - if (data is ICollection) - { - return JsonConvert.DeserializeObject>(serialized); - } - else - { - return JsonConvert.DeserializeObject>(serialized, serializerSettings); - } - } - - /// - /// Transforms the defined filters into the expected Postgrest format. - /// - /// See: http://postgrest.org/en/v7.0.0/api.html#operators - /// - /// - /// - internal KeyValuePair PrepareFilter(QueryFilter filter) - { - var attr = filter.Op.GetAttribute(); - if (attr is MapToAttribute asAttribute) - { - var strBuilder = new StringBuilder(); - switch (filter.Op) - { - case Operator.Or: - case Operator.And: - if (filter.Criteria is List subFilters) - { - var list = new List>(); - foreach (var subFilter in subFilters) - list.Add(PrepareFilter(subFilter)); - - foreach (var preppedFilter in list) - strBuilder.Append($"{preppedFilter.Key}.{preppedFilter.Value},"); - - return new KeyValuePair(asAttribute.Mapping, $"({strBuilder.ToString().Trim(',')})"); - } - break; - case Operator.Not: - if (filter.Criteria is QueryFilter notFilter) - { - var prepped = PrepareFilter(notFilter); - return new KeyValuePair(prepped.Key, $"not.{prepped.Value}"); - } - break; - case Operator.Like: - case Operator.ILike: - if (filter.Criteria is string likeCriteria && filter.Property != null) - { - return new KeyValuePair(filter.Property, $"{asAttribute.Mapping}.{likeCriteria.Replace("%", "*")}"); - } - break; - case Operator.In: - if (filter.Criteria is List inCriteria && filter.Property != null) - { - foreach (var item in inCriteria) - strBuilder.Append($"\"{item}\","); - - return new KeyValuePair(filter.Property, $"{asAttribute.Mapping}.({strBuilder.ToString().Trim(',')})"); - } - else if (filter.Criteria is Dictionary dictCriteria && filter.Property != null) - { - return new KeyValuePair(filter.Property, $"{asAttribute.Mapping}.{JsonConvert.SerializeObject(dictCriteria)}"); - } - break; - case Operator.Contains: - case Operator.ContainedIn: - case Operator.Overlap: - if (filter.Criteria is List listCriteria && filter.Property != null) - { - foreach (var item in listCriteria) - strBuilder.Append($"{item},"); - - return new KeyValuePair(filter.Property, $"{asAttribute.Mapping}.{{{strBuilder.ToString().Trim(',')}}}"); - } - else if (filter.Criteria is Dictionary dictCriteria && filter.Property != null) - { - return new KeyValuePair(filter.Property, $"{asAttribute.Mapping}.{JsonConvert.SerializeObject(dictCriteria)}"); - } - else if (filter.Criteria is IntRange rangeCriteria && filter.Property != null) - { - return new KeyValuePair(filter.Property, $"{asAttribute.Mapping}.{rangeCriteria.ToPostgresString()}"); - } - break; - case Operator.StrictlyLeft: - case Operator.StrictlyRight: - case Operator.NotRightOf: - case Operator.NotLeftOf: - case Operator.Adjacent: - if (filter.Criteria is IntRange rangeCritera && filter.Property != null) - { - return new KeyValuePair(filter.Property, $"{asAttribute.Mapping}.{rangeCritera.ToPostgresString()}"); - } - break; - case Operator.FTS: - case Operator.PHFTS: - case Operator.PLFTS: - case Operator.WFTS: - if (filter.Criteria is FullTextSearchConfig searchConfig && filter.Property != null) - { - return new KeyValuePair(filter.Property, $"{asAttribute.Mapping}({searchConfig.Config}).{searchConfig.QueryText}"); - } - break; - default: - return new KeyValuePair(filter.Property ?? "", $"{asAttribute.Mapping}.{filter.Criteria}"); - } - } - return new KeyValuePair(); - } - - /// - /// Clears currently defined query values. - /// - public void Clear() - { - columnQuery = null; - - filters.Clear(); - orderers.Clear(); - columns.Clear(); - setData.Clear(); - - rangeFrom = int.MinValue; - rangeTo = int.MinValue; - - limit = int.MinValue; - limitForeignKey = null; - - offset = int.MinValue; - offsetForeignKey = null; - - onConflict = null; - } - - - /// - /// Performs an INSERT Request. - /// - /// - /// - /// - /// - private Task> PerformInsert(object data, QueryOptions? options = null, CancellationToken cancellationToken = default) - { - method = HttpMethod.Post; - if (options == null) - options = new QueryOptions(); - - if (!string.IsNullOrEmpty(options.OnConflict)) - { - OnConflict(options.OnConflict!); - } - - var request = Send(method, data, options.ToHeaders(), cancellationToken, isInsert: true); - - Clear(); - - return request; - } - - private Task Send(HttpMethod method, object? data, Dictionary? headers = null, CancellationToken cancellationToken = default, bool isInsert = false, bool isUpdate = false) - { - var requestHeaders = Helpers.PrepareRequestHeaders(method, headers, options, rangeFrom, rangeTo); - - if (GetHeaders != null) - { - requestHeaders = GetHeaders().MergeLeft(requestHeaders); - } - - var preparedData = PrepareRequestData(data, isInsert, isUpdate); - return Helpers.MakeRequest(options, method, GenerateUrl(), serializerSettings, preparedData, requestHeaders, cancellationToken); - } - - private Task> Send(HttpMethod method, object? data, Dictionary? headers = null, CancellationToken cancellationToken = default, bool isInsert = false, bool isUpdate = false) where U : BaseModel, new() - { - var requestHeaders = Helpers.PrepareRequestHeaders(method, headers, options, rangeFrom, rangeTo); - - if (GetHeaders != null) - { - requestHeaders = GetHeaders().MergeLeft(requestHeaders); - } - - var preparedData = PrepareRequestData(data, isInsert, isUpdate); - return Helpers.MakeRequest(options, method, GenerateUrl(), serializerSettings, preparedData, requestHeaders, GetHeaders, cancellationToken); - } - - private string? FindColumnName(string propertyName) - { - var property = typeof(T).GetProperty(propertyName); - var attrs = property.GetCustomAttributes(true); - - foreach (var attr in attrs) - { - if (attr is ColumnAttribute columnAttribute) - return columnAttribute.ColumnName; - else if (attr is PrimaryKeyAttribute keyAttribute) - return keyAttribute.ColumnName; - } - - return null; - } - - internal static string FindTableName(object? obj = null) - { - var type = obj == null ? typeof(T) : obj is Type t ? t : obj.GetType(); - var attr = Attribute.GetCustomAttribute(type, typeof(TableAttribute)); - - if (attr is TableAttribute tableAttr) - { - return tableAttr.Name; - } - - return type.Name; - } - } -} + /// + /// Class created from a model derived from `BaseModel` that can generate query requests to a Postgrest Endpoint. + /// + /// Representative of a `USE $TABLE` command. + /// + /// Model derived from `BaseModel`. + public class Table : IPostgrestTable where T : BaseModel, new() + { + public string BaseUrl { get; } + + /// + /// Name of the Table parsed by the Model. + /// + public string TableName { get; } + + /// + /// Function that can be set to return dynamic headers. + /// + /// Headers specified in the constructor will ALWAYS take precedence over headers returned by this function. + /// + public Func>? GetHeaders { get; set; } + + private readonly ClientOptions _options; + private readonly JsonSerializerSettings _serializerSettings; + + private HttpMethod _method = HttpMethod.Get; + + #region Pending Query State + + private string? _columnQuery; + + private readonly List _filters = new(); + private readonly List _orderers = new(); + private readonly List _columns = new(); + + private readonly Dictionary _setData = new(); + + private readonly List _references = new(); + + private int _rangeFrom = int.MinValue; + private int _rangeTo = int.MinValue; + + private int _limit = int.MinValue; + private string? _limitForeignKey; + + private int _offset = int.MinValue; + private string? _offsetForeignKey; + + private string? _onConflict; + + #endregion + + /// + /// Typically called from the Client `new Client.Table` + /// + /// Api Endpoint (ex: "http://localhost:8000"), no trailing slash required. + /// Optional client configuration. + public Table(string baseUrl, JsonSerializerSettings serializerSettings, ClientOptions? options = null) + { + BaseUrl = baseUrl; + + _options = options ?? new ClientOptions(); + _serializerSettings = serializerSettings; + + foreach (var property in typeof(T).GetProperties()) + { + var attrs = property.GetCustomAttributes(typeof(ReferenceAttribute), true); + + if (attrs.Length > 0) + _references.Add((ReferenceAttribute)attrs.First()); + } + + TableName = FindTableName(); + } + + /// + /// Add a filter to a query request using a predicate to select column. + /// + /// Expects a columns from the Model to be returned + /// Operation to perform. + /// Value to filter with, must be a `string`, `List`, `Dictionary`, `FullTextSearchConfig`, or `Range` + /// + /// + public Table Filter(Expression> predicate, Operator op, object? criterion) + { + var visitor = new SelectExpressionVisitor(); + visitor.Visit(predicate); + + if (visitor.Columns.Count == 0) + throw new ArgumentException("Expected predicate to return a reference to a Model column."); + + if (visitor.Columns.Count > 1) + throw new ArgumentException("Only one column should be returned from the predicate."); + + return Filter(visitor.Columns.First(), op, criterion); + } + + + /// + /// Add a Filter to a query request + /// + /// Column Name in Table. + /// Operation to perform. + /// Value to filter with, must be a `string`, `List`, `Dictionary`, `FullTextSearchConfig`, or `Range` + /// + public Table Filter(string columnName, Operator op, object? criterion) + { + if (criterion == null) + { + switch (op) + { + case Operator.Equals: + case Operator.Is: + _filters.Add(new QueryFilter(columnName, Operator.Is, QueryFilter.NullVal)); + break; + case Operator.Not: + case Operator.NotEqual: + _filters.Add(new QueryFilter(columnName, Operator.Not, + new QueryFilter(columnName, Operator.Is, QueryFilter.NullVal))); + break; + default: + throw new Exception("NOT filters must use the `Equals`, `Is`, `Not` or `NotEqual` operators"); + } + + return this; + } + else if (criterion is string stringCriterion) + { + _filters.Add(new QueryFilter(columnName, op, stringCriterion)); + return this; + } + else if (criterion is int intCriterion) + { + _filters.Add(new QueryFilter(columnName, op, intCriterion)); + return this; + } + else if (criterion is float floatCriterion) + { + _filters.Add(new QueryFilter(columnName, op, floatCriterion)); + return this; + } + else if (criterion is List listCriteria) + { + _filters.Add(new QueryFilter(columnName, op, listCriteria)); + return this; + } + else if (criterion is Dictionary dictCriteria) + { + _filters.Add(new QueryFilter(columnName, op, dictCriteria)); + return this; + } + else if (criterion is IntRange rangeCriteria) + { + _filters.Add(new QueryFilter(columnName, op, rangeCriteria)); + return this; + } + else if (criterion is FullTextSearchConfig fullTextSearchCriteria) + { + _filters.Add(new QueryFilter(columnName, op, fullTextSearchCriteria)); + return this; + } + + throw new Exception( + "Unknown criterion type, is it of type `string`, `int`, `float`, `List`, `Dictionary`, `FullTextSearchConfig`, or `Range`?"); + } + + /// + /// Adds a NOT filter to the current query args. + /// + /// + /// + public Table Not(QueryFilter filter) + { + _filters.Add(new QueryFilter(Operator.Not, filter)); + return this; + } + + /// + /// Adds a NOT filter to the current query args. + /// + /// Allows queries like: + /// + /// await client.Table().Not("status", Operators.Equal, "OFFLINE").Get(); + /// + /// + /// + /// + /// + /// + public Table Not(string columnName, Operator op, string criterion) => + Not(new QueryFilter(columnName, op, criterion)); + + /// + /// Adds a NOT filter to the current query args. + /// Allows queries like: + /// + /// await client.Table().Not("status", Operators.In, new List {"AWAY", "OFFLINE"}).Get(); + /// + /// + /// + /// + /// + /// + public Table Not(string columnName, Operator op, List criteria) => + Not(new QueryFilter(columnName, op, criteria)); + + /// + /// Adds a NOT filter to the current query args. + /// + /// + /// + /// + /// + public Table Not(string columnName, Operator op, Dictionary criteria) => + Not(new QueryFilter(columnName, op, criteria)); + + /// + /// Adds an AND Filter to the current query args. + /// + /// + /// + public Table And(List filters) + { + this._filters.Add(new QueryFilter(Operator.And, filters)); + return this; + } + + /// + /// Adds a NOT Filter to the current query args. + /// + /// + /// + public Table Or(List filters) + { + this._filters.Add(new QueryFilter(Operator.Or, filters)); + return this; + } + + /// + /// Fills in query parameters based on a given model's primary key(s). + /// + /// A model with a primary key column + /// + public Table Match(T model) + { + foreach (var kvp in model.PrimaryKey) + { + _filters.Add(new QueryFilter(kvp.Key.ColumnName, Operator.Equals, kvp.Value)); + } + + return this; + } + + /// + /// Finds all rows whose columns match the specified `query` object. + /// + /// The object to filter with, with column names as keys mapped to their filter values. + /// + public Table Match(Dictionary query) + { + foreach (var param in query) + { + _filters.Add(new QueryFilter(param.Key, Operator.Equals, param.Value)); + } + + return this; + } + + /// + /// Adds an ordering to the current query args using a predicate function. + /// + /// NOTE: If multiple orderings are required, chain this function with another call to . + /// + /// + /// >Expects a columns from the Model to be returned + /// + /// + public Table Order(Expression> predicate, Ordering ordering, + NullPosition nullPosition = NullPosition.First) + { + var visitor = new SelectExpressionVisitor(); + visitor.Visit(predicate); + + if (visitor.Columns.Count == 0) + throw new ArgumentException("Expected predicate to return a reference to a Model column."); + + if (visitor.Columns.Count > 1) + throw new ArgumentException("Only one column should be returned from the predicate."); + + return Order(visitor.Columns.First(), ordering, nullPosition); + } + + /// + /// Adds an ordering to the current query args. + /// + /// NOTE: If multiple orderings are required, chain this function with another call to . + /// + /// Column Name + /// + /// + /// + public Table Order(string column, Ordering ordering, NullPosition nullPosition = NullPosition.First) + { + _orderers.Add(new QueryOrderer(null, column, ordering, nullPosition)); + return this; + } + + /// + /// Adds an ordering to the current query args. + /// + /// NOTE: If multiple orderings are required, chain this function with another call to . + /// + /// + /// + /// + /// + /// + public Table Order(string foreignTable, string column, Ordering ordering, + NullPosition nullPosition = NullPosition.First) + { + _orderers.Add(new QueryOrderer(foreignTable, column, ordering, nullPosition)); + return this; + } + + /// + /// Sets a FROM range, similar to a `StartAt` query. + /// + /// + /// + public Table Range(int from) + { + _rangeFrom = from; + return this; + } + + /// + /// Sets a bounded range to the current query. + /// + /// + /// + /// + public Table Range(int from, int to) + { + _rangeFrom = from; + _rangeTo = to; + return this; + } + + /// + /// Select columns for query. + /// + /// + /// + public Table Select(string columnQuery) + { + _method = HttpMethod.Get; + this._columnQuery = columnQuery; + return this; + } + + /// + /// Select columns using a predicate function. + /// + /// For example: + /// `Table().Select(x => new[] { x.Id, x.Name, x.CreatedAt }).Get();` + /// + /// Expects an array of columns from the Model to be returned. + /// + public Table Select(Expression> predicate) + { + var visitor = new SelectExpressionVisitor(); + visitor.Visit(predicate); + + if (visitor.Columns.Count == 0) + throw new ArgumentException( + "Unable to find column(s) to select from the given predicate, did you return an array of Model Properties?"); + + return Select(string.Join(",", visitor.Columns)); + } + + /// + /// Filter a query based on a predicate function. + /// + /// Note: Chaining multiple calls will + /// be parsed as an "AND" query. + /// + /// Examples: + /// `Table().Where(x => x.Name == "Top Gun").Get();` + /// `Table().Where(x => x.Name == "Top Gun" || x.Name == "Mad Max").Get();` + /// `Table().Where(x => x.Name.Contains("Gun")).Get();` + /// `Table().Where(x => x.CreatedAt <= new DateTime(2022, 08, 21)).Get();` + /// `Table().Where(x => x.Id > 5 && x.Name.Contains("Max")).Get();` + /// + /// + /// + public Table Where(Expression> predicate) + { + var visitor = new WhereExpressionVisitor(); + visitor.Visit(predicate); + + if (visitor.Filter == null) + throw new ArgumentException( + "Unable to parse the supplied predicate, did you return a predicate where each left hand of the condition is a Model property?"); + + if (visitor.Filter.Op == Operator.Equals && visitor.Filter.Criteria == null) + _filters.Add(new QueryFilter(visitor.Filter.Property!, Operator.Is, QueryFilter.NullVal)); + else if (visitor.Filter.Op == Operator.NotEqual && visitor.Filter.Criteria == null) + _filters.Add(new QueryFilter(visitor.Filter.Property!, Operator.Not, + new QueryFilter(visitor.Filter.Property!, Operator.Is, QueryFilter.NullVal))); + else + _filters.Add(visitor.Filter); + + + return this; + } + + + /// + /// Sets a limit with an optional foreign table reference. + /// + /// + /// + /// + public Table Limit(int limit, string? foreignTableName = null) + { + this._limit = limit; + this._limitForeignKey = foreignTableName; + return this; + } + + /// + /// By specifying the onConflict query parameter, you can make UPSERT work on a column(s) that has a UNIQUE constraint. + /// + /// + /// + public Table OnConflict(string columnName) + { + _onConflict = columnName; + return this; + } + + /// + /// Set an onConflict query parameter for UPSERTing on a column that has a UNIQUE constraint using a linq predicate. + /// + /// Expects a column from the model to be returned. + /// + public Table OnConflict(Expression> predicate) + { + var visitor = new SelectExpressionVisitor(); + visitor.Visit(predicate); + + if (visitor.Columns.Count == 0) + throw new ArgumentException("Expected predicate to return a reference to a Model column."); + + if (visitor.Columns.Count > 1) + throw new ArgumentException("Only one column should be returned from the predicate."); + + OnConflict(visitor.Columns.First()); + + return this; + } + + /// + /// By using the columns query parameter it’s possible to specify the payload keys that will be inserted and ignore the rest of the payload. + /// + /// The rest of the JSON keys will be ignored. + /// Using this also has the side-effect of being more efficient for Bulk Insert since PostgREST will not process the JSON and it’ll send it directly to PostgreSQL. + /// + /// See: https://postgrest.org/en/stable/api.html#specifying-columns + /// + /// + /// + public Table Columns(string[] columns) + { + foreach (var column in columns) + this._columns.Add(column); + + return this; + } + + /// + /// By using the columns query parameter it’s possible to specify the payload keys that will be inserted and ignore the rest of the payload. + /// + /// The rest of the JSON keys will be ignored. + /// Using this also has the side-effect of being more efficient for Bulk Insert since PostgREST will not process the JSON and it’ll send it directly to PostgreSQL. + /// + /// See: https://postgrest.org/en/stable/api.html#specifying-columns + /// + /// + /// + public Table Columns(Expression> predicate) + { + var visitor = new SelectExpressionVisitor(); + visitor.Visit(predicate); + + if (visitor.Columns.Count == 0) + throw new ArgumentException("Expected predicate to return an array of references to a Model column."); + + return Columns(visitor.Columns.ToArray()); + } + + /// + /// Sets an offset with an optional foreign table reference. + /// + /// + /// + /// + public Table Offset(int offset, string? foreignTableName = null) + { + this._offset = offset; + this._offsetForeignKey = foreignTableName; + return this; + } + + /// + /// Executes an INSERT query using the defined query params on the current instance. + /// + /// + /// + /// + /// A typed model response from the database. + public Task> Insert(T model, QueryOptions? options = null, + CancellationToken cancellationToken = default) => PerformInsert(model, options, cancellationToken); + + /// + /// Executes a BULK INSERT query using the defined query params on the current instance. + /// + /// + /// + /// + /// A typed model response from the database. + public Task> Insert(ICollection models, QueryOptions? options = null, + CancellationToken cancellationToken = default) => PerformInsert(models, options, cancellationToken); + + /// + /// Executes an UPSERT query using the defined query params on the current instance. + /// + /// By default the new record is returned. Set QueryOptions.ReturnType to Minimal if you don't need this value. + /// By specifying the QueryOptions.OnConflict parameter, you can make UPSERT work on a column(s) that has a UNIQUE constraint. + /// QueryOptions.DuplicateResolution.IgnoreDuplicates Specifies if duplicate rows should be ignored and not inserted. + /// + /// + /// + /// + /// + public Task> Upsert(T model, QueryOptions? options = null, + CancellationToken cancellationToken = default) + { + options ??= new QueryOptions(); + + // Enforce Upsert + options.Upsert = true; + + return PerformInsert(model, options, cancellationToken); + } + + /// + /// Executes an UPSERT query using the defined query params on the current instance. + /// + /// By default the new record is returned. Set QueryOptions.ReturnType to Minimal if you don't need this value. + /// By specifying the QueryOptions.OnConflict parameter, you can make UPSERT work on a column(s) that has a UNIQUE constraint. + /// QueryOptions.DuplicateResolution.IgnoreDuplicates Specifies if duplicate rows should be ignored and not inserted. + /// + /// + /// + /// + /// + public Task> Upsert(ICollection model, QueryOptions? options = null, + CancellationToken cancellationToken = default) + { + options ??= new QueryOptions(); + + // Enforce Upsert + options.Upsert = true; + + return PerformInsert(model, options, cancellationToken); + } + + + /// + /// Specifies a key and value to be updated. Should be combined with filters/where clauses. + /// + /// Can be called multiple times to set multiple values. + /// + /// + /// + /// + public Table Set(Expression> keySelector, object? value) + { + var visitor = new SetExpressionVisitor(); + visitor.Visit(keySelector); + + if (visitor.Column == null || visitor.ExpectedType == null) + throw new ArgumentException( + "Expression should return a KeyValuePair with a key of a Model Property and a value."); + + if (value == null) + { + if (Nullable.GetUnderlyingType(visitor.ExpectedType) == null) + throw new ArgumentException( + $"Expected Value to be of Type: {visitor.ExpectedType.Name}, instead received: {null}."); + } + else if (!visitor.ExpectedType.IsInstanceOfType(value)) + { + throw new ArgumentException(string.Format("Expected Value to be of Type: {0}, instead received: {1}.", + visitor.ExpectedType.Name, value.GetType().Name)); + } + + _setData.Add(visitor.Column, value); + + return this; + } + + /// + /// Specifies a KeyValuePair to be updated. Should be combined with filters/where clauses. + /// + /// Can be called multiple times to set multiple values. + /// + /// + /// + /// + public Table Set(Expression>> keyValuePairExpression) + { + var visitor = new SetExpressionVisitor(); + visitor.Visit(keyValuePairExpression); + + if (visitor.Column == null || visitor.Value == default) + throw new ArgumentException( + "Expression should return a KeyValuePair with a key of a Model Property and a value."); + + _setData.Add(visitor.Column, visitor.Value); + + return this; + } + + /// + /// Calls an Update function after `Set` has been called. + /// + /// + /// + /// + /// + public Task> Update(QueryOptions? options = null, + CancellationToken cancellationToken = default) + { + options ??= new QueryOptions(); + + if (_setData.Keys.Count == 0) + throw new ArgumentException("No data has been set to update, was `Set` called?"); + + _method = new HttpMethod("PATCH"); + + var request = Send(_method, _setData, options.ToHeaders(), cancellationToken, isUpdate: true); + + Clear(); + + return request; + } + + /// + /// Executes an UPDATE query using the defined query params on the current instance. + /// + /// + /// + /// A typed response from the database. + public Task> Update(T model, QueryOptions? options = null, + CancellationToken cancellationToken = default) + { + options ??= new QueryOptions(); + + _method = new HttpMethod("PATCH"); + + Match(model); + + var request = Send(_method, model, options.ToHeaders(), cancellationToken, isUpdate: true); + + Clear(); + + return request; + } + + /// + /// Executes a delete request using the defined query params on the current instance. + /// + /// + public Task Delete(QueryOptions? options = null, CancellationToken cancellationToken = default) + { + options ??= new QueryOptions(); + + _method = HttpMethod.Delete; + + var request = Send(_method, null, options.ToHeaders(), cancellationToken); + + Clear(); + + return request; + } + + /// + /// Executes a delete request using the model's primary key as the filter for the request. + /// + /// + /// + /// + public Task> Delete(T model, QueryOptions? options = null, + CancellationToken cancellationToken = default) + { + options ??= new QueryOptions(); + + _method = HttpMethod.Delete; + + Match(model); + + var request = Send(_method, null, options.ToHeaders(), cancellationToken); + Clear(); + return request; + } + + /// + /// Returns ONLY a count from the specified query. + /// + /// See: https://postgrest.org/en/v7.0.0/api.html?highlight=count + /// + /// The kind of count. + /// + /// + public async Task Count(CountType type, CancellationToken cancellationToken = default) + { + _method = HttpMethod.Head; + + var attr = type.GetAttribute(); + + var headers = new Dictionary + { + { "Prefer", $"count={attr?.Mapping}" } + }; + + var request = Send(_method, null, headers, cancellationToken); + Clear(); + + var response = await request; + var countStr = response.ResponseMessage?.Content.Headers.GetValues("Content-Range").FirstOrDefault(); + + // Returns X-Y/COUNT [0-3/4] + return int.Parse(countStr?.Split('/')[1] ?? throw new InvalidOperationException()); + } + + /// + /// Executes a query that expects to have a single object returned, rather than returning list of models + /// it will return a single model. + /// + /// + /// + public async Task Single(CancellationToken cancellationToken = default) + { + _method = HttpMethod.Get; + var headers = new Dictionary + { + { "Accept", "application/vnd.pgrst.object+json" }, + { "Prefer", "return=representation" } + }; + + var request = Send(_method, null, headers, cancellationToken); + + Clear(); + + try + { + var result = await request; + return result.Models.FirstOrDefault(); + } + catch (PostgrestException e) + { + if (e.Response!.StatusCode == System.Net.HttpStatusCode.NotAcceptable) + return null; + else + throw; + } + } + + /// + /// Executes the query using the defined filters on the current instance. + /// + /// + /// + public Task> Get(CancellationToken cancellationToken = default) + { + var request = Send(_method, null, null, cancellationToken); + Clear(); + return request; + } + + /// + /// Generates the encoded URL with defined query parameters that will be sent to the Postgrest API. + /// + /// + public string GenerateUrl() + { + var builder = new UriBuilder($"{BaseUrl}/{TableName}"); + var query = HttpUtility.ParseQueryString(builder.Query); + + foreach (var param in _options.QueryParams) + query.Add(param.Key, param.Value); + + if (_options.Headers.TryGetValue("apikey", out var header)) + query.Add("apikey", header); + + if (_columns.Count > 0) + query["columns"] = string.Join(",", _columns); + + foreach (var parsedFilter in _filters.Select(PrepareFilter)) + query.Add(parsedFilter.Key, parsedFilter.Value); + + foreach (var orderer in _orderers) + { + var nullPosAttr = orderer.NullPosition.GetAttribute(); + var orderingAttr = orderer.Ordering.GetAttribute(); + + if (nullPosAttr == null || orderingAttr == null) continue; + + var key = !string.IsNullOrEmpty(orderer.ForeignTable) ? $"{orderer.ForeignTable}.order" : "order"; + query.Add(key, $"{orderer.Column}.{orderingAttr.Mapping}.{nullPosAttr.Mapping}"); + } + + if (!string.IsNullOrEmpty(_columnQuery)) + query["select"] = Regex.Replace(_columnQuery!, @"\s", ""); + + if (_references.Count > 0) + { + query["select"] ??= "*"; + + foreach (var reference in _references) + { + if (reference.IncludeInQuery) + { + var columns = string.Join(",", reference.Columns.ToArray()); + + if (reference.ShouldFilterTopLevel) + query["select"] = query["select"] + $",{reference.TableName}!inner({columns})"; + else + query["select"] = query["select"] + $",{reference.TableName}({columns})"; + } + } + } + + if (!string.IsNullOrEmpty(_onConflict)) + query["on_conflict"] = _onConflict; + + if (_limit != int.MinValue) + { + var key = _limitForeignKey != null ? $"{_limitForeignKey}.limit" : "limit"; + query[key] = _limit.ToString(); + } + + if (_offset != int.MinValue) + { + var key = _offsetForeignKey != null ? $"{_offsetForeignKey}.offset" : "offset"; + query[key] = _offset.ToString(); + } + + builder.Query = query.ToString(); + return builder.Uri.ToString(); + } + + /// + /// Transforms an object into a string mapped list/dictionary using `JsonSerializerSettings`. + /// + /// + /// + private object? PrepareRequestData(object? data, bool isInsert = false, bool isUpdate = false, + bool isUpsert = false) + { + if (data == null) return new Dictionary(); + + // Specified in constructor; + var resolver = (PostgrestContractResolver)_serializerSettings.ContractResolver!; + + resolver.SetState(isInsert, isUpdate, isUpsert); + + var serialized = JsonConvert.SerializeObject(data, _serializerSettings); + + resolver.SetState(); + + // Check if data is a Collection for the Insert Bulk case + if (data is ICollection) + return JsonConvert.DeserializeObject>(serialized, _serializerSettings); + else + return JsonConvert.DeserializeObject>(serialized, _serializerSettings); + } + + /// + /// Transforms the defined filters into the expected Postgrest format. + /// + /// See: http://postgrest.org/en/v7.0.0/api.html#operators + /// + /// + /// + internal KeyValuePair PrepareFilter(QueryFilter filter) + { + var asAttribute = filter.Op.GetAttribute(); + var strBuilder = new StringBuilder(); + + if (asAttribute == null) + return new KeyValuePair(); + + switch (filter.Op) + { + case Operator.Or: + case Operator.And: + if (filter.Criteria is List subFilters) + { + var list = new List>(); + foreach (var subFilter in subFilters) + list.Add(PrepareFilter(subFilter)); + + foreach (var preppedFilter in list) + strBuilder.Append($"{preppedFilter.Key}.{preppedFilter.Value},"); + + return new KeyValuePair(asAttribute.Mapping, + $"({strBuilder.ToString().Trim(',')})"); + } + + break; + case Operator.Not: + if (filter.Criteria is QueryFilter notFilter) + { + var prepped = PrepareFilter(notFilter); + return new KeyValuePair(prepped.Key, $"not.{prepped.Value}"); + } + + break; + case Operator.Like: + case Operator.ILike: + if (filter.Criteria is string likeCriteria && filter.Property != null) + { + return new KeyValuePair(filter.Property, + $"{asAttribute.Mapping}.{likeCriteria.Replace("%", "*")}"); + } + + break; + case Operator.In: + if (filter.Criteria is List inCriteria && filter.Property != null) + { + foreach (var item in inCriteria) + strBuilder.Append($"\"{item}\","); + + return new KeyValuePair(filter.Property, + $"{asAttribute.Mapping}.({strBuilder.ToString().Trim(',')})"); + } + else if (filter.Criteria is Dictionary dictCriteria && filter.Property != null) + { + return new KeyValuePair(filter.Property, + $"{asAttribute.Mapping}.{JsonConvert.SerializeObject(dictCriteria)}"); + } + + break; + case Operator.Contains: + case Operator.ContainedIn: + case Operator.Overlap: + if (filter.Criteria is List listCriteria && filter.Property != null) + { + foreach (var item in listCriteria) + strBuilder.Append($"{item},"); + + return new KeyValuePair(filter.Property, + $"{asAttribute.Mapping}.{{{strBuilder.ToString().Trim(',')}}}"); + } + else if (filter.Criteria is Dictionary dictCriteria && filter.Property != null) + { + return new KeyValuePair(filter.Property, + $"{asAttribute.Mapping}.{JsonConvert.SerializeObject(dictCriteria)}"); + } + else if (filter.Criteria is IntRange rangeCriteria && filter.Property != null) + { + return new KeyValuePair(filter.Property, + $"{asAttribute.Mapping}.{rangeCriteria.ToPostgresString()}"); + } + + break; + case Operator.StrictlyLeft: + case Operator.StrictlyRight: + case Operator.NotRightOf: + case Operator.NotLeftOf: + case Operator.Adjacent: + if (filter.Criteria is IntRange rangeCriterion && filter.Property != null) + { + return new KeyValuePair(filter.Property, + $"{asAttribute.Mapping}.{rangeCriterion.ToPostgresString()}"); + } + + break; + case Operator.FTS: + case Operator.PHFTS: + case Operator.PLFTS: + case Operator.WFTS: + if (filter.Criteria is FullTextSearchConfig searchConfig && filter.Property != null) + { + return new KeyValuePair(filter.Property, + $"{asAttribute.Mapping}({searchConfig.Config}).{searchConfig.QueryText}"); + } + + break; + default: + return new KeyValuePair(filter.Property ?? "", + $"{asAttribute.Mapping}.{filter.Criteria}"); + } + + return new KeyValuePair(); + } + + /// + /// Clears currently defined query values. + /// + public void Clear() + { + _columnQuery = null; + + _filters.Clear(); + _orderers.Clear(); + _columns.Clear(); + _setData.Clear(); + + _rangeFrom = int.MinValue; + _rangeTo = int.MinValue; + + _limit = int.MinValue; + _limitForeignKey = null; + + _offset = int.MinValue; + _offsetForeignKey = null; + + _onConflict = null; + } + + + /// + /// Performs an INSERT Request. + /// + /// + /// + /// + /// + private Task> PerformInsert(object data, QueryOptions? options = null, + CancellationToken cancellationToken = default) + { + _method = HttpMethod.Post; + options ??= new QueryOptions(); + + if (!string.IsNullOrEmpty(options.OnConflict)) + { + OnConflict(options.OnConflict!); + } + + var request = Send(_method, data, options.ToHeaders(), cancellationToken, isInsert: true, + isUpsert: options.Upsert); + + Clear(); + + return request; + } + + private Task Send(HttpMethod method, object? data, Dictionary? headers = null, + CancellationToken cancellationToken = default, bool isInsert = false, bool isUpdate = false, + bool isUpsert = false) + { + var requestHeaders = Helpers.PrepareRequestHeaders(method, headers, _options, _rangeFrom, _rangeTo); + + if (GetHeaders != null) + { + requestHeaders = GetHeaders().MergeLeft(requestHeaders); + } + + var preparedData = PrepareRequestData(data, isInsert, isUpdate, isUpsert); + return Helpers.MakeRequest(_options, method, GenerateUrl(), _serializerSettings, preparedData, + requestHeaders, cancellationToken); + } + + private Task> Send(HttpMethod method, object? data, + Dictionary? headers = null, CancellationToken cancellationToken = default, + bool isInsert = false, bool isUpdate = false, bool isUpsert = false) where TU : BaseModel, new() + { + var requestHeaders = Helpers.PrepareRequestHeaders(method, headers, _options, _rangeFrom, _rangeTo); + + if (GetHeaders != null) + { + requestHeaders = GetHeaders().MergeLeft(requestHeaders); + } + + var preparedData = PrepareRequestData(data, isInsert, isUpdate, isUpsert); + return Helpers.MakeRequest(_options, method, GenerateUrl(), _serializerSettings, preparedData, + requestHeaders, GetHeaders, cancellationToken); + } + + private static string FindTableName(object? obj = null) + { + var type = obj == null ? typeof(T) : obj is Type t ? t : obj.GetType(); + var attr = Attribute.GetCustomAttribute(type, typeof(TableAttribute)); + + if (attr is TableAttribute tableAttr) + { + return tableAttr.Name; + } + + return type.Name; + } + } +} \ No newline at end of file diff --git a/PostgrestTests/ClientApi.cs b/PostgrestTests/ClientApi.cs deleted file mode 100644 index 79dc3b4..0000000 --- a/PostgrestTests/ClientApi.cs +++ /dev/null @@ -1,1131 +0,0 @@ -using System; -using System.Collections.Generic; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using Postgrest; -using PostgrestTests.Models; -using System.Threading.Tasks; -using System.Linq; -using static Postgrest.Constants; -using System.Net.Http; -using System.Threading; -using Postgrest.Responses; - -namespace PostgrestTests -{ - [TestClass] - public class ClientApi - { - private static string baseUrl = "http://localhost:3000"; - - [TestMethod("Initilizes")] - public void TestInitilization() - { - var client = new Client(baseUrl, null); - Assert.AreEqual(baseUrl, client.BaseUrl); - } - - [TestMethod("with optional query params")] - public void TestQueryParams() - { - var client = new Client(baseUrl, options: new ClientOptions - { - QueryParams = new Dictionary - { - { "some-param", "foo" }, - { "other-param", "bar" } - } - }); - - Assert.AreEqual($"{baseUrl}/users?some-param=foo&other-param=bar", (client.Table() as Table)!.GenerateUrl()); - } - - [TestMethod("will use TableAttribute")] - public void TestTableAttribute() - { - var client = new Client(baseUrl, null); - Assert.AreEqual($"{baseUrl}/users", (client.Table() as Table)!.GenerateUrl()); - } - - [TestMethod("will default to Class.name in absence of TableAttribute")] - public void TestTableAttributeDefault() - { - var client = new Client(baseUrl, null); - Assert.AreEqual($"{baseUrl}/Stub", (client.Table() as Table)!.GenerateUrl()); - } - - [TestMethod("will set header from options")] - public void TestHeadersToken() - { - var headers = Postgrest.Helpers.PrepareRequestHeaders(HttpMethod.Get, new Dictionary { { "Authorization", $"Bearer token" } }); - - Assert.AreEqual("Bearer token", headers["Authorization"]); - } - - [TestMethod("will set apikey as query string")] - public void TestQueryApiKey() - { - var client = new Client(baseUrl, new ClientOptions - { - Headers = - { - { "apikey", "some-key" } - } - }); - Assert.AreEqual($"{baseUrl}/users?apikey=some-key", (client.Table() as Table)!.GenerateUrl()); - } - - [TestMethod("filters: simple")] - public void TestFiltersSimple() - { - var client = new Client(baseUrl); - var dict = new Dictionary - { - { Operator.Equals, "eq.bar" }, - { Operator.GreaterThan, "gt.bar" }, - { Operator.GreaterThanOrEqual, "gte.bar" }, - { Operator.LessThan, "lt.bar" }, - { Operator.LessThanOrEqual, "lte.bar" }, - { Operator.NotEqual, "neq.bar" }, - { Operator.Is, "is.bar" }, - }; - - foreach (var pair in dict) - { - var filter = new QueryFilter("foo", pair.Key, "bar"); - var result = (client.Table() as Table)!.PrepareFilter(filter); - Assert.AreEqual("foo", result.Key); - Assert.AreEqual(pair.Value, result.Value); - } - } - - [TestMethod("filters: like & ilike")] - public void TestFiltersLike() - { - var client = new Client(baseUrl); - var dict = new Dictionary - { - { Operator.Like, "like.*bar*" }, - { Operator.ILike, "ilike.*bar*" }, - }; - - foreach (var pair in dict) - { - var filter = new QueryFilter("foo", pair.Key, "%bar%"); - var result = (client.Table() as Table)!.PrepareFilter(filter); - Assert.AreEqual("foo", result.Key); - Assert.AreEqual(pair.Value, result.Value); - } - } - - /// - /// See: http://postgrest.org/en/v7.0.0/api.html#operators - /// - [TestMethod("filters: `In` with List arguments")] - public void TestFiltersArraysWithLists() - { - var client = new Client(baseUrl); - - // UrlEncoded {"bar","buzz"} - string exp = "(\"bar\",\"buzz\")"; - var dict = new Dictionary - { - { Operator.In, $"in.{exp}" }, - }; - - foreach (var pair in dict) - { - var list = new List { "bar", "buzz" }; - var filter = new QueryFilter("foo", pair.Key, list); - var result = (client.Table() as Table)!.PrepareFilter(filter); - Assert.AreEqual("foo", result.Key); - Assert.AreEqual(pair.Value, result.Value); - } - } - - /// - /// See: http://postgrest.org/en/v7.0.0/api.html#operators - /// - [TestMethod("filters: `Contains`, `ContainedIn`, `Overlap` with List arguments")] - public void TestFiltersContainsArraysWithLists() - { - var client = new Client(baseUrl); - - // UrlEncoded {bar,buzz} - according to documentation, does not accept quoted strings - string exp = "{bar,buzz}"; - var dict = new Dictionary - { - { Operator.Contains, $"cs.{exp}" }, - { Operator.ContainedIn, $"cd.{exp}" }, - { Operator.Overlap, $"ov.{exp}" }, - }; - - foreach (var pair in dict) - { - var list = new List { "bar", "buzz" }; - var filter = new QueryFilter("foo", pair.Key, list); - var result = (client.Table() as Table)!.PrepareFilter(filter); - Assert.AreEqual("foo", result.Key); - Assert.AreEqual(pair.Value, result.Value); - } - } - - [TestMethod("filters: arrays with Dictionary arguments")] - public void TestFiltersArraysWithDictionaries() - { - var client = new Client(baseUrl); - - string exp = "{\"bar\":100,\"buzz\":\"zap\"}"; - var dict = new Dictionary - { - { Operator.In, $"in.{exp}" }, - { Operator.Contains, $"cs.{exp}" }, - { Operator.ContainedIn, $"cd.{exp}" }, - { Operator.Overlap, $"ov.{exp}" }, - }; - - foreach (var pair in dict) - { - var value = new Dictionary { { "bar", 100 }, { "buzz", "zap" } }; - var filter = new QueryFilter("foo", pair.Key, value); - var result = (client.Table() as Table)!.PrepareFilter(filter); - Assert.AreEqual("foo", result.Key); - Assert.AreEqual(pair.Value, result.Value); - } - } - - [TestMethod("filters: full text search")] - public void TestFiltersFullTextSearch() - { - var client = new Client(baseUrl); - - // UrlEncoded [2,3] - var exp = "(english).bar"; - var dict = new Dictionary - { - { Operator.FTS, $"fts{exp}" }, - { Operator.PHFTS, $"phfts{exp}" }, - { Operator.PLFTS, $"plfts{exp}" }, - { Operator.WFTS, $"wfts{exp}" }, - }; - - foreach (var pair in dict) - { - var config = new FullTextSearchConfig("bar", "english"); - var filter = new QueryFilter("foo", pair.Key, config); - var result = (client.Table() as Table)!.PrepareFilter(filter); - Assert.AreEqual("foo", result.Key); - Assert.AreEqual(pair.Value, result.Value); - } - } - - [TestMethod("filters: ranges")] - public void TestFiltersRanges() - { - var client = new Client(baseUrl); - - var exp = "[2,3]"; - var dict = new Dictionary - { - { Operator.StrictlyLeft, $"sl.{exp}" }, - { Operator.StrictlyRight, $"sr.{exp}" }, - { Operator.NotRightOf, $"nxr.{exp}" }, - { Operator.NotLeftOf, $"nxl.{exp}" }, - { Operator.Adjacent, $"adj.{exp}" }, - }; - - foreach (var pair in dict) - { - var config = new IntRange(2, 3); - var filter = new QueryFilter("foo", pair.Key, config); - var result = (client.Table() as Table)!.PrepareFilter(filter); - Assert.AreEqual("foo", result.Key); - Assert.AreEqual(pair.Value, result.Value); - } - } - - [TestMethod("filters: not")] - public void TestFiltersNot() - { - var client = new Client(baseUrl); - var filter = new QueryFilter("foo", Operator.Equals, "bar"); - var notFilter = new QueryFilter(Operator.Not, filter); - var result = (client.Table() as Table)!.PrepareFilter(notFilter); - - Assert.AreEqual("foo", result.Key); - Assert.AreEqual("not.eq.bar", result.Value); - } - - [TestMethod("filters: and & or")] - public void TestFiltersAndOr() - { - var client = new Client(baseUrl); - var exp = "(a.gte.0,a.lte.100)"; - - var dict = new Dictionary - { - { Operator.And, $"and={exp}" }, - { Operator.Or, $"or={exp}" }, - }; - - var subfilters = new List { - new QueryFilter("a", Operator.GreaterThanOrEqual, "0"), - new QueryFilter("a", Operator.LessThanOrEqual, "100") - }; - - foreach (var pair in dict) - { - var filter = new QueryFilter(pair.Key, subfilters); - var result = (client.Table() as Table)!.PrepareFilter(filter); - Assert.AreEqual(pair.Value, $"{result.Key}={result.Value}"); - } - } - - [TestMethod("update: basic")] - public async Task TestBasicUpdate() - { - var client = new Client(baseUrl); - - var user = await client.Table().Filter("username", Operator.Equals, "supabot").Single(); - - if (user != null) - { - // Update user status - user.Status = "OFFLINE"; - var response = await user.Update(); - - var updatedUser = response.Models.FirstOrDefault(); - - Assert.AreEqual(1, response.Models.Count); - Assert.AreEqual(user.Username, updatedUser.Username); - Assert.AreEqual(user.Status, updatedUser.Status); - - } - } - - - [TestMethod("insert: basic")] - public async Task TestBasicInsert() - { - var client = new Client(baseUrl); - - var newUser = new User - { - Username = Guid.NewGuid().ToString(), - AgeRange = new IntRange(18, 22), - Catchphrase = "what a shot", - Status = "ONLINE" - }; - - var response = await client.Table().Insert(newUser); - var insertedUser = response.Models.First(); - - Assert.AreEqual(1, response.Models.Count); - Assert.AreEqual(newUser.Username, insertedUser.Username); - Assert.AreEqual(newUser.AgeRange, insertedUser.AgeRange); - Assert.AreEqual(newUser.Status, insertedUser.Status); - - await client.Table().Delete(newUser); - - var response2 = await client.Table().Insert(newUser, new QueryOptions { Returning = QueryOptions.ReturnType.Minimal }); - Assert.AreEqual("", response2.Content); - - await client.Table().Delete(newUser); - } - - [TestMethod("insert: headers generated")] - public void TestInsertHeaderGeneration() - { - var option = new QueryOptions { }; - Assert.AreEqual("return=representation", option.ToHeaders()["Prefer"]); - - option.Returning = QueryOptions.ReturnType.Minimal; - Assert.AreEqual("return=minimal", option.ToHeaders()["Prefer"]); - - option.Upsert = true; - Assert.AreEqual("resolution=merge-duplicates,return=minimal", option.ToHeaders()["Prefer"]); - - option.DuplicateResolution = QueryOptions.DuplicateResolutionType.IgnoreDuplicates; - Assert.AreEqual("resolution=ignore-duplicates,return=minimal", option.ToHeaders()["Prefer"]); - - option.Upsert = false; - option.Count = QueryOptions.CountType.Exact; - Assert.AreEqual("return=minimal,count=exact", option.ToHeaders()["Prefer"]); - } - - [TestMethod("Exceptions: Throws when inserting a user with same primary key value as an existing one without upsert option")] - public async Task TestThrowsRequestExceptionInsertPkConflict() - { - var client = new Client(baseUrl); - - await Assert.ThrowsExceptionAsync(async () => - { - var newUser = new User - { - Username = "supabot" - }; - await client.Table().Insert(newUser); - }); - } - - [TestMethod("insert: upsert")] - public async Task TestInsertWithUpsert() - { - var client = new Client(baseUrl); - - var supaUpdated = new User - { - Username = "supabot", - AgeRange = new IntRange(3, 8), - Status = "OFFLINE", - Catchphrase = "fat cat" - }; - - var insertOptions = new QueryOptions - { - Upsert = true - }; - - var response = await client.Table().Insert(supaUpdated, insertOptions); - - var kitchenSink1 = new KitchenSink - { - UniqueValue = "Testing" - }; - - var ks1 = await client.Table().OnConflict("unique_value").Upsert(kitchenSink1); - var uks1 = ks1.Models.First(); - uks1.StringValue = "Testing 1"; - var ks3 = await client.Table().OnConflict(x => x.UniqueValue!).Upsert(uks1); - - var updatedUser = response.Models.First(); - - Assert.AreEqual(1, response.Models.Count); - Assert.AreEqual(supaUpdated.Username, updatedUser.Username); - Assert.AreEqual(supaUpdated.AgeRange, updatedUser.AgeRange); - Assert.AreEqual(supaUpdated.Status, updatedUser.Status); - } - - [TestMethod("order: basic")] - public async Task TestOrderBy() - { - var client = new Client(baseUrl); - - var orderedResponse = await client.Table().Order("username", Ordering.Descending).Get(); - var unorderedResponse = await client.Table().Get(); - - var supaOrderedUsers = orderedResponse.Models; - var linqOrderedUsers = unorderedResponse.Models.OrderByDescending(u => u.Username).ToList(); - - CollectionAssert.AreEqual(linqOrderedUsers, supaOrderedUsers); - } - - [TestMethod("limit: basic")] - public async Task TestLimit() - { - var client = new Client(baseUrl); - - var limitedUsersResponse = await client.Table().Limit(2).Get(); - var usersResponse = await client.Table().Get(); - - var supaLimitUsers = limitedUsersResponse.Models; - var linqLimitUsers = usersResponse.Models.Take(2).ToList(); - - CollectionAssert.AreEqual(linqLimitUsers, supaLimitUsers); - } - - [TestMethod("offset: basic")] - public async Task TestOffset() - { - var client = new Client(baseUrl); - - var offsetUsersResponse = await client.Table().Offset(2).Get(); - var usersResponse = await client.Table().Get(); - - var supaOffsetUsers = offsetUsersResponse.Models; - var linqSkipUsers = usersResponse.Models.Skip(2).ToList(); - - CollectionAssert.AreEqual(linqSkipUsers, supaOffsetUsers); - } - - [TestMethod("range: from")] - public async Task TestRangeFrom() - { - var client = new Client(baseUrl); - - var rangeUsersResponse = await client.Table().Range(2).Get(); - var usersResponse = await client.Table().Get(); - - var supaRangeUsers = rangeUsersResponse.Models; - var linqSkipUsers = usersResponse.Models.Skip(2).ToList(); - - CollectionAssert.AreEqual(linqSkipUsers, supaRangeUsers); - } - - [TestMethod("range: from and to")] - public async Task TestRangeFromAndTo() - { - var client = new Client(baseUrl); - - var rangeUsersResponse = await client.Table().Range(1, 3).Get(); - var usersResponse = await client.Table().Get(); - - var supaRangeUsers = rangeUsersResponse.Models; - var linqRangeUsers = usersResponse.Models.Skip(1).Take(3).ToList(); - - CollectionAssert.AreEqual(linqRangeUsers, supaRangeUsers); - } - - [TestMethod("range: limit and offset")] - public async Task TestRangeWithLimitAndOffset() - { - var client = new Client(baseUrl); - - var rangeUsersResponse = await client.Table().Limit(1).Offset(3).Get(); - var usersResponse = await client.Table().Get(); - - var supaRangeUsers = rangeUsersResponse.Models; - var linqRangeUsers = usersResponse.Models.Skip(3).Take(1).ToList(); - - CollectionAssert.AreEqual(linqRangeUsers, supaRangeUsers); - } - - [TestMethod("filters: not")] - public async Task TestNotFilter() - { - var client = new Client(baseUrl); - var filter = new QueryFilter("username", Operator.Equals, "supabot"); - - var filteredResponse = await client.Table().Not(filter).Get(); - var usersResponse = await client.Table().Get(); - - var supaFilteredUsers = filteredResponse.Models; - var linqFilteredUsers = usersResponse.Models.Where(u => u.Username != "supabot").ToList(); - - CollectionAssert.AreEqual(linqFilteredUsers, supaFilteredUsers); - } - - [TestMethod("filters: `not` shorthand")] - public async Task TestNotShorthandFilter() - { - var client = new Client(baseUrl); - - // Standard NOT Equal Op. - var filteredResponse = await client.Table().Not("username", Operator.Equals, "supabot").Get(); - var usersResponse = await client.Table().Get(); - - var supaFilteredUsers = filteredResponse.Models; - var linqFilteredUsers = usersResponse.Models.Where(u => u.Username != "supabot").ToList(); - - CollectionAssert.AreEqual(linqFilteredUsers, supaFilteredUsers); - - // NOT `In` Shorthand Op. - var notInFilterResponse = await client.Table().Not("username", Operator.In, new List { "supabot", "kiwicopple" }).Get(); - var supaNotInList = notInFilterResponse.Models; - var linqNotInList = usersResponse.Models.Where(u => u.Username != "supabot").Where(u => u.Username != "kiwicopple").ToList(); - - CollectionAssert.AreEqual(supaNotInList, linqNotInList); - } - - [TestMethod("filters: null operation `Equals`")] - public async Task TestEqualsNullFilterEquals() - { - var client = new Client(baseUrl); - - await client.Table().Insert(new User { Username = "acupofjose", Status = "ONLINE", Catchphrase = null }, new QueryOptions { Upsert = true }); - - var filteredResponse = await client.Table().Filter("catchphrase", Operator.Equals, null).Get(); - var usersResponse = await client.Table().Get(); - - var supaFilteredUsers = filteredResponse.Models; - var linqFilteredUsers = usersResponse.Models.Where(u => u.Catchphrase == null).ToList(); - - CollectionAssert.AreEqual(linqFilteredUsers, supaFilteredUsers); - } - - [TestMethod("filters: null operation `Is`")] - public async Task TestEqualsNullFilterIs() - { - var client = new Client(baseUrl); - - await client.Table().Insert(new User { Username = "acupofjose", Status = "ONLINE", Catchphrase = null }, new QueryOptions { Upsert = true }); - - var filteredResponse = await client.Table().Filter("catchphrase", Operator.Is, null).Get(); - var usersResponse = await client.Table().Get(); - - var supaFilteredUsers = filteredResponse.Models; - var linqFilteredUsers = usersResponse.Models.Where(u => u.Catchphrase == null).ToList(); - - CollectionAssert.AreEqual(linqFilteredUsers, supaFilteredUsers); - } - - [TestMethod("filters: null operation `NotEquals`")] - public async Task TestEqualsNullFilterNotEquals() - { - var client = new Client(baseUrl); - - await client.Table().Insert(new User { Username = "acupofjose", Status = "ONLINE", Catchphrase = null }, new QueryOptions { Upsert = true }); - - var filteredResponse = await client.Table().Filter("catchphrase", Operator.NotEqual, null).Get(); - var usersResponse = await client.Table().Get(); - - var supaFilteredUsers = filteredResponse.Models; - var linqFilteredUsers = usersResponse.Models.Where(u => u.Catchphrase != null).ToList(); - - CollectionAssert.AreEqual(linqFilteredUsers, supaFilteredUsers); - } - - [TestMethod("filters: null operation `Not`")] - public async Task TestEqualsNullNot() - { - var client = new Client(baseUrl); - - await client.Table().Insert(new User { Username = "acupofjose", Status = "ONLINE", Catchphrase = null }, new QueryOptions { Upsert = true }); - - var filteredResponse = await client.Table().Filter("catchphrase", Operator.Not, null).Get(); - var usersResponse = await client.Table().Get(); - - var supaFilteredUsers = filteredResponse.Models; - var linqFilteredUsers = usersResponse.Models.Where(u => u.Catchphrase != null).ToList(); - - CollectionAssert.AreEqual(linqFilteredUsers, supaFilteredUsers); - } - - [TestMethod("filters: in")] - public async Task TestInFilter() - { - var client = new Client(baseUrl); - - var criteria = new List { "supabot", "kiwicopple" }; - - var filteredResponse = await client.Table().Filter("username", Operator.In, criteria).Order("username", Ordering.Descending).Get(); - var usersResponse = await client.Table().Get(); - - var supaFilteredUsers = filteredResponse.Models; - var linqFilteredUsers = usersResponse.Models.OrderByDescending(u => u.Username).Where(u => u.Username == "supabot" || u.Username == "kiwicopple").ToList(); - - CollectionAssert.AreEqual(linqFilteredUsers, supaFilteredUsers); - } - - [TestMethod("filters: eq")] - public async Task TestEqualsFilter() - { - var client = new Client(baseUrl); - - var filteredResponse = await client.Table().Filter("username", Operator.Equals, "supabot").Get(); - var usersResponse = await client.Table().Get(); - - var supaFilteredUsers = filteredResponse.Models; - var linqFilteredUsers = usersResponse.Models.Where(u => u.Username == "supabot").ToList(); - - Assert.AreEqual(1, supaFilteredUsers.Count); - CollectionAssert.AreEqual(linqFilteredUsers, supaFilteredUsers); - } - - [TestMethod("filters: gt")] - public async Task TestGreaterThanFilter() - { - var client = new Client(baseUrl); - - var filteredResponse = await client.Table().Filter("id", Operator.GreaterThan, "1").Get(); - var messagesResponse = await client.Table().Get(); - - var supaFilteredMessages = filteredResponse.Models; - var linqFilteredMessages = messagesResponse.Models.Where(m => m.Id > 1).ToList(); - - Assert.AreEqual(1, supaFilteredMessages.Count); - CollectionAssert.AreEqual(linqFilteredMessages, supaFilteredMessages); - } - - [TestMethod("filters: gte")] - public async Task TestGreaterThanOrEqualFilter() - { - var client = new Client(baseUrl); - - var filteredResponse = await client.Table().Filter("id", Operator.GreaterThanOrEqual, "1").Get(); - var messagesResponse = await client.Table().Get(); - - var supaFilteredMessages = filteredResponse.Models; - var linqFilteredMessages = messagesResponse.Models.Where(m => m.Id >= 1).ToList(); - - CollectionAssert.AreEqual(linqFilteredMessages, supaFilteredMessages); - } - - [TestMethod("filters: lt")] - public async Task TestlessThanFilter() - { - var client = new Client(baseUrl); - - var filteredResponse = await client.Table().Filter("id", Operator.LessThan, "2").Get(); - var messagesResponse = await client.Table().Get(); - - var supaFilteredMessages = filteredResponse.Models; - var linqFilteredMessages = messagesResponse.Models.Where(m => m.Id < 2).ToList(); - - CollectionAssert.AreEqual(linqFilteredMessages, supaFilteredMessages); - } - - [TestMethod("filters: lte")] - public async Task TestLessThanOrEqualFilter() - { - var client = new Client(baseUrl); - - var filteredResponse = await client.Table().Filter("id", Operator.LessThanOrEqual, "2").Get(); - var messagesResponse = await client.Table().Get(); - - var supaFilteredMessages = filteredResponse.Models; - var linqFilteredMessages = messagesResponse.Models.Where(m => m.Id <= 2).ToList(); - - CollectionAssert.AreEqual(linqFilteredMessages, supaFilteredMessages); - } - - [TestMethod("filters: nqe")] - public async Task TestNotEqualFilter() - { - var client = new Client(baseUrl); - - var filteredResponse = await client.Table().Filter("id", Operator.NotEqual, "2").Get(); - var messagesResponse = await client.Table().Get(); - - var supaFilteredMessages = filteredResponse.Models; - var linqFilteredMessages = messagesResponse.Models.Where(m => m.Id != 2).ToList(); - - CollectionAssert.AreEqual(linqFilteredMessages, supaFilteredMessages); - } - - [TestMethod("filters: like")] - public async Task TestLikeFilter() - { - var client = new Client(baseUrl); - - var filteredResponse = await client.Table().Filter("username", Operator.Like, "s%").Get(); - var messagesResponse = await client.Table().Get(); - - var supaFilteredMessages = filteredResponse.Models; - var linqFilteredMessages = messagesResponse.Models.Where(m => m.UserName!.StartsWith('s')).ToList(); - - CollectionAssert.AreEqual(linqFilteredMessages, supaFilteredMessages); - } - - [TestMethod("filters: cs")] - public async Task TestContainsFilter() - { - var client = new Client(baseUrl); - - await client.Table().Insert(new User { Username = "skikra", Status = "ONLINE", AgeRange = new IntRange(1, 3) }, new QueryOptions { Upsert = true }); - var filteredResponse = await client.Table().Filter("age_range", Operator.Contains, new IntRange(1, 2)).Get(); - var usersResponse = await client.Table().Get(); - - var testAgainst = usersResponse.Models.Where(m => m.AgeRange?.Start.Value <= 1 && m.AgeRange?.End.Value >= 2).ToList(); - - CollectionAssert.AreEqual(testAgainst, filteredResponse.Models); - } - - [TestMethod("filters: cd")] - public async Task TestContainedFilter() - { - var client = new Client(baseUrl); - - var filteredResponse = await client.Table().Filter("age_range", Operator.ContainedIn, new IntRange(25, 35)).Get(); - var usersResponse = await client.Table().Get(); - - var testAgainst = usersResponse.Models.Where(m => m.AgeRange?.Start.Value >= 25 && m.AgeRange?.End.Value <= 35).ToList(); - - CollectionAssert.AreEqual(testAgainst, filteredResponse.Models); - } - - [TestMethod("filters: sr")] - public async Task TestStrictlyLeftFilter() - { - var client = new Client(baseUrl); - - await client.Table().Insert(new User { Username = "minds3t", Status = "ONLINE", AgeRange = new IntRange(3, 6) }, new QueryOptions { Upsert = true }); - var filteredResponse = await client.Table().Filter("age_range", Operator.StrictlyLeft, new IntRange(7, 8)).Get(); - var usersResponse = await client.Table().Get(); - - var testAgainst = usersResponse.Models.Where(m => m.AgeRange?.Start.Value < 7 && m.AgeRange?.End.Value < 7).ToList(); - - CollectionAssert.AreEqual(testAgainst, filteredResponse.Models); - } - - [TestMethod("filters: sl")] - public async Task TestStrictlyRightFilter() - { - var client = new Client(baseUrl); - - var filteredResponse = await client.Table().Filter("age_range", Operator.StrictlyRight, new IntRange(1, 2)).Get(); - var usersResponse = await client.Table().Get(); - - var testAgainst = usersResponse.Models.Where(m => m.AgeRange?.Start.Value > 2 && m.AgeRange?.End.Value > 2).ToList(); - - CollectionAssert.AreEqual(testAgainst, filteredResponse.Models); - } - - [TestMethod("filters: nxl")] - public async Task TestNotExtendToLeftFilter() - { - var client = new Client(baseUrl); - - var filteredResponse = await client.Table().Filter("age_range", Operator.NotLeftOf, new IntRange(2, 4)).Get(); - var usersResponse = await client.Table().Get(); - - var testAgainst = usersResponse.Models.Where(m => m.AgeRange?.Start.Value >= 2 && m.AgeRange?.End.Value >= 2).ToList(); - - CollectionAssert.AreEqual(testAgainst, filteredResponse.Models); - } - - [TestMethod("filters: nxr")] - public async Task TestNotExtendToRightFilter() - { - var client = new Client(baseUrl); - - var filteredResponse = await client.Table().Filter("age_range", Operator.NotRightOf, new IntRange(2, 4)).Get(); - var usersResponse = await client.Table().Get(); - - var testAgainst = usersResponse.Models.Where(m => m.AgeRange?.Start.Value <= 4 && m.AgeRange?.End.Value <= 4).ToList(); - - CollectionAssert.AreEqual(testAgainst, filteredResponse.Models); - } - - [TestMethod("filters: adj")] - public async Task TestAdjacentFilter() - { - var client = new Client(baseUrl); - - var filteredResponse = await client.Table().Filter("age_range", Operator.Adjacent, new IntRange(1, 2)).Get(); - var usersResponse = await client.Table().Get(); - - var testAgainst = usersResponse.Models.Where(m => m.AgeRange?.End.Value == 0 || m.AgeRange?.Start.Value == 3).ToList(); - - CollectionAssert.AreEqual(testAgainst, filteredResponse.Models); - } - - [TestMethod("filters: ov")] - public async Task TestOverlapFilter() - { - var client = new Client(baseUrl); - - var filteredResponse = await client.Table().Filter("age_range", Operator.Overlap, new IntRange(2, 4)).Get(); - var usersResponse = await client.Table().Get(); - - var testAgainst = usersResponse.Models.Where(m => m.AgeRange?.Start.Value <= 4 && m.AgeRange?.End.Value >= 2).ToList(); - - CollectionAssert.AreEqual(testAgainst, filteredResponse.Models); - } - - [TestMethod("filters: ilike")] - public async Task TestILikeFilter() - { - var client = new Client(baseUrl); - - var filteredResponse = await client.Table().Filter("username", Operator.ILike, "%SUPA%").Get(); - var messagesResponse = await client.Table().Get(); - - var supaFilteredMessages = filteredResponse.Models; - var linqFilteredMessages = messagesResponse.Models.Where(m => m.UserName!.Contains("SUPA", StringComparison.OrdinalIgnoreCase)).ToList(); - - CollectionAssert.AreEqual(linqFilteredMessages, supaFilteredMessages); - } - - [TestMethod("filters: fts")] - public async Task TestFullTextSearch() - { - var client = new Client(baseUrl); - var config = new FullTextSearchConfig("'fat' & 'cat'", "english"); - - var filteredResponse = await client.Table().Filter("catchphrase", Operator.FTS, config).Get(); - - Assert.AreEqual(1, filteredResponse.Models.Count); - Assert.AreEqual("supabot", filteredResponse.Models.FirstOrDefault()?.Username); - } - - [TestMethod("filters: plfts")] - public async Task TestPlaintoFullTextSearch() - { - var client = new Client(baseUrl); - var config = new FullTextSearchConfig("'fat' & 'cat'", "english"); - - var filteredResponse = await client.Table().Filter("catchphrase", Operator.PLFTS, config).Get(); - - Assert.AreEqual(1, filteredResponse.Models.Count); - Assert.AreEqual("supabot", filteredResponse.Models.FirstOrDefault()?.Username); - } - - [TestMethod("filters: phfts")] - public async Task TestPhrasetoFullTextSearch() - { - var client = new Client(baseUrl); - var config = new FullTextSearchConfig("'cat'", "english"); - - var filteredResponse = await client.Table().Filter("catchphrase", Operator.PHFTS, config).Get(); - var usersResponse = await client.Table().Filter("catchphrase", Operator.NotEqual, null).Get(); - - var testAgainst = usersResponse.Models.Where(u => u.Catchphrase!.Contains("'cat'")).ToList(); - CollectionAssert.AreEqual(testAgainst, filteredResponse.Models); - } - - [TestMethod("filters: wfts")] - public async Task TestWebFullTextSearch() - { - var client = new Client(baseUrl); - var config = new FullTextSearchConfig("'fat' & 'cat'", "english"); - - var filteredResponse = await client.Table().Filter("catchphrase", Operator.WFTS, config).Get(); - - Assert.AreEqual(1, filteredResponse.Models.Count); - Assert.AreEqual("supabot", filteredResponse.Models.FirstOrDefault()?.Username); - } - - [TestMethod("filters: match")] - public async Task TestMatchFilter() - { - //Arrange - var client = new Client(baseUrl); - var usersResponse = await client.Table().Get(); - var testAgaint = usersResponse.Models.Where(u => u.Username == "kiwicopple" && u.Status == "OFFLINE").ToList(); - - //Act - var filters = new Dictionary() - { - { "username", "kiwicopple" }, - { "status", "OFFLINE" } - }; - var filteredResponse = await client.Table().Match(filters).Get(); - - //Assert - CollectionAssert.AreEqual(testAgaint, filteredResponse.Models); - } - - [TestMethod("select: basic")] - public async Task TestSelect() - { - var client = new Client(baseUrl); - - var response = await client.Table().Select("username").Get(); - foreach (var user in response.Models) - { - Assert.IsNotNull(user.Username); - Assert.IsNull(user.Catchphrase); - Assert.IsNull(user.Status); - } - } - - [TestMethod("select: multiple columns")] - public async Task TestSelectWithMultipleColumns() - { - var client = new Client(baseUrl); - - var response = await client.Table().Select("username, status").Get(); - foreach (var user in response.Models) - { - Assert.IsNotNull(user.Username); - Assert.IsNotNull(user.Status); - Assert.IsNull(user.Catchphrase); - } - } - - [TestMethod("insert: bulk")] - public async Task TestInsertBulk() - { - var client = new Client(baseUrl); - var rocketUser = new User - { - Username = "rocket", - AgeRange = new IntRange(35, 40), - Status = "ONLINE" - }; - - var aceUser = new User - { - Username = "ace", - AgeRange = new IntRange(21, 28), - Status = "OFFLINE" - }; - - var users = new List - { - rocketUser, - aceUser - }; - - var response = await client.Table().Insert(users); - var insertedUsers = response.Models; - - - CollectionAssert.AreEqual(users, insertedUsers); - - await client.Table().Delete(rocketUser); - await client.Table().Delete(aceUser); - } - - [TestMethod("count")] - public async Task TestCount() - { - var client = new Client(baseUrl); - - var resp = await client.Table().Count(CountType.Exact); - // Lame, I know. We should check an actual number. However, the tests are run asynchronously - // so we get inconsitent counts depending on the order that the tests are actually executed. - Assert.IsNotNull(resp); - } - - [TestMethod("count: with filter")] - public async Task TestCountWithFilter() - { - var client = new Client(baseUrl); - - var resp = await client.Table().Filter("status", Operator.Equals, "ONLINE").Count(CountType.Exact); - Assert.IsNotNull(resp); - } - - [TestMethod("support: int arrays")] - public async Task TestSupportIntArraysAsLists() - { - var client = new Client(baseUrl); - - var numbers = new List { 1, 2, 3 }; - var result = await client.Table().Insert(new User { Username = "WALRUS", Status = "ONLINE", Catchphrase = "I'm a walrus", FavoriteNumbers = numbers, AgeRange = new IntRange(15, 25) }, new QueryOptions { Upsert = true }); - - CollectionAssert.AreEqual(numbers, result.Models.First().FavoriteNumbers); - } - - [TestMethod("stored procedure")] - public async Task TestStoredProcedure() - { - //Arrange - var client = new Client(baseUrl); - - //Act - var parameters = new Dictionary() - { - { "name_param", "supabot" } - }; - var response = await client.Rpc("get_status", parameters); - - //Assert - Assert.AreEqual(true, response?.ResponseMessage?.IsSuccessStatusCode); - Assert.AreEqual(true, response?.Content?.Contains("OFFLINE")); - } - - [TestMethod("switch schema")] - public async Task TestSwitchSchema() - { - //Arrange - var options = new ClientOptions - { - Schema = "personal" - }; - var client = new Client(baseUrl, options); - - //Act - var response = await client.Table().Filter("username", Operator.Equals, "leroyjenkins").Get(); - - //Assert - Assert.AreEqual(1, response.Models.Count); - Assert.AreEqual("leroyjenkins", response.Models.FirstOrDefault()?.Username); - } - - [TestMethod("JSON.NET NullValueHandling is processed on Columns")] - public async Task TestNullValueHandlingOnColumn() - { - var client = new Client(baseUrl); - var now = DateTime.UtcNow; - var model = new KitchenSink - { - DateTimeValue = now, - DateTimeValue1 = now - }; - - var insertResponse = await client.Table().Insert(model); - - Assert.AreEqual(now.ToString(), insertResponse.Models[0].DateTimeValue.ToString()); - Assert.AreEqual(now.ToString(), insertResponse.Models[0].DateTimeValue1.ToString()); - - insertResponse.Models[0].DateTimeValue = null; - insertResponse.Models[0].DateTimeValue1 = null; - - var updatedResponse = await client.Table().Update(insertResponse.Models[0]); - - Assert.IsNull(updatedResponse.Models[0].DateTimeValue); - Assert.IsNotNull(updatedResponse.Models[0].DateTimeValue1); - } - - [TestMethod("Test cancellation token")] - public async Task TestCancellationToken() - { - var client = new Client(baseUrl); - var now = DateTime.UtcNow; - - var cts = new CancellationTokenSource(); - cts.CancelAfter(TimeSpan.FromTicks(1)); - - var model = new KitchenSink - { - DateTimeValue = now, - DateTimeValue1 = now - }; - - ModeledResponse? insertResponse = null; - - try - { - insertResponse = await client.Table().Insert(model, cancellationToken: cts.Token); - } - catch (Exception ex) - { - Assert.IsInstanceOfType(ex, typeof(TaskCanceledException)); - Assert.IsNull(insertResponse); - } - } - - [TestMethod("references")] - public async Task TestReferences() - { - var client = new Client(baseUrl); - - var movies = await client.Table() - .Order(x => x.Id, Ordering.Ascending) - .Get(); - - Assert.IsTrue(movies.Models.Count > 0); - - var first = movies.Models.First(); - Assert.IsTrue(first.Persons?.Count > 0); - - var people = first.Persons.First(); - Assert.IsNotNull(people.Profile); - - var person = await client.Table() - .Filter("first_name", Operator.Equals, "Bob") - .Single(); - - Assert.IsNotNull(person?.Profile); - - var byEmail = await client.Table() - .Order(x => x.CreatedAt, Ordering.Ascending) - .Filter("profile.email", Operator.Equals, "bob.saggett@supabase.io") - .Single(); - - Assert.IsNotNull(byEmail); - } - - [TestMethod("columns")] - public async Task TestColumns() - { - var client = new Client(baseUrl); - - var movies = await client.Table().Get(); - var first = movies.Models.First(); - var originalName = first.Name; - var originalDate = first.CreatedAt; - var newName = $"{first.Name} (Changed)"; - - first.Name = newName; - first.CreatedAt = DateTime.UtcNow; - - var result = await client.Table().Columns(new[] { "name" }).Update(first); - - Assert.AreEqual(originalDate, result.Models.First().CreatedAt); - Assert.AreNotEqual(originalName, result.Models.First().Name); - } - } -} diff --git a/PostgrestTests/ClientTests.cs b/PostgrestTests/ClientTests.cs new file mode 100644 index 0000000..e285b4a --- /dev/null +++ b/PostgrestTests/ClientTests.cs @@ -0,0 +1,1212 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net.Http; +using System.Runtime.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Postgrest; +using Postgrest.Exceptions; +using Postgrest.Responses; +using PostgrestTests.Models; +using static Postgrest.Constants; + +namespace PostgrestTests +{ + [TestClass] + public class ClientTests + { + private const string BaseUrl = "http://localhost:3000"; + + [TestMethod("Initializes")] + public void TestInitialization() + { + var client = new Client(BaseUrl); + Assert.AreEqual(BaseUrl, client.BaseUrl); + } + + [TestMethod("with optional query params")] + public void TestQueryParams() + { + var client = new Client(BaseUrl, options: new ClientOptions + { + QueryParams = new Dictionary + { + { "some-param", "foo" }, + { "other-param", "bar" } + } + }); + + Assert.AreEqual($"{BaseUrl}/users?some-param=foo&other-param=bar", + (client.Table() as Table)!.GenerateUrl()); + } + + [TestMethod("will use TableAttribute")] + public void TestTableAttribute() + { + var client = new Client(BaseUrl); + Assert.AreEqual($"{BaseUrl}/users", (client.Table() as Table)!.GenerateUrl()); + } + + [TestMethod("will default to Class.name in absence of TableAttribute")] + public void TestTableAttributeDefault() + { + var client = new Client(BaseUrl); + Assert.AreEqual($"{BaseUrl}/Stub", (client.Table() as Table)!.GenerateUrl()); + } + + [TestMethod("will set header from options")] + public void TestHeadersToken() + { + var headers = Postgrest.Helpers.PrepareRequestHeaders(HttpMethod.Get, + new Dictionary { { "Authorization", "Bearer token" } }); + + Assert.AreEqual("Bearer token", headers["Authorization"]); + } + + [TestMethod("will set apikey as query string")] + public void TestQueryApiKey() + { + var client = new Client(BaseUrl, new ClientOptions + { + Headers = + { + { "apikey", "some-key" } + } + }); + Assert.AreEqual($"{BaseUrl}/users?apikey=some-key", (client.Table() as Table)!.GenerateUrl()); + } + + [TestMethod("filters: simple")] + public void TestFiltersSimple() + { + var client = new Client(BaseUrl); + var dict = new Dictionary + { + { Operator.Equals, "eq.bar" }, + { Operator.GreaterThan, "gt.bar" }, + { Operator.GreaterThanOrEqual, "gte.bar" }, + { Operator.LessThan, "lt.bar" }, + { Operator.LessThanOrEqual, "lte.bar" }, + { Operator.NotEqual, "neq.bar" }, + { Operator.Is, "is.bar" }, + }; + + foreach (var pair in dict) + { + var filter = new QueryFilter("foo", pair.Key, "bar"); + var result = (client.Table() as Table)!.PrepareFilter(filter); + Assert.AreEqual("foo", result.Key); + Assert.AreEqual(pair.Value, result.Value); + } + } + + [TestMethod("filters: like & ilike")] + public void TestFiltersLike() + { + var client = new Client(BaseUrl); + var dict = new Dictionary + { + { Operator.Like, "like.*bar*" }, + { Operator.ILike, "ilike.*bar*" }, + }; + + foreach (var pair in dict) + { + var filter = new QueryFilter("foo", pair.Key, "%bar%"); + var result = (client.Table() as Table)!.PrepareFilter(filter); + Assert.AreEqual("foo", result.Key); + Assert.AreEqual(pair.Value, result.Value); + } + } + + /// + /// See: http://postgrest.org/en/v7.0.0/api.html#operators + /// + [TestMethod("filters: `In` with List arguments")] + public void TestFiltersArraysWithLists() + { + var client = new Client(BaseUrl); + + // UrlEncoded {"bar","buzz"} + string exp = "(\"bar\",\"buzz\")"; + var dict = new Dictionary + { + { Operator.In, $"in.{exp}" }, + }; + + foreach (var pair in dict) + { + var list = new List { "bar", "buzz" }; + var filter = new QueryFilter("foo", pair.Key, list); + var result = (client.Table() as Table)!.PrepareFilter(filter); + Assert.AreEqual("foo", result.Key); + Assert.AreEqual(pair.Value, result.Value); + } + } + + /// + /// See: http://postgrest.org/en/v7.0.0/api.html#operators + /// + [TestMethod("filters: `Contains`, `ContainedIn`, `Overlap` with List arguments")] + public void TestFiltersContainsArraysWithLists() + { + var client = new Client(BaseUrl); + + // UrlEncoded {bar,buzz} - according to documentation, does not accept quoted strings + string exp = "{bar,buzz}"; + var dict = new Dictionary + { + { Operator.Contains, $"cs.{exp}" }, + { Operator.ContainedIn, $"cd.{exp}" }, + { Operator.Overlap, $"ov.{exp}" }, + }; + + foreach (var pair in dict) + { + var list = new List { "bar", "buzz" }; + var filter = new QueryFilter("foo", pair.Key, list); + var result = (client.Table() as Table)!.PrepareFilter(filter); + Assert.AreEqual("foo", result.Key); + Assert.AreEqual(pair.Value, result.Value); + } + } + + [TestMethod("filters: arrays with Dictionary arguments")] + public void TestFiltersArraysWithDictionaries() + { + var client = new Client(BaseUrl); + + string exp = "{\"bar\":100,\"buzz\":\"zap\"}"; + var dict = new Dictionary + { + { Operator.In, $"in.{exp}" }, + { Operator.Contains, $"cs.{exp}" }, + { Operator.ContainedIn, $"cd.{exp}" }, + { Operator.Overlap, $"ov.{exp}" }, + }; + + foreach (var pair in dict) + { + var value = new Dictionary { { "bar", 100 }, { "buzz", "zap" } }; + var filter = new QueryFilter("foo", pair.Key, value); + var result = (client.Table() as Table)!.PrepareFilter(filter); + Assert.AreEqual("foo", result.Key); + Assert.AreEqual(pair.Value, result.Value); + } + } + + [TestMethod("filters: full text search")] + public void TestFiltersFullTextSearch() + { + var client = new Client(BaseUrl); + + // UrlEncoded [2,3] + var exp = "(english).bar"; + var dict = new Dictionary + { + { Operator.FTS, $"fts{exp}" }, + { Operator.PHFTS, $"phfts{exp}" }, + { Operator.PLFTS, $"plfts{exp}" }, + { Operator.WFTS, $"wfts{exp}" }, + }; + + foreach (var pair in dict) + { + var config = new FullTextSearchConfig("bar", "english"); + var filter = new QueryFilter("foo", pair.Key, config); + var result = (client.Table() as Table)!.PrepareFilter(filter); + Assert.AreEqual("foo", result.Key); + Assert.AreEqual(pair.Value, result.Value); + } + } + + [TestMethod("filters: ranges")] + public void TestFiltersRanges() + { + var client = new Client(BaseUrl); + + var exp = "[2,3]"; + var dict = new Dictionary + { + { Operator.StrictlyLeft, $"sl.{exp}" }, + { Operator.StrictlyRight, $"sr.{exp}" }, + { Operator.NotRightOf, $"nxr.{exp}" }, + { Operator.NotLeftOf, $"nxl.{exp}" }, + { Operator.Adjacent, $"adj.{exp}" }, + }; + + foreach (var pair in dict) + { + var config = new IntRange(2, 3); + var filter = new QueryFilter("foo", pair.Key, config); + var result = (client.Table() as Table)!.PrepareFilter(filter); + Assert.AreEqual("foo", result.Key); + Assert.AreEqual(pair.Value, result.Value); + } + } + + [TestMethod("filters: not")] + public void TestFiltersNot() + { + var client = new Client(BaseUrl); + var filter = new QueryFilter("foo", Operator.Equals, "bar"); + var notFilter = new QueryFilter(Operator.Not, filter); + var result = (client.Table() as Table)!.PrepareFilter(notFilter); + + Assert.AreEqual("foo", result.Key); + Assert.AreEqual("not.eq.bar", result.Value); + } + + [TestMethod("filters: and & or")] + public void TestFiltersAndOr() + { + var client = new Client(BaseUrl); + var exp = "(a.gte.0,a.lte.100)"; + + var dict = new Dictionary + { + { Operator.And, $"and={exp}" }, + { Operator.Or, $"or={exp}" }, + }; + + var subfilters = new List + { + new QueryFilter("a", Operator.GreaterThanOrEqual, "0"), + new QueryFilter("a", Operator.LessThanOrEqual, "100") + }; + + foreach (var pair in dict) + { + var filter = new QueryFilter(pair.Key, subfilters); + var result = (client.Table() as Table)!.PrepareFilter(filter); + Assert.AreEqual(pair.Value, $"{result.Key}={result.Value}"); + } + } + + [TestMethod("update: basic")] + public async Task TestBasicUpdate() + { + var client = new Client(BaseUrl); + + var user = await client.Table().Filter("username", Operator.Equals, "supabot").Single(); + + if (user != null) + { + // Update user status + user.Status = "OFFLINE"; + var response = await user.Update(); + + var updatedUser = response.Models.FirstOrDefault(); + + if (updatedUser == null) + Assert.Fail(); + + Assert.AreEqual(1, response.Models.Count); + Assert.AreEqual(user.Username, updatedUser.Username); + Assert.AreEqual(user.Status, updatedUser.Status); + } + } + + + [TestMethod("insert: basic")] + public async Task TestBasicInsert() + { + var client = new Client(BaseUrl); + + var newUser = new User + { + Username = Guid.NewGuid().ToString(), + AgeRange = new IntRange(18, 22), + Catchphrase = "what a shot", + Status = "ONLINE" + }; + + var response = await client.Table().Insert(newUser); + var insertedUser = response.Models.First(); + + Assert.AreEqual(1, response.Models.Count); + Assert.AreEqual(newUser.Username, insertedUser.Username); + Assert.AreEqual(newUser.AgeRange, insertedUser.AgeRange); + Assert.AreEqual(newUser.Status, insertedUser.Status); + + await client.Table().Delete(newUser); + + var response2 = await client.Table() + .Insert(newUser, new QueryOptions { Returning = QueryOptions.ReturnType.Minimal }); + Assert.AreEqual("", response2.Content); + + await client.Table().Delete(newUser); + } + + [TestMethod("insert: headers generated")] + public void TestInsertHeaderGeneration() + { + var option = new QueryOptions(); + Assert.AreEqual("return=representation", option.ToHeaders()["Prefer"]); + + option.Returning = QueryOptions.ReturnType.Minimal; + Assert.AreEqual("return=minimal", option.ToHeaders()["Prefer"]); + + option.Upsert = true; + Assert.AreEqual("resolution=merge-duplicates,return=minimal", option.ToHeaders()["Prefer"]); + + option.DuplicateResolution = QueryOptions.DuplicateResolutionType.IgnoreDuplicates; + Assert.AreEqual("resolution=ignore-duplicates,return=minimal", option.ToHeaders()["Prefer"]); + + option.Upsert = false; + option.Count = QueryOptions.CountType.Exact; + Assert.AreEqual("return=minimal,count=exact", option.ToHeaders()["Prefer"]); + } + + [TestMethod( + "Exceptions: Throws when inserting a user with same primary key value as an existing one without upsert option")] + public async Task TestThrowsRequestExceptionInsertPkConflict() + { + var client = new Client(BaseUrl); + + await Assert.ThrowsExceptionAsync(async () => + { + var newUser = new User + { + Username = "supabot" + }; + await client.Table().Insert(newUser); + }); + } + + [TestMethod("insert: upsert")] + public async Task TestInsertWithUpsert() + { + var client = new Client(BaseUrl); + + var supaUpdated = new User + { + Username = "supabot", + AgeRange = new IntRange(3, 8), + Status = "OFFLINE", + Catchphrase = "fat cat" + }; + + var insertOptions = new QueryOptions + { + Upsert = true + }; + + var response = await client.Table().Insert(supaUpdated, insertOptions); + + var kitchenSink1 = new KitchenSink + { + Id = 2, + UniqueValue = "Testing" + }; + + var ks1 = await client.Table().OnConflict("unique_value").Upsert(kitchenSink1); + var uks1 = ks1.Models.First(); + uks1.StringValue = "Testing 1"; + await client.Table().OnConflict(x => x.UniqueValue!).Upsert(uks1); + + var updatedUser = response.Models.First(); + + Assert.AreEqual(1, response.Models.Count); + Assert.AreEqual(supaUpdated.Username, updatedUser.Username); + Assert.AreEqual(supaUpdated.AgeRange, updatedUser.AgeRange); + Assert.AreEqual(supaUpdated.Status, updatedUser.Status); + + await client.Table().Get(); + } + + [TestMethod("order: basic")] + public async Task TestOrderBy() + { + var client = new Client(BaseUrl); + + var orderedResponse = await client.Table().Order("username", Ordering.Descending).Get(); + var unorderedResponse = await client.Table().Get(); + + var supaOrderedUsers = orderedResponse.Models; + var linqOrderedUsers = unorderedResponse.Models.OrderByDescending(u => u.Username).ToList(); + + CollectionAssert.AreEqual(linqOrderedUsers, supaOrderedUsers); + } + + [TestMethod("limit: basic")] + public async Task TestLimit() + { + var client = new Client(BaseUrl); + + var limitedUsersResponse = await client.Table().Limit(2).Get(); + var usersResponse = await client.Table().Get(); + + var supaLimitUsers = limitedUsersResponse.Models; + var linqLimitUsers = usersResponse.Models.Take(2).ToList(); + + CollectionAssert.AreEqual(linqLimitUsers, supaLimitUsers); + } + + [TestMethod("offset: basic")] + public async Task TestOffset() + { + var client = new Client(BaseUrl); + + var offsetUsersResponse = await client.Table().Offset(2).Get(); + var usersResponse = await client.Table().Get(); + + var supaOffsetUsers = offsetUsersResponse.Models; + var linqSkipUsers = usersResponse.Models.Skip(2).ToList(); + + CollectionAssert.AreEqual(linqSkipUsers, supaOffsetUsers); + } + + [TestMethod("range: from")] + public async Task TestRangeFrom() + { + var client = new Client(BaseUrl); + + var rangeUsersResponse = await client.Table().Range(2).Get(); + var usersResponse = await client.Table().Get(); + + var supaRangeUsers = rangeUsersResponse.Models; + var linqSkipUsers = usersResponse.Models.Skip(2).ToList(); + + CollectionAssert.AreEqual(linqSkipUsers, supaRangeUsers); + } + + [TestMethod("range: from and to")] + public async Task TestRangeFromAndTo() + { + var client = new Client(BaseUrl); + + var rangeUsersResponse = await client.Table().Range(1, 3).Get(); + var usersResponse = await client.Table().Get(); + + var supaRangeUsers = rangeUsersResponse.Models; + var linqRangeUsers = usersResponse.Models.Skip(1).Take(3).ToList(); + + CollectionAssert.AreEqual(linqRangeUsers, supaRangeUsers); + } + + [TestMethod("range: limit and offset")] + public async Task TestRangeWithLimitAndOffset() + { + var client = new Client(BaseUrl); + + var rangeUsersResponse = await client.Table().Limit(1).Offset(3).Get(); + var usersResponse = await client.Table().Get(); + + var supaRangeUsers = rangeUsersResponse.Models; + var linqRangeUsers = usersResponse.Models.Skip(3).Take(1).ToList(); + + CollectionAssert.AreEqual(linqRangeUsers, supaRangeUsers); + } + + [TestMethod("filters: not")] + public async Task TestNotFilter() + { + var client = new Client(BaseUrl); + var filter = new QueryFilter("username", Operator.Equals, "supabot"); + + var filteredResponse = await client.Table().Not(filter).Get(); + var usersResponse = await client.Table().Get(); + + var supaFilteredUsers = filteredResponse.Models; + var linqFilteredUsers = usersResponse.Models.Where(u => u.Username != "supabot").ToList(); + + CollectionAssert.AreEqual(linqFilteredUsers, supaFilteredUsers); + } + + [TestMethod("filters: `not` shorthand")] + public async Task TestNotShorthandFilter() + { + var client = new Client(BaseUrl); + + // Standard NOT Equal Op. + var filteredResponse = await client.Table().Not("username", Operator.Equals, "supabot").Get(); + var usersResponse = await client.Table().Get(); + + var supaFilteredUsers = filteredResponse.Models; + var linqFilteredUsers = usersResponse.Models.Where(u => u.Username != "supabot").ToList(); + + CollectionAssert.AreEqual(linqFilteredUsers, supaFilteredUsers); + + // NOT `In` Shorthand Op. + var notInFilterResponse = await client.Table() + .Not("username", Operator.In, new List { "supabot", "kiwicopple" }).Get(); + var supaNotInList = notInFilterResponse.Models; + var linqNotInList = usersResponse.Models.Where(u => u.Username != "supabot") + .Where(u => u.Username != "kiwicopple").ToList(); + + CollectionAssert.AreEqual(supaNotInList, linqNotInList); + } + + [TestMethod("filters: null operation `Equals`")] + public async Task TestEqualsNullFilterEquals() + { + var client = new Client(BaseUrl); + + await client.Table() + .Insert(new User { Username = "acupofjose", Status = "ONLINE", Catchphrase = null }, + new QueryOptions { Upsert = true }); + + var filteredResponse = await client.Table().Filter("catchphrase", Operator.Equals, null).Get(); + var usersResponse = await client.Table().Get(); + + var supaFilteredUsers = filteredResponse.Models; + var linqFilteredUsers = usersResponse.Models.Where(u => u.Catchphrase == null).ToList(); + + CollectionAssert.AreEqual(linqFilteredUsers, supaFilteredUsers); + } + + [TestMethod("filters: null operation `Is`")] + public async Task TestEqualsNullFilterIs() + { + var client = new Client(BaseUrl); + + await client.Table() + .Insert(new User { Username = "acupofjose", Status = "ONLINE", Catchphrase = null }, + new QueryOptions { Upsert = true }); + + var filteredResponse = await client.Table().Filter("catchphrase", Operator.Is, null).Get(); + var usersResponse = await client.Table().Get(); + + var supaFilteredUsers = filteredResponse.Models; + var linqFilteredUsers = usersResponse.Models.Where(u => u.Catchphrase == null).ToList(); + + CollectionAssert.AreEqual(linqFilteredUsers, supaFilteredUsers); + } + + [TestMethod("filters: null operation `NotEquals`")] + public async Task TestEqualsNullFilterNotEquals() + { + var client = new Client(BaseUrl); + + await client.Table() + .Insert(new User { Username = "acupofjose", Status = "ONLINE", Catchphrase = null }, + new QueryOptions { Upsert = true }); + + var filteredResponse = await client.Table().Filter("catchphrase", Operator.NotEqual, null).Get(); + var usersResponse = await client.Table().Get(); + + var supaFilteredUsers = filteredResponse.Models; + var linqFilteredUsers = usersResponse.Models.Where(u => u.Catchphrase != null).ToList(); + + CollectionAssert.AreEqual(linqFilteredUsers, supaFilteredUsers); + } + + [TestMethod("filters: null operation `Not`")] + public async Task TestEqualsNullNot() + { + var client = new Client(BaseUrl); + + await client.Table() + .Insert(new User { Username = "acupofjose", Status = "ONLINE", Catchphrase = null }, + new QueryOptions { Upsert = true }); + + var filteredResponse = await client.Table().Filter("catchphrase", Operator.Not, null).Get(); + var usersResponse = await client.Table().Get(); + + var supaFilteredUsers = filteredResponse.Models; + var linqFilteredUsers = usersResponse.Models.Where(u => u.Catchphrase != null).ToList(); + + CollectionAssert.AreEqual(linqFilteredUsers, supaFilteredUsers); + } + + [TestMethod("filters: in")] + public async Task TestInFilter() + { + var client = new Client(BaseUrl); + + var criteria = new List { "supabot", "kiwicopple" }; + + var filteredResponse = await client.Table().Filter("username", Operator.In, criteria) + .Order("username", Ordering.Descending).Get(); + var usersResponse = await client.Table().Get(); + + var supaFilteredUsers = filteredResponse.Models; + var linqFilteredUsers = usersResponse.Models.OrderByDescending(u => u.Username) + .Where(u => u.Username == "supabot" || u.Username == "kiwicopple").ToList(); + + CollectionAssert.AreEqual(linqFilteredUsers, supaFilteredUsers); + } + + [TestMethod("filters: eq")] + public async Task TestEqualsFilter() + { + var client = new Client(BaseUrl); + + var filteredResponse = await client.Table().Filter("username", Operator.Equals, "supabot").Get(); + var usersResponse = await client.Table().Get(); + + var supaFilteredUsers = filteredResponse.Models; + var linqFilteredUsers = usersResponse.Models.Where(u => u.Username == "supabot").ToList(); + + Assert.AreEqual(1, supaFilteredUsers.Count); + CollectionAssert.AreEqual(linqFilteredUsers, supaFilteredUsers); + } + + [TestMethod("filters: gt")] + public async Task TestGreaterThanFilter() + { + var client = new Client(BaseUrl); + + var filteredResponse = await client.Table().Filter("id", Operator.GreaterThan, "1").Get(); + var messagesResponse = await client.Table().Get(); + + var supaFilteredMessages = filteredResponse.Models; + var linqFilteredMessages = messagesResponse.Models.Where(m => m.Id > 1).ToList(); + + Assert.AreEqual(1, supaFilteredMessages.Count); + CollectionAssert.AreEqual(linqFilteredMessages, supaFilteredMessages); + } + + [TestMethod("filters: gte")] + public async Task TestGreaterThanOrEqualFilter() + { + var client = new Client(BaseUrl); + + var filteredResponse = await client.Table().Filter("id", Operator.GreaterThanOrEqual, "1").Get(); + var messagesResponse = await client.Table().Get(); + + var supaFilteredMessages = filteredResponse.Models; + var linqFilteredMessages = messagesResponse.Models.Where(m => m.Id >= 1).ToList(); + + CollectionAssert.AreEqual(linqFilteredMessages, supaFilteredMessages); + } + + [TestMethod("filters: lt")] + public async Task TestLessThanFilter() + { + var client = new Client(BaseUrl); + + var filteredResponse = await client.Table().Filter("id", Operator.LessThan, "2").Get(); + var messagesResponse = await client.Table().Get(); + + var supaFilteredMessages = filteredResponse.Models; + var linqFilteredMessages = messagesResponse.Models.Where(m => m.Id < 2).ToList(); + + CollectionAssert.AreEqual(linqFilteredMessages, supaFilteredMessages); + } + + [TestMethod("filters: lte")] + public async Task TestLessThanOrEqualFilter() + { + var client = new Client(BaseUrl); + + var filteredResponse = await client.Table().Filter("id", Operator.LessThanOrEqual, "2").Get(); + var messagesResponse = await client.Table().Get(); + + var supaFilteredMessages = filteredResponse.Models; + var linqFilteredMessages = messagesResponse.Models.Where(m => m.Id <= 2).ToList(); + + CollectionAssert.AreEqual(linqFilteredMessages, supaFilteredMessages); + } + + [TestMethod("filters: nqe")] + public async Task TestNotEqualFilter() + { + var client = new Client(BaseUrl); + + var filteredResponse = await client.Table().Filter("id", Operator.NotEqual, "2").Get(); + var messagesResponse = await client.Table().Get(); + + var supaFilteredMessages = filteredResponse.Models; + var linqFilteredMessages = messagesResponse.Models.Where(m => m.Id != 2).ToList(); + + CollectionAssert.AreEqual(linqFilteredMessages, supaFilteredMessages); + } + + [TestMethod("filters: like")] + public async Task TestLikeFilter() + { + var client = new Client(BaseUrl); + + var filteredResponse = await client.Table().Filter("username", Operator.Like, "s%").Get(); + var messagesResponse = await client.Table().Get(); + + var supaFilteredMessages = filteredResponse.Models; + var linqFilteredMessages = messagesResponse.Models.Where(m => m.UserName!.StartsWith("s")).ToList(); + + CollectionAssert.AreEqual(linqFilteredMessages, supaFilteredMessages); + } + + [TestMethod("filters: cs")] + public async Task TestContainsFilter() + { + var client = new Client(BaseUrl); + + await client.Table() + .Insert(new User { Username = "skikra", Status = "ONLINE", AgeRange = new IntRange(1, 3) }, + new QueryOptions { Upsert = true }); + var filteredResponse = + await client.Table().Filter("age_range", Operator.Contains, new IntRange(1, 2)).Get(); + var usersResponse = await client.Table().Get(); + + var testAgainst = usersResponse.Models + .Where(m => m.AgeRange?.Start.Value <= 1 && m.AgeRange?.End.Value >= 2).ToList(); + + CollectionAssert.AreEqual(testAgainst, filteredResponse.Models); + } + + [TestMethod("filters: cd")] + public async Task TestContainedFilter() + { + var client = new Client(BaseUrl); + + var filteredResponse = await client.Table() + .Filter("age_range", Operator.ContainedIn, new IntRange(25, 35)).Get(); + var usersResponse = await client.Table().Get(); + + var testAgainst = usersResponse.Models + .Where(m => m.AgeRange?.Start.Value >= 25 && m.AgeRange?.End.Value <= 35).ToList(); + + CollectionAssert.AreEqual(testAgainst, filteredResponse.Models); + } + + [TestMethod("filters: sr")] + public async Task TestStrictlyLeftFilter() + { + var client = new Client(BaseUrl); + + await client.Table() + .Insert(new User { Username = "minds3t", Status = "ONLINE", AgeRange = new IntRange(3, 6) }, + new QueryOptions { Upsert = true }); + var filteredResponse = await client.Table() + .Filter("age_range", Operator.StrictlyLeft, new IntRange(7, 8)).Get(); + var usersResponse = await client.Table().Get(); + + var testAgainst = usersResponse.Models.Where(m => m.AgeRange?.Start.Value < 7 && m.AgeRange?.End.Value < 7) + .ToList(); + + CollectionAssert.AreEqual(testAgainst, filteredResponse.Models); + } + + [TestMethod("filters: sl")] + public async Task TestStrictlyRightFilter() + { + var client = new Client(BaseUrl); + + var filteredResponse = await client.Table() + .Filter("age_range", Operator.StrictlyRight, new IntRange(1, 2)).Get(); + var usersResponse = await client.Table().Get(); + + var testAgainst = usersResponse.Models.Where(m => m.AgeRange?.Start.Value > 2 && m.AgeRange?.End.Value > 2) + .ToList(); + + CollectionAssert.AreEqual(testAgainst, filteredResponse.Models); + } + + [TestMethod("filters: nxl")] + public async Task TestNotExtendToLeftFilter() + { + var client = new Client(BaseUrl); + + var filteredResponse = + await client.Table().Filter("age_range", Operator.NotLeftOf, new IntRange(2, 4)).Get(); + var usersResponse = await client.Table().Get(); + + var testAgainst = usersResponse.Models + .Where(m => m.AgeRange?.Start.Value >= 2 && m.AgeRange?.End.Value >= 2).ToList(); + + CollectionAssert.AreEqual(testAgainst, filteredResponse.Models); + } + + [TestMethod("filters: nxr")] + public async Task TestNotExtendToRightFilter() + { + var client = new Client(BaseUrl); + + var filteredResponse = + await client.Table().Filter("age_range", Operator.NotRightOf, new IntRange(2, 4)).Get(); + var usersResponse = await client.Table().Get(); + + var testAgainst = usersResponse.Models + .Where(m => m.AgeRange?.Start.Value <= 4 && m.AgeRange?.End.Value <= 4).ToList(); + + CollectionAssert.AreEqual(testAgainst, filteredResponse.Models); + } + + [TestMethod("filters: adj")] + public async Task TestAdjacentFilter() + { + var client = new Client(BaseUrl); + + var filteredResponse = + await client.Table().Filter("age_range", Operator.Adjacent, new IntRange(1, 2)).Get(); + var usersResponse = await client.Table().Get(); + + var testAgainst = usersResponse.Models + .Where(m => m.AgeRange?.End.Value == 0 || m.AgeRange?.Start.Value == 3).ToList(); + + CollectionAssert.AreEqual(testAgainst, filteredResponse.Models); + } + + [TestMethod("filters: ov")] + public async Task TestOverlapFilter() + { + var client = new Client(BaseUrl); + + var filteredResponse = + await client.Table().Filter("age_range", Operator.Overlap, new IntRange(2, 4)).Get(); + var usersResponse = await client.Table().Get(); + + var testAgainst = usersResponse.Models + .Where(m => m.AgeRange?.Start.Value <= 4 && m.AgeRange?.End.Value >= 2).ToList(); + + CollectionAssert.AreEqual(testAgainst, filteredResponse.Models); + } + + [TestMethod("filters: ilike")] + public async Task TestILikeFilter() + { + var client = new Client(BaseUrl); + + var filteredResponse = await client.Table().Filter("username", Operator.ILike, "%SUPA%").Get(); + var messagesResponse = await client.Table().Get(); + + var supaFilteredMessages = filteredResponse.Models; + var linqFilteredMessages = messagesResponse.Models.Where(m => m.UserName!.Contains("SUPA", StringComparison.OrdinalIgnoreCase)).ToList(); + + CollectionAssert.AreEqual(linqFilteredMessages, supaFilteredMessages); + } + + [TestMethod("filters: fts")] + public async Task TestFullTextSearch() + { + var client = new Client(BaseUrl); + var config = new FullTextSearchConfig("'fat' & 'cat'", "english"); + + var filteredResponse = await client.Table().Filter("catchphrase", Operator.FTS, config).Get(); + + Assert.AreEqual(1, filteredResponse.Models.Count); + Assert.AreEqual("supabot", filteredResponse.Models.FirstOrDefault()?.Username); + } + + [TestMethod("filters: plfts")] + public async Task TestPlainToFullTextSearch() + { + var client = new Client(BaseUrl); + var config = new FullTextSearchConfig("'fat' & 'cat'", "english"); + + var filteredResponse = await client.Table().Filter("catchphrase", Operator.PLFTS, config).Get(); + + Assert.AreEqual(1, filteredResponse.Models.Count); + Assert.AreEqual("supabot", filteredResponse.Models.FirstOrDefault()?.Username); + } + + [TestMethod("filters: phfts")] + public async Task TestPhraseToFullTextSearch() + { + var client = new Client(BaseUrl); + var config = new FullTextSearchConfig("'cat'", "english"); + + var filteredResponse = await client.Table().Filter("catchphrase", Operator.PHFTS, config).Get(); + var usersResponse = await client.Table().Filter("catchphrase", Operator.NotEqual, null).Get(); + + var testAgainst = usersResponse.Models.Where(u => u.Catchphrase!.Contains("'cat'")).ToList(); + CollectionAssert.AreEqual(testAgainst, filteredResponse.Models); + } + + [TestMethod("filters: wfts")] + public async Task TestWebFullTextSearch() + { + var client = new Client(BaseUrl); + var config = new FullTextSearchConfig("'fat' & 'cat'", "english"); + + var filteredResponse = await client.Table().Filter("catchphrase", Operator.WFTS, config).Get(); + + Assert.AreEqual(1, filteredResponse.Models.Count); + Assert.AreEqual("supabot", filteredResponse.Models.FirstOrDefault()?.Username); + } + + [TestMethod("filters: match")] + public async Task TestMatchFilter() + { + //Arrange + var client = new Client(BaseUrl); + var usersResponse = await client.Table().Get(); + var expected = usersResponse.Models.Where(u => u.Username == "kiwicopple" && u.Status == "OFFLINE") + .ToList(); + + //Act + var filters = new Dictionary + { + { "username", "kiwicopple" }, + { "status", "OFFLINE" } + }; + var filteredResponse = await client.Table().Match(filters).Get(); + + //Assert + CollectionAssert.AreEqual(expected, filteredResponse.Models); + } + + [TestMethod("select: basic")] + public async Task TestSelect() + { + var client = new Client(BaseUrl); + + var response = await client.Table().Select("username").Get(); + foreach (var user in response.Models) + { + Assert.IsNotNull(user.Username); + Assert.IsNull(user.Catchphrase); + Assert.IsNull(user.Status); + } + } + + [TestMethod("select: multiple columns")] + public async Task TestSelectWithMultipleColumns() + { + var client = new Client(BaseUrl); + + var response = await client.Table().Select("username, status").Get(); + foreach (var user in response.Models) + { + Assert.IsNotNull(user.Username); + Assert.IsNotNull(user.Status); + Assert.IsNull(user.Catchphrase); + } + } + + [TestMethod("insert: bulk")] + public async Task TestInsertBulk() + { + var client = new Client(BaseUrl); + var rocketUser = new User + { + Username = "rocket", + AgeRange = new IntRange(35, 40), + Status = "ONLINE" + }; + + var aceUser = new User + { + Username = "ace", + AgeRange = new IntRange(21, 28), + Status = "OFFLINE" + }; + + var users = new List + { + rocketUser, + aceUser + }; + + var response = await client.Table().Insert(users); + var insertedUsers = response.Models; + + + CollectionAssert.AreEqual(users, insertedUsers); + + await client.Table().Delete(rocketUser); + await client.Table().Delete(aceUser); + } + + [TestMethod("count")] + public async Task TestCount() + { + var client = new Client(BaseUrl); + + var resp = await client.Table().Count(CountType.Exact); + // Lame, I know. We should check an actual number. However, the tests are run asynchronously + // so we get inconsistent counts depending on the order that the tests are actually executed. + Assert.IsNotNull(resp); + } + + [TestMethod("count: with filter")] + public async Task TestCountWithFilter() + { + var client = new Client(BaseUrl); + + var resp = await client.Table().Filter("status", Operator.Equals, "ONLINE").Count(CountType.Exact); + Assert.IsNotNull(resp); + } + + [TestMethod("support: int arrays")] + public async Task TestSupportIntArraysAsLists() + { + var client = new Client(BaseUrl); + + var numbers = new List { 1, 2, 3 }; + var result = await client.Table() + .Insert( + new User + { + Username = "WALRUS", Status = "ONLINE", Catchphrase = "I'm a walrus", FavoriteNumbers = numbers, + AgeRange = new IntRange(15, 25) + }, new QueryOptions { Upsert = true }); + + CollectionAssert.AreEqual(numbers, result.Models.First().FavoriteNumbers); + } + + [TestMethod("stored procedure")] + public async Task TestStoredProcedure() + { + //Arrange + var client = new Client(BaseUrl); + + //Act + var parameters = new Dictionary + { + { "name_param", "supabot" } + }; + var response = await client.Rpc("get_status", parameters); + + //Assert + Assert.AreEqual(true, response.ResponseMessage?.IsSuccessStatusCode); + Assert.AreEqual(true, response.Content?.Contains("OFFLINE")); + } + + [TestMethod("switch schema")] + public async Task TestSwitchSchema() + { + //Arrange + var options = new ClientOptions + { + Schema = "personal" + }; + var client = new Client(BaseUrl, options); + + //Act + var response = await client.Table().Filter("username", Operator.Equals, "leroyjenkins").Get(); + + //Assert + Assert.AreEqual(1, response.Models.Count); + Assert.AreEqual("leroyjenkins", response.Models.FirstOrDefault()?.Username); + } + + [TestMethod("JSON.NET NullValueHandling is processed on Columns")] + public async Task TestNullValueHandlingOnColumn() + { + var client = new Client(BaseUrl); + var now = DateTime.UtcNow; + var model = new KitchenSink + { + DateTimeValue = now, + DateTimeValue1 = now + }; + + var insertResponse = await client.Table().Insert(model); + + Assert.AreEqual(now.ToString(CultureInfo.CurrentCulture), + insertResponse.Models[0].DateTimeValue.ToString()); + Assert.AreEqual(now.ToString(CultureInfo.CurrentCulture), + insertResponse.Models[0].DateTimeValue1.ToString()); + + insertResponse.Models[0].DateTimeValue = null; + insertResponse.Models[0].DateTimeValue1 = null; + + var updatedResponse = await client.Table().Update(insertResponse.Models[0]); + + Assert.IsNull(updatedResponse.Models[0].DateTimeValue); + Assert.IsNotNull(updatedResponse.Models[0].DateTimeValue1); + } + + [TestMethod("Test cancellation token")] + public async Task TestCancellationToken() + { + var client = new Client(BaseUrl); + var now = DateTime.UtcNow; + + var cts = new CancellationTokenSource(); + cts.CancelAfter(TimeSpan.FromTicks(1)); + + var model = new KitchenSink + { + DateTimeValue = now, + DateTimeValue1 = now + }; + + ModeledResponse? insertResponse = null; + + try + { + insertResponse = await client.Table().Insert(model, cancellationToken: cts.Token); + } + catch (Exception ex) + { + Assert.IsInstanceOfType(ex, typeof(TaskCanceledException)); + Assert.IsNull(insertResponse); + } + } + + [TestMethod("references")] + public async Task TestReferences() + { + var client = new Client(BaseUrl); + + var movies = await client.Table() + .Order(x => x.Id, Ordering.Ascending) + .Get(); + + Assert.IsTrue(movies.Models.Count > 0); + + var first = movies.Models.First(); + Assert.IsTrue(first.Persons?.Count > 0); + + var people = first.Persons.First(); + Assert.IsNotNull(people.Profile); + + var person = await client.Table() + .Filter("first_name", Operator.Equals, "Bob") + .Single(); + + Assert.IsNotNull(person?.Profile); + + var byEmail = await client.Table() + .Order(x => x.CreatedAt, Ordering.Ascending) + .Filter("profile.email", Operator.Equals, "bob.saggett@supabase.io") + .Single(); + + Assert.IsNotNull(byEmail); + } + + private string? GetEnumMemberAttrValue(T enumVal) + { + var enumType = typeof(T); + var memInfo = enumType.GetMember(enumVal!.ToString()!); + + if (memInfo == null) + throw new ArgumentException("Supplied enum value is unknown."); + + var attr = memInfo.FirstOrDefault()?.GetCustomAttributes(false).OfType() + .FirstOrDefault(); + if (attr != null) + { + return attr.Value; + } + + return null; + } + + [TestMethod("enums")] + public async Task TestEnums() + { + var client = Helpers.GetLocalClient(); + + await client.Table().Filter(x => x.Status, Operator.Equals, + GetEnumMemberAttrValue(Todo.TodoStatus.IN_PROGRESS)).Get(); + } + + [TestMethod("columns")] + public async Task TestColumns() + { + var client = new Client(BaseUrl); + + var movies = await client.Table().Get(); + var first = movies.Models.First(); + var originalName = first.Name; + var originalDate = first.CreatedAt; + var newName = $"{first.Name} (Changed)"; + + first.Name = newName; + first.CreatedAt = DateTime.UtcNow; + + var result = await client.Table().Columns(new[] { "name" }).Update(first); + + Assert.AreEqual(originalDate, result.Models.First().CreatedAt); + Assert.AreNotEqual(originalName, result.Models.First().Name); + } + } +} \ No newline at end of file diff --git a/PostgrestTests/Coercion.cs b/PostgrestTests/CoercionTests.cs similarity index 91% rename from PostgrestTests/Coercion.cs rename to PostgrestTests/CoercionTests.cs index 7f65c48..1b2caa6 100644 --- a/PostgrestTests/Coercion.cs +++ b/PostgrestTests/CoercionTests.cs @@ -9,16 +9,16 @@ namespace PostgrestTests { [TestClass] - public class Coercion + public class CoercionTests { - private static string baseUrl = "http://localhost:3000"; + private static string _baseUrl = "http://localhost:3000"; [TestMethod] public async Task CanCoerceData() { // Check against already included case (inserted in `01-dummy-data.sql` - var existingItem = await new Client(baseUrl).Table().Single(); + var existingItem = await new Client(_baseUrl).Table().Single(); if (existingItem != null) { @@ -60,7 +60,7 @@ public async Task CanCoerceData() }; - var insertedModel = await new Client(baseUrl).Table().Insert(model); + var insertedModel = await new Client(_baseUrl).Table().Insert(model); var actual = insertedModel.Models.First(); Assert.AreEqual(model.StringValue, actual.StringValue); diff --git a/PostgrestTests/Converters.cs b/PostgrestTests/ConverterTests.cs similarity index 77% rename from PostgrestTests/Converters.cs rename to PostgrestTests/ConverterTests.cs index 5f7352b..40ea57b 100644 --- a/PostgrestTests/Converters.cs +++ b/PostgrestTests/ConverterTests.cs @@ -1,34 +1,35 @@ using System; using Microsoft.VisualStudio.TestTools.UnitTesting; using Postgrest; +using Postgrest.Converters; using Postgrest.Extensions; namespace PostgrestTests { [TestClass] - public class Converters + public class ConverterTests { [TestMethod("`intRange` should parse according to postgres docs")] public void TestIntRangeParsing() { // Test cases from 8.17.5 https://www.postgresql.org/docs/9.3/rangetypes.html var test1 = "[3,7)"; - var result1 = Postgrest.Converters.RangeConverter.ParseIntRange(test1); + var result1 = RangeConverter.ParseIntRange(test1); Assert.AreEqual(3, result1.Start); Assert.AreEqual(6, result1.End); var test2 = "(3,7)"; - var result2 = Postgrest.Converters.RangeConverter.ParseIntRange(test2); + var result2 = RangeConverter.ParseIntRange(test2); Assert.AreEqual(4, result2.Start); Assert.AreEqual(6, result2.End); var test3 = "[4,4]"; - var result3 = Postgrest.Converters.RangeConverter.ParseIntRange(test3); + var result3 = RangeConverter.ParseIntRange(test3); Assert.AreEqual(4, result3.Start); Assert.AreEqual(4, result3.End); var test4 = "[4,4)"; - var result4 = Postgrest.Converters.RangeConverter.ParseIntRange(test4); + var result4 = RangeConverter.ParseIntRange(test4); Assert.AreEqual(0, result4.Start); Assert.AreEqual(0, result4.End); @@ -39,7 +40,7 @@ public void TestIntRangeParsing() public void TestIntRangeParseInvalidFormat() { var test = "[1.2,3]"; - Postgrest.Converters.RangeConverter.ParseIntRange(test); + RangeConverter.ParseIntRange(test); } [TestMethod("`Range` should serialize into a string postgres understands")] diff --git a/PostgrestTests/ExceptionTests.cs b/PostgrestTests/ExceptionTests.cs new file mode 100644 index 0000000..3c655a1 --- /dev/null +++ b/PostgrestTests/ExceptionTests.cs @@ -0,0 +1,6 @@ +namespace PostgrestTests +{ + internal class ExceptionTests + { + } +} diff --git a/PostgrestTests/Extensions.cs b/PostgrestTests/ExtensionTests.cs similarity index 78% rename from PostgrestTests/Extensions.cs rename to PostgrestTests/ExtensionTests.cs index 09358fd..106c723 100644 --- a/PostgrestTests/Extensions.cs +++ b/PostgrestTests/ExtensionTests.cs @@ -1,13 +1,11 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; -using System; -using System.Collections.Generic; -using System.Text; +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; using Postgrest.Extensions; namespace PostgrestTests { [TestClass] - public class Extensions + public class ExtensionTests { [TestMethod] public void UriExtensionGetBaseUri() diff --git a/PostgrestTests/Helpers.cs b/PostgrestTests/Helpers.cs new file mode 100644 index 0000000..bca09f5 --- /dev/null +++ b/PostgrestTests/Helpers.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using Postgrest; + +namespace PostgrestTests +{ + internal static class Helpers + { + internal static Client GetHostedClient() + { + var url = Environment.GetEnvironmentVariable("SUPABASE_URL"); + var publicKey = Environment.GetEnvironmentVariable("SUPABASE_PUBLIC_KEY"); + + var client = new Client($"{url}/rest/v1", new ClientOptions + { + Headers = new Dictionary + { + {"apikey", publicKey! } + } + }); + + return client; + } + + internal static Client GetLocalClient() + { + var url = Environment.GetEnvironmentVariable("SUPABASE_URL"); + if (url == null) url = "http://localhost:3000"; + var publicKey = Environment.GetEnvironmentVariable("SUPABASE_PUBLIC_KEY"); + if (publicKey == null) publicKey = "reallyreallyreallyreallyverysafe"; + + var client = new Client($"{url}/rest/v1", new ClientOptions + { + Headers = new Dictionary + { + {"apikey", publicKey! } + } + }); + + return client; + } + } +} diff --git a/PostgrestTests/Linq.cs b/PostgrestTests/LinqTests.cs similarity index 84% rename from PostgrestTests/Linq.cs rename to PostgrestTests/LinqTests.cs index 8453878..04c6efa 100644 --- a/PostgrestTests/Linq.cs +++ b/PostgrestTests/LinqTests.cs @@ -12,12 +12,12 @@ namespace PostgrestTests [TestClass] public class LinqTests { - private static string baseUrl = "http://localhost:3000"; + private const string BaseUrl = "http://localhost:3000"; [TestMethod("Linq: Select")] public async Task TestLinqSelect() { - var client = new Client(baseUrl); + var client = new Client(BaseUrl); var query1 = await client.Table() .Select(x => new object[] { x.Id }) @@ -45,7 +45,7 @@ public async Task TestLinqSelect() [TestMethod("Linq: Where")] public async Task TestLinqWhere() { - var client = new Client(baseUrl); + var client = new Client(BaseUrl); var testValue = 2; // Test boolean equality @@ -99,20 +99,20 @@ public async Task TestLinqWhere() foreach (var q in query7.Models) Assert.IsNotNull(q.DateTimeValue); - var query8 = await client.Table() + await client.Table() .Where(x => x.DateTimeValue == DateTime.Now) .Get(); - var query9 = await client.Table() + await client.Table() .Where(x => x.DateTimeValue == null) .Get(); - var query10 = await client.Table() + await client.Table() .Set(x => x.BooleanValue!, true) .Where(x => x.Id == 10) .Update(); - var query11 = await client.Table() + await client.Table() .Set(x => x.BooleanValue!, null) .Where(x => x.Id == 10) .Update(); @@ -122,7 +122,7 @@ public async Task TestLinqWhere() [TestMethod("Linq: OnConflict")] public async Task TestLinqOnConflict() { - var client = new Client(baseUrl); + var client = new Client(BaseUrl); var supaUpdated = new User { @@ -134,13 +134,13 @@ public async Task TestLinqOnConflict() var response = await client.Table().Insert(supaUpdated, new QueryOptions { Upsert = true }); - // Upserting a model. - var kitchenSink1 = new KitchenSink { UniqueValue = "Testing" }; + // Upsert-ing a model. + var kitchenSink1 = new KitchenSink { Id = 2, UniqueValue = "Testing" }; var ks1 = await client.Table().OnConflict(x => x.UniqueValue!).Upsert(kitchenSink1); var uks1 = ks1.Models.First(); uks1.StringValue = "Testing 1"; - var ks3 = await client.Table().OnConflict(x => x.UniqueValue!).Upsert(uks1); + await client.Table().OnConflict(x => x.UniqueValue!).Upsert(uks1); var updatedUser = response.Models.First(); @@ -164,7 +164,7 @@ public async Task TestLinqOnConflict() [TestMethod("Linq: Order")] public async Task TestLinqOrderBy() { - var client = new Client(baseUrl); + var client = new Client(BaseUrl); var orderedResponse = await client.Table().Order(x => x.Username!, Ordering.Descending).Get(); var unorderedResponse = await client.Table().Get(); @@ -188,7 +188,7 @@ public async Task TestLinqOrderBy() [TestMethod("Linq: Columns")] public async Task TestLinqColumns() { - var client = new Client(baseUrl); + var client = new Client(BaseUrl); var movies = await client.Table().Get(); var first = movies.Models.First(); @@ -199,7 +199,7 @@ public async Task TestLinqColumns() first.Name = newName; first.CreatedAt = DateTime.UtcNow; - var result = await client.Table().Columns(x => new[] { x.Name! }).Update(first); + var result = await client.Table().Columns(x => new object[] { x.Name! }).Update(first); Assert.AreEqual(originalDate, result.Models.First().CreatedAt); Assert.AreNotEqual(originalName, result.Models.First().Name); @@ -213,11 +213,11 @@ public async Task TestLinqColumns() [TestMethod("Linq: Update")] public async Task TestLinqUpdate() { - var client = new Client(baseUrl); + var client = new Client(BaseUrl); var newName = $"Top Gun (Updated By Linq) at {DateTime.Now}"; - var movie = await client.Table() - .Set(x => new KeyValuePair(x.Name!, newName)) + await client.Table() + .Set(x => new KeyValuePair(x.Name!, newName)) .Where(x => x.Name!.Contains("Top Gun")) .Update(); @@ -237,12 +237,12 @@ public async Task TestLinqUpdate() Assert.IsNotNull(originalRecord); var newRecord = await client.Table() - .Set(x => new KeyValuePair(x.BooleanValue!, !originalRecord.BooleanValue!)) - .Set(x => new KeyValuePair(x.IntValue!, originalRecord.IntValue! + 1)) - .Set(x => new KeyValuePair(x.FloatValue, originalRecord.FloatValue + 1)) - .Set(x => new KeyValuePair(x.DoubleValue, originalRecord.DoubleValue + 1)) - .Set(x => new KeyValuePair(x.DateTimeValue!, DateTime.Now)) - .Set(x => new KeyValuePair(x.ListOfStrings!, new List(originalRecord.ListOfStrings!) + .Set(x => new KeyValuePair(x.BooleanValue!, !originalRecord.BooleanValue!)) + .Set(x => new KeyValuePair(x.IntValue!, originalRecord.IntValue! + 1)) + .Set(x => new KeyValuePair(x.FloatValue, originalRecord.FloatValue + 1)) + .Set(x => new KeyValuePair(x.DoubleValue, originalRecord.DoubleValue + 1)) + .Set(x => new KeyValuePair(x.DateTimeValue!, DateTime.Now)) + .Set(x => new KeyValuePair(x.ListOfStrings!, new List(originalRecord.ListOfStrings!) { "updated" })) @@ -293,19 +293,19 @@ public async Task TestLinqUpdate() Assert.ThrowsException(() => { - return client.Table().Set(x => new KeyValuePair(x.Name!, DateTime.Now)).Update(); + return client.Table().Set(x => new KeyValuePair(x.Name!, DateTime.Now)).Update(); }); Assert.ThrowsException(() => { - return client.Table().Set(x => new KeyValuePair(DateTime.Now, newName)).Update(); + return client.Table().Set(x => new KeyValuePair(DateTime.Now, newName)).Update(); }); } [TestMethod("Linq: Delete")] public async Task TestLinqDelete() { - var client = new Client(baseUrl); + var client = new Client(BaseUrl); var newMovie = new Movie { diff --git a/PostgrestTests/Models/KitchenSink.cs b/PostgrestTests/Models/KitchenSink.cs index 61f0b88..70634aa 100644 --- a/PostgrestTests/Models/KitchenSink.cs +++ b/PostgrestTests/Models/KitchenSink.cs @@ -1,17 +1,17 @@ -using System; +#nullable enable +using System; using System.Collections.Generic; using Newtonsoft.Json; using Postgrest; using Postgrest.Attributes; using Postgrest.Models; -#nullable enable namespace PostgrestTests.Models { [Table("kitchen_sink")] public class KitchenSink : BaseModel { - [PrimaryKey("id", false)] + [PrimaryKey("id")] public int? Id { get; set; } [Column("string_value")] diff --git a/PostgrestTests/Models/Message.cs b/PostgrestTests/Models/Message.cs index b7a6b5a..e069a14 100644 --- a/PostgrestTests/Models/Message.cs +++ b/PostgrestTests/Models/Message.cs @@ -1,6 +1,6 @@ -using Postgrest.Attributes; +using System; +using Postgrest.Attributes; using Postgrest.Models; -using System; namespace PostgrestTests.Models { diff --git a/PostgrestTests/Models/Movie.cs b/PostgrestTests/Models/Movie.cs index d58e2b5..04cd1c5 100644 --- a/PostgrestTests/Models/Movie.cs +++ b/PostgrestTests/Models/Movie.cs @@ -1,8 +1,7 @@ -using Postgrest.Attributes; -using Postgrest.Models; -using System; +using System; using System.Collections.Generic; -using System.Text; +using Postgrest.Attributes; +using Postgrest.Models; namespace PostgrestTests.Models { @@ -26,7 +25,7 @@ public class Movie : BaseModel [Table("person")] public class Person : BaseModel { - [PrimaryKey("id", false)] + [PrimaryKey("id")] public int Id { get; set; } [Column("first_name")] diff --git a/PostgrestTests/Models/Todo.cs b/PostgrestTests/Models/Todo.cs new file mode 100644 index 0000000..6f28200 --- /dev/null +++ b/PostgrestTests/Models/Todo.cs @@ -0,0 +1,44 @@ +using System.Runtime.Serialization; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Postgrest.Attributes; +using Postgrest.Models; + +namespace PostgrestTests.Models +{ + [Table("todos")] + public class Todo : BaseModel + { + [JsonConverter(typeof(StringEnumConverter))] + public enum TodoStatus + { + [EnumMember(Value = "NOT STARTED")] + NOT_STARTED, + [EnumMember(Value = "IN PROGRESS")] + IN_PROGRESS, + [EnumMember(Value = "DONE")] + DONE, + } + + [PrimaryKey("id")] + public int Id { get; set; } + + [Column("user_id")] + public int UserId { get; set; } + + [Column("status")] + public TodoStatus Status { get; set; } + + [Column("name")] + public string? Name { get; set; } + + [Column("notes")] + public string? Notes { get; set; } + + [Column("done")] + public bool Done { get; set; } + + [Column("details")] + public string? Details { get; set; } + } +} diff --git a/PostgrestTests/PostgrestTests.csproj b/PostgrestTests/PostgrestTests.csproj index ffca315..d8646e3 100644 --- a/PostgrestTests/PostgrestTests.csproj +++ b/PostgrestTests/PostgrestTests.csproj @@ -1,9 +1,9 @@  - netstandard2.0;net461 false 3.1.3 + net7.0 @@ -14,6 +14,7 @@ + diff --git a/README.md b/README.md index 07b52bd..46a7b20 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,19 @@ The bulk of this library is a translation and c-sharp-ification of the [supabase Postgrest-csharp is _heavily_ dependent on Models deriving from `BaseModel`. To interact with the API, one must have the associated model specified. +To use this library on the Supabase Hosted service but separately from the `supabase-csharp`, you'll need to specify your url and public key like so: +```c# +var auth = new Supabase.Gotrue.Client(new ClientOptions +{ + Url = "https://PROJECT_ID.supabase.co/auth/v1", + Headers = new Dictionary + { + { "apikey", SUPABASE_PUBLIC_KEY }, + { "Authorization", $"Bearer {SUPABASE_USER_TOKEN}" } + } +}) +``` + Leverage `Table`,`PrimaryKey`, and `Column` attributes to specify names of classes/properties that are different from their C# Versions. ```c# diff --git a/docker-compose.yml b/docker-compose.yml index f004fb1..d48f405 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,7 @@ services: PGRST_DB_URI: postgres://postgres:postgres@db:5432/postgres PGRST_DB_SCHEMA: public, personal PGRST_DB_ANON_ROLE: postgres + PGRST_JWT_SECRET: "reallyreallyreallyreallyverysafe" depends_on: - db db: