Skip to content

.copy() aliases custom_join state: extending on()/cjoin() on a copy mutates the original (build-time sibling of #43) #112

Description

@PingoLee

Summary

.copy() does not fully isolate a query's custom_join state. deepcopy(::SQLObjectQuery)
shallow-copies custom_join (copy(obj.custom_join)), so a query and its .copy() share the
inner per-join Dict objects by reference
. Adding another on() / cjoin() on the same join
path
to the copy then rewrites the original's join filters in place.

This is the custom_join sibling of the CTE aliasing fixed in #43. #43 fixed the CTE half (ctes
now deep-copied via _copy_ctes, dropping the transient "model"); custom_join was intentionally
left shallow because it is not mutated during execution, so it did not affect #43's
execution-focused acceptance criteria. This issue tracks closing the remaining build-time gap.

Verified evidence

deepcopy(::SQLObjectQuery) (src/querybuilder/types.jl) copies the outer dict but shares the
values:

custom_join=copy(obj.custom_join)  # shallow: inner per-join Dicts shared by reference

on() mutates the shared inner dict in place (src/querybuilder/ctes.jl):

existing = get(q.custom_join, join_path, Dict{String,Any}())
existing["filters"] = vcat(existing_filters, parsed_filters)  # in-place write into the shared inner dict
existing["join_type"] = join_type_normalized
q.custom_join[join_path] = existing

When join_path already exists, existing is the original's inner dict, so
existing["filters"] = … and existing["join_type"] = … overwrite the original's join
definition. (cjoin() on a duplicate path throws "Join path already exists", so the primary
trigger is a repeated/extended on() on the same path.)

Reproduction shape:

q = M.Result.objects
q.on("driverid", "driverid__nationality" => "British")   # existing ON predicate on path "driverid"

q2 = q.copy()
q2.on("driverid", "driverid__nationality" => "Brazilian") # extends the SAME path on the copy

# q (the original) now also carries the "Brazilian" predicate — its join filters were mutated
# because q.custom_join["driverid"] and q2.custom_join["driverid"] are the same object.

Why it matters

.copy() should produce an independent query. #43 established this for CTE state; this is the same
promise for custom joins. It is lower severity than #43 because the trigger is narrower — it only
surfaces at build time (defining an additional join on a copy that shares a join path), never
during plain .list() / execution — but it is the same class of shared-mutable-state footgun and is
cheap to settle before publish while breaking changes are free.

Fix direction

Give custom_join the same per-entry independent copy that ctes got in #43 — a _copy_custom_join
helper mirroring _copy_ctes:

  • fresh outer dict and a fresh inner Dict per join path;
  • copy "filters" into a new vector (on() reassigns the vector, so a shallow copy of the vector
    is sufficient — deep-copying the FilterType objects is not required for this bug);
  • share the "field" PormGField reference (it holds Model_Type → Module, which deepcopy cannot
    traverse — the same constraint that forced the CTE "query"-only deep copy);
  • carry scalar entries ("join_type") by value.

Acceptance criteria

  • q.copy() yields distinct inner custom_join dicts: q2.object.custom_join[path] !== q.object.custom_join[path].
  • Extending an existing join path via on() on a copy does not alter the original's
    custom_join filters or join_type.
  • Regression test (unit, no DB): build a query with on(), .copy() it, add another on() on
    the same path to the copy, assert the original's join definition is unchanged and the two render
    different SQL.
  • Existing cjoin + on behavior unchanged (no execution-time regression).

Notes

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingnew featurefeaturespre-publishSettle before first General-registry publishpriority:lowTech-debt, edge case, long-termtech-debtCleanup / simplification with no user-visible feature

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions