Why
generator/src/main/kotlin/norm/generator/SqlLexer.kt owns skipLexicalToken, the one function that
knows how to step over a string literal, E'' escape string, quoted identifier, dollar-quoted string,
line comment, and block comment. Four other places re-implement subsets of that by hand, each with a
different gap:
| Place |
What it hand-rolls |
Known gap |
SqlPlaceholders.kt:20-77 replaceParameterPlaceholders |
quote/''/--//* */ scanner |
no dollar quotes (KDoc admits it), no quoted identifiers, no E'' |
SqlPlaceholders.kt:90-148 replaceParameterPlaceholdersWithSentinels |
same scanner, copy-pasted |
same |
QueryFileParser.kt:175-257 convertNamedParameters, findClosingQuote, isIdentifierStart/Part |
quote/''/-- scanner |
no block comments, no quoted identifiers, no dollar quotes; the mixed-style guard require('?' !in sql) at :227 scans the raw text, so 'really?' plus a :name parameter is rejected as "mixing styles" |
SqlParameterInferrer.kt:366-375 unquoteIdentifier |
strip quotes, collapse "" |
duplicates unescapeQuotedIdentifier + truncateIdentifier from SqlIdentifiers.kt |
JdbcAnalyzer.kt:394-410 buildIdentifierQuoter/SAFE_IDENTIFIER/needsQuoting and TypeRepository.kt:964-989 SQL_UNQUOTED_IDENTIFIER/quoteSqlIdentifierIfNeeded |
identifier quoting rule |
two copies; TypeRepository.kt:955-963 KDoc admits it is a copy |
Target state
A. SqlPlaceholders.kt
One function:
/**
* Replaces each `?` parameter placeholder in [sql] with [replacement] of its 0-based parameter index.
* A `?` inside a string literal, quoted identifier, dollar-quoted string, or comment is left alone.
*/
internal fun replaceParameterPlaceholders(sql: String, replacement: (parameterIndex: Int) -> String): String {
if ('?' !in sql) return sql
val result = StringBuilder(sql.length + 16)
var index = 0
var parameterIndex = 0
while (index < sql.length) {
val afterToken = skipLexicalToken(sql, index)
if (afterToken != index) {
result.append(sql, index, afterToken)
index = afterToken
continue
}
if (sql[index] == '?') result.append(replacement(parameterIndex++)) else result.append(sql[index])
index++
}
return result.toString()
}
Callers in ColumnNullabilityAnalyzer.kt:
:328 → replaceParameterPlaceholders(sql) { "NULL" }
:172 → replaceParameterPlaceholders(sql) { sentinels.getOrElse(it) { "NULL" } }
Delete replaceParameterPlaceholdersWithSentinels. nonNullSentinel is unchanged. Remove the
"Dollar-quoted string literals are not handled" note from the KDoc; it is no longer true.
B. QueryFileParser.convertNamedParameters
Keep the algorithm shape (single pass, :: passthrough, :name → ?) but replace the three
hand-written skip cases with one skipLexicalToken call at the top of the loop, exactly as
replaceParameterPlaceholders above does. Delete findClosingQuote, isIdentifierStart,
isIdentifierPart.
Keep the parameter-name character class as ASCII [A-Za-z_][A-Za-z0-9_]*. Widening it to
isIdentifierChar would change which SQL is accepted; that is a feature decision, not part of this
refactor. Replace the two private predicates with a small private Regex or inline checks; either is
fine as long as the class is unchanged.
Fix the mixed-style guard: instead of require('?' !in sql) over raw text, count ? characters
encountered outside lexical tokens during the same pass (increment a counter in the else branch
when sql[index] == '?'). Throw the same IllegalArgumentException message when that counter is
non-zero and at least one :name was converted. This is a bug fix bundled here because the old check
cannot be expressed once the scanner is lexer-aware.
C. SqlParameterInferrer.unquoteIdentifier
private fun unquoteIdentifier(identifier: String): String =
truncateIdentifier(if (isQuotedIdentifier(identifier)) unescapeQuotedIdentifier(identifier) else identifier)
No case folding is added; today it does not fold and changing that is out of scope.
D. One identifier-quoting rule
Move quoteSqlIdentifierIfNeeded(identifier: String, reservedWords: Set<String>): String and its
regex into SqlIdentifiers.kt as internal. JdbcAnalyzer.buildIdentifierQuoter becomes:
public fun buildIdentifierQuoter(): (String) -> String {
val reservedWords = fetchReservedWords()
return { identifier -> quoteSqlIdentifierIfNeeded(identifier, reservedWords) }
}
Delete SAFE_IDENTIFIER, needsQuoting, and TypeRepository's private copies. Equivalence: the
JdbcAnalyzer version tests identifier.lowercase() in reservedWords; the TypeRepository version
tests identifier in reservedWords but only after the regex [a-z_][a-z0-9_$]* matched, which
already guarantees lowercase. Any identifier with an uppercase letter fails the regex in both versions
and is quoted regardless. Same output for every input.
Test design (write first)
SqlPlaceholdersTest.kt: rewrite the two nested groups against the single function. Add cases:
? inside $$...$$ and $tag$...$tag$ untouched; ? inside "quoted?identifier" untouched; ?
inside E'a\'?b' untouched; replacement receives indices 0, 1, 2 in order for three placeholders;
fewer sentinels than placeholders falls back per the lambda.
QueryFileParserTest.kt: add cases: :name inside a block comment is not converted; :name
inside a quoted identifier "a:b" is not converted; :name inside $$...$$ is not converted;
WHERE note = 'really?' AND id = :id parses (the old false-positive mixed-style error); ? and
:id genuinely mixed still throws. Existing tests stay.
SqlParameterInferrerTest.kt: existing quoted-identifier tests ("a""b") pin C.
SqlIdentifiersTest.kt: add direct tests for quoteSqlIdentifierIfNeeded: author bare;
order (reserved) quoted; Foo quoted; a"b → "a""b"; my$col bare. The four existing
JdbcAnalyzerTest.buildIdentifierQuoter ... tests stay and pin the live-reserved-word path.
Acceptance criteria
- Outside
SqlLexer.kt, no function in generator/src/main inspects ', ", $, --, or /*
to decide where a token ends. git grep -n "== '\\\\''" generator/src/main should hit only
SqlLexer.kt.
replaceParameterPlaceholdersWithSentinels, findClosingQuote, SAFE_IDENTIFIER,
needsQuoting, and SQL_UNQUOTED_IDENTIFIER no longer exist.
- All tests in "Test design" exist and pass.
./gradlew :generator:check :gradle-plugin:test passes; golden regeneration leaves
test-scenarios* unchanged.
Files
generator/src/main/kotlin/norm/generator/SqlPlaceholders.kt
generator/src/main/kotlin/norm/generator/QueryFileParser.kt
generator/src/main/kotlin/norm/generator/SqlParameterInferrer.kt
generator/src/main/kotlin/norm/generator/SqlIdentifiers.kt
generator/src/main/kotlin/norm/generator/JdbcAnalyzer.kt
generator/src/main/kotlin/norm/generator/TypeRepository.kt
generator/src/main/kotlin/norm/generator/ColumnNullabilityAnalyzer.kt (two call sites)
- tests listed above
Conventions every issue inherits
- Repo root:
/Volumes/Code/3rd-party/norm. Module under change is almost always generator/.
- Style: 2-space indent, 120 columns, full words in identifiers (
parameter, not param), no
section-separator comments, @Nested classes group tests. Format with ./gradlew spotlessApply.
- TDD: tests are written first or alongside. For a behavior-preserving refactor the existing suite is
the pin; each issue says which tests must also be added.
- Golden files under
test-scenarios*/ are never hand-edited. A refactor is behavior-preserving only
if ./gradlew :gradle-plugin:generateGoldenFiles leaves git status --porcelain test-scenarios test-scenarios-frameworks empty.
- Verification for a
generator/ change: ./gradlew :generator:check :gradle-plugin:test (Docker
required). Then the golden regeneration check above.
- Commit message explains the design decision, not the diff.
- Never run
./gradlew clean or disable the configuration/build cache to "fix" a build problem.
Why
generator/src/main/kotlin/norm/generator/SqlLexer.ktownsskipLexicalToken, the one function thatknows how to step over a string literal,
E''escape string, quoted identifier, dollar-quoted string,line comment, and block comment. Four other places re-implement subsets of that by hand, each with a
different gap:
SqlPlaceholders.kt:20-77replaceParameterPlaceholders''/--//* */scannerE''SqlPlaceholders.kt:90-148replaceParameterPlaceholdersWithSentinelsQueryFileParser.kt:175-257convertNamedParameters,findClosingQuote,isIdentifierStart/Part''/--scannerrequire('?' !in sql)at:227scans the raw text, so'really?'plus a:nameparameter is rejected as "mixing styles"SqlParameterInferrer.kt:366-375unquoteIdentifier""unescapeQuotedIdentifier+truncateIdentifierfromSqlIdentifiers.ktJdbcAnalyzer.kt:394-410buildIdentifierQuoter/SAFE_IDENTIFIER/needsQuotingandTypeRepository.kt:964-989SQL_UNQUOTED_IDENTIFIER/quoteSqlIdentifierIfNeededTypeRepository.kt:955-963KDoc admits it is a copyTarget state
A.
SqlPlaceholders.ktOne function:
Callers in
ColumnNullabilityAnalyzer.kt::328→replaceParameterPlaceholders(sql) { "NULL" }:172→replaceParameterPlaceholders(sql) { sentinels.getOrElse(it) { "NULL" } }Delete
replaceParameterPlaceholdersWithSentinels.nonNullSentinelis unchanged. Remove the"Dollar-quoted string literals are not handled" note from the KDoc; it is no longer true.
B.
QueryFileParser.convertNamedParametersKeep the algorithm shape (single pass,
::passthrough,:name→?) but replace the threehand-written skip cases with one
skipLexicalTokencall at the top of the loop, exactly asreplaceParameterPlaceholdersabove does. DeletefindClosingQuote,isIdentifierStart,isIdentifierPart.Keep the parameter-name character class as ASCII
[A-Za-z_][A-Za-z0-9_]*. Widening it toisIdentifierCharwould change which SQL is accepted; that is a feature decision, not part of thisrefactor. Replace the two private predicates with a small private
Regexor inline checks; either isfine as long as the class is unchanged.
Fix the mixed-style guard: instead of
require('?' !in sql)over raw text, count?charactersencountered outside lexical tokens during the same pass (increment a counter in the
elsebranchwhen
sql[index] == '?'). Throw the sameIllegalArgumentExceptionmessage when that counter isnon-zero and at least one
:namewas converted. This is a bug fix bundled here because the old checkcannot be expressed once the scanner is lexer-aware.
C.
SqlParameterInferrer.unquoteIdentifierNo case folding is added; today it does not fold and changing that is out of scope.
D. One identifier-quoting rule
Move
quoteSqlIdentifierIfNeeded(identifier: String, reservedWords: Set<String>): Stringand itsregex into
SqlIdentifiers.ktasinternal.JdbcAnalyzer.buildIdentifierQuoterbecomes:Delete
SAFE_IDENTIFIER,needsQuoting, andTypeRepository's private copies. Equivalence: theJdbcAnalyzerversion testsidentifier.lowercase() in reservedWords; theTypeRepositoryversiontests
identifier in reservedWordsbut only after the regex[a-z_][a-z0-9_$]*matched, whichalready guarantees lowercase. Any identifier with an uppercase letter fails the regex in both versions
and is quoted regardless. Same output for every input.
Test design (write first)
SqlPlaceholdersTest.kt: rewrite the two nested groups against the single function. Add cases:?inside$$...$$and$tag$...$tag$untouched;?inside"quoted?identifier"untouched;?inside
E'a\'?b'untouched; replacement receives indices0, 1, 2in order for three placeholders;fewer sentinels than placeholders falls back per the lambda.
QueryFileParserTest.kt: add cases::nameinside a block comment is not converted;:nameinside a quoted identifier
"a:b"is not converted;:nameinside$$...$$is not converted;WHERE note = 'really?' AND id = :idparses (the old false-positive mixed-style error);?and:idgenuinely mixed still throws. Existing tests stay.SqlParameterInferrerTest.kt: existing quoted-identifier tests ("a""b") pin C.SqlIdentifiersTest.kt: add direct tests forquoteSqlIdentifierIfNeeded:authorbare;order(reserved) quoted;Fooquoted;a"b→"a""b";my$colbare. The four existingJdbcAnalyzerTest.buildIdentifierQuoter ...tests stay and pin the live-reserved-word path.Acceptance criteria
SqlLexer.kt, no function ingenerator/src/maininspects',",$,--, or/*to decide where a token ends.
git grep -n "== '\\\\''" generator/src/mainshould hit onlySqlLexer.kt.replaceParameterPlaceholdersWithSentinels,findClosingQuote,SAFE_IDENTIFIER,needsQuoting, andSQL_UNQUOTED_IDENTIFIERno longer exist../gradlew :generator:check :gradle-plugin:testpasses; golden regeneration leavestest-scenarios*unchanged.Files
generator/src/main/kotlin/norm/generator/SqlPlaceholders.ktgenerator/src/main/kotlin/norm/generator/QueryFileParser.ktgenerator/src/main/kotlin/norm/generator/SqlParameterInferrer.ktgenerator/src/main/kotlin/norm/generator/SqlIdentifiers.ktgenerator/src/main/kotlin/norm/generator/JdbcAnalyzer.ktgenerator/src/main/kotlin/norm/generator/TypeRepository.ktgenerator/src/main/kotlin/norm/generator/ColumnNullabilityAnalyzer.kt(two call sites)Conventions every issue inherits
/Volumes/Code/3rd-party/norm. Module under change is almost alwaysgenerator/.parameter, notparam), nosection-separator comments,
@Nestedclasses group tests. Format with./gradlew spotlessApply.the pin; each issue says which tests must also be added.
test-scenarios*/are never hand-edited. A refactor is behavior-preserving onlyif
./gradlew :gradle-plugin:generateGoldenFilesleavesgit status --porcelain test-scenarios test-scenarios-frameworksempty.generator/change:./gradlew :generator:check :gradle-plugin:test(Dockerrequired). Then the golden regeneration check above.
./gradlew cleanor disable the configuration/build cache to "fix" a build problem.