Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

9 changes: 4 additions & 5 deletions generator/src/main/kotlin/norm/generator/DomainBuilder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
6 changes: 3 additions & 3 deletions generator/src/main/kotlin/norm/generator/EnumBuilder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
47 changes: 23 additions & 24 deletions generator/src/main/kotlin/norm/generator/GroupRteSubstitution.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions generator/src/main/kotlin/norm/generator/JdbcAnalyzer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion generator/src/main/kotlin/norm/generator/JsonValue.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions generator/src/main/kotlin/norm/generator/Main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -160,9 +160,9 @@ private fun findOwnJoinNodes(mergeModifyTableNode: JsonValue.JsonObject): List<J
}

/**
* Every `"Relation Name"` (a base table or view) OR `"CTE Name"` (a `MATERIALIZED`, or otherwise
* Every `"Relation Name"` (a base table or view) or `"CTE Name"` (a `MATERIALIZED`, or otherwise
* non-inlined, CTE's own `"CTE Scan"` node) reachable from [node], including [node] itself, at any
* depth. Both fields are collected together because a CTE source can appear as EITHER, depending
* depth. Both fields are collected together because a CTE source can appear as either, depending
* on a planner decision the parsed query tree cannot predict — see [explainMergeSideNullability]'s
* own KDoc for how its caller offers both candidate names to cover either shape.
*/
Expand Down
4 changes: 2 additions & 2 deletions generator/src/main/kotlin/norm/generator/Model.kt
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,10 @@ public data class Identifier(val catalog: String = "", val schema: String = "",
* column's own select item is merely a bare reference into a CTE's output (e.g. the outer query
* reads `description_upper`, but the CTE body actually computed it as `UPPER(description)`).
* `null` when there is no such CTE-body expression to report: a plain table column, a column
* whose defining expression was written directly in the OUTER query (already covered by
* whose defining expression was written directly in the outer query (already covered by
* [TypeRepository]'s own top-level computed-expression handling), a CTE body pass-through of
* another column with no transformation, or a shape [NodeTreeProvenanceResolver] and
* [resolveNodeTreeProvenanceExpression] could not PROVE correct by cross-validating the query's
* [resolveNodeTreeProvenanceExpression] could not prove correct by cross-validating the query's
* own parsed node tree against its original SQL text — see those functions' KDoc for the full
* list of gates that must all hold before this is ever populated.
*/
Expand Down
Loading