Fix SQL DML Clone correctness bugs#466
Open
snaumenko-st wants to merge 1 commit intoDataObjects-NET:masterfrom
Open
Fix SQL DML Clone correctness bugs#466snaumenko-st wants to merge 1 commit intoDataObjects-NET:masterfrom
snaumenko-st wants to merge 1 commit intoDataObjects-NET:masterfrom
Conversation
- SqlUpdate.Clone: copy Limit from t.limit (was t.where). - SqlSelect.Clone: deep-clone Limit/Offset via context. - SqlConcat/SqlRow.Clone: register in NodeMapping via GetOrAdd. - SqlJoinHint/SqlForceJoinOrderHint.Clone: propagate clone context to inner tables. - SqlInsertValuesCollection.Clone: pass ctx to row clones for cross-row dedup.
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.
TL;DR;
1.
SqlUpdate.Clone— Limit was being copied fromWhereFile:
Orm/Xtensive.Orm/Sql/Dml/Statements/SqlUpdate.csBefore
What was wrong
A copy/paste mistake: when
t.limitwas non-null the clone copiedt.whereintoLimit. Two consequences:SqlUpdatehad itsLimitpointing at a clone of the WHERE expression — which would either fail validation (SqlValidator.EnsureIsLimitOffsetArgumentis invoked from theLimitsetter) or produce a logically incorrect statement when the WHERE happens to be a numeric expression.t.wherewasnullbutt.limitwas not, this dereferenced null.wheretwice.Fix
Use
t.limit.Clone(c), mirroring how every other field is cloned.2.
SqlSelect.Clone—LimitandOffsetwere shared by referenceFile:
Orm/Xtensive.Orm/Sql/Dml/Statements/SqlSelect.csBefore
What was wrong
Every other expression-typed field of
SqlSelectis deep-cloned through the context (t.where.Clone(c),t.having.Clone(c), etc.).LimitandOffsetwere the onlySqlExpression-typed members assigned directly. That breaks the deep-clone invariant ofClone(SqlNodeCloneContext): mutating the cloned tree'sLimit/Offsetwould mutate the source tree's, and vice-versa.This is also subtle —
Limitis often a literal likeSqlDml.Literal(10), so for many queries no one ever notices. But whenLimit/Offsetis a parameter binding, computed expression, or anything that gets re-walked or post-processed, the alias becomes a real bug (and breaksSqlNodeCloneContext'sNodeMappinginvariant — that mapping should let visitors find the cloned counterpart of any source node).Fix
Clone via the active context, preserving
nullsemantics:The setters validate via
SqlValidator.EnsureIsLimitOffsetArgument, which still works becauseClonereturns the same shape of expression.3.
SqlConcat.Clone— never registered itself inNodeMappingFile:
Orm/Xtensive.Orm/Sql/Dml/Expressions/SqlConcat.csBefore
What was wrong
The method checks
NodeMappingfor an existing clone, but never inserts the new clone. TheTryGetValuebranch was effectively dead code — unless some external caller pre-registered thisSqlConcat, it would always miss. Net effect:SqlConcatreached more than once during a single clone walk was rebuilt from scratch (more allocations, deeper recursion).SqlExpressionListsubclass that usescontext.GetOrAdd.Fix
Route through the standard helper, which both checks the mapping and records the new clone:
Two side benefits beyond the bug fix:
staticlambda +GetOrAdd<T>→ no captured-closure allocation.foroverIReadOnlyList<SqlExpression>avoids theIEnumerator<SqlExpression>heap allocation thatforeachwould have produced (sinceexpressionsis typed as the interface, not a concreteList<>).4.
SqlRow.Clone— sameTryGet-without-register bug, plus LINQ allocationFile:
Orm/Xtensive.Orm/Sql/Dml/Expressions/SqlRow.csBefore
What was wrong
Same dead-code problem as
SqlConcat: theTryGet(this)lookup will always returnnullbecause nothing inserts the newSqlRowintoNodeMapping. So:TryGetruns every time uselessly — and worse,TryGet<T>allocates internally if the cast returns null (it doesn't actually, but the boxed dictionary lookup is wasted).SqlRowre-clones from scratch.expressions.Select(...).ToArray()allocates aSelect-iterator and aFunc<SqlExpression, SqlExpression>(closure overcontext) per clone, on top of the result array.Fix
Same pattern as
SqlConcat, plus drops the now-unusedusing System.Linq;:5.
SqlJoinHint.Clone— dropped the clone contextFile:
Orm/Xtensive.Orm/Sql/Dml/Hints/SqlJoinHint.csBefore
What was wrong
t.Table.Clone()(noc) is the parameterless overload onSqlNodethat creates a freshSqlNodeCloneContextinternally. That means:NodeMappingdoesn't see this fresh table, so any subsequent reference to the sameSqlTableduring the outer clone will produce a second, divergent clone — i.e. the cloned join hint references a differentSqlTableinstance than the join in the FROM/JOIN tree it's annotating.In query optimizer hint resolution that lookup-by-reference is exactly what the translator relies on, so this was a real correctness bug for any cloned statement that carried a
SqlJoinHint.Fix
Pass the surrounding context through:
Now the table will be deduplicated against any other reference to the same source table within the same outer clone.
6.
SqlForceJoinOrderHint.Clone— same context-drop, plusSelect(...).ToArray()File:
Orm/Xtensive.Orm/Sql/Dml/Hints/SqlForceJoinOrderHint.csBefore
What was wrong
Same fundamental issue as
SqlJoinHint, multiplied by the number of tables in the order hint:table.Clone()(noc) detaches that table from the outer mapping.t.Tables?.Select(...).ToArray()adds the standard LINQ allocations: aSelectiterator and aFuncclosure.?.short-circuit was preserved by emitting the parameterlessSqlForceJoinOrderHint()ctor whenTablesis null.Fix
Loop with the shared context, pre-sized array, no LINQ; drop the
using System.Linq;:Now every table inside the order hint dedupes against the FROM-clause tables in the cloned statement.
7.
SqlInsertValuesCollection.Clone— fresh context per rowFile:
Orm/Xtensive.Orm/Sql/Dml/Collections/SqlInsertValuesCollection.csBefore
What was wrong
The columns list was cloned correctly through the supplied
ctx(it even readsctx.NodeMapping[oldColumn]directly, depending on the outer statement having registered the columns). But each row was cloned via the parameterlessoldRow.Clone(), which:SqlNodeCloneContextper row. The struct's backingDictionary<SqlNode, SqlNode>is allocated for every row in the INSERT. For multi-row INSERTs this isNextra dictionaries.ISqlLValuereferenced both bycolumnsand inside a row's expressions resolves to the same cloned node. Before, it would be split.SqlRow.Clone(...)already returnsSqlRow; the(SqlRow)cast was for the parameterlessClone()which returnsSqlNode.Fix
Pass
ctxthrough, drop the cast:Combined with the
SqlRow.Clonefix above (which now actually registers inNodeMapping), this means: sharedSqlRowinstances across the INSERT statement now clone exactly once, and shared sub-expressions inside those rows now also dedupe within the same outer clone.Cross-cutting themes
A few invariants the original code violated that the fixes restore:
Clone(SqlNodeCloneContext)must always traverse with the supplied context. The parameterlessClone()is for "I want a brand-new isolated copy of this subtree" — never for a sub-call inside anotherClone(ctx). Both hint classes andSqlInsertValuesCollectionwere violating this.Clone(SqlNodeCloneContext)must register the produced clone inNodeMappingbefore returning. Otherwise the dedup test at the start of every otherCloneis meaningless and you can produce divergent clones of the same source node. The canonical pattern iscontext.GetOrAdd(this, static (t, c) => …).SqlConcatandSqlRowwere violating this.All
SqlExpression-typed fields in a statement should be deep-cloned, not assigned by reference.SqlSelect.Limit/Offsetwere the only fields breaking this rule.The Tier-1 perf wins are incidental. Static lambdas avoid closure allocations; pre-sized arrays + indexed
foroverIReadOnlyList<>avoidSelect/ToArrayand enumerator boxing. These are small per call, butCloneis on the cached-corrector path so they multiply.Made-with the help of AI tooling