Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
0b2267f
Feat: Carrier class optimization for PrebuiltDispatch chains (#9)
DJGosnell Mar 15, 2026
75813a9
Refactor: Change entity set accessors from properties to methods
DJGosnell Mar 15, 2026
e6679e6
Feat: Add carrier base classes with interface implementations
DJGosnell Mar 15, 2026
4af1fe6
Feat: Add Limit/Offset/Distinct/WithTimeout carrier interceptors
DJGosnell Mar 15, 2026
798daa8
Feat: Add Delete/Update carrier branches and base classes
DJGosnell Mar 15, 2026
69b8b0f
Feat: Add nullable heuristic for carrier parameter fields
DJGosnell Mar 15, 2026
561b4b1
Fix: Carrier eligibility validation and QRY033 false positive
DJGosnell Mar 15, 2026
ed33660
Feat: Add ChainRoot interception and fix chain analysis regression
DJGosnell Mar 15, 2026
0f303e0
Fix: AnalyzabilityChecker and UsageSiteDiscovery for method-based acc…
DJGosnell Mar 15, 2026
89cf518
Feat: Add IEntityAccessor<T> slim interface and EntityAccessor<T> struct
DJGosnell Mar 16, 2026
7dc0f97
WIP: IEntityAccessor unification — partial (136 build errors remain)
DJGosnell Mar 16, 2026
e7503f2
WIP: IEntityAccessor — 0 build errors, runtime failures remain
DJGosnell Mar 16, 2026
4488471
Feat: Complete IEntityAccessor unification — 0 build errors, 2 pre-ex…
DJGosnell Mar 16, 2026
ba63ddc
Fix: IEntityAccessor Unsafe.As crash in standalone interceptors
DJGosnell Mar 16, 2026
163972d
Feat: Delete/Update/All transitions, WHERE TRUE elision, ExecuteScala…
DJGosnell Mar 16, 2026
a1664d8
Feat: Add QueryPlan API, remove CS9144 suppression, document IParamet…
DJGosnell Mar 16, 2026
0c858c5
Feat: Add ExecutionMode enum and 7 WithCommand carrier execution methods
DJGosnell Mar 16, 2026
8d9518e
Feat: Add IsSensitive, IsEnum, EnumUnderlyingType to chain parameter …
DJGosnell Mar 16, 2026
9ce7b21
Feat: Add CarrierStaticField model, emit static FieldInfo caches on c…
DJGosnell Mar 16, 2026
998c286
Feat: Terminal inline parameter binding — eliminate param array alloc…
DJGosnell Mar 16, 2026
79d599f
Feat: Remap FieldInfo caches to carrier class, skip interceptor-class…
DJGosnell Mar 16, 2026
a27aded
Feat: Remove old array-based carrier executor methods and helpers
DJGosnell Mar 16, 2026
fd714ce
Fix: Only emit static FieldInfo cache for captured parameters
DJGosnell Mar 16, 2026
8ffcb86
Fix: Inline __timeout variable and fix UPDATE parameter offset for co…
DJGosnell Mar 17, 2026
40d7829
Refactor: Unify Delete/Update Where interceptors and extract shared m…
DJGosnell Mar 17, 2026
4b8d93d
Feat: Add carrier optimization for Insert operations
DJGosnell Mar 17, 2026
772e6e3
Fix: Include execution sites in chain member tracking and add carrier…
DJGosnell Mar 17, 2026
5ed6d24
Fix: Address PR review items for carrier optimization
DJGosnell Mar 17, 2026
212b984
Fix: Reject 3+ param lambdas in two-entity join translator to enable …
DJGosnell Mar 17, 2026
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
6 changes: 3 additions & 3 deletions src/Quarry.Benchmarks/Benchmarks/AggregateBenchmarks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public async Task<int> EfCore_Count()
[Benchmark]
public async Task<int> Quarry_Count()
{
return await QuarryDb.Users
return await QuarryDb.Users()
.Select(u => Sql.Count())
.ExecuteScalarAsync<int>();
}
Expand Down Expand Up @@ -64,7 +64,7 @@ public async Task<decimal> EfCore_Sum()
[Benchmark]
public async Task<decimal> Quarry_Sum()
{
return await QuarryDb.Orders
return await QuarryDb.Orders()
.Select(o => Sql.Sum(o.Total))
.ExecuteScalarAsync<decimal>();
}
Expand Down Expand Up @@ -95,7 +95,7 @@ public async Task<decimal> EfCore_Avg()
[Benchmark]
public async Task<decimal> Quarry_Avg()
{
return await QuarryDb.Orders
return await QuarryDb.Orders()
.Select(o => Sql.Avg(o.Total))
.ExecuteScalarAsync<decimal>();
}
Expand Down
4 changes: 2 additions & 2 deletions src/Quarry.Benchmarks/Benchmarks/ComplexQueryBenchmarks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ public async Task<List<UserOrderDto>> EfCore_JoinFilterPaginate()
[Benchmark]
public async Task<List<UserOrderDto>> Quarry_JoinFilterPaginate()
{
return await QuarryDb.Users
return await QuarryDb.Users()
.Where(u => u.IsActive)
.Join<Order>((u, o) => u.UserId == o.UserId.Id)
.Select((u, o) => new UserOrderDto
Expand Down Expand Up @@ -116,7 +116,7 @@ public async Task<int> EfCore_MultiJoinAggregate()
[Benchmark]
public async Task<int> Quarry_MultiJoinAggregate()
{
var results = await QuarryDb.Users
var results = await QuarryDb.Users()
.Where(u => u.IsActive)
.Join<Order>((u, o) => u.UserId == o.UserId.Id)
.Join<OrderItem>((u, o, oi) => o.OrderId == oi.OrderId.Id)
Expand Down
6 changes: 3 additions & 3 deletions src/Quarry.Benchmarks/Benchmarks/FilterBenchmarks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public async Task<List<EfUser>> EfCore_WhereActive()
[Benchmark]
public async Task<List<EfUser>> Quarry_WhereActive()
{
return await QuarryDb.Users
return await QuarryDb.Users()
.Where(u => u.IsActive)
.Select(u => new EfUser
{
Expand Down Expand Up @@ -109,7 +109,7 @@ public async Task<List<UserSummaryDto>> EfCore_WhereCompound()
[Benchmark]
public async Task<List<UserSummaryDto>> Quarry_WhereCompound()
{
return await QuarryDb.Users
return await QuarryDb.Users()
.Where(u => u.IsActive)
.Where(u => u.Email != null)
.Select(u => new UserSummaryDto
Expand Down Expand Up @@ -163,7 +163,7 @@ public async Task<List<UserSummaryDto>> Quarry_WhereCompound()
[Benchmark]
public async Task<EfUser?> Quarry_WhereById()
{
return await QuarryDb.Users
return await QuarryDb.Users()
.Where(u => u.UserId == 42)
.Select(u => new EfUser
{
Expand Down
4 changes: 2 additions & 2 deletions src/Quarry.Benchmarks/Benchmarks/InsertBenchmarks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ public async Task<int> EfCore_SingleInsert()
[Benchmark]
public async Task<int> Quarry_SingleInsert()
{
return await QuarryDb.Insert(new User
return await QuarryDb.Users().Insert(new User
{
UserName = "BenchUser",
Email = "bench@example.com",
Expand Down Expand Up @@ -134,6 +134,6 @@ public async Task<int> Quarry_BatchInsert10()
IsActive = true,
CreatedAt = DateTime.UtcNow
});
return await QuarryDb.InsertMany(users).ExecuteNonQueryAsync();
return await QuarryDb.Users().InsertMany(users).ExecuteNonQueryAsync();
}
}
4 changes: 2 additions & 2 deletions src/Quarry.Benchmarks/Benchmarks/JoinBenchmarks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ public async Task<List<UserOrderDto>> EfCore_InnerJoin()
[Benchmark]
public async Task<List<UserOrderDto>> Quarry_InnerJoin()
{
return await QuarryDb.Users
return await QuarryDb.Users()
.Join<Order>((u, o) => u.UserId == o.UserId.Id)
.Select((u, o) => new UserOrderDto
{
Expand Down Expand Up @@ -112,7 +112,7 @@ public async Task<List<UserOrderItemDto>> EfCore_ThreeTableJoin()
[Benchmark]
public async Task<List<UserOrderItemDto>> Quarry_ThreeTableJoin()
{
return await QuarryDb.Users
return await QuarryDb.Users()
.Join<Order>((u, o) => u.UserId == o.UserId.Id)
.Join<OrderItem>((u, o, oi) => o.OrderId == oi.OrderId.Id)
.Select((u, o, oi) => new UserOrderItemDto
Expand Down
4 changes: 2 additions & 2 deletions src/Quarry.Benchmarks/Benchmarks/PaginationBenchmarks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public async Task<List<EfUser>> EfCore_LimitOffset()
[Benchmark]
public async Task<List<EfUser>> Quarry_LimitOffset()
{
return await QuarryDb.Users
return await QuarryDb.Users()
.Select(u => new EfUser
{
UserId = u.UserId,
Expand Down Expand Up @@ -108,7 +108,7 @@ public async Task<List<EfUser>> EfCore_FirstPage()
[Benchmark]
public async Task<List<EfUser>> Quarry_FirstPage()
{
return await QuarryDb.Users
return await QuarryDb.Users()
.Select(u => new EfUser
{
UserId = u.UserId,
Expand Down
4 changes: 2 additions & 2 deletions src/Quarry.Benchmarks/Benchmarks/SelectBenchmarks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public async Task<List<EfUser>> EfCore_SelectAll()
[Benchmark]
public async Task<List<EfUser>> Quarry_SelectAll()
{
return await QuarryDb.Users
return await QuarryDb.Users()
.Select(u => new EfUser
{
UserId = u.UserId,
Expand Down Expand Up @@ -105,7 +105,7 @@ public async Task<List<UserSummaryDto>> EfCore_SelectProjection()
[Benchmark]
public async Task<List<UserSummaryDto>> Quarry_SelectProjection()
{
return await QuarryDb.Users
return await QuarryDb.Users()
.Select(u => new UserSummaryDto
{
UserId = u.UserId,
Expand Down
4 changes: 2 additions & 2 deletions src/Quarry.Benchmarks/Benchmarks/StringOpBenchmarks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ public async Task<List<UserSummaryDto>> EfCore_Contains()
[Benchmark]
public async Task<List<UserSummaryDto>> Quarry_Contains()
{
return await QuarryDb.Users
return await QuarryDb.Users()
.Where(u => u.UserName.Contains("User05"))
.Select(u => new UserSummaryDto
{
Expand Down Expand Up @@ -109,7 +109,7 @@ public async Task<List<UserSummaryDto>> EfCore_StartsWith()
[Benchmark]
public async Task<List<UserSummaryDto>> Quarry_StartsWith()
{
return await QuarryDb.Users
return await QuarryDb.Users()
.Where(u => u.UserName.StartsWith("User0"))
.Select(u => new UserSummaryDto
{
Expand Down
6 changes: 3 additions & 3 deletions src/Quarry.Benchmarks/Context/BenchDb.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ namespace Quarry.Benchmarks.Context;
[QuarryContext(Dialect = SqlDialect.SQLite)]
public partial class BenchDb : QuarryContext
{
public partial IQueryBuilder<User> Users { get; }
public partial IQueryBuilder<Order> Orders { get; }
public partial IQueryBuilder<OrderItem> OrderItems { get; }
public partial IEntityAccessor<User> Users();
public partial IEntityAccessor<Order> Orders();
public partial IEntityAccessor<OrderItem> OrderItems();
}
15 changes: 15 additions & 0 deletions src/Quarry.Generator/DiagnosticDescriptors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,21 @@ internal static class DiagnosticDescriptors
"The existing runtime SqlBuilder path will be used. This is not an error — " +
"consider restructuring the query to enable optimization.");

/// <summary>
/// QRY033: Forked query chain — builder variable consumed by multiple execution paths.
/// Severity: Error
/// </summary>
public static readonly DiagnosticDescriptor ForkedQueryChain = new(
id: "QRY033",
title: "Forked query chain",
messageFormat: "Query builder variable '{0}' is consumed by multiple execution paths. Each execution path must use its own builder chain expression.",
category: Category,
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true,
description: "A query builder variable is used as the receiver for multiple execution-terminating calls " +
"(e.g., ExecuteFetchAllAsync). Each execution path must use its own independent builder chain " +
"to avoid confusing aliasing behavior from the immutable builder contract.");

// ─── Migration diagnostics (QRY050–QRY055) ────────────────────────

/// <summary>
Expand Down
167 changes: 167 additions & 0 deletions src/Quarry.Generator/Generation/CarrierClassBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
using System.Collections.Generic;
using System.Linq;
using Quarry.Generators.Models;

namespace Quarry.Generators.Generation;

/// <summary>
/// Builds <see cref="CarrierClassInfo"/> from a <see cref="PrebuiltChainInfo"/>.
/// The carrier class is a lightweight <c>file sealed class</c> that inherits from
/// a carrier base class and declares only chain-specific fields.
/// </summary>
internal static class CarrierClassBuilder
{
/// <summary>
/// Builds a CarrierClassInfo for a carrier-eligible PrebuiltDispatch chain.
/// Returns null if the chain cannot be carrier-optimized.
/// </summary>
public static CarrierClassInfo? Build(PrebuiltChainInfo chain, int chainIndex, string? resolvedBaseClass = null)
{
if (!chain.IsCarrierEligible)
return null;

var fields = new List<CarrierField>();

// Fields: typed parameters P0, P1, ...
foreach (var param in chain.ChainParameters)
{
fields.Add(new CarrierField($"P{param.Index}", NormalizeFieldType(param.TypeName), FieldRole.Parameter));
}

// Field: Mask (if chain has conditional clauses)
if (chain.Analysis.ConditionalClauses.Count > 0)
{
var bitCount = chain.Analysis.ConditionalClauses.Count;
var maskType = bitCount <= 8 ? "byte" : bitCount <= 16 ? "ushort" : "uint";
fields.Add(new CarrierField("Mask", maskType, FieldRole.ClauseMask));
}

// Fields: Limit/Offset (if chain has runtime pagination values)
foreach (var clause in chain.Analysis.Clauses)
{
if (clause.Role == ClauseRole.Limit)
fields.Add(new CarrierField("Limit", "int", FieldRole.Limit));
if (clause.Role == ClauseRole.Offset)
fields.Add(new CarrierField("Offset", "int", FieldRole.Offset));
}

// Field: Timeout (if chain contains WithTimeout)
if (chain.Analysis.Clauses.Any(c => c.Role == ClauseRole.WithTimeout))
fields.Add(new CarrierField("Timeout", "TimeSpan?", FieldRole.Timeout));

// Field: Entity (for insert chains — stores the entity passed to .Insert())
if (chain.QueryKind == QueryKind.Insert)
{
var entityType = InterceptorCodeGenerator.GetShortTypeName(chain.EntityTypeName);
fields.Add(new CarrierField("Entity", entityType + "?", FieldRole.Entity));
}

// Static FieldInfo cache fields — only for captured params needing expression tree extraction
var staticFields = new List<CarrierStaticField>();
foreach (var param in chain.ChainParameters)
{
if (param.NeedsFieldInfoCache)
staticFields.Add(new CarrierStaticField($"F{param.Index}", "FieldInfo?", param.Index));
}

// Determine base class from chain shape (caller may provide pre-resolved base)
var baseClassName = resolvedBaseClass ?? SelectBaseClass(chain);

var className = $"Chain_{chainIndex}";

return new CarrierClassInfo(
className: className,
implementedInterfaces: new[] { baseClassName },
fields: fields,
deadMethods: System.Array.Empty<CarrierInterfaceStub>(),
staticFields: staticFields);
}

/// <summary>
/// Selects the appropriate carrier base class based on the chain's shape
/// (join count, whether it has a Select projection).
/// </summary>
private static string SelectBaseClass(PrebuiltChainInfo chain)
{
var entityType = InterceptorCodeGenerator.GetShortTypeName(chain.EntityTypeName);
var hasSelect = chain.Analysis.Clauses.Any(c => c.Role == ClauseRole.Select);
var joinCount = chain.IsJoinChain ? (chain.JoinedEntityTypeNames?.Count ?? 1) - 1 : 0;

if (joinCount == 0)
{
if (hasSelect && chain.ResultTypeName != null)
{
var resultType = InterceptorCodeGenerator.GetShortTypeName(chain.ResultTypeName);
return $"CarrierBase<{entityType}, {resultType}>";
}
return $"CarrierBase<{entityType}>";
}

var joinedTypes = chain.JoinedEntityTypeNames!.Select(InterceptorCodeGenerator.GetShortTypeName).ToArray();
var joinedTypesStr = string.Join(", ", joinedTypes);

if (hasSelect && chain.ResultTypeName != null)
{
var resultType = InterceptorCodeGenerator.GetShortTypeName(chain.ResultTypeName);
return joinCount switch
{
1 => $"JoinedCarrierBase<{joinedTypesStr}, {resultType}>",
2 => $"JoinedCarrierBase3<{joinedTypesStr}, {resultType}>",
3 => $"JoinedCarrierBase4<{joinedTypesStr}, {resultType}>",
_ => $"JoinedCarrierBase<{joinedTypesStr}, {resultType}>"
};
}

return joinCount switch
{
1 => $"JoinedCarrierBase<{joinedTypesStr}>",
2 => $"JoinedCarrierBase3<{joinedTypesStr}>",
3 => $"JoinedCarrierBase4<{joinedTypesStr}>",
_ => $"JoinedCarrierBase<{joinedTypesStr}>"
};
}

/// <summary>
/// Known value types that don't need nullable annotation.
/// </summary>
private static readonly HashSet<string> ValueTypes = new(System.StringComparer.Ordinal)
{
"int", "long", "short", "byte", "sbyte", "uint", "ulong", "ushort",
"float", "double", "decimal", "bool", "char",
"DateTime", "DateTimeOffset", "TimeSpan", "Guid", "DateOnly", "TimeOnly",
"Int32", "Int64", "Int16", "Byte", "SByte", "UInt32", "UInt64", "UInt16",
"Single", "Double", "Decimal", "Boolean", "Char"
};

/// <summary>
/// Normalizes a parameter type for carrier field emission.
/// - Normalizes <c>Nullable&lt;T&gt;</c> to <c>T?</c>
/// - Appends <c>?</c> to reference types (non-value-types without existing <c>?</c>)
/// to suppress nullable warnings in <c>#nullable enable</c> context
/// </summary>
private static string NormalizeFieldType(string typeName)
{
// Normalize Nullable<T> → T?
if (typeName.StartsWith("System.Nullable<") || typeName.StartsWith("Nullable<"))
{
var inner = typeName.Substring(typeName.IndexOf('<') + 1).TrimEnd('>');
return inner + "?";
}

// Already nullable — pass through
if (typeName.EndsWith("?"))
return typeName;

// Value types don't need ?
if (ValueTypes.Contains(typeName))
return typeName;

// Enum types (usually PascalCase without dots) — assume value type, pass through
// Generic types, array types — pass through (complex to analyze)
if (typeName.Contains('<') || typeName.Contains('[') || typeName.Contains('.'))
return typeName;

// Reference types (string, class names) — append ? for nullable context
return typeName + "?";
}
}
Loading
Loading