From 950e420a3b220ca557f8cf3e5e8797a4e1c97473 Mon Sep 17 00:00:00 2001 From: Joseph Schultz Date: Tue, 20 Sep 2022 23:41:20 -0500 Subject: [PATCH] References #48: Implemented new attribute `Reference` which supports nested querying and allows for insert/upsert interaction on root resource. --- Postgrest/Attributes/ColumnAttribute.cs | 21 ++++- Postgrest/Attributes/ReferenceAttribute.cs | 97 ++++++++++++++++++++++ Postgrest/Attributes/callerName.cs | 6 ++ Postgrest/Postgrest.csproj | 1 - Postgrest/PostgrestContractResolver.cs | 33 +++++++- Postgrest/Table.cs | 93 +++++++++++++++++---- PostgrestExample/Models/Movie.cs | 51 ++++++++++++ PostgrestExample/PostgrestExample.csproj | 3 - PostgrestTests/ClientApi.cs | 50 ++++++++++- PostgrestTests/Models/Movie.cs | 51 ++++++++++++ PostgrestTests/db/00-schema.sql | 42 ++++++++++ 11 files changed, 425 insertions(+), 23 deletions(-) create mode 100644 Postgrest/Attributes/ReferenceAttribute.cs create mode 100644 Postgrest/Attributes/callerName.cs create mode 100644 PostgrestExample/Models/Movie.cs create mode 100644 PostgrestTests/Models/Movie.cs diff --git a/Postgrest/Attributes/ColumnAttribute.cs b/Postgrest/Attributes/ColumnAttribute.cs index e92154e..6e8907f 100644 --- a/Postgrest/Attributes/ColumnAttribute.cs +++ b/Postgrest/Attributes/ColumnAttribute.cs @@ -18,13 +18,32 @@ namespace Postgrest.Attributes [AttributeUsage(AttributeTargets.Property)] public class ColumnAttribute : Attribute { + /// + /// The name in postgres of this column. + /// public string ColumnName { get; set; } + + /// + /// Specifies what should be serialied in the event this column's value is NULL + /// public NullValueHandling NullValueHandling { get; set; } - public ColumnAttribute([CallerMemberName] string columnName = null, NullValueHandling nullValueHandling = NullValueHandling.Include) + /// + /// If the performed query is an Insert or Upsert, should this value be ignored? + /// + public bool IgnoreOnInsert { get; set; } + + /// + /// If the performed query is an Update, should this value be ignored? + /// + public bool IgnoreOnUpdate { get; set; } + + public ColumnAttribute([CallerMemberName] string columnName = null, NullValueHandling nullValueHandling = NullValueHandling.Include, bool ignoreOnInsert = false, bool ignoreOnUpdate = false) { ColumnName = columnName; NullValueHandling = nullValueHandling; + IgnoreOnInsert = ignoreOnInsert; + IgnoreOnUpdate = ignoreOnUpdate; } } } diff --git a/Postgrest/Attributes/ReferenceAttribute.cs b/Postgrest/Attributes/ReferenceAttribute.cs new file mode 100644 index 0000000..6521290 --- /dev/null +++ b/Postgrest/Attributes/ReferenceAttribute.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using Newtonsoft.Json; +using Postgrest.Models; + +namespace Postgrest.Attributes +{ + /// + /// Used to specify that a foreign key relationship exists in PostgreSQL + /// + /// See: https://postgrest.org/en/stable/api.html#resource-embedding + /// + [AttributeUsage(AttributeTargets.Property)] + public class ReferenceAttribute : Attribute + { + /// + /// Type of the model referenced + /// + public Type Model { get; } + + /// + /// Associated property name + /// + public string PropertyName { get; private set; } + + /// + /// Table name of model + /// + public string TableName { get; private set; } + + /// + /// Columns that exist on the model we will select from. + /// + public List Columns { get; } = new List(); + + /// + /// If the performed query is an Insert or Upsert, should this value be ignored? (DEFAULT TRUE) + /// + public bool IgnoreOnInsert { get; set; } + + /// + /// If the performed query is an Update, should this value be ignored? (DEFAULT TRUE) + /// + public bool IgnoreOnUpdate { get; set; } + + /// + /// If Reference should automatically be included in queries on this reference. (DEFAULT TRUE) + /// + public bool IncludeInQuery { get; set; } + + /// Model referenced + /// Should referenced be included in queries? + /// Should reference data be excluded from inserts/upserts? + /// Should reference data be excluded from updates? + /// + /// + public ReferenceAttribute(Type model, bool includeInQuery = true, bool ignoreOnInsert = true, bool ignoreOnUpdate = true, [CallerMemberName] string propertyName = "") + { + if (model.BaseType != typeof(BaseModel)) + { + throw new Exception("RefernceAttribute must be used with Postgrest BaseModels."); + } + + Model = model; + IncludeInQuery = includeInQuery; + IgnoreOnInsert = ignoreOnInsert; + IgnoreOnUpdate = ignoreOnUpdate; + PropertyName = propertyName; + + var attr = GetCustomAttribute(model, typeof(TableAttribute)); + if (attr is TableAttribute tableAttr) + { + TableName = tableAttr.Name; + } + else + { + TableName = model.Name; + } + + foreach (var property in model.GetProperties()) + { + var attrs = property.GetCustomAttributes(true); + + foreach (var item in attrs) + { + if (item is ColumnAttribute col) + Columns.Add(col.ColumnName); + else if (item is PrimaryKeyAttribute pk) + Columns.Add(pk.ColumnName); + else if (item is ReferenceAttribute ra) + Columns.Add($"{ra.TableName}!inner({string.Join(",", ra.Columns.ToArray())})"); + } + } + } + } +} diff --git a/Postgrest/Attributes/callerName.cs b/Postgrest/Attributes/callerName.cs new file mode 100644 index 0000000..7b67296 --- /dev/null +++ b/Postgrest/Attributes/callerName.cs @@ -0,0 +1,6 @@ +namespace Postgrest.Attributes +{ + public class callerName + { + } +} \ No newline at end of file diff --git a/Postgrest/Postgrest.csproj b/Postgrest/Postgrest.csproj index 1fa3404..0795f15 100644 --- a/Postgrest/Postgrest.csproj +++ b/Postgrest/Postgrest.csproj @@ -37,7 +37,6 @@ The bulk of this library is a translation and c-sharp-ification of the supabase/ - \ No newline at end of file diff --git a/Postgrest/PostgrestContractResolver.cs b/Postgrest/PostgrestContractResolver.cs index ecfc71a..06652a6 100644 --- a/Postgrest/PostgrestContractResolver.cs +++ b/Postgrest/PostgrestContractResolver.cs @@ -14,6 +14,15 @@ namespace Postgrest.Attributes /// public class PostgrestContractResolver : DefaultContractResolver { + public bool IsUpdate { get; private set; } = false; + public bool IsInsert { get; private set; } = false; + + public void SetState(bool isInsert = false, bool isUpdate = false) + { + IsUpdate = isUpdate; + IsInsert = isInsert; + } + protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization) { JsonProperty prop = base.CreateProperty(member, memberSerialization); @@ -51,6 +60,28 @@ protected override JsonProperty CreateProperty(MemberInfo member, MemberSerializ { prop.PropertyName = columnAttribute.ColumnName; prop.NullValueHandling = columnAttribute.NullValueHandling; + + if (IsInsert && columnAttribute.IgnoreOnInsert) + prop.Ignored = true; + + if (IsUpdate && columnAttribute.IgnoreOnUpdate) + prop.Ignored = true; + + return prop; + } + + var referenceAttr = member.GetCustomAttribute(); + + if (referenceAttr != null) + { + prop.PropertyName = referenceAttr.TableName; + + if (IsInsert && referenceAttr.IgnoreOnInsert) + prop.Ignored = true; + + if (IsUpdate && referenceAttr.IgnoreOnUpdate) + prop.Ignored = true; + return prop; } @@ -60,7 +91,7 @@ protected override JsonProperty CreateProperty(MemberInfo member, MemberSerializ { return prop; } - + prop.PropertyName = primaryKeyAttribute.ColumnName; prop.ShouldSerialize = instance => primaryKeyAttribute.ShouldInsert; return prop; diff --git a/Postgrest/Table.cs b/Postgrest/Table.cs index cda6114..64a100f 100644 --- a/Postgrest/Table.cs +++ b/Postgrest/Table.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Net.Http; +using System.Reflection; using System.Text; using System.Text.RegularExpressions; using System.Threading; @@ -40,6 +41,9 @@ namespace Postgrest private List filters = new List(); private List orderers = new List(); + private List columns = new List(); + + private List references = new List(); private int rangeFrom = int.MinValue; private int rangeTo = int.MinValue; @@ -67,15 +71,15 @@ public Table(string baseUrl, ClientOptions options = null) serializerSettings = StatelessClient.SerializerSettings(options); - var attr = Attribute.GetCustomAttribute(typeof(T), typeof(TableAttribute)); - - if (attr is TableAttribute tableAttr) + foreach (var property in typeof(T).GetProperties()) { - TableName = tableAttr.Name; - return; + var attrs = property.GetCustomAttributes(typeof(ReferenceAttribute), true); + + if (attrs.Length > 0) + references.Add((ReferenceAttribute)attrs.First()); } - TableName = typeof(T).Name; + TableName = FindTableName(); } /// @@ -326,6 +330,23 @@ public Table OnConflict(string columnName) 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; + } /// /// Sets an offset with an optional foreign table reference. @@ -423,7 +444,7 @@ public Task> Update(T model, QueryOptions options = null, Can filters.Add(new QueryFilter(model.PrimaryKeyColumn, Operator.Equals, model.PrimaryKeyValue.ToString())); - var request = Send(method, model, options.ToHeaders(), cancellationToken); + var request = Send(method, model, options.ToHeaders(), cancellationToken, isUpdate: true); Clear(); @@ -591,6 +612,11 @@ internal string GenerateUrl() query.Add("apikey", options.Headers["apikey"]); } + if (columns.Count > 0) + { + query["columns"] = string.Join(",", columns); + } + foreach (var filter in filters) { var parsedFilter = PrepareFilter(filter); @@ -613,6 +639,21 @@ internal string GenerateUrl() 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()); + query["select"] = query["select"] + $",{reference.TableName}!inner({columns})"; + } + } + } + if (!string.IsNullOrEmpty(onConflict)) { query["on_conflict"] = onConflict; @@ -639,19 +680,25 @@ internal string GenerateUrl() /// /// /// - internal object PrepareRequestData(object data) + internal object PrepareRequestData(object data, bool isInsert = false, bool isUpdate = false) { if (data == null) return new Dictionary(); + var resolver = (PostgrestContractResolver)serializerSettings.ContractResolver; + + 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) { - var serialized = JsonConvert.SerializeObject(data, serializerSettings); return JsonConvert.DeserializeObject>(serialized); } else { - var serialized = JsonConvert.SerializeObject(data, serializerSettings); return JsonConvert.DeserializeObject>(serialized, serializerSettings); } } @@ -766,6 +813,7 @@ public void Clear() filters.Clear(); orderers.Clear(); + columns.Clear(); rangeFrom = int.MinValue; rangeTo = int.MinValue; @@ -798,23 +846,38 @@ private Task> PerformInsert(object data, QueryOptions options OnConflict(options.OnConflict); } - var request = Send(method, data, options.ToHeaders(), cancellationToken); + 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) + 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); - return Helpers.MakeRequest(method, GenerateUrl(), serializerSettings, PrepareRequestData(data), requestHeaders, cancellationToken); + var preparedData = PrepareRequestData(data, isInsert, isUpdate); + return Helpers.MakeRequest(method, GenerateUrl(), serializerSettings, preparedData, requestHeaders, cancellationToken); } - private Task> Send(HttpMethod method, object data, Dictionary headers = null, CancellationToken cancellationToken = default) where U : BaseModel, new() + 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); - return Helpers.MakeRequest(method, GenerateUrl(), serializerSettings, PrepareRequestData(data), requestHeaders, cancellationToken); + var preparedData = PrepareRequestData(data, isInsert, isUpdate); + return Helpers.MakeRequest(method, GenerateUrl(), serializerSettings, preparedData, requestHeaders, cancellationToken); + } + + 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; } } } diff --git a/PostgrestExample/Models/Movie.cs b/PostgrestExample/Models/Movie.cs new file mode 100644 index 0000000..928b8b2 --- /dev/null +++ b/PostgrestExample/Models/Movie.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using Postgrest; +using Postgrest.Attributes; +using Postgrest.Models; + +namespace PostgrestExample.Models +{ + [Table("movie")] + public class Movie : BaseModel + { + [PrimaryKey("id", false)] + public int Id { get; set; } + + [Column("name")] + public string Name { get; set; } + + [Reference(typeof(Person))] + public List Persons { get; set; } + + + [Column("created_at")] + public DateTime CreatedAt { get; set; } + } + + [Table("person")] + public class Person : BaseModel + { + [PrimaryKey("id",false)] + public int Id { get; set; } + + [Column("first_name")] + public string FirstName { get; set; } + + [Column("last_name")] + public string LastName { get; set; } + + [Reference(typeof(Profile))] + public Profile Profile { get; set; } + + [Column("created_at")] + public DateTime CreatedAt { get; set; } + } + + [Table("profile")] + public class Profile : BaseModel + { + [Column("email")] + public string Email { get; set; } + } +} diff --git a/PostgrestExample/PostgrestExample.csproj b/PostgrestExample/PostgrestExample.csproj index 92f20c9..6932278 100644 --- a/PostgrestExample/PostgrestExample.csproj +++ b/PostgrestExample/PostgrestExample.csproj @@ -9,7 +9,4 @@ - - - diff --git a/PostgrestTests/ClientApi.cs b/PostgrestTests/ClientApi.cs index 16a8ede..276116d 100644 --- a/PostgrestTests/ClientApi.cs +++ b/PostgrestTests/ClientApi.cs @@ -1070,10 +1070,10 @@ public async Task TestCancellationToken() { var client = Client.Initialize(baseUrl); var now = DateTime.UtcNow; - + var cts = new CancellationTokenSource(); cts.CancelAfter(TimeSpan.FromTicks(1)); - + var model = new KitchenSink { DateTimeValue = now, @@ -1092,5 +1092,51 @@ public async Task TestCancellationToken() Assert.IsNull(insertResponse); } } + + [TestMethod("references")] + public async Task TestReferences() + { + var client = Client.Initialize(baseUrl); + + var movies = await client.Table().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() + .Filter("profile.email", Operator.Equals, "bob.saggett@supabase.io") + .Single(); + + Assert.IsNotNull(byEmail); + } + + [TestMethod("columns")] + public async Task TestColumns() + { + var client = Client.Initialize(baseUrl); + + var movies = await client.Table().Get(); + var first = movies.Models.First(); + var originalTime = first.CreatedAt; + var newTime = DateTime.UtcNow; + + first.Name = "I should be ignored on insert attempt."; + first.CreatedAt = newTime; + + var result = await client.Table().Columns(new[] { "created_at" }).Update(first); + + Assert.AreNotEqual(first.Name, result.Models.First().Name); + Assert.AreNotEqual(originalTime, result.Models.First().CreatedAt); + } } } diff --git a/PostgrestTests/Models/Movie.cs b/PostgrestTests/Models/Movie.cs new file mode 100644 index 0000000..0592afc --- /dev/null +++ b/PostgrestTests/Models/Movie.cs @@ -0,0 +1,51 @@ +using Postgrest.Attributes; +using Postgrest.Models; +using System; +using System.Collections.Generic; +using System.Text; + +namespace PostgrestTests.Models +{ + [Table("movie")] + public class Movie : BaseModel + { + [PrimaryKey("id", false)] + public int Id { get; set; } + + [Column("name")] + public string Name { get; set; } + + [Reference(typeof(Person))] + public List Persons { get; set; } + + + [Column("created_at")] + public DateTime CreatedAt { get; set; } + } + + [Table("person")] + public class Person : BaseModel + { + [PrimaryKey("id", false)] + public int Id { get; set; } + + [Column("first_name")] + public string FirstName { get; set; } + + [Column("last_name")] + public string LastName { get; set; } + + [Reference(typeof(Profile))] + public Profile Profile { get; set; } + + [Column("created_at")] + public DateTime CreatedAt { get; set; } + } + + [Table("profile")] + public class Profile : BaseModel + { + [Column("email")] + public string Email { get; set; } + } +} diff --git a/PostgrestTests/db/00-schema.sql b/PostgrestTests/db/00-schema.sql index a496ac6..cc02c07 100644 --- a/PostgrestTests/db/00-schema.sql +++ b/PostgrestTests/db/00-schema.sql @@ -62,6 +62,48 @@ create table "public"."kitchen_sink" ( "int_range" INT4RANGE null ); +CREATE TABLE public.movie ( + id int GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + created_at timestamp without time zone NOT NULL DEFAULT now(), + name character varying(255) NULL +); + +CREATE TABLE public.person ( + id int GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + created_at timestamp without time zone NOT NULL DEFAULT now(), + first_name character varying(255) NULL, + last_name character varying(255) NULL +); + +CREATE TABLE public.profile ( + profile_id int PRIMARY KEY references person(id), + email character varying(255) null, + created_at timestamp without time zone NOT NULL DEFAULT now() +); + +CREATE TABLE public.movie_person ( + id int generated by default as identity, + movie_id int references movie(id), + person_id int references person(id), + primary key(id, movie_id, person_id) +); + +insert into "public"."movie" ("created_at", "id", "name") values ('2022-08-20 00:29:45.400188', 1, 'Top Gun: Maverick'); +insert into "public"."movie" ("created_at", "id", "name") values ('2022-08-20 00:29:45.400188', 2, 'Mad Max: Fury Road'); + +insert into "public"."person" ("created_at", "first_name", "id", "last_name") values ('2022-08-20 00:30:02.120528', 'Tom', 1, 'Cruise'); +insert into "public"."person" ("created_at", "first_name", "id", "last_name") values ('2022-08-20 00:30:02.120528', 'Tom', 2, 'Holland'); +insert into "public"."person" ("created_at", "first_name", "id", "last_name") values ('2022-08-20 00:30:33.72443', 'Bob', 3, 'Saggett'); + +insert into "public"."profile" ("created_at", "email", "profile_id") values ('2022-08-20 00:30:33.72443', 'tom.cruise@supabase.io', 1); +insert into "public"."profile" ("created_at", "email", "profile_id") values ('2022-08-20 00:30:33.72443', 'tom.holland@supabase.io', 2); +insert into "public"."profile" ("created_at", "email", "profile_id") values ('2022-08-20 00:30:33.72443', 'bob.saggett@supabase.io', 3); + +insert into "public"."movie_person" ("id", "movie_id", "person_id") values (1, 1, 1); +insert into "public"."movie_person" ("id", "movie_id", "person_id") values (2, 2, 2); +insert into "public"."movie_person" ("id", "movie_id", "person_id") values (3, 1, 3); + + -- STORED FUNCTION CREATE FUNCTION public.get_status(name_param text) RETURNS user_status AS $$