Skip to content

Commit f6c0f5e

Browse files
committed
remediate: address review findings (#273)
(A) findings — addressed in code: - Doc comments on `LikeExpr.NeedsEscape` and `EscapeLikeMetaChars` updated to flag dialect-aware render-time escaping (#1) - New `MaybeDoubleBackslashes` helper: prefix/suffix in non-folded LIKE concat path now defensively backslash-doubled for MySQL+default (#7) - Inline comments at both literal-pattern emission sites explain the single-quote-first / backslash-double-second order (#9) - `SqlDialectConfig.ParseAttribute` returns `(Config, Schema)` tuple in a single pass; `ContextParser` consumes the tuple (#28) - Code comment in `ParseAttribute` documents the SQLite default for missing `Dialect=` as intentional pre-existing behavior (#11) - All 11 method bodies in `SqlAssembler.cs` now uniformly use the `var dialect = config.Dialect;` shadow at entry (#25) - Extracted `IsDockerUnavailable`, `TableExistsAsync`, `ExecAsync` from the two MySQL test containers into shared `TestContainerHelpers.cs` (#26) - Added `<remarks>` footgun warning to the back-compat `Render(SqlDialect)` overload — silently defaults the carrier flag (#33) - Updated `MySqlBackslashEscapes` XML doc to make non-MySQL no-op behavior explicit; future QRY rule noted as follow-up (#37) (B) findings — added gap-filling tests: - `Contains_ParameterBound_BackslashEscapesTrue_NoDoubling` proves the parameter-bound LIKE path bypasses doubling (#20) - `Where_Contains_LiteralBackslash_DefaultMode_ReturnsMatch` integration test exercises the seeded-but-unqueried `"a\b"` row through the full pipeline against live default-mode MySQL (#21) - `Where_Contains_AnsiForm_NoBackslashEscapesSession_ReturnsMatch` integration test exercises the opt-out roundtrip with new `MyAnsiSessionDb` context against default-mode container with session-level `SET sql_mode = ...,NO_BACKSLASH_ESCAPES` (#2) All 3,384 tests pass.
1 parent 2c6c116 commit f6c0f5e

18 files changed

Lines changed: 433 additions & 140 deletions

_sessions/273-sql-dialect-config/review.md

Lines changed: 114 additions & 0 deletions
Large diffs are not rendered by default.

_sessions/273-sql-dialect-config/workflow.md

Lines changed: 2 additions & 2 deletions
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: IMPLEMENT
9+
phase: REMEDIATE
1010
status: active
1111
issue: #273
1212
pr:
@@ -56,4 +56,4 @@ Leave `NO_BACKSLASH_ESCAPES` in the main `MySqlTestContainer.cs` `--sql-mode` ar
5656
## Session Log
5757
| # | Phase Start | Phase End | Summary |
5858
|---|------------|-----------|---------|
59-
| 1 | INTAKE | IMPLEMENT | 2026-04-29: Loaded issue #273. Worktree/branch created. Baseline 3364 tests green. DESIGN: 7 decisions recorded (3.B mode-aware emit, refactor+fix scope, internal sealed record, MySqlBackslashEscapes default true, attribute-only-MySqlBackslashEscapes-this-PR, keep test mitigation+add focused container, generator-internal carrier). PLAN: 4 phases written and approved. |
59+
| 1 | INTAKE | REVIEW | 2026-04-29: Loaded issue #273. Worktree/branch created. Baseline 3364 tests green. DESIGN: 7 decisions recorded (3.B mode-aware emit, refactor+fix scope, internal sealed record, MySqlBackslashEscapes default true, attribute-only-MySqlBackslashEscapes-this-PR, keep test mitigation+add focused container, generator-internal carrier). PLAN: 4 phases written and approved. IMPLEMENT: 4 phases committed. Phase 1 introduced SqlDialectConfig carrier (3364 tests green). Phase 2 added MySqlBackslashEscapes attribute+carrier flag (3372 tests). Phase 3 threaded SqlDialectConfig through SqlExprRenderer + SqlAssembler and branched LIKE emit on the flag (3378 tests). Phase 4 added default-mode container + 3 integration regression tests (3381 tests). |

src/Quarry.Generator/IR/SqlAssembler.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1010,11 +1010,13 @@ SqlDialect.SQLite or SqlDialect.PostgreSQL
10101010

10111011
private static void AppendTableRef(StringBuilder sb, SqlDialectConfig config, TableRef table)
10121012
{
1013-
sb.Append(SqlFormatting.FormatTableName(config.Dialect, table.TableName, table.SchemaName));
1013+
var dialect = config.Dialect;
1014+
sb.Append(SqlFormatting.FormatTableName(dialect, table.TableName, table.SchemaName));
10141015
}
10151016

10161017
private static void AppendSelectColumns(StringBuilder sb, SqlDialectConfig config, IReadOnlyList<ProjectedColumn> columns, int paramOffset = 0)
10171018
{
1019+
var dialect = config.Dialect;
10181020
for (int i = 0; i < columns.Count; i++)
10191021
{
10201022
if (i > 0) sb.Append(", ");
@@ -1025,7 +1027,7 @@ private static void AppendSelectColumns(StringBuilder sb, SqlDialectConfig confi
10251027
if (col.IsAggregateFunction && !string.IsNullOrEmpty(col.Alias))
10261028
{
10271029
sb.Append(" AS ");
1028-
sb.Append(SqlFormatting.QuoteIdentifier(config.Dialect, col.Alias!));
1030+
sb.Append(SqlFormatting.QuoteIdentifier(dialect, col.Alias!));
10291031
}
10301032
}
10311033
}

src/Quarry.Generator/IR/SqlExprNodes.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,13 @@ internal sealed class LikeExpr : SqlExpr
377377
public string? LikePrefix { get; }
378378
/// <summary>Suffix for LIKE pattern (e.g., "%" for Contains).</summary>
379379
public string? LikeSuffix { get; }
380-
/// <summary>Whether the pattern needs ESCAPE '\'.</summary>
380+
/// <summary>
381+
/// Whether the pattern needs an ESCAPE clause. Set at parse time when the
382+
/// literal pattern contained LIKE metacharacters that <see cref="Translation.SqlLikeHelpers.EscapeLikeMetaChars"/>
383+
/// escaped. The actual ESCAPE character emitted is dialect-aware: ANSI single
384+
/// backslash (`'\'`) by default, doubled (`'\\'`) when the renderer's
385+
/// <c>SqlDialectConfig</c> targets MySQL with <c>MySqlBackslashEscapes=true</c>.
386+
/// </summary>
381387
public bool NeedsEscape { get; }
382388

383389
public LikeExpr(SqlExpr operand, SqlExpr pattern, bool isNegated = false,

src/Quarry.Generator/IR/SqlExprRenderer.cs

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,17 @@ public static string Render(SqlExpr expr, SqlDialectConfig config, int parameter
3333
/// default <see cref="SqlDialectConfig"/> and forwards. Callers that don't yet have a
3434
/// <see cref="SqlDialectConfig"/> can keep passing the bare enum.
3535
/// </summary>
36+
/// <remarks>
37+
/// <b>Footgun:</b> the wrapper defaults <c>MySqlBackslashEscapes</c> to <c>true</c>
38+
/// (matching the attribute default and stock MySQL). Calling this overload with
39+
/// <see cref="SqlDialect.MySQL"/> will silently emit doubled-backslash LIKE patterns —
40+
/// which is correct for stock MySQL but wrong for servers configured with
41+
/// <c>NO_BACKSLASH_ESCAPES</c>. Prefer the
42+
/// <see cref="Render(SqlExpr, SqlDialectConfig, int, bool, bool)"/> overload and pass
43+
/// the per-context <c>SqlDialectConfig</c> from <c>BoundCallSite.DialectConfig</c>.
44+
/// This overload exists for fragment-render paths that have no per-context dialect
45+
/// (e.g. <c>TranslatedClause.SqlFragment</c>, which always uses PG).
46+
/// </remarks>
3647
public static string Render(SqlExpr expr, SqlDialect dialect, int parameterBaseIndex = 0, bool useGenericParamFormat = false, bool stripOuterParens = false)
3748
=> Render(expr, new SqlDialectConfig(dialect), parameterBaseIndex, useGenericParamFormat, stripOuterParens);
3849

@@ -64,7 +75,9 @@ public static void RenderTo(StringBuilder sb, SqlExpr expr, SqlDialectConfig con
6475

6576
/// <summary>
6677
/// Backwards-compatible overload accepting a bare <see cref="SqlDialect"/>. Wraps it in a
67-
/// default <see cref="SqlDialectConfig"/> and forwards.
78+
/// default <see cref="SqlDialectConfig"/> and forwards. See the same-named
79+
/// <see cref="Render(SqlExpr, SqlDialect, int, bool, bool)"/> overload for the
80+
/// <c>MySqlBackslashEscapes</c> footgun warning that applies here too.
6881
/// </summary>
6982
public static void RenderTo(StringBuilder sb, SqlExpr expr, SqlDialect dialect, int parameterBaseIndex = 0, bool useGenericParamFormat = false, bool stripOuterParens = false)
7083
=> RenderTo(sb, expr, new SqlDialectConfig(dialect), parameterBaseIndex, useGenericParamFormat, stripOuterParens);
@@ -388,12 +401,16 @@ private static void RenderLike(LikeExpr like, SqlDialectConfig config, int param
388401
if ((hasPrefix || hasSuffix) && like.Pattern is LiteralExpr literalPattern
389402
&& literalPattern.ClrType == "string" && !literalPattern.IsNull)
390403
{
404+
// Order matters: single-quote escape FIRST (to '' pairs), then backslash
405+
// doubling. Reversing would let the backslash pass turn an emitted '' pair
406+
// into '\\' '' (no real input contains that today, but the order keeps the
407+
// pipeline correct under any future input).
391408
var escaped = literalPattern.SqlText.Replace("'", "''");
392409
if (doubleBackslashes) escaped = escaped.Replace("\\", "\\\\");
393410
sb.Append('\'');
394-
if (hasPrefix) sb.Append(like.LikePrefix);
411+
if (hasPrefix) sb.Append(MaybeDoubleBackslashes(like.LikePrefix!, doubleBackslashes));
395412
sb.Append(escaped);
396-
if (hasSuffix) sb.Append(like.LikeSuffix);
413+
if (hasSuffix) sb.Append(MaybeDoubleBackslashes(like.LikeSuffix!, doubleBackslashes));
397414
sb.Append('\'');
398415
}
399416
else if (!hasPrefix && !hasSuffix)
@@ -404,6 +421,7 @@ private static void RenderLike(LikeExpr like, SqlDialectConfig config, int param
404421
if (doubleBackslashes && like.Pattern is LiteralExpr bareLit
405422
&& bareLit.ClrType == "string" && !bareLit.IsNull)
406423
{
424+
// See comment above — same single-quote-first, backslash-double-second order.
407425
var bareEscaped = bareLit.SqlText.Replace("'", "''").Replace("\\", "\\\\");
408426
sb.Append('\'').Append(bareEscaped).Append('\'');
409427
}
@@ -415,13 +433,13 @@ private static void RenderLike(LikeExpr like, SqlDialectConfig config, int param
415433
else
416434
{
417435
var parts = new List<string>();
418-
if (hasPrefix) parts.Add($"'{like.LikePrefix}'");
436+
if (hasPrefix) parts.Add($"'{MaybeDoubleBackslashes(like.LikePrefix!, doubleBackslashes)}'");
419437

420438
var patternSb = new StringBuilder();
421439
RenderExpr(like.Pattern, config, paramBase, patternSb, genericParams);
422440
parts.Add(patternSb.ToString());
423441

424-
if (hasSuffix) parts.Add($"'{like.LikeSuffix}'");
442+
if (hasSuffix) parts.Add($"'{MaybeDoubleBackslashes(like.LikeSuffix!, doubleBackslashes)}'");
425443

426444
if (parts.Count == 1)
427445
{
@@ -450,6 +468,17 @@ private static void RenderLike(LikeExpr like, SqlDialectConfig config, int param
450468
}
451469
}
452470

471+
/// <summary>
472+
/// When <paramref name="doubleBackslashes"/> is <c>true</c>, doubles every backslash
473+
/// in <paramref name="text"/> so the SQL parser on default-mode MySQL collapses
474+
/// the doubled form back to a single literal backslash. No-op otherwise.
475+
/// Today's analyzer only emits LIKE prefix/suffix as <c>%</c>, so the doubling
476+
/// is a defensive guard for a future code path that puts a backslash in
477+
/// <see cref="LikeExpr.LikePrefix"/> or <see cref="LikeExpr.LikeSuffix"/>.
478+
/// </summary>
479+
private static string MaybeDoubleBackslashes(string text, bool doubleBackslashes)
480+
=> doubleBackslashes && text.IndexOf('\\') >= 0 ? text.Replace("\\", "\\\\") : text;
481+
453482
private static void RenderSubquery(SubqueryExpr sub, SqlDialectConfig config, int paramBase, StringBuilder sb, bool genericParams)
454483
{
455484
if (!sub.IsResolved)

src/Quarry.Generator/Parsing/ContextParser.cs

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -86,19 +86,8 @@ private static bool InheritsFromQuarryContext(INamedTypeSymbol classSymbol)
8686
if (attributeData == null)
8787
return null;
8888

89-
// Extract dialect configuration and Schema from attribute
90-
var dialectConfig = SqlDialectConfig.FromAttribute(attributeData);
91-
string? schema = null;
92-
93-
foreach (var namedArg in attributeData.NamedArguments)
94-
{
95-
switch (namedArg.Key)
96-
{
97-
case "Schema":
98-
schema = namedArg.Value.Value as string;
99-
break;
100-
}
101-
}
89+
// Extract dialect configuration and Schema from attribute (single pass)
90+
var (dialectConfig, schema) = SqlDialectConfig.ParseAttribute(attributeData);
10291

10392
// Discover entities via partial QueryBuilder<T> properties
10493
var (entities, mappings) = DiscoverEntities(classDeclaration, semanticModel, cancellationToken);

src/Quarry.Generator/Sql/SqlDialectConfig.cs

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,21 @@ internal sealed record SqlDialectConfig(
1212
SqlDialect Dialect,
1313
bool MySqlBackslashEscapes = true)
1414
{
15-
public static SqlDialectConfig FromAttribute(AttributeData attribute)
15+
/// <summary>
16+
/// Parses a <c>QuarryContextAttribute</c> into both a <see cref="SqlDialectConfig"/>
17+
/// (dialect + mode flags) and a <c>Schema</c> string. Single pass over
18+
/// <see cref="AttributeData.NamedArguments"/> so callers don't need to iterate twice.
19+
/// </summary>
20+
public static (SqlDialectConfig Config, string? Schema) ParseAttribute(AttributeData attribute)
1621
{
22+
// Default Dialect=SQLite when the attribute omits a Dialect= named arg.
23+
// Preserves pre-refactor ContextParser behavior (the same silent default).
24+
// A consumer-facing QRY diagnostic for missing Dialect= would be a clearer
25+
// ergonomic improvement — tracked as a follow-up; out of scope for #273
26+
// which is the carrier refactor + LIKE-emit fix.
1727
var dialect = SqlDialect.SQLite;
1828
var mysqlBackslashEscapes = true;
29+
string? schema = null;
1930

2031
foreach (var named in attribute.NamedArguments)
2132
{
@@ -29,9 +40,20 @@ public static SqlDialectConfig FromAttribute(AttributeData attribute)
2940
if (named.Value.Value is bool b)
3041
mysqlBackslashEscapes = b;
3142
break;
43+
case "Schema":
44+
schema = named.Value.Value as string;
45+
break;
3246
}
3347
}
3448

35-
return new SqlDialectConfig(dialect, mysqlBackslashEscapes);
49+
return (new SqlDialectConfig(dialect, mysqlBackslashEscapes), schema);
3650
}
51+
52+
/// <summary>
53+
/// Parses just the dialect-config portion of a <c>QuarryContextAttribute</c>.
54+
/// Convenience for tests; production code that also needs Schema should use
55+
/// <see cref="ParseAttribute(AttributeData)"/>.
56+
/// </summary>
57+
public static SqlDialectConfig FromAttribute(AttributeData attribute)
58+
=> ParseAttribute(attribute).Config;
3759
}

src/Quarry.Generator/Translation/SqlLikeHelpers.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ namespace Quarry.Generators.Translation;
1111
internal static class SqlLikeHelpers
1212
{
1313
/// <summary>
14-
/// Escapes LIKE metacharacters using backslash escaping (cross-dialect with ESCAPE '\').
14+
/// Escapes LIKE metacharacters (<c>%</c>, <c>_</c>, <c>\</c>) using ANSI single-backslash
15+
/// form. Runs at parse time before context binding, so the result is dialect-agnostic.
16+
/// The renderer (<see cref="IR.SqlExprRenderer"/>) applies dialect-aware doubling at
17+
/// emit time when targeting MySQL with <c>MySqlBackslashEscapes=true</c> — this layer
18+
/// always produces ANSI form (single backslash escapes).
1519
/// </summary>
1620
public static string EscapeLikeMetaChars(string value)
1721
{

src/Quarry.Tests/Integration/MySqlBackslashEscapesIntegrationTests.cs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using Quarry;
44
using Quarry.Tests.Samples;
55
using MyDefault = Quarry.Tests.Samples.MyDefault;
6+
using MyAnsi = Quarry.Tests.Samples.MyAnsi;
67

78
namespace Quarry.Tests.Integration;
89

@@ -50,6 +51,32 @@ public async Task Where_Contains_LiteralUnderscore_DefaultMode_ReturnsMatch()
5051
Assert.That(rows, Is.EquivalentTo(new[] { 1 }));
5152
}
5253

54+
/// <summary>
55+
/// The most subtle escape case: the user's literal contains an actual backslash.
56+
/// Pipeline: input <c>"a\b"</c> (1 backslash) → <c>EscapeLikeMetaChars</c> doubles to
57+
/// <c>"a\\b"</c> (2) → renderer doubles again for MySQL+default to <c>"a\\\\b"</c>
58+
/// (4 backslashes in SQL source) → MySQL parser collapses each <c>\\</c> to <c>\</c>,
59+
/// yielding the LIKE pattern <c>"a\\b"</c> (2 backslashes in the runtime pattern) →
60+
/// with <c>ESCAPE '\\'</c> (parsed: <c>\</c>), the LIKE evaluator interprets the second
61+
/// <c>\\</c> in the pattern as escape+<c>\</c> = literal <c>\</c>, matching <c>"a\b"</c>
62+
/// (1 literal backslash) in the column value.
63+
/// </summary>
64+
[Test]
65+
public async Task Where_Contains_LiteralBackslash_DefaultMode_ReturnsMatch()
66+
{
67+
var cs = await MySqlDefaultModeTestContainer.GetConnectionStringAsync();
68+
await using var conn = new MySqlConnection(cs);
69+
await conn.OpenAsync();
70+
await using var db = new MyDefault.MyDefaultDb(conn);
71+
72+
var rows = await db.Users()
73+
.Where(u => u.UserName.Contains("a\\b"))
74+
.Select(u => u.UserId)
75+
.ExecuteFetchAllAsync();
76+
77+
Assert.That(rows, Is.EquivalentTo(new[] { 3 }));
78+
}
79+
5380
[Test]
5481
public async Task Where_Contains_LiteralPercent_DefaultMode_ReturnsMatch()
5582
{
@@ -66,6 +93,44 @@ public async Task Where_Contains_LiteralPercent_DefaultMode_ReturnsMatch()
6693
Assert.That(rows, Is.EquivalentTo(new[] { 2 }));
6794
}
6895

96+
/// <summary>
97+
/// Opt-out roundtrip: <c>MyAnsiSessionDb</c> has <c>MySqlBackslashEscapes = false</c>,
98+
/// so the generator emits ANSI single-backslash form (<c>'%user\_name%' ESCAPE '\'</c>).
99+
/// We boot the default-mode container (stock sql_mode = backslash IS an escape),
100+
/// then explicitly flip the session's <c>sql_mode</c> to add
101+
/// <c>NO_BACKSLASH_ESCAPES</c> on this connection only. With the session-level
102+
/// override, the ANSI form parses correctly and matches the expected row.
103+
/// Proves both directions of the carrier flag → emit path work end-to-end.
104+
/// </summary>
105+
[Test]
106+
public async Task Where_Contains_AnsiForm_NoBackslashEscapesSession_ReturnsMatch()
107+
{
108+
var cs = await MySqlDefaultModeTestContainer.GetConnectionStringAsync();
109+
await using var conn = new MySqlConnection(cs);
110+
await conn.OpenAsync();
111+
112+
// Session-level override: switch THIS connection to NO_BACKSLASH_ESCAPES
113+
// sql_mode without affecting the container's server-wide mode (which other
114+
// tests in this fixture rely on for the default-mode behavior).
115+
await using (var setMode = conn.CreateCommand())
116+
{
117+
setMode.CommandText =
118+
"SET SESSION sql_mode = " +
119+
"'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE," +
120+
"ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION,NO_BACKSLASH_ESCAPES'";
121+
await setMode.ExecuteNonQueryAsync();
122+
}
123+
124+
await using var db = new MyAnsi.MyAnsiSessionDb(conn);
125+
126+
var rows = await db.Users()
127+
.Where(u => u.UserName.Contains("user_name"))
128+
.Select(u => u.UserId)
129+
.ExecuteFetchAllAsync();
130+
131+
Assert.That(rows, Is.EquivalentTo(new[] { 1 }));
132+
}
133+
69134
/// <summary>
70135
/// Sanity probe: under default sql_mode the broken-on-#273 emit shape
71136
/// (single ESCAPE backslash, ANSI form) actually fails with 1064.

0 commit comments

Comments
 (0)