From cb3265898e130bc1ed4c7b9b213bc7723528fef3 Mon Sep 17 00:00:00 2001 From: Philip Conrad Date: Tue, 10 Dec 2024 19:57:52 -0500 Subject: [PATCH] csharp: Switch over to using Styra.Ucast.Linq library. Signed-off-by: Philip Conrad --- .../Authorization/QueryableExtensions.cs | 178 ------------------ .../TicketHub/Authorization/UCASTNode.cs | 45 ----- .../TicketHub/Controllers/TicketController.cs | 3 +- server/csharp/TicketHub/TicketHub.csproj | 1 + 4 files changed, 2 insertions(+), 225 deletions(-) delete mode 100644 server/csharp/TicketHub/Authorization/QueryableExtensions.cs delete mode 100644 server/csharp/TicketHub/Authorization/UCASTNode.cs diff --git a/server/csharp/TicketHub/Authorization/QueryableExtensions.cs b/server/csharp/TicketHub/Authorization/QueryableExtensions.cs deleted file mode 100644 index 0a898f87..00000000 --- a/server/csharp/TicketHub/Authorization/QueryableExtensions.cs +++ /dev/null @@ -1,178 +0,0 @@ - -using System.Linq.Expressions; - -namespace TicketHub.Authorization; - -public static class QueryableExtensions -{ - /// - /// Builds a LINQ Lambda Expression from the UCAST tree, and then invokes - /// it under a LINQ Where expression on some queryable data source. - /// In our case, this *should* usually be an EF Core ORM model. - /// - /// LINQ data source (same type as ). - /// The top-level UCAST node to build a LINQ Expression tree from. - /// Dictionary mapping UCAST property names to lambdas that generate LINQ Expressions. - /// Result, an IQueryable<>. - public static IQueryable ApplyUCASTFilter(this IQueryable source, UCASTNode root, Dictionary> mapper) - { - var parameter = Expression.Parameter(typeof(T), "x"); - var expression = BuildExpression(root, parameter, mapper); - return source.Where(Expression.Lambda>(expression, parameter)); - } - - /// - /// The entry point for recursively constructing a LINQ Expression tree - /// from a given UCASTNode. - /// - /// Current UCAST node in the conditions tree. - /// LINQ data source (same type as ). - /// Dictionary mapping UCAST property names to lambdas that generate LINQ Expressions. - /// Result, a LINQ Expression. - public static Expression BuildExpression(UCASTNode node, ParameterExpression parameter, Dictionary> mapper) - { - // Switch expression: - return node.Type.ToLower() switch - { - "field" => BuildFieldExpression(node, parameter, mapper), - "document" => BuildFieldExpression(node, parameter, mapper), // TODO: Fix this to provide actual document-level operations once we have any. - "compound" => BuildCompoundExpression(node, parameter, mapper), - _ => throw new ArgumentException($"Unknown node type: {node.Type}"), - }; - } - - /// - /// Constructs a field-level UCAST condition using LINQ Expressions. Most - /// operators of interest in UCAST field-level conditionsare represented - /// as BinaryExpression types. Some typecasts (such as Int32 upcasting to - /// Int64) are detected and included in the LINQ Expression tree - /// automatically, to ensure that the binary expressions won't fail at - /// runtime due to type mismatches between operands. - /// - /// Current UCAST node in the conditions tree. - /// LINQ data source (same type as ). - /// Dictionary mapping UCAST property names to lambdas that generate LINQ Expressions. - /// Result, a LINQ Expression (Usually a BinaryExpression). - private static Expression BuildFieldExpression(UCASTNode node, ParameterExpression parameter, Dictionary> mapper) - { - var property = mapper[node.Field!](parameter); // Note: This will throw a KeyNotFoundException if the field name does not exist. - Expression value = Expression.Constant(node.Value); - - // TODO: Add more robust type mismatch handling and possibly exceptions. - Type lhsType = property.Type; - Type rhsType = value.Type; - if (lhsType != rhsType) - { - // Upcast smaller numeric type from System.Int32 -> System.Int64. - if (lhsType == typeof(int) && rhsType == typeof(long)) - { - property = Expression.Convert(property, typeof(long)); - } - else if (lhsType == typeof(long) && rhsType == typeof(int)) - { - value = Expression.Convert(value, typeof(long)); - } - } - - // Switch expression: - return node.Op.ToLower() switch - { - "eq" => Expression.Equal(property, value), - "ne" => Expression.NotEqual(property, value), - "gt" => Expression.GreaterThan(property, value), - "ge" => Expression.GreaterThanOrEqual(property, value), - "gte" => Expression.GreaterThanOrEqual(property, value), - "lt" => Expression.LessThan(property, value), - "le" => Expression.LessThanOrEqual(property, value), - "lte" => Expression.LessThanOrEqual(property, value), - "contains" => Expression.Call(property, typeof(string).GetMethod("Contains", new[] { typeof(string) }), value), - _ => throw new ArgumentException($"Unknown operator: {node.Op}"), - }; - } - - /// - /// Constructs a compound UCAST condition. Recursively constructs its child - /// conditions, then binds the child nodes together with a LINQ aggregate - /// operation. - /// - /// Current UCAST node in the conditions tree. - /// LINQ data source (same type as ). - /// Dictionary mapping UCAST property names to lambdas that generate LINQ Expressions. - /// Result, an aggregate LINQ Expression. - private static Expression BuildCompoundExpression(UCASTNode node, ParameterExpression parameter, Dictionary> mapper) - { - // TODO: Detect wrong types, and/or empty child condition lists. - var childNodes = (List)node.Value; - var childExpressions = childNodes.Select(child => BuildExpression(child, parameter, mapper)); - - // Switch expression: - return node.Op.ToLower() switch - { - "and" => childExpressions.Aggregate(Expression.AndAlso), - "or" => childExpressions.Aggregate(Expression.OrElse), - _ => throw new ArgumentException($"Unknown compound operator: {node.Op}"), - }; - } - - /// - /// BuildDefaultMapperDictionary constructs a Dictionary mapping UCAST - /// property names to lambda functions. The lambda functions allow - /// late-binding a LINQ data source into LINQ Property expression lookups, - /// which are used extensively when building conditions over EF Core models. - /// - /// When deciding on names for data source properties, we follow a small set - /// of default construction rules: - /// - Example.Id -> "example.id" - /// - Example.LastUpdated -> "example.last_updated" - /// - Example.UserNavigation.Id -> "example.user.id" - /// - /// Name of the LINQ data source, as it will appear in UCAST field references. Used as a prefix for the generated property mappings. - /// Result, a Dictionary. - public static Dictionary> BuildDefaultMapperDictionary(string prefix = "") - { - var result = new Dictionary>(); - var properties = typeof(T).GetProperties(); - foreach (var property in properties) - { - var propertyName = property.Name; - // Normal properties, or a property just named "navigation" (case invariant) should be processed normally. - if (!propertyName.EndsWith("Navigation") || propertyName.ToLower() == "Navigation") - { - propertyName = string.IsNullOrEmpty(prefix) ? propertyName.ToSnakeCase() : $"{prefix}.{propertyName.ToSnakeCase()}"; - result[propertyName] = param => Expression.Property(param, property.Name); - continue; - } - // Implicit else: Properties with the "Navigation" suffix are - // usually ORM tooling for foreign key/entity lookups in EF Core. We - // indirect one level, and enumerate the non-Navigation properties - // of that type. - propertyName = property.Name[..^"Navigation".Length]; - propertyName = string.IsNullOrEmpty(prefix) ? propertyName.ToSnakeCase() : $"{prefix}.{propertyName.ToSnakeCase()}"; - - Type memberType = property.PropertyType; - var memberProperties = memberType.GetProperties(); - foreach (var memberProp in memberProperties) - { - // Skip cases like "Ticket.CustomerNavigation.TenantNavigation". - if (memberProp.Name.EndsWith("Navigation") && memberProp.Name.ToLower() != "Navigation") - { - continue; - } - var memberPropertyName = memberProp.Name.ToSnakeCase(); - result[$"{propertyName}.{memberPropertyName}"] = param => Expression.Property(Expression.Property(param, property.Name), memberPropertyName); - } - } - - return result; - } - - private static string ToSnakeCase(this string input) - { - if (string.IsNullOrEmpty(input)) - { - return input; - } - - return string.Concat(input.Select((x, i) => i > 0 && char.IsUpper(x) ? "_" + x.ToString() : x.ToString())).ToLower(); - } -} diff --git a/server/csharp/TicketHub/Authorization/UCASTNode.cs b/server/csharp/TicketHub/Authorization/UCASTNode.cs deleted file mode 100644 index 44ef0cd6..00000000 --- a/server/csharp/TicketHub/Authorization/UCASTNode.cs +++ /dev/null @@ -1,45 +0,0 @@ - -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -namespace TicketHub.Authorization; - -public class UCASTNode -{ - [JsonProperty("type")] - public required string Type; - - [JsonProperty("operator")] - public required string Op; - - [JsonProperty("field")] - public string? Field; - - [JsonProperty("value")] - [JsonConverter(typeof(UCASTNodeValueConverter))] - public object? Value; // Either another string, or a List. -} - -public class UCASTNodeValueConverter : JsonConverter -{ - public override bool CanConvert(Type objectType) - { - return objectType == typeof(object); - } - - public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) - { - JToken token = JToken.Load(reader); - if (token.Type == JTokenType.Array) - { - return token.ToObject>(); - } - - return token.ToObject(); - } - - public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) - { - serializer.Serialize(writer, value); - } -} diff --git a/server/csharp/TicketHub/Controllers/TicketController.cs b/server/csharp/TicketHub/Controllers/TicketController.cs index b25c0e04..f81fd825 100644 --- a/server/csharp/TicketHub/Controllers/TicketController.cs +++ b/server/csharp/TicketHub/Controllers/TicketController.cs @@ -2,11 +2,10 @@ using Microsoft.EntityFrameworkCore; using Newtonsoft.Json; using Styra.Opa; +using Styra.Ucast.Linq; using System.Linq.Expressions; using System.Net.Mime; using System.Reflection; -using System.Reflection.Metadata; -using System.Runtime.CompilerServices; using TicketHub.Authorization; using TicketHub.Database; diff --git a/server/csharp/TicketHub/TicketHub.csproj b/server/csharp/TicketHub/TicketHub.csproj index 18339006..1104f6e0 100644 --- a/server/csharp/TicketHub/TicketHub.csproj +++ b/server/csharp/TicketHub/TicketHub.csproj @@ -18,6 +18,7 @@ +