Skip to content

Stored query signatures - value-free typed named parameters - #4438

Draft
sergei-pustovykh wants to merge 25 commits into
FoundationDB:mainfrom
sergei-pustovykh:stored-query-signature
Draft

Stored query signatures - value-free typed named parameters#4438
sergei-pustovykh wants to merge 25 commits into
FoundationDB:mainfrom
sergei-pustovykh:stored-query-signature

Conversation

@sergei-pustovykh

@sergei-pustovykh sergei-pustovykh commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Adds a typed named-parameter signature to CREATE STORED QUERY. Parameters are declared once on the query and referenced as bare identifiers wherever the body or its DECLAREd functions expect a value. At warm-up they are planned value-free from their declared types, and at runtime a client reuses the warmed plan by re-issuing the same SQL with those parameters bound by name.

CREATE STORED QUERY sq(param_a bigint, param_b bigint)
  DECLARE
    FUNCTION f1(IN p bigint) AS (SELECT * FROM t1 WHERE (p IS NULL OR col1 = p) AND col2 = param_a)
AS
  SELECT id FROM f1(param_b)

Previously, warming a stored query meant writing concrete literals in its body (e.g. WHERE col1 = 10). But for a genuinely parameterized query the specific values are arbitrary — you'd have to invent placeholder literals purely to warm a plan, which don't reflect any real intent. A signature lets you declare the parameters by type (or NULL) instead, warming a reusable plan without naming any values.

What it does

Signature. CREATE STORED QUERY name(param type, ...) — a parameter may be a primitive type, or the keyword NULL to declare it exactly null (a null-specialized plan, see below). BOOLEAN is excluded, and so are ARRAY and composite types.

Bare-identifier references. Signature parameters are used without a ? prefix, in the outer query and inside declared functions (captured as free variables). At CREATE time each reference in a value position is rewritten to a named parameter ?param in the persisted texts, so warm-up parses exactly what a runtime client re-issues. Because only value positions are rewritten, a parameter may share a column's name — t.param stays a column reference and AS param stays an alias.

Value-free warm-up. Each parameter is planned as a typed ConstantObjectValue with no value, guarded by an OfType + IS_NOT_NULL plan constraint. Runtime reuse: the client re-declares the temp function(s) and runs the SELECT binding ?param by name; the bound value satisfies the constraint and hits the warmed plan (matched by canonical SQL + the temp-routine fingerprint, exactly as literal stored queries already reuse). The runtime statement must match the persisted form token for token, apart from keyword case and whitespace.

NULL-specialized plans. A parameter declared NULL is exactly null, so warm-up binds it to null — the same thing setNull does at runtime — which yields an IS_NULL constraint and lets the planner optimize the null case (e.g. folding param IS NULL). The value case and the null case are two separate stored queries that coexist as distinct plans under different cache keys; a value binding vs. setNull selects the matching one.

How it works

Value-free ConstantObjectValue core — a COV that carries a declared type but no value. Bindings.MissingBindingException plus a guard in QueryPlanConstraint.compileTimeEval make value-free plan-cache lookup a safe non-match.

Value-free literals — a value-free constant is an OrderedLiteral that reserves a constant id and declares a type but has no value, and is filtered out of Literals.asMap(); that filter is what leaves the constant id unbound. Because they live in the literal table, they cross from a declared function body into the enclosing query's plan constraint by the same path as ordinary literals.

Parse-tree-driven rewrite — a reference is recognised structurally (a fullColumnName used as an expressionAtom, with a single unqualified uid), not by matching token text, so qualified references, aliases, table names and function names are excluded by construction. The rewritten text is re-parsed before being persisted, so unparseable output cannot reach metadata.

Signature metadata — persisted on StoredQuery/PStoredQuery as repeated PStoredQueryParameter entries of (name, type code); warm-up threads the declared types into planning via PreparedParams.

Scope / limitations

  • Signature parameter types are primitive, or NULL. Non-primitive types are out of scope. Note the type is carried as a Type.TypeCode name rather than a serialized PType, because fdb-relational-api does not depend on fdb-record-layer-core and both representations of a stored query must hold the same data.
  • BOOLEAN is rejected. A boolean's value can change the plan substantially — predicates fold away, branches disappear, index choices differ — so it belongs in the body as a literal, where the planner specializes on it and the resulting IS_TRUE/IS_FALSE plan constraint keeps true and false as distinct cached plans. Declaring it as a value-free parameter would discard exactly that: the parameter could only carry "is not null", and one unspecialized plan would serve both values.
  • Value-free parameters form no value-equality constraints, since there is no value to compare.
  • Parameter matching is case-sensitive, consistent with named-parameter semantics. Parameter names must be simple unquoted identifiers, and may not name the same identifier as a declared function's own parameter — in the body the two would be indistinguishable. (That collision check compares identifiers, so it is case-insensitive unless the connection is case-sensitive.)
  • Applies to table functions (AS SELECT ...); macro-function bodies do not participate.

@sergei-pustovykh sergei-pustovykh added the enhancement New feature or request label Aug 11, 2026
return namedParams.get(name);
}

public boolean hasNamedParamValue(@Nonnull String name) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you invoke this function to perform the assertion at line 93?

* (value-bound) execution.
*/
@Nonnull
private final Map<String, Type> declaredTypes;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There seems to be a design flaw here, it binds a declared type to named parameters only leaving no room to extend support to unnamed parameters. It also uses Cascades Type however I think it should instead use our own DataType (and continue to use Type under the hood for SerDe).

You could instead introduce a PreparedParam record, that has an identifier (position or name), value, and a nullable DataType. This change PreparedParameters such that it could use a collection of PreparedParam instead.

* the parameter is value-bound or unknown.
*/
@Nonnull
public Optional<Type> declaredTypeMaybe(@Nonnull String name) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is a bit strange, because if the parameter is value-bound, it has a, rather implicitly, declared type as well.

// Using unmodifiableMap because it allows null values, which are valid here
// and represent either null constants or null prepared parameters in queries.
// Value-free literals are excluded: a present key with a null value means "bound to NULL", whereas an absent
// key means "no value at all", which is what leaves the constant id unbound in the evaluation context.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The added comments are overly-intensive about the differentiation between value-free literals and value-bound literals bound to null, can you please adjust the comments in this file and make them more about the code flow itself?

}
}
literalReverseLookup.putIfAbsent(orderedLiteral.getLiteralObject(), orderedLiteral);
if (!orderedLiteral.isValueFree()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a positive check instead? if (orderedLiteral.isValueBound()

if (param instanceof Array || param instanceof Struct) {
allowLiteralAddition = true;
}
if (!preparedStatementParameters.hasNamedParamValue(parameterName)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add unit tests for this in AstNormalizerTests?

;

storedQueryParameter
: parameterName=uid (parameterType=functionColumnType | NULL_LITERAL)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The NULL literal is a bit awkward honestly.

@Nonnull final Set<String> declaredNames,
@Nonnull final List<RelationalParser.UidContext> references) {
if (tree instanceof RelationalParser.FullColumnNameExpressionAtomContext atom) {
final var uids = atom.fullColumnName().fullId().uid();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should probably use IdentifierVisitor#visitUid

final var storedQueryTexts = tempFunctionTexts.build();
// The rewritten text is what gets persisted and re-parsed at warm-up, so prove now that it parses.
// This keeps a rewrite defect from becoming a startup-time failure that the author never sees.
validateRewrittenText(name, queryString, storedQueryTexts);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validateRewrittenText should probably be a test instead of having it running in production logic.

// stored query its warm-up.
final PreparedParams warmupParams;
try {
warmupParams = warmupParamsFor(storedQuery);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need to extract the parameters here? in other words, what's the use of the warmupParams when normalizing / generating the plan? It looks like the stored query itself is self-describing (the value-free parameters appear as "literals" in it) so it looks like we don't need to do this extra round of extraction, right?

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.

2 participants