Skip to content

Plan a named parameter from its declared type without a value - #4542

Draft
sergei-pustovykh wants to merge 5 commits into
apple/sergei-pustovykh/stored-query/signaturefrom
apple/sergei-pustovykh/stored-query/value-free-planning
Draft

Plan a named parameter from its declared type without a value#4542
sergei-pustovykh wants to merge 5 commits into
apple/sergei-pustovykh/stored-query/signaturefrom
apple/sergei-pustovykh/stored-query/value-free-planning

Conversation

@sergei-pustovykh

@sergei-pustovykh sergei-pustovykh commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

A query can now be planned when a named parameter carries a declared type but no value. The parameter becomes a typed constant with nothing bound to it, so the plan is complete and reusable, and a value arrives later — which is what lets a plan be built once at startup and matched by a client that binds real values. The declared type is the whole of what such a plan knows about the parameter, including whether a null binding is one of the values it serves.

Nothing in SQL changes, and no existing path behaves differently: this is reachable only from a caller that passes PreparedParams.withDeclaredTypes(...). Stored-query warm-up becomes that caller two PRs later. An unbound named parameter without a declared type still fails exactly as before.

The caller chooses the type, and for stored queries it comes from the prepared case rather than from the signature. A signature may declare p BIGINT, which is nullable, while a p IS NOT NULL case passes a non-nullable BIGINT. That distinction carries real weight: a nullable declared type on a value-free constant would let a null binding match the plan, and such a plan is not correct for null, because a null comparand is encoded into the scan range as the value to look for. Warm-up therefore never passes a nullable type for a parameter it leaves value-free.

Mechanism

A value-free literal is distinct from one bound to NULL — both have a null literal object, and conflating them is the failure this PR is mostly about.

  • OrderedLiteral gains a valueFree flag, a factory for it, and a refusal to serialize one (the wire format encodes an absent value as NULL, so serializing would silently convert it)
  • Literals gains addValueFreeLiteral, isValueFree and literalOf. A value-free literal reserves its constant id and declares its type but contributes no binding, which is precisely what leaves the id unbound in the evaluation context
  • PreparedParams carries declaredTypes, with withDeclaredTypes and declaredTypeMaybe
  • PlanGenerator gains two overloads taking PreparedParams; the existing ones delegate with empty()
  • the plan's constraint for such a constant is OfType(declared type), plus IS_NOT_NULL when that type is non-nullable, which is exactly what a plan built from a concrete non-null value carries
  • Bindings.MissingBindingException plus a catch in QueryPlanConstraint.satisfies: a cache lookup whose context does not bind a constant the plan references is a non-match, not an exception
  • a filtered index refuses to match a value-free parameter, loudly, rather than falling back to a scan

Two places have to agree, and both are required: AstNormalizer canonicalizes the parameter to ?name while pulling no value and adding nothing to its literal table, and MutablePlanGenerationContext.processNamedPreparedParam creates the typed value-free constant. Miss either one and the other fails at the first opportunity.

Decisions worth attention

A value-free constant's constraint is deliberately the same one a bound value would produce. The declared type alone would already be enough to decide null: OfTypeValue.eval answers expectedType.isNullable() when the bound value is null, so OfType(T) by itself rejects a null binding for a NOT NULL declaration and accepts one for a nullable declaration. IS_NOT_NULL is emitted alongside it anyway when the declaration is non-nullable, because a plan built from a concrete non-null value states it too — its constant dereferences to a value — and the constraint is the plan cache key (PlanGenerator stores every plan under PhysicalPlanEquivalence.withConstraint, and two such keys compare member-wise). Stating it makes the two constraints equal member for member, so a warmed plan and a runtime-built plan for the same parameter cannot become two competing entries for one binding. Type.fromObject gives a non-null value a non-nullable type, so the OfType halves already agree; this closes the only remaining gap. The nullability read here is the same cov.getResultType() that feeds OfTypeValue.from(cov) two statements above, so the predicate and the type it mirrors cannot drift apart. For a nullable declaration nothing extra is emitted: such a plan serves a null binding, and IS_NOT_NULL would contradict that. nonNullableDeclaredTypeConstrainsAsABoundValueDoes and nullableDeclaredTypeConstrainsMoreLooselyThanABoundValue pin both halves.

An unbound constant is not equal to a NULL literal. ValueEquivalence.ConstantValueEquivalence.isDefinedEqual dereferenced the constant without checking that it is bound. For a value-free constant that yields null, which the null branch below then declares equal to a NULL literal and constrains with IS_NULL — a constraint no non-null binding could satisfy, leaving the plan warmed and permanently unreachable. It now returns "not equal" when the constant carries no value. This is the same class of mistake as the dedup guards below: absence of a value read as the value NULL.

Deduplication needed four separate guards. Before this PR, literalObject == null unambiguously meant SQL NULL, and the builder relies on that: literalReverseLookup is keyed by value. A value-free literal would claim the null key and become the dedup target for a literal genuinely bound to NULL, so it is not registered there; getFirstValueDuplicateMaybe therefore had to become ofNullable; getFirstDuplicateOfConstantIdMaybe returns empty for a value-free literal instead of looking up someone else's null; and deepEquals now compares the flag, so importing a function's literals cannot silently drop a real binding by mistaking it for a value-free one at the same constant id.

asMap() is now asBindings(), with a new literalOf(). A point lookup could not tell "bound to NULL" (key present, value null) from "value-free" (key absent) from "not a literal at all" (key absent), so every caller had to consult isValueFree first — a protocol nothing enforced, and getting it wrong folds a value-free constant to NULL. literalOf returns the literal, so one lookup answers all three. Expression.dereferenced uses it.

Literals.isEmpty() is removed. A non-empty literal table can now bind nothing, so "is the table empty" is the wrong question. Its only caller, QueryExecutionContext.getEvaluationContext, now asks asBindings().isEmpty() — which is also a small fix: with only value-free literals the old check would have called setConstant(constant, emptyMap), binding a constant name with nothing under it, instead of binding no constant at all.

LiteralsTest was merged into the pre-existing LiteralsTests. Two classes one letter apart, same package, same subject.

Behaviour changes

None on existing paths — a constant that is neither bound nor value-free could not exist before this PR, so every new branch is unreachable without declared types.

Bindings.get now throws MissingBindingException with the name in log info rather than interpolated into the message; nothing matches on that text. And Expression.dereferenced still folds an unknown constant id to NULL, exactly as before — that looks wrong to me, but it is unrelated to value-free planning and left for a separate change, with a comment saying so.

Tests

LiteralsTests — ten cases covering the four dedup guards, the binding/value-free/absent distinction, toString, and the serialization refusal. ExpressionTests — the dereference guard. CompilableSqlFunctionTest — a value-free literal survives the hop into a compiled function's auxiliary literals.

OfflineValueFreePlanGenerationTest — a query planned from a declared type with no value, through both PlanGenerator overloads; that a named parameter with neither a value nor a declared type is still rejected; the nullability contract from both sides, a NOT NULL declaration rejecting a null binding while a nullable one accepts it; and that a NOT NULL declaration yields the same constraint as a bound non-null value while a nullable one does not. The constant ids there are read out of the constraint rather than assumed, since they follow token positions.

ConstantValueEquivalenceTest — new, four cases: a bound constant equals a matching literal, a constant bound to NULL equals a NULL literal, and a value-free constant equals neither — checked both with other constants bound (the shape a query mixing literals with a value-free parameter produces) and with none bound at all.

Stack

  1. Add a typed named parameter signature to CREATE STORED QUERY — declares each parameter's name, type and nullability, and persists them alongside the query text.
  2. {this PR} Plan a named parameter from its declared type without a value — lets a query be planned when a parameter has a type but no value, and the declared type alone says which bindings the plan serves.
  3. Add PREPARE FOR, required, with every parameter in every case — the author enumerates the NULL, NOT NULL, TRUE and FALSE combinations that deserve a plan, and every signature parameter is pinned in each one.
  4. Warm one plan per prepared case — builds each plan at startup, resolving the declared types against the template, and reports when a plan is reused.

@sergei-pustovykh
sergei-pustovykh changed the base branch from main to apple/sergei-pustovykh/stored-query/signature September 1, 2026 13:45
@sergei-pustovykh sergei-pustovykh added the enhancement New feature or request label Sep 1, 2026
@sergei-pustovykh
sergei-pustovykh force-pushed the apple/sergei-pustovykh/stored-query/value-free-planning branch from f1dbae4 to eb47d39 Compare September 2, 2026 11:33
@sergei-pustovykh
sergei-pustovykh force-pushed the apple/sergei-pustovykh/stored-query/value-free-planning branch from eb47d39 to c65f048 Compare September 2, 2026 13:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant