From 76f0ee7ad85b6d168890f5595b052d3a6aca3d19 Mon Sep 17 00:00:00 2001 From: Bram Kraaijeveld Date: Fri, 10 Jun 2022 14:36:04 +0200 Subject: [PATCH 1/2] add initial sql aggregate --- EventSourcing.Core/Records/Event.cs | 1 + EventSourcing.Core/Records/Record.cs | 35 ++- EventSourcing.Core/Records/Snapshot.cs | 1 + .../RecordConverter/RecordConverter.cs | 31 +- .../EventSourcing.EF.SqlAggregate.csproj | 18 ++ EventSourcing.EF.SqlAggregate/SqlAggregate.cs | 23 ++ .../SqlAggregateConverter.cs | 253 +++++++++++++++ .../SqlAggregateExpressionConverter.cs | 291 ++++++++++++++++++ .../BankAccountSqlAggregate.cs | 35 +++ .../EventSourcing.EF.Tests.Postgres.csproj | 11 +- .../PostgresEventSourcingTests.cs | 10 + .../PostgresTestContext.cs | 17 +- .../EventSourcing.EF.Tests.csproj | 1 + EventSourcing.EF/TypeExtensions.cs | 6 +- EventSourcing.sln | 14 + 15 files changed, 682 insertions(+), 65 deletions(-) create mode 100644 EventSourcing.EF.SqlAggregate/EventSourcing.EF.SqlAggregate.csproj create mode 100644 EventSourcing.EF.SqlAggregate/SqlAggregate.cs create mode 100644 EventSourcing.EF.SqlAggregate/SqlAggregateConverter.cs create mode 100644 EventSourcing.EF.SqlAggregate/SqlAggregateExpressionConverter.cs create mode 100644 EventSourcing.EF.Tests.Postgres/BankAccountSqlAggregate.cs diff --git a/EventSourcing.Core/Records/Event.cs b/EventSourcing.Core/Records/Event.cs index 45ced89..8958ffa 100644 --- a/EventSourcing.Core/Records/Event.cs +++ b/EventSourcing.Core/Records/Event.cs @@ -10,6 +10,7 @@ public record Event : Record /// /// The index of this with respect to /// + [JsonPropertyOrder(-2)] public long Index { get; init; } /// diff --git a/EventSourcing.Core/Records/Record.cs b/EventSourcing.Core/Records/Record.cs index 700cf2d..14e4e0e 100644 --- a/EventSourcing.Core/Records/Record.cs +++ b/EventSourcing.Core/Records/Record.cs @@ -31,23 +31,10 @@ public enum RecordKind /// public abstract record Record { - /// - /// of this . - /// - /// - /// Used to differentiate between kinds in database queries - /// - public RecordKind Kind => this switch - { - Projection => RecordKind.Projection, - Snapshot => RecordKind.Snapshot, - Event => RecordKind.Event, - _ => RecordKind.None - }; - /// /// String representation of Record Type. Defaults to GetType().Name /// + [JsonPropertyOrder(-8)] public string Type { get; init; } /// @@ -58,8 +45,24 @@ public abstract record Record /// Set to . when is added to an Aggregate. /// /// + [JsonPropertyOrder(-7)] public string? AggregateType { get; init; } + /// + /// of this . + /// + /// + /// Used to differentiate between kinds in database queries + /// + [JsonPropertyOrder(-6)] + public RecordKind Kind => this switch + { + Projection => RecordKind.Projection, + Snapshot => RecordKind.Snapshot, + Event => RecordKind.Event, + _ => RecordKind.None + }; + /// /// Unique Partition identifier. /// @@ -76,6 +79,7 @@ public abstract record Record /// i.e. no transactions involving multiple 's can be committed. /// /// + [JsonPropertyOrder(-5)] public Guid PartitionId { get; init; } /// @@ -86,16 +90,19 @@ public abstract record Record /// Set to . when is added to an Aggregate. /// /// + [JsonPropertyOrder(-4)] public Guid AggregateId { get; init; } /// /// Record creation/update time. Defaults to . on creation. /// + [JsonPropertyOrder(-3)] public DateTimeOffset Timestamp { get; init; } /// /// Unique Database identifier. /// + [JsonPropertyOrder(-1)] public abstract string id { get; } /// diff --git a/EventSourcing.Core/Records/Snapshot.cs b/EventSourcing.Core/Records/Snapshot.cs index 32aae1c..c1036be 100644 --- a/EventSourcing.Core/Records/Snapshot.cs +++ b/EventSourcing.Core/Records/Snapshot.cs @@ -20,6 +20,7 @@ public record Snapshot : Record /// /// The index of this with respect to /// + [JsonPropertyOrder(-2)] public long Index { get; init; } /// diff --git a/EventSourcing.Cosmos/RecordConverter/RecordConverter.cs b/EventSourcing.Cosmos/RecordConverter/RecordConverter.cs index 5d62de4..c55465f 100644 --- a/EventSourcing.Cosmos/RecordConverter/RecordConverter.cs +++ b/EventSourcing.Cosmos/RecordConverter/RecordConverter.cs @@ -49,32 +49,11 @@ public override TRecord Read(ref Utf8JsonReader reader, Type typeToConvert, Json private Type DeserializeRecordType(Utf8JsonReader reader) { - var json = JsonSerializer.Deserialize>(ref reader); - - // Get Record.Type String from Json - if (json == null || !json.TryGetValue("Type", out var typeString) || typeString.ValueKind != JsonValueKind.String) + if (reader.TokenType == JsonTokenType.StartObject && + reader.Read() && reader.TokenType == JsonTokenType.PropertyName && reader.GetString() == nameof(Record.Type) && + reader.Read() && reader.TokenType == JsonTokenType.String) + return _recordTypeCache.GetRecordType(reader.GetString()!); - // Throw Exception when json has no "Type" Property - throw new RecordValidationException( - $"Error converting {typeof(TRecord)}. " + - $"Couldn't parse {typeof(TRecord)}.Type string from Json. " + - $"Does the Json contain a {nameof(Record.Type)} field?"); - - var type = _recordTypeCache.GetRecordType(typeString.GetString()!); - - if (!_throwOnMissingNonNullableProperties) return type; - - var missing = _recordTypeCache.GetNonNullableRecordProperties(type) - .Where(property => !json.TryGetValue(property.Name, out var value) || value.ValueKind == JsonValueKind.Null) - .Select(property => property.Name) - .ToList(); - - if (missing.Count > 0) - throw new RecordValidationException( - $"Error converting Json to {type}'.\n" + - $"One ore more non-nullable properties are missing or null: {string.Join(", ", missing.Select(property => $"{type.Name}.{property}"))}.\n" + - $"Either make properties nullable or use a RecordMigrator to handle {typeof(TRecord)} versioning."); - - return type; + throw new JsonException("Could not deserialize Record Type"); } } \ No newline at end of file diff --git a/EventSourcing.EF.SqlAggregate/EventSourcing.EF.SqlAggregate.csproj b/EventSourcing.EF.SqlAggregate/EventSourcing.EF.SqlAggregate.csproj new file mode 100644 index 0000000..a894cd9 --- /dev/null +++ b/EventSourcing.EF.SqlAggregate/EventSourcing.EF.SqlAggregate.csproj @@ -0,0 +1,18 @@ + + + + net6.0 + enable + enable + + + + + + + + + + + + diff --git a/EventSourcing.EF.SqlAggregate/SqlAggregate.cs b/EventSourcing.EF.SqlAggregate/SqlAggregate.cs new file mode 100644 index 0000000..75c4804 --- /dev/null +++ b/EventSourcing.EF.SqlAggregate/SqlAggregate.cs @@ -0,0 +1,23 @@ +using System.Linq.Expressions; +using Finaps.EventSourcing.Core; + +namespace EventSourcing.EF.SqlAggregate; + +public abstract class SqlAggregate +{ + internal abstract List Clauses { get; } + + public Guid PartitionId { get; init; } + public Guid AggregateId { get; init; } + public long Version { get; init; } +} + +public class SqlAggregate : SqlAggregate + where TSqlAggregate : SqlAggregate, new() + where TAggregate : Aggregate, new() +{ + internal override List Clauses { get; } = new(); + + protected void Apply(Expression> expression) + where TEvent : Event => Clauses.Add(expression); +} \ No newline at end of file diff --git a/EventSourcing.EF.SqlAggregate/SqlAggregateConverter.cs b/EventSourcing.EF.SqlAggregate/SqlAggregateConverter.cs new file mode 100644 index 0000000..9b9f426 --- /dev/null +++ b/EventSourcing.EF.SqlAggregate/SqlAggregateConverter.cs @@ -0,0 +1,253 @@ +using System.Collections; +using System.Collections.Specialized; +using System.Linq.Expressions; +using System.Net; +using System.Net.NetworkInformation; +using System.Numerics; +using System.Reflection; +using System.Text.Json; +using Finaps.EventSourcing.Core; +using Finaps.EventSourcing.EF; +using Finaps.EventSourcing.EF.SqlAggregate; +using NpgsqlTypes; + +namespace EventSourcing.EF.SqlAggregate; + +public class SqlAggregateConverter where TSqlAggregate : SqlAggregate, new() +{ + public readonly string EventTableName = GetAggregateType(typeof(TSqlAggregate)).EventTable(); + public readonly string AggregateTypeName = typeof(TSqlAggregate).Name; + public string ApplyFunctionName => $"{AggregateTypeName}Apply"; + public string AggregateFunctionName => $"{AggregateTypeName}Aggregate"; + + public string AggregateTypeDefinition => $"CREATE TYPE {AggregateTypeName} AS ({string.Join(", ", ConvertPropertyTypes())});"; + + public string ApplyFunctionDefinition => + $"CREATE FUNCTION {ApplyFunctionName}({AggregateToken} {AggregateTypeName}, {EventToken} \"{EventTableName}\") " + + $"RETURNS {AggregateTypeName}\n" + + $"RETURN CASE\n{string.Join("\n", new TSqlAggregate().Clauses.Select(ConvertClause))}\nELSE {AggregateToken}\nEND;"; + + public string AggregateFunctionDefinition => + $"CREATE AGGREGATE {AggregateFunctionName}(\"{EventTableName}\")\n" + + "(\n" + + $" sfunc = {ApplyFunctionName},\n" + + $" stype = {AggregateTypeName},\n" + + $" initcond = '({string.Join(",", ConvertDefaultPropertyValues())})'\n" + + ");"; + + private static IEnumerable Properties => typeof(TSqlAggregate).GetProperties().OrderBy(x => x.MetadataToken); + private const string AggregateToken = "aggregate"; + private const string EventToken = "event"; + + private static IEnumerable ConvertPropertyTypes() => Properties + .Select(x => $"{x.Name} {ConvertType(x.PropertyType)}"); + + private static IEnumerable ConvertDefaultPropertyValues() => Properties + .Select(x => ConvertDefaultValue(x.PropertyType)); + + private static string ConvertDefaultValue(Type type) + { + if (ConstructorTypeToSqlDefaultValue.TryGetValue(type, out var result)) + return result?.ToString() ?? "null"; + + throw new NotSupportedException($"Type {type} is not supported"); + } + + private static string ConvertType(Type type) + { + if (ConstructorTypeToSqlType.TryGetValue(type, out var result)) + return result; + + throw new NotSupportedException($"Type {type} is not supported"); + } + + private static string ConvertClause(LambdaExpression expression) => + $"WHEN {EventToken}.\"{nameof(Event.Type)}\" = '{expression.Parameters.Last().Type.Name}' THEN {new SqlAggregateExpressionConverter().Convert(expression)}"; + + private static Type GetAggregateType(Type? type) + { + while (type != null) + { + var aggregateType = type.GetGenericArguments().FirstOrDefault(typeof(Aggregate).IsAssignableFrom); + if (aggregateType != null) return aggregateType; + type = type.BaseType; + } + + throw new InvalidOperationException("Couldn't find Aggregate Type"); + } + + // Adapted from: https://github.com/npgsql/npgsql/blob/main/src/Npgsql/TypeMapping/BuiltInTypeHandlerResolver.cs + private static readonly Dictionary ConstructorTypeToSqlType = new() + { + // Numeric types + { typeof(byte), "smallint" }, + { typeof(short), "smallint" }, + { typeof(int), "integer" }, + { typeof(long), "bigint" }, + { typeof(float), "real" }, + { typeof(double), "double precision" }, + { typeof(decimal), "decimal" }, + { typeof(BigInteger), "decimal" }, + + // Text types + { typeof(string), "text" }, + { typeof(char[]), "text" }, + { typeof(char), "text" }, + { typeof(ArraySegment), "text" }, + { typeof(JsonDocument), "jsonb" }, + + // Date/time types + // The DateTime entry is for LegacyTimestampBehavior mode only. In regular mode we resolve through + // ResolveValueDependentValue below + { typeof(DateTime), "timestamp without time zone" }, + { typeof(DateTimeOffset), "timestamp with time zone" }, + { typeof(DateOnly), "date" }, + { typeof(TimeOnly), "time without time zone" }, + { typeof(TimeSpan), "interval" }, + { typeof(NpgsqlInterval), "interval" }, + + // Network types + { typeof(IPAddress), "inet" }, + // See ReadOnlyIPAddress below + { typeof((IPAddress Address, int Subnet)), "inet" }, +#pragma warning disable 618 + { typeof(NpgsqlInet), "inet" }, +#pragma warning restore 618 + { typeof(PhysicalAddress), "macaddr" }, + + // Full-text types + { typeof(NpgsqlTsVector), "tsvector" }, + { typeof(NpgsqlTsQueryLexeme), "tsquery" }, + { typeof(NpgsqlTsQueryAnd), "tsquery" }, + { typeof(NpgsqlTsQueryOr), "tsquery" }, + { typeof(NpgsqlTsQueryNot), "tsquery" }, + { typeof(NpgsqlTsQueryEmpty), "tsquery" }, + { typeof(NpgsqlTsQueryFollowedBy), "tsquery" }, + + // Geometry types + { typeof(NpgsqlBox), "box" }, + { typeof(NpgsqlCircle), "circle" }, + { typeof(NpgsqlLine), "line" }, + { typeof(NpgsqlLSeg), "lseg" }, + { typeof(NpgsqlPath), "path" }, + { typeof(NpgsqlPoint), "point" }, + { typeof(NpgsqlPolygon), "polygon" }, + + // Misc types + { typeof(bool), "boolean" }, + { typeof(byte[]), "bytea" }, + { typeof(ArraySegment), "bytea" }, + { typeof(Guid), "uuid" }, + { typeof(BitArray), "bit varying" }, + { typeof(BitVector32), "bit varying" }, + { typeof(Dictionary), "hstore" }, + + // Internal types + { typeof(NpgsqlLogSequenceNumber), "pg_lsn" }, + { typeof(NpgsqlTid), "tid" }, + { typeof(DBNull), "unknown" }, + + // Built-in range types + { typeof(NpgsqlRange), "int4range" }, + { typeof(NpgsqlRange), "int8range" }, + { typeof(NpgsqlRange), "numrange" }, + { typeof(NpgsqlRange), "daterange" }, + + // Built-in multirange types + { typeof(NpgsqlRange[]), "int4multirange" }, + { typeof(List>), "int4multirange" }, + { typeof(NpgsqlRange[]), "int8multirange" }, + { typeof(List>), "int8multirange" }, + { typeof(NpgsqlRange[]), "nummultirange" }, + { typeof(List>), "nummultirange" }, + { typeof(NpgsqlRange[]), "datemultirange" }, + { typeof(List>), "datemultirange" }, + }; + + private static readonly Dictionary ConstructorTypeToSqlDefaultValue = new() + { + // Numeric types + { typeof(byte), default(byte) }, + { typeof(short), default(short) }, + { typeof(int), default(int) }, + { typeof(long), default(long) }, + { typeof(float), default(float) }, + { typeof(double), default(double) }, + { typeof(decimal), default(decimal) }, + { typeof(BigInteger), default(BigInteger) }, + + // Text types + { typeof(string), default(string) }, + { typeof(char[]), default(char[]) }, + { typeof(char), default(char[]) }, + { typeof(ArraySegment), default(ArraySegment) }, + { typeof(JsonDocument), default(JsonDocument) }, + + // Date/time types + // The DateTime entry is for LegacyTimestampBehavior mode only. In regular mode we resolve through + // ResolveValueDependentValue below + { typeof(DateTime), default(DateTime) }, + { typeof(DateTimeOffset), default(DateTimeOffset) }, + { typeof(DateOnly), default(DateOnly) }, + { typeof(TimeOnly), default(TimeOnly) }, + { typeof(TimeSpan), default(TimeSpan) }, + { typeof(NpgsqlInterval), default(NpgsqlInterval) }, + + // Network types + { typeof(IPAddress), default(IPAddress) }, + // See ReadOnlyIPAddress below + { typeof((IPAddress Address, int Subnet)), default((IPAddress, int)) }, +#pragma warning disable 618 + { typeof(NpgsqlInet), default(NpgsqlInet) }, +#pragma warning restore 618 + { typeof(PhysicalAddress), default(PhysicalAddress) }, + + // Full-text types + { typeof(NpgsqlTsVector), default(NpgsqlTsVector) }, + { typeof(NpgsqlTsQueryLexeme), default(NpgsqlTsQueryLexeme) }, + { typeof(NpgsqlTsQueryAnd), default(NpgsqlTsQueryAnd) }, + { typeof(NpgsqlTsQueryOr), default(NpgsqlTsQueryOr) }, + { typeof(NpgsqlTsQueryNot), default(NpgsqlTsQueryNot) }, + { typeof(NpgsqlTsQueryEmpty), default(NpgsqlTsQueryEmpty) }, + { typeof(NpgsqlTsQueryFollowedBy), default(NpgsqlTsQueryFollowedBy) }, + + // Geometry types + { typeof(NpgsqlBox), default(NpgsqlBox) }, + { typeof(NpgsqlCircle), default(NpgsqlCircle) }, + { typeof(NpgsqlLine), default(NpgsqlLine) }, + { typeof(NpgsqlLSeg), default(NpgsqlLSeg) }, + { typeof(NpgsqlPath), default(NpgsqlPath) }, + { typeof(NpgsqlPoint), default(NpgsqlPoint) }, + { typeof(NpgsqlPolygon), default(NpgsqlPolygon) }, + + // Misc types + { typeof(bool), default(bool) }, + { typeof(byte[]), default(byte[]) }, + { typeof(ArraySegment), default(ArraySegment) }, + { typeof(Guid), default(Guid) }, + { typeof(BitArray), default(BitArray) }, + { typeof(BitVector32), default(BitVector32) }, + { typeof(Dictionary), default(Dictionary) }, + + // Internal types + { typeof(NpgsqlLogSequenceNumber), default(NpgsqlLogSequenceNumber) }, + { typeof(NpgsqlTid), default(NpgsqlTid) }, + { typeof(DBNull), default(DBNull) }, + + // Built-in range types + { typeof(NpgsqlRange), default(NpgsqlRange) }, + { typeof(NpgsqlRange), default(NpgsqlRange) }, + { typeof(NpgsqlRange), default(NpgsqlRange) }, + { typeof(NpgsqlRange), default(NpgsqlRange) }, + + // Built-in multirange types + { typeof(NpgsqlRange[]), default(NpgsqlRange[]) }, + { typeof(List>), default(List>) }, + { typeof(NpgsqlRange[]), default(NpgsqlRange[]) }, + { typeof(List>), default(List>) }, + { typeof(NpgsqlRange[]), default(NpgsqlRange[]) }, + { typeof(List>), default(List>) }, + { typeof(NpgsqlRange[]), default(NpgsqlRange[]) }, + { typeof(List>), default(List>) } + }; +} \ No newline at end of file diff --git a/EventSourcing.EF.SqlAggregate/SqlAggregateExpressionConverter.cs b/EventSourcing.EF.SqlAggregate/SqlAggregateExpressionConverter.cs new file mode 100644 index 0000000..4869c71 --- /dev/null +++ b/EventSourcing.EF.SqlAggregate/SqlAggregateExpressionConverter.cs @@ -0,0 +1,291 @@ +using System.Linq.Expressions; +using System.Text.RegularExpressions; +using Finaps.EventSourcing.Core; + +namespace Finaps.EventSourcing.EF.SqlAggregate; + +public class SqlAggregateExpressionConverter : ExpressionVisitor +{ + private readonly List _tokens = new(); + + public string Convert(Expression expression) + { + _tokens.Clear(); + + Visit(expression); + + return string.Join(" ", _tokens) + .Replace("( ", "(") + .Replace(" )", ")") + .Replace(" ,", ","); + } + + // https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-PRECEDENCE-TABLE + protected virtual int Precedence(Expression expression) => + expression.NodeType switch + { + ExpressionType.OrElse => -4, + ExpressionType.AndAlso => -3, + ExpressionType.Not => -2, + ExpressionType.LessThan or ExpressionType.LessThanOrEqual or + ExpressionType.Equal or ExpressionType.NotEqual or + ExpressionType.GreaterThan or ExpressionType.GreaterThanOrEqual => -1, + // (any other operator) => 0 + ExpressionType.Add or ExpressionType.Subtract => 1, + ExpressionType.Multiply or ExpressionType.Divide or ExpressionType.Modulo => 2, + ExpressionType.Power => 3, + ExpressionType.UnaryPlus or ExpressionType.Negate => 4, + ExpressionType.Index => 5, + ExpressionType.Convert => 6, + ExpressionType.MemberAccess or ExpressionType.Constant => 7, + _ => 0 + }; + + protected virtual string ConvertUnary(UnaryExpression node) => + node.NodeType switch + { + ExpressionType.Not => "NOT", + ExpressionType.Negate => "-", + _ => throw new NotSupportedException($"The unary operator '{node.NodeType}' is not supported") + }; + + protected virtual object ConvertObject(object? obj) => + obj switch + { + null => "NULL", + bool x => x, + sbyte x => x, + short x => x, + int x => x, + long x => x, + byte x => x, + ushort x => x, + uint x => x, + ulong x => x, + decimal x => x, + float x => x, + double x => x, + string x => SingleQuote(x), + Guid x => SingleQuote(x), + DateTime x => SingleQuote(x), + DateTimeOffset x => SingleQuote(x), + _ => throw new NotSupportedException($"The constant for '{obj}' is not supported") + }; + + protected virtual string ConvertBinary(BinaryExpression node) => + node.NodeType switch + { + // Binary Operators + ExpressionType.And => "&", + ExpressionType.AndAlso => "AND", + ExpressionType.Or => "|", + ExpressionType.OrElse => "OR", + + ExpressionType.Equal => IsNullConstant(node.Right) ? "IS" : "=", + ExpressionType.NotEqual => IsNullConstant(node.Right) ? "IS NOT" : "<>", + + ExpressionType.LessThan => "<", + ExpressionType.LessThanOrEqual => "<=", + ExpressionType.GreaterThan => ">", + ExpressionType.GreaterThanOrEqual => ">=", + + ExpressionType.Add => node.Type == typeof(string) ? "||" : "+", + ExpressionType.Subtract => "-", + ExpressionType.Multiply => "*", + ExpressionType.Divide => "/", + ExpressionType.Modulo => "%", + ExpressionType.Power => "^", + + // Binary Methods + ExpressionType.Coalesce => "coalesce", + + _ => throw new NotSupportedException($"The binary operator '{node.NodeType}' is not supported") + }; + + public void Visit(Expression? node, bool brackets) + { + if (brackets) _tokens.Add("("); + base.Visit(node); + if (brackets) _tokens.Add(")"); + } + + protected override Expression VisitUnary(UnaryExpression node) + { + if (node.NodeType != ExpressionType.Convert) + _tokens.Add(ConvertUnary(node)); + + Visit(node.Operand, Precedence(node) > Precedence(node.Operand)); + return node; + } + + protected override Expression VisitBinary(BinaryExpression node) + { + return node.NodeType is ExpressionType.Coalesce + ? VisitBinaryFunction(node) + : VisitBinaryOperator(node); + } + + protected virtual Expression VisitBinaryOperator(BinaryExpression node) + { + Visit(node.Left, Precedence(node) > Precedence(node.Left)); + + _tokens.Add(ConvertBinary(node)); + + Visit(node.Right, Precedence(node) >= Precedence(node.Right)); + + return node; + } + + protected virtual Expression VisitBinaryFunction(BinaryExpression node) + { + _tokens.Add($"{ConvertBinary(node)}("); + + Visit(node.Left); + + _tokens.Add(","); + + Visit(node.Right); + + _tokens.Add(")"); + + return node; + } + + protected override Expression VisitConditional(ConditionalExpression node) + { + _tokens.Add("CASE WHEN"); + + Visit(node.Test); + + _tokens.Add("THEN"); + + Visit(node.IfTrue); + + _tokens.Add("ELSE"); + + Visit(node.IfFalse); + + _tokens.Add("END"); + + return node; + } + + protected override Expression VisitSwitch(SwitchExpression node) + { + throw new NotSupportedException(); + } + + protected override Expression VisitConstant(ConstantExpression node) + { + _tokens.Add(ConvertObject(node.Value).ToString()!); + return node; + } + + protected override Expression VisitMember(MemberExpression node) + { + switch (IsParameterAccess(node)) + { + // When current node accesses an event parameter (e.g. e => e.A) -> resolve as event."A" + case true when node.Expression is { NodeType: ExpressionType.Parameter } && node.Expression.Type.IsAssignableTo(typeof(Event)): + _tokens.Add($"event.\"{node.Member.Name}\""); + break; + + case true when node.Expression is { NodeType: ExpressionType.Parameter } && node.Expression.Type.IsAssignableTo(typeof(global::EventSourcing.EF.SqlAggregate.SqlAggregate)): + _tokens.Add($"aggregate.{node.Member.Name}"); + break; + + // When current node accesses the str.Length member -> resolve as char_length(str) + case true or false when node.Member.DeclaringType == typeof(string) && node.Member.Name == nameof(string.Length): + _tokens.Add("char_length("); + Visit(node.Expression); + _tokens.Add(")"); + break; + + // When current node accesses non parameter member -> resolve object + case false: + _tokens.Add(ConvertObject(GetValue(node)).ToString()!); + break; + + // Otherwise, member access is not supported + default: + throw new NotSupportedException($"Member {node.Member.DeclaringType}.{node.Member.Name} is not supported"); + } + + return node; + } + + protected override Expression VisitMethodCall(MethodCallExpression node) + { + switch (node.Method.Name) + { + case nameof(Regex.IsMatch) when node.Object != null && GetValue(node.Object) is Regex regex: + VisitRegex(node.Arguments.Single(), regex); + break; + case nameof(Regex.IsMatch) when node.Method.DeclaringType == typeof(Regex): + VisitRegex(node.Arguments.First(), node.Arguments.Skip(1).Single()); + break; + case nameof(string.ToLower): + _tokens.Add("lower("); + Visit(node.Object); + _tokens.Add(")"); + break; + case nameof(string.ToUpper): + _tokens.Add("upper("); + Visit(node.Object); + _tokens.Add(")"); + break; + default: + throw new NotSupportedException($"Method {node.Method.DeclaringType}.{node.Method.Name} is not supported"); + } + + return node; + } + + protected virtual void VisitRegex(Expression argument, Regex regex) + { + Visit(argument, 0 > Precedence(argument)); + _tokens.AddRange(new[] { "~", SingleQuote(regex) }); + } + + protected virtual void VisitRegex(Expression argument, Expression regex) + { + Visit(argument, 0 > Precedence(argument)); + _tokens.Add("~"); + Visit(regex, 0 > Precedence(regex)); + } + + protected override Expression VisitMemberInit(MemberInitExpression node) + { + var bindings = node.Bindings.Cast().ToDictionary(x => x.Member.Name); + var properties = node.NewExpression.Type.GetProperties().OrderBy(x => x.MetadataToken).ToList(); + + _tokens.Add("ROW("); + + foreach (var property in properties) + { + if (bindings.TryGetValue(property.Name, out var binding)) Visit(binding.Expression); + else if (property.Name == nameof(global::EventSourcing.EF.SqlAggregate.SqlAggregate.Version)) _tokens.Add($"aggregate.{property.Name} + 1"); + else _tokens.Add($"aggregate.{property.Name}"); + + if (property != properties.Last()) _tokens.Add(","); + } + + _tokens.Add($")::{node.NewExpression.Type.Name}"); + + return node; + } + + private static object GetValue(Expression member) => + Expression.Lambda>(Expression.Convert(member, typeof(object))).Compile()(); + + private static bool IsParameterAccess(MemberExpression? e) => + e != null && + (e.Expression is { NodeType: ExpressionType.Parameter } || IsParameterAccess(e.Expression as MemberExpression)); + + private static bool IsNullConstant(Expression exp) + { + return exp.NodeType == ExpressionType.Constant && ((ConstantExpression)exp).Value == null; + } + + private static string SingleQuote(object x) => $"'{x}'"; +} \ No newline at end of file diff --git a/EventSourcing.EF.Tests.Postgres/BankAccountSqlAggregate.cs b/EventSourcing.EF.Tests.Postgres/BankAccountSqlAggregate.cs new file mode 100644 index 0000000..bd6d2bb --- /dev/null +++ b/EventSourcing.EF.Tests.Postgres/BankAccountSqlAggregate.cs @@ -0,0 +1,35 @@ +using EventSourcing.EF.SqlAggregate; +using Finaps.EventSourcing.Core.Tests.Mocks; + +namespace Finaps.EventSourcing.EF; + +class BankAccountSqlAggregate : SqlAggregate +{ + public string? Name { get; init; } + public string? Iban { get; init; } + public decimal Amount { get; init; } + + public BankAccountSqlAggregate() + { + Apply((aggregate, e) => new BankAccountSqlAggregate + { + Name = e.Name, + Iban = e.Iban + }); + + Apply((aggregate, e) => new BankAccountSqlAggregate + { + Amount = aggregate.Amount + e.Amount + }); + + Apply((aggregate, e) => new BankAccountSqlAggregate + { + Amount = aggregate.Amount - e.Amount + }); + + Apply((aggregate, e) => new BankAccountSqlAggregate + { + Amount = aggregate.Amount - (aggregate.AggregateId == e.DebtorAccount ? -e.Amount : e.Amount) + }); + } +} \ No newline at end of file diff --git a/EventSourcing.EF.Tests.Postgres/EventSourcing.EF.Tests.Postgres.csproj b/EventSourcing.EF.Tests.Postgres/EventSourcing.EF.Tests.Postgres.csproj index 4eb35d0..26dc4de 100644 --- a/EventSourcing.EF.Tests.Postgres/EventSourcing.EF.Tests.Postgres.csproj +++ b/EventSourcing.EF.Tests.Postgres/EventSourcing.EF.Tests.Postgres.csproj @@ -15,7 +15,7 @@ - + all @@ -38,13 +38,4 @@ - - - ..\..\..\..\..\usr\local\share\dotnet\shared\Microsoft.AspNetCore.App\5.0.8\Microsoft.Extensions.Configuration.dll - - - ..\..\..\..\..\usr\local\share\dotnet\shared\Microsoft.AspNetCore.App\5.0.8\Microsoft.Extensions.Configuration.Abstractions.dll - - - diff --git a/EventSourcing.EF.Tests.Postgres/PostgresEventSourcingTests.cs b/EventSourcing.EF.Tests.Postgres/PostgresEventSourcingTests.cs index ee5f5da..4e5c894 100644 --- a/EventSourcing.EF.Tests.Postgres/PostgresEventSourcingTests.cs +++ b/EventSourcing.EF.Tests.Postgres/PostgresEventSourcingTests.cs @@ -1,5 +1,7 @@ using System; +using EventSourcing.EF.SqlAggregate; using Finaps.EventSourcing.Core; +using Xunit; namespace Finaps.EventSourcing.EF.Tests.Postgres; @@ -7,4 +9,12 @@ public class PostgresEventSourcingTests : EntityFrameworkEventSourcingTests { protected override IRecordStore RecordStore => new EntityFrameworkRecordStore(RecordContext); public override RecordContext RecordContext => new TestContextFactory().CreateDbContext(Array.Empty()); + + [Fact] + public void Can_Get_Sql_Type_Definition() + { + var type = new SqlAggregateConverter(); + + Assert.Equal("create type BankAccountSqlAggregate AS (PartitionId uuid);", type.AggregateTypeDefinition); + } } \ No newline at end of file diff --git a/EventSourcing.EF.Tests.Postgres/PostgresTestContext.cs b/EventSourcing.EF.Tests.Postgres/PostgresTestContext.cs index 74f4fe3..26787be 100644 --- a/EventSourcing.EF.Tests.Postgres/PostgresTestContext.cs +++ b/EventSourcing.EF.Tests.Postgres/PostgresTestContext.cs @@ -1,4 +1,4 @@ -using System; +using EventSourcing.EF.SqlAggregate; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; using Microsoft.Extensions.Configuration; @@ -12,18 +12,11 @@ public PostgresTestRecordContext(DbContextOptions opt public class TestContextFactory : IDesignTimeDbContextFactory { - public PostgresTestRecordContext CreateDbContext(string[] args) - { - var configuration = new ConfigurationBuilder() - .AddJsonFile("appsettings.json", false) - .AddJsonFile("appsettings.local.json", true) - .AddEnvironmentVariables() - .Build(); - - return new PostgresTestRecordContext(new DbContextOptionsBuilder() - .UseNpgsql(configuration.GetConnectionString("RecordStore")) + public PostgresTestRecordContext CreateDbContext(string[] args) => + new (new DbContextOptionsBuilder() + .UseNpgsql(new ConfigurationBuilder().AddJsonFile("appsettings.json").Build() + .GetConnectionString("RecordStore")) .UseAllCheckConstraints() .EnableSensitiveDataLogging() .Options); - } } \ No newline at end of file diff --git a/EventSourcing.EF.Tests/EventSourcing.EF.Tests.csproj b/EventSourcing.EF.Tests/EventSourcing.EF.Tests.csproj index efefa32..7c0b47a 100644 --- a/EventSourcing.EF.Tests/EventSourcing.EF.Tests.csproj +++ b/EventSourcing.EF.Tests/EventSourcing.EF.Tests.csproj @@ -18,6 +18,7 @@ + diff --git a/EventSourcing.EF/TypeExtensions.cs b/EventSourcing.EF/TypeExtensions.cs index 9db28c5..c502294 100644 --- a/EventSourcing.EF/TypeExtensions.cs +++ b/EventSourcing.EF/TypeExtensions.cs @@ -2,8 +2,8 @@ namespace Finaps.EventSourcing.EF; -internal static class TypeExtensions +public static class TypeExtensions { - public static string EventTable(this Type type) => $"{type.Name}{nameof(Event)}s"; - public static string SnapshotTable(this Type type) => $"{type.Name}{nameof(Snapshot)}s"; + public static string EventTable(this Type type) => $"{type.Name}{nameof(Event)}"; + public static string SnapshotTable(this Type type) => $"{type.Name}{nameof(Snapshot)}"; } diff --git a/EventSourcing.sln b/EventSourcing.sln index 5c75742..65a78ca 100644 --- a/EventSourcing.sln +++ b/EventSourcing.sln @@ -27,6 +27,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventSourcing.Example.Tests EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventSourcing.Example.Tests.Postgres", "EventSourcing.Example.Tests.Postgres\EventSourcing.Example.Tests.Postgres.csproj", "{A8976424-DA37-4C61-A6D9-FB837CD632D0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventSourcing.EF.SqlAggregate", "EventSourcing.EF.SqlAggregate\EventSourcing.EF.SqlAggregate.csproj", "{CF29CA6A-FA71-4325-9870-18D713C11B04}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -196,5 +198,17 @@ Global {A8976424-DA37-4C61-A6D9-FB837CD632D0}.Release|x64.Build.0 = Release|Any CPU {A8976424-DA37-4C61-A6D9-FB837CD632D0}.Release|x86.ActiveCfg = Release|Any CPU {A8976424-DA37-4C61-A6D9-FB837CD632D0}.Release|x86.Build.0 = Release|Any CPU + {CF29CA6A-FA71-4325-9870-18D713C11B04}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CF29CA6A-FA71-4325-9870-18D713C11B04}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CF29CA6A-FA71-4325-9870-18D713C11B04}.Debug|x64.ActiveCfg = Debug|Any CPU + {CF29CA6A-FA71-4325-9870-18D713C11B04}.Debug|x64.Build.0 = Debug|Any CPU + {CF29CA6A-FA71-4325-9870-18D713C11B04}.Debug|x86.ActiveCfg = Debug|Any CPU + {CF29CA6A-FA71-4325-9870-18D713C11B04}.Debug|x86.Build.0 = Debug|Any CPU + {CF29CA6A-FA71-4325-9870-18D713C11B04}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CF29CA6A-FA71-4325-9870-18D713C11B04}.Release|Any CPU.Build.0 = Release|Any CPU + {CF29CA6A-FA71-4325-9870-18D713C11B04}.Release|x64.ActiveCfg = Release|Any CPU + {CF29CA6A-FA71-4325-9870-18D713C11B04}.Release|x64.Build.0 = Release|Any CPU + {CF29CA6A-FA71-4325-9870-18D713C11B04}.Release|x86.ActiveCfg = Release|Any CPU + {CF29CA6A-FA71-4325-9870-18D713C11B04}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection EndGlobal From bf7cf1427965e1e702b267d03603b17d4f7de426 Mon Sep 17 00:00:00 2001 From: Bram Kraaijeveld Date: Tue, 14 Jun 2022 15:01:08 +0200 Subject: [PATCH 2/2] make sql aggregate work with ef core --- .../EventSourcing.EF.SqlAggregate.csproj | 2 + .../MigrationBuilderExtensions.cs | 9 + .../ModelBuilderExtensions.cs | 10 + .../RecordContextExtensions.cs | 14 ++ EventSourcing.EF.SqlAggregate/SqlAggregate.cs | 17 +- ...ateConverter.cs => SqlAggregateBuilder.cs} | 174 +++++------------- .../SqlAggregateExpressionConverter.cs | 14 +- .../SqlAggregateMigrationBuilder.cs | 77 ++++++++ .../BankAccountSqlAggregate.cs | 27 +-- .../EventSourcing.EF.Tests.Postgres.csproj | 5 +- .../PostgresEventSourcingTests.cs | 13 +- .../PostgresTestContext.cs | 34 +++- 12 files changed, 210 insertions(+), 186 deletions(-) create mode 100644 EventSourcing.EF.SqlAggregate/MigrationBuilderExtensions.cs create mode 100644 EventSourcing.EF.SqlAggregate/ModelBuilderExtensions.cs create mode 100644 EventSourcing.EF.SqlAggregate/RecordContextExtensions.cs rename EventSourcing.EF.SqlAggregate/{SqlAggregateConverter.cs => SqlAggregateBuilder.cs} (51%) create mode 100644 EventSourcing.EF.SqlAggregate/SqlAggregateMigrationBuilder.cs diff --git a/EventSourcing.EF.SqlAggregate/EventSourcing.EF.SqlAggregate.csproj b/EventSourcing.EF.SqlAggregate/EventSourcing.EF.SqlAggregate.csproj index a894cd9..0dc854f 100644 --- a/EventSourcing.EF.SqlAggregate/EventSourcing.EF.SqlAggregate.csproj +++ b/EventSourcing.EF.SqlAggregate/EventSourcing.EF.SqlAggregate.csproj @@ -12,6 +12,8 @@ + + diff --git a/EventSourcing.EF.SqlAggregate/MigrationBuilderExtensions.cs b/EventSourcing.EF.SqlAggregate/MigrationBuilderExtensions.cs new file mode 100644 index 0000000..861a3cc --- /dev/null +++ b/EventSourcing.EF.SqlAggregate/MigrationBuilderExtensions.cs @@ -0,0 +1,9 @@ +using Finaps.EventSourcing.EF.SqlAggregate; + +namespace Microsoft.EntityFrameworkCore.Migrations; + +public static class MigrationBuilderExtensions +{ + public static void CreateSqlAggregate(this MigrationBuilder builder, string? assemblyQualifiedName) => + builder.Operations.Add(new AddSqlAggregateOperation(assemblyQualifiedName)); +} \ No newline at end of file diff --git a/EventSourcing.EF.SqlAggregate/ModelBuilderExtensions.cs b/EventSourcing.EF.SqlAggregate/ModelBuilderExtensions.cs new file mode 100644 index 0000000..3c163cc --- /dev/null +++ b/EventSourcing.EF.SqlAggregate/ModelBuilderExtensions.cs @@ -0,0 +1,10 @@ +using Finaps.EventSourcing.Core; +using Microsoft.EntityFrameworkCore; + +namespace EventSourcing.EF.SqlAggregate; + +public static class ModelBuilderExtensions +{ + public static SqlAggregateBuilder Aggregate(this ModelBuilder builder) + where TAggregate : Aggregate, new() where TSqlAggregate : SQLAggregate, new() => new(builder); +} \ No newline at end of file diff --git a/EventSourcing.EF.SqlAggregate/RecordContextExtensions.cs b/EventSourcing.EF.SqlAggregate/RecordContextExtensions.cs new file mode 100644 index 0000000..ced09a2 --- /dev/null +++ b/EventSourcing.EF.SqlAggregate/RecordContextExtensions.cs @@ -0,0 +1,14 @@ +using Finaps.EventSourcing.Core; +using Finaps.EventSourcing.EF; +using Microsoft.EntityFrameworkCore; + +namespace EventSourcing.EF.SqlAggregate; + +public static class RecordContextExtensions +{ + public static IQueryable Aggregate(this RecordContext context) + where TAggregate : Aggregate, new() where TSqlAggregate : SQLAggregate, new() => context.Set().FromSqlRaw( +$@"SELECT ({typeof(TAggregate).Name}{typeof(TSqlAggregate).Name}Aggregate(e ORDER BY ""{nameof(Event.Index)}"")).* +FROM ""{typeof(TAggregate).EventTable()}"" AS e +GROUP BY ""{nameof(SQLAggregate.AggregateId)}"""); +} \ No newline at end of file diff --git a/EventSourcing.EF.SqlAggregate/SqlAggregate.cs b/EventSourcing.EF.SqlAggregate/SqlAggregate.cs index 75c4804..5aab8de 100644 --- a/EventSourcing.EF.SqlAggregate/SqlAggregate.cs +++ b/EventSourcing.EF.SqlAggregate/SqlAggregate.cs @@ -1,23 +1,8 @@ -using System.Linq.Expressions; -using Finaps.EventSourcing.Core; - namespace EventSourcing.EF.SqlAggregate; -public abstract class SqlAggregate +public abstract record SQLAggregate { - internal abstract List Clauses { get; } - public Guid PartitionId { get; init; } public Guid AggregateId { get; init; } public long Version { get; init; } -} - -public class SqlAggregate : SqlAggregate - where TSqlAggregate : SqlAggregate, new() - where TAggregate : Aggregate, new() -{ - internal override List Clauses { get; } = new(); - - protected void Apply(Expression> expression) - where TEvent : Event => Clauses.Add(expression); } \ No newline at end of file diff --git a/EventSourcing.EF.SqlAggregate/SqlAggregateConverter.cs b/EventSourcing.EF.SqlAggregate/SqlAggregateBuilder.cs similarity index 51% rename from EventSourcing.EF.SqlAggregate/SqlAggregateConverter.cs rename to EventSourcing.EF.SqlAggregate/SqlAggregateBuilder.cs index 9b9f426..bd89186 100644 --- a/EventSourcing.EF.SqlAggregate/SqlAggregateConverter.cs +++ b/EventSourcing.EF.SqlAggregate/SqlAggregateBuilder.cs @@ -9,39 +9,69 @@ using Finaps.EventSourcing.Core; using Finaps.EventSourcing.EF; using Finaps.EventSourcing.EF.SqlAggregate; +using Microsoft.EntityFrameworkCore; using NpgsqlTypes; namespace EventSourcing.EF.SqlAggregate; -public class SqlAggregateConverter where TSqlAggregate : SqlAggregate, new() + +public abstract class SqlAggregateBuilder { - public readonly string EventTableName = GetAggregateType(typeof(TSqlAggregate)).EventTable(); - public readonly string AggregateTypeName = typeof(TSqlAggregate).Name; - public string ApplyFunctionName => $"{AggregateTypeName}Apply"; - public string AggregateFunctionName => $"{AggregateTypeName}Aggregate"; - - public string AggregateTypeDefinition => $"CREATE TYPE {AggregateTypeName} AS ({string.Join(", ", ConvertPropertyTypes())});"; + internal static Dictionary> Cache { get; } = new(); - public string ApplyFunctionDefinition => - $"CREATE FUNCTION {ApplyFunctionName}({AggregateToken} {AggregateTypeName}, {EventToken} \"{EventTableName}\") " + - $"RETURNS {AggregateTypeName}\n" + - $"RETURN CASE\n{string.Join("\n", new TSqlAggregate().Clauses.Select(ConvertClause))}\nELSE {AggregateToken}\nEND;"; + public abstract string SQL { get; } +} - public string AggregateFunctionDefinition => +public class SqlAggregateBuilder : SqlAggregateBuilder + where TAggregate : Aggregate, new() + where TSqlAggregate : SQLAggregate, new() +{ + public override string SQL => $"{ApplyFunctionDefinition}\n{AggregateFunctionDefinition}"; + + private List Clauses { get; } = new(); + + private static string EventTableName => typeof(TAggregate).EventTable(); + private static string ApplyFunctionName => $"{typeof(TAggregate).Name}{typeof(TSqlAggregate).Name}Apply"; + private static string AggregateFunctionName => $"{typeof(TAggregate).Name}{typeof(TSqlAggregate).Name}Aggregate"; + private static string AggregateFunctionDefinition => $"CREATE AGGREGATE {AggregateFunctionName}(\"{EventTableName}\")\n" + "(\n" + $" sfunc = {ApplyFunctionName},\n" + - $" stype = {AggregateTypeName},\n" + + $" stype = \"{typeof(TSqlAggregate).Name}\",\n" + $" initcond = '({string.Join(",", ConvertDefaultPropertyValues())})'\n" + ");"; - - private static IEnumerable Properties => typeof(TSqlAggregate).GetProperties().OrderBy(x => x.MetadataToken); + private string ApplyFunctionDefinition => + $"CREATE FUNCTION {ApplyFunctionName}({AggregateToken} \"{typeof(TSqlAggregate).Name}\", {EventToken} \"{EventTableName}\") " + + $"RETURNS \"{typeof(TSqlAggregate).Name}\"\n" + + $"RETURN CASE\n{string.Join("\n", Clauses.Select(ConvertClause))}\nELSE {AggregateToken}\nEND;"; + + private static IEnumerable Properties => typeof(TSqlAggregate).GetProperties().OrderBy(x => x.Name); private const string AggregateToken = "aggregate"; private const string EventToken = "event"; - - private static IEnumerable ConvertPropertyTypes() => Properties - .Select(x => $"{x.Name} {ConvertType(x.PropertyType)}"); + public SqlAggregateBuilder(ModelBuilder builder) + { + if (!Cache.ContainsKey(typeof(TSqlAggregate))) + Cache.Add(typeof(TSqlAggregate), new Dictionary()); + + Cache[typeof(TSqlAggregate)].TryAdd(typeof(TAggregate), this); + + builder.Entity() + .HasKey(x => new { x.PartitionId, x.AggregateId }); + + foreach (var (property, i) in typeof(TSqlAggregate).GetProperties().OrderBy(x => x.Name).Select((info, i) => (info, i))) + builder.Entity() + .Property(property.Name).HasColumnOrder(i); + } + + public SqlAggregateBuilder Apply( + Expression> expression) + where TEvent : Event + { + Clauses.Add(expression); + return this; + } + private static IEnumerable ConvertDefaultPropertyValues() => Properties .Select(x => ConvertDefaultValue(x.PropertyType)); @@ -52,118 +82,10 @@ private static string ConvertDefaultValue(Type type) throw new NotSupportedException($"Type {type} is not supported"); } - - private static string ConvertType(Type type) - { - if (ConstructorTypeToSqlType.TryGetValue(type, out var result)) - return result; - - throw new NotSupportedException($"Type {type} is not supported"); - } private static string ConvertClause(LambdaExpression expression) => $"WHEN {EventToken}.\"{nameof(Event.Type)}\" = '{expression.Parameters.Last().Type.Name}' THEN {new SqlAggregateExpressionConverter().Convert(expression)}"; - private static Type GetAggregateType(Type? type) - { - while (type != null) - { - var aggregateType = type.GetGenericArguments().FirstOrDefault(typeof(Aggregate).IsAssignableFrom); - if (aggregateType != null) return aggregateType; - type = type.BaseType; - } - - throw new InvalidOperationException("Couldn't find Aggregate Type"); - } - - // Adapted from: https://github.com/npgsql/npgsql/blob/main/src/Npgsql/TypeMapping/BuiltInTypeHandlerResolver.cs - private static readonly Dictionary ConstructorTypeToSqlType = new() - { - // Numeric types - { typeof(byte), "smallint" }, - { typeof(short), "smallint" }, - { typeof(int), "integer" }, - { typeof(long), "bigint" }, - { typeof(float), "real" }, - { typeof(double), "double precision" }, - { typeof(decimal), "decimal" }, - { typeof(BigInteger), "decimal" }, - - // Text types - { typeof(string), "text" }, - { typeof(char[]), "text" }, - { typeof(char), "text" }, - { typeof(ArraySegment), "text" }, - { typeof(JsonDocument), "jsonb" }, - - // Date/time types - // The DateTime entry is for LegacyTimestampBehavior mode only. In regular mode we resolve through - // ResolveValueDependentValue below - { typeof(DateTime), "timestamp without time zone" }, - { typeof(DateTimeOffset), "timestamp with time zone" }, - { typeof(DateOnly), "date" }, - { typeof(TimeOnly), "time without time zone" }, - { typeof(TimeSpan), "interval" }, - { typeof(NpgsqlInterval), "interval" }, - - // Network types - { typeof(IPAddress), "inet" }, - // See ReadOnlyIPAddress below - { typeof((IPAddress Address, int Subnet)), "inet" }, -#pragma warning disable 618 - { typeof(NpgsqlInet), "inet" }, -#pragma warning restore 618 - { typeof(PhysicalAddress), "macaddr" }, - - // Full-text types - { typeof(NpgsqlTsVector), "tsvector" }, - { typeof(NpgsqlTsQueryLexeme), "tsquery" }, - { typeof(NpgsqlTsQueryAnd), "tsquery" }, - { typeof(NpgsqlTsQueryOr), "tsquery" }, - { typeof(NpgsqlTsQueryNot), "tsquery" }, - { typeof(NpgsqlTsQueryEmpty), "tsquery" }, - { typeof(NpgsqlTsQueryFollowedBy), "tsquery" }, - - // Geometry types - { typeof(NpgsqlBox), "box" }, - { typeof(NpgsqlCircle), "circle" }, - { typeof(NpgsqlLine), "line" }, - { typeof(NpgsqlLSeg), "lseg" }, - { typeof(NpgsqlPath), "path" }, - { typeof(NpgsqlPoint), "point" }, - { typeof(NpgsqlPolygon), "polygon" }, - - // Misc types - { typeof(bool), "boolean" }, - { typeof(byte[]), "bytea" }, - { typeof(ArraySegment), "bytea" }, - { typeof(Guid), "uuid" }, - { typeof(BitArray), "bit varying" }, - { typeof(BitVector32), "bit varying" }, - { typeof(Dictionary), "hstore" }, - - // Internal types - { typeof(NpgsqlLogSequenceNumber), "pg_lsn" }, - { typeof(NpgsqlTid), "tid" }, - { typeof(DBNull), "unknown" }, - - // Built-in range types - { typeof(NpgsqlRange), "int4range" }, - { typeof(NpgsqlRange), "int8range" }, - { typeof(NpgsqlRange), "numrange" }, - { typeof(NpgsqlRange), "daterange" }, - - // Built-in multirange types - { typeof(NpgsqlRange[]), "int4multirange" }, - { typeof(List>), "int4multirange" }, - { typeof(NpgsqlRange[]), "int8multirange" }, - { typeof(List>), "int8multirange" }, - { typeof(NpgsqlRange[]), "nummultirange" }, - { typeof(List>), "nummultirange" }, - { typeof(NpgsqlRange[]), "datemultirange" }, - { typeof(List>), "datemultirange" }, - }; - private static readonly Dictionary ConstructorTypeToSqlDefaultValue = new() { // Numeric types diff --git a/EventSourcing.EF.SqlAggregate/SqlAggregateExpressionConverter.cs b/EventSourcing.EF.SqlAggregate/SqlAggregateExpressionConverter.cs index 4869c71..698173e 100644 --- a/EventSourcing.EF.SqlAggregate/SqlAggregateExpressionConverter.cs +++ b/EventSourcing.EF.SqlAggregate/SqlAggregateExpressionConverter.cs @@ -190,8 +190,8 @@ protected override Expression VisitMember(MemberExpression node) _tokens.Add($"event.\"{node.Member.Name}\""); break; - case true when node.Expression is { NodeType: ExpressionType.Parameter } && node.Expression.Type.IsAssignableTo(typeof(global::EventSourcing.EF.SqlAggregate.SqlAggregate)): - _tokens.Add($"aggregate.{node.Member.Name}"); + case true when node.Expression is { NodeType: ExpressionType.Parameter } && node.Expression.Type.IsAssignableTo(typeof(global::EventSourcing.EF.SqlAggregate.SQLAggregate)): + _tokens.Add($"aggregate.\"{node.Member.Name}\""); break; // When current node accesses the str.Length member -> resolve as char_length(str) @@ -257,20 +257,22 @@ protected virtual void VisitRegex(Expression argument, Expression regex) protected override Expression VisitMemberInit(MemberInitExpression node) { var bindings = node.Bindings.Cast().ToDictionary(x => x.Member.Name); - var properties = node.NewExpression.Type.GetProperties().OrderBy(x => x.MetadataToken).ToList(); + var properties = node.NewExpression.Type.GetProperties().OrderBy(x => x.Name).ToList(); _tokens.Add("ROW("); foreach (var property in properties) { if (bindings.TryGetValue(property.Name, out var binding)) Visit(binding.Expression); - else if (property.Name == nameof(global::EventSourcing.EF.SqlAggregate.SqlAggregate.Version)) _tokens.Add($"aggregate.{property.Name} + 1"); - else _tokens.Add($"aggregate.{property.Name}"); + else if (property.Name == nameof(global::EventSourcing.EF.SqlAggregate.SQLAggregate.PartitionId)) _tokens.Add($"event.\"{property.Name}\""); + else if (property.Name == nameof(global::EventSourcing.EF.SqlAggregate.SQLAggregate.AggregateId)) _tokens.Add($"event.\"{property.Name}\""); + else if (property.Name == nameof(global::EventSourcing.EF.SqlAggregate.SQLAggregate.Version)) _tokens.Add($"aggregate.\"{property.Name}\" + 1"); + else _tokens.Add($"aggregate.\"{property.Name}\""); if (property != properties.Last()) _tokens.Add(","); } - _tokens.Add($")::{node.NewExpression.Type.Name}"); + _tokens.Add($")::\"{node.NewExpression.Type.Name}\""); return node; } diff --git a/EventSourcing.EF.SqlAggregate/SqlAggregateMigrationBuilder.cs b/EventSourcing.EF.SqlAggregate/SqlAggregateMigrationBuilder.cs new file mode 100644 index 0000000..c15ec5a --- /dev/null +++ b/EventSourcing.EF.SqlAggregate/SqlAggregateMigrationBuilder.cs @@ -0,0 +1,77 @@ +using EventSourcing.EF.SqlAggregate; +using Microsoft.EntityFrameworkCore.ChangeTracking.Internal; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations.Design; +using Microsoft.EntityFrameworkCore.Migrations.Internal; +using Microsoft.EntityFrameworkCore.Migrations.Operations; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.EntityFrameworkCore.Update; +using Microsoft.EntityFrameworkCore.Update.Internal; +using Microsoft.Extensions.DependencyInjection; +using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure.Internal; +using Npgsql.EntityFrameworkCore.PostgreSQL.Migrations; + +namespace Finaps.EventSourcing.EF.SqlAggregate; + +public class AddSqlAggregateOperation : MigrationOperation +{ + public string SQL { get; } + + public AddSqlAggregateOperation(string sql) => SQL = sql; +} + +public class SqlAggregateMigrationsModelDiffer : MigrationsModelDiffer +{ + public SqlAggregateMigrationsModelDiffer(IRelationalTypeMappingSource typeMappingSource, IMigrationsAnnotationProvider migrationsAnnotations, IChangeDetector changeDetector, IUpdateAdapterFactory updateAdapterFactory, CommandBatchPreparerDependencies commandBatchPreparerDependencies) : base(typeMappingSource, migrationsAnnotations, changeDetector, updateAdapterFactory, commandBatchPreparerDependencies) { } + + protected override IEnumerable Add(ITable target, DiffContext diffContext) + { + var type = target.EntityTypeMappings.First().EntityType.ClrType; + + var operations = base.Add(target, diffContext).ToList(); + + if (type.IsSubclassOf(typeof(SQLAggregate)) && SqlAggregateBuilder.Cache.TryGetValue(type, out var builders)) + operations.AddRange(builders.Values.Select(b => new AddSqlAggregateOperation(b.SQL))); + + return operations; + } +} + +public class SqlAggregateMigrationOperationGenerator : CSharpMigrationOperationGenerator +{ + public SqlAggregateMigrationOperationGenerator(CSharpMigrationOperationGeneratorDependencies dependencies) : base(dependencies) { } + + protected override void Generate(MigrationOperation operation, IndentedStringBuilder builder) + { + if (operation is AddSqlAggregateOperation op) + builder.Append(@$".{nameof(MigrationBuilderExtensions.CreateSqlAggregate)}({ + Microsoft.CodeAnalysis.CSharp.SymbolDisplay.FormatLiteral(op.SQL, true)})"); + } +} + +public class SqlAggregateMigrationsSqlGenerator : NpgsqlMigrationsSqlGenerator +{ + public SqlAggregateMigrationsSqlGenerator(MigrationsSqlGeneratorDependencies dependencies, INpgsqlOptions npgsqlOptions) : base(dependencies, npgsqlOptions) { } + + protected override void Generate(MigrationOperation operation, IModel? model, MigrationCommandListBuilder builder) + { + if (operation is AddSqlAggregateOperation op) + builder.AppendLine(op.SQL).EndCommand(); + else + base.Generate(operation, model, builder); + } +} + +public class MyDesignTimeServices : IDesignTimeServices +{ + public void ConfigureDesignTimeServices(IServiceCollection services) + { + services + .AddSingleton() + .AddSingleton() + .AddSingleton(); + } +} \ No newline at end of file diff --git a/EventSourcing.EF.Tests.Postgres/BankAccountSqlAggregate.cs b/EventSourcing.EF.Tests.Postgres/BankAccountSqlAggregate.cs index bd6d2bb..d54b363 100644 --- a/EventSourcing.EF.Tests.Postgres/BankAccountSqlAggregate.cs +++ b/EventSourcing.EF.Tests.Postgres/BankAccountSqlAggregate.cs @@ -1,35 +1,10 @@ using EventSourcing.EF.SqlAggregate; -using Finaps.EventSourcing.Core.Tests.Mocks; namespace Finaps.EventSourcing.EF; -class BankAccountSqlAggregate : SqlAggregate +record BankAccountSqlAggregate : SQLAggregate { public string? Name { get; init; } public string? Iban { get; init; } public decimal Amount { get; init; } - - public BankAccountSqlAggregate() - { - Apply((aggregate, e) => new BankAccountSqlAggregate - { - Name = e.Name, - Iban = e.Iban - }); - - Apply((aggregate, e) => new BankAccountSqlAggregate - { - Amount = aggregate.Amount + e.Amount - }); - - Apply((aggregate, e) => new BankAccountSqlAggregate - { - Amount = aggregate.Amount - e.Amount - }); - - Apply((aggregate, e) => new BankAccountSqlAggregate - { - Amount = aggregate.Amount - (aggregate.AggregateId == e.DebtorAccount ? -e.Amount : e.Amount) - }); - } } \ No newline at end of file diff --git a/EventSourcing.EF.Tests.Postgres/EventSourcing.EF.Tests.Postgres.csproj b/EventSourcing.EF.Tests.Postgres/EventSourcing.EF.Tests.Postgres.csproj index 26dc4de..098ddcf 100644 --- a/EventSourcing.EF.Tests.Postgres/EventSourcing.EF.Tests.Postgres.csproj +++ b/EventSourcing.EF.Tests.Postgres/EventSourcing.EF.Tests.Postgres.csproj @@ -8,10 +8,7 @@ - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/EventSourcing.EF.Tests.Postgres/PostgresEventSourcingTests.cs b/EventSourcing.EF.Tests.Postgres/PostgresEventSourcingTests.cs index 4e5c894..d62ea23 100644 --- a/EventSourcing.EF.Tests.Postgres/PostgresEventSourcingTests.cs +++ b/EventSourcing.EF.Tests.Postgres/PostgresEventSourcingTests.cs @@ -1,6 +1,10 @@ using System; +using System.Linq; +using System.Threading.Tasks; using EventSourcing.EF.SqlAggregate; using Finaps.EventSourcing.Core; +using Finaps.EventSourcing.Core.Tests.Mocks; +using Microsoft.EntityFrameworkCore; using Xunit; namespace Finaps.EventSourcing.EF.Tests.Postgres; @@ -11,10 +15,11 @@ public class PostgresEventSourcingTests : EntityFrameworkEventSourcingTests public override RecordContext RecordContext => new TestContextFactory().CreateDbContext(Array.Empty()); [Fact] - public void Can_Get_Sql_Type_Definition() + public async Task Can_Aggregate_Sql_Aggregate() { - var type = new SqlAggregateConverter(); - - Assert.Equal("create type BankAccountSqlAggregate AS (PartitionId uuid);", type.AggregateTypeDefinition); + var result = await RecordContext + .Aggregate() + .Where(x => x.Amount > 50) + .ToListAsync(); } } \ No newline at end of file diff --git a/EventSourcing.EF.Tests.Postgres/PostgresTestContext.cs b/EventSourcing.EF.Tests.Postgres/PostgresTestContext.cs index 26787be..2ea12b2 100644 --- a/EventSourcing.EF.Tests.Postgres/PostgresTestContext.cs +++ b/EventSourcing.EF.Tests.Postgres/PostgresTestContext.cs @@ -1,6 +1,10 @@ using EventSourcing.EF.SqlAggregate; +using Finaps.EventSourcing.Core.Tests.Mocks; +using Finaps.EventSourcing.EF.SqlAggregate; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations.Design; using Microsoft.Extensions.Configuration; namespace Finaps.EventSourcing.EF.Tests.Postgres; @@ -8,15 +12,37 @@ namespace Finaps.EventSourcing.EF.Tests.Postgres; public class PostgresTestRecordContext : EntityFrameworkTestRecordContext { public PostgresTestRecordContext(DbContextOptions options) : base(options) {} + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + builder + .Aggregate() + .Apply((a, e) => + new() { Name = e.Name, Iban = e.Iban }) + .Apply((a, e) => + new() { Amount = a.Amount + e.Amount }) + .Apply((a, e) => + new() { Amount = a.Amount - e.Amount }) + .Apply((a, e) => + new() { Amount = a.Amount - (a.AggregateId == e.DebtorAccount ? -e.Amount : e.Amount) }); + } } +// Needed for EF Core Migrations to spot the design time migration services +public class TestContextServices : MyDesignTimeServices, IDesignTimeServices { } + public class TestContextFactory : IDesignTimeDbContextFactory { - public PostgresTestRecordContext CreateDbContext(string[] args) => + public PostgresTestRecordContext CreateDbContext(string[] args) => new (new DbContextOptionsBuilder() - .UseNpgsql(new ConfigurationBuilder().AddJsonFile("appsettings.json").Build() - .GetConnectionString("RecordStore")) + .UseNpgsql(new ConfigurationBuilder().AddJsonFile("appsettings.json").Build().GetConnectionString("RecordStore")) + .ReplaceService() + .ReplaceService() + .ReplaceService() .UseAllCheckConstraints() .EnableSensitiveDataLogging() .Options); -} \ No newline at end of file +} +