Skip to content

Commit b2526ec

Browse files
DJGosnellclaude
andauthored
Add Action<T> Set overload for assignment syntax (#33)
* Add Action<T> Set overload for assignment syntax (#32) Add Set(Action<T>) overload to IUpdateBuilder<T> and IExecutableUpdateBuilder<T> supporting both single-assignment expression lambdas and multi-assignment statement lambdas. The source generator extracts property assignments from the lambda body at compile time, producing carrier-optimized interceptors with zero delegate allocation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Remove accidentally committed conversation log file Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix review items: captured variable safety, style, and test coverage - Fix redundant LiteralExpressionSyntax check in IsCapturedVariable - Restrict captured variable extraction to simple IdentifierNameSyntax (complex expressions like obj.Value or GetName() are inlined instead of attempting broken GetField lookup) - Remove ReSharper suppress from IModificationBuilder.cs - Remove global:: prefix from Action<T> in generated code to match Expression<Func<>> convention - Add captured variable test (single and multi-assignment) - Add type-mapped column test (Money/Balance) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add SQLite integration tests for all Update Set forms - Add UpdateIntegrationTests with 10 end-to-end tests covering: Set<TValue>(Expression, TValue), Set(T entity), Set(Action<T>) single/multi-assignment, captured variables, chained forms, and All() - Fix carrier terminal logging for non-nullable value types: use .ToString() directly instead of ?.ToString() which fails on bool/int - Fix pre-existing CS8604 warnings in carrier logging for nullable reference type fields (string?) by using ?.ToString() ?? "null" - Extract CarrierClassBuilder.IsNonNullableValueType() for shared use Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix captured variable carrier optimization, add conditional chain tests, update README - Fix Set(Action<T>) captured variables not carrier-optimized: skip UpdateSetAction clauses from the direct-path check in BuildPrebuiltChainInfo, since they use delegate.Target + FieldInfo extraction, not expression tree paths. - Add per-interceptor carrier-optimized label in XML remarks. - Add ConditionalCarrierTests (19 tests): validates carrier class + bitmask dispatch code generation for Select, Update, and Delete conditional chains. - Add ConditionalChainSqlTests (18 tests): validates runtime ToDiagnostics() SQL output for conditional chains across all operation types. - Add carrier generation tests for Set(Action<T>) literal and captured variable. - Update README: db.Users property -> db.Users() method, QueryBuilder<T> -> IEntityAccessor<T>, add Set(Action<T>) docs, add ToDiagnostics(), remove Set Operations (API removed in #27). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix boolean literal formatting for non-PostgreSQL dialects in Set(Action<T>) During discovery, inlined boolean constants were formatted using the default PostgreSQL dialect (TRUE/FALSE). Non-PostgreSQL dialects (SQLite, MySQL, SQL Server) require 0/1 instead. Added ReformatInlinedBooleanForDialect to re-format boolean tokens during enrichment when the correct dialect is known. Closes #46 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Inline compile-time constants in Set(Action<T>) SQL, update README Add constant detection to TranslateSetAction: values that are compile-time constants (literals, const fields) are inlined directly into the SQL string instead of bound as parameters. For example, Set(u => u.IsActive = false) now produces SET "IsActive" = 0 instead of SET "IsActive" = @p0. - ClauseInfo.cs: Add InlinedSqlValue, InlinedCSharpExpression, IsInlined to SetActionAssignment for carrying inlined constant data. - ClauseTranslator.cs: Check GetConstantValue() before creating parameters; use FormatConstantAsSqlLiteral for inlineable values. - ExpressionSyntaxTranslator.cs: Make FormatConstantAsSqlLiteral internal. - CompileTimeSqlBuilder.cs: Emit InlinedSqlValue for inlined assignments, track parameter offsets separately from assignment count. - InterceptorCodeGenerator.Modifications.cs: Handle inlined assignments in standalone path using InlinedCSharpExpression. - CrossDialectUpdateTests.cs: Update expected SQL to reflect inlined constants. - README.md: Add ToDiagnostics() usage section, expand Set(Action<T>) docs with captured variable example. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8f82f90 commit b2526ec

25 files changed

Lines changed: 2482 additions & 1857 deletions

2026-03-18-043306-local-command-caveatcaveat-the-messages-below-w.txt

Lines changed: 0 additions & 1808 deletions
This file was deleted.

README.md

Lines changed: 86 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ The Roslyn incremental source generator analyzes every query call site and emits
7070

7171
### Execution Interceptors
7272

73-
All terminal methods — `ExecuteFetchAllAsync`, `ExecuteNonQueryAsync`, `ExecuteScalarAsync`, `ToAsyncEnumerable`, and `ToSql` — are intercepted at compile time. The generator emits pre-built SQL, ordinal-based readers, and pre-allocated parameter arrays directly into the interceptor, bypassing the runtime query builder entirely.
73+
All terminal methods — `ExecuteFetchAllAsync`, `ExecuteNonQueryAsync`, `ExecuteScalarAsync`, `ToAsyncEnumerable`, and `ToDiagnostics` — are intercepted at compile time. The generator emits pre-built SQL, ordinal-based readers, and pre-allocated parameter arrays directly into the interceptor, bypassing the runtime query builder entirely.
7474

7575
### Chain Analysis and Optimization Tiers
7676

@@ -85,7 +85,7 @@ The generator performs dataflow analysis on query chains to determine the best o
8585
Queries built with `if`/`else` branching are fully supported at compile time. The generator assigns each conditional clause a bit index and enumerates all possible clause combinations as a bitmask. Each combination maps to its own pre-built SQL variant, so conditional query construction has zero runtime SQL building cost.
8686

8787
```csharp
88-
var query = db.Users.Select(u => u);
88+
var query = db.Users().Select(u => u);
8989

9090
if (activeOnly)
9191
query = query.Where(u => u.IsActive);
@@ -160,13 +160,13 @@ public class UserSchema : Schema
160160
[QuarryContext(Dialect = SqlDialect.SQLite)]
161161
public partial class AppDb : QuarryContext
162162
{
163-
public partial QueryBuilder<User> Users { get; }
163+
public partial IEntityAccessor<User> Users();
164164
}
165165

166166
// 3. Query
167167
await using var db = new AppDb(connection);
168168

169-
var activeUsers = await db.Users
169+
var activeUsers = await db.Users()
170170
.Select(u => new { u.UserName, u.Email })
171171
.Where(u => u.IsActive)
172172
.OrderBy(u => u.UserName)
@@ -221,8 +221,8 @@ public class UserSchema : Schema
221221
[QuarryContext(Dialect = SqlDialect.SQLite, Schema = "public")]
222222
public partial class AppDb : QuarryContext
223223
{
224-
public partial QueryBuilder<User> Users { get; }
225-
public partial QueryBuilder<Order> Orders { get; }
224+
public partial IEntityAccessor<User> Users();
225+
public partial IEntityAccessor<Order> Orders();
226226
}
227227
```
228228

@@ -239,16 +239,16 @@ All query builder methods return interfaces (`IQueryBuilder<T>`, `IJoinedQueryBu
239239
### Select
240240

241241
```csharp
242-
db.Users.Select(u => u); // entity
243-
db.Users.Select(u => u.UserName); // single column
244-
db.Users.Select(u => (u.UserId, u.UserName)); // tuple
245-
db.Users.Select(u => new UserDto { Name = u.UserName }); // DTO
242+
db.Users().Select(u => u); // entity
243+
db.Users().Select(u => u.UserName); // single column
244+
db.Users().Select(u => (u.UserId, u.UserName)); // tuple
245+
db.Users().Select(u => new UserDto { Name = u.UserName }); // DTO
246246
```
247247

248248
### Where
249249

250250
```csharp
251-
db.Users.Where(u => u.IsActive && u.UserId > minId);
251+
db.Users().Where(u => u.IsActive && u.UserId > minId);
252252

253253
// Operators: ==, !=, <, >, <=, >=, &&, ||, !
254254
// Null: u.Email == null, u.Email != null
@@ -260,10 +260,10 @@ db.Users.Where(u => u.IsActive && u.UserId > minId);
260260
### OrderBy, GroupBy, Aggregates
261261

262262
```csharp
263-
db.Users.OrderBy(u => u.UserName);
264-
db.Users.OrderBy(u => u.CreatedAt, Direction.Descending);
263+
db.Users().OrderBy(u => u.UserName);
264+
db.Users().OrderBy(u => u.CreatedAt, Direction.Descending);
265265

266-
db.Orders.GroupBy(o => o.Status)
266+
db.Orders().GroupBy(o => o.Status)
267267
.Having(o => Sql.Count() > 5)
268268
.Select(o => (o.Status, Sql.Count(), Sql.Sum(o.Total)));
269269
```
@@ -273,24 +273,24 @@ Aggregate markers: `Sql.Count()`, `Sql.Sum()`, `Sql.Avg()`, `Sql.Min()`, `Sql.Ma
273273
### Pagination and Distinct
274274

275275
```csharp
276-
db.Users.Select(u => u).Limit(10).Offset(20);
277-
db.Users.Select(u => u.UserName).Distinct();
276+
db.Users().Select(u => u).Limit(10).Offset(20);
277+
db.Users().Select(u => u.UserName).Distinct();
278278
```
279279

280280
### Joins
281281

282282
```csharp
283283
// 2-table join (also LeftJoin, RightJoin)
284-
db.Users.Join<Order>((u, o) => u.UserId == o.UserId.Id)
284+
db.Users().Join<Order>((u, o) => u.UserId == o.UserId.Id)
285285
.Where((u, o) => o.Total > 100)
286286
.Select((u, o) => (u.UserName, o.Total));
287287

288288
// Navigation-based join
289-
db.Users.Join(u => u.Orders)
289+
db.Users().Join(u => u.Orders)
290290
.Select((u, o) => (u.UserName, o.Total));
291291

292292
// 3/4-table chained joins (max 4 tables)
293-
db.Users.Join<Order>((u, o) => u.UserId == o.UserId.Id)
293+
db.Users().Join<Order>((u, o) => u.UserId == o.UserId.Id)
294294
.Join<OrderItem>((u, o, oi) => o.OrderId == oi.OrderId.Id)
295295
.Select((u, o, oi) => (u.UserName, o.Total, oi.ProductName));
296296
```
@@ -300,17 +300,11 @@ db.Users.Join<Order>((u, o) => u.UserId == o.UserId.Id)
300300
On `Many<T>` properties inside `Where`:
301301

302302
```csharp
303-
db.Users.Where(u => u.Orders.Any()); // EXISTS
304-
db.Users.Where(u => u.Orders.Any(o => o.Total > 100)); // filtered EXISTS
305-
db.Users.Where(u => u.Orders.All(o => o.Status == "paid")); // NOT EXISTS + negated
306-
db.Users.Where(u => u.Orders.Count() > 5); // scalar COUNT
307-
db.Users.Where(u => u.Orders.Count(o => o.Total > 50) > 2); // filtered COUNT
308-
```
309-
310-
### Set Operations
311-
312-
```csharp
313-
db.Union(query1, query2); // also UnionAll, Except, Intersect
303+
db.Users().Where(u => u.Orders.Any()); // EXISTS
304+
db.Users().Where(u => u.Orders.Any(o => o.Total > 100)); // filtered EXISTS
305+
db.Users().Where(u => u.Orders.All(o => o.Status == "paid")); // NOT EXISTS + negated
306+
db.Users().Where(u => u.Orders.Count() > 5); // scalar COUNT
307+
db.Users().Where(u => u.Orders.Count(o => o.Total > 50) > 2); // filtered COUNT
314308
```
315309

316310
---
@@ -321,24 +315,52 @@ db.Union(query1, query2); // also UnionAll, Except, Intersect
321315

322316
```csharp
323317
// Initializer-aware — only set properties generate columns
324-
await db.Insert(new User { UserName = "x", IsActive = true }).ExecuteNonQueryAsync();
325-
var id = await db.Insert(user).ExecuteScalarAsync<int>(); // returns generated key
326-
await db.InsertMany(users).ExecuteNonQueryAsync();
318+
await db.Users().Insert(new User { UserName = "x", IsActive = true }).ExecuteNonQueryAsync();
319+
var id = await db.Users().Insert(user).ExecuteScalarAsync<int>(); // returns generated key
320+
await db.Users().InsertMany(users).ExecuteNonQueryAsync();
327321
```
328322

329323
### Update
330324

325+
Requires `Where()` or `All()` before execution. Three `Set` overloads:
326+
331327
```csharp
332-
// Requires Where() or All() before execution
333-
await db.Update<User>().Set(u => u.UserName, "New").Where(u => u.UserId == 1).ExecuteNonQueryAsync();
334-
await db.Update<User>().Set(new User { UserName = "New" }).Where(u => u.UserId == 1).ExecuteNonQueryAsync();
328+
// Column + value form
329+
await db.Users().Update()
330+
.Set(u => u.UserName, "New")
331+
.Where(u => u.UserId == 1)
332+
.ExecuteNonQueryAsync();
333+
334+
// Assignment syntax — single or multiple columns in one lambda
335+
await db.Users().Update()
336+
.Set(u => u.UserName = "New")
337+
.Where(u => u.UserId == 1)
338+
.ExecuteNonQueryAsync();
339+
340+
await db.Users().Update()
341+
.Set(u => { u.UserName = "New"; u.IsActive = true; })
342+
.Where(u => u.UserId == 1)
343+
.ExecuteNonQueryAsync();
344+
345+
// Captured variables work — extracted at runtime from the delegate closure
346+
var newName = GetNameFromInput();
347+
await db.Users().Update()
348+
.Set(u => { u.UserName = newName; u.IsActive = true; })
349+
.Where(u => u.UserId == 1)
350+
.ExecuteNonQueryAsync();
351+
352+
// Entity form — sets all initialized properties
353+
await db.Users().Update()
354+
.Set(new User { UserName = "New" })
355+
.Where(u => u.UserId == 1)
356+
.ExecuteNonQueryAsync();
335357
```
336358

337359
### Delete
338360

339361
```csharp
340362
// Requires Where() or All() before execution
341-
await db.Delete<User>().Where(u => u.UserId == 1).ExecuteNonQueryAsync();
363+
await db.Users().Delete().Where(u => u.UserId == 1).ExecuteNonQueryAsync();
342364
```
343365

344366
### Execution Methods
@@ -352,8 +374,34 @@ await db.Delete<User>().Where(u => u.UserId == 1).ExecuteNonQueryAsync();
352374
| `ExecuteScalarAsync<T>()` | `Task<T>` |
353375
| `ExecuteNonQueryAsync()` | `Task<int>` |
354376
| `ToAsyncEnumerable()` | `IAsyncEnumerable<T>` |
377+
| `ToDiagnostics()` | `QueryDiagnostics` (SQL, parameters, optimization tier, clause breakdown) |
355378
| `ToSql()` | `string` (preview SQL) |
356379

380+
### Query Diagnostics
381+
382+
`ToDiagnostics()` returns a `QueryDiagnostics` object with the generated SQL, bound parameters, optimization metadata, and a per-clause breakdown. Available on all builder types.
383+
384+
```csharp
385+
var diag = db.Users()
386+
.Where(u => u.IsActive)
387+
.OrderBy(u => u.UserName)
388+
.Select(u => u)
389+
.ToDiagnostics();
390+
391+
Console.WriteLine(diag.Sql); // SELECT ... FROM "users" WHERE ...
392+
Console.WriteLine(diag.Dialect); // SQLite
393+
Console.WriteLine(diag.Tier); // PrebuiltDispatch
394+
Console.WriteLine(diag.IsCarrierOptimized); // True
395+
396+
foreach (var p in diag.Parameters)
397+
Console.WriteLine($"{p.Name} = {p.Value}");
398+
399+
foreach (var clause in diag.Clauses)
400+
Console.WriteLine($"{clause.ClauseType}: {clause.SqlFragment} (active={clause.IsActive})");
401+
```
402+
403+
For conditional chains, each clause reports `IsConditional` and `IsActive` so you can inspect which branches were taken and verify the generated SQL for each path.
404+
357405
---
358406

359407
## Raw SQL
@@ -446,7 +494,7 @@ quarry scaffold -c "Server=localhost;Database=mydb" -d sqlserver -o Schemas --ni
446494
### What It Generates
447495

448496
- One schema class per table with `Key<T>`, `Col<T>`, `Ref<T, TKey>`, and `Many<T>` properties
449-
- A `QuarryContext` subclass with `QueryBuilder<T>` properties for each table
497+
- A `QuarryContext` subclass with `IEntityAccessor<T>` methods for each table
450498
- Automatic detection of junction tables (many-to-many), implicit foreign keys by naming convention, and naming style inference
451499

452500
---

src/Quarry.Generator/Generation/CarrierClassBuilder.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,22 @@ private static string SelectBaseClass(PrebuiltChainInfo chain)
138138
};
139139
}
140140

141+
/// <summary>
142+
/// Checks if the given CLR type name is a non-nullable value type.
143+
/// Used by the logging emitter to decide between .ToString() and ?.ToString() ?? "null".
144+
/// </summary>
145+
internal static bool IsNonNullableValueType(string typeName)
146+
{
147+
if (typeName.EndsWith("?"))
148+
return false;
149+
if (ValueTypes.Contains(typeName))
150+
return true;
151+
// Enum types (PascalCase, no dots/generics) are value types
152+
if (!typeName.Contains('<') && !typeName.Contains('[') && !typeName.Contains('.'))
153+
return false; // Could be a class name — treat conservatively as reference
154+
return false;
155+
}
156+
141157
/// <summary>
142158
/// Known value types that don't need nullable annotation.
143159
/// </summary>

src/Quarry.Generator/Generation/InterceptorCodeGenerator.Carrier.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -815,10 +815,13 @@ private static void EmitInlineParameterLogging(StringBuilder sb, PrebuiltChainIn
815815
}
816816
else
817817
{
818-
// Entity-sourced params read from Entity field, not P{n}.
819-
// Box to object? to handle value types (bool, int, etc.) that can't use ?.ToString().
818+
// Non-nullable value types use .ToString() directly.
819+
// Nullable/reference types use ?.ToString() ?? "null" since carrier fields
820+
// are declared as T? by NormalizeFieldType.
820821
if (param.EntityPropertyExpression != null)
821822
sb.AppendLine($" ParameterLog.Bound(__opId, {i}, ((object?){param.EntityPropertyExpression})?.ToString() ?? \"null\");");
823+
else if (CarrierClassBuilder.IsNonNullableValueType(param.TypeName))
824+
sb.AppendLine($" ParameterLog.Bound(__opId, {i}, __c.P{i}.ToString());");
822825
else
823826
sb.AppendLine($" ParameterLog.Bound(__opId, {i}, __c.P{i}?.ToString() ?? \"null\");");
824827
}

0 commit comments

Comments
 (0)