From 59ce1115eaa68e981ec2c5d1b0c1794a6bac33ea Mon Sep 17 00:00:00 2001 From: root Date: Thu, 26 Feb 2026 19:49:00 +0100 Subject: [PATCH] Expand typed join syntax with no lock, outer and custom join types --- AmalgamationTool/DynamORM.Amalgamation.cs | 253 ++++++++++++++++-- DynamORM.Tests/Helpers/TypedJoinModels.cs | 33 +++ .../Select/TypedFluentBuilderTests.cs | 2 +- .../Select/TypedFluentJoinSyntaxTests.cs | 102 +++++++ DynamORM/Builders/DynamicJoinType.cs | 6 +- .../IDynamicTypedSelectQueryBuilder.cs | 12 +- .../DynamicSelectQueryBuilder.cs | 2 +- .../DynamicTypedSelectQueryBuilder.cs | 160 +++++++++-- DynamORM/Builders/TypedJoinBuilder.cs | 76 +++++- DynamORM/Builders/TypedJoinExtensions.cs | 12 +- 10 files changed, 611 insertions(+), 47 deletions(-) create mode 100644 DynamORM.Tests/Helpers/TypedJoinModels.cs create mode 100644 DynamORM.Tests/Select/TypedFluentJoinSyntaxTests.cs diff --git a/AmalgamationTool/DynamORM.Amalgamation.cs b/AmalgamationTool/DynamORM.Amalgamation.cs index 4f01c18..73602bb 100644 --- a/AmalgamationTool/DynamORM.Amalgamation.cs +++ b/AmalgamationTool/DynamORM.Amalgamation.cs @@ -6782,9 +6782,13 @@ namespace DynamORM public enum DynamicJoinType { Inner = 0, + Join, Left, Right, - Full + Full, + LeftOuter, + RightOuter, + FullOuter } /// Dynamic delete query builder interface. /// This interface it publicly available. Implementation should be hidden. @@ -7187,8 +7191,18 @@ namespace DynamORM /// Join ON predicate. /// Optional alias for joined table. /// Join type. + /// Adds NOLOCK hint to joined source when supported by provider options. /// Builder instance. - IDynamicTypedSelectQueryBuilder Join(Expression> on, string alias = null, DynamicJoinType joinType = DynamicJoinType.Inner); + IDynamicTypedSelectQueryBuilder Join(Expression> on, string alias = null, DynamicJoinType joinType = DynamicJoinType.Inner, bool noLock = false); + + /// Add typed join with custom join type text (for example: CROSS APPLY). + /// Joined mapped entity type. + /// Optional join ON predicate. + /// Optional alias for joined table. + /// Join type text. + /// Adds NOLOCK hint to joined source when supported by provider options. + /// Builder instance. + IDynamicTypedSelectQueryBuilder Join(Expression> on, string alias, string joinType, bool noLock = false); /// Add typed join using join-spec builder syntax (As(), join kind and On()). /// Joined mapped entity type. @@ -7415,7 +7429,7 @@ namespace DynamORM { internal TypedJoinBuilder() { - JoinType = DynamicJoinType.Inner; + JoinType = DynamicJoinType.Join; } /// Gets join alias. public string Alias { get; private set; } @@ -7423,9 +7437,18 @@ namespace DynamORM /// Gets join type. public DynamicJoinType JoinType { get; private set; } + /// Gets custom join type text. + public string CustomJoinType { get; private set; } + + /// Gets a value indicating whether joined source should use NOLOCK. + public bool UseNoLock { get; private set; } + /// Gets ON predicate. public Expression> OnPredicate { get; private set; } + /// Gets raw ON condition. + public string OnRawCondition { get; private set; } + /// Sets join alias. public TypedJoinBuilder As(string alias) { @@ -7436,24 +7459,71 @@ namespace DynamORM public TypedJoinBuilder Inner() { JoinType = DynamicJoinType.Inner; + CustomJoinType = null; + return this; + } + /// Sets plain JOIN. + public TypedJoinBuilder Join() + { + JoinType = DynamicJoinType.Join; + CustomJoinType = null; return this; } /// Sets LEFT JOIN. public TypedJoinBuilder Left() { JoinType = DynamicJoinType.Left; + CustomJoinType = null; return this; } /// Sets RIGHT JOIN. public TypedJoinBuilder Right() { JoinType = DynamicJoinType.Right; + CustomJoinType = null; return this; } /// Sets FULL JOIN. public TypedJoinBuilder Full() { JoinType = DynamicJoinType.Full; + CustomJoinType = null; + return this; + } + /// Sets LEFT OUTER JOIN. + public TypedJoinBuilder LeftOuter() + { + JoinType = DynamicJoinType.LeftOuter; + CustomJoinType = null; + return this; + } + /// Sets RIGHT OUTER JOIN. + public TypedJoinBuilder RightOuter() + { + JoinType = DynamicJoinType.RightOuter; + CustomJoinType = null; + return this; + } + /// Sets FULL OUTER JOIN. + public TypedJoinBuilder FullOuter() + { + JoinType = DynamicJoinType.FullOuter; + CustomJoinType = null; + return this; + } + /// Sets custom join type text (for example: CROSS APPLY). + public TypedJoinBuilder Type(string joinType) + { + if (string.IsNullOrEmpty(joinType)) + throw new ArgumentNullException("joinType"); + + CustomJoinType = joinType.Trim(); + return this; + } + /// Marks joined source with NOLOCK hint. + public TypedJoinBuilder NoLock(bool use = true) + { + UseNoLock = use; return this; } /// Sets ON predicate. @@ -7463,6 +7533,17 @@ namespace DynamORM throw new ArgumentNullException("predicate"); OnPredicate = predicate; + OnRawCondition = null; + return this; + } + /// Sets raw ON clause (without the ON keyword). + public TypedJoinBuilder OnRaw(string condition) + { + if (string.IsNullOrEmpty(condition)) + throw new ArgumentNullException("condition"); + + OnRawCondition = condition.Trim(); + OnPredicate = null; return this; } } @@ -7484,12 +7565,22 @@ namespace DynamORM DynamicJoinType jt = DynamicJoinType.Inner; string normalized = (joinType ?? string.Empty).Trim().ToUpperInvariant(); - if (normalized == "LEFT JOIN" || normalized == "LEFT") + if (normalized == "JOIN") + jt = DynamicJoinType.Join; + else if (normalized == "LEFT OUTER JOIN" || normalized == "LEFT OUTER") + jt = DynamicJoinType.LeftOuter; + else if (normalized == "RIGHT OUTER JOIN" || normalized == "RIGHT OUTER") + jt = DynamicJoinType.RightOuter; + else if (normalized == "FULL OUTER JOIN" || normalized == "FULL OUTER") + jt = DynamicJoinType.FullOuter; + else if (normalized == "LEFT JOIN" || normalized == "LEFT") jt = DynamicJoinType.Left; else if (normalized == "RIGHT JOIN" || normalized == "RIGHT") jt = DynamicJoinType.Right; else if (normalized == "FULL JOIN" || normalized == "FULL") jt = DynamicJoinType.Full; + else if (normalized != "INNER JOIN" && normalized != "INNER" && normalized != "JOIN") + return builder.Join(on, alias, joinType); return builder.Join(on, alias, jt); } @@ -9223,7 +9314,7 @@ namespace DynamORM private string _select; private string _from; - private string _join; + protected string _join; private string _groupby; private string _orderby; @@ -10639,10 +10730,14 @@ namespace DynamORM return this; } - public IDynamicTypedSelectQueryBuilder Join(Expression> on, string alias = null, DynamicJoinType joinType = DynamicJoinType.Inner) + public IDynamicTypedSelectQueryBuilder Join(Expression> on, string alias = null, DynamicJoinType joinType = DynamicJoinType.Inner, bool noLock = false) { - if (on == null) - throw new ArgumentNullException("on"); + return Join(on, alias, GetJoinKeyword(joinType), noLock); + } + public IDynamicTypedSelectQueryBuilder Join(Expression> on, string alias, string joinType, bool noLock = false) + { + if (string.IsNullOrEmpty(joinType)) + throw new ArgumentNullException("joinType"); DynamicTypeMap rightMapper = DynamicMapperCache.GetMapper(typeof(TRight)); if (rightMapper == null) @@ -10654,27 +10749,28 @@ namespace DynamORM string rightOwner = rightMapper.Table == null ? null : rightMapper.Table.Owner; string rightAlias = string.IsNullOrEmpty(alias) ? "t" + (Tables.Count + 1).ToString() : alias; - BinaryExpression be = on.Body as BinaryExpression; - if (be == null || be.NodeType != ExpressionType.Equal) - throw new NotSupportedException("Typed join expression is currently limited to equality comparisons."); - string leftPrefix = GetRootAliasOrTableName(); if (string.IsNullOrEmpty(leftPrefix)) throw new InvalidOperationException("Join requires source table to be present."); - string leftExpr = ParseTypedJoinMember(be.Left, leftPrefix, _mapper); - string rightExpr = ParseTypedJoinMember(be.Right, rightAlias, rightMapper); + string condition = null; + if (on != null) + condition = ParseTypedJoinCondition(on.Body, leftPrefix, rightAlias, _mapper, rightMapper, on.Parameters[0], on.Parameters[1]); string ownerPrefix = string.IsNullOrEmpty(rightOwner) ? string.Empty : Database.DecorateName(rightOwner) + "."; string rightTableExpr = ownerPrefix + Database.DecorateName(rightTable); - string joinExpr = string.Format("{0} {1} AS {2} ON ({3} = {4})", - GetJoinKeyword(joinType), + string joinExpr = string.Format("{0} {1} AS {2}", + joinType.Trim(), rightTableExpr, - rightAlias, - leftExpr, - rightExpr); + rightAlias); - base.Join(x => joinExpr); + if (SupportNoLock && noLock) + joinExpr += " WITH(NOLOCK)"; + + if (!string.IsNullOrEmpty(condition)) + joinExpr += string.Format(" ON {0}", condition); + + AppendJoinClause(joinExpr); return this; } public IDynamicTypedSelectQueryBuilder Join(Func, TypedJoinBuilder> specification) @@ -10685,10 +10781,34 @@ namespace DynamORM TypedJoinBuilder spec = specification(new TypedJoinBuilder()); if (spec == null) throw new ArgumentException("Join specification cannot resolve to null.", "specification"); - if (spec.OnPredicate == null) - throw new ArgumentException("Join specification must define ON predicate.", "specification"); + if (spec.OnPredicate != null) + return Join(spec.OnPredicate, spec.Alias, spec.CustomJoinType ?? GetJoinKeyword(spec.JoinType), spec.UseNoLock); - return Join(spec.OnPredicate, spec.Alias, spec.JoinType); + DynamicTypeMap 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 = rightMapper.Table == null || string.IsNullOrEmpty(rightMapper.Table.Name) + ? typeof(TRight).Name + : rightMapper.Table.Name; + string rightOwner = rightMapper.Table == null ? null : rightMapper.Table.Owner; + string rightAlias = string.IsNullOrEmpty(spec.Alias) ? "t" + (Tables.Count + 1).ToString() : spec.Alias; + + string ownerPrefix = string.IsNullOrEmpty(rightOwner) ? string.Empty : Database.DecorateName(rightOwner) + "."; + string rightTableExpr = ownerPrefix + Database.DecorateName(rightTable); + string joinExpr = string.Format("{0} {1} AS {2}", + (spec.CustomJoinType ?? GetJoinKeyword(spec.JoinType)).Trim(), + rightTableExpr, + rightAlias); + + if (SupportNoLock && spec.UseNoLock) + joinExpr += " WITH(NOLOCK)"; + + if (!string.IsNullOrEmpty(spec.OnRawCondition)) + joinExpr += string.Format(" ON {0}", spec.OnRawCondition); + + AppendJoinClause(joinExpr); + return this; } public IDynamicTypedSelectQueryBuilder InnerJoin(Expression> on, string alias = null) { @@ -11061,16 +11181,82 @@ namespace DynamORM { switch (joinType) { + case DynamicJoinType.Join: + return "JOIN"; case DynamicJoinType.Left: return "LEFT JOIN"; case DynamicJoinType.Right: return "RIGHT JOIN"; case DynamicJoinType.Full: return "FULL JOIN"; + case DynamicJoinType.LeftOuter: + return "LEFT OUTER JOIN"; + case DynamicJoinType.RightOuter: + return "RIGHT OUTER JOIN"; + case DynamicJoinType.FullOuter: + return "FULL OUTER JOIN"; default: return "INNER JOIN"; } } + private void AppendJoinClause(string joinClause) + { + _join = string.IsNullOrEmpty(_join) + ? joinClause + : string.Format("{0} {1}", _join, joinClause); + } + private string ParseTypedJoinCondition(Expression expression, string leftPrefix, string rightPrefix, DynamicTypeMap leftMapper, DynamicTypeMap rightMapper, ParameterExpression leftParameter, ParameterExpression rightParameter) + { + expression = UnwrapConvert(expression); + + if (expression is BinaryExpression binary) + { + switch (binary.NodeType) + { + case ExpressionType.AndAlso: + case ExpressionType.And: + return string.Format("({0} AND {1})", + ParseTypedJoinCondition(binary.Left, leftPrefix, rightPrefix, leftMapper, rightMapper, leftParameter, rightParameter), + ParseTypedJoinCondition(binary.Right, leftPrefix, rightPrefix, leftMapper, rightMapper, leftParameter, rightParameter)); + case ExpressionType.OrElse: + case ExpressionType.Or: + return string.Format("({0} OR {1})", + ParseTypedJoinCondition(binary.Left, leftPrefix, rightPrefix, leftMapper, rightMapper, leftParameter, rightParameter), + ParseTypedJoinCondition(binary.Right, leftPrefix, rightPrefix, leftMapper, rightMapper, leftParameter, rightParameter)); + case ExpressionType.Equal: + case ExpressionType.NotEqual: + case ExpressionType.GreaterThan: + case ExpressionType.GreaterThanOrEqual: + case ExpressionType.LessThan: + case ExpressionType.LessThanOrEqual: + { + string left = ParseTypedJoinValue(binary.Left, leftPrefix, rightPrefix, leftMapper, rightMapper, leftParameter, rightParameter); + string right = ParseTypedJoinValue(binary.Right, leftPrefix, rightPrefix, leftMapper, rightMapper, leftParameter, rightParameter); + string op = GetBinaryOperator(binary.NodeType, IsNullConstant(binary.Right)); + return string.Format("({0} {1} {2})", left, op, right); + } + } + } + if (expression is UnaryExpression unary && unary.NodeType == ExpressionType.Not) + return string.Format("(NOT {0})", ParseTypedJoinCondition(unary.Operand, leftPrefix, rightPrefix, leftMapper, rightMapper, leftParameter, rightParameter)); + + throw new NotSupportedException(string.Format("Typed join condition is not supported: {0}", expression)); + } + private string ParseTypedJoinValue(Expression expression, string leftPrefix, string rightPrefix, DynamicTypeMap leftMapper, DynamicTypeMap rightMapper, ParameterExpression leftParameter, ParameterExpression rightParameter) + { + expression = UnwrapConvert(expression); + MemberExpression member = expression as MemberExpression; + if (member != null && member.Expression is ParameterExpression parameter) + { + if (parameter == leftParameter) + return ParseTypedJoinMemberByMapper(member, leftPrefix, leftMapper); + if (parameter == rightParameter) + return ParseTypedJoinMemberByMapper(member, rightPrefix, rightMapper); + } + DynamicSchemaColumn? col = null; + object value = EvaluateExpression(expression); + return ParseConstant(value, Parameters, col); + } private string ParseTypedJoinMember(Expression expression, string tablePrefix, DynamicTypeMap mapper) { expression = UnwrapConvert(expression); @@ -11097,6 +11283,27 @@ namespace DynamORM return string.Format("{0}.{1}", tablePrefix, Database.DecorateName(mappedColumn)); } + private string ParseTypedJoinMemberByMapper(MemberExpression member, string tablePrefix, DynamicTypeMap mapper) + { + string mappedColumn = null; + PropertyInfo property = member.Member as PropertyInfo; + if (property != null) + { + var attrs = property.GetCustomAttributes(typeof(ColumnAttribute), true); + ColumnAttribute colAttr = attrs == null ? null : attrs.Cast().FirstOrDefault(); + if (colAttr != null && !string.IsNullOrEmpty(colAttr.Name)) + mappedColumn = colAttr.Name; + } + if (string.IsNullOrEmpty(mappedColumn)) + mappedColumn = 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}", tablePrefix, Database.DecorateName(mappedColumn)); + } private static Expression UnwrapConvert(Expression expression) { while (expression is UnaryExpression unary && diff --git a/DynamORM.Tests/Helpers/TypedJoinModels.cs b/DynamORM.Tests/Helpers/TypedJoinModels.cs new file mode 100644 index 0000000..e29b31a --- /dev/null +++ b/DynamORM.Tests/Helpers/TypedJoinModels.cs @@ -0,0 +1,33 @@ +/* + * DynamORM - Dynamic Object-Relational Mapping library. + * Copyright (c) 2012-2026, Grzegorz Russek (grzegorz.russek@gmail.com) + * All rights reserved. + */ + +using DynamORM.Mapper; + +namespace DynamORM.Tests.Helpers +{ + [Table(Name = "Users", Owner = "dbo")] + public class TypedJoinUser + { + [Column("Id_User", true)] + public long IdUser { get; set; } + + [Column("Active")] + public int Active { get; set; } + } + + [Table(Name = "UserClients", Owner = "dbo")] + public class TypedJoinUserClient + { + [Column("User_Id", true)] + public long UserId { get; set; } + + [Column("Users")] + public string Users { get; set; } + + [Column("Deleted")] + public int Deleted { get; set; } + } +} diff --git a/DynamORM.Tests/Select/TypedFluentBuilderTests.cs b/DynamORM.Tests/Select/TypedFluentBuilderTests.cs index 968e120..84ff65a 100644 --- a/DynamORM.Tests/Select/TypedFluentBuilderTests.cs +++ b/DynamORM.Tests/Select/TypedFluentBuilderTests.cs @@ -99,7 +99,7 @@ namespace DynamORM.Tests.Select public void TestTypedJoin() { var cmd = Database.FromTyped("u") - .Join(j => j.As("x").On((l, r) => l.Id == r.Id)) + .Join(j => j.Inner().As("x").On((l, r) => l.Id == r.Id)) .Select(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\")", diff --git a/DynamORM.Tests/Select/TypedFluentJoinSyntaxTests.cs b/DynamORM.Tests/Select/TypedFluentJoinSyntaxTests.cs new file mode 100644 index 0000000..4c29f68 --- /dev/null +++ b/DynamORM.Tests/Select/TypedFluentJoinSyntaxTests.cs @@ -0,0 +1,102 @@ +/* + * DynamORM - Dynamic Object-Relational Mapping library. + * Copyright (c) 2012-2026, Grzegorz Russek (grzegorz.russek@gmail.com) + * All rights reserved. + */ + +using System.Linq; +using DynamORM.Tests.Helpers; +using NUnit.Framework; + +namespace DynamORM.Tests.Select +{ + [TestFixture] + public class TypedFluentJoinSyntaxTests : TestsBase + { + [SetUp] + public void SetUp() + { + CreateTestDatabase(); + CreateDynamicDatabase( + DynamicDatabaseOptions.SingleConnection | + DynamicDatabaseOptions.SingleTransaction | + DynamicDatabaseOptions.SupportNoLock); + } + + [TearDown] + public void TearDown() + { + DestroyDynamicDatabase(); + DestroyTestDatabase(); + } + + [Test] + public void TestTypedJoinDefaultJoinKeyword() + { + var cmd = Database.From("usr") + .Join(j => j.As("uc").On((u, c) => u.IdUser == c.UserId)); + + Assert.AreEqual("SELECT * FROM \"dbo\".\"Users\" AS usr JOIN \"dbo\".\"UserClients\" AS uc ON (usr.\"Id_User\" = uc.\"User_Id\")", cmd.CommandText()); + } + + [Test] + public void TestTypedInnerJoinWithAndNull() + { + var cmd = Database.From("usr") + .Join(j => j.Inner().As("uc").On((u, c) => u.IdUser == c.UserId && c.Users != null)) + .Select(u => u.IdUser); + + Assert.AreEqual("SELECT usr.\"Id_User\" FROM \"dbo\".\"Users\" AS usr INNER JOIN \"dbo\".\"UserClients\" AS uc ON ((usr.\"Id_User\" = uc.\"User_Id\") AND (uc.\"Users\" IS NOT NULL))", cmd.CommandText()); + } + + [Test] + public void TestTypedJoinWithNoLock() + { + var cmd = Database.From("usr", noLock: true) + .Join(j => j.Inner().As("uc").NoLock().On((u, c) => u.IdUser == c.UserId)); + + Assert.AreEqual("SELECT * FROM \"dbo\".\"Users\" AS usr WITH(NOLOCK) INNER JOIN \"dbo\".\"UserClients\" AS uc WITH(NOLOCK) ON (usr.\"Id_User\" = uc.\"User_Id\")", cmd.CommandText()); + } + + [Test] + public void TestTypedLeftOuterJoin() + { + var cmd = Database.From("usr") + .Join(j => j.LeftOuter().As("uc").On((u, c) => u.IdUser == c.UserId)); + + Assert.AreEqual("SELECT * FROM \"dbo\".\"Users\" AS usr LEFT OUTER JOIN \"dbo\".\"UserClients\" AS uc ON (usr.\"Id_User\" = uc.\"User_Id\")", cmd.CommandText()); + } + + [Test] + public void TestTypedRightOuterJoin() + { + var cmd = Database.From("usr") + .Join(j => j.RightOuter().As("uc").On((u, c) => u.IdUser == c.UserId)); + + Assert.AreEqual("SELECT * FROM \"dbo\".\"Users\" AS usr RIGHT OUTER JOIN \"dbo\".\"UserClients\" AS uc ON (usr.\"Id_User\" = uc.\"User_Id\")", cmd.CommandText()); + } + + [Test] + public void TestTypedCustomJoinTypeCrossApply() + { + var cmd = Database.From("usr") + .Join(j => j.Type("CROSS APPLY").As("uc")); + + Assert.AreEqual("SELECT * FROM \"dbo\".\"Users\" AS usr CROSS APPLY \"dbo\".\"UserClients\" AS uc", cmd.CommandText()); + } + + [Test] + public void TestTypedJoinAndWhereParameterOrder() + { + var cmd = Database.From("usr") + .Join(j => j.As("uc").On((u, c) => u.IdUser == c.UserId && c.Deleted == 0)) + .Where(u => u.Active == 1); + + Assert.AreEqual( + string.Format("SELECT * FROM \"dbo\".\"Users\" AS usr JOIN \"dbo\".\"UserClients\" AS uc ON ((usr.\"Id_User\" = uc.\"User_Id\") AND (uc.\"Deleted\" = [${0}])) WHERE (usr.\"Active\" = [${1}])", + cmd.Parameters.Keys.ElementAt(0), + cmd.Parameters.Keys.ElementAt(1)), + cmd.CommandText()); + } + } +} diff --git a/DynamORM/Builders/DynamicJoinType.cs b/DynamORM/Builders/DynamicJoinType.cs index a4da19f..b81a498 100644 --- a/DynamORM/Builders/DynamicJoinType.cs +++ b/DynamORM/Builders/DynamicJoinType.cs @@ -10,8 +10,12 @@ namespace DynamORM.Builders public enum DynamicJoinType { Inner = 0, + Join, Left, Right, - Full + Full, + LeftOuter, + RightOuter, + FullOuter } } diff --git a/DynamORM/Builders/IDynamicTypedSelectQueryBuilder.cs b/DynamORM/Builders/IDynamicTypedSelectQueryBuilder.cs index 237f30f..4a637a5 100644 --- a/DynamORM/Builders/IDynamicTypedSelectQueryBuilder.cs +++ b/DynamORM/Builders/IDynamicTypedSelectQueryBuilder.cs @@ -40,8 +40,18 @@ namespace DynamORM.Builders /// Join ON predicate. /// Optional alias for joined table. /// Join type. + /// Adds NOLOCK hint to joined source when supported by provider options. /// Builder instance. - IDynamicTypedSelectQueryBuilder Join(Expression> on, string alias = null, DynamicJoinType joinType = DynamicJoinType.Inner); + IDynamicTypedSelectQueryBuilder Join(Expression> on, string alias = null, DynamicJoinType joinType = DynamicJoinType.Inner, bool noLock = false); + + /// Add typed join with custom join type text (for example: CROSS APPLY). + /// Joined mapped entity type. + /// Optional join ON predicate. + /// Optional alias for joined table. + /// Join type text. + /// Adds NOLOCK hint to joined source when supported by provider options. + /// Builder instance. + IDynamicTypedSelectQueryBuilder Join(Expression> on, string alias, string joinType, bool noLock = false); /// Add typed join using join-spec builder syntax (As(), join kind and On()). /// Joined mapped entity type. diff --git a/DynamORM/Builders/Implementation/DynamicSelectQueryBuilder.cs b/DynamORM/Builders/Implementation/DynamicSelectQueryBuilder.cs index 387f54b..f2bae87 100644 --- a/DynamORM/Builders/Implementation/DynamicSelectQueryBuilder.cs +++ b/DynamORM/Builders/Implementation/DynamicSelectQueryBuilder.cs @@ -50,7 +50,7 @@ namespace DynamORM.Builders.Implementation private string _select; private string _from; - private string _join; + protected string _join; private string _groupby; private string _orderby; diff --git a/DynamORM/Builders/Implementation/DynamicTypedSelectQueryBuilder.cs b/DynamORM/Builders/Implementation/DynamicTypedSelectQueryBuilder.cs index 085b344..af340e0 100644 --- a/DynamORM/Builders/Implementation/DynamicTypedSelectQueryBuilder.cs +++ b/DynamORM/Builders/Implementation/DynamicTypedSelectQueryBuilder.cs @@ -65,10 +65,15 @@ namespace DynamORM.Builders.Implementation return this; } - public IDynamicTypedSelectQueryBuilder Join(Expression> on, string alias = null, DynamicJoinType joinType = DynamicJoinType.Inner) + public IDynamicTypedSelectQueryBuilder Join(Expression> on, string alias = null, DynamicJoinType joinType = DynamicJoinType.Inner, bool noLock = false) { - if (on == null) - throw new ArgumentNullException("on"); + return Join(on, alias, GetJoinKeyword(joinType), noLock); + } + + public IDynamicTypedSelectQueryBuilder Join(Expression> on, string alias, string joinType, bool noLock = false) + { + if (string.IsNullOrEmpty(joinType)) + throw new ArgumentNullException("joinType"); DynamicTypeMap rightMapper = DynamicMapperCache.GetMapper(typeof(TRight)); if (rightMapper == null) @@ -80,27 +85,28 @@ namespace DynamORM.Builders.Implementation string rightOwner = rightMapper.Table == null ? null : rightMapper.Table.Owner; string rightAlias = string.IsNullOrEmpty(alias) ? "t" + (Tables.Count + 1).ToString() : alias; - BinaryExpression be = on.Body as BinaryExpression; - if (be == null || be.NodeType != ExpressionType.Equal) - throw new NotSupportedException("Typed join expression is currently limited to equality comparisons."); - string leftPrefix = GetRootAliasOrTableName(); if (string.IsNullOrEmpty(leftPrefix)) throw new InvalidOperationException("Join requires source table to be present."); - string leftExpr = ParseTypedJoinMember(be.Left, leftPrefix, _mapper); - string rightExpr = ParseTypedJoinMember(be.Right, rightAlias, rightMapper); + string condition = null; + if (on != null) + condition = ParseTypedJoinCondition(on.Body, leftPrefix, rightAlias, _mapper, rightMapper, on.Parameters[0], on.Parameters[1]); string ownerPrefix = string.IsNullOrEmpty(rightOwner) ? string.Empty : Database.DecorateName(rightOwner) + "."; string rightTableExpr = ownerPrefix + Database.DecorateName(rightTable); - string joinExpr = string.Format("{0} {1} AS {2} ON ({3} = {4})", - GetJoinKeyword(joinType), + string joinExpr = string.Format("{0} {1} AS {2}", + joinType.Trim(), rightTableExpr, - rightAlias, - leftExpr, - rightExpr); + rightAlias); - base.Join(x => joinExpr); + if (SupportNoLock && noLock) + joinExpr += " WITH(NOLOCK)"; + + if (!string.IsNullOrEmpty(condition)) + joinExpr += string.Format(" ON {0}", condition); + + AppendJoinClause(joinExpr); return this; } @@ -112,10 +118,34 @@ namespace DynamORM.Builders.Implementation TypedJoinBuilder spec = specification(new TypedJoinBuilder()); if (spec == null) throw new ArgumentException("Join specification cannot resolve to null.", "specification"); - if (spec.OnPredicate == null) - throw new ArgumentException("Join specification must define ON predicate.", "specification"); + if (spec.OnPredicate != null) + return Join(spec.OnPredicate, spec.Alias, spec.CustomJoinType ?? GetJoinKeyword(spec.JoinType), spec.UseNoLock); - return Join(spec.OnPredicate, spec.Alias, spec.JoinType); + DynamicTypeMap 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 = rightMapper.Table == null || string.IsNullOrEmpty(rightMapper.Table.Name) + ? typeof(TRight).Name + : rightMapper.Table.Name; + string rightOwner = rightMapper.Table == null ? null : rightMapper.Table.Owner; + string rightAlias = string.IsNullOrEmpty(spec.Alias) ? "t" + (Tables.Count + 1).ToString() : spec.Alias; + + string ownerPrefix = string.IsNullOrEmpty(rightOwner) ? string.Empty : Database.DecorateName(rightOwner) + "."; + string rightTableExpr = ownerPrefix + Database.DecorateName(rightTable); + string joinExpr = string.Format("{0} {1} AS {2}", + (spec.CustomJoinType ?? GetJoinKeyword(spec.JoinType)).Trim(), + rightTableExpr, + rightAlias); + + if (SupportNoLock && spec.UseNoLock) + joinExpr += " WITH(NOLOCK)"; + + if (!string.IsNullOrEmpty(spec.OnRawCondition)) + joinExpr += string.Format(" ON {0}", spec.OnRawCondition); + + AppendJoinClause(joinExpr); + return this; } public new IDynamicTypedSelectQueryBuilder Join(params Func[] func) @@ -511,17 +541,88 @@ namespace DynamORM.Builders.Implementation { switch (joinType) { + case DynamicJoinType.Join: + return "JOIN"; case DynamicJoinType.Left: return "LEFT JOIN"; case DynamicJoinType.Right: return "RIGHT JOIN"; case DynamicJoinType.Full: return "FULL JOIN"; + case DynamicJoinType.LeftOuter: + return "LEFT OUTER JOIN"; + case DynamicJoinType.RightOuter: + return "RIGHT OUTER JOIN"; + case DynamicJoinType.FullOuter: + return "FULL OUTER JOIN"; default: return "INNER JOIN"; } } + private void AppendJoinClause(string joinClause) + { + _join = string.IsNullOrEmpty(_join) + ? joinClause + : string.Format("{0} {1}", _join, joinClause); + } + + private string ParseTypedJoinCondition(Expression expression, string leftPrefix, string rightPrefix, DynamicTypeMap leftMapper, DynamicTypeMap rightMapper, ParameterExpression leftParameter, ParameterExpression rightParameter) + { + expression = UnwrapConvert(expression); + + if (expression is BinaryExpression binary) + { + switch (binary.NodeType) + { + case ExpressionType.AndAlso: + case ExpressionType.And: + return string.Format("({0} AND {1})", + ParseTypedJoinCondition(binary.Left, leftPrefix, rightPrefix, leftMapper, rightMapper, leftParameter, rightParameter), + ParseTypedJoinCondition(binary.Right, leftPrefix, rightPrefix, leftMapper, rightMapper, leftParameter, rightParameter)); + case ExpressionType.OrElse: + case ExpressionType.Or: + return string.Format("({0} OR {1})", + ParseTypedJoinCondition(binary.Left, leftPrefix, rightPrefix, leftMapper, rightMapper, leftParameter, rightParameter), + ParseTypedJoinCondition(binary.Right, leftPrefix, rightPrefix, leftMapper, rightMapper, leftParameter, rightParameter)); + case ExpressionType.Equal: + case ExpressionType.NotEqual: + case ExpressionType.GreaterThan: + case ExpressionType.GreaterThanOrEqual: + case ExpressionType.LessThan: + case ExpressionType.LessThanOrEqual: + { + string left = ParseTypedJoinValue(binary.Left, leftPrefix, rightPrefix, leftMapper, rightMapper, leftParameter, rightParameter); + string right = ParseTypedJoinValue(binary.Right, leftPrefix, rightPrefix, leftMapper, rightMapper, leftParameter, rightParameter); + string op = GetBinaryOperator(binary.NodeType, IsNullConstant(binary.Right)); + return string.Format("({0} {1} {2})", left, op, right); + } + } + } + + if (expression is UnaryExpression unary && unary.NodeType == ExpressionType.Not) + return string.Format("(NOT {0})", ParseTypedJoinCondition(unary.Operand, leftPrefix, rightPrefix, leftMapper, rightMapper, leftParameter, rightParameter)); + + throw new NotSupportedException(string.Format("Typed join condition is not supported: {0}", expression)); + } + + private string ParseTypedJoinValue(Expression expression, string leftPrefix, string rightPrefix, DynamicTypeMap leftMapper, DynamicTypeMap rightMapper, ParameterExpression leftParameter, ParameterExpression rightParameter) + { + expression = UnwrapConvert(expression); + MemberExpression member = expression as MemberExpression; + if (member != null && member.Expression is ParameterExpression parameter) + { + if (parameter == leftParameter) + return ParseTypedJoinMemberByMapper(member, leftPrefix, leftMapper); + if (parameter == rightParameter) + return ParseTypedJoinMemberByMapper(member, rightPrefix, rightMapper); + } + + DynamicSchemaColumn? col = null; + object value = EvaluateExpression(expression); + return ParseConstant(value, Parameters, col); + } + private string ParseTypedJoinMember(Expression expression, string tablePrefix, DynamicTypeMap mapper) { expression = UnwrapConvert(expression); @@ -550,6 +651,29 @@ namespace DynamORM.Builders.Implementation return string.Format("{0}.{1}", tablePrefix, Database.DecorateName(mappedColumn)); } + private string ParseTypedJoinMemberByMapper(MemberExpression member, string tablePrefix, DynamicTypeMap mapper) + { + string mappedColumn = null; + PropertyInfo property = member.Member as PropertyInfo; + if (property != null) + { + var attrs = property.GetCustomAttributes(typeof(ColumnAttribute), true); + ColumnAttribute colAttr = attrs == null ? null : attrs.Cast().FirstOrDefault(); + if (colAttr != null && !string.IsNullOrEmpty(colAttr.Name)) + mappedColumn = colAttr.Name; + } + + if (string.IsNullOrEmpty(mappedColumn)) + mappedColumn = 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}", tablePrefix, Database.DecorateName(mappedColumn)); + } + private static Expression UnwrapConvert(Expression expression) { while (expression is UnaryExpression unary && diff --git a/DynamORM/Builders/TypedJoinBuilder.cs b/DynamORM/Builders/TypedJoinBuilder.cs index ebc5bb3..ac0440b 100644 --- a/DynamORM/Builders/TypedJoinBuilder.cs +++ b/DynamORM/Builders/TypedJoinBuilder.cs @@ -16,7 +16,7 @@ namespace DynamORM.Builders { internal TypedJoinBuilder() { - JoinType = DynamicJoinType.Inner; + JoinType = DynamicJoinType.Join; } /// Gets join alias. @@ -25,9 +25,18 @@ namespace DynamORM.Builders /// Gets join type. public DynamicJoinType JoinType { get; private set; } + /// Gets custom join type text. + public string CustomJoinType { get; private set; } + + /// Gets a value indicating whether joined source should use NOLOCK. + public bool UseNoLock { get; private set; } + /// Gets ON predicate. public Expression> OnPredicate { get; private set; } + /// Gets raw ON condition. + public string OnRawCondition { get; private set; } + /// Sets join alias. public TypedJoinBuilder As(string alias) { @@ -39,6 +48,15 @@ namespace DynamORM.Builders public TypedJoinBuilder Inner() { JoinType = DynamicJoinType.Inner; + CustomJoinType = null; + return this; + } + + /// Sets plain JOIN. + public TypedJoinBuilder Join() + { + JoinType = DynamicJoinType.Join; + CustomJoinType = null; return this; } @@ -46,6 +64,7 @@ namespace DynamORM.Builders public TypedJoinBuilder Left() { JoinType = DynamicJoinType.Left; + CustomJoinType = null; return this; } @@ -53,6 +72,7 @@ namespace DynamORM.Builders public TypedJoinBuilder Right() { JoinType = DynamicJoinType.Right; + CustomJoinType = null; return this; } @@ -60,6 +80,48 @@ namespace DynamORM.Builders public TypedJoinBuilder Full() { JoinType = DynamicJoinType.Full; + CustomJoinType = null; + return this; + } + + /// Sets LEFT OUTER JOIN. + public TypedJoinBuilder LeftOuter() + { + JoinType = DynamicJoinType.LeftOuter; + CustomJoinType = null; + return this; + } + + /// Sets RIGHT OUTER JOIN. + public TypedJoinBuilder RightOuter() + { + JoinType = DynamicJoinType.RightOuter; + CustomJoinType = null; + return this; + } + + /// Sets FULL OUTER JOIN. + public TypedJoinBuilder FullOuter() + { + JoinType = DynamicJoinType.FullOuter; + CustomJoinType = null; + return this; + } + + /// Sets custom join type text (for example: CROSS APPLY). + public TypedJoinBuilder Type(string joinType) + { + if (string.IsNullOrEmpty(joinType)) + throw new ArgumentNullException("joinType"); + + CustomJoinType = joinType.Trim(); + return this; + } + + /// Marks joined source with NOLOCK hint. + public TypedJoinBuilder NoLock(bool use = true) + { + UseNoLock = use; return this; } @@ -70,6 +132,18 @@ namespace DynamORM.Builders throw new ArgumentNullException("predicate"); OnPredicate = predicate; + OnRawCondition = null; + return this; + } + + /// Sets raw ON clause (without the ON keyword). + public TypedJoinBuilder OnRaw(string condition) + { + if (string.IsNullOrEmpty(condition)) + throw new ArgumentNullException("condition"); + + OnRawCondition = condition.Trim(); + OnPredicate = null; return this; } } diff --git a/DynamORM/Builders/TypedJoinExtensions.cs b/DynamORM/Builders/TypedJoinExtensions.cs index c3b5d07..1c8e7ce 100644 --- a/DynamORM/Builders/TypedJoinExtensions.cs +++ b/DynamORM/Builders/TypedJoinExtensions.cs @@ -27,12 +27,22 @@ namespace DynamORM.Builders DynamicJoinType jt = DynamicJoinType.Inner; string normalized = (joinType ?? string.Empty).Trim().ToUpperInvariant(); - if (normalized == "LEFT JOIN" || normalized == "LEFT") + if (normalized == "JOIN") + jt = DynamicJoinType.Join; + else if (normalized == "LEFT OUTER JOIN" || normalized == "LEFT OUTER") + jt = DynamicJoinType.LeftOuter; + else if (normalized == "RIGHT OUTER JOIN" || normalized == "RIGHT OUTER") + jt = DynamicJoinType.RightOuter; + else if (normalized == "FULL OUTER JOIN" || normalized == "FULL OUTER") + jt = DynamicJoinType.FullOuter; + else if (normalized == "LEFT JOIN" || normalized == "LEFT") jt = DynamicJoinType.Left; else if (normalized == "RIGHT JOIN" || normalized == "RIGHT") jt = DynamicJoinType.Right; else if (normalized == "FULL JOIN" || normalized == "FULL") jt = DynamicJoinType.Full; + else if (normalized != "INNER JOIN" && normalized != "INNER" && normalized != "JOIN") + return builder.Join(on, alias, joinType); return builder.Join(on, alias, jt); }