From 0a9d3de50a9e4c170c80f1c6529631855c6f25f7 Mon Sep 17 00:00:00 2001 From: Marius Volkhart Date: Fri, 4 Sep 2026 11:08:53 -0400 Subject: [PATCH] docs: strip self-assessment from comments added since 437e6324 Comments merged over the last 113 commits accumulated a habit of reporting on the work rather than on the code: "verified live", "load-bearing", "Regression-shaped proof that", "MUTATION TESTED". That prose tells a reviewer the author was diligent. It tells the next maintainer nothing, and it buries the facts that are actually load bearing -- which PostgreSQL versions were checked, what the server returns, why a given query shape makes a bug bite. Every such phrase either carried a fact or it did not. Where it did, the fact stays and the self-assessment goes: "Verified live on PostgreSQL 16, 17, and 18: concat_ws(NULL,'x','y') IS NULL" becomes "PostgreSQL 16-18: concat_ws(NULL,'x','y') is NULL". Where it did not, the sentence goes. Also removed: emphasis-caps on ordinary English words, internal ticket references (#212, #219, #223, #238, #239, #240) and a PLAN-240.md pointer that will not resolve for anyone reading this later, and comments restating the line below them. Kept deliberately: intent markers whose reason is not recoverable from the code, since they guard against a future "fix"; the per-function audit trail in NeverNullSafeLists, which is a maintained list backed by a live sweep; full KDoc contract text on runtime's public API; and pgjdbc source citations, which name a specific method as evidence. Comment text only -- no code line added, removed, or reordered. Co-Authored-By: Claude Opus 5 (1M context) --- .../generator/ColumnNullabilityAnalyzer.kt | 170 +- .../kotlin/norm/generator/DomainBuilder.kt | 9 +- .../main/kotlin/norm/generator/EnumBuilder.kt | 6 +- .../norm/generator/GroupRteSubstitution.kt | 47 +- .../norm/generator/ImplementationBuilder.kt | 2 +- .../kotlin/norm/generator/JdbcAnalyzer.kt | 6 +- .../main/kotlin/norm/generator/JsonValue.kt | 2 +- .../src/main/kotlin/norm/generator/Main.kt | 6 +- .../norm/generator/MergeSideNullability.kt | 8 +- .../src/main/kotlin/norm/generator/Model.kt | 4 +- .../norm/generator/NeverNullSafeLists.kt | 59 +- .../generator/NodeTreeNullabilityAnalyzer.kt | 204 +- .../norm/generator/NodeTreeProvenance.kt | 12 +- .../generator/NodeTreeProvenanceExpression.kt | 20 +- .../norm/generator/OriginalAdjacency.kt | 8 +- .../kotlin/norm/generator/PgCatalogLoader.kt | 195 +- .../kotlin/norm/generator/PgNodeExpression.kt | 20 +- .../kotlin/norm/generator/PgNodeTreeParser.kt | 197 +- .../kotlin/norm/generator/SqlCteClause.kt | 20 +- .../kotlin/norm/generator/SqlIdentifiers.kt | 176 +- .../norm/generator/SqlKeywordScanner.kt | 97 +- .../main/kotlin/norm/generator/SqlLexer.kt | 192 +- .../main/kotlin/norm/generator/SqlMappable.kt | 4 +- .../kotlin/norm/generator/SqlOutputClause.kt | 88 +- .../norm/generator/SqlParameterInferrer.kt | 8 +- .../main/kotlin/norm/generator/SqlStarItem.kt | 156 +- .../kotlin/norm/generator/TypeRepository.kt | 26 +- .../CollapseCosmeticWhitespaceTest.kt | 12 +- .../norm/generator/ColumnTypeMappingTest.kt | 18 +- .../norm/generator/ExplainAnalysisTest.kt | 49 +- .../kotlin/norm/generator/GenerateCodeTest.kt | 21 +- .../generator/GroupRteSubstitutionTest.kt | 29 +- .../GroupingSetNullExtensionSweepTest.kt | 52 +- .../generator/InterfaceBuilderKdocTest.kt | 4 +- .../kotlin/norm/generator/JdbcAnalyzerTest.kt | 72 +- .../generator/KdocProvenanceRoundTripTest.kt | 59 +- .../KeywordOperandProvenanceCorpusTest.kt | 18 +- .../norm/generator/MarkdownEscapingTest.kt | 2 +- .../NodeTreeNullabilityAnalyzerTest.kt | 74 +- .../NodeTreeProvenanceExpressionTest.kt | 98 +- .../NodeTreeProvenanceResolverTest.kt | 31 +- .../NodeTreeProvenanceScopeSweepTest.kt | 60 +- .../norm/generator/PgNodeTreeParserTest.kt | 52 +- .../norm/generator/QueryAnalysisTest.kt | 1826 ++++++++--------- .../norm/generator/SafeListSweepTest.kt | 188 +- .../SourceReferenceLiveVerificationTest.kt | 51 +- .../kotlin/norm/generator/SqlCteClauseTest.kt | 32 +- .../norm/generator/SqlIdentifiersTest.kt | 3 +- .../norm/generator/SqlKeywordScannerTest.kt | 59 +- .../kotlin/norm/generator/SqlLexerTest.kt | 56 +- .../norm/generator/SqlOutputClauseTest.kt | 379 ++-- .../generator/SqlParameterInferrerTest.kt | 43 +- .../kotlin/norm/generator/SqlStarItemTest.kt | 2 +- .../norm/generator/TypeRepositoryTest.kt | 20 +- .../norm/gradle/IdeSyncIntegrationTest.kt | 131 +- .../test/kotlin/norm/gradle/NormPluginTest.kt | 41 +- runtime/src/main/kotlin/norm/PlainArrays.kt | 4 +- 57 files changed, 2536 insertions(+), 2692 deletions(-) diff --git a/generator/src/main/kotlin/norm/generator/ColumnNullabilityAnalyzer.kt b/generator/src/main/kotlin/norm/generator/ColumnNullabilityAnalyzer.kt index dffaed1b..f71f4021 100644 --- a/generator/src/main/kotlin/norm/generator/ColumnNullabilityAnalyzer.kt +++ b/generator/src/main/kotlin/norm/generator/ColumnNullabilityAnalyzer.kt @@ -9,19 +9,19 @@ import java.util.UUID * of NESTED `ANY_SUBLINK`/`ALL_SUBLINK` (a `SubLink` whose own subselect contains another * `SubLink`) are resolved before defaulting to nullable — see that method's KDoc. * - * This is NOT required to prevent an infinite loop: [PgNodeExpression.SubLink.subselectBlock] is - * always extracted, via [PgNodeTreeParser.extractFieldExpression], as a genuine SUBSTRING of its + * This does not prevent an infinite loop: [PgNodeExpression.SubLink.subselectBlock] is + * always extracted, via [PgNodeTreeParser.extractFieldExpression], as a genuine substring of its * enclosing `SubLink`'s own text, strictly shorter than it — [PgNodeTreeParser] has no mechanism to * produce a cyclic or self-referential node-tree text, so this recursion is provably bounded by the - * ORIGINAL query text's finite length regardless of this constant's value, or even its presence. - * The budget exists instead as a defensive bound on STACK DEPTH and repeated analysis WORK for a + * original query text's finite length regardless of this constant's value, or even its presence. + * The budget exists instead as a defensive bound on stack depth and repeated analysis work for a * pathologically deep (if syntactically legal) chain of nested `= ANY (...)`/`ALL (...)` sublinks, * the same role [NodeTreeNullabilityAnalyzer.MAX_EXPRESSION_DEPTH] plays for a deeply nested parsed * expression tree elsewhere in this codebase. Deliberately small: this analysis is only ever needed * for the (typically shallow) nullability proof of an `IN`/`= ANY` subquery's single output column, * not for arbitrarily deep query nesting in general — see `QueryAnalysisTest`'s four-level-nesting - * test for a query shape that is semantically NOT NULL end-to-end but is reported nullable at this - * budget, pinning that the budget's specific VALUE (not merely its presence) is what is enforced. + * test for a query shape that is semantically not null end-to-end but is reported nullable at this + * budget, pinning that the budget's specific value, not merely its presence, is what is enforced. */ private const val SUBLINK_ANALYSIS_DEPTH_BUDGET = 3 @@ -36,7 +36,7 @@ private const val SUBLINK_ANALYSIS_DEPTH_BUDGET = 3 * cycles, possible at much shallower depths, are handled by * [ColumnNullabilityAnalyzer.viewColumnNullabilityInProgress] instead. * - * Views within this many levels of the deepest point of a chain that ITSELF exceeds the budget can + * Views within this many levels of the deepest point of a chain that itself exceeds the budget can * get either the truncated or the fully-resolved answer, depending on which views were memoized * first: the depth check runs before the memo lookup, so a relid at or past the budget always * truncates, but an untainted cached answer for a strictly shallower relid is reused without @@ -101,7 +101,7 @@ internal class ColumnNullabilityAnalyzer(private val loader: PgCatalogLoader) { * relid is not a view or materialized view at all, so [isColumnNotNull] must fall through to * base-table resolution. * - * Written UNCONDITIONALLY by every successful resolution, even for a tainted answer — see + * Written unconditionally by every successful resolution, even for a tainted answer — see * [viewColumnNullabilityTaintedRelids]. Needs no synchronization (single-threaded [connection]). */ private val viewColumnNullabilityMemo = mutableMapOf?>() @@ -110,10 +110,10 @@ internal class ColumnNullabilityAnalyzer(private val loader: PgCatalogLoader) { * Relids currently being resolved by [resolveViewColumnNullability] — guards against infinite * recursion through a view dependency cycle. * - * Load-bearing, not merely defensive: `CREATE OR REPLACE VIEW` only requires the relations the new + * Not merely defensive: `CREATE OR REPLACE VIEW` only requires the relations the new * definition references to exist at replace time, so both a mutual cycle (`a` selects from `b`, * then `b` is replaced to select from `a`) and a direct self-cycle are constructible. PostgreSQL - * refuses to QUERY such a view at all, so the nullable placeholder this guard returns has no true + * refuses to query such a view at all, so the nullable placeholder this guard returns has no true * answer to under-approximate. */ private val viewColumnNullabilityInProgress = mutableSetOf() @@ -176,25 +176,25 @@ internal class ColumnNullabilityAnalyzer(private val loader: PgCatalogLoader) { } /** - * For [nodeTree]'s OWN outermost statement — never recursing into a CTE it declares; each CTE's + * For [nodeTree]'s own outermost statement — never recursing into a CTE it declares; each CTE's * own body resolves its own MERGE independently (see [analyzeCteBodyNullability]) — determines * which of its two base-table relations (identified by `:rtable` varno) can be entirely absent * for some result row, via [explainMergeSideNullability] rather than `:mergeActionList`/text * inspection. * * A `MERGE`'s match-optionality (`WHEN NOT MATCHED BY SOURCE`, `WHEN NOT MATCHED [BY TARGET] - * THEN INSERT`) is invisible to `:varnullingrels` — verified live: a `MERGE ... WHEN NOT MATCHED - * BY SOURCE THEN DELETE RETURNING src.col` has an EMPTY `:varnullingrels` on `src.col`'s `Var`, + * THEN INSERT`) is invisible to `:varnullingrels`: a `MERGE ... WHEN NOT MATCHED + * BY SOURCE THEN DELETE RETURNING src.col` has an empty `:varnullingrels` on `src.col`'s `Var`, * identical to an ordinary, always-present reference. [explainMergeSideNullability]'s KDoc has * the full reasoning for why `EXPLAIN`'s own join type answers this precisely instead. * * @param sql the EXACT (already sentinel-substituted) statement text to run `EXPLAIN` against — - * the WHOLE top-level statement, including any leading `WITH` clause, so a `MERGE` nested - * inside a CTE resolves through the SAME call as a top-level one, keyed by ITS OWN + * the whole top-level statement, including any leading `WITH` clause, so a `MERGE` nested + * inside a CTE resolves through the same call as a top-level one, keyed by its own * target/source relation names * @return an EMPTY map when [nodeTree]'s own outermost statement is not a `MERGE` at all; a map * from varno to whether THAT relation can be entirely absent (containing the target and/or - * source varno, per [MergeSideNullability]) when it IS a `MERGE` and `EXPLAIN` successfully + * source varno, per [MergeSideNullability]) when it is a `MERGE` and `EXPLAIN` successfully * attributed the join; `null` when it's a `MERGE` but `EXPLAIN` could not resolve it (e.g. a * `USING` clause with more than one relation of its own) — the caller must then treat this * `MERGE` as entirely untrustworthy, never guessing at a partial answer @@ -217,8 +217,8 @@ internal class ColumnNullabilityAnalyzer(private val loader: PgCatalogLoader) { return emptyMap() } val targetRelid = rangeTable[targetVarno] ?: return null - // A simple `MERGE INTO target USING source ON ...` has exactly one OTHER :rtable entry besides - // the target — the source, of ANY rtekind. A `USING` clause with more than one relation of its + // A simple `MERGE INTO target USING source ON ...` has exactly one other :rtable entry besides + // the target — the source, of any rtekind. A `USING` clause with more than one relation of its // own (e.g. a join or subquery source) has no single relation this method can attribute a join // side to, so it bails rather than guess. Reads the FULL range table, not [rangeTable] (base // tables only) — a CTE source's own varno never appears there at all — since the returned map @@ -272,7 +272,7 @@ internal class ColumnNullabilityAnalyzer(private val loader: PgCatalogLoader) { } /** - * The bare table name [cteName]'s body resolves to, ONLY when that body is nothing but a plain + * The bare table name [cteName]'s body resolves to, only when that body is nothing but a plain * `SELECT ... FROM oneBaseTable` — a single `rtekind 0` range-table entry and nothing else. See * [mergeSourceRelationNameCandidates]'s own KDoc for why this narrow shape is the only one this * offers as an inlining candidate. @@ -331,11 +331,11 @@ internal class ColumnNullabilityAnalyzer(private val loader: PgCatalogLoader) { connection.createStatement().use { statement -> statement.execute( "CREATE FUNCTION pg_temp.$functionName() RETURNS SETOF record LANGUAGE sql " + - // The newline before "; END" is load-bearing, not style: [substitutedSql] is caller- + // The newline before "; END" is required, not style: [substitutedSql] is caller- // supplied SQL text that can legitimately end in a trailing `--` line comment (ordinary - // in a queries.sql), which extends to end of LINE. Without a newline separating it from + // in a queries.sql), which extends to end of line. Without a newline separating it from // "; END", the comment swallows the terminator too, and PostgreSQL sees unterminated - // input instead of a syntax error naming the real cause. Verified live. + // input instead of a syntax error naming the real cause. "BEGIN ATOMIC $substitutedSql\n; END", ) } @@ -357,9 +357,9 @@ internal class ColumnNullabilityAnalyzer(private val loader: PgCatalogLoader) { val mergeAbsent = mergeAbsentVarnos(nodeTree, rangeTable, substitutedSql) ?: return null // '?' in sql, not substitutedSql: a sentinel-substituted CONST is byte-identical to a // hand-written literal once embedded in the SQL text — the parsed tree retains no memory - // of which one it was. trustAssignedExpressions=false whenever the ORIGINAL sql had ANY + // of which one it was. trustAssignedExpressions=false whenever the original sql had any // parameter blocks analyzeNodeTree's :targetList-to-:returningList substitution (see its - // KDoc) for the WHOLE statement, not just the specific assignment a parameter feeds, + // KDoc) for the whole statement, not just the specific assignment a parameter feeds, // because there is no structural way from here to tell which assignment(s) it was. val nullability = analyzeNodeTree( nodeTree, @@ -426,7 +426,7 @@ internal class ColumnNullabilityAnalyzer(private val loader: PgCatalogLoader) { * Reads the `:targetList` first — this covers every plain `SELECT`, including one that reaches * this function only because it CONTAINS a data-modifying CTE (the outer statement is still a * `SELECT`, so PostgreSQL's own `CREATE VIEW` restriction, and by extension nothing here, ever - * blocked it). Falls back to `:returningList` when the outer statement's OWN target list is + * blocked it). Falls back to `:returningList` when the outer statement's own target list is * empty — true only for a topmost `UPDATE`/`DELETE`/`MERGE ... RETURNING`: a plain `SELECT` * always has a non-empty target list, or [connection] would have rejected it as a query with no * result columns before ever reaching this point. @@ -473,21 +473,21 @@ internal class ColumnNullabilityAnalyzer(private val loader: PgCatalogLoader) { val subqueryColumnNotNull = buildSubqueryColumnNotNull(nodeTree, resolvedCtes, applyQualNarrowing, sql) val cteColumnNotNull = buildCteColumnNotNull(nodeTree, resolvedCtes) // A non-zero :resultRelation means this is an INSERT/UPDATE/DELETE/MERGE, not a SELECT — see - // parseResultRelation's KDoc. Its :targetList holds the value expressions being WRITTEN to + // parseResultRelation's KDoc. Its :targetList holds the value expressions being written to // each explicitly-assigned column of the target relation (keyed by :resno = the column's - // attribute number), which is exactly what a :returningList Var referencing that SAME - // (resultRelationVarno, attno) pair actually reads back — NOT the column's general catalog - // constraint, which says nothing about what THIS statement is about to write. See + // attribute number), which is exactly what a :returningList Var referencing that same + // (resultRelationVarno, attno) pair actually reads back — not the column's general catalog + // constraint, which says nothing about what this statement is about to write. See // targetListByResno's use below. val resultRelationVarno = nodeTreeParser.parseResultRelation(nodeTree) val targetListByResno = if (resultRelationVarno == 0 || !trustAssignedExpressions) { - // !trustAssignedExpressions means the ORIGINAL sql (before sentinel substitution) contained + // !trustAssignedExpressions means the original sql (before sentinel substitution) contained // a `?` parameter placeholder somewhere — see queryColumnNullabilityViaProsqlbody's call // site KDoc. A sentinel-substituted CONST is byte-identical, in the parsed tree, to a // hand-written literal: there is no structural signal left to tell "the caller supplied // this at runtime, and could supply NULL" from "the query text itself guarantees this value" - // for any SPECIFIC assignment, so trusting :targetList at all is unsafe for the WHOLE - // statement once ANY parameter exists anywhere in it. Verified live: `INSERT INTO t(name) + // for any specific assignment, so trusting :targetList at all is unsafe for the whole + // statement once any parameter exists anywhere in it. `INSERT INTO t(name) // VALUES (?) RETURNING name` reports NOT NULL if the sentinel substitution is trusted here, // even though the caller can bind an actual `NULL` for that exact parameter. emptyMap() @@ -526,7 +526,7 @@ internal class ColumnNullabilityAnalyzer(private val loader: PgCatalogLoader) { // A MERGE relation EXPLAIN determined can be entirely absent for some result row (see // mergeAbsentVarnos' KDoc) can never be proven non-null here, regardless of what a qual or // this column's own catalog constraint would otherwise say — those both describe the - // relation's rows WHEN PRESENT, which says nothing about whether this specific result row + // relation's rows when present, which says nothing about whether this specific result row // has one at all. false } else if (isProvenByQuals(qualNotNullVars, groupRteMap, varno, varattno)) { @@ -555,21 +555,21 @@ internal class ColumnNullabilityAnalyzer(private val loader: PgCatalogLoader) { resolvedCtes = resolvedCtes, isSourceColumnNotNull = plainIsSourceColumnNotNull, ) - // :returningList must be checked FIRST, not as a fallback for an empty :targetList: an INSERT - // or UPDATE's OWN :targetList holds the value expressions being WRITTEN to each assigned + // :returningList must be checked first, not as a fallback for an empty :targetList: an INSERT + // or UPDATE's own :targetList holds the value expressions being written to each assigned // column — a completely different, and typically shorter or differently-shaped, list than its // RETURNING projection — so it is very often non-empty even when :returningList is what this - // call actually needs to read (verified live: `INSERT INTO t(name) VALUES ('test') RETURNING + // call actually needs to read (`INSERT INTO t(name) VALUES ('test') RETURNING // *` against `t(id, name)` has a one-entry :targetList for "name" alone, but a two-entry // :returningList for "id, name"). A plain `SELECT` never populates :returningList at all, so // this ordering only ever matters for the DML-with-RETURNING case // [queryColumnNullabilityViaProsqlbody] reaches at the top level. val returningEntries = nodeTreeParser.parseReturningList(nodeTree) if (returningEntries.isNotEmpty()) { - // A SEPARATE analyzer whose isSourceColumnNotNull substitutes a Var referencing - // (resultRelationVarno, resno) with the ASSIGNED expression's own nullability — evaluated by - // the PLAIN analyzer, deliberately NOT this substituting one, so a self-referencing - // assignment (`SET note = note || 'x'`) reads note's OLD (plain, un-substituted) value for + // A separate analyzer whose isSourceColumnNotNull substitutes a Var referencing + // (resultRelationVarno, resno) with the assigned expression's own nullability — evaluated by + // the plain analyzer, deliberately not this substituting one, so a self-referencing + // assignment (`SET note = note || 'x'`) reads note's old (plain, un-substituted) value for // that inner reference rather than looping back into its own substitution forever. A column // the statement never assigns (no entry in targetListByResno) falls through to the identical // plain catalog/qual/subquery/CTE resolution [analyzer] itself uses, which is exactly correct @@ -619,7 +619,7 @@ internal class ColumnNullabilityAnalyzer(private val loader: PgCatalogLoader) { /** * Resolves [relid]'s per-column nullability by fully evaluating its view definition's own node * tree (`pg_rewrite`'s `_RETURN` rule) rather than inheriting a same-named source column's - * constraint the way the `pg_depend` name-join this replaces did (`#256`: `SELECT NULLIF(v, 'x') AS + * constraint the way the `pg_depend` name-join this replaces did (`SELECT NULLIF(v, 'x') AS * v FROM u` was reported NOT NULL whenever `u.v` was). * * @return one nullable flag per user-visible column (`attnum > 0 AND NOT attisdropped`), index `i` @@ -633,7 +633,7 @@ internal class ColumnNullabilityAnalyzer(private val loader: PgCatalogLoader) { internal fun resolveViewColumnNullability(relid: Int): List? { if (viewColumnNullabilityRecursionDepth >= VIEW_NULLABILITY_RECURSION_DEPTH_BUDGET) { // Depth guard — a deep but acyclic pass-through chain can exhaust the JVM stack before ever - // revisiting a relid the cycle guard below would catch. Runs BEFORE the memo lookup so a relid + // revisiting a relid the cycle guard below would catch. Runs before the memo lookup so a relid // at or past the budget always truncates rather than returning a cached answer; see // VIEW_NULLABILITY_RECURSION_DEPTH_BUDGET's KDoc. A taint event: correct only while this deep. viewColumnNullabilityTaintEventCount++ @@ -778,15 +778,15 @@ internal class ColumnNullabilityAnalyzer(private val loader: PgCatalogLoader) { * subquery). * * @param applyQualNarrowing See [analyzeNodeTree]'s parameter of the same name — passed through - * only so [isSubLinkSubqueryColumnNotNull]'s wiring below can apply the SAME qual-narrowing + * only so [isSubLinkSubqueryColumnNotNull]'s wiring below can apply the same qual-narrowing * policy to a `SubLink`'s subselect that the caller applies to everything else. * @param depth The [subLinkSubqueryColumnNotNull] recursion budget for a `SubLink` encountered by * the returned analyzer — see that method's KDoc for why a budget is mandatory (a subselect can * itself contain a `SubLink`, whose own subselect can contain another). Defaults to * [SUBLINK_ANALYSIS_DEPTH_BUDGET] for every analyzer built directly from a top-level node tree, * CTE body, or subquery-RTE body; [subLinkSubqueryColumnNotNull] passes `depth - 1` when - * building the analyzer for a `SubLink`'s OWN subselect, so the budget only ever decreases - * along a chain of NESTED sublinks, never along the unrelated CTE/subquery-RTE recursion this + * building the analyzer for a `SubLink`'s own subselect, so the budget only ever decreases + * along a chain of nested sublinks, never along the unrelated CTE/subquery-RTE recursion this * class already performs independently of it. * * No `MERGE`-resolution parameter is threaded through here: a sublink's `:subselect` is, by SQL @@ -797,7 +797,7 @@ internal class ColumnNullabilityAnalyzer(private val loader: PgCatalogLoader) { * @param resolvedCtes CTE bodies declared directly in the query block this analyzer is built for — * see [analyzeQueryBlockNullability]'s parameter of the same name for the invariant every caller * must uphold. Threaded to [subLinkSubqueryColumnNotNull] so a `SubLink`'s subselect can resolve a - * reference to an ENCLOSING `WITH` clause (`#257`). Defaults to `emptyMap()`, the safe (nullable) + * reference to an enclosing `WITH` clause. Defaults to `emptyMap()`, the safe (nullable) * answer for a query block with no CTEs of its own. */ private fun buildAnalyzer( @@ -827,7 +827,7 @@ internal class ColumnNullabilityAnalyzer(private val loader: PgCatalogLoader) { /** * Backs [NodeTreeNullabilityAnalyzer]'s `isSubLinkSubqueryColumnNotNull` callback: `true` when * [subselectBlock] — the raw `{QUERY ...}` text of an `ANY_SUBLINK`'s or `ALL_SUBLINK`'s - * `:subselect` — produces EXACTLY ONE non-junk output column and that column is provably non-null. + * `:subselect` — produces exactly one non-junk output column and that column is provably non-null. * * Set-operation subselects (`UNION`/`INTERSECT`/`EXCEPT`) are rejected outright, the same * conservative default [buildSubqueryColumnNotNull] applies to a `FROM`-clause subquery RTE for @@ -839,7 +839,7 @@ internal class ColumnNullabilityAnalyzer(private val loader: PgCatalogLoader) { * `SubLink`'s subselect cannot recurse indefinitely. * @param resolvedCtes See [buildAnalyzer]'s parameter of the same name — CTE bodies declared * directly in [subselectBlock]'s own enclosing query block, so [subselectBlock] can resolve a - * reference to one of them (`#257`). + * reference to one of them. */ private fun subLinkSubqueryColumnNotNull( subselectBlock: String, @@ -861,7 +861,7 @@ internal class ColumnNullabilityAnalyzer(private val loader: PgCatalogLoader) { */ private fun forcesNewNullable(nodeTree: String): Boolean = when (nodeTreeParser.parseCommandType(nodeTree)) { PgNodeTreeParser.COMMAND_TYPE_DELETE -> true - // Only a MERGE with at least one DELETE action can leave NO new row behind for SOME result + // Only a MERGE with at least one DELETE action can leave no new row behind for some result // row — see hasDeleteMergeAction's KDoc. A MERGE with only UPDATE/INSERT actions always // writes or inserts a row, so NEW is exactly as trustworthy there as an ordinary column. PgNodeTreeParser.COMMAND_TYPE_MERGE -> nodeTreeParser.hasDeleteMergeAction(nodeTree) @@ -910,14 +910,14 @@ internal class ColumnNullabilityAnalyzer(private val loader: PgCatalogLoader) { } /** - * Analyzes every CTE declared in [nodeTree]'s OWN `:cteList` and returns each one's per-column + * Analyzes every CTE declared in [nodeTree]'s own `:cteList` and returns each one's per-column * nullability, keyed by CTE name. * - * Shared by [buildCteColumnNotNull] (resolving a CTE reference in [nodeTree]'s OWN `:rtable`) + * Shared by [buildCteColumnNotNull] (resolving a CTE reference in [nodeTree]'s own `:rtable`) * and [buildSubqueryColumnNotNull] (resolving a CTE reference — `:ctelevelsup 1` — one level - * DOWN, inside a nested subquery's OWN `:rtable`): a CTE's declaration scope is [nodeTree]'s + * down, inside a nested subquery's own `:rtable`): a CTE's declaration scope is [nodeTree]'s * level regardless of which nesting level actually references it, so both callers resolve - * against the SAME set of CTE bodies. + * against the same set of CTE bodies. */ private fun resolveCteBodies( nodeTree: String, @@ -936,8 +936,8 @@ internal class ColumnNullabilityAnalyzer(private val loader: PgCatalogLoader) { /** * @param sql See [mergeAbsentVarnos]'s parameter of the same name — passed through unchanged so - * a MERGE nested in [cte]'s own body can be resolved by the SAME EXPLAIN call this parameter - * documents, keyed by ITS OWN target/source relation names. + * a MERGE nested in [cte]'s own body can be resolved by the same EXPLAIN call this parameter + * documents, keyed by its own target/source relation names. */ private fun analyzeCteBodyNullability( cte: NodeTreeCteDefinition, @@ -951,11 +951,11 @@ internal class ColumnNullabilityAnalyzer(private val loader: PgCatalogLoader) { val cteRangeTable = nodeTreeParser.parseRangeTable(cte.queryBlock) val mergeAbsent = mergeAbsentVarnos(cte.queryBlock, cteRangeTable, sql) ?: return null val analyzer = buildCteBodyAnalyzer(cte.queryBlock, previouslyResolved, applyQualNarrowing, mergeAbsent, sql) - // :returningList must be checked FIRST, not as a fallback for an empty :targetList — see - // analyzeNodeTree's identical guard for the full reasoning (an INSERT/UPDATE's OWN :targetList - // holds the value expressions being WRITTEN, a completely different list from its RETURNING + // :returningList must be checked first, not as a fallback for an empty :targetList — see + // analyzeNodeTree's identical guard for the full reasoning (an INSERT/UPDATE's own :targetList + // holds the value expressions being written, a completely different list from its RETURNING // projection, and is very often non-empty even when :returningList is what must be read). A - // data-modifying CTE body reaches this method with its RAW, un-rewritten :returningList only + // data-modifying CTE body reaches this method with its raw, un-rewritten :returningList only // via [queryColumnNullabilityViaProsqlbody] — `prosqlbody` is the only mechanism that ever // populates a node tree for a data-modifying CTE in the first place (see that function's // KDoc), so there is no other caller shape this ordering needs to account for. @@ -973,21 +973,21 @@ internal class ColumnNullabilityAnalyzer(private val loader: PgCatalogLoader) { /** * Analyzes a `UNION`/`UNION ALL`/`INTERSECT`/`EXCEPT` [queryBlock] branch-by-branch and combines * each branch's per-column nullability with OR: a column is nullable in the combined result if - * ANY branch can produce `null` for it. + * any branch can produce `null` for it. * * A `WITH RECURSIVE` CTE's recursive term(s) reference the CTE by name ([cteName]) — resolved by * [buildCteBodyAnalyzer] via `previouslyResolved` — creating a genuine fixpoint problem: the - * recursive term's own nullability depends on the CTE's OWN combined nullability, which this - * function is what computes. PostgreSQL requires the FIRST branch (the seed/non-recursive term) + * recursive term's own nullability depends on the CTE's own combined nullability, which this + * function is what computes. PostgreSQL requires the first branch (the seed/non-recursive term) * to never reference the CTE itself, so it alone is computed once, outside the loop, as a known * starting point. Every subsequent branch (there is always exactly one recursive term for a * `WITH RECURSIVE` CTE, but this handles a plain multi-branch `UNION` identically) is then - * re-analyzed, feeding back the CURRENT combined result as [cteName]'s own nullability, and the + * re-analyzed, feeding back the current combined result as [cteName]'s own nullability, and the * combined result is recomputed — repeated until a pass changes nothing. * * This converges because the per-column nullability lattice (`false` = NOT NULL, `true` = * nullable, ordered `false < true`) is monotone under this loop's own update rule: OR-combining - * MORE branch results (now including a possibly-wider self-reference) can only ever ADD `true` + * more branch results (now including a possibly-wider self-reference) can only ever add `true` * bits, never remove one. Starting from the seed's own (fixed, correct) nullability — the * narrowest value the CTE's self-reference could possibly have — and iterating a * monotone-widening step over a `columnCount`-bit lattice reaches its fixpoint in at most @@ -1038,7 +1038,7 @@ internal class ColumnNullabilityAnalyzer(private val loader: PgCatalogLoader) { val result = branchAnalyzer.extractColumnNullability(branchBlock) // An empty result means this branch's own nullability could not be determined at all — not // "this branch has zero columns" (impossible; every branch of a set operation has the same - // column count). Silently dropping it from the OR-combination would let the OTHER + // column count). Silently dropping it from the OR-combination would let the other // branches' (possibly narrower, even all-NOT-NULL) answer stand as if this branch // contributed nothing, when in truth its contribution is simply unknown. Flagging it here // forces every column nullable below instead. @@ -1063,7 +1063,7 @@ internal class ColumnNullabilityAnalyzer(private val loader: PgCatalogLoader) { * `:ctelevelsup 0` means [queryBlock] declares that CTE itself, possibly shadowing a sibling of the * same name one level up, so it resolves from [ownResolvedCtes]; anything greater resolves from * [previouslyResolved]. Without this split a local shadowing `WITH` resolved against the wrong - * sibling body — an unsound answer, not merely a widened one (`#257`). + * sibling body — an unsound answer, not merely a widened one. * * @param ownResolvedCtes CTE bodies declared directly in [queryBlock]'s own `:cteList`. * @param previouslyResolved CTE bodies declared in the same outer `:cteList` [queryBlock]'s own CTE @@ -1089,15 +1089,15 @@ internal class ColumnNullabilityAnalyzer(private val loader: PgCatalogLoader) { /** * @param applyQualNarrowing See [analyzeNodeTree]'s parameter of the same name. * @param mergeAbsentVarnos See [analyzeNodeTree]'s parameter of the same name — [queryBlock]'s - * OWN varno-to-canBeAbsent map when [queryBlock] itself is a `MERGE` (resolved by + * own varno-to-canBeAbsent map when [queryBlock] itself is a `MERGE` (resolved by * [analyzeCteBodyNullability] before ever calling this method), empty otherwise. * @param sql See [mergeAbsentVarnos]'s (the method, not this parameter) `sql` parameter — passed - * through only so a subquery WITHIN [queryBlock] that references a CTE declared in - * [queryBlock]'s OWN nested `WITH` clause can resolve THAT (deeper) CTE's `MERGE`, if it has + * through only so a subquery within [queryBlock] that references a CTE declared in + * [queryBlock]'s own nested `WITH` clause can resolve that (deeper) CTE's `MERGE`, if it has * one, through [buildSubqueryColumnNotNull]. Defaults to an empty string for the (`SELECT`-only, * never `MERGE`-shaped) set-operation branch callers in [analyzeSetOperationBranches], where an * empty `EXPLAIN` target simply fails harmlessly (caught, treated as "cannot resolve") for the - * narrow, deeper case of a subquery nested that deep referencing ITS OWN local `MERGE` CTE. + * narrow, deeper case of a subquery nested that deep referencing its own local `MERGE` CTE. */ private fun buildCteBodyAnalyzer( queryBlock: String, @@ -1117,9 +1117,9 @@ internal class ColumnNullabilityAnalyzer(private val loader: PgCatalogLoader) { } else { nodeTreeParser.parseGroupRteMap(queryBlock) } - // queryBlock's OWN nested WITH clause, distinct from previouslyResolved (sibling CTEs one level + // queryBlock's own nested WITH clause, distinct from previouslyResolved (sibling CTEs one level // further up). Resolving any of the three uses below against previouslyResolved instead would - // resolve a shadowing local WITH against the wrong body — see #257. + // resolve a shadowing local WITH against the wrong body. val ownResolvedCtes = resolveCteBodies(queryBlock, applyQualNarrowing, sql) val innerCteNotNull = buildInnerCteNotNull(queryBlock, ownResolvedCtes, previouslyResolved) val subqueryColumnNotNull = buildSubqueryColumnNotNull(queryBlock, ownResolvedCtes, applyQualNarrowing, sql) @@ -1127,9 +1127,9 @@ internal class ColumnNullabilityAnalyzer(private val loader: PgCatalogLoader) { // hasGroupingSets: a grouping key is exactly the thing a GROUPING SETS/CUBE/ROLLUP query // null-extends after WHERE has already run. A non-zero :resultRelation suppresses narrowing // for the identical reason analyzeNodeTree's own guard does: a data-modifying CTE body's WHERE - // clause can prove something about a column its OWN SET clause is about to overwrite — see + // clause can prove something about a column its own SET clause is about to overwrite — see // e.g. `WITH c AS (UPDATE t SET a = NULL FROM u WHERE u.id = t.id AND t.a IS NOT NULL - // RETURNING t.a) SELECT a FROM c`, verified against real Postgres to return `a = NULL`, not the + // RETURNING t.a) SELECT a FROM c` returns `a = NULL`, not the // value the WHERE clause proved before the SET ran. val isDml = nodeTreeParser.parseResultRelation(queryBlock) != 0 val qualNotNullVars = if (applyQualNarrowing && !hasGroupingSets && !isDml) { @@ -1187,7 +1187,7 @@ internal class ColumnNullabilityAnalyzer(private val loader: PgCatalogLoader) { * nested `{QUERY ...}` block reached via [analyzeQueryBlockNullability] (a `SubLink`'s own * subselect, or a derived table nested inside one) * @param resolvedCtes CTE bodies declared directly in [nodeTree]'s own `:cteList`. A subquery - * nested inside [nodeTree] can reference one of these via `:ctelevelsup 1` inside its OWN + * nested inside [nodeTree] can reference one of these via `:ctelevelsup 1` inside its own * `:rtable`, not [nodeTree]'s. Computed by every caller so the same resolution also feeds * [buildAnalyzer] for a `SubLink` nested in [nodeTree]. * @param applyQualNarrowing See [analyzeNodeTree]'s parameter of the same name. @@ -1196,8 +1196,8 @@ internal class ColumnNullabilityAnalyzer(private val loader: PgCatalogLoader) { * Callers resolving [nodeTree]'s own top-level subquery RTEs use the default, full budget — a * `FROM`-clause hop is not a nested-sublink hop. [analyzeQueryBlockNullability]'s own recursive * call passes its current, possibly already-decremented `depth` through unchanged: it is the only - * path reaching a derived table nested INSIDE a `SubLink`'s subselect, and refilling the budget - * there would let a chain of `= ANY` sublinks separated by derived tables bypass it (`#257`). + * path reaching a derived table nested inside a `SubLink`'s subselect, and refilling the budget + * there would let a chain of `= ANY` sublinks separated by derived tables bypass it. * @return A map from `(varno, varattno)` pairs to `true` when the subquery column is non-null */ private fun buildSubqueryColumnNotNull( @@ -1209,7 +1209,7 @@ internal class ColumnNullabilityAnalyzer(private val loader: PgCatalogLoader) { ): Map, Boolean> { // Set-operation queries (UNION ALL, INTERSECT, EXCEPT) store their branches as rtekind=1 // subquery RTEs. Tracing through them would incorrectly report the first branch's nullability - // as the result's nullability — the true result is the union across ALL branches, some of which + // as the result's nullability — the true result is the union across all branches, some of which // may introduce nulls (e.g., a branch with LEFT JOIN). Return empty so the analyzer conservatively // treats set-operation output columns as nullable (the correct safe default). This also covers // analyzeQueryBlockNullability's recursive call into this method. @@ -1232,17 +1232,17 @@ internal class ColumnNullabilityAnalyzer(private val loader: PgCatalogLoader) { * Computes per-column nullability for a single query block ([queryBlock]) — the shared core of * [buildSubqueryColumnNotNull] (a `FROM`-clause subquery RTE) and [subLinkSubqueryColumnNotNull] * (an `ANY_SUBLINK`'s or `ALL_SUBLINK`'s `:subselect`): given a raw `{QUERY ...}` block, build a resolver over the - * block's OWN `parseRangeTable`/`parseCteRangeTableEntries`/`parseGroupRteMap`/subquery-RTE/qual + * block's own `parseRangeTable`/`parseCteRangeTableEntries`/`parseGroupRteMap`/subquery-RTE/qual * narrowing, then run [NodeTreeNullabilityAnalyzer.extractColumnNullability] against it. * * [queryBlock]'s own `:rtable` can hold three things resolved differently: a base table (via * [isColumnNotNull]), a nested subquery RTE (a derived table, resolved by recursing into - * [buildSubqueryColumnNotNull] on [queryBlock] itself), and a CTE RTE. Before `#257` only the + * [buildSubqueryColumnNotNull] on [queryBlock] itself), and a CTE RTE. Before this fix only the * base-table case was handled, so a `SubLink`'s subselect reading either of the others degraded to * nullable. * * @param resolvedCtes CTE bodies visible via `:ctelevelsup` greater than `0` relative to - * [queryBlock] — declared in whichever scope ENCLOSES it, never [queryBlock]'s own nested `WITH` + * [queryBlock] — declared in whichever scope encloses it, never [queryBlock]'s own nested `WITH` * clause. Every caller must uphold this; resolving a `Var` against the wrong CTE body is silently * worse than widening — see [NodeTreeCteReference.ctelevelsup]. A CTE at `:ctelevelsup 2` (a * sibling of [queryBlock]'s own enclosing CTE) is out of this flat map's reach and stays @@ -1282,10 +1282,10 @@ internal class ColumnNullabilityAnalyzer(private val loader: PgCatalogLoader) { } else { emptySet() } - // A ctelevelsup-0 reference must resolve against queryBlock's OWN CTEs, never the resolvedCtes + // A ctelevelsup-0 reference must resolve against queryBlock's own CTEs, never the resolvedCtes // parameter, which belongs to an enclosing scope. val ownResolvedCtes = resolveCteBodies(queryBlock, applyQualNarrowing, sql) - // depth is threaded, not defaulted: this is the recursive hop that must NOT refill the budget. + // depth is threaded, not defaulted: this is the recursive hop that must not refill the budget. val subqueryColumnNotNull = buildSubqueryColumnNotNull(queryBlock, ownResolvedCtes, applyQualNarrowing, sql, depth) val subAnalyzer = buildAnalyzer( hasGroupingSets = hasGroupingSets, @@ -1323,7 +1323,7 @@ internal class ColumnNullabilityAnalyzer(private val loader: PgCatalogLoader) { * * @return `true` only when [relid] and every transitive inheritance/partition descendant is a * plain table with no risky `relkind`, no mutating row-level trigger, and no non-view rewrite - * rule — `false` for every other case, INCLUDING the catalog query itself failing to execute + * rule — `false` for every other case, including the catalog query itself failing to execute * (treated exactly like a confirmed risk: [analyzeNodeTree] must not trust the substitution * when it cannot rule the risk out). */ @@ -1375,7 +1375,7 @@ internal class ColumnNullabilityAnalyzer(private val loader: PgCatalogLoader) { /** * The bare (unqualified) table name for [relid], via `pg_class.relname` — used to attribute - * an `EXPLAIN` plan's `"Relation Name"` fields (which are always the REAL table name, never an + * an `EXPLAIN` plan's `"Relation Name"` fields (which are always the real table name, never an * alias) back to a specific `:rtable` entry this class already resolved structurally, without * ever re-parsing the SQL text for a table name or alias. See [mergeAbsentVarnos]'s only caller. * diff --git a/generator/src/main/kotlin/norm/generator/DomainBuilder.kt b/generator/src/main/kotlin/norm/generator/DomainBuilder.kt index 20a02500..60b6776b 100644 --- a/generator/src/main/kotlin/norm/generator/DomainBuilder.kt +++ b/generator/src/main/kotlin/norm/generator/DomainBuilder.kt @@ -131,11 +131,10 @@ internal fun domainAdapterPropertyName(domain: Domain): String = "${domain.name. * [resolveJdbcTypeInfo] has an entry for every type [TypeRepository]'s `BASE_TYPE_RESOLVERS` * accepts as a plain column type (enforced by [ColumnTypeMappingTest]'s domain-base-type-parity * sweep), so [error] here is unreachable for a domain built on any of those — `CREATE DOMAIN d AS - * timestamptz`/`uuid`/`date`/etc. all resolve. It remains reachable, by design, only for a - * Postgres type Norm has never mapped to Kotlin AT ALL, as a plain column or otherwise (`xml`, - * `interval`, `money`, ...) — Postgres permits a domain over any of these (verified against a live - * server), so this is a genuine, expected case, not an oversight. Failing fast with the - * unsupported type's name is preferable to silently guessing a mapping Norm has no tested + * timestamptz`/`uuid`/`date`/etc. all resolve. It stays reachable for a Postgres type Norm has + * never mapped to Kotlin at all, as a plain column or otherwise (`xml`, `interval`, `money`, ...). + * Postgres allows a domain over any of these, so hitting this is expected, not a bug — failing + * fast with the unsupported type's name beats silently guessing a mapping Norm has no tested * behavior for. */ internal fun domainKotlinBaseType(baseTypeName: String): TypeName = diff --git a/generator/src/main/kotlin/norm/generator/EnumBuilder.kt b/generator/src/main/kotlin/norm/generator/EnumBuilder.kt index a63895ae..410392f1 100644 --- a/generator/src/main/kotlin/norm/generator/EnumBuilder.kt +++ b/generator/src/main/kotlin/norm/generator/EnumBuilder.kt @@ -29,9 +29,9 @@ internal fun buildEnumTypeSpec(enumDefinition: Enum, packageName: String): TypeS val enumClassName = ClassName(packageName, enumDefinition.name.snakeToCamelCase().titleCase()) val enumBuilder = TypeSpec.enumBuilder(enumClassName) - // Add KDoc to the enum class if a comment is present. The comment must be passed as a "%L" - // argument, never interpolated into the format string itself -- a literal "%" in the comment - // would otherwise be read as a KotlinPoet format specifier and throw building the KDoc. + // Passed as a "%L" argument rather than interpolated into the format string itself -- a + // literal "%" in the comment would otherwise be read as a KotlinPoet format specifier and + // throw building the KDoc. .apply { if (enumDefinition.comment.isNotEmpty()) addKdoc("%L\n\n", enumDefinition.comment) } .addKdoc("@property databaseValue The representation of this enum in Postgres.") .primaryConstructor( diff --git a/generator/src/main/kotlin/norm/generator/GroupRteSubstitution.kt b/generator/src/main/kotlin/norm/generator/GroupRteSubstitution.kt index cc8c33a5..a2b44de5 100644 --- a/generator/src/main/kotlin/norm/generator/GroupRteSubstitution.kt +++ b/generator/src/main/kotlin/norm/generator/GroupRteSubstitution.kt @@ -10,57 +10,56 @@ import norm.generator.NodeTreeNullabilityAnalyzer.Companion.MAX_EXPRESSION_DEPTH * lets [NodeTreeNullabilityAnalyzer] apply one set of nullability rules to every supported * PostgreSQL version, instead of needing PG18-specific reasoning layered on top. * - * A `Var` is substituted only when ALL of the following hold — otherwise it is returned unchanged: + * A `Var` is substituted only when all of the following hold — otherwise it is returned unchanged: * - [PgNodeExpression.Var.levelsUp] is `0`. A `Var` with `levelsUp > 0` refers to an outer query * level, whose range table [groupExpressionsByVarno] does not describe — substituting against it - * would resolve against the WRONG query level's GROUP RTE, if that varno happens to collide. + * would resolve against the wrong query level's GROUP RTE, if that varno happens to collide. * - [PgNodeExpression.Var.varno] is a key of [groupExpressionsByVarno]. * - [PgNodeExpression.Var.varattno] is a valid 1-based index into that varno's resolved list (i.e. * in `1..list.size`) — an out-of-range `varattno` means either malformed input or a node-tree * shape this parser does not (yet) model correctly, and substituting against a nonexistent entry * would silently invent an expression PostgreSQL never produced. * - The resolved expression is not [PgNodeExpression.Unknown] — an unmodelled node type or a parse - * failure must not replace a `Var` that [PgNodeTreeParser.parseGroupRteMap]'s coarser, VAR-only + * failure must not replace a `Var` that [PgNodeTreeParser.parseGroupRteMap]'s coarser, `Var`-only * resolution could still succeed at (see that method's continued use as a fallback wherever this * substitution declines to apply). * * Nothing is inherited from the replaced `Var` — no union of [PgNodeExpression.Var.nullingRelations], - * no carrying over [PgNodeExpression.Var.returningType]. This is deliberate, not an oversight: the - * resolved `:groupexprs` expression ALREADY carries whatever outer-join nulling information applies - * to it directly. Verified live on PostgreSQL 18 (`SELECT b.x, count(*) FROM t LEFT JOIN u b ON - * b.id = t.id GROUP BY b.x`): the target-list `Var` referencing the GROUP RTE has an EMPTY - * `:varnullingrels` (PostgreSQL does not propagate the outer join's nulling relations onto the - * wrapper `Var` at all), while the GROUP RTE's OWN `:groupexprs` entry — `{VAR :varno 2 :varattno 2 - * :varnullingrels (b 3) ...}` — carries the correct, non-empty set. Inheriting anything from the - * replaced `Var` here would discard that correct information in favor of the wrapper's misleadingly - * empty one. + * no carrying over [PgNodeExpression.Var.returningType]: the resolved `:groupexprs` expression already + * carries whatever outer-join nulling information applies to it directly. On PostgreSQL 18 + * (`SELECT b.x, count(*) FROM t LEFT JOIN u b ON b.id = t.id GROUP BY b.x`), the target-list `Var` + * referencing the GROUP RTE has an empty `:varnullingrels` (PostgreSQL does not propagate the outer + * join's nulling relations onto the wrapper `Var` at all), while the GROUP RTE's own `:groupexprs` + * entry — `{VAR :varno 2 :varattno 2 :varnullingrels (b 3) ...}` — carries the correct, non-empty + * set. Inheriting anything from the replaced `Var` here would discard that correct information in + * favor of the wrapper's misleadingly empty one. * - * SINGLE PASS ONLY: the resolved expression substituted in for a matching `Var` is returned - * VERBATIM, never itself recursively substituted. This makes a hypothetical cycle within + * Single pass only: the resolved expression substituted in for a matching `Var` is returned + * verbatim, never itself recursively substituted. This makes a hypothetical cycle within * `:groupexprs` (one grouping-key expression's resolution referencing a `Var` that is itself * GROUP-RTE-shaped) structurally unable to loop, without this function ever having to prove * PostgreSQL cannot emit such a cycle — a proof this function does not attempt. * * Child coverage mirrors [NodeTreeNullabilityAnalyzer.containsVarOutsideRelation], not - * [NodeTreeNullabilityAnalyzer.safetyWalkChildren]: the latter deliberately drops several children - * (e.g. [PgNodeExpression.Aggref]'s own arguments, [PgNodeExpression.JsonExpr]'s `PASSING`-adjacent + * [NodeTreeNullabilityAnalyzer.safetyWalkChildren]: the latter drops several children (e.g. + * [PgNodeExpression.Aggref]'s own arguments, [PgNodeExpression.JsonExpr]'s `PASSING`-adjacent * fields, everything past [PgNodeExpression.SubLink.outerOperand]) for reasons specific to the - * grouping-set SAFETY analysis it backs, which do not apply here. A GROUP RTE `Var` verifiably CAN - * appear buried inside a non-`Aggref` node this substitution must walk into — e.g. `count(*) + + * grouping-set safety analysis it backs, which do not apply here. A GROUP RTE `Var` can appear + * buried inside a non-`Aggref` node this substitution must walk into — e.g. `count(*) + * 0::bigint` when `0::bigint` is also the grouping key rewrites that operand of the `+` to a GROUP - * RTE `Var`, sitting alongside the `Aggref` as an [PgNodeExpression.OpExpr] argument (verified live). - * [PgNodeExpression.Aggref]'s OWN arguments are a DIFFERENT case: verified live that PostgreSQL never - * rewrites them at all, on PG18 or otherwise — `count(lower(a))`/`string_agg(lower(a), ',')` under + * RTE `Var`, sitting alongside the `Aggref` as an [PgNodeExpression.OpExpr] argument. + * [PgNodeExpression.Aggref]'s own arguments are a different case: PostgreSQL never rewrites them at + * all, on PG18 or otherwise — `count(lower(a))`/`string_agg(lower(a), ',')` under * `GROUP BY ROLLUP(lower(a))` keep the real base-relation `Var` inside the `AGGREF`'s `:args`, - * because aggregate arguments are evaluated PRE-grouping, before any GROUP RTE substitution could + * because aggregate arguments are evaluated pre-grouping, before any GROUP RTE substitution could * apply. This substitution still walks into `Aggref`'s arguments anyway — not because any known PG18 - * shape needs it, but defensively: the exhaustive `when` below requires SOME branch for `Aggref` + * shape needs it, but defensively: the exhaustive `when` below requires some branch for `Aggref` * regardless, and doing the real rewrite there (rather than treating it as a childless leaf) means a * future PostgreSQL shape, or an unrelated caller that hands this function an `Aggref` subtree * directly, cannot silently escape substitution. Every child this substitution's `when` retains must * be rewritten, or a buried GROUP RTE `Var` would silently survive un-substituted. * - * The `when` below is EXHAUSTIVE over the sealed [PgNodeExpression] hierarchy with no `else ->` + * The `when` below is exhaustive over the sealed [PgNodeExpression] hierarchy with no `else ->` * branch: adding a new [PgNodeExpression] subtype without updating this function fails the build, * rather than silently leaving that subtype's children unwalked. * diff --git a/generator/src/main/kotlin/norm/generator/ImplementationBuilder.kt b/generator/src/main/kotlin/norm/generator/ImplementationBuilder.kt index beed803b..ca6702ed 100644 --- a/generator/src/main/kotlin/norm/generator/ImplementationBuilder.kt +++ b/generator/src/main/kotlin/norm/generator/ImplementationBuilder.kt @@ -498,7 +498,7 @@ internal const val MAPPER_PARAMETER_NAME = "mapper" /** * Produces a [CodeBlock] per JDBC bind position that sets the parameter on the [PreparedStatement]. * - * @param nameTransform Converts the parameter's NAME reference (built via `%N`, so a name needing + * @param nameTransform Converts the parameter's name reference (built via `%N`, so a name needing * backtick-escaping — e.g. `My Col` — is escaped exactly as [ParameterSpec]'s own declaration * already is, unlike the plain string interpolation this replaced) into the code expression that * provides the value. For single-item functions this is just the name reference itself; for batch diff --git a/generator/src/main/kotlin/norm/generator/JdbcAnalyzer.kt b/generator/src/main/kotlin/norm/generator/JdbcAnalyzer.kt index 7649f589..2d27e787 100644 --- a/generator/src/main/kotlin/norm/generator/JdbcAnalyzer.kt +++ b/generator/src/main/kotlin/norm/generator/JdbcAnalyzer.kt @@ -219,10 +219,10 @@ public class JdbcAnalyzer(private val connection: Connection) { // bounds. val analysis = columnNullability.getOrElse(i - 1) { ColumnAnalysis(nullable = true, provenanceExpression = null) } - // analysis.originalColumnName (from the node tree's own :resorigtbl/:resorigcol) is the REAL + // analysis.originalColumnName (from the node tree's own :resorigtbl/:resorigcol) is the real // source column, resolved by PostgreSQL itself even through an intervening CTE alias -- prefer - // it over the parsed SELECT item, which only sees the select-list TEXT and would otherwise - // report a CTE's own output alias as if it were the original column (#238). PostgreSQL JDBC + // it over the parsed SELECT item, which only sees the select-list text and would otherwise + // report a CTE's own output alias as if it were the original column. PostgreSQL JDBC // returns the alias for both getColumnName and getColumnLabel when AS is used, so // rsmd.getColumnName is the least reliable of the three and stays the last resort. val originalColumnName = analysis.originalColumnName ?: selectItem?.columnName ?: rsmd.getColumnName(i) diff --git a/generator/src/main/kotlin/norm/generator/JsonValue.kt b/generator/src/main/kotlin/norm/generator/JsonValue.kt index 288183f9..dccdb32e 100644 --- a/generator/src/main/kotlin/norm/generator/JsonValue.kt +++ b/generator/src/main/kotlin/norm/generator/JsonValue.kt @@ -5,7 +5,7 @@ package norm.generator * * PostgreSQL's `EXPLAIN (FORMAT JSON)` output is the only JSON this generator ever reads. JSON's * grammar is small, deterministic, and unambiguous — unlike SQL, which is why the codebase's own - * "no hand-rolled SQL text scanning" concern does not apply here: this is a REAL, complete parser + * "no hand-rolled SQL text scanning" concern does not apply here: this is a real, complete parser * for the format, not an ad-hoc scanner for specific field names inside SQL text. * * Only the value shapes this generator's own EXPLAIN consumers need are modeled: objects diff --git a/generator/src/main/kotlin/norm/generator/Main.kt b/generator/src/main/kotlin/norm/generator/Main.kt index 41fb1936..dab63688 100644 --- a/generator/src/main/kotlin/norm/generator/Main.kt +++ b/generator/src/main/kotlin/norm/generator/Main.kt @@ -28,9 +28,9 @@ private val TRANSACTABLE = ClassName(RUNTIME_PACKAGE, "Transactable") * [JdbcAnalyzer.fetchReservedWords] — used when rendering a `` `table.column` `` KDoc source * reference for a relation or column named after a reserved word (`order`, `user`), which must be * quoted rather than emitted as text PostgreSQL rejects with a syntax error. Required rather than - * defaulted here deliberately: this is the seam where a per-database, per-run value must be - * threaded through explicitly rather than silently falling back to an empty set (or, worse, a - * hardcoded snapshot that could drift from whichever server this run actually targets). + * defaulted here: this is the seam where a per-database, per-run value must be threaded through + * explicitly rather than silently falling back to an empty set (or, worse, a hardcoded snapshot + * that could drift from whichever server this run actually targets). * @param typeMappings User-configured type/column overrides. Type-level overrides suppress * auto-generation of the matching enum or domain. * @return The generated files. File names include the package hierarchy. diff --git a/generator/src/main/kotlin/norm/generator/MergeSideNullability.kt b/generator/src/main/kotlin/norm/generator/MergeSideNullability.kt index 95ee2163..48149745 100644 --- a/generator/src/main/kotlin/norm/generator/MergeSideNullability.kt +++ b/generator/src/main/kotlin/norm/generator/MergeSideNullability.kt @@ -96,8 +96,8 @@ private fun attributeJoinToSides( val targetIsInner = targetRelationName in innerRelationNames val sourceIsOuter = sourceRelationNames.any { it in outerRelationNames } val sourceIsInner = sourceRelationNames.any { it in innerRelationNames } - // Each relation must appear on EXACTLY ONE side, and target/source must be on DIFFERENT sides — - // otherwise this ISN'T the join being searched for (e.g. it's an unrelated join the outer + // Each relation must appear on exactly one side, and target/source must be on different sides — + // otherwise this isn't the join being searched for (e.g. it's an unrelated join the outer // statement introduces, or the USING clause has more than one relation of its own) and this // join cannot safely be attributed to either one. if (targetIsOuter == targetIsInner || sourceIsOuter == sourceIsInner || targetIsOuter == sourceIsOuter) { @@ -160,9 +160,9 @@ private fun findOwnJoinNodes(mergeModifyTableNode: JsonValue.JsonObject): List=", "timestamp", "timestamptz"), SafeOperatorSignature(">=", "timestamp", "timestamp"), SafeOperatorSignature(">=", "uuid", "uuid"), - // Unary (prefix) bitwise-complement overloads of "~" — a DIFFERENT operator from the + // Unary (prefix) bitwise-complement overloads of "~" — a different operator from the // binary regex-match "~" immediately below; PostgreSQL overloads the symbol. Complementing // any bit pattern of a fixed-width representation always fits back in that same width, so // there is no overflow case the way there is for negation. `macaddr`/`macaddr8`/`inet` - // overloads of unary "~" also exist in pg_catalog but are deliberately NOT listed — network + // overloads of unary "~" also exist in pg_catalog but are deliberately not listed — network // address types are outside the families this fix targets, so an expression using them // stays conservatively nullable rather than being swept and verified. SafeOperatorSignature("~", null, "int8"), diff --git a/generator/src/main/kotlin/norm/generator/NodeTreeNullabilityAnalyzer.kt b/generator/src/main/kotlin/norm/generator/NodeTreeNullabilityAnalyzer.kt index 4b1f1be9..ea5f5093 100644 --- a/generator/src/main/kotlin/norm/generator/NodeTreeNullabilityAnalyzer.kt +++ b/generator/src/main/kotlin/norm/generator/NodeTreeNullabilityAnalyzer.kt @@ -20,26 +20,26 @@ import norm.generator.NodeTreeNullabilityAnalyzer.Companion.MAX_EXPRESSION_DEPTH * `varattno` has a `NOT NULL` constraint. Used for [isNonNull] evaluation of [PgNodeExpression.Var]. * @param isOuterJoinNullable Returns `true` if the given `nullingRelations` set indicates the column * can be nulled by an outer join. Typically `true` when the set is non-empty. - * @param isAlwaysNonNull Returns `true` for function OIDs that never return `null` for ANY + * @param isAlwaysNonNull Returns `true` for function OIDs that never return `null` for any * combination of argument values, including when every argument is `null` (e.g., `concat`, which - * renders a `null` argument as an empty string) — but ONLY for the ORDINARY (non-`VARIADIC`) - * calling form. `concat(VARIADIC arr)` IS `null` when `arr` itself is `null` (verified live on - * PostgreSQL 16, 17, and 18): `isNonNull`'s [PgNodeExpression.FuncExpr] branch checks + * renders a `null` argument as an empty string) — but only for the ordinary (non-`VARIADIC`) + * calling form. `concat(VARIADIC arr)` is `null` when `arr` itself is `null` (PostgreSQL 16-18): + * `isNonNull`'s [PgNodeExpression.FuncExpr] branch checks * [PgNodeExpression.FuncExpr.isVariadic] before trusting this callback at all, and this * parameter's own guarantee never covers that form. See * [PgCatalogLoader.alwaysNonNullFunctionOids]'s KDoc for why the non-`VARIADIC` guarantee must - * be unconditional in every argument position — `concat_ws` is deliberately NOT eligible here - * despite also being non-strict, because it depends on WHICH argument is `null` (only a `null` + * be unconditional in every argument position — `concat_ws` is deliberately not eligible here + * despite also being non-strict, because it depends on which argument is `null` (only a `null` * separator, its first argument, makes the result `null`); see * [isNonNullIffFirstArgumentNonNull] for how that case is modeled instead. - * @param isNeverNullForNonNullInput Returns `true` for function/operator OIDs that are proven TOTAL + * @param isNeverNullForNonNullInput Returns `true` for function/operator OIDs that are proven total * on non-null input — every combination of non-null arguments produces a non-null result (an - * ERROR is fine; only a silent `null` return disqualifies a candidate). `pg_proc.proisstrict` - * alone cannot answer this: STRICT only guarantees NULL-in => NULL-out, never the converse, so - * this is required as an ADDITIONAL conjunct alongside [isStrict] below, never a substitute for + * error is fine; only a silent `null` return disqualifies a candidate). `pg_proc.proisstrict` + * alone cannot answer this: strict only guarantees NULL-in => NULL-out, never the converse, so + * this is required as an additional conjunct alongside [isStrict] below, never a substitute for * it. See [PgCatalogLoader.neverNullForNonNullInputOids] for the safe-list this is normally * backed by, and why omission from that list is always the safe default. That safe-list's - * verification (see `SafeListSweepTest`) covers only the ORDINARY, element-wise calling + * verification (see `SafeListSweepTest`) covers only the ordinary, element-wise calling * convention — `isNonNull`'s [PgNodeExpression.FuncExpr] branch never consults this * parameter at all for a `VARIADIC` call (see [PgNodeExpression.FuncExpr.isVariadic]'s KDoc): * the array argument being non-null says nothing about whether an element inside it is, and no @@ -55,10 +55,10 @@ import norm.generator.NodeTreeNullabilityAnalyzer.Companion.MAX_EXPRESSION_DEPTH * the ORDINARY (non-`VARIADIC`) calling form — used for [isNonNull] evaluation of * [PgNodeExpression.FuncExpr] when [PgNodeExpression.FuncExpr.isVariadic] is `false`. Currently * backs `concat_ws`: its first argument is the separator, and `concat_ws(null, 'x', 'y')` is - * `null` even though the later, individually-null-tolerant arguments are non-null (verified live - * on PostgreSQL 16, 17, and 18). `concat_ws(',', VARIADIC arr)` is a DIFFERENT case this - * parameter's guarantee does NOT cover: it is `null` when `arr` itself is `null` even though the - * literal separator is non-null (also verified live on PostgreSQL 16, 17, and 18). See + * `null` even though the later, individually-null-tolerant arguments are non-null (PostgreSQL + * 16-18). `concat_ws(',', VARIADIC arr)` is a different case this + * parameter's guarantee does not cover: it is `null` when `arr` itself is `null` even though the + * literal separator is non-null (also true on PostgreSQL 16-18). See * [PgCatalogLoader.nonNullIffFirstArgumentNonNullFunctionOids] for the safe-list this is normally * backed by, and why it is intentionally separate from [isAlwaysNonNull]. Also consulted by * [isSafeFromGroupingSetNullExtension] for the identical non-`VARIADIC` `FuncExpr` shape. @@ -70,7 +70,7 @@ import norm.generator.NodeTreeNullabilityAnalyzer.Companion.MAX_EXPRESSION_DEPTH * without grouping sets. * @param isSubLinkSubqueryColumnNotNull Returns `true` when [subselectBlock] — the raw `{QUERY ...}` * text of an `ANY_SUBLINK`'s or `ALL_SUBLINK`'s `:subselect` (see - * [PgNodeExpression.SubLink.subselectBlock]) — produces EXACTLY ONE non-junk output column and + * [PgNodeExpression.SubLink.subselectBlock]) — produces exactly one non-junk output column and * that column is provably non-null. Used by [isNonNull]'s `SubLink` branch as the third, most * expensive leg of the identical `ANY_SUBLINK`/`ALL_SUBLINK` nullability rule (see that branch's * own comment for the full three-condition rule and why each condition is required). Defaults to @@ -80,15 +80,15 @@ import norm.generator.NodeTreeNullabilityAnalyzer.Companion.MAX_EXPRESSION_DEPTH * `ColumnNullabilityAnalyzer`'s wiring of this callback for that budget). * @param forceNewNullable `true` when a `RETURNING WITH (OLD AS o, NEW AS n)` reference to `NEW` * (`Var.returningType == `[PgNodeExpression.VAR_RETURNING_TYPE_NEW]`) must be treated as - * unconditionally nullable, the same way [isNonNull]'s `Var` branch ALWAYS treats `OLD` + * unconditionally nullable, the same way [isNonNull]'s `Var` branch always treats `OLD` * (`VAR_RETURNING_TYPE_OLD`) regardless of this flag. Set by the caller when the enclosing - * statement is a plain `DELETE` (`NEW` never exists — the row is gone — verified live: `NEW.col` + * statement is a plain `DELETE` (`NEW` never exists — the row is gone; `NEW.col` * is `NULL` for every row a `DELETE` returns) or a `MERGE` (an individual result row's `NEW` may * or may not exist depending on which `WHEN` clause matched — e.g. `WHEN MATCHED THEN DELETE` * leaves no `NEW` row — a fact this analyzer cannot isolate per-row any more than it can for an * ordinary, non-`OLD`/`NEW` `MERGE` column; see [PgCatalogLoader.mergeAbsentVarnos]'s * KDoc for that companion safety net). Left `false` (the default) for a plain `UPDATE`/`INSERT`, - * where the row a `RETURNING` clause reports on always has BOTH an `OLD` and a `NEW` state, so + * where the row a `RETURNING` clause reports on always has both an `OLD` and a `NEW` state, so * `NEW` is exactly as trustworthy as an ordinary column reference. */ internal class NodeTreeNullabilityAnalyzer( @@ -123,9 +123,9 @@ internal class NodeTreeNullabilityAnalyzer( * expression, not a `Var` referencing the synthesized `*GROUP*` RTE — so every rule below * ([groupingSortGroupRefs], [groupingKeyExpressions], [isEffectivelyNonNull], * [isSafeFromGroupingSetNullExtension], [isNonNull]) runs identically regardless of which - * PostgreSQL version produced [nodeTreeText]. This applies to a PLAIN `GROUP BY` exactly as much + * PostgreSQL version produced [nodeTreeText]. This applies to a plain `GROUP BY` exactly as much * as to `GROUPING SETS`/`CUBE`/`ROLLUP` — PostgreSQL 18 creates a GROUP RTE for a plain `GROUP BY` - * too (verified live) — regardless of [hasGroupingSets]. + * too — regardless of [hasGroupingSets]. * * CTE column resolution is handled by the caller through the [isSourceColumnNotNull] callback. * The caller must include CTE column not-null information in this callback so that VAR nodes @@ -168,7 +168,7 @@ internal class NodeTreeNullabilityAnalyzer( * override before falling back to ordinary [isNonNull] evaluation. * * When [hasGroupingSets] is `true`, [entry] is forced nullable when any of: - * - [entry] IS a grouping key itself: its [TargetEntry.sortGroupRef] is non-zero and appears in + * - [entry] is a grouping key itself: its [TargetEntry.sortGroupRef] is non-zero and appears in * [groupingSortGroupRefs] (from [PgNodeTreeParser.parseGroupingSortGroupRefs]); or * - [entry]'s expression structurally equals one of [groupingKeyExpressions] — a *duplicate* * occurrence of a grouping key expression that PostgreSQL did not assign the matching @@ -176,7 +176,7 @@ internal class NodeTreeNullabilityAnalyzer( * one above, not a redundant restatement of it); or * - [entry]'s expression is not proven [isSafeFromGroupingSetNullExtension]. * - * All three conditions are load-bearing and independent. The first alone would miss a *derived* + * All three conditions are necessary and independent. The first alone would miss a *derived* * expression over a key (e.g. `upper(lower(a))` when the key is `lower(a)`, which has * `sortGroupRef == 0` — it does not match the key textually, only structurally, which * [isSafeFromGroupingSetNullExtension] is what actually catches). The second is not subsumed by @@ -185,22 +185,22 @@ internal class NodeTreeNullabilityAnalyzer( * the expression *as a whole* being wholesale swapped for `NULL` because it happens to * structurally repeat the grouping key. The third would miss a bare-`Const` grouping key (e.g. * `GROUP BY ROLLUP('ALL'::text)`) — a `Const` is [isSafeFromGroupingSetNullExtension] by - * definition (see that method), yet PostgreSQL still null-extends it when it IS the grouping + * definition (see that method), yet PostgreSQL still null-extends it when it is the grouping * key, which only the first condition (or, for an unref'd duplicate Const, the second) catches. * * @param groupingKeyExpressions the expressions of every entry whose own [TargetEntry.sortGroupRef] - * IS a grouping key (per [groupingSortGroupRefs]), excluding any that are a bare + * is a grouping key (per [groupingSortGroupRefs]), excluding any that are a bare * [PgNodeExpression.Const] or that [foldsToConst] — PostgreSQL's structural matching * (`search_indexed_tlist_for_non_var` in `setrefs.c`) explicitly refuses to match a `Const` * node (see [isSafeFromGroupingSetNullExtension]'s KDoc), and an expression that folds to a * `Const` before that matching pass runs (e.g. `upper('a')`) is, by the time the pass runs, - * already a `Const` too — verified live on EVERY supported version (16, 17, and 18): `SELECT + * already a `Const` too — true on every supported version (16, 17, and 18): `SELECT * upper('a') AS u1, upper('a') AS u2, ... GROUP BY ROLLUP(upper('a'))` leaves the un-ref'd * duplicate `u2` as `'A'`, never `NULL`, in the ROLLUP summary row, unlike a duplicate that - * does NOT fold (see the `date_trunc` case in [isSafeFromGroupingSetNullExtension]'s KDoc, - * where BOTH occurrences are null-extended). Without this exclusion, a duplicate literal like + * does not fold (see the `date_trunc` case in [isSafeFromGroupingSetNullExtension]'s KDoc, + * where both occurrences are null-extended). Without this exclusion, a duplicate literal like * `SELECT 'ALL'::text AS l1, 'ALL'::text AS l2, ... GROUP BY ROLLUP('ALL'::text)` would be - * wrongly forced nullable for `l2` — verified live on every supported version that `l2` stays + * wrongly forced nullable for `l2` on every supported version: `l2` stays * `'ALL'`, never `NULL`, even though `l1` (the ref'd occurrence) does become `NULL`. * * On PostgreSQL 18, [entry] arrives here already having been run through @@ -248,7 +248,7 @@ internal class NodeTreeNullabilityAnalyzer( * under it. * - `Const` — PostgreSQL's grouping-key matching specifically refuses to match a bare constant * (there would be no point: a constant is trivially recomputable), so a lone `Const` is never - * itself null-extended. This does NOT extend to a `Const` wrapped in a non-folded coercion + * itself null-extended. This does not extend to a `Const` wrapped in a non-folded coercion * chain (e.g. a `text`-to-`timestamptz` cast, which is a real function call, not a no-op) — * that wrapping expression is a stable, matchable subexpression like any other, and the `Const` * underneath it does not make it safe. @@ -259,9 +259,9 @@ internal class NodeTreeNullabilityAnalyzer( * `Aggref`/`GroupingFunc` → safe (matches the first special case); `Const` → safe (matches the * second, though [foldsToConst] already subsumes it); `WindowFunc` → safe iff every child is * itself safe (a window function can never itself be a grouping key — window functions, like - * aggregates, are illegal inside `GROUP BY` — but unlike `Aggref` it does NOT get blanket safety: + * aggregates, are illegal inside `GROUP BY` — but unlike `Aggref` it does not get blanket safety: * its arguments are evaluated over already-grouped, potentially null-extended rows, e.g. - * `first_value(b) OVER (...)` is genuinely nullable, verified live — see the ground truth in + * `first_value(b) OVER (...)` is genuinely nullable — see the ground truth in * `QueryAnalysisTest`); everything else, **including a bare `Var`**, → safe iff `e`'s parsed * descendants include at least one `Aggref`/`GroupingFunc`/`WindowFunc` (per the first special * case — a `WindowFunc` counts here too, since it likewise can never itself be a grouping key) AND @@ -270,8 +270,8 @@ internal class NodeTreeNullabilityAnalyzer( * descendants, so it is never safe under this rule (correct: a bare column reference is exactly * what a grouping key most commonly is, or is derived from). `count(*) + 1` is safe (its `OpExpr` * has an `Aggref` descendant and both children — `Aggref`, `Const` — are themselves safe); - * `count(*) || some_stable_cast(a_const)` is NOT safe, because the cast side has no - * `Aggref`/`WindowFunc` descendant AND does not [foldsToConst] (a stable cast survives constant + * `count(*) || some_stable_cast(a_const)` is not safe, because the cast side has no + * `Aggref`/`WindowFunc` descendant and does not [foldsToConst] (a stable cast survives constant * folding) even though the `||` as a whole has an `Aggref` — safety is required of every child * independently, not just the subtree as a whole, otherwise a matchable non-aggregate side would * be missed. @@ -279,7 +279,7 @@ internal class NodeTreeNullabilityAnalyzer( * This walk is only sound for a [PgNodeExpression] subtype whose parsed representation retains * every child expression the underlying Postgres node actually has — for a subtype that drops a * child, this method cannot rule out an unseen child changing the answer. - * [PgNodeExpression.CaseExpr] is the motivating example that IS handled faithfully: + * [PgNodeExpression.CaseExpr] is the motivating example that is handled faithfully: * [PgNodeExpression.CaseExpr.testExpression] and [PgNodeExpression.CaseExpr.whenConditions] exist * on that type purely so this walk (not [isNonNull], which correctly ignores them, since a `CASE` * result's nullability never depends on its own test/condition expressions) can see a `Var` that @@ -291,64 +291,64 @@ internal class NodeTreeNullabilityAnalyzer( * retain a `JSON_VALUE`/`JSON_QUERY`/`JSON_EXISTS` `PASSING` clause's values, so a `Var` living * only there (e.g. `JSON_EXISTS(doc, '\$.a ? (@ == \$v)' PASSING a AS v)`) is invisible; parsing * the `PASSING` clause was deliberately not attempted (parser work against an unconfirmed node - * shape for marginal precision gain). Both are therefore hardcoded UNSAFE unconditionally, + * shape for marginal precision gain). Both are therefore hardcoded unsafe unconditionally, * regardless of an `Aggref` elsewhere in the tree — an `Aggref` sibling cannot rescue a subtree * that might independently contain a hidden, matchable `Var`. The same treatment applies once * [depth] is exhausted. * - * A fourth, independent leg alongside [foldsToConst]: a NON-`VARIADIC` [PgNodeExpression.FuncExpr] + * A fourth, independent leg alongside [foldsToConst]: a non-`VARIADIC` [PgNodeExpression.FuncExpr] * whose function is [isAlwaysNonNull] (e.g. `concat` — see - * [PgCatalogLoader.alwaysNonNullFunctionOids]) is safe from having its OWN RESULT forced `null` + * [PgCatalogLoader.alwaysNonNullFunctionOids]) is safe from having its own result forced `null` * by a deeper subexpression being null-extended — by that list's own definition, `concat` renders - * a `null` argument as an empty string, so null-extending one of its ARGUMENTS (e.g. `a` inside - * `concat(a, '-')` when `a` alone, not the whole `concat` call, is the grouping key — verified - * live, PostgreSQL 16/17/18: `concat(a, '-')` stays `'-'`, never `null`, in that case) cannot make - * the call's result `null`. This is a DIFFERENT scenario from `concat(a, '-')` ITSELF being + * a `null` argument as an empty string, so null-extending one of its arguments (e.g. `a` inside + * `concat(a, '-')` when `a` alone, not the whole `concat` call, is the grouping key — PostgreSQL + * 16-18: `concat(a, '-')` stays `'-'`, never `null`, in that case) cannot make + * the call's result `null`. This is a different scenario from `concat(a, '-')` itself being * null-extended wholesale because it structurally repeats the grouping key expression (e.g. - * `GROUP BY ROLLUP(concat(a, '-'))`, verified live to null-extend a duplicate, un-ref'd + * `GROUP BY ROLLUP(concat(a, '-'))` null-extends a duplicate, un-ref'd * occurrence too, not just the one PostgreSQL attached `ressortgroupref` to) — there, - * PostgreSQL's substitution replaces the ENTIRE call's result before `concat` ever runs, so its + * PostgreSQL's substitution replaces the entire call's result before `concat` ever runs, so its * argument-null-tolerance is irrelevant and provides no protection. This leg does not (and, from * inside a single expression's own subtree, structurally cannot) distinguish the two; ruling out * the second is [isEffectivelyNonNull]'s job via its `groupingKeyExpressions` structural-duplicate * check, which this leg's safety claim depends on to stay sound. Deferring to ordinary [isNonNull] * evaluation for the first scenario independently reaches the same conclusion via the identical * [isAlwaysNonNull] check in its own [PgNodeExpression.FuncExpr] branch. `concat_ws` is - * deliberately NOT on [isAlwaysNonNull]'s list — despite also being non-strict, it is non-null + * deliberately not on [isAlwaysNonNull]'s list — despite also being non-strict, it is non-null * only when its first argument (the separator) is non-null, so it gets no dedicated leg here and * falls through to the generic aggregate/window domination rule below like any other `FuncExpr`, - * where a `Var` in ANY of its argument positions — including the separator — correctly makes it - * unsafe; see [PgCatalogLoader.alwaysNonNullFunctionOids]'s KDoc for why this distinction is - * load-bearing. The `VARIADIC` exclusion matters for the same reason [isNonNull] excludes it: - * `concat(VARIADIC arr)` is `null` when `arr` itself is `null` (verified live on PostgreSQL 16, - * 17, and 18) — a `VARIADIC` call gets NO short-circuit here at all and falls through to the + * where a `Var` in any of its argument positions — including the separator — correctly makes it + * unsafe; see [PgCatalogLoader.alwaysNonNullFunctionOids]'s KDoc for why this distinction matters. + * The `VARIADIC` exclusion matters for the same reason [isNonNull] excludes it: + * `concat(VARIADIC arr)` is `null` when `arr` itself is `null` (PostgreSQL 16-18) + * — a `VARIADIC` call gets no short-circuit here at all and falls through to the * generic rule, where its sole argument (the array — a `Var` for a column, or an `ArrayExpr` * for a literal) is evaluated on its own merits, correctly unsafe if it can be null-extended. - * This does NOT extend to a function merely on the (much - * larger, STRICT-only) [isNeverNullForNonNullInput] safe-list — that list only proves totality - * for NON-NULL arguments, and says nothing about whether the function's result stays non-null - * when one of ITS OWN ARGUMENTS is individually null-extended (the first scenario above), which + * This does not extend to a function merely on the (much + * larger, strict-only) [isNeverNullForNonNullInput] safe-list — that list only proves totality + * for non-null arguments, and says nothing about whether the function's result stays non-null + * when one of its own arguments is individually null-extended (the first scenario above), which * is exactly the scenario this leg exists to guard against for the (much narrower) functions - * that ARE on [isAlwaysNonNull]'s list. + * that are on [isAlwaysNonNull]'s list. * * Several `when` branches below answer the same argument-independence question the fourth leg does: * null-extension substitutes `NULL` for an ARGUMENT's value, so any rule that proves a result * non-null without consulting its arguments' nullability already answers it. The - * [isNonNullIffFirstArgumentNonNull] branch is the one conditional case — safe iff its FIRST + * [isNonNullIffFirstArgumentNonNull] branch is the one conditional case — safe iff its first * argument is itself safe, since a `concat_ws` separator can be a grouping key. * [immuneByNoGroupingKeyMatch] is a structurally different leg, for constructs with no * per-node-kind rule at all, e.g. `now()`. * * `XML_IS_XMLFOREST` and `XML_IS_XMLPI` are excluded because neither is total over `null` input * (measurements in [evaluateXmlExpr]); admitting them produced a real wrong non-null — `SELECT - * xmlforest(lower(a) AS q), count(*) FROM t2 GROUP BY ROLLUP(a)`, which live PostgreSQL 16, 17 and + * xmlforest(lower(a) AS q), count(*) FROM t2 GROUP BY ROLLUP(a)`, which PostgreSQL 16, 17 and * 18 all return `NULL` for in the rollup summary row. PostgreSQL has no equality operator for `xml` - * or for `json` (verified live), so neither an `XmlExpr` nor a default-`RETURNING` - * `JSON_OBJECT`/`JSON_ARRAY` (which yields `json`) can itself BE a grouping key; `jsonb` does have + * or for `json`, so neither an `XmlExpr` nor a default-`RETURNING` + * `JSON_OBJECT`/`JSON_ARRAY` (which yields `json`) can itself be a grouping key; `jsonb` does have * one, so a `RETURNING jsonb` call can be. * * The self-match guard runs before every leg because the legs prove only that an expression's own - * result survives null-extension of a DEEPER subexpression, never that the expression itself is not + * result survives null-extension of a deeper subexpression, never that the expression itself is not * replaced by `NULL` wholesale. Running it at every node the walk reaches, not only at * [isEffectivelyNonNull]'s entry root, fixes a measured wrong non-null: `SELECT count(*)::text || * concat(a, b) FROM t2 GROUP BY ROLLUP(concat(a, b))` reported non-null where live PostgreSQL 16 @@ -357,7 +357,7 @@ internal class NodeTreeNullabilityAnalyzer( * @param groupingKeyExpressions the same set [isEffectivelyNonNull] receives; see that method's * identically-named parameter. * @param depth remaining recursion budget, mirroring [MAX_EXPRESSION_DEPTH]; returns `false` - * (safe: assume UNSAFE — i.e. possibly null-extended) once exhausted + * (assume unsafe — i.e. possibly null-extended) once exhausted */ internal fun isSafeFromGroupingSetNullExtension( expression: PgNodeExpression, @@ -448,22 +448,22 @@ internal class NodeTreeNullabilityAnalyzer( * one, or a [PgNodeExpression.ArrayExpr] whose every element does. * * This matters because PostgreSQL's grouping-set null-extension substitution - * (`search_indexed_tlist_for_non_var` in `setrefs.c`) runs at the END of planning and explicitly + * (`search_indexed_tlist_for_non_var` in `setrefs.c`) runs at the end of planning and explicitly * refuses to match a `Const` (`if (IsA(node, Const)) return NULL`), while constant folding itself - * (`eval_const_expressions`, from `preprocess_expression`) runs EARLY, well before that + * (`eval_const_expressions`, from `preprocess_expression`) runs early, well before that * substitution. An expression that will fold to a `Const` by the time the substitution runs was - * therefore NEVER a candidate for it — safe regardless of whether it contains an + * therefore never a candidate for it — safe regardless of whether it contains an * `Aggref`/`GroupingFunc`/`WindowFunc`, unlike the general rule in [isSafeFromGroupingSetNullExtension]. * * IMMUTABLE only. A STABLE function — e.g. `date_trunc('month', current_date)`, which depends on - * the current date — is NOT constant-folded, survives to become a genuine, matchable - * subexpression, and PostgreSQL DOES null-extend it when it matches a grouping key (verified - * live). VOLATILE is not safe either — `GROUP BY random()` is legal SQL, and the key-matching - * itself is structural (`equal()`), not a volatility check. + * the current date — is not constant-folded, survives to become a genuine, matchable + * subexpression, and PostgreSQL does null-extend it when it matches a grouping key. VOLATILE is + * not safe either — `GROUP BY random()` is legal SQL, and the key-matching itself is structural + * (`equal()`), not a volatility check. * * [PgNodeExpression.CoerceViaIo], [PgNodeExpression.CoerceToDomain], * [PgNodeExpression.ArrayCoerceExpr], [PgNodeExpression.RowExpr], - * [PgNodeExpression.SqlValueFunction], and [PgNodeExpression.NextValExpr] are deliberately NOT + * [PgNodeExpression.SqlValueFunction], and [PgNodeExpression.NextValExpr] are deliberately not * treated as foldable: none of them expose a function/operator OID this class can check * immutability for (unlike [PgNodeExpression.FuncExpr]/[PgNodeExpression.OpExpr]), so treating any * of them as folding would be an unverified guess. This matters concretely for @@ -605,16 +605,16 @@ internal class NodeTreeNullabilityAnalyzer( // "regardless of any argument" and isNonNullIffFirstArgumentNonNull's "only the // first argument matters" both assume the ordinary calling form and are unsound // here: concat(VARIADIC arr) and concat_ws(',', VARIADIC arr) are both null when - // arr itself is null (verified live on PostgreSQL 16, 17, and 18). Requiring every + // arr itself is null (PostgreSQL 16-18). Requiring every // argument non-null is sound for both functions in this form (the separator is // still one of the arguments) and preserves real precision (concat_ws(',', // VARIADIC ARRAY['a', NULL]) is 'a', still non-null). expression.arguments.all(recurse) } else { - // Deliberately does NOT fall through to the isStrict/isNeverNullForNonNullInput leg + // Deliberately does not fall through to the isStrict/isNeverNullForNonNullInput leg // below. That safe-list's "total on non-null input" guarantee (see // PgCatalogLoader.neverNullForNonNullInputOids's KDoc and SafeListSweepTest) was - // verified for the ORDINARY, element-wise calling convention. For a VARIADIC call, + // verified for the ordinary, element-wise calling convention. For a VARIADIC call, // "every argument non-null" only means the array Datum itself is non-null — // recurse() on the array (an ArrayExpr) is unconditionally true regardless of NULL // elements inside it (an array container is never NULL merely because one of its @@ -660,12 +660,12 @@ internal class NodeTreeNullabilityAnalyzer( expression.subLinkType == PgNodeExpression.SUBLINK_TYPE_ARRAY || // ANY_SUBLINK (`x = ANY (subquery)` / `x IN (subquery)`) is three-valued: PostgreSQL // returns NULL, not FALSE, when the subquery yields a NULL row and no row matches — - // verified live on PostgreSQL 17: `CREATE TABLE t (id INT PRIMARY KEY, a TEXT NOT NULL); + // on PostgreSQL 17: `CREATE TABLE t (id INT PRIMARY KEY, a TEXT NOT NULL); // CREATE TABLE u (v TEXT); INSERT INTO t VALUES (1,'x'); INSERT INTO u VALUES ('q'), // (NULL); SELECT a = ANY (SELECT v FROM u) FROM t;` is NULL, not FALSE. Proving a - // non-null result therefore requires ALL THREE of: the outer operand is non-null (an + // non-null result therefore requires all three of: the outer operand is non-null (an // ANY_SUBLINK with a null outer operand is NULL outright, same as any comparison); the - // comparison operator behind the sublink is both isStrict AND isNeverNullForNonNullInput + // comparison operator behind the sublink is both isStrict and isNeverNullForNonNullInput // — a non-strict or non-total operator could itself manufacture a NULL from non-null // operands, same two-predicate proof OpExpr/ScalarArrayOpExpr require above; and the // subquery's single output column is itself provably non-null — a NULL row in the @@ -677,7 +677,7 @@ internal class NodeTreeNullabilityAnalyzer( // computed above; the two OID predicates are cheap map lookups; isSubLinkSubqueryColumnNotNull // is the only leg that re-enters full query-block analysis, so it is checked last. // - // ALL_SUBLINK (`x op ALL (subquery)`) gets the IDENTICAL proof, being ANY's dual over AND + // ALL_SUBLINK (`x op ALL (subquery)`) gets the identical proof, being ANY's dual over AND // instead of OR: `x op ALL (S)` is NULL only when some comparison is NULL and none is FALSE, // which the same three conditions rule out. An empty S is TRUE for ALL (FALSE for ANY) — // non-null either way, so no empty-subquery case needs handling. `NOT IN` desugars to a @@ -763,7 +763,7 @@ internal class NodeTreeNullabilityAnalyzer( * [PgNodeExpression.Aggref]/[PgNodeExpression.WindowFunc] in * [PgNodeExpression.JsonConstructorExpr.function] is recursed into instead, reaching the existing * rules that already report an aggregate over an empty group nullable. - * - `PARSE`/`SCALAR`/`SERIALIZE`: strict single-argument constructs, non-null only when there IS an + * - `PARSE`/`SCALAR`/`SERIALIZE`: strict single-argument constructs, non-null only when there is an * argument and every argument is non-null. This is the original bug report: `JSON_SERIALIZE` was * reported non-null regardless of its argument. * - Any other code falls through to `false` (nullable), the safe default. @@ -803,9 +803,9 @@ internal class NodeTreeNullabilityAnalyzer( } // JSON_VALUE unwraps a path match to an SQL/JSON `null` value into a genuine SQL NULL. That is - // a SUCCESSFUL match, not the "no match" (EMPTY)/"error" (ERROR) case the ON EMPTY/ON ERROR + // a successful match, not the "no match" (EMPTY)/"error" (ERROR) case the ON EMPTY/ON ERROR // clauses control, so no combination of those codes rules the successful-match-to-JSON-null - // case out. Verified live (PostgreSQL 17 and 18): `JSON_VALUE('{"name": null}'::jsonb, '$.name' + // case out. PostgreSQL 17 and 18: `JSON_VALUE('{"name": null}'::jsonb, '$.name' // RETURNING TEXT ERROR ON EMPTY ERROR ON ERROR) IS NULL` is `true`. PgNodeExpression.JSON_VALUE_OP -> false @@ -813,32 +813,32 @@ internal class NodeTreeNullabilityAnalyzer( } /** - * Returns `true` only for a `JsonBehaviorType` code POSITIVELY VERIFIED (live, PostgreSQL 17 and + * Returns `true` only for a `JsonBehaviorType` code confirmed (on PostgreSQL 17 and * 18) to make a `JSON_QUERY` `ON EMPTY`/`ON ERROR` clause produce a definite, non-null outcome — - * an ALLOW-list. An unrecognized code defaults to nullable, the safe direction. + * an allow-list. An unrecognized code defaults to nullable, the safe direction. * * The four allowed codes and why each is safe: * - [PgNodeExpression.JSON_BEHAVIOR_ERROR]: raises a runtime error rather than returning a value at - * all for the row. Verified live: `:expr <>` (absent) for this code, so + * all for the row. `:expr` is absent for this code, so * [PgNodeExpression.JsonExpr.onEmptyDefault]/[PgNodeExpression.JsonExpr.onErrorDefault] parses to * `null` here, and `null?.let(recurse) != false` is `true` unconditionally — no default * expression exists to recurse into. * - [PgNodeExpression.JSON_BEHAVIOR_EMPTY_ARRAY]/[PgNodeExpression.JSON_BEHAVIOR_EMPTY_OBJECT]: * substitute Postgres's own internal `[]`/`{}` `jsonb` constant, never a user-supplied - * expression. Verified live: `:expr` for these codes is always a `{CONST ... :constisnull + * expression. `:expr` for these codes is always a `{CONST ... :constisnull * false ...}` block, so `recurse` on it is unconditionally `true` — checked anyway, defensively, * rather than special-cased to skip [PgNodeExpression.JsonExpr.onEmptyDefault]/`onErrorDefault` * entirely. - * - [PgNodeExpression.JSON_BEHAVIOR_DEFAULT]: the ONE code among these four backed by a genuinely - * user-supplied expression (`DEFAULT expr ON EMPTY`/`ON ERROR`) — verified live to always carry + * - [PgNodeExpression.JSON_BEHAVIOR_DEFAULT]: the one code among these four backed by a genuinely + * user-supplied expression (`DEFAULT expr ON EMPTY`/`ON ERROR`) — it always carries * a real `:expr` block, which is why [emptyOk]/[errorOk] recurse into it rather than trusting the - * behavior code alone; `DEFAULT null::jsonb ON EMPTY` is legal and must NOT be treated as + * behavior code alone; `DEFAULT null::jsonb ON EMPTY` is legal and must not be treated as * non-null. * - * Deliberately NOT on this list: [PgNodeExpression.JSON_BEHAVIOR_NULL] (explicitly nullable by - * definition); `JSON_BEHAVIOR_TRUE`/`FALSE`/`UNKNOWN` — verified live that Postgres rejects all + * Deliberately not on this list: [PgNodeExpression.JSON_BEHAVIOR_NULL] (explicitly nullable by + * definition); `JSON_BEHAVIOR_TRUE`/`FALSE`/`UNKNOWN` — Postgres rejects all * three for a `JSON_QUERY` `ON EMPTY`/`ON ERROR` clause outright, so they never appear here; and - * `JSON_TABLE`'s per-column `ON EMPTY`/`ON ERROR` — verified live that a `JSON_TABLE` column + * `JSON_TABLE`'s per-column `ON EMPTY`/`ON ERROR` — a `JSON_TABLE` column * resolves to a plain `VAR` against an `RTE_TABLEFUNC` range-table entry in the outer query's * target list, never a [PgNodeExpression.JsonExpr] node this method ever sees, so this allow-list * has no bearing on it either way. @@ -850,16 +850,16 @@ internal class NodeTreeNullabilityAnalyzer( behaviorType == PgNodeExpression.JSON_BEHAVIOR_DEFAULT /** - * Returns `true` only for a `JsonBehaviorType` code POSITIVELY VERIFIED (live, PostgreSQL 17) to + * Returns `true` only for a `JsonBehaviorType` code confirmed (on PostgreSQL 17) to * make `JSON_EXISTS`'s `ON ERROR` clause produce a definite, non-null (`true`/`false`) outcome, or * raise an error rather than returning a value at all. `JSON_EXISTS` has no `ON EMPTY` clause. * * [PgNodeExpression.JSON_BEHAVIOR_TRUE]/[PgNodeExpression.JSON_BEHAVIOR_FALSE] are the codes for an - * explicit `TRUE`/`FALSE ON ERROR` clause. Verified live: with no `ON ERROR` clause written at + * explicit `TRUE`/`FALSE ON ERROR` clause. With no `ON ERROR` clause written at * all, Postgres materializes `:btype 4` ([PgNodeExpression.JSON_BEHAVIOR_FALSE]) — the SQL-standard * default — so an absent clause is exactly as safe as writing `FALSE ON ERROR` explicitly. - * Deliberately NOT on this list: [PgNodeExpression.JSON_BEHAVIOR_UNKNOWN], which produces a - * genuine SQL NULL on a path error (verified live), and [PgNodeExpression.JSON_BEHAVIOR_NULL]/ + * Deliberately not on this list: [PgNodeExpression.JSON_BEHAVIOR_UNKNOWN], which produces a + * genuine SQL NULL on a path error, and [PgNodeExpression.JSON_BEHAVIOR_NULL]/ * `EMPTY_ARRAY`/`EMPTY_OBJECT`/`DEFAULT`, which Postgres's parser rejects outright for * `JSON_EXISTS`'s `ON ERROR` clause and so never appear here. */ @@ -871,14 +871,14 @@ internal class NodeTreeNullabilityAnalyzer( /** * Evaluates an `XMLELEMENT`/`XMLFOREST`/`XMLPI`/`XMLCONCAT`/`XMLROOT`/`XMLPARSE`/`XMLSERIALIZE` * construct, branching on [PgNodeExpression.XmlExpr.op]. Only `XMLELEMENT` is total over `null` - * input. Measured live on PostgreSQL 16, 17 and 18, which agree: - * - `xmlelement(name e, NULL::text)` is NOT `null` — a null child renders as empty content, and a + * input. PostgreSQL 16, 17 and 18 agree: + * - `xmlelement(name e, NULL::text)` is not `null` — a null child renders as empty content, and a * null `xmlattributes` value omits that attribute, so the element tag itself always materializes. - * - `xmlforest(NULL::text AS q)` IS `null`, while `xmlforest(NULL::text AS q, 'x' AS r)` is NOT — - * a null field is omitted and the result nulls only once EVERY field is gone, hence + * - `xmlforest(NULL::text AS q)` is `null`, while `xmlforest(NULL::text AS q, 'x' AS r)` is not — + * a null field is omitted and the result nulls only once every field is gone, hence * [Iterable.any]. `xmlforest()` is a syntax error, and `any` on an empty list would answer * `false` (nullable) anyway. - * - `xmlpi(name php, NULL::text)` IS `null`, while the content-less `xmlpi(name php)` is NOT, which + * - `xmlpi(name php, NULL::text)` is `null`, while the content-less `xmlpi(name php)` is not, which * [Iterable.all] states exactly: vacuously `true` for the zero-argument form, content required * otherwise. * @@ -933,9 +933,9 @@ internal class NodeTreeNullabilityAnalyzer( } /** - * `true` if [expression] contains an ORDINARY `Var` (`returningType == 0` — i.e. NOT an `OLD` + * `true` if [expression] contains an ordinary `Var` (`returningType == 0` — i.e. not an `OLD` * or `NEW` reference, which carry their own independent, already-safe handling — see - * [PgNodeExpression.Var.returningType]'s KDoc) whose `varno` is anything OTHER THAN + * [PgNodeExpression.Var.returningType]'s KDoc) whose `varno` is anything other than * [relationVarno]. * * Used by [PgCatalogLoader.mergeAbsentVarnos]'s caller to decide whether a `MERGE`'s @@ -948,7 +948,7 @@ internal class NodeTreeNullabilityAnalyzer( * when the `RETURNING` list never actually depended on knowing which side that join favors. * * Exhausts every [PgNodeExpression] variant explicitly, so the compiler's own exhaustiveness - * check over the sealed [PgNodeExpression] hierarchy guarantees every node type is LISTED here. + * check over the sealed [PgNodeExpression] hierarchy guarantees every node type is listed here. * That guarantee is necessary but not sufficient: exhaustiveness only proves no variant was * left out of the `when`, not that a listed variant's own child expressions are walked. A * variant that genuinely carries no [PgNodeExpression] child (e.g. [PgNodeExpression.Const]) @@ -956,7 +956,7 @@ internal class NodeTreeNullabilityAnalyzer( * `argument`) must recurse into every such child, or a `Var` buried inside it silently * disappears from this check — this exact mistake, for [PgNodeExpression.JsonExpr], is what * let a `MERGE`'s `RETURNING JSON_QUERY(source.column, ...)` skip `EXPLAIN` resolution and - * report a genuinely nullable expression as NOT NULL. + * report a genuinely nullable expression as not null. * * @param depth remaining recursion budget; exhausting it answers `true` (needs resolving) * rather than `false`, the same fail-toward-conservative default every depth guard in this diff --git a/generator/src/main/kotlin/norm/generator/NodeTreeProvenance.kt b/generator/src/main/kotlin/norm/generator/NodeTreeProvenance.kt index 513725d1..ccd0f19e 100644 --- a/generator/src/main/kotlin/norm/generator/NodeTreeProvenance.kt +++ b/generator/src/main/kotlin/norm/generator/NodeTreeProvenance.kt @@ -16,7 +16,7 @@ private const val MAX_PROVENANCE_CHAIN_DEPTH = 50 * * [resolveNodeTreeProvenanceExpression] replays a [NodeTreeColumnProvenance.hops] list against the * user's own SQL text, so a nested `WITH` that shadows an outer CTE of the same name resolves to the - * declaration actually used, not merely a same-named one elsewhere in the statement (#238). + * declaration actually used, not merely a same-named one elsewhere in the statement. */ internal data class CteHop(val name: String, val ctelevelsup: Int) @@ -29,8 +29,8 @@ internal data class CteHop(val name: String, val ctelevelsup: Int) * can shadow an outer CTE of the same name with a different body; only replaying the whole path * distinguishes which declaration was meant. Always non-empty. * - * Deliberately not the expression text itself: resolution answers only "where"; extracting and - * cross-validating the source text against the user's original SQL is a separate step. + * Not the expression text itself: resolution answers only "where"; extracting and cross-validating + * the source text against the user's original SQL is a separate step. * * @property cteName The last hop's CTE name, for a caller that only needs "where the text lives". */ @@ -108,12 +108,12 @@ internal class NodeTreeProvenanceResolver(private val parser: PgNodeTreeParser = * [scopeStack] tracks lexical `WITH`-clause nesting: index `0` is [queryBlock]'s own `:cteList`, * index `1` the block one level up that declared it, and so on. Scope belongs to a CTE's * declaration site, not to the hop path taken to reach it — hopping into a sibling CTE declared in - * the SAME `:cteList` is not a nesting level, matching PostgreSQL's own `:ctelevelsup` for that + * the same `:cteList` is not a nesting level, matching PostgreSQL's own `:ctelevelsup` for that * reference. So resolving a reference against `scopeStack[reference.ctelevelsup]` and then * entering that CTE's body rebuilds the stack as `scopeStack.drop(reference.ctelevelsup)` with the * body's own `:cteList` pushed on front, rather than prepending onto the full accumulated * [scopeStack]; otherwise stale frames attribute a chained reference to the wrong same-named CTE, - * and a chain of three or more sibling CTEs resolves to nothing (#238). + * and a chain of three or more sibling CTEs resolves to nothing. * * A `:ctelevelsup` deeper than [scopeStack] bails rather than reading past what has been tracked — * a real reference's levelsup can never exceed the number of `WITH` clauses actually enclosing it. @@ -158,7 +158,7 @@ internal class NodeTreeProvenanceResolver(private val parser: PgNodeTreeParser = currentQueryBlock = definition.queryBlock val ownScope = parser.parseCteList(definition.queryBlock).associateBy { it.name } // drop, not prepend-onto-the-full-stack: see this method's KDoc on why scope belongs to - // the declaration site (reference.ctelevelsup levels up from HERE), never the hop path. + // the declaration site (reference.ctelevelsup levels up from here), never the hop path. currentScopeStack = listOf(ownScope) + currentScopeStack.drop(reference.ctelevelsup) currentVar = bodyVar } diff --git a/generator/src/main/kotlin/norm/generator/NodeTreeProvenanceExpression.kt b/generator/src/main/kotlin/norm/generator/NodeTreeProvenanceExpression.kt index da9ad7fc..0b1dcdd2 100644 --- a/generator/src/main/kotlin/norm/generator/NodeTreeProvenanceExpression.kt +++ b/generator/src/main/kotlin/norm/generator/NodeTreeProvenanceExpression.kt @@ -4,19 +4,19 @@ package norm.generator * Extracts, from the developer's own original SQL text, the expression a [NodeTreeColumnProvenance] * points at. * - * [sql] must be the query's ORIGINAL text — never [nodeTreeText]'s sentinel-substituted or deparsed + * [sql] must be the query's original text — never [nodeTreeText]'s sentinel-substituted or deparsed * form — so a sentinel literal built only to satisfy `?`'s type during analysis can never leak into - * generated KDoc. [nodeTreeText] is trusted only for WHERE the expression lives ([provenance]'s CTE - * name and body position); [sql] is trusted only for WHAT it says. + * generated KDoc. [nodeTreeText] is trusted only for where the expression lives ([provenance]'s CTE + * name and body position); [sql] is trusted only for what it says. * - * This proves its answer rather than merely computing one: a text-only re-lex of the CTE body can - * mis-split or mis-merge an item boundary (an unbalanced-looking comment, a pathological string - * literal) without the item count changing, silently handing back a neighboring item's expression. - * Every gate below returns `null` (no provenance) instead of risking a wrong one: + * A text-only re-lex of the CTE body can mis-split or mis-merge an item boundary (an + * unbalanced-looking comment, a pathological string literal) without the item count changing, + * silently handing back a neighboring item's expression. Every gate below returns `null` (no + * provenance) instead of risking a wrong one: * - the CTE [provenance] points at is found by replaying [NodeTreeColumnProvenance.hops] step by * step (see [scopedNodeTreeCteQueryBlock] and [scopedSqlCteDefinition]), never by a flat, name-only * search — so a nested `WITH` that shadows an outer CTE of the same name resolves against the exact - * declaration [NodeTreeProvenanceResolver] walked to, not merely a same-named one elsewhere (#238). + * declaration [NodeTreeProvenanceResolver] walked to, not merely a same-named one elsewhere. * - [parseOutputItemsWithAlias] over that CTE's body must yield exactly as many top-level items as * [nodeTreeText] has non-junk body target entries for that CTE. * - every position's own name — not merely [provenance]'s — must fold-match that position's own @@ -26,8 +26,8 @@ package norm.generator * - [provenance]'s own item must have a verifiable name: an explicit `AS x`, an implicit trailing * alias token, or being itself a bare column reference — see [verifiedItem]. * - [provenance]'s own matched item must not have been verified only via an implicit alias: its - * complete text is legal only as a SELECT-LIST ITEM (`UPPER(name) y`), never as a standalone - * EXPRESSION — the context a `@property` source reference renders it in — because whether a + * complete text is legal only as a select-list item (`UPPER(name) y`), never as a standalone + * expression — the context a `@property` source reference renders it in — because whether a * trailing bare word is an alias or a required operand (`ts AT TIME ZONE timezone`) cannot be * decided from text alone. An explicit `AS` alias has no such problem: [extractAlias] already * splits it off before the item's expression is formed. diff --git a/generator/src/main/kotlin/norm/generator/OriginalAdjacency.kt b/generator/src/main/kotlin/norm/generator/OriginalAdjacency.kt index 261f21d4..13708046 100644 --- a/generator/src/main/kotlin/norm/generator/OriginalAdjacency.kt +++ b/generator/src/main/kotlin/norm/generator/OriginalAdjacency.kt @@ -1,21 +1,21 @@ package norm.generator /** - * "The characters at [leftIndex] and `leftIndex + 1` were adjacent in the ORIGINAL SQL text" — the + * "The characters at [leftIndex] and `leftIndex + 1` were adjacent in the original SQL text" — the * fact every multi-character lexical decision in the lexer that spans two adjacent characters (a * `--` line-comment or `/* */` block-comment opener, a `$`-prefixed dollar-quote's identifier * lookback, a standalone-`E` escape-string marker's lookback, a `''`/`""` doubled-quote escape, and * [matchTrailingAliasSegment]'s own bare-identifier run stopping before a `$` that should instead * open a fresh dollar-quoted string) must be gated on, whenever the text being scanned might be - * [stripCommentsAndWhitespace]'s STRIPPED output rather than raw SQL. + * [stripCommentsAndWhitespace]'s stripped output rather than raw SQL. * * This exists because deleting a separator PostgreSQL itself lexed on can manufacture a token that * was never in the query: `1 - -1` (two separate `-` tokens, genuinely separated by a space) strips * to `1--1`, which [skipLexicalToken] would otherwise read as a `--` line comment that was never * there. [StrippedText] is the sole [OriginalAdjacency] implementation with real gaps to report — - * see its KDoc — while [ALL_ADJACENT] is what every RAW-text caller passes (implicitly, via the + * see its KDoc — while [ALL_ADJACENT] is what every raw-text caller passes (implicitly, via the * default parameter on [skipLexicalToken]/[findMatchingCloseParenthesis]): for text that was never - * stripped, every neighbouring pair of characters genuinely IS adjacent, so the gate is always + * stripped, every neighbouring pair of characters genuinely is adjacent, so the gate is always * satisfied and raw-text callers see no behavior change at all. */ internal fun interface OriginalAdjacency { diff --git a/generator/src/main/kotlin/norm/generator/PgCatalogLoader.kt b/generator/src/main/kotlin/norm/generator/PgCatalogLoader.kt index ca3b1a0a..12ea3c6e 100644 --- a/generator/src/main/kotlin/norm/generator/PgCatalogLoader.kt +++ b/generator/src/main/kotlin/norm/generator/PgCatalogLoader.kt @@ -77,36 +77,33 @@ internal class PgCatalogLoader(internal val connection: Connection) { val aggregateHasNonNullInitialValue: Map by lazy(::loadAggregateInitialValues) /** - * OIDs of non-strict functions that are guaranteed to never return `null` for ANY combination of - * argument values passed in the ORDINARY (non-`VARIADIC`) calling form, including when EVERY - * argument is `null`. Currently `concat` only: verified live `concat(NULL::text, NULL::text)` - * returns `''` (empty string), never `null`. - * - * The `VARIADIC` calling form (`concat(VARIADIC arr)`) is a DIFFERENT case this list's claim does - * NOT cover: it passes the array argument itself as one value rather than exploding it into - * elements, and `concat(VARIADIC arr)` IS `null` when `arr` itself is `null` (verified live on - * PostgreSQL 16, 17, and 18). [PgNodeExpression.FuncExpr.isVariadic] exists specifically so - * [NodeTreeNullabilityAnalyzer.isNonNull] and - * [NodeTreeNullabilityAnalyzer.isSafeFromGroupingSetNullExtension] can detect this form and + * OIDs of non-strict functions that are guaranteed to never return `null` for any combination of + * argument values passed in the ordinary (non-`VARIADIC`) calling form, including when every + * argument is `null`. Currently `concat` only: `concat(NULL::text, NULL::text)` returns `''` + * (empty string), never `null`. + * + * The `VARIADIC` calling form (`concat(VARIADIC arr)`) is a different case this list's claim does + * not cover: it passes the array argument itself as one value rather than exploding it into + * elements, and `concat(VARIADIC arr)` is `null` when `arr` itself is `null` (PostgreSQL 16-18). + * [PgNodeExpression.FuncExpr.isVariadic] exists specifically so [NodeTreeNullabilityAnalyzer.isNonNull] + * and [NodeTreeNullabilityAnalyzer.isSafeFromGroupingSetNullExtension] can detect this form and * require every argument non-null instead of trusting this list unconditionally — see both * methods' KDoc. * - * `concat_ws` is deliberately NOT on this list at all, even for the ordinary calling form, despite - * also being non-strict: it is non-null only when its FIRST argument (the separator) is non-null - * — `concat_ws(NULL, 'x', 'y')` returns `null` (verified live on PostgreSQL 16, 17, and 18), - * because a `null` separator poisons the whole result even though the later arguments are - * individually null-tolerant. That argument-position-dependent condition does not fit - * "unconditionally non-null" at all, so it is modeled separately — see - * [nonNullIffFirstArgumentNonNullFunctionOids] and [NodeTreeNullabilityAnalyzer]'s `concat_ws` - * handling in `isNonNull`'s `FuncExpr` branch. - * - * This distinction matters beyond precision: [NodeTreeNullabilityAnalyzer.isSafeFromGroupingSetNullExtension] - * treats membership on THIS list (for a non-`VARIADIC` call) as an unconditional safety proof for - * the grouping-sets null-extension gate specifically because "non-null regardless of input" also - * means "non-null regardless of which argument grouping-set null-extension replaces with `null`". - * A function that is only non-null for a PARTICULAR argument (like `concat_ws`'s separator) does - * not have that property — null-extension could target exactly that argument — so it must never - * be added here. + * `concat_ws` is not on this list at all, even for the ordinary calling form, despite also being + * non-strict: it is non-null only when its first argument (the separator) is non-null — + * `concat_ws(NULL, 'x', 'y')` returns `null` (PostgreSQL 16-18), because a `null` separator + * poisons the whole result even though the later arguments are individually null-tolerant. That + * argument-position-dependent condition does not fit "unconditionally non-null", so it is modeled + * separately — see [nonNullIffFirstArgumentNonNullFunctionOids] and + * [NodeTreeNullabilityAnalyzer]'s `concat_ws` handling in `isNonNull`'s `FuncExpr` branch. + * + * [NodeTreeNullabilityAnalyzer.isSafeFromGroupingSetNullExtension] treats membership on this list + * (for a non-`VARIADIC` call) as an unconditional safety proof for the grouping-sets + * null-extension gate specifically because "non-null regardless of input" also means "non-null + * regardless of which argument grouping-set null-extension replaces with `null`". A function that + * is only non-null for a particular argument (like `concat_ws`'s separator) does not have that + * property — null-extension could target exactly that argument — so it must never be added here. * * Restricted to `pronamespace = 'pg_catalog'` at query time — a user-defined function sharing the * name `concat` must not ride along onto this list; see the loader. @@ -114,21 +111,21 @@ internal class PgCatalogLoader(internal val connection: Connection) { val alwaysNonNullFunctionOids: Set by lazy(::loadAlwaysNonNullFunctions) /** - * OIDs of functions that are non-null if and only if their FIRST argument is non-null, regardless - * of any other argument's nullability, in the ORDINARY (non-`VARIADIC`) calling form. Currently - * `concat_ws` only: verified live `concat_ws(',', NULL, NULL)` returns `','`-joined empty string - * (`''`, non-null) but `concat_ws(NULL, 'x', 'y')` returns `null` — the separator (first argument) - * alone determines whether the whole call can be `null`. + * OIDs of functions that are non-null if and only if their first argument is non-null, regardless + * of any other argument's nullability, in the ordinary (non-`VARIADIC`) calling form. Currently + * `concat_ws` only: `concat_ws(',', NULL, NULL)` returns `','`-joined empty string (`''`, + * non-null) but `concat_ws(NULL, 'x', 'y')` returns `null` — the separator (first argument) alone + * determines whether the whole call can be `null`. * - * The `VARIADIC` calling form (`concat_ws(',', VARIADIC arr)`) does NOT get this treatment: it - * passes the array argument itself as one value, and `concat_ws(',', VARIADIC arr)` IS `null` - * when `arr` itself is `null` even though the literal separator is non-null (verified live on - * PostgreSQL 16, 17, and 18) — see [PgNodeExpression.FuncExpr.isVariadic]'s KDoc. + * The `VARIADIC` calling form (`concat_ws(',', VARIADIC arr)`) does not get this treatment: it + * passes the array argument itself as one value, and `concat_ws(',', VARIADIC arr)` is `null` + * when `arr` itself is `null` even though the literal separator is non-null (PostgreSQL 16-18) — + * see [PgNodeExpression.FuncExpr.isVariadic]'s KDoc. * * Used by [NodeTreeNullabilityAnalyzer.isNonNull]'s [PgNodeExpression.FuncExpr] branch (for the * non-`VARIADIC` form only). Not used by the grouping-sets safety gate * ([NodeTreeNullabilityAnalyzer.isSafeFromGroupingSetNullExtension]) at all, `VARIADIC` or not: - * unlike [alwaysNonNullFunctionOids], this property depends on WHICH argument is non-null, so a + * unlike [alwaysNonNullFunctionOids], this property depends on which argument is non-null, so a * `Var` in the first-argument position is exactly as unsafe under grouping-set null-extension as * any other `Var` — the generic aggregate/window-domination rule already handles it correctly * without a dedicated leg. @@ -140,84 +137,85 @@ internal class PgCatalogLoader(internal val connection: Connection) { /** * OIDs of functions, cast functions, and operators (materialized to their implementing function - * OID via `pg_operator.oprcode`) that are proven TOTAL on non-null input — every combination of - * non-null arguments produces a non-null result. An ERROR is fine; only a silent `null` return + * OID via `pg_operator.oprcode`) that are proven total on non-null input — every combination of + * non-null arguments produces a non-null result. An error is fine; only a silent `null` return * disqualifies a candidate. * - * This is verified only for the ORDINARY, element-wise calling convention (see `SafeListSweepTest`). + * This is verified only for the ordinary, element-wise calling convention (see `SafeListSweepTest`). * [NodeTreeNullabilityAnalyzer.isNonNull]'s [PgNodeExpression.FuncExpr] branch never consults * this set for a `VARIADIC` call: a non-null array argument says nothing about whether an - * element inside it is non-null, and no function on this list is variadic today (verified live - * on PostgreSQL 16, 17, and 18: `provariadic <> 0` intersected with every safe-listed name here - * is empty) — but this must not silently start trusting the list for that shape the moment one - * is added. See [NodeTreeNullabilityAnalyzer]'s `isNeverNullForNonNullInput` KDoc. + * element inside it is non-null, and no function on this list is variadic today (`provariadic <> + * 0` intersected with every safe-listed name here is empty on PostgreSQL 16-18) — but this must + * not silently start trusting the list for that shape the moment one is added. See + * [NodeTreeNullabilityAnalyzer]'s `isNeverNullForNonNullInput` KDoc. * - * `pg_proc.proisstrict` is NOT sufficient for this on its own. STRICT only guarantees + * `pg_proc.proisstrict` is not sufficient for this on its own. Strict only guarantees * NULL-in => NULL-out; it says nothing about the converse. `substring(text, '(z)')` (regex, no * match), `regexp_match(text, pattern)` (no match), and `array_length(ARRAY[]::text[], 1)` - * (empty array) are all STRICT and all return `null` on fully non-null, well-typed input. Any + * (empty array) are all strict and all return `null` on fully non-null, well-typed input. Any * inference rule built from strictness alone is therefore unsound. This set exists to be an - * ADDITIONAL conjunct alongside strictness in [NodeTreeNullabilityAnalyzer], never a + * additional conjunct alongside strictness in [NodeTreeNullabilityAnalyzer], never a * replacement for it — so an unforeseen non-strict overload of a listed name can never slip * through. * - * Functions are safe-listed by `pg_proc.proname` PLUS ARGUMENT TYPE SIGNATURE — see + * Functions are safe-listed by `pg_proc.proname` plus argument type signature — see * [NeverNullSafeLists.NEVER_NULL_FUNCTION_SIGNATURES] — restricted to `pronamespace = 'pg_catalog'`. Keying by - * name alone is NOT safe: `lower(anyrange)`/`upper(anyrange)`/`lower(anymultirange)`/ + * name alone is not safe: `lower(anyrange)`/`upper(anyrange)`/`lower(anymultirange)`/ * `upper(anymultirange)` share `proname` with the totally-safe `lower(text)`/`upper(text)` but * return `null` on a non-null, well-typed, non-empty-but-unbounded range or an empty range — * `SELECT upper(int4range '[1,)')` and `SELECT lower(int4range 'empty')` both return `null`. * `substring` is the reason a signature-only match still is not always enough on its own: * `substring(text, int, int)` is total but `substring(text FROM pattern)` is not, and both - * would share the SAME two-argument-count shape if only argument count were checked — this is - * why the match is on the full ordered list of argument TYPE NAMES (via `pg_type.typname`), not + * would share the same two-argument-count shape if only argument count were checked — this is + * why the match is on the full ordered list of argument type names (via `pg_type.typname`), not * just arity. `substring` itself is simply left off the list entirely rather than enumerated, * since its regex overloads are non-total. * - * Casts are safe-listed by (source type, target type) PAIR — see [NeverNullSafeLists.NEVER_NULL_CAST_SIGNATURES] — - * rather than a class-wide blanket over every `pg_cast.castfunc` in `pg_catalog`. A blanket was - * tried first and is FALSE: `('null'::jsonb)::int4` (and every other `jsonb` → numeric/`boolean` + * Casts are safe-listed by (source type, target type) pair — see + * [NeverNullSafeLists.NEVER_NULL_CAST_SIGNATURES] — rather than a class-wide blanket over every + * `pg_cast.castfunc` in `pg_catalog`. A blanket was + * tried first and is false: `('null'::jsonb)::int4` (and every other `jsonb` → numeric/`boolean` * cast) returns `null` on well-typed, non-null input with no error, because the cast function - * special-cases the JSON literal `null` rather than raising "cannot convert". A brute-force - * sweep against live PostgreSQL of every `jsonb`-targeting numeric/`boolean` cast confirmed this - * for all seven overloads (`int2`, `int4`, `int8`, `numeric`, `float4`, `float8`, `bool`); none - * of the seven appear in [NeverNullSafeLists.NEVER_NULL_CAST_SIGNATURES]. The same sweep also found - * `timestamp`/`timestamptz` → `time`/`timetz` silently returns `null` for the infinite - * (`'infinity'`/`'-infinity'`) input, rather than erroring the way `'infinity'::interval::time` - * does — so those three pairs are excluded too. Every other pair the sweep checked (see - * [SafeListSweepTest] for the corpus and the full case count) proved total, including on `NaN`, - * `Infinity`, `-Infinity`, min/max integer values, and empty strings. + * special-cases the JSON literal `null` rather than raising "cannot convert". A sweep of every + * `jsonb`-targeting numeric/`boolean` cast confirmed this for all seven overloads (`int2`, `int4`, + * `int8`, `numeric`, `float4`, `float8`, `bool`); none of the seven appear in + * [NeverNullSafeLists.NEVER_NULL_CAST_SIGNATURES]. The same sweep also found `timestamp`/`timestamptz` → + * `time`/`timetz` silently returns `null` for the infinite (`'infinity'`/`'-infinity'`) input, + * rather than erroring the way `'infinity'::interval::time` does — so those three pairs are + * excluded too. Every other pair the sweep checked (see [SafeListSweepTest] for the corpus and the + * full case count) proved total, including on `NaN`, `Infinity`, `-Infinity`, min/max integer + * values, and empty strings. * * pgcrypto's `digest` and `hmac` are the one extension carve-out, keyed through `pg_depend` * (`deptype = 'e'`) to the `pgcrypto` extension itself, so a user-defined `digest` in `public` * cannot ride this carve-out. `encode`/`decode` are ordinary `pg_catalog` functions and are * safe-listed on the main function list above, not here. All four `digest`/`hmac` overloads * (`digest(text, text)`, `digest(bytea, text)`, `hmac(text, text, text)`, `hmac(bytea, bytea, - * text)`) were verified total on empty non-null input; an unrecognized hash algorithm name - * errors rather than returning `null`. + * text)`) are total on empty non-null input; an unrecognized hash algorithm name errors rather + * than returning `null`. * - * Operators are safe-listed by (symbol, left operand type, right operand type) TRIPLE — see + * Operators are safe-listed by (symbol, left operand type, right operand type) triple — see * [NeverNullSafeLists.NEVER_NULL_OPERATOR_SIGNATURES] — restricted to `oprnamespace = 'pg_catalog'`, and * materialized to the OID of the implementing function via `oprcode`, the same OID space * [PgNodeExpression.OpExpr] and [PgNodeExpression.ScalarArrayOpExpr] (e.g. `= ANY(...)`) are - * keyed by, so no separate operator-specific lookup is needed. Symbol alone is NOT safe: `path + + * keyed by, so no separate operator-specific lookup is needed. Symbol alone is not safe: `path + * path` (`path_add`) shares the `+` symbol with the totally-safe `int4 + int4`, but returns - * `null`, not an error, when either operand is a CLOSED path (`SELECT ((0,0),(1,1),(2,0)) + - * ((0,0),(1,1),(2,0))` on two well-typed, non-null closed paths). A brute-force sweep against - * live PostgreSQL of every symbol-restricted-but-unrestricted-by-type combination found exactly - * this one bad shape; `path` is entirely absent from [NeverNullSafeLists.NEVER_NULL_OPERATOR_SIGNATURES] as a + * `null`, not an error, when either operand is a closed path (`SELECT ((0,0),(1,1),(2,0)) + + * ((0,0),(1,1),(2,0))` on two well-typed, non-null closed paths). A sweep of every + * symbol-restricted-but-unrestricted-by-type combination found exactly this one bad shape; `path` + * is entirely absent from [NeverNullSafeLists.NEVER_NULL_OPERATOR_SIGNATURES] as a * result — every triple that remains was independently swept and found total (see * [SafeListSweepTest]). A left or right type of `null` in a signature means the operator is * unary on that side (no left operand for a prefix operator, no right operand for a postfix * operator), mirroring `pg_operator.oprleft`/`oprright` themselves being `0` (no operand) for a * unary operator — e.g. unary (prefix) `-` (negation), `+`, and `~` (bitwise complement) are all - * PREFIX-only overloads of symbols that are ALSO binary elsewhere in this same list (binary `-` + * prefix-only overloads of symbols that are also binary elsewhere in this same list (binary `-` * is subtraction, binary `~` is regex match); they needed adding here alongside the binary - * overloads specifically because the earlier symbol-only blanket rule this list replaced made - * every overload — unary and binary alike — safe together, and losing the unary overloads - * would have been an unintended narrowing this fix must not introduce. + * overloads because the earlier symbol-only blanket rule this list replaced made every overload — + * unary and binary alike — safe together, and losing the unary overloads would have been an + * unintended narrowing. * - * Omitting a signature from this set only WIDENS the result to nullable — it never narrows a + * Omitting a signature from this set only widens the result to nullable — it never narrows a * truly nullable expression to non-null — so when in doubt about whether a specific signature is * total on every non-null, well-typed input (including infinite/empty/unbounded edge values, not * just "typical" ones — see [NeverNullSafeLists.NEVER_NULL_FUNCTION_SIGNATURES] for the `extract`/`date_part` @@ -251,7 +249,7 @@ internal class PgCatalogLoader(internal val connection: Connection) { * just base tables. * * Used by [ColumnNullabilityAnalyzer] to resolve a `TargetEntry`'s `:resorigtbl`/`:resorigcol` - * back to the REAL source column name, for a result column whose own select-list item is merely a + * back to the real source column name, for a result column whose own select-list item is merely a * reference to an alias assigned somewhere upstream (a CTE's own `RETURNING`/`SELECT` list * renaming a column, e.g.) — PostgreSQL's `markTargetListOrigins` walks through such a reference to * find the ultimate source column, so this is a plain OID/attnum lookup, not a name-based one. @@ -354,8 +352,8 @@ internal class PgCatalogLoader(internal val connection: Connection) { // No separate pg_operator/oprcode query is needed: an operator's implementing function // (oprcode) is itself a row in pg_proc, so this single query already covers operators too — // PgNodeExpression.OpExpr/ScalarArrayOpExpr are keyed by that same function OID, not by any - // pg_operator-specific ID. Measured empirically: zero operator oprcode OIDs satisfying the - // volatility/proretset filter fall outside this query's result. + // pg_operator-specific ID. No operator oprcode OID satisfying the volatility/proretset filter + // falls outside this query's result. stmt.executeQuery( "SELECT oid::integer FROM pg_catalog.pg_proc WHERE provolatile = 'i' AND NOT proretset AND prokind IN ('f', 'w')", ).use { rs -> @@ -382,9 +380,8 @@ internal class PgCatalogLoader(internal val connection: Connection) { private fun loadAlwaysNonNullFunctions(): Set = buildSet { connection.createStatement().use { stmt -> - // pronamespace restricted to pg_catalog: a user-defined function named `concat` must not - // ride onto this list just by sharing the name (verified live: CREATE FUNCTION - // us.concat(...) with different null behavior gets picked up without this restriction). + // pronamespace restricted to pg_catalog: without this, a user-defined function named + // `concat` with different null behavior would ride onto this list by sharing the name. stmt.executeQuery( """ SELECT p.oid::integer AS oid @@ -528,8 +525,8 @@ internal class PgCatalogLoader(internal val connection: Connection) { * * A thin name-resolution adapter over [ColumnNullabilityAnalyzer.isColumnNotNull], the single * relid-keyed source of truth for column nullability, base table and view alike. Unlike the - * `pg_depend` name-join this replaces (`#256`), a view column's answer comes from fully evaluating - * the view's own defining query rather than from tracing a same-named source column and inheriting + * `pg_depend` name-join this replaces, a view column's answer comes from fully evaluating the + * view's own defining query rather than from tracing a same-named source column and inheriting * its constraint. This function does no computation of its own. * * @param schemaName The schema to check. @@ -544,7 +541,7 @@ internal class PgCatalogLoader(internal val connection: Connection) { * [schemaName] — the name-resolution half of [loadViewColumnNullability]'s adapter over * [ColumnNullabilityAnalyzer.isColumnNotNull]. * - * `ORDER BY c.oid, a.attnum` is load-bearing, not cosmetic: [loadViewColumnNullability] resolves + * `ORDER BY c.oid, a.attnum` matters, not just style: [loadViewColumnNullability] resolves * these rows one at a time through a single shared [ColumnNullabilityAnalyzer], whose answer for a * view deep enough to hit [ColumnNullabilityAnalyzer.VIEW_NULLABILITY_RECURSION_DEPTH_BUDGET] can * depend on which views were memoized beforehand. Absent an `ORDER BY`, PostgreSQL may return these @@ -757,16 +754,16 @@ internal class PgCatalogLoader(internal val connection: Connection) { * Routes every statement — a plain `SELECT` exactly the same as a data-modifying statement or * CTE — through [ColumnNullabilityAnalyzer.queryColumnNullabilityViaProsqlbody]. `prosqlbody` * holds the identical post-parse-analysis `{QUERY ...}` shape `CREATE VIEW`'s `pg_rewrite.ev_action` - * does (see that method's KDoc), so a plain `SELECT` needs no separate route of its own: a live - * sweep across PostgreSQL 16/17/18 of every shape `CREATE VIEW` accepts but a SQL-standard - * function body might plausibly reject or - * reinterpret — `UNION`/`INTERSECT`/`EXCEPT`, `WITH RECURSIVE`, `ORDER BY`/`LIMIT`/`OFFSET`, - * `FOR UPDATE`/`FOR SHARE`, `DISTINCT ON`, a `VALUES` list, a set-returning function in the - * target list, `LATERAL`, `TABLESAMPLE`, `WITH ORDINALITY`, and a query selecting from another - * view — found no disagreement on any of them (see [QueryAnalysisTest]'s `SELECT DISTINCT ON` - * and `TABLESAMPLE` cases, the two shapes the corpus had no other coverage for; every other - * shape is exercised elsewhere in [QueryAnalysisTest] and in the `test-scenarios` golden-file - * corpus, which pins the exact generated Kotlin type derived from this function's answer). + * does (see that method's KDoc), so a plain `SELECT` needs no separate route of its own: on + * PostgreSQL 16-18, every shape `CREATE VIEW` accepts but a SQL-standard function body might + * plausibly reject or reinterpret — `UNION`/`INTERSECT`/`EXCEPT`, `WITH RECURSIVE`, + * `ORDER BY`/`LIMIT`/`OFFSET`, `FOR UPDATE`/`FOR SHARE`, `DISTINCT ON`, a `VALUES` list, a + * set-returning function in the target list, `LATERAL`, `TABLESAMPLE`, `WITH ORDINALITY`, and a + * query selecting from another view — agrees between the two routes (see [QueryAnalysisTest]'s + * `SELECT DISTINCT ON` and `TABLESAMPLE` cases, the two shapes the corpus had no other coverage + * for; every other shape is exercised elsewhere in [QueryAnalysisTest] and in the + * `test-scenarios` golden-file corpus, which pins the exact generated Kotlin type derived from + * this function's answer). * * @return one [ColumnAnalysis] per result column. If * [ColumnNullabilityAnalyzer.queryColumnNullabilityViaProsqlbody] cannot produce an answer at @@ -775,8 +772,8 @@ internal class PgCatalogLoader(internal val connection: Connection) { * result column (via `PreparedStatement.getMetaData()`, the only source of a column count this * deep into a fallback) is reported nullable with no provenance: the safe direction, and * consistent with every other fallback in this file. A statement with no result columns at all - * (`INSERT`/`UPDATE`/`DELETE`/`MERGE` with no `RETURNING`) naturally reports an EMPTY list here, - * since its real column count is `0` — there is nothing for a caller to treat as nullable OR + * (`INSERT`/`UPDATE`/`DELETE`/`MERGE` with no `RETURNING`) naturally reports an empty list here, + * since its real column count is `0` — there is nothing for a caller to treat as nullable or * not. */ fun queryColumnNullability(@Language("PostgreSQL") sql: String): List = @@ -785,9 +782,9 @@ internal class PgCatalogLoader(internal val connection: Connection) { /** * The real number of result columns [sql] produces, via `PreparedStatement.getMetaData()` — used - * ONLY by [queryColumnNullability]'s final, otherwise-blind fallback to size its all-nullable + * only by [queryColumnNullability]'s final, otherwise-blind fallback to size its all-nullable * default correctly (in particular, `0` for a `RETURNING`-less `INSERT`/`UPDATE`/`DELETE`/ - * `MERGE`, which must report an EMPTY list, not a list of one `true` per some guessed count). + * `MERGE`, which must report an empty list, not a list of one `true` per some guessed count). */ private fun realColumnCount(@Language("PostgreSQL") sql: String): Int = try { connection.prepareStatement(sql).use { it.metaData?.columnCount ?: 0 } diff --git a/generator/src/main/kotlin/norm/generator/PgNodeExpression.kt b/generator/src/main/kotlin/norm/generator/PgNodeExpression.kt index 8496a768..945b4266 100644 --- a/generator/src/main/kotlin/norm/generator/PgNodeExpression.kt +++ b/generator/src/main/kotlin/norm/generator/PgNodeExpression.kt @@ -37,10 +37,10 @@ internal sealed interface PgNodeExpression { /** * @property isVariadic `true` when the call uses `VARIADIC` (e.g. `concat(VARIADIC arr)`) — - * from `:funcvariadic`. In this form the LAST entry of [arguments] is the array expression + * from `:funcvariadic`. In this form the last entry of [arguments] is the array expression * itself, passed through as one value, not exploded into its elements. This matters for - * nullability: `concat(VARIADIC arr)` is `null` when `arr` itself is `null` (verified live on - * PostgreSQL 16, 17, and 18), which neither [PgCatalogLoader.alwaysNonNullFunctionOids] nor + * nullability: `concat(VARIADIC arr)` is `null` when `arr` itself is `null` on PostgreSQL + * 16, 17, and 18, which neither [PgCatalogLoader.alwaysNonNullFunctionOids] nor * [PgCatalogLoader.nonNullIffFirstArgumentNonNullFunctionOids] account for on their own — both * assume the ordinary (non-`VARIADIC`) calling form where every argument is an individual * scalar value, and [NodeTreeNullabilityAnalyzer.isNonNull] must check this flag before @@ -155,8 +155,8 @@ internal sealed interface PgNodeExpression { * * @property type The `JsonConstructorType` code — see the `JSON_CONSTRUCTOR_TYPE_*` constants below. * @property arguments The parsed `:args` list: each flattened key/value pair for `OBJECT`, each - * element for `ARRAY`, the single value argument for `PARSE`/`SCALAR`/`SERIALIZE`. ALWAYS EMPTY for - * `OBJECTAGG`/`ARRAYAGG`, which put the underlying aggregate in [function] instead. + * element for `ARRAY`, the single value argument for `PARSE`/`SCALAR`/`SERIALIZE`. Always empty + * for `OBJECTAGG`/`ARRAYAGG`, which put the underlying aggregate in [function] instead. * @property function The parsed `:func` node — `null` (from `:func <>`) for every type except * `OBJECTAGG`/`ARRAYAGG`, where it holds the [Aggref] (or [WindowFunc], when `OVER` is used) that * computes the aggregated JSON value. [NodeTreeNullabilityAnalyzer.isNonNull] recurses into this @@ -276,12 +276,12 @@ internal data class NodeTreeCteDefinition(val name: String, val queryBlock: Stri * A CTE range-table-entry reference (`rtekind 6`) parsed from a query block's own `:rtable`. * * @property name The referenced CTE's name (from `:ctename`). - * @property ctelevelsup `0` when the CTE is declared in the SAME query block's own `:cteList`; a + * @property ctelevelsup `0` when the CTE is declared in the same query block's own `:cteList`; a * value greater than `0` means the declaration is that many query levels further up, in an * enclosing scope. The distinction is required when a block declares its own `WITH c` that shadows * an enclosing `WITH c` of the same name with a different body — see * [ColumnNullabilityAnalyzer.analyzeQueryBlockNullability]. - * @property selfReference Whether this specific reference is a recursive CTE's OWN recursive term + * @property selfReference Whether this specific reference is a recursive CTE's own recursive term * referring back to itself (from `:self_reference`). `false` for every ordinary CTE reference — * this is only ever `true` inside a `WITH RECURSIVE` CTE's own recursive query term. Defaults to * `false` since only [RangeTableEntry.Cte] (built by [PgNodeTreeParser.parseRangeTableEntries]) @@ -290,7 +290,7 @@ internal data class NodeTreeCteDefinition(val name: String, val queryBlock: Stri internal data class NodeTreeCteReference(val name: String, val ctelevelsup: Int, val selfReference: Boolean = false) /** - * A single range-table entry, keyed by 1-based `varno`, covering EVERY `rtekind` — unlike + * A single range-table entry, keyed by 1-based `varno`, covering every `rtekind` — unlike * [PgNodeTreeParser.parseRangeTable] ([Relation] only), [PgNodeTreeParser.parseSubqueryRangeTable] * ([Subquery] only), and [PgNodeTreeParser.parseCteRangeTableEntries] ([Cte] only), which each * recognize exactly one kind and silently skip every entry of any other kind. @@ -314,7 +314,7 @@ internal sealed interface RangeTableEntry { /** * `rtekind 2`: a `JOIN` (including its `USING`/`NATURAL`-merged output columns). * - * @property joinAliasVars One parsed expression per join OUTPUT column, in order — 1-based + * @property joinAliasVars One parsed expression per join output column, in order — 1-based * `varattno - 1` indexes into this list. An ordinary (non-merged) column's entry is a bare * [PgNodeExpression.Var] pointing at whichever side produced it; a `USING`/`NATURAL`-merged * column's entry is a [PgNodeExpression.CoalesceExpr] of the two sides' Vars. That distinction @@ -350,7 +350,7 @@ internal sealed interface RangeTableEntry { * `:groupClause`/`:groupingSets`, this entry IS a `GROUP BY` grouping key — see * [NodeTreeNullabilityAnalyzer]'s GROUPING SETS/CUBE/ROLLUP handling. * @property originalTableOid The entry's `:resorigtbl` value — the OID of the real relation this - * column ultimately traces back to (PostgreSQL's own `markTargetListOrigins` walks THROUGH a CTE + * column ultimately traces back to (PostgreSQL's own `markTargetListOrigins` walks through a CTE * or subquery reference to find it, not merely the immediate FROM item), or `0` when there is no * single source column (a computed expression, an aggregate, a set-operation branch, or a * `USING`/`NATURAL`-merged join column). diff --git a/generator/src/main/kotlin/norm/generator/PgNodeTreeParser.kt b/generator/src/main/kotlin/norm/generator/PgNodeTreeParser.kt index a8566a1a..41f661aa 100644 --- a/generator/src/main/kotlin/norm/generator/PgNodeTreeParser.kt +++ b/generator/src/main/kotlin/norm/generator/PgNodeTreeParser.kt @@ -10,23 +10,19 @@ import java.util.logging.Logger * integers, booleans, bitmapsets (`(b N ...)`), or nested `{...}` blocks. * * **Escaping**: Postgres's node-tree writer (`outToken` in `outfuncs.c`) backslash-escapes any - * character in a string/identifier VALUE that would otherwise be misread as structural by the - * reader: `{`, `}`, `(`, `)`, whitespace, and the backslash character itself (verified empirically - * against a live server — e.g. a column alias of `k}x` is written as `:resname k\}x`, and a - * literal backslash in a value is written as `\\`). Every backslash-prefixed pair is exactly two - * characters (the escaping is never multi-character, e.g. there is no `\n`-for-newline mnemonic — - * a literal newline is written as a backslash followed by the raw newline byte). Every function in - * this class that scans raw text character-by-character for structural `{`, `}`, `(`, or `)` must - * treat a `\`-prefixed pair as an opaque, non-structural unit — see [nextUnescapedIndexOf], - * [findMarkerAtDepthOne], and [extractBalancedDelimiters]. Field-name markers (e.g. `:targetList (`, - * `:expr {`) are Postgres's own fixed labels, never user data, so they are never escaped, and the - * plain (non-escape-aware) substring searches for them elsewhere in this class ([extractArgListSection] - * and similar) remain safe as long as they hand off to an escape-aware balanced-delimiter scan for - * everything past the marker. [extractFieldExpression] is the one exception worth calling out - * explicitly: unlike those, it searches via the escape-aware AND depth-one-aware - * [findMarkerAtDepthOne] rather than a plain `indexOf` — see its own KDoc for why depth-one-awareness - * is required there specifically (several node types have a field whose own value can legally - * contain another node of the same outer type carrying the same field name, nested deeper). + * character in a string/identifier value that would otherwise be misread as structural: `{`, `}`, + * `(`, `)`, whitespace, and the backslash itself. A column alias of `k}x` is written as + * `:resname k\}x`; a literal backslash in a value is written as `\\`. Every backslash-prefixed pair + * is exactly two characters — there is no `\n`-for-newline mnemonic; a literal newline is written as + * a backslash followed by the raw newline byte. Every function here that scans raw text + * character-by-character for structural `{`, `}`, `(`, or `)` must treat a `\`-prefixed pair as an + * opaque unit — see [nextUnescapedIndexOf], [findMarkerAtDepthOne], and [extractBalancedDelimiters]. + * Field-name markers (e.g. `:targetList (`, `:expr {`) are Postgres's own fixed labels, never user + * data, so they are never escaped, and the plain substring searches for them elsewhere in this class + * ([extractArgListSection] and similar) are safe as long as they hand off to an escape-aware scan + * for everything past the marker. [extractFieldExpression] is the exception: it searches via the + * escape-aware and depth-one-aware [findMarkerAtDepthOne] rather than a plain `indexOf` — see its + * own KDoc for why depth-one-awareness is needed there specifically. * * This class is stateless. Call [parseExpression] with any `{NODE_TYPE ...}` text to get a typed * node. Unrecognized node types become [PgNodeExpression.Unknown] and malformed input becomes @@ -158,16 +154,16 @@ internal class PgNodeTreeParser { } /** - * Parses GROUP RTE (`rtekind 9`) group expressions — the FULLY PARSED counterpart to + * Parses GROUP RTE (`rtekind 9`) group expressions — the fully-parsed counterpart to * [parseGroupRteMap] — from a full `pg_node_tree` text. * * PostgreSQL 18 introduced an `RTE_GROUP` range-table entry (`:rtekind 9`, alias `*GROUP*`) that - * carries a `:groupexprs` list of the query's grouping-key expressions, and rewrites EVERY - * target-list occurrence of a grouping-key expression (not merely the one PostgreSQL assigns + * carries a `:groupexprs` list of the query's grouping-key expressions, and rewrites every + * target-list occurrence of a grouping-key expression (not just the one PostgreSQL assigns * `:ressortgroupref` to) into a bare `Var` referencing this RTE. PostgreSQL 16 and 17 have no - * such RTE — no `:rtable` entry there ever has `:rtekind 9` — so on those versions this method's - * return value is always an empty map, and the original expression is left in place in the - * target list for [parseTargetList] to see directly. + * such RTE — no `:rtable` entry there ever has `:rtekind 9` — so on those versions this method + * always returns an empty map, and the original expression is left in place in the target list + * for [parseTargetList] to see directly. * * Unlike [parseGroupRteMap], which only resolves a GROUP RTE entry that is itself a bare `VAR` * (mapping it back to a `(baseVarno, baseVarattno)` pair), this method parses the `:groupexprs` @@ -352,14 +348,13 @@ internal class PgNodeTreeParser { * ROLLUP/CUBE/SETS (`:kind` 2/3/4) nest further `{GROUPINGSET ...}` blocks in `:content` instead — * PostgreSQL does not pre-expand ROLLUP/CUBE into their individual grouping sets at parse-analysis * time, that happens later in the planner. Rather than modeling that nesting, this method takes the - * union of every `(i ...)` integer list found anywhere inside `:groupingSets`, which — since a - * `GROUPINGSET`'s only other fields are the scalar `:kind` and `:location` integers, never - * `(i ...)`-formatted — is exactly the set of grouping-key `tleSortGroupRef`s regardless of nesting. + * union of every `(i ...)` integer list found anywhere inside `:groupingSets`, which is exactly the + * set of grouping-key `tleSortGroupRef`s regardless of nesting, since a `GROUPINGSET`'s only other + * fields are the scalar `:kind` and `:location` integers, never `(i ...)`-formatted. * - * Both halves matter: relying on `:groupClause` alone would miss a `GROUPING SETS`/`CUBE`/`ROLLUP` - * grouping key that a target-list entry's `:ressortgroupref` still points at, and relying on - * `:groupingSets` alone is needlessly fragile against alternate/older node-tree shapes — the union - * is taken defensively rather than trusting either source alone. + * Relying on `:groupClause` alone would miss a `GROUPING SETS`/`CUBE`/`ROLLUP` grouping key that a + * target-list entry's `:ressortgroupref` still points at, and relying on `:groupingSets` alone is + * needlessly fragile against alternate/older node-tree shapes, so both sources are unioned. * * @param nodeTreeText the raw `pg_rewrite.ev_action` text * @return the set of `tleSortGroupRef` values that are `GROUP BY` grouping keys; empty if the @@ -428,11 +423,11 @@ internal class PgNodeTreeParser { if (cteQueryIndex == -1) return@mapNotNull null val braceStart = cteQueryIndex + cteQueryMarker.length - 1 val queryBlock = extractBalancedBraces(block, braceStart) ?: return@mapNotNull null - // :cterecursive is serialized AFTER :ctequery in COMMONTABLEEXPR's own field order, so a naive + // :cterecursive is serialized after :ctequery in COMMONTABLEEXPR's field order, so a naive // whole-block scan could find a nested COMMONTABLEEXPR's same-named field first if queryBlock - // itself declares a nested WITH clause -- extractBoolFieldAtDepthOne scopes the search to - // block's own outermost brace to avoid that. :ctename above needs no such scoping: it is - // always serialized before :ctequery. + // itself declares a nested WITH clause; extractBoolFieldAtDepthOne scopes the search to + // block's own outermost brace to avoid that. :ctename needs no such scoping — it is always + // serialized before :ctequery. val recursive = extractBoolFieldAtDepthOne(block, ":cterecursive") ?: false NodeTreeCteDefinition(name = cteName, queryBlock = queryBlock, recursive = recursive) } @@ -444,8 +439,8 @@ internal class PgNodeTreeParser { * Extracts the `:rtable` section and returns a map from 1-based `varno` to a * [NodeTreeCteReference] (the CTE's `:ctename` and `:ctelevelsup`) for each range table entry * with `rtekind 6`. `:ctelevelsup` defaults to `0` when absent, matching PostgreSQL's own default - * for a same-level reference (verified live, PostgreSQL 18: the field is always present on a real - * CTE RTE, so this default is defensive only). + * for a same-level reference; on PostgreSQL 18 the field is always present on a real CTE RTE, so + * this default is defensive only. * * This is the CTE counterpart to [parseRangeTable] (which handles `rtekind 0` base tables) * and [parseSubqueryRangeTable] (which handles `rtekind 1` subqueries). @@ -468,7 +463,7 @@ internal class PgNodeTreeParser { } /** - * Parses EVERY range-table entry from [nodeTreeText]'s own `:rtable`, regardless of `rtekind`, + * Parses every range-table entry from [nodeTreeText]'s own `:rtable`, regardless of `rtekind`, * into a [RangeTableEntry] — see that type's KDoc for why a resolver needs visibility into every * kind, not just the ones [parseRangeTable], [parseSubqueryRangeTable], and * [parseCteRangeTableEntries] each recognize individually. @@ -476,7 +471,7 @@ internal class PgNodeTreeParser { * None of the fields read here need [findMarkerAtDepthOne]'s depth-one-awareness: a `JOINEXPR` * range-table entry's own fields (`:jointype`, `:joinaliasvars`, etc.) contain no nested `QUERY` * block that could shadow them (`:joinaliasvars`'s entries are scalar expressions, never a whole - * query), and a `CTE` range-table entry (`rtekind 6`) carries only the CTE's NAME and scope + * query), and a `CTE` range-table entry (`rtekind 6`) carries only the CTE's name and scope * metadata, not its body — the body lives in `:cteList`, parsed separately by [parseCteList]. * * @param nodeTreeText the raw `pg_rewrite.ev_action` text (or a bare `{QUERY ...}` block) @@ -558,12 +553,11 @@ internal class PgNodeTreeParser { * table an `INSERT`/`UPDATE`/`DELETE`/`MERGE` writes to, or `0` for a plain `SELECT` (`0` is * never a valid `rtable` index, so it is a safe "no target relation" sentinel for callers). * - * [extractIntField] finds the FIRST unscoped textual occurrence of `:resultRelation` in + * [extractIntField] finds the first unscoped textual occurrence of `:resultRelation` in * [nodeTreeText], which is always the outermost QUERY's own field, never a nested CTE's or - * subquery's: `:resultRelation` is serialized immediately after `:utilityStmt` — before - * `:cteList` or `:rtable`, both of which is where any nested `{QUERY ...}` block would appear — - * on every PostgreSQL version this class supports (16, 17, 18), the same field-order argument - * [parseVar]'s own KDoc already relies on for `:varreturningtype`. + * subquery's: `:resultRelation` is serialized immediately after `:utilityStmt`, before + * `:cteList` or `:rtable` — where a nested `{QUERY ...}` block would appear — on PostgreSQL 16, + * 17, and 18. * * @param nodeTreeText the raw `pg_rewrite.ev_action` or `pg_proc.prosqlbody` text, or a bare * `{QUERY ...}` block @@ -584,8 +578,8 @@ internal class PgNodeTreeParser { fun parseCommandType(nodeTreeText: String): Int = extractIntField(nodeTreeText, ":commandType") ?: 0 /** - * `true` when [nodeTreeText]'s outermost `MERGE` statement declares AT LEAST ONE `WHEN ... THEN - * DELETE` action — i.e. at least one `{MERGEACTION ...}` block in `:mergeActionList` whose OWN + * `true` when [nodeTreeText]'s outermost `MERGE` statement declares at least one `WHEN ... THEN + * DELETE` action — i.e. at least one `{MERGEACTION ...}` block in `:mergeActionList` whose own * `:commandType` is `4` (`DELETE`, same enum as [parseCommandType]'s top-level use, but scoped * here to each individual action rather than the outermost statement). * @@ -611,9 +605,9 @@ internal class PgNodeTreeParser { ?: error("Missing :varattno in VAR node") val nullingRelations = extractBitmapset(text, ":varnullingrels") val levelsUp = extractIntField(text, ":varlevelsup") ?: 0 - // ":varreturningtype" only appears on PostgreSQL 18+ (RETURNING WITH (OLD AS o, NEW AS n)) and - // is absent entirely on 16/17 — see PgNodeExpression.Var.returningType's KDoc for why 0 (never - // 1, "OLD") is the correct default for a missing field. + // :varreturningtype only appears on PostgreSQL 18+ (RETURNING WITH (OLD AS o, NEW AS n)) and is + // absent on 16/17 — see PgNodeExpression.Var.returningType's KDoc for why 0 (never 1, "OLD") is + // the correct default for a missing field. val returningType = extractIntField(text, ":varreturningtype") ?: 0 return PgNodeExpression.Var( varno = varno, @@ -625,11 +619,9 @@ internal class PgNodeTreeParser { } private fun parseConst(text: String): PgNodeExpression.Const { - // Default to `true` (nullable) when `:constisnull` cannot be read — never `false`. `:constisnull` - // is always emitted by a live server, but that guarantee only covers today's known-good format; - // if a future Postgres version ever renamed or reordered the field, this default is what a - // malformed/absent read degrades to, and it must fail toward nullable, never toward a confidently - // wrong NOT NULL. + // Default to `true` (nullable) when `:constisnull` cannot be read, never `false`: a future + // Postgres version renaming or reordering the field must degrade toward nullable, not toward a + // confidently wrong NOT NULL. val isNull = extractBoolField(text, ":constisnull") ?: true return PgNodeExpression.Const(isNull = isNull) } @@ -637,22 +629,16 @@ internal class PgNodeTreeParser { /** * Parses a `{FUNCEXPR ...}` block. * - * Correctness of [PgNodeExpression.FuncExpr.isVariadic] rests on an invariant of the - * `pg_node_tree` format, verified live on PostgreSQL 16, 17, and 18: `:funcvariadic` is a - * scalar field of `FUNCEXPR` that always precedes `:args` in that node's own field order, and - * it is always emitted explicitly — including `:funcvariadic false` for an ordinary call, never - * omitted. [extractBoolField] matches the FIRST occurrence of `:funcvariadic` anywhere in - * [text], including inside nested blocks, so this is only safe because the outer node's own - * field is textually guaranteed to appear before any nested `FUNCEXPR`'s field, regardless of - * whether the nesting is variadic-in-non-variadic or non-variadic-in-variadic (both directions - * verified live — see `PgNodeTreeParserTest`). If a future PostgreSQL version ever reordered - * `FUNCEXPR`'s fields or made `:funcvariadic` conditional, this extraction would silently start - * reading the wrong node's flag — which is exactly why the fallback below defaults to `true` - * (variadic) rather than `false`: the "always emitted" guarantee describes today's known-good - * format, not a reason to trust an optimistic default should that ever stop holding. - * [NodeTreeNullabilityAnalyzer.isNonNull]'s `FuncExpr` branch treats a variadic call far more - * conservatively than an ordinary one (see its own KDoc), so defaulting `true` here can only - * make an unreadable flag resolve toward nullable, never toward a confidently wrong NOT NULL. + * `:funcvariadic` always precedes `:args` in `FUNCEXPR`'s field order on PostgreSQL 16, 17, and + * 18, and is always emitted explicitly, including `:funcvariadic false`. [extractBoolField] + * matches the first occurrence of `:funcvariadic` anywhere in [text], including nested blocks, so + * this relies on the outer node's own field appearing before any nested `FUNCEXPR`'s field — both + * nesting directions are covered by `PgNodeTreeParserTest`. If a future PostgreSQL version ever + * reordered the fields, this extraction would silently read the wrong node's flag instead, and + * the fallback below cannot catch that: it only fires when `:funcvariadic` is absent from [text] + * entirely. For that absent-field case, the default is `true` (variadic), not `false`: variadic + * calls are treated more conservatively by [NodeTreeNullabilityAnalyzer.isNonNull]'s `FuncExpr` + * branch, so this default fails toward nullable, never toward a wrong NOT NULL. */ private fun parseFuncExpr(text: String): PgNodeExpression.FuncExpr { val functionOid = extractIntField(text, ":funcid") ?: error("Missing :funcid in FUNCEXPR node") @@ -707,10 +693,9 @@ internal class PgNodeTreeParser { // :testexpr is extracted unconditionally, not just for ANY/ALL: outerOperand also feeds // NodeTreeNullabilityAnalyzer.safetyWalkChildren/containsVarOutsideRelation and // GroupRteSubstitution's Var walk. Every other sublink type either emits no :testexpr or emits a - // ROWCOMPAREEXPR with no readable :args, so those consumers see nothing new today; what this buys - // is that a future SubLinkType carrying a real testexpr becomes visible automatically instead of - // being silently hidden by a subLinkType gate. isNonNull's ANY/ALL proof stays gated below, so - // this alone cannot make any sublink provably non-null. + // ROWCOMPAREEXPR with no readable :args, so a future SubLinkType carrying a real testexpr becomes + // visible automatically instead of being hidden by a subLinkType gate. isNonNull's ANY/ALL proof + // stays gated below, so this alone cannot make any sublink provably non-null. val testExprBlock = extractFieldExpression(text, ":testexpr") val outerOperand = testExprBlock?.let { testExpr -> extractArgListSection(testExpr, ":args")?.let { splitBraceBlocks(it).firstOrNull()?.let(::parseExpression) } @@ -718,11 +703,10 @@ internal class PgNodeTreeParser { val testExpressionOperatorOid = (testExprBlock?.let(::parseExpression) as? PgNodeExpression.OpExpr) ?.operatorFunctionOid // :subselect holds the sublink's subquery body ({QUERY ...}), mirroring parseSubqueryRangeTable - // and parseCteList's extraction of the same node shape — see subselectBlock's KDoc for why it - // is captured verbatim rather than parsed here. Depth-one-aware (via extractFieldExpression) - // is mandatory, not incidental: :testexpr precedes :subselect in SUBLINK's own field order, and - // :testexpr's own value can contain a NESTED sublink with its own :subselect — see - // extractFieldExpression's KDoc for the live-verified repro this guards against. + // and parseCteList's extraction of the same node shape. Depth-one-awareness (via + // extractFieldExpression) is required: :testexpr precedes :subselect in SUBLINK's field order, + // and :testexpr's own value can contain a nested sublink with its own :subselect — see + // extractFieldExpression's KDoc for the repro this guards against. val subselectBlock = extractFieldExpression(text, ":subselect") return PgNodeExpression.SubLink( subLinkType = subLinkType, @@ -844,10 +828,10 @@ internal class PgNodeTreeParser { * needing `FORMAT`-aware coercion — by transparently unwrapping to its `:formatted_expr` child * rather than modeling it as its own [PgNodeExpression] variant. * - * Load-bearing, not defensive: without this case a `{JSONVALUEEXPR ...}` parses to - * [PgNodeExpression.Unknown], which [NodeTreeNullabilityAnalyzer.isNonNull] always treats as - * nullable, so `JSON(a_not_null_column)`/`JSON_SERIALIZE(a_not_null_column)` would be reported - * nullable regardless of the source column's constraint. `JSON()`'s single argument always wraps; + * Without this case a `{JSONVALUEEXPR ...}` parses to [PgNodeExpression.Unknown], which + * [NodeTreeNullabilityAnalyzer.isNonNull] always treats as nullable, so + * `JSON(a_not_null_column)`/`JSON_SERIALIZE(a_not_null_column)` would be reported nullable + * regardless of the source column's constraint. `JSON()`'s single argument always wraps; * `JSON_SERIALIZE`'s wraps only when its argument is not already `jsonb`; `JSON_SCALAR`'s never does. * * `:formatted_expr`, not `:raw_expr`, is the value this node contributes to the enclosing @@ -901,14 +885,14 @@ internal class PgNodeTreeParser { * Extracts the content of the `(...)` list after [fieldName] — a direct field of the node [text] * itself represents, at brace depth 1 — without parsing it. * - * Depth-one-awareness (via [findMarkerAtDepthOne]) is load-bearing here, not only for - * [extractFieldExpression]'s `{...}`-valued fields: when [text]'s own [fieldName] value is EMPTY - * (`fieldName <>`, no `(` at all) but [text] also contains a deeper node carrying its own - * `fieldName (` — a `JSONCONSTRUCTOREXPR` with an empty `:args <>` whose `:func` holds an `AGGREF` - * with a real `:args (...)`, exactly how `JSON_OBJECTAGG`/`JSON_ARRAYAGG` are shaped — a plain - * `text.indexOf("$fieldName (")` finds that unrelated nested list and silently attributes the inner - * node's arguments to the outer one. See [extractFieldExpression] for why scanning for literal - * marker text is nonetheless safe against a string value that resembles a field marker. + * Depth-one-awareness (via [findMarkerAtDepthOne]) matters here too: when [text]'s own [fieldName] + * value is empty (`fieldName <>`, no `(` at all) but [text] also contains a deeper node carrying + * its own `fieldName (` — a `JSONCONSTRUCTOREXPR` with an empty `:args <>` whose `:func` holds an + * `AGGREF` with a real `:args (...)`, exactly how `JSON_OBJECTAGG`/`JSON_ARRAYAGG` are shaped — a + * plain `text.indexOf("$fieldName (")` would find that unrelated nested list and silently + * attribute the inner node's arguments to the outer one. See [extractFieldExpression] for why + * scanning for literal marker text is nonetheless safe against a string value that resembles a + * field marker. * * @return `null` if the field is absent at depth 1, or its value is `<>` (empty/absent in * `pg_node_tree`), or the value at that position is not actually a `(...)` list. @@ -936,23 +920,22 @@ internal class PgNodeTreeParser { * represents, not a same-named field belonging to some node NESTED inside one of [text]'s own * field values. * - * Depth-one-awareness is load-bearing, not defensive polish: [fieldName] is often a field whose - * OWN value is a full expression subtree that can legally contain another node of the SAME - * outer type carrying the SAME field name — e.g. a `SUBLINK`'s `:testexpr` field can itself - * contain a nested `SUBLINK` with its own `:testexpr`/`:subselect`, a `CASEWHEN`'s `:expr` - * condition can contain a nested `CASEEXPR` with its own `:result`/`:defresult`, and a - * `JSONEXPR`'s `:on_empty`/`:on_error` behavior can nest another `JSONEXPR`. Because Postgres - * serializes a node depth-first, a same-named field belonging to a NESTED node is written - * INSIDE the outer field's own value — textually EARLIER than the outer node's OWN later field - * of that name would be, whenever the outer field being searched for comes before the nested - * one in that node's field order. Verified live, PostgreSQL 17 and 18, for `SUBLINK`: its - * `:testexpr` field precedes its `:subselect` field, so for `SELECT EXISTS (SELECT v FROM u) = - * ANY (SELECT b FROM x) FROM t`, the OUTER `ANY_SUBLINK`'s `:testexpr` (an `OPEXPR` whose first - * argument is the nested `EXISTS` sublink, itself a genuine `{SUBLINK ... :subselect {QUERY - * ... u ...} ...}` block) textually precedes the outer `ANY_SUBLINK`'s OWN `:subselect {QUERY - * ... x ...}` — a naive first-match `text.indexOf(":subselect {")` scan over the OUTER - * `ANY_SUBLINK`'s full text therefore returns the INNER `EXISTS` sublink's `u`-block, not the - * outer sublink's own `x`-block, silently proving the wrong subquery's column nullable or not. + * Depth-one-awareness matters here: [fieldName] is often a field whose own value is a full + * expression subtree that can legally contain another node of the same outer type carrying the + * same field name — e.g. a `SUBLINK`'s `:testexpr` field can itself contain a nested `SUBLINK` + * with its own `:testexpr`/`:subselect`, a `CASEWHEN`'s `:expr` condition can contain a nested + * `CASEEXPR` with its own `:result`/`:defresult`, and a `JSONEXPR`'s `:on_empty`/`:on_error` + * behavior can nest another `JSONEXPR`. Postgres serializes a node depth-first, so a same-named + * field belonging to a nested node is written inside the outer field's own value — textually + * earlier than the outer node's own later field of that name, whenever the outer field being + * searched for comes before the nested one in that node's field order. On PostgreSQL 17 and 18, + * `SUBLINK`'s `:testexpr` field precedes its `:subselect` field, so for `SELECT EXISTS (SELECT v + * FROM u) = ANY (SELECT b FROM x) FROM t`, the outer `ANY_SUBLINK`'s `:testexpr` (an `OPEXPR` + * whose first argument is the nested `EXISTS` sublink, itself a genuine `{SUBLINK ... :subselect + * {QUERY ... u ...} ...}` block) textually precedes the outer `ANY_SUBLINK`'s own `:subselect + * {QUERY ... x ...}` — a naive first-match `text.indexOf(":subselect {")` scan over the outer + * `ANY_SUBLINK`'s full text would return the inner `EXISTS` sublink's `u`-block, not the outer + * sublink's own `x`-block, silently proving the wrong subquery's column nullable or not. * [findMarkerAtDepthOne] (reused here, the same helper [parseWhereQuals] uses for its * `:jointree`/`:quals` extraction) only matches [fieldName] directly inside [text]'s own * outermost `{...}` block, so a nested node's same-named field can never shadow it. diff --git a/generator/src/main/kotlin/norm/generator/SqlCteClause.kt b/generator/src/main/kotlin/norm/generator/SqlCteClause.kt index 342e2f85..9e288f73 100644 --- a/generator/src/main/kotlin/norm/generator/SqlCteClause.kt +++ b/generator/src/main/kotlin/norm/generator/SqlCteClause.kt @@ -3,10 +3,10 @@ package norm.generator /** * A parsed CTE definition from a `WITH` clause. * - * @property name The CTE name, with surrounding double quotes stripped (if any). Safe for DISPLAY, - * and for a quote-INSENSITIVE comparison where losing the quoted/unquoted distinction is - * genuinely harmless. NOT safe for constructing SQL to send to PostgreSQL, and NOT safe for any - * comparison that resolves what the name actually ADDRESSES — quoting changes case-folding + * @property name The CTE name, with surrounding double quotes stripped (if any). Safe for display, + * and for a quote-insensitive comparison where losing the quoted/unquoted distinction is + * genuinely harmless. Not safe for constructing SQL to send to PostgreSQL, and not safe for any + * comparison that resolves what the name actually addresses — quoting changes case-folding * (`"MyCte"` is distinct from `MyCte`, which folds to `mycte`), so both building a `FROM ` * reference from this stripped form, and comparing it against another identifier to decide * whether they denote the same relation, can silently pick the wrong (or a nonexistent) one — @@ -19,7 +19,7 @@ package norm.generator * @property hasColumnList Whether the CTE was declared with an explicit column list * (`name(col1, col2) AS (...)`). The list renames/repositions the body's own output names — but * [resolveNodeTreeProvenanceExpression] never consults this flag: it cross-validates against the - * CTE BODY's own `:resname`s (read from the node tree, via [PgNodeTreeParser.parseTargetList]), + * CTE body's own `:resname`s (read from the node tree, via [PgNodeTreeParser.parseTargetList]), * which an explicit column list never changes, so a renamed CTE still resolves correctly. Kept * for callers that need to know a column list was present, not because expression resolution * depends on it. @@ -108,11 +108,11 @@ private fun parseSingleCteDefinition(sql: String, startPosition: Int): Pair= 0x80` (see [matchTrailingAliasSegment], which enforces the same rule - * for an implicit alias's own first character) — but NOT a digit or `$`, both of which are legal - * only after the first character (see [isIdentifierChar]). + * character whose code is `>= 0x80` (see [matchTrailingAliasSegment], which enforces the same + * rule for an implicit alias's own first character) — never a digit or `$`, both legal only after + * the first character (see [isIdentifierChar]). */ internal const val COLUMN_REFERENCE_IDENTIFIER_START = """[\p{L}_\x{80}-\x{10FFFF}]""" /** - * The character class an unquoted PostgreSQL identifier's characters AFTER the first may be — the - * same class [isIdentifierChar] checks, expressed as a regex character class: a Unicode letter - * (`\p{L}`, matching [Char.isLetter]) or decimal digit (`\p{Nd}`, matching [Char.isDigit]), `_`, - * `$`, or any character whose code is `>= 0x80` (`\x{80}-\x{10FFFF}`, a CODE-POINT range, not a - * per-`Char` one — this is what lets it match a supplementary-plane character written as a - * surrogate pair in a Kotlin `String`, verified directly: `Regex("[\\x{80}-\\x{10FFFF}]").matches` - * on a single surrogate-pair string returns `true`, consuming both UTF-16 code units as the ONE - * code point they represent, rather than requiring the range to be repeated to cover each half). + * The character class an unquoted PostgreSQL identifier's characters after the first may be — + * the same class [isIdentifierChar] checks, expressed as a regex character class: a Unicode + * letter (`\p{L}`, matching [Char.isLetter]) or decimal digit (`\p{Nd}`, matching [Char.isDigit]), + * `_`, `$`, or any character whose code is `>= 0x80` (`\x{80}-\x{10FFFF}`, a code-point range, not + * a per-`Char` one — this is what lets it match a supplementary-plane character written as a + * surrogate pair in a Kotlin `String`: `Regex("[\\x{80}-\\x{10FFFF}]").matches` on a single + * surrogate-pair string returns `true`, consuming both UTF-16 code units as the one code point + * they represent, rather than needing the range repeated for each half). */ internal const val COLUMN_REFERENCE_IDENTIFIER_CONTINUATION = """[\p{L}\p{Nd}_$\x{80}-\x{10FFFF}]""" @@ -114,12 +107,12 @@ private const val COLUMN_REFERENCE_IDENTIFIER = /** * Matches a double-quoted PostgreSQL identifier, quotes included — `"` followed by any number of * (a non-`"` character) or (a doubled `""`, PostgreSQL's escape for a literal `"` inside the - * name), followed by the closing `"`. Deliberately NOT unescaped by this pattern itself — that is - * [unescapeQuotedIdentifier]'s job, once a caller has the matched RAW token (quotes and any + * name), followed by the closing `"`. Not unescaped by this pattern itself — that is + * [unescapeQuotedIdentifier]'s job, once a caller has the matched raw token (quotes and any * doubled escapes still intact) in hand. * * A separate constant from [COLUMN_REFERENCE_IDENTIFIER] (never merged into it): unlike an - * unquoted identifier, a quoted one is legal ONLY as a `table`/`column` position in + * unquoted identifier, a quoted one is legal only as a `table`/`column` position in * [COLUMN_REFERENCE] — never as a bare function/type name (see [FUNCTION_CALL_START], which * still uses [COLUMN_REFERENCE_IDENTIFIER_START]/[COLUMN_REFERENCE_IDENTIFIER_CONTINUATION] * directly and is unaffected by this constant). @@ -128,18 +121,18 @@ private const val QUOTED_IDENTIFIER = "\"(?:[^\"]|\"\")*\"" /** * [QUOTED_IDENTIFIER] compiled once, for callers that need to find/match a quoted identifier - * token starting at a KNOWN position within a larger string — [Regex.matchAt] — rather than + * token starting at a known position within a larger string — [Regex.matchAt] — rather than * matching an entire already-isolated string the way [COLUMN_REFERENCE] does via * [Regex.matchEntire]. [parseAliasToken] is the one caller: it needs the escape-aware end of a - * quoted alias token starting at a specific index, the exact same escape handling - * [COLUMN_REFERENCE] already applies via [COLUMN_REFERENCE_IDENTIFIER_OR_QUOTED] — sharing this - * one compiled pattern (rather than writing a second, hand-rolled quote scanner) is what keeps - * that handling from drifting out of sync between the two call sites. + * quoted alias token starting at a specific index, the same escape handling [COLUMN_REFERENCE] + * already applies via [COLUMN_REFERENCE_IDENTIFIER_OR_QUOTED] — sharing this one compiled + * pattern, rather than writing a second, hand-rolled quote scanner, keeps that handling from + * drifting out of sync between the two call sites. */ internal val QUOTED_IDENTIFIER_PATTERN = Regex(QUOTED_IDENTIFIER) /** - * Matches EITHER an unquoted [COLUMN_REFERENCE_IDENTIFIER] or a [QUOTED_IDENTIFIER] — the shape + * Matches either an unquoted [COLUMN_REFERENCE_IDENTIFIER] or a [QUOTED_IDENTIFIER] — the shape * [COLUMN_REFERENCE] uses for both its `table` and `column` positions, so either position can * independently be quoted or unquoted (`t.col`, `"t".col`, `t."col"`, `"t"."col"`). */ @@ -151,38 +144,37 @@ private const val COLUMN_REFERENCE_IDENTIFIER_OR_QUOTED = * PostgreSQL identifier ([COLUMN_REFERENCE_IDENTIFIER_START] followed by zero or more * [COLUMN_REFERENCE_IDENTIFIER_CONTINUATION] characters — never a bare `\w+`: PostgreSQL's * identifier class is wider than `\w` (it admits `$` and any `>= 0x80` character — see - * [isIdentifierChar]) but its FIRST character is narrower (`\w` itself, unlike `\w+`, doesn't - * enforce that a digit or `$` may only appear after the first character at all)) OR a - * double-quoted one ([QUOTED_IDENTIFIER] — `"ux"`, `"My Col"`, `"He""llo"`), matched via - * [COLUMN_REFERENCE_IDENTIFIER_OR_QUOTED] for each position independently. + * [isIdentifierChar]) but its first character is narrower (`\w+` doesn't enforce that a digit or + * `$` may only appear after the first character)) or a double-quoted one ([QUOTED_IDENTIFIER] — + * `"ux"`, `"My Col"`, `"He""llo"`), matched via [COLUMN_REFERENCE_IDENTIFIER_OR_QUOTED] for each + * position independently. * - * The leading-character restriction on the UNQUOTED alternative matters in the WIDENING direction - * specifically: without it, a digit- or `$`-led fragment that merely happens to be followed by - * identifier-continuation characters — e.g. the item text `2€`, which PostgreSQL itself rejects - * outright ("trailing junk after numeric literal", verified against PostgreSQL 18.4) — would - * [Regex.matchEntire] as a whole "identifier" once the continuation class is widened to admit `€`, - * handing back a `columnName` PostgreSQL would never actually resolve to that name. - * [parseColumnReference] returning `null` for anything that isn't a real identifier is the safe, - * INTENDED outcome (see [parseSelectItems]'s KDoc on why a lost name degrades safely to - * `ResultSetMetaData` while a WRONG one does not). + * The leading-character restriction on the unquoted alternative matters in the widening direction + * specifically: without it, a digit- or `$`-led fragment merely followed by + * identifier-continuation characters — e.g. `2€`, which PostgreSQL itself rejects outright + * ("trailing junk after numeric literal", on PostgreSQL 18.4) — would [Regex.matchEntire] as a + * whole "identifier" once the continuation class widens to admit `€`, handing back a `columnName` + * PostgreSQL would never resolve to that name. [parseColumnReference] returning `null` for + * anything that isn't a real identifier is the safe, intended outcome (see [parseSelectItems]'s + * KDoc on why a lost name degrades safely to `ResultSetMetaData` while a wrong one does not). * - * A matched group's captured text still includes its surrounding quotes (if any) — the WHOLE raw + * A matched group's captured text still includes its surrounding quotes (if any) — the whole raw * token, exactly as [QUOTED_IDENTIFIER] defines it — since [Regex] group captures always span - * whatever the sub-pattern matched; [parseColumnReference] is what turns that raw capture into - * the LOGICAL value [SelectItem.columnName]/[SelectItem.tableName] actually store (see their own - * KDoc), via [unescapeQuotedIdentifier]. + * whatever the sub-pattern matched; [parseColumnReference] turns that raw capture into the + * logical value [SelectItem.columnName]/[SelectItem.tableName] actually store, via + * [unescapeQuotedIdentifier]. */ internal val COLUMN_REFERENCE = Regex( """(?:(?$COLUMN_REFERENCE_IDENTIFIER_OR_QUOTED)\.)?(?$COLUMN_REFERENCE_IDENTIFIER_OR_QUOTED)""", ) /** - * Converts a RAW double-quoted identifier token — including its surrounding quotes, exactly as - * [QUOTED_IDENTIFIER] matches it — into PostgreSQL's LOGICAL identifier value: the surrounding + * Converts a raw double-quoted identifier token — including its surrounding quotes, exactly as + * [QUOTED_IDENTIFIER] matches it — into PostgreSQL's logical identifier value: the surrounding * quotes removed, and each doubled `""` escape collapsed to the single literal `"` it represents. - * Verified directly against a live PostgreSQL 18: `ResultSetMetaData.getColumnName` for `SELECT - * "He""llo" FROM (SELECT 1 AS "He""llo") s` reports `He"llo` (no quotes, escape already - * collapsed) — exactly what this function produces from the raw token `"He""llo"`. + * On PostgreSQL 18, `ResultSetMetaData.getColumnName` for `SELECT "He""llo" FROM (SELECT 1 AS + * "He""llo") s` reports `He"llo` (no quotes, escape already collapsed) — exactly what this + * function produces from the raw token `"He""llo"`. * * [rawQuotedToken] must be the exact matched text of a [QUOTED_IDENTIFIER] — starting and ending * with `"`, with at least those two characters present. Passing anything else is a caller bug, diff --git a/generator/src/main/kotlin/norm/generator/SqlKeywordScanner.kt b/generator/src/main/kotlin/norm/generator/SqlKeywordScanner.kt index 38416ef6..c9df0602 100644 --- a/generator/src/main/kotlin/norm/generator/SqlKeywordScanner.kt +++ b/generator/src/main/kotlin/norm/generator/SqlKeywordScanner.kt @@ -42,19 +42,18 @@ internal fun findMatchingCloseParenthesis( * * For `"EXISTS(...) AS valid, col1"` split on `,`, returns `["EXISTS(...) AS valid", "col1"]`. * - * Both bracket kinds share ONE depth counter rather than two independently-tracked ones: SQL + * Both bracket kinds share one depth counter rather than two independently-tracked ones: SQL * never interleaves them invalidly (a `[` is always closed by its own `]` before any enclosing * `(` closes, and vice versa), so treating `(`/`[` as "one level deeper" and `)`/`]` as "one level * shallower" — regardless of which bracket kind opened that level — is sufficient to find the - * REAL top-level delimiters. Leaving square brackets untracked would split an `ARRAY[1, 2]` item - * into two, since its internal comma is not hidden by any enclosing `(...)` (`ARRAY[1, 2] AS arr, - * OLD.tval AS oldv` — verified against real Postgres to split 2 real columns into 3 items); worse, - * that error can silently CANCEL OUT a separate star-caused split error elsewhere in the same + * real top-level delimiters. Leaving square brackets untracked would split an `ARRAY[1, 2]` item + * into two, since its internal comma is not hidden by any enclosing `(...)` — on real Postgres, + * `ARRAY[1, 2] AS arr, OLD.tval AS oldv` splits into 3 items instead of 2 real columns. Worse, + * that error can silently cancel out a separate star-caused split error elsewhere in the same * list, making a real-column-count cross-check see a coincidentally-matching count and trust a - * garbled, wrongly-indexed split (`tgt . *, OLD.tval AS oldv, ARRAY[1, 2] AS arr` — verified - * against real Postgres to split into 4 items against 4 real columns, the SAME count, while the - * split itself is `["tgt . *", "OLD.tval AS oldv", "ARRAY[1", "2] AS arr"]` — nothing about that - * split's actual items corresponds to the real 4 columns). + * garbled, wrongly-indexed split: `tgt . *, OLD.tval AS oldv, ARRAY[1, 2] AS arr` splits into 4 + * items against 4 real columns, the same count, while the split itself is `["tgt . *", "OLD.tval + * AS oldv", "ARRAY[1", "2] AS arr"]` — nothing about those items corresponds to the real columns. * * Skips string literals, quoted identifiers, dollar-quoted strings, and comments via * [skipLexicalToken], so a `(`/`)`/`[`/`]`/[delimiter] that only appears inside one of those (e.g. @@ -63,8 +62,8 @@ internal fun findMatchingCloseParenthesis( * This function is also used by `SqlParameterInferrer.extractFunctionCalls` (a function call's * comma-separated arguments) and `SqlParameterInferrer.extractValuesExpressions` (an `INSERT ... * VALUES (...)` clause's comma-separated expressions) to attribute each `?` placeholder to its - * argument/column position for parameter-NAME inference: a multi-element `ARRAY[...]` literal - * (2+ placeholders inside it) must be tracked as ONE argument/column slot, not split into several + * argument/column position for parameter-name inference: a multi-element `ARRAY[...]` literal + * (2+ placeholders inside it) must be tracked as one argument/column slot, not split into several * by its own internal commas, or every placeholder sharing that argument list or `VALUES` list — * including ones inside the array itself — gets attributed to the wrong position. */ @@ -105,19 +104,19 @@ internal fun splitAtTopLevel(text: String, delimiter: Char): List { * mistaken for the keywords `FROM`/`SET` they merely contain as a substring. * * A bare `)` with no matching `(` before it (`depth` going negative) means [sql] is not the - * well-formed, already-balanced text this scan assumes — BAILS immediately to `-1` (not found) - * rather than clamping `depth` at `0` and continuing. Clamping would let the scan silently recover - * and keep searching past the unbalanced point, which is not obviously safe either way: for a - * search this function's own callers (`parseSelectItems`'s KDoc for one) treat a MISSING keyword - * as the dangerous direction — e.g. a missing `FROM` making `parseSelectItems` fall through to - * `window.substring(itemsStart)`, taking MORE text as items than it should — a clamp-and-continue - * scan could just as easily find some LATER, wrongly-in-scope keyword instead of correctly finding - * none at all. An unbalanced scan's assumptions are already void by that point, so returning `-1` - * loudly, rather than guessing which recovery is safe, favors an honestly-wrong "not found" a - * caller's existing fallback already handles, over a confidently-wrong match this function cannot - * itself tell apart from a correct one. + * well-formed, already-balanced text this scan assumes — this bails immediately to `-1` (not + * found) rather than clamping `depth` at `0` and continuing. Clamping would let the scan silently + * recover and keep searching past the unbalanced point, which is not obviously safe either way: + * this function's own callers (`parseSelectItems`'s KDoc for one) treat a missing keyword as the + * dangerous direction — e.g. a missing `FROM` making `parseSelectItems` fall through to + * `window.substring(itemsStart)`, taking more text as items than it should — a clamp-and-continue + * scan could just as easily find some later, wrongly-in-scope keyword instead of correctly + * finding none at all. An unbalanced scan's assumptions are already void by that point, so + * returning `-1` loudly, rather than guessing which recovery is safe, favors an honestly-wrong + * "not found" a caller's existing fallback already handles, over a confidently-wrong match this + * function cannot itself tell apart from a correct one. * - * @return The index of the keyword, or `-1` if not found at the top level, INCLUDING when [sql] + * @return The index of the keyword, or `-1` if not found at the top level, including when [sql] * contains an unbalanced closing parenthesis before any top-level match — see above. */ internal fun findTopLevelKeyword(sql: String, keyword: String, startIndex: Int = 0): Int { @@ -155,42 +154,42 @@ internal fun findTopLevelKeyword(sql: String, keyword: String, startIndex: Int = /** * Finds the top-level `RETURNING` clause keyword in [sql] — distinguishing it from a bare * `returning` used as an explicit `AS returning` column alias, which is otherwise legal PostgreSQL - * syntax (verified against PostgreSQL 18.4: `CREATE TABLE bad (returning int)`, `FROM t returning`, - * `t AS returning`, and the implicit alias `SELECT email returning FROM users` are ALL syntax - * errors — an explicit `AS returning` column alias is the ONLY position a bare `returning` token - * is legal in). A plain [findTopLevelKeyword] search returns the FIRST match, which can be that - * alias rather than the real clause (`SELECT email AS returning, x FROM t RETURNING id` — the - * alias comes first); this function instead returns the first `RETURNING` NOT immediately preceded - * by the word `AS`, which is exact rather than heuristic given the grammar fact above — an alias + * syntax. On PostgreSQL 18.4, `CREATE TABLE bad (returning int)`, `FROM t returning`, `t AS + * returning`, and the implicit alias `SELECT email returning FROM users` are all syntax errors — + * an explicit `AS returning` column alias is the only position a bare `returning` token is legal + * in. A plain [findTopLevelKeyword] search returns the first match, which can be that alias + * rather than the real clause (`SELECT email AS returning, x FROM t RETURNING id` — the alias + * comes first); this function instead returns the first `RETURNING` not immediately preceded by + * the word `AS`, which is exact rather than heuristic given the grammar fact above — an alias * position is always `AS`-preceded, and the real clause keyword never is. * * A forward single-pass walk in the style of [findTopLevelKeyword]: parenthesis depth is tracked * so only a depth-0 `RETURNING` counts, and [skipLexicalToken] skips string literals, quoted * identifiers, dollar-quoted strings, and comments so a keyword-like substring inside one of those - * is never mistaken for a real keyword. A COMMENT does NOT disturb the "was the previous word - * `AS`" state — a comment between `AS` and its alias is a separator, not a token (verified valid: - * both `SELECT 1 AS/*c*/returning` and `SELECT 1 AS--x` followed by a newline then `returning` are - * legal syntax) — while a string literal, quoted identifier, or dollar-quoted string DOES clear - * that state, since none of those can themselves be the word `AS`. Plain whitespace, like a - * comment, is also not disturbing: it is the ordinary separator between `AS` and an unquoted - * alias, which is the common case this function must not break. Any other character (a - * parenthesis, comma, or operator) clears the state, since none of those can be the word `AS` - * either. Word-boundary handling is inherently correct here, unlike a substring search would be, - * because the walk consumes whole words at a time via [isIdentifierChar] — an ordinary identifier - * like `returning_batch`, or one continuing with a `>= 0x80` character like `returning€` (a legal + * is never mistaken for a real keyword. A comment does not disturb the "was the previous word + * `AS`" state — a comment between `AS` and its alias is a separator, not a token (both `SELECT 1 + * AS/*c*/returning` and `SELECT 1 AS--x` followed by a newline then `returning` are legal + * syntax) — while a string literal, quoted identifier, or dollar-quoted string does clear that + * state, since none of those can themselves be the word `AS`. Plain whitespace, like a comment, + * is also not disturbing: it is the ordinary separator between `AS` and an unquoted alias, which + * is the common case this function must not break. Any other character (a parenthesis, comma, or + * operator) clears the state, since none of those can be the word `AS` either. Word-boundary + * handling is inherently correct here, unlike a substring search would be, because the walk + * consumes whole words at a time via [isIdentifierChar] — an ordinary identifier like + * `returning_batch`, or one continuing with a `>= 0x80` character like `returning€` (a legal * PostgreSQL column name — PostgreSQL's lexer admits any byte `>= 0x80` inside an unquoted - * identifier), is read as ONE word, never mistaken for the bare keyword. Stopping the word scan at - * `€` instead would see the bare word `returning` and misidentify the alias position as the real - * clause. + * identifier), is read as one word, never mistaken for the bare keyword. Stopping the word scan + * at `€` instead would see the bare word `returning` and misidentify the alias position as the + * real clause. * - * The identifier branch is checked BEFORE the whitespace branch, deliberately: Kotlin's - * `Char.isWhitespace()` is `true` for several `>= 0x80` characters PostgreSQL does NOT treat as - * whitespace at all (e.g. U+00A0 NO-BREAK SPACE, U+2000-U+200A the various Unicode spaces, U+3000 - * IDEOGRAPHIC SPACE) — every character PostgreSQL's own lexer treats as whitespace is ASCII + * The identifier branch is checked before the whitespace branch, deliberately: Kotlin's + * `Char.isWhitespace()` is `true` for several `>= 0x80` characters PostgreSQL does not treat as + * whitespace at all (e.g. U+00A0 no-break space, U+2000-U+200A the various Unicode spaces, U+3000 + * ideographic space) — every character PostgreSQL's own lexer treats as whitespace is ASCII * (`< 0x80`), so [isIdentifierChar]'s wide `>= 0x80` branch never conflicts with a genuine * PostgreSQL whitespace character. Checking whitespace first would let one of those non-ASCII * "whitespace" characters act as an ordinary separator between two otherwise-adjacent words — - * splitting what PostgreSQL's lexer reads as ONE identifier into a leading word that can, in turn, + * splitting what PostgreSQL's lexer reads as one identifier into a leading word that can, in turn, * be misread as the bare `RETURNING` keyword. * * @return The index of the keyword, or `-1` if there is no top-level `RETURNING` that isn't itself diff --git a/generator/src/main/kotlin/norm/generator/SqlLexer.kt b/generator/src/main/kotlin/norm/generator/SqlLexer.kt index e73354d8..7fa0fd84 100644 --- a/generator/src/main/kotlin/norm/generator/SqlLexer.kt +++ b/generator/src/main/kotlin/norm/generator/SqlLexer.kt @@ -1,12 +1,11 @@ package norm.generator /** - * Removes every `--` line comment and `/* */` block comment from [text], replacing each one with a - * SINGLE space rather than deleting it outright — so that two tokens a comment used to separate - * (the far more ordinary `d\n-- only the active ones\nWHERE`, where the line comment's own - * trailing newline is what [skipLineComment] consumes along with the comment text itself) can - * never fuse into one run once the comment text is gone. Preserves everything else — including - * ordinary whitespace, string literals, quoted identifiers, and dollar-quoted strings — verbatim. + * Removes every `--` line comment and `/* */` block comment from [text], replacing each with a + * single space rather than deleting it outright, so two tokens a comment used to separate (e.g. + * `d\n-- only the active ones\nWHERE`, where [skipLineComment] consumes the comment's own trailing + * newline along with its text) can't fuse into one run once the comment text is gone. + * Preserves whitespace, string literals, quoted identifiers, and dollar-quoted strings verbatim. */ internal fun stripComments(text: String): String { val builder = StringBuilder(text.length) @@ -39,58 +38,31 @@ internal fun stripComments(text: String): String { /** * True if [character] can appear inside an unquoted PostgreSQL identifier, at any position after * the first: a letter, digit, underscore, dollar sign, or any character whose code is `>= 0x80`. - * PostgreSQL's actual identifier-continuation class (`scan.l`'s `ident_cont`) is wider than a - * plain letter/digit/`_`/`$` check: a combining mark (an alias written in NFD), a currency or - * other symbol (`€`, `©`, `¹`, `・`), and a supplementary-plane character (a surrogate pair in a - * Kotlin/UTF-16 `String` — both code units are `>= 0x80`, so no special surrogate handling is - * needed) are all legal identifier characters that Kotlin's `isLetterOrDigit()` alone does not - * recognize (none of them is a Unicode letter or digit). + * PostgreSQL's `scan.l` `ident_cont` class is wider than a plain letter/digit/`_`/`$` check — it + * also admits combining marks, currency and other symbols (`€`, `©`, `¹`), and supplementary-plane + * characters (a UTF-16 surrogate pair, both units `>= 0x80`) that Kotlin's `isLetterOrDigit()` + * alone doesn't recognize. * - * This is the SINGLE predicate every keyword word-boundary check in this file uses - * ([findTopLevelKeyword], [findTopLevelReturningKeyword], - * [matchUnicodeEscapeIdentifierSegment]'s `UESCAPE` boundary, [skipOptionalKeyword], - * [extractAlias]'s `AS` boundary, and [skipLexicalToken]'s - * dollar-quote guard), as well as [matchTrailingAliasSegment] (an implicit alias's own - * continuation characters), [isStarQualifierAcceptable] (scanning the identifier run before a - * star's qualifying `.`), [parseSingleCteDefinition] (a CTE name's characters AFTER the first — - * see [isIdentifierStartChar] for the first character itself), and [COLUMN_REFERENCE]'s - * continuation character class — deliberately ONE predicate everywhere: a real PostgreSQL keyword - * can never legitimately abut a `>= 0x80` character outside a string literal, a quoted identifier, - * a dollar-quoted string, or a comment (all already skipped by [skipLexicalToken]), so a keyword - * boundary check and an identifier-run scan must always agree on where an identifier ends — - * otherwise a PostgreSQL-legal `>= 0x80` character can truncate a run or a word at the wrong place - * (e.g. `returning€`, a legal column name, misread as the bare keyword `RETURNING` plus a stray - * `€`, or `x€9.`, where stopping at `€` instead of continuing through it leaves `9` looking like a - * qualifier's own start and misclassifies the whole thing as a numeric literal). + * Every keyword word-boundary check and identifier-run scan in this file shares this one + * predicate, so they always agree on where an identifier ends — otherwise a PostgreSQL-legal + * `>= 0x80` character could truncate a run at the wrong place (e.g. `returning€`, a legal column + * name, misread as the bare keyword `RETURNING` plus a stray `€`). */ internal fun isIdentifierChar(character: Char): Boolean = character.isLetterOrDigit() || character == '_' || character == '$' || character.code >= 0x80 /** - * True if [character] can START an unquoted PostgreSQL identifier: a letter, `_`, or any character + * True if [character] can start an unquoted PostgreSQL identifier: a letter, `_`, or any character * whose code is `>= 0x80` — never a digit or `$`, both of which are legal only after the first * character (see [isIdentifierChar]). * - * This is also the predicate for what can START a dollar-quote TAG (the `tag` in `$tag$...$tag$`): - * per PostgreSQL's `scan.l`, `ident_start` (an ordinary identifier's first character) and - * `dolq_start` (a dollar-quote tag's first character) are defined by the IDENTICAL character class, - * `[A-Za-z\200-\377_]` — a letter, `_`, or any byte `>= 0x80` (`\200`-`\377` in octal), never a - * digit and never `$`. That is a genuine coincidence in `scan.l`, not an approximation: unlike the - * two CONTINUATION classes, which `scan.l` defines DIFFERENTLY (`ident_cont` admits `$` in addition - * to letters/digits/`_`/`>= 0x80`; `dolq_cont` admits digits but never `$` — see - * [isDollarQuoteTagContinuationChar]'s KDoc), the two START classes have no such divergence, so - * unifying them here does not "collapse two positions onto one class" the way merging the - * continuation predicates would. Do NOT extend this merge to the continuation predicates for that - * reason: [isIdentifierChar] and [isDollarQuoteTagContinuationChar] must stay separate. - * - * This is the SINGLE predicate every identifier-START and dollar-quote-tag-START check in this file - * uses — [matchTrailingAliasSegment] (an implicit alias's own first character), - * [parseSingleCteDefinition] (an unquoted CTE name's first character), and [skipDollarQuotedString] - * (a dollar-quote tag's first character) all call this function directly, and - * [COLUMN_REFERENCE_IDENTIFIER_START] is the same rule expressed as a regex character class for - * [COLUMN_REFERENCE] and [FUNCTION_CALL_START] to embed — deliberately kept separate from - * [isIdentifierChar] (the CONTINUATION predicate), since using it for a CTE name's first character - * would wrongly accept a `$`-led name (`WITH $x AS (...)`, which PostgreSQL rejects outright). + * Also the predicate for what can start a dollar-quote tag (the `tag` in `$tag$...$tag$`): per + * PostgreSQL's `scan.l`, `ident_start` and `dolq_start` are defined by the identical character + * class, `[A-Za-z\200-\377_]` — a genuine coincidence, not an approximation. The two continuation + * classes diverge instead (`ident_cont` admits `$`; `dolq_cont` admits digits but never `$` — see + * [isDollarQuoteTagContinuationChar]), which is why [isIdentifierChar] and + * [isDollarQuoteTagContinuationChar] stay separate predicates even though the start class is + * shared here. */ internal fun isIdentifierStartChar(character: Char): Boolean = character.isLetter() || character == '_' || character.code >= 0x80 @@ -167,26 +139,24 @@ internal fun skipBlockComment(sql: String, start: Int): Int { /** * If `sql[position]` begins a lexical token that character-by-character scanners in this file - * must treat as an OPAQUE unit — a single-quoted string literal (including `E'...'` escape - * strings and `''`-doubled quotes), a double-quoted identifier (including `""`-doubled quotes), - * a dollar-quoted string (`$$...$$` or `$tag$...$tag$`, but ONLY when the `$` is not itself - * continuing an identifier — see the dollar-quote branch below and [skipDollarQuotedString]'s - * KDoc; PostgreSQL allows `$` inside an unquoted identifier, so `a$b$c` is one identifier, not a - * dollar-quote-delimited string starting after `a`), a `--` line comment, or a `/* */` block - * comment — returns the index immediately after that token. Otherwise returns [position] - * unchanged, meaning the caller should process this character itself (as a keyword character, a - * parenthesis, a delimiter, etc.). + * must treat as an opaque unit — a single-quoted string literal (`E'...'` escape strings, + * `''`-doubled quotes), a double-quoted identifier (`""`-doubled quotes), a dollar-quoted string + * (`$$...$$` or `$tag$...$tag$`, but only when the `$` is not itself continuing an identifier — + * see the dollar-quote branch below and [skipDollarQuotedString]'s KDoc; PostgreSQL allows `$` + * inside an unquoted identifier, so `a$b$c` is one identifier, not a dollar-quote-delimited + * string starting after `a`), a `--` line comment, or a `/* */` block comment — returns the index + * immediately after that token. Otherwise returns [position] unchanged, meaning the caller should + * process this character itself (as a keyword character, a parenthesis, a delimiter, etc.). * * This is the single place that understands enough of SQL's lexical structure to keep the raw * text scanners in this file ([findTopLevelKeyword], [findMatchingCloseParenthesis], * `splitAtTopLevel`, `extractAlias`, and any other paren-depth or keyword search) from - * misreading a `(`, `)`, or keyword that only APPEARS inside a string, a quoted identifier, or a + * misreading a `(`, `)`, or keyword that only appears inside a string, a quoted identifier, or a * comment — e.g. `RETURNING regexp_replace(name, '\(', '')` has an unbalanced `(` inside its * string literal, and `SET name = 'copied from source'` has the word `from` inside a string - * literal, neither of which is a real paren or keyword. Every such scanner MUST call this at - * each position and jump ahead when it returns a different index, rather than inspecting - * `sql[position]` directly — as of this writing, every character-by-character scanner in this - * file does. + * literal, neither of which is a real paren or keyword. Every such scanner calls this at each + * position and jumps ahead when it returns a different index, rather than inspecting + * `sql[position]` directly. * * @param adjacency See [OriginalAdjacency]'s KDoc. Defaults to [ALL_ADJACENT], correct for raw SQL * text; [StrippedText] threads itself here for its own [StrippedText.skipLexicalToken] entry @@ -202,18 +172,18 @@ internal fun skipLexicalToken(sql: String, position: Int, adjacency: OriginalAdj sql[position] == '\'' -> skipSingleQuotedString(sql, position, adjacency) sql[position] == '"' -> skipDoubleQuotedIdentifier(sql, position, adjacency) // A "$" immediately after an identifier character (e.g. the second "$" in "a$b$c", or - // either "$" in "x$$y") can never OPEN a dollar quote — it is continuing the identifier - // that started before it, exactly as PostgreSQL's own lexer only recognizes dollar-quote - // tags at the START of a new token. Without this guard, "a$b$c" reads as "a" followed by a - // "$b$"-tagged dollar-quote opener that swallows everything up to the next literal "$b$" (or - // the rest of the string, if there isn't one). + // either "$" in "x$$y") can't open a dollar quote — it continues the identifier that + // started before it, since PostgreSQL's own lexer only recognizes dollar-quote tags at the + // start of a new token. Without this guard, "a$b$c" reads as "a" followed by a "$b$"-tagged + // dollar-quote opener that swallows everything up to the next literal "$b$" (or the rest of + // the string, if there isn't one). // // Gated on adjacency.wereAdjacent(position - 1): stripping only ever removes whitespace and // comments, neither of which is an identifier character, so if the character now sitting - // immediately before this "$" was NOT actually adjacent to it in the original text, whatever - // real separator used to sit there means this "$" genuinely opens a new token, regardless of - // what character stripping happened to fuse in front of it (e.g. "x $q$...$q$" strips to - // "x$q$...$q$", where the "x" was never really continuing into "$q$" in the original query). + // immediately before this "$" was not actually adjacent to it in the original text, this "$" + // genuinely opens a new token regardless of what character stripping fused in front of it + // (e.g. "x $q$...$q$" strips to "x$q$...$q$", where the "x" never actually continued into + // "$q$" in the original query). sql[position] == '$' && !(position > 0 && adjacency.wereAdjacent(position - 1) && isIdentifierChar(sql[position - 1])) -> // skipDollarQuotedString takes the same adjacency and applies its own further gates to the @@ -235,13 +205,13 @@ internal fun skipLexicalToken(sql: String, position: Int, adjacency: OriginalAdj * always consumes the following character as a literal, so it can never end the string). * * The "standalone" check — the character before that `E`/`e`, if any, is not itself a letter, - * digit, or `_` — uses [isIdentifierChar], the SAME predicate every other word-boundary check in - * this file uses, GATED on [adjacency] (see [OriginalAdjacency]'s KDoc): [isStarItem] normalizes a - * select item through [stripCommentsAndWhitespace] and then RE-LEXES the stripped string, so a - * lookback using the full [isIdentifierChar] class (which admits `$` and any `>= 0x80` character, - * both legal PostgreSQL identifier-continuation characters that a narrower letter/digit/`_` check - * would miss) must be gated on whether the character immediately before `E`/`e` was genuinely - * adjacent in the ORIGINAL text — otherwise a separator stripping removed (e.g. the space in + * digit, or `_` — uses [isIdentifierChar], the same predicate every other word-boundary check in + * this file uses, gated on [adjacency]: [isStarItem] normalizes a select item through + * [stripCommentsAndWhitespace] and then re-lexes the stripped string, so a lookback using the + * full [isIdentifierChar] class (which admits `$` and any `>= 0x80` character, both legal + * PostgreSQL identifier-continuation characters that a narrower letter/digit/`_` check would + * miss) must be gated on whether the character immediately before `E`/`e` was genuinely adjacent + * in the original text — otherwise a separator stripping removed (e.g. the space in * `x€ E'a\'b'`) could fuse into `E` and manufacture a standalone-`E` escape string match that was * never in the query, mis-lexing a valid typed-literal call (`x E'a\'b'`, PostgreSQL's * `AexprConst: func_name Sconst` form) as something else entirely. @@ -249,7 +219,7 @@ internal fun skipLexicalToken(sql: String, position: Int, adjacency: OriginalAdj * @param adjacency See [OriginalAdjacency]'s KDoc. Gates both the "is the character immediately * before the opening quote genuinely `E`/`e`" check and, when it is, the standalone lookback one * position further back — non-adjacency at that second position means the real predecessor was a - * separator PostgreSQL itself lexed on, so `E`/`e` IS standalone regardless of what character + * separator PostgreSQL itself lexed on, so `E`/`e` is standalone regardless of what character * stripping fused in front of it. * @return The index after the closing quote, or `sql.length` if unterminated. */ @@ -267,14 +237,13 @@ private fun skipSingleQuotedString(sql: String, openQuoteIndex: Int, adjacency: continue } if (sql[i] == '\'') { - // The '' doubled-quote-escape check is gated on adjacency too: this is load-bearing, not - // uniformity-for-its-own-sake — fusion composes with escape-string mode. If a separator - // PostgreSQL lexed between two genuinely SEPARATE quote characters gets stripped away, - // fusing them into what LOOKS like a doubled '' escape, treating it as one would keep this - // scan going (still in isEscapeString mode, if it started that way) past what should have - // been the first string's real terminator — potentially overrunning all the way to - // sql.length and defeating findTrailingImplicitAliasStart's "last segment must end exactly - // at text.length" anchor (see its KDoc) in the dangerous direction (see isStarItem's KDoc). + // The '' doubled-quote-escape check is gated on adjacency too: if a separator PostgreSQL + // lexed between two genuinely separate quote characters gets stripped away, fusing them + // into what looks like a doubled '' escape, treating it as one would keep this scan going + // (still in isEscapeString mode, if it started that way) past what should have been the + // first string's real terminator — potentially overrunning all the way to sql.length and + // defeating findTrailingImplicitAliasStart's "last segment must end exactly at + // text.length" anchor (see its KDoc) in the dangerous direction (see isStarItem's KDoc). val firstQuoteIndex = i i++ if (i < sql.length && sql[i] == '\'' && adjacency.wereAdjacent(firstQuoteIndex)) { @@ -295,12 +264,9 @@ private fun skipSingleQuotedString(sql: String, openQuoteIndex: Int, adjacency: * identifier `foo"bar`). Unlike string literals, double-quoted identifiers do not support * backslash escapes. * - * @param adjacency See [OriginalAdjacency]'s KDoc. Gates the `""` doubled-quote-escape check, for - * the same invariant [skipSingleQuotedString]'s own `''` gate states — though, unlike that one, - * this is uniformity rather than a fix earning its own test: every double-quoted-identifier span - * this codebase's tests construct happens to coincide whether or not this specific check is - * gated, so this is included for the same structural reason as every other multi-character - * adjacency decision in this file, not because a concrete wrong-answer case was found. + * @param adjacency See [OriginalAdjacency]'s KDoc. Gates the `""` doubled-quote-escape check for + * consistency with [skipSingleQuotedString]'s own `''` gate — no concrete wrong-answer case has + * been found for double-quoted identifiers specifically, but the two scanners should agree. * @return The index after the closing quote, or `sql.length` if unterminated. */ internal fun skipDoubleQuotedIdentifier( @@ -326,18 +292,16 @@ internal fun skipDoubleQuotedIdentifier( } /** - * True if [character] can continue a dollar-quote TAG after its first character, per + * True if [character] can continue a dollar-quote tag after its first character, per * PostgreSQL's `scan.l`: `dolq_cont = [A-Za-z\200-\377_0-9]`. A tag's first character is instead - * gated by [isIdentifierStartChar] — per `scan.l`, `dolq_start` and `ident_start` (an ordinary - * identifier's first character) are the IDENTICAL class, so this file shares one predicate for - * both (see [isIdentifierStartChar]'s KDoc). This continuation predicate does NOT admit `$` - * (unlike [isIdentifierChar], an ordinary identifier's continuation class), since `$` delimits the - * tag rather than continuing it — this is the one place `scan.l` splits the two kinds of run - * apart, which is why continuation stays a separate predicate even though start does not. It DOES - * admit digits, unlike a tag's first character (a tag may not START with one — PostgreSQL rejects + * gated by [isIdentifierStartChar] — per `scan.l`, `dolq_start` and `ident_start` are the + * identical class, so this file shares one predicate for both (see [isIdentifierStartChar]'s + * KDoc). This continuation predicate does not admit `$` (unlike [isIdentifierChar]), since `$` + * delimits the tag rather than continuing it — the one place `scan.l` splits the two kinds of run + * apart, which is why continuation stays a separate predicate even though start does not. It does + * admit digits, unlike a tag's first character (a tag may not start with one — PostgreSQL rejects * `$1$foo$1$` as a dollar-quoted string entirely, leaving the `$1` to be read as an ordinary, - * non-quote `$`-prefixed token instead) — only a tag's first character is restricted, per - * `scan.l`'s `dolq_start`/`dolq_cont` split. + * non-quote `$`-prefixed token instead). */ private fun isDollarQuoteTagContinuationChar(character: Char): Boolean = character.isLetterOrDigit() || character == '_' || character.code >= 0x80 @@ -345,7 +309,7 @@ private fun isDollarQuoteTagContinuationChar(character: Char): Boolean = /** * `true` if every consecutive pair of characters in `[start, endExclusive)` was genuinely * adjacent in the original text, per [adjacency] — see [OriginalAdjacency]'s KDoc. Used by - * [skipDollarQuotedString] to verify its OPENING delimiter is lexically contiguous, not merely + * [skipDollarQuotedString] to verify its opening delimiter is lexically contiguous, not merely * contiguous in [stripCommentsAndWhitespace]'s stripped output — see that function's KDoc for why * the closing delimiter needs no such check of its own. */ @@ -362,13 +326,13 @@ private fun isAdjacencyContiguousSpan(adjacency: OriginalAdjacency, start: Int, * [isDollarQuoteTagContinuationChar] characters — advances past the matching closing tag (the * same `$$`/`$tag$` again). * - * Callers MUST first confirm [position] is not immediately preceded by an identifier character + * Callers must first confirm [position] is not immediately preceded by an identifier character * (see [skipLexicalToken]'s call site) — this function has no way to tell, from `$` alone, * whether it is looking at a genuine dollar-quote opener or the second `$` of an ordinary * identifier like `a$b$c` (PostgreSQL allows `$` inside an unquoted identifier), so that * decision is made by the caller before this is even invoked. * - * The OPENING delimiter is additionally required to be adjacency-contiguous (see + * The opening delimiter is additionally required to be adjacency-contiguous (see * [OriginalAdjacency]'s KDoc): every character of it — the leading `$` at [position], the tag run, * and the tag's closing `$` — must have been genuinely adjacent to its neighbour in the original * text. Without this, stripping can invent a delimiter that was never one lexical unit in the @@ -376,17 +340,17 @@ private fun isAdjacencyContiguousSpan(adjacency: OriginalAdjacency, start: Int, * token, none of them a real dollar-quote) strips to `$qb$//`, whose fused `$qb$` would otherwise * be read as an opening delimiter with tag `qb`, and `//` as its (unterminated) body. * - * The CLOSING delimiter needs no adjacency check of its own: once the opening delimiter has passed + * The closing delimiter needs no adjacency check of its own: once the opening delimiter has passed * both this gate and the caller's own `$`-not-preceded-by-an-identifier-character lookback (see - * [skipLexicalToken]'s call site), the ORIGINAL text genuinely opened a dollar-quoted string at + * [skipLexicalToken]'s call site), the original text genuinely opened a dollar-quoted string at * [position] — and [stripCommentsAndWhitespace] makes that identical determination (via this same - * function, on the original text) BEFORE ever stripping anything, so it copies the entire matched - * token — opening delimiter, body, and closing delimiter alike — through to its output VERBATIM, + * function, on the original text) before ever stripping anything, so it copies the entire matched + * token — opening delimiter, body, and closing delimiter alike — through to its output verbatim, * giving every character in that span consecutive original offsets. A real closing tag inside a * verbatim-copied span is therefore always adjacency-contiguous already, by construction, and no - * fused, fake closing tag can appear inside one either — there is nothing left for a second gate to - * catch. (An UNTERMINATED dollar-quote — no closing tag found in [sql] at all — is unaffected by - * any of this: it is not a stripping artifact, and is handled the same way regardless.) + * fused, fake closing tag can appear inside one either. An unterminated dollar-quote — no closing + * tag found in [sql] at all — is unaffected by any of this: it is not a stripping artifact, and is + * handled the same way regardless. * * @param adjacency See [OriginalAdjacency]'s KDoc. Defaults to [ALL_ADJACENT], correct for raw SQL * text — every neighbouring pair is trivially adjacent there, so this gate never rejects a diff --git a/generator/src/main/kotlin/norm/generator/SqlMappable.kt b/generator/src/main/kotlin/norm/generator/SqlMappable.kt index e5a79c77..2c4856c0 100644 --- a/generator/src/main/kotlin/norm/generator/SqlMappable.kt +++ b/generator/src/main/kotlin/norm/generator/SqlMappable.kt @@ -343,8 +343,8 @@ internal class JsonSqlMappable(private val notNull: Boolean) : SqlMappable { * legacy `java.sql.Date`/`Time`/`Timestamp` even at runtime, and `uuid`, where pgjdbc's plain * `getObject(int)` does return a `java.util.UUID` at runtime (`PgResultSet.internalGetObject` * special-cases the Postgres `uuid` type by name) but the static type is still `Any` — the class hint - * is required in both cases, for different reasons (verified against pgjdbc 42.7.13's - * `PgResultSet.getObject(int, Class)`, which special-cases each of these classes explicitly). + * is required in both cases, for different reasons (pgjdbc 42.7.13's + * `PgResultSet.getObject(int, Class)` special-cases each of these classes explicitly). * `null` only for types read via a named, non-generic getter (e.g. `getString`, `getBlob`, `getBytes`), * whose declared return type already is [kotlinType]. * @property convertOffsetDateTimeToInstant When `true`, the wire value read via [getterClassHint] diff --git a/generator/src/main/kotlin/norm/generator/SqlOutputClause.kt b/generator/src/main/kotlin/norm/generator/SqlOutputClause.kt index 46baafa8..462f430e 100644 --- a/generator/src/main/kotlin/norm/generator/SqlOutputClause.kt +++ b/generator/src/main/kotlin/norm/generator/SqlOutputClause.kt @@ -4,27 +4,27 @@ package norm.generator * A parsed item from a SQL SELECT clause. * * @property expression The full SQL expression text (e.g. `COUNT(*)`, `author.name`, `book.title`). - * @property columnName The column's LOGICAL name for a simple column reference — `null` for a - * computed expression. For a QUOTED reference (`"My Col"`, `"He""llo"`), this is the identifier + * @property columnName The column's logical name for a simple column reference — `null` for a + * computed expression. For a quoted reference (`"My Col"`, `"He""llo"`), this is the identifier * PostgreSQL itself resolves to: surrounding quotes removed and any doubled `""` escape - * collapsed to the single literal `"` it represents (verified directly against a live + * collapsed to the single literal `"` it represents (checked directly against a live * PostgreSQL 18: `ResultSetMetaData.getColumnName` for `SELECT "He""llo" FROM (SELECT 1 AS * "He""llo") s` reports `He"llo` — no quotes, escape already collapsed — which is exactly what * this property must agree with, since [JdbcAnalyzer]'s own `originalName` falls back to - * `columnName` first and only reaches `getColumnName` when this is `null`). This is NEVER the + * `columnName` first and only reaches `getColumnName` when this is `null`). This is never the * raw, quote-decorated source text — see [isColumnNameQuoted] for how the quoted/unquoted * distinction PostgreSQL folding depends on is preserved instead, alongside the logical value * rather than encoded inside it. - * @property tableName The table qualifier's LOGICAL name for a qualified reference (e.g. `author` + * @property tableName The table qualifier's logical name for a qualified reference (e.g. `author` * in `author.name`, or `My Table` in `"My Table".name`) — same logical-value convention as * [columnName], and `null` for an unqualified reference or a computed expression. - * @property isColumnNameQuoted Whether [columnName] came from a QUOTED source identifier — + * @property isColumnNameQuoted Whether [columnName] came from a quoted source identifier — * `false` when [columnName] is `null`. PostgreSQL folds a quoted identifier reference - * EXACTLY (case preserved, never lowercased) and an unquoted one to lowercase; since + * exactly (case preserved, never lowercased) and an unquoted one to lowercase; since * [columnName] itself no longer carries the quotes that would otherwise signal which rule * applies (they were already removed to produce the logical value), this flag is what a caller * doing that folding (see `foldIdentifier`'s two-argument overload) must consult instead. - * @property isTableNameQuoted Whether [tableName] came from a QUOTED source identifier — same + * @property isTableNameQuoted Whether [tableName] came from a quoted source identifier — same * convention as [isColumnNameQuoted], `false` when [tableName] is `null`. */ internal data class SelectItem( @@ -38,8 +38,8 @@ internal data class SelectItem( /** * Parses the output clause of a SQL statement to extract individual items. * - * Paired POSITIONALLY against `java.sql.ResultSetMetaData` columns by - * `JdbcAnalyzer.buildResultColumns`, so the search is restricted to the statement's MAIN query — + * Paired positionally against `java.sql.ResultSetMetaData` columns by + * `JdbcAnalyzer.buildResultColumns`, so the search is restricted to the statement's main query — * after any leading `WITH` clause's CTEs, via [parseCteClause]'s [ParsedCteClause.mainQueryStart] * — and `SELECT` is located with [findTopLevelKeyword] (depth-0, lexically aware, never * `String.indexOf`), so a nested `SELECT` or a keyword-like substring inside a literal/comment is @@ -57,19 +57,19 @@ internal data class SelectItem( * * On the `SELECT` branch only (a `RETURNING` clause has no such quantifier), an optional leading * set quantifier — `ALL`, or `DISTINCT` optionally followed by `ON (...)` — is skipped via - * [skipOptionalSetQuantifier] BEFORE the clause is split into items, so the FIRST item's + * [skipOptionalSetQuantifier] before the clause is split into items, so the first item's * `expression`/`columnName` reflect the bare column, not the quantifier glued onto it (`SELECT * DISTINCT x, id FROM t` → first item is `x`, not `DISTINCTx`). This must happen here, not inside - * [isStarItem]: [isStarItem] normalizes by stripping ALL whitespace, so `ALL 2.*a` and a genuine - * star on a table named `all2` (`SELECT all2.* a, p FROM all2` — verified valid, 3 columns) become - * textually IDENTICAL (`ALL2.*a`/`all2.*a`) once whitespace is gone; only stripping the quantifier - * BEFORE that normalization, using the still-whitespace-intact `window`, can tell them apart — + * [isStarItem]: [isStarItem] normalizes by stripping all whitespace, so `ALL 2.*a` and a genuine + * star on a table named `all2` (`SELECT all2.* a, p FROM all2` — valid, 3 columns) become + * textually identical (`ALL2.*a`/`all2.*a`) once whitespace is gone; only stripping the quantifier + * before that normalization, using the still-whitespace-intact `window`, can tell them apart — * `skipOptionalKeyword`'s own word-boundary check is what refuses to match `ALL` as a prefix of the - * identifier `all2` (verified: `SELECT ALL 2.*a lbl, b FROM t` is arithmetic, 2 columns, distinct + * identifier `all2` (`SELECT ALL 2.*a lbl, b FROM t` is arithmetic, 2 columns, distinct * from the `all2` table case despite normalizing identically further down the pipeline). * * A star item (`*`/`table.*`, via [isStarItem] — which also recognizes an implicit alias on a - * star, including a quoted, `U&`-escaped, or non-ASCII unquoted alias, though NOT claimed + * star, including a quoted, `U&`-escaped, or non-ASCII unquoted alias, though not claimed * exhaustive — see [isStarItem]'s own KDoc) expands to however many columns the starred relation * has, shifting every later item onto the wrong metadata column; an item before a star is * unaffected by that unknown width, so only the first star and everything after it is dropped — a @@ -77,9 +77,9 @@ internal data class SelectItem( * * @return Items strictly before the first star, if any; the full list if there is no star or it's * a single star item; or empty if the output clause can't be found (e.g. a `VALUES` list, a - * `TABLE` shorthand, or two SEPARATELY parenthesized set-operation branches like `(SELECT a) + * `TABLE` shorthand, or two separately parenthesized set-operation branches like `(SELECT a) * UNION (SELECT b)` — none have a depth-0 `SELECT`/`RETURNING`; a main query or CTE body wrapped - * in one redundant pair of parentheses, `(SELECT ...)`, DOES resolve — see + * in one redundant pair of parentheses, `(SELECT ...)`, does resolve — see * [stripRedundantOuterParentheses]). Both consumers degrade safely for a missing item, falling * back to `ResultSetMetaData.getColumnName` rather than reporting a wrong original name. */ @@ -93,7 +93,7 @@ internal fun parseSelectItems(sql: String): List = parseOutputItemsW * * A guard and the parser it guards must see the same text: without sharing this function, a set * operation wrapped in one parenthesis pair (`(SELECT a UNION SELECT b)`) sits at paren depth one in - * the raw text, invisible to a guard that skips the stripping step the parser applies (#238). + * the raw text, invisible to a guard that skips the stripping step the parser applies. */ private fun mainQueryWindow(sql: String): String { val mainQueryStart = parseCteClause(sql)?.mainQueryStart ?: 0 @@ -130,10 +130,10 @@ private val SET_OPERATION_KEYWORDS = listOf("UNION", "INTERSECT", "EXCEPT") * [alias] that was stripped off, if any. * * Kept separate from [SelectItem] because [SelectItem.columnName] already carries a meaning for - * simple column references — reusing it for an alias would conflate "this item IS a bare column - * reference" with "this item HAS an alias", which are different facts a computed expression can + * simple column references — reusing it for an alias would conflate "this item is a bare column + * reference" with "this item has an alias", which are different facts a computed expression can * have independently (`UPPER(description) AS description_upper` has an alias but is not a bare - * column reference; `description AS description_upper` is a bare column reference AND has an + * column reference; `description AS description_upper` is a bare column reference and has an * alias). * * @property selectItem The parsed expression/columnName/tableName, alias already removed. @@ -148,7 +148,7 @@ internal data class OutputItemWithAlias(val selectItem: SelectItem, val alias: S * why the alias can't be dropped there). Locates the same output clause [parseSelectItems] * documents finding — see its KDoc for the window/`RETURNING`-gating/star-truncation rules, all of * which apply identically here. The window itself is [mainQueryWindow] — see its KDoc for why - * [hasTopLevelSetOperation] must compute that SAME window rather than its own copy. + * [hasTopLevelSetOperation] must compute that same window rather than its own copy. */ internal fun parseOutputItemsWithAlias(sql: String): List { val window = mainQueryWindow(sql) @@ -201,19 +201,19 @@ internal fun parseOutputItemsWithAlias(sql: String): List { } /** - * Strips a REDUNDANT `(`...`)` pair wrapping [text]'s ENTIRE remaining content, repeatedly, as + * Strips a redundant `(`...`)` pair wrapping [text]'s entire remaining content, repeatedly, as * long as one remains — `(SELECT ...)`, `((SELECT ...))`, etc. * * A CTE body (or a whole top-level query) may legally wrap its `SELECT`/`RETURNING`/DML statement * in one or more redundant parenthesis pairs. Without stripping them first, [findTopLevelKeyword] - * never finds an unquoted `SELECT`/`RETURNING` at paren depth ZERO: the extra, unmatched leading - * `(` puts the whole rest of the text at depth ONE instead, so [parseOutputItemsWithAlias] returns - * no items at all for an otherwise-ordinary body (#238). + * never finds an unquoted `SELECT`/`RETURNING` at paren depth zero: the extra, unmatched leading + * `(` puts the whole rest of the text at depth one instead, so [parseOutputItemsWithAlias] returns + * no items at all for an otherwise-ordinary body. * - * A pair only counts as wrapping the ENTIRE remaining text when the first non-whitespace/comment - * character is `(` AND its own matching close parenthesis is the LAST non-whitespace/comment + * A pair only counts as wrapping the entire remaining text when the first non-whitespace/comment + * character is `(` and its own matching close parenthesis is the last non-whitespace/comment * character in [text] — never merely "starts with ( and ends with )", which would also match two - * SEPARATELY parenthesized set-operation branches (`(SELECT a) UNION (SELECT b)`), where the first + * separately parenthesized set-operation branches (`(SELECT a) UNION (SELECT b)`), where the first * `(`'s own match is nowhere near the end. */ private fun stripRedundantOuterParentheses(text: String): String { @@ -231,13 +231,13 @@ private fun stripRedundantOuterParentheses(text: String): String { /** * Skips an optional leading SQL set quantifier starting at or after [position] in [sql]: `ALL`, * or `DISTINCT` optionally followed by `ON (` ... `)`. Used by [parseSelectItems] to separate a - * `SELECT`'s quantifier from its first real item BEFORE [isStarItem] ever sees it — see that + * `SELECT`'s quantifier from its first real item before [isStarItem] ever sees it — see that * function's KDoc for why this can't be done inside [isStarItem] itself. * * [position] is the index right after the `SELECT` keyword — still followed by whitespace, since - * [skipOptionalKeyword] (unlike [skipWhitespaceAndComments]) requires its keyword to start EXACTLY + * [skipOptionalKeyword] (unlike [skipWhitespaceAndComments]) requires its keyword to start exactly * where it's told to look, with no leading separator of its own to skip. This function skips that - * whitespace/comments FIRST, then tries `ALL`/`DISTINCT` at the resulting position — otherwise + * whitespace/comments first, then tries `ALL`/`DISTINCT` at the resulting position — otherwise * `regionMatches` would fail immediately on the space between `SELECT` and the quantifier, this * function would report no quantifier present, and `parseSelectItems` would go right back to * feeding [isStarItem] the fused, whitespace-bearing text this function exists to prevent. @@ -276,8 +276,8 @@ private fun skipOptionalSetQuantifier(sql: String, position: Int): Int { * [skipLexicalToken], so an `AS`-like substring or an unbalanced paren inside one of those is * not mistaken for a real `AS` keyword or a real parenthesis. * - * Tracks `(`/`)` only, deliberately NOT `[`/`]` — unlike [splitAtTopLevel], which shares one depth - * counter across both bracket kinds because it scans RAW, not-yet-split clause text, where a + * Tracks `(`/`)` only, deliberately not `[`/`]` — unlike [splitAtTopLevel], which shares one depth + * counter across both bracket kinds because it scans raw, not-yet-split clause text, where a * top-level-looking `,` can sit directly inside an unsplit `ARRAY[...]` literal (see * [splitAtTopLevel]'s own KDoc). [extractAlias] instead only ever * receives an item [parseOutputItemsWithAlias] already split via [splitAtTopLevel] — so any @@ -290,11 +290,11 @@ private fun skipOptionalSetQuantifier(sql: String, position: Int): Int { * * The word-boundary check on either side of a candidate `AS`/`as` uses [isIdentifierChar] — the * same predicate [findTopLevelKeyword] and every other keyword scanner in this file use — rather - * than `Char.isWhitespace()`: PostgreSQL's `AS` keyword only needs to NOT be fused into a longer - * identifier on either side, not to be surrounded by literal whitespace. Verified against + * than `Char.isWhitespace()`: PostgreSQL's `AS` keyword only needs to not be fused into a longer + * identifier on either side, not to be surrounded by literal whitespace. On * PostgreSQL 18.4: `SELECT (1)AS b` returns column `b` — `AS` directly abuts the closing `)` with * no whitespace, and `)` is not an identifier character, so this is the real keyword. Conversely - * `SELECT 1 AS$b` returns column `as$b`, a SINGLE implicit alias identifier — `$` is a valid + * `SELECT 1 AS$b` returns column `as$b`, a single implicit alias identifier — `$` is a valid * identifier-continuation character (see [isIdentifierChar]), so `AS$b` is one word, not the * keyword `AS` followed by `$b`. * @@ -304,7 +304,7 @@ private fun skipOptionalSetQuantifier(sql: String, position: Int): Int { * [parseColumnReference] — [parseOutputItemsWithAlias] is this function's only caller, and does * `val (expression, alias) = extractAlias(item)`, keeping the alias half (unlike [parseSelectItems], * which discards it) — so whatever the split changes `expression` to feeds directly into that - * derivation. Verified against PostgreSQL 18.4: `SELECT a AS"b", id FROM t` is valid (columns `b`, + * derivation. On PostgreSQL 18.4: `SELECT a AS"b", id FROM t` is valid (columns `b`, * `id`; PostgreSQL reports `a` as the source column of the first result column) — `AS"b"` (no * space before the quote) is recognized as the keyword here, since `"` is not an identifier * character, so the right-hand boundary holds without requiring whitespace; the item splits into @@ -317,7 +317,7 @@ private fun skipOptionalSetQuantifier(sql: String, position: Int): Int { * a computed expression (`selectItem.columnName == null && column.table == null`). * * @return A pair of (expression, alias). `alias` is `null` when there is no `AS` keyword at all, - * AND when there is one but [parseAliasToken] finds nothing that legitimately looks like an + * and when there is one but [parseAliasToken] finds nothing that legitimately looks like an * alias right after it (see that function's own KDoc — a trailing comment, an unterminated * quote, or a string literal where an alias should be, none of which contribute a real alias * name). @@ -412,7 +412,7 @@ private fun parseAliasToken(item: String, start: Int): String? { * reference (possibly qualified with a table name, either or both parts quoted or unquoted) or a * computed expression. * - * A quoted position whose LOGICAL value comes out empty (`SELECT "" FROM t`) is treated as NO + * A quoted position whose logical value comes out empty (`SELECT "" FROM t`) is treated as no * match at all — the same `columnName = null`/`tableName = null` fallback as an expression that * doesn't match [COLUMN_REFERENCE] to begin with — rather than an empty-string name: PostgreSQL * itself rejects a zero-length delimited identifier outright (`zero-length delimited identifier` @@ -492,8 +492,8 @@ private fun parseOldNewAliasPrologue(dml: String, afterReturningKeyword: Int): P * [entry] has no top-level `AS`. * * Whitespace and comments between `AS` and the alias, and trailing the alias before the entry - * ends, are skipped rather than captured — PostgreSQL accepts both (`OLD AS o /*c*/`, verified - * live on PostgreSQL 18) and neither is part of the declared name. A naive + * ends, are skipped rather than captured — PostgreSQL accepts both (`OLD AS o /*c*/`, on + * PostgreSQL 18) and neither is part of the declared name. A naive * `substring(asIndex + 2).trim()` captured a trailing comment as part of the alias (`o /*c*/` * instead of `o`), which then never matched any real `RETURNING` item reference and silently * dropped the alias from the set of recognized `OLD`/`NEW` aliases. diff --git a/generator/src/main/kotlin/norm/generator/SqlParameterInferrer.kt b/generator/src/main/kotlin/norm/generator/SqlParameterInferrer.kt index 2008b606..5a84fe11 100644 --- a/generator/src/main/kotlin/norm/generator/SqlParameterInferrer.kt +++ b/generator/src/main/kotlin/norm/generator/SqlParameterInferrer.kt @@ -391,10 +391,10 @@ internal class SqlParameterInferrer(private val functionOverloads: Map= 0x80`-named function - * (`fn€(...)`) is likewise legal, while a digit-led name (`2fn(...)`) is rejected outright - * ("trailing junk after numeric literal") — exactly the identifier shape this regex now encodes. + * On PostgreSQL 18.4, `CREATE FUNCTION "my$fn"(...)` and the unquoted call `my$fn(...)` both + * resolve to the same function, and an unquoted `>= 0x80`-named function (`fn€(...)`) is likewise + * legal, while a digit-led name (`2fn(...)`) is rejected outright ("trailing junk after numeric + * literal") — exactly the identifier shape this regex now encodes. */ internal val FUNCTION_CALL_START = Regex( """($COLUMN_REFERENCE_IDENTIFIER_START$COLUMN_REFERENCE_IDENTIFIER_CONTINUATION*)\(""", diff --git a/generator/src/main/kotlin/norm/generator/SqlStarItem.kt b/generator/src/main/kotlin/norm/generator/SqlStarItem.kt index c0128ba7..74328491 100644 --- a/generator/src/main/kotlin/norm/generator/SqlStarItem.kt +++ b/generator/src/main/kotlin/norm/generator/SqlStarItem.kt @@ -1,15 +1,15 @@ package norm.generator /** - * Removes every comment AND all whitespace from [text], keeping every other character — including + * Removes every comment and all whitespace from [text], keeping every other character — including * the full contents of a string literal, quoted identifier, or dollar-quoted string — verbatim - * and in relative order. A comment is always removed OUTRIGHT, with nothing put in its place. + * and in relative order. A comment is always removed outright, with nothing put in its place. * Comments are recognized (and dropped) via the same [skipLineComment]/[skipBlockComment] logic - * [skipWhitespaceAndComments] uses, applied at EVERY position in [text] rather than only at an - * edge, so this finds and removes a comment ANYWHERE — including one sitting between two + * [skipWhitespaceAndComments] uses, applied at every position in [text] rather than only at an + * edge, so this finds and removes a comment anywhere — including one sitting between two * otherwise-adjacent tokens (`tgt./*c*/ *`) — not merely one that leads or trails the whole * string. A string literal, quoted identifier, or dollar-quoted string is recognized via - * [skipLexicalToken] and copied through UNCHANGED (including any whitespace or `--`/`/* */`-shaped + * [skipLexicalToken] and copied through unchanged (including any whitespace or `--`/`/* */`-shaped * text inside it, which is real content, not a comment) rather than having its own contents * stripped. * @@ -22,12 +22,12 @@ package norm.generator * The result is a [StrippedText], not a plain `String`: deleting a separator PostgreSQL itself * lexed on can fuse two characters that were never adjacent in the original query into a token * that never existed — `1 - -1` (two independently-lexed `-` tokens) strips to `1--1`, which a - * naive re-lex of the OUTPUT `String` alone would read as a `--` line comment. [StrippedText] - * carries, alongside the stripped characters, each one's ORIGINAL offset, so any later + * naive re-lex of the output `String` alone would read as a `--` line comment. [StrippedText] + * carries, alongside the stripped characters, each one's original offset, so any later * multi-character adjacency decision made against this output (a `--` line-comment or `/* */` * block-comment opener, the `$` dollar-quote identifier lookback, the standalone-`E` escape-string * lookback, a `''`/`""` doubled-quote escape) can be gated on whether the two characters were - * REALLY adjacent in the query PostgreSQL itself lexed, not merely in this function's output. + * really adjacent in the query PostgreSQL itself lexed, not merely in this function's output. */ internal fun stripCommentsAndWhitespace(text: String): StrippedText { val builder = StringBuilder(text.length) @@ -56,17 +56,17 @@ internal fun stripCommentsAndWhitespace(text: String): StrippedText { } /** - * The output of [stripCommentsAndWhitespace] — the stripped characters, plus each one's ORIGINAL + * The output of [stripCommentsAndWhitespace] — the stripped characters, plus each one's original * offset in the pre-stripping text, so [wereAdjacent] can answer whether two stripped characters * that now sit next to each other in [text] genuinely were adjacent before stripping, or whether a * comment/whitespace separator PostgreSQL itself lexed on used to sit between them. When a lexical * token (a string literal, a quoted identifier, a dollar-quoted string) is copied through wholesale - * by [stripCommentsAndWhitespace], each of its characters maps to CONSECUTIVE original offsets, so + * by [stripCommentsAndWhitespace], each of its characters maps to consecutive original offsets, so * [wereAdjacent] is `true` throughout the token's own interior — only a genuinely removed - * whitespace/comment separator between two DIFFERENT tokens (or bare characters) ever breaks that + * whitespace/comment separator between two different tokens (or bare characters) ever breaks that * consecutiveness. * - * The raw stripped `String` is kept PRIVATE: every lexer entry point stripped-path code needs + * The raw stripped `String` is kept private: every lexer entry point stripped-path code needs * ([skipLexicalToken], [findMatchingCloseParenthesis]) is exposed as a member here that threads * `this` as the [OriginalAdjacency], so stripped-path code cannot reach the `String`-taking lexer * functions and silently pass the wrong (or no) adjacency — the only way out to a plain `String` is @@ -84,7 +84,7 @@ internal class StrippedText(private val text: String, private val originalOffset leftIndex + 1 < originalOffsets.size && originalOffsets[leftIndex + 1] == originalOffsets[leftIndex] + 1 - /** `true` if the stripped text is EXACTLY [other] — the whole-string equivalent of `==`. */ + /** `true` if the stripped text is exactly [other] — the whole-string equivalent of `==`. */ fun contentEquals(other: String): Boolean = text == other fun endsWith(suffix: String): Boolean = text.endsWith(suffix) @@ -97,9 +97,9 @@ internal class StrippedText(private val text: String, private val originalOffset StrippedText(text.substring(from, until), originalOffsets.copyOfRange(from, until)) /** - * The ORIGINAL (pre-stripping) index that stripped index [strippedIndex] came from — used by + * The original (pre-stripping) index that stripped index [strippedIndex] came from — used by * [splitTrailingImplicitAlias] to translate a boundary located in stripped space (comments and - * whitespace already removed) back into the ORIGINAL text, so the expression half of the split + * whitespace already removed) back into the original text, so the expression half of the split * can be sliced out of the caller's own, un-stripped item text, comments and all, rather than the * stripped copy this class holds privately. */ @@ -114,7 +114,7 @@ internal class StrippedText(private val text: String, private val originalOffset /** * The single, deliberately named escape hatch out of this class's own lexer entry points, back - * to a plain `String` — see this class's own KDoc for why every OTHER accessor exists instead of + * to a plain `String` — see this class's own KDoc for why every other accessor exists instead of * this one. Safe only for a caller that does no lexing at all, i.e. has no adjacency decision to * gate; [isStarQualifierAcceptable] (inspecting the character class of a qualifier's trailing * run) and [splitTrailingImplicitAlias] (extracting an already-located trailing alias segment's @@ -126,58 +126,58 @@ internal class StrippedText(private val text: String, private val originalOffset /** * Check for whether a single `RETURNING`/`SELECT` item is a star (`*`, `tbl.*`), with or without * an implicit (no-`AS`) alias — including parenthesized (`(tgt.*)`), and any placement of - * comments and whitespace around or BETWEEN its tokens (`tgt.* /*c*/`, `tgt.* -- comment`, - * `tgt . *`, `tgt./*c*/ *`, `tgt.*whatever`, `tgt.*`/*c*/`whatever`), in ANY order relative to a - * wrapping `(...)` (a trailing comment can sit inside OR outside the parentheses: `(tgt.*) -- c`, + * comments and whitespace around or between its tokens (`tgt.* /*c*/`, `tgt.* -- comment`, + * `tgt . *`, `tgt./*c*/ *`, `tgt.*whatever`, `tgt.*`/*c*/`whatever`), in any order relative to a + * wrapping `(...)` (a trailing comment can sit inside or outside the parentheses: `(tgt.*) -- c`, * `(tgt.*) /*c*/`). * - * Normalizes by first stripping every comment and all whitespace from the ENTIRE item — via + * Normalizes by first stripping every comment and all whitespace from the entire item — via * [stripCommentsAndWhitespace], which finds a comment anywhere in the text, not merely at an * edge. * * Two paths, tried in order: * - * PATH 1 — the `text == "*" || text.endsWith(".*")` check (via [unwrapWrappingParentheses] then a - * literal suffix comparison). For [isStarItem], `false` is the DANGEROUS answer (an unrecognized + * Path 1 — the `text == "*" || text.endsWith(".*")` check (via [unwrapWrappingParentheses] then a + * literal suffix comparison). For [isStarItem], `false` is the dangerous answer (an unrecognized * star lets a later item survive at its raw list position, silently shifted onto the wrong - * `ResultSetMetaData` column) and `true` is the SAFE one (later items are dropped, falling back to + * `ResultSetMetaData` column) and `true` is the safe one (later items are dropped, falling back to * metadata names instead of a wrong mapping), so a rule that already answers `true` for some text * must never be replaced by one that answers `false` for that same text — only new `true` answers - * may be ADDED on top, never removed. This path alone already covers every shape whose NORMALIZED + * may be added on top, never removed. This path alone already covers every shape whose normalized * text ends in `.*` verbatim with nothing after it: `t.*`, a parenthesized composite expansion - * (`(t).*`, `(u.*)`, `((t.*))` — verified valid PostgreSQL syntax; the unwrap loop only requires - * the OUTERMOST wrapping pair to match, so it repeats until no more wrapping parens remain), a + * (`(t).*`, `(u.*)`, `((t.*))` — valid PostgreSQL syntax; the unwrap loop only requires + * the outermost wrapping pair to match, so it repeats until no more wrapping parens remain), a * `DISTINCT ON (...)` prefix ([parseSelectItems] strips this before [isStarItem] ever sees it — - * see its KDoc), and a Unicode-escape quoted identifier (`U&"my*table".*`, verified valid). + * see its KDoc), and a Unicode-escape quoted identifier (`U&"my*table".*`, also valid). * - * PATH 2 — reached ONLY when path 1 answers `false`, i.e. only for an item with a trailing + * Path 2 — reached only when path 1 answers `false`, i.e. only for an item with a trailing * implicit alias (something other than `*` is the last character, so path 1's literal suffix check * can never match). [findTrailingImplicitAliasStart] finds where that alias starts by walking the - * text FORWARD and tracking the last SEGMENT seen — PostgreSQL's grammar guarantees an implicit - * alias is always the FINAL token of a select item, so anchoring on "the last segment reaches + * text forward and tracking the last segment seen — PostgreSQL's grammar guarantees an implicit + * alias is always the final token of a select item, so anchoring on "the last segment reaches * exactly the end of the text" is grammar-backed, unlike enumerating everything that may * legitimately precede a star's qualifying dot (parentheses, brackets, quotes, a `DISTINCT ON` * prefix, a Unicode-escape identifier...), which is fragile against a qualifier shape not yet on - * the list. With the alias located, [unwrapWrappingParentheses] is applied to the PREFIX (the text + * the list. With the alias located, [unwrapWrappingParentheses] is applied to the prefix (the text * with that alias - * removed), and the SAME path-1 logic is re-run on it: accept if the unwrapped prefix is `*`, or if - * it ends in `.*` AND [isStarQualifierAcceptable] accepts the qualifier (everything before that - * final `.`) — the ONLY additional check path 2 needs beyond path 1's, since a digit-leading run - * immediately before the dot (`2.` in `SELECT 2.*3 lbl, a FROM t` — verified arithmetic returning 2 - * columns, not a star) is the ONE lexical ambiguity a numeric literal creates with a real + * removed), and the same path-1 logic is re-run on it: accept if the unwrapped prefix is `*`, or if + * it ends in `.*` and [isStarQualifierAcceptable] accepts the qualifier (everything before that + * final `.`) — the only additional check path 2 needs beyond path 1's, since a digit-leading run + * immediately before the dot (`2.` in `SELECT 2.*3 lbl, a FROM t` — arithmetic returning 2 + * columns, not a star) is the one lexical ambiguity a numeric literal creates with a real * qualifying dot; see [isStarQualifierAcceptable]'s KDoc for why every other qualifier shape is * accepted rather than enumerated. * - * Verified against PostgreSQL 18.4: `SELECT u.*whatever, preferences FROM users u` and `SELECT + * On PostgreSQL 18.4: `SELECT u.*whatever, preferences FROM users u` and `SELECT * u.*`/*c*/`whatever, preferences FROM users u` both return 5 columns (star expands, implicit - * alias ignored) — an alias directly ABUTTING the star (no separator at all, comment or otherwise) + * alias ignored) — an alias directly abutting the star (no separator at all, comment or otherwise) * must still be recognized as an implicit alias, not just one separated by whitespace or a - * comment. `SELECT *whatever, a FROM t` (a BARE star with an abutting alias) is, by contrast, a + * comment. `SELECT *whatever, a FROM t` (a bare star with an abutting alias) is, by contrast, a * genuine PostgreSQL syntax error — a bare `*` cannot itself take an alias — so this function's * willingness to call it a star for an empty qualifier is unreachable on real input, not a gap * that needs closing. * - * NOT claimed exhaustive: [parseSelectItems] has no independent real-column-count to cross-check + * Not claimed exhaustive: [parseSelectItems] has no independent real-column-count to cross-check * against at the point it runs, so a spelling this function fails to recognize there degrades * silently to a wrong, shifted mapping rather than a fail-safe. */ @@ -194,9 +194,9 @@ internal fun isStarItem(item: String): Boolean { } /** - * Repeatedly strips a wrapping `(...)` from [text] — verified via - * [StrippedText.findMatchingCloseParenthesis] to be a genuine matching pair (not merely the first - * and last characters happening to be `(` and `)`) — until none remains: `((t.*))` unwraps in two + * Repeatedly strips a wrapping `(...)` from [text] — via + * [StrippedText.findMatchingCloseParenthesis], so it is a genuine matching pair, not merely the first + * and last characters happening to be `(` and `)` — until none remains: `((t.*))` unwraps in two * passes to `t.*`. */ private fun unwrapWrappingParentheses(text: StrippedText): StrippedText { @@ -215,14 +215,14 @@ private fun unwrapWrappingParentheses(text: StrippedText): StrippedText { * Finds where a trailing implicit alias starts in [text] (already normalized by * [stripCommentsAndWhitespace]), or `null` if there is none. * - * Walks [text] FORWARD, recording the start and end of the last SEGMENT seen (see + * Walks [text] forward, recording the start and end of the last segment seen (see * [matchTrailingAliasSegment] for what counts as one). A character that doesn't start a segment, - * and a lexical token that ISN'T a segment (a single-quoted string literal, a dollar-quoted + * and a lexical token that isn't a segment (a single-quoted string literal, a dollar-quoted * string — skipped via [skipLexicalToken]), are walked over without ending the search; they simply - * mean the segment recorded so far is not the final one. An implicit alias EXISTS only if the last - * recorded segment ends EXACTLY at `text.length` (nothing trails it) AND starts at an index + * mean the segment recorded so far is not the final one. An implicit alias exists only if the last + * recorded segment ends exactly at `text.length` (nothing trails it) and starts at an index * greater than `0` (there is something — the qualifier and its star — before it; a segment - * spanning the ENTIRE text is not "prefix plus alias", it's just one bare identifier with no star + * spanning the entire text is not "prefix plus alias", it's just one bare identifier with no star * in it at all, e.g. `preferencesprefs`). * * @return The index where the trailing alias segment starts, or `null` if [text] has no such @@ -252,14 +252,14 @@ private fun findTrailingImplicitAliasStart(text: StrippedText): Int? { * trailing alias at all, generalizing [findTrailingImplicitAliasStart] (the same detection * [isStarItem] uses for a star's own trailing alias) beyond star items to any item's text. * - * That function already answers only for a trailing segment that is the item's FINAL token and - * does NOT span the entire item, so a bare column reference (`description` — one segment covering - * the whole text) correctly returns `null` here, not itself, matching this function's OWN "no + * That function already answers only for a trailing segment that is the item's final token and + * does not span the entire item, so a bare column reference (`description` — one segment covering + * the whole text) correctly returns `null` here, not itself, matching this function's own "no * implicit alias" contract for that shape. * - * [item] is stripped of comments/whitespace only to LOCATE the split point ([findTrailingImplicitAliasStart] + * [item] is stripped of comments/whitespace only to locate the split point ([findTrailingImplicitAliasStart] * needs that normalized form) — the returned [ItemAndImplicitAlias.expression] is sliced out of - * [item] itself, ORIGINAL formatting (including any comment [stripComments] must still remove + * [item] itself, original formatting (including any comment [stripComments] must still remove * downstream) intact, via [StrippedText.originalIndexOf] translating the stripped split point back * to [item]'s own indices. * @@ -283,21 +283,21 @@ internal fun splitTrailingImplicitAlias(item: String): ItemAndImplicitAlias? { internal data class ItemAndImplicitAlias(val expression: String, val alias: String) /** - * Matches ONE segment starting at [start] in [text], in this precedence order: + * Matches one segment starting at [start] in [text], in this precedence order: * 1. A Unicode-escape identifier — `U&`/`u&` immediately followed by a double-quoted identifier * (via [skipLexicalToken]), optionally followed by the word `UESCAPE` and a single-quoted * escape-character string, in which case the segment extends through that string — see - * [matchUnicodeEscapeIdentifierSegment]. Tried FIRST: for `u.*U&"a"`, checking the bare-identifier + * [matchUnicodeEscapeIdentifierSegment]. Tried first: for `u.*U&"a"`, checking the bare-identifier * rule (3, below) first would match `U` alone as a one-character identifier segment, then * `"a"` as a separate later segment — the alias would appear to end at `"a"`, but the prefix * would wrongly include the dangling `U&`, and the star would never be found as `.` + `*` - * immediately before it. Trying the Unicode-escape rule first consumes `U&"a"` as ONE segment, + * immediately before it. Trying the Unicode-escape rule first consumes `U&"a"` as one segment, * so the star at `.*` immediately precedes it, exactly as PostgreSQL itself parses it. * 2. A bare double-quoted identifier, `"..."` (`""`-doubling included, via [skipLexicalToken]). * 3. An unquoted identifier: first character [isIdentifierStartChar] (a letter, `_`, or any * character whose code is `>= 0x80`), every subsequent character [isIdentifierChar] — * PostgreSQL identifiers may not start with a digit or `$`. A `$` encountered mid-run stops the - * run instead of continuing it, per [OriginalAdjacency]'s own gate, when [text] says it was NOT + * run instead of continuing it, per [OriginalAdjacency]'s own gate, when [text] says it was not * genuinely adjacent to the character before it — see the loop below. * * @return The index immediately after the matched segment, or `null` if [start] does not begin @@ -310,16 +310,16 @@ private fun matchTrailingAliasSegment(text: StrippedText, start: Int): Int? { val afterToken = text.skipLexicalToken(start) return if (afterToken != start) afterToken else null } - // PostgreSQL's lexer admits ANY byte >= 0x80 to START an unquoted identifier too (not merely to + // PostgreSQL's lexer admits any byte >= 0x80 to start an unquoted identifier too (not merely to // continue one, which [isIdentifierChar] already covers) — not merely a - // Unicode `isLetter()`. Verified: a combining mark (an alias written in NFD, e.g. "préfs" spelled + // Unicode `isLetter()`. A combining mark (an alias written in NFD, e.g. "préfs" spelled // p-r-e-COMBINING_ACUTE-f-s), a symbol (a currency sign `€`, `©`, `°`), and a supplementary-plane - // character (an astral emoji `🚀`, a mathematical alphanumeric symbol `𝐀`) are all legal FIRST + // character (an astral emoji `🚀`, a mathematical alphanumeric symbol `𝐀`) are all legal first // characters of an unquoted identifier that PostgreSQL accepts, none of which `isLetter()` - // recognizes as a letter — `isLetter()` alone therefore MISSED every one of those alias shapes, + // recognizes as a letter — `isLetter()` alone therefore missed every one of those alias shapes, // the dangerous direction (see [isStarItem]'s KDoc). A surrogate pair (as a supplementary-plane // character always is, in a Kotlin/UTF-16 `String`) is naturally covered without special - // handling: BOTH of its code units are >= 0x80, so the ordinary per-character loop below + // handling: both of its code units are >= 0x80, so the ordinary per-character loop below // consumes each half in turn. isIdentifierStartChar is the shared predicate for this rule — see // its KDoc for the other call sites. if (!isIdentifierStartChar(text[start])) return null @@ -330,7 +330,7 @@ private fun matchTrailingAliasSegment(text: StrippedText, start: Int): Int? { // apart -- "description dx" strips to "descriptiondx", and without this gate the run would // swallow both tokens into one fused segment spanning the whole text, tripping // findTrailingImplicitAliasStart's "a segment spanning the entire text is not an alias" guard so - // no alias is found at all (#238). Stopping at the first non-adjacent character ends the first + // no alias is found at all. Stopping at the first non-adjacent character ends the first // identifier's segment at its real token boundary, letting findTrailingImplicitAliasStart pick // the second identifier up as its own later segment. This also covers "$": a non-adjacent "$" // stops the run here and is handed back to [StrippedText.skipLexicalToken], which recognizes a @@ -344,19 +344,19 @@ private fun matchTrailingAliasSegment(text: StrippedText, start: Int): Int? { * Matches a Unicode-escape identifier — `U&`/`u&` immediately followed by a double-quoted * identifier, e.g. `U&"my*table"` — starting at [start] in [text], optionally extended by a * `UESCAPE ''` clause naming a custom escape character (PostgreSQL merges the identifier, - * the `UESCAPE` keyword, and the single-quoted escape-character string into ONE lexical unit — - * verified: `U&"d!0061t" UESCAPE '!'` and `U&"!0074" UESCAPE '!'` are each a single identifier, + * the `UESCAPE` keyword, and the single-quoted escape-character string into one lexical unit — + * `U&"d!0061t" UESCAPE '!'` and `U&"!0074" UESCAPE '!'` are each a single identifier, * both resolving via the `!`-escape to the same characters `U&"data"`/`U&"t"` would spell without * one). [text] has already had all whitespace removed by [stripCommentsAndWhitespace], so the * `UESCAPE` keyword and its string abut the identifier directly with no separator to skip. * - * The `UESCAPE` keyword's own adjacency to the identifier before it is deliberately left UNGATED, + * The `UESCAPE` keyword's own adjacency to the identifier before it is deliberately left ungated, * unlike every other multi-character adjacency decision in this file: PostgreSQL itself permits - * whitespace between a `U&"..."` identifier and its `UESCAPE` clause (verified: `U&"!0074" + * whitespace between a `U&"..."` identifier and its `UESCAPE` clause (`U&"!0074" * UESCAPE '!'` and `U&"!0074"UESCAPE'!'` both resolve identically), so the abutment stripping * creates here is not a manufactured token — [stripCommentsAndWhitespace] is implementing the real * grammar, not accidentally fusing two things PostgreSQL lexed apart. And even if some other - * adjacency this function doesn't check turned out to matter, a false match here only EXTENDS the + * adjacency this function doesn't check turned out to matter, a false match here only extends the * matched segment further than it should — the same safe direction [isStarItem] relies on * throughout (see its KDoc), not the dangerous one a gate exists to prevent. * @@ -385,20 +385,20 @@ private fun matchUnicodeEscapeIdentifierSegment(text: StrippedText, start: Int): /** * Checks that [qualifierEndingInDot] (the qualifier before a star recognized on path 2 of - * [isStarItem], guaranteed by its caller to end in `.`) is acceptable. Deliberately NOT an + * [isStarItem], guaranteed by its caller to end in `.`) is acceptable. Deliberately not an * enumeration of every character that may legitimately precede the dot (`"`, `)`, `]`, an * identifier run, a `DISTINCT ON (...)` prefix, a parenthesized composite expansion, an array - * subscript, a Unicode-escape identifier with a `UESCAPE` clause...): `false` is the DANGEROUS - * answer here (see [isStarItem]'s KDoc), so it must be EARNED by an actual disqualifying shape, + * subscript, a Unicode-escape identifier with a `UESCAPE` clause...): `false` is the dangerous + * answer here (see [isStarItem]'s KDoc), so it must be earned by an actual disqualifying shape, * not handed out by default whenever a new qualifier shape isn't yet on an enumerated list. * - * Rejects ONLY when the run of [isIdentifierChar] characters immediately preceding the final `.` - * is NON-EMPTY and its first character is an ASCII digit (`'0'..'9'`, NOT `Char.isDigit()`) — a + * Rejects only when the run of [isIdentifierChar] characters immediately preceding the final `.` + * is non-empty and its first character is an ASCII digit (`'0'..'9'`, not `Char.isDigit()`) — a * digit-leading run before a dot is a numeric literal (`2.` in `SELECT 2.*3 lbl, a FROM t` — - * verified arithmetic returning 2 columns, not a star), the ONE lexical ambiguity a real - * qualifying dot has, and PostgreSQL numeric literals use ASCII digits EXCLUSIVELY — so a + * arithmetic returning 2 columns, not a star), the one lexical ambiguity a real + * qualifying dot has, and PostgreSQL numeric literals use ASCII digits exclusively — so a * non-ASCII digit (Unicode category Nd, e.g. `٣` ARABIC-INDIC DIGIT THREE, `3` FULLWIDTH DIGIT - * THREE) can only be an identifier's first character there, never a numeral: verified with a real + * THREE) can only be an identifier's first character there, never a numeral: a real * table literally named `٣`, `SELECT ٣.* x, a FROM ٣` returns 3 columns. `Char.isDigit()` is * Unicode-aware and would wrongly reject that qualifier as if it were numeric. * @@ -409,9 +409,9 @@ private fun matchUnicodeEscapeIdentifierSegment(text: StrippedText, start: Int): * before it looking like the run's own start (`x€9.`: scanning backward with a letter-or-digit-only * check stops at `€`, making `9` look like the run's start and wrongly rejecting the whole thing as * numeric, when the real run is `x€9` — letter-led, and correctly acceptable). Sharing one - * predicate makes that symmetry STRUCTURAL rather than a comment to remember to keep in sync. + * predicate makes that symmetry structural rather than a comment to remember to keep in sync. * - * Accepts in every other case, INCLUDING an empty run (the character immediately before the dot is + * Accepts in every other case, including an empty run (the character immediately before the dot is * `"`, `)`, `]`, or anything else that isn't an identifier-continuation character at all) — this * is what lets a Unicode-escape qualifier like `U&"!0074"UESCAPE'!'.` (ending in the escape * string's closing `'`) work with no special case for it whatsoever. diff --git a/generator/src/main/kotlin/norm/generator/TypeRepository.kt b/generator/src/main/kotlin/norm/generator/TypeRepository.kt index 30170170..ce4e262a 100644 --- a/generator/src/main/kotlin/norm/generator/TypeRepository.kt +++ b/generator/src/main/kotlin/norm/generator/TypeRepository.kt @@ -563,8 +563,8 @@ internal class TypeRepository( * enforces between the two maps. * * Includes the `serial`/`smallserial`/`bigserial` pseudo-types even though Postgres rejects - * `CREATE DOMAIN ... AS serial` outright (`type "serial" does not exist` — verified against a live - * server; a domain's base is always a REAL registered `pg_type`, so `domain.baseType` can never + * `CREATE DOMAIN ... AS serial` outright (`type "serial" does not exist` on a live + * server; a domain's base is always a real, registered `pg_type`, so `domain.baseType` can never * actually be one of these), and even though `resolveJdbcTypeInfo`'s only other callers * ([TypeRepository.buildUserConfiguredMappable]'s user-configured type mappings) also only ever * see the real, JDBC-reported type name, never a serial alias: keeping them out would make this @@ -633,12 +633,12 @@ internal val BASE_TYPE_RESOLVERS: Map SqlMappable> * `createArrayOf` would fail with `Unable to find server array type for provided name {0}`, since * `pg_type` has no row named `integer` — only `int4`. * - * Every branch below was verified against a live PostgreSQL 17 server via + * Every branch below was checked against a live PostgreSQL 17 server via * `SELECT typname FROM pg_type WHERE oid = to_regtype(?)`: every alias here resolves to the * canonical name on its right-hand side, and every `pg_catalog.`-qualified spelling of an - * ALREADY-canonical name (e.g. `pg_catalog.uuid`, `pg_catalog.timestamptz`) resolves to itself — + * already-canonical name (e.g. `pg_catalog.uuid`, `pg_catalog.timestamptz`) resolves to itself — * confirming the universal `removePrefix` below is sufficient for those without a dedicated - * branch. `pg_catalog.boolean` and `pg_catalog.integer` do NOT resolve on a live server (`boolean` + * branch. `pg_catalog.boolean` and `pg_catalog.integer` do not resolve on a live server (`boolean` * and `integer` are SQL-standard keyword aliases recognized only unqualified, not as schema- * qualified `pg_catalog` names) — but that combination can never actually reach this function: * JDBC's `TYPE_NAME`/`getColumnTypeName` always report the canonical, unqualified name. @@ -677,7 +677,7 @@ internal fun postgresArrayElementTypeName(typeName: String): String = * mapping for the same key exactly — see [JdbcTypeInfo.getterClassHint] and * [JdbcTypeInfo.convertOffsetDateTimeToInstant] for the cases (`java.time` types, `uuid`, and * `timestamptz` specifically) where matching the non-domain path requires more than a plain - * `getX`/`setX` method pair, each verified against pgjdbc 42.7.13's source rather than assumed. + * `getX`/`setX` method pair, each checked against pgjdbc 42.7.13's source rather than assumed. */ internal fun resolveJdbcTypeInfo(baseTypeName: String): JdbcTypeInfo? = when (baseTypeName) { "smallserial", "serial2", "smallint", "int2" -> @@ -717,11 +717,11 @@ internal fun resolveJdbcTypeInfo(baseTypeName: String): JdbcTypeInfo? = when (ba "bytea" -> JdbcTypeInfo("getBytes", "setBytes", false, "BINARY", kotlinType = ByteArray::class.asTypeName()) // Matches PostgresSupportedTypes.LOCAL_DATE/LOCAL_TIME/OFFSET_TIME/LOCAL_DATE_TIME: pgjdbc's - // plain getObject(int) returns java.sql.Date/Time/Timestamp for these columns, NOT the java.time + // plain getObject(int) returns java.sql.Date/Time/Timestamp for these columns, not the java.time // type, so the read needs the class-qualified getObject(int, Class) overload (getterClassHint). // The write side needs no such qualification: PgPreparedStatement.setObject(int, Object) already // dispatches on the runtime type of a LocalDate/LocalTime/OffsetTime/LocalDateTime/OffsetDateTime - // argument directly (verified against pgjdbc 42.7.13's source). + // argument directly (pgjdbc 42.7.13's source). "date" -> JdbcTypeInfo( "getObject", @@ -759,7 +759,7 @@ internal fun resolveJdbcTypeInfo(baseTypeName: String): JdbcTypeInfo? = when (ba getterClassHint = LocalDateTime::class.asClassName(), ) // Matches InstantSqlMappable: the wire representation is OffsetDateTime (read via the - // class-qualified getObject, written via plain setObject — both verified against pgjdbc's + // class-qualified getObject, written via plain setObject — both checked against pgjdbc's // source the same way as the other java.time entries above), but the Kotlin representation the // non-domain scalar path uses is Instant, via a `.toInstant()`/`OffsetDateTime.ofInstant(...)` // conversion — see JdbcTypeInfo.convertOffsetDateTimeToInstant's KDoc. @@ -776,7 +776,7 @@ internal fun resolveJdbcTypeInfo(baseTypeName: String): JdbcTypeInfo? = when (ba // Matches PostgresSupportedTypes.UUID: java.sql.ResultSet.getObject(int) is declared to return // Object, so a bare getObject(index) call is statically Any in Kotlin regardless of what pgjdbc // returns at runtime — PgResultSet's internalGetObject does special-case the Postgres "uuid" - // type by name and hands back a java.util.UUID instance (verified against pgjdbc 42.7.13's + // type by name and hands back a java.util.UUID instance (per pgjdbc 42.7.13's // source), but that's a runtime fact, not a static type, and ColumnAdapter.decode requires a statically-typed UUID argument. The class-qualified // getObject(int, Class) overload (getterClassHint) fixes the static type; pgjdbc's @@ -955,17 +955,17 @@ private fun String.formatAsKdocPropertyReference(): String? = when { * A PostgreSQL identifier that never needs double-quoting when written back into SQL: starts with * a lowercase letter or underscore, followed by any number of lowercase letters, digits, * underscores, or dollar signs (Postgres's own `SAFE_IDENTIFIER` rule, matching what an - * ALREADY-live-connected caller does in [JdbcAnalyzer.buildIdentifierQuoter] — this copy exists + * already-live-connected caller does in [JdbcAnalyzer.buildIdentifierQuoter] — this copy exists * because [TypeRepository] has no connection of its own to query, per this file's own doc comment * on why `TypeRepository` re-lexes rather than re-querying). Matching this pattern is necessary but - * NOT sufficient — [quoteSqlIdentifierIfNeeded] additionally rejects a RESERVED word, which this + * not sufficient — [quoteSqlIdentifierIfNeeded] additionally rejects a reserved word, which this * pattern alone cannot rule out (`order` and `user` both match it). */ private val SQL_UNQUOTED_IDENTIFIER = Regex("[a-z_][a-z0-9_\$]*") /** * Double-quotes [identifier] exactly as PostgreSQL itself requires it to be written back into SQL - * — doubling any embedded `"` per PostgreSQL's own quoted-identifier escape rule — unless BOTH + * — doubling any embedded `"` per PostgreSQL's own quoted-identifier escape rule — unless both * [SQL_UNQUOTED_IDENTIFIER] accepts it bare AND it is not one of [reservedWords]. * * Without the [SQL_UNQUOTED_IDENTIFIER] half, a mixed-case or space-containing column name (`"Foo"`, diff --git a/generator/src/test/kotlin/norm/generator/CollapseCosmeticWhitespaceTest.kt b/generator/src/test/kotlin/norm/generator/CollapseCosmeticWhitespaceTest.kt index 3ff689f8..4fc3a27f 100644 --- a/generator/src/test/kotlin/norm/generator/CollapseCosmeticWhitespaceTest.kt +++ b/generator/src/test/kotlin/norm/generator/CollapseCosmeticWhitespaceTest.kt @@ -17,8 +17,8 @@ class CollapseCosmeticWhitespaceTest { @Test fun `two literal spaces inside a quoted identifier survive untouched`() { - // Verified live: CREATE TABLE t (id INT NOT NULL, "My Col" TEXT NOT NULL) then - // UPPER("My Col") -- collapsing the internal double space to one names a DIFFERENT, + // PostgreSQL: CREATE TABLE t (id INT NOT NULL, "My Col" TEXT NOT NULL) then + // UPPER("My Col") -- collapsing the internal double space to one names a different, // nonexistent column ("My Col"), which PostgreSQL rejects outright. assertThat(collapseCosmeticWhitespace("""UPPER("My Col")""")).isEqualTo("""UPPER("My Col")""") } @@ -79,10 +79,10 @@ class CollapseCosmeticWhitespaceTest { @Test fun `a block comment between two tokens is walked over as one opaque unit, never rewritten`() { - // collapseCosmeticWhitespace runs AFTER stripComments in the real pipeline, so a raw comment - // is not expected input here -- this pins that a comment reached directly is copied through - // VERBATIM (never a byte inside it rewritten), matching every other opaque span this - // function protects; removing the comment itself is stripComments' own job, not this one's. + // collapseCosmeticWhitespace runs after stripComments in the real pipeline, so a raw comment is + // not expected input here -- this pins that a comment reached directly is copied through + // verbatim, like every other opaque span this function protects. Removing the comment itself is + // stripComments' own job, not this one's. assertThat(collapseCosmeticWhitespace("UPPER(/* a b */name)")).isEqualTo("UPPER(/* a b */name)") } diff --git a/generator/src/test/kotlin/norm/generator/ColumnTypeMappingTest.kt b/generator/src/test/kotlin/norm/generator/ColumnTypeMappingTest.kt index 2f7b8b57..39bbae28 100644 --- a/generator/src/test/kotlin/norm/generator/ColumnTypeMappingTest.kt +++ b/generator/src/test/kotlin/norm/generator/ColumnTypeMappingTest.kt @@ -982,7 +982,7 @@ class ColumnTypeMappingTest { /** * Anti-drift sweep for [postgresArrayElementTypeName], the same intent as - * [DomainBaseTypeAntiDriftSweep] but pinned against a HARDCODED, independently-verified + * [DomainBaseTypeAntiDriftSweep] but pinned against a hardcoded, independently-verified * classification rather than [BASE_TYPE_RESOLVERS] membership. A membership check is a * tautology here: every [BASE_TYPE_RESOLVERS] key that is not folded still passes itself * through unchanged (`postgresArrayElementTypeName`'s `else` branch), and every SQL-spelling @@ -990,12 +990,12 @@ class ColumnTypeMappingTest { * would still leave every folded result a [BASE_TYPE_RESOLVERS] key and a membership check * green. * - * [expectedCanonicalNameByAlias] and [alreadyCanonicalNames] below are hand-verified against a - * live PostgreSQL 17 server via `SELECT typname FROM pg_type WHERE oid = to_regtype(?)` — see + * [expectedCanonicalNameByAlias] and [alreadyCanonicalNames] below come from a PostgreSQL 17 + * server via `SELECT typname FROM pg_type WHERE oid = to_regtype(?)` — see * [postgresArrayElementTypeName]'s KDoc — and never derived from [BASE_TYPE_RESOLVERS] or * [postgresArrayElementTypeName] themselves. The set-equality assertion catches a new * [BASE_TYPE_RESOLVERS] key added without being classified into either bucket; the per-alias - * assertions catch a fold branch that is deleted, or wrong, by checking the ACTUAL fold result + * assertions catch a fold branch that is deleted, or wrong, by checking the actual fold result * against this table's fixed expectation rather than a self-referential set. * * Serial variants (`serial`, `bigserial`, ...) are excluded: [postgresArrayElementTypeName]'s @@ -2182,7 +2182,7 @@ class ColumnTypeMappingTest { @Test fun `unsupported type returns null`() { // xml has no entry anywhere -- Norm has never mapped it to a Kotlin type, as a plain column - // type or a domain base. bytea IS supported (see BASE_TYPE_RESOLVERS/DomainBaseTypes below) -- + // type or a domain base. bytea is supported (see BASE_TYPE_RESOLVERS/DomainBaseTypes below) -- // it used to return null here, which is exactly the bug this fix closes: CREATE DOMAIN d AS // bytea aborted code generation entirely. assertThat(resolveJdbcTypeInfo("xml")).isEqualTo(null) @@ -2191,7 +2191,7 @@ class ColumnTypeMappingTest { /** * Anti-drift sweep for the bug where a domain over a common base type (e.g. `CREATE DOMAIN d AS - * timestamptz`) aborted code generation: [resolveJdbcTypeInfo] must have an entry for EVERY + * timestamptz`) aborted code generation: [resolveJdbcTypeInfo] must have an entry for every * canonical type name [BASE_TYPE_RESOLVERS] accepts, since [TypeRepository]'s domain resolution * chains through [resolveJdbcTypeInfo] for the domain's base type. The corpus is * [BASE_TYPE_RESOLVERS]'s own keys -- the exact set [TypeRepository.resolveBaseType] accepts -- @@ -2378,10 +2378,10 @@ class ColumnTypeMappingTest { @Test fun `column override matches the real source column, not a CTE's own output alias`() { - // #238: JdbcAnalyzer.buildResultColumns now populates originalName from the node tree's own - // :resorigtbl/:resorigcol -- the REAL source column, resolved through a CTE even when the + // JdbcAnalyzer.buildResultColumns now populates originalName from the node tree's own + // :resorigtbl/:resorigcol -- the real source column, resolved through a CTE even when the // outer select item is a plain reference to the CTE's own (possibly renamed) output alias. - // tryResolveColumnOverride keys columnLevelOverrides off that SAME originalName, so a + // tryResolveColumnOverride keys columnLevelOverrides off that same originalName, so a // columnMapping("parent", "id") must match a column whose outer name is "parentId" as long as // its originalName is "id" -- before this fix, originalName mirrored the alias itself // ("parentId"), and the mapping silently never matched. diff --git a/generator/src/test/kotlin/norm/generator/ExplainAnalysisTest.kt b/generator/src/test/kotlin/norm/generator/ExplainAnalysisTest.kt index 5cbba583..13e4cda6 100644 --- a/generator/src/test/kotlin/norm/generator/ExplainAnalysisTest.kt +++ b/generator/src/test/kotlin/norm/generator/ExplainAnalysisTest.kt @@ -15,10 +15,10 @@ import java.sql.DriverManager import java.util.concurrent.atomic.AtomicInteger /** - * Stage 2 of the `prosqlbody` cutover: proves `EXPLAIN (FORMAT JSON)` never executes the - * statement it plans, and that it correctly reports a `MERGE`'s per-relation match-optionality — - * the one thing [PgCatalogLoader.mergeAbsentVarnos]'s KDoc documents as invisible to - * `:varnullingrels` on EITHER the `CREATE VIEW`/`ev_action` or `prosqlbody` route. + * Stage 2 of the `prosqlbody` cutover: `EXPLAIN (FORMAT JSON)` never executes the statement it + * plans, and reports a `MERGE`'s per-relation match-optionality — the one thing + * [PgCatalogLoader.mergeAbsentVarnos]'s KDoc documents as invisible to `:varnullingrels` on the + * `CREATE VIEW`/`ev_action` or `prosqlbody` route. */ @Testcontainers class ExplainAnalysisTest { @@ -93,14 +93,13 @@ class ExplainAnalysisTest { @Test fun `WHEN NOT MATCHED BY SOURCE alone reports only source can be absent, through a CTE with an outer JOIN`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 17, "WHEN NOT MATCHED BY SOURCE requires PostgreSQL 17+") - // A MERGE nested in a CTE sits inside a LARGER plan the outer statement can add its own, - // UNRELATED joins to. The CTE's own write plan is attached to that outer join as a THIRD, - // "InitPlan" sibling (verified live) — not a join child — so the outer join's OWN 2-children - // check correctly rejects it, and the search must continue into the MERGE's own nested join - // to find the real one. This MERGE also flips which side is preserved relative to the - // "WHEN NOT MATCHED THEN INSERT" tests above: with no INSERT action, the TARGET row always - // exists (every result row IS an existing target row); only the SOURCE can be missing. - // Verified live (target id=2 has no matching source row): id=2, sval=NULL, id=1, sval='x'. + // A MERGE nested in a CTE sits inside a larger plan the outer statement can add unrelated + // joins to. The CTE's write plan attaches to that outer join as a third "InitPlan" sibling, + // not a join child, so the outer join's 2-children check rejects it and the search + // continues into the MERGE's own nested join. This MERGE also flips which side is preserved + // relative to the "WHEN NOT MATCHED THEN INSERT" tests above: with no INSERT action, the + // target row always exists; only the source can be missing. + // With target id=2 having no matching source row: id=2, sval=NULL, id=1, sval='x'. val schemaName = "test_${schemaCounter.incrementAndGet()}" DriverManager.getConnection(container.jdbcUrl, container.username, container.password).use { connection -> connection.createStatement().use { @@ -140,10 +139,10 @@ class ExplainAnalysisTest { fun `an unrelated join in a WHEN condition over the same relation names does not win the MERGE's own join`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 17, "WHEN NOT MATCHED BY SOURCE requires PostgreSQL 17+") // The WHEN MATCHED condition's own EXISTS plans as an InitPlan sibling of the MERGE's real - // join, built from the SAME two tables the merge itself joins (tgt/src) — verified live: a - // naive first-join-found search picks this Inner join (tgt outer, src inner) over the real - // Left join, wrongly reporting neither side can be absent. Verified live (target id=2 has no - // matching source row): id=2, name=NULL. + // join, built from the same two tables the merge itself joins (tgt/src). A naive + // first-join-found search picks this Inner join (tgt outer, src inner) over the real Left + // join, wrongly reporting neither side can be absent. With target id=2 having no matching + // source row: id=2, name=NULL. val result = withMergeSideNullabilitySchema { connection -> explainMergeSideNullability( connection, @@ -180,10 +179,10 @@ class ExplainAnalysisTest { @Test fun `a MERGE nested in a CTE keeps its own attribution despite its own unrelated join and an outer JOIN`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 17, "WHEN NOT MATCHED BY SOURCE requires PostgreSQL 17+") - // Combines two distractions at once: the CTE's OWN WHEN condition has an unrelated join over - // tgt/src (the same hazard as the top-level test above, reached this time through the - // CTE-nested code path), AND the outer SELECT adds a real JOIN of its own against a THIRD - // table. Neither may cause this MERGE's target/source attribution to waver. + // Combines two distractions: the CTE's WHEN condition has an unrelated join over tgt/src + // (the same hazard as the top-level test above, reached here through the CTE-nested code + // path), and the outer SELECT adds a real join against a third table. Neither should shift + // this MERGE's target/source attribution. val schemaName = "test_${schemaCounter.incrementAndGet()}" DriverManager.getConnection(container.jdbcUrl, container.username, container.password).use { connection -> connection.createStatement().use { @@ -222,9 +221,9 @@ class ExplainAnalysisTest { @Test fun `a MERGE whose own plan contains multiple joins still attributes the real one`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 17, "WHEN NOT MATCHED BY SOURCE requires PostgreSQL 17+") - // TWO separate WHEN-condition EXISTS clauses each plan as their OWN InitPlan join over - // tgt/src, sitting alongside the merge's real join -- three join nodes total inside the - // ModifyTable's own subtree, only one of which is the merge's own. + // Two separate WHEN-condition EXISTS clauses each plan as their own InitPlan join over + // tgt/src, alongside the merge's real join -- three join nodes total inside the + // ModifyTable's subtree, only one of which is the merge's own. val result = withMergeSideNullabilitySchema { connection -> explainMergeSideNullability( connection, @@ -352,8 +351,8 @@ class ExplainAnalysisTest { targetRelationName = "tgt", sourceRelationNames = setOf("src"), ) - // Every case here is a real, live-executable MERGE — a null result would silently hide - // a mapping failure behind the caller's own safe fallback, defeating this test's point. + // Every case here is a real MERGE executed against PostgreSQL; a null result would hide + // a mapping failure behind the caller's safe fallback. assertThat(result != null).isTrue() return result } finally { diff --git a/generator/src/test/kotlin/norm/generator/GenerateCodeTest.kt b/generator/src/test/kotlin/norm/generator/GenerateCodeTest.kt index 07b219be..46590de7 100644 --- a/generator/src/test/kotlin/norm/generator/GenerateCodeTest.kt +++ b/generator/src/test/kotlin/norm/generator/GenerateCodeTest.kt @@ -135,10 +135,10 @@ class GenerateCodeTest { if (propsFile.exists()) propsFile.inputStream().use { load(it) } } val allParsedQueries = if (scenarioProperties.getProperty("generateCrud", "false").toBoolean()) { - // Must match NormGenerateTask's own production call exactly: without the real quoter, a - // synthesized statement referencing a quoted/mixed-case/space-containing column (e.g. the - // crud_generation scenario's "quoted_columns" table, #238) comes back unquoted and fails - // with a genuine PostgreSQL syntax error at analysis time, never reaching golden comparison. + // Must match NormGenerateTask's production call: without the real quoter, a synthesized + // statement referencing a quoted/mixed-case/space-containing column (e.g. the + // crud_generation scenario's "quoted_columns" table) comes back unquoted and fails with a + // PostgreSQL syntax error at analysis time, before reaching golden comparison. CrudQuerySynthesizer.synthesizeAndMerge(catalog, parsedQueries, analyzer.buildIdentifierQuoter()) } else { parsedQueries @@ -154,11 +154,10 @@ class GenerateCodeTest { analyzedQueries, effectivePackageName, frameworks, - // Must match NormGenerateTask's own production call: without the real, live-fetched reserved - // word set, a scenario naming a relation/column after a reserved word (e.g. "order", "user") - // would render an unquoted source reference that never actually runs against PostgreSQL, and - // golden comparison would silently accept text this repository's own live-server verification - // test (SourceReferenceLiveVerificationTest) would then reject. + // Must match NormGenerateTask's production call: without the live-fetched reserved word + // set, a scenario naming a relation/column after a reserved word (e.g. "order", "user") + // renders an unquoted source reference that never runs against PostgreSQL, and golden + // comparison would silently accept text SourceReferenceLiveVerificationTest would reject. analyzer.fetchReservedWords(), typeMappings, ) @@ -224,8 +223,8 @@ class GenerateCodeTest { ) val analyzedQueries = parsedQueries.map { analyzer.analyzeQuery(it, catalog) } - // Configured with the FULL, untruncated names -- exactly what a user would write, taken straight - // from their own DDL -- not the server-truncated forms the catalog actually holds. + // Configured with the full, untruncated names -- what a user would write, taken from their + // own DDL -- not the server-truncated forms the catalog actually holds. val mapping = TypeMapping( "", overLengthTableName, diff --git a/generator/src/test/kotlin/norm/generator/GroupRteSubstitutionTest.kt b/generator/src/test/kotlin/norm/generator/GroupRteSubstitutionTest.kt index 07306035..b905cb7b 100644 --- a/generator/src/test/kotlin/norm/generator/GroupRteSubstitutionTest.kt +++ b/generator/src/test/kotlin/norm/generator/GroupRteSubstitutionTest.kt @@ -17,14 +17,13 @@ class GroupRteSubstitutionTest { @Test fun `a Var buried inside an Aggref argument is substituted — synthetic input, not a shape PG18 produces`() { - // safetyWalkChildren (NodeTreeNullabilityAnalyzer's OTHER child-walk) deliberately treats - // Aggref as childless. This substitution must NOT copy that behavior, since it needs SOME branch - // for Aggref regardless (the when is exhaustive) — but unlike the OpExpr case this file also - // covers, no live PG18 tree is known to put a GROUP-RTE Var inside an Aggref's OWN arguments: - // verified live that PostgreSQL never rewrites aggregate arguments at all (they are evaluated - // PRE-grouping, before any GROUP RTE substitution could apply — see substituteGroupRteVars's own - // KDoc). This input is therefore synthetic, constructed purely to prove the traversal itself is - // correct and defensive, not to reproduce a real node-tree shape. + // safetyWalkChildren (NodeTreeNullabilityAnalyzer's other child-walk) treats Aggref as + // childless, but this substitution needs a branch for Aggref regardless (the when is + // exhaustive). Unlike the OpExpr case this file also covers, no live PG18 tree puts a + // GROUP-RTE Var inside an Aggref's own arguments -- PostgreSQL never rewrites aggregate + // arguments; they're evaluated pre-grouping, before any GROUP RTE substitution could apply + // (see substituteGroupRteVars's own KDoc). This input is synthetic, constructed to prove the + // traversal itself is correct and defensive, not to reproduce a real node-tree shape. val groupRteVar = PgNodeExpression.Var(varno = 2, varattno = 1, nullingRelations = emptySet()) val aggref = PgNodeExpression.Aggref(aggregateFunctionOid = 2147, arguments = listOf(groupRteVar)) val groupExpressionsByVarno = mapOf(2 to listOf(baseColumn)) @@ -36,7 +35,7 @@ class GroupRteSubstitutionTest { @Test fun `a Var with levelsUp greater than 0 is left unchanged`() { - // levelsUp > 0 means this Var refers to an OUTER query level's range table, which + // levelsUp > 0 means this Var refers to an outer query level's range table, which // groupExpressionsByVarno does not describe — substituting against it would resolve against // the wrong query level entirely, if the varno happened to collide. val outerVar = PgNodeExpression.Var(varno = 2, varattno = 1, nullingRelations = emptySet(), levelsUp = 1) @@ -61,7 +60,7 @@ class GroupRteSubstitutionTest { fun `a Var whose resolved group expression is Unknown is left unchanged`() { // An Unknown resolution means the groupexprs entry is either a parse failure or an unmodelled // node type. Substituting it in would replace a Var that PgNodeTreeParser.parseGroupRteMap's - // coarser, VAR-only fallback might still be able to resolve — so this Var must be left exactly + // coarser, Var-only fallback might still be able to resolve — so this Var must be left exactly // as parsed, not swapped for Unknown. val groupRteVar = PgNodeExpression.Var(varno = 2, varattno = 1, nullingRelations = emptySet()) val groupExpressionsByVarno = mapOf(2 to listOf(PgNodeExpression.Unknown("XMLTABLE"))) @@ -73,7 +72,7 @@ class GroupRteSubstitutionTest { @Test fun `substitution is single-pass — a resolved expression's own GROUP-RTE-shaped Var is not re-rewritten`() { - // The resolved groupexprs entry for varno 2 is ITSELF a Var that would match varno 3's entry if + // The resolved groupexprs entry for varno 2 is itself a Var that would match varno 3's entry if // recursed into again. A correct single-pass substitution returns it verbatim; a buggy // multi-pass implementation would keep rewriting until it reached baseColumn instead. val innerGroupRteShapedVar = PgNodeExpression.Var(varno = 3, varattno = 1, nullingRelations = emptySet()) @@ -90,10 +89,10 @@ class GroupRteSubstitutionTest { @Test fun `nothing is inherited from the replaced Var — the resolved expression's own fields win`() { - // Verified live (PostgreSQL 18): `SELECT b.x, count(*) FROM t LEFT JOIN u b ON b.id = t.id - // GROUP BY b.x` — the target-list Var referencing the GROUP RTE has EMPTY :varnullingrels, - // while the GROUP RTE's own :groupexprs entry carries the real, non-empty nulling relations - // from the LEFT JOIN. The wrapper Var's empty nullingRelations must not leak into the result. + // In PostgreSQL 18, `SELECT b.x, count(*) FROM t LEFT JOIN u b ON b.id = t.id GROUP BY b.x` + // — the target-list Var referencing the GROUP RTE has empty :varnullingrels, while the GROUP + // RTE's own :groupexprs entry carries the real, non-empty nulling relations from the LEFT + // JOIN. The wrapper Var's empty nullingRelations must not leak into the result. val wrapperVar = PgNodeExpression.Var(varno = 4, varattno = 1, nullingRelations = emptySet()) val resolvedJoinColumn = PgNodeExpression.Var(varno = 2, varattno = 2, nullingRelations = setOf(3)) val groupExpressionsByVarno = mapOf(4 to listOf(resolvedJoinColumn)) diff --git a/generator/src/test/kotlin/norm/generator/GroupingSetNullExtensionSweepTest.kt b/generator/src/test/kotlin/norm/generator/GroupingSetNullExtensionSweepTest.kt index 957ad086..5923a63d 100644 --- a/generator/src/test/kotlin/norm/generator/GroupingSetNullExtensionSweepTest.kt +++ b/generator/src/test/kotlin/norm/generator/GroupingSetNullExtensionSweepTest.kt @@ -10,26 +10,24 @@ import java.sql.DriverManager import java.util.concurrent.atomic.AtomicInteger /** - * Live cross-version brute-force verification, against a REAL PostgreSQL instance, of issue #240's - * own precondition for its "suggested direction" option 2: "option 2 should only land with a live - * cross-version sweep behind it." For every [cases] entry, this test runs the query through the real - * pipeline ([JdbcAnalyzer.analyzeQuery]) AND executes it live, then fails on any column where the - * analyzer reports `notNull = true` but PostgreSQL actually returned `null` in some row — a - * WRONG-NON-NULL, the one direction of error this whole gate exists to prevent. A column the - * analyzer reports nullable while PostgreSQL never actually returns `null` for it is NOT a failure - * here — that is an accepted, safe over-widening (imprecise, never wrong), the same standard - * [NodeTreeNullabilityAnalyzer]'s own KDoc holds itself to throughout. + * Cross-version brute-force verification, against a real PostgreSQL instance, that the analyzer's + * `notNull` decisions for grouping-set queries never disagree with what PostgreSQL actually returns. + * For every [cases] entry, this test runs the query through the real pipeline + * ([JdbcAnalyzer.analyzeQuery]) and executes it live, then fails on any column where the analyzer + * reports `notNull = true` but PostgreSQL returned `null` in some row -- a wrong non-null, the one + * direction of error this gate exists to prevent. A column the analyzer reports nullable while + * PostgreSQL never actually returns `null` is not a failure here -- that is an accepted, safe + * over-widening, the same standard [NodeTreeNullabilityAnalyzer]'s own KDoc holds itself to. * - * This sweep reads its expectation from live PostgreSQL alone — never from a hand-written - * true/false table — mirroring [SafeListSweepTest]'s own reasoning for why a brute-force sweep, - * not hand-reasoning about a specific shape, is what LICENSES a claim like this. + * This sweep reads its expectation from live PostgreSQL alone, never from a hand-written true/false + * table, mirroring [SafeListSweepTest]'s reasoning for why a brute-force sweep -- not hand-reasoning + * about a specific shape -- backs a claim like this. * - * [cases] covers, at minimum: the five shapes recovered by this fix (issue #240's shapes 1, 2, 7, 8, - * 9 — see `PLAN-240.md`'s "Measured ground truth" table), every row of that plan's "negative tests" - * table (constructs that must stay nullable despite superficially resembling a recovered shape), the - * self-match-guard shape from that plan's criterion 5 (a nested occurrence of a grouping key inside - * an already-shipped `isAlwaysNonNull` call), a `Var`-free grouping key that is never itself selected - * in the target list, and a `SubLink` in a target entry alongside a grouping-sets key. + * [cases] covers: the five grouping-set null-extension shapes this fix recovers (`now()`, + * `current_date`, `concat_ws`, `xmlelement`, `JSON_OBJECT`), constructs that must stay nullable + * despite superficially resembling a recovered shape, the self-match guard (a nested occurrence of + * the grouping key inside an already-shipped `isAlwaysNonNull` call), a `Var`-free grouping key never + * itself selected in the target list, and a `SubLink` in a target entry alongside a grouping-sets key. */ @Testcontainers class GroupingSetNullExtensionSweepTest { @@ -42,7 +40,7 @@ class GroupingSetNullExtensionSweepTest { """.trimIndent() private val cases = listOf( - // Issue #240 shapes 1, 2, 7, 8, 9 — recovered by this fix, must now be non-null (criterion 1). + // Shapes recovered by this fix -- must now be non-null. Case( "shape 1 — now() is non-null under ROLLUP(a)", twoNotNullTextColumns, @@ -93,7 +91,7 @@ class GroupingSetNullExtensionSweepTest { "SELECT xmlpi(name php, a) AS x, count(*) AS c FROM t2 GROUP BY GROUPING SETS ((a), ())", ), - // Negative-tests table (PLAN-240.md, criterion 6) — must stay nullable on every version. + // Constructs that must stay nullable on every version despite resembling a recovered shape. Case( "negative — now() as its own grouping key stays nullable", twoNotNullTextColumns, @@ -124,25 +122,23 @@ class GroupingSetNullExtensionSweepTest { // A pass-through CoerceViaIo (now()::text) wrapping the exact grouping key: ordinary isNonNull // for CoerceViaIo is an unconditional pass-through to its argument (now() is independently // non-null via the isNeverNullForNonNullInput safe list), so the grouping-set gate's own - // structural-match condition (leg B, condition 3) is the ONLY thing standing between this and - // a wrong non-null — unlike the `now() || 'x'` case above, which stays nullable for an - // unrelated reason (the `||` overload PostgreSQL actually picks for a non-text left operand is - // not on the operator safe list at all, masking the gate's own answer). Verified live - // (PostgreSQL 16): NULL in the ROLLUP summary row. + // structural-match condition (leg B, condition 3) is the only thing standing between this and a + // wrong non-null -- unlike the `now() || 'x'` case above, which stays nullable for an unrelated + // reason (the `||` overload PostgreSQL picks for a non-text left operand is not on the operator + // safe list at all, masking the gate's own answer). PostgreSQL 16: NULL in the ROLLUP summary row. "negative — a cast pass-through wrapping the exact grouping key stays nullable", twoNotNullTextColumns, "SELECT now()::text AS label, count(*) AS n FROM t2 GROUP BY ROLLUP(now())", ), - // Criterion 5 — the self-match guard: a nested occurrence of the grouping key inside an - // already-shipped isAlwaysNonNull call must stay nullable. + // Self-match guard: a nested occurrence of the grouping key inside an already-shipped + // isAlwaysNonNull call must stay nullable. Case( "criterion 5 — a nested occurrence of the grouping key inside concat() stays nullable", twoNotNullTextColumns, "SELECT count(*)::text || concat(a, b) AS label FROM t2 GROUP BY ROLLUP(concat(a, b))", ), - // A Var-free grouping key selected nowhere in the target list. Case( "a Var-free grouping key not selected anywhere leaves the aggregate non-null", twoNotNullTextColumns, diff --git a/generator/src/test/kotlin/norm/generator/InterfaceBuilderKdocTest.kt b/generator/src/test/kotlin/norm/generator/InterfaceBuilderKdocTest.kt index 10eafd0b..08391e7a 100644 --- a/generator/src/test/kotlin/norm/generator/InterfaceBuilderKdocTest.kt +++ b/generator/src/test/kotlin/norm/generator/InterfaceBuilderKdocTest.kt @@ -14,7 +14,7 @@ import org.junit.jupiter.api.Test /** * Regression coverage for [addSqlStatementInterfaceMethod]'s `@param` KDoc lines: a stray backtick in - * one parameter's column comment must not be free to pair with a backtick belonging to a LATER + * one parameter's column comment must not be free to pair with a backtick belonging to a later * parameter's own comment, the exact defect class [escapeMarkdownBacktick] already fixed for * [TypeRepository]'s `@property` lines — this emission path shares the same "single `\n`, no blank * line, between consecutive tags" shape, but is invisible to @@ -37,7 +37,7 @@ class InterfaceBuilderKdocTest { builder.addSqlStatementInterfaceMethod(statement) val kdoc = builder.build().funSpecs.first().kdoc.toString() - // Without escaping, CommonMark parses "`hello\n@param bParam cruel world`" as ONE inline code + // Without escaping, CommonMark parses "`hello\n@param bParam cruel world`" as one inline code // span spanning both `@param` lines -- hiding bParam's own tag and comment text inside it. // Escaping the source backticks means no code span forms here at all. val codeSpans = mutableListOf() diff --git a/generator/src/test/kotlin/norm/generator/JdbcAnalyzerTest.kt b/generator/src/test/kotlin/norm/generator/JdbcAnalyzerTest.kt index 4474b6e4..eabcd698 100644 --- a/generator/src/test/kotlin/norm/generator/JdbcAnalyzerTest.kt +++ b/generator/src/test/kotlin/norm/generator/JdbcAnalyzerTest.kt @@ -24,7 +24,7 @@ import org.testcontainers.junit.jupiter.Testcontainers import java.sql.Connection import java.sql.DriverManager -// Every test in this class shares ONE JDBC Connection (see the companion object below) -- a +// Every test in this class shares one JDBC Connection (see the companion object below) -- a // java.sql.Connection is not safe for concurrent use from multiple threads, and this repository // enables concurrent JUnit test execution by default (JavaConventionsPlugin). Without pinning this // class to a single thread, two tests issuing overlapping statements on the shared connection race @@ -1061,18 +1061,16 @@ class JdbcAnalyzerTest { @Test fun `fetchReservedWords matches an independently issued pg_get_keywords query against the same connection`() { - // Proves fetchReservedWords() genuinely round-trips to the CONNECTED server on every call - // rather than returning a hardcoded snapshot — a membership assertion over a fixed list (as - // the two tests above use) cannot tell a live query apart from a hardcoded set that happens to - // contain those few words. This test instead issues its OWN pg_get_keywords() query, entirely - // independent of fetchReservedWords()'s own implementation, against the SAME live connection, - // and compares the two results for EXACT set equality. A hardcoded set baked into - // fetchReservedWords() would have to happen to match this exact connection's live keyword set - // byte-for-byte to pass this comparison — any staleness, transcription slip, or drift from a - // PostgreSQL version whose reserved set differs (verified: system_user became reserved only in - // PostgreSQL 16, and a hardcoded set predating that change once missed it silently) fails here - // outright. Run at every PostgreSQL major version this project supports (-Dnorm.test.pgVersion) - // so a hardcoded snapshot has no single version left where staleness could hide. + // Confirms fetchReservedWords() round-trips to the connected server rather than returning a + // hardcoded snapshot — a membership assertion over a fixed list (as the two tests above use) + // cannot tell a live query apart from a hardcoded set that happens to contain those few words. + // This test issues its own pg_get_keywords() query, independent of fetchReservedWords()'s own + // implementation, against the same live connection, and compares the two results for exact set + // equality. Any staleness, transcription slip, or drift from a PostgreSQL version whose + // reserved set differs (system_user became reserved only in PostgreSQL 16, and a hardcoded set + // predating that change once missed it silently) fails here outright. Run at every PostgreSQL + // major version this project supports (-Dnorm.test.pgVersion) so a hardcoded snapshot has no + // single version left where staleness could hide. val fromFetchReservedWords = analyzer.fetchReservedWords() val fromIndependentLiveQuery = connection.createStatement().use { statement -> statement.executeQuery("SELECT word FROM pg_get_keywords() WHERE catcode IN ('R', 'T')").use { resultSet -> @@ -1105,18 +1103,18 @@ class JdbcAnalyzerTest { @Test fun `foldIdentifier matches the live server's parse_ident across a representative identifier corpus`() { - // Each pair's second value is what live parse_ident ALREADY reported when this corpus was - // written (verified against PostgreSQL 18.4) -- re-querying it here, rather than trusting - // that literal, is what makes this a genuine differential test: it fails if THIS run's - // connected server (any of PG16/17/18 -- see -Dnorm.test.pgVersion) disagrees with either - // the recorded expectation or foldIdentifier's own answer, not merely with a fixed list. + // Each pair's second value is what live parse_ident reported on PostgreSQL 18.4 when this + // corpus was written -- re-querying it here, rather than trusting that literal, is what + // makes this a genuine differential test: it fails if this run's connected server (any of + // PG16/17/18 -- see -Dnorm.test.pgVersion) disagrees with either the recorded expectation or + // foldIdentifier's own answer, not merely with a fixed list. val corpus = listOf( "foo" to "foo", // ASCII lowercase, unquoted "FooBar" to "foobar", // ASCII mixed case, unquoted -- folds "FOO" to "foo", // ASCII uppercase, unquoted -- folds "\"FooBar\"" to "FooBar", // quoted -- case preserved, never folded "\"He\"\"llo\"" to "He\"llo", // quoted, doubled-quote escape collapsed - "Ü" to "Ü", // non-ASCII uppercase, unquoted -- NOT folded (see foldAsciiCase's own KDoc) + "Ü" to "Ü", // non-ASCII uppercase, unquoted -- not folded (see foldAsciiCase's own KDoc) "ü" to "ü", // non-ASCII lowercase, unquoted -- unchanged "\"2fn\"" to "2fn", // quoted leading-digit -- legal only when quoted "\"select\"" to "select", // quoted reserved keyword @@ -1138,13 +1136,13 @@ class JdbcAnalyzerTest { @Test fun `foldIdentifier's divergences from parse_ident are all unreachable via this file's own token capture`() { - // These are DOCUMENTED, not fixed -- each has a reason it can never bite through any actual + // These are documented, not fixed -- each has a reason it can never bite through any actual // foldIdentifier call site in this file. This test exists so the corpus test above is never // read as claiming universal agreement. // 1. Empty quoted identifier: real PostgreSQL rejects this outright ("zero-length delimited - // identifier"), and parse_ident agrees ("Quoted identifier must not be empty" -- verified - // directly). foldIdentifier has no such guard, but its own real caller, + // identifier"), and parse_ident agrees ("Quoted identifier must not be empty"). + // foldIdentifier has no such guard, but its own real caller, // parseColumnReference, already treats an empty logical value as no match at all (see // that function's own KDoc), so an empty result here never reaches a real comparison. assertThat( @@ -1152,8 +1150,8 @@ class JdbcAnalyzerTest { ).isTrue() assertThat(foldIdentifier("\"\"")).isEqualTo("") - // 2. A syntactically invalid UNQUOTED identifier (leading digit, leading `$`): parse_ident - // rejects both outright (verified directly). foldIdentifier does not validate identifier + // 2. A syntactically invalid unquoted identifier (leading digit, leading `$`): parse_ident + // rejects both outright. foldIdentifier does not validate identifier // shape at all -- but neither input can ever reach it as a whole raw token in the first // place: every regex in this file that captures a raw identifier token // (COLUMN_REFERENCE_IDENTIFIER_START and its uses) already requires a legal start @@ -1163,8 +1161,8 @@ class JdbcAnalyzerTest { assertThat(foldIdentifier("\$foo")).isEqualTo("\$foo") // 3. A Unicode-escape identifier (U&"..."): parse_ident does not understand this syntax at - // all ("string is not a valid identifier" -- verified directly). isQuotedIdentifier also - // rejects it (it only recognizes a PLAIN "..." token), so foldIdentifier would, if ever + // all ("string is not a valid identifier"). isQuotedIdentifier also + // rejects it (it only recognizes a plain "..." token), so foldIdentifier would, if ever // handed one, wrongly ASCII-fold the literal "U&..." text instead of resolving the // escape -- but QUOTED_IDENTIFIER (the only pattern that ever captures a raw // table/column/alias/CTE-name token in this file) matches a plain quote only, so @@ -1172,15 +1170,15 @@ class JdbcAnalyzerTest { assertThat(runCatching { liveParseIdent("U&\"foo\"") }.isFailure).isTrue() // 4. NAMEDATALEN truncation: a real, overlong (>63-byte) identifier is silently truncated to - // 63 bytes once it becomes a genuine catalog object (verified directly: - // "CREATE TEMP TABLE t AS SELECT 1 AS <100 a's>" truncates the real column to 63 a's). - // parse_ident itself does NOT reproduce that truncation for EITHER a quoted or an - // unquoted overlong name (verified directly: parse_ident(repeat('a', 100)) returns all + // 63 bytes once it becomes a genuine catalog object + // ("CREATE TEMP TABLE t AS SELECT 1 AS <100 a's>" truncates the real column to 63 a's). + // parse_ident itself does not reproduce that truncation for either a quoted or an + // unquoted overlong name (parse_ident(repeat('a', 100)) returns all // 100 a's, on PostgreSQL 16, 17, and 18 alike) -- so foldIdentifier agrees with - // parse_ident here (neither truncates); both merely disagree with what a REAL, already- + // parse_ident here (neither truncates); both merely disagree with what a real, already- // created 100-character column would actually be named. That residual gap has no // reachable call site in the current codebase: every foldIdentifier comparison in this - // file is between two RAW-TEXT-parsed values read from the SAME query text (a CTE body + // file is between two raw-text-parsed values read from the same query text (a CTE body // alias vs. an outer reference) -- never against a JDBC-reported, already-truncated real // column name. val overlongName = "a".repeat(100) @@ -1194,7 +1192,7 @@ class JdbcAnalyzerTest { /** * Creates a real one-column table named [tableName] whose column is [logicalColumnName] - * (quoted verbatim, so any character is legal), reads back the server's OWN truncated + * (quoted verbatim, so any character is legal), reads back the server's own truncated * `pg_attribute.attname` for that column, then drops the table -- proving [truncateIdentifier] * agrees with what PostgreSQL itself actually stores, not merely with a re-derivation of the * same logic. @@ -1221,7 +1219,7 @@ class JdbcAnalyzerTest { // A property sweep, not a membership test: the corpus is generated by construction (ASCII at // every named boundary, plus 2-, 3-, and 4-byte multi-byte characters crossing byte 63 at // every possible phase via a run of consecutive prefix lengths), and every entry is checked - // for EQUALITY against the live server's own answer -- a wrong truncateIdentifier + // for equality against the live server's own answer -- a wrong truncateIdentifier // implementation fails this, not merely a hand-picked case. val corpus = listOf(62, 63, 64, 70).map { length -> "a".repeat(length) } + (58..66).flatMap { prefixLength -> @@ -1251,8 +1249,8 @@ class JdbcAnalyzerTest { @Test fun `buildCatalog keeps a backtick in a column's name and originalName identical`() { - // A table name distinct from sibling tests' own -- these tests can run CONCURRENTLY (parallel - // test execution is enabled repository-wide), and all create/drop DDL against the SAME shared + // A table name distinct from sibling tests' own -- these tests can run concurrently (parallel + // test execution is enabled repository-wide), and all create/drop DDL against the same shared // connection/schema. connection.createStatement().use { it.execute("""CREATE TABLE backtick_test_catalog (id INT, "a`b" TEXT)""") @@ -1414,7 +1412,7 @@ class JdbcAnalyzerTest { val parsed = ParsedQuery( "getOverLengthDomainRow", ":one", - // Written with the FULL, over-length column name -- exactly as a developer would copy it + // Written with the full, over-length column name -- exactly as a developer would copy it // from their own DDL -- while the server-side relation/column are already truncated. "SELECT * FROM $truncatedTableName WHERE $overLengthColumnName = ?", emptyList(), diff --git a/generator/src/test/kotlin/norm/generator/KdocProvenanceRoundTripTest.kt b/generator/src/test/kotlin/norm/generator/KdocProvenanceRoundTripTest.kt index 27092c09..a7c71783 100644 --- a/generator/src/test/kotlin/norm/generator/KdocProvenanceRoundTripTest.kt +++ b/generator/src/test/kotlin/norm/generator/KdocProvenanceRoundTripTest.kt @@ -20,21 +20,21 @@ import java.sql.DriverManager /** * End-to-end proof, against a live PostgreSQL server, that a source-reference expression rendered * into generated KDoc ([TypeRepository.addClassKdoc]'s `` @property x (`EXPR`) `` line) is exactly - * the developer's own SQL — not merely that the string computed internally looks right, but that - * the text a developer would read off the generated `.kt` FILE, extracted back out of a REAL + * the developer's own SQL -- not merely that the string computed internally looks right, but that + * the text a developer would read off the generated `.kt` file, extracted back out of a real * Markdown render, still means what the original SQL meant when run against a real server. * * Reads [renderedFileTextFor], which runs the [TypeSpec] through KotlinPoet's own [Any.toString] - * emission — the SAME [com.squareup.kotlinpoet.CodeWriter] machinery a real generated `.kt` file - * goes through — NEVER `typeSpec.kdoc.toString()`. `kdoc.toString()` is the pre-render [CodeBlock] + * emission -- the same [com.squareup.kotlinpoet.CodeWriter] machinery a real generated `.kt` file + * goes through -- never `typeSpec.kdoc.toString()`. `kdoc.toString()` is the pre-render [CodeBlock] * text, computed before [com.squareup.kotlinpoet.CodeWriter] ever sees it, so it cannot show * [com.squareup.kotlinpoet.CodeWriter]'s own unconditional `"/*"`/`"*/"` → `"/*"`/`"*/"` * rewrite inside every KDoc block (see [BlockCommentDelimiterDeclines]). * - * Extracts the rendered expression with a REAL CommonMark parse ([extractSourceReferenceExpression]) - * rather than cutting the surrounding text at the first `)` — the previous version of this test did - * that, which cannot handle an expression containing its own parenthesis at all (nearly every real - * expression — see [ParenthesisContainingExpressionRoundTrips]). + * Extracts the rendered expression with a real CommonMark parse ([extractSourceReferenceExpression]) + * rather than cutting the surrounding text at the first `)` -- the previous version of this test did + * that, which cannot handle an expression containing its own parenthesis (nearly every real + * expression -- see [ParenthesisContainingExpressionRoundTrips]). */ @Testcontainers class KdocProvenanceRoundTripTest { @@ -95,8 +95,8 @@ class KdocProvenanceRoundTripTest { repository.buildTypeProjectionForQuery("backtickExpression", listOf(expressionColumn), "SELECT u FROM c") val renderedExpression = extractSourceReferenceExpression("u", renderedFileTextFor(repository)) - // Decoding the REAL Markdown render must recover the developer's own expression BYTE FOR - // BYTE -- proving the escaping never altered the text itself, only how it is delimited. + // Decoding the real Markdown render must recover the developer's own expression byte for byte + // -- the escaping never altered the text itself, only how it is delimited. assertThat(renderedExpression).isEqualTo("'x' || '`'") DriverManager.getConnection(container.jdbcUrl, container.username, container.password).use { connection -> @@ -116,10 +116,10 @@ class KdocProvenanceRoundTripTest { @Test fun `an expression containing its own nested parentheses round-trips whole, not truncated at the first one`() { - // This test's own point: the OLD extraction helper cut the span at the first ")" in the - // surrounding text, so ANY expression containing a parenthesis -- nearly every real one -- - // would have been truncated to "COALESCE(a, 0" here, which does not even parse. A real - // CommonMark parse locates the actual Code node instead of guessing from a bracket count. + // The old extraction helper cut the span at the first ")" in the surrounding text, so any + // expression containing a parenthesis -- nearly every real one -- would have been truncated to + // "COALESCE(a, 0" here, which does not even parse. A real CommonMark parse locates the actual + // Code node instead of guessing from a bracket count. val expressionColumn = Column( name = "u", notNull = false, @@ -237,29 +237,26 @@ class KdocProvenanceRoundTripTest { } /** - * Renders [repository]'s first generated type through KotlinPoet's OWN [Any.toString] emission - * (`buildCodeString { emit(this, null) }`, the same [com.squareup.kotlinpoet.CodeWriter]-based - * machinery `FileSpec.writeTo` uses to produce a real generated `.kt` file) — never - * `typeSpec.kdoc.toString()`, which returns the [CodeBlock]'s own pre-render text, computed - * before [com.squareup.kotlinpoet.CodeWriter] ever applies its KDoc-block escaping. + * Renders [repository]'s first generated type through KotlinPoet's own [Any.toString] emission -- + * the same [com.squareup.kotlinpoet.CodeWriter] machinery `FileSpec.writeTo` uses for a real + * generated `.kt` file -- never `typeSpec.kdoc.toString()`, which returns the pre-render + * [CodeBlock] text before KDoc-block escaping is applied. */ private fun renderedFileTextFor(repository: TypeRepository): String = repository.requiredTypes.first().toString() /** - * Extracts the rendered Markdown CONTENT of the inline code span following + * Extracts the rendered Markdown content of the inline code span following * `` @property [propertyName] ( `` in [generatedFileText]'s KDoc block, or `null` if there is no - * such `@property` line with a source-reference span at all (the "declined" case). + * such `@property` line with a source-reference span (the "declined" case). * - * Unlike cutting the surrounding text at the first `)` (what this test used to do, and which - * cannot handle an expression containing its own parenthesis — see - * [ParenthesisContainingExpressionRoundTrips]), this locates the actual [Code] inline node a REAL - * CommonMark parse produces: [generatedFileText]'s `/** ... */` block is unwrapped back to its - * own Markdown source first (stripping the ` * `/` *` line prefix - * [com.squareup.kotlinpoet.CodeWriter] adds to every KDoc line — a real KDoc renderer, e.g. an - * IDE's quick-doc popup or Dokka, strips that same prefix before treating the remainder as - * Markdown), then parsed, then walked tracking the plain-text stream immediately preceding each - * [Code] node so the marker match is by POSITION, not merely by "some code span exists somewhere - * in the document" (which would misattribute if more than one property has a source reference). + * Unlike cutting the surrounding text at the first `)` (see + * [ParenthesisContainingExpressionRoundTrips] for why that fails), this locates the actual [Code] + * inline node a real CommonMark parse produces: [generatedFileText]'s `/** ... */` block is + * unwrapped back to its own Markdown source (stripping the ` * `/` *` line prefix + * [com.squareup.kotlinpoet.CodeWriter] adds -- the same prefix a real KDoc renderer strips before + * treating the remainder as Markdown), then parsed and walked, tracking the plain-text stream + * immediately preceding each [Code] node so the marker match is by position, not merely by "some + * code span exists somewhere in the document." */ private fun extractSourceReferenceExpression(propertyName: String, generatedFileText: String): String? { val kdocMarkdown = extractKdocMarkdown(generatedFileText) diff --git a/generator/src/test/kotlin/norm/generator/KeywordOperandProvenanceCorpusTest.kt b/generator/src/test/kotlin/norm/generator/KeywordOperandProvenanceCorpusTest.kt index d752c421..57e80c51 100644 --- a/generator/src/test/kotlin/norm/generator/KeywordOperandProvenanceCorpusTest.kt +++ b/generator/src/test/kotlin/norm/generator/KeywordOperandProvenanceCorpusTest.kt @@ -41,15 +41,15 @@ internal data class KeywordOperandCase( * (`ts AT TIME ZONE 'UTC' res`), never a legal standalone expression (`(ts AT TIME ZONE 'UTC' res)` * is a syntax error), which is exactly the context a `@property` source reference renders it in. * - * Every shape is run through the REAL pipeline (a `prosqlbody` node tree built from a live + * Every shape is run through the real pipeline (a `prosqlbody` node tree built from a live * PostgreSQL server, exactly as production does) in two forms: - * - WITH a real trailing implicit (no-`AS`) alias: PostgreSQL's own `:resname` for the item DOES - * verify against that alias — proving the cross-validation gate itself still works for every one - * of these keyword-operand shapes — but [resolveNodeTreeProvenanceExpression] must still resolve - * to `null`, never [case]'s truncated OR complete text. - * - WITHOUT any alias at all: PostgreSQL's own auto-generated `:resname` (`?column?`, `case`, ...) + * - With a real trailing implicit (no-`AS`) alias: PostgreSQL's own `:resname` for the item does + * verify against that alias, proving the cross-validation gate itself still works for every one + * of these keyword-operand shapes, but [resolveNodeTreeProvenanceExpression] must still resolve + * to `null`, never [case]'s truncated or complete text. + * - Without any alias at all: PostgreSQL's own auto-generated `:resname` (`?column?`, `case`, ...) * has no corresponding text to verify against either, so the resolved expression must be `null` - * here too — never a guess. + * here too, never a guess. */ @Testcontainers internal class KeywordOperandProvenanceCorpusTest { @@ -152,8 +152,8 @@ internal class KeywordOperandProvenanceCorpusTest { "INSERT INTO t VALUES (5, 3, true, NULL, 'abc', '2024-01-01 10:00:00', ARRAY[10, 20, 30])" /** - * Every shape the issue calls out, verified live against PostgreSQL 18.4 before being encoded - * here (`docker exec verify-pg18 psql -U postgres`) — see this file's own KDoc. + * Every shape here was run against PostgreSQL 18.4 (`docker exec verify-pg18 psql -U postgres`) + * before being encoded. */ private val CORPUS = listOf( KeywordOperandCase("IS NULL", "a IS NULL"), diff --git a/generator/src/test/kotlin/norm/generator/MarkdownEscapingTest.kt b/generator/src/test/kotlin/norm/generator/MarkdownEscapingTest.kt index 9b4bdcd0..5317a07e 100644 --- a/generator/src/test/kotlin/norm/generator/MarkdownEscapingTest.kt +++ b/generator/src/test/kotlin/norm/generator/MarkdownEscapingTest.kt @@ -30,7 +30,7 @@ class MarkdownEscapingTest { @Test fun `a single embedded backtick is escaped with a double-backtick delimiter, unpadded`() { - // The backtick sits in the MIDDLE of the text, not touching either end, so no padding space + // The backtick sits in the middle of the text, not touching either end, so no padding space // is needed for the delimiter to stay unambiguous. assertThat(markdownInlineCodeSpan("s || '`'")).isEqualTo("``s || '`'``") } diff --git a/generator/src/test/kotlin/norm/generator/NodeTreeNullabilityAnalyzerTest.kt b/generator/src/test/kotlin/norm/generator/NodeTreeNullabilityAnalyzerTest.kt index 3349e5e5..0b93d577 100644 --- a/generator/src/test/kotlin/norm/generator/NodeTreeNullabilityAnalyzerTest.kt +++ b/generator/src/test/kotlin/norm/generator/NodeTreeNullabilityAnalyzerTest.kt @@ -33,7 +33,7 @@ class NodeTreeNullabilityAnalyzerTest { isSubLinkSubqueryColumnNotNull = isSubLinkSubqueryColumnNotNull, ) - // JsonBehaviorType codes below are verified live (PostgreSQL 17 and 18): a JSON_VALUE/JSON_QUERY + // JsonBehaviorType codes below, on PostgreSQL 17 and 18: a JSON_VALUE/JSON_QUERY // `ON EMPTY ERROR`/`ON ERROR ERROR` clause emits `:btype 1` with `:expr <>` (absent); `ON EMPTY // EMPTY ARRAY`/`EMPTY OBJECT` emit `:btype 6`/`7` with `:expr {CONST ... :constisnull false // ...}` (Postgres's own internal `[]`/`{}` constant); `DEFAULT expr ON EMPTY`/`ON ERROR` emits @@ -62,11 +62,11 @@ class NodeTreeNullabilityAnalyzerTest { inner class JsonValueIsUnconditionallyNullable { // JSON_VALUE unwraps a path match to an SQL/JSON `null` value into a genuine SQL NULL. That - // is a SUCCESSFUL match, not the "no match" (EMPTY)/"error" (ERROR) case the ON EMPTY/ON ERROR + // is a successful match, not the "no match" (EMPTY)/"error" (ERROR) case the ON EMPTY/ON ERROR // clauses control, so no combination of those codes — however "safe" they looked for the - // no-match/error case — can rule the successful-match-to-JSON-null case out. Verified live - // (PostgreSQL 17 and 18): `JSON_VALUE('{"name": null}'::jsonb, '$.name' RETURNING TEXT ERROR - // ON EMPTY ERROR ON ERROR) IS NULL` is `true`. + // no-match/error case — can rule the successful-match-to-JSON-null case out. On PostgreSQL 17 + // and 18, `JSON_VALUE('{"name": null}'::jsonb, '$.name' RETURNING TEXT ERROR ON EMPTY ERROR ON + // ERROR) IS NULL` is `true`. @Test fun `ON EMPTY ERROR combined with ON ERROR ERROR is still nullable`() { @@ -116,10 +116,10 @@ class NodeTreeNullabilityAnalyzerTest { inner class JsonQueryEmptyErrorBehavior { // Unlike JSON_VALUE, JSON_QUERY never unwraps a matched JSON `null` into a genuine SQL NULL — - // it returns the JSON text `null` instead (verified live: `JSON_QUERY('{"a": null}'::jsonb, + // it returns the JSON text `null` instead (`JSON_QUERY('{"a": null}'::jsonb, // '$.a' EMPTY ARRAY ON EMPTY EMPTY OBJECT ON ERROR) IS NULL` is `false`). So, unlike // JSON_VALUE, its ON EMPTY/ON ERROR behavior codes remain a valid non-null signal — provided - // the context item itself is also proven non-null (verified live: `JSON_QUERY(NULL::jsonb, + // the context item itself is also proven non-null (`JSON_QUERY(NULL::jsonb, // '$' EMPTY ARRAY ON EMPTY EMPTY OBJECT ON ERROR) IS NULL` is `true`). private fun jsonQuery( @@ -196,9 +196,9 @@ class NodeTreeNullabilityAnalyzerTest { @Test fun `an unrecognized ON EMPTY behavior code defaults to nullable, the safe direction`() { // This is the deny-list bug: a hypothetical future JsonBehaviorType Postgres has not added - // yet (modeled here as an arbitrary out-of-range code) must NOT be treated as non-null - // merely because it is not JSON_BEHAVIOR_NULL. ON ERROR is pinned to the verified-safe - // ERROR code so this test isolates the ON EMPTY allow-list check specifically. + // yet (modeled here as an arbitrary out-of-range code) must not be treated as non-null + // merely because it is not JSON_BEHAVIOR_NULL. ON ERROR is pinned to the safe ERROR code so + // this test isolates the ON EMPTY allow-list check specifically. val unrecognizedFutureBehaviorCode = 42 val expression = jsonQuery(onEmpty = unrecognizedFutureBehaviorCode, onError = PgNodeExpression.JSON_BEHAVIOR_ERROR) @@ -219,11 +219,11 @@ class NodeTreeNullabilityAnalyzerTest { // JSON_EXISTS has no ON EMPTY clause at all — only ON ERROR, whose codes are TRUE(3)/ // FALSE(4)/ERROR(1)/UNKNOWN(5), never NULL(0)/EMPTY_ARRAY(6)/EMPTY_OBJECT(7)/DEFAULT(8) (all - // rejected by Postgres's parser for JSON_EXISTS). Verified live: with no ON ERROR clause - // written, Postgres materializes `:btype 4` (FALSE) — the SQL-standard default — so absence of - // the clause is exactly as safe as writing FALSE/TRUE/ERROR explicitly. Only UNKNOWN ON ERROR, - // or a NULL context item, produces a genuine SQL NULL (verified live: - // `JSON_EXISTS(NULL::jsonb, '$.a') IS NULL` is `true`). + // rejected by Postgres's parser for JSON_EXISTS). With no ON ERROR clause written, Postgres + // materializes `:btype 4` (FALSE) — the SQL-standard default — so absence of the clause is + // exactly as safe as writing FALSE/TRUE/ERROR explicitly. Only UNKNOWN ON ERROR, or a NULL + // context item, produces a genuine SQL NULL (`JSON_EXISTS(NULL::jsonb, '$.a') IS NULL` is + // `true`). private fun jsonExists(argument: PgNodeExpression = PgNodeExpression.Const(isNull = false), onError: Int) = jsonExpr(op = PgNodeExpression.JSON_EXISTS_OP, argument = argument, onError = onError) @@ -286,12 +286,12 @@ class NodeTreeNullabilityAnalyzerTest { @Nested inner class SubLinks { - // See issue #239's own live-verified repro (PostgreSQL 17): `a = ANY (SELECT v FROM u)` is - // NULL, not FALSE, when u.v is nullable and the subquery yields a NULL row with no match — - // three-valued logic, not the two-valued logic a naive "outer operand is non-null" check - // assumes. Proving ANY_SUBLINK non-null therefore requires ALL of: a non-null outer operand, - // a strict-and-total comparison operator, and a provably non-null single-column subquery - // result — see NodeTreeNullabilityAnalyzer's SubLink branch for the full rule. + // On PostgreSQL 17, `a = ANY (SELECT v FROM u)` is NULL, not FALSE, when u.v is nullable and + // the subquery yields a NULL row with no match — three-valued logic, not the two-valued logic + // a naive "outer operand is non-null" check assumes. Proving ANY_SUBLINK non-null therefore + // requires all of: a non-null outer operand, a strict-and-total comparison operator, and a + // provably non-null single-column subquery result — see NodeTreeNullabilityAnalyzer's SubLink + // branch for the full rule. private val comparisonOperatorOid = 100 @@ -337,7 +337,7 @@ class NodeTreeNullabilityAnalyzerTest { @Test fun `ROWCOMPARE sublink is nullable even when all three ANY-style conditions are satisfied`() { - // An EMPTY subquery yields NULL for ROWCOMPARE, unlike ANY/ALL, so no combination of the three + // An empty subquery yields NULL for ROWCOMPARE, unlike ANY/ALL, so no combination of the three // conditions can rescue it — it is deliberately excluded from isNonNull's SubLink proof. val expression = PgNodeExpression.SubLink( subLinkType = PgNodeExpression.SUBLINK_TYPE_ROWCOMPARE, @@ -417,7 +417,7 @@ class NodeTreeNullabilityAnalyzerTest { @Test fun `a Var with levelsUp 1 is nullable even when isSourceColumnNotNull returns true`() { - // A Var with levelsUp greater than 0 indexes an ENCLOSING query's range table (see + // A Var with levelsUp greater than 0 indexes an enclosing query's range table (see // PgNodeExpression.Var.levelsUp's own KDoc) — this block's isSourceColumnNotNull says // nothing trustworthy about it, so it must stay nullable regardless of what that callback // returns. This guard is what makes the ANY_SUBLINK subquery-column-nullability leg sound @@ -429,11 +429,11 @@ class NodeTreeNullabilityAnalyzerTest { } /** - * Unit tests for [NodeTreeNullabilityAnalyzer.isSafeFromGroupingSetNullExtension]'s issue-#240 - * "leg A" additions: node kinds whose [NodeTreeNullabilityAnalyzer.isNonNull] rule already ignores + * Unit tests for [NodeTreeNullabilityAnalyzer.isSafeFromGroupingSetNullExtension]'s "leg A" + * additions: node kinds whose [NodeTreeNullabilityAnalyzer.isNonNull] rule already ignores * argument nullability entirely, so a deeper subexpression being null-extended cannot change the * result — see that method's own KDoc for the full soundness argument for each leg. Each of these - * synthetic trees would be reported UNSAFE by the pre-#240 gate (no `Aggref`/`GroupingFunc`/ + * synthetic trees would be reported unsafe by the earlier gate (no `Aggref`/`GroupingFunc`/ * `WindowFunc` descendant to satisfy [NodeTreeNullabilityAnalyzer.containsDominatingConstruct]), * which is exactly what each leg here exists to fix. */ @@ -445,9 +445,9 @@ class NodeTreeNullabilityAnalyzerTest { @Test fun `self-match guard makes a grouping key itself unsafe even though it would otherwise qualify for a leg`() { - // Regression guard for the bug the plan's self-match guard fixes: without it, a FuncExpr on - // isAlwaysNonNull's list would be rescued by that (already-shipped) leg even when the call - // itself IS the grouping key being null-extended wholesale. + // Regression guard for the self-match bug: without it, a FuncExpr on isAlwaysNonNull's list + // would be rescued by that (already-shipped) leg even when the call itself is the grouping + // key being null-extended wholesale. val key = PgNodeExpression.FuncExpr(functionOid = someFunctionOid, arguments = listOf(varArgument)) val safe = analyzer(isAlwaysNonNull = { it == someFunctionOid }) .isSafeFromGroupingSetNullExtension(key, groupingKeyExpressions = setOf(key)) @@ -485,10 +485,10 @@ class NodeTreeNullabilityAnalyzerTest { @Test fun `a VARIADIC FuncExpr with isNonNullIffFirstArgumentNonNull ignores this leg even for a safe first argument`() { // Models concat_ws(',', VARIADIC arr) under GROUP BY ROLLUP(arr): in the VARIADIC form, the - // array itself (the LAST argument, not the first) is a single value that can be - // null-extended, and concat_ws(',', VARIADIC arr) really is NULL when arr is NULL (verified - // live) regardless of the separator. Without the isVariadic guard, this leg would wrongly - // treat the safe literal separator as proof the whole call is safe. + // array itself (the last argument, not the first) is a single value that can be + // null-extended, and concat_ws(',', VARIADIC arr) really is NULL when arr is NULL, regardless + // of the separator. Without the isVariadic guard, this leg would wrongly treat the safe + // literal separator as proof the whole call is safe. val arrayArgument = PgNodeExpression.Var(varno = 2, varattno = 1, nullingRelations = emptySet()) val expression = PgNodeExpression.FuncExpr( functionOid = someFunctionOid, @@ -577,12 +577,12 @@ class NodeTreeNullabilityAnalyzerTest { } /** - * Unit tests for [NodeTreeNullabilityAnalyzer.isSafeFromGroupingSetNullExtension]'s issue-#240 "leg - * B" addition — the private `immuneByNoGroupingKeyMatch` helper, exercised here only through the + * Unit tests for [NodeTreeNullabilityAnalyzer.isSafeFromGroupingSetNullExtension]'s "leg B" + * addition — the private `immuneByNoGroupingKeyMatch` helper, exercised here only through the * public [NodeTreeNullabilityAnalyzer.isSafeFromGroupingSetNullExtension] entry point since it has * no test-visible seam of its own. Each test isolates one of its three required conditions by - * constructing a case where the OTHER two conditions hold (and the generic aggregate/window-domination - * rule also cannot rescue the case on its own, since none of these trees contains an + * constructing a case where the other two conditions hold (and the generic aggregate/window- + * domination rule also cannot rescue the case on its own, since none of these trees contains an * `Aggref`/`GroupingFunc`/`WindowFunc`), so only the condition under test can flip the answer. */ @Nested diff --git a/generator/src/test/kotlin/norm/generator/NodeTreeProvenanceExpressionTest.kt b/generator/src/test/kotlin/norm/generator/NodeTreeProvenanceExpressionTest.kt index 9e53cce9..9881b852 100644 --- a/generator/src/test/kotlin/norm/generator/NodeTreeProvenanceExpressionTest.kt +++ b/generator/src/test/kotlin/norm/generator/NodeTreeProvenanceExpressionTest.kt @@ -23,8 +23,8 @@ import java.util.concurrent.atomic.AtomicInteger * resolved (see [NodeTreeProvenanceResolverTest] for that half). * * Ports the shape corpus from the deleted `SqlCteOutputExpressionTest` (the retired text-only - * whitelist's own live-verified test suite): a shape the whitelist resolved must resolve to the - * SAME string here; a shape it declined that Route D now proves correct gets its expectation + * whitelist's own test suite): a shape the whitelist resolved must resolve to the + * same string here; a shape it declined that Route D now resolves correctly gets its expectation * updated (each such update is called out below, and was independently confirmed against * `verify-pg18` before being written); a shape that must stay `null` (an ambiguous body, an * unverifiable auto-generated alias, a niladic keyword reference, ...) still does. A handful of the @@ -96,7 +96,7 @@ class NodeTreeProvenanceExpressionTest { fun `a CTE body wrapped in redundant parentheses still resolves`() { // `parseOutputItemsWithAlias` previously found no top-level SELECT once the sliced body text // started with an extra, unmatched "(", since that put the whole rest of the text at paren - // depth one (#238). + // depth one. val ddl = "CREATE TABLE parent (id INT, name TEXT)" val sql = """ WITH a AS ( @@ -147,12 +147,12 @@ class NodeTreeProvenanceExpressionTest { @Nested inner class WhitelistPreconditionsRouteDNoLongerNeeds { - // These shapes were previously null ONLY because the retired text-only whitelist required + // These shapes were previously null only because the retired text-only whitelist required // "exactly one declared CTE" and "the main query's FROM is exactly that CTE's bare name" as // proxies for "this reference can only mean the CTE" -- proxies needed because pure text has no // other way to rule out a same-named schema table or an ambiguous multi-source FROM. Route D // needs neither proxy: it starts from the outer query's own parsed Var, which PostgreSQL has - // ALREADY resolved unambiguously by the time the node tree exists. Each expectation below was + // already resolved unambiguously by the time the node tree exists. Each expectation below was // confirmed against verify-pg18 before being written. @Test @@ -209,11 +209,10 @@ class NodeTreeProvenanceExpressionTest { @Test fun `a syntactically WITH RECURSIVE CTE that never actually self-references resolves normally`() { - // Verified live: PostgreSQL's own ":cterecursive" flag is false here -- a CTE declared under - // WITH RECURSIVE with NO self-reference in its body genuinely runs once, exactly like an - // ordinary CTE, and PostgreSQL itself knows that. The retired whitelist bailed on the SQL - // TEXT alone ("WITH RECURSIVE" present anywhere), overly conservative for this shape; Route D - // reads the authoritative flag instead. + // PostgreSQL's own ":cterecursive" flag is false here -- a CTE declared under WITH RECURSIVE + // with no self-reference in its body genuinely runs once, exactly like an ordinary CTE. The + // retired whitelist bailed on the SQL text alone ("WITH RECURSIVE" present anywhere), overly + // conservative for this shape; Route D reads the authoritative flag instead. val ddl = "CREATE TABLE parent (id INT, name TEXT, description TEXT)" val sql = """ WITH RECURSIVE a AS ( @@ -300,7 +299,7 @@ class NodeTreeProvenanceExpressionTest { @Test fun `an over-length explicit alias on a computed expression still resolves through its truncated resname`() { // Site NodeTreeProvenanceExpression.kt:140 (AliasMatchKind.EXPLICIT_ALIAS). verifiedItem must - // truncate the RAW, over-length alias before fold-comparing it against the node tree's own + // truncate the raw, over-length alias before fold-comparing it against the node tree's own // server-truncated :resname, or a computed expression named with an over-length "AS alias" // never verifies and resolves to null instead of its real expression. val overLongAlias = "v".repeat(70) @@ -334,10 +333,10 @@ class NodeTreeProvenanceExpressionTest { @Test fun `declining an implicit-alias position leaves an explicit-AS sibling position in the same body emitting`() { - // The all-position cross-validation gate (this file's own top-level KDoc) still requires EVERY + // The all-position cross-validation gate (this file's own top-level KDoc) still requires every // position's name to verify -- including the implicit-alias one -- to prove the body wasn't - // mis-split. Declining that one position's OWN emission must not also silence a DIFFERENT - // position resolved by a SEPARATE call against the SAME body. + // mis-split. Declining that one position's own emission must not also silence a different + // position resolved by a separate call against the same body. val ddl = "CREATE TABLE parent (id INT, name TEXT, description TEXT)" val sql = """ WITH a AS (SELECT UPPER(name) AS ux, LOWER(description) dx FROM parent) @@ -726,7 +725,7 @@ class NodeTreeProvenanceExpressionTest { SELECT ux FROM b """.trimIndent() - // Proves the INNER shadowing declaration wins, not the outer one: LOWER(y), never UPPER(x). + // Proves the inner shadowing declaration wins, not the outer one: LOWER(y), never UPPER(x). assertThat(resolvedExpression(ddl, sql)).isEqualTo("LOWER(y)") } @@ -752,7 +751,7 @@ class NodeTreeProvenanceExpressionTest { // An unqualified "SELECT ux FROM a" cannot be used here at all -- PostgreSQL itself refuses to // parse it ("column reference \"ux\" is ambiguous"), since the CTE's own two output columns // fold to the identical name. "SELECT *" sidesteps that: PostgreSQL expands it into one bare - // Var per attribute POSITIONALLY, with no by-name lookup involved, so the outer query itself + // Var per attribute positionally, with no by-name lookup involved, so the outer query itself // stays valid even though the body's own two names collide. val ddl = "CREATE TABLE parent (id INT, name TEXT, description TEXT)" val sql = """ @@ -766,16 +765,14 @@ class NodeTreeProvenanceExpressionTest { @Test fun `without the uniqueness gate, a duplicated resname would let a shifted match through`() { - // Regression-shaped proof that the uniqueness check is load-bearing, not redundant with the - // all-position check: body positions 1 and 3 both fold to "same", and position 2 sits between - // them with a DIFFERENT, correctly-matching alias "mid". A per-position check alone (comparing - // text position i against resname position i) already can't be fooled by this SPECIFIC swap -- - // position 1 and 3's own text/resname still line up index-for-index -- but this is exactly the - // shape the uniqueness gate exists to reject regardless: with "same" appearing twice, nothing - // here can tell purely from folded names alone whether position 1's or position 3's OWN text - // is truly bound to position 1, so the whole body remains provably ambiguous, and the uniqueness - // gate is what turns "duplicated names exist" into an outright refusal rather than trusting - // either occurrence. Uses "SELECT *" for the same by-name-ambiguity reason as the test above. + // Body positions 1 and 3 both fold to "same", with position 2's own, differently-named "mid" + // alias sitting between them and matching correctly. A per-position check alone (comparing + // text position i against resname position i) already can't be fooled by this specific swap -- + // position 1 and 3's own text/resname still line up index-for-index -- but the uniqueness gate + // exists to reject this shape regardless: with "same" appearing twice, nothing here can tell + // purely from folded names whether position 1's or position 3's text is truly bound to + // position 1, so the whole body remains ambiguous. Uses "SELECT *" for the same by-name- + // ambiguity reason as the test above. val ddl = "CREATE TABLE t (a TEXT, b TEXT, c TEXT)" val sql = """ WITH x AS (SELECT UPPER(a) AS same, LOWER(b) AS mid, UPPER(c) AS same FROM t) @@ -792,17 +789,16 @@ class NodeTreeProvenanceExpressionTest { @Test fun `a text-resname mismatch at a position OTHER than the requested one still blocks resolution`() { - // Proves the all-position check is load-bearing, distinct from the uniqueness check above: a - // mismatch confined to a position the caller never asked about must still block resolution of - // the position it DID ask about, because a text/resname disagreement anywhere in the body is - // exactly the symptom a paired lexer mis-split/mis-merge would leave behind (see this class's - // own KDoc's discussion of NodeTreeProvenanceExpression's cross-validation). + // A mismatch confined to a position the caller never asked about still blocks resolution of + // the position it did ask about: a text/resname disagreement anywhere in the body is exactly + // the symptom a paired lexer mis-split/mis-merge would leave behind, distinct from the + // uniqueness check above. // - // This needs a hand-crafted node tree, not a live round trip: under CORRECT parsing, the text - // this file's own parseOutputItemsWithAlias computes for a position ALWAYS agrees with + // This needs a hand-crafted node tree, not a live round trip: under correct parsing, the text + // this file's own parseOutputItemsWithAlias computes for a position always agrees with // PostgreSQL's own real `:resname` for it (whatever alias you write becomes the resname // verbatim) -- there is no way to make a genuinely valid, live-executable query disagree with - // itself, only a designed one exercising the code path a bug WOULD trigger. Synthetic + // itself, only a designed one exercising the code path a bug would trigger. Synthetic // `pg_node_tree` fixtures are an established pattern for exactly this in this codebase already // (see PgNodeTreeParserTest). val nodeTree = @@ -991,19 +987,18 @@ class NodeTreeProvenanceExpressionTest { @Test fun `every reserved word PostgreSQL itself accepts as a bare outer reference resolves to nothing`() { - // Sweeps the FULL live-fetched reserved-keyword set -- never a fixed subset -- through the + // Sweeps the full live-fetched reserved-keyword set -- never a fixed subset -- through the // identical CTE-wrapped shape the named tests above exercise individually. A reserved word // that PostgreSQL itself refuses to parse as a bare, unaliased value at all (e.g. "select", - // "join" -- not niladic keywords, and not usable as a bare expression at all: verified live - // that even a QUOTED body alias changes nothing here, since the rejection is always the OUTER - // bare reference, never the alias) can never reach TypeRepository from a real query in the - // first place, since JdbcAnalyzer's own PreparedStatement.prepareStatement would already have - // rejected it -- those words are simply skipped here, the same "never reachable" reasoning - // applied throughout this file. Every word PostgreSQL DOES accept as a bare reference must - // still resolve to nothing. + // "join" -- not niladic keywords, and not usable as a bare expression at all, even with a + // quoted body alias, since the rejection is always the outer bare reference, never the alias) + // can never reach TypeRepository from a real query in the first place, since JdbcAnalyzer's own + // PreparedStatement.prepareStatement would already have rejected it -- those words are simply + // skipped here, the same "never reachable" reasoning applied throughout this file. Every word + // PostgreSQL does accept as a bare reference must still resolve to nothing. // - // The skip is proven CORRECT, not merely present: [expectedReachableWords] independently - // determines -- via a SEPARATE live query the resolution pipeline never touches -- exactly + // The skip is proven correct, not merely present: [expectedReachableWords] independently + // determines -- via a separate live query the resolution pipeline never touches -- exactly // which words PostgreSQL's own grammar allows as a bare, FROM-less `SELECT word`. If the main // loop's own skip decisions ever drifted from that independent answer (attempting a word // PostgreSQL cannot actually parse bare, or skipping one it can), this equality fails; a bare @@ -1047,11 +1042,11 @@ class NodeTreeProvenanceExpressionTest { @Test fun `a parameterised query still emits the developer's own question mark, never the sentinel literal`() { - // The node tree passed to resolveNodeTreeProvenanceExpression is built from SENTINEL- - // SUBSTITUTED SQL (a real '?' is not valid standalone PostgreSQL syntax, so the temp probe - // function cannot be created from the ORIGINAL text directly) -- exactly as + // The node tree passed to resolveNodeTreeProvenanceExpression is built from sentinel- + // substituted SQL (a real '?' is not valid standalone PostgreSQL syntax, so the temp probe + // function cannot be created from the original text directly) -- exactly as // ColumnNullabilityAnalyzer.queryColumnNullabilityViaProsqlbody does in production. The - // ORIGINAL text, question mark intact, is what must come back. + // original text, question mark intact, is what must come back. val ddl = "CREATE TABLE parent (description TEXT)" val substitutedForNodeTree = """ WITH c AS (SELECT UPPER(description) || '' AS d FROM parent) @@ -1088,9 +1083,8 @@ class NodeTreeProvenanceExpressionTest { * independent of [resolvedExpression]'s own CTE-wrapped probe, so it cannot share a bug with * whatever it is being used to check. Only a reserved word with its own niladic-keyword-shaped * grammar production (`user`, `current_date`, `true`, ...) parses this way; an ordinary reserved - * word (`table`, `select`, ...) has no bare-expression production at all, in ANY context -- verified - * live that neither quoting the alias it is bound to, nor parenthesizing the bare reference itself, - * changes that. + * word (`table`, `select`, ...) has no bare-expression production at all, in any context -- neither + * quoting the alias it is bound to, nor parenthesizing the bare reference itself, changes that. */ private fun isParseableAsBareSelectItem(connection: Connection, word: String): Boolean = try { connection.createStatement().use { it.execute("SELECT $word") } @@ -1100,7 +1094,7 @@ class NodeTreeProvenanceExpressionTest { } /** - * Builds the SAME `prosqlbody` node tree [ColumnNullabilityAnalyzer.queryColumnNullabilityViaProsqlbody] + * Builds the same `prosqlbody` node tree [ColumnNullabilityAnalyzer.queryColumnNullabilityViaProsqlbody] * does: a temporary, zero-argument `BEGIN ATOMIC ... END` SQL-standard function. See * [NodeTreeProvenanceResolverTest]'s identical helper for why no parameter-substitution logic is * needed here — every caller in this file either has no `?` at all, or (in diff --git a/generator/src/test/kotlin/norm/generator/NodeTreeProvenanceResolverTest.kt b/generator/src/test/kotlin/norm/generator/NodeTreeProvenanceResolverTest.kt index 3de653fb..3b62dbf2 100644 --- a/generator/src/test/kotlin/norm/generator/NodeTreeProvenanceResolverTest.kt +++ b/generator/src/test/kotlin/norm/generator/NodeTreeProvenanceResolverTest.kt @@ -15,14 +15,14 @@ import java.util.concurrent.atomic.AtomicInteger /** * Live-database pins for [NodeTreeProvenanceResolver]: every shape it resolves a - * [NodeTreeColumnProvenance] for, AND every shape the "correct or silent" invariant requires it to + * [NodeTreeColumnProvenance] for, and every shape the "correct or silent" invariant requires it to * return `null` for instead of guessing. * - * Each test builds the SAME node tree production analysis uses — a temporary, zero-argument + * Each test builds the same node tree production analysis uses — a temporary, zero-argument * `BEGIN ATOMIC ... END` SQL-standard function's `prosqlbody` (see * [ColumnNullabilityAnalyzer.queryColumnNullabilityViaProsqlbody]'s own KDoc for why this is the * production path, not `CREATE VIEW`: only `prosqlbody` is populated for a data-modifying - * statement) — against a REAL PostgreSQL server, then feeds that raw text straight to the resolver + * statement) — against a real PostgreSQL server, then feeds that raw text straight to the resolver * under test. No node-tree text is hand-written. */ @Testcontainers @@ -97,7 +97,7 @@ class NodeTreeProvenanceResolverTest { ) // "y" is declared at the top level (hop 1, ctelevelsup 0 relative to the outer query's own // scope). y's own body has no nested WITH of its own, so its reference to "x" is one level - // FURTHER up (hop 2, ctelevelsup 1) -- back at the SAME top-level scope hop 1 was found in. + // further up (hop 2, ctelevelsup 1) -- back at the same top-level scope hop 1 was found in. assertThat(provenance).containsExactly( NodeTreeColumnProvenance(listOf(CteHop("y", 0), CteHop("x", 1)), 1), ) @@ -186,9 +186,9 @@ class NodeTreeProvenanceResolverTest { "d AS (WITH c AS (SELECT LOWER(description) AS ux FROM parent) SELECT ux, 1 AS n FROM c) " + "SELECT ux, n FROM d", ) - // "ux" resolves through d's own reference to its INNER "c" (ctelevelsup 0 relative to d's own + // "ux" resolves through d's own reference to its inner "c" (ctelevelsup 0 relative to d's own // body), landing on the inner CTE's own computed expression -- hop 1 is "d" (declared at the - // top level), hop 2 is the INNER "c" (declared inside d's own body, ctelevelsup 0 relative to + // top level), hop 2 is the inner "c" (declared inside d's own body, ctelevelsup 0 relative to // that body). "n" never enters a CTE reference at all -- d's own body position 2 is the // literal "1 AS n" -- so it resolves to d's own body alone, not to either "c". assertThat(provenance).containsExactly( @@ -281,15 +281,14 @@ class NodeTreeProvenanceResolverTest { @Nested inner class LevelsUpGuards { - // Both shapes below cannot occur through a real, live PostgreSQL round trip: a top-level + // Neither shape below can occur through a real, live PostgreSQL round trip: a top-level // statement's own :targetList Var always has :varlevelsup 0 (there is no enclosing scope to // point past), and an ordinary (non-merged) :joinaliasvars entry always references one of the - // JOIN's own two inputs at :varlevelsup 0. Each guard is nonetheless load-bearing defensive code - // -- see NodeTreeProvenanceResolver's own KDoc -- so these are hand-crafted `pg_node_tree` - // fixtures (the same established pattern PgNodeTreeParserTest and + // JOIN's own two inputs at :varlevelsup 0. Both guards are defensive code for a shape PostgreSQL + // never actually produces -- see NodeTreeProvenanceResolver's own KDoc -- so these are + // hand-crafted `pg_node_tree` fixtures (the same established pattern PgNodeTreeParserTest and // NodeTreeProvenanceExpressionTest's AllPositionCheckGate test use) exercising exactly the shape - // each guard exists to refuse. Both mutations (deleting the guarded line) were run by hand against - // this class and confirmed to turn the corresponding test red before being reverted. + // each guard exists to refuse. @Test fun `an outer target-list Var with a nonzero levelsup resolves to nothing, never an enclosing scope's CTE`() { @@ -414,9 +413,9 @@ class NodeTreeProvenanceResolverTest { @Test fun `INSERT SELECT FROM cte RETURNING resolves to nothing, since provenance comes from the INSERT target`() { - // Verified live: the RETURNING Var's varno points at the INSERT's OWN target relation "b" - // (rtekind 0), never at the feeding subquery's CTE reference — this is the CORRECT node-tree - // reading, not a resolver gap, so "nothing" here is the right answer, not a missing feature. + // The RETURNING Var's varno points at the INSERT's own target relation "b" (rtekind 0), never + // at the feeding subquery's CTE reference — this is the correct node-tree reading, not a + // resolver gap, so "nothing" here is the right answer, not a missing feature. val provenance = provenanceFor( "CREATE TABLE t (d TEXT); CREATE TABLE b (id INT, d TEXT)", "WITH c AS (SELECT UPPER(d) AS d FROM t) " + @@ -444,7 +443,7 @@ class NodeTreeProvenanceResolverTest { resolver.resolveColumnProvenance(nodeTreeFor(ddl, sql)) /** - * Builds the SAME `prosqlbody` node tree + * Builds the same `prosqlbody` node tree * [ColumnNullabilityAnalyzer.queryColumnNullabilityViaProsqlbody] does: a temporary, zero-argument * `BEGIN ATOMIC ... END` SQL-standard function, so `?` parameters never need to appear (this * resolver has no parameter-substitution logic of its own to exercise). diff --git a/generator/src/test/kotlin/norm/generator/NodeTreeProvenanceScopeSweepTest.kt b/generator/src/test/kotlin/norm/generator/NodeTreeProvenanceScopeSweepTest.kt index 576b2269..fe51a3aa 100644 --- a/generator/src/test/kotlin/norm/generator/NodeTreeProvenanceScopeSweepTest.kt +++ b/generator/src/test/kotlin/norm/generator/NodeTreeProvenanceScopeSweepTest.kt @@ -16,26 +16,26 @@ import java.util.UUID /** * Brute-force, ground-truth sweep guarding [NodeTreeProvenanceResolver]'s "correct or silent" - * invariant across the scope-stack bug class fixed in #238 — a bug class two unit test suites (this - * one's own sibling files) already caught only because a fresh-context verifier happened to probe - * the exact shape that triggers it. This test exists so the next scope defect, whatever shape it - * takes, is caught by a systematic sweep instead of by luck. + * invariant across the scope-stack bug class this file's sibling test suites already caught — only + * because a fresh-context verifier happened to probe the exact shape that triggers it. This test + * exists so the next scope defect, whatever shape it takes, is caught by a systematic sweep instead + * of by luck. * - * For every generated shape, this test does NOT compare against a hand-computed expected string — - * it runs the ACTUAL query against seeded rows to capture what PostgreSQL itself returns for the + * For every generated shape, this test does not compare against a hand-computed expected string — + * it runs the actual query against seeded rows to capture what PostgreSQL itself returns for the * result column ([groundTruthValue]), then — if [NodeTreeProvenanceResolver] emitted anything at - * all — evaluates the EMITTED expression text directly against the same seeded row and requires - * the two to agree. Emitting nothing is a PASS (the "or silent" half of the invariant); emitting + * all — evaluates the emitted expression text directly against the same seeded row and requires + * the two to agree. Emitting nothing is a pass (the "or silent" half of the invariant); emitting * something that does not reproduce what the query itself returns is the one and only failure * mode, matching this whole subsystem's design (see [NodeTreeProvenanceResolver]'s own KDoc). * * [generateSweepCases] crosses four axes: * - **chain depth** (`1..6`): total CTEs in a straight reference chain `c1 <- c2 <- ... <- cN`. - * - **sibling count** (`0..2`): extra, UNREFERENCED CTEs declared in the same top-level `WITH` - * clause, computing a DIFFERENT value than the real chain — proving their mere presence never + * - **sibling count** (`0..2`): extra, unreferenced CTEs declared in the same top-level `WITH` + * clause, computing a different value than the real chain — proving their mere presence never * changes what the real chain resolves to. * - **nesting depth** (`0..2`): the whole chain wrapped in that many extra layers of a genuinely - * LEXICALLY nested `WITH` (`ctelevelsup 0` at every such layer, never a sibling hop) — proving + * lexically nested `WITH` (`ctelevelsup 0` at every such layer, never a sibling hop) — proving * real nesting neither breaks a correct resolution nor "accidentally" fixes a wrong one. * - **shadowing**: for `chainDepth >= 2`, the outermost CTE additionally declares its own nested * `WITH c1 AS (...)`, re-declaring the first CTE's name with a differently-computed, easily @@ -47,13 +47,11 @@ import java.util.UUID * test declares, and correctly bails to `null` instead) — still asserted here, since "correct or * silent" makes `null` a pass, not a gap in coverage. * - * MUTATION TESTED: reverting [NodeTreeProvenanceResolver.resolveVar]'s `currentScopeStack = - * listOf(ownScope) + currentScopeStack.drop(reference.ctelevelsup)` back to the old, unconditional - * `listOf(ownScope) + currentScopeStack` was run by hand against this test and confirmed to turn - * `every generated CTE chain shape emits either nothing or the value it actually produced` red, - * failing on the `chainDepth=3, shadowed=true` cases with the emitted expression evaluating to the - * shadow's `LOWER(description)` value instead of the real chain's `UPPER(name)` value — before - * being reverted back to the fix. + * Reverting [NodeTreeProvenanceResolver.resolveVar]'s `currentScopeStack = listOf(ownScope) + + * currentScopeStack.drop(reference.ctelevelsup)` to the old, unconditional `listOf(ownScope) + + * currentScopeStack` fails this test on the `chainDepth=3, shadowed=true` cases: the emitted + * expression evaluates to the shadow's `LOWER(description)` value instead of the real chain's + * `UPPER(name)` value. */ @Testcontainers class NodeTreeProvenanceScopeSweepTest { @@ -61,7 +59,7 @@ class NodeTreeProvenanceScopeSweepTest { @Test fun `every generated CTE chain shape emits either nothing or the value it actually produced`() { val cases = generateSweepCases() - // The REAL corpus size, not a lower bound: shrinking any axis below changes this literal, + // The real corpus size, not a lower bound: shrinking any axis below changes this literal, // forcing a conscious update rather than a silently smaller sweep going unnoticed. assertThat(cases.size).isEqualTo(99) @@ -84,11 +82,11 @@ class NodeTreeProvenanceScopeSweepTest { // c1's own computed expression through nothing but bare-column pass-throughs -- no axis // (chainDepth, shadowed, siblingCount, nestingDepth) combination here has a legitimate reason to // bail to null. `isGreaterThan(0)` previously let a regression that bails for every chainDepth >= - // 2 case slip through GREEN: the 9 chainDepth == 1 cases (siblingCount 0..2 x nestingDepth 0..2, - // no shadow variant since chainDepth 1 has none) still emit, satisfying "more than zero" while 90 - // genuine resolutions silently vanished. Asserting the full expected count instead -- confirmed - // by hand: reverting NodeTreeProvenanceResolver.resolveVar to bail after the first CTE hop turns - // this exact assertion red at 9, not 99 -- closes that gap. + // 2 case slip through: the 9 chainDepth == 1 cases (siblingCount 0..2 x nestingDepth 0..2, no + // shadow variant since chainDepth 1 has none) still emit, satisfying "more than zero" while 90 + // genuine resolutions silently vanished. Asserting the full expected count closes that gap: + // reverting NodeTreeProvenanceResolver.resolveVar to bail after the first CTE hop turns this + // exact assertion red at 9, not 99. assertThat(evaluatedNonNullCount).isEqualTo(cases.size) assertThat(failures).isEmpty() } @@ -116,9 +114,9 @@ class NodeTreeProvenanceScopeSweepTest { /** * `c1` computes the real value (`UPPER(name)`); every `c2..cN` is an ordinary pass-through of - * its predecessor, EXCEPT the outermost (`cN`) when [shadowed] — which additionally declares - * its own nested `WITH c1 AS (...)`, a DIFFERENT body ([shadowed]'s KDoc explains why this - * specific placement is the one that reproduces the #238 scope-stack bug at `chainDepth == 3`) + * its predecessor, except the outermost (`cN`) when [shadowed] — which additionally declares + * its own nested `WITH c1 AS (...)`, a different body (this file's own KDoc explains why this + * specific placement is the one that reproduces the scope-stack bug at `chainDepth == 3`) * that `cN`'s own `SELECT` never itself references. */ private fun buildChainDefinitions(): List { @@ -135,8 +133,8 @@ class NodeTreeProvenanceScopeSweepTest { } /** - * Wraps [core] in [nestingDepth] extra layers of a genuinely LEXICALLY nested `WITH` — each - * layer's own `wrap_N` CTE has [core] (or the PREVIOUS layer) as its ENTIRE body, so the + * Wraps [core] in [nestingDepth] extra layers of a genuinely lexically nested `WITH` — each + * layer's own `wrap_N` CTE has [core] (or the previous layer) as its entire body, so the * reference from one layer into the next is always `ctelevelsup 0` (a real nesting level), * never a sibling hop `ctelevelsup 1` — the one relationship * [NodeTreeProvenanceResolver.resolveVar]'s scope-stack fix changes the handling of at all (see @@ -154,7 +152,7 @@ class NodeTreeProvenanceScopeSweepTest { private fun generateSweepCases(): List { val cases = mutableListOf() for (chainDepth in 1..6) { - // A shadow re-declares "c1" from INSIDE the outermost CTE's own nested WITH -- meaningless + // A shadow re-declares "c1" from inside the outermost CTE's own nested WITH -- meaningless // (and, since chainDepth 1 has no "c{chainDepth - 1}" to pass through from at all, not even // constructible) for a chain shorter than 2 CTEs. val shadowOptions = if (chainDepth >= 2) listOf(false, true) else listOf(false) @@ -186,7 +184,7 @@ class NodeTreeProvenanceScopeSweepTest { } /** - * Builds the SAME `prosqlbody` node tree [ColumnNullabilityAnalyzer.queryColumnNullabilityViaProsqlbody] + * Builds the same `prosqlbody` node tree [ColumnNullabilityAnalyzer.queryColumnNullabilityViaProsqlbody] * does: a temporary, zero-argument `BEGIN ATOMIC ... END` SQL-standard function. See * [NodeTreeProvenanceResolverTest]'s identical helper — no parameter substitution is needed here, * since no case in this sweep has a `?` placeholder. diff --git a/generator/src/test/kotlin/norm/generator/PgNodeTreeParserTest.kt b/generator/src/test/kotlin/norm/generator/PgNodeTreeParserTest.kt index b9296c26..f592bbd8 100644 --- a/generator/src/test/kotlin/norm/generator/PgNodeTreeParserTest.kt +++ b/generator/src/test/kotlin/norm/generator/PgNodeTreeParserTest.kt @@ -45,7 +45,7 @@ class PgNodeTreeParserTest { @Test fun `hasGroupingSets is not fooled by a backslash immediately followed by a brace`() { // An alias containing a literal backslash followed by a literal `}` (e.g. an identifier - // "k\}m") is written as THREE consecutive backslashes then `}` — the escaped backslash + // "k\}m") is written as three consecutive backslashes then `}` — the escaped backslash // ("\\") immediately followed by the escaped brace ("\}"). A scanner that does not process // escapes strictly left-to-right, two characters at a time, can lose parity here and either // treat the final `}` as real or desynchronize entirely. @@ -55,7 +55,7 @@ class PgNodeTreeParserTest { @Test fun `parseTargetList reads the full, unescaped resname of an entry whose alias contains an escaped brace`() { - // Without escape-awareness, extractBalancedBraces closes the FIRST {TARGETENTRY ...} block + // Without escape-awareness, extractBalancedBraces closes the first {TARGETENTRY ...} block // early at the escaped `}` inside its own :resname, truncating the block before ":resname"'s // real value ends — resultName comes back as "k\}" (missing the trailing "x") instead of // the correctly-bounded and unescaped "k}x". This is the same brace-counting bug @@ -79,18 +79,18 @@ class PgNodeTreeParserTest { @Nested inner class FuncVariadicFieldExtraction { - // parseFuncExpr's :funcvariadic extraction matches the FIRST occurrence anywhere in a node's + // parseFuncExpr's :funcvariadic extraction matches the first occurrence anywhere in a node's // text, including inside nested blocks — see its KDoc for why that is only safe because the // outer FUNCEXPR's own :funcvariadic always precedes :args textually. These two tests use real - // ev_action text (verified live on PostgreSQL 17: `upper(concat(VARIADIC arr))` and + // ev_action text from PostgreSQL 17 (`upper(concat(VARIADIC arr))` and // `concat(VARIADIC ARRAY[upper(a)])`) covering both nesting directions, so a future PostgreSQL // format change that broke this invariant would fail a test here instead of silently flipping // a nullability verdict. @Test fun `a non-variadic outer FuncExpr wrapping a variadic inner one keeps both flags correct`() { - // upper(concat(VARIADIC arr)): the OUTER upper(...) call is not variadic, but its own :args - // contains a nested concat(VARIADIC ...) call that IS. + // upper(concat(VARIADIC arr)): the outer upper(...) call is not variadic, but its own :args + // contains a nested concat(VARIADIC ...) call that is. val text = "{FUNCEXPR :funcid 871 :funcresulttype 25 :funcretset false :funcvariadic false " + ":funcformat 0 :funccollid 100 :inputcollid 100 :args " + "({FUNCEXPR :funcid 3058 :funcresulttype 25 :funcretset false :funcvariadic true " + @@ -105,8 +105,8 @@ class PgNodeTreeParserTest { @Test fun `a variadic outer FuncExpr wrapping a non-variadic inner one keeps both flags correct`() { - // concat(VARIADIC ARRAY[upper(a)]): the reverse nesting — the OUTER concat(...) call IS - // variadic, but its array literal argument contains a nested upper(...) call that is NOT. + // concat(VARIADIC ARRAY[upper(a)]): the reverse nesting — the outer concat(...) call is + // variadic, but its array literal argument contains a nested upper(...) call that is not. val text = "{FUNCEXPR :funcid 3058 :funcresulttype 25 :funcretset false :funcvariadic true " + ":funcformat 0 :funccollid 100 :inputcollid 100 :args " + "({ARRAYEXPR :array_typeid 1009 :array_collid 100 :element_typeid 25 :elements " + @@ -165,9 +165,9 @@ class PgNodeTreeParserTest { @Test fun `a PostgreSQL 18 GROUP RTE's groupexprs entry parses into the expression it describes`() { - // Shape verified live against a real PostgreSQL 18 pg_rewrite.ev_action for + // Shape taken from PostgreSQL 18 pg_rewrite.ev_action for // `SELECT count(*) + 0::bigint AS c, 0::bigint AS k FROM t GROUP BY ROLLUP((0::bigint))`: the - // GROUP RTE (rtekind 9) is the SECOND :rtable entry, so its varno is 2, and its :groupexprs + // GROUP RTE (rtekind 9) is the second :rtable entry, so its varno is 2, and its :groupexprs // holds one FUNCEXPR (the implicit int4->int8 cast of the literal 0) wrapping one CONST. val text = """ {QUERY :rtable ( @@ -247,11 +247,11 @@ class PgNodeTreeParserTest { @Nested inner class SubLinkParsing { - // Shapes verified live against a real PostgreSQL 17 ev_action for `a = ANY (SELECT v FROM u)` - // (see issue #239's own repro) — a SUBLINK's :testexpr is a single top-level OPEXPR whose - // :opfuncid is the comparison operator's function OID, and :subselect holds the subquery's own - // {QUERY ...} block. These synthetic texts reproduce that shape at a level a unit test can - // construct directly, without depending on a live server. + // Shape taken from a PostgreSQL 17 ev_action for `a = ANY (SELECT v FROM u)` — a SUBLINK's + // :testexpr is a single top-level OPEXPR whose :opfuncid is the comparison operator's function + // OID, and :subselect holds the subquery's own {QUERY ...} block. These synthetic texts + // reproduce that shape at a level a unit test can construct directly, without depending on a + // live server. private val anyOpexprTestexpr = "{OPEXPR :opno 98 :opfuncid 67 :args " + "({VAR :varno 1 :varattno 2 :varlevelsup 0 :location -1} " + @@ -335,19 +335,19 @@ class PgNodeTreeParserTest { // extractFieldExpression must find a field at brace depth 1 of the node it is given, not the // first textual occurrence of that field name anywhere in the text — several node types have - // an earlier-serialized field whose OWN value can legally contain ANOTHER node of the same - // type, carrying the same field name, nested deeper. A first-match indexOf scan finds the - // NESTED (wrong) occurrence whenever the earlier field's value textually precedes the node's - // OWN later field of that name, silently proving the wrong subtree. + // an earlier-serialized field whose own value can legally contain another node of the same + // type, carrying the same field name, nested deeper. A first-match indexOf scan finds that + // nested (wrong) occurrence whenever the earlier field's value textually precedes the node's + // own later field of that name, silently proving the wrong subtree. @Test fun `a SUBLINK's testexpr containing a nested SUBLINK does not shadow the outer subselect`() { - // Live-verified repro (PostgreSQL 17 and 18): `SELECT EXISTS (SELECT v FROM u) = ANY + // Repro on PostgreSQL 17 and 18: `SELECT EXISTS (SELECT v FROM u) = ANY // (SELECT b FROM x) FROM t` — the outer ANY_SUBLINK's :testexpr (an OPEXPR whose first // argument is the nested EXISTS sublink) precedes its own :subselect in SUBLINK's field - // order, and the nested EXISTS sublink has its OWN :subselect (the `u` query) textually + // order, and the nested EXISTS sublink has its own :subselect (the `u` query) textually // inside that :testexpr, before the outer sublink's real :subselect (the `x` query) ever - // appears. A naive first-match scan for ":subselect {" returns the INNER (u) block. + // appears. A naive first-match scan for ":subselect {" returns the inner (u) block. val innerSubselect = "{QUERY :targetList (" + "{TARGETENTRY :expr {VAR :varno 900 :varattno 1 :varlevelsup 0 :location -1} :resno 1 " + ":resname inner_col :resjunk false}) :setOperations <>}" @@ -413,8 +413,8 @@ class PgNodeTreeParserTest { // syntactically-repeated grouping key expression (e.g. `concat(a, '-')` written twice in // the same SELECT list — see QueryAnalysisTest.Grouping's "duplicate concat call that IS // the ROLLUP key" test) by comparing parsed PgNodeExpression subtrees with `==`. Postgres - // assigns each occurrence of the SAME literal its OWN :location (a source-text byte - // offset), so this equality must hold across TWO Consts whose :location values genuinely + // assigns each occurrence of the same literal its own :location (a source-text byte + // offset), so this equality must hold across two Consts whose :location values genuinely // differ — pinning this directly, rather than only through the end-to-end grouping-sets // test, so a future change adding a location-like field to Const (this project already // tried once, for prosqlbody's per-assignment parameter-trust check, and reverted it for @@ -538,7 +538,7 @@ class PgNodeTreeParserTest { @Nested inner class AggrefArgumentExtraction { - // Both fixtures are VERBATIM `{AGGREF ...}` blocks from a live PostgreSQL 18.4 ev_action dump. + // Both fixtures are verbatim `{AGGREF ...}` blocks from a PostgreSQL 18.4 ev_action dump. // extractArgListSection's prior non-depth-aware `indexOf(":args (")` mis-parsed both, attributing // a nested node's argument list to the aggregate. No isNonNull answer moved (that branch never // reads Aggref.arguments), but the arguments also feed containsVarOutsideRelation and @@ -565,7 +565,7 @@ class PgNodeTreeParserTest { @Test fun `an AGGREF with WITHIN GROUP does not attribute aggdirectargs' nested args to the aggregate`() { // `:aggdirectargs` (the `0.5`, wrapped in a numeric-cast FUNCEXPR with its own nested `:args`) - // is serialized BEFORE the aggregate's real `:args` — so a plain scan returns just the literal. + // is serialized before the aggregate's real `:args` — so a plain scan returns just the literal. val text = "{AGGREF :aggfnoid 3974 :aggtype 701 :aggcollid 0 :inputcollid 0 :aggtranstype 0 " + ":aggargtypes (o 701 701) :aggdirectargs ({FUNCEXPR :funcid 1746 :funcresulttype 701 " + ":funcretset false :funcvariadic false :funcformat 2 :funccollid 0 :inputcollid 0 " + diff --git a/generator/src/test/kotlin/norm/generator/QueryAnalysisTest.kt b/generator/src/test/kotlin/norm/generator/QueryAnalysisTest.kt index ca16e53c..f6505c9d 100644 --- a/generator/src/test/kotlin/norm/generator/QueryAnalysisTest.kt +++ b/generator/src/test/kotlin/norm/generator/QueryAnalysisTest.kt @@ -40,17 +40,17 @@ internal data class WindowFunctionCase( } /** - * One live-DB view-graph shape for `ViewNullability`'s + * One view-graph shape for `ViewNullability`'s * `resolving one view first does not change a later, independent resolution of another` pin. * * @property ddl Creates every view in [views], run against a fresh, isolated schema. - * @property views Every view the [ddl] creates — the CHECK axis. Each one's answer on a completely - * untouched analyzer is the ground truth every warmed resolution must still match. - * @property warmTriggers The views resolved first, each as its own complete top-level call — the - * WARM axis. NOT necessarily all of [views]: for the straight chain, only the DEEPEST tip is - * proven safe, since its resolution taints and evicts every other view before returning. Warming - * with an intermediate view can complete untainted and stay memoized permanently, which a later - * deeper resolution then reuses instead of truncating — see + * @property views Every view the [ddl] creates. Each one's answer on a completely untouched + * analyzer is the ground truth every warmed resolution must still match. + * @property warmTriggers The views resolved first, each as its own complete top-level call. Not + * necessarily all of [views]: for the straight chain, only the deepest tip is proven safe, since + * its resolution taints and evicts every other view before returning. Warming with an + * intermediate view can complete untainted and stay memoized permanently, which a later deeper + * resolution then reuses instead of truncating — see * [ColumnNullabilityAnalyzer.VIEW_NULLABILITY_RECURSION_DEPTH_BUDGET] for why that residual is * accepted. */ @@ -107,8 +107,8 @@ class QueryAnalysisTest { @Test fun `an unquoted uppercase column reference still resolves the column's Postgres comment`() { - // #238: pgjdbc's ResultSetMetaData.getColumnName reports the FOLDED name ("id"), but - // JdbcAnalyzer.buildResultColumns prefers the PARSED select-item's columnName when present. + // pgjdbc's ResultSetMetaData.getColumnName reports the folded name ("id"), but + // JdbcAnalyzer.buildResultColumns prefers the parsed select-item's columnName when present. // Before ASCII-folding was applied there, an unquoted "ID" stayed "ID" verbatim, so // catalog.findColumn("t", "ID") missed the catalog row keyed by "id" and silently dropped the // column's comment. @@ -126,8 +126,8 @@ class QueryAnalysisTest { @Test fun `a CTE output column referenced through its own alias resolves to the real source column`() { - // #238: the outer query references the CTE's OWN output alias ("parentId"), not the real - // underlying column ("id") -- before this fix, originalName fell back to that alias, so + // The outer query references the CTE's own output alias ("parentId"), not the real + // underlying column ("id"). Before this fix, originalName fell back to that alias, so // generated KDoc's "@property parentId (`parent.parentId`)" named a column "parent" never // had, instead of the real "parent.id". The node tree's :resorigtbl/:resorigcol on the outer // target entry name the true source column regardless of what alias the CTE assigned it. @@ -146,9 +146,9 @@ class QueryAnalysisTest { @Test fun `a UNION's output column has no single original column, so originalName falls back rather than guessing`() { - // #238: :resorigtbl/:resorigcol are both 0 for a SetOp's own target list -- there is no - // single source column a union's result traces back to -- so this must fall back to the - // ordinary selectItem/JDBC-label resolution rather than reporting a wrong source column. + // :resorigtbl/:resorigcol are both 0 for a SetOp's own target list: there is no single + // source column a union's result traces back to, so this falls back to the ordinary + // selectItem/JDBC-label resolution instead of reporting a wrong source column. val query = analyzeWithSchema( "CREATE TABLE t (id INT NOT NULL)", "SELECT id FROM t UNION SELECT id FROM t", @@ -582,11 +582,10 @@ class QueryAnalysisTest { @Test fun `concat_ws with a nullable separator is nullable`() { - // concat_ws is non-null iff its FIRST argument (the separator) is non-null — a null + // concat_ws is non-null iff its first argument (the separator) is non-null. A null // separator poisons the whole result even though later arguments are individually - // null-tolerant. Verified live on PostgreSQL 16, 17, and 18: concat_ws(NULL, 'x', 'y') IS - // NULL. This is the pre-existing bug (present before grouping-sets work ever touched this - // file): concat_ws was wrongly on the unconditional always-non-null list. + // null-tolerant. PostgreSQL 16-18: concat_ws(NULL, 'x', 'y') IS NULL. This bug predates the + // grouping-sets work: concat_ws was wrongly on the unconditional always-non-null list. val query = analyzeWithSchema( "CREATE TABLE t3 (s TEXT)", "SELECT concat_ws(s, 'x', 'y') AS k FROM t3", @@ -599,7 +598,7 @@ class QueryAnalysisTest { // isAlwaysNonNull's "regardless of any argument" and isNonNullIffFirstArgumentNonNull's "only // the first argument matters" both assume the ordinary calling form and are unsound for // VARIADIC: concat(VARIADIC arr) and concat_ws(',', VARIADIC arr) are both NULL when arr - // itself is NULL. Verified live on PostgreSQL 16, 17, and 18. + // itself is NULL. PostgreSQL 16-18. @Test fun `concat with VARIADIC over a nullable array is nullable`() { @@ -643,9 +642,9 @@ class QueryAnalysisTest { @Test fun `concat_ws with VARIADIC over an array literal containing a NULL element is still non-null`() { - // Verified live on PostgreSQL 16, 17, and 18: concat_ws(',', VARIADIC ARRAY['a', NULL]) is - // 'a', never NULL — the ARRAY[] constructor itself is never NULL even when an element is, - // and that (not "every element non-null") is the condition that matters here. + // PostgreSQL 16-18: concat_ws(',', VARIADIC ARRAY['a', NULL]) is 'a', never NULL. The + // ARRAY[] constructor itself is never NULL even when an element is, and that (not "every + // element non-null") is the condition that matters here. val query = analyzeWithSchema( "CREATE TABLE t (id INT NOT NULL)", "SELECT concat_ws(',', VARIADIC ARRAY['a', NULL]) AS w FROM t", @@ -733,15 +732,13 @@ class QueryAnalysisTest { } /** - * P0-2 audit finding (issue #226 follow-up, discovered while re-auditing every safe-listed - * entry, not one of the verifier's originally measured defects): `extract`/`date_part` over - * `date`, `interval`, `timestamp`, or `timestamp with time zone` is NOT total, unlike - * `date_trunc` over the same types. All four base types support an `'infinity'` value, and - * `extract`/`date_part` special-case only a FEW fields (`epoch`, `year`, `century`, ...) to - * return an infinite result for infinite input — every OTHER field (`hour`, `month`, `day`, + * Found while re-auditing every safe-listed entry: `extract`/`date_part` over `date`, + * `interval`, `timestamp`, or `timestamp with time zone` is not total, unlike `date_trunc` + * over the same types. All four base types support an `'infinity'` value, and + * `extract`/`date_part` special-case only a few fields (`epoch`, `year`, `century`, ...) to + * return an infinite result for infinite input — every other field (`hour`, `month`, `day`, * ...) silently returns `null`, with no error, even though the input is a well-typed non-null - * value. Verified against real PostgreSQL 18.4: - * `extract(hour FROM 'infinity'::timestamp)` returns `null`, and + * value. PostgreSQL 18.4: `extract(hour FROM 'infinity'::timestamp)` returns `null`, and * `extract(month FROM 'infinity'::interval)` and `extract(day FROM 'infinity'::date)` do too. * Because the safe-list is keyed by the function's OID (one per `(name, argument types)` * signature), not by the field-name string literal passed at a given call site, the whole @@ -759,12 +756,12 @@ class QueryAnalysisTest { } /** - * Issue #226 root cause: `proisstrict` only guarantees NULL-in => NULL-out, never the - * converse. `substring(text FROM pattern)` is STRICT, yet returns `null` on a non-matching - * pattern even though every input is non-null. `substring` shares its `proname` with the - * TOTAL `substring(text, int, int)` overload, so the safe-list excludes the name wholesale - * (see [PgCatalogLoader.neverNullForNonNullInputOids]) rather than trying to key it by - * argument signature. + * Root cause: `proisstrict` only guarantees NULL-in => NULL-out, never the converse. + * `substring(text FROM pattern)` is STRICT, yet returns `null` on a non-matching pattern even + * though every input is non-null. `substring` shares its `proname` with the total + * `substring(text, int, int)` overload, so the safe-list excludes the name wholesale (see + * [PgCatalogLoader.neverNullForNonNullInputOids]) rather than trying to key it by argument + * signature. */ @Test fun `substring with a non-matching regex reports nullable over a NOT NULL source column`() { @@ -776,8 +773,8 @@ class QueryAnalysisTest { } /** - * Issue #226: `regexp_match` is STRICT but returns `null` (not an empty array) when the - * pattern does not match, even over a non-null input string. + * `regexp_match` is STRICT but returns `null` (not an empty array) when the pattern does not + * match, even over a non-null input string. */ @Test fun `regexp_match with a non-matching pattern reports nullable over a NOT NULL source column`() { @@ -789,8 +786,8 @@ class QueryAnalysisTest { } /** - * Issue #226: `array_length` is STRICT but returns `null` (not `0`) for an empty array, even - * though the array literal itself is a non-null value. + * `array_length` is STRICT but returns `null` (not `0`) for an empty array, even though the + * array literal itself is a non-null value. */ @Test fun `array_length of an empty array reports nullable over a NOT NULL source column`() { @@ -802,8 +799,8 @@ class QueryAnalysisTest { } /** - * Closes the class of unsoundness issue #226 fixes for user code: a STRICT function is no - * longer inferred non-null just because it is strict. Only functions on the + * Closes a class of unsoundness for user code: a STRICT function is no longer inferred + * non-null just because it is strict. Only functions on the * [PgCatalogLoader.neverNullForNonNullInputOids] safe-list — which is restricted to * `pg_catalog` — get that inference; a user-defined STRICT function in `public` does not, * because Norm cannot prove it is total on non-null input. @@ -839,10 +836,10 @@ class QueryAnalysisTest { } /** - * P0-2 measured defect: `upper(anyrange)` shares `proname = 'upper'` with the total - * `upper(text)`, but is STRICT and returns `null` for a non-null, well-typed, UNBOUNDED range - * — `SELECT upper(int4range '[1,)')` returns `null` with no error. Verified against real - * PostgreSQL 18.4. Before this fix, keying the safe list by name alone reported this NOT NULL. + * `upper(anyrange)` shares `proname = 'upper'` with the total `upper(text)`, but is STRICT + * and returns `null` for a non-null, well-typed, unbounded range — `SELECT upper(int4range + * '[1,)')` returns `null` with no error. PostgreSQL 18.4. Before this fix, keying the safe + * list by name alone reported this NOT NULL. */ @Test fun `upper of an unbounded NOT NULL int4range reports nullable`() { @@ -854,9 +851,9 @@ class QueryAnalysisTest { } /** - * P0-2 measured defect: same as the `upper` test above, but for `lower(anyrange)` over an - * EMPTY (rather than unbounded) range — `SELECT lower(int4range 'empty')` returns `null` with - * no error. Verified against real PostgreSQL 18.4. + * Same as the `upper` test above, but for `lower(anyrange)` over an empty (rather than + * unbounded) range — `SELECT lower(int4range 'empty')` returns `null` with no error. + * PostgreSQL 18.4. */ @Test fun `lower of an empty NOT NULL int4range reports nullable`() { @@ -868,10 +865,9 @@ class QueryAnalysisTest { } /** - * P0-2 measured defect: `lower(anymultirange)` shares `proname = 'lower'` with the total - * `lower(text)`, but is STRICT and returns `null` for a non-null, well-typed, EMPTY - * multirange — `SELECT lower(int4multirange '{}')` returns `null` with no error. Verified - * against real PostgreSQL 18.4. + * `lower(anymultirange)` shares `proname = 'lower'` with the total `lower(text)`, but is + * STRICT and returns `null` for a non-null, well-typed, empty multirange — `SELECT + * lower(int4multirange '{}')` returns `null` with no error. PostgreSQL 18.4. */ @Test fun `lower of an empty NOT NULL int4multirange reports nullable`() { @@ -883,12 +879,12 @@ class QueryAnalysisTest { } /** - * P0-2 measured defect: `to_char(timestamp, text)` is STRICT but returns `null` for a - * non-null, well-typed, EMPTY format string — `SELECT to_char(now(), '')` returns `null` with - * no error. Verified against real PostgreSQL 18.4. `to_char` is now dropped from the safe - * list entirely (see [NeverNullSafeLists.NEVER_NULL_FUNCTION_SIGNATURES]) rather than narrowed, - * since the empty-format behavior is a property of every overload's shared formatting engine, - * not of the first argument's type. + * `to_char(timestamp, text)` is STRICT but returns `null` for a non-null, well-typed, empty + * format string — `SELECT to_char(now(), '')` returns `null` with no error. PostgreSQL 18.4. + * `to_char` is now dropped from the safe list entirely (see + * [NeverNullSafeLists.NEVER_NULL_FUNCTION_SIGNATURES]) rather than narrowed, since the + * empty-format behavior is a property of every overload's shared formatting engine, not of + * the first argument's type. */ @Test fun `to_char with a NOT NULL empty format string reports nullable`() { @@ -1011,9 +1007,6 @@ class QueryAnalysisTest { assertThat(query.columns[0].notNull).isFalse() } - /** - * Verifies the JSONB `->>` operator. - */ @Test fun `jsonb operator 1`() { val query = analyzeWithSchema( @@ -1023,9 +1016,6 @@ class QueryAnalysisTest { assertThat(query.columns[0].notNull).isFalse() } - /** - * Verifies the JSONB `->` operator. - */ @Test fun `jsonb operator 2`() { val query = analyzeWithSchema( @@ -1038,7 +1028,7 @@ class QueryAnalysisTest { /** * Regression guard for the operator blanket rule this fix replaces with signature keying: * `path_add` (the `+` operator's `path` overload) returns `null`, not an error, when either - * operand is a CLOSED path — even though both operands are non-null and well-typed. A + * operand is a closed path — even though both operands are non-null and well-typed. A * symbol-only safe-list (every `pg_catalog` overload of `+` is safe) cannot see this, because * `+` is also the totally-safe `int4 + int4`. See * [NeverNullSafeLists.NEVER_NULL_OPERATOR_SIGNATURES]'s KDoc. @@ -1578,11 +1568,11 @@ class QueryAnalysisTest { assertThat(query.columns[2].notNull).isTrue() } - // These exercise the expression-grouping-key rule directly, WITHOUT a proving WHERE clause. + // These exercise the expression-grouping-key rule directly, without a proving WHERE clause. // A bare-Var grouping key (e.g. GROUP BY ROLLUP(a)) also resolves via ordinary Var evaluation // on PostgreSQL 18 because the *GROUP* RTE masks it, but on PostgreSQL 16/17 the target-list // Var for that key sits at the same varno/varattno as the base table column, so this rule is - // needed even for bare keys there. An EXPRESSION grouping key (e.g. lower(a)) never produces a + // needed even for bare keys there. An expression grouping key (e.g. lower(a)) never produces a // {VAR } target-list entry at all — only walking the typed expression tree // (NodeTreeNullabilityAnalyzer.isSafeFromGroupingSetNullExtension) catches these on every // PostgreSQL version. @@ -1712,7 +1702,7 @@ class QueryAnalysisTest { @Test fun `constant-only CASE column under grouping sets is non-null — issue 240's Var-free immunity leg`() { // CASE WHEN true THEN 1 ELSE 2 END is never actually null-extended by Postgres (its leaves - // are all Const, which the planner never matches to a grouping key). Before issue #240's + // are all Const, which the planner never matches to a grouping key). Before the // immuneByNoGroupingKeyMatch leg, isSafeFromGroupingSetNullExtension required an // Aggref/GroupingFunc descendant to prove safety and this expression has none, so it widened // to nullable — an accepted, deliberate cost at the time. The new leg now recognizes it: the @@ -1731,7 +1721,7 @@ class QueryAnalysisTest { // JSON_EXISTS/JSON_VALUE/JSON_QUERY do not retain their PASSING clause's values (see // PgNodeExpression.JsonExpr's KDoc), so a Var living only there is invisible to // isSafeFromGroupingSetNullExtension's walk. Unlike a CaseExpr's dropped children, this one is - // not recovered by parsing more — it is closed by hardcoding JsonExpr as always UNSAFE, + // not recovered by parsing more — it is closed by hardcoding JsonExpr as always unsafe, // matching Unknown, regardless of an Aggref elsewhere in the tree. @Test @@ -1771,7 +1761,7 @@ class QueryAnalysisTest { // A `}`, `{`, or `\` in a column alias is backslash-escaped by Postgres in the raw node-tree // text (e.g. an alias `k}x` is written as `:resname k\}x`). PgNodeTreeParser's brace-counting // scanners must skip escaped pairs, or an escaped brace derails brace-depth tracking and - // hasGroupingSets silently returns false for the WHOLE query block — not just the offending + // hasGroupingSets silently returns false for the whole query block — not just the offending // column, since the scan that finds `:groupingSets` runs once over the entire node tree. @Test @@ -1812,7 +1802,7 @@ class QueryAnalysisTest { @Test fun `a brace in one column's alias does not poison a different column's nullability`() { - // The exact reported repro shape: the brace-bearing alias sits on the AGGREGATE column, and + // The exact reported repro shape: the brace-bearing alias sits on the aggregate column, and // the grouping key with no alias issue at all is a separate column — proving the derailment // is block-wide (hasGroupingSets), not confined to whichever column carries the brace. val query = analyzeWithSchema( @@ -1828,7 +1818,7 @@ class QueryAnalysisTest { // structural match against the grouping key's own (stable, non-folded) subexpression — a Const // key still gets null-extended, and so does any larger expression built on top of one, even // though a lone Const is itself immune (the planner never matches a bare Const). This is why - // isEffectivelyNonNull needs BOTH the sortGroupRef-based key check AND + // isEffectivelyNonNull needs both the sortGroupRef-based key check and // isSafeFromGroupingSetNullExtension's aggregate-domination walk — neither alone is sufficient. @Test @@ -1879,8 +1869,8 @@ class QueryAnalysisTest { // count(*)::text || ('2026-01-01'::text::timestamptz)::text: the '::text::timestamptz' cast // chain is a real, non-folded coercion function call (unlike a single literal cast, which // resolves directly to a Const), so it is a stable, matchable subexpression in its own right - // — even though its only leaf is a Const. Verified live: PG16 returns NULL for this column in - // the ROLLUP summary row; PG18 does not (a genuine cross-version behavioral divergence, not + // — even though its only leaf is a Const. PG16 returns NULL for this column in the ROLLUP + // summary row; PG18 does not (a genuine cross-version behavioral divergence, not // an analysis difference). Since the generated Kotlin type must be correct on every supported // Postgres version, this column must be nullable — which is an accepted over-widening on // PG18, not something to "fix" in the PG18 direction. @@ -1897,7 +1887,7 @@ class QueryAnalysisTest { assertThat(query.columns[1].notNull).isTrue() } - // The following must NOT widen: each has an Aggref/GroupingFunc dominating every part of its + // The following must not widen: each has an Aggref/GroupingFunc dominating every part of its // expression tree (modulo Consts), so isSafeFromGroupingSetNullExtension proves it immune to // null-extension, and none of them is itself a grouping key. @@ -1908,9 +1898,9 @@ class QueryAnalysisTest { // grouping-sets safety walk, not general operator-safelist coverage. 'B' (not 'A') on the // concatenation side deliberately differs from the 'A' grouping key: on PostgreSQL 18, the // GROUP RTE mechanism structurally substitutes a GROUP-RTE Var for any target-list - // subexpression that EXACTLY matches the grouping key — reusing 'A'::text here would trigger - // that substitution and make `d` genuinely nullable, verified live, which is a different, - // legitimate case, not this one. + // subexpression that exactly matches the grouping key — reusing 'A'::text here would trigger + // that substitution and make `d` genuinely nullable, a different, legitimate case, not this + // one. val query = analyzeWithSchema( "CREATE TABLE t (a TEXT NOT NULL)", "SELECT count(*)::text || 'B'::text AS d, 'A'::text AS k FROM t GROUP BY ROLLUP('A'::text)", @@ -1956,8 +1946,8 @@ class QueryAnalysisTest { // isSafeFromGroupingSetNullExtension's aggregate-domination rule alone over-widens: an implicit // int4->int8 literal like `0::bigint` is a FuncExpr over a Const, not a folded Const, so - // `count(*) + 0::bigint` has no Aggref descendant on ITS OWN unless the whole subtree is - // examined together — the domination check finds count(*)'s Aggref for the OUTER `+`, but the + // `count(*) + 0::bigint` has no Aggref descendant on its own unless the whole subtree is + // examined together — the domination check finds count(*)'s Aggref for the outer `+`, but the // literal side still needs to be proven safe independently via constant-folding // (isSafeFromGroupingSetNullExtension's foldsToConst leg), not via any aggregate. Without that // leg, ordinary aggregate arithmetic like `coalesce(count(*), 0)` was wrongly nullable. @@ -1985,15 +1975,15 @@ class QueryAnalysisTest { @Test fun `count plus an implicit widening cast of a literal stays non-null while a different literal is the key`() { - // The aggregate side deliberately uses a DIFFERENT literal (5, not 0) than the key (0): on - // PostgreSQL 18, using the SAME literal on both sides makes the GROUP RTE mechanism + // The aggregate side deliberately uses a different literal (5, not 0) than the key (0): on + // PostgreSQL 18, using the same literal on both sides makes the GROUP RTE mechanism // structurally substitute a nullingrels-flagged Var for the aggregate side's matching - // subexpression too — verified live this substitution does NOT actually make that combined - // value null at runtime (count(*) + 0::bigint is never null even in the summary row), but our - // analyzer cannot see through that PG18-only substitution artifact to know that, so it - // conservatively (and, for this exact same-literal case, needlessly) reports nullable. Using - // a different literal avoids the substitution entirely and tests the real feature under test - // — aggregate arithmetic over an implicit-cast literal — without hitting that artifact. + // subexpression too. That substitution does not actually make the combined value null at + // runtime (count(*) + 0::bigint is never null even in the summary row), but our analyzer + // cannot see through that PG18-only substitution artifact to know that, so it conservatively + // (and, for this exact same-literal case, needlessly) reports nullable. Using a different + // literal avoids the substitution entirely and tests the real feature under test — aggregate + // arithmetic over an implicit-cast literal — without hitting that artifact. val query = analyzeWithSchema( "CREATE TABLE t (a TEXT NOT NULL)", "SELECT count(*) + 5::bigint AS c, 0::bigint AS k FROM t GROUP BY ROLLUP((0::bigint))", @@ -2080,10 +2070,10 @@ class QueryAnalysisTest { @Test fun `a window function whose own argument is not dominated by an aggregate stays nullable`() { - // Regression guard for the WindowFunc special case: it must NOT get Aggref's blanket safety. + // Regression guard for the WindowFunc special case: it must not get Aggref's blanket safety. // first_value(b)'s argument is a bare Var — evaluated over already-grouped, null-extended - // rows — so it is genuinely nullable even though b is NOT NULL. Verified live: all three - // ROLLUP(b) rows return NULL for this column on both PG16 and PG18. + // rows — so it is genuinely nullable even though b is NOT NULL. All three ROLLUP(b) rows + // return NULL for this column on both PG16 and PG18. val query = analyzeWithSchema( "CREATE TABLE t2 (b TEXT NOT NULL, c INT NOT NULL)", "SELECT first_value(b) OVER (ORDER BY grouping(b) DESC) AS fv FROM t2 GROUP BY ROLLUP(b)", @@ -2094,10 +2084,10 @@ class QueryAnalysisTest { @Test fun `a folded-constant expression that IS the grouping key is still nullable via rule (b)`() { - // Proves the foldsToConst leg did not break the sortGroupRef-based key check: lower('X'::text) - // folds to a Const (lower is IMMUTABLE), which would make it "safe" under foldsToConst alone — - // but it is also the literal grouping key, so rule (b) must still force it nullable. Verified - // live: NULL in the ROLLUP summary row. + // The foldsToConst leg must not break the sortGroupRef-based key check: lower('X'::text) + // folds to a Const (lower is IMMUTABLE), which would make it "safe" under foldsToConst alone + // — but it is also the literal grouping key, so rule (b) must still force it nullable. NULL + // in the ROLLUP summary row. val query = analyzeWithSchema( "CREATE TABLE t (a TEXT NOT NULL)", "SELECT lower('X'::text) AS k, count(*) AS n FROM t GROUP BY ROLLUP(lower('X'::text))", @@ -2110,9 +2100,9 @@ class QueryAnalysisTest { @Test fun `concat stays non-null under a nullable ROLLUP key because it is on the always-non-null list`() { // concat is non-strict — it renders a null argument as an empty string — so null-extending - // one of its arguments cannot make the result null. Verified live on PG16, 17, and 18: never - // NULL for the plain ROLLUP case, for a LEFT JOIN that null-extends one argument, and for an - // empty input table. + // one of its arguments cannot make the result null. PostgreSQL 16-18: never NULL for the + // plain ROLLUP case, for a LEFT JOIN that null-extends one argument, and for an empty input + // table. val query = analyzeWithSchema( "CREATE TABLE t2 (a TEXT NOT NULL, b TEXT NOT NULL)", "SELECT concat(a, '-', b) AS label, count(*) AS n FROM t2 GROUP BY ROLLUP(a, b)", @@ -2125,11 +2115,10 @@ class QueryAnalysisTest { @Test fun `concat_ws with a null-extended separator argument is nullable — the P0 regression`() { // concat_ws's non-nullness depends on its separator (first argument) specifically, not on - // whether SOME argument is non-null — unlike concat, it must NOT get the always-non-null - // short-circuit. Here the separator IS the ROLLUP key, so it is null-extended for the - // summary row, and concat_ws(NULL, 'x', 'y') really is NULL there (verified live on - // PostgreSQL 16, 17, and 18). Before this fix, concat_ws's presence on the always-non-null - // list made this wrongly non-null. + // whether some argument is non-null — unlike concat, it must not get the always-non-null + // short-circuit. Here the separator is the ROLLUP key, so it is null-extended for the + // summary row, and concat_ws(NULL, 'x', 'y') really is NULL there (PostgreSQL 16-18). Before + // this fix, concat_ws's presence on the always-non-null list made this wrongly non-null. val query = analyzeWithSchema( "CREATE TABLE t2 (a TEXT NOT NULL, b TEXT NOT NULL)", "SELECT concat_ws(a, 'x', 'y') AS k, count(*) AS n FROM t2 GROUP BY ROLLUP(a)", @@ -2141,15 +2130,14 @@ class QueryAnalysisTest { @Test fun `concat_ws with a literal separator over a null-extended later argument is non-null — issue 240 shape 7`() { - // Verified live on PostgreSQL 16, 17, and 18: concat_ws(',', a, b) is NEVER null under - // GROUP BY ROLLUP(a, b), including the fully null-extended summary row — a non-null literal - // separator makes concat_ws total regardless of the other (even null-extended) arguments. - // Before the issue #240 fix, this analyzer reported it nullable: the grouping-sets safety - // gate's generic rule requires an Aggref/GroupingFunc/WindowFunc descendant to prove safety, - // and this expression has none. isSafeFromGroupingSetNullExtension's new - // isNonNullIffFirstArgumentNonNull leg now recognizes that concat_ws's result depends ONLY on - // its first (separator) argument, which here is a Const — safe regardless of the other, - // null-extendable arguments. + // PostgreSQL 16-18: concat_ws(',', a, b) is never null under GROUP BY ROLLUP(a, b), + // including the fully null-extended summary row — a non-null literal separator makes + // concat_ws total regardless of the other (even null-extended) arguments. Before this fix, + // this analyzer reported it nullable: the grouping-sets safety gate's generic rule requires + // an Aggref/GroupingFunc/WindowFunc descendant to prove safety, and this expression has + // none. isSafeFromGroupingSetNullExtension's new isNonNullIffFirstArgumentNonNull leg now + // recognizes that concat_ws's result depends only on its first (separator) argument, which + // here is a Const — safe regardless of the other, null-extendable arguments. val query = analyzeWithSchema( "CREATE TABLE t2 (a TEXT NOT NULL, b TEXT NOT NULL)", "SELECT concat_ws(',', a, b) AS k, count(*) AS n FROM t2 GROUP BY ROLLUP(a, b)", @@ -2161,11 +2149,10 @@ class QueryAnalysisTest { @Test fun `concat with VARIADIC over a null-extended NOT NULL array ROLLUP key is nullable`() { - // arr is NOT NULL by schema, but IS the ROLLUP key, so it is null-extended for the summary - // row, and concat(VARIADIC arr) really is NULL there — verified live on PostgreSQL 16, 17, - // and 18. Before this fix, concat's VARIADIC form was wrongly reported non-null via the - // isAlwaysNonNull short-circuit, which is unconditional and does not (and cannot) inspect - // arguments at all. + // arr is NOT NULL by schema, but is the ROLLUP key, so it is null-extended for the summary + // row, and concat(VARIADIC arr) really is NULL there (PostgreSQL 16-18). Before this fix, + // concat's VARIADIC form was wrongly reported non-null via the isAlwaysNonNull short-circuit, + // which is unconditional and does not (and cannot) inspect arguments at all. val query = analyzeWithSchema( "CREATE TABLE t4 (arr TEXT[] NOT NULL)", "SELECT concat(VARIADIC arr) AS c, count(*) AS n FROM t4 GROUP BY ROLLUP(arr)", @@ -2178,16 +2165,16 @@ class QueryAnalysisTest { @Test fun `duplicate concat call that IS the ROLLUP key is nullable in both occurrences`() { // GROUP BY ROLLUP(concat(a, '-')) makes concat(a, '-') itself the grouping key. PostgreSQL's - // grouping-set null-extension (setrefs.c) assigns :ressortgroupref to only ONE of the two - // syntactically-identical target-list entries (verified live: the first), but its - // null-extension matching is STRUCTURAL, not ressortgroupref-keyed — it null-extends every - // target-list entry that structurally equals the grouping key, including the duplicate with - // ressortgroupref 0. Verified live (PostgreSQL 16 and 17): both x and y are NULL in the - // ROLLUP summary row, alongside 'p-'/'p-' and 'q-'/'q-' in the per-group rows. Before this - // fix, the isAlwaysNonNull short-circuit for concat wrongly reported y non-null: that - // short-circuit only proves concat tolerates a null ARGUMENT (it renders one as an empty - // string), which says nothing about the case at hand, where the entire concat(...) call is - // itself null-extended wholesale and never evaluated at all. + // grouping-set null-extension (setrefs.c) assigns :ressortgroupref to only one of the two + // syntactically-identical target-list entries (the first), but its null-extension matching + // is structural, not ressortgroupref-keyed — it null-extends every target-list entry that + // structurally equals the grouping key, including the duplicate with ressortgroupref 0. + // PostgreSQL 16-17: both x and y are NULL in the ROLLUP summary row, alongside 'p-'/'p-' and + // 'q-'/'q-' in the per-group rows. Before this fix, the isAlwaysNonNull short-circuit for + // concat wrongly reported y non-null: that short-circuit only proves concat tolerates a null + // argument (it renders one as an empty string), which says nothing about the case at hand, + // where the entire concat(...) call is itself null-extended wholesale and never evaluated at + // all. val query = analyzeWithSchema( "CREATE TABLE gs_t (a TEXT NOT NULL, b INT NOT NULL)", "SELECT concat(a, '-') AS x, concat(a, '-') AS y, count(*) AS c FROM gs_t GROUP BY ROLLUP(concat(a, '-'))", @@ -2200,12 +2187,12 @@ class QueryAnalysisTest { @Test fun `concat over a bare-Var ROLLUP key stays non-null, unlike the identical-expression-as-key case above`() { - // GROUP BY ROLLUP(a): the grouping key is the bare column a, NOT concat(a, '-') itself, so + // GROUP BY ROLLUP(a): the grouping key is the bare column a, not concat(a, '-') itself, so // concat(a, '-') is never a candidate for whole-expression substitution — only the nested a // reference can be null-extended, and concat tolerates that (renders it as an empty string). - // Verified live (PostgreSQL 16, 17, and 18): 'p-'/'q-' per group, '-' (never NULL) in the - // ROLLUP summary row. This is the positive control the fix for the case above must not - // over-correct: concat's isAlwaysNonNull short-circuit must still apply here. + // PostgreSQL 16-18: 'p-'/'q-' per group, '-' (never NULL) in the ROLLUP summary row. This is + // the positive control the fix for the case above must not over-correct: concat's + // isAlwaysNonNull short-circuit must still apply here. val query = analyzeWithSchema( "CREATE TABLE gs_t (a TEXT NOT NULL, b INT NOT NULL)", "SELECT concat(a, '-') AS z, count(*) AS c FROM gs_t GROUP BY ROLLUP(a)", @@ -2217,20 +2204,19 @@ class QueryAnalysisTest { @Test fun `duplicate bare Const grouping key stays non-null in the un-ref'd occurrence`() { - // GROUP BY ROLLUP('ALL') matches the FIRST 'ALL'::text occurrence (l1) by ressortgroupref. - // Verified live on EVERY supported version (16, 17, and 18): l1 is NULL, l2 stays 'ALL', in - // the ROLLUP summary row — PostgreSQL's structural null-extension (setrefs.c) explicitly - // refuses to match a bare Const node at all (see isSafeFromGroupingSetNullExtension's - // KDoc), so the un-ref'd duplicate l2 is genuinely never NULL on any version. This pins the - // asymmetry groupingKeyExpressions' Const/foldsToConst exclusion depends on — including l2 - // in that set would wrongly force it nullable. + // GROUP BY ROLLUP('ALL') matches the first 'ALL'::text occurrence (l1) by ressortgroupref. + // PostgreSQL 16-18: l1 is NULL, l2 stays 'ALL', in the ROLLUP summary row — PostgreSQL's + // structural null-extension (setrefs.c) explicitly refuses to match a bare Const node at all + // (see isSafeFromGroupingSetNullExtension's KDoc), so the un-ref'd duplicate l2 is genuinely + // never NULL on any version. This pins the asymmetry groupingKeyExpressions' + // Const/foldsToConst exclusion depends on — including l2 in that set would wrongly force it + // nullable. // - // Unconditional across every supported version: the GroupRteSubstitution fix restores the - // PostgreSQL 16/17 tree shape on PostgreSQL 18 too — both l1 and l2 arrive at - // groupingKeyExpressions as the genuine bare Const '"ALL"' PostgreSQL 16/17 always showed - // directly, not a Var referencing the synthesized "*GROUP*" RTE, so the Const-vs-key - // distinction this exclusion depends on is available on every version, not lost to PG18's - // pre-resolution. + // Holds on every supported version: the GroupRteSubstitution fix restores the PostgreSQL + // 16/17 tree shape on PostgreSQL 18 too — both l1 and l2 arrive at groupingKeyExpressions as + // the genuine bare Const '"ALL"' PostgreSQL 16/17 always showed directly, not a Var + // referencing the synthesized "*GROUP*" RTE, so the Const-vs-key distinction this exclusion + // depends on is available on every version, not lost to PG18's pre-resolution. val query = analyzeWithSchema( "CREATE TABLE gs_t (a TEXT NOT NULL, b INT NOT NULL)", "SELECT 'ALL'::text AS l1, 'ALL'::text AS l2, count(*) AS c FROM gs_t GROUP BY ROLLUP('ALL'::text)", @@ -2244,17 +2230,16 @@ class QueryAnalysisTest { @Test fun `duplicate IMMUTABLE-folding call stays non-null in the un-ref'd occurrence`() { // upper('a') is IMMUTABLE over a literal argument, so PostgreSQL's early constant folding - // (eval_const_expressions, well before setrefs.c's null-extension substitution) reduces - // BOTH occurrences to a bare Const('A') before the substitution pass ever runs. Verified - // live on EVERY supported version (16, 17, and 18): u1 is NULL, u2 stays 'A', in the ROLLUP - // summary row — u2 is genuinely never NULL on any version (unlike a STABLE call over the - // same shape, which does NOT fold and IS null-extended in both occurrences — see the - // date_trunc case below). + // (eval_const_expressions, well before setrefs.c's null-extension substitution) reduces both + // occurrences to a bare Const('A') before the substitution pass ever runs. PostgreSQL 16-18: + // u1 is NULL, u2 stays 'A', in the ROLLUP summary row — u2 is genuinely never NULL on any + // version (unlike a STABLE call over the same shape, which does not fold and is + // null-extended in both occurrences — see the date_trunc case below). // - // Unconditional across every supported version — same reasoning as the bare-Const case - // above: the GroupRteSubstitution fix resolves both u1 and u2's target-list Var back to the - // real upper('a') FuncExpr before groupingKeyExpressions ever runs, so both fold to the same - // bare Const('A') on PostgreSQL 18 exactly as they always did on 16/17. + // Holds on every supported version — same reasoning as the bare-Const case above: the + // GroupRteSubstitution fix resolves both u1 and u2's target-list Var back to the real + // upper('a') FuncExpr before groupingKeyExpressions ever runs, so both fold to the same bare + // Const('A') on PostgreSQL 18 exactly as they always did on 16/17. val query = analyzeWithSchema( "CREATE TABLE gs_t (a TEXT NOT NULL, b INT NOT NULL)", "SELECT upper('a') AS u1, upper('a') AS u2, count(*) AS c FROM gs_t GROUP BY ROLLUP(upper('a'))", @@ -2268,20 +2253,20 @@ class QueryAnalysisTest { @Test fun `duplicate STABLE call is null-extended in BOTH occurrences — the sharpest proof matching is structural`() { // date_trunc('month', current_date) is STABLE, not IMMUTABLE, so — unlike upper('a') above — - // it survives constant folding and remains a genuine, matchable subexpression when setrefs.c's - // null-extension substitution runs. Since that substitution matches STRUCTURALLY rather than - // by ressortgroupref, it null-extends d1 (the ref'd occurrence) AND d2 (the un-ref'd - // duplicate) alike — this is the same mechanism, applied to a non-Const, non-folding - // expression, that the concat bug this fix addresses exploited. Verified live (PostgreSQL 16 - // and 17): BOTH d1 and d2 are NULL in the ROLLUP summary row. + // it survives constant folding and remains a genuine, matchable subexpression when + // setrefs.c's null-extension substitution runs. Since that substitution matches structurally + // rather than by ressortgroupref, it null-extends d1 (the ref'd occurrence) and d2 (the + // un-ref'd duplicate) alike — this is the same mechanism, applied to a non-Const, non-folding + // expression, that the concat bug this fix addresses exploited. PostgreSQL 16-17: both d1 + // and d2 are NULL in the ROLLUP summary row. // - // PostgreSQL 18's own RUNTIME actually diverges here (verified live): only d1 (the entry - // PostgreSQL assigned :ressortgroupref to) is NULL in the summary row; d2 keeps its real - // value. Norm deliberately does NOT chase that: after GroupRteSubstitution, d2's target-list - // Var resolves to the SAME structural expression as d1's — indistinguishable from the - // ressortgroupref-carrying occurrence once resolved — so d2 is reported nullable on every - // version, including 18. This is a safe, accepted over-widening (matches the true PG16/17 - // runtime, and is never wrong in the PG18-confident-NOT-NULL direction), not an oversight. + // PostgreSQL 18's own runtime actually diverges here: only d1 (the entry PostgreSQL assigned + // :ressortgroupref to) is NULL in the summary row; d2 keeps its real value. Norm deliberately + // does not chase that: after GroupRteSubstitution, d2's target-list Var resolves to the same + // structural expression as d1's — indistinguishable from the ressortgroupref-carrying + // occurrence once resolved — so d2 is reported nullable on every version, including 18. This + // is a safe, accepted over-widening (matches the true PG16/17 runtime, and is never wrong in + // the PG18-confident-NOT-NULL direction), not an oversight. val query = analyzeWithSchema( "CREATE TABLE gs_t (a TEXT NOT NULL, b INT NOT NULL)", """ @@ -2301,10 +2286,10 @@ class QueryAnalysisTest { // random() is VOLATILE, yet it is still matched and null-extended in both occurrences — // proving the null-extension substitution is a pure structural-equality check // (PostgreSQL's equal()), never a volatility check that would otherwise justify treating a - // VOLATILE call as immune to whole-expression substitution. Verified live (PostgreSQL 16 and - // 17): both r1 and r2 are NULL in the ROLLUP summary row, and in the per-group rows, r1 and - // r2 hold the SAME value within a row (the executor evaluates the target list once per row, - // not once per occurrence). + // VOLATILE call as immune to whole-expression substitution. PostgreSQL 16-17: both r1 and r2 + // are NULL in the ROLLUP summary row, and in the per-group rows, r1 and r2 hold the same + // value within a row (the executor evaluates the target list once per row, not once per + // occurrence). val query = analyzeWithSchema( "CREATE TABLE gs_t (a TEXT NOT NULL, b INT NOT NULL)", "SELECT random() AS r1, random() AS r2, count(*) AS c FROM gs_t GROUP BY ROLLUP(random())", @@ -2319,8 +2304,8 @@ class QueryAnalysisTest { fun `aggregate domination — count(*) + 1 over a nullable ROLLUP key is never null`() { // count(*) never returns null (it has a non-null initial transition value), and the OpExpr // wrapping it (+ 1) is on the isNeverNullForNonNullInput safe-list, so n is provably - // non-null regardless of a itself being nullable in the ROLLUP summary row. Verified live - // (PostgreSQL 16 and 17): n is 3 in the summary row, never NULL. + // non-null regardless of a itself being nullable in the ROLLUP summary row. PostgreSQL + // 16-17: n is 3 in the summary row, never NULL. val query = analyzeWithSchema( "CREATE TABLE gs_t (a TEXT NOT NULL, b INT NOT NULL)", "SELECT count(*) + 1 AS n FROM gs_t GROUP BY ROLLUP(a)", @@ -2329,15 +2314,15 @@ class QueryAnalysisTest { assertThat(query.columns[0].notNull).isTrue() } - // GitHub issue #240: GROUPING SETS/CUBE/ROLLUP over-widening. isSafeFromGroupingSetNullExtension - // gained a self-match guard (a nested occurrence of the grouping key is unsafe at ANY depth, not - // merely at the target-list entry's own root) plus new legs for node kinds isNonNull already - // proves are argument-nullability-independent (isNonNullIffFirstArgumentNonNull FuncExpr, + // GROUPING SETS/CUBE/ROLLUP over-widening. isSafeFromGroupingSetNullExtension gained a + // self-match guard (a nested occurrence of the grouping key is unsafe at any depth, not merely + // at the target-list entry's own root) plus new legs for node kinds isNonNull already proves + // are argument-nullability-independent (isNonNullIffFirstArgumentNonNull FuncExpr, // xmlelement/xmlforest/processing-instruction XmlExpr, JSON_OBJECT/JSON_ARRAY // JsonConstructorExpr, SqlValueFunction, NextValExpr) and a Var-free/no-structural-match leg for // constructs with no per-node-kind rule at all (e.g. now()). See NodeTreeNullabilityAnalyzer's - // own KDoc and PLAN-240.md's "Measured ground truth" table for the full soundness argument and - // live verification (PostgreSQL 16, 17, and 18) behind every test below. + // own KDoc for the soundness argument. Every test below was checked on PostgreSQL 16, 17, + // and 18. @Test fun `now() is non-null under a ROLLUP key it is not itself part of — issue 240 shape 1`() { @@ -2374,8 +2359,8 @@ class QueryAnalysisTest { @Test fun `xmlforest under a ROLLUP key is nullable — XMLFOREST is not total over null fields`() { - // Live on PostgreSQL 16, 17 and 18: xmlforest(NULL::text AS q) IS NULL, so null-extending the - // only field nulls the whole forest in the ROLLUP summary row. + // PostgreSQL 16-18: xmlforest(NULL::text AS q) IS NULL, so null-extending the only field + // nulls the whole forest in the ROLLUP summary row. val query = analyzeWithSchema( "CREATE TABLE t2 (a TEXT NOT NULL)", "SELECT xmlforest(lower(a) AS q) AS x, count(*) AS c FROM t2 GROUP BY ROLLUP(a)", @@ -2386,7 +2371,7 @@ class QueryAnalysisTest { @Test fun `xmlpi under a ROLLUP key is nullable — a null content expression nulls the result`() { - // Live on PostgreSQL 16, 17 and 18: xmlpi(name php, NULL::text) IS NULL. + // PostgreSQL 16-18: xmlpi(name php, NULL::text) IS NULL. val query = analyzeWithSchema( "CREATE TABLE t2 (a TEXT NOT NULL)", "SELECT xmlpi(name php, lower(a)) AS x, count(*) AS c FROM t2 GROUP BY ROLLUP(a)", @@ -2407,12 +2392,12 @@ class QueryAnalysisTest { @Test fun `a nested occurrence of the grouping key inside an always-non-null call is nullable — the self-match guard`() { - // Measured at HEAD (PLAN-240.md, PostgreSQL 16 and 18): before the self-match guard, the - // OLD walk reached concat(a, b) — a child of the || operator, itself IDENTICAL to the - // grouping key — and let isAlwaysNonNull("concat") rescue it outright, wrongly reporting - // notNull = true even though live PostgreSQL returns NULL for label in the ROLLUP summary - // row. The guard now checks every subexpression the walk reaches against - // groupingKeyExpressions, not merely the target-list entry's own root. + // PostgreSQL 16 and 18: before the self-match guard, the old walk reached + // concat(a, b) — a child of the || operator, itself identical to the grouping key — and let + // isAlwaysNonNull("concat") rescue it outright, wrongly reporting notNull = true even though + // live PostgreSQL returns NULL for label in the ROLLUP summary row. The guard now checks + // every subexpression the walk reaches against groupingKeyExpressions, not merely the + // target-list entry's own root. val query = analyzeWithSchema( "CREATE TABLE t2 (a TEXT NOT NULL, b TEXT NOT NULL)", "SELECT count(*)::text || concat(a, b) AS label FROM t2 GROUP BY ROLLUP(concat(a, b))", @@ -2424,9 +2409,9 @@ class QueryAnalysisTest { @Test fun `now() as its own ROLLUP grouping key stays nullable despite the new Var-free immunity leg`() { // Negative control for the new immuneByNoGroupingKeyMatch leg: now() is Var-free and would - // otherwise qualify, but here it IS the grouping key itself, so the self-match guard at the - // top of isSafeFromGroupingSetNullExtension must still force it nullable. Verified live - // (PostgreSQL 16 and 18): NULL in the ROLLUP summary row. + // otherwise qualify, but here it is the grouping key itself, so the self-match guard at the + // top of isSafeFromGroupingSetNullExtension must still force it nullable. PostgreSQL 16 and + // 18: NULL in the ROLLUP summary row. val query = analyzeWithSchema( "CREATE TABLE t2 (a TEXT NOT NULL)", "SELECT now() AS n, count(*) AS c FROM t2 GROUP BY ROLLUP(now())", @@ -2440,8 +2425,8 @@ class QueryAnalysisTest { fun `concat_ws whose separator is the grouping key stays nullable despite the new leg`() { // Negative control for the new isNonNullIffFirstArgumentNonNull leg: the separator argument // (concat(a, b)) is itself the grouping key, so the leg must recurse into it — and the - // self-match guard on that recursive call is what forces the result nullable. Verified live - // (PostgreSQL 16 and 18): NULL in the ROLLUP summary row. + // self-match guard on that recursive call is what forces the result nullable. PostgreSQL 16 + // and 18: NULL in the ROLLUP summary row. val query = analyzeWithSchema( "CREATE TABLE t2 (a TEXT NOT NULL, b TEXT NOT NULL)", "SELECT concat_ws(concat(a, b), 'x', 'y') AS label, count(*) AS n FROM t2 GROUP BY ROLLUP(concat(a, b))", @@ -2456,7 +2441,7 @@ class QueryAnalysisTest { // Negative control for the new immuneByNoGroupingKeyMatch leg: now() is a subexpression of // now() || 'x', matching groupingKeyExpressions, so condition 3 must force the whole // expression nullable even though now() || 'x' itself is Var-free. PostgreSQL 16 - // null-extends this live; PostgreSQL 18 does not (a genuine cross-version divergence) — this + // null-extends this; PostgreSQL 18 does not (a genuine cross-version divergence) — this // analyzer reports nullable on every version, the only answer that is never wrong. val query = analyzeWithSchema( "CREATE TABLE t2 (a TEXT NOT NULL)", @@ -2467,14 +2452,14 @@ class QueryAnalysisTest { assertThat(query.columns[1].notNull).isTrue() } - // GitHub issue #241: on PostgreSQL 18, an RTE_GROUP range-table entry (:rtekind 9, alias - // *GROUP*) rewrites EVERY target-list occurrence of a grouping-key expression — not merely the - // one entry PostgreSQL assigns :ressortgroupref to — into a bare Var referencing that RTE. Prior - // to the GroupRteSubstitution fix, the aggregate side of these two queries (c below) fell - // through to "source column not found" for that buried Var and was wrongly reported nullable on - // PostgreSQL 18 whenever the SAME literal/alias appeared on both the aggregate and the key side - // — exactly what these two tests use, unlike the pre-existing tests elsewhere in this class that - // deliberately used a DIFFERENT literal to sidestep the bug. These are the issue's own repro + // On PostgreSQL 18, an RTE_GROUP range-table entry (:rtekind 9, alias *GROUP*) rewrites every + // target-list occurrence of a grouping-key expression — not merely the one entry PostgreSQL + // assigns :ressortgroupref to — into a bare Var referencing that RTE. Prior to the + // GroupRteSubstitution fix, the aggregate side of these two queries (c below) fell through to + // "source column not found" for that buried Var and was wrongly reported nullable on + // PostgreSQL 18 whenever the same literal/alias appeared on both the aggregate and the key + // side — exactly what these two tests use, unlike the pre-existing tests elsewhere in this + // class that deliberately used a different literal to sidestep the bug. These are the repro // shapes and must be unconditionally correct on every supported PostgreSQL version. @Test @@ -2501,11 +2486,10 @@ class QueryAnalysisTest { @Test fun `a plain GROUP BY on a constant-folding key is non-null — no ROLLUP, still a GROUP RTE on PostgreSQL 18`() { - // A GROUP RTE is created for a PLAIN GROUP BY too, not only GROUPING SETS/CUBE/ROLLUP - // (verified live). hasGroupingSets is false here — parseGroupRteMap alone cannot resolve this - // key, because its groupexprs entry is a FUNCEXPR/CONST, not a bare VAR (see that method's - // KDoc) — so this exercises the substitution fix on the code path GROUPING SETS tests never - // touch. + // A GROUP RTE is created for a plain GROUP BY too, not only GROUPING SETS/CUBE/ROLLUP. + // hasGroupingSets is false here — parseGroupRteMap alone cannot resolve this key, because + // its groupexprs entry is a FUNCEXPR/CONST, not a bare VAR (see that method's KDoc) — so + // this exercises the substitution fix on the code path GROUPING SETS tests never touch. val query = analyzeWithSchema( "CREATE TABLE t (a TEXT NOT NULL)", "SELECT 0::bigint AS k, count(*) AS n FROM t GROUP BY 0::bigint", @@ -2517,13 +2501,14 @@ class QueryAnalysisTest { @Test fun `plain GROUP BY on the nullable side of a LEFT JOIN is nullable — the wrong-NOT-NULL regression`() { - // Before the GroupRteSubstitution fix, this was a CONFIDENTLY WRONG NOT NULL on PostgreSQL 18, - // not merely an over-widening: the target-list Var wrapping the GROUP RTE reference carries an - // EMPTY :varnullingrels (PostgreSQL does not propagate the outer join's nulling relations onto - // it), while parseGroupRteMap's coarser VAR-only resolution maps it back to the base column by - // (varno, varattno) alone and discards the GROUP RTE's OWN :groupexprs entry — the one that - // actually carries the correct, non-empty nulling relations from the LEFT JOIN. x is NOT NULL - // by schema, but the join can still leave it absent for an unmatched t row. + // Before the GroupRteSubstitution fix, this was a confidently wrong NOT NULL on PostgreSQL + // 18, not merely an over-widening: the target-list Var wrapping the GROUP RTE reference + // carries an empty :varnullingrels (PostgreSQL does not propagate the outer join's nulling + // relations onto it), while parseGroupRteMap's coarser VAR-only resolution maps it back to + // the base column by (varno, varattno) alone and discards the GROUP RTE's own :groupexprs + // entry — the one that actually carries the correct, non-empty nulling relations from the + // LEFT JOIN. x is NOT NULL by schema, but the join can still leave it absent for an unmatched + // t row. val query = analyzeWithSchema( """ CREATE TABLE t (id INT PRIMARY KEY, a TEXT NOT NULL); @@ -2553,23 +2538,23 @@ class QueryAnalysisTest { @Test fun `a second ROLLUP key at varattno 2 resolves its own groupexprs entry, not the first key's`() { // Two grouping keys means the GROUP RTE's :groupexprs has two entries: index 0 for u.x (the - // FIRST key, join-nullable), index 1 for the literal 5::bigint (the SECOND key, always - // non-null). c's aggregate arithmetic reuses the literal 5, so PostgreSQL rewrites THAT + // first key, join-nullable), index 1 for the literal 5::bigint (the second key, always + // non-null). c's aggregate arithmetic reuses the literal 5, so PostgreSQL rewrites that // occurrence to a Var whose :varattno is 2 (1-based) — resolving it correctly requires // picking groupexprs[1] (the Const), not groupexprs[0] (u.x). A duplicate-of-a-raw-Var // detection shortcut (matching k1's own un-substituted Var structurally) cannot rescue this // case the way it does for a bare duplicate key — c's Var sits inside an OpExpr, a - // structurally DIFFERENT shape from k1's bare Var, so only correct varattno-to-list-index - // resolution can prove c non-null. The columns[0] (c) assertion is the ONLY one that + // structurally different shape from k1's bare Var, so only correct varattno-to-list-index + // resolution can prove c non-null. The columns[0] (c) assertion is the only one that // discriminates an off-by-one or reversed-order bug in either the parser or the substitution: - // such a bug would resolve c's buried Var to u.x instead of the Const — genuinely nullable via - // the LEFT JOIN — and wrongly report c nullable instead of non-null. columns[1] (k1) is NOT a - // discriminator despite also referencing u.x: k1 IS the grouping key itself (its own + // such a bug would resolve c's buried Var to u.x instead of the Const — genuinely nullable + // via the LEFT JOIN — and wrongly report c nullable instead of non-null. columns[1] (k1) is + // not a discriminator despite also referencing u.x: k1 is the grouping key itself (its own // :ressortgroupref is 1, in groupingSortGroupRefs), so isEffectivelyNonNull's first condition - // (NodeTreeNullabilityAnalyzer.kt's groupingSortGroupRefs check) forces it nullable before its - // substituted expression is ever consulted — an off-by-one bug could resolve k1 to the WRONG - // groupexprs entry and it would still come out nullable regardless. columns[1]/[2] are kept as - // controls (proving both keys are still correctly nullable), not because they catch this bug. + // (NodeTreeNullabilityAnalyzer.kt's groupingSortGroupRefs check) forces it nullable before + // its substituted expression is ever consulted — an off-by-one bug could resolve k1 to the + // wrong groupexprs entry and it would still come out nullable regardless. columns[1]/[2] are + // kept as controls (both keys are still correctly nullable), not because they catch this bug. val query = analyzeWithSchema( """ CREATE TABLE t (id INT PRIMARY KEY, a TEXT NOT NULL); @@ -2618,10 +2603,10 @@ class QueryAnalysisTest { @Test fun `the key column and a derived expression over it both stay nullable`() { - // k is the grouping key itself (sortGroupRef match); d is a DIFFERENT expression built on top + // k is the grouping key itself (sortGroupRef match); d is a different expression built on top // of the same key (upper(lower(a)), not lower(a) itself) — d's own sortGroupRef is 0 and it - // only matches structurally, deep inside upper(...), which only isSafeFromGroupingSetNullExtension's - // tree walk over the SUBSTITUTED expression can catch. + // only matches structurally, deep inside upper(...), which only + // isSafeFromGroupingSetNullExtension's tree walk over the substituted expression can catch. val query = analyzeWithSchema( "CREATE TABLE t (a TEXT NOT NULL)", "SELECT lower(a) AS k, upper(lower(a)) AS d, count(*) AS c FROM t GROUP BY ROLLUP(lower(a))", @@ -2791,10 +2776,10 @@ class QueryAnalysisTest { @Test fun `ANY sublink over a subquery with a nullable column is nullable even with a non-null outer operand`() { - // Issue #239's own repro, verified live against PostgreSQL 17: `a = ANY (SELECT v FROM u)` is - // NULL, not FALSE, when u.v is nullable, u has a row whose v IS NULL, and no row matches — - // three-valued logic. Before this fix, the old ANY branch only checked the outer operand's - // own nullability and reported this NOT NULL, which is wrong. + // PostgreSQL 17: `a = ANY (SELECT v FROM u)` is NULL, not FALSE, when u.v is nullable, u has + // a row whose v IS NULL, and no row matches — three-valued logic. Before this fix, the old + // ANY branch only checked the outer operand's own nullability and reported this NOT NULL, + // which is wrong. val query = analyzeWithSchema( "CREATE TABLE t (id INT PRIMARY KEY, a TEXT NOT NULL); CREATE TABLE u (v TEXT)", "SELECT a = ANY (SELECT v FROM u) AS result FROM t", @@ -2805,15 +2790,15 @@ class QueryAnalysisTest { @Test fun `ANY sublink whose outer operand is itself a SUBLINK does not shadow the outer subselect`() { - // Live-verified repro (PostgreSQL 17 and 18): `SELECT EXISTS (SELECT v FROM u) = ANY (SELECT - // b FROM x) FROM t` returns NULL when x.b is nullable, u.v is NOT NULL, and no row of x - // matches — the same three-valued ANY_SUBLINK logic as the issue's own repro. This pins the - // P0 fix specifically: the outer ANY_SUBLINK's :testexpr contains the nested EXISTS - // sublink's own :subselect (over u), which textually precedes the outer sublink's OWN - // :subselect (over x). Before extractFieldExpression became depth-one-aware, a raw - // first-match scan for ":subselect {" found u's block (NOT NULL) instead of x's (nullable), - // reporting this NOT NULL — the control below (u nullable too) would have flipped the wrong - // way if that bug were still present, proving it reads x.b, not u.v. + // PostgreSQL 17 and 18: `SELECT EXISTS (SELECT v FROM u) = ANY (SELECT b FROM x) FROM t` + // returns NULL when x.b is nullable, u.v is NOT NULL, and no row of x matches — the same + // three-valued ANY_SUBLINK logic as the repro above. This pins the fix specifically: the + // outer ANY_SUBLINK's :testexpr contains the nested EXISTS sublink's own :subselect (over + // u), which textually precedes the outer sublink's own :subselect (over x). Before + // extractFieldExpression became depth-one-aware, a raw first-match scan for ":subselect {" + // found u's block (NOT NULL) instead of x's (nullable), reporting this NOT NULL — the + // control below (u nullable too) would have flipped the wrong way if that bug were still + // present, proving it reads x.b, not u.v. val query = analyzeWithSchema( "CREATE TABLE t (id INT NOT NULL); CREATE TABLE u (v BOOLEAN NOT NULL); CREATE TABLE x (b BOOLEAN)", "SELECT EXISTS (SELECT v FROM u) = ANY (SELECT b FROM x) AS result FROM t", @@ -2869,9 +2854,9 @@ class QueryAnalysisTest { @Test fun `correlated ANY sublink whose subselect target list is an outer reference is nullable`() { - // SELECT t.a FROM u's target-list Var refers to the OUTER query's t (levelsUp 1), not u's + // SELECT t.a FROM u's target-list Var refers to the outer query's t (levelsUp 1), not u's // own range table — resolving it against u's own schema (as the pre-fix code would have, - // absent the levelsUp guard) risks a varno collision: u's OWN varno-1/attno-2 column + // absent the levelsUp guard) risks a varno collision: u's own varno-1/attno-2 column // (dummy2, NOT NULL below) is a completely different column that happens to share the same // (varno, varattno) pair as t.a purely because each query block numbers its range table // independently starting from 1. The correct answer is "not proven" (nullable), regardless @@ -2889,14 +2874,14 @@ class QueryAnalysisTest { @Test fun `LATERAL derived table column over a nullable outer column is nullable, fixing a real collision`() { - // s's own subselect body (`SELECT t.a AS x FROM u`) has ONE local range-table entry, u, at - // local varno 1. t.a is an outer reference (varlevelsup 1) whose :varno ALSO happens to be - // 1, because it indexes the OUTER query's OWN rtable position for t — each query block + // s's own subselect body (`SELECT t.a AS x FROM u`) has one local range-table entry, u, at + // local varno 1. t.a is an outer reference (varlevelsup 1) whose :varno also happens to be + // 1, because it indexes the outer query's own rtable position for t — each query block // numbers its range table independently starting from 1, so this collision is not contrived, // it is the general case. Before the levelsUp guard, isSourceColumnNotNull for this Var - // resolved against the SUBQUERY's own range table (u), i.e. against u.junk's NOT NULL + // resolved against the subquery's own range table (u), i.e. against u.junk's NOT NULL // constraint, which has nothing to do with t.a. PostgreSQL's real answer here is NULL, since - // t.a is nullable — pinning that the guard fixes this, not merely widens it in this case. + // t.a is nullable — the guard fixes this, not merely widens it in this case. val query = analyzeWithSchema( "CREATE TABLE t (a TEXT); CREATE TABLE u (junk TEXT NOT NULL)", "SELECT s.x FROM t CROSS JOIN LATERAL (SELECT t.a AS x FROM u) s", @@ -2908,18 +2893,18 @@ class QueryAnalysisTest { @Test fun `LATERAL derived table column referencing a NOT NULL outer column is still conservatively nullable`() { // Identical shape to the test above, except t.a is NOT NULL here — PostgreSQL's real answer - // is therefore NEVER NULL (t.a can never be null, so s.x can't either). The blunt levelsUp + // is therefore never NULL (t.a can never be null, so s.x can't either). The blunt levelsUp // guard cannot tell this case apart from the one above: resolving an outer-reference Var - // against the INNER block's own range table (u) is unsound in BOTH directions — it could + // against the inner block's own range table (u) is unsound in both directions — it could // read a NOT NULL column that has nothing to do with the real (nullable) source, as above, - // or, as here, it could just as easily have collided with a NULLABLE column of u and + // or, as here, it could just as easily have collided with a nullable column of u and // under-reported a genuinely NOT NULL outer column. Correctly distinguishing this case would - // require resolving levelsUp > 0 Vars against the ENCLOSING query's own range table, which + // require resolving levelsUp > 0 Vars against the enclosing query's own range table, which // this analyzer's architecture does not support (see the levelsUp guard's own KDoc) — nested // blocks are analyzed eagerly into memoized maps with no live handle back to an enclosing // scope, and the varlevelsup chain has its own traps (set-operation branches the analyzer // never enters as their own level; CTE bodies not in the chain at all) where mis-counting - // would resolve a Var against the WRONG table entirely, strictly worse than this widening. + // would resolve a Var against the wrong table entirely, strictly worse than this widening. // main's earlier NOT NULL answer for this exact shape was accidental — a coincidence of // t.a's own NOT NULL constraint matching what u.junk's collision happened to resolve to, not // a real proof — not a real signal this analyzer intentionally computed. @@ -2933,8 +2918,8 @@ class QueryAnalysisTest { @Test fun `NOT IN subquery with a nullable inner column is nullable`() { - // `a NOT IN (subquery)` does NOT compile to an ALL_SUBLINK — verified live (PostgreSQL 17) - // against pg_rewrite.ev_action: it is a BOOLEXPR :boolop not wrapping an ordinary + // `a NOT IN (subquery)` does not compile to an ALL_SUBLINK — checked against + // pg_rewrite.ev_action on PostgreSQL 17: it is a BOOLEXPR :boolop not wrapping an ordinary // ANY_SUBLINK (subLinkType 2, operName "="), i.e. `NOT (a = ANY (subquery))`. This test pins // that the BoolExpr branch's `expression.arguments.all(recurse)` correctly propagates the // wrapped ANY_SUBLINK's own nullability (NULL NOT is NULL) rather than the wrapping NOT @@ -2964,12 +2949,12 @@ class QueryAnalysisTest { @Test fun `two levels of nested ANY sublinks resolve to a precise NOT NULL, well within the depth budget`() { - // Two levels of ANY_SUBLINK nesting terminate and resolve correctly. Note this alone does - // NOT pin the depth guard's necessity — a sublink's :subselect is always a genuine substring - // of its parent's own text (see PgNodeTreeParser), so this recursion is provably bounded by - // input length regardless of any explicit budget, and two levels resolve identically with - // the budget removed entirely. See the next test for a case that actually depends on the - // budget's value. + // Two levels of ANY_SUBLINK nesting terminate and resolve correctly. This alone does not pin + // the depth guard's necessity — a sublink's :subselect is always a genuine substring of its + // parent's own text (see PgNodeTreeParser), so this recursion is provably bounded by input + // length regardless of any explicit budget, and two levels resolve identically with the + // budget removed entirely. See the next test for a case that actually depends on the budget's + // value. val query = analyzeWithSchema( """ CREATE TABLE t (id INT NOT NULL, a BOOLEAN NOT NULL); @@ -2985,14 +2970,15 @@ class QueryAnalysisTest { @Test fun `four levels of nested ANY sublinks exceed the depth budget and degrade to nullable`() { // Every level here uses a NOT NULL boolean column and the safe-listed, strict, total boolean - // `=` operator, so the semantically correct answer — and the answer this analyzer WOULD give - // with a larger (or no) budget, verified by temporarily raising SUBLINK_ANALYSIS_DEPTH_BUDGET - // during development — is NOT NULL, all the way out. With the budget at 3, the 4th (innermost) - // ANY_SUBLINK's own callback is invoked at depth 0 — isSubLinkSubqueryColumnNotNull's `depth - // <= 0` guard fires before it even looks at u4, forcing that innermost sublink nullable, which - // cascades outward through every enclosing level. This is the case the two-level test above - // cannot exercise: it depends on the budget's actual VALUE, not merely on recursion - // terminating — raising the budget (or removing it) flips this test's expected answer. + // `=` operator, so the semantically correct answer — and the answer this analyzer would give + // with a larger (or no) budget, checked by temporarily raising SUBLINK_ANALYSIS_DEPTH_BUDGET + // during development — is NOT NULL, all the way out. With the budget at 3, the 4th + // (innermost) ANY_SUBLINK's own callback is invoked at depth 0 — + // isSubLinkSubqueryColumnNotNull's `depth <= 0` guard fires before it even looks at u4, + // forcing that innermost sublink nullable, which cascades outward through every enclosing + // level. This is the case the two-level test above cannot exercise: it depends on the + // budget's actual value, not merely on recursion terminating — raising the budget (or + // removing it) flips this test's expected answer. val query = analyzeWithSchema( """ CREATE TABLE t (id INT NOT NULL, a BOOLEAN NOT NULL); @@ -3048,14 +3034,13 @@ class QueryAnalysisTest { @Test fun `ANY sublink over a subquery reading a derived table is non-null, issue #257`() { - // #257's own repro, verified live against PostgreSQL 18: `a = ANY (SELECT z.v FROM (SELECT v - // FROM u) z) FROM t` returns false, never null, when u.v is NOT NULL — identical semantics to - // the un-wrapped `a = ANY (SELECT v FROM u)` case above, just with the subquery's single - // output column read through an intervening derived table (a subquery range-table entry) - // instead of directly from u. Before this fix, analyzeQueryBlockNullability resolved a Var - // only against its OWN base-table range table, so z.v's varno (indexing the derived table's - // rtekind-1 range-table entry, not a base table) fell through to nullable regardless of what - // u.v's own constraint said. + // PostgreSQL 18: `a = ANY (SELECT z.v FROM (SELECT v FROM u) z) FROM t` returns false, never + // null, when u.v is NOT NULL — identical semantics to the un-wrapped `a = ANY (SELECT v FROM + // u)` case above, just with the subquery's single output column read through an intervening + // derived table (a subquery range-table entry) instead of directly from u. Before this fix, + // analyzeQueryBlockNullability resolved a Var only against its own base-table range table, + // so z.v's varno (indexing the derived table's rtekind-1 range-table entry, not a base + // table) fell through to nullable regardless of what u.v's own constraint said. val query = analyzeWithSchema( "CREATE TABLE t (id INT PRIMARY KEY, a TEXT NOT NULL); CREATE TABLE u (v TEXT NOT NULL)", "SELECT a = ANY (SELECT z.v FROM (SELECT v FROM u) z) AS result FROM t", @@ -3067,8 +3052,8 @@ class QueryAnalysisTest { @Test fun `ANY sublink over a subquery reading a derived table two levels deep is non-null`() { // Pins that the derived-table recursion is not one-level-only: y is itself a derived table - // read from within z's own body, not directly from u. Verified live against PostgreSQL 18: - // returns false, never null. + // read from within z's own body, not directly from u. PostgreSQL 18: returns false, never + // null. val query = analyzeWithSchema( "CREATE TABLE t (id INT PRIMARY KEY, a TEXT NOT NULL); CREATE TABLE u (v TEXT NOT NULL)", "SELECT a = ANY (SELECT z2.v FROM (SELECT y.v FROM (SELECT v FROM u) y) z2) AS result FROM t", @@ -3079,12 +3064,11 @@ class QueryAnalysisTest { @Test fun `ANY sublink over a subquery reading an enclosing CTE is non-null, issue #257`() { - // #257's own repro, verified live against PostgreSQL 18: identical semantics to the derived- - // table case above, but the sublink's own subselect reads an ENCLOSING CTE (declared in the - // outer query's own WITH clause) rather than a FROM-clause derived table. Before this fix, - // subLinkSubqueryColumnNotNull passed an EMPTY resolvedCtes map into - // analyzeQueryBlockNullability, so a CTE reference always fell through to nullable regardless - // of the CTE body's own provable nullability. + // PostgreSQL 18: identical semantics to the derived-table case above, but the sublink's own + // subselect reads an enclosing CTE (declared in the outer query's own WITH clause) rather + // than a FROM-clause derived table. Before this fix, subLinkSubqueryColumnNotNull passed an + // empty resolvedCtes map into analyzeQueryBlockNullability, so a CTE reference always fell + // through to nullable regardless of the CTE body's own provable nullability. val query = analyzeWithSchema( "CREATE TABLE t (id INT PRIMARY KEY, a TEXT NOT NULL); CREATE TABLE u (v TEXT NOT NULL)", "WITH c AS (SELECT v FROM u) SELECT a = ANY (SELECT v FROM c) AS result FROM t", @@ -3095,11 +3079,11 @@ class QueryAnalysisTest { @Test fun `ANY sublink over a derived table reading a genuinely nullable column stays nullable`() { - // The #239 three-valued-logic hazard, now reached through a derived table instead of directly + // The three-valued-logic hazard, now reached through a derived table instead of directly // from a base table: w.v is nullable, so a row with v IS NULL and no match makes the whole - // ANY_SUBLINK NULL, not false. Verified live against PostgreSQL 18: returns null once w has a - // NULL row and t.a matches no non-null row. An unsound fix that widens purely because a - // derived table is now traced would flip this to notNull incorrectly. + // ANY_SUBLINK NULL, not false. PostgreSQL 18: returns null once w has a NULL row and t.a + // matches no non-null row. An unsound fix that widens purely because a derived table is now + // traced would flip this to notNull incorrectly. val query = analyzeWithSchema( "CREATE TABLE t (id INT PRIMARY KEY, a TEXT NOT NULL); CREATE TABLE w (v TEXT)", "SELECT a = ANY (SELECT z.v FROM (SELECT v FROM w) z) AS result FROM t", @@ -3110,9 +3094,8 @@ class QueryAnalysisTest { @Test fun `ANY sublink over an enclosing CTE reading a genuinely nullable column stays nullable`() { - // Same #239 hazard as above, reached through an enclosing CTE reference instead of a derived - // table. Verified live against PostgreSQL 18: returns null once w has a NULL row and t.a - // matches no non-null row. + // Same hazard as above, reached through an enclosing CTE reference instead of a derived + // table. PostgreSQL 18: returns null once w has a NULL row and t.a matches no non-null row. val query = analyzeWithSchema( "CREATE TABLE t (id INT PRIMARY KEY, a TEXT NOT NULL); CREATE TABLE w (v TEXT)", "WITH c AS (SELECT v FROM w) SELECT a = ANY (SELECT v FROM c) AS result FROM t", @@ -3125,9 +3108,9 @@ class QueryAnalysisTest { fun `ANY sublink over a derived table wrapping a UNION subquery stays nullable, the conservative bail-out`() { // Mirrors the existing plain-UNION bail-out test, but with the UNION reached through an // intervening derived table — pinning that buildSubqueryColumnNotNull's own hasSetOperations - // guard still fires when it is THIS recursive call (from inside analyzeQueryBlockNullability), - // not only the top-level one. Verified live against PostgreSQL 18: every row of both branches - // is NOT NULL, but the bail-out is still deliberately conservative here. + // guard still fires when it is this recursive call (from inside + // analyzeQueryBlockNullability), not only the top-level one. PostgreSQL 18: every row of + // both branches is NOT NULL, but the bail-out is still deliberately conservative here. val query = analyzeWithSchema( """ CREATE TABLE t (id INT NOT NULL, a TEXT NOT NULL); @@ -3146,7 +3129,7 @@ class QueryAnalysisTest { // the same four-level chain the un-wrapped test above pins. If the derived-table hop refilled // the budget instead of threading it, v1's chain would restart at 3 rather than the threaded 2 // and all four levels would resolve, reporting this NOT NULL. PostgreSQL's own unbounded - // answer for this shape IS NOT NULL; Norm's defensive bound truncates deliberately. + // answer for this shape is NOT NULL; Norm's defensive bound truncates deliberately. val query = analyzeWithSchema( """ CREATE TABLE t (id INT NOT NULL, a BOOLEAN NOT NULL); @@ -3175,12 +3158,12 @@ class QueryAnalysisTest { @Test fun `a sublink's own WITH shadows an enclosing CTE of the same name with a different, wrong-answer body`() { - // Pins the ctelevelsup handling: the OUTER WITH c reads a nullable column (outer_source.v has - // no NOT NULL constraint), while the sublink's OWN WITH c — same name, different body — reads + // Pins the ctelevelsup handling: the outer WITH c reads a nullable column (outer_source.v has + // no NOT NULL constraint), while the sublink's own WITH c — same name, different body — reads // a NOT NULL column instead. Resolving against the wrong (outer) map would report this - // nullable; the correct, ctelevelsup-aware resolution reports it non-null. Verified live - // against PostgreSQL 18: inner_source is empty, so ANY over it is always false, never null, - // regardless of outer_source's own contents. + // nullable; the correct, ctelevelsup-aware resolution reports it non-null. PostgreSQL 18: + // inner_source is empty, so ANY over it is always false, never null, regardless of + // outer_source's own contents. val query = analyzeWithSchema( """ CREATE TABLE t (id INT NOT NULL, a TEXT NOT NULL); @@ -3200,9 +3183,9 @@ class QueryAnalysisTest { @Test fun `ANY sublink over a derived table reading a NULLIF-wrapped view column stays nullable`() { - // Confirms #256's fix is what carries this, not a coincidence: the view's OWN target-list - // expression (NULLIF) is analyzed, not the source column's name-matched constraint. Verified - // live against PostgreSQL 18: NULLIF(v, 'x') is null whenever v = 'x', so this stays nullable + // Confirms the view-target-list fix is what carries this, not a coincidence: the view's own + // target-list expression (NULLIF) is analyzed, not the source column's name-matched + // constraint. PostgreSQL 18: NULLIF(v, 'x') is null whenever v = 'x', so this stays nullable // even though u.v itself is NOT NULL. See the paired NOT-NULL view test below. val query = analyzeWithSchema( """ @@ -3221,7 +3204,7 @@ class QueryAnalysisTest { // Paired with the NULLIF view test above: a plain pass-through view column over a NOT NULL // source is correctly non-null, proving the previous test's nullable result comes from // NULLIF's own semantics, not from every view-backed derived table being conservatively - // widened regardless of its actual defining expression. Verified live against PostgreSQL 18. + // widened regardless of its actual defining expression. PostgreSQL 18. val query = analyzeWithSchema( """ CREATE TABLE t (id INT NOT NULL, a TEXT NOT NULL); @@ -3262,13 +3245,13 @@ class QueryAnalysisTest { @Test fun `ALL sublink over a subquery with a nullable column stays nullable, the ALL three-valued-logic hazard`() { - // #239's own three-valued-logic hazard, now reached through ALL_SUBLINK instead of - // ANY_SUBLINK: `x op ALL (S)` is NULL, not TRUE, when some row's comparison is NULL and no - // row's comparison is FALSE. Verified live against PostgreSQL 18.4: `CREATE TABLE t (id INT - // PRIMARY KEY, a TEXT NOT NULL); CREATE TABLE w (v TEXT); INSERT INTO t VALUES (1, 'x'); - // INSERT INTO w VALUES (NULL); SELECT a <> ALL (SELECT v FROM w) FROM t;` is NULL — w's only - // row has v IS NULL, so `a <> v` for that row is itself NULL, and no other row exists to make - // the whole comparison FALSE. + // The three-valued-logic hazard, now reached through ALL_SUBLINK instead of ANY_SUBLINK: + // `x op ALL (S)` is NULL, not TRUE, when some row's comparison is NULL and no row's + // comparison is FALSE. PostgreSQL 18.4: `CREATE TABLE t (id INT PRIMARY KEY, a TEXT NOT + // NULL); CREATE TABLE w (v TEXT); INSERT INTO t VALUES (1, 'x'); INSERT INTO w VALUES + // (NULL); SELECT a <> ALL (SELECT v FROM w) FROM t;` is NULL — w's only row has v IS NULL, + // so `a <> v` for that row is itself NULL, and no other row exists to make the whole + // comparison FALSE. val query = analyzeWithSchema( "CREATE TABLE t (id INT PRIMARY KEY, a TEXT NOT NULL); CREATE TABLE w (v TEXT)", "SELECT a <> ALL (SELECT v FROM w) AS result FROM t", @@ -3292,14 +3275,14 @@ class QueryAnalysisTest { @Test fun `multi-column ALL sublink is nullable even when every column is NOT NULL, the ROWCOMPAREEXPR form`() { - // Unlike the `<>`/`=` row-comparison forms above (a BOOLEXPR testexpr), `(a, id) < ALL (SELECT - // v, 1 FROM u)` compiles its testexpr to a ROWCOMPAREEXPR instead — verified live on - // PostgreSQL 18.4 by dumping this exact query's ev_action: `{ROWCOMPAREEXPR :cmptype 1 :opnos - // (o 97 97) ... :largs (...) :rargs (...)}`, no `:location` field of its own. This parser's - // `when` dispatch does not recognize ROWCOMPAREEXPR, so it parses to Unknown; that alone - // already makes testExpressionOperatorOid null (no OPEXPR to cast) and outerOperand null (no - // `:args` field — ROWCOMPAREEXPR carries `:largs`/`:rargs` instead), so the proof fails on both - // legs, independently of the BOOLEXPR mechanism the `<>`/`=` forms use. + // Unlike the `<>`/`=` row-comparison forms above (a BOOLEXPR testexpr), `(a, id) < ALL + // (SELECT v, 1 FROM u)` compiles its testexpr to a ROWCOMPAREEXPR instead — confirmed on + // PostgreSQL 18.4 by dumping this exact query's ev_action: `{ROWCOMPAREEXPR :cmptype 1 + // :opnos (o 97 97) ... :largs (...) :rargs (...)}`, no `:location` field of its own. This + // parser's `when` dispatch does not recognize ROWCOMPAREEXPR, so it parses to Unknown; that + // alone already makes testExpressionOperatorOid null (no OPEXPR to cast) and outerOperand + // null (no `:args` field — ROWCOMPAREEXPR carries `:largs`/`:rargs` instead), so the proof + // fails on both legs, independently of the BOOLEXPR mechanism the `<>`/`=` forms use. val query = analyzeWithSchema( "CREATE TABLE t (a INT NOT NULL, id INT NOT NULL); CREATE TABLE u (v INT NOT NULL)", "SELECT (a, id) < ALL (SELECT v, 1 FROM u) AS result FROM t", @@ -3324,11 +3307,11 @@ class QueryAnalysisTest { @Test fun `ROWCOMPARE sublink, a row comparison against a one-row subquery, stays nullable`() { // (a, id) < (SELECT v, 1 FROM u), with no ALL/ANY keyword, compiles to a ROWCOMPARE_SUBLINK - // (subLinkType 3 — verified live on PostgreSQL 18.4 by dumping this exact query's prosqlbody - // node tree), genuinely different from ALL/ANY: an EMPTY subquery yields NULL for ROWCOMPARE, + // (subLinkType 3 — confirmed on PostgreSQL 18.4 by dumping this exact query's prosqlbody + // node tree), genuinely different from ALL/ANY: an empty subquery yields NULL for ROWCOMPARE, // not TRUE/FALSE, so no combination of operand/operator/column conditions can rescue it — it - // is deliberately excluded from isNonNull's SubLink proof. Verified live against PostgreSQL - // 18.4: with `u` empty, `(a, id) < (SELECT v, 1 FROM u)` is NULL, not FALSE. + // is deliberately excluded from isNonNull's SubLink proof. PostgreSQL 18.4: with `u` empty, + // `(a, id) < (SELECT v, 1 FROM u)` is NULL, not FALSE. val query = analyzeWithSchema( "CREATE TABLE t (id INT NOT NULL, a INT NOT NULL); CREATE TABLE u (v INT NOT NULL)", "SELECT (a, id) < (SELECT v, 1 FROM u) AS result FROM t", @@ -3384,13 +3367,13 @@ class QueryAnalysisTest { @Test fun `a recursive CTE column that only becomes nullable through later iterations is reported nullable`() { - // Verified live on PostgreSQL: this query produces rows 1|1, 1|, | — "n" - // IS genuinely nullable, even though the seed term's own "n" (1) is NOT NULL. Analyzing the - // recursive term ("SELECT m, NULL::int FROM r WHERE n < 5") only ONCE against the SEED's - // nullability (n=NOT NULL, m=NOT NULL) misses this: the recursive term's own "n" column reads - // "m" from the PREVIOUS iteration, which becomes nullable starting at the second iteration - // (its own second column is a literal NULL) — a fixpoint over the whole recursion is required - // to see that "m" (and therefore "n") widens to nullable. + // On PostgreSQL, this query produces rows 1|1, 1|, | — "n" is genuinely + // nullable, even though the seed term's own "n" (1) is NOT NULL. Analyzing the recursive term + // ("SELECT m, NULL::int FROM r WHERE n < 5") only once against the seed's nullability + // (n=NOT NULL, m=NOT NULL) misses this: the recursive term's own "n" column reads "m" from + // the previous iteration, which becomes nullable starting at the second iteration (its own + // second column is a literal NULL) — a fixpoint over the whole recursion is required to see + // that "m" (and therefore "n") widens to nullable. val query = analyzeWithSchema( "CREATE TABLE t (id INT NOT NULL)", """ @@ -3661,8 +3644,8 @@ class QueryAnalysisTest { @Test fun `chained data-modifying CTEs referencing earlier CTE with trailing no-RETURNING CTE`() { - // Reproduces #202: a later data-modifying CTE ("inserted") references an earlier CTE - // ("input"), and a trailing CTE ("logged") has no RETURNING clause at all. + // A later data-modifying CTE ("inserted") references an earlier CTE ("input"), and a + // trailing CTE ("logged") has no RETURNING clause at all. val query = analyzeWithSchema( """ CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL); @@ -3689,10 +3672,9 @@ class QueryAnalysisTest { @Test fun `forward-referencing data-modifying CTE under WITH RECURSIVE`() { - // Reproduces #203: "ins" is a data-modifying CTE whose body references "later", a CTE - // declared AFTER it in the same WITH RECURSIVE clause. A prefix built only from preceding - // CTE definitions omits "later" and fails to prepare; the probe must use the full WITH - // clause instead. + // "ins" is a data-modifying CTE whose body references "later", a CTE declared after it in + // the same WITH RECURSIVE clause. A prefix built only from preceding CTE definitions omits + // "later" and fails to prepare; the probe must use the full WITH clause instead. val query = analyzeWithSchema( "CREATE TABLE t (id SERIAL NOT NULL, name TEXT NOT NULL)", """ @@ -3754,12 +3736,12 @@ class QueryAnalysisTest { @Test fun `plain WITH does not let a later CTE shadow a base table referenced by an earlier body`() { - // Regression guard: the full-WITH-clause probe prefix must not be tried FIRST for a - // non-recursive WITH clause. "src" is both a base table AND the name of a CTE declared - // AFTER "upd". A plain WITH clause resolves "upd"'s reference to "src" against the base - // table (a later CTE never shadows for an earlier body) — verified against real Postgres - // by inserting distinguishable rows: the query returns the base table's value, not the - // CTE's, so "c" must report the base table src.name's nullability (nullable). + // Regression guard: the full-WITH-clause probe prefix must not be tried first for a + // non-recursive WITH clause. "src" is both a base table and the name of a CTE declared + // after "upd". A plain WITH clause resolves "upd"'s reference to "src" against the base + // table (a later CTE never shadows for an earlier body): inserting distinguishable rows + // shows the query returns the base table's value, not the CTE's, so "c" must report the + // base table src.name's nullability (nullable). val query = analyzeWithSchema( """ CREATE TABLE other (name TEXT NOT NULL); @@ -3784,9 +3766,8 @@ class QueryAnalysisTest { // every CTE name in a WITH RECURSIVE clause visible to every other CTE's body (this is // what permits forward and mutual references), so "src" in "upd" resolves to the sibling // CTE "src" (backed by "other", whose name column is NOT NULL) instead of the base table - // "src" (nullable name) — verified against real Postgres by inserting distinguishable - // rows: the query returns the CTE's value, not the base table's, so "c" must report the - // CTE's column nullability (non-null). + // "src" (nullable name): inserting distinguishable rows shows the query returns the CTE's + // value, not the base table's, so "c" must report the CTE's column nullability (non-null). val query = analyzeWithSchema( """ CREATE TABLE other (name TEXT NOT NULL); @@ -3812,8 +3793,8 @@ class QueryAnalysisTest { // "helper" verbatim in front of "SELECT src.name AS c FROM t, src, helper" — which then // goes through the same node-tree analysis as any other CTE, resolving "src" against the // sibling CTE (declared before "upd", so it shadows the base table normally) rather than a - // metadata probe that would be blind to this either way. Verified against real Postgres by - // inserting a NULL row into "other": the sibling CTE "src" wins, so "c" must be nullable. + // metadata probe that would be blind to this either way. Inserting a NULL row into "other" + // shows the sibling CTE "src" wins, so "c" must be nullable. val query = analyzeWithSchema( """ CREATE TABLE other (name TEXT); @@ -3835,11 +3816,10 @@ class QueryAnalysisTest { @Test fun `WITH RECURSIVE variant of nested-WITH shadowing also resolves the sibling CTE`() { - // Same shape as the test above, but WITH RECURSIVE. Verified against real Postgres - // (inserting a NULL row into "other"): the sibling CTE "src" wins here too — RECURSIVE - // does not change which "src" a body sharing scope with a PRECEDING sibling resolves to, - // only whether FOLLOWING siblings are visible — so "c" must be nullable, same as the - // plain-WITH case. + // Same shape as the test above, but WITH RECURSIVE. Inserting a NULL row into "other" + // shows the sibling CTE "src" wins here too — RECURSIVE does not change which "src" a + // body sharing scope with a preceding sibling resolves to, only whether following siblings + // are visible — so "c" must be nullable, same as the plain-WITH case. val query = analyzeWithSchema( """ CREATE TABLE other (name TEXT); @@ -3861,7 +3841,7 @@ class QueryAnalysisTest { @Test fun `no-RETURNING data-modifying CTE with its own nested WITH at a non-zero index`() { - // "logged" is the SECOND CTE (index > 0, so the bare-body candidate that commit d4f8d7c + // "logged" is the second CTE (index > 0, so the bare-body candidate that commit d4f8d7c // relied on is never tried, by design) and has no RETURNING clause, so the true-scope // probe ("SELECT * FROM logged") itself fails to prepare. This exercises the further // fallback: " SELECT 1" confirms the WITH clause is otherwise sound, so a @@ -3911,11 +3891,11 @@ class QueryAnalysisTest { """.trimIndent(), ) assertThat(query.columns).hasSize(1) - // Verified directly against the driver: PostgreSQL's target-list origin tracking traces - // "id" all the way through "SELECT * FROM \"MyIns\"" back to t.id, so isNullable reports - // NOT NULL precisely here (tableName came back as "t", not unknown) — a bare column - // RETURNING with no intervening expression preserves lineage. "MyIns" is a plain INSERT - // (no FROM/USING/MERGE join in the OUTER statement, only inside its own SELECT source), + // PostgreSQL's target-list origin tracking traces "id" all the way through "SELECT * FROM + // \"MyIns\"" back to t.id, so isNullable reports NOT NULL precisely here (tableName came + // back as "t", not unknown) — a bare column RETURNING with no intervening expression + // preserves lineage. "MyIns" is a plain INSERT (no FROM/USING/MERGE join in the outer + // statement, only inside its own SELECT source), // so it never reaches convertDmlCteBodyToSelect's join-preserving conversion and stays on // the probe/stub path — which is fine specifically because INSERT's RETURNING sees only // the just-inserted row: there is no outer join here for the probe to be blind to. The @@ -3928,9 +3908,9 @@ class QueryAnalysisTest { fun `UPDATE FROM LEFT JOIN RETURNING joined column inside a data-modifying CTE`() { // A metadata probe (PreparedStatement.getMetaData().isNullable) reports base-table // attnotnull — b.val is NOT NULL in the schema — and is blind to the LEFT JOIN - // null-extending it at runtime. Verified against real Postgres by inserting an "a" row - // with no matching "b" row: the query returns v = NULL, so this MUST be nullable. Before - // convertDmlCteBodyToSelect existed, the probe/stub path reported this NOT NULL. + // null-extending it at runtime. Inserting an "a" row with no matching "b" row: the query + // returns v = NULL, so this must be nullable. Before convertDmlCteBodyToSelect existed, + // the probe/stub path reported this NOT NULL. val query = analyzeWithSchema( """ CREATE TABLE t (id SERIAL NOT NULL, name TEXT); @@ -3953,7 +3933,7 @@ class QueryAnalysisTest { // Same shape as the test above, but "upd"'s body carries its own nested WITH ("helper"), // exercising convertDmlCteBodyToSelect's nested-WITH reattachment path (WITH helper AS // (...) SELECT b.val AS v FROM t, a LEFT JOIN b ON ..., helper) rather than the plain - // conversion path. Verified against real Postgres the same way: v = NULL. + // conversion path. Confirmed the same way: v = NULL. val query = analyzeWithSchema( """ CREATE TABLE t (id SERIAL NOT NULL, name TEXT); @@ -3974,18 +3954,17 @@ class QueryAnalysisTest { @Test fun `sibling CTE body with its own nested WITH keeps its LEFT JOIN nullability alongside a DML CTE`() { - // Issue #205's exact repro. "j"'s body starts with its own nested WITH ("inner_cte"), so - // isNonDataModifyingCteBody must classify it by "inner_cte"'s own main statement (a plain - // SELECT) rather than stubbing "j" outright — a stub is built from - // PreparedStatement.getMetaData().isNullable, which reflects base-table attnotnull and is - // blind to the LEFT JOIN inside "j". Before the fix, "j" fell into the stub branch, whose - // hasOuterJoin safety net (PgCatalogLoader.kt's forceAllNullable) then forced EVERY column - // of "j" nullable — so "did", which is genuinely NOT NULL, was wrongly reported nullable; - // "label" also came back nullable, but only because it was forced along with everything - // else, not because the stub actually saw the LEFT JOIN. "did" comes from the LEFT JOIN's - // preserved side (d), so it stays NOT NULL; "label" comes from the null-extended side (u), - // so it must be nullable. Verified against real Postgres by inserting a "d" row with no - // matching "u" row: the query returns did = , label = NULL. + // "j"'s body starts with its own nested WITH ("inner_cte"), so isNonDataModifyingCteBody + // must classify it by "inner_cte"'s own main statement (a plain SELECT) rather than + // stubbing "j" outright — a stub is built from PreparedStatement.getMetaData().isNullable, + // which reflects base-table attnotnull and is blind to the LEFT JOIN inside "j". Before the + // fix, "j" fell into the stub branch, whose hasOuterJoin safety net (PgCatalogLoader.kt's + // forceAllNullable) then forced every column of "j" nullable — so "did", which is + // genuinely NOT NULL, was wrongly reported nullable; "label" also came back nullable, but + // only because it was forced along with everything else, not because the stub actually saw + // the LEFT JOIN. "did" comes from the LEFT JOIN's preserved side (d), so it stays NOT NULL; + // "label" comes from the null-extended side (u), so it must be nullable. Inserting a "d" + // row with no matching "u" row: the query returns did = , label = NULL. val query = analyzeWithSchema( """ CREATE TABLE d (id INT NOT NULL); @@ -4017,8 +3996,8 @@ class QueryAnalysisTest { // the new nested-WITH handling. If it were misclassified, the embedded UPDATE would still // be present when this SQL is used to CREATE VIEW, which PostgreSQL rejects, and the whole // analysis would fall back to asserting every column NOT NULL — masking the LEFT JOIN's - // real nullability. Verified against real Postgres by inserting an "a" row with no - // matching "b" row: the query returns v = NULL. + // real nullability. Inserting an "a" row with no matching "b" row: the query returns v = + // NULL. val query = analyzeWithSchema( """ CREATE TABLE t (id SERIAL NOT NULL, name TEXT); @@ -4042,7 +4021,7 @@ class QueryAnalysisTest { @Test fun `parenthesized nested-WITH CTE body keeps its LEFT JOIN nullability, alongside a data-modifying CTE`() { - // Same shape as issue #205's repro, but "j"'s body is additionally wrapped in its own + // Same shape as the nested-WITH CTE-body test above, but "j"'s body is additionally wrapped in its own // parentheses (PostgreSQL accepts this: confirmed directly against a real server that // "j AS ((WITH inner_cte AS (...) SELECT ...))" parses and returns did = 1, label = NULL // for a "d" row with no matching "u" row). isNonDataModifyingCteBody must skip the extra @@ -4075,8 +4054,8 @@ class QueryAnalysisTest { @Test fun `DELETE USING LEFT JOIN RETURNING joined column inside a data-modifying CTE`() { - // Same defect as the UPDATE case, for DELETE ... USING. Verified against real Postgres - // (inserting an "a" row with no matching "b" row): the query returns v = NULL. + // Same defect as the UPDATE case, for DELETE ... USING. Inserting an "a" row with no + // matching "b" row: the query returns v = NULL. val query = analyzeWithSchema( """ CREATE TABLE t (id SERIAL NOT NULL, name TEXT); @@ -4097,14 +4076,14 @@ class QueryAnalysisTest { @Test fun `self-join LEFT JOIN RETURNING kills the rejected getTableName heuristic`() { // This is the shape that rules out a metadata heuristic considered and rejected in favor - // of structural conversion: "t2" is an alias for the TARGET table "t" itself, sitting on + // of structural conversion: "t2" is an alias for the target table "t" itself, sitting on // the nullable side of a LEFT JOIN. ResultSetMetaData.getTableName() reports the base // relation "t" for t2.name — indistinguishable, by name alone, from the actual DML target // "t" — so a heuristic keyed on "does getTableName() match the target table name" would - // conclude t2.name is NOT the join side and keep it fabricated NOT NULL. Structural + // conclude t2.name is not the join side and keep it fabricated NOT NULL. Structural // conversion sidesteps this entirely: it operates on the real join structure via aliases, - // not on relation names. Verified against real Postgres (inserting an "a" row with no - // matching "k"): the query returns v = NULL. + // not on relation names. Inserting an "a" row with no matching "k": the query returns v = + // NULL. val query = analyzeWithSchema( """ CREATE TABLE t (id SERIAL NOT NULL, k INT NOT NULL, name TEXT NOT NULL); @@ -4127,9 +4106,9 @@ class QueryAnalysisTest { @Test fun `MERGE WHEN NOT MATCHED BY SOURCE THEN DELETE RETURNING source column inside a CTE`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 17, "WHEN NOT MATCHED BY SOURCE requires PostgreSQL 17+") - // WHEN NOT MATCHED BY SOURCE fires for TARGET rows with no matching source row — the - // shape convertMergeToSelect models as a LEFT JOIN. Verified against real Postgres: a - // target row with no matching source row returns s.name = NULL through this RETURNING. + // WHEN NOT MATCHED BY SOURCE fires for target rows with no matching source row — the + // shape convertMergeToSelect models as a LEFT JOIN. On real Postgres, a target row with no + // matching source row returns s.name = NULL through this RETURNING. val query = analyzeWithSchema( """ CREATE TABLE t (id INT PRIMARY KEY, name TEXT NOT NULL); @@ -4153,8 +4132,8 @@ class QueryAnalysisTest { fun `MERGE without WHEN NOT MATCHED BY SOURCE keeps source column NOT NULL`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 17, "MERGE RETURNING requires PostgreSQL 17+") // No "WHEN NOT MATCHED BY SOURCE" clause: convertMergeToSelect models this as a plain - // (inner) join, since every row RETURNING can see has a genuine source match. Verified - // against real Postgres: s.name is never NULL through this RETURNING. + // (inner) join, since every row RETURNING can see has a genuine source match. On real + // Postgres, s.name is never NULL through this RETURNING. val query = analyzeWithSchema( """ CREATE TABLE t (id INT PRIMARY KEY, name TEXT NOT NULL); @@ -4176,12 +4155,12 @@ class QueryAnalysisTest { @Test fun `RETURNING star on UPDATE with a joined relation returns every relation's columns`() { - // Verified against real Postgres (via psql \gdesc on the actual UPDATE): "RETURNING *" on - // UPDATE ... FROM is NOT limited to the target table's columns — it expands to every - // relation in the statement's scope, target AND joined, identically to a plain "SELECT *" - // over the same FROM list (t.id, t.name, a.id, a.label — 4 columns, not 2). This is why - // convertDmlToSelect passes RETURNING clauses through verbatim rather than qualifying a - // bare "*" to the target alone (which would have produced the WRONG column count here). + // Checked via psql \gdesc on the actual UPDATE: "RETURNING *" on UPDATE ... FROM is not + // limited to the target table's columns — it expands to every relation in the statement's + // scope, target and joined, identically to a plain "SELECT *" over the same FROM list + // (t.id, t.name, a.id, a.label — 4 columns, not 2). This is why convertDmlToSelect passes + // RETURNING clauses through verbatim rather than qualifying a bare "*" to the target alone + // (which would have produced the wrong column count here). val query = analyzeWithSchema( """ CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL); @@ -4204,10 +4183,10 @@ class QueryAnalysisTest { @Test fun `target-table NOT NULL column stays NOT NULL alongside an outer-joined nullable column`() { // Guard against over-nullability: converting the CTE body to a join-preserving SELECT - // must not make EVERYTHING nullable just because a join is present — only the columns + // must not make everything nullable just because a join is present — only the columns // actually reached through the LEFT JOIN's nullable side. "t.id" comes from the target // row, which always exists (UPDATE only touches rows that exist), so it must stay NOT - // NULL even though "v" (from the LEFT JOIN) is nullable. Verified against real Postgres. + // NULL even though "v" (from the LEFT JOIN) is nullable. val query = analyzeWithSchema( """ CREATE TABLE t (id SERIAL NOT NULL, name TEXT); @@ -4231,8 +4210,7 @@ class QueryAnalysisTest { // Regression guard: findTopLevelKeyword previously counted parens inside string literals, // so the "(" inside '\(' hid the real FROM from convertDmlToSelect, silently falling back // to the metadata probe/stub path — which is blind to the LEFT JOIN and fabricates NOT - // NULL. Verified against real Postgres (inserting an "a" row with no matching "b" row): - // the query returns v = NULL. + // NULL. Inserting an "a" row with no matching "b" row: the query returns v = NULL. val query = analyzeWithSchema( """ CREATE TABLE t (id SERIAL NOT NULL, name TEXT); @@ -4256,8 +4234,8 @@ class QueryAnalysisTest { fun `MERGE RETURNING source column survives an unbalanced parenthesis inside a string literal`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 17, "WHEN NOT MATCHED BY SOURCE requires PostgreSQL 17+") // Same defect as above, for MERGE: the "(" inside a SET expression's string literal must - // not hide the real WHEN NOT MATCHED BY SOURCE clause from convertMergeToSelect. Verified - // against real Postgres: a target row with no matching source row returns sname = NULL. + // not hide the real WHEN NOT MATCHED BY SOURCE clause from convertMergeToSelect. On real + // Postgres, a target row with no matching source row returns sname = NULL. val query = analyzeWithSchema( """ CREATE TABLE mt (tid INT PRIMARY KEY, tname TEXT NOT NULL); @@ -4280,11 +4258,11 @@ class QueryAnalysisTest { @Test fun `a string literal containing the word FROM with no real FROM clause does not abort generation`() { - // REGRESSION guard vs main: before the lexer fix, an unvalidated conversion could replace + // Regression guard: before the lexer fix, an unvalidated conversion could replace // PostgreSQL's own parse with garbled text derived from misreading "from" inside a string // literal as if it introduced a real FROM clause — aborting generation entirely on SQL - // PostgreSQL accepts fine. Verified against real Postgres: id = 1 (NOT NULL, as expected - // for a SERIAL primary key) — there is no join here at all, real or otherwise. + // PostgreSQL accepts fine. On real Postgres, id = 1 (NOT NULL, as expected for a SERIAL + // primary key) — there is no join here at all, real or otherwise. val query = analyzeWithSchema( "CREATE TABLE t (id SERIAL NOT NULL, name TEXT NOT NULL)", """ @@ -4300,12 +4278,12 @@ class QueryAnalysisTest { @Test fun `a line comment containing FROM between SET and the real FROM clause does not abort generation`() { - // The comment must sit BETWEEN "SET ..." and the real "FROM" — a comment before "UPDATE" + // The comment must sit between "SET ..." and the real "FROM" — a comment before "UPDATE" // is already skipped by the leading-whitespace/comment handling every DML-recognition // check starts with, on both old and new code, so it would not exercise this bug (that - // shape doesn't demonstrate anything). This one forces findTopLevelKeyword to scan THROUGH - // the comment while searching for the real FROM. Verified against real Postgres (inserting - // an "a" row with no matching "b" row): the query returns v = NULL. + // shape doesn't demonstrate anything). This one forces findTopLevelKeyword to scan through + // the comment while searching for the real FROM. Inserting an "a" row with no matching "b" + // row: the query returns v = NULL. val query = analyzeWithSchema( """ CREATE TABLE t (id SERIAL NOT NULL, name TEXT); @@ -4326,8 +4304,8 @@ class QueryAnalysisTest { @Test fun `a block comment containing FROM between SET and the real FROM clause does not abort generation`() { - // Same reasoning as the line-comment variant above. Verified against real Postgres - // (inserting an "a" row with no matching "b" row): the query returns v = NULL. + // Same reasoning as the line-comment variant above. Inserting an "a" row with no matching + // "b" row: the query returns v = NULL. val query = analyzeWithSchema( """ CREATE TABLE t (id SERIAL NOT NULL, name TEXT); @@ -4349,9 +4327,8 @@ class QueryAnalysisTest { @Test fun `a string literal containing the word from alongside a real FROM LEFT JOIN is still nullable`() { // Combines the two failure modes: the SET expression's literal contains "from" (which must - // not be mistaken for, or hide, anything), and there IS a real FROM with a LEFT JOIN right - // after it. Verified against real Postgres (inserting an "a" row with no matching "b" - // row): the query returns v = NULL. + // not be mistaken for, or hide, anything), and there is a real FROM with a LEFT JOIN right + // after it. Inserting an "a" row with no matching "b" row: the query returns v = NULL. val query = analyzeWithSchema( """ CREATE TABLE t (id SERIAL NOT NULL, name TEXT); @@ -4372,12 +4349,12 @@ class QueryAnalysisTest { @Test fun `MERGE WHEN NOT MATCHED BY SOURCE RETURNING star has correct column order, names, and nullability`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 17, "WHEN NOT MATCHED BY SOURCE requires PostgreSQL 17+") - // Verified against real Postgres (\gdesc plus executing the query): "RETURNING *" expands - // SOURCE-first — sid, sname, tid, tname — regardless of which WHEN clauses are present, - // and for a target row with no matching source row the actual returned values are - // [NULL, NULL, 1, 'target-row']. convertMergeToSelect must emit "FROM source RIGHT JOIN - // target" (source first) to match — a target-first conversion would report the nullability - // for the WRONG columns even though the metadata (names/types) could look plausible. + // Checked via \gdesc plus executing the query: "RETURNING *" expands source-first — sid, + // sname, tid, tname — regardless of which WHEN clauses are present, and for a target row + // with no matching source row the actual returned values are [NULL, NULL, 1, 'target-row']. + // convertMergeToSelect must emit "FROM source RIGHT JOIN target" (source first) to match — + // a target-first conversion would report the nullability for the wrong columns even though + // the metadata (names/types) could look plausible. val query = analyzeWithSchema( """ CREATE TABLE mt (tid INT PRIMARY KEY, tname TEXT NOT NULL); @@ -4405,8 +4382,8 @@ class QueryAnalysisTest { fun `literal text matching the WHEN NOT MATCHED BY SOURCE phrase does not trigger the LEFT JOIN model`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 17, "MERGE RETURNING requires PostgreSQL 17+") // hasWhenNotMatchedBySourceClause must not misfire on a SET expression's string literal - // that happens to contain the phrase "when not matched by source ". Verified against real - // Postgres: with no genuine WHEN NOT MATCHED BY SOURCE clause, ms.sname is never NULL. + // that happens to contain the phrase "when not matched by source ". On real Postgres, with + // no genuine WHEN NOT MATCHED BY SOURCE clause, ms.sname is never NULL. val query = analyzeWithSchema( """ CREATE TABLE mt (tid INT PRIMARY KEY, tname TEXT NOT NULL); @@ -4428,17 +4405,16 @@ class QueryAnalysisTest { @Test fun `data-modifying CTE preceded by a sibling CTE containing a closing parenthesis in a literal`() { - // End-to-end companion to the SqlUtilsTest paren-in-literal coverage: the FIRST CTE's body + // End-to-end companion to the SqlUtilsTest paren-in-literal coverage: the first CTE's body // contains a ')' inside a string literal, which (before the lexer fix) corrupted // findMatchingCloseParenthesis's body-boundary detection for that CTE — parseCteClause - // then stopped after that ONE (corrupted) definition, treating "upd" as part of the - // garbled MAIN QUERY text instead of a second CTE. "upd" has a LEFT JOIN specifically so + // then stopped after that one (corrupted) definition, treating "upd" as part of the + // garbled main-query text instead of a second CTE. "upd" has a LEFT JOIN specifically so // this is visible: the garbled-query fallback (the top-level no-join-structure DML path, - // "assume every column non-null" before issue #207's fix) happens to give the RIGHT answer - // for a plain INSERT (as in the SqlUtilsTest e2e companion above), but gives the WRONG - // answer here, where the true answer is nullable. Verified against real Postgres (inserting - // an "a" row with no matching "b" - // row): the query returns v = NULL. + // "assume every column non-null" before the not-null-fallback fix) happens to give the + // right answer for a plain INSERT (as in the SqlUtilsTest e2e companion above), but gives + // the wrong answer here, where the true answer is nullable. Inserting an "a" row with no + // matching "b" row: the query returns v = NULL. val query = analyzeWithSchema( """ CREATE TABLE t (id SERIAL NOT NULL, name TEXT); @@ -4486,8 +4462,8 @@ class QueryAnalysisTest { // findTopLevelKeyword's word-boundary check, so "valid_from" matched the keyword "FROM" // at its own position — before the real "FROM a LEFT JOIN b" clause — corrupting // conversion (which then failed validation) and falling back to the metadata probe/stub, - // which is blind to the LEFT JOIN and fabricated NOT NULL. Verified against real Postgres - // (inserting an "a" row with no matching "b" row): the query returns bval = NULL. + // which is blind to the LEFT JOIN and fabricated NOT NULL. Inserting an "a" row with no + // matching "b" row: the query returns bval = NULL. val query = analyzeWithSchema( """ CREATE TABLE t (id INT NOT NULL, valid_from TEXT); @@ -4509,13 +4485,13 @@ class QueryAnalysisTest { @Test fun `SET-clause column named returning_note no longer aborts generation`() { - // REGRESSION guard vs main: before the word-boundary fix, "returning_note" matched - // "RETURNING" as a keyword, making returningIndex point INSIDE the SET clause — earlier - // than the join clause start computed from the (correctly found) later FROM — and + // Regression guard: before the word-boundary fix, "returning_note" matched "RETURNING" as + // a keyword, making returningIndex point inside the SET clause — earlier than the join + // clause start computed from the (correctly found) later FROM — and // buildSelectFromDml's substring(joinClauseStart, returningIndex) threw // StringIndexOutOfBoundsException, aborting generation on SQL PostgreSQL itself accepts - // fine. Verified against real Postgres: id = 1 (NOT NULL, as expected for a plain UPDATE - // with no outer join at all). + // fine. On real Postgres, id = 1 (NOT NULL, as expected for a plain UPDATE with no outer + // join at all). val query = analyzeWithSchema( """ CREATE TABLE t (id INT NOT NULL, returning_note TEXT); @@ -4538,16 +4514,16 @@ class QueryAnalysisTest { // PostgreSQL 18's RETURNING OLD.col forces this body onto the stub path: the structural // conversion builds a plain SELECT where "OLD" is not a valid range variable, so it fails // to prepare and validatedConversion correctly rejects it. Before the stub-path safety - // net, the metadata probe reported the UNRELATED sibling column b.bval as NOT NULL (blind + // net, the metadata probe reported the unrelated sibling column b.bval as NOT NULL (blind // to the real LEFT JOIN elsewhere in the same body) even though oldname's own OLD-based - // imprecision was already an accepted limitation. Verified against real Postgres (inserting - // an "a" row with no matching "b" row): oldname = 'orig' (the target row always exists for - // a plain UPDATE, so OLD.name is never actually null here), bval = NULL. The safety net - // deliberately over-approximates — marking every stub column nullable once any outer join - // is detected in the body, not just the ones actually reached through it — so oldname is - // ALSO reported nullable here even though its true answer is NOT NULL: safe-direction - // imprecision, not a regression, and a documented tradeoff (see PgCatalogLoader's - // buildSelectStub and tryPrepareStub KDoc). + // imprecision was already an accepted limitation. Inserting an "a" row with no matching + // "b" row: oldname = 'orig' (the target row always exists for a plain UPDATE, so OLD.name + // is never actually null here), bval = NULL. The safety net deliberately + // over-approximates — marking every stub column nullable once any outer join is detected + // in the body, not just the ones actually reached through it — so oldname is also reported + // nullable here even though its true answer is NOT NULL: safe-direction imprecision, not a + // regression, and a documented tradeoff (see PgCatalogLoader's buildSelectStub and + // tryPrepareStub KDoc). val query = analyzeWithSchema( """ CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL); @@ -4570,11 +4546,11 @@ class QueryAnalysisTest { @Test fun `MERGE detects WHEN NOT MATCHED BY SOURCE despite a comment abutting NOT and MATCHED`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 17, "WHEN NOT MATCHED BY SOURCE requires PostgreSQL 17+") - // skipOptionalKeyword previously required LITERAL whitespace immediately after each + // skipOptionalKeyword previously required literal whitespace immediately after each // keyword, so a comment directly abutting NOT and MATCHED with no surrounding whitespace // broke clause detection entirely, choosing a plain JOIN and fabricating NOT NULL for the - // source column. Verified against real Postgres: id = 1 (NOT NULL, target row), sval = - // NULL (nullable, no matching source row). + // source column. On real Postgres, id = 1 (NOT NULL, target row), sval = NULL (nullable, + // no matching source row). val query = analyzeWithSchema( """ CREATE TABLE tgt (id INT PRIMARY KEY, name TEXT NOT NULL); @@ -4597,13 +4573,13 @@ class QueryAnalysisTest { @Test fun `INSERT with a LEFT JOIN in its own SELECT source reports NOT NULL, not fabricated nullable`() { - // REGRESSION guard: the stub-path safety net previously fired for ANY body with a + // Regression guard: the stub-path safety net previously fired for any body with a // detectable outer join, INSERT included — but an INSERT's RETURNING sees only the row // just inserted, and nothing in its own SELECT source (however joined) can null-extend - // it. Verified against real Postgres: INSERT INTO b(id, bval) SELECT a.id, 'v' FROM a - // LEFT JOIN b2 ON b2.id = a.id RETURNING id, bval returns id=1, bval='v' — both non-null — - // despite the LEFT JOIN in its source. See the companion test below for the UPDATE shape, - // where the net must still fire. + // it. On real Postgres, INSERT INTO b(id, bval) SELECT a.id, 'v' FROM a LEFT JOIN b2 ON + // b2.id = a.id RETURNING id, bval returns id=1, bval='v' — both non-null — despite the + // LEFT JOIN in its source. See the companion test below for the UPDATE shape, where the + // net must still fire. val query = analyzeWithSchema( """ CREATE TABLE a (id INT NOT NULL); @@ -4626,8 +4602,8 @@ class QueryAnalysisTest { @Test fun `UPDATE with a LEFT JOIN still reports nullable — the safety net must keep working`() { // Companion to the INSERT test above: confirms excluding INSERT from the safety net did - // NOT also (over-broadly) exclude UPDATE, which genuinely needs it. Verified against real - // Postgres (inserting an "a" row with no matching "b" row): the query returns bval = NULL. + // not also (over-broadly) exclude UPDATE, which genuinely needs it. Inserting an "a" row + // with no matching "b" row: the query returns bval = NULL. val query = analyzeWithSchema( """ CREATE TABLE t (id INT NOT NULL, name TEXT); @@ -4653,8 +4629,7 @@ class QueryAnalysisTest { // "$b$"-tagged dollar-quote, swallowing the rest of the statement — including the real // "FROM a LEFT JOIN b" — as unterminated string content. Conversion then failed (or // produced garbage), falling back to the metadata probe/stub, which is blind to the LEFT - // JOIN. Verified against real Postgres (inserting an "a" row with no matching "b" row): - // the query returns bval = NULL. + // JOIN. Inserting an "a" row with no matching "b" row: the query returns bval = NULL. val query = analyzeWithSchema( """ CREATE TABLE t (id INT NOT NULL, a${'$'}b${'$'}c TEXT); @@ -4681,8 +4656,8 @@ class QueryAnalysisTest { // nested inside the USING subquery went undetected — merge_action() in RETURNING already // forces this body onto the stub path (it isn't valid outside MERGE's own RETURNING, so // conversion to a plain SELECT fails to prepare and is rejected), and the stub then - // fabricated NOT NULL for the joined column. Verified against real Postgres (sx has no row - // matching src): act = 'UPDATE', id = 1, xval = NULL. The safety net's over-approximation + // fabricated NOT NULL for the joined column. With sx having no row matching src: act = + // 'UPDATE', id = 1, xval = NULL. The safety net's over-approximation // also demotes "id" (the target's PK, always present for a MATCHED row) to nullable here — // an accepted, documented tradeoff, since the stub cannot isolate which columns are // actually reached through the nested join (see buildSelectStub's KDoc). @@ -4709,15 +4684,15 @@ class QueryAnalysisTest { @Test fun `DELETE RETURNING OLD-col alongside an unrelated column no longer drags it into nullable`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 18, "RETURNING OLD requires PostgreSQL 18+") - // FIX 1 regression guard: at e4679ff, forceAllNullable applied to the WHOLE stub once ANY + // Regression guard: at e4679ff, forceAllNullable applied to the whole stub once any // RETURNING item referenced OLD/NEW — so "id" (never touched by OLD/NEW at all) was - // fabricated nullable purely because it shared a RETURNING list with "oldname". Verified - // against real Postgres: DELETE FROM t WHERE id = 1 RETURNING OLD.name, t.id returns - // oldname = 'orig' and id = 1 — BOTH genuinely NOT NULL for this exact row, but "id" is the - // one this fix must stop fabricating nullable for; "oldname" itself is still forced - // nullable (over-approximating in the safe direction, unchanged) since knowing OLD is - // genuinely never-null for a DELETE specifically would require statement-kind-aware logic - // this fix does not add — see oldOrNewReturningColumns's KDoc. + // fabricated nullable purely because it shared a RETURNING list with "oldname". On real + // Postgres, DELETE FROM t WHERE id = 1 RETURNING OLD.name, t.id returns oldname = 'orig' + // and id = 1 — both genuinely NOT NULL for this exact row, but "id" is the one this fix + // must stop fabricating nullable for; "oldname" itself is still forced nullable + // (over-approximating in the safe direction, unchanged) since knowing OLD is genuinely + // never-null for a DELETE specifically would require statement-kind-aware logic this fix + // does not add — see oldOrNewReturningColumns's KDoc. val query = analyzeWithSchema( "CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL)", """ @@ -4735,10 +4710,10 @@ class QueryAnalysisTest { @Test fun `UPDATE RETURNING OLD-col alongside the target's own column stays NOT NULL for the target column`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 18, "RETURNING OLD requires PostgreSQL 18+") - // Same regression as above, UPDATE form. Verified against real Postgres: UPDATE t SET name - // = 'x' WHERE id = 1 RETURNING OLD.name AS oldname, t.id returns oldname = 'orig', id = 1 — - // both genuinely NOT NULL (an UPDATE's target row always exists), but only "id" (untouched - // by OLD/NEW) is asserted NOT NULL here; "oldname" is still forced nullable by design. + // Same regression as above, UPDATE form. On real Postgres, UPDATE t SET name = 'x' WHERE + // id = 1 RETURNING OLD.name AS oldname, t.id returns oldname = 'orig', id = 1 — both + // genuinely NOT NULL (an UPDATE's target row always exists), but only "id" (untouched by + // OLD/NEW) is asserted NOT NULL here; "oldname" is still forced nullable by design. val query = analyzeWithSchema( "CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL)", """ @@ -4756,10 +4731,10 @@ class QueryAnalysisTest { @Test fun `INSERT ON CONFLICT RETURNING OLD-col alongside an unrelated column stays NOT NULL for it`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 18, "RETURNING OLD requires PostgreSQL 18+") - // Same regression, INSERT ON CONFLICT form. Verified against real Postgres: INSERT INTO t - // (id, bval) VALUES (1, 'new') ON CONFLICT (id) DO UPDATE SET bval = 'updated' RETURNING - // OLD.bval, t.id returns id = 1 (NOT NULL — the conflicting row's id, always present), - // bval potentially NULL (a fresh insert has no OLD row) — asserting only on "id" here. + // Same regression, INSERT ON CONFLICT form. On real Postgres, INSERT INTO t (id, bval) + // VALUES (1, 'new') ON CONFLICT (id) DO UPDATE SET bval = 'updated' RETURNING OLD.bval, + // t.id returns id = 1 (NOT NULL — the conflicting row's id, always present), bval + // potentially NULL (a fresh insert has no OLD row) — asserting only on "id" here. val query = analyzeWithSchema( "CREATE TABLE t (id INT PRIMARY KEY, bval TEXT)", """ @@ -4779,9 +4754,9 @@ class QueryAnalysisTest { @Test fun `DELETE USING RETURNING OLD-col alongside the target's own column stays NOT NULL for it`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 18, "RETURNING OLD requires PostgreSQL 18+") - // Same regression, DELETE ... USING form. Verified against real Postgres: DELETE FROM t - // USING a WHERE t.id = a.id RETURNING OLD.name AS oldname, t.id returns oldname = 'orig', - // id = 1 — both genuinely NOT NULL — asserting only on "id" here (untouched by OLD/NEW). + // Same regression, DELETE ... USING form. On real Postgres, DELETE FROM t USING a WHERE + // t.id = a.id RETURNING OLD.name AS oldname, t.id returns oldname = 'orig', id = 1 — both + // genuinely NOT NULL — asserting only on "id" here (untouched by OLD/NEW). val query = analyzeWithSchema( """ CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL); @@ -4803,14 +4778,14 @@ class QueryAnalysisTest { fun `RETURNING star alongside an OLD reference reports id and name NOT NULL via prosqlbody`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 18, "RETURNING OLD requires PostgreSQL 18+") // Before the prosqlbody cutover, a star item recognized alongside an OLD/NEW reference fell - // back to forcing EVERY column nullable — an accepted tradeoff for the old text-based + // back to forcing every column nullable — an accepted tradeoff for the old text-based // machinery, since a star's real expansion width is unknown to it without asking PostgreSQL. // prosqlbody reads "id"/"name" as plain Vars against a real DELETE target row (never // rewritten into a stub, no OLD/NEW tagging on either), so it correctly reports both NOT // NULL per the schema's own constraint. "oldname" stays nullable regardless — this // analyzer's OLD-forcing rule is deliberately blanket (see PgNodeExpression.Var.returningType's // KDoc), over-conservative for a plain DELETE specifically (OLD always exists there) but - // still safe. Verified live: DELETE FROM t WHERE id = 1 RETURNING OLD.name, t.* returns + // still safe. On real Postgres, DELETE FROM t WHERE id = 1 RETURNING OLD.name, t.* returns // oldname = 'orig', id = 1, name = 'orig' — all genuinely NOT NULL. val query = analyzeWithSchema( "CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL)", @@ -4839,7 +4814,7 @@ class QueryAnalysisTest { // NodeTreeNullabilityAnalyzer's own KDoc) — since "name" is declared NOT NULL, it correctly // reports newname NOT NULL. "oldname" stays nullable regardless — this analyzer's OLD-forcing // rule is deliberately blanket, over-conservative for a plain UPDATE specifically (OLD always - // exists there) but still safe. Verified live: UPDATE t SET name = 'x' WHERE id = 1 + // exists there) but still safe. On real Postgres, UPDATE t SET name = 'x' WHERE id = 1 // RETURNING WITH (OLD AS o, NEW AS n) o.name AS oldname, n.name AS newname, t.id returns // oldname = 'orig', newname = 'x', id = 1 — all genuinely NOT NULL. val query = analyzeWithSchema( @@ -4861,15 +4836,15 @@ class QueryAnalysisTest { @Test fun `MERGE fed by a sibling CTE with an internal LEFT JOIN forces the joined column nullable`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 17, "merge_action() requires PostgreSQL 17+") - // FIX 3: "pre" (a sibling CTE, not part of "m"'s own body text) contains a LEFT JOIN whose + // "pre" (a sibling CTE, not part of "m"'s own body text) contains a LEFT JOIN whose // null-extension is entirely invisible to any scan of "m"'s own text — "m" itself has no // join at all. merge_action() in RETURNING forces this body onto the stub path (not valid // outside MERGE's own RETURNING, so the structural conversion fails to prepare and is // rejected); before this fix, the stub's base-table attnotnull fabricated "bval" as NOT - // NULL despite the real LEFT JOIN living in "pre". Verified against real Postgres (an "a" - // row with no matching "b" row): act = 'UPDATE', bval = NULL, id = 1 (the target's own PK, - // always present for a MATCHED row — also demoted to nullable here, an accepted tradeoff, - // same as the existing nested-USING-subquery LEFT JOIN test above). + // NULL despite the real LEFT JOIN living in "pre". With an "a" row with no matching "b" + // row: act = 'UPDATE', bval = NULL, id = 1 (the target's own PK, always present for a + // MATCHED row — also demoted to nullable here, an accepted tradeoff, same as the existing + // nested-USING-subquery LEFT JOIN test above). val query = analyzeWithSchema( """ CREATE TABLE tgt (id INT PRIMARY KEY, tval TEXT NOT NULL); @@ -4895,12 +4870,11 @@ class QueryAnalysisTest { @Test fun `MERGE fed by a double-quoted sibling reference still forces the joined column nullable`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 17, "merge_action() requires PostgreSQL 17+") - // Issue #208, Gap 1: quoting an otherwise unremarkable lowercase sibling name ("pre") - // used to defeat referencesAnyName entirely, since its underlying scan skipped - // double-quoted identifiers as an opaque lexical token by design. referencesAnyName now - // scans a quoted identifier's own contents instead. Verified against real Postgres 18 (an - // "a" row with no matching "b" row, target "tgt" row id = 1): act = 'UPDATE', bval = NULL, - // id = 1. + // Quoting an otherwise unremarkable lowercase sibling name ("pre") used to defeat + // referencesAnyName entirely, since its underlying scan skipped double-quoted identifiers + // as an opaque lexical token by design. referencesAnyName now scans a quoted identifier's + // own contents instead. PostgreSQL 18, with an "a" row with no matching "b" row and target + // "tgt" row id = 1: act = 'UPDATE', bval = NULL, id = 1. val query = analyzeWithSchema( """ CREATE TABLE tgt (id INT PRIMARY KEY, tval TEXT NOT NULL); @@ -4926,16 +4900,16 @@ class QueryAnalysisTest { @Test fun `MERGE with a ROLLUP source forces the grouped column nullable`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 17, "merge_action() requires PostgreSQL 17+") - // Issue #208, Gap 2: a ROLLUP supertotal row makes the grouped column NULL by definition, - // matched into the target only via a COALESCE in the ON condition -- no LEFT/RIGHT/FULL - // JOIN keyword and no WHEN NOT MATCHED BY SOURCE clause appears anywhere in the body for - // the pre-existing detectors to find. hasGroupingSetConstruct now recognizes ROLLUP/CUBE/ - // GROUPING SETS as a third null-extending construct. Verified against real Postgres 18 - // (an "a" row with id = 1, tgt rows id = 1 and id = 2): the id = 1 row of the source - // matches tgt id = 1 (sid = 1, not the supertotal), and the ROLLUP supertotal row (s.id = - // NULL) matches tgt id = 2 via COALESCE(s.id, 2) = 2 -- two result rows, merge_action = - // 'UPDATE' for both, {sid = 1, id = 1} and {sid = NULL, id = 2} (MERGE's own output order - // across rows is unspecified; only the values were confirmed, not this ordering). + // A ROLLUP supertotal row makes the grouped column NULL by definition, matched into the + // target only via a COALESCE in the ON condition -- no LEFT/RIGHT/FULL JOIN keyword and no + // WHEN NOT MATCHED BY SOURCE clause appears anywhere in the body for the pre-existing + // detectors to find. hasGroupingSetConstruct now recognizes ROLLUP/CUBE/GROUPING SETS as a + // third null-extending construct. PostgreSQL 18, with an "a" row with id = 1 and tgt rows + // id = 1 and id = 2: the id = 1 row of the source matches tgt id = 1 (sid = 1, not the + // supertotal), and the ROLLUP supertotal row (s.id = NULL) matches tgt id = 2 via + // COALESCE(s.id, 2) = 2 -- two result rows, merge_action = 'UPDATE' for both, {sid = 1, id + // = 1} and {sid = NULL, id = 2} (MERGE's own output order across rows is unspecified; only + // the values were confirmed, not this ordering). val query = analyzeWithSchema( """ CREATE TABLE tgt (id INT PRIMARY KEY, tval TEXT NOT NULL); @@ -4958,13 +4932,12 @@ class QueryAnalysisTest { @Test fun `MERGE fed by a transitive sibling chain forces the joined column nullable`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 17, "merge_action() requires PostgreSQL 17+") - // Issue #208, Gap 3: "m" references "mid", which has no join of its own -- the LEFT JOIN - // lives in "j", which is "mid"'s sibling, not "m"'s. The pre-existing one-level-deep - // sibling-danger check stopped at "mid" and never saw "j". computeDangerousSiblingNames now - // computes the danger set as a fixpoint over the whole WITH clause, so "mid" (which - // references "j") joins the dangerous set first, then "m" (which references "mid") joins - // next. Verified against real Postgres 18 (an "a" row with no matching "b" row): act = - // 'UPDATE', bval = NULL, id = 1. + // "m" references "mid", which has no join of its own -- the LEFT JOIN lives in "j", which + // is "mid"'s sibling, not "m"'s. The pre-existing one-level-deep sibling-danger check + // stopped at "mid" and never saw "j". computeDangerousSiblingNames now computes the danger + // set as a fixpoint over the whole WITH clause, so "mid" (which references "j") joins the + // dangerous set first, then "m" (which references "mid") joins next. PostgreSQL 18, with + // an "a" row with no matching "b" row: act = 'UPDATE', bval = NULL, id = 1. val query = analyzeWithSchema( """ CREATE TABLE tgt (id INT PRIMARY KEY, tval TEXT NOT NULL); @@ -4987,10 +4960,10 @@ class QueryAnalysisTest { @Test fun `MERGE USING a non-MATERIALIZED CTE source correctly reports a passed-through key column NOT NULL`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 17, "MERGE RETURNING requires PostgreSQL 17+") - // #238: end-to-end pin for the exact shape that previously lost provenance for the WHOLE - // query (queryColumnNullabilityViaProsqlbody's mergeAbsentVarnos ?: return null short- - // circuited the ENTIRE probe, so every column -- not just the CTE-sourced ones -- fell back - // to nullable with no provenance). "desc_source.id" is parent's own primary key, genuinely + // End-to-end pin for the exact shape that previously lost provenance for the whole query + // (queryColumnNullabilityViaProsqlbody's mergeAbsentVarnos ?: return null short-circuited + // the entire probe, so every column -- not just the CTE-sourced ones -- fell back to + // nullable with no provenance). "desc_source.id" is parent's own primary key, genuinely // NOT NULL; "merged_description" is UPPER(description) over a nullable column, genuinely // nullable regardless of this fix. val query = analyzeWithSchema( @@ -5016,7 +4989,7 @@ class QueryAnalysisTest { @Test fun `MERGE fed by a CTE source correctly reports the passed-through column NOT NULL`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 17, "merge_action() requires PostgreSQL 17+") - // #238: PgCatalogLoader.mergeAbsentVarnos now attributes a MERGE's join to a CTE source too + // PgCatalogLoader.mergeAbsentVarnos now attributes a MERGE's join to a CTE source too // (previously only a plain base table), via the CTE's own literal name -- "ins" appears // directly as a "CTE Scan" node's "CTE Name" here, since a data-modifying CTE is never // inlined. With an "a" row and no matching "b" row, the INSERT inserts one row into @@ -5050,18 +5023,18 @@ class QueryAnalysisTest { @Test fun `MERGE fed by an INSERT ON CONFLICT sibling that RETURNS OLD-col forces the passed-through column nullable`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 18, "RETURNING OLD requires PostgreSQL 18+") - // Issue #208 P1 follow-up: the seed's "!isInsertBody" exclusion (added for the precision - // guard test above) is correct for an INSERT's ORDINARY target-column RETURNING, but an + // A follow-up finding: the seed's "!isInsertBody" exclusion (added for the precision + // guard test above) is correct for an INSERT's ordinary target-column RETURNING, but an // INSERT's RETURNING can also read OLD./NEW., whose own conditional existence attnotnull - // cannot see regardless of statement kind -- excluding EVERY INSERT body from the seed, + // cannot see regardless of statement kind -- excluding every INSERT body from the seed, // rather than only excluding hasNullExtendingConstruct's own-join trigger, silently dropped // this danger sign. computeDangerousSiblingNames now seeds separately on // oldOrNewReturningColumns (which also understands a RETURNING WITH (OLD AS alias, ...) // prologue), regardless of isInsertBody. "ins" is an INSERT ... ON CONFLICT DO UPDATE // RETURNING OLD.val -- OLD is NULL exactly when the row was freshly inserted (no prior - // conflict) -- and "m" merely passes ins.oldval through. Verified against real Postgres 18 - // (an "a" row with no matching "b" row, no pre-existing "it2" row so the INSERT always - // takes the fresh-insert branch): act = 'UPDATE', ov = NULL, id = 1. + // conflict) -- and "m" merely passes ins.oldval through. PostgreSQL 18, with an "a" row + // with no matching "b" row and no pre-existing "it2" row so the INSERT always takes the + // fresh-insert branch: act = 'UPDATE', ov = NULL, id = 1. val query = analyzeWithSchema( """ CREATE TABLE tgt (id INT PRIMARY KEY, tval TEXT NOT NULL); @@ -5096,9 +5069,9 @@ class QueryAnalysisTest { // (unlike a bare referencesOldOrNew call) already understands this prologue, so an aliased // reference must trip the same seed. "ins" is an INSERT ... ON CONFLICT DO UPDATE // RETURNING WITH (OLD AS o) id, o.val AS oldval -- OLD is NULL exactly when the row was - // freshly inserted -- and "m" merely passes ins.oldval through. Verified against real - // Postgres 18 (an "a" row with no pre-existing "it2" row, so the INSERT always takes the - // fresh-insert branch): act = 'UPDATE', ov = NULL, id = 1. + // freshly inserted -- and "m" merely passes ins.oldval through. PostgreSQL 18, with an "a" + // row with no pre-existing "it2" row, so the INSERT always takes the fresh-insert branch: + // act = 'UPDATE', ov = NULL, id = 1. val query = analyzeWithSchema( """ CREATE TABLE tgt (id INT PRIMARY KEY, tval TEXT NOT NULL); @@ -5126,13 +5099,13 @@ class QueryAnalysisTest { @Test fun `forward-referencing MERGE under WITH RECURSIVE fed by a later sibling with a LEFT JOIN is nullable`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 17, "merge_action() requires PostgreSQL 17+") - // FIX 3 / genuine NEW-harm regression: "m" (declared FIRST) forward-references "pre" - // (declared AFTER it) under WITH RECURSIVE, which makes every sibling name visible to every - // other body regardless of declaration order. At e4679ff, this shape silently typed "bval" - // NOT NULL — the referencesAnyName sibling check (and its only trigger point) did not exist - // yet, so the stub path had nothing to force it nullable with, despite "pre"'s own LEFT - // JOIN null-extending it exactly as in the plain-WITH sibling test above. Verified against - // real Postgres (an "a" row with no matching "b" row): act = 'UPDATE', bval = NULL, id = 1. + // "m" (declared first) forward-references "pre" (declared after it) under WITH RECURSIVE, + // which makes every sibling name visible to every other body regardless of declaration + // order. At e4679ff, this shape silently typed "bval" NOT NULL — the referencesAnyName + // sibling check (and its only trigger point) did not exist yet, so the stub path had + // nothing to force it nullable with, despite "pre"'s own LEFT JOIN null-extending it + // exactly as in the plain-WITH sibling test above. With an "a" row with no matching "b" + // row: act = 'UPDATE', bval = NULL, id = 1. val query = analyzeWithSchema( """ CREATE TABLE tgt (id INT PRIMARY KEY, tval TEXT NOT NULL); @@ -5164,8 +5137,8 @@ class QueryAnalysisTest { // are not valid range variables outside RETURNING, so the converted SELECT fails to // prepare) nor plain metadata (which reflects base-table attnotnull, oblivious to OLD/NEW's // conditional existence) can see this — the safety net now forces both nullable whenever a - // body's RETURNING references OLD./NEW., regardless of join structure. Verified against - // real Postgres: OLD.name = 'orig', NEW.name = NULL. + // body's RETURNING references OLD./NEW., regardless of join structure. On real Postgres, + // OLD.name = 'orig', NEW.name = NULL. val query = analyzeWithSchema( "CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL)", """ @@ -5183,18 +5156,18 @@ class QueryAnalysisTest { @Test fun `INSERT ON CONFLICT RETURNING OLD-col is nullable even though INSERT skips the join-based net`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 18, "RETURNING OLD requires PostgreSQL 18+") - // NOT independently demonstrative of Fix 4: checked directly against the driver - // (PreparedStatement.getMetaData()), PostgreSQL's OWN metadata already reports + // This alone does not demonstrate the OLD/NEW safety-net fix: checked directly against the + // driver (PreparedStatement.getMetaData()), PostgreSQL's own metadata already reports // OLD.bval as nullable for this exact shape, with no forceAllNullable involved — so this - // body would pass even without referencesOldOrNew. What this DOES confirm (per the - // coordinator's request) is that the two forceAllNullable triggers are independent: this - // body IS an INSERT (per isInsertBody, excluded from the join-based trigger) with no join - // at all, and still correctly ends up nullable — proving isInsertBody's exclusion doesn't - // also (incorrectly) suppress the OLD/NEW trigger. The DELETE test below, where raw - // PostgreSQL metadata IS wrong without the fix, is the demonstrative case for Fix 4. - // Ground truth for OLD.bval's real nullability: with an existing row (a genuine conflict), - // OLD.bval = 'orig'; with no conflict (a fresh insert), OLD.bval = NULL — so across - // possible executions the column is genuinely nullable, not merely over-approximated. + // body would pass even without referencesOldOrNew. What this does confirm is that the two + // forceAllNullable triggers are independent: this body is an INSERT (per isInsertBody, + // excluded from the join-based trigger) with no join at all, and still correctly ends up + // nullable — proving isInsertBody's exclusion doesn't also (incorrectly) suppress the + // OLD/NEW trigger. The DELETE test below, where raw PostgreSQL metadata is wrong without + // the fix, is the demonstrative case. Ground truth for OLD.bval's real nullability: with + // an existing row (a genuine conflict), OLD.bval = 'orig'; with no conflict (a fresh + // insert), OLD.bval = NULL — so across possible executions the column is genuinely + // nullable, not merely over-approximated. val query = analyzeWithSchema( "CREATE TABLE t (id INT PRIMARY KEY, bval TEXT)", """ @@ -5397,15 +5370,15 @@ class QueryAnalysisTest { fun `RETURNING item with no usable name is resolved by the outer query`() { // A RETURNING item that is neither a plain column reference nor a simple cast (here, // string concatenation) has no name of its own, so PostgreSQL reports it as the literal - // "?column?" (verified directly against real Postgres via psql \gdesc) — not a valid bare - // identifier at all. Before the fix, tryPrepareStub emitted it unquoted ("AS ?column?"), - // a syntax error in the stub SELECT, which failed CREATE VIEW the same way #204's mixed- - // case alias did. Deliberately concatenates the nullable "name" column (rather than a - // literal like "RETURNING 1", which is always non-null and would pass either way, masking - // the bug the same way "id" did in the test above) so the CREATE-VIEW failure's - // top-level analyzeUnconvertibleDml fallback (queryColumnNullability's last resort when + // "?column?" (confirmed via psql \gdesc) — not a valid bare identifier at all. Before the + // fix, tryPrepareStub emitted it unquoted ("AS ?column?"), a syntax error in the stub + // SELECT, which failed CREATE VIEW the same way the mixed-case-alias test below's shape + // did. Deliberately concatenates the nullable "name" column (rather than a literal like + // "RETURNING 1", which is always non-null and would pass either way, masking the bug the + // same way "id" did in the test above) so the CREATE-VIEW failure's top-level + // analyzeUnconvertibleDml fallback (queryColumnNullability's last resort when // analyzeViaTemporaryView on the transformed SQL itself throws) is observable — before - // issue #207's fix, that fallback wrongly asserted NOT NULL here regardless of truth. + // this fix, that fallback wrongly asserted NOT NULL here regardless of truth. val query = analyzeWithSchema( "CREATE TABLE t (id SERIAL NOT NULL, name TEXT)", """ @@ -5422,20 +5395,19 @@ class QueryAnalysisTest { @Test fun `quoted mixed-case RETURNING alias in a data-modifying CTE body is resolved by the outer query`() { - // Reproduces #204: "ins"'s body is a plain INSERT, so it never reaches - // convertDmlCteBodyToSelect (whose join-preserving conversion is limited to - // UPDATE/DELETE/MERGE) and stays on the tryPrepareStub path. - // ResultSetMetaData.getColumnName reports the RETURNING alias exactly as declared, - // "myId", but before the fix tryPrepareStub emitted it unquoted ("AS myId"), which - // PostgreSQL folds to lowercase "myid" when building the stub SELECT used for + // "ins"'s body is a plain INSERT, so it never reaches convertDmlCteBodyToSelect (whose + // join-preserving conversion is limited to UPDATE/DELETE/MERGE) and stays on the + // tryPrepareStub path. ResultSetMetaData.getColumnName reports the RETURNING alias exactly + // as declared, "myId", but before the fix tryPrepareStub emitted it unquoted ("AS myId"), + // which PostgreSQL folds to lowercase "myid" when building the stub SELECT used for // CREATE VIEW. The outer query's quoted reference to ins."myId" then fails to resolve - // against the stub ("column ins.myId does not exist" — verified directly against real - // Postgres via psql). Inside queryColumnNullability, that SQLException is caught and - // degraded to analyzeUnconvertibleDml's fallback — before issue #207's fix, that fallback - // asserted EVERY column NOT NULL regardless of truth — so "name" is nullable in the schema - // (no NOT NULL constraint), but before the fix this test wrongly reports it NOT NULL. Deliberately uses a nullable - // source column (not id, which is NOT NULL and would pass either way, masking the bug) - // so the wrong fallback is actually observable as an assertion failure. + // against the stub ("column ins.myId does not exist" — confirmed via psql). Inside + // queryColumnNullability, that SQLException is caught and degraded to + // analyzeUnconvertibleDml's fallback — before this fix, that fallback asserted every + // column NOT NULL regardless of truth — so "name" is nullable in the schema (no NOT NULL + // constraint), but before the fix this test wrongly reports it NOT NULL. Deliberately uses + // a nullable source column (not id, which is NOT NULL and would pass either way, masking + // the bug) so the wrong fallback is actually observable as an assertion failure. val query = analyzeWithSchema( "CREATE TABLE t (id SERIAL NOT NULL, name TEXT)", """ @@ -5457,8 +5429,8 @@ class QueryAnalysisTest { // real column name is my"Id). tryPrepareStub must double the embedded quote when // re-quoting the alias for the stub SELECT ("AS \"my\"\"Id\""); emitting only a single // doubled quote or none at all would produce invalid SQL or fold/mismatch the name, and - // the outer query's reference would fail to resolve the same way as #204 — degrading to - // the same wrong-NOT-NULL fallback described above. + // the outer query's reference would fail to resolve the same way as the test above — + // degrading to the same wrong-NOT-NULL fallback described there. val query = analyzeWithSchema( "CREATE TABLE t (id SERIAL NOT NULL, name TEXT)", """ @@ -5475,11 +5447,11 @@ class QueryAnalysisTest { @Test fun `a CTE body's own local WITH shadowing a sibling of the same name resolves against the local body`() { - // #257 follow-up: buildInnerCteNotNull resolved a DIRECT :rtable reference to a CTE against + // buildInnerCteNotNull resolved a direct :rtable reference to a CTE against // previouslyResolved (sibling CTEs) with no ctelevelsup check, so b's own local "c" (over - // nullable w.v) was shadowed by the OUTER sibling "c" (over NOT NULL u.v) and this reported - // notNull=true. Verified live against PostgreSQL 18: returns null once w has a NULL row, - // since b's own body reads its OWN local c, not the outer one. + // nullable w.v) was shadowed by the outer sibling "c" (over NOT NULL u.v) and this reported + // notNull=true. PostgreSQL 18: returns null once w has a NULL row, since b's own body + // reads its own local c, not the outer one. val query = analyzeWithSchema( "CREATE TABLE u (v TEXT NOT NULL); CREATE TABLE w (v TEXT)", """ @@ -5496,8 +5468,8 @@ class QueryAnalysisTest { // Same ctelevelsup hazard as the direct-reference test above, but reached through a SubLink // inside b's own body instead of a plain target-list reference — this is what // buildAnalyzer's `resolvedCtes = ownResolvedCtes` (not previouslyResolved) at - // buildCteBodyAnalyzer's call site protects. Verified live against PostgreSQL 18: returns - // null once w has a NULL row and t.a matches no non-null row of b's OWN local sib. + // buildCteBodyAnalyzer's call site protects. PostgreSQL 18: returns null once w has a NULL + // row and t.a matches no non-null row of b's own local sib. val query = analyzeWithSchema( """ CREATE TABLE t (id INT NOT NULL, a TEXT NOT NULL); @@ -5624,9 +5596,9 @@ class QueryAnalysisTest { @Test fun `UPDATE FROM INNER JOIN RETURNING`() { - // prosqlbody reads the RAW :targetList assignment directly — "nickname" is assigned + // prosqlbody reads the raw :targetList assignment directly — "nickname" is assigned // `d.id::TEXT`, a cast over a NOT NULL column reached through a plain (non-outer) JOIN — so - // it correctly reports "nickname" NOT NULL. Verified live: this exact UPDATE returns + // it correctly reports "nickname" NOT NULL. On real Postgres, this exact UPDATE returns // `nickname = '1'`, never NULL. val query = analyzeWithSchema( "CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL, nickname TEXT); CREATE TABLE d (id INT NOT NULL, t_id INT NOT NULL)", @@ -5669,8 +5641,8 @@ class QueryAnalysisTest { @Test fun `MERGE RETURNING with a non-table source reports every column nullable`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 17, "MERGE RETURNING requires PostgreSQL 17+") - // RETURNING * on a MERGE expands to BOTH relations' columns — verified live: this exact - // statement returns 4 columns (s.id, s.name, t.id, t.name), all genuinely NOT NULL here + // RETURNING * on a MERGE expands to both relations' columns — this exact statement returns + // 4 columns (s.id, s.name, t.id, t.name), all genuinely NOT NULL here // (the source is a fixed-literal derived table, never actually absent or null). But // PgCatalogLoader.mergeAbsentVarnos only attributes a MERGE's join to a source relation // that is itself a plain base table (an :rtable entry with rtekind 0) — a subquery/VALUES @@ -5697,15 +5669,15 @@ class QueryAnalysisTest { @Test fun `top-level MERGE RETURNING merge_action() does not abort generation`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 17, "merge_action() requires PostgreSQL 17+") - // FIX 1 [P0]: convertDmlToSelect splices merge_action() into a plain SELECT verbatim, where - // it is not valid PostgreSQL (merge_action() only works inside MERGE's own RETURNING) — so - // the converted SELECT fails to prepare. At d1153f3, Phase 2 (the TOP-LEVEL, non-CTE - // conversion path) had no validation gate at all: the bad SELECT reached CREATE VIEW, and - // the resulting SQLException was thrown from INSIDE queryColumnNullability's own catch - // block, escaping uncaught and aborting the whole build on SQL PostgreSQL itself accepts - // fine. Verified against real Postgres (a matched row, no WHEN NOT MATCHED branch — every - // returned row genuinely has a target AND source row present): act = 'UPDATE', aval = 'a1', - // id = 1 — all three genuinely NOT NULL. + // convertDmlToSelect splices merge_action() into a plain SELECT verbatim, where it is not + // valid PostgreSQL (merge_action() only works inside MERGE's own RETURNING) — so the + // converted SELECT fails to prepare. At d1153f3, Phase 2 (the top-level, non-CTE conversion + // path) had no validation gate at all: the bad SELECT reached CREATE VIEW, and the + // resulting SQLException was thrown from inside queryColumnNullability's own catch block, + // escaping uncaught and aborting the whole build on SQL PostgreSQL itself accepts fine. On + // real Postgres, with a matched row and no WHEN NOT MATCHED branch — every returned row + // genuinely has a target and source row present: act = 'UPDATE', aval = 'a1', id = 1 — all + // three genuinely NOT NULL. // // "aval" and "id" are NOT NULL via honestly-read ResultSetMetaData (a simple column // reference tracing to its source column's attnotnull). "act" (a bare function call) reports @@ -5715,7 +5687,7 @@ class QueryAnalysisTest { // (both because merge_action() itself is invalid there, and because "a" isn't in that // probe's FROM list at all) — the probe returns `null` and analyzeUnconvertibleDml falls // back to its own nullable default for "act": nullability analysis must be correct or - // silent, and this file cannot PROVE merge_action() is non-null here even though it always + // silent, and this file cannot prove merge_action() is non-null here even though it always // is in practice. val query = analyzeWithSchema( """ @@ -5766,29 +5738,30 @@ class QueryAnalysisTest { @Test fun `top-level MERGE RETURNING OLD-col does not abort generation`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 18, "RETURNING OLD requires PostgreSQL 18+") - // Same FIX 1 crash, OLD-reference variant (also not valid outside RETURNING, same failure - // mode as merge_action()) — rejected conversion falls through to analyzeUnconvertibleDml. - // Verified against real Postgres (matched-row-only MERGE, so every returned row's OLD is the - // pre-existing target row, always present): OLD.tval = 'x', genuinely NOT NULL for this - // exact shape. Before issue #207's fix, this happened to be reported correctly (NOT NULL) - // only by coincidence, via the old fallback that asserted every column NOT NULL - // unconditionally. analyzeUnconvertibleDml now applies the SAME per-column OLD/NEW forcing - // the CTE-body stub path always has (see `UPDATE RETURNING OLD-col alongside the target's - // own column stays NOT NULL for the target column` above for the CTE-wrapped precedent), - // which forces every OLD/NEW-referencing column nullable BY DESIGN regardless of whether a - // specific MERGE shape happens to make it always present — an accepted, deliberate loss of - // precision in the safe direction, not a regression. + // Same crash as above, OLD-reference variant (also not valid outside RETURNING, same + // failure mode as merge_action()) — rejected conversion falls through to + // analyzeUnconvertibleDml. On real Postgres, with a matched-row-only MERGE, so every + // returned row's OLD is the pre-existing target row, always present: OLD.tval = 'x', + // genuinely NOT NULL for this exact shape. Before this fix, this happened to be reported + // correctly (NOT NULL) only by coincidence, via the old fallback that asserted every column + // NOT NULL unconditionally. analyzeUnconvertibleDml now applies the same per-column OLD/NEW + // forcing the CTE-body stub path always has (see `UPDATE RETURNING OLD-col alongside the + // target's own column stays NOT NULL for the target column` above for the CTE-wrapped + // precedent), which forces every OLD/NEW-referencing column nullable by design regardless + // of whether a specific MERGE shape happens to make it always present — an accepted, + // deliberate loss of precision in the safe direction, not a regression. // - // This assertion CANNOT be restored to main's `isTrue()`: this column's own nullability is + // This assertion cannot be restored to main's `isTrue()`: this column's own nullability is // driven entirely by the per-column OLD/NEW forcing above, never by // `metadata.isNullable`/`columnNullableUnknown` (there is no non-OLD/NEW column here at // all), so it is untouched by, and independent of, the `columnNullableUnknown` handling fix // (see the `merge_action()` tests above). Reverting it to NOT NULL would mean removing the - // OLD/NEW forcing for THIS shape specifically while keeping it for `WHEN NOT MATCHED THEN - // INSERT ... RETURNING OLD.tval` (the "freshly-inserted row" test below), which the text scan - // this predicate runs on cannot distinguish — both are `RETURNING OLD.tval` on a single-item - // list; only the `WHEN` branches differ, and no static scan tells them apart. Keeping the - // over-approximation for both is the same accepted tradeoff already documented above. + // OLD/NEW forcing for this shape specifically while keeping it for `WHEN NOT MATCHED THEN + // INSERT ... RETURNING OLD.tval` (the "freshly-inserted row" test below), which the text + // scan this predicate runs on cannot distinguish — both are `RETURNING OLD.tval` on a + // single-item list; only the `WHEN` branches differ, and no static scan tells them apart. + // Keeping the over-approximation for both is the same accepted tradeoff already documented + // above. val query = analyzeWithSchema( """ CREATE TABLE tgt (id INT PRIMARY KEY, tval TEXT NOT NULL); @@ -5807,11 +5780,11 @@ class QueryAnalysisTest { @Test fun `MERGE INTO with WHEN NOT MATCHED THEN INSERT RETURNING OLD-col is nullable for a freshly-inserted row`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 18, "RETURNING OLD requires PostgreSQL 18+") - // Issue #207 shape 1: a freshly INSERTed row via MERGE has no prior row, so OLD.tval is - // genuinely NULL — before the fix, the top-level no-join-structure fallback - // (analyzeUnconvertibleDml's predecessor) asserted it NOT NULL unconditionally. Verified - // against real Postgres (a is INSERT INTO a VALUES (1, 'a1'), tgt starts empty, so a.id has - // no matching tgt row and WHEN NOT MATCHED fires): oldv = NULL. + // A freshly INSERTed row via MERGE has no prior row, so OLD.tval is genuinely NULL — + // before the fix, the top-level no-join-structure fallback + // (analyzeUnconvertibleDml's predecessor) asserted it NOT NULL unconditionally. On real + // Postgres, with a is INSERT INTO a VALUES (1, 'a1'), tgt starts empty, so a.id has no + // matching tgt row and WHEN NOT MATCHED fires: oldv = NULL. val query = analyzeWithSchema( """ CREATE TABLE tgt (id INT PRIMARY KEY, tval TEXT NOT NULL); @@ -5830,9 +5803,8 @@ class QueryAnalysisTest { @Test fun `MERGE INTO with WHEN MATCHED THEN DELETE RETURNING NEW-col is nullable for a deleted row`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 18, "RETURNING NEW requires PostgreSQL 18+") - // Issue #207 shape 2: a deleted row has no resulting row, so NEW.tval is genuinely NULL — - // same fallback, same fix. Verified against real Postgres (tgt has a matching row for a): - // NEW.tval = NULL. + // A deleted row has no resulting row, so NEW.tval is genuinely NULL — same fallback, same + // fix. On real Postgres, with tgt having a matching row for a: NEW.tval = NULL. val query = analyzeWithSchema( """ CREATE TABLE tgt (id INT PRIMARY KEY, tval TEXT NOT NULL); @@ -5851,10 +5823,10 @@ class QueryAnalysisTest { @Test fun `INSERT ON CONFLICT DO UPDATE RETURNING OLD-col is nullable for a freshly-inserted row`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 18, "RETURNING OLD requires PostgreSQL 18+") - // Issue #207 shape 3: a fresh INSERT (no conflicting row) has no prior row, so OLD.tval is - // genuinely NULL — a plain top-level INSERT ... ON CONFLICT with no CTE and no MERGE at all, - // the simplest possible top-level DML shape this fix covers. Verified against real Postgres - // (tgt starts empty, so the INSERT hits no conflict): OLD.tval = NULL. + // A fresh INSERT (no conflicting row) has no prior row, so OLD.tval is genuinely NULL — a + // plain top-level INSERT ... ON CONFLICT with no CTE and no MERGE at all, the simplest + // possible top-level DML shape this fix covers. On real Postgres, with tgt starting empty, + // so the INSERT hits no conflict: OLD.tval = NULL. val query = analyzeWithSchema( "CREATE TABLE tgt (id INT PRIMARY KEY, tval TEXT NOT NULL)", """ @@ -5869,15 +5841,15 @@ class QueryAnalysisTest { @Test fun `top-level MERGE RETURNING merge_action() alongside a LEFT JOIN in USING forces every column nullable`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 17, "merge_action() requires PostgreSQL 17+") - // Issue #207 shape 4: merge_action() forces Phase 2's conversion to be rejected (not valid - // outside MERGE's own RETURNING), so this falls to analyzeUnconvertibleDml — which now - // detects the LEFT JOIN nested in the USING subquery via the same null-extending-construct - // trigger the CTE-body stub path already has, forcing EVERY column nullable, "act" included - // — the same accepted over-approximation the CTE-wrapped equivalent test above (`MERGE with - // a LEFT JOIN nested in its USING subquery reports the joined column nullable`) documents, - // now reached for a bare top-level statement instead of one wrapped in a CTE. Verified - // against real Postgres (b has no row matching a): act = 'UPDATE', bval = NULL (genuinely - // nullable — the LEFT JOIN's real effect). + // merge_action() forces Phase 2's conversion to be rejected (not valid outside MERGE's own + // RETURNING), so this falls to analyzeUnconvertibleDml — which now detects the LEFT JOIN + // nested in the USING subquery via the same null-extending-construct trigger the CTE-body + // stub path already has, forcing every column nullable, "act" included — the same accepted + // over-approximation the CTE-wrapped equivalent test above (`MERGE with a LEFT JOIN nested + // in its USING subquery reports the joined column nullable`) documents, now reached for a + // bare top-level statement instead of one wrapped in a CTE. On real Postgres, with b + // having no row matching a: act = 'UPDATE', bval = NULL (genuinely nullable — the LEFT + // JOIN's real effect). val query = analyzeWithSchema( """ CREATE TABLE tgt (id INT PRIMARY KEY, tval TEXT NOT NULL); @@ -5897,8 +5869,8 @@ class QueryAnalysisTest { @Test fun `top-level DELETE RETURNING reports a nullable column as nullable, not NOT NULL`() { - // Issue #207 shape 5: the everyday, no-OLD-NEW-MERGE case — a plain top-level DELETE with no - // FROM/USING clause has no join structure for convertDmlToSelect to convert, so it goes + // The everyday, no-OLD-NEW-MERGE case — a plain top-level DELETE with no FROM/USING clause + // has no join structure for convertDmlToSelect to convert, so it goes // straight to analyzeUnconvertibleDml, which now reads real ResultSetMetaData.isNullable // instead of discarding it. "note" has no NOT NULL constraint, so it is genuinely nullable; // "id" and "name" are declared NOT NULL and stay that way — this is not "mark everything @@ -5929,14 +5901,14 @@ class QueryAnalysisTest { @Test fun `top-level UPDATE with a LEFT JOIN only inside a WHERE subquery does not force RETURNING nullable`() { - // The null-extending-construct arm used to scan the WHOLE statement's text for an outer + // The null-extending-construct arm used to scan the whole statement's text for an outer // join, not just the clause whose join structure can actually reach RETURNING — so a LEFT // JOIN sitting inside an unrelated `WHERE ... IN (subquery)` (which only narrows which rows // the UPDATE touches, and cannot null-extend anything in RETURNING) fabricated nullable for - // both columns. Verified against real Postgres 18 (a matching row exists so the WHERE - // filter passes; t.id is even the PRIMARY KEY): id and name are genuinely NOT NULL. + // both columns. PostgreSQL 18, with a matching row existing so the WHERE filter passes; + // t.id is even the PRIMARY KEY: id and name are genuinely NOT NULL. // dmlSourceClauseRegion returns null here (no top-level FROM clause on this UPDATE at all), - // so the join arm is forced OFF rather than scanning the WHERE subquery's own LEFT JOIN. + // so the join arm is forced off rather than scanning the WHERE subquery's own LEFT JOIN. val query = analyzeWithSchema( """ CREATE TABLE t (id INT PRIMARY KEY, name TEXT NOT NULL); @@ -5986,15 +5958,15 @@ class QueryAnalysisTest { // valid range variable in the converted SELECT), landing on analyzeUnconvertibleDml — the // top-level counterpart of `stub path forces every column nullable when RETURNING OLD-col // accompanies a real LEFT JOIN` above. dmlSourceClauseRegion scopes the join scan to this - // UPDATE's own FROM ... WHERE region, which DOES contain the real LEFT JOIN, so bval (and, + // UPDATE's own FROM ... WHERE region, which does contain the real LEFT JOIN, so bval (and, // by the same accepted over-approximation as the CTE case, oldname and name too) are forced // nullable — proving the scoped region still finds a join that genuinely belongs to the // source clause, not merely refusing to force anything at all. - // prosqlbody reads the RAW :targetList assignment for "name" directly (a literal 'x', + // prosqlbody reads the raw :targetList assignment for "name" directly (a literal 'x', // untouched by either the OLD-forcing rule or the LEFT JOIN), so it correctly isolates // "name" as NOT NULL, "oldname" as nullable (the blanket OLD-forcing rule), and "bval" as - // nullable (the genuine LEFT JOIN). Verified live: this exact UPDATE returns `name = 'x'`, - // never NULL, for a matching row. + // nullable (the genuine LEFT JOIN). On real Postgres, this exact UPDATE returns `name = + // 'x'`, never NULL, for a matching row. val query = analyzeWithSchema( """ CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL); @@ -6017,8 +5989,8 @@ class QueryAnalysisTest { assumeTrue(pgVersion.substringBefore('.').toInt() >= 18, "RETURNING OLD requires PostgreSQL 18+") // DELETE USING equivalent of the UPDATE FROM case above. "t.name" is DELETE's own target // column — read directly, never assigned — so prosqlbody correctly reports it NOT NULL - // (the schema's own constraint). Verified live: this exact DELETE returns `name = 'orig'`, - // never NULL. + // (the schema's own constraint). On real Postgres, this exact DELETE returns `name = + // 'orig'`, never NULL. val query = analyzeWithSchema( """ CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL); @@ -6038,16 +6010,16 @@ class QueryAnalysisTest { @Test fun `INSERT RETURNING a literal and a bare integer constant reports both NOT NULL`() { - // R2 regression: a literal or constant expression RETURNING item reports + // A literal or constant expression RETURNING item reports // ResultSetMetaData.columnNullableUnknown (PostgreSQL cannot describe a literal's // nullability any more precisely than that) — a literal is never NULL, so - // analyzeUnconvertibleDml must treat that as NOT NULL. Issue #226's probe actually confirms - // this exactly, rather than merely defaulting to it: it builds `SELECT id, name, 'lit'::TEXT - // AS lbl, 1 AS one FROM t` and reads its real per-column nullability via the same node-tree + // analyzeUnconvertibleDml must treat that as NOT NULL. The probe actually confirms this + // exactly, rather than merely defaulting to it: it builds `SELECT id, name, 'lit'::TEXT AS + // lbl, 1 AS one FROM t` and reads its real per-column nullability via the same node-tree // analyzer a plain SELECT already uses, independently reporting both literal columns NOT // NULL. "name" has no NOT NULL constraint and is left untouched by this rule (the probe's // own answer for it is irrelevant), since it traces to a real column whose base-table - // attnotnull IS already known (columnNoNulls), not unknown. + // attnotnull is already known (columnNoNulls), not unknown. val query = analyzeWithSchema( "CREATE TABLE t (id SERIAL PRIMARY KEY, name TEXT)", "INSERT INTO t (name) VALUES (?) RETURNING id, name, 'lit'::TEXT AS lbl, 1 AS one", @@ -6061,13 +6033,12 @@ class QueryAnalysisTest { } /** - * Issue #226: `PgCatalogLoader.analyzeUnconvertibleDml` treats - * `ResultSetMetaData.columnNullableUnknown` as NOT NULL — correct for a literal/constant - * (`1 AS one`), but wrong for an expression built over a genuinely nullable source column - * (`lower(note)`, where `note` has no NOT NULL constraint). These tests exercise - * `PgCatalogLoader.probeUnknownColumnNullability`, the supplementary probe that resolves such a - * column's real nullability instead of defaulting, and the gates that fall back to today's NOT - * NULL default when the probe cannot be trusted. + * `PgCatalogLoader.analyzeUnconvertibleDml` treats `ResultSetMetaData.columnNullableUnknown` + * as NOT NULL — correct for a literal/constant (`1 AS one`), but wrong for an expression built + * over a genuinely nullable source column (`lower(note)`, where `note` has no NOT NULL + * constraint). These tests exercise `PgCatalogLoader.probeUnknownColumnNullability`, the + * supplementary probe that resolves such a column's real nullability instead of defaulting, + * and the gates that fall back to today's NOT NULL default when the probe cannot be trusted. */ @Nested inner class UnknownColumnNullabilityProbe { @@ -6120,7 +6091,7 @@ class QueryAnalysisTest { @Test fun `a star item in RETURNING is expanded so the probe still runs and reports the real answer`() { - // "note" has no NOT NULL constraint, so lower(note) genuinely CAN be NULL — a star item must + // "note" has no NOT NULL constraint, so lower(note) genuinely can be NULL — a star item must // not prevent the probe from proving that: probeUnknownColumnNullability expands "*" against // "t"'s own catalog columns (id, note) before counting items, so the 2-item RETURNING list // ("*", "lower(note)") correctly resolves to the real 3-column count and the probe runs. @@ -6172,14 +6143,14 @@ class QueryAnalysisTest { } /** - * Issue #228: [PgCatalogLoader.probeUnknownColumnNullability]'s bare `SELECT FROM - * ` probe (issue #226) evaluates a `RETURNING` expression against the UNMODIFIED target - * table, so it cannot see a value the statement's OWN `SET` clause assigns — `UPDATE t SET note - * = 'x' RETURNING lower(note)` widened to nullable even though `note` can only ever be `'x'` in - * this result, because `note` has no `NOT NULL` constraint in the catalog. These tests exercise + * [PgCatalogLoader.probeUnknownColumnNullability]'s bare `SELECT FROM ` + * probe evaluates a `RETURNING` expression against the unmodified target table, so it cannot + * see a value the statement's own `SET` clause assigns — `UPDATE t SET note = 'x' RETURNING + * lower(note)` widened to nullable even though `note` can only ever be `'x'` in this result, + * because `note` has no `NOT NULL` constraint in the catalog. These tests exercise * [PgCatalogLoader.buildUpdateSetAwareFromClause], which wraps the probe's target in a derived * table carrying the `SET`-assigned expressions, plus every bail condition that must keep the - * bare (pre-#228) column instead. + * bare, pre-fix column instead. */ @Nested inner class SetAssignmentAwareProbe { @@ -6218,10 +6189,10 @@ class QueryAnalysisTest { @Test fun `a row-form assignment reports NOT NULL, since PostgreSQL attributes it to one column cleanly`() { // PostgreSQL's own parser decomposes `(note, other) = ('x', 'y')` into two ordinary, - // independent :targetList entries (resno matching each column, one CONST apiece) — verified - // live in the raw prosqlbody text — so prosqlbody attributes 'x' to "note" exactly as - // cleanly as a plain `SET note = 'x'` would. Verified live: this exact UPDATE returns - // `n = 'x'`, never NULL. + // independent :targetList entries (resno matching each column, one CONST apiece), confirmed + // in the raw prosqlbody text — so prosqlbody attributes 'x' to "note" exactly as cleanly as + // a plain `SET note = 'x'` would. On real Postgres, this exact UPDATE returns `n = 'x'`, + // never NULL. val query = analyzeWithSchema( schema, "UPDATE t SET (note, other) = ('x', 'y') WHERE id = 1 RETURNING lower(note) AS n", @@ -6262,10 +6233,10 @@ class QueryAnalysisTest { fun `a system column the SET-aware derived table can't carry doesn't collapse a sibling column's real answer`() { // The wrapped `FROM (SELECT ... FROM t) AS t` derived table has no `ctid` — PostgreSQL fails // to prepare `SELECT ..., ctid FROM (SELECT ... FROM t) AS t` with "column \"ctid\" does not - // exist". `ctid` itself is reported NOT NULL directly by `ResultSetMetaData` on the RAW + // exist". `ctid` itself is reported NOT NULL directly by `ResultSetMetaData` on the raw // `UPDATE ... RETURNING` statement — it never goes through the probe at all, at HEAD or - // here — so it stays NOT NULL regardless. What the wrapped probe's failure must NOT be - // allowed to do is collapse the SEPARATE, otherwise-resolvable `lower(note)` column's own + // here — so it stays NOT NULL regardless. What the wrapped probe's failure must not be + // allowed to do is collapse the separate, otherwise-resolvable `lower(note)` column's own // answer down to the probe's NOT NULL default: without the bare-`FROM t` retry, the whole // combined probe (covering every `RETURNING` item at once) fails to prepare because of // `ctid` alone, silently breaking `lower(note)`'s nullability too even though nothing about @@ -6308,14 +6279,14 @@ class QueryAnalysisTest { } /** - * A verifier found that [SetAssignmentAwareProbe]'s substitution splices the `SET` right-hand - * side into the derived table's column list VERBATIM, with no cast to the column's own declared - * type. An untyped literal (`'empty'`) spliced bare is typed `text` by PostgreSQL inside the - * derived table — a DIFFERENT type than the real column's — which can resolve a `RETURNING` - * function call against a completely different, sometimes safe-listed, overload than the real - * statement would ever use: `lower(text)` is safe-listed, `lower(anyrange)` is not, and - * `UPDATE t SET r = 'empty' RETURNING lower(r)` resolves the LATTER against the real column but - * (pre-fix) the FORMER against the bare-text derived table, silently reporting NOT NULL for a + * [SetAssignmentAwareProbe]'s substitution splices the `SET` right-hand side into the derived + * table's column list verbatim, with no cast to the column's own declared type. An untyped + * literal (`'empty'`) spliced bare is typed `text` by PostgreSQL inside the derived table — a + * different type than the real column's — which can resolve a `RETURNING` function call + * against a completely different, sometimes safe-listed, overload than the real statement would + * ever use: `lower(text)` is safe-listed, `lower(anyrange)` is not, and `UPDATE t SET r = + * 'empty' RETURNING lower(r)` resolves the latter against the real column but (pre-fix) the + * former against the bare-text derived table, silently reporting NOT NULL for a * value that is actually `NULL` at runtime. [PgCatalogLoader.buildUpdateSetAwareFromClause] now * casts every substituted expression to the column's own declared type — via * [PgCatalogLoader.lookupDeclaredColumnTypes]'s `format_type(atttypid, atttypmod)` — so the @@ -6414,10 +6385,10 @@ class QueryAnalysisTest { } /** - * Issue #228 follow-up: a verifier found that [SetAssignmentAwareProbe]'s substitution ignored - * anything that rewrites the tuple BETWEEN the `SET` clause and `RETURNING` — `RETURNING` always - * sees the FINAL, post-trigger, post-rule tuple, never the raw `SET` expression, so a `BEFORE` - * row trigger, an `INSTEAD OF` trigger, a rewrite rule, or a foreign data wrapper's own write path + * [SetAssignmentAwareProbe]'s substitution ignored anything that rewrites the tuple between the + * `SET` clause and `RETURNING` — `RETURNING` always sees the final, post-trigger, post-rule + * tuple, never the raw `SET` expression, so a `BEFORE` row trigger, an `INSTEAD OF` trigger, a + * rewrite rule, or a foreign data wrapper's own write path * can each substitute something else entirely for a value * [PgCatalogLoader.buildUpdateSetAwareFromClause] would otherwise splice in as provably non-null. * These tests exercise [PgCatalogLoader.targetRelationMayRewriteTupleBeforeReturning], the @@ -6494,7 +6465,7 @@ class QueryAnalysisTest { // postgres_fdw's loopback connection caching races under JUnit5's default concurrent // execution mode when multiple tests concurrently CREATE/DROP a SERVER + FOREIGN TABLE - // against the SAME shared container — observed as a spurious "server ... does not exist" + // against the same shared container — observed as a spurious "server ... does not exist" // surfacing from an unrelated concurrent test's DROP SERVER, not from this test's own logic. // @ResourceLock serializes every postgres_fdw-loopback test in this class against each other // (but not against unrelated tests elsewhere), matching the existing pattern in @@ -6546,13 +6517,13 @@ class QueryAnalysisTest { @Test @ResourceLock("postgres_fdw_loopback") fun `a foreign partition of a partitioned target reports nullable`() { - // P0-1 regression: targetRelationMayRewriteTupleBeforeReturning previously checked - // relkind only for the ROOT relation ("p", a partitioned table — relkind 'p'), never for + // Regression guard: targetRelationMayRewriteTupleBeforeReturning previously checked + // relkind only for the root relation ("p", a partitioned table — relkind 'p'), never for // its descendants. A partition that is itself a FOREIGN TABLE (relkind 'f') was therefore // invisible, and an FDW's own write path can produce any tuple it likes, independent of - // this statement's SET clause. Verified against real PostgreSQL 18.4 (via a postgres_fdw - // loopback with a BEFORE UPDATE row trigger on the remote table nulling "note"): before the - // fix this reported NOT NULL; the actual RETURNING value is NULL. + // this statement's SET clause. PostgreSQL 18.4, via a postgres_fdw loopback with a BEFORE + // UPDATE row trigger on the remote table nulling "note": before the fix this reported NOT + // NULL; the actual RETURNING value is NULL. val schemaName = "test_${schemaCounter.incrementAndGet()}" DriverManager.getConnection(container.jdbcUrl, container.username, container.password).use { connection -> connection.createStatement().use { statement -> @@ -6605,12 +6576,11 @@ class QueryAnalysisTest { @Test @ResourceLock("postgres_fdw_loopback") fun `a foreign inheritance child of a plain (non-partitioned) target reports nullable`() { - // Same P0-1 regression as the foreign-partition test above, but for plain multiple- - // inheritance (INHERITS) rather than declarative partitioning: the root relation ("parent2") - // is an ordinary table (relkind 'r'), but its inheritance child ("child2") is a FOREIGN - // TABLE. Verified against real PostgreSQL 18.4 that "child2 (...) INHERITS (parent2) SERVER - // ..." is valid syntax and that the child's remote BEFORE UPDATE trigger nulls the returned - // value. + // Same regression as the foreign-partition test above, but for plain multiple-inheritance + // (INHERITS) rather than declarative partitioning: the root relation ("parent2") is an + // ordinary table (relkind 'r'), but its inheritance child ("child2") is a FOREIGN TABLE. + // Confirmed on PostgreSQL 18.4 that "child2 (...) INHERITS (parent2) SERVER ..." is valid + // syntax and that the child's remote BEFORE UPDATE trigger nulls the returned value. val schemaName = "test_${schemaCounter.incrementAndGet()}" DriverManager.getConnection(container.jdbcUrl, container.username, container.password).use { connection -> connection.createStatement().use { statement -> @@ -6683,22 +6653,22 @@ class QueryAnalysisTest { * abort guards, but exercising a data-modifying CTE body specifically, hence the `WITH` wrapper * the other three tests intentionally omit. * - * NOTE on coverage of PgCatalogLoader's item-count-vs-real-column-count cross-check + * Coverage note on PgCatalogLoader's item-count-vs-real-column-count cross-check * (`oldOrNewColumns.isNotEmpty() && oldOrNewAnalysis.itemCount != totalColumnCount`): this - * class's `an ALIASED star reaches the item-count cross-check directly` test below DOES reach + * class's `an ALIASED star reaches the item-count cross-check directly` test below does reach * this branch, reliably. `oldOrNewReturningColumns` (unlike `parseSelectItems`) never - * alias-strips an item before checking `isStarItem` against it — so ANY star carrying an + * alias-strips an item before checking `isStarItem` against it — so any star carrying an * alias, explicit (`tgt.* AS whatever`) or implicit (`tgt.* whatever`), is simply left * unrecognized there: `isStarItem`'s own comment/whitespace/parenthesis normalization has no * concept of an `AS` keyword or an implicit alias to look past, so the alias text survives * normalization and the result never ends in `.*`. That unrecognized star's real expansion still * shows up in the real column count, mismatching the assumed item count, which is exactly what - * this cross-check exists to catch — its outcome (forcing every column nullable) is the SAME - * safe over-approximation a RECOGNIZED star produces via the `forcedColumns = null` path, just + * this cross-check exists to catch — its outcome (forcing every column nullable) is the same + * safe over-approximation a recognized star produces via the `forcedColumns = null` path, just * reached through the sibling branch instead. * - * This is deliberately NOT "fixed" by alias-stripping inside `oldOrNewReturningColumns`: doing - * so would only change WHICH branch produces the answer, never the answer itself (both branches + * This is deliberately not "fixed" by alias-stripping inside `oldOrNewReturningColumns`: doing + * so would only change which branch produces the answer, never the answer itself (both branches * force every column nullable), so there is no functional reason to add alias-awareness to this * specific call site — and doing so would silently delete this branch's only current test * coverage. This note makes no claim that this branch is otherwise unreachable in general — only @@ -6707,7 +6677,7 @@ class QueryAnalysisTest { * * What the star-shape tests in this class (aside from the aliased-star one) still prove — see * `an OLD reference without a star still forces only the referencing column, not the whole body` - * below — is that the PER-COLUMN forcing mechanism (`forcedColumns` non-`null` AND + * below — is that the per-column forcing mechanism (`forcedColumns` non-`null` and * itemCount-matching) survives when no star is involved at all, distinguishing it from the * whole-body `forceAllNullable` fallback every star-plus-`OLD`/`NEW` test in this class * exercises (via one of the two possible routes: `forcedColumns == null`, or the item-count @@ -6719,23 +6689,23 @@ class QueryAnalysisTest { @Test fun `parenthesized star-plus-OLD in a CTE body forces every column, not just the wrong one`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 18, "RETURNING OLD requires PostgreSQL 18+") - // FIX 2: "(tgt.*)" is a parenthesized star — isStarItem's regex previously only recognized - // a bare "*"/"tbl.*", so this shape's real 2-column expansion (id, tval) was miscounted as - // a single RETURNING item, shifting "oldv" (the genuinely OLD-dependent column) off its - // real index and forcing the WRONG column (tval) nullable instead of the real OLD-dependent + // "(tgt.*)" is a parenthesized star — isStarItem's regex previously only recognized a bare + // "*"/"tbl.*", so this shape's real 2-column expansion (id, tval) was miscounted as a + // single RETURNING item, shifting "oldv" (the genuinely OLD-dependent column) off its real + // index and forcing the wrong column (tval) nullable instead of the real OLD-dependent // column. isStarItem now recognizes the parenthesized form, so oldOrNewReturningColumns - // reports the mapping as KNOWN unreliable (forcedColumns = null) — the caller falls back to - // forcing EVERY column nullable, same accepted over-approximation as the star-plus-OLD test - // elsewhere in this file, not an attempt at precisely isolating "oldv" alone. Verified - // against real Postgres (a fresh insert via ON CONFLICT — no prior row): id = 99, tval = - // 'x' (both genuinely NOT NULL — the just-inserted row's own columns), oldv = NULL - // (genuinely nullable) — but the safety net over-approximates all three to nullable here. + // reports the mapping as known unreliable (forcedColumns = null) — the caller falls back to + // forcing every column nullable, same accepted over-approximation as the star-plus-OLD test + // elsewhere in this file, not an attempt at precisely isolating "oldv" alone. On real + // Postgres, with a fresh insert via ON CONFLICT — no prior row: id = 99, tval = 'x' (both + // genuinely NOT NULL — the just-inserted row's own columns), oldv = NULL (genuinely + // nullable) — but the safety net over-approximates all three to nullable here. // prosqlbody reports "id" NOT NULL directly off its PRIMARY KEY catalog constraint — true // regardless of which INSERT/ON-CONFLICT branch actually ran. "tval" stays nullable: this // analyzer does not (yet) trace a CTE-nested INSERT's own :targetList/onConflict assignment - // the way it does for a TOP-LEVEL one (see PgCatalogLoader.analyzeNodeTree's targetListByResno + // the way it does for a top-level one (see PgCatalogLoader.analyzeNodeTree's targetListByResno // KDoc), so it falls back to tval's own (nullable) catalog constraint — safe, though not as - // precise as the live-verified 'x' this comment already documents. "oldv" stays nullable via + // precise as the confirmed 'x' this comment already documents. "oldv" stays nullable via // the blanket OLD-forcing rule. val query = analyzeWithSchema( "CREATE TABLE tgt (id INT PRIMARY KEY, tval TEXT)", @@ -6756,21 +6726,21 @@ class QueryAnalysisTest { @Test fun `an ALIASED star reaches the item-count cross-check directly`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 18, "RETURNING OLD requires PostgreSQL 18+") - // Verified against real PostgreSQL 18.4: "RETURNING tgt.* AS whatever, OLD.id AS oldv" is - // valid syntax, returning 3 real columns for a 2-column "tgt" (tgt.id, tgt.tval, oldv). - // Unlike parseSelectItems, oldOrNewReturningColumns never alias-strips an item before - // checking isStarItem against it, so "tgt.* AS whatever" as a whole does not end in ".*" - // and is NOT recognized as a star here — hasRecognizedStarItem is false. oldOrNewColumnIndices - // still correctly identifies item 2 ("OLD.id AS oldv") as the OLD-referencing item, so - // oldOrNewReturningColumns returns forcedColumns = {2} (NON-null) with itemCount = 2. Since - // the REAL column count is 3 (the star's own 2-column expansion was never counted), + // PostgreSQL 18.4: "RETURNING tgt.* AS whatever, OLD.id AS oldv" is valid syntax, returning + // 3 real columns for a 2-column "tgt" (tgt.id, tgt.tval, oldv). Unlike parseSelectItems, + // oldOrNewReturningColumns never alias-strips an item before checking isStarItem against + // it, so "tgt.* AS whatever" as a whole does not end in ".*" and is not recognized as a + // star here — hasRecognizedStarItem is false. oldOrNewColumnIndices still correctly + // identifies item 2 ("OLD.id AS oldv") as the OLD-referencing item, so + // oldOrNewReturningColumns returns forcedColumns = {2} (non-null) with itemCount = 2. Since + // the real column count is 3 (the star's own 2-column expansion was never counted), // PgCatalogLoader's "oldOrNewColumns.isNotEmpty() && itemCount != totalColumnCount" check - // (2 != 3) fires and forces EVERY column nullable — the item-count cross-check itself, not + // (2 != 3) fires and forces every column nullable — the item-count cross-check itself, not // the "forcedColumns == null" branch the other star-plus-OLD tests in this class exercise. // The outcome is the same safe over-approximation either way, which is exactly why this // gap in oldOrNewReturningColumns's alias-awareness is not itself a bug: the cross-check - // makes the missed recognition harmless. Verified against real Postgres (a fresh insert via - // ON CONFLICT — no prior row): id = 99, tval = 'x' (both genuinely NOT NULL), oldv = NULL + // makes the missed recognition harmless. On real Postgres, with a fresh insert via ON + // CONFLICT — no prior row: id = 99, tval = 'x' (both genuinely NOT NULL), oldv = NULL // (genuinely nullable) — but the safety net over-approximates all three to nullable here. // Same reasoning as the parenthesized-star test above: id NOT NULL via its PRIMARY KEY // catalog constraint, tval nullable (CTE-nested INSERT assignment tracing not implemented — @@ -6794,22 +6764,20 @@ class QueryAnalysisTest { @Test fun `star with whitespace around the dot is recognized by isStarItem and forces every column`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 18, "RETURNING OLD requires PostgreSQL 18+") - // CORRECTED (was: "not recognized by isStarItem's text heuristic"): #212's fix taught - // isStarItem to collapse whitespace around a qualifying dot, so "tgt . *" IS now recognized - // — verified via SqlUtilsTest's "recognizes a star item with whitespace around the + // isStarItem now collapses whitespace around a qualifying dot, so "tgt . *" is recognized — + // confirmed via SqlUtilsTest's "recognizes a star item with whitespace around the // qualifying dot". That means oldOrNewReturningColumns itself now returns forcedColumns = - // null directly (a RECOGNIZED star coincides with an OLD/NEW reference — see its KDoc), so + // null directly (a recognized star coincides with an OLD/NEW reference — see its KDoc), so // PgCatalogLoader's forceNullableColumn short-circuits on "oldOrNewColumns == null" and - // NEVER reaches the itemCount-vs-real-column-count cross-check below it — this test no + // never reaches the itemCount-vs-real-column-count cross-check below it — this test no // longer exercises that cross-check at all (see the class KDoc). Kept as a regression guard - // for the observable end-to-end nullability outcome (which happens to be UNCHANGED here, + // for the observable end-to-end nullability outcome (which happens to be unchanged here, // because "tgt" has two columns and the pre-fix itemCount already mismatched the real // column count regardless of recognition — see `an OLD reference forces every column // nullable when a star recognition change loses per-column precision` for a case where - // recognizing a star DOES change the observable outcome), not as cross-check coverage. - // Verified against real Postgres (a fresh insert via ON CONFLICT — no prior row, so OLD - // does not exist): id = 99, tval = 'y' (both genuinely NOT NULL), oldv = NULL (genuinely - // nullable). + // recognizing a star does change the observable outcome), not as cross-check coverage. On + // real Postgres, with a fresh insert via ON CONFLICT — no prior row, so OLD does not exist: + // id = 99, tval = 'y' (both genuinely NOT NULL), oldv = NULL (genuinely nullable). // Same reasoning as the two star-plus-OLD tests above. val query = analyzeWithSchema( "CREATE TABLE tgt (id INT PRIMARY KEY, tval TEXT)", @@ -6830,21 +6798,21 @@ class QueryAnalysisTest { @Test fun `an OLD reference forces every column nullable when a star recognition change loses per-column precision`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 18, "RETURNING OLD requires PostgreSQL 18+") - // Pins a REAL, INTENTIONAL nullability outcome change from teaching isStarItem to + // Pins a real, intentional nullability outcome change from teaching isStarItem to // recognize "tgt . *" — not merely a different code path reaching the same answer, unlike - // the sibling tests above. "tgt" here has exactly ONE column, so "tgt.*"'s real expansion + // the sibling tests above. "tgt" here has exactly one column, so "tgt.*"'s real expansion // (1 column) plus "oldv" (1 column) happens to equal the assumed item count (2) — before // isStarItem recognized "tgt . *", oldOrNewReturningColumns computed forcedColumns = {2} - // (only "oldv") with NO itemCount mismatch to trigger the cross-check, so PgCatalogLoader's - // per-column forcing applied PRECISELY: "id" (from tgt.*'s expansion) was governed by its + // (only "oldv") with no itemCount mismatch to trigger the cross-check, so PgCatalogLoader's + // per-column forcing applied precisely: "id" (from tgt.*'s expansion) was governed by its // real attnotnull (a PRIMARY KEY column — genuinely NOT NULL), and only "oldv" was forced. - // Now that "tgt . *" IS recognized, oldOrNewReturningColumns returns forcedColumns = null - // directly, and PgCatalogLoader forces EVERY column nullable — "id" included, even though - // it is genuinely NOT NULL. The direction is SAFE (over-nullable beats a fabricated NOT + // Now that "tgt . *" is recognized, oldOrNewReturningColumns returns forcedColumns = null + // directly, and PgCatalogLoader forces every column nullable — "id" included, even though + // it is genuinely NOT NULL. The direction is safe (over-nullable beats a fabricated NOT // NULL elsewhere), which is why it is kept rather than reverted, but it is a real loss of // precision for this specific shape, not merely a different route to an unchanged answer. - // Verified against real Postgres (a fresh insert via ON CONFLICT — no prior row, so OLD - // does not exist): id = 99 (genuinely NOT NULL), oldv = NULL (genuinely nullable). + // On real Postgres, with a fresh insert via ON CONFLICT — no prior row, so OLD does not + // exist: id = 99 (genuinely NOT NULL), oldv = NULL (genuinely nullable). // prosqlbody reports "id" NOT NULL directly off its PRIMARY KEY catalog constraint (the // precision the pre-fix production code path happened to have here too), "oldv" nullable // via the blanket OLD-forcing rule. @@ -6866,12 +6834,12 @@ class QueryAnalysisTest { @Test fun `the same precision loss extends to one of the newly-normalized star spellings`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 18, "RETURNING OLD requires PostgreSQL 18+") - // Same outcome change as the test above, for one of the THREE spellings isStarItem was - // fixed to additionally normalize (a trailing comment sitting OUTSIDE a wrapping + // Same outcome change as the test above, for one of the three spellings isStarItem was + // fixed to additionally normalize (a trailing comment sitting outside a wrapping // parenthesis) — confirming the precision loss extends to those spellings too, exactly as // expected: any input that flips from "unrecognized" to "recognized" on a one-column - // relation loses this same per-column precision. Verified against real Postgres 18.4: "(tgt - // .*) -- c" is valid syntax, id = 99 (genuinely NOT NULL), oldv = NULL (genuinely nullable). + // relation loses this same per-column precision. PostgreSQL 18.4: "(tgt .*) -- c" is valid + // syntax, id = 99 (genuinely NOT NULL), oldv = NULL (genuinely nullable). // Same reasoning as the test above. val query = analyzeWithSchema( "CREATE TABLE tgt (id INT PRIMARY KEY)", @@ -6890,27 +6858,26 @@ class QueryAnalysisTest { @Test fun `an untracked bracket can no longer cancel out a star's split error and defeat the cross-check`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 18, "RETURNING OLD requires PostgreSQL 18+") - // FIX 1: splitAtTopLevel previously did not track "[...]", so "ARRAY[1, 2]"'s internal comma - // split it into two items — which, in THIS exact list, numerically canceled out the "tgt . *" + // splitAtTopLevel previously did not track "[...]", so "ARRAY[1, 2]"'s internal comma + // split it into two items — which, in this exact list, numerically canceled out the "tgt . *" // star's own split error: 4 real columns (id, tval, oldv, arr), and a broken split - // ["tgt . *", "OLD.tval AS oldv", "ARRAY[1", "2] AS arr"] that ALSO produced 4 items, + // ["tgt . *", "OLD.tval AS oldv", "ARRAY[1", "2] AS arr"] that also produced 4 items, // defeating oldOrNewReturningColumns's real-column-count cross-check entirely and forcing - // the WRONG column (the second half of "tgt.*"'s expansion, i.e. tval) instead of "oldv". - // CORRECTED (was: "the safety net over-approximates ... because real column count 4 vs. the - // CORRECTLY split item count 3"): #212's later fix taught isStarItem to recognize "tgt . *" - // (whitespace around the dot), so oldOrNewReturningColumns now returns forcedColumns = null - // directly for THIS list — a RECOGNIZED star coincides with an OLD/NEW reference — and - // PgCatalogLoader's forceNullableColumn short-circuits on that null before ever comparing - // item count (3) against real column count (4). The item-count cross-check this test - // originally exercised is therefore NOT reached here anymore either; see the class KDoc. - // The observable outcome (all four forced nullable) is UNCHANGED here regardless, because - // the pre-fix itemCount already mismatched the real column count on its own — see `an OLD - // reference forces every column nullable when a star recognition change loses per-column - // precision` for a case where recognizing a star DOES change the observable outcome. - // Verified against real Postgres (a fresh insert via ON CONFLICT — no prior row): id = 99, - // tval = 'x', arr = {1,2} (all genuinely NOT NULL), oldv = NULL (genuinely nullable) — the - // safety net still over-approximates all four to nullable, same accepted tradeoff as the - // other star-plus-OLD tests in this file, just reached via a different branch than before. + // the wrong column (the second half of "tgt.*"'s expansion, i.e. tval) instead of "oldv". + // isStarItem was later taught to recognize "tgt . *" (whitespace around the dot), so + // oldOrNewReturningColumns now returns forcedColumns = null directly for this list — a + // recognized star coincides with an OLD/NEW reference — and PgCatalogLoader's + // forceNullableColumn short-circuits on that null before ever comparing item count (3) + // against real column count (4). The item-count cross-check this test originally exercised + // is therefore not reached here anymore either; see the class KDoc. The observable outcome + // (all four forced nullable) is unchanged here regardless, because the pre-fix itemCount + // already mismatched the real column count on its own — see `an OLD reference forces every + // column nullable when a star recognition change loses per-column precision` for a case + // where recognizing a star does change the observable outcome. On real Postgres, with a + // fresh insert via ON CONFLICT — no prior row: id = 99, tval = 'x', arr = {1,2} (all + // genuinely NOT NULL), oldv = NULL (genuinely nullable) — the safety net still + // over-approximates all four to nullable, same accepted tradeoff as the other star-plus-OLD + // tests in this file, just reached via a different branch than before. // prosqlbody: id NOT NULL (PRIMARY KEY catalog constraint), tval nullable (CTE-nested // assignment tracing not implemented, safe not maximal), oldv nullable (blanket OLD-forcing), // arr NOT NULL — ARRAY[1, 2] is a genuine array-literal constructor, never itself NULL. @@ -6934,22 +6901,22 @@ class QueryAnalysisTest { @Test fun `an untracked bracket alone, with no star at all, no longer corrupts the item count`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 18, "RETURNING OLD requires PostgreSQL 18+") - // NOT independently demonstrative of FIX 1: confirmed (via the established git-stash - // before/after technique) that this exact shape ALREADY passed at 94b5a2d — the untracked - // "[...]" corrupted the split into 3 items ("ARRAY[1", "2] AS arr", "OLD.tval AS oldv") - // against 2 real columns, and that MISMATCH (3 != 2) was already caught by the existing - // real-column-count cross-check, which forced every column nullable — coincidentally - // correct for "oldv" (genuinely nullable) even before this fix. What this DOES confirm is - // that FIX 1 doesn't regress this shape: after tracking "[...]", the split is the CORRECT 2 - // items, oldOrNewReturningColumns identifies "oldv" (not "arr") as the OLD-referencing item - // via precise per-column mapping rather than the coarser "force everything" fallback — a - // structural improvement even though it happens to produce the same observable nullability - // here. It does NOT prove "arr" keeps its true NOT NULL status either way: the stub path's - // own metadata probe reports a computed `ARRAY[]` expression's nullability as - // unknown/nullable regardless of forceNullableColumn, a separate, pre-existing imprecision - // of the metadata-probe stub itself, not of this fix. Verified against real Postgres (a - // fresh insert via ON CONFLICT): arr = {1,2} (genuinely NOT NULL), oldv = NULL (genuinely - // nullable — no prior row). + // This alone does not demonstrate the "[...]"-tracking fix: confirmed (via the established + // git-stash before/after technique) that this exact shape already passed at 94b5a2d — the + // untracked "[...]" corrupted the split into 3 items ("ARRAY[1", "2] AS arr", "OLD.tval AS + // oldv") against 2 real columns, and that mismatch (3 != 2) was already caught by the + // existing real-column-count cross-check, which forced every column nullable — + // coincidentally correct for "oldv" (genuinely nullable) even before this fix. What this + // does confirm is that the fix doesn't regress this shape: after tracking "[...]", the + // split is the correct 2 items, oldOrNewReturningColumns identifies "oldv" (not "arr") as + // the OLD-referencing item via precise per-column mapping rather than the coarser "force + // everything" fallback — a structural improvement even though it happens to produce the + // same observable nullability here. It does not prove "arr" keeps its true NOT NULL status + // either way: the stub path's own metadata probe reports a computed `ARRAY[]` expression's + // nullability as unknown/nullable regardless of forceNullableColumn, a separate, + // pre-existing imprecision of the metadata-probe stub itself, not of this fix. On real + // Postgres, with a fresh insert via ON CONFLICT: arr = {1,2} (genuinely NOT NULL), oldv = + // NULL (genuinely nullable — no prior row). // prosqlbody structurally recognizes ARRAY[1, 2] as a genuine array-literal constructor, // never itself NULL — "oldv" stays nullable via the blanket OLD-forcing rule. val query = analyzeWithSchema( @@ -6970,19 +6937,19 @@ class QueryAnalysisTest { @Test fun `an OLD reference without a star still forces only the referencing column, not the whole body`() { assumeTrue(pgVersion.substringBefore('.').toInt() >= 18, "RETURNING OLD requires PostgreSQL 18+") - // Restores coverage lost when #212 taught isStarItem to recognize "tgt . *": every OTHER + // Restores coverage lost when isStarItem was taught to recognize "tgt . *": every other // test in this class now involves a star, so every one of them resolves via - // oldOrNewReturningColumns's forcedColumns = null (a RECOGNIZED star coincides with an + // oldOrNewReturningColumns's forcedColumns = null (a recognized star coincides with an // OLD/NEW reference) and PgCatalogLoader's whole-body forceAllNullable fallback — leaving - // NOTHING in this class asserting on the DISTINCT per-column forcing mechanism + // nothing in this class asserting on the distinct per-column forcing mechanism // (forcedColumns non-null, containing only the specific OLD/NEW-referencing item's index). // With no star at all here, itemCount trivially matches the real column count, so // oldOrNewMappingUnreliable is false and PgCatalogLoader's // "oldOrNewColumns.orEmpty().contains(columnIndex)" line is what decides each column's // fate. If that per-column check were ever replaced by forcing the whole body nullable - // whenever ANY OLD/NEW reference is present, "id" below would flip from NOT NULL to - // nullable and this assertion would fail. Verified against real Postgres (a fresh insert - // via ON CONFLICT — no prior row, so OLD does not exist): id = 99 (genuinely NOT NULL, the + // whenever any OLD/NEW reference is present, "id" below would flip from NOT NULL to + // nullable and this assertion would fail. On real Postgres, with a fresh insert via ON + // CONFLICT — no prior row, so OLD does not exist: id = 99 (genuinely NOT NULL, the // just-inserted row's own column), oldv = NULL (genuinely nullable). val query = analyzeWithSchema( "CREATE TABLE tgt (id INT PRIMARY KEY, tval TEXT)", @@ -7008,7 +6975,7 @@ class QueryAnalysisTest { * The target list VARs reference the first subquery RTE, whose varno has no entry in the base-table * range table, so [NodeTreeNullabilityAnalyzer] defaults to nullable. * - * CTE-wrapped set operations DO analyze branches (see [CommonTableExpressions]) because the CTE + * CTE-wrapped set operations do analyze branches (see [CommonTableExpressions]) because the CTE * analysis pipeline can safely iterate all branches. Direct (non-CTE) set operations could be * improved similarly but aren't yet — these tests document the current conservative behavior. */ @@ -7155,9 +7122,9 @@ class QueryAnalysisTest { @Test fun `JSON_VALUE with DEFAULT on empty and error is still nullable`() { // JSON_VALUE unwraps a path match to a JSON `null` value into a genuine SQL NULL — a - // successful match, not the "no match"/"error" case ON EMPTY/ON ERROR control. Verified - // live: `JSON_VALUE('{"name": null}'::jsonb, '$.name' RETURNING TEXT DEFAULT 'N/A' ON EMPTY - // DEFAULT 'ERR' ON ERROR) IS NULL` is `true`. + // successful match, not the "no match"/"error" case ON EMPTY/ON ERROR control. On real + // Postgres, `JSON_VALUE('{"name": null}'::jsonb, '$.name' RETURNING TEXT DEFAULT 'N/A' ON + // EMPTY DEFAULT 'ERR' ON ERROR) IS NULL` is `true`. assumeTrue(pgVersion.substringBefore('.').toInt() >= 17, "JSON_VALUE requires PostgreSQL 17+") val query = analyzeWithSchema( "CREATE TABLE t (data jsonb NOT NULL)", @@ -7169,9 +7136,10 @@ class QueryAnalysisTest { @Test fun `JSON_QUERY with DEFAULT on empty and error is non-null for a non-null context item`() { // Unlike JSON_VALUE, JSON_QUERY never unwraps a matched JSON `null` into a genuine SQL NULL - // — it returns the JSON text `null` instead. Verified live: `JSON_QUERY('{"a": null}'::jsonb, - // '$.a' EMPTY ARRAY ON EMPTY EMPTY OBJECT ON ERROR) IS NULL` is `false`, so a proven non-null - // context item combined with safe ON EMPTY/ON ERROR behavior is a genuinely non-null column. + // — it returns the JSON text `null` instead. On real Postgres, + // `JSON_QUERY('{"a": null}'::jsonb, '$.a' EMPTY ARRAY ON EMPTY EMPTY OBJECT ON ERROR) IS + // NULL` is `false`, so a proven non-null context item combined with safe ON EMPTY/ON ERROR + // behavior is a genuinely non-null column. assumeTrue(pgVersion.substringBefore('.').toInt() >= 17, "JSON_QUERY requires PostgreSQL 17+") val query = analyzeWithSchema( "CREATE TABLE t (data jsonb NOT NULL)", @@ -7203,8 +7171,8 @@ class QueryAnalysisTest { @Test fun `MERGE WHEN NOT MATCHED BY SOURCE RETURNING JSON_QUERY over the source column is nullable`() { // The source Var (jsrc.doc) is buried inside a JsonExpr argument, not read directly — this - // is the shape containsVarOutsideRelation must recurse into a JsonExpr to see. Verified - // live: for the deleted (source-absent) row, this whole expression is NULL despite doc + // is the shape containsVarOutsideRelation must recurse into a JsonExpr to see. On real + // Postgres, for the deleted (source-absent) row, this whole expression is NULL despite doc // being NOT NULL and both ON EMPTY/ON ERROR defaults being non-null constants — the source // row itself never existed, so there was nothing for JSON_QUERY to evaluate against. assumeTrue(pgVersion.substringBefore('.').toInt() >= 17, "WHEN NOT MATCHED BY SOURCE requires PostgreSQL 17+") @@ -7226,7 +7194,7 @@ class QueryAnalysisTest { @Test fun `MERGE WHEN NOT MATCHED BY SOURCE RETURNING JSON_EXISTS over the source column is nullable`() { // Same JsonExpr-hides-a-Var shape as the JSON_QUERY case above, for the JSON_EXISTS_OP - // variant of JsonExpr. Verified live: NULL, not `false`, for the source-absent row — the + // variant of JsonExpr. On real Postgres: NULL, not `false`, for the source-absent row — the // source row itself is absent, so there is nothing for JSON_EXISTS to test against. assumeTrue(pgVersion.substringBefore('.').toInt() >= 17, "WHEN NOT MATCHED BY SOURCE requires PostgreSQL 17+") val query = analyzeWithSchema( @@ -7246,8 +7214,9 @@ class QueryAnalysisTest { @Test fun `MERGE WHEN NOT MATCHED BY SOURCE RETURNING JSON_VALUE over the source column is nullable`() { - // Same JsonExpr-hides-a-Var shape, for the JSON_VALUE_OP variant. Verified live: NULL for - // the source-absent row despite both ON EMPTY/ON ERROR defaults being non-null constants. + // Same JsonExpr-hides-a-Var shape, for the JSON_VALUE_OP variant. On real Postgres: NULL + // for the source-absent row despite both ON EMPTY/ON ERROR defaults being non-null + // constants. assumeTrue(pgVersion.substringBefore('.').toInt() >= 17, "WHEN NOT MATCHED BY SOURCE requires PostgreSQL 17+") val query = analyzeWithSchema( """ @@ -7275,7 +7244,7 @@ class QueryAnalysisTest { // the fixture data, is the shape that can actually resolve NOT NULL — `to_jsonb(text)` was // deliberately avoided here since it is not itself on any never-null-for-non-null-input // safe-list and would make this control fail for a reason unrelated to containsVarOutsideRelation. - // Verified live: `IS NULL` is `false` for the matched row. + // On real Postgres, `IS NULL` is `false` for the matched row. assumeTrue(pgVersion.substringBefore('.').toInt() >= 17, "MERGE RETURNING requires PostgreSQL 17+") val query = analyzeWithSchema( """ @@ -7315,7 +7284,7 @@ class QueryAnalysisTest { @Test fun `JSON_SERIALIZE over a nullable jsonb column is nullable`() { - // The bug this fix closes: JSON_SERIALIZE(NULL::jsonb) IS NULL is true (verified live, + // The bug this fix closes: JSON_SERIALIZE(NULL::jsonb) IS NULL is true (confirmed on // PostgreSQL 18.4), so a nullable jsonb argument must produce a nullable result column — // previously reported unconditionally NOT NULL regardless of the argument. assumeTrue(pgVersion.substringBefore('.').toInt() >= 17, "JSON_SERIALIZE requires PostgreSQL 17+") @@ -7338,10 +7307,10 @@ class QueryAnalysisTest { @Test fun `JSON() over a nullable text column is nullable`() { - // JSON(expr) is JSON_CONSTRUCTOR_TYPE_PARSE — its single argument is ALWAYS wrapped in a - // JSONVALUEEXPR (verified live), so this also pins that the parser's transparent unwrap keeps - // the real column's own nullability visible rather than degrading to Unknown (always - // nullable, masking the distinction this test exists to catch). + // JSON(expr) is JSON_CONSTRUCTOR_TYPE_PARSE — its single argument is always wrapped in a + // JSONVALUEEXPR, so this also pins that the parser's transparent unwrap keeps the real + // column's own nullability visible rather than degrading to Unknown (always nullable, + // masking the distinction this test exists to catch). assumeTrue(pgVersion.substringBefore('.').toInt() >= 17, "JSON() requires PostgreSQL 17+") val query = analyzeWithSchema( "CREATE TABLE t (data text)", @@ -7353,7 +7322,7 @@ class QueryAnalysisTest { @Test fun `JSON() over a NOT NULL text column is non-null`() { // The precision half of the JSONVALUEEXPR-unwrap pin above: without unwrapping, this argument - // would parse to Unknown and ALWAYS be reported nullable regardless of the source column's own + // would parse to Unknown and always be reported nullable regardless of the source column's own // NOT NULL constraint — a spurious widening this test would catch as a failure. assumeTrue(pgVersion.substringBefore('.').toInt() >= 17, "JSON() requires PostgreSQL 17+") val query = analyzeWithSchema( @@ -7385,16 +7354,16 @@ class QueryAnalysisTest { @Test fun `JSON_OBJECT with zero arguments is non-null`() { - // Verified live: JSON_OBJECT() IS NULL is false on every supported version — JSON_OBJECT/ - // JSON_ARRAY (JsonConstructorType 1/2) are stable back to PostgreSQL 16. + // On real Postgres, JSON_OBJECT() IS NULL is false on every supported version — + // JSON_OBJECT/JSON_ARRAY (JsonConstructorType 1/2) are stable back to PostgreSQL 16. val query = analyzeWithSchema("CREATE TABLE t (id int NOT NULL)", "SELECT JSON_OBJECT() AS result FROM t") assertThat(query.columns[0].notNull).isTrue() } @Test fun `JSON_OBJECT with NULL ON NULL over a nullable value column stays non-null`() { - // ABSENT ON NULL / NULL ON NULL only change the JSON document's CONTENT, never whether the - // SQL-level result itself is null — verified live: JSON_OBJECT('a': NULL NULL ON NULL) IS + // ABSENT ON NULL / NULL ON NULL only change the JSON document's content, never whether the + // SQL-level result itself is null. On real Postgres, JSON_OBJECT('a': NULL NULL ON NULL) IS // NULL is false, even though the value argument is a literal SQL NULL. val query = analyzeWithSchema( "CREATE TABLE t (val text)", @@ -7411,8 +7380,8 @@ class QueryAnalysisTest { @Test fun `JSON_OBJECTAGG is nullable`() { - // Verified live: JSON_OBJECTAGG over an empty group IS NULL is true — the aggregate has no - // non-null initial transition value (pg_aggregate.agginitval IS NULL), the same property + // On real Postgres, JSON_OBJECTAGG over an empty group IS NULL is true — the aggregate has + // no non-null initial transition value (pg_aggregate.agginitval IS NULL), the same property // hasNonNullInitialValue already checks for every other Aggref. JsonConstructorType 3/4 are // stable back to PostgreSQL 16. val query = analyzeWithSchema( @@ -7424,7 +7393,7 @@ class QueryAnalysisTest { @Test fun `JSON_ARRAYAGG is nullable`() { - // Verified live: JSON_ARRAYAGG over an empty group IS NULL is true. + // On real Postgres, JSON_ARRAYAGG over an empty group IS NULL is true. val query = analyzeWithSchema( "CREATE TABLE t (v int NOT NULL)", "SELECT JSON_ARRAYAGG(v) AS result FROM t", @@ -7434,7 +7403,7 @@ class QueryAnalysisTest { @Test fun `JSON_OBJECTAGG OVER a window is nullable`() { - // The WindowFunc leg of JsonConstructorExpr.function: verified live that JSON_OBJECTAGG(...) + // The WindowFunc leg of JsonConstructorExpr.function: confirmed that JSON_OBJECTAGG(...) // OVER (...) puts a WINDOWFUNC node, not an AGGREF, under :func — this pins that // safetyWalkChildren/isNonNull still reach it and route through the existing WindowFunc rule // (which reports nullable here, same as any other non-safe-listed window function). @@ -7557,8 +7526,8 @@ class QueryAnalysisTest { @Test fun `XMLFOREST over a single nullable field is nullable`() { - // Live on PostgreSQL 16, 17 and 18: xmlforest(NULL::text AS q) IS NULL. XMLFOREST omits a null - // field and yields null only once every field has been omitted. + // PostgreSQL 16-18: xmlforest(NULL::text AS q) IS NULL. XMLFOREST omits a null field and + // yields null only once every field has been omitted. val query = analyzeWithSchema( "CREATE TABLE t (name TEXT)", "SELECT xmlforest(name) AS result FROM t", @@ -7568,7 +7537,8 @@ class QueryAnalysisTest { @Test fun `XMLFOREST with one non-null field among nullable ones is non-null`() { - // Live: xmlforest(NULL::text AS q, 'x' AS r) is NOT null — one surviving field is enough. + // On real Postgres, xmlforest(NULL::text AS q, 'x' AS r) is not null — one surviving field + // is enough. val query = analyzeWithSchema( "CREATE TABLE t (name TEXT, kept TEXT NOT NULL)", "SELECT xmlforest(name, kept) AS result FROM t", @@ -7578,7 +7548,8 @@ class QueryAnalysisTest { @Test fun `XMLPI without a content expression is non-null`() { - // Live: xmlpi(name php) is NOT null — the processing instruction still materializes. + // On real Postgres, xmlpi(name php) is not null — the processing instruction still + // materializes. val query = analyzeWithSchema( "CREATE TABLE t (id INT NOT NULL)", "SELECT xmlpi(name php) AS result FROM t", @@ -7588,7 +7559,7 @@ class QueryAnalysisTest { @Test fun `XMLPI over a nullable content expression is nullable`() { - // Live: xmlpi(name php, NULL::text) IS NULL. + // On real Postgres, xmlpi(name php, NULL::text) IS NULL. val query = analyzeWithSchema( "CREATE TABLE t (body TEXT)", "SELECT xmlpi(name php, body) AS result FROM t", @@ -7598,7 +7569,8 @@ class QueryAnalysisTest { @Test fun `XMLELEMENT over a nullable child stays non-null`() { - // Live: xmlelement(name e, NULL::text) is NOT null — the element tag always materializes. + // On real Postgres, xmlelement(name e, NULL::text) is not null — the element tag always + // materializes. val query = analyzeWithSchema( "CREATE TABLE t (name TEXT)", "SELECT xmlelement(NAME e, name) AS result FROM t", @@ -7678,8 +7650,8 @@ class QueryAnalysisTest { // that fallback was a text-based probe with no way to recognize a RowExpr's own shape, so it // reported nullable rather than assert a proof it did not actually perform, even though a // constructed ROW(...) value is, in fact, never itself NULL. The fallback is prosqlbody now: - // it is NOT subject to CREATE VIEW's pseudo-type rejection, and RowExpr is a node type its - // structural analysis already recognizes as never itself NULL. Verified live: `SELECT + // it is not subject to CREATE VIEW's pseudo-type rejection, and RowExpr is a node type its + // structural analysis already recognizes as never itself NULL. On real Postgres, `SELECT // ROW(a, b) AS result FROM t` returns `result = (1,2)` for a matching row, never NULL. val query = analyzeWithSchema( "CREATE TABLE t (a INT NOT NULL, b INT NOT NULL)", @@ -7693,13 +7665,13 @@ class QueryAnalysisTest { // This SQL has no DML at all, but CREATE VIEW rejects a bare ROW(...)'s anonymous "record" // pseudo-type regardless, so it still reaches queryColumnNullability's fallback — prosqlbody // now, in production. Before the cutover, that fallback's text-based null-extending-construct - // scan found the LEFT JOIN and forced EVERY column nullable, including "t.a", which is + // scan found the LEFT JOIN and forced every column nullable, including "t.a", which is // declared NOT NULL and is never actually null-extended by this join (t is the LEFT side, // not u) — an accepted, safe-direction imprecision at the time (a metadata-only answer that // instead trusted the join structure would have been unsafe for the mirror case, `SELECT u.x - // FROM t LEFT JOIN u`, where `u.x` genuinely CAN be null-extended). prosqlbody reads the same - // :varnullingrels this whole cutover is built on, so it resolves BOTH correctly without that - // tradeoff: verified live, both columns are genuinely NOT NULL for a matching row. + // FROM t LEFT JOIN u`, where `u.x` genuinely can be null-extended). prosqlbody reads the same + // :varnullingrels this whole cutover is built on, so it resolves both correctly without that + // tradeoff: on real Postgres, both columns are genuinely NOT NULL for a matching row. val query = analyzeWithSchema( """ CREATE TABLE t (a INT NOT NULL, b INT NOT NULL); @@ -7731,9 +7703,9 @@ class QueryAnalysisTest { @Test fun `NULLIF makes a view column nullable even though the source column of the same name is NOT NULL`() { - // #256: the previous pg_depend name-join inherited "u.v"'s NOT NULL constraint for the view - // column purely because both are named "v", never consulting NULLIF itself — which can always - // return null. Verified live: PostgreSQL returns null for every row here. + // The previous pg_depend name-join inherited "u.v"'s NOT NULL constraint for the view + // column purely because both are named "v", never consulting NULLIF itself — which can + // always return null. On real Postgres, PostgreSQL returns null for every row here. val query = analyzeWithSchema( """ CREATE TABLE u (v TEXT NOT NULL); @@ -7834,10 +7806,10 @@ class QueryAnalysisTest { @Test fun `renamed pass-through view column stays NOT NULL, matching the same-named case`() { - // #256 corollary: the previous pg_depend name-join matched by NAME, so renaming a genuine - // NOT NULL pass-through column ("v" to "other_name") lost the match entirely and fell back - // to reporting it nullable — even though PostgreSQL never returns null for it. This is a - // BEHAVIOR CHANGE from the pre-fix `false` answer. + // The previous pg_depend name-join matched by name, so renaming a genuine NOT NULL + // pass-through column ("v" to "other_name") lost the match entirely and fell back to + // reporting it nullable — even though PostgreSQL never returns null for it. This is a + // behavior change from the pre-fix `false` answer. val query = analyzeWithSchema( """ CREATE TABLE u (v TEXT NOT NULL); @@ -7850,10 +7822,10 @@ class QueryAnalysisTest { @Test fun `an ORDER BY expression appended as a resjunk target entry does not shift a real column's attnum alignment`() { - // #256's resno-contiguity fact: "length(v)" is not in the SELECT list, so PostgreSQL appends - // it as a resjunk=true target-list entry AFTER the real "v" column. If the view-column - // resolver mis-aligned by index instead of by (junk-filtered, resno-sorted) position, this - // would misread "v" using the junk entry's answer instead of its own. + // "length(v)" is not in the SELECT list, so PostgreSQL appends it as a resjunk=true + // target-list entry after the real "v" column. If the view-column resolver mis-aligned by + // index instead of by (junk-filtered, resno-sorted) position, this would misread "v" using + // the junk entry's answer instead of its own. val query = analyzeWithSchema( """ CREATE TABLE u (v TEXT NOT NULL); @@ -7893,10 +7865,10 @@ class QueryAnalysisTest { @Test fun `a mutual view dependency cycle built via CREATE OR REPLACE VIEW resolves nullable, not a stack overflow`() { - // A genuine cyclic view CAN be constructed against a live PostgreSQL database: CREATE OR - // REPLACE VIEW does not require every relation the NEW definition references to have existed - // when the ORIGINAL view was created, only that they exist at the moment of the replace - // itself. Verified live, PostgreSQL 18.4: "a" and "b" below end up mutually dependent. + // A genuine cyclic view can be constructed against a live PostgreSQL database: CREATE OR + // REPLACE VIEW does not require every relation the new definition references to have existed + // when the original view was created, only that they exist at the moment of the replace + // itself. PostgreSQL 18.4: "a" and "b" below end up mutually dependent. val schemaName = "test_${schemaCounter.incrementAndGet()}" DriverManager.getConnection(container.jdbcUrl, container.username, container.password).use { connection -> connection.createStatement().use { @@ -7963,11 +7935,11 @@ class QueryAnalysisTest { fun `warming with a view at exactly the truncation boundary no longer changes the deepest view's answer`() { // Direct pin for the depth-before-memo ordering fix: on a chain of exactly // VIEW_NULLABILITY_RECURSION_DEPTH_BUDGET + 2 views, the deepest view's own resolution - // truncates trying to enter view_1 — the relid sitting EXACTLY at the boundary. Before this + // truncates trying to enter view_1 — the relid sitting exactly at the boundary. Before this // fix, warming with view_1 first (a shallow view that completes correctly and stays // permanently memoized) let the deepest view's later resolution skip truncation entirely by - // reusing that cache — verified live, PostgreSQL 18.4: resolve(deepest) alone gave `[true]`, - // but resolve(view_1) first then resolve(deepest) gave `[false]` instead. Both now agree. + // reusing that cache. PostgreSQL 18.4: resolve(deepest) alone gave `[true]`, but + // resolve(view_1) first then resolve(deepest) gave `[false]` instead. Both now agree. val chainDepth = VIEW_NULLABILITY_RECURSION_DEPTH_BUDGET + 2 val schemaName = "test_${schemaCounter.incrementAndGet()}" DriverManager.getConnection(container.jdbcUrl, container.username, container.password).use { connection -> @@ -8126,9 +8098,9 @@ class QueryAnalysisTest { fun `resolving one view first does not change a later, independent resolution of another`( fixture: ViewOrderIndependenceFixture, ) { - // Regression pin for the memo-poisoning/taint-tracking defect: a LATER, SEPARATE top-level + // Regression pin for the memo-poisoning/taint-tracking defect: a later, separate top-level // resolveViewColumnNullability call must give the identical answer regardless of what an - // EARLIER, already-completed (and fully unwound) top-level call happened to touch first — see + // earlier, already-completed (and fully unwound) top-level call happened to touch first — see // viewColumnNullabilityTaintedRelids' KDoc for the mechanism this pins (taint-tracking + // evict-at-depth-0), and viewOrderIndependenceFixtures' KDoc for why each of the three // fixtures is here. @@ -8141,7 +8113,7 @@ class QueryAnalysisTest { } try { val relidByView = fixture.views.associateWith { view -> regclassOid(connection, view) } - // Ground truth: each view resolved on its OWN completely untouched analyzer — nothing else + // Ground truth: each view resolved on its own completely untouched analyzer — nothing else // has ever been resolved on it, so its answer cannot possibly have been influenced by // resolution order. val groundTruth = fixture.views.associateWith { view -> @@ -8150,9 +8122,9 @@ class QueryAnalysisTest { ).resolveViewColumnNullability(relidByView.getValue(view)) } - // Every ORDERED pair (warm, check) with warm != check: resolving `warm` first, as its OWN + // Every ordered pair (warm, check) with warm != check: resolving `warm` first, as its own // complete top-level call (which fully unwinds before `check` is ever touched), must not - // change `check`'s own, later, independent resolution — checked for EVERY view in the + // change `check`'s own, later, independent resolution — checked for every view in the // fixture (both as the thing warmed with, and the thing checked afterward), which is what // "for every view in the fixture, in both resolution orders" means here. for (warmView in fixture.warmTriggers) { @@ -8181,7 +8153,7 @@ class QueryAnalysisTest { * PostgreSQL itself cannot execute — so what must not happen is that ambiguity leaking into a * later, standalone resolution of either view. * 3. `top -> {a, mid}`, `a -> mid`, `mid -> self-cycle` — the decisive shape for memo-hit taint - * propagation: `top` reads `mid` before `a`, so `a`'s own reference to `mid` is a PURE memo + * propagation: `top` reads `mid` before `a`, so `a`'s own reference to `mid` is a pure memo * read of an already-tainted entry. Because `mid` is an unconditional self-cycle, its answer * is invariant whether recomputed or served stale, so this fixture cannot observe the hole by * value — see the [ColumnNullabilityAnalyzer.isRelidMemoized] test below. @@ -8217,9 +8189,9 @@ class QueryAnalysisTest { name = "a chain deeper than the recursion budget", ddl = chainDdl, views = chainViews, - // Deliberately ONLY the deepest tip, not every view — see this fixture's own KDoc (and - // VIEW_NULLABILITY_RECURSION_DEPTH_BUDGET's) for why warming with an INTERMEDIATE, - // shallower view is NOT safe in general, even after the depth-before-memo fix. + // Deliberately only the deepest tip, not every view — see this fixture's own KDoc (and + // VIEW_NULLABILITY_RECURSION_DEPTH_BUDGET's) for why warming with an intermediate, + // shallower view is not safe in general, even after the depth-before-memo fix. warmTriggers = listOf(chainViews.last()), ), ViewOrderIndependenceFixture( @@ -8239,12 +8211,12 @@ class QueryAnalysisTest { @Test fun `a view reached only through an already-tainted memo entry is still evicted, not left stale`() { - // The white-box pin for the memo-hit taint-propagation hole itself, specific to this ONE - // fixture shape — see ColumnNullabilityAnalyzer.isRelidMemoized's KDoc for why THIS + // The white-box pin for the memo-hit taint-propagation hole itself, specific to this one + // fixture shape — see ColumnNullabilityAnalyzer.isRelidMemoized's KDoc for why this // particular fixture cannot be pinned by output values alone ("mid" is an unconditional // self-cycle, so every value derived from it is identical whether "a" is correctly // evicted-and-recomputed or incorrectly left stale), unlike the black-box pin right below, - // which demonstrates the SAME class of bug IS normally value-observable. + // which demonstrates the same class of bug is normally value-observable. val schemaName = "test_${schemaCounter.incrementAndGet()}" DriverManager.getConnection(container.jdbcUrl, container.username, container.password).use { connection -> connection.createStatement().use { @@ -8368,7 +8340,7 @@ class QueryAnalysisTest { // A generous bound, not a tight wall-clock assertion (see this test's own KDoc for why): // linear resolution of ~(prefixDepth + diamondDepth) distinct views is expected to take // well under a second; the exponential (skip-memoization) shape this pins against took - // over 30 SECONDS at this exact diamondDepth over a comparable truncating prefix, and + // over 30 seconds at this exact diamondDepth over a comparable truncating prefix, and // roughly doubles for every additional level beyond it. assertThat(elapsedMillis < 10_000L).isTrue() } finally { @@ -8448,7 +8420,7 @@ class QueryAnalysisTest { @Test fun `a nullable source column stays nullable, even when a same-named column elsewhere is NOT NULL`() { - // Under the pre-#256 pg_depend name-join, "val" matched BOTH "a.val" (NOT NULL, referenced + // Under the previous pg_depend name-join, "val" matched both "a.val" (NOT NULL, referenced // only in the JOIN condition) and "b.val" (nullable, the actual SELECTed source) purely by // name, requiring an any-nullable-source-wins reduction to get this case right. Under full // node-tree evaluation there is no name matching at all: the view's target list is a plain @@ -8504,10 +8476,10 @@ class QueryAnalysisTest { @Test fun `materialized view LEFT JOIN column is genuinely nullable`() { - // Before #256, materialized views were entirely excluded from outer-join analysis, so + // Before this fix, materialized views were entirely excluded from outer-join analysis, so // "mv.label" — genuinely nullable, on the right side of a LEFT JOIN — was wrongly reported // NOT NULL (inherited from "e.label"'s own constraint via the pg_depend name-join, with no - // outer-join subtraction ever applied to a materialized view). This INVERTS that prior + // outer-join subtraction ever applied to a materialized view). This inverts that prior // (incorrect) answer. val schemaName = "test_${schemaCounter.incrementAndGet()}" DriverManager.getConnection(container.jdbcUrl, container.username, container.password).use { connection -> @@ -8540,13 +8512,13 @@ class QueryAnalysisTest { @Test fun `RETURNING OLD is nullable while RETURNING NEW is not, for the same NOT NULL column`() { // PostgreSQL 18's RETURNING WITH (OLD AS o, NEW AS n) marks both the OLD and NEW Vars - // byte-identical except :varreturningtype (verified live) — including an EMPTY - // :varnullingrels on both — so nothing but that field distinguishes them. This MERGE's WHEN - // NOT MATCHED THEN INSERT action has no OLD row to read at all (the target row didn't exist - // before this statement ran), so o.name must be nullable regardless of "name"'s own NOT NULL - // constraint, while n.name — a fresh value just inserted into that same NOT NULL column, not - // outer-join-nulled — is genuinely proven non-null. Verified against real Postgres: querying - // the function's own result shows o.name = NULL, n.name = 'new-name' for the inserted row. + // byte-identical except :varreturningtype — including an empty :varnullingrels on both, so + // nothing but that field distinguishes them. This MERGE's WHEN NOT MATCHED THEN INSERT + // action has no OLD row to read at all (the target row didn't exist before this statement + // ran), so o.name must be nullable regardless of "name"'s own NOT NULL constraint, while + // n.name — a fresh value just inserted into that same NOT NULL column, not + // outer-join-nulled — is genuinely proven non-null. On real Postgres, querying the + // function's own result shows o.name = NULL, n.name = 'new-name' for the inserted row. assumeTrue(pgVersion.substringBefore('.').toInt() >= 18, "RETURNING WITH (OLD AS o, NEW AS n) requires PG 18+") val schemaName = "test_${schemaCounter.incrementAndGet()}" DriverManager.getConnection(container.jdbcUrl, container.username, container.password).use { connection -> @@ -8573,7 +8545,7 @@ class QueryAnalysisTest { @Test fun `a trailing line comment does not swallow the probe function's terminator`() { - // A trailing "--" comment with nothing after it is an ordinary, syntactically COMPLETE SQL + // A trailing "--" comment with nothing after it is an ordinary, syntactically complete SQL // statement on its own — the same query text a "queries.sql" file could legitimately end // with. The probe wraps it as "BEGIN ATOMIC ; END": without a newline separating // from "; END", the trailing line comment extends over the appended terminator too, and the @@ -8603,8 +8575,8 @@ class QueryAnalysisTest { @Test fun `a trailing terminated block comment never needed the newline fix`() { // Unlike a "--" line comment, a "/* ... */" block comment closes itself before the appended - // "; END" regardless of a newline — verified live. Pinned here so a future change to the - // terminator-safety fix cannot silently start relying on this shape needing help it doesn't. + // "; END" regardless of a newline. Pinned here so a future change to the terminator-safety + // fix cannot silently start relying on this shape needing help it doesn't. val schemaName = "test_${schemaCounter.incrementAndGet()}" DriverManager.getConnection(container.jdbcUrl, container.username, container.password).use { connection -> connection.createStatement().use { @@ -8723,10 +8695,10 @@ class QueryAnalysisTest { @Test fun `aggregateHasNonNullInitialValue-listed aggregates are non-null over empty input, unlike sum and avg`() { - // The mis-named predecessor of this test only asserted that BOTH the true and false subsets - // of aggregateHasNonNullInitialValue were non-empty — a property satisfied even by a WRONG + // The mis-named predecessor of this test only asserted that both the true and false subsets + // of aggregateHasNonNullInitialValue were non-empty — a property satisfied even by a wrong // classification, as long as at least one aggregate landed on each side. This version runs - // every aggregate this map claims is non-null-initial over a genuinely EMPTY input (`... + // every aggregate this map claims is non-null-initial over a genuinely empty input (`... // WHERE false`) and asserts the result really is non-null — the ground truth the map's name // claims — then spot-checks that sum/avg/max/min, which the map correctly excludes, really // do return null over the same empty input. @@ -8780,14 +8752,14 @@ class QueryAnalysisTest { @Test fun `immutableFunctionOids includes an immutable example and excludes stable, volatile, and set-returning ones`() { - // Deliberately does NOT compare immutableFunctionOids against a live re-query of + // Deliberately does not compare immutableFunctionOids against a live re-query of // `provolatile = 'i' AND NOT proretset AND prokind IN ('f', 'w')` — PgCatalogLoader.loadImmutableFunctionOids - // runs EXACTLY that predicate (see PgCatalogLoader.kt), so an `isEqualTo` against the same + // runs exactly that predicate (see PgCatalogLoader.kt), so an `isEqualTo` against the same // predicate here could only ever detect broken plumbing (a query that fails to run at all), - // never a WRONG predicate: a mutation to the production SQL would move both sides of the + // never a wrong predicate: a mutation to the production SQL would move both sides of the // comparison together and the test would stay green. The named-function assertions below are // this test's only teeth, so they are widened past the original two (STABLE, VOLATILE) to - // also cover the SET-RETURNING dimension of the predicate, which nothing here previously + // also cover the set-returning dimension of the predicate, which nothing here previously // exercised with a concrete example at all: `unnest(anyarray)` is itself IMMUTABLE // (`provolatile = 'i'`) but set-returning (`proretset = true`), so it must be excluded by the // `NOT proretset` conjunct specifically, not by volatility. @@ -8820,7 +8792,7 @@ class QueryAnalysisTest { } } } - // abs(int4) is IMMUTABLE and non-set-returning: it MUST be present. + // abs(int4) is IMMUTABLE and non-set-returning: it must be present. val absImmutableOid = requireNotNull(exampleOids["abs(int4)"]) assertThat(immutableOids.contains(absImmutableOid)).isTrue() // date_trunc(text, timestamptz) is STABLE (its result depends on the session timezone). @@ -8829,7 +8801,7 @@ class QueryAnalysisTest { // random() is VOLATILE. val randomVolatileOid = requireNotNull(exampleOids["random()"]) assertThat(immutableOids.contains(randomVolatileOid)).isFalse() - // unnest(anyarray) is IMMUTABLE but SET-RETURNING — excluded by NOT proretset, not by volatility. + // unnest(anyarray) is IMMUTABLE but set-returning — excluded by NOT proretset, not by volatility. val unnestSetReturningOid = requireNotNull(exampleOids["unnest(anyarray)"]) assertThat(immutableOids.contains(unnestSetReturningOid)).isFalse() } @@ -8837,10 +8809,10 @@ class QueryAnalysisTest { @Test fun `neverNullForNonNullInputOids and lagLeadWithDefaultOids contain no VARIADIC pg_proc rows`() { - // PgCatalogLoader.neverNullForNonNullInputOids's KDoc claims NO function on that list is - // VARIADIC ("verified live on PostgreSQL 16, 17, and 18") — unlike alwaysNonNullFunctionOids - // and nonNullIffFirstArgumentNonNullFunctionOids, whose sole entries (concat/concat_ws) are - // DELIBERATELY, DOCUMENTED-ly VARIADIC ("any") and are excluded from this check for exactly + // PgCatalogLoader.neverNullForNonNullInputOids's KDoc claims no function on that list is + // VARIADIC, checked on PostgreSQL 16-18 — unlike alwaysNonNullFunctionOids and + // nonNullIffFirstArgumentNonNullFunctionOids, whose sole entries (concat/concat_ws) are + // deliberately, documented VARIADIC ("any") and are excluded from this check for exactly // that reason (see PgNodeExpression.FuncExpr.isVariadic's KDoc and the two properties' own // KDoc for why the VARIADIC calling form is handled separately rather than trusted here). DriverManager.getConnection(container.jdbcUrl, container.username, container.password).use { connection -> @@ -8860,7 +8832,7 @@ class QueryAnalysisTest { fun `pg_proc rows for concat and concat_ws match a known snapshot`() { // The catalog-drift analogue, for alwaysNonNullFunctionOids/nonNullIffFirstArgumentNonNullFunctionOids, // of `pg_operator rows for safe-listed symbols match a known snapshot`/`pg_proc rows for - // safe-listed function names match a known snapshot` above: enumerates EVERY pg_catalog + // safe-listed function names match a known snapshot` above: enumerates every pg_catalog // overload of concat/concat_ws (not just the strictness-filtered ones production actually // loads), so a future PostgreSQL version adding a new overload of either name — e.g. a // second, STRICT concat overload that would need excluding from alwaysNonNullFunctionOids @@ -8897,7 +8869,7 @@ class QueryAnalysisTest { @Test fun `pg_proc rows for lag and lead match a known snapshot`() { // The catalog-drift analogue, for lagLeadWithDefaultOids, of the snapshot tests above: - // enumerates EVERY window-function overload of lag/lead (1-, 2-, and 3-argument alike), so a + // enumerates every window-function overload of lag/lead (1-, 2-, and 3-argument alike), so a // future PostgreSQL version adding a 4th overload fails loudly here, forcing a human to // decide whether it belongs on lagLeadWithDefaultOids before it silently would (or // wouldn't). @@ -8943,7 +8915,7 @@ class QueryAnalysisTest { fun `pg_aggregate rows match a known snapshot`() { // The catalog-drift analogue, for aggregateHasNonNullInitialValue, of the pg_proc/pg_cast // snapshot tests above. aggregateHasNonNullInitialValue has no name restriction at all (see - // its loader) — it classifies EVERY pg_aggregate row in the whole catalog — so its drift + // its loader) — it classifies every pg_aggregate row in the whole catalog — so its drift // surface is the entire catalog, not a curated name list. KNOWN_AGGREGATE_SNAPSHOT below was // captured on PostgreSQL 16 (identical to 17); PostgreSQL 18 added exactly four new // castfunc-unrelated overloads (max/min of bytea and record), none of which have a non-null @@ -9093,9 +9065,9 @@ class QueryAnalysisTest { @Test fun `pg_proc rows for safe-listed function names match a known snapshot`() { - // Enumerates EVERY pg_catalog overload for every function NAME appearing in + // Enumerates every pg_catalog overload for every function name appearing in // NEVER_NULL_FUNCTION_SIGNATURES — not just the safe-listed signatures — so a PostgreSQL - // version bump that adds a NEW overload of one of these names (exactly how the + // version bump that adds a new overload of one of these names (exactly how the // anyrange/anymultirange overloads of lower/upper slipped in, silently widening a // name-keyed safe list to an untotal overload) fails loudly here, forcing a human to look // at the new overload and decide whether it is total before adding it to @@ -9105,7 +9077,7 @@ class QueryAnalysisTest { // PostgreSQL 18 — see the identically-shaped `pg_cast rows for safe-listed source types` // test's KDoc immediately below for why comparing the live server directly against a fixed // snapshot containing a version-18-only entry would fail on 16/17, and why splitting the - // expectation into a safe-listed portion (derived from what actually resolves HERE) and a + // expectation into a safe-listed portion (derived from what actually resolves here) and a // non-safe-listed portion (pinned to the fixed snapshot, since `lower`/`upper`'s // anyrange/anymultirange overloads and `trunc`'s macaddr/macaddr8 overloads are not // version-sensitive across 16/17/18) keeps this an exact equality without reintroducing that @@ -9113,8 +9085,8 @@ class QueryAnalysisTest { // // Deriving the safe-listed portion of the expectation from `actualSignatures` itself (rather // than from the safe list directly) has one absorbed gap: a safe-listed signature - // DISAPPEARING from the live server — a real regression, not a version difference — would - // vanish from `actualSignatures.intersect(safeListedSignatures)` on the actual side AND from + // disappearing from the live server — a real regression, not a version difference — would + // vanish from `actualSignatures.intersect(safeListedSignatures)` on the actual side and from // the same computation on the expected side, so the two stay equal and the test passes // silently. `versionGatedSafeListedSignatures` below closes that gap for every safe-listed // signature except the one entry actually known to be version-gated (`reverse(bytea)`, @@ -9123,7 +9095,7 @@ class QueryAnalysisTest { // one. A future safe-listed signature that becomes version-gated on some future PostgreSQL // release would need adding to `versionGatedSafeListedSignatures` by hand, exactly the way // `reverse(bytea)` already is — this does not reintroduce the original brittleness (a fixed - // snapshot compared directly against the live server), since every OTHER signature's + // snapshot compared directly against the live server), since every other signature's // presence is still checked live, not against a fixed count. DriverManager.getConnection(container.jdbcUrl, container.username, container.password).use { connection -> val names = NeverNullSafeLists.NEVER_NULL_FUNCTION_SIGNATURES.map { it.name }.toSortedSet() @@ -9218,40 +9190,40 @@ class QueryAnalysisTest { @Test fun `pg_cast rows for safe-listed source types match a known snapshot`() { - // Enumerates EVERY castfunc-backed pg_catalog cast FROM any source type appearing in + // Enumerates every castfunc-backed pg_catalog cast from any source type appearing in // NEVER_NULL_CAST_SIGNATURES — not just the safe-listed (source, target) pairs — so a - // PostgreSQL version bump that adds a NEW castfunc-backed target for one of these source + // PostgreSQL version bump that adds a new castfunc-backed target for one of these source // types fails loudly here, forcing a human to look at the new cast and decide whether it is // total before adding it to NEVER_NULL_CAST_SIGNATURES. This is the cast analogue of the // `lower`/`upper` overload-drift risk the function and operator snapshot tests above guard // against: the previous blanket rule ("every pg_catalog castfunc is safe") could never have // been caught by a test like this, since it had no explicit pair list to diff against. // - // KNOWN_CAST_PAIR_SNAPSHOT below was captured against PostgreSQL 18, which is a STRICT - // SUPERSET of 16 and 17 for these source types: PostgreSQL 18 added six castfunc-backed + // KNOWN_CAST_PAIR_SNAPSHOT below was captured against PostgreSQL 18, which is a strict + // superset of 16 and 17 for these source types: PostgreSQL 18 added six castfunc-backed // int2/int4/int8 <-> bytea casts (all six safe-listed) that simply do not exist in pg_cast // on 16/17. Asserting the live server's actual pairs equal that fixed snapshot directly (as // a prior version of this test did) therefore fails on 16/17 even though nothing about those - // six pairs is wrong there — they just do not resolve on THIS connected server, exactly the + // six pairs is wrong there — they just do not resolve on this connected server, exactly the // way PgCatalogLoader.loadNeverNullForNonNullInputOids's live catalog lookup finds no row // for them either. // // The fix keeps the assertion an exact equality (never weakened to a subset check) by // splitting expectedPairs into two halves: the portion of KNOWN_CAST_PAIR_SNAPSHOT that is - // NOT itself safe-listed (reg*/oid/char/xml/name — pairs this test enumerates for visibility + // not itself safe-listed (reg*/oid/char/xml/name — pairs this test enumerates for visibility // but NEVER_NULL_CAST_SIGNATURES has no opinion on) stays pinned to the fixed snapshot, since - // that portion is not version-sensitive across 16/17/18 (verified directly against all - // three). The safe-listed portion is instead derived from THIS live server — whichever of + // that portion is not version-sensitive across 16/17/18 (confirmed directly against all + // three). The safe-listed portion is instead derived from this live server — whichever of // NEVER_NULL_CAST_SIGNATURES's pairs actually appear in actualPairs — so it silently // shrinks on a server where a safe-listed pair does not yet resolve, without ever silently - // growing: a genuinely NEW overload for one of these source types still shows up in + // growing: a genuinely new overload for one of these source types still shows up in // actualPairs but not in expectedPairs (it's neither a known non-safe-listed extra nor a // safe-listed pair with a matching row in actualPairs) unless a human adds it to one of the // two sets, so drift is still caught exactly the way it was before this fix. // // Deriving the safe-listed portion of expectedPairs from actualPairs itself has one absorbed // gap, the cast analogue of the one described in `pg_proc rows for safe-listed function - // names match a known snapshot`'s KDoc above: a safe-listed pair DISAPPEARING from the live + // names match a known snapshot`'s KDoc above: a safe-listed pair disappearing from the live // server for a real (non-version) reason would vanish from both sides of the comparison and // pass silently. `versionGatedSafeListedPairs` below closes that gap for every safe-listed // pair except the six actually known to be version-gated (the int2/int4/int8 <-> bytea @@ -9335,12 +9307,12 @@ class QueryAnalysisTest { // The real exact + behavioral check for this property now lives in SafeListSweepTest's // `lagLeadWithDefaultOids-listed 3-argument lag and lead fill window boundaries from a // non-null default` test. This one is kept, rather than deleted as redundant, for - // consistency with every sibling test in THIS class (each PgCatalogLoader-loaded property + // consistency with every sibling test in this class (each PgCatalogLoader-loaded property // gets its own small, cheap, exact-by-name OID check here) — but upgraded from a bare - // `isNotEmpty()` to genuine exact membership, resolved by NAME from pg_catalog independently + // `isNotEmpty()` to genuine exact membership, resolved by name from pg_catalog independently // of PgCatalogLoader.loadLagLeadWithDefaultOids's own `pronargs = 3` predicate, so a mutation // that widens or narrows that predicate (e.g. to the 2-argument overloads) is caught by the - // resulting set INEQUALITY here too, not just in the heavier live sweep. + // resulting set inequality here too, not just in the heavier live sweep. DriverManager.getConnection(container.jdbcUrl, container.username, container.password).use { connection -> val catalogLoader = PgCatalogLoader(connection) val expectedOids = connection.createStatement().use { stmt -> @@ -9362,9 +9334,9 @@ class QueryAnalysisTest { fun `alwaysNonNull OIDs equal exactly the concat overloads, and exclude concat_ws`() { // concat_ws is deliberately excluded: it is non-null only when its separator (first // argument) is non-null, not unconditionally — see alwaysNonNullFunctionOids's KDoc and - // nonNullIffFirstArgumentNonNullFunctionOids below. Both halves are resolved by NAME from + // nonNullIffFirstArgumentNonNullFunctionOids below. Both halves are resolved by name from // pg_catalog, restricted to that namespace for the same reason as the concat_ws sibling test - // above: this class's tests run in PARALLEL against the same shared container, and + // above: this class's tests run in parallel against the same shared container, and // `user-defined concat and concat_ws in another schema do not ride onto either safe-list` // concurrently creates and drops schema-scoped `concat`/`concat_ws` functions that an // un-namespaced query could intermittently pick up. @@ -9401,14 +9373,14 @@ class QueryAnalysisTest { fun `nonNullIffFirstArgumentNonNull OIDs equal exactly the concat_ws overloads`() { // `isNotEmpty()` alone is the exact `size >= 2` shape that let the original concat_ws bug // ship: it stays green under a mutation that puts `concat` (or any other function) on this - // list ALONGSIDE or INSTEAD OF `concat_ws`, since the set is still non-empty either way. The + // list alongside or instead of `concat_ws`, since the set is still non-empty either way. The // OIDs resolved here are independent of PgCatalogLoader.loadNonNullIffFirstArgumentNonNullFunctionOids's - // own SQL — matched by NAME alone, not by re-asserting its `NOT proisstrict` predicate — so a + // own SQL — matched by name alone, not by re-asserting its `NOT proisstrict` predicate — so a // mutation that widens or narrows the production predicate to a different name (or an - // additional one) is caught by the resulting set INEQUALITY, not absorbed by both sides + // additional one) is caught by the resulting set inequality, not absorbed by both sides // moving together. Restricted to `pg_catalog` (unlike the sibling `alwaysNonNull` test's - // intersect-only check, which does not need it): tests in this class run in PARALLEL, each on - // its own connection but against the SAME shared container, and `user-defined concat and + // intersect-only check, which does not need it): tests in this class run in parallel, each on + // its own connection but against the same shared container, and `user-defined concat and // concat_ws in another schema do not ride onto either safe-list` concurrently creates and // drops a schema-scoped `concat_ws` — an un-namespaced query here would intermittently pick up // that shadow OID too and fail this exact-equality assertion on a false positive. @@ -9431,7 +9403,7 @@ class QueryAnalysisTest { @Test fun `user-defined concat and concat_ws in another schema do not ride onto either safe-list`() { - // Verified live: without a pronamespace restriction, a user-defined function sharing the + // On real Postgres, without a pronamespace restriction, a user-defined function sharing the // name `concat`/`concat_ws` (even with completely different, always-NULL behavior) gets // picked up by these lists' `proname`-only predicate and would be wrongly trusted as // non-null. @@ -9730,14 +9702,14 @@ class QueryAnalysisTest { // // The three `!hasGroupingSets` qual-narrowing suppressions in ColumnNullabilityAnalyzer (lines // 293, 664, 752) that these tests were originally named for cannot be exercised by any query. - // Verified live: under GROUPING SETS, PostgreSQL rejects the primary-key functional-dependency - // shortcut that plain GROUP BY allows, so every non-aggregated column in a grouping-sets - // target list must be, or textually match, the grouping key — forcing it nullable via - // `isEffectivelyNonNull`'s `isGroupingKey` check before `isNonNull`, and therefore before qual - // narrowing, is ever reached. This holds identically at the top-level, CTE-body, and - // subquery-RTE sites, because `isSafeFromGroupingSetNullExtension` keys on `PgNodeExpression` - // shape, never on what a `Var`'s `varno` resolves to. Both halves of the suppression (qual - // narrowing and `groupRteMap = emptyMap()`) are dead for the same reason. + // On real Postgres, under GROUPING SETS, PostgreSQL rejects the primary-key + // functional-dependency shortcut that plain GROUP BY allows, so every non-aggregated column in + // a grouping-sets target list must be, or textually match, the grouping key — forcing it + // nullable via `isEffectivelyNonNull`'s `isGroupingKey` check before `isNonNull`, and therefore + // before qual narrowing, is ever reached. This holds identically at the top-level, CTE-body, + // and subquery-RTE sites, because `isSafeFromGroupingSetNullExtension` keys on + // `PgNodeExpression` shape, never on what a `Var`'s `varno` resolves to. Both halves of the + // suppression (qual narrowing and `groupRteMap = emptyMap()`) are dead for the same reason. // // The one thing that would revive the suppression: extending `Aggref`/`GroupingFunc` // evaluation in `isNonNull` to consult its own arguments' nullability (e.g. a future MIN/MAX @@ -9786,10 +9758,10 @@ class QueryAnalysisTest { assertThat(query.columns[0].notNull).isFalse() } - // These four EXPRESSION-grouping-key tests pin the same thing as the four bare-Var tests + // These four expression-grouping-key tests pin the same thing as the four bare-Var tests // above (see the comment there for the full explanation). Here the grouping key is an - // EXPRESSION (e.g. `lower(a)`) rather than a bare column, but the target-list entry for - // `lower(a)` structurally IS the grouping key, so it gets the ROLLUP's own `:ressortgroupref` + // expression (e.g. `lower(a)`) rather than a bare column, but the target-list entry for + // `lower(a)` structurally is the grouping key, so it gets the ROLLUP's own `:ressortgroupref` // and is forced nullable by `isGroupingKey` before `isNonNull` is ever reached — exactly as // for the bare-Var cluster. @@ -9915,10 +9887,10 @@ class QueryAnalysisTest { @Test fun `a strict, never-null-for-non-null-input function is not trusted for a VARIADIC call`() { - // Guards the exact failure mode that produced the concat/concat_ws P0s: a curated + // Guards the exact failure mode that produced the concat/concat_ws bugs: a curated // safe-list's guarantee, verified only for the ordinary calling convention, silently // applied to VARIADIC too. No real function on NEVER_NULL_FUNCTION_SIGNATURES resolves to - // a provariadic <> 0 function on PostgreSQL 16, 17, or 18 (verified live), so this stubs + // a provariadic <> 0 function on PostgreSQL 16, 17, or 18, so this stubs // isStrict/isNeverNullForNonNullInput to simulate one being added in variadic form — the // only way to reach this path without editing the safe-list itself. The ArrayExpr argument // contains a NULL element; isNonNull's own ArrayExpr branch is unconditionally true @@ -9973,7 +9945,7 @@ class QueryAnalysisTest { * incidentally — escaped it, then returns its result. * * Used by `ViewNullability`'s cyclic-view and deep-recursion pins, which specifically need to - * prove the ABSENCE of a `StackOverflowError` as part of what they assert, not merely that a + * prove the absence of a `StackOverflowError` as part of what they assert, not merely that a * value came back — detekt's `ForbiddenImport` rule blocks `org.junit.jupiter.api.Assertions.*` * project-wide (this codebase uses assertk), so this wraps the same "did not throw" check in an * assertk-compatible form instead of JUnit's own `assertDoesNotThrow`. @@ -9992,7 +9964,7 @@ class QueryAnalysisTest { } /** - * Resolves the `pg_class.oid` of the relation named [relationName] on the CURRENT `search_path` + * Resolves the `pg_class.oid` of the relation named [relationName] on the current `search_path` * of [connection] — used by `ViewNullability`'s deep-chain pins to get a relid to call * [ColumnNullabilityAnalyzer.resolveViewColumnNullability] with directly, rather than through * [PgCatalogLoader.loadViewColumnNullability]'s unordered schema-wide sweep (which would leave @@ -10008,7 +9980,7 @@ class QueryAnalysisTest { /** * Ground truth for [PgCatalogLoader.aggregateHasNonNullInitialValue]: calls the aggregate named - * [aggregateName], with arguments typed [argumentTypeNames], over a genuinely EMPTY input (`... + * [aggregateName], with arguments typed [argumentTypeNames], over a genuinely empty input (`... * WHERE false`, not merely an aggregate over no matching group), and reports whether the result * is `null`. `count` with zero declared arguments is `count(*)` — the only ordinary-call-syntax * exception PostgreSQL's aggregate grammar needs here, mirroring @@ -10016,7 +9988,7 @@ class QueryAnalysisTest { * the same reason: `count()` alone is a syntax error. * * A declared `any` argument type (e.g. `count`'s one-argument overload, `count(any)`) is the - * PSEUDO-type PostgreSQL uses to mean "accepts a value of any concrete type" — it has no literal + * pseudo-type PostgreSQL uses to mean "accepts a value of any concrete type" — it has no literal * form of its own, so `NULL::any` is a syntax error the same way `NULL::anyelement` would be. * Instantiated here to a concrete `int4` column instead, the same way * [SafeListSweepTest.concreteInstantiationsFor] instantiates `anyelement` to `int4` for the same diff --git a/generator/src/test/kotlin/norm/generator/SafeListSweepTest.kt b/generator/src/test/kotlin/norm/generator/SafeListSweepTest.kt index 4b0c9286..8d9b8b2c 100644 --- a/generator/src/test/kotlin/norm/generator/SafeListSweepTest.kt +++ b/generator/src/test/kotlin/norm/generator/SafeListSweepTest.kt @@ -19,35 +19,30 @@ import java.sql.SQLException import java.sql.Statement /** - * Brute-force verification, against a LIVE PostgreSQL instance, that every entry in + * Brute-force check, against a live PostgreSQL instance, that every entry in * [NeverNullSafeLists.NEVER_NULL_FUNCTION_SIGNATURES], [NeverNullSafeLists.NEVER_NULL_CAST_SIGNATURES], - * [NeverNullSafeLists.NEVER_NULL_OPERATOR_SIGNATURES], and pgcrypto's `digest`/`hmac` really is TOTAL - * on non-null input — the property [NodeTreeNullabilityAnalyzer] relies on those lists for, in - * addition to `pg_proc.proisstrict`/an equivalent implicit-strictness check, licensing the claim - * that a call is non-null whenever every one of its arguments is non-null. + * [NeverNullSafeLists.NEVER_NULL_OPERATOR_SIGNATURES], and pgcrypto's `digest`/`hmac` really is total + * on non-null input. [NodeTreeNullabilityAnalyzer] relies on these lists, alongside + * `pg_proc.proisstrict`/an equivalent implicit-strictness check, to conclude a call is non-null + * whenever every one of its arguments is non-null. * - * This sweep — not hand-reasoning about a specific overload's documented behavior — is what - * LICENSES an entry on any of these lists. For each signature, this test substitutes every - * combination (the cartesian product across argument positions) of non-null EDGE-CASE values - * from [EDGE_VALUE_CORPUS] for that signature's argument(s) and asserts the resulting call - * `IS NOT NULL`. The corpus specifically targets the values that have historically broken a - * "total" claim for some `pg_catalog` overload: empty strings, `NaN`/`Infinity`/`-Infinity`, - * PostgreSQL's `infinity`/`-infinity` date/time sentinels, zero, negative and min/max integer - * values, empty and unbounded ranges, empty arrays and multiranges, and a CLOSED geometric path - * (the exact shape that broke `path + path` — see [NeverNullSafeLists.NEVER_NULL_OPERATOR_SIGNATURES]). + * For each signature, this test substitutes every combination (the cartesian product across + * argument positions) of non-null edge-case values from [EDGE_VALUE_CORPUS] and asserts the + * resulting call `IS NOT NULL`. The corpus targets values that have historically broken a "total" + * claim for some `pg_catalog` overload: empty strings, `NaN`/`Infinity`/`-Infinity`, PostgreSQL's + * `infinity`/`-infinity` date/time sentinels, zero, negative and min/max integer values, empty and + * unbounded ranges, empty arrays and multiranges, and a closed geometric path (the shape that broke + * `path + path` — see [NeverNullSafeLists.NEVER_NULL_OPERATOR_SIGNATURES]). * - * A case that raises a [SQLException] is a PASS only when the exception proves the call actually - * EVALUATED and then genuinely errored — see [isNotEvaluatedSqlState] for the syntax-error / - * undefined-function / cannot-coerce PostgreSQL SQLSTATE classes that mean the call never got that - * far (a syntax error is not evidence about the function's runtime behavior). An actual `null` - * RESULT is always a failure, named by the exact entry and the exact literal SQL expression that - * produced it. + * A [SQLException] only counts as a pass when it proves the call actually evaluated and then + * genuinely errored — see [isNotEvaluatedSqlState] for the syntax-error / undefined-function / + * cannot-coerce SQLSTATE classes that mean the call never got that far. An actual `null` result is + * always a failure, named by the exact entry and the exact literal SQL expression that produced it. * - * A case being a PASS is not, by itself, enough to license a signature: [CoverageTracker] requires - * every signature to additionally accumulate at least one case that actually EVALUATED and - * returned a non-null value. A signature whose every case errored (or, worse, never got past - * parsing) provides ZERO information about whether the signature is actually total, and is - * reported as a failure in its own right — see `requirePositiveCoverage`. + * Passing every case is not enough to license a signature: [CoverageTracker] requires each + * signature to also accumulate at least one case that evaluated and returned a non-null value. A + * signature whose every case errored (or never got past parsing) proves nothing about whether it's + * actually total, and is reported as a failure in its own right — see `requirePositiveCoverage`. */ @Testcontainers class SafeListSweepTest { @@ -175,11 +170,10 @@ class SafeListSweepTest { /** * pgcrypto's `digest`/`hmac` are keyed through `pg_depend` rather than appearing on any of the * three static safe lists (see [PgCatalogLoader.loadNeverNullForNonNullInputOids]'s pgcrypto - * carve-out), so none of the three tests above ever exercises them. This test brings all four - * documented-total overloads (`digest(text, text)`, `digest(bytea, text)`, `hmac(text, text, - * text)`, `hmac(bytea, bytea, text)`) into the same brute-force sweep, backing the KDoc's claim - * that they were verified total. `postgres:$pgVersion-alpine` can `CREATE EXTENSION pgcrypto` - * without any extra setup, so there is no need for an alternate verification path. + * carve-out), so none of the three tests above exercises them. This test runs the same + * brute-force sweep over all four documented-total overloads (`digest(text, text)`, + * `digest(bytea, text)`, `hmac(text, text, text)`, `hmac(bytea, bytea, text)`). + * `postgres:$pgVersion-alpine` can `CREATE EXTENSION pgcrypto` without any extra setup. */ @Test fun `every pgcrypto digest and hmac signature is total on the edge-value corpus`() { @@ -207,21 +201,19 @@ class SafeListSweepTest { /** * A sweep with no positive-coverage requirement, and no distinction between "evaluated and - * errored" and "never evaluated", is a sweep that can silently rot into a no-op — it would keep - * reporting BUILD SUCCESSFUL forever, even for a signature added later that is provably NOT - * total, as long as every one of its cases happens to error. This test drives [checkTotal] and - * [CoverageTracker] directly against `extract(text, timestamp)` — a signature deliberately never - * added to [NeverNullSafeLists.NEVER_NULL_FUNCTION_SIGNATURES] because - * `extract(hour FROM 'infinity'::timestamp)` returns `null` with no error (see that list's KDoc) - * — and asserts the sweep machinery reports it as a failure. If this test ever passes without - * failures being produced, the sweep's core safety property has silently stopped working. + * errored" and "never evaluated", can rot into a no-op: it would keep passing even for a + * signature that is provably not total, as long as every one of its cases happens to error. This + * test drives [checkTotal] and [CoverageTracker] directly against `extract(text, timestamp)` — a + * signature deliberately never added to [NeverNullSafeLists.NEVER_NULL_FUNCTION_SIGNATURES] + * because `extract(hour FROM 'infinity'::timestamp)` returns `null` with no error (see that + * list's KDoc) — and asserts the sweep machinery reports it as a failure. * - * This test alone only proves [checkTotal] catches an actual `null` RESULT — `extract` still + * This test alone only proves [checkTotal] catches an actual `null` result — `extract` still * accumulates plenty of `EvaluatedNonNull` cases from every non-infinity timestamp in the corpus, * so it says nothing about [CoverageTracker.requirePositiveCoverage] itself. The two tests below * it (`requirePositiveCoverage fails ...` / `requirePositiveCoverage passes ...`) close that gap * by calling `requirePositiveCoverage` directly against a synthetic zero-coverage and a synthetic - * real-coverage [CoverageTracker], the only way to exercise the ZERO-coverage branch, since every + * real-coverage [CoverageTracker] — the only way to exercise the zero-coverage branch, since every * signature actually on a safe list has real coverage by construction. */ @Test @@ -273,7 +265,7 @@ class SafeListSweepTest { } /** - * The positive counterpart to the test above: a [CoverageTracker] that HAS recorded a genuine + * The positive counterpart to the test above: a [CoverageTracker] that has recorded a genuine * [CaseOutcome.EvaluatedNonNull] case reports no failure. Without this test, a * `requirePositiveCoverage` that always failed (rather than one that failed only on zero * coverage) would also make the test above pass, hiding a rule that rejects every signature. @@ -292,7 +284,7 @@ class SafeListSweepTest { * The signature this test models: `int2->bytea`, safe-listed but only castfunc-backed starting * in PostgreSQL 18 — on 16/17 every corpus case dies on `42846: cannot cast type smallint to * bytea`, a class-42 (never-evaluated) SQLSTATE, for every single literal regardless of value. - * [CoverageTracker.neverResolvedOnThisServer] must recognize this as a SKIP, not a failure — see + * [CoverageTracker.neverResolvedOnThisServer] must recognize this as a skip, not a failure — see * that method's KDoc for why "every case NotEvaluated" reliably means "does not resolve here". */ @Test @@ -304,9 +296,9 @@ class SafeListSweepTest { } /** - * The counterpart to the test above: a signature that DOES resolve here but happens to error on + * The counterpart to the test above: a signature that does resolve here but happens to error on * every corpus value it was given (a genuine "corpus is insufficient" bug, not a version gap) - * must NOT be classified as a skip — that mix ([CaseOutcome.EvaluatedError] alongside + * must not be classified as a skip — that mix ([CaseOutcome.EvaluatedError] alongside * [CaseOutcome.NotEvaluated], or [CaseOutcome.EvaluatedError] alone) is impossible for a * signature that never resolves at all (see [CoverageTracker.neverResolvedOnThisServer]'s * KDoc), so it is left to fail normally via [CoverageTracker.requirePositiveCoverage] instead. @@ -330,13 +322,13 @@ class SafeListSweepTest { /** * Builds the literal SQL call expression for one case of [signature] given [rawArguments] (one * literal per argument position, already present in [EDGE_VALUE_CORPUS] order) and - * [argumentTypeNames] — the CONCRETE type to cast each literal to at the call site. Defaults to + * [argumentTypeNames] — the concrete type to cast each literal to at the call site. Defaults to * [SafeFunctionSignature.argumentTypeNames] itself, which is correct for every ordinary, * non-pseudo-typed signature; a signature with a pseudo-type argument (e.g. * `cardinality(anyarray)`) passes the concrete instantiation actually under test for this case * (e.g. `integer[]`) instead, since a literal cannot be cast directly to a pseudo-type. Most * functions use ordinary `name(arg, arg, ...)` call syntax with each argument explicitly cast - * (`$literal::$typeName`) so PostgreSQL resolves the EXACT overload under test. Three names need + * (`$literal::$typeName`) so PostgreSQL resolves the exact overload under test. Three names need * special-casing because their only valid invocation form differs from an ordinary function * call: * - `ntile` is a window function (`prokind = 'w'`) — it cannot be called as an ordinary function, @@ -344,7 +336,7 @@ class SafeListSweepTest { * - `extract` is SQL-standard grammar, not an ordinary function call — `extract('hour'::text, * x::time)` is a syntax error (`extract` cannot be followed by a parenthesized, comma-separated * argument list the way a real function can); the only valid call form is `EXTRACT( - * FROM )`, with `` a BARE keyword (`HOUR`, not `'hour'::text`). + * FROM )`, with `` a bare keyword (`HOUR`, not `'hour'::text`). * - `position` is likewise SQL-standard grammar, not an ordinary function call — * `position('a'::text, 'abc'::text)` is a syntax error; the only valid call form is * `POSITION( IN )`. @@ -411,19 +403,19 @@ class SafeListSweepTest { /** * The mirror image of [checkTotal]: executes `SELECT () IS NULL` and requires the - * result to actually BE `null` — used by the [nonNullIffFirstArgumentNonNullFunctionOids] sweep's + * result to actually be `null` — used by the [nonNullIffFirstArgumentNonNullFunctionOids] sweep's * property (ii), where a `NULL` first argument must poison the whole result regardless of every * other argument. * - * Returns `true` ONLY when the case genuinely EVALUATED and CONFIRMED a `null` result — the one + * Returns `true` only when the case genuinely evaluated and confirmed a `null` result — the one * outcome that actually proves the property, mirroring how only [CaseOutcome.EvaluatedNonNull] * proves totality for [checkTotal]. Returns `false` for a non-`null` result (also recorded as a - * failure) and `null` for anything that did NOT confirm the property one way or the other: a - * class-42 (never-evaluated) SQLSTATE, matching [isNotEvaluatedSqlState], AND a genuine runtime - * error. An earlier version of this method conflated "evaluated to `null`" with "raised ANY + * failure) and `null` for anything that did not confirm the property one way or the other: a + * class-42 (never-evaluated) SQLSTATE, matching [isNotEvaluatedSqlState], or a genuine runtime + * error. An earlier version of this method conflated "evaluated to `null`" with "raised any * non-class-42 exception", returning `true` for both — a future entry whose every NULL-first case * happened to raise a real (non-class-42) runtime error, rather than actually returning `null`, - * would then have passed this sweep having proven NOTHING about the property under test. The + * would then have passed this sweep having proven nothing about the property under test. The * caller must require at least one `true` case per signature, exactly the way * [CoverageTracker.requirePositiveCoverage] requires at least one [CaseOutcome.EvaluatedNonNull] * for [checkTotal] — a `false`/`null` case is not proof, the same way an [CaseOutcome.EvaluatedError]/ @@ -453,18 +445,18 @@ class SafeListSweepTest { /** * Brute-force verification that every entry in [PgCatalogLoader.alwaysNonNullFunctionOids] - * really is non-null for ANY combination of argument values, including when EVERY argument is + * really is non-null for any combination of argument values, including when every argument is * `NULL` — the exact claim [concat_ws] shipping on this list would have violated (`concat_ws` * returns `null` when its separator is `NULL`, even though every other argument is non-null). - * This is the test the `concat_ws` bug this file's KDoc describes needed: unlike the totality - * sweep above, which never substitutes an actual `NULL` literal for any argument, - * [nullEveryPositionCases] specifically forces a `NULL` into every position, one at a time (with - * every other position drawn from [EDGE_VALUE_CORPUS]), plus the all-`NULL` combination. + * Unlike the totality sweep above, which never substitutes an actual `NULL` literal for any + * argument, [nullEveryPositionCases] specifically forces a `NULL` into every position, one at a + * time (with every other position drawn from [EDGE_VALUE_CORPUS]), plus the all-`NULL` + * combination. * * OIDs are read from [PgCatalogLoader.alwaysNonNullFunctionOids] itself — computed live, the same * way production does — rather than a hardcoded OID, and resolved back to a `pg_proc.proname` via * [resolveProcName] so this test automatically covers whatever the production list actually - * contains today. [NULL_ARGUMENT_SWEEP_SIGNATURES_BY_NAME] supplies the CONCRETE arity/types to + * contains today. [NULL_ARGUMENT_SWEEP_SIGNATURES_BY_NAME] supplies the concrete arity/types to * call each name with, since `concat`'s single `pg_catalog` row is declared `VARIADIC "any"` — a * pseudo-type with no literal form of its own, unlike `anyarray`/`anyrange`/`anyelement`, which * [concreteInstantiationsFor] already knows how to instantiate. @@ -501,13 +493,13 @@ class SafeListSweepTest { } /** - * Brute-force verification of BOTH directions of + * Brute-force verification of both directions of * [PgCatalogLoader.nonNullIffFirstArgumentNonNullFunctionOids]'s claim: (i) a non-null first * argument with `NULL`(s) anywhere else never produces a `null` result, and (ii) a `NULL` first - * argument ALWAYS produces a `null` result, regardless of the other arguments. Property (ii) is + * argument always produces a `null` result, regardless of the other arguments. Property (ii) is * what distinguishes this list from [alwaysNonNullFunctionOids] — an entry here is non-null only - * CONDITIONALLY, and a signature that turns out to be non-null even with a `NULL` first argument - * belongs on that list instead, not this one. + * conditionally, and a signature that turns out to be non-null even with a `NULL` first argument + * belongs on that list instead. * * OIDs and corpus signatures are resolved the same way as the `alwaysNonNullFunctionOids` sweep * above — see that test's KDoc. @@ -566,13 +558,13 @@ class SafeListSweepTest { /** * Verification of [PgCatalogLoader.lagLeadWithDefaultOids]'s claim: the 3-argument `lag`/`lead` - * overloads are non-null when their value and default expressions are non-null, EVEN at a window + * overloads are non-null when their value and default expressions are non-null, even at a window * boundary where the 1- and 2-argument forms would return `null` (no such row exists to fetch). * Runs both `lag` and `lead` over a small non-null, ordered dataset with a non-null literal - * default and asserts every row's output is non-null, then asserts the actual BOUNDARY row (the + * default and asserts every row's output is non-null, then asserts the actual boundary row (the * one with no preceding/following row for the given offset) equals the literal default exactly — * proving the default genuinely filled in the boundary, not merely that no row happened to be - * `null` for some other reason. Also asserts the 2-argument form is NOT on this list: it has no + * `null` for some other reason. Also asserts the 2-argument form is not on this list: it has no * default to fall back on, so it genuinely does return `null` at a window boundary. */ @Test @@ -627,7 +619,7 @@ class SafeListSweepTest { * generic text corpus (empty string, `'abc'`) would only ever error (invalid field name), * never exercising the field-dependent `null`-on-infinity behavior these functions are * documented (see [NeverNullSafeLists.NEVER_NULL_FUNCTION_SIGNATURES]) to special-case for only - * SOME fields. Quoted, since `date_trunc`/`date_part` take the field name as an ordinary + * some fields. Quoted, since `date_trunc`/`date_part` take the field name as an ordinary * `text` argument; [functionCallExpression] strips the quotes back off for `extract`, whose * SQL-standard grammar takes the field name as a bare keyword instead. */ @@ -648,7 +640,7 @@ class SafeListSweepTest { /** * Format-name literals for `encode`/`decode`'s second argument. `base64`/`hex`/`escape` are * PostgreSQL's only three recognized formats — without at least one of them in the corpus, - * EVERY case dies on `unrecognized encoding: ""` (a genuine runtime error, but never a + * every case dies on `unrecognized encoding: ""` (a genuine runtime error, but never a * successful non-null result), leaving the signature with zero positive coverage despite the * sweep having run dozens of cases against it. The empty-string entry is kept specifically so * an unrecognized-format case stays in the corpus too, landing in the "evaluated and errored" @@ -666,7 +658,7 @@ class SafeListSweepTest { /** * Codepoint literals for `chr`'s single argument. `chr`'s min/max-`int4` edge values (`0`, - * `-2147483648`, `2147483647`) ALL error (`null character not permitted` / `character number + * `-2147483648`, `2147483647`) all error (`null character not permitted` / `character number * must be positive` / `requested character too large for encoding`), so sweeping only * [EDGE_VALUE_CORPUS]'s plain `int4` corpus would leave `chr` with zero positive coverage * despite genuinely being total. `65` (`'A'`) and `1114111` (the maximum valid Unicode code @@ -679,17 +671,17 @@ class SafeListSweepTest { /** * Edge-case literal values (unquoted where bare numeric literals suffice, single-quoted * otherwise), keyed by `pg_type.typname`. Every literal is cast explicitly (`$literal::type`) - * at the call site rather than left untyped, so PostgreSQL resolves the EXACT overload under + * at the call site rather than left untyped, so PostgreSQL resolves the exact overload under * test instead of picking whichever overload its untyped-literal defaulting rules prefer. */ private val EDGE_VALUE_CORPUS: Map> = mapOf( // The minimum-integer literals are parenthesized because every call site embeds a corpus - // entry as `$literal::$typeName` (`::` binds tighter than unary `-`), so an UNPARENTHESIZED - // `-32768::int2` parses as `-(32768::int2)` — the INNER, still-positive literal overflows + // entry as `$literal::$typeName` (`::` binds tighter than unary `-`), so an unparenthesized + // `-32768::int2` parses as `-(32768::int2)` — the inner, still-positive literal overflows // int2 on its own coercion before the outer negation ever runs, raising a data-exception - // SQLSTATE (class `22`) that [isNotEvaluatedSqlState] correctly treats as an evaluated PASS, + // SQLSTATE (class `22`) that [isNotEvaluatedSqlState] correctly treats as an evaluated pass, // even though the minimum value never actually reached the entry under test. Parenthesizing - // forces `(-32768)::int2` — negate the untyped literal FIRST, then coerce the already-signed + // forces `(-32768)::int2` — negate the untyped literal first, then coerce the already-signed // `-32768` to `int2`, which is in range. `-1` needs no parentheses: `-1::int2` parses as // `-(1::int2)`, and `1` is in range for every one of these types, so there is no overflow to // mask. @@ -715,7 +707,7 @@ class SafeListSweepTest { "interval" to listOf("'1 day'", "'infinity'", "'-infinity'", "'0'"), "uuid" to listOf("'00000000-0000-0000-0000-000000000000'"), "lseg" to listOf("'[(0,0),(1,1)]'"), - // A CLOSED path and an OPEN path — the exact distinction that makes `path + path` unsafe. + // A closed path and an open path — the exact distinction that makes `path + path` unsafe. "path" to listOf("'((0,0),(1,1),(2,0))'", "'[(0,0),(1,1)]'"), "tsvector" to listOf("''", "'a b c'"), "integer[]" to listOf("'{}'", "'{1,2}'", "'{1,NULL}'"), @@ -729,7 +721,7 @@ class SafeListSweepTest { /** * pgcrypto's `digest`/`hmac` overloads, brute-force-swept for total-ness the same way as the - * three static [PgCatalogLoader] safe lists, but NOT sourced from any of them: they are an + * three static [PgCatalogLoader] safe lists, but not sourced from any of them: they are an * extension carve-out keyed through `pg_depend`, not a name/argument-type entry on a list (see * [PgCatalogLoader.loadNeverNullForNonNullInputOids]). Defined here, in the test, rather than * in production code, since nothing else needs a [SafeFunctionSignature] for them. @@ -742,10 +734,10 @@ class SafeListSweepTest { ) /** - * A self-cast (source type == target type, e.g. `varchar` -> `varchar`) with NO typmod on the + * A self-cast (source type == target type, e.g. `varchar` -> `varchar`) with no typmod on the * target folds away to a no-op: `EXPLAIN (VERBOSE) SELECT v::varchar FROM t` (`v` a `varchar` * column) shows bare `Output: v` — no cast node at all, and `pg_cast.castfunc` is never called. - * These entries are licensed as typmod-ENFORCEMENT casts (see + * These entries are licensed as typmod-enforcement casts (see * [NeverNullSafeLists.NEVER_NULL_CAST_SIGNATURES]'s KDoc), so sweeping them without a typmod would * grant coverage while never invoking the function the entry actually licenses. The target * side of every self-cast entry here therefore carries an explicit typmod shorter than at least @@ -766,9 +758,9 @@ class SafeListSweepTest { ) /** - * Literal corpus for the TYPMOD form of each self-cast entry — a superset of + * Literal corpus for the typmod form of each self-cast entry — a superset of * [EDGE_VALUE_CORPUS]'s plain corpus for the same source type, with at least one literal added - * that is LONGER/more-precise than the typmod in [SELF_CAST_TYPMOD_SUFFIX], so truncation or + * that is longer/more-precise than the typmod in [SELF_CAST_TYPMOD_SUFFIX], so truncation or * rounding is actually exercised (`'abcdef'::varchar(3)` truncates to `'abc'`; * `123.456::numeric(5,2)` rounds to `123.46`) rather than every case happening to already fit. */ @@ -790,17 +782,17 @@ class SafeListSweepTest { ) /** - * Source-side typmod override for [SELF_CAST_TYPMOD_LITERAL_CORPUS] entries whose type's BARE - * form (no typmod at all) does NOT mean "unconstrained" — unlike `varchar`/`numeric`/ + * Source-side typmod override for [SELF_CAST_TYPMOD_LITERAL_CORPUS] entries whose type's bare + * form (no typmod at all) does not mean "unconstrained" — unlike `varchar`/`numeric`/ * `timestamp`/etc., whose bare form has no length/precision limit, bare `bit` means `bit(1)` * (the SQL-standard default length). Casting a literal straight to bare `bit` therefore - * truncates it to a single bit BEFORE the self-cast under test ever runs, making every + * truncates it to a single bit before the self-cast under test ever runs, making every * source-side value length-1 and defeating the truncation [SELF_CAST_TYPMOD_SUFFIX]'s KDoc * describes this whole mechanism as existing to exercise: `('101'::bit)::bit(1)` would - * truncate `'101'` down to `'1'` at the FIRST cast, so the outer, narrower `bit(1)` cast would + * truncate `'101'` down to `'1'` at the first cast, so the outer, narrower `bit(1)` cast would * receive an already-1-bit value and never do any truncation of its own. This override casts * the source side to an explicit typmod wide enough to hold every literal in - * `SELF_CAST_TYPMOD_LITERAL_CORPUS["bit"]` first (`bit(3)`), so the OUTER cast — to + * `SELF_CAST_TYPMOD_LITERAL_CORPUS["bit"]` first (`bit(3)`), so the outer cast — to * [SELF_CAST_TYPMOD_SUFFIX]'s narrower `bit(1)` — is the one actually doing the truncation. */ private val SELF_CAST_SOURCE_TYPMOD_SUFFIX: Map = mapOf("bit" to "(3)") @@ -808,11 +800,11 @@ class SafeListSweepTest { /** * Concrete arities/types to sweep for [PgCatalogLoader.alwaysNonNullFunctionOids]'s and * [PgCatalogLoader.nonNullIffFirstArgumentNonNullFunctionOids]'s NULL-argument properties, - * keyed by `pg_proc.proname`. Both properties are keyed by NAME ALONE in production (see + * keyed by `pg_proc.proname`. Both properties are keyed by name alone in production (see * [PgCatalogLoader.loadAlwaysNonNullFunctions]/ * [PgCatalogLoader.loadNonNullIffFirstArgumentNonNullFunctionOids]'s `proname = '...'` * predicates), because `concat`/`concat_ws`'s single `pg_catalog` row for each is declared - * `VARIADIC "any"`/`VARIADIC "any"` — the DECLARED argument type is the pseudo-type `any` + * `VARIADIC "any"`/`VARIADIC "any"` — the declared argument type is the pseudo-type `any` * itself, with no literal form of its own, unlike `anyarray`/`anyrange`/`anyelement`, which * [concreteInstantiationsFor] already knows how to instantiate. This map supplies the concrete * arity/types actually exercised at the call site instead. @@ -820,7 +812,7 @@ class SafeListSweepTest { * `concat_ws` is registered here even though production correctly never lists it under * [PgCatalogLoader.alwaysNonNullFunctionOids] today — if that regressed (this is exactly the * shipped bug this whole file's KDoc describes), the always-non-null sweep must actually - * EXERCISE `concat_ws`'s real NULL-argument behavior and fail on the genuine semantic violation + * exercise `concat_ws`'s real NULL-argument behavior and fail on the genuine semantic violation * (`concat_ws(NULL, 'x', 'y')` returns `null`), not merely fail on a missing corpus * registration that would just as easily hide a real regression. */ @@ -835,9 +827,9 @@ class SafeListSweepTest { ) /** - * Every argument combination needed to prove a function is non-null for ANY combination of - * argument values INCLUDING when every argument is `NULL`: the all-`NULL` combination, plus, - * for each argument position, that position forced to `NULL` with every OTHER position drawn + * Every argument combination needed to prove a function is non-null for any combination of + * argument values including when every argument is `NULL`: the all-`NULL` combination, plus, + * for each argument position, that position forced to `NULL` with every other position drawn * from the cartesian product of [EDGE_VALUE_CORPUS] for that position's type. `"NULL"` needs no * special handling from [functionCallExpression] — it is a valid raw argument literal the same * way `"'abc'"` or `"0"` is, since `$literal::$typeName` becomes plain `NULL::$typeName`. @@ -856,7 +848,7 @@ class SafeListSweepTest { /** * Resolves [oid] to its unqualified `pg_proc.proname` — lets the * `alwaysNonNullFunctionOids`/`nonNullIffFirstArgumentNonNullFunctionOids` sweeps look up which - * concrete [NULL_ARGUMENT_SWEEP_SIGNATURES_BY_NAME] entry to test for an OID computed LIVE by + * concrete [NULL_ARGUMENT_SWEEP_SIGNATURES_BY_NAME] entry to test for an OID computed live by * [PgCatalogLoader] (the same way production does), rather than hardcoding an OID that would * silently go stale across a PostgreSQL version bump. */ @@ -894,7 +886,7 @@ class SafeListSweepTest { /** * The literal SQL cast expressions to sweep for [signature]. A self-cast entry (source == - * target, and present in [SELF_CAST_TYPMOD_SUFFIX]) is swept in its TYPMOD form — see + * target, and present in [SELF_CAST_TYPMOD_SUFFIX]) is swept in its typmod form — see * [SELF_CAST_TYPMOD_SUFFIX]'s KDoc for why the plain, no-typmod form would be a no-op. Every * other entry keeps the original bare `($literal::source)::target` form. */ @@ -917,7 +909,7 @@ class SafeListSweepTest { /** * Concrete (type name, corpus) instantiations for [typeName]. A pseudo-type — `anyarray`, - * `anyrange`, `anymultirange`, `anyelement` — instantiates to SEVERAL concrete types, because + * `anyrange`, `anymultirange`, `anyelement` — instantiates to several concrete types, because * a single generic `pg_operator` row (e.g. `anyrange && anyrange`) is shared by every * concrete range type at runtime; sweeping only one concrete instantiation would leave the * others unverified. A concrete type instantiates to itself. @@ -963,7 +955,7 @@ class SafeListSweepTest { /** * `true` when [signature] resolves to a real `pg_proc` row on this connected server, checked - * by the SAME lookup [PgCatalogLoader.loadNeverNullForNonNullInputOids] performs in + * by the same lookup [PgCatalogLoader.loadNeverNullForNonNullInputOids] performs in * production (name plus the exact ordered list of declared argument `pg_type.typname` * values, restricted to `pronamespace = 'pg_catalog'`) — just run per-signature here instead * of batched. This is the independent check [neverResolvedOnThisServer]'s KDoc says every @@ -1072,9 +1064,9 @@ class SafeListSweepTest { * cannot-coerce type mismatch (`42846`) alike. A `SQLException` in this class is not evidence * the underlying function/cast/operator is total; the call never got far enough to say * anything about that. Every other class (a data exception, an out-of-range value, a - * feature-not-supported unit, ...) means the call DID reach the function's body and it chose - * to raise rather than return — which IS evidence of total-ness, so it counts as a pass (see - * [checkTotal]) even though it is not POSITIVE coverage (see [CoverageTracker]). + * feature-not-supported unit, ...) means the call did reach the function's body and it chose + * to raise rather than return — which is evidence of total-ness, so it counts as a pass (see + * [checkTotal]) even though it is not positive coverage (see [CoverageTracker]). */ private fun isNotEvaluatedSqlState(sqlState: String?): Boolean = sqlState != null && sqlState.take(2) == "42" } diff --git a/generator/src/test/kotlin/norm/generator/SourceReferenceLiveVerificationTest.kt b/generator/src/test/kotlin/norm/generator/SourceReferenceLiveVerificationTest.kt index 77b2081e..f0cbfd29 100644 --- a/generator/src/test/kotlin/norm/generator/SourceReferenceLiveVerificationTest.kt +++ b/generator/src/test/kotlin/norm/generator/SourceReferenceLiveVerificationTest.kt @@ -41,18 +41,18 @@ import kotlin.io.path.readText * against a live server makes this a permanent part of the suite: a future change that makes any * golden span unparseable now fails the build here. * - * A golden `.kt` file IS the generated file, checked into the repository — [GenerateCodeTest] + * A golden `.kt` file is the generated file, checked into the repository — [GenerateCodeTest] * already proves, for every one of these same scenarios, that a freshly generated file matches its - * golden BYTE FOR BYTE, so reading the committed golden text is equivalent to driving the generator + * golden byte for byte, so reading the committed golden text is equivalent to driving the generator * again and is far cheaper across this many files. * * Verification strategy: a source-reference span is either a `` `table."Column"` `` reference or a * computed expression (see [PropertySource.sourceReference]'s own KDoc); either way, the span is - * proven by finding SOME syntactically valid context — a real table from the scenario's own schema, + * proven by finding some syntactically valid context — a real table from the scenario's own schema, * or a CTE body's own `FROM` clause taken from the golden's embedded `sql` fence — in which * `EXPLAIN SELECT (span) ` parses and analyzes without error. `EXPLAIN` (never executed) - * is used deliberately: several golden queries are `DELETE`/`UPDATE` CTE bodies, and this test must - * never mutate the schema it is verifying against. + * is used because several golden queries are `DELETE`/`UPDATE` CTE bodies, and this test must never + * mutate the schema it is verifying against. */ @Testcontainers @Execution(ExecutionMode.SAME_THREAD) @@ -74,11 +74,11 @@ class SourceReferenceLiveVerificationTest { if (markerCount == 0) return val spans = extractSourceReferenceSpans(markdown) - // A raw "(`" in the KDoc's own Markdown SOURCE (before CommonMark parses it) is the exact, + // A raw "(`" in the KDoc's own Markdown source (before CommonMark parses it) is the exact, // and only, text addClassKdoc emits via `append("($source)")` where $source is itself - // backtick-wrapped -- so every occurrence means a span WAS emitted here, and each must parse - // back out on its own. Comparing the FULL count -- not just checking spans is non-empty -- - // catches a PARTIAL loss too: losing one span while others survive still leaves the list + // backtick-wrapped -- so every occurrence means a span was emitted here, and each must parse + // back out on its own. Comparing the full count -- not just checking spans is non-empty -- + // catches a partial loss too: losing one span while others survive still leaves the list // non-empty, which a plain emptiness check would silently accept. A mismatch means span // delimitation broke somewhere in this file's KDoc paragraph: a stray, unpaired backtick or // backslash elsewhere in the same paragraph closed, reopened, or swallowed a code span in the @@ -108,10 +108,10 @@ class SourceReferenceLiveVerificationTest { fun `a swallowed span among surviving ones is a count mismatch, not silently accepted`() { // Reproduces the exact shape a plain "spans.isEmpty()" check cannot see -- property b's own // unescaped backtick ("Say `hello", the defect class fixed elsewhere by escapeMarkdownBacktick) - // pairs with property c's marker backtick instead of a's, swallowing - // c's ENTIRE span into a bigger code span that is not preceded by "(" at all. Property a's own - // span, earlier in the same paragraph and fully self-contained, still parses correctly -- so - // the result is non-empty (a's span alone), which the old guard would have accepted outright. + // pairs with property c's marker backtick instead of a's, swallowing c's entire span into a + // bigger code span that is not preceded by "(" at all. Property a's own span, earlier in the + // same paragraph and fully self-contained, still parses correctly -- so the result is non-empty + // (a's span alone), which the old guard would have accepted outright. val markdown = "@property a (`t.\"x\"`)\n@property b Say `hello\n@property c (`SOME_EXPR`)" val spans = extractSourceReferenceSpans(markdown) @@ -119,7 +119,7 @@ class SourceReferenceLiveVerificationTest { assertThat(spans).containsExactly("t.\"x\"") assertThat(markerCount).isEqualTo(2) - // The guard's own comparison: a non-empty, but INCOMPLETE, span list must still be caught. + // The guard's own comparison: a non-empty, but incomplete, span list must still be caught. assertThat(spans.size).isEqualTo(1) } @@ -156,7 +156,7 @@ class SourceReferenceLiveVerificationTest { * For every CTE declared anywhere in [sql] (including a CTE body's own nested `WITH` clause, * walked recursively), the CTE's own `WITH` prefix text paired with its body's `FROM`-onward * text — a syntactically valid context for a computed expression drawn from that body, since a - * sibling CTE the body's `FROM` clause references by name is declared in that SAME `WITH` prefix. + * sibling CTE the body's `FROM` clause references by name is declared in that same `WITH` prefix. * * A CTE body with no top-level `FROM` at all (a bare `VALUES`/`TABLE` body, or one whose only * source is another CTE addressed without needing a table) contributes nothing here; such an @@ -206,7 +206,7 @@ class SourceReferenceLiveVerificationTest { /** * Extracts every source-reference span from [markdown]: a [Code] node whose immediately preceding * text ends with `(` — the exact, and only, shape [TypeSpec.Builder.addClassKdoc] produces via - * `append("($source)")`. A `@property` tag's own NAME token can also be a [Code] node (when the + * `append("($source)")`. A `@property` tag's own name token can also be a [Code] node (when the * property name itself needs backtick-quoting), but that one is always preceded by `@property ` * or comment prose, never a bare `(`, so this marker never confuses the two. */ @@ -232,7 +232,7 @@ class SourceReferenceLiveVerificationTest { } /** - * Counts every raw `` (` `` marker in [markdown]'s own Markdown SOURCE, before CommonMark parses + * Counts every raw `` (` `` marker in [markdown]'s own Markdown source, before CommonMark parses * it — the exact, and only, text [TypeSpec.Builder.addClassKdoc] emits via `append("($source)")` * where `$source` is itself backtick-wrapped. Compared against [extractSourceReferenceSpans]'s * parsed count, this is what catches a partial span loss: [extractSourceReferenceSpans] alone @@ -261,21 +261,20 @@ class SourceReferenceLiveVerificationTest { } /** - * Unwraps the FIRST `/** ... */` KDoc block out of [fileText] back to its own Markdown source — + * Unwraps the first `/** ... */` KDoc block out of [fileText] back to its own Markdown source — * indentation-agnostic, unlike [KdocProvenanceRoundTripTest]'s identical-in-spirit helper, which - * only ever reads a single, TOP-LEVEL (zero-indent) class KDoc rendered in isolation. A generated - * FILE (as opposed to one [com.squareup.kotlinpoet.TypeSpec] rendered alone) can contain many + * only ever reads a single, top-level (zero-indent) class KDoc rendered in isolation. A generated + * file (as opposed to one [com.squareup.kotlinpoet.TypeSpec] rendered alone) can contain many * KDoc blocks nested at different indentation depths (one per interface method, for instance), so - * the opening/closing markers are matched by their TRIMMED content (`/**`/`*/`) rather than a + * the opening/closing markers are matched by their trimmed content (`/**`/`*/`) rather than a * fixed literal indent. * * Only the first block is ever examined: a data-class projection file — the only kind that ever * carries an `@property` source-reference span, via `addClassKdoc` — has exactly one [TypeSpec] - * and therefore at most one KDoc block, so there is nothing later in such a file to miss. A file - * with no source-reference-bearing KDoc at all (an interface/implementation file, or a table - * projection with no documentation) either has no KDoc block (`null`, below) or a first block that - * [extractSourceReferenceSpans] simply finds no matching span in — the caller already handles - * both by skipping. + * and therefore at most one KDoc block. A file with no source-reference-bearing KDoc at all (an + * interface/implementation file, or a table projection with no documentation) either has no KDoc + * block (`null`, below) or a first block that [extractSourceReferenceSpans] finds no matching span + * in — the caller already handles both by skipping. * * @return `null` if [fileText] has no KDoc block at all. */ diff --git a/generator/src/test/kotlin/norm/generator/SqlCteClauseTest.kt b/generator/src/test/kotlin/norm/generator/SqlCteClauseTest.kt index ce141781..2dbdb18b 100644 --- a/generator/src/test/kotlin/norm/generator/SqlCteClauseTest.kt +++ b/generator/src/test/kotlin/norm/generator/SqlCteClauseTest.kt @@ -64,9 +64,9 @@ class SqlCteClauseTest { fun `CTE body containing a closing parenthesis inside a string literal parses correctly`() { // Latent bug fixed alongside the DML lexer work: findMatchingCloseParenthesis previously // counted the ')' inside 'closing )' as if it closed the CTE body, truncating it and - // corrupting everything parsed after — including a SECOND CTE that follows. With lexical + // corrupting everything parsed after — including a second CTE that follows. With lexical // awareness, the literal's ')' is skipped as part of the string token, so the CTE body's - // TRUE closing paren (the one right before the comma) is what's found. + // real closing paren (the one right before the comma) is what's found. val sql = """ WITH note AS ( SELECT 'closing )'::TEXT AS msg @@ -89,11 +89,11 @@ class SqlCteClauseTest { @Test fun `CTE body containing a column named with two dollar signs parses correctly`() { - // Behavior CHANGE from the dollar-quote identifier fix: before it, the "$" between "b" and - // "c" in "a$b$c" was misread as opening a "$b$"-tagged dollar-quote, swallowing the CTE - // body's own closing ")" (and everything after) as unterminated string content — parseCteClause - // would have found only ONE (corrupted) definition, or none at all. This is the corrected, - // intended behavior: "a$b$c" is an ordinary identifier, not a dollar-quoted string. + // Before the dollar-quote identifier fix, the "$" between "b" and "c" in "a$b$c" was + // misread as opening a "$b$"-tagged dollar-quote, swallowing the CTE body's own closing ")" + // (and everything after) as unterminated string content — parseCteClause would have found + // only one (corrupted) definition, or none at all. "a$b$c" is an ordinary identifier, not a + // dollar-quoted string. val sql = """ WITH renamed AS ( SELECT a${'$'}b${'$'}c AS msg FROM note @@ -111,9 +111,9 @@ class SqlCteClauseTest { @Test fun `CTE name containing a non-ASCII character parses correctly`() { - // Verified against a real PostgreSQL 18.4: "WITH data€x AS (SELECT 1 AS inner_name) SELECT - // inner_name AS outer_name FROM data€x" is accepted -- "data€x" is an ordinary unquoted - // identifier (PostgreSQL's lexer admits any byte >= 0x80 inside one). Before the fix, the + // In PostgreSQL 18.4, "WITH data€x AS (SELECT 1 AS inner_name) SELECT inner_name AS + // outer_name FROM data€x" is accepted -- "data€x" is an ordinary unquoted identifier + // (PostgreSQL's lexer admits any byte >= 0x80 inside one). Before the fix, the // CTE-name run used the narrow letter/digit/underscore class, which stopped at "€", leaving // "x AS (SELECT 1 AS inner_name) SELECT inner_name AS outer_name FROM data€x" where an "AS" // keyword was expected -- so parsing failed and this returned null instead of one definition. @@ -127,7 +127,7 @@ class SqlCteClauseTest { @Test fun `a quoted name with an escaped embedded double quote keeps the WHOLE token in rawName`() { - // #238: the quoted-name scan previously stopped at the first '"', truncating rawName to + // The quoted-name scan previously stopped at the first '"', truncating rawName to // `"He"` for a CTE actually named `He"llo` (SQL source `"He""llo"`) -- the escaped `""` in the // middle was misread as the closing quote. `WITH "He""llo" AS (SELECT 1) SELECT 1 FROM // "He""llo"` is valid PostgreSQL, and the CTE's real name is `He"llo` (one literal embedded @@ -140,11 +140,11 @@ class SqlCteClauseTest { @Test fun `a dollar-led CTE name is not recognized, since PostgreSQL itself rejects one`() { - // Issue #219 follow-up: parseSingleCteDefinition's unquoted-name run originally used - // isIdentifierChar -- the CONTINUATION predicate -- for the name's FIRST character too, so - // a leading "$" was wrongly accepted as starting a CTE name. Verified against a real - // PostgreSQL 18.4: "WITH $x AS (SELECT 1) SELECT a FROM x" is a syntax error ("at or near - // $") -- "$" may only continue an identifier, never start one. With the first character + // parseSingleCteDefinition's unquoted-name run originally used isIdentifierChar -- the + // continuation predicate -- for the name's first character too, so a leading "$" was + // wrongly accepted as starting a CTE name. In PostgreSQL 18.4, "WITH $x AS (SELECT 1) + // SELECT a FROM x" is a syntax error ("at or near $") -- "$" may only continue an + // identifier, never start one. With the first character // correctly gated by isIdentifierStartChar, no CTE name is found here at all, so this // returns null exactly as it does on main. val result = parseCteClause("WITH \$x AS (SELECT 1) SELECT a FROM x") diff --git a/generator/src/test/kotlin/norm/generator/SqlIdentifiersTest.kt b/generator/src/test/kotlin/norm/generator/SqlIdentifiersTest.kt index 611564e6..e461197c 100644 --- a/generator/src/test/kotlin/norm/generator/SqlIdentifiersTest.kt +++ b/generator/src/test/kotlin/norm/generator/SqlIdentifiersTest.kt @@ -38,8 +38,7 @@ class SqlIdentifiersTest { fun `32 two-byte characters -- 64 bytes -- truncate to 31 characters, 62 bytes, the mid-character crossing`() { // Each "é" is 2 UTF-8 bytes -- 32 of them is 64 bytes, one byte over budget. The 32nd "é" // cannot be split in half, so it is dropped whole, landing on 62 bytes rather than the full - // 63-byte budget -- verified against the live server in - // JdbcAnalyzerTest.TruncateIdentifierServerDifferentialTest. + // 63-byte budget (PostgreSQL's `NAMEDATALEN - 1` limit). val name = "é".repeat(32) val result = truncateIdentifier(name) diff --git a/generator/src/test/kotlin/norm/generator/SqlKeywordScannerTest.kt b/generator/src/test/kotlin/norm/generator/SqlKeywordScannerTest.kt index 0762f90b..45a987b6 100644 --- a/generator/src/test/kotlin/norm/generator/SqlKeywordScannerTest.kt +++ b/generator/src/test/kotlin/norm/generator/SqlKeywordScannerTest.kt @@ -82,21 +82,20 @@ class SqlKeywordScannerTest { @Test fun `splits correctly around a column name containing two dollar signs`() { - // Behavior CHANGE from wiring splitAtTopLevel through skipLexicalToken: before the dollar- - // quote identifier fix, the "$" between "b" and "c" was misread as opening a "$b$"-tagged - // dollar-quote, swallowing everything after it (including the real commas) as unterminated - // string content — yielding ONE item instead of three. This is the corrected, intended - // behavior, not a regression: "a$b$c" is an ordinary PostgreSQL identifier. + // Before the dollar-quote fix, the "$" between "b" and "c" was misread as opening a + // "$b$"-tagged dollar-quote, swallowing everything after it (including the real commas) as + // unterminated string content — yielding one item instead of three. "a$b$c" is an ordinary + // PostgreSQL identifier. val result = splitAtTopLevel("a\$b\$c, id, name", ',') assertThat(result).containsExactly("a\$b\$c", " id", " name") } @Test fun `preserves content inside square brackets`() { - // Regression guard: square brackets were not tracked as a nesting level at all, so - // ARRAY[1, 2]'s internal comma split the item in two — verified against real PostgreSQL to - // corrupt oldOrNewReturningColumns's item count, and (worse) able to numerically CANCEL OUT - // an unrelated star-caused split error, defeating its real-column-count cross-check entirely. + // Square brackets were not tracked as a nesting level, so ARRAY[1, 2]'s internal comma + // split the item in two — corrupting oldOrNewReturningColumns's item count, and even + // capable of numerically canceling out an unrelated star-caused split error, defeating its + // real-column-count cross-check entirely. val result = splitAtTopLevel("ARRAY[1, 2] AS arr, name", ',') assertThat(result).containsExactly("ARRAY[1, 2] AS arr", " name") } @@ -194,7 +193,7 @@ class SqlKeywordScannerTest { keyword = "FROM", expected = { it.indexOf("FROM a") }, ), - // "data_set" as a TABLE name ends in "_set" — the character before "set" is "_", which + // "data_set" as a table name ends in "_set" — the character before "set" is "_", which // must count as an identifier character so the real "SET" keyword afterward is what's found. KeywordScanCase( description = "does not match SET inside the identifier data_set", @@ -228,8 +227,8 @@ class SqlKeywordScannerTest { keyword = "FROM", expected = { it.indexOf("FROM a") }, ), - // Issue #219: PostgreSQL's lexer admits any byte >= 0x80 inside an unquoted identifier, so - // "from€" is a single identifier, not the keyword FROM followed by a stray "€". + // PostgreSQL's lexer admits any byte >= 0x80 inside an unquoted identifier, so "from€" is + // a single identifier, not the keyword FROM followed by a stray "€". KeywordScanCase( description = "does not match FROM when immediately followed by a non-ASCII identifier-continuation character", sql = "SELECT * FROM€ t", @@ -249,7 +248,7 @@ class SqlKeywordScannerTest { @Test fun `bails to not-found once an unmatched closing parenthesis drives depth negative`() { // A bare ")" with no matching "(" before it means the input is not the well-formed, - // balanced text this scan assumes. Without a floor, depth stays negative until a LATER + // balanced text this scan assumes. Without a floor, depth stays negative until a later // unmatched "(" happens to bring it back to exactly 0 — at which point a keyword genuinely // inside that malformed region would wrongly be treated as top-level. Bailing to -1 at the // first negative dip is the safe direction: an honest "not found" over a confidently wrong @@ -264,9 +263,9 @@ class SqlKeywordScannerTest { @Test fun `a trailing non-ASCII character on a returning-shaped identifier is not mistaken for the bare keyword`() { - // The exact shape from #219: "returning€" is a legal PostgreSQL column name (PostgreSQL's - // lexer admits any byte >= 0x80 inside an unquoted identifier), so this must not be split - // into the bare word "returning" plus a stray "€". + // "returning€" is a legal PostgreSQL column name (PostgreSQL's lexer admits any byte + // >= 0x80 inside an unquoted identifier), so this must not be split into the bare word + // "returning" plus a stray "€". val sql = "INSERT INTO users (email, age, preferences) " + "SELECT returning€, age, preferences FROM src RETURNING id, email" val result = findTopLevelReturningKeyword(sql) @@ -297,10 +296,9 @@ class SqlKeywordScannerTest { @Test fun `still finds the real keyword when it is not preceded by AS`() { - // NOT demonstrative of any fix in this file — the AS-preceded-alias exclusion predates this - // branch's >= 0x80 boundary work (it's the #212-era fix). Kept as a regression guard that - // the ordinary, already-working case (a real RETURNING clause with no AS before it) still - // matches. + // Not new behavior — the AS-preceded-alias exclusion predates this branch's >= 0x80 + // boundary work. Kept as a regression guard that a real RETURNING clause with no AS before + // it still matches. val sql = "DELETE FROM t WHERE id = 1 RETURNING id" val result = findTopLevelReturningKeyword(sql) assertThat(result).isEqualTo(sql.indexOf("RETURNING id")) @@ -308,9 +306,9 @@ class SqlKeywordScannerTest { @Test fun `does not match an AS-preceded column alias`() { - // NOT demonstrative of any fix in this file — same reasoning as above: the AS-preceded - // exclusion is pre-existing behavior, not part of this branch's >= 0x80 boundary work. Kept - // as a regression guard against that exclusion becoming overly broad or narrow. + // Not new behavior — same as above, the AS-preceded exclusion predates this branch's + // >= 0x80 boundary work. Kept as a regression guard against that exclusion becoming overly + // broad or narrow. val sql = "SELECT email AS returning, x FROM t" val result = findTopLevelReturningKeyword(sql) assertThat(result).isEqualTo(-1) @@ -322,8 +320,8 @@ class SqlKeywordScannerTest { @Test fun `a FROM that is really part of IS DISTINCT FROM is not mistaken for the clause boundary`() { - // #238: a plain findTopLevelKeyword search returns the FIRST depth-0 "FROM", which here is - // the one glued to "IS DISTINCT" -- truncating the select list to "a IS DISTINCT" and leaving + // A plain findTopLevelKeyword search returns the first depth-0 "FROM", which here is the + // one glued to "IS DISTINCT" -- truncating the select list to "a IS DISTINCT" and leaving // "b FROM t" (the expression's own right-hand operand) looking like the real clause. val sql = "SELECT a IS DISTINCT FROM b FROM t" val result = findTopLevelFromClauseKeyword(sql, 0) @@ -377,18 +375,17 @@ class SqlKeywordScannerTest { @Test fun `does not consume OUTER when immediately followed by a non-ASCII identifier-continuation character`() { - // Issue #219: "OUTER€" is a single identifier, not the keyword OUTER followed by a stray - // "€" -- the position must be left unchanged, exactly as for any other non-matching text. + // "OUTER€" is a single identifier, not the keyword OUTER followed by a stray "€" -- the + // position must be left unchanged, exactly as for any other non-matching text. val sql = "OUTER€ JOIN b" assertThat(skipOptionalKeyword(sql, 0, "OUTER")).isEqualTo(0) } @Test fun `still consumes a real keyword followed by ordinary whitespace`() { - // NOT demonstrative of any fix in this file — a positive control confirming the ordinary, - // already-working case (a real keyword followed by plain whitespace) still advances past - // it. Kept as a regression guard against the non-ASCII boundary check above becoming overly - // broad and rejecting this too. + // Not new behavior — a positive control confirming a real keyword followed by plain + // whitespace still advances past it. Kept as a regression guard against the non-ASCII + // boundary check above becoming overly broad and rejecting this too. val sql = "OUTER JOIN b" assertThat(skipOptionalKeyword(sql, 0, "OUTER")).isEqualTo(sql.indexOf("JOIN")) } diff --git a/generator/src/test/kotlin/norm/generator/SqlLexerTest.kt b/generator/src/test/kotlin/norm/generator/SqlLexerTest.kt index 10a13db9..69081d62 100644 --- a/generator/src/test/kotlin/norm/generator/SqlLexerTest.kt +++ b/generator/src/test/kotlin/norm/generator/SqlLexerTest.kt @@ -83,7 +83,7 @@ class SqlLexerTest { @Test fun `does not open a dollar quote immediately after a non-ASCII identifier-continuation character`() { - // Issue #219: under PostgreSQL's flex longest-match, "x€\$\$y\$\$" is ONE identifier, since + // Under PostgreSQL's flex longest-match, "x€\$\$y\$\$" is one identifier, since // "€" (>= 0x80) is itself a legal identifier-continuation character -- so the "\$" right // after it must not be treated as opening a dollar-quoted string. val sql = "x€\$\$y\$\$" @@ -105,7 +105,7 @@ class SqlLexerTest { @Test fun `a digit-leading dollar-quote tag is not recognized as a tag`() { - // Verified against PostgreSQL 18.4: scan.l's dolq_start excludes digits (unlike dolq_cont, + // PostgreSQL 18.4 scan.l: dolq_start excludes digits (unlike dolq_cont, // which admits them after the first character) -- "$1$foo$1$" is not a dollar-quoted // string at all; "$1" is left as an ordinary "$"-prefixed token. Before the fix, the tag // run's start character used the same letter/digit/underscore class as its continuation, so @@ -120,19 +120,19 @@ class SqlLexerTest { @Test fun `a non-ASCII character before a standalone E means it is not standalone, matching PostgreSQL`() { - // Real PostgreSQL treats "x€E" as ONE identifier -- "€" is a legal identifier-continuation - // character -- so the "E" immediately before the quote is NOT a standalone E'...' + // Real PostgreSQL treats "x€E" as one identifier -- "€" is a legal identifier-continuation + // character -- so the "E" immediately before the quote is not a standalone E'...' // escape-string marker here. With no escape-string mode, the backslash before the closing // quote is an ordinary character, not an escape: the first bare "'" after it -- not a // doubled "''" -- ends the string. // // Before OriginalAdjacency existed, this lookback used a narrow letter/digit/underscore-only // class that did not recognize "€" as an identifier character at all, wrongly treating "E" - // as standalone here and accepting this as a deliberate, KNOWN deviation from PostgreSQL's - // lexer (issue #222). Now that the lookback can distinguish a genuinely fused character from + // as standalone here and accepting this as a deliberate, known deviation from PostgreSQL's + // lexer. Now that the lookback can distinguish a genuinely fused character from // one a stripped-away separator merely left adjacent (see OriginalAdjacency's KDoc), it is - // safe to use the full isIdentifierChar class -- and for this RAW, never-stripped call - // (using the default ALL_ADJACENT, correct here since every neighbour genuinely IS + // safe to use the full isIdentifierChar class -- and for this raw, never-stripped call + // (using the default ALL_ADJACENT, correct here since every neighbour genuinely is // adjacent), that makes this call match PostgreSQL's real answer instead of deviating from // it. val sql = "x€E'a\\' FROM t" @@ -143,7 +143,7 @@ class SqlLexerTest { @Test fun `a genuinely separate string immediately after an E-string is not swallowed by a stripped-away separator`() { - // Point 3 of issue #223's fix: fusion composes with escape-string mode. "E'a'" (a complete + // Fusion composes with escape-string mode. "E'a'" (a complete // escape string) followed by a real separator, then another, entirely independent "'\'" // strips (the separator is plain whitespace, which stripCommentsAndWhitespace removes the // same as a comment) to "E'a''\'", where the fused "''" reads as a doubled-quote escape and @@ -151,19 +151,19 @@ class SqlLexerTest { // text, which would defeat findTrailingImplicitAliasStart's "last segment must end exactly // at text.length" anchor in the dangerous direction (see isStarItem's KDoc). // - // NOT achievable as valid SQL end to end, so this is a defensive gate, not a repro of a - // reachable bug: verified against a real PostgreSQL 18.4 container, PostgreSQL only + // Not achievable as valid SQL end to end, so this is a defensive gate, not a repro of a + // reachable bug: on PostgreSQL 18.4, it only // concatenates two adjacent bare string literals when the whitespace between them contains // an actual newline ("SELECT 'a' 'b'" and "SELECT 'a'/*c*/'b'" are both syntax errors -- // neither a plain space nor a comment alone licenses concatenation), and a genuine // newline-separated concatenation is a different construct with its own surprising semantics - // -- "SELECT E'a'\n'\'" is ITSELF "unterminated" on real PostgreSQL, because the escape-string + // -- "SELECT E'a'\n'\'" is itself "unterminated" on real PostgreSQL, because the escape-string // mode is inherited across the concatenation boundary. No PostgreSQL-valid SELECT item was // found that exercises this specific composition end to end; the gate is kept anyway, for // the same structural reason every other multi-character adjacency decision in this file is // gated (see OriginalAdjacency's KDoc), not because a concrete wrong-answer case was found. val stripped = stripCommentsAndWhitespace("E'a' '\\'") - // Without the gate, an ungated re-scan of stripCommentsAndWhitespace's own OUTPUT (simulated + // Without the gate, an ungated re-scan of stripCommentsAndWhitespace's own output (simulated // here via ALL_ADJACENT, i.e. pretending every neighbour really was adjacent) overruns to the // end of the stripped text. assertThat(skipLexicalToken(stripped.asPlainString(), 1, ALL_ADJACENT)).isEqualTo(stripped.length) @@ -210,12 +210,12 @@ class SqlLexerTest { inner class LexicalTokenParityTest { /** - * Walks [sql] position by position, collecting the text of every OPAQUE lexical token + * Walks [sql] position by position, collecting the text of every opaque lexical token * [skipLexicalToken] recognizes (a string literal, a quoted identifier, a dollar-quoted * string) as one list entry, and every other non-whitespace character as its own * single-character entry. A recognized comment is dropped entirely, matching what * [stripCommentsAndWhitespace] itself deletes. This is exactly the token sequence - * [stripCommentsAndWhitespace]'s output SHOULD still contain, in order — see + * [stripCommentsAndWhitespace]'s output should still contain, in order — see * [lexicalTokensOfStripped] and this class's own test. */ private fun lexicalTokensOfOriginal(sql: String): List { @@ -266,32 +266,32 @@ class SqlLexerTest { @Test fun `a stripped-away separator does not fuse unrelated dollar signs and a tag run into a dollar-quote`() { - // Verifier-supplied regression: "$q b $/ /" strips (removing the two spaces after "q" and + // Regression: "$q b $/ /" strips (removing the two spaces after "q" and // the one after "b" and each "/") to "$qb$//", whose fused "$qb$" would otherwise be read as // an opening dollar-quote delimiter with tag "qb", and the following "//" as its (unterminated) // body -- one manufactured token in place of six independent original ones ("$", "q", "$", - // "/", "/", plus the surrounding words), the same "danger" direction as every other fusion - // class in this file (see OriginalAdjacency's KDoc): skipDollarQuotedString's OPENING-delimiter + // "/", "/", plus the surrounding words), the same fusion direction as every other class in + // this file (see OriginalAdjacency's KDoc): skipDollarQuotedString's opening-delimiter // adjacency gate (see its KDoc) must refuse this fusion. (The closing delimiter needs, and // has, no gate of its own -- see that function's KDoc for why.) // - // No PostgreSQL-valid input is known to reach this composition: verified against a real - // PostgreSQL 18.4 container, even the simpler "SELECT \$q FROM t" is rejected outright - // ("syntax error at or near "\$"") -- a bare "\$" not immediately followed by a digit (a - // positional parameter) or a genuine dollar-quote tag is not valid PostgreSQL syntax at all. - // Like the "''"/E-string composition gate above, this closes the class structurally rather - // than fixing an observed user-visible bug. + // No PostgreSQL-valid input is known to reach this composition (checked on PostgreSQL 18.4): + // even the simpler "SELECT \$q FROM t" is rejected outright ("syntax error at or near "\$""), + // since a bare "\$" not immediately followed by a digit (a positional parameter) or a genuine + // dollar-quote tag is not valid PostgreSQL syntax at all. Like the "''"/E-string composition + // gate above, this closes the class structurally rather than fixing an observed user-visible + // bug. val input = "SELECT \$q b \$/ / FROM t" assertThat(lexicalTokensOfStripped(input)).isEqualTo(lexicalTokensOfOriginal(input)) } @Test fun `stripping never changes what skipLexicalToken sees, for every issue 223 fusion class and every star shape`() { - // The invariant issue #223 relies on: walking the ORIGINAL text and the STRIPPED text with - // skipLexicalToken must find the SAME lexical tokens, in the SAME order — if stripping ever + // The invariant this relies on: walking the original text and the stripped text with + // skipLexicalToken must find the same lexical tokens, in the same order — if stripping ever // fuses two characters into a token that was never in the original query (or hides one that - // WAS there), this comparison catches it directly, rather than relying on each individual - // gate's own, narrower unit test. Includes every fusion-class input from the issue, plus a + // was there), this comparison catches it directly, rather than relying on each individual + // gate's own, narrower unit test. Includes every fusion-class input below, plus a // representative sample of the star shapes already covered elsewhere in this file (line and // block comments, string literals, dollar-quoted strings, double-quoted identifiers // containing a literal star, a Unicode-escape identifier with a UESCAPE clause, and diff --git a/generator/src/test/kotlin/norm/generator/SqlOutputClauseTest.kt b/generator/src/test/kotlin/norm/generator/SqlOutputClauseTest.kt index 98c82891..e7aa363c 100644 --- a/generator/src/test/kotlin/norm/generator/SqlOutputClauseTest.kt +++ b/generator/src/test/kotlin/norm/generator/SqlOutputClauseTest.kt @@ -19,7 +19,7 @@ internal data class StarGuardCase(val description: String, val sql: String) { override fun toString() = description } -/** One SQL input that must NOT trip the star guard, resolving to [expected] instead. */ +/** One SQL input that must not trip the star guard, resolving to [expected] instead. */ internal data class NotStarGuardCase(val description: String, val sql: String, val expected: List) { override fun toString() = description } @@ -116,7 +116,7 @@ class SqlOutputClauseTest { @Test fun `RETURNING WITH OLD-NEW alias prologue is stripped before the first item`() { - // PostgreSQL 18's `RETURNING WITH (OLD AS o, NEW AS n) o.x, n.x` — verified live to return + // PostgreSQL 18's `RETURNING WITH (OLD AS o, NEW AS n) o.x, n.x` — confirmed to return // 2 columns. Without stripping the prologue, the first item's expression becomes // "WITH (OLD AS o, NEW AS n) o.x", which parseColumnReference cannot make sense of // (columnName/tableName null), and that unparsed text would be embedded verbatim in @@ -172,9 +172,9 @@ class SqlOutputClauseTest { @Test fun `WITH RECURSIVE CTE body SELECT is not mistaken for the main query's SELECT`() { - // Reproduces #212: the CTE body's own SELECT ("SELECT label FROM parent_name") used to be - // picked over the main query's SELECT ("SELECT id, name FROM new_parent") because the old - // implementation searched for the first SELECT anywhere in the statement. + // The CTE body's own SELECT ("SELECT label FROM parent_name") used to be picked over the + // main query's SELECT ("SELECT id, name FROM new_parent") because the old implementation + // searched for the first SELECT anywhere in the statement. val result = parseSelectItems( """ WITH RECURSIVE new_parent AS ( @@ -238,13 +238,13 @@ class SqlOutputClauseTest { @Test fun `RETURNING as a plain SELECT's column alias is not mistaken for the RETURNING keyword`() { - // Regression guard for a defect this very fix introduced: RETURNING is NOT a reserved word - // in PostgreSQL — it is legal as a column alias in a SELECT's target list — verified valid + // Regression guard for a defect this very fix introduced: RETURNING is not a reserved word + // in PostgreSQL — it is legal as a column alias in a SELECT's target list — confirmed valid // syntax on PostgreSQL 18.4 ("CREATE TABLE t (returning int)" and "FROM users AS returning" - // ARE rejected, so the alias position specifically is the hole). Before the main query's - // OWN leading keyword was checked, this alias was mistaken for the RETURNING clause + // are rejected, so the alias position specifically is the hole). Before the main query's + // own leading keyword was checked, this alias was mistaken for the RETURNING clause // keyword, misreading everything after it (the real "FROM users") as a single bogus item — - // the same lost column-level-override failure class #212 exists to fix. + // the same lost column-level-override failure class this fix exists to close. val result = parseSelectItems("SELECT preferences AS returning FROM users") assertThat(result).containsExactly( SelectItem("preferences", "preferences", null), @@ -270,10 +270,10 @@ class SqlOutputClauseTest { @Test fun `an AS returning alias inside a DML statement's own source SELECT is not mistaken for the RETURNING keyword`() { - // Fixes #215 shape 3: verified against a real PostgreSQL 18.4 container, "INSERT INTO users - // (email, preferences) SELECT email AS returning, preferences FROM users RETURNING id, - // email, preferences" returns exactly 3 columns (id, email, preferences). A plain - // findTopLevelKeyword search returns the FIRST "returning"-shaped match, which is this + // Confirmed against a real PostgreSQL 18.4 container: "INSERT INTO users (email, + // preferences) SELECT email AS returning, preferences FROM users RETURNING id, email, + // preferences" returns exactly 3 columns (id, email, preferences). A plain + // findTopLevelKeyword search returns the first "returning"-shaped match, which is this // alias, not the real clause — mis-locating the RETURNING list entirely. val result = parseSelectItems( "INSERT INTO users (email, preferences) SELECT email AS returning, preferences " + @@ -288,8 +288,8 @@ class SqlOutputClauseTest { @Test fun `an AS returning alias in the RETURNING list itself is not mistaken for an earlier real keyword`() { - // The symmetric case a naive "last match wins" rule would break: here the alias FOLLOWS the - // real keyword, so findTopLevelReturningKeyword must still return the FIRST RETURNING (not + // The symmetric case a naive "last match wins" rule would break: here the alias follows the + // real keyword, so findTopLevelReturningKeyword must still return the first RETURNING (not // AS-preceded), not skip past it looking for a later one. val result = parseSelectItems("UPDATE t SET x = 1 RETURNING id AS returning") assertThat(result).containsExactly( @@ -308,7 +308,7 @@ class SqlOutputClauseTest { @Test fun `a block comment between AS and its returning alias does not disturb keyword recognition`() { - // Verified against a real PostgreSQL 18.4 container: "SELECT 1 AS/*c*/returning" is valid + // Confirmed against a real PostgreSQL 18.4 container: "SELECT 1 AS/*c*/returning" is valid // syntax — a comment is a legal separator between "AS" and its alias. val result = parseSelectItems("INSERT INTO t (a) SELECT 1 AS/*c*/returning RETURNING a, b") assertThat(result).containsExactly( @@ -319,7 +319,7 @@ class SqlOutputClauseTest { @Test fun `a line comment between AS and its returning alias does not disturb keyword recognition`() { - // Verified against a real PostgreSQL 18.4 container: "SELECT 1 AS--x" followed by a newline + // Confirmed against a real PostgreSQL 18.4 container: "SELECT 1 AS--x" followed by a newline // then "returning" is valid syntax, same as the block-comment case above. val result = parseSelectItems("INSERT INTO t (a) SELECT 1 AS--x\nreturning RETURNING a, b") assertThat(result).containsExactly( @@ -330,7 +330,7 @@ class SqlOutputClauseTest { @Test fun `a source column named with a trailing non-ASCII character is not mistaken for an AS returning alias`() { - // #219: verified against a real PostgreSQL 18.4 container against "users(id, email, age, + // Confirmed against a real PostgreSQL 18.4 container against "users(id, email, age, // preferences)" and a source table "src": "INSERT INTO users (email, age, preferences) // SELECT returning€, age, preferences FROM src RETURNING id, email" returns exactly 2 // columns (id, email) -- "returning€" is a legal column name (PostgreSQL's lexer admits any @@ -350,7 +350,7 @@ class SqlOutputClauseTest { @Test fun `a dollar-quoted string with a non-ASCII tag is recognized, so later items are not misaligned`() { - // Follow-up to #219: verified against a real PostgreSQL 18.4 -- "SELECT $€$x,$€$ AS lbl, + // Confirmed against a real PostgreSQL 18.4: "SELECT $€$x,$€$ AS lbl, // age, email FROM users" returns exactly 3 columns (lbl, age, email); "$€$x,$€$" is a single // dollar-quoted string literal tagged "€" (a legal tag character -- PostgreSQL's scan.l // admits any byte >= 0x80 in a dollar-quote tag, same as in an ordinary identifier). Before @@ -368,7 +368,7 @@ class SqlOutputClauseTest { @Test fun `RETURNING a column with a trailing non-ASCII character keeps its original name`() { - // Follow-up to #219: verified against a real PostgreSQL 18.4 container -- "CREATE TABLE + // Confirmed against a real PostgreSQL 18.4 container -- "CREATE TABLE // users(id int, email text, age int, preferences€ text)" then "INSERT INTO // users(id,email,age,preferences€) VALUES (1,'e',2,'p') RETURNING preferences€, email" // returns columns named exactly "preferences€" and "email" -- "preferences€" is a legal @@ -385,12 +385,12 @@ class SqlOutputClauseTest { @Test fun `a column name written with a supplementary-plane character resolves via the widened regex`() { - // A supplementary-plane character is a SURROGATE PAIR in a Kotlin/UTF-16 String -- both code + // A supplementary-plane character is a surrogate pair in a Kotlin/UTF-16 String -- both code // units are >= 0x80, so PostgreSQL's own lexer admits it in an unquoted identifier the same - // as any other >= 0x80 byte (verified against PostgreSQL 18.4: "CREATE TABLE astral(id int, + // as any other >= 0x80 byte (confirmed against PostgreSQL 18.4: "CREATE TABLE astral(id int, // x𝐀y text)" -- U+1D400 MATHEMATICAL BOLD CAPITAL A -- succeeds, and "SELECT // x𝐀y FROM astral" resolves it unquoted). COLUMN_REFERENCE's ">= 0x80" range is - // written as a CODE-POINT range ("\\x{80}-\\x{10FFFF}"), not a per-Char one, specifically so + // written as a code-point range ("\\x{80}-\\x{10FFFF}"), not a per-Char one, specifically so // it matches the whole surrogate pair as one code point rather than requiring the range to // be repeated to cover each UTF-16 half. val result = parseSelectItems("SELECT x𝐀y, id FROM astral") @@ -402,8 +402,8 @@ class SqlOutputClauseTest { @Test fun `a digit-leading fragment abutting a non-ASCII character is not mistaken for a column reference`() { - // Widening COLUMN_REFERENCE's continuation class to admit ">= 0x80" characters must NOT also - // let a digit-leading fragment match as a whole "identifier": verified against PostgreSQL + // Widening COLUMN_REFERENCE's continuation class to admit ">= 0x80" characters must not also + // let a digit-leading fragment match as a whole "identifier": confirmed on PostgreSQL // 18.4, "SELECT 2€" is rejected outright ("trailing junk after numeric literal") -- "2€" is // not a legal identifier PostgreSQL would ever lex, digit-leading or otherwise. Without // COLUMN_REFERENCE_IDENTIFIER_START's separate (narrower) leading-character class, a naive @@ -419,7 +419,7 @@ class SqlOutputClauseTest { @Test fun `an AS keyword directly abutting a closing parenthesis is recognized as an alias boundary`() { - // Verified against PostgreSQL 18.4: "SELECT (1)AS b" returns column "b" -- "AS" here abuts + // Confirmed on PostgreSQL 18.4: "SELECT (1)AS b" returns column "b" -- "AS" here abuts // the closing ")" with no whitespace at all, yet is still the real keyword, because ")" is // not an identifier character. Before the fix, extractAlias's whitespace-only boundary check // required literal whitespace on both sides, so this "AS" was never recognized as the @@ -434,7 +434,7 @@ class SqlOutputClauseTest { @Test fun `an AS keyword directly abutting a closing parenthesis, followed by a quoted alias, is recognized`() { - // Verified against PostgreSQL 18.4: "SELECT (1)AS"b"" returns column "b" -- same boundary as + // Confirmed on PostgreSQL 18.4: "SELECT (1)AS"b"" returns column "b" -- same boundary as // the plain-alias case above, but with a double-quoted alias directly following "AS" (no // whitespace on that side either). Before the fix, the "after" whitespace check rejected // this for the same reason as the "before" side: a double quote is not whitespace, so the @@ -447,10 +447,10 @@ class SqlOutputClauseTest { @Test fun `an identifier ending in the letters AS is not mistaken for the AS keyword`() { - // Verified against PostgreSQL 18.4: "CREATE TABLE d1(dataAS text)" then "SELECT 1 dataAS" - // returns column "dataas" -- "dataAS" lexes as ONE identifier, never as "data" followed by + // Confirmed on PostgreSQL 18.4: "CREATE TABLE d1(dataAS text)" then "SELECT 1 dataAS" + // returns column "dataas" -- "dataAS" lexes as one identifier, never as "data" followed by // the keyword "AS". extractAlias's boundary check must reject the "AS" inside "dataAS" as - // not a real keyword occurrence on BOTH the old (whitespace) and new (isIdentifierChar) rule + // not a real keyword occurrence on both the old (whitespace) and new (isIdentifierChar) rule // -- included as a regression guard for the widened rule, not a reproduction of a bug. val result = parseSelectItems("SELECT dataAS b FROM users") assertThat(result).containsExactly( @@ -460,8 +460,8 @@ class SqlOutputClauseTest { @Test fun `an AS keyword fused with a following non-ASCII character is not an alias boundary`() { - // Verified against PostgreSQL 18.4: "SELECT 1 AS€b" returns column "as€b" -- "AS€b" lexes as - // ONE implicit-alias identifier (€ is a legal identifier-continuation character), never as + // Confirmed on PostgreSQL 18.4: "SELECT 1 AS€b" returns column "as€b" -- "AS€b" lexes as + // one implicit-alias identifier (€ is a legal identifier-continuation character), never as // the keyword "AS" followed by "€b". Regression guard for the widened rule: the old // whitespace-only check already rejected this ("€" is not whitespace either), so this // confirms the new isIdentifierChar-based check agrees for the right reason. @@ -473,8 +473,8 @@ class SqlOutputClauseTest { @Test fun `an AS keyword fused with a preceding non-ASCII character is not an alias boundary`() { - // Verified against PostgreSQL 18.4: "SELECT age x€AS b FROM users" is a syntax error -- - // "x€AS" lexes as ONE implicit-alias identifier (the same reasoning as the "AS€b" case + // Confirmed on PostgreSQL 18.4: "SELECT age x€AS b FROM users" is a syntax error -- + // "x€AS" lexes as one implicit-alias identifier (the same reasoning as the "AS€b" case // above, from the other side), leaving a stray extra token "b" with nothing to attach to. // Regression guard for the widened rule, same as the case above. val result = parseSelectItems("SELECT age x€AS FROM users") @@ -511,7 +511,7 @@ class SqlOutputClauseTest { // pairing -- an unquoted "Ü" fails to resolve against a column actually named "ü" ("column // "Ü" does not exist"), and instead resolves against a column named "Ü" (quoted, uppercase). // Folding via Kotlin's String.lowercase() here would silently disagree with PostgreSQL about - // which of two differently-cased columns an unquoted reference targets -- the exact #229 + // which of two differently-cased columns an unquoted reference targets -- the exact // regression foldAsciiCase exists to prevent. val result = parseSelectItems("SELECT Ü FROM t") assertThat(result).containsExactly( @@ -529,15 +529,15 @@ class SqlOutputClauseTest { @Test fun `a parenthesized main query following a CTE clause still resolves`() { - // #238: stripRedundantOuterParentheses peels the one pair wrapping the WHOLE main query - // before the depth-0 search runs, so this now resolves exactly like the unparenthesized form. + // stripRedundantOuterParentheses peels the one pair wrapping the whole main query before + // the depth-0 search runs, so this now resolves exactly like the unparenthesized form. val result = parseSelectItems("WITH c AS (SELECT 1 AS a) (SELECT a FROM c)") assertThat(result).containsExactly(SelectItem("a", "a", null)) } @Test fun `a parenthesized main query with no WITH clause still resolves`() { - // #238: same fix as above, with no leading WITH clause at all. + // Same fix as above, with no leading WITH clause at all. val result = parseSelectItems("(SELECT a FROM x)") assertThat(result).containsExactly(SelectItem("a", "a", null)) } @@ -551,25 +551,26 @@ class SqlOutputClauseTest { @Test fun `VALUES clause yields no items`() { - // Regression guard, not a #212 reproduction: this already passed before the fix, since a - // bare VALUES list has no top-level SELECT/RETURNING for either implementation to find. + // Regression guard, not a reproduction of the earlier bug: this already passed before the + // fix, since a bare VALUES list has no top-level SELECT/RETURNING for either implementation + // to find. val result = parseSelectItems("VALUES (1, 2)") assertThat(result).isEmpty() } @Test fun `TABLE clause yields no items`() { - // Regression guard, not a #212 reproduction: this already passed before the fix, for the - // same reason as the VALUES case above. + // Regression guard, not a reproduction of the earlier bug: this already passed before the + // fix, for the same reason as the VALUES case above. val result = parseSelectItems("TABLE t") assertThat(result).isEmpty() } @Test fun `a body wrapped in one redundant pair of parentheses still finds the top-level SELECT`() { - // #238: a CTE body like `(SELECT ...)` slices to exactly this text. The extra, unmatched - // leading "(" previously put the whole rest of the text at paren depth ONE, so - // findTopLevelKeyword never found "SELECT" at depth zero and this returned empty. + // A CTE body like `(SELECT ...)` slices to exactly this text. The extra, unmatched leading + // "(" previously put the whole rest of the text at paren depth one, so findTopLevelKeyword + // never found "SELECT" at depth zero and this returned empty. val result = parseSelectItems("(SELECT id, UPPER(name) AS name_upper FROM parent)") assertThat(result).containsExactly( SelectItem("id", "id", null), @@ -585,7 +586,7 @@ class SqlOutputClauseTest { @Test fun `two separately parenthesized set-operation branches are NOT treated as one redundant wrapping`() { - // The first "(" here does NOT wrap the ENTIRE text -- its own matching ")" is followed by + // The first "(" here does not wrap the entire text -- its own matching ")" is followed by // " UNION (...)", not the end of the string -- so stripping must decline rather than peel it // off and misread only the first branch as the whole body. val result = parseSelectItems("(SELECT id FROM parent) UNION (SELECT id FROM child)") @@ -607,8 +608,8 @@ class SqlOutputClauseTest { description = "star alongside another item returns empty list to avoid shifting later items", sql = "SELECT *, id FROM t", ), - // Verified against real PostgreSQL: "(tgt.*)" is valid syntax, equivalent to "tgt.*". The - // star is the FIRST item here, so nothing precedes it to keep. + // Confirmed on real PostgreSQL: "(tgt.*)" is valid syntax, equivalent to "tgt.*". The + // star is the first item here, so nothing precedes it to keep. StarGuardCase( description = "parenthesized star as the first item drops the whole list", sql = "SELECT (tgt.*), id AS ident FROM tgt", @@ -625,13 +626,14 @@ class SqlOutputClauseTest { description = "star with whitespace around the qualifying dot as the first item drops the whole list", sql = "SELECT tgt . *, id AS ident FROM tgt", ), - // Verified against real PostgreSQL 18: "SELECT t.* AS whatever, 7 AS id FROM t" returns + // Confirmed on PostgreSQL 18: "SELECT t.* AS whatever, 7 AS id FROM t" returns // 3 columns (a, b, id) for a two-column "t". Before this guard checked the alias-stripped // expression rather than the raw item text, the star's own "AS whatever" alias defeated // recognition entirely, so the guard never fired and the later "id" item stayed in the // list — positionally misattributed to whichever real column followed t's expansion. This - // is the #212 failure mode itself, not merely the truncation trade-off documented on - // parseSelectItems: a WRONG mapping survives, rather than degrading to no mapping at all. + // is the same failure mode as the earlier bug, not merely the truncation trade-off + // documented on parseSelectItems: a wrong mapping survives, rather than degrading to no + // mapping at all. StarGuardCase( description = "an aliased star still triggers the star guard", sql = "SELECT t.* AS whatever, 7 AS id FROM t", @@ -640,24 +642,24 @@ class SqlOutputClauseTest { description = "an aliased star in RETURNING still triggers the star guard", sql = "UPDATE t SET x = 1 RETURNING t.* AS whatever, 7 AS id", ), - // Verified against real PostgreSQL 18.4: valid syntax, returns every column of "tgt". An - // earlier version of isStarItem stripped wrapping parentheses BEFORE stripping comments, so - // this comment — sitting OUTSIDE the parentheses, on the far side of the closing ")" — was + // Confirmed on PostgreSQL 18.4: valid syntax, returns every column of "tgt". An earlier + // version of isStarItem stripped wrapping parentheses before stripping comments, so this + // comment — sitting outside the parentheses, on the far side of the closing ")" — was // never removed, "(tgt.*) -- c" never reduced to "tgt.*", and the guard never fired: the - // #212 failure mode (a later item shifted onto the wrong ResultSetMetaData column) survived - // for exactly this spelling. + // same failure mode (a later item shifted onto the wrong ResultSetMetaData column) + // survived for exactly this spelling. StarGuardCase( description = "a trailing line comment outside a wrapping parenthesis still triggers the star guard", sql = "SELECT (tgt.*) -- c\n, id AS ident FROM tgt", ), - // Verified against real PostgreSQL 18.4: valid syntax, same defect as the line-comment case + // Confirmed on PostgreSQL 18.4: valid syntax, same defect as the line-comment case // above. StarGuardCase( description = "a trailing block comment outside a wrapping parenthesis still triggers the star guard", sql = "SELECT (tgt.*) /*c*/, id AS ident FROM tgt", ), - // Verified against real PostgreSQL 18.4: valid syntax. An earlier version of isStarItem - // only collapsed WHITESPACE around the dot, not a comment sitting between the dot and the + // Confirmed on PostgreSQL 18.4: valid syntax. An earlier version of isStarItem only + // collapsed whitespace around the dot, not a comment sitting between the dot and the // star, so "tgt./*c*/ *" was never recognized. StarGuardCase( description = "a comment between the dot and the star still triggers the star guard", @@ -671,7 +673,7 @@ class SqlOutputClauseTest { // "u.* whatever" as a whole does not end in ".*", so the guard never fired and "preferences" // (a later, unrelated item) stayed in the list — positionally misattributed to whichever // real column followed u's expansion, applying a real column-level type override meant for - // a different column entirely. This is the #212 failure mode itself. + // a different column entirely. This is the same failure mode as the earlier bug. StarGuardCase( description = "an implicit alias on a star still triggers the star guard", sql = "SELECT u.* whatever, preferences FROM users u", @@ -680,15 +682,15 @@ class SqlOutputClauseTest { description = "an explicit AS alias on a star keeps working alongside the implicit-alias check", sql = "SELECT u.* AS whatever, preferences FROM users u", ), - // Verified against real PostgreSQL 18.4: a double-quoted implicit alias is valid syntax. - // The whitespace INSIDE the quoted identifier ("My Col") must not be mistaken for the + // Confirmed on PostgreSQL 18.4: a double-quoted implicit alias is valid syntax. The + // whitespace inside the quoted identifier ("My Col") must not be mistaken for the // boundary before it — only the whitespace between "u.*" and the quoted identifier counts. StarGuardCase( description = "a quoted implicit alias on a star still triggers the star guard", sql = """SELECT u.* "My Col", preferences FROM users u""", ), // "u.* whatever -- c" is valid PostgreSQL syntax (an implicit alias on a star, with a - // trailing comment). stripCommentsAndWhitespace removes both the whitespace AND the comment + // trailing comment). stripCommentsAndWhitespace removes both the whitespace and the comment // outright, normalizing this to "u.*whatever" — the same shape as the zero-separator case, // which isStarItem's structural check already recognizes as a star (qualifier "u.", implicit // alias "whatever"). @@ -701,7 +703,7 @@ class SqlOutputClauseTest { description = "an implicit alias followed by a block comment still triggers the star guard", sql = "SELECT u.* whatever /*c*/, preferences FROM users u", ), - // Fixes #215 shape 2: verified against a real PostgreSQL 18.4 container, "SELECT + // Confirmed against a real PostgreSQL 18.4 container: "SELECT // u.*/*c*/whatever, preferences FROM users u" returns 5 columns against a 4-column "users" // (the star's own expansion plus "preferences") — the comment is just as much a // non-separator as no separator at all. isStarItem's structural check finds the star, its @@ -713,7 +715,7 @@ class SqlOutputClauseTest { "a comment abutting an implicit alias with no surrounding whitespace now triggers the star guard", sql = "SELECT u.*/*c*/whatever, preferences FROM users u", ), - // Fixes #215 shape 2, line-comment form: verified against a real PostgreSQL 18 container, + // Confirmed against a real PostgreSQL 18 container: // "SELECT t.*-- c\nwhatever, id FROM t" against a 3-column "t" is valid and returns 4 real // columns (t's own 3 plus "id") — a star. The old text-only guard needed a literal // whitespace run outside any comment to find a trailing-token boundary at all, which a line @@ -723,7 +725,7 @@ class SqlOutputClauseTest { description = "a line comment abutting an implicit alias now triggers the star guard", sql = "SELECT t.*-- c\nwhatever, id FROM t", ), - // Fixes #215 shape 1: verified against a real PostgreSQL 18.4 container, "SELECT + // Confirmed against a real PostgreSQL 18.4 container: "SELECT // u.*whatever, preferences FROM users u" returns 5 columns against a 4-column "users" — the // star's own expansion plus "preferences" — with the implicit alias "whatever" ignored. StarGuardCase( @@ -731,11 +733,11 @@ class SqlOutputClauseTest { sql = "SELECT u.*whatever, preferences FROM users u", ), // Regression guard: the double-quoted identifier "my*table" (a table literally named that) - // contains a "*" character that is NOT the star item's own "*" token. isStarItem's alias - // search recognizes the WHOLE quoted identifier as one lexical segment (via skipLexicalToken) + // contains a "*" character that is not the star item's own "*" token. isStarItem's alias + // search recognizes the whole quoted identifier as one lexical segment (via skipLexicalToken) // when walking forward, so it is never mistaken for two separate tokens split around the // internal "*", and the star immediately after the qualifying "." is found correctly. - // Verified against a real PostgreSQL 18.4 container: `SELECT "my*table".*z, c FROM + // Confirmed against a real PostgreSQL 18.4 container: `SELECT "my*table".*z, c FROM // "my*table"` is valid and returns 3 columns (the star's own 2 plus "c"), implicit alias "z" // ignored. StarGuardCase( @@ -748,7 +750,7 @@ class SqlOutputClauseTest { description = "a star qualified by a double-quoted identifier containing a literal star character, no alias", sql = """SELECT "my*table".*, c FROM "my*table"""", ), - // Verified against a real PostgreSQL 18.4 container: "SELECT DISTINCT ON (a) t.*, a FROM t" + // Confirmed against a real PostgreSQL 18.4 container: "SELECT DISTINCT ON (a) t.*, a FROM t" // returns 3 columns (t's own 2 plus "a") against a 2-column "t" — a star, with no implicit // alias here. parseSelectItems' skipOptionalSetQuantifier strips "DISTINCT ON (a) " before // isStarItem ever sees the item text, so isStarItem is handed the plain "t.*" — ending in the @@ -757,7 +759,7 @@ class SqlOutputClauseTest { description = "a DISTINCT ON prefix before a qualified star still triggers the star guard", sql = "SELECT DISTINCT ON (a) t.*, a FROM t", ), - // Verified against a real PostgreSQL 18.4 container: "SELECT (t).*, a FROM t" returns 3 + // Confirmed against a real PostgreSQL 18.4 container: "SELECT (t).*, a FROM t" returns 3 // columns (t's own 2 plus "a") — PostgreSQL's composite-value-expansion idiom, distinct from // the wrapping-parenthesis case ("(tgt.*)") this guard already handled: here only "(t)" is // parenthesized, not the whole item, so the wrapping-paren unwrap loop never fires and the @@ -766,7 +768,7 @@ class SqlOutputClauseTest { description = "a parenthesized composite expansion still triggers the star guard", sql = "SELECT (t).*, a FROM t", ), - // Same idiom as above, with a function call instead of a bare relation alias — verified + // Same idiom as above, with a function call instead of a bare relation alias — confirmed // against a real PostgreSQL 18.4 container: "SELECT (f2(1)).*, a FROM t" returns 3 columns // ("f2" is a real function returning a row of "t"'s shape). StarGuardCase( @@ -777,7 +779,7 @@ class SqlOutputClauseTest { description = "a parenthesized composite expansion in RETURNING still triggers the star guard", sql = "UPDATE t SET a = a RETURNING (t).*, a", ), - // Verified against a real PostgreSQL 18.4 container: `SELECT U&"my*table".*, c FROM + // Confirmed against a real PostgreSQL 18.4 container: `SELECT U&"my*table".*, c FROM // "my*table"` returns 3 columns (the star's own 2 plus "c") -- the "U&" Unicode-escape prefix // on the quoted identifier is ordinary PostgreSQL syntax, not something this guard needs to // understand specially: the item's full text ends in ".*" directly (the quoted identifier @@ -787,11 +789,11 @@ class SqlOutputClauseTest { description = "a Unicode-escape quoted identifier qualifying a star still triggers the star guard", sql = """SELECT U&"my*table".*, c FROM "my*table"""", ), - // Unlike the no-alias form above, "(t).*x" does NOT end in the literal ".*" (it ends in + // Unlike the no-alias form above, "(t).*x" does not end in the literal ".*" (it ends in // "x"), so path 1 fails and this only reaches path 2: isStarItem finds "x" as the trailing - // alias segment, leaving the prefix "(t).*" — which DOES end in ".*", with a qualifier + // alias segment, leaving the prefix "(t).*" — which does end in ".*", with a qualifier // "(t)." whose character immediately before the final "." is ")", not an identifier - // character at all, so isStarQualifierAcceptable accepts it (an EMPTY identifier run before + // character at all, so isStarQualifierAcceptable accepts it (an empty identifier run before // the dot is always accepted — see its KDoc). StarGuardCase( description = "an implicit alias on a parenthesized composite expansion still triggers the star guard", @@ -809,19 +811,19 @@ class SqlOutputClauseTest { description = "a Unicode-escape quoted identifier with an implicit alias still triggers the star guard", sql = """SELECT U&"my*table".*z, c FROM "my*table"""", ), - // Verified against a real PostgreSQL 18.4 container: "SELECT (t.*::t).* whatever, a FROM t" + // Confirmed against a real PostgreSQL 18.4 container: "SELECT (t.*::t).* whatever, a FROM t" // returns 3 columns (t's own 2 plus "a") against a 2-column "t". The qualifier "(t.*::t)." - // contains its OWN star (inside the cast expression, a leftover "t.*" that Postgres accepts + // contains its own star (inside the cast expression, a leftover "t.*" that Postgres accepts // as an argument to the composite cast) — isStarItem's alias search is not misled by it: it // finds "whatever" as the trailing segment regardless of how many "*" characters sit earlier - // in the text, since it walks FORWARD tracking only the LAST segment, never searching for a + // in the text, since it walks forward tracking only the last segment, never searching for a // "*" character directly at all. StarGuardCase( description = "a star qualifier that itself contains a star via a parenthesized cast still triggers the star guard", sql = "SELECT (t.*::t).* whatever, a FROM t", ), - // Verified against a real PostgreSQL 18.4 container: "SELECT (ARRAY[t.*])[1].* whatever, a + // Confirmed against a real PostgreSQL 18.4 container: "SELECT (ARRAY[t.*])[1].* whatever, a // FROM t" returns 3 columns (t's own 2 plus "a"). The qualifier "(ARRAY[t.*])[1]." contains // its own star inside the ARRAY literal, harmless to the alias search (see the cast case // above); the array-subscript close "]" immediately before the qualifier's final "." is not @@ -831,7 +833,7 @@ class SqlOutputClauseTest { "a star qualifier that itself contains a star via an ARRAY subscript still triggers the star guard", sql = "SELECT (ARRAY[t.*])[1].* whatever, a FROM t", ), - // Verified against a real PostgreSQL 18.4 container: "SELECT (f(a*2)).* z, a FROM t" returns + // Confirmed against a real PostgreSQL 18.4 container: "SELECT (f(a*2)).* z, a FROM t" returns // 3 columns (t's own 2 plus "a") — "f" is a real function returning a row of "t"'s shape. The // qualifier "(f(a*2))." contains an unrelated multiplication star inside the function call's // own argument. @@ -841,16 +843,16 @@ class SqlOutputClauseTest { sql = "SELECT (f(a*2)).* z, a FROM t", ), // Same shape as the ARRAY-subscript case above, with no separator before the implicit alias - // — issue shape 1 for this qualifier. Verified against a real PostgreSQL 18.4 container: + // — the same shape, for this qualifier. Confirmed against a real PostgreSQL 18.4 container: // "SELECT (ARRAY[t.*])[1].*whatever, a FROM t" returns the same 3 columns. StarGuardCase( description = "a zero-separator implicit alias on a star qualifier containing its own star still triggers the star guard", sql = "SELECT (ARRAY[t.*])[1].*whatever, a FROM t", ), - // Fixes a regression: the old wrapping-parenthesis unwrap loop required the ENTIRE + // Fixes a regression: the old wrapping-parenthesis unwrap loop required the entire // normalized text to end in ")", so "(u.*)whatever" (only "u.*" is parenthesized, not the - // trailing alias) never unwrapped and was missed. Verified against a real PostgreSQL 18.4 + // trailing alias) never unwrapped and was missed. Confirmed against a real PostgreSQL 18.4 // container: "SELECT (u.*) whatever, preferences FROM users u" returns 5 columns against a // 4-column "users" (the star's own expansion plus "preferences"). isStarItem's alias search // finds "whatever" as the trailing segment, leaving the prefix "(u.*)", which unwraps to @@ -859,51 +861,51 @@ class SqlOutputClauseTest { description = "a star wrapped only up to its implicit alias still triggers the star guard", sql = "SELECT (u.*) whatever, preferences FROM users u", ), - // Verified against a real PostgreSQL 18.4 container: "SELECT ((t.*)) x, a FROM t" returns 3 - // columns (t's own 2 plus "a") against a 2-column "t". unwrapWrappingParentheses strips BOTH + // Confirmed against a real PostgreSQL 18.4 container: "SELECT ((t.*)) x, a FROM t" returns 3 + // columns (t's own 2 plus "a") against a 2-column "t". unwrapWrappingParentheses strips both // layers of the prefix "((t.*))" in two passes, down to "t.*". StarGuardCase( description = "a doubly-wrapped star with an implicit alias still triggers the star guard", sql = "SELECT ((t.*)) x, a FROM t", ), - // Verified against a real PostgreSQL 18.4 container: "UPDATE t SET a = 1 RETURNING (t.*) x, + // Confirmed against a real PostgreSQL 18.4 container: "UPDATE t SET a = 1 RETURNING (t.*) x, // a" returns 3 columns (t's own 2 plus "a"). StarGuardCase( description = "a wrapped star with an implicit alias in RETURNING still triggers the star guard", sql = "UPDATE t SET a = 1 RETURNING (t.*) x, a", ), - // The U&-lookahead ORDERING GUARD: this is the exact shape that breaks if the bare-identifier + // The U&-lookahead ordering guard: this is the exact shape that breaks if the bare-identifier // rule is tried before the Unicode-escape rule when searching for the trailing alias segment // — a naive forward walk would match "U" alone as a one-character identifier segment, then // separately match "\"a\"" as a later segment, making the alias appear to end at the quoted // identifier while leaving a dangling "U&" attached to the prefix (`u.*U&`), which is not - // star-shaped at all. Trying the Unicode-escape rule FIRST consumes "U&\"a\"" as ONE segment, - // so the prefix correctly reduces to "u.*". Verified against a real PostgreSQL 18.4 + // star-shaped at all. Trying the Unicode-escape rule first consumes "U&\"a\"" as one segment, + // so the prefix correctly reduces to "u.*". Confirmed against a real PostgreSQL 18.4 // container: `SELECT u.* U&"a", preferences FROM users u` returns 5 columns against a // 4-column "users". StarGuardCase( description = "a Unicode-escape identifier as the implicit alias itself still triggers the star guard", sql = """SELECT u.* U&"a", preferences FROM users u""", ), - // Verified against a real PostgreSQL 18.4 container: `SELECT t.* U&"d!0061t" UESCAPE '!', a + // Confirmed against a real PostgreSQL 18.4 container: `SELECT t.* U&"d!0061t" UESCAPE '!', a // FROM t` returns 3 columns (t's own 2 plus "a") against a 2-column "t" — PostgreSQL merges // the Unicode-escape identifier, the UESCAPE keyword, and its single-quoted escape-character - // string into ONE lexical unit (here spelling the same identifier "U&\"data\"" would, via the + // string into one lexical unit (here spelling the same identifier "U&\"data\"" would, via the // custom "!" escape character), so the whole thing is the implicit alias. StarGuardCase( description = "a Unicode-escape alias with a UESCAPE clause still triggers the star guard", sql = """SELECT t.* U&"d!0061t" UESCAPE '!', a FROM t""", ), - // Verified against a real PostgreSQL 18.4 container: `SELECT U&"!0074" UESCAPE '!'.* x, a + // Confirmed against a real PostgreSQL 18.4 container: `SELECT U&"!0074" UESCAPE '!'.* x, a // FROM t` returns 3 columns (t's own 2 plus "a"). Here the UESCAPE-extended Unicode-escape - // identifier is the QUALIFIER, not the alias — its closing "'" (from the escape-character + // identifier is the qualifier, not the alias — its closing "'" (from the escape-character // string) sits immediately before the star's qualifying ".", which is not an identifier // character, so isStarQualifierAcceptable accepts the (empty) run before it. StarGuardCase( description = "a Unicode-escape qualifier with a UESCAPE clause still triggers the star guard", sql = """SELECT U&"!0074" UESCAPE '!'.* x, a FROM t""", ), - // Verified against a real PostgreSQL 18.4 container: "SELECT ALL t.* x, a FROM t" returns 3 + // Confirmed against a real PostgreSQL 18.4 container: "SELECT ALL t.* x, a FROM t" returns 3 // columns (t's own 2 plus "a"). parseSelectItems' skipOptionalSetQuantifier strips "ALL " // before splitting the clause into items, so isStarItem is handed the plain "t.* x" — never // seeing the quantifier fused onto it. @@ -912,7 +914,7 @@ class SqlOutputClauseTest { "an ALL quantifier before a qualified star with an implicit alias still triggers the star guard", sql = "SELECT ALL t.* x, a FROM t", ), - // Verified against a real PostgreSQL 18.4 container: "SELECT all2.* a, p FROM all2" returns 3 + // Confirmed against a real PostgreSQL 18.4 container: "SELECT all2.* a, p FROM all2" returns 3 // columns (all2's own 2 plus "p") against a real table named "all2". skipOptionalKeyword's own // word-boundary check is what keeps "ALL" from matching the first 3 letters of "all2" — the // character immediately after ("2") is still an identifier character, so it isn't a real @@ -925,17 +927,17 @@ class SqlOutputClauseTest { // Written as a Kotlin unicode escape (\u0301, COMBINING ACUTE ACCENT) so the intent survives // any editor, and so a precomposed character (which already works and would not exercise this // fix) never sneaks in: the alias is "p", "r", "e", COMBINING ACUTE ACCENT (U+0301), "f", "s" - // -- the NFD spelling of "prefs" with an accent on the "e" -- NOT the single precomposed + // -- the NFD spelling of "prefs" with an accent on the "e" -- not the single precomposed // "e-acute" codepoint. PostgreSQL's lexer accepts any byte >= 0x80 in an unquoted identifier; // a combining mark (Unicode category Mn) is one such byte, but is not a Unicode "letter" - // (Char.isLetter() rejects it). Verified against a real PostgreSQL 18.4 container (via + // (Char.isLetter() rejects it). Confirmed against a real PostgreSQL 18.4 container (via // psql -f, to avoid shell/encoding mangling the input): this exact alias (NFD) on "users" // returns 5 columns against a 4-column "users". StarGuardCase( description = "an implicit alias written in NFD with a combining mark still triggers the star guard", sql = "SELECT u.* pre\u0301fs, preferences FROM users u", ), - // Written as a Kotlin unicode escape (\u20AC, the Euro sign). Verified against a real + // Written as a Kotlin unicode escape (\u20AC, the Euro sign). Confirmed against a real // PostgreSQL 18.4 container: this exact query returns 3 columns (t's own 2 plus "a") against // a 2-column "t" -- a currency sign is Unicode category Sc (symbol), not a letter, but // PostgreSQL's lexer still accepts it (any byte >= 0x80) as an unquoted identifier character. @@ -943,7 +945,7 @@ class SqlOutputClauseTest { description = "an implicit alias containing a currency symbol still triggers the star guard", sql = "SELECT t.* \u20ACtotal, a FROM t", ), - // Written as a Kotlin unicode escape (\u00A9, the copyright sign). Verified against a real + // Written as a Kotlin unicode escape (\u00A9, the copyright sign). Confirmed against a real // PostgreSQL 18.4 container: this exact query returns 3 columns (t's own 2 plus "a") -- the // copyright sign (Unicode category So, symbol/other) is, on its own, a complete legal // unquoted identifier in PostgreSQL. @@ -952,45 +954,46 @@ class SqlOutputClauseTest { sql = "SELECT t.* \u00A9, a FROM t", ), // Written as a Kotlin unicode escape surrogate pair (\uD83D\uDE80, the rocket emoji, - // U+1F680 -- a supplementary-plane character). Verified against a real PostgreSQL 18.4 + // U+1F680 -- a supplementary-plane character). Confirmed against a real PostgreSQL 18.4 // container: this exact query returns 5 columns against a 4-column "users". A - // supplementary-plane character is a SURROGATE PAIR in a Kotlin/UTF-16 String -- both code + // supplementary-plane character is a surrogate pair in a Kotlin/UTF-16 String -- both code // units are >= 0x80, so no special surrogate-aware handling is needed for the per-character // loop to consume it correctly. StarGuardCase( description = "an implicit alias that is a supplementary-plane character still triggers the star guard", sql = "SELECT u.* \uD83D\uDE80, preferences FROM users u", ), - // Same alias as the SELECT currency-symbol case above, RETURNING form. Verified against a + // Same alias as the SELECT currency-symbol case above, RETURNING form. Confirmed against a // real PostgreSQL 18.4 container: this exact statement returns 3 columns (t's own 2 plus // "a"). StarGuardCase( description = "an implicit alias containing a currency symbol in RETURNING still triggers the star guard", sql = "UPDATE t SET a = 1 RETURNING t.* \u20ACtotal, a", ), - // Issue #215 shape 1 for a non-ASCII alias -- unfixed in BOTH the pre-existing code and every - // earlier round of this fix until now, since none of them recognized a combining mark as an - // identifier character at all, separator or not. Written as a Kotlin unicode escape - // (\u0301, COMBINING ACUTE ACCENT): the alias is "c", "a", "f", "e", COMBINING ACUTE ACCENT - // (U+0301) -- the NFD spelling of "cafe" with an accent on the "e" -- NOT the precomposed - // codepoint. Verified against a real PostgreSQL 18.4 container: this exact query returns 3 + // The same non-ASCII-alias shape as elsewhere in this fix -- unfixed in both the pre-existing + // code and every earlier round of this fix until now, since none of them recognized a + // combining mark as an identifier character at all, separator or not. Written as a Kotlin + // unicode escape (\u0301, COMBINING ACUTE ACCENT): the alias is "c", "a", "f", "e", COMBINING + // ACUTE ACCENT (U+0301) -- the NFD spelling of "cafe" with an accent on the "e" -- not the + // precomposed codepoint. Confirmed against a real PostgreSQL 18.4 container: this exact query + // returns 3 // columns (t's own 2 plus "a"). StarGuardCase( description = "a zero-separator NFD alias still triggers the star guard", sql = "SELECT t.*cafe\u0301, a FROM t", ), // Regression guard: isStarQualifierAcceptable used to reject via Char.isDigit(), which is - // Unicode-aware and therefore ALSO matched non-ASCII digits (Unicode category Nd) -- but + // Unicode-aware and therefore also matched non-ASCII digits (Unicode category Nd) -- but // PostgreSQL numeric literals use ASCII digits exclusively, so a non-ASCII digit starting a // qualifier's run can only be an identifier's first character, never a numeral. The table // here is literally named \u0663 (ARABIC-INDIC DIGIT THREE), written as a Kotlin unicode - // escape. Verified against a real PostgreSQL 18.4 container with a real table named \u0663: + // escape. Confirmed against a real PostgreSQL 18.4 container with a real table named \u0663: // "SELECT \u0663.* x, a FROM \u0663" returns 3 columns (\u0663's own 2 plus "a"). StarGuardCase( description = "a qualifier starting with a non-ASCII digit still triggers the star guard", sql = "SELECT \u0663.* x, a FROM \u0663", ), - // Same regression guard as above, zero-separator implicit-alias form. Verified against a + // Same regression guard as above, zero-separator implicit-alias form. Confirmed against a // real PostgreSQL 18.4 container: "SELECT \u0663.*x, a FROM \u0663" returns the same 3 // columns. StarGuardCase( @@ -1001,11 +1004,11 @@ class SqlOutputClauseTest { // Regression guard, third instance of one root cause: isStarQualifierAcceptable used to scan // the run before the qualifying "." with isIdentifierChar alone, which rejects "\u20AC" (the // Euro sign, >= 0x80 but neither a letter nor a digit). Scanning backward from the ASCII - // digit "9" therefore stopped AT "\u20AC", leaving "9" looking like the run's own start and + // digit "9" therefore stopped at "\u20AC", leaving "9" looking like the run's own start and // wrongly rejecting the whole qualifier as numeric -- when the real run is "x\u20AC9" // (letter-led, correctly acceptable). isIdentifierChar (shared with // matchTrailingAliasSegment) runs past "\u20AC" instead. The table alias here is "x", - // "\u20AC", "9", written as Kotlin unicode escapes. Verified against a real PostgreSQL 18.4 + // "\u20AC", "9", written as Kotlin unicode escapes. Confirmed against a real PostgreSQL 18.4 // container: "SELECT x\u20AC9.* w, preferences FROM users x\u20AC9" returns 5 columns // against a 4-column "users". StarGuardCase( @@ -1016,7 +1019,7 @@ class SqlOutputClauseTest { // Same regression guard as above, with a different >= 0x80-but-not-letter-or-digit character: // "\u00B9" (SUPERSCRIPT ONE, Unicode category No -- number, other -- still not a letter or a // digit per isIdentifierChar's isLetterOrDigit()). The table alias here is "x", "\u00B9", - // "9", written as Kotlin unicode escapes. Verified against a real PostgreSQL 18.4 container: + // "9", written as Kotlin unicode escapes. Confirmed against a real PostgreSQL 18.4 container: // "SELECT x¹9.* w, a FROM t x¹9" returns 3 columns (t's own 2 plus "a"). StarGuardCase( description = @@ -1026,7 +1029,7 @@ class SqlOutputClauseTest { // Same regression guard again, with a combining mark instead of a symbol: the qualifier is // "c", "a", "f", "e", COMBINING ACUTE ACCENT (\u0301), "2" -- the NFD spelling of "cafe" with // an accent on the "e", followed by an ASCII digit, written as Kotlin unicode escapes. - // Verified against a real PostgreSQL 18.4 container: "SELECT café2.* w, a FROM t café2" (NFD) + // Confirmed against a real PostgreSQL 18.4 container: "SELECT café2.* w, a FROM t café2" (NFD) // returns 3 columns (t's own 2 plus "a"). StarGuardCase( description = "a qualifier written in NFD followed by an ASCII digit still triggers the star guard", @@ -1036,18 +1039,18 @@ class SqlOutputClauseTest { // an incidental character-class exclusion: "x€ E'a\'b'" (a real space between "€" and the // standalone "E") strips to "x€E'a\'b'", and skipSingleQuotedString's standalone-E lookback // gates its "was € really continuing an identifier into E" check on whether € and E were - // ADJACENT in the original text -- they were not (the character stripping removed there was + // adjacent in the original text -- they were not (the character stripping removed there was // a real separator) -- so E is correctly recognized as standalone regardless of what // character stripping happened to fuse in front of it (see that function's own KDoc). With E - // correctly standalone, the whole "E'a\'b'" is scanned as ONE backslash-escaped string, so - // isStarItem correctly reaches and recognizes the trailing ".* y" — this item IS star-shaped, + // correctly standalone, the whole "E'a\'b'" is scanned as one backslash-escaped string, so + // isStarItem correctly reaches and recognizes the trailing ".* y" — this item is star-shaped, // and parseSelectItems drops it and everything after it, giving the fail-safe emptyList() // this test asserts. // - // Verified directly (by running this exact input against the pre-#223 code too): this query - // already returned emptyList() before OriginalAdjacency existed, via the narrower - // letter/digit/underscore-only class from issue #222 — which also happens to exclude "€" — - // reaching the SAME star recognition for a narrower, coincidental reason. What changed is + // Confirmed directly (by running this exact input against the code that predates this fix + // too): this query already returned emptyList() before OriginalAdjacency existed, via the + // narrower letter/digit/underscore-only class from an earlier fix — which also happens to + // exclude "€" — reaching the same star recognition for a narrower, coincidental reason. What changed is // that recognizing "E" as standalone here no longer depends on an accident of "€" failing // that narrow class; it depends on the actual structural fact that "€" was never truly // adjacent to "E" in the original query. @@ -1060,8 +1063,8 @@ class SqlOutputClauseTest { // "1--1", which skipLexicalToken would otherwise read as a "--" line comment that was never // in the query — skipLineComment then jumps to the end of the stripped text, so no segment // ends exactly at text.length, findTrailingImplicitAliasStart returns null, and isStarItem - // answers false — the DANGEROUS direction (a later item silently shifts onto the wrong - // ResultSetMetaData column). Verified against a real PostgreSQL 18.4 container: this query + // answers false — the dangerous direction (a later item silently shifts onto the wrong + // ResultSetMetaData column). Confirmed against a real PostgreSQL 18.4 container: this query // returns 4 columns (f1, f2, id, name) against a 2-column "t" — the star and everything // before it must be dropped, since two items come after it. StarGuardCase( @@ -1070,20 +1073,20 @@ class SqlOutputClauseTest { sql = "SELECT (ROW(1, 2 - -1)).* y, id, name FROM t", ), // Same fusion class as the ROW case above, inside a function call's own argument instead of - // a ROW constructor. Verified against a real PostgreSQL 18.4 container: this query returns 3 + // a ROW constructor. Confirmed against a real PostgreSQL 18.4 container: this query returns 3 // columns (f1, f2, id) — f(int) returns a 2-column composite. StarGuardCase( description = "a negative literal subtraction inside a function-call star qualifier does not manufacture a --comment", sql = "SELECT (f(1 - -1)).* y, id FROM t", ), - // Same fusion class, inside a CAST's ROW argument. Verified against a real PostgreSQL 18.4 + // Same fusion class, inside a CAST's ROW argument. Confirmed against a real PostgreSQL 18.4 // container: this query returns 3 columns (f1, f2, id). StarGuardCase( description = "a negative literal subtraction inside a CAST star qualifier does not manufacture a --comment", sql = "SELECT (CAST(ROW(1 - -1, 2) AS pair)).* y, id FROM t", ), - // Same fusion class, inside an ARRAY subscript. Verified against a real PostgreSQL 18.4 + // Same fusion class, inside an ARRAY subscript. Confirmed against a real PostgreSQL 18.4 // container: this query returns 3 columns (id, name, id) — "t.*" expands to id/name, then // the trailing bare "id" is a separate item after the star. StarGuardCase( @@ -1097,7 +1100,7 @@ class SqlOutputClauseTest { // adjacency, the "q" immediately before "E" would look like it continues "xq" into the "E", // wrongly denying the escape string its backslash-escape processing and letting the // backslash-quote inside it terminate the string early, garbling everything scanned after. - // Verified against a real PostgreSQL 18.4 container: this query returns 3 columns (f1, f2, + // Confirmed against a real PostgreSQL 18.4 container: this query returns 3 columns (f1, f2, // id) — f(xq) returns a 2-column composite. StarGuardCase( description = "a space-separated E-string typed literal is not fused into a standalone-E escape string", @@ -1107,7 +1110,7 @@ class SqlOutputClauseTest { // lookback on original adjacency, the "q" immediately before "$" would look like it // continues the "xq" identifier (PostgreSQL allows "$" inside an unquoted identifier), so the // "$" would never be recognized as opening a dollar-quoted string at all, and its body — - // including the "'" it contains — would be scanned as ordinary SQL. Verified against a real + // including the "'" it contains — would be scanned as ordinary SQL. Confirmed against a real // PostgreSQL 18.4 container: this query returns 3 columns (f1, f2, id). StarGuardCase( description = "a space-separated dollar-quoted typed literal is not fused into the identifier before it", @@ -1122,7 +1125,7 @@ class SqlOutputClauseTest { } fun notMistakenForStarGuardCases(): List = listOf( - // Regression guard: an earlier version of the star guard discarded the ENTIRE list whenever + // Regression guard: an earlier version of the star guard discarded the entire list whenever // a star shared it with another item, even for an item like "id" here, whose mapping is // positionally exact regardless of the star's own unknown expansion width. NotStarGuardCase( @@ -1139,17 +1142,17 @@ class SqlOutputClauseTest { SelectItem("id", "id", null), ), ), - // Regression guard: a looser normalizer that strips ALL whitespace (needed to recognize + // Regression guard: a looser normalizer that strips all whitespace (needed to recognize // "tgt . *") must not also start recognizing "count( * )" as a star merely because it // contains the character "*" once whitespace is gone ("count(*)") — the trailing ")" - // still rules it out. This is only true because "count(*)" does not ALSO look like a + // still rules it out. This is only true because "count(*)" does not also look like a // wrapping parenthesis around a star: isStarItem's parenthesis-stripping loop only strips a - // pair that wraps the ENTIRE remaining text (verified via findMatchingCloseParenthesis, not + // pair that wraps the entire remaining text (checked via findMatchingCloseParenthesis, not // merely the first/last characters), and "count(*)" does not start with "(" at all — its // first character is "c", and the real "(" is the 6th character (index 5) — so the loop's // own startsWith("(") check never fires and the trailing ")" is decisive. (A genuine wrapping // case like "(tgt.*)" normalizes to itself first — still ending in ")" — and only becomes a - // recognized star AFTER the stripping loop removes that wrapping pair, leaving "tgt.*".) + // recognized star after the stripping loop removes that wrapping pair, leaving "tgt.*".) NotStarGuardCase( description = "count star is not mistaken for the star guard even with internal whitespace", sql = "SELECT count( * ) AS total, id FROM t", @@ -1190,10 +1193,10 @@ class SqlOutputClauseTest { SelectItem("id", "id", null), ), ), - // Regression guard: stripCommentsAndWhitespace removes the comment OUTRIGHT, with nothing + // Regression guard: stripCommentsAndWhitespace removes the comment outright, with nothing // put in its place, so "t.*/*c*/::text" normalizes to "t.*::text" — not "t.* ::text" or // anything else with a boundary in it. isStarItem's alias search finds "text" as the trailing - // segment (a valid identifier), leaving the prefix "t.*::" — which does NOT end in the + // segment (a valid identifier), leaving the prefix "t.*::" — which does not end in the // literal ".*" (it ends in "::"), so this is correctly rejected as not a star. NotStarGuardCase( description = "a cast on a star with a comment immediately before it is not mistaken for the star guard", @@ -1203,10 +1206,10 @@ class SqlOutputClauseTest { SelectItem("a", "a", null), ), ), - // Verified against a real PostgreSQL 18 container: "SELECT t.* ::text, a FROM t" is valid - // and returns 2 columns (t, a), with NO star expansion. stripCommentsAndWhitespace removes - // ALL whitespace, so this normalizes to the same "t.*::text" as the comment-abutting case - // above, and is rejected the same way (prefix "t.*::" does not end in ".*"). + // On PostgreSQL 18, "SELECT t.* ::text, a FROM t" is valid and returns 2 columns (t, a), + // with no star expansion. stripCommentsAndWhitespace removes all whitespace, so this + // normalizes to the same "t.*::text" as the comment-abutting case above, and is rejected + // the same way (prefix "t.*::" does not end in ".*"). NotStarGuardCase( description = "a whitespace-separated cast on a star is not mistaken for an implicit alias", sql = "SELECT t.* ::text, a FROM t", @@ -1225,12 +1228,12 @@ class SqlOutputClauseTest { SelectItem("a", "a", null), ), ), - // Verified against a real PostgreSQL 18 container: "SELECT t.*-- c\n::text, a FROM t" is - // valid and returns 2 columns (t, a), with NO star expansion. This already resolved - // correctly before this round's "::" fix — skipLineComment consumes the whole "-- c\n" span - // (including its terminating newline) as ONE opaque token, so none of the three candidates - // ever finds a top-level whitespace boundary to strip a trailing token at here at all; "::" - // stays attached to "t.*" regardless. It had no dedicated test pinning it, though. + // On PostgreSQL 18, "SELECT t.*-- c\n::text, a FROM t" is valid and returns 2 columns + // (t, a), with no star expansion. This already worked before the "::" fix -- skipLineComment + // consumes the whole "-- c\n" span (including its terminating newline) as one opaque token, + // so none of the three candidates ever finds a top-level whitespace boundary to strip a + // trailing token here; "::" stays attached to "t.*" regardless. It had no dedicated test + // pinning it, though. NotStarGuardCase( description = "a line comment between a star and its cast is not mistaken for an implicit alias", sql = "SELECT t.*-- c\n::text, a FROM t", @@ -1251,10 +1254,9 @@ class SqlOutputClauseTest { ), // Regression guard: PostgreSQL identifiers cannot start with a digit, so "2." is not a valid // qualifier — this is ordinary multiplication ("2 * 3"), with "3 lbl" an implicit alias on - // the numeric literal "3" (verified against a real PostgreSQL 18.4 container: "SELECT - // 2.*3 lbl, a FROM t" returns 2 columns). A qualifier check based on `\w` (ASCII word - // characters) rather than "letter or underscore, then identifier characters" would wrongly - // accept "2." as a qualifier here. + // the numeric literal "3" (PostgreSQL 18.4: "SELECT 2.*3 lbl, a FROM t" returns 2 columns). + // A qualifier check based on `\w` (ASCII word characters) rather than "letter or underscore, + // then identifier characters" would wrongly accept "2." as a qualifier here. NotStarGuardCase( description = "a digit-leading qualifier before a star is arithmetic, not a star", sql = "SELECT 2.*3 lbl, a FROM t", @@ -1274,8 +1276,8 @@ class SqlOutputClauseTest { // Negative guard confirming isStarQualifierAcceptable's digit-leading rejection still fires // regardless of whether an implicit alias follows the star: "2." is rejected because the // identifier-character run immediately before its final "." is "2", and that run's first - // character is a digit. Verified against a real PostgreSQL 18.4 container: "SELECT 2.*a lbl, - // a FROM t" is valid arithmetic ("2 * a AS lbl"), returning 2 columns. + // character is a digit. On PostgreSQL 18.4, "SELECT 2.*a lbl, a FROM t" is valid arithmetic + // ("2 * a AS lbl"), returning 2 columns. NotStarGuardCase( description = "a digit-leading qualifier before a star with an implicit alias remains arithmetic, not a star", sql = "SELECT 2.*a lbl, a FROM t", @@ -1285,11 +1287,11 @@ class SqlOutputClauseTest { ), ), // Negative guard confirming the alias-first design did not break the ordinary case where an - // EARLIER part of the expression contains a star ("count(*)"). Verified against a real - // PostgreSQL 18.4 container: "SELECT count(*) * 2 tot, (SELECT 1) AS id FROM t" returns 2 - // columns (tot, id) — isStarItem finds "tot" as the trailing alias segment, leaving the - // prefix "count(*)*2", which does not end in the literal ".*" (it ends in "*2"), so this is - // correctly rejected without needing to reason about the "*" inside "count(*)" at all. + // earlier part of the expression contains a star ("count(*)"). On PostgreSQL 18.4, + // "SELECT count(*) * 2 tot, (SELECT 1) AS id FROM t" returns 2 columns (tot, id) -- + // isStarItem finds "tot" as the trailing alias segment, leaving the prefix "count(*)*2", + // which does not end in the literal ".*" (it ends in "*2"), so this is correctly rejected + // without needing to reason about the "*" inside "count(*)" at all. NotStarGuardCase( description = "an aggregate star multiplied by a constant is not mistaken for the star guard", sql = "SELECT count(*) * 2 tot, (SELECT 1) AS id FROM t", @@ -1300,10 +1302,10 @@ class SqlOutputClauseTest { ), // Negative guard for the point where skipOptionalSetQuantifier matters most: after // isStarItem's own whitespace-stripping, "ALL 2.*a" and a genuine star on a table named - // "all2" would normalize IDENTICALLY ("all2.*a") if the quantifier were stripped inside + // "all2" would normalize identically ("all2.*a") if the quantifier were stripped inside // isStarItem itself rather than beforehand in parseSelectItems, using the still-whitespace- - // intact clause text. Verified against a real PostgreSQL 18.4 container: "SELECT ALL 2.*a - // lbl, b FROM t" is valid arithmetic ("ALL 2 * a AS lbl"), returning 2 columns. + // intact clause text. On PostgreSQL 18.4, "SELECT ALL 2.*a lbl, b FROM t" is valid + // arithmetic ("ALL 2 * a AS lbl"), returning 2 columns. NotStarGuardCase( description = "an ALL quantifier fused with a digit-leading qualifier is still arithmetic, not a star", sql = "SELECT ALL 2.*a lbl, b FROM t", @@ -1312,10 +1314,10 @@ class SqlOutputClauseTest { SelectItem("b", "b", null), ), ), - // Pins the point-4 behavior change: previously the quantifier text was glued onto the first - // item's expression. Verified against a real PostgreSQL 18.4 container: "SELECT DISTINCT a, b - // FROM t" returns 2 columns (a, b) — a plain DISTINCT with no ON (...) clause. The first - // item's expression/columnName must now be the bare "a", not "DISTINCTa". + // Previously the quantifier text was glued onto the first item's expression. On PostgreSQL + // 18.4, "SELECT DISTINCT a, b FROM t" returns 2 columns (a, b) -- a plain DISTINCT with no + // ON (...) clause. The first item's expression/columnName must now be the bare "a", not + // "DISTINCTa". NotStarGuardCase( description = "a DISTINCT quantifier with no ON clause is stripped from the first item", sql = "SELECT DISTINCT a, b FROM t", @@ -1324,21 +1326,18 @@ class SqlOutputClauseTest { SelectItem("b", "b", null), ), ), - // This is a DESIGN regression guard, not coverage of any Unicode relaxation: it pins that - // isStarQualifierAcceptable still rejects an ASCII-digit-led qualifier run ("2") at all -- - // i.e. that the digit check has not been silently dropped or weakened to "always accept" -- - // regardless of what the TRAILING alias is made of (here an "x" followed by a COMBINING ACUTE - // ACCENT, \u0301, written as a Kotlin unicode escape). It does NOT discriminate between any - // version of the qualifier-side fix, past or present: a non-ASCII digit like "\u0663" starting - // the qualifier's OWN run (which Char.isDigit() used to wrongly reject, and which the shared - // isIdentifierChar predicate now correctly runs past) is a DIFFERENT shape -- see the - // "\u0663" tests below for that discriminating coverage. Verified against a - // real PostgreSQL 18.4 container using a temporary table (this literal identifier isn't a - // real column anywhere, so a plain SELECT against "t" can't execute to confirm a row, only a - // parse): creating a temp table with an "a" column and a column named by this exact - // identifier, then "SELECT 2.* lbl, a FROM temp_check" returns 2 columns - // (lbl, a) -- ordinary arithmetic, not a star, matching the identically-shaped (ASCII) - // "2.*a lbl, a FROM t" case already pinned above. + // A design regression guard, not Unicode-relaxation coverage: it pins that + // isStarQualifierAcceptable still rejects an ASCII-digit-led qualifier run ("2") regardless + // of what the trailing alias is made of (here an "x" followed by a COMBINING ACUTE ACCENT, + // \u0301, written as a Kotlin unicode escape). It does not discriminate between versions of + // the qualifier-side fix -- a non-ASCII digit like "\u0663" starting the qualifier's own run + // (which Char.isDigit() used to wrongly reject, and which the shared isIdentifierChar + // predicate now correctly runs past) is a different shape; see the "\u0663" tests below for + // that. Checked against PostgreSQL 18.4 with a temp table (this identifier isn't a real + // column anywhere, so a plain SELECT against "t" can't execute to confirm a row, only a + // parse): a column named by this exact identifier plus "SELECT 2.* lbl, a + // FROM temp_check" returns 2 columns (lbl, a) -- ordinary arithmetic, not a star, matching + // the identically-shaped ASCII "2.*a lbl, a FROM t" case above. NotStarGuardCase( description = "a digit-leading qualifier before a star with an NFD implicit alias remains arithmetic, not a star", @@ -1439,14 +1438,14 @@ class SqlOutputClauseTest { SelectItem("t.*", null, null), ), ), - // "preferences prefs" is a real implicit alias (no "AS"), but NEITHER candidate 1 - // ("preferences prefs") NOR candidate 2 ("preferences") is star-shaped, so the guard does - // not fire and both items survive. columnName is null for the FIRST item specifically - // because parseColumnReference's own regex requires the ENTIRE (unstripped) expression to - // match a bare or qualified identifier — "preferences prefs" contains a space, so the whole - // match fails — NOT because this function mistakes it for something else. This is a LOST + // "preferences prefs" is a real implicit alias (no "AS"), but neither candidate 1 + // ("preferences prefs") nor candidate 2 ("preferences") is star-shaped, so the guard does + // not fire and both items survive. columnName is null for the first item specifically + // because parseColumnReference's own regex requires the entire (unstripped) expression to + // match a bare or qualified identifier -- "preferences prefs" contains a space, so the whole + // match fails, not because this function mistakes it for something else. This is a lost // name (parseColumnReference simply cannot see past the implicit alias it wasn't taught to - // strip), not a WRONG one: the resulting `emptyList()`-style fallback in JdbcAnalyzer + // strip), not a wrong one: the resulting `emptyList()`-style fallback in JdbcAnalyzer // degrades to metadata, exactly as documented on parseSelectItems for any expression this // function cannot resolve. NotStarGuardCase( @@ -1469,7 +1468,7 @@ class SqlOutputClauseTest { SelectItem("'x'", null, null), ), ), - // Regression guard, not a #212 reproduction: this already passed before the fix — the old + // Regression guard, not a bug reproduction: this already passed before the fix -- the old // implementation's naive first-SELECT-anywhere search happens to find the same, correct // SELECT here, since there is no WITH clause and this branch's SELECT is the first in the // statement either way. @@ -1504,7 +1503,7 @@ class SqlOutputClauseTest { @Test fun `a quoted column reference with a doubled-quote escape is not mangled, and collapses to one literal quote`() { - // Verified directly against a live PostgreSQL 18: ResultSetMetaData.getColumnName for + // On PostgreSQL 18, ResultSetMetaData.getColumnName for // `SELECT "He""llo" FROM (SELECT 1 AS "He""llo") s` reports `He"llo` (no surrounding quotes, // the doubled "" already collapsed to a single literal ") -- this must agree exactly, since // JdbcAnalyzer.buildResultColumns' originalName prefers this value over rsmd.getColumnName. diff --git a/generator/src/test/kotlin/norm/generator/SqlParameterInferrerTest.kt b/generator/src/test/kotlin/norm/generator/SqlParameterInferrerTest.kt index a286cc00..215f5f7a 100644 --- a/generator/src/test/kotlin/norm/generator/SqlParameterInferrerTest.kt +++ b/generator/src/test/kotlin/norm/generator/SqlParameterInferrerTest.kt @@ -20,8 +20,7 @@ class SqlParameterInferrerTest { "encode" to listOf(FunctionOverload(emptyList(), isStrict = true)), "hmac" to listOf(FunctionOverload(emptyList(), isStrict = true)), "upper" to listOf(FunctionOverload(listOf("str"), isStrict = true)), - // Matches real PostgreSQL pg_proc for the 3-arg overload, verified directly: - // proargnames = {string,pattern,replacement}. + // PostgreSQL's pg_proc for the 3-arg overload has proargnames = {string,pattern,replacement}. "regexp_replace" to listOf(FunctionOverload(listOf("string", "pattern", "replacement"), isStrict = true)), ) @@ -204,18 +203,16 @@ class SqlParameterInferrerTest { @Test fun `resolves the formal argument name even when a later argument's literal contains an unbalanced parenthesis`() { - // Pins a behavior change: findMatchingCloseParenthesis previously miscounted the "(" inside - // the string literal '\(' as a real parenthesis, so extractFunctionCalls's own paren search - // never found a balanced close for this call — the call was skipped entirely (not "found - // with the wrong args"), and the parameter fell through to a caller-level generic default - // (p1) instead of a real name. Fixed by SqlUtils.kt's lexical-aware findMatchingCloseParenthesis. - // "string" IS the correct name per Norm's own established rule (see "infers formal argument - // names from pg_proc" above): a pg_proc formal argument name always wins over a generic - // fallback, and regexp_replace(string, pattern, replacement) is regexp_replace's real - // 3-argument signature (verified directly against PostgreSQL pg_proc), so the FIRST ? is - // "string" here for exactly the same reason the first ? in crypt(?, gen_salt('bf')) is - // "password" above — this is not new behavior, just this shape finally reaching the rule - // that was always intended for it. + // findMatchingCloseParenthesis previously miscounted the "(" inside the string literal + // '\(' as a real parenthesis, so extractFunctionCalls's own paren search never found a + // balanced close for this call — the call was skipped entirely, and the parameter fell + // through to a caller-level generic default (p1) instead of a real name. Fixed by + // SqlUtils.kt's lexical-aware findMatchingCloseParenthesis. + // "string" is the correct name per Norm's own rule (see "infers formal argument names from + // pg_proc" above): a pg_proc formal argument name always wins over a generic fallback, and + // regexp_replace(string, pattern, replacement) is regexp_replace's real 3-argument + // signature, so the first ? is "string" here for the same reason the first ? in + // crypt(?, gen_salt('bf')) is "password" above. val result = inferrer.inferParameterInfo( """SELECT id FROM p WHERE name = regexp_replace(?, '\(', '')""", ) @@ -337,27 +334,27 @@ class SqlParameterInferrerTest { fun `captures the whole dollar-containing function name, not just the run after the dollar sign`() { // FUNCTION_CALL_START previously used a bare "\w+", which excludes "$" -- "\w+" cannot match // "my$fn" as one run, so findAll instead matched the shorter run "fn" immediately before the - // "(", handing SqlParameterInferrer.extractFunctionCalls the WRONG function name. Verified - // against a real PostgreSQL 18.4: "my$fn" is a legal unquoted function name (CREATE FUNCTION - // "my$fn"(...) and the unquoted call my$fn(...) resolve to the same function). + // "(", handing SqlParameterInferrer.extractFunctionCalls the wrong function name. In + // PostgreSQL 18.4, "my$fn" is a legal unquoted function name (CREATE FUNCTION "my$fn"(...) + // and the unquoted call my$fn(...) resolve to the same function). val match = FUNCTION_CALL_START.find("SELECT my\$fn(?)") assertThat(match!!.groupValues[1]).isEqualTo("my\$fn") } @Test fun `captures a function name continuing with a non-ASCII character`() { - // "\w+" is ASCII-only, so it also excludes any ">= 0x80" character -- verified against a - // real PostgreSQL 18.4: an unquoted function named "fn€" is legal and callable unquoted. + // "\w+" is ASCII-only, so it also excludes any ">= 0x80" character. In PostgreSQL 18.4, an + // unquoted function named "fn€" is legal and callable unquoted. val match = FUNCTION_CALL_START.find("SELECT fn€(?)") assertThat(match!!.groupValues[1]).isEqualTo("fn€") } @Test fun `does not capture a digit as the start of a function name`() { - // Verified against a real PostgreSQL 18.4: "2fn(...)" is rejected outright ("trailing junk - // after numeric literal") -- a digit may never START an identifier. The regex's own - // leading-character restriction means a match beginning with "2" is impossible; the only - // match found here starts at "f". + // In PostgreSQL 18.4, "2fn(...)" is rejected outright ("trailing junk after numeric + // literal") -- a digit may never start an identifier. The regex's own leading-character + // restriction means a match beginning with "2" is impossible; the only match found here + // starts at "f". val match = FUNCTION_CALL_START.find("SELECT 2fn(?)") assertThat(match!!.groupValues[1]).isEqualTo("fn") } diff --git a/generator/src/test/kotlin/norm/generator/SqlStarItemTest.kt b/generator/src/test/kotlin/norm/generator/SqlStarItemTest.kt index e7b37e9f..fb9efc67 100644 --- a/generator/src/test/kotlin/norm/generator/SqlStarItemTest.kt +++ b/generator/src/test/kotlin/norm/generator/SqlStarItemTest.kt @@ -41,7 +41,7 @@ class SqlStarItemTest { @Test fun `a dollar-quoted string's own opening dollar sign is not mistaken for an identifier boundary`() { // Regression guard for the adjacency gate this generalizes: "$" still only breaks a run when - // it was NOT genuinely adjacent to the identifier character before it -- a real adjacency + // it was not genuinely adjacent to the identifier character before it -- a real adjacency // (never separated by whitespace/comment in the original text) must keep continuing the run // exactly as before. val result = splitTrailingImplicitAlias("a\$b dx") diff --git a/generator/src/test/kotlin/norm/generator/TypeRepositoryTest.kt b/generator/src/test/kotlin/norm/generator/TypeRepositoryTest.kt index adefa9cd..5b62608c 100644 --- a/generator/src/test/kotlin/norm/generator/TypeRepositoryTest.kt +++ b/generator/src/test/kotlin/norm/generator/TypeRepositoryTest.kt @@ -20,7 +20,7 @@ class TypeRepositoryTest { * `parseSelectItems` call site: [parseSelectItems] has no independent way to confirm its own * item count against the real result column count (see its KDoc), so a spelling it * mis-recognizes -- most concretely, an unrecognized star item that expands to several real - * columns -- degrades SILENTLY to a shifted, wrong mapping of names/expressions onto columns + * columns -- degrades silently to a shifted, wrong mapping of names/expressions onto columns * they don't belong to, rather than the documented empty-list fail-safe, unless the caller * cross-checks the count itself. * @@ -37,10 +37,10 @@ class TypeRepositoryTest { // 3 select items, all computed expressions with distinct, individually-recognizable text. val queryText = "SELECT LENGTH(a) AS a_len, UPPER(b) AS b_upper, LOWER(c) AS c_lower FROM t" - // Only 2 REAL result columns -- simulates the exact shape of the bug: parseSelectItems' + // Only 2 real result columns -- simulates the exact shape of the bug: parseSelectItems' // item count (3) disagrees with the real column count (2, what queryResults.size stands in // for here). Without the guard, the second queryResult ("c_lower", conceptually LOWER(c)) - // would incorrectly borrow selectItems[1] -- UPPER(b), a DIFFERENT expression entirely. + // would incorrectly borrow selectItems[1] -- UPPER(b), a different expression entirely. val queryResults = listOf( Column(name = "a_len", notNull = true, type = Identifier(name = "int4")), Column(name = "c_lower", notNull = true, type = Identifier(name = "text")), @@ -55,7 +55,7 @@ class TypeRepositoryTest { // is what actually distinguishes the fail-safe from the wrong, shifted mapping. assertThat(kdoc).doesNotContain("@property c_lower (`UPPER(b)`)") assertThat(kdoc).doesNotContain("@property c_lower") - // The fail-safe treats the WHOLE mapping as unreliable once the counts disagree -- not just + // The fail-safe treats the whole mapping as unreliable once the counts disagree -- not just // the specific item that would otherwise be shifted -- so a_len loses its (individually // correct) attribution too, exactly like oldOrNewReturningColumns' callers, which fall back // to forcing every column rather than only the ones they can specifically identify as risky. @@ -73,12 +73,12 @@ class TypeRepositoryTest { // parseSelectItems("SELECT 5") wrongly resolved the literal "5" as if it were a column named // "5" (columnName = "5"). That made isComputedExpression (TypeRepository.kt) false, so // PropertySource.expression was never populated, and sourceReference() returned null for - // this property entirely -- the KDoc carried NO reference to the literal's origin. + // this property entirely -- the KDoc carried no reference to the literal's origin. // // With COLUMN_REFERENCE's leading-character restricted to a legal identifier start (no // digit, no "$"), "5" no longer matches as a column reference: columnName is correctly // null, isComputedExpression is true, and sourceReference() now renders "`5`" -- this is - // the desired, MORE correct behavior (see COLUMN_REFERENCE's own KDoc), but nothing + // the desired, more correct behavior (see COLUMN_REFERENCE's own KDoc), but nothing // previously exercised the downstream KDoc-generation effect of that change. val repository = TypeRepository("test", Catalog()) val literalColumn = Column(name = "column1", notNull = true, type = Identifier(name = "int4")) @@ -176,7 +176,7 @@ class TypeRepositoryTest { // Contrast case, pinning existing accepted behavior: PostgreSQL itself names a set operation's // whole result column after branch 1 alone, so echoing a bare column reference is not // misleading the way a computed expression is -- this must keep behaving exactly as an - // ordinary bare column reference already does (no source-reference line either way), NOT + // ordinary bare column reference already does (no source-reference line either way), not // regress to something new merely because a set operation is present. val queryText = "SELECT a FROM x UNION SELECT b FROM y" val plainColumn = Column(name = "a", notNull = true, type = Identifier(name = "int4")) @@ -194,7 +194,7 @@ class TypeRepositoryTest { @Test fun `a CTE-wrapped expression column gets a source-reference KDoc line resolved through the CTE body`() { - // Regression test for issue #229: a result column that is BOTH CTE-wrapped AND + // Regression test: a result column that is both CTE-wrapped and // expression-derived previously got no @property line at all. The outer select item // (`description_upper`, a plain column reference into the CTE) makes isComputedExpression // false, so this only passes if TypeRepository also reads column.provenanceExpression — @@ -541,7 +541,7 @@ class TypeRepositoryTest { @Test fun `a plain lowercase original column name is rendered bare, unquoted`() { - // Contrast case: a normal identifier must NOT gain spurious quotes. + // Contrast case: a normal identifier must not gain spurious quotes. val plainColumn = Column( name = "id", notNull = true, @@ -605,7 +605,7 @@ class TypeRepositoryTest { @Test fun `an empty reserved-word set (the default) leaves an ordinary identifier bare, unquoted`() { // Contrast case: TypeRepository's own default (emptySet()) must not spuriously quote every - // all-lowercase identifier -- only the connected server's OWN reported reserved words. + // all-lowercase identifier -- only the connected server's own reported reserved words. val plainColumn = Column( name = "id", notNull = true, diff --git a/gradle-plugin/src/test/kotlin/norm/gradle/IdeSyncIntegrationTest.kt b/gradle-plugin/src/test/kotlin/norm/gradle/IdeSyncIntegrationTest.kt index 4c8ffd96..0ec0b4dc 100644 --- a/gradle-plugin/src/test/kotlin/norm/gradle/IdeSyncIntegrationTest.kt +++ b/gradle-plugin/src/test/kotlin/norm/gradle/IdeSyncIntegrationTest.kt @@ -20,18 +20,18 @@ import kotlin.io.path.writeText /** * Verifies Norm's sync-time generation mechanism (`IdeIntegration.configureGenerationOnIdeSync`): wiring - * `normGenerate` tasks as dependencies of `prepareKotlinIdeaImportNorm`, a task Norm registers under - * its own name and that IntelliJ IDEA runs on every Gradle sync (see [IdeIntegration] for why the name + * `normGenerate` tasks as dependencies of `prepareKotlinIdeaImportNorm`, the task Norm registers + * under its own name that IntelliJ IDEA runs on every Gradle sync (see [IdeIntegration] for why the name * matters). * - * This mechanism replaces an earlier `gradle-idea-ext` `afterSync`-based approach, which required reaching - * from a project into `project.rootProject` and was therefore illegal under Gradle's Isolated Projects for - * any project other than the root. `configureGenerationOnIdeSync` only ever touches its own project's - * `tasks` container, so it needs no such guard. + * Replaces an earlier `gradle-idea-ext` `afterSync` approach, which reached from a project into + * `project.rootProject` and was illegal under Gradle's Isolated Projects for any project but the root. + * `configureGenerationOnIdeSync` only touches its own project's `tasks` container, so it needs no such + * guard. * - * Shares [TestProject.COMPOSITE_BUILD_RESOURCE_LOCK] with `NormPluginTest`: both classes run TestKit builds - * that `includeBuild` the Norm root project, and running two of those builds at once races on the shared - * `buildSrc` project's incremental compilation caches. See the lock's KDoc for details. + * Shares [TestProject.COMPOSITE_BUILD_RESOURCE_LOCK] with `NormPluginTest`: both run TestKit builds that + * `includeBuild` the Norm root project, and two running at once race on the shared `buildSrc` project's + * incremental compilation cache. See the lock's KDoc for details. */ @ResourceLock(TestProject.COMPOSITE_BUILD_RESOURCE_LOCK) @Execution(ExecutionMode.SAME_THREAD) @@ -44,12 +44,9 @@ class IdeSyncIntegrationTest { inner class SubprojectUnderIsolatedProjects { /** - * Covers the case named in the task's acceptance criteria: Norm applied to a subproject, not the - * root. A real Testcontainers-backed run of `:app:normGenerateTest` is exercised more cheaply in - * [RealGenerationOnSync], as a single-project build; here, `--dry-run` is enough to prove the - * *dependency* exists without paying for a real container start, and is what actually needs a - * multi-project shape to be meaningful (a single-project build can't demonstrate the subproject case - * at all). + * Norm applied to a subproject, not the root. `--dry-run` proves the dependency edge exists without + * paying for a real container start; [RealGenerationOnSync] covers actual execution more cheaply as a + * single-project build. */ @Test fun `dry run shows normGenerateTest as a dependency of prepareKotlinIdeaImportNorm`() { @@ -57,9 +54,8 @@ class IdeSyncIntegrationTest { val result = gradleRunner(":app:prepareKotlinIdeaImportNorm", "--dry-run", "--isolated-projects").build() - // ":app:normGenerateTest" is not itself on the command line, so its appearance here is only - // explained by the dependency edge under test, unlike ":app:prepareKotlinIdeaImportNorm" (the - // requested task), which dry-run would always echo regardless of whether the edge exists. + // ":app:normGenerateTest" isn't on the command line, so it only appears here because of the + // dependency edge under test — unlike ":app:prepareKotlinIdeaImportNorm", which dry-run always echoes. assertThat(result.output).contains(":app:normGenerateTest") } @@ -67,10 +63,9 @@ class IdeSyncIntegrationTest { fun `configures cleanly with zero cross-project problems`() { setUpMultiProjectAppBuild(appDirectory = projectDir.resolve("app")) - // Under Gradle 9.7, an Isolated Projects violation fails the build rather than merely printing a - // diagnostic. `.build()` returning at all — instead of throwing `UnexpectedBuildFailure` — is - // therefore already the proof that no cross-project access occurred; there is no build output left - // to assert against for a violation that, by construction, could not have happened here. + // Under Gradle 9.7, an Isolated Projects violation fails the build instead of printing a + // diagnostic. `.build()` returning at all (not throwing `UnexpectedBuildFailure`) is the proof no + // cross-project access occurred. val result = gradleRunner(":app:help", "--isolated-projects").build() assertThat(result.task(":app:help")?.outcome).isEqualTo(SUCCESS) @@ -81,11 +76,10 @@ class IdeSyncIntegrationTest { inner class RealGenerationOnSync { /** - * Proves the wiring actually runs generation, not just that a dependency edge exists on paper. Uses a - * single-project build (cheaper than starting a second Testcontainers-backed build against a - * multi-project shape) since [SubprojectUnderIsolatedProjects] already covers the subproject-specific - * dependency wiring via `--dry-run`. Isolated Projects is also enabled here to demonstrate that real - * execution, not just configuration, is unaffected by it. + * Confirms the wiring actually runs generation, not just that a dependency edge exists on paper. + * Single-project build, cheaper than a second Testcontainers-backed multi-project build, since + * [SubprojectUnderIsolatedProjects] already covers the subproject dependency wiring via `--dry-run`. + * Isolated Projects stays enabled here too, to confirm real execution is unaffected by it. */ @Test fun `running prepareKotlinIdeaImportNorm actually generates code on first sync`() { @@ -103,12 +97,10 @@ class IdeSyncIntegrationTest { inner class ResolvedTaskGraph { /** - * Asserts directly on [org.gradle.api.tasks.TaskDependency], not on `--dry-run` text or a successful - * build outcome: a verification task queries `prepareKotlinIdeaImportNorm`'s resolved - * [org.gradle.api.tasks.TaskDependency] and fails the build itself if `normGenerateTest` is not one of - * the tasks it resolves to. This is what would have caught the aliasing bug directly — a refactor that - * left `prepareKotlinIdeaImportNorm`'s `dependsOn` resolving to an empty list would fail *this* - * assertion even if the overall build still happened to succeed. + * Asserts directly on [org.gradle.api.tasks.TaskDependency] rather than `--dry-run` text or build + * outcome: a verification task queries `prepareKotlinIdeaImportNorm`'s resolved dependencies and fails + * the build if `normGenerateTest` is not among them. Catches a `dependsOn` resolving to an empty list + * even if the rest of the build still happens to succeed. */ @Test fun `prepareKotlinIdeaImportNorm resolves normGenerateTest as a task dependency`() { @@ -150,13 +142,11 @@ class IdeSyncIntegrationTest { inner class ConsumerRegistersItsOwnPrepareKotlinIdeaImportTask { /** - * Guarantees a consumer may register their own task named exactly `prepareKotlinIdeaImport` at any - * point — including from inside their own `afterEvaluate { }` block — without colliding with Norm. - * This is safe because Norm registers its sync-time task under - * [IdeIntegration.PREPARE_KOTLIN_IDEA_IMPORT_TASK_NAME] (`prepareKotlinIdeaImportNorm`), a name Norm - * owns outright and never shares with a consumer or with Kotlin Gradle Plugin's own task, so there is - * no task named exactly `prepareKotlinIdeaImport` for a consumer's task of that name to collide with, - * however or whenever they register it. + * A consumer may register their own task named exactly `prepareKotlinIdeaImport` at any point — + * including inside `afterEvaluate { }` — without colliding with Norm. Norm's sync-time task is + * [IdeIntegration.PREPARE_KOTLIN_IDEA_IMPORT_TASK_NAME] (`prepareKotlinIdeaImportNorm`), a name it + * owns outright, so no `prepareKotlinIdeaImport`-named task from Kotlin Gradle Plugin or a consumer + * can collide with it. */ @Test fun `builds successfully when the consumer's task is registered inside afterEvaluate`() { @@ -168,9 +158,9 @@ class IdeSyncIntegrationTest { } /** - * The same scenario, but with the consumer's task registered at build-script top level, which already - * ran before Norm's `afterEvaluate` in the pre-fix design and so already passed. Kept to cover the - * ordering that never triggered the defect, alongside the one above that did. + * Same scenario, but the consumer's task is registered at build-script top level, which ran before + * Norm's `afterEvaluate` even in the pre-fix design and so already passed. Kept alongside the test + * above to cover both orderings. */ @Test fun `builds successfully when the consumer's task is registered at top level`() { @@ -186,15 +176,12 @@ class IdeSyncIntegrationTest { inner class GenerateOnIdeSyncOptOut { /** - * With `generateOnIdeSync = false`, `prepareKotlinIdeaImportNorm` is still registered — it is Norm's - * own task, so registering it unconditionally is harmless — but must depend on nothing. - * `project.gradle(":prepareKotlinIdeaImportNorm", ...).build()` succeeding at all already proves the - * task exists (Gradle fails outright if a requested task is unregistered); asserting `:normGenerateTest` - * is absent from the `--dry-run` output on top of that proves the flag specifically suppressed the - * dependency. Unlike a fixture that pre-registers a consumer's own `prepareKotlinIdeaImport` task, this - * fixture registers nothing besides what Norm itself registers, so there is no other source of a - * `prepareKotlinIdeaImport*` task name that could make the "no dependency" assertion pass for the wrong - * reason. + * With `generateOnIdeSync = false`, `prepareKotlinIdeaImportNorm` is still registered — it's Norm's + * own task, so registering it unconditionally is harmless — but depends on nothing. The build + * succeeding at all proves the task exists (Gradle fails outright on an unregistered task); the + * `--dry-run` output missing `:normGenerateTest` proves the flag suppressed the dependency. This + * fixture registers nothing besides Norm's own tasks, so there's no other source of a + * `prepareKotlinIdeaImport*` task name. */ @Test fun `disables the dependency when set before the databases block`() { @@ -219,11 +206,10 @@ class IdeSyncIntegrationTest { inner class TaskNamePrefixContract { /** - * Pins the single most fragile fact in this design: IntelliJ IDEA discovers sync-time tasks by - * matching task names that START WITH `prepareKotlinIdeaImport`, not by exact name (see - * [IdeIntegration.PREPARE_KOTLIN_IDEA_IMPORT_TASK_NAME]'s KDoc). Renaming Norm's task to anything that - * doesn't keep this prefix would silently break IDE sync generation with no build failure to catch it - * — this test is the cheap guard against that happening by accident. + * Pins the most fragile fact in this design: IntelliJ IDEA discovers sync-time tasks by matching names + * that start with `prepareKotlinIdeaImport`, not by exact match (see + * [IdeIntegration.PREPARE_KOTLIN_IDEA_IMPORT_TASK_NAME]'s KDoc). Renaming Norm's task to drop that + * prefix would silently break IDE sync generation with no build failure to catch it. */ @Test fun `Norm's sync task name starts with prepareKotlinIdeaImport`() { @@ -232,9 +218,8 @@ class IdeSyncIntegrationTest { } /** - * Writes a minimal, valid `author` table schema and a matching query into [directory], for tests that - * need Norm to configure (and, in [RealGenerationOnSync], actually run) generation without exercising - * any particular SQL feature. + * Minimal, valid `author` schema and matching query — enough for Norm to configure generation without + * exercising any particular SQL feature. */ private fun writeAuthorSchemaAndQueries(directory: Path) { directory.resolve("schema.sql").writeText(AUTHOR_TABLE_SCHEMA_SQL) @@ -258,8 +243,8 @@ class IdeSyncIntegrationTest { """.trimIndent() /** - * Builds a single-project build with Norm configured against a valid schema and query, and no - * `prepareKotlinIdeaImport*` task registered by anything other than Norm itself. + * Single-project build with Norm configured against a valid schema and query; no + * `prepareKotlinIdeaImport*` task other than Norm's own is registered. */ private fun singleProjectWithNormConfigured(): TestProject { writeAuthorSchemaAndQueries(projectDir) @@ -282,11 +267,11 @@ class IdeSyncIntegrationTest { } /** - * Builds a single-project build where the consumer registers their own, unrelated task named exactly - * `prepareKotlinIdeaImport`, either inside an `afterEvaluate { }` block (matching how Kotlin Gradle - * Plugin's own tooling — or a future release of it — might register the task lazily, per - * [IdeIntegration.PREPARE_KOTLIN_IDEA_IMPORT_TASK_NAME]'s KDoc) or at build-script top level, depending - * on [registerInsideAfterEvaluate]. + * Single-project build where the consumer registers their own, unrelated task named exactly + * `prepareKotlinIdeaImport`, either inside `afterEvaluate { }` (matching how Kotlin Gradle Plugin's own + * tooling might register the task lazily, per + * [IdeIntegration.PREPARE_KOTLIN_IDEA_IMPORT_TASK_NAME]'s KDoc) or at top level, depending on + * [registerInsideAfterEvaluate]. */ private fun singleProjectWithConsumerPrepareKotlinIdeaImportTask(registerInsideAfterEvaluate: Boolean): TestProject { writeAuthorSchemaAndQueries(projectDir) @@ -323,9 +308,9 @@ class IdeSyncIntegrationTest { } /** - * Builds a single-project build with `generateOnIdeSync = false`, set either before or after the - * `databases { }` block depending on [setBeforeDatabasesBlock]. Registers nothing besides Norm itself, so - * the only `prepareKotlinIdeaImport*` task that can appear in the build is Norm's own. + * Single-project build with `generateOnIdeSync = false`, set either before or after the + * `databases { }` block depending on [setBeforeDatabasesBlock]. Registers nothing besides Norm itself, + * so the only `prepareKotlinIdeaImport*` task that can appear is Norm's own. */ private fun singleProjectWithGenerateOnIdeSyncDisabled(setBeforeDatabasesBlock: Boolean): TestProject { writeAuthorSchemaAndQueries(projectDir) @@ -355,9 +340,9 @@ class IdeSyncIntegrationTest { } /** - * Sets up a two-project build: an empty root project and an included `app` subproject applying the - * Kotlin JVM plugin and Norm. No `gradle-idea-ext` is involved — `prepareKotlinIdeaImportNorm` wiring is - * entirely project-local, so the root project needs no configuration at all. + * Two-project build: an empty root project and an included `app` subproject applying Kotlin JVM and + * Norm. No `gradle-idea-ext` involved — `prepareKotlinIdeaImportNorm` wiring is project-local, so the + * root project needs no configuration. */ private fun setUpMultiProjectAppBuild(appDirectory: Path) { appDirectory.createDirectories() diff --git a/gradle-plugin/src/test/kotlin/norm/gradle/NormPluginTest.kt b/gradle-plugin/src/test/kotlin/norm/gradle/NormPluginTest.kt index c1c74c72..e2bfeebf 100644 --- a/gradle-plugin/src/test/kotlin/norm/gradle/NormPluginTest.kt +++ b/gradle-plugin/src/test/kotlin/norm/gradle/NormPluginTest.kt @@ -432,34 +432,27 @@ class NormPluginTest { assertThat(result.output).contains("Cannot mix named") } - // A dedicated "synthesized CRUD query failure identifies the source table" test used to live here, - // triggered by a table name containing a literal double quote ("tab""le") that CrudQuerySynthesizer - // wrapped without doubling the embedded quote, producing invalid SQL. That was a bug in the - // QUOTING, not an inherent property of the trigger (#238 11.4 fixed it), so the build started + // A "synthesized CRUD query failure identifies the source table" test used to live here, triggered by a + // table name containing a literal double quote ("tab""le") that CrudQuerySynthesizer wrapped without + // doubling the embedded quote, producing invalid SQL. That quoting bug was fixed, so the build started // succeeding instead of failing and the test broke. // - // Searched for a replacement trigger that fails to ANALYZE by construction, live against - // PostgreSQL 18, rather than by an escaping bug that could be fixed out from under it again: - // reserved/mixed-case/space-containing/quote-containing identifiers (all now correctly quoted), - // GENERATED ALWAYS AS IDENTITY and STORED-generated columns (already correctly excluded from - // INSERT via pgjdbc's own IS_AUTOINCREMENT/IS_GENERATEDCOLUMN metadata), a PRIMARY KEY of a type - // with no default btree operator class (point, json, xml -- these fail at CREATE TABLE itself, - // before CrudQuerySynthesizer ever sees the table), a zero-column table, a foreign table (already - // excluded -- introspectTables' own getTables() call never requests "FOREIGN TABLE" as one of its - // types), and a partitioned table with zero partitions attached (PREPARE never checks partition - // routing or writability -- those are execution-time concerns, and Norm's analysis phase only - // ever prepares a statement, never executes one). None reproduces a PREPARE-time failure. + // No replacement trigger was found that fails to analyze by construction, live against PostgreSQL 18: + // reserved/mixed-case/space-containing/quote-containing identifiers are now correctly quoted; + // GENERATED ALWAYS AS IDENTITY and STORED-generated columns are already excluded from INSERT via + // pgjdbc's IS_AUTOINCREMENT/IS_GENERATEDCOLUMN metadata; a primary key of a type with no default btree + // operator class (point, json, xml) fails at CREATE TABLE, before CrudQuerySynthesizer sees the table; + // a zero-column table; foreign tables are already excluded (introspectTables never requests + // "FOREIGN TABLE"); a partitioned table with no partitions attached still succeeds, since PREPARE + // never checks partition routing or writability. // - // Every statement CrudQuerySynthesizer builds is one of: `INSERT INTO t (cols) VALUES (?, ...)` - // (each `?`'s type is resolved unambiguously from its target column, which is by definition a - // real column of a creatable type), or `... WHERE pk = ? [AND pk2 = ? ...]` (each `pk` column is, - // by definition, one PostgreSQL already required to have a working btree equality operator in - // order to create the primary key in the first place). There is no longer a live PostgreSQL - // relation shape that makes synthesized CRUD fail to analyze -- CrudQuerySynthesizer's own output - // is total over anything the catalog can hold. The shared error-wrapping mechanism itself + // Every statement CrudQuerySynthesizer builds is either `INSERT INTO t (cols) VALUES (?, ...)`, where + // each `?` resolves unambiguously from a real column, or `... WHERE pk = ? [AND pk2 = ? ...]`, where each + // `pk` column already has a working btree equality operator by virtue of being a primary key. There is no + // longer a schema shape that makes synthesized CRUD fail to analyze. The shared error-wrapping mechanism // (`NormGenerateTask.sourceLabel`) is still exercised by the sibling - // `query analysis failure includes the query name in the error` test above, via a hand-written - // query referencing a table that does not exist -- a trigger that can never be "fixed" away. + // `query analysis failure includes the query name in the error` test, via a query referencing a + // nonexistent table. @Test fun `schemas can be specified as a directory`() { diff --git a/runtime/src/main/kotlin/norm/PlainArrays.kt b/runtime/src/main/kotlin/norm/PlainArrays.kt index cf2ff007..98b8490f 100644 --- a/runtime/src/main/kotlin/norm/PlainArrays.kt +++ b/runtime/src/main/kotlin/norm/PlainArrays.kt @@ -53,8 +53,8 @@ public fun Array<*>.toSqlArray(connection: Connection, typeName: String): java.s * with the element's own OID, so reading column `2` goes through exactly the same code path as * reading a scalar column of that type. * - * The cost is one [ResultSet] row per element where the bulk path allocated none. That is accepted - * deliberately: correctness for `timetz[]` and `time[]` is not available any other way. + * The cost is one [ResultSet] row per element where the bulk path allocated none, accepted because + * correctness for `timetz[]` and `time[]` is not available any other way. * * Elements are always nullable, because a Postgres array can contain `NULL` values regardless of * the column's `NOT NULL` constraint. `T` is inferred from [read]'s return type, so