Skip to content

Retain SQL invocation information through binding and optimization - #25720

Open
kryonix wants to merge 9 commits into
duckdb:v2.0-cyanopterafrom
kryonix:sql-reconstruction-metadata
Open

kryonix wants to merge 9 commits into
duckdb:v2.0-cyanopterafrom
kryonix:sql-reconstruction-metadata

Conversation

@kryonix

@kryonix kryonix commented Sep 15, 2026

Copy link
Copy Markdown
Member

This PR is stacked onto #25733.

The logical plan needs to retain enough information to reconstruct its SQL semantics. Binding and optimization currently consume some of that information: a catalog-qualified call becomes a specialized function implementation, an argument moves into bind data, or an OFFSET is partly satisfied by row-group pruning. Recovering the original invocation later by recognizing the resulting operator shape is fragile.

I retain that information at the point where it is known and carry it through the copies, serialization and rewrites that maintain the plan. This builds on the function catalog qualification work. Function identity includes the catalog and schema needed to resolve the call again, while retained invocation data describes arguments that binding has specialized or absorbed. Examples include the list sort key, struct_insert argument names and the name observed by alias.

The optimizer annotations have the same purpose. Original window RANGE inputs and the unpruned LIMIT/TopN offset preserve user-visible boundaries after optimization rewrites them. Compression-origin annotations distinguish representation-only expressions created by compressed materialization from ordinary user expressions. File predicates consumed by pruning remain available so SQL reconstruction does not lose a filter merely because it has already restricted the scan's file set.

Secure views need both their source identity and a stable correspondence between visible columns and retained source expressions. Caller predicates pushed through the secure-view boundary are retained over those source positions before child bindings are rewritten. The native pushdown and barrier behavior remains in place. The SQL consumer can then refer to the named view and its caller predicates without exposing the view's private child plan. Missing correspondence remains explicit instead of being reconstructed from display strings.

These fields describe SQL semantics and provenance. They do not serialize a frozen catalog, inferred file list or runtime observation. A reconstructed invocation can consult those inputs again when it is rebound. Added optional serialization properties have defaults, and copies retain the annotations needed by their consumers.


private:
//! The aggregate functions installed when the catalog entry was created
vector<shared_ptr<const AggregateFunction>> registered_functions;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why is this necessary when we have the AggregateFunctionSet already?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I use this to distinguish the definitions installed when the catalog entry was created from later replacements. AggregateFunctionSet::ApplyToFunctions() replaces each definition with a modified copy, so the live set no longer retains that identity. The snapshot shares the original definitions rather than copying the function objects. This, too, is relevant in the follow up when validating PIVOT's internal list aggregate, where recognizing the name and signature alone is insufficient.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I dont think we ever add functions or call ApplyToFunctions() at "runtime", its just during registration that the function set actually gets mutated, so I still don't see why this is necessary?
Functions identity should (is) be by value anyway, so even if the functions are replaced, they should still compare properly.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Right. I was treating replacement of the shared pointer as a loss of function identity, which is stronger than the contract we need. The snapshot was defending against manually modified definitions rather than a normal execution path. I should be able to remove it and use the existing function identity/comparison mechanisms where the exporter needs to validate a function.

public:
AggregateFunctionCatalogEntry(Catalog &catalog, SchemaCatalogEntry &schema, CreateAggregateFunctionInfo &info);

bool IsRegisteredFunction(const shared_ptr<const AggregateFunction> &function) const;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is this actually used anywhere?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not in this preparatory PR. The follow-up uses the aggregate version to check that PIVOT's internal list aggregate still resolves to an original registered definition before reconstructing it. The scalar equivalent currently has no caller. I could move it to the next PR.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could you elaborate on the distinction between a "original registered" function and one that is not? In my mind any function that is part of a function-set in the catalog is by definition a registered function.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

By original I meant the objects present when the catalog entry was constructed. That was an implementation distinction, not a meaningful registration distinction. Functions subsequently present in the catalog set are registered too. IsRegisteredFunction is therefore misleading, and I will remove both the method and the snapshot.

class BoundFunctionExpression;

struct AliasBindData final : public FunctionData {
explicit AliasBindData(Identifier alias_p) : FunctionData(InternalKind::ALIAS), alias(std::move(alias_p)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't really see why FunctionData has to have a InternalKind?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Mh yeah, fair point. I introduced this as a defensive check for the SQL exporter, but it adds a parallel type system to FunctionData. I will remove it and keep bind-data interpretation in function-owned helpers, relying on the existing binder/deserializer contract.

Comment on lines +23 to +36
template <class FUNC>
static void SerializeSchemaPath(Serializer &, const FUNC &) {
}

static void SerializeSchemaPath(Serializer &serializer, const TableFunction &function) {
auto path = function.GetQualifiedName().Path();
if (path.size() > 3) {
path.pop_back();
} else {
path.clear();
}
serializer.WritePropertyWithDefault(507, "schema_path", path, vector<Identifier>());
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We should just change functions to have a QualifiedName and serialize the qualified name for all functions types - e.g. replace the:

		serializer.WriteProperty(500, "name", function.GetName());
	    ...
		serializer.WritePropertyWithDefault<Identifier>(505, "catalog_name", function.GetCatalogName(), Identifier());
		serializer.WritePropertyWithDefault<Identifier>(506, "schema_name", function.GetSchemaName(), Identifier());

With a

        ...
	    serializer.WriteProperty(507, "qname", function.GetQualifiedName()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good call! A single QualifiedName across function types is cleaner than adding a separate schema-path property for table functions. I will consolidate the representation and serialization, retaining the legacy fields when targeting older versions and reconstructing the qualified name when reading older serialized plans.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Awesome!

typedef string (*function_to_string_t)(FunctionToStringInput &input);

//! Get the SQL argument names of a bound scalar function
typedef vector<Identifier> (*scalar_function_argument_names_t)(const BoundScalarFunction &function);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I dont think this is necessary either - named arguments are reordered into positional arguments during binding. The only functions where the argument names matter will store it into their bind data anyway.

If we really want to have the argument names saved so that we can recreate the SQL, we should just do that - store them separately next to the argument expressions themselves. This is what we do in the parsed FunctionExpression, and we could do something similar here too. (maybe not introduce a BoundFunctionArgument, but just have separate:

class BoundFunctionExpression { 
  BoundScalarFunction function;

  vector<unique_ptr<Expression>> argument_exprs;
  vector<Identifier> argument_names;
}

But again, the fundamental problem is that we have functions that alter their input expressions at bind-time. I think that is the real problem we will have to solve before we can go further with this sql-reexport without creating a million special cases and introducing a ton of complexity.

@Maxxen Maxxen Sep 15, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Ok, keeping the argument vectors in sync across optimizations may be difficult as well, depending on what kind of optimizations we do, but at that point, why not just have a typedef unique_ptr<ParsedExpression> scalar_function_unbind_t(const BoundScalarFunction &function) that just gives you the ParsedExpression immediately?, or even a string scalar_function_get_sql_t(const BoundScalarFunction &function)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yes, this fits the implemented follow-up better than maintaining parallel argument vectors. The exporter already reconstructs children using the current column-binding context, so it could pass those parsed children, the bound function and its bind data to an unbind callback. The function could then construct its own ParsedExpression, while qualification checks and result-type preservation remain in the exporter. I prefer that over returning SQL text, so composition and quoting continue through the existing AST and printer.

unique_ptr<Expression> end_expr;

//! Literal SQL offsets before endpoint arithmetic; these are not execution children.
unique_ptr<Expression> sql_range_start;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

So these should be ParsedExpressions then?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yes. These are retained SQL offsets, not execution expressions, so ParsedExpression is a better representation.

!TypeVisitor::Contains(type, [](const LogicalType &child) { return !IsSQLExportType(child.id()); });
}

inline bool SQLTypesMatch(const LogicalType &left, const LogicalType &right) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Doesn't the LogicalType::operator== already handle this?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not for collations: StringTypeInfo::EqualsInternal() explicitly ignores them. VARCHAR and VARCHAR COLLATE nocase therefore compare equal, including when nested inside lists or structs. This helper first checks ordinary type equality, then compares the nested string collations. The exporter needs that stronger comparison to avoid treating expressions with different SQL comparison semantics as interchangeable.

"name": "value",
"type": "Value"
"type": "Value",
"serialize_property": "value.WithType(return_type)"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What triggered to make this necessary?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

A constant such as 'A' COLLATE nocase exposes the problem. The COLLATE binder updates the expression's return type, but its stored Value still has the original uncollated type. Previously, serialization wrote only that value, and deserialization constructed the constant's return type from it, losing the collation.

Comment thread src/optimizer/expression_rewriter.cpp Outdated
Comment on lines +37 to +38
// Type equality ignores annotations such as collation.
result->SetReturnType(std::move(return_type));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This seems like a bigger (separate) problem. Can we open an issue?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed. I will try to extract the annotation-preservation issue into a separate PR with the reduced reproducers and a focused fix. That keeps this PR about retaining reconstruction metadata rather than mixing in broader optimizer correctness changes.

Comment thread src/optimizer/statistics_propagator.cpp Outdated
Comment on lines +166 to +169
if (expr->GetReturnType() == return_type) {
// Replacing an expression must preserve annotations such as collation.
expr->SetReturnType(std::move(return_type));
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Again, this seems like a bandaid for a bigger problem.

@kryonix kryonix Sep 15, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Same problem, yes.

auto &context = deserializer.Get<ClientContext &>();
return BoundCastExpression::AddCastToType(context, std::move(result), return_type);
} else {
result->SetReturnType(std::move(return_type));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same here

Comment on lines +184 to +185
serializer.WritePropertyWithDefault(204, "compression_origin", compression_origin,
CompressedMaterializationOrigin::NONE);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This seems like it should not be something the FunctionSerializer should be concerned about

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is expression-level optimizer metadata, rather than function identity or bind data. The property is written by BoundFunctionExpression::Serialize, outside FunctionSerializer. It needs to survive serialization because LogicalOperator::Copy() uses that path. The follow-up uses it to distinguish compression-generated expressions from ordinary calls, especially casts. Would you prefer this to live in a more general expression-annotation mechanism?

Comment on lines 168 to 176
if (!serializer.ShouldSerialize(StorageVersion::V2_0_0) && function.HasLegacySerializeCallback()) {
// serialize legacy expression for backwards compatibility
FunctionToStringInput input(function, bind_info.get(), children);
auto legacy_expr = function.GetLegacySerializeCallback()(input);
legacy_expr->SetReturnType(return_type);
legacy_expr->SetAlias(alias);
legacy_expr->SetQueryLocation(query_location);
legacy_expr->Serialize(serializer);
return;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AFAIK, we never serialize plans back to an older version anyway.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

You're right about the normal plan-copy path: it explicitly uses the latest format. I added the fallback after testing an older serialization target, but the callers I found that select older targets are verification paths, not a supported cross-version plan-export workflow. I will remove it.

Comment on lines +70 to +71
//! Serialize without bind data for legacy readers that rebind the function.
static unique_ptr<Expression> SerializeAsLegacyRebind(FunctionToStringInput &input);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This seems backwards to me legacy readers do not always rebind the function, in fact most of serialization headaches come from those that don't. If anything we should change how we serialize so that we always rebind.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yeah, the comment is too broad. This helper forces the no-bind-data path for these particular functions. It does not describe legacy deserialization generally. I will remove it with the older-target fallback.

Always rebinding would be a cleaner direction, but we need to retain the complete binding inputs first. For example, list sorting resolves ordering and collation during binding, so rebinding against changed session defaults can alter the result. That ties into the invocation-preservation work discussed above.

Comment on lines +159 to +164
vector<unique_ptr<Expression>> children;
for (auto &child : input.children) {
children.push_back(child->Copy());
}
auto bind_data = input.bind_data ? input.bind_data->Copy() : nullptr;
return make_uniq<BoundFunctionExpression>(std::move(function), std::move(children), std::move(bind_data));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Doing all these copies of child expressions, which in turn can also be "SerializeAsLegacyRebind`'d will have quadratic complexity, which is problematic for deep expression trees right?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch. I will revisit this.

Comment on lines +224 to +225
auto compression_origin = deserializer.ReadPropertyWithExplicitDefault<CompressedMaterializationOrigin>(
204, "compression_origin", CompressedMaterializationOrigin::NONE);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Again, id like this to leak into the general expression tree given that it's a relatively self-contained and specialized optimizer pass.

Comment on lines +205 to +206
new_window->sql_range_start = sql_range_start ? sql_range_start->Copy() : nullptr;
new_window->sql_range_end = sql_range_end ? sql_range_end->Copy() : nullptr;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Maybe this is a nitpick, but it does seem like we would like to avoid copying here. Start and end expressions are probably not that complex - but maybe Its easier if we just preserve the parsed expressions during binding?

Comment on lines +335 to +337
deserializer.ReadPropertyWithExplicitDefault(215, "sql_range_start", result->sql_range_start,
unique_ptr<Expression>());
deserializer.ReadPropertyWithExplicitDefault(216, "sql_range_end", result->sql_range_end, unique_ptr<Expression>());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Like argument expressions, if we just serialize the "original", we can re-derive the one we use for execution during deserialization (by rebinding) instead of serializing both?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

That would avoid serializing both representations. We would need to factor the RANGE binding logic so deserialization can rebuild the endpoint arithmetic and ordering coercions from the retained frame inputs. One constraint from the implemented follow-up is that optimizer changes must remain authoritative: serializing the original syntax must not silently undo a changed endpoint. I will check that contract as part of the refactor rather than simply replacing the serialized execution expressions with the original AST.

Comment thread src/planner/operator/logical_get.cpp Outdated
serializer.WritePropertyWithDefault<unique_ptr<RowGroupOrderOptions>>(214, "row_group_order_options",
row_group_order_options);
serializer.WritePropertyWithDefault(215, "scan_partition_indices", scan_partition_indices, vector<idx_t>());
serializer.WritePropertyWithDefault<unique_ptr<TableRef>>(216, "table_function_ref", table_function_ref);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I suppose we can store the table ref, but couldn't we also have a callback here instead, e.g. table_function_to_sql() that can be opt-in? I presume most table functions know how to represent themselves as SQL just given their bind data.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The follow-up already has a to_sql callback returning a table-reference AST. The retained TableRef supports its generic fallback, rather than being required by every callback. I will revisit whether that fallback should require explicit opt-in and whether each supported function can reconstruct from its bound inputs instead. Bind data alone won't necessarily retain the original argument expressions.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

ah cool, I suppose you only have to actually store it if the to_sql is not set then - or always store it, I guess it doesn't really matter that much.

auto limit_val = deserializer.ReadProperty<BoundLimitNode>(200, "limit_val");
auto offset_val = deserializer.ReadProperty<BoundLimitNode>(201, "offset_val");
auto result = duckdb::unique_ptr<LogicalLimit>(new LogicalLimit(std::move(limit_val), std::move(offset_val)));
deserializer.ReadPropertyWithExplicitDefault<optional_idx>(202, "unpruned_offset", result->unpruned_offset, optional_idx());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

In general, do these offsets have to be constants or can they be expressions? Like, what is the desired semantics if I do e.g.

SET VARIABLE foobar = 2
SELECT * FROM tbl LIMIT $foobar

Do we re-export the SQL as bound (with foobar resolved to 2), or unbound?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

LIMIT and OFFSET can be expressions. This field only records a constant offset that row-group pruning has partly consumed. Expression-valued limits and offsets remain in BoundLimitNode.

For a value already resolved to 2 in the bound plan, the intended export is 2, rather than recovering the original parameter spelling.

@Teggy Teggy Sep 15, 2026

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.

Sorry to jump in here, but would this be the time to also consider the use of expressions in the SAMPLE clause? Currently its argument is forced to be a constant. I have had countless cases where I wanted to write

SET VARIABLE n = 100
FROM t SAMPLE $n;

but could not.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Hi @Teggy! Yes, sounds good to me, I'd like that too 👍🏻 I think it deserves a separate PR, though. SAMPLE currently rejects non-constant arguments during parsing, so we'd need to carry the expression into binding and evaluate and validate it there. It's related to what I'm doing here, but it adds functionality beyond retaining the information needed for SQL reconstruction (this PR really is the basis for a proper EXPLAIN (SQL), but 🤫).

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.

Thanks, reasonable. I move that request to its own PR. 👍🏻

P.S. EXPLAIN (SQL): want! 😉

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Soon™ 😉

@kryonix
kryonix force-pushed the sql-reconstruction-metadata branch from 6769f51 to 8f5e160 Compare September 15, 2026 14:00
@kryonix
kryonix force-pushed the sql-reconstruction-metadata branch from 8f5e160 to 0bbd021 Compare September 15, 2026 14:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants