Plan a named parameter from its declared type without a value - #4542
Draft
sergei-pustovykh wants to merge 5 commits into
Draft
Conversation
sergei-pustovykh
changed the base branch from
main
to
apple/sergei-pustovykh/stored-query/signature
September 1, 2026 13:45
sergei-pustovykh
force-pushed
the
apple/sergei-pustovykh/stored-query/value-free-planning
branch
from
September 2, 2026 11:33
f1dbae4 to
eb47d39
Compare
sergei-pustovykh
force-pushed
the
apple/sergei-pustovykh/stored-query/value-free-planning
branch
from
September 2, 2026 13:36
eb47d39 to
c65f048
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 ap IS NOT NULLcase passes a non-nullableBIGINT. 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.OrderedLiteralgains avalueFreeflag, a factory for it, and a refusal to serialize one (the wire format encodes an absent value asNULL, so serializing would silently convert it)LiteralsgainsaddValueFreeLiteral,isValueFreeandliteralOf. 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 contextPreparedParamscarriesdeclaredTypes, withwithDeclaredTypesanddeclaredTypeMaybePlanGeneratorgains two overloads takingPreparedParams; the existing ones delegate withempty()OfType(declared type), plusIS_NOT_NULLwhen that type is non-nullable, which is exactly what a plan built from a concrete non-null value carriesBindings.MissingBindingExceptionplus a catch inQueryPlanConstraint.satisfies: a cache lookup whose context does not bind a constant the plan references is a non-match, not an exceptionTwo places have to agree, and both are required:
AstNormalizercanonicalizes the parameter to?namewhile pulling no value and adding nothing to its literal table, andMutablePlanGenerationContext.processNamedPreparedParamcreates 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.evalanswersexpectedType.isNullable()when the bound value is null, soOfType(T)by itself rejects a null binding for aNOT NULLdeclaration and accepts one for a nullable declaration.IS_NOT_NULLis 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 (PlanGeneratorstores every plan underPhysicalPlanEquivalence.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.fromObjectgives a non-null value a non-nullable type, so theOfTypehalves already agree; this closes the only remaining gap. The nullability read here is the samecov.getResultType()that feedsOfTypeValue.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, andIS_NOT_NULLwould contradict that.nonNullableDeclaredTypeConstrainsAsABoundValueDoesandnullableDeclaredTypeConstrainsMoreLooselyThanABoundValuepin both halves.An unbound constant is not equal to a
NULLliteral.ValueEquivalence.ConstantValueEquivalence.isDefinedEqualdereferenced the constant without checking that it is bound. For a value-free constant that yieldsnull, which the null branch below then declares equal to aNULLliteral and constrains withIS_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 valueNULL.Deduplication needed four separate guards. Before this PR,
literalObject == nullunambiguously meant SQLNULL, and the builder relies on that:literalReverseLookupis keyed by value. A value-free literal would claim thenullkey and become the dedup target for a literal genuinely bound toNULL, so it is not registered there;getFirstValueDuplicateMaybetherefore had to becomeofNullable;getFirstDuplicateOfConstantIdMaybereturns empty for a value-free literal instead of looking up someone else'snull; anddeepEqualsnow 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 nowasBindings(), with a newliteralOf(). A point lookup could not tell "bound toNULL" (key present, value null) from "value-free" (key absent) from "not a literal at all" (key absent), so every caller had to consultisValueFreefirst — a protocol nothing enforced, and getting it wrong folds a value-free constant toNULL.literalOfreturns the literal, so one lookup answers all three.Expression.dereferenceduses 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 asksasBindings().isEmpty()— which is also a small fix: with only value-free literals the old check would have calledsetConstant(constant, emptyMap), binding a constant name with nothing under it, instead of binding no constant at all.LiteralsTestwas merged into the pre-existingLiteralsTests. 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.getnow throwsMissingBindingExceptionwith the name in log info rather than interpolated into the message; nothing matches on that text. AndExpression.dereferencedstill folds an unknown constant id toNULL, 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 bothPlanGeneratoroverloads; that a named parameter with neither a value nor a declared type is still rejected; the nullability contract from both sides, aNOT NULLdeclaration rejecting a null binding while a nullable one accepts it; and that aNOT NULLdeclaration 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 toNULLequals aNULLliteral, 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
CREATE STORED QUERY— declares each parameter's name, type and nullability, and persists them alongside the query text.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.