Skip to content
Merged
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
4 changes: 2 additions & 2 deletions docs/articles/analyzer-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ No additional configuration is required. All QRA rules are enabled by default an

| Code | Severity | Description |
|---|---|---|
| QRY001 | Warning | Query chain not fully analyzable |
| QRY001 | Error | Query chain not fully analyzable — the site gets no interceptor and the call throws InvalidOperationException at runtime |
| QRY002 | Error | Missing `Table` property on schema |
| QRY003 | Error | Invalid column type |
| QRY004 | Error | Unknown navigation entity |
Expand All @@ -50,7 +50,7 @@ No additional configuration is required. All QRA rules are enabled by default an
| QRY014 | Error | Anonymous type unsupported in this context |
| QRY015 | Warning | Ambiguous context resolution |
| QRY016 | Error | Unbound parameter |
| QRY019 | Warning | Clause not translatable. The message format is `"<clause-context>. The original runtime method will be used instead."` — the clause-context substitution is supplied complete by the call-site translator, so contributors adding new translator error messages must not include trailing punctuation in the substituted text |
| QRY019 | Error | Clause not translatable. The message format is `"<clause-context>. The clause is not intercepted and the call will throw InvalidOperationException at runtime."` — the clause-context substitution is supplied complete by the call-site translator, so contributors adding new translator error messages must not include trailing punctuation in the substituted text |

### Subquery (QRY020--QRY025)

Expand Down
2 changes: 1 addition & 1 deletion docs/articles/schema-definition.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,4 +289,4 @@ Use it in the schema like any other mapping:
public Col<JsonDoc> Metadata => Mapped<JsonDoc, JsonDocMapping>();
```

Both `GetSqlTypeName` and `ConfigureParameter` are called by the runtime `TypeMappingRegistry` on the fallback path. On the compile-time interceptor path, the generator inlines the `ToDb`/`FromDb` calls directly, but parameter configuration is still applied when the mapping implements `IDialectAwareTypeMapping`.
`GetSqlTypeName` is consulted at generation time for DDL and CAST expressions. The generator inlines the `ToDb`/`FromDb` calls directly into interceptor code, and the generated parameter binding calls `ConfigureParameter` when the mapping implements `IDialectAwareTypeMapping`.
12 changes: 10 additions & 2 deletions src/Quarry.Generator/CodeGen/FileEmitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ internal sealed class FileEmitter
private readonly IReadOnlyList<TranslatedCallSite> _sites;
private readonly IReadOnlyList<AssembledPlan>? _chains;
private readonly IReadOnlyList<CarrierPlan>? _carrierPlans;
private readonly bool _emitTraceComments;
private readonly List<Models.DiagnosticInfo> _emitDiagnostics = new();
private readonly CarrierAssignmentRecorder _carrierAssignmentRecorder = new();

Expand Down Expand Up @@ -83,14 +84,21 @@ public FileEmitter(
string fileTag,
IReadOnlyList<TranslatedCallSite> sites,
IReadOnlyList<AssembledPlan>? chains = null,
IReadOnlyList<CarrierPlan>? carrierPlans = null)
IReadOnlyList<CarrierPlan>? carrierPlans = null,
bool emitTraceComments = false)
{
_contextClassName = contextClassName;
_contextNamespace = contextNamespace;
_fileTag = fileTag;
_sites = sites;
_chains = chains;
_carrierPlans = carrierPlans;
// AssembledPlan.TraceLines is populated for every traced chain regardless of
// the QUARRY_TRACE symbol (the orchestrator has no compilation access), so the
// symbol gate is applied here instead of by mutating the cached plan. Default
// is false (fail safe): only the QuarryGenerator caller, which has the
// compilation to check the symbol, opts in.
_emitTraceComments = emitTraceComments;
}

/// <summary>
Expand Down Expand Up @@ -461,7 +469,7 @@ public string Emit()
var label = chain.IsOperandChain
? $"Operand: {execMethod} at line {chain.ExecutionSite.Line}"
: $"Chain: {execMethod} at line {chain.ExecutionSite.Line}";
chainGroups.Add((label, chainSites, chain.TraceLines));
chainGroups.Add((label, chainSites, _emitTraceComments ? chain.TraceLines : null));
}
}
}
Expand Down
18 changes: 11 additions & 7 deletions src/Quarry.Generator/DiagnosticDescriptors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,20 @@ internal static class DiagnosticDescriptors

/// <summary>
/// QRY001: Query not fully analyzable.
/// Severity: Warning
/// Severity: Error — under the carrier-only model a non-analyzable site gets no
/// interceptor and the builder throw stub fails at runtime, so this is a broken
/// query, not a degraded one (#311).
/// </summary>
public static readonly DiagnosticDescriptor QueryNotAnalyzable = new(
id: "QRY001",
title: "Query not fully analyzable",
messageFormat: "Query is not fully analyzable: {0}",
category: Category,
defaultSeverity: DiagnosticSeverity.Warning,
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true,
description: "The query chain contains patterns that prevent compile-time analysis. " +
"The original runtime method will be used instead. " +
"Calls on this chain are not intercepted, and builder methods are compile-time-only " +
"stubs, so executing the query will throw InvalidOperationException at runtime. " +
"Consider restructuring the query as a fluent chain without variable assignment or conditionals.");

/// <summary>
Expand Down Expand Up @@ -336,7 +339,7 @@ internal static class DiagnosticDescriptors
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true,
description: "Two or more TypeMapping classes map the same TCustom type. " +
"The runtime TypeMappingRegistry allows only one mapping per custom type. " +
"The generator cannot decide which mapping to inline for the type's columns. " +
"Remove the duplicate mapping or consolidate into a single TypeMapping class.");

/// <summary>
Expand All @@ -348,12 +351,13 @@ internal static class DiagnosticDescriptors
title: "Clause not translatable at compile time",
// {0} must be a complete clause without trailing punctuation; see
// CallSiteTranslator errorMessage callsites.
messageFormat: "{0}. The original runtime method will be used instead.",
messageFormat: "{0}. The clause is not intercepted and the call will throw InvalidOperationException at runtime.",
category: Category,
defaultSeverity: DiagnosticSeverity.Warning,
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true,
description: "The source generator could not translate this clause expression to SQL. " +
"The query will use the original runtime method, which evaluates the expression tree at runtime. " +
"The clause interceptor is skipped, and builder methods are compile-time-only stubs, " +
"so executing this query will throw InvalidOperationException at runtime. " +
"Consider restructuring the expression for compile-time analysis.");

// ─── Custom EntityReader diagnostics (QRY026–QRY027) ────────────────────
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,9 @@ internal static string GetRelativePath(string filePath)

/// <summary>
/// Checks if a clause interceptor should be skipped because the clause could not be translated.
/// When skipped, the original runtime method runs instead of a silent no-op fallback.
/// When skipped, the call falls through to the default-interface throw stub
/// (IEntityAccessor) — a loud InvalidOperationException at runtime instead of a
/// silent no-op; QRY019 warns about this at compile time.
/// </summary>
internal static bool ShouldSkipNonTranslatableClause(TranslatedCallSite site)
{
Expand Down
77 changes: 77 additions & 0 deletions src/Quarry.Generator/IR/BindStageResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
using System;

namespace Quarry.Generators.IR;

/// <summary>
/// Output of the Stage 3 bind transform: either a successfully bound call site or a
/// bind failure. Bind exceptions produce no <see cref="BoundCallSite"/> to attach an
/// error to, so failures travel as first-class pipeline values — a dedicated output
/// node collects them and reports QRY900. This replaced the [ThreadStatic]
/// PipelineErrorBag side-channel (#311), which was thread-affine and whose entries
/// were drained-and-discarded before reporting.
/// </summary>
internal sealed class BindStageResult : IEquatable<BindStageResult>
{
public BindStageResult(BoundCallSite site)
{
// A both-null instance would pass neither downstream filter and vanish from
// the pipeline — the exact failure shape this type exists to eliminate.
Site = site ?? throw new ArgumentNullException(nameof(site));
}

public BindStageResult(BindFailure failure)
{
Failure = failure ?? throw new ArgumentNullException(nameof(failure));
}

public BoundCallSite? Site { get; }
public BindFailure? Failure { get; }

public bool Equals(BindStageResult? other)
{
if (other is null) return false;
if (ReferenceEquals(this, other)) return true;
return Equals(Site, other.Site) && Equals(Failure, other.Failure);
}

public override bool Equals(object? obj) => Equals(obj as BindStageResult);

public override int GetHashCode()
=> Site?.GetHashCode() ?? Failure?.GetHashCode() ?? 0;
}

/// <summary>
/// A Stage 3 bind exception, carrying enough location detail to report QRY900 at the
/// failing call site. Equality includes the message so an error-state change
/// invalidates the incremental cache (mirrors TranslatedCallSite.PipelineError).
/// </summary>
internal sealed class BindFailure : IEquatable<BindFailure>
{
public BindFailure(string filePath, int line, int column, string message)
{
FilePath = filePath;
Line = line;
Column = column;
Message = message;
}

public string FilePath { get; }
public int Line { get; }
public int Column { get; }
public string Message { get; }

public bool Equals(BindFailure? other)
{
if (other is null) return false;
if (ReferenceEquals(this, other)) return true;
return FilePath == other.FilePath
&& Line == other.Line
&& Column == other.Column
&& Message == other.Message;
}

public override bool Equals(object? obj) => Equals(obj as BindFailure);

public override int GetHashCode()
=> HashCode.Combine(FilePath, Line, Column, Message);
}
14 changes: 14 additions & 0 deletions src/Quarry.Generator/IR/CallSiteBinder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ namespace Quarry.Generators.IR;
/// </summary>
internal static class CallSiteBinder
{
/// <summary>
/// Test hook: when non-null, Bind() throws for call sites whose MethodName matches
/// ("*" matches every site), simulating an internal binder defect. Set from test
/// code before running the generator (same pattern as
/// <see cref="Parsing.ChainAnalyzer.TestCapturedChains"/>) to exercise the QRY900
/// bind-failure reporting path end-to-end — "*" produces the group-less shape where
/// a file has no surviving sites and therefore no FileInterceptorGroup.
/// </summary>
[System.ThreadStatic]
internal static string? TestThrowOnMethodName;

/// <summary>
/// Binds a raw call site against the entity registry to produce bound call sites.
/// Returns one element for most sites; may return multiple for navigation joins
Expand All @@ -25,6 +36,9 @@ public static ImmutableArray<BoundCallSite> Bind(
{
ct.ThrowIfCancellationRequested();

if (TestThrowOnMethodName != null && (TestThrowOnMethodName == "*" || TestThrowOnMethodName == raw.MethodName))
throw new System.InvalidOperationException($"Test-forced bind failure for '{raw.MethodName}'");

// Resolve entity from registry with ambiguity detection
var entry = registry.Resolve(raw.EntityTypeName, raw.ContextClassName, out var isAmbiguous);

Expand Down
14 changes: 8 additions & 6 deletions src/Quarry.Generator/IR/CallSiteTranslator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,15 +100,17 @@ public static TranslatedCallSite Translate(
}
catch (Exception ex)
{
// Translation failed — produce a failed clause so QRY019 is emitted.
TraceCapture.Log(raw.UniqueId, $"Translation failed: {ex.GetType().Name}: {ex.Message}");
// Translation failed — produce a failed clause so QRY019 is emitted. No
// TraceCapture log here: this transform is per-site cached, so a ThreadStatic
// line would be lost on warm runs anyway (#311); the failure text reaches the
// trace via Clause.ErrorMessage in ChainAnalyzer's retroactive LogSiteTrace.
var clauseKind = raw.ClauseKind ?? ClauseKind.Where;
var failedClause = new TranslatedClause(
clauseKind,
new LiteralExpr("1", "int"),
Array.Empty<Translation.ParameterInfo>(),
isSuccess: false,
// No trailing punctuation: QRY019 messageFormat appends ". The original runtime method...".
// No trailing punctuation: QRY019 messageFormat appends ". The clause is not intercepted...".
errorMessage: $"{clauseKind} clause translation failed: {ex.Message}");
return new TranslatedCallSite(bound, failedClause);
}
Expand All @@ -128,7 +130,7 @@ private static TranslatedCallSite TranslateClause(BoundCallSite bound, EntityReg
new LiteralExpr("1", "int"),
Array.Empty<Translation.ParameterInfo>(),
isSuccess: false,
// No trailing punctuation: QRY019 messageFormat appends ". The original runtime method...".
// No trailing punctuation: QRY019 messageFormat appends ". The clause is not intercepted...".
errorMessage: $"{raw.ClauseKind ?? ClauseKind.Where} clause contains an expression that cannot be translated to SQL");
return new TranslatedCallSite(bound, failedClause);
}
Expand All @@ -151,7 +153,7 @@ private static TranslatedCallSite TranslateClause(BoundCallSite bound, EntityReg
new LiteralExpr("1", "int"),
Array.Empty<Translation.ParameterInfo>(),
isSuccess: false,
// No trailing punctuation: QRY019 messageFormat appends ". The original runtime method...".
// No trailing punctuation: QRY019 messageFormat appends ". The clause is not intercepted...".
errorMessage: $"{clauseKind} clause could not resolve entity metadata for column binding");
return new TranslatedCallSite(bound, failedClause);
}
Expand Down Expand Up @@ -254,7 +256,7 @@ private static TranslatedCallSite TranslateClause(BoundCallSite bound, EntityReg
new LiteralExpr("1", "int"),
Array.Empty<Translation.ParameterInfo>(),
isSuccess: false,
// No trailing punctuation: QRY019 messageFormat appends ". The original runtime method...".
// No trailing punctuation: QRY019 messageFormat appends ". The clause is not intercepted...".
errorMessage: $"{clauseKind} clause rendered to empty SQL");
return new TranslatedCallSite(bound, failedClause);
}
Expand Down
43 changes: 0 additions & 43 deletions src/Quarry.Generator/IR/PipelineErrorBag.cs

This file was deleted.

Loading
Loading