From 40a3907570b7303766d0c13c9712950ab8250906 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 26 Feb 2026 16:23:41 +0100 Subject: [PATCH] Add typed modify and join fluent extensions --- AmalgamationTool/DynamORM.Amalgamation.cs | 198 ++++++++++++++++++ .../Modify/TypedModifyExtensionsTests.cs | 75 +++++++ .../Select/TypedFluentBuilderTests.cs | 11 + DynamORM/Builders/TypedJoinExtensions.cs | 86 ++++++++ DynamORM/Builders/TypedModifyExtensions.cs | 155 ++++++++++++++ 5 files changed, 525 insertions(+) create mode 100644 DynamORM.Tests/Modify/TypedModifyExtensionsTests.cs create mode 100644 DynamORM/Builders/TypedJoinExtensions.cs create mode 100644 DynamORM/Builders/TypedModifyExtensions.cs diff --git a/AmalgamationTool/DynamORM.Amalgamation.cs b/AmalgamationTool/DynamORM.Amalgamation.cs index a59403d..528d5f2 100644 --- a/AmalgamationTool/DynamORM.Amalgamation.cs +++ b/AmalgamationTool/DynamORM.Amalgamation.cs @@ -7371,6 +7371,204 @@ namespace DynamORM return source; } } + /// Typed join helpers for typed select builder. + public static class TypedJoinExtensions + { + /// Add typed join on mapped members. Supports simple equality join expression only. + public static IDynamicTypedSelectQueryBuilder JoinTyped( + this IDynamicTypedSelectQueryBuilder builder, + string alias, + Expression> on, + string joinType = "INNER JOIN") + { + if (builder == null) throw new ArgumentNullException("builder"); + if (on == null) throw new ArgumentNullException("on"); + + var rightMapper = DynamicMapperCache.GetMapper(typeof(TRight)); + if (rightMapper == null) + throw new InvalidOperationException(string.Format("Cant assign unmapable type as a table ({0}).", typeof(TRight).FullName)); + + string rightTable = string.IsNullOrEmpty(rightMapper.Table.NullOr(t => t.Name)) + ? typeof(TRight).Name + : rightMapper.Table.Name; + + string rightOwner = rightMapper.Table.NullOr(t => t.Owner); + string rightAlias = string.IsNullOrEmpty(alias) ? "t" + (builder.Tables.Count + 1).ToString() : alias; + + var be = on.Body as BinaryExpression; + if (be == null || be.NodeType != ExpressionType.Equal) + throw new NotSupportedException("JoinTyped currently supports only equality join expressions."); + + string leftPrefix = builder.Tables.FirstOrDefault().NullOr(t => string.IsNullOrEmpty(t.Alias) ? t.Name : t.Alias, null); + if (string.IsNullOrEmpty(leftPrefix)) + throw new InvalidOperationException("JoinTyped requires source table to be present."); + + string leftExpr = ResolveMappedSide(be.Left, typeof(TLeft), leftPrefix, builder.Database); + string rightExpr = ResolveMappedSide(be.Right, typeof(TRight), rightAlias, builder.Database); + + string ownerPrefix = string.IsNullOrEmpty(rightOwner) ? string.Empty : builder.Database.DecorateName(rightOwner) + "."; + string rightTableExpr = ownerPrefix + builder.Database.DecorateName(rightTable); + string joinExpr = string.Format("{0} {1} AS {2} ON ({3} = {4})", joinType, rightTableExpr, rightAlias, leftExpr, rightExpr); + + builder.Join(x => joinExpr); + return builder; + } + private static string ResolveMappedSide(Expression expression, Type modelType, string prefix, DynamicDatabase db) + { + expression = UnwrapConvert(expression); + var member = expression as MemberExpression; + if (member == null) + throw new NotSupportedException("Join side must be mapped member access."); + + var mapper = DynamicMapperCache.GetMapper(modelType); + string col = mapper.PropertyMap.TryGetValue(member.Member.Name) + ?? mapper.PropertyMap + .Where(x => string.Equals(x.Key, member.Member.Name, StringComparison.OrdinalIgnoreCase)) + .Select(x => x.Value) + .FirstOrDefault() + ?? member.Member.Name; + + return string.Format("{0}.{1}", prefix, db.DecorateName(col)); + } + private static Expression UnwrapConvert(Expression expression) + { + while (expression is UnaryExpression && + (((UnaryExpression)expression).NodeType == ExpressionType.Convert || + ((UnaryExpression)expression).NodeType == ExpressionType.ConvertChecked)) + expression = ((UnaryExpression)expression).Operand; + + return expression; + } + } + /// Typed helper extensions for update/insert/delete fluent APIs. + public static class TypedModifyExtensions + { + /// Add typed where predicate (AND-composed comparisons) for update builder. + public static IDynamicUpdateQueryBuilder WhereTyped(this IDynamicUpdateQueryBuilder builder, Expression> predicate) + { + AddTypedWhere(builder, predicate == null ? null : predicate.Body); + return builder; + } + /// Add typed where predicate (AND-composed comparisons) for delete builder. + public static IDynamicDeleteQueryBuilder WhereTyped(this IDynamicDeleteQueryBuilder builder, Expression> predicate) + { + AddTypedWhere(builder, predicate == null ? null : predicate.Body); + return builder; + } + /// Add typed value assignment for update builder. + public static IDynamicUpdateQueryBuilder SetTyped(this IDynamicUpdateQueryBuilder builder, Expression> selector, object value) + { + string col = GetColumnName(typeof(T), selector == null ? null : selector.Body); + return builder.Values(col, value); + } + /// Add typed value assignment for insert builder. + public static IDynamicInsertQueryBuilder InsertTyped(this IDynamicInsertQueryBuilder builder, Expression> selector, object value) + { + string col = GetColumnName(typeof(T), selector == null ? null : selector.Body); + return builder.Insert(col, value); + } + /// Insert mapped object with compile-time type information. + public static IDynamicInsertQueryBuilder InsertTyped(this IDynamicInsertQueryBuilder builder, T value) where T : class + { + return builder.Insert(value); + } + /// Update mapped object with compile-time type information. + public static IDynamicUpdateQueryBuilder UpdateTyped(this IDynamicUpdateQueryBuilder builder, T value) where T : class + { + return builder.Update(value); + } + private static void AddTypedWhere(dynamic builder, Expression expression) + { + if (expression == null) + throw new ArgumentNullException("predicate"); + + expression = UnwrapConvert(expression); + BinaryExpression be = expression as BinaryExpression; + + if (be != null && (be.NodeType == ExpressionType.AndAlso || be.NodeType == ExpressionType.And)) + { + AddTypedWhere(builder, be.Left); + AddTypedWhere(builder, be.Right); + return; + } + if (be != null) + { + string col = GetColumnName(GetRootParameterType(be.Left), be.Left); + object val = EvaluateExpression(be.Right); + + switch (be.NodeType) + { + case ExpressionType.Equal: + builder.Where(col, DynamicColumn.CompareOperator.Eq, val); + return; + case ExpressionType.NotEqual: + builder.Where(col, DynamicColumn.CompareOperator.Not, val); + return; + case ExpressionType.GreaterThan: + builder.Where(col, DynamicColumn.CompareOperator.Gt, val); + return; + case ExpressionType.GreaterThanOrEqual: + builder.Where(col, DynamicColumn.CompareOperator.Gte, val); + return; + case ExpressionType.LessThan: + builder.Where(col, DynamicColumn.CompareOperator.Lt, val); + return; + case ExpressionType.LessThanOrEqual: + builder.Where(col, DynamicColumn.CompareOperator.Lte, val); + return; + } + } + throw new NotSupportedException(string.Format("Typed where expression is currently limited to AND-composed binary comparisons: {0}", expression)); + } + private static Type GetRootParameterType(Expression expression) + { + expression = UnwrapConvert(expression); + MemberExpression m = expression as MemberExpression; + if (m != null && m.Expression is ParameterExpression) + return ((ParameterExpression)m.Expression).Type; + + throw new NotSupportedException(string.Format("Unsupported typed selector: {0}", expression)); + } + private static string GetColumnName(Type modelType, Expression expression) + { + if (modelType == null) + modelType = GetRootParameterType(expression); + + expression = UnwrapConvert(expression); + MemberExpression member = expression as MemberExpression; + if (member == null) + throw new NotSupportedException(string.Format("Selector must target a mapped property: {0}", expression)); + + DynamicTypeMap mapper = DynamicMapperCache.GetMapper(modelType); + if (mapper == null) + throw new InvalidOperationException(string.Format("Cant assign unmapable type as a table ({0}).", modelType.FullName)); + + string col = mapper.PropertyMap.TryGetValue(member.Member.Name) + ?? mapper.PropertyMap + .Where(x => string.Equals(x.Key, member.Member.Name, StringComparison.OrdinalIgnoreCase)) + .Select(x => x.Value) + .FirstOrDefault() + ?? member.Member.Name; + + return col; + } + private static Expression UnwrapConvert(Expression expression) + { + while (expression is UnaryExpression && + (((UnaryExpression)expression).NodeType == ExpressionType.Convert || + ((UnaryExpression)expression).NodeType == ExpressionType.ConvertChecked)) + expression = ((UnaryExpression)expression).Operand; + + return expression; + } + private static object EvaluateExpression(Expression expression) + { + expression = UnwrapConvert(expression); + var objectMember = Expression.Convert(expression, typeof(object)); + var getter = Expression.Lambda>(objectMember).Compile(); + return getter(); + } + } namespace Extensions { internal static class DynamicHavingQueryExtensions diff --git a/DynamORM.Tests/Modify/TypedModifyExtensionsTests.cs b/DynamORM.Tests/Modify/TypedModifyExtensionsTests.cs new file mode 100644 index 0000000..a5390fa --- /dev/null +++ b/DynamORM.Tests/Modify/TypedModifyExtensionsTests.cs @@ -0,0 +1,75 @@ +/* + * DynamORM - Dynamic Object-Relational Mapping library. + * Copyright (c) 2012-2026, Grzegorz Russek (grzegorz.russek@gmail.com) + * All rights reserved. + */ + +using System.Linq; +using DynamORM.Builders; +using DynamORM.Tests.Helpers; +using NUnit.Framework; + +namespace DynamORM.Tests.Modify +{ + [TestFixture] + public class TypedModifyExtensionsTests : TestsBase + { + [SetUp] + public void SetUp() + { + CreateTestDatabase(); + CreateDynamicDatabase( + DynamicDatabaseOptions.SingleConnection | + DynamicDatabaseOptions.SingleTransaction | + DynamicDatabaseOptions.SupportLimitOffset | + DynamicDatabaseOptions.SupportSchema); + } + + [TearDown] + public void TearDown() + { + DestroyDynamicDatabase(); + DestroyTestDatabase(); + } + + [Test] + public void TestTypedUpdateSetAndWhere() + { + var cmd = Database.Update() + .SetTyped(u => u.Code, "777") + .WhereTyped(u => u.Id == 1 && u.Code == "1"); + + Assert.AreEqual( + string.Format("UPDATE \"sample_users\" SET \"code\" = [${0}] WHERE (\"id\" = [${1}]) AND (\"code\" = [${2}])", + cmd.Parameters.Keys.ElementAt(0), + cmd.Parameters.Keys.ElementAt(1), + cmd.Parameters.Keys.ElementAt(2)), + cmd.CommandText()); + } + + [Test] + public void TestTypedDeleteWhere() + { + var cmd = Database.Delete() + .WhereTyped(u => u.Id == 2); + + Assert.AreEqual( + string.Format("DELETE FROM \"sample_users\" WHERE (\"id\" = [${0}])", cmd.Parameters.Keys.First()), + cmd.CommandText()); + } + + [Test] + public void TestTypedInsertColumns() + { + var cmd = Database.Insert() + .InsertTyped(u => u.Code, "900") + .InsertTyped(u => u.First, "Typed"); + + Assert.AreEqual( + string.Format("INSERT INTO \"sample_users\" (\"code\", \"first\") VALUES ([${0}], [${1}])", + cmd.Parameters.Keys.ElementAt(0), + cmd.Parameters.Keys.ElementAt(1)), + cmd.CommandText()); + } + } +} diff --git a/DynamORM.Tests/Select/TypedFluentBuilderTests.cs b/DynamORM.Tests/Select/TypedFluentBuilderTests.cs index 0d83954..6528378 100644 --- a/DynamORM.Tests/Select/TypedFluentBuilderTests.cs +++ b/DynamORM.Tests/Select/TypedFluentBuilderTests.cs @@ -95,5 +95,16 @@ namespace DynamORM.Tests.Select Assert.AreEqual("SELECT u.\"user_code\" FROM \"sample_users\" AS u GROUP BY u.\"user_code\" HAVING (u.\"user_code\" IS NOT NULL) ORDER BY u.\"user_code\" ASC", cmd.CommandText()); } + + [Test] + public void TestTypedJoin() + { + var cmd = Database.From("u") + .JoinTyped("x", (l, r) => l.Id == r.Id) + .SelectTyped(u => u.Id); + + Assert.AreEqual("SELECT u.\"id_user\" FROM \"sample_users\" AS u INNER JOIN \"sample_users\" AS x ON (u.\"id_user\" = x.\"id_user\")", + cmd.CommandText()); + } } } diff --git a/DynamORM/Builders/TypedJoinExtensions.cs b/DynamORM/Builders/TypedJoinExtensions.cs new file mode 100644 index 0000000..6ce4e27 --- /dev/null +++ b/DynamORM/Builders/TypedJoinExtensions.cs @@ -0,0 +1,86 @@ +/* + * DynamORM - Dynamic Object-Relational Mapping library. + * Copyright (c) 2012-2026, Grzegorz Russek (grzegorz.russek@gmail.com) + * All rights reserved. + */ + +using System; +using System.Linq; +using System.Linq.Expressions; +using DynamORM.Helpers; +using DynamORM.Mapper; + +namespace DynamORM.Builders +{ + /// Typed join helpers for typed select builder. + public static class TypedJoinExtensions + { + /// Add typed join on mapped members. Supports simple equality join expression only. + public static IDynamicTypedSelectQueryBuilder JoinTyped( + this IDynamicTypedSelectQueryBuilder builder, + string alias, + Expression> on, + string joinType = "INNER JOIN") + { + if (builder == null) throw new ArgumentNullException("builder"); + if (on == null) throw new ArgumentNullException("on"); + + var rightMapper = DynamicMapperCache.GetMapper(typeof(TRight)); + if (rightMapper == null) + throw new InvalidOperationException(string.Format("Cant assign unmapable type as a table ({0}).", typeof(TRight).FullName)); + + string rightTable = string.IsNullOrEmpty(rightMapper.Table.NullOr(t => t.Name)) + ? typeof(TRight).Name + : rightMapper.Table.Name; + + string rightOwner = rightMapper.Table.NullOr(t => t.Owner); + string rightAlias = string.IsNullOrEmpty(alias) ? "t" + (builder.Tables.Count + 1).ToString() : alias; + + var be = on.Body as BinaryExpression; + if (be == null || be.NodeType != ExpressionType.Equal) + throw new NotSupportedException("JoinTyped currently supports only equality join expressions."); + + string leftPrefix = builder.Tables.FirstOrDefault().NullOr(t => string.IsNullOrEmpty(t.Alias) ? t.Name : t.Alias, null); + if (string.IsNullOrEmpty(leftPrefix)) + throw new InvalidOperationException("JoinTyped requires source table to be present."); + + string leftExpr = ResolveMappedSide(be.Left, typeof(TLeft), leftPrefix, builder.Database); + string rightExpr = ResolveMappedSide(be.Right, typeof(TRight), rightAlias, builder.Database); + + string ownerPrefix = string.IsNullOrEmpty(rightOwner) ? string.Empty : builder.Database.DecorateName(rightOwner) + "."; + string rightTableExpr = ownerPrefix + builder.Database.DecorateName(rightTable); + string joinExpr = string.Format("{0} {1} AS {2} ON ({3} = {4})", joinType, rightTableExpr, rightAlias, leftExpr, rightExpr); + + builder.Join(x => joinExpr); + return builder; + } + + private static string ResolveMappedSide(Expression expression, Type modelType, string prefix, DynamicDatabase db) + { + expression = UnwrapConvert(expression); + var member = expression as MemberExpression; + if (member == null) + throw new NotSupportedException("Join side must be mapped member access."); + + var mapper = DynamicMapperCache.GetMapper(modelType); + string col = mapper.PropertyMap.TryGetValue(member.Member.Name) + ?? mapper.PropertyMap + .Where(x => string.Equals(x.Key, member.Member.Name, StringComparison.OrdinalIgnoreCase)) + .Select(x => x.Value) + .FirstOrDefault() + ?? member.Member.Name; + + return string.Format("{0}.{1}", prefix, db.DecorateName(col)); + } + + private static Expression UnwrapConvert(Expression expression) + { + while (expression is UnaryExpression && + (((UnaryExpression)expression).NodeType == ExpressionType.Convert || + ((UnaryExpression)expression).NodeType == ExpressionType.ConvertChecked)) + expression = ((UnaryExpression)expression).Operand; + + return expression; + } + } +} diff --git a/DynamORM/Builders/TypedModifyExtensions.cs b/DynamORM/Builders/TypedModifyExtensions.cs new file mode 100644 index 0000000..f1687c9 --- /dev/null +++ b/DynamORM/Builders/TypedModifyExtensions.cs @@ -0,0 +1,155 @@ +/* + * DynamORM - Dynamic Object-Relational Mapping library. + * Copyright (c) 2012-2026, Grzegorz Russek (grzegorz.russek@gmail.com) + * All rights reserved. + */ + +using System; +using System.Linq; +using System.Linq.Expressions; +using DynamORM.Mapper; + +namespace DynamORM.Builders +{ + /// Typed helper extensions for update/insert/delete fluent APIs. + public static class TypedModifyExtensions + { + /// Add typed where predicate (AND-composed comparisons) for update builder. + public static IDynamicUpdateQueryBuilder WhereTyped(this IDynamicUpdateQueryBuilder builder, Expression> predicate) + { + AddTypedWhere(builder, predicate == null ? null : predicate.Body); + return builder; + } + + /// Add typed where predicate (AND-composed comparisons) for delete builder. + public static IDynamicDeleteQueryBuilder WhereTyped(this IDynamicDeleteQueryBuilder builder, Expression> predicate) + { + AddTypedWhere(builder, predicate == null ? null : predicate.Body); + return builder; + } + + /// Add typed value assignment for update builder. + public static IDynamicUpdateQueryBuilder SetTyped(this IDynamicUpdateQueryBuilder builder, Expression> selector, object value) + { + string col = GetColumnName(typeof(T), selector == null ? null : selector.Body); + return builder.Values(col, value); + } + + /// Add typed value assignment for insert builder. + public static IDynamicInsertQueryBuilder InsertTyped(this IDynamicInsertQueryBuilder builder, Expression> selector, object value) + { + string col = GetColumnName(typeof(T), selector == null ? null : selector.Body); + return builder.Insert(col, value); + } + + /// Insert mapped object with compile-time type information. + public static IDynamicInsertQueryBuilder InsertTyped(this IDynamicInsertQueryBuilder builder, T value) where T : class + { + return builder.Insert(value); + } + + /// Update mapped object with compile-time type information. + public static IDynamicUpdateQueryBuilder UpdateTyped(this IDynamicUpdateQueryBuilder builder, T value) where T : class + { + return builder.Update(value); + } + + private static void AddTypedWhere(dynamic builder, Expression expression) + { + if (expression == null) + throw new ArgumentNullException("predicate"); + + expression = UnwrapConvert(expression); + BinaryExpression be = expression as BinaryExpression; + + if (be != null && (be.NodeType == ExpressionType.AndAlso || be.NodeType == ExpressionType.And)) + { + AddTypedWhere(builder, be.Left); + AddTypedWhere(builder, be.Right); + return; + } + + if (be != null) + { + string col = GetColumnName(GetRootParameterType(be.Left), be.Left); + object val = EvaluateExpression(be.Right); + + switch (be.NodeType) + { + case ExpressionType.Equal: + builder.Where(col, DynamicColumn.CompareOperator.Eq, val); + return; + case ExpressionType.NotEqual: + builder.Where(col, DynamicColumn.CompareOperator.Not, val); + return; + case ExpressionType.GreaterThan: + builder.Where(col, DynamicColumn.CompareOperator.Gt, val); + return; + case ExpressionType.GreaterThanOrEqual: + builder.Where(col, DynamicColumn.CompareOperator.Gte, val); + return; + case ExpressionType.LessThan: + builder.Where(col, DynamicColumn.CompareOperator.Lt, val); + return; + case ExpressionType.LessThanOrEqual: + builder.Where(col, DynamicColumn.CompareOperator.Lte, val); + return; + } + } + + throw new NotSupportedException(string.Format("Typed where expression is currently limited to AND-composed binary comparisons: {0}", expression)); + } + + private static Type GetRootParameterType(Expression expression) + { + expression = UnwrapConvert(expression); + MemberExpression m = expression as MemberExpression; + if (m != null && m.Expression is ParameterExpression) + return ((ParameterExpression)m.Expression).Type; + + throw new NotSupportedException(string.Format("Unsupported typed selector: {0}", expression)); + } + + private static string GetColumnName(Type modelType, Expression expression) + { + if (modelType == null) + modelType = GetRootParameterType(expression); + + expression = UnwrapConvert(expression); + MemberExpression member = expression as MemberExpression; + if (member == null) + throw new NotSupportedException(string.Format("Selector must target a mapped property: {0}", expression)); + + DynamicTypeMap mapper = DynamicMapperCache.GetMapper(modelType); + if (mapper == null) + throw new InvalidOperationException(string.Format("Cant assign unmapable type as a table ({0}).", modelType.FullName)); + + string col = mapper.PropertyMap.TryGetValue(member.Member.Name) + ?? mapper.PropertyMap + .Where(x => string.Equals(x.Key, member.Member.Name, StringComparison.OrdinalIgnoreCase)) + .Select(x => x.Value) + .FirstOrDefault() + ?? member.Member.Name; + + return col; + } + + private static Expression UnwrapConvert(Expression expression) + { + while (expression is UnaryExpression && + (((UnaryExpression)expression).NodeType == ExpressionType.Convert || + ((UnaryExpression)expression).NodeType == ExpressionType.ConvertChecked)) + expression = ((UnaryExpression)expression).Operand; + + return expression; + } + + private static object EvaluateExpression(Expression expression) + { + expression = UnwrapConvert(expression); + var objectMember = Expression.Convert(expression, typeof(object)); + var getter = Expression.Lambda>(objectMember).Compile(); + return getter(); + } + } +}