Skip to content

Route every SQL text scanner through SqlLexer #276

Description

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:

  • :328replaceParameterPlaceholders(sql) { "NULL" }
  • :172replaceParameterPlaceholders(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

  1. 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.
  2. replaceParameterPlaceholdersWithSentinels, findClosingQuote, SAFE_IDENTIFIER,
    needsQuoting, and SQL_UNQUOTED_IDENTIFIER no longer exist.
  3. All tests in "Test design" exist and pass.
  4. ./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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions