Skip to content

Commit 204d6fc

Browse files
committed
feat: add support for translation of static members
1 parent b9d2f99 commit 204d6fc

7 files changed

Lines changed: 464 additions & 31 deletions

File tree

.claude/CLAUDE.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -253,8 +253,10 @@ private NpgsqlBulkOperationExecutor SUT => field ??= ActDbContext.GetService<Npg
253253
- PostgreSQL resolves `NpgsqlDbType` per column via `INpgsqlTypeMapping` for correct type handling (e.g., `jsonb`/`json` from `string`)
254254
- Table/schema override via options; property selection via `IEntityPropertiesProvider`
255255
- PostgreSQL temp table collation: strings with `.` split into schema+name by default; set `SplitCollationComponents = false` to escape as single identifier
256-
- **Query-based bulk update** (all providers): `DbSet<TTarget>.BulkUpdateAsync(sourceQuery, targetKey, sourceKey, setBuilder, filter?, options?)`. SQL Server generates `UPDATE t SET ... FROM target INNER JOIN (sourceQuerySql) AS s ON ... [WHERE filterCondition]`; PostgreSQL/SQLite generate `UPDATE target AS t SET ... FROM (sourceQuerySql) AS s WHERE ... [AND filterCondition]`. Uses `SetPropertyBuilder<TTarget, TSource>` for fluent property assignment. Key selectors support single and composite keys (anonymous types). Source SQL and parameters extracted via `CreateDbCommand()`. Optional `filter` (`Expression<Func<TTarget, TSource, bool>>`) restricts which rows are updated; filter can reference both target and source properties (e.g., `(e, f) => e.Count < f.Count`); filter is translated via `ValueExpressionTranslator` (same instance as SET values), with unified `@__ev_N` parameters. Shared expression analysis logic in `BulkUpdateFromQueryHelper`. Table/schema override via `SqlServerBulkUpdateFromQueryOptions`, `NpgsqlBulkUpdateFromQueryOptions`, or `SqliteBulkUpdateFromQueryOptions`. Value expressions support constants and captured variables (e.g., `.Set(e => e.Count, (e, f) => 42)` or `.Set(e => e.Name, (e, f) => myVar)`), complex arithmetic expressions referencing target and/or source properties (e.g., `.Set(e => e.Count, (e, f) => e.Count + f.Count * 2)` or `.Set(e => e.Count, (e, f) => f.Count + 10)`), in addition to simple property access and `EF.Property` calls; constant values are emitted as SQL parameters (`@__bv_N`); complex expressions are translated to SQL via `ValueExpressionTranslator` with expression-value parameters (`@__ev_N`).
257-
- **Query-based bulk insert** (all providers): `DbSet<TTarget>.BulkInsertAsync(sourceQuery, mapBuilder)`. Generates `INSERT INTO target (cols) SELECT s.cols FROM (sourceQuerySql) AS s` — identical structure across all three providers (only identifier quoting differs). Uses `InsertPropertyBuilder<TTarget, TSource>` for fluent column mapping via `Map(target => target.Prop, source => source.Prop)` with single-parameter value selector. No key selectors needed (insert, not join). Table/schema override via `SqlServerBulkInsertFromQueryOptions`, `NpgsqlBulkInsertFromQueryOptions`, or `SqliteBulkInsertFromQueryOptions`. Value selectors support constants and captured variables (e.g., `.Map(e => e.Count, f => 42)` or `.Map(e => e.Name, f => myVar)`), complex arithmetic expressions referencing source properties (e.g., `.Map(e => e.Count, f => f.Count * 2 + 1)`), in addition to simple property access and `EF.Property` calls; constant values are emitted as SQL parameters (`@__bv_N`); complex expressions are translated to SQL via `ValueExpressionTranslator` with expression-value parameters (`@__ev_N`).
256+
- **Query-based bulk update** (all providers): `DbSet<TTarget>.BulkUpdateAsync(sourceQuery, targetKey, sourceKey, setBuilder, filter?, options?)`. SQL Server generates `UPDATE t SET ... FROM target INNER JOIN (sourceQuerySql) AS s ON ... [WHERE filterCondition]`; PostgreSQL/SQLite generate `UPDATE target AS t SET ... FROM (sourceQuerySql) AS s WHERE ... [AND filterCondition]`. Uses `SetPropertyBuilder<TTarget, TSource>` for fluent property assignment. Key selectors support single and composite keys (anonymous types). Source SQL and parameters extracted via `CreateDbCommand()`. Optional `filter` (`Expression<Func<TTarget, TSource, bool>>`) restricts which rows are updated; filter can reference both target and source properties (e.g., `(e, f) => e.Count < f.Count`); filter is translated via `ValueExpressionTranslator` (same instance as SET values), with unified `@__ev_N` parameters. Shared expression analysis logic in `BulkUpdateFromQueryHelper`. Table/schema override via `SqlServerBulkUpdateFromQueryOptions`, `NpgsqlBulkUpdateFromQueryOptions`, or `SqliteBulkUpdateFromQueryOptions`. Value expressions support constants, captured variables, and static members (e.g., `.Set(e => e.Count, (e, f) => 42)`, `.Set(e => e.Name, (e, f) => myVar)`, or `.Set(e => e.Status, (e, f) => Status.Published)`), complex arithmetic expressions referencing target and/or source properties (e.g., `.Set(e => e.Count, (e, f) => e.Count + f.Count * 2)` or `.Set(e => e.Count, (e, f) => f.Count + 10)`), in addition to simple property access and `EF.Property` calls; constant values are emitted as SQL parameters (`@__bv_N`); complex expressions are translated to SQL via `ValueExpressionTranslator` with expression-value parameters (`@__ev_N`).
257+
- **Query-based bulk insert** (all providers): `DbSet<TTarget>.BulkInsertAsync(sourceQuery, mapBuilder)`. Generates `INSERT INTO target (cols) SELECT s.cols FROM (sourceQuerySql) AS s` — identical structure across all three providers (only identifier quoting differs). Uses `InsertPropertyBuilder<TTarget, TSource>` for fluent column mapping via `Map(target => target.Prop, source => source.Prop)` with single-parameter value selector. No key selectors needed (insert, not join). Table/schema override via `SqlServerBulkInsertFromQueryOptions`, `NpgsqlBulkInsertFromQueryOptions`, or `SqliteBulkInsertFromQueryOptions`. Value selectors support constants, captured variables, and static members (e.g., `.Map(e => e.Count, f => 42)` or `.Map(e => e.Name, f => myVar)`), complex arithmetic expressions referencing source properties (e.g., `.Map(e => e.Count, f => f.Count * 2 + 1)`), in addition to simple property access and `EF.Property` calls; constant values are emitted as SQL parameters (`@__bv_N`); complex expressions are translated to SQL via `ValueExpressionTranslator` with expression-value parameters (`@__ev_N`).
258+
259+
- **Value expression evaluation** (query-based bulk update/insert): `ClosureFlatteningVisitor` in `EfCoreValueExpressionTranslator` evaluates closure fields **and static members** (e.g. smart-enum values like `Status.Published`) to constants, guarded by `IEvaluatableExpressionFilter` so server-translatable members (e.g. `DateTime.Now`) stay untouched. Resulting `ConstantExpression`s bypass EF's SQL translator (which fails for types without a built-in type mapping) and are emitted directly with the target column's type mapping, so value-converter-typed values work; in the insert path, the converter is applied via `ConvertToProvider` before the value is bound as a raw parameter.
258260

259261
- **Source entity type resolution** (query-based bulk update/insert): Source entity type is resolved via `FindEntityType(typeof(TSource))` first (regular CLR-type lookup), then falls back to `FindEntityType(EntityNameProvider.GetTempTableName(typeof(TSource)))` for temp-table-only entities registered via `ConfigureTempTableEntity<T>()`. Without the fallback, column name resolution uses property names instead of mapped column names, causing SQL errors.
260262

docs

Submodule docs updated from 1d9ecfd to 583c36b

src/Thinktecture.EntityFrameworkCore.BulkOperations/EntityFrameworkCore/BulkOperations/Internal/EfCoreValueExpressionTranslator.cs

Lines changed: 53 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -94,17 +94,9 @@ public EfCoreValueExpressionTranslator(
9494
throw new ArgumentException($"Update value expression must have exactly 2 parameters. Expression: {valueExpr}");
9595

9696
var remapped = RemapTwoParameterLambda(valueExpr, targetShaper, sourceShaper);
97-
var flattenedRemapped = FlattenClosures(_logger, remapped);
97+
var flattenedRemapped = FlattenClosures(remapped);
9898

99-
var sqlValue = sqlTranslator.TranslateProjection(flattenedRemapped)
100-
?? throw new InvalidOperationException($"Could not translate value expression: {valueExpr}");
101-
102-
if (sqlValue is not SqlExpression sqlExpr)
103-
throw new InvalidOperationException($"Translated expression is not a SqlExpression: {sqlValue.GetType().Name}");
104-
105-
// Apply type mapping from target column
106-
var typeMapping = targetProp.GetRelationalTypeMapping();
107-
sqlExpr = sqlExprFactory.ApplyTypeMapping(sqlExpr, typeMapping);
99+
var sqlExpr = TranslateValueExpression(valueExpr, flattenedRemapped, sqlTranslator, sqlExprFactory, targetProp.GetRelationalTypeMapping());
108100

109101
setters.Add(new ColumnValueSetter(targetColumn, sqlExpr));
110102
}
@@ -116,7 +108,7 @@ public EfCoreValueExpressionTranslator(
116108
throw new ArgumentException($"Filter expression must have exactly 2 parameters. Expression: {filter}");
117109

118110
var remappedFilter = RemapTwoParameterLambda(filter, targetShaper, sourceShaper);
119-
var flattenedFilter = FlattenClosures(_logger, remappedFilter);
111+
var flattenedFilter = FlattenClosures(remappedFilter);
120112

121113
var sqlFilter = sqlTranslator.TranslateProjection(flattenedFilter)
122114
?? throw new InvalidOperationException($"Could not translate filter expression: {filter}");
@@ -225,17 +217,9 @@ private string ApplyTableSchemaAndNameOverride(
225217
throw new ArgumentException($"Insert value expression must have exactly 1 parameter. Expression: {valueExpr}");
226218

227219
var remapped = RemapSingleParameterLambda(valueExpr, sourceShaper);
228-
var flattenedRemapped = FlattenClosures(_logger, remapped);
229-
230-
var sqlValue = sqlTranslator.TranslateProjection(flattenedRemapped)
231-
?? throw new InvalidOperationException($"Could not translate value expression: {valueExpr}");
220+
var flattenedRemapped = FlattenClosures(remapped);
232221

233-
if (sqlValue is not SqlExpression sqlExpr)
234-
throw new InvalidOperationException($"Translated expression is not a SqlExpression: {sqlValue.GetType().Name}");
235-
236-
// Apply type mapping from target column
237-
var typeMapping = targetProp.GetRelationalTypeMapping();
238-
sqlExpr = sqlExprFactory.ApplyTypeMapping(sqlExpr, typeMapping);
222+
var sqlExpr = TranslateValueExpression(valueExpr, flattenedRemapped, sqlTranslator, sqlExprFactory, targetProp.GetRelationalTypeMapping());
239223

240224
valueExpressions.Add(sqlExpr);
241225
}
@@ -251,7 +235,9 @@ private string ApplyTableSchemaAndNameOverride(
251235
if (valExpr is SqlConstantExpression constExpr)
252236
{
253237
var paramName = $"__insert_const_{constParamIndex++}";
254-
parameterValues[paramName] = constExpr.Value;
238+
// The parameter value is bound as-is by the executors, so the value converter (if any) must be applied here.
239+
var converter = constExpr.TypeMapping?.Converter;
240+
parameterValues[paramName] = converter is null ? constExpr.Value : converter.ConvertToProvider(constExpr.Value);
255241
projectionExpressions.Add(new SqlParameterExpression(paramName, valExpr.Type, constExpr.TypeMapping));
256242
}
257243
else
@@ -420,9 +406,31 @@ private static Expression RemapSingleParameterLambda(LambdaExpression lambda, Ex
420406
return ReplacingExpressionVisitor.Replace(lambda.Parameters[0], sourceShaper, lambda.Body);
421407
}
422408

423-
private static Expression FlattenClosures(ILogger logger, Expression expression)
409+
private Expression FlattenClosures(Expression expression)
424410
{
425-
return new ClosureFlatteningVisitor(logger).Visit(expression);
411+
return new ClosureFlatteningVisitor(_logger, _ctx.GetService<IEvaluatableExpressionFilter>(), _ctx.Model).Visit(expression);
412+
}
413+
414+
private static SqlExpression TranslateValueExpression(
415+
LambdaExpression valueExpr,
416+
Expression flattenedValue,
417+
RelationalSqlTranslatingExpressionVisitor sqlTranslator,
418+
ISqlExpressionFactory sqlExprFactory,
419+
RelationalTypeMapping targetTypeMapping)
420+
{
421+
// Constants of types without a built-in type mapping (e.g. properties with a value converter)
422+
// cannot be translated by EF Core; create the constant with the target column's type mapping directly.
423+
if (flattenedValue is ConstantExpression constant)
424+
return sqlExprFactory.Constant(constant.Value, constant.Type, targetTypeMapping);
425+
426+
var sqlValue = sqlTranslator.TranslateProjection(flattenedValue)
427+
?? throw new InvalidOperationException($"Could not translate value expression: {valueExpr}");
428+
429+
if (sqlValue is not SqlExpression sqlExpr)
430+
throw new InvalidOperationException($"Translated expression is not a SqlExpression: {sqlValue.GetType().Name}");
431+
432+
// Apply type mapping from target column
433+
return sqlExprFactory.ApplyTypeMapping(sqlExpr, targetTypeMapping);
426434
}
427435

428436
private static string GetTargetTableAlias(SelectExpression selectExpression)
@@ -444,21 +452,38 @@ private static ColumnExpression CreateColumnExpression(
444452
property.IsNullable);
445453
}
446454

447-
private sealed partial class ClosureFlatteningVisitor(ILogger logger) : ExpressionVisitor
455+
private sealed partial class ClosureFlatteningVisitor(
456+
ILogger logger,
457+
IEvaluatableExpressionFilter evaluatableExpressionFilter,
458+
IModel model) : ExpressionVisitor
448459
{
449460
protected override Expression VisitMember(MemberExpression node)
450461
{
462+
// Static member access (e.g. MyType.StaticValue) — evaluate it to a constant
463+
// unless EF Core translates it server-side (e.g. DateTime.Now).
464+
if (node.Expression is null)
465+
{
466+
return evaluatableExpressionFilter.IsEvaluatableExpression(node, model)
467+
? Evaluate(node, null) ?? node
468+
: node;
469+
}
470+
451471
var expression = Visit(node.Expression);
452472

453473
if (expression is not ConstantExpression constant)
454474
return node.Update(expression);
455475

476+
return Evaluate(node, constant.Value) ?? node.Update(expression);
477+
}
478+
479+
private Expression? Evaluate(MemberExpression node, object? instance)
480+
{
456481
try
457482
{
458483
var value = node.Member switch
459484
{
460-
FieldInfo fi => fi.GetValue(constant.Value),
461-
PropertyInfo pi => pi.GetValue(constant.Value),
485+
FieldInfo fi => fi.GetValue(instance),
486+
PropertyInfo pi => pi.GetValue(instance),
462487
_ => throw new NotSupportedException($"Unsupported member type: {node.Member.GetType().Name}")
463488
};
464489

@@ -469,7 +494,7 @@ protected override Expression VisitMember(MemberExpression node)
469494
LogClosureFlatteningFailed(logger, ex, node.Member.Name, node.Member.DeclaringType?.Name);
470495
}
471496

472-
return node.Update(expression);
497+
return null;
473498
}
474499

475500
[LoggerMessage(Level = LogLevel.Warning,

tests/Thinktecture.EntityFrameworkCore.PostgreSQL.Tests/EntityFrameworkCore/BulkOperations/NpgsqlBulkOperationExecutorTests/BulkUpdateFromQueryAsync.cs

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,126 @@ public async Task Should_update_with_captured_variable()
628628
loaded.Name.Should().Be("CapturedValue");
629629
}
630630

631+
public static string StaticName => "StaticValue";
632+
private static readonly ConvertibleClass _staticConvertibleClass = new(42);
633+
634+
[Fact]
635+
public async Task Should_update_with_static_member_value()
636+
{
637+
var entity = new TestEntity { Id = new Guid("40B5CA93-5C02-48AD-B8A1-12BC13313866"), Name = "Original", RequiredName = "RequiredName", Count = 0 };
638+
ArrangeDbContext.Add(entity);
639+
await ArrangeDbContext.SaveChangesAsync();
640+
641+
var updatedEntities = new List<TestEntity>
642+
{
643+
new() { Id = new Guid("40B5CA93-5C02-48AD-B8A1-12BC13313866"), RequiredName = "RequiredName" }
644+
};
645+
646+
await using var tempTable = await SUT.BulkInsertIntoTempTableAsync(updatedEntities, new NpgsqlTempTableBulkInsertOptions());
647+
648+
var sourceQuery = tempTable.Query;
649+
650+
var affectedRows = await ActDbContext.Set<TestEntity>().BulkUpdateAsync(
651+
sourceQuery,
652+
e => e.Id,
653+
f => f.Id,
654+
builder => builder.Set(e => e.Name, (e, f) => StaticName));
655+
656+
affectedRows.Should().Be(1);
657+
658+
var loaded = await AssertDbContext.TestEntities.SingleAsync();
659+
loaded.Name.Should().Be("StaticValue");
660+
}
661+
662+
[Fact]
663+
public async Task Should_update_with_static_member_of_converted_type()
664+
{
665+
var entity = new TestEntity { Id = new Guid("40B5CA93-5C02-48AD-B8A1-12BC13313866"), Name = "Original", RequiredName = "RequiredName", ConvertibleClass = new ConvertibleClass(1) };
666+
ArrangeDbContext.Add(entity);
667+
await ArrangeDbContext.SaveChangesAsync();
668+
669+
var updatedEntities = new List<TestEntity>
670+
{
671+
new() { Id = new Guid("40B5CA93-5C02-48AD-B8A1-12BC13313866"), RequiredName = "RequiredName" }
672+
};
673+
674+
await using var tempTable = await SUT.BulkInsertIntoTempTableAsync(updatedEntities, new NpgsqlTempTableBulkInsertOptions());
675+
676+
var sourceQuery = tempTable.Query;
677+
678+
var affectedRows = await ActDbContext.Set<TestEntity>().BulkUpdateAsync(
679+
sourceQuery,
680+
e => e.Id,
681+
f => f.Id,
682+
builder => builder.Set(e => e.ConvertibleClass, (e, f) => _staticConvertibleClass));
683+
684+
affectedRows.Should().Be(1);
685+
686+
var loaded = await AssertDbContext.TestEntities.SingleAsync();
687+
loaded.ConvertibleClass.Should().Be(new ConvertibleClass(42));
688+
}
689+
690+
[Fact]
691+
public async Task Should_update_with_captured_variable_of_converted_type()
692+
{
693+
var entity = new TestEntity { Id = new Guid("40B5CA93-5C02-48AD-B8A1-12BC13313866"), Name = "Original", RequiredName = "RequiredName", ConvertibleClass = new ConvertibleClass(1) };
694+
ArrangeDbContext.Add(entity);
695+
await ArrangeDbContext.SaveChangesAsync();
696+
697+
var updatedEntities = new List<TestEntity>
698+
{
699+
new() { Id = new Guid("40B5CA93-5C02-48AD-B8A1-12BC13313866"), RequiredName = "RequiredName" }
700+
};
701+
702+
await using var tempTable = await SUT.BulkInsertIntoTempTableAsync(updatedEntities, new NpgsqlTempTableBulkInsertOptions());
703+
704+
var sourceQuery = tempTable.Query;
705+
706+
var newValue = new ConvertibleClass(43);
707+
708+
var affectedRows = await ActDbContext.Set<TestEntity>().BulkUpdateAsync(
709+
sourceQuery,
710+
e => e.Id,
711+
f => f.Id,
712+
builder => builder.Set(e => e.ConvertibleClass, (e, f) => newValue));
713+
714+
affectedRows.Should().Be(1);
715+
716+
var loaded = await AssertDbContext.TestEntities.SingleAsync();
717+
loaded.ConvertibleClass.Should().Be(new ConvertibleClass(43));
718+
}
719+
720+
[Fact]
721+
public async Task Should_translate_datetime_now_server_side_instead_of_evaluating_it()
722+
{
723+
var entity = new TestEntity { Id = new Guid("40B5CA93-5C02-48AD-B8A1-12BC13313866"), Name = "Original", RequiredName = "RequiredName", Count = 0 };
724+
ArrangeDbContext.Add(entity);
725+
await ArrangeDbContext.SaveChangesAsync();
726+
727+
var updatedEntities = new List<TestEntity>
728+
{
729+
new() { Id = new Guid("40B5CA93-5C02-48AD-B8A1-12BC13313866"), RequiredName = "RequiredName" }
730+
};
731+
732+
await using var tempTable = await SUT.BulkInsertIntoTempTableAsync(updatedEntities, new NpgsqlTempTableBulkInsertOptions());
733+
734+
var sourceQuery = tempTable.Query;
735+
736+
var affectedRows = await ActDbContext.Set<TestEntity>().BulkUpdateAsync(
737+
sourceQuery,
738+
e => e.Id,
739+
f => f.Id,
740+
builder => builder.Set(e => e.Count, (e, f) => DateTime.Now.Year));
741+
742+
affectedRows.Should().Be(1);
743+
744+
// DateTime.Now must not be evaluated to a constant at translation time but be translated server-side.
745+
ExecutedCommands.Last().Should().Contain("now()");
746+
747+
var loaded = await AssertDbContext.TestEntities.SingleAsync();
748+
loaded.Count.Should().Be(DateTime.Now.Year);
749+
}
750+
631751
[Fact]
632752
public async Task Should_update_with_source_arithmetic_expression()
633753
{

0 commit comments

Comments
 (0)