Skip to content

Commit f99e201

Browse files
committed
fix(generator): remediate review findings for #259
Review items addressed inline: - #3 (B, Correctness): `CheckRowEntityMaterializability` now also rejects abstract classes and interfaces. CS0144 would otherwise fire against the generated `new T()` with no indication of which row type was at fault. - #5 (B, Tests): adds a nested-row test driving the struct-reader fallback by using an expression SELECT list, exercising `SanitizeForIdentifier` and the FQN-in-`IRowReader<T>` path. - #6 (B, Tests): strengthens the namespace-level-row regression to assert that `using TestApp.Rows;` IS emitted. - #7 (B, Tests): adds a QRY044 test where `build_property.InterceptorsNamespaces` is entirely absent (null), confirming the diagnostic still fires. - Plus QRY043 tests for abstract and interface row types. Test totals: 3256 passing (+4 from Phase 5 green baseline).
1 parent 20e3335 commit f99e201

5 files changed

Lines changed: 271 additions & 1 deletion

File tree

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# Review: #259
2+
3+
## Classifications
4+
5+
| # | Section | Finding (one line) | Sev | Rec | Class | Action Taken |
6+
|---|---------|---------------------|-----|-----|-------|--------------|
7+
| 1 | Plan Compliance | `RawCallSite.EntityNamespace` field from plan was not added; Phase 2 filters by `RawSqlTypeInfo.IsNestedType` instead | Info | D | D | Dismissed: functionally equivalent outcome. |
8+
| 2 | Plan Compliance | Analyzer not registered "alongside `QuarryQueryAnalyzer`"; ships as a standalone `[DiagnosticAnalyzer]` class | Info | D | D | Dismissed: Roslyn discovery works identically. |
9+
| 3 | Correctness | `CheckRowEntityMaterializability` does not reject abstract classes or interfaces used as `T` | Minor | B | B | |
10+
| 4 | Correctness | QRY043 suppression covers both the interceptor and the struct emission (positive observation) | Info | D | D | Positive observation, no action. |
11+
| 5 | Test Quality | No test for nested row type taking the struct-reader fallback branch | Minor | B | B | |
12+
| 6 | Test Quality | Namespace-level-row regression does not assert the `using Rows;` directive is emitted | Minor | B | B | |
13+
| 7 | Test Quality | QRY044 with `build_property.InterceptorsNamespaces` explicitly null is not directly tested | Minor | B | B | |
14+
| 8 | Codebase Consistency | QRY044 uses `Category = "QuarryAnalyzer"` while neighboring QRY042 uses `"QuarryMigration"` | Info | D | D | Defensible: analyzer-emitted, not migration-related. |
15+
| 9 | Integration | Nested-type FQN emission uses Roslyn `global::`-prefixed names (positive observation) | Info | D | D | Positive observation, no action. |
16+
17+
## Plan Compliance
18+
19+
The plan specified adding an `EntityNamespace` (string?) field to `RawCallSite` and wiring `FileEmitter` to consume that directly (plan.md lines 82, 116-121). The actual Phase 2 implementation skipped that field — `RawCallSite` only gained `MaterializabilityError` (`src/Quarry.Generator/IR/RawCallSite.cs:237-240`). Instead, `FileEmitter.Emit()` (`src/Quarry.Generator/CodeGen/FileEmitter.cs:92-97`) filters out nested sites by `s.RawSqlTypeInfo?.IsNestedType != true` and still runs the old `GetNamespaceFromTypeName` path. Functionally equivalent, but it drifts from the plan's "resolve the actual namespace from the Roslyn symbol and store it" approach.
20+
21+
Plan also specified registering the analyzer alongside `QuarryQueryAnalyzer` (plan.md line 234); actual impl ships `InterceptorsNamespacesAnalyzer` as an independent `[DiagnosticAnalyzer]` class (`src/Quarry.Analyzers/InterceptorsNamespacesAnalyzer.cs:20-21`). Roslyn discovery works on attribute, so this is benign. It is placed at the assembly root (next to `QuarryQueryAnalyzer.cs`, `RawSqlMigrationAnalyzer.cs`) rather than under `Rules/` — consistent with how the other top-level analyzer classes are organized.
22+
23+
| Finding | Severity | Why It Matters |
24+
|---|---|---|
25+
| `RawCallSite.EntityNamespace` field from plan was not added; Phase 2 instead filters by `RawSqlTypeInfo.IsNestedType` in `FileEmitter.cs:92-97` | Info | Plan drift. Outcome is equivalent for the RawSql path, but a future maintainer reading plan.md will not find the promised field. |
26+
| Analyzer not registered "alongside `QuarryQueryAnalyzer`"; ships as standalone class with `[DiagnosticAnalyzer]` | Info | Works identically for consumers; discrepancy with plan text only. |
27+
28+
## Correctness
29+
30+
- `CheckRowEntityMaterializability` (`DisplayClassEnricher.cs:281-314`): the struct branch skips the constructor check. Safe — C# requires a public parameterless constructor on a struct regardless of declared visibility (the implicit one always exists as public). `typeArgSymbol.TypeKind == TypeKind.TypeParameter || TypeKind.Error` is filtered out earlier at line 211, so generics/errors cannot reach this method. However, the check does not reject abstract classes or interfaces — if a user were able to pass an abstract class as `T`, `CheckRowEntityMaterializability` would see a public parameterless ctor and approve, and `new T()` would then fail downstream with `CS0144`. Unlikely in practice because `RawSqlAsync<T>` is generic without a `new()` constraint but also without special handling.
31+
- `RawSqlTypeInfo.Equals`: includes all new fields. `GetHashCode` (`RawSqlTypeInfo.cs:99`) omits `FullyQualifiedResultTypeName`, which is acceptable since it is derived-correlated with `ResultTypeName` and including it would not materially change distribution.
32+
- `FileEmitter.SanitizeForIdentifier` (`FileEmitter.cs:888-901`): for inputs beginning with `global::`, strips the prefix, then replaces every non-alphanumeric/underscore. For the nested-type FQNs produced by `ResolveRawSqlTypeInfo` the first character is always a letter (the root namespace), so leading-digit identifiers are not possible in practice. Empty input returns the original empty string unchanged (early return at 890-891); but callers only reach this with a non-empty `ResultTypeName`, so this is not exercisable.
33+
- `InterceptorsNamespacesAnalyzer.HasQuarryContextAttributeSyntactic` (`InterceptorsNamespacesAnalyzer.cs:72-89`): matches `QuarryContext`/`QuarryContextAttribute`, walking `QualifiedNameSyntax` to its `Right`. Catches `[QuarryContext]`, `[Quarry.QuarryContext]`, `[global::Quarry.QuarryContext]`. It does NOT guard against attributes with `AliasQualifiedNameSyntax` (e.g., `using Foo = Quarry.QuarryContextAttribute; [Foo]`) — rare but the semantic-level check at line 44 catches it. Assembly-level `[assembly: QuarryContext]` is irrelevant here because the analyzer only registers for `SyntaxKind.ClassDeclaration`.
34+
- QRY043 suppression (`FileEmitter.cs:505-510`): suppresses the interceptor method itself. Struct emission is also gated by `site.MaterializabilityError == null` at `FileEmitter.cs:246`, so the `file struct IRowReader<T>` is not emitted either. Consistent.
35+
- `PipelineOrchestrator.CollectTranslatedDiagnostics` (`PipelineOrchestrator.cs:136-145`): reports QRY043 using `raw.ResultTypeName ?? raw.EntityTypeName`. For RawSql sites `resultTypeName == entityTypeName` (`UsageSiteDiscovery.cs:3989-3990`), so both render the same FQN.
36+
37+
| Finding | Severity | Why It Matters |
38+
|---|---|---|
39+
| `CheckRowEntityMaterializability` does not reject abstract classes or interfaces used as `T` | Minor | Unlikely in practice (most users supply concrete DTOs), but if it occurs the user will see a downstream CS0144 against generated code rather than a clear QRY043. |
40+
| QRY043 suppression covers both the interceptor and the struct emission (`FileEmitter.cs:246` and `:508-510`) | Info | Positive observation — no "half-emitted" state. |
41+
42+
## Security
43+
44+
No new runtime surface. `build_property.InterceptorsNamespaces` is read by the analyzer and echoed back into the compiler-time warning message as the literal `<InterceptorsNamespaces>$(InterceptorsNamespaces);{1}</InterceptorsNamespaces>` template (`AnalyzerDiagnosticDescriptors.cs:226-228`); only the namespace symbol name (not the raw property value) is interpolated, so malformed MSBuild values cannot escape into a misleading message.
45+
46+
No concerns.
47+
48+
## Test Quality
49+
50+
Phase 1/2/4 tests cover the intended happy-path and failure-mode triggers:
51+
- QRY043: positional record (`RawSqlGeneratorPipelineTests.cs:381-431`), init-only (`:433-479`), plain-class negative (`:481-524`). Severity and message contents are asserted.
52+
- Nested row type: positive compile + FQN emission (`:256-316`); regression confirming namespace-level types still use short name + `using` (`:318-375`).
53+
- QRY044: opted-in, missing, global namespace, multiple-contexts-mixed, non-context (`InterceptorsNamespacesAnalyzerTests.cs:50-151`).
54+
55+
Gaps:
56+
- Nested row type + struct-reader fallback is not tested: the nested test SQL `"SELECT Id, Name FROM users"` resolves via `RawSqlColumnResolver` and emits a static lambda; `SanitizeForIdentifier` and the `file struct RawSqlReader_TestApp_Host_NestedRow_0` path never run. A test with an unresolvable SQL (e.g. `"SELECT id*2 FROM users"`) on a nested row type would close this.
57+
- The "namespace-level row still uses short name" assertion (`RawSqlGeneratorPipelineTests.cs:371-374`) checks for `new UserRow()` and absence of `global::TestApp.Rows.UserRow`. Correct for the happy-path but it does not verify that a `using TestApp.Rows;` directive is emitted. A grep for the `using` would complete the regression guarantee.
58+
- QRY044 when `build_property.InterceptorsNamespaces` is entirely absent (null, not empty) combined with a `[QuarryContext]` class is not tested. Production code at `InterceptorsNamespacesAnalyzer.cs:57-62` treats null identically to empty, which is correct — but a test asserting "null + context in `MyApp.Data` → QRY044" would pin the behavior.
59+
60+
| Finding | Severity | Why It Matters |
61+
|---|---|---|
62+
| No test for nested row type taking the struct-reader fallback branch | Minor | `SanitizeForIdentifier` and `EmitRowReaderStruct` with an FQN `ResultTypeName` are untested; a regression could ship silently. |
63+
| Namespace-level-row regression does not assert the `using Rows;` directive is emitted | Minor | Currently confirms the short name is used but not that the namespace is imported; a bug that forgot the `using` would still pass this test. |
64+
| QRY044 with `build_property.InterceptorsNamespaces` explicitly null is not directly tested | Minor | Covered indirectly (null and empty converge at line 57-62), but an explicit test would pin the contract. |
65+
66+
## Codebase Consistency
67+
68+
- `CheckRowEntityMaterializability` is a new method; no earlier equivalent in the repo (DTO projection validation at the chain level lives in `ProjectionAnalyzer`, which looks at projection lambdas, not row-type materializability). No reusable utility existed to share.
69+
- `RawSqlTypeInfo.IsNestedType`/`FullyQualifiedResultTypeName` are used only by the RawSql emitter — correct scoping. Other emitters (`ClauseBodyEmitter`, `JoinBodyEmitter`, etc.) work off `EntityTypeName` from `TranslatedCallSite`, which is already FQN-capable via `ToFullyQualifiedDisplayString()`. No lurking inconsistency.
70+
- `DiagnosticDescriptors.RowEntityNotMaterializable` (`DiagnosticDescriptors.cs:564-576`) matches the style of the existing QRY0xx entries: `id`, `title`, `messageFormat` with argument placeholders, `category = "Quarry"`, severity, and a description that restates the remediation. Placement in a grouped region (`// ─── RawSql row-shape diagnostics (QRY043) ───`) matches the file's organization convention.
71+
- `AnalyzerDiagnosticDescriptors.InterceptorsNamespaceMissing` (`AnalyzerDiagnosticDescriptors.cs:223-232`) is placed under the `Category = "QuarryAnalyzer"` (same as the QRA rules) rather than the `MigrationCategory = "QuarryMigration"` used for QRY042. That categorization is defensible (analyzer-emitted, not migration-related) but inconsistent with QRY042, the immediate neighbor — both are QRY-prefixed in the analyzer assembly and could reasonably share a category.
72+
73+
| Finding | Severity | Why It Matters |
74+
|---|---|---|
75+
| QRY044 uses `Category = "QuarryAnalyzer"` while neighboring QRY042 uses `"QuarryMigration"` | Info | Both live in `Quarry.Analyzers` but fall under different IDE rule-set groupings. Users filtering by category may miss one. |
76+
77+
## Integration / Breaking Changes
78+
79+
- `RawSqlTypeInfo` ctor: both new parameters (`isNestedType`, `fullyQualifiedResultTypeName`) are optional with defaults. All existing call sites compile unchanged. Verified in `PatchWithColumnMetadata` (`DisplayClassEnricher.cs:361-369`) and the scalar/DTO callers in `UsageSiteDiscovery.cs:4029-4036, 4087-4093` — all pass the new args explicitly.
80+
- `RawCallSite`: added mutable `MaterializabilityError` property. Excluded from `Equals`/`GetHashCode` (`RawCallSite.cs:237-240` comment confirms), consistent with other mutable enrichment fields like `RawSqlTypeInfo` and `DisplayClassName`. All three `With*` copy methods propagate it (`RawCallSite.cs:306, 371, 439`).
81+
- `Quarry.targets`: uses `<InterceptorsNamespaces>$(InterceptorsNamespaces);Quarry.Generated</InterceptorsNamespaces>` (`src/Quarry/build/Quarry.targets:12`). NuGet imports package `.targets` *after* the consuming project's `<PropertyGroup>`, so a consumer that already sets `<InterceptorsNamespaces>MyApp.Data</InterceptorsNamespaces>` ends up with `MyApp.Data;Quarry.Generated`. No override risk. Existing sample/test projects all use the `$(InterceptorsNamespaces);` prefix pattern (`src/Samples/*/*.csproj`, `src/Quarry.Tests/Quarry.Tests.csproj:11`), so everything composes.
82+
- QRY044 severity is `Warning` on a condition that would otherwise surface as CS9137 error. This does not clutter output — the CS9137 would fire once per namespace per build; QRY044 fires once per context class per build (typically 1-2). QRY044 is also emitted earlier (IDE authoring time) rather than only at compile time, which is the explicit design goal.
83+
- `Quarry.Generator.props` adds `InterceptorsNamespaces` as a `CompilerVisibleProperty` (`build/Quarry.Generator.props:7`). Existing consumers of this props file (all Quarry consumers, since `Quarry.Generator` is a PackageReference) see the property surface to analyzers with no action required.
84+
85+
| Finding | Severity | Why It Matters |
86+
|---|---|---|
87+
| Nested-type FQN emission assumes the FQN includes `global::` prefix (`RawSqlBodyEmitter.cs:57, 59`). `typeSymbol.ToFullyQualifiedDisplayString()` in Roslyn returns `global::`-prefixed names, so `new global::Outer.Row()` is emitted for nested types — legal C# syntax. | Info | Positive observation — no parsing issues. |
88+
89+
## Issues Created
90+
91+
(Leave empty — to be filled during classification.)

_sessions/259-docs-dx-friction-points/workflow.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ remote: https://github.com/Dtronix/Quarry.git
66
base-branch: master
77

88
## State
9-
phase: REVIEW
9+
phase: REMEDIATE
1010
status: active
1111
issue: #259
1212
pr:

src/Quarry.Analyzers.Tests/InterceptorsNamespacesAnalyzerTests.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,27 @@ public partial class AppDbB : QuarryContext
134134
Assert.That(diagnostics[0].GetMessage(), Does.Contain("MyApp.DataB"));
135135
}
136136

137+
[Test]
138+
public async Task EmitsQRY044_WhenInterceptorsNamespacesPropertyIsAbsent()
139+
{
140+
// Pass null (rather than an empty string) so TryGetValue returns false —
141+
// simulates a consumer project that hasn't set <InterceptorsNamespaces> at all.
142+
var source = @"
143+
using Quarry;
144+
145+
namespace MyApp.Data
146+
{
147+
[QuarryContext(Dialect = SqlDialect.SQLite)]
148+
public partial class AppDb : QuarryContext
149+
{
150+
}
151+
}";
152+
153+
var diagnostics = await GetDiagnosticsAsync(source, interceptorsNamespaces: null);
154+
Assert.That(diagnostics, Has.Length.EqualTo(1));
155+
Assert.That(diagnostics[0].GetMessage(), Does.Contain("MyApp.Data"));
156+
}
157+
137158
[Test]
138159
public async Task NoDiagnostic_ForNonContextClass()
139160
{

src/Quarry.Generator/Parsing/DisplayClassEnricher.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,13 @@ private static void EnrichRawSqlTypeInfo(
280280
/// </summary>
281281
private static string? CheckRowEntityMaterializability(INamedTypeSymbol type)
282282
{
283+
// Interfaces and abstract classes cannot be instantiated via `new T()`; CS0144
284+
// would fire against the generated code with no hint of which row type is at fault.
285+
if (type.TypeKind == TypeKind.Interface)
286+
return "type is an interface and cannot be instantiated via 'new T()'";
287+
if (type.IsAbstract)
288+
return "type is abstract and cannot be instantiated via 'new T()'";
289+
283290
// Structs always have an implicit parameterless constructor.
284291
var isStruct = type.TypeKind == TypeKind.Struct;
285292
if (!isStruct)

0 commit comments

Comments
 (0)