Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/Dommel/SetPropertyCalls.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using System;
using System.ComponentModel;

namespace Dommel;

/// <summary>
/// Supports building the set-clause of a bulk update by chaining
/// <see cref="SetProperty{TProperty}(Func{TEntity, TProperty}, TProperty)"/> calls.
/// Used as the parameter of the update expression passed to
/// <see cref="DommelMapper.UpdateMultiple{TEntity}"/> and
/// <see cref="DommelMapper.UpdateMultipleAsync{TEntity}"/>.
/// </summary>
/// <typeparam name="TEntity">The type of the entity to update.</typeparam>
public sealed class SetPropertyCalls<TEntity>

Check warning on line 14 in src/Dommel/SetPropertyCalls.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This class can't be instantiated; make its constructor 'public'.

See more on https://sonarcloud.io/project/issues?id=henkmollema_Dommel&issues=AZ750TRpCU09N42Cuuyx&open=AZ750TRpCU09N42Cuuyx&pullRequest=320
{
private SetPropertyCalls()
{
}

/// <summary>
/// Specifies that the property selected by <paramref name="propertySelector"/>
/// should be assigned the constant <paramref name="value"/>.
/// </summary>
/// <typeparam name="TProperty">The type of the property.</typeparam>
/// <param name="propertySelector">A property access expression. E.g. <c>p => p.Name</c>.</param>
/// <param name="value">The value to assign to the property.</param>
/// <returns>The current <see cref="SetPropertyCalls{TEntity}"/> instance so calls can be chained.</returns>
public SetPropertyCalls<TEntity> SetProperty<TProperty>(Func<TEntity, TProperty> propertySelector, TProperty value)
=> throw new InvalidOperationException("SetProperty can only be used within an UpdateMultiple expression.");

/// <summary>
/// Specifies that the property selected by <paramref name="propertySelector"/>
/// should be assigned the value produced by <paramref name="valueSelector"/>.
/// The value selector may reference columns of the entity and use arithmetic,
/// e.g. <c>p => p.AmountInStock - 1</c>.
/// </summary>
/// <typeparam name="TProperty">The type of the property.</typeparam>
/// <param name="propertySelector">A property access expression. E.g. <c>p => p.Name</c>.</param>
/// <param name="valueSelector">An expression producing the value to assign. E.g. <c>p => p.AmountInStock - 1</c>.</param>
/// <returns>The current <see cref="SetPropertyCalls{TEntity}"/> instance so calls can be chained.</returns>
public SetPropertyCalls<TEntity> SetProperty<TProperty>(Func<TEntity, TProperty> propertySelector, Func<TEntity, TProperty> valueSelector)
=> throw new InvalidOperationException("SetProperty can only be used within an UpdateMultiple expression.");

/// <inheritdoc />
[EditorBrowsable(EditorBrowsableState.Never)]
public override string? ToString() => base.ToString();

/// <inheritdoc />
[EditorBrowsable(EditorBrowsableState.Never)]
public override bool Equals(object? obj) => base.Equals(obj);

/// <inheritdoc />
[EditorBrowsable(EditorBrowsableState.Never)]
public override int GetHashCode() => base.GetHashCode();
}
123 changes: 122 additions & 1 deletion src/Dommel/SqlExpression.cs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,125 @@ private void AppendToWhere(string? conditionOperator, Expression expression)
_whereStatements.Add((sqlExpression, _whereStatements.Count == 0 ? null : conditionOperator));
}

/// <summary>
/// Builds the set-clause of a bulk update query from the specified chain of
/// <see cref="SetPropertyCalls{TEntity}.SetProperty{TProperty}(Func{TEntity, TProperty}, TProperty)"/> calls.
/// Parameters created for the assigned values are added to this expression so they share
/// numbering with any subsequent <see cref="Where(Expression{Func{TEntity, bool}})"/> call.
/// </summary>
/// <param name="setExpression">The update expression chaining <c>SetProperty</c> calls.</param>
/// <returns>The set-clause, e.g. <c>[FullName] = @p1, [CategoryId] = [Products].[CategoryId] * @p2</c>.</returns>
public virtual string ToSetClause(Expression<Func<SetPropertyCalls<TEntity>, SetPropertyCalls<TEntity>>> setExpression)
{
if (setExpression == null)
{
throw new ArgumentNullException(nameof(setExpression));
}

var setters = new List<(LambdaExpression Property, Expression Value)>();
PopulateSetPropertyCalls(setExpression.Body, setters, setExpression.Parameters[0]);
if (setters.Count == 0)
{
throw new ArgumentException("At least one SetProperty call is required.", nameof(setExpression));
}

// Calls are collected outermost-first; reverse to match the order they were written.
setters.Reverse();

var assignments = new List<string>(setters.Count);
foreach (var (propertyLambda, valueArgument) in setters)
{
// Resolve the assignment target. It must not be table-qualified, because some
// databases (e.g. Postgres) reject "set products.name = ..." in an update.
var propertyBody = StripConvert(propertyLambda.Body);
if (propertyBody is not MemberExpression { Member: PropertyInfo property })
{
throw new ArgumentException("The SetProperty target must be a property access expression, e.g. p => p.Name.", nameof(setExpression));
}

var column = Resolvers.Column(property, SqlBuilder, includeTableName: false);

// Determine the value. A value selector (p => p.X * 2) translates to a SQL fragment
// when it references the entity; everything else (constants, captured variables) is
// parameterized.
string valueSql;
if (UnwrapQuote(valueArgument) is LambdaExpression valueSelector)
{
var value = VisitExpression(valueSelector.Body);
if (ReferencesParameter(valueSelector.Body, valueSelector.Parameters[0]))
{
valueSql = (string)value;
}
else
{
AddParameter(value!, out valueSql);
}
}
else
{
AddParameter(VisitExpression(valueArgument)!, out valueSql);
}

assignments.Add($"{column} = {valueSql}");
}

return string.Join(", ", assignments);

static void PopulateSetPropertyCalls(Expression expression, List<(LambdaExpression, Expression)> list, ParameterExpression parameter)
{
switch (expression)
{
case ParameterExpression p when p == parameter:
break;

case MethodCallExpression
{
Method: { IsGenericMethod: true, Name: nameof(SetPropertyCalls<TEntity>.SetProperty), DeclaringType.IsGenericType: true }
} methodCall when methodCall.Method.DeclaringType!.GetGenericTypeDefinition() == typeof(SetPropertyCalls<>):
list.Add(((LambdaExpression)UnwrapQuote(methodCall.Arguments[0]), methodCall.Arguments[1]));
PopulateSetPropertyCalls(methodCall.Object!, list, parameter);
break;

default:
throw new ArgumentException($"Unsupported expression in the SetProperty chain: '{expression}'.");
}
}
}

private static Expression UnwrapQuote(Expression expression) =>
expression is UnaryExpression { NodeType: ExpressionType.Quote } quote ? quote.Operand : expression;

private static Expression StripConvert(Expression expression)
{
while (expression is UnaryExpression { NodeType: ExpressionType.Convert } convert)
{
expression = convert.Operand;
}
return expression;
}

private static bool ReferencesParameter(Expression expression, ParameterExpression parameter)
{
var visitor = new ParameterUsageVisitor(parameter);
visitor.Visit(expression);
return visitor.Found;
}

private sealed class ParameterUsageVisitor(ParameterExpression parameter) : ExpressionVisitor
{
public bool Found { get; private set; }

protected override Expression VisitParameter(ParameterExpression node)
{
if (node == parameter)
{
Found = true;
}

return base.VisitParameter(node);
}
}

/// <summary>
/// Adds a paging-statement to the current expression.
/// </summary>
Expand Down Expand Up @@ -326,7 +445,9 @@ private void AppendOrderBy(string? column, string direction, bool prepend = fals
ExpressionType.Lambda => VisitLambda((LambdaExpression)expression),
ExpressionType.LessThan or ExpressionType.LessThanOrEqual or ExpressionType.GreaterThan or
ExpressionType.GreaterThanOrEqual or ExpressionType.Equal or ExpressionType.NotEqual or
ExpressionType.And or ExpressionType.AndAlso or ExpressionType.Or or ExpressionType.OrElse
ExpressionType.And or ExpressionType.AndAlso or ExpressionType.Or or ExpressionType.OrElse or
ExpressionType.Add or ExpressionType.Subtract or ExpressionType.Multiply or
ExpressionType.Divide or ExpressionType.Modulo
=> VisitBinary((BinaryExpression)expression),
ExpressionType.Convert or ExpressionType.Not => VisitUnary((UnaryExpression)expression),
ExpressionType.New => VisitNew((NewExpression)expression),
Expand Down
74 changes: 74 additions & 0 deletions src/Dommel/Update.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Data;
using System.Linq;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;
using Dapper;
Expand Down Expand Up @@ -42,6 +43,79 @@ public static async Task<bool> UpdateAsync<TEntity>(this IDbConnection connectio
return await connection.ExecuteAsync(new CommandDefinition(sql, entity, transaction: transaction, cancellationToken: cancellationToken)) > 0;
}

/// <summary>
/// Updates all entities of type <typeparamref name="TEntity"/> matching the specified
/// <paramref name="predicate"/>, assigning the values specified by <paramref name="set"/>.
/// Returns the number of rows affected.
/// </summary>
/// <typeparam name="TEntity">The type of the entity.</typeparam>
/// <param name="connection">The connection to the database. This can either be open or closed.</param>
/// <param name="set">
/// An expression specifying the properties to update and their values, e.g.
/// <c>update => update.SetProperty(p => p.Name, "foo").SetProperty(p => p.Stock, p => p.Stock - 1)</c>.
/// A value selector may reference columns of the entity and use the <c>+ - * / %</c> operators
/// with a constant operand; column-to-column arithmetic is not supported.
/// </param>
/// <param name="predicate">A predicate to filter which entities are updated.</param>
/// <param name="transaction">Optional transaction for the command.</param>
/// <returns>The number of rows affected.</returns>
public static int UpdateMultiple<TEntity>(
this IDbConnection connection,
Expression<Func<SetPropertyCalls<TEntity>, SetPropertyCalls<TEntity>>> set,
Expression<Func<TEntity, bool>> predicate,
IDbTransaction? transaction = null)
{
var sql = BuildUpdateMultipleQuery<TEntity>(GetSqlBuilder(connection), set, predicate, out var parameters);
LogQuery<TEntity>(sql);
return connection.Execute(sql, parameters, transaction);
}

/// <summary>
/// Updates all entities of type <typeparamref name="TEntity"/> matching the specified
/// <paramref name="predicate"/>, assigning the values specified by <paramref name="set"/>.
/// Returns the number of rows affected.
/// </summary>
/// <typeparam name="TEntity">The type of the entity.</typeparam>
/// <param name="connection">The connection to the database. This can either be open or closed.</param>
/// <param name="set">
/// An expression specifying the properties to update and their values, e.g.
/// <c>update => update.SetProperty(p => p.Name, "foo").SetProperty(p => p.Stock, p => p.Stock - 1)</c>.
/// A value selector may reference columns of the entity and use the <c>+ - * / %</c> operators
/// with a constant operand; column-to-column arithmetic is not supported.
/// </param>
/// <param name="predicate">A predicate to filter which entities are updated.</param>
/// <param name="transaction">Optional transaction for the command.</param>
/// <param name="cancellationToken">Optional cancellation token for the command.</param>
/// <returns>The number of rows affected.</returns>
public static async Task<int> UpdateMultipleAsync<TEntity>(
this IDbConnection connection,
Expression<Func<SetPropertyCalls<TEntity>, SetPropertyCalls<TEntity>>> set,
Expression<Func<TEntity, bool>> predicate,
IDbTransaction? transaction = null,
CancellationToken cancellationToken = default)
{
var sql = BuildUpdateMultipleQuery<TEntity>(GetSqlBuilder(connection), set, predicate, out var parameters);
LogQuery<TEntity>(sql);
return await connection.ExecuteAsync(new CommandDefinition(sql, parameters, transaction: transaction, cancellationToken: cancellationToken));
}

internal static string BuildUpdateMultipleQuery<TEntity>(
ISqlBuilder sqlBuilder,
Expression<Func<SetPropertyCalls<TEntity>, SetPropertyCalls<TEntity>>> set,
Expression<Func<TEntity, bool>> predicate,
out DynamicParameters parameters)
{
var tableName = Resolvers.Table(typeof(TEntity), sqlBuilder);

// Use a single SqlExpression instance for both the set-clause and the where-clause so
// their auto-numbered parameters share numbering and never collide. Going through the
// factory keeps the where-clause JSON-aware when Dommel.Json is used.
var expression = CreateSqlExpression<TEntity>(sqlBuilder);
var setClause = expression.ToSetClause(set);
var whereSql = expression.Where(predicate).ToSql(out parameters);
return $"update {tableName} set {setClause}{whereSql}";
}

internal static string BuildUpdateQuery(ISqlBuilder sqlBuilder, Type type)
{
var cacheKey = new QueryCacheKey(QueryCacheType.Update, sqlBuilder, type);
Expand Down
64 changes: 63 additions & 1 deletion test/Dommel.IntegrationTests/UpdateTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.Threading.Tasks;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Xunit;

namespace Dommel.IntegrationTests;
Expand Down Expand Up @@ -55,4 +57,64 @@ public async Task UpdateAsync(DatabaseDriver database)
await con.UpdateAsync(product);
}
}

[Theory]
[ClassData(typeof(DatabaseTestData))]
public void UpdateMultiple(DatabaseDriver database)
{
using var con = database.GetConnection();
con.InsertAll(new List<Product>
{
new Product { Name = "update-multiple", CategoryId = 10 },
new Product { Name = "update-multiple", CategoryId = 10 },
new Product { Name = "update-multiple", CategoryId = 10 },
});

try
{
var affected = con.UpdateMultiple<Product>(
update => update.SetProperty(p => p.Name, "updated-sync").SetProperty(p => p.CategoryId, p => p.CategoryId * 2),
p => p.Name == "update-multiple");

Assert.Equal(3, affected);

var updated = con.Select<Product>(p => p.Name == "updated-sync").ToList();
Assert.Equal(3, updated.Count);
Assert.All(updated, p => Assert.Equal(20, p.CategoryId));
}
finally
{
con.DeleteMultiple<Product>(p => p.Name == "update-multiple" || p.Name == "updated-sync");
}
}

[Theory]
[ClassData(typeof(DatabaseTestData))]
public async Task UpdateMultipleAsync(DatabaseDriver database)
{
using var con = database.GetConnection();
await con.InsertAllAsync(new List<Product>
{
new Product { Name = "update-multiple-async", CategoryId = 10 },
new Product { Name = "update-multiple-async", CategoryId = 10 },
new Product { Name = "update-multiple-async", CategoryId = 10 },
});

try
{
var affected = await con.UpdateMultipleAsync<Product>(
update => update.SetProperty(p => p.Name, "updated-async").SetProperty(p => p.CategoryId, p => p.CategoryId * 2),
p => p.Name == "update-multiple-async");

Assert.Equal(3, affected);

var updated = (await con.SelectAsync<Product>(p => p.Name == "updated-async")).ToList();
Assert.Equal(3, updated.Count);
Assert.All(updated, p => Assert.Equal(20, p.CategoryId));
}
finally
{
await con.DeleteMultipleAsync<Product>(p => p.Name == "update-multiple-async" || p.Name == "updated-async");
}
}
}
8 changes: 8 additions & 0 deletions test/Dommel.Tests/SqlExpressions/SqlExpressionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ public void ToStringVisitation_ReturnsSql()
Assert.Equal(sql, sqlExpression.ToString());
}

[Fact]
public void Where_TranslatesArithmetic()
{
var sqlExpression = new SqlExpression<Product>(new SqlServerSqlBuilder());
var sql = sqlExpression.Where(p => p.CategoryId * 2 == 4).ToSql();
Assert.Equal(" where [Products].[CategoryId] * @p1 = @p2", sql);
}

[Fact]
public void ToString_ThrowsWhenCalledWithArgument()
{
Expand Down
Loading