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..0dc854f --- /dev/null +++ b/EventSourcing.EF.SqlAggregate/EventSourcing.EF.SqlAggregate.csproj @@ -0,0 +1,20 @@ + + + + net6.0 + enable + enable + + + + + + + + + + + + + + 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 new file mode 100644 index 0000000..5aab8de --- /dev/null +++ b/EventSourcing.EF.SqlAggregate/SqlAggregate.cs @@ -0,0 +1,8 @@ +namespace EventSourcing.EF.SqlAggregate; + +public abstract record SQLAggregate +{ + public Guid PartitionId { get; init; } + public Guid AggregateId { get; init; } + public long Version { get; init; } +} \ No newline at end of file diff --git a/EventSourcing.EF.SqlAggregate/SqlAggregateBuilder.cs b/EventSourcing.EF.SqlAggregate/SqlAggregateBuilder.cs new file mode 100644 index 0000000..bd89186 --- /dev/null +++ b/EventSourcing.EF.SqlAggregate/SqlAggregateBuilder.cs @@ -0,0 +1,175 @@ +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 Microsoft.EntityFrameworkCore; +using NpgsqlTypes; + +namespace EventSourcing.EF.SqlAggregate; + + +public abstract class SqlAggregateBuilder +{ + internal static Dictionary> Cache { get; } = new(); + + public abstract string SQL { get; } +} + +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 = \"{typeof(TSqlAggregate).Name}\",\n" + + $" initcond = '({string.Join(",", ConvertDefaultPropertyValues())})'\n" + + ");"; + 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"; + + 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)); + + 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 ConvertClause(LambdaExpression expression) => + $"WHEN {EventToken}.\"{nameof(Event.Type)}\" = '{expression.Parameters.Last().Type.Name}' THEN {new SqlAggregateExpressionConverter().Convert(expression)}"; + + 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..698173e --- /dev/null +++ b/EventSourcing.EF.SqlAggregate/SqlAggregateExpressionConverter.cs @@ -0,0 +1,293 @@ +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.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.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}\""); + + 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.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 new file mode 100644 index 0000000..d54b363 --- /dev/null +++ b/EventSourcing.EF.Tests.Postgres/BankAccountSqlAggregate.cs @@ -0,0 +1,10 @@ +using EventSourcing.EF.SqlAggregate; + +namespace Finaps.EventSourcing.EF; + +record BankAccountSqlAggregate : SQLAggregate +{ + public string? Name { get; init; } + public string? Iban { get; init; } + public decimal Amount { get; init; } +} \ 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..098ddcf 100644 --- a/EventSourcing.EF.Tests.Postgres/EventSourcing.EF.Tests.Postgres.csproj +++ b/EventSourcing.EF.Tests.Postgres/EventSourcing.EF.Tests.Postgres.csproj @@ -8,14 +8,11 @@ - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + - + all @@ -38,13 +35,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..d62ea23 100644 --- a/EventSourcing.EF.Tests.Postgres/PostgresEventSourcingTests.cs +++ b/EventSourcing.EF.Tests.Postgres/PostgresEventSourcingTests.cs @@ -1,5 +1,11 @@ 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; @@ -7,4 +13,13 @@ public class PostgresEventSourcingTests : EntityFrameworkEventSourcingTests { protected override IRecordStore RecordStore => new EntityFrameworkRecordStore(RecordContext); public override RecordContext RecordContext => new TestContextFactory().CreateDbContext(Array.Empty()); + + [Fact] + public async Task Can_Aggregate_Sql_Aggregate() + { + 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 74f4fe3..2ea12b2 100644 --- a/EventSourcing.EF.Tests.Postgres/PostgresTestContext.cs +++ b/EventSourcing.EF.Tests.Postgres/PostgresTestContext.cs @@ -1,6 +1,10 @@ -using System; +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,22 +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) - { - 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")) + .ReplaceService() + .ReplaceService() + .ReplaceService() .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