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