Skip to content

Commit aea6c3d

Browse files
authored
Merge pull request #458 from PingoLee/fix/452-multipath-delete-params
fix(#452): one render path for cascade deletes, and count rows from the driver
2 parents 9e12d4b + 10e7b80 commit aea6c3d

7 files changed

Lines changed: 612 additions & 50 deletions

File tree

docs/src/architecture.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -194,10 +194,17 @@ characterizations worth stating plainly.
194194
position (`nested_parameter_mark` / `detach_nested_run!`). All four such sites in the **read**
195195
builder go through it — `Exists`, a projected `Subquery`, an `__@in` subquery, and a CTE body.
196196

197-
`deletion.jl` splices subqueries into hand-built `DELETE`/`UPDATE` clauses without it, and is
198-
correct today only by coincidence: it builds a fresh collector per statement, and a lone
199-
subquery's own text order *is* `_BUCKET_ORDER`, so the flatten happens to agree. That is a
200-
narrower claim than "contained", and deliberately so.
197+
`deletion.jl` splices subqueries into hand-built `DELETE`/`UPDATE` clauses, and both splice sites
198+
now go through the same machinery — so the claim is "contained" there too. The narrowing was
199+
about *why* it held: a **lone** subquery's own text order **is** `_BUCKET_ORDER`, so the flatten
200+
happened to agree. #452 removed the lone-subquery premise — a cascade reaching one model by two
201+
paths splices **two** subqueries into one collector — without, as it turns out, breaking the
202+
result. Measured: a fragment that binds at its own top-level `:join` **does** exist (the user's
203+
root queryset), but it is never accompanied by a second fragment in the same statement, and every
204+
other fragment is a bare `filter("<fk>__@in" => parent)` with no join to contribute. An interleave
205+
needs two join-binding fragments in one statement, which the cascade graph cannot produce. So the
206+
wrap there is **insurance, not a live fix** — it lets this paragraph claim containment instead of
207+
re-deriving that reachability argument every time a fragment's shape changes.
201208

202209
!!! note "\"Async-first\" is backend-specific"
203210
For **PostgreSQL** the async path is genuine: the pool lock is released before the round-trip and

src/querybuilder/deletion.jl

Lines changed: 139 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -190,31 +190,55 @@ function delete(objct::SQLObjectHandler;
190190
end
191191

192192
# If no objects to delete, return early (unless we're just inspecting the query)
193+
#
194+
# #452: this probe deliberately stays OUTSIDE the transaction, unlike the per-path probes in
195+
# `find_related_objects!` (moved inside, below). The asymmetry is the failure mode, not the cost:
196+
# being wrong here yields a delete that found nothing, which is indistinguishable from the delete
197+
# having run an instant earlier and can never produce a PARTIAL cascade. Being wrong there deletes
198+
# the root and skips a dependent path. Keeping this one out also avoids opening a transaction —
199+
# and, on SQLite, taking the process-wide write lock — for every no-op delete.
193200
if show_query === :execute && objct |> !_exists
194201
return 0, Dict{String, Integer}()
195202
end
196203

197204
# We'll track deletion counts
198205
deleted_counter = Dict{String, Integer}()
199-
200-
# Collect related models that need special handling
201-
collector = DeletionCollector(model, settings, show_query)
202-
203-
# Add the primary objects to delete
204-
add_objects_to_collector!(collector, objct |> deepcopy, model)
205-
206-
# Build and sort the deletion graph
207-
process_collector!(collector)
208206

209207
# Definition of run_deletions (backend agnostic)
210208
results = []
211209
run_deletions = function(conn)
212-
# Process fast deletes first (objects that can be deleted directly)
213-
for (model, keys) in collector.fast_deletes
214-
res = delete_objects(connection, model, keys, show_query, deleted_counter, conn)
210+
# Plan INSIDE the transaction (#452).
211+
#
212+
# `find_related_objects!` probes every cascade path with `_exists` and DROPS the ones that come
213+
# back empty. That planning used to run before `BEGIN`, so a row inserted on a pruned path
214+
# between the probe and the DELETE was never deleted: the root went, the dependent stayed. It is
215+
# also what kept #452 itself invisible — the F1 fixture declares two CASCADE paths into
216+
# `just_a_test_deletion`, and the second was always pruned because nothing populates it.
217+
#
218+
# On SQLite this closes the window: `BEGIN IMMEDIATE` plus the process-wide write lock means no
219+
# other writer can commit while planning runs.
220+
#
221+
# On PostgreSQL it only NARROWS it, and the comment says so rather than implying otherwise:
222+
# READ COMMITTED gives every statement a fresh snapshot even inside a transaction, so a
223+
# concurrent insert between a probe and its DELETE is still possible. Closing it there needs
224+
# REPEATABLE READ / SERIALIZABLE or explicit row locks — a separate decision, not this fix.
225+
#
226+
# Two costs, both accepted deliberately: SQLite now holds the write lock across the probe
227+
# SELECTs, and a `ProtectedError` / `ModelDefinitionError` raised while planning now costs a
228+
# BEGIN + ROLLBACK instead of throwing before any transaction existed.
229+
collector = DeletionCollector(model, settings, show_query)
230+
add_objects_to_collector!(collector, objct |> deepcopy, model)
231+
process_collector!(collector)
232+
233+
# Process fast deletes first (objects that can be deleted directly).
234+
# Named `fast_model` rather than `model`: the enclosing `model` is now read inside this closure
235+
# (the collector is built here since #452), and two meanings for one name in one scope is a
236+
# reading hazard even where Julia's loop scoping makes it harmless.
237+
for (fast_model, fast_keys) in collector.fast_deletes
238+
res = delete_objects(connection, fast_model, fast_keys, show_query, deleted_counter, conn)
215239
push!(results, res)
216240
# Remove from objects to prevent double deletion
217-
delete!(collector.objects, model)
241+
delete!(collector.objects, fast_model)
218242
end
219243

220244
# Process field updates (for SET_NULL, SET_DEFAULT, etc.)
@@ -558,65 +582,129 @@ function collect_fast_deletes!(collector::DeletionCollector)
558582
end
559583
end
560584

585+
# ─────────────────────────────────────────────────────────────────────────────
586+
# Rows a statement actually removed (#452)
587+
#
588+
# Taken from the driver, not from a `SELECT COUNT(*)` issued just before the DELETE. That pre-count
589+
# was one extra round trip per table, and on PostgreSQL it was not even atomic: READ COMMITTED gives
590+
# every statement a fresh snapshot *inside* a transaction, so a row committed between the COUNT and
591+
# the DELETE was removed and not counted.
592+
#
593+
# This is `update()`'s contract rather than a new one (see execution.jl): PostgreSQL exposes the
594+
# count on the driver result; SQLite needs `changes()` on the SAME connection, which `conn`
595+
# guarantees here because every delete statement runs inside the collector's transaction on the
596+
# pinned connection. RETURNING is deliberately not used — `insert()` documents why SQLite RETURNING
597+
# is avoided (it can hang inside libsqlite3 for some table shapes).
598+
#
599+
# Only ever called under `show_query === :execute`, which is also the only state in which `conn` is
600+
# non-nothing. The guard below keeps that structural rather than documentary: on SQLite, with
601+
# `conn = nothing`, `with_transaction` leases a connection with `release_conn = false` and this call
602+
# site discards the handle, so every delete would leak a pool slot until `acquire_connection` began
603+
# timing out. A silent leak is worth one comparison to rule out.
604+
#
605+
# Two things about that guard that the obvious reading gets wrong:
606+
#
607+
# - It sits ABOVE the PostgreSQL return, so it refuses `conn = nothing` on a backend where nothing
608+
# leases and nothing can leak. Deliberate: "keep PostgreSQL and SQLite aligned" applies to
609+
# preconditions too, and a contract that holds on one backend only is the kind of divergence
610+
# that gets discovered by a bug report rather than by a test.
611+
# - It is an ASSERTION, not a precondition — it runs after the DELETE has already executed. If it
612+
# ever fired, the enclosing transaction would roll the statement back, which is the outcome we
613+
# want; but do not read it as guarding the write.
614+
# ─────────────────────────────────────────────────────────────────────────────
615+
function _affected_row_count(connection::Union{PormGPostgres, PormGSQLite}, result, conn)::Int
616+
conn === nothing && error(_emsg("PormG internal error in delete(): _affected_row_count needs the statement's connection — this should not happen; please report it."))
617+
connection isa PormGPostgres && return backend_num_affected_rows(connection, result)
618+
changes, _ = with_transaction(connection, "SELECT changes();", conn=conn)
619+
return Int((changes |> DataFrames.DataFrame)[1, 1])
620+
end
621+
561622
function delete_objects(connection::Union{PormGPostgres, PormGSQLite}, model::PormGModel, keys::Vector{Dict{Symbol, Union{String, SQLObjectHandler}}},
562623
show_query::Symbol, deleted_counter::Dict{String, Integer}, conn)
563624
@pormg_debug false
625+
isempty(keys) && error(_emsg("PormG internal error in delete(): delete_objects was called with no keys for $(model.name) — this should not happen; please report it."))
626+
564627
if size(keys, 1) == 1 && keys[1][:key] == DIRECT_DELETE_KEY_SENTINEL
565628
objct = keys[1][:objct]
566629
isempty(objct.object.filter) || throw(QueryBuildError("Delete on keyless model $(model.name) with filters is not supported; define a primary key or delete all rows explicitly"))
567630

568-
deleted_counter[model.name] = show_query === :execute ? (objct |> _count) : 0
569631
sql = "DELETE FROM $(safe_table_identifier(Models.model_table_name(model), connection))"
570632

571633
if show_query !== :execute
572634
return _show_query_result(show_query, sql, connection, model, :delete, parameters=nothing)
573635
end
574636

575-
with_transaction(connection, sql, conn=conn, params=nothing)
637+
result, _ = with_transaction(connection, sql, conn=conn, params=nothing)
638+
deleted_counter[model.name] = _affected_row_count(connection, result, conn)
576639
return deleted_counter
577640
end
578641

579-
# Execute the actual deletion SQL
642+
# Checked over EVERY entry, not just the first. Entries for one model do NOT necessarily share a
643+
# resolved key: `resolve_delete_key` falls back to the referencing FIELD name when the model has no
644+
# primary key, so a keyless child reached by two foreign keys resolves two different keys. The
645+
# sentinel means "no key at all", which no WHERE fragment can address.
646+
any(k -> k[:key] == DIRECT_DELETE_KEY_SENTINEL, keys) &&
647+
throw(QueryBuildError("Multi-path delete on keyless model $(model.name) is not supported; define a primary key"))
648+
649+
# One WHERE fragment per cascade path, ORed together — and the ONLY thing that renders into
650+
# `parameters`.
651+
#
652+
# #452: the multi-path case used to build these fragments, throw the text away, and re-render the
653+
# same subqueries through a `Qor` into this SAME collector. The statement then carried twice as
654+
# many bound values as it had markers — SQLite refuses the surplus outright
655+
# ("values should be provided for all query placeholders"), PostgreSQL orphans the leading run.
656+
# That branch also addressed every arm with `keys[1][:key]`, which is a silent WRONG DELETE the
657+
# moment two entries resolve different keys. A keyless child reached by two FKs compared ONE
658+
# column against the OTHER column's values — measured, `"owner" IN (SELECT … "backup" …)`. Which
659+
# of the two wins is `related_objects` Dict order, so the direction flips between model sets; that
660+
# it is arbitrary is the point. Both defects are gone with the branch rather than guarded — each
661+
# fragment carries its own key, and there is exactly one renderer and one collector here.
580662
_where = String[]
581663
parameters = get_parameter(connection)
582-
# @info keys[1][:objct] |> query
583664
for key in keys
584665
pk_field = key[:key]
666+
# #432's nested-run machinery, the same wrap the read builder's four splice sites use: mark every
667+
# bucket, let the inner build file its values under its OWN clauses (`own_contexts`), then lift
668+
# the run and re-emit it as one contiguous, clause-ordered block at this fragment's marker
669+
# position.
670+
#
671+
# NOT load-bearing today, and the honest note is worth more than the flattering one. Measured on
672+
# a two-path cascade whose ROOT carries a parameterized `cjoin` — the shape that should
673+
# interleave — removing this wrap leaves every statement's SQL text and flattened value vector
674+
# identical. (It does move one thing: the ROOT statement's `ON` value sits in `:join` unwrapped
675+
# and in `:where` wrapped. Same flatten, but `:parameter_buckets` is public `:dict` output, so
676+
# that difference is visible — it is not "no change at all".)
677+
#
678+
# Why it cannot interleave, stated precisely — the tempting short version ("no delete fragment
679+
# binds at its own `:join`") is FALSE, so do not simplify back to it. A fragment can: the user's
680+
# root queryset does. What it cannot do is share a statement with a second fragment.
681+
# Every OTHER fragment is built by `find_related_objects!` as
682+
# `child.objects.filter("<fk>__@in" => parent).values(<key>)`, which carries no join of its own;
683+
# and a second entry for the ROOT model needs an FK cycle, which `topological_sort` refuses
684+
# (measured: a self-loop and a two-model cycle both raise "Circular dependency detected in
685+
# model relationships"). So the interleave needs two join-binding fragments in one statement,
686+
# and the graph cannot produce them.
687+
#
688+
# Kept as insurance, not decoration: it is what makes the alignment a property of the code
689+
# rather than of that reachability argument, which nothing enforces and which a future fragment
690+
# shape (a join, a HAVING) would quietly invalidate. `docs/src/architecture.md` states the same
691+
# thing rather than claiming a fix that is not one.
692+
nested_mark = nested_parameter_mark(parameters)
693+
subquery = query(key[:objct], parameters=parameters, own_contexts=true)
694+
reattach_parameters!(parameters, detach_nested_run!(parameters, nested_mark))
585695
# Outer WHERE targets the physical column (db_column when set); the subquery already
586696
# projects/aliases the key, so only this identifier needs resolving (#50).
587-
push!(_where, """$(safe_column_identifier(Models.model_column(model, pk_field), connection)) IN ($(query(key[:objct], parameters=parameters)))""")
588-
end
589-
sql::String = ""
590-
if size(keys, 1) == 1
591-
deleted_counter[model.name] = show_query === :execute ? (keys[1][:objct] |> _count) : 0
592-
sql = "DELETE FROM $(safe_table_identifier(Models.model_table_name(model), connection)) WHERE $(join(_where, " OR "))"
593-
else
594-
# Multi-path merge: all entries for the same model share the same resolved key field.
595-
# Keyless (sentinel) models reaching this path are not supported.
596-
pk_field = keys[1][:key]
597-
pk_field == DIRECT_DELETE_KEY_SENTINEL && throw(QueryBuildError("Multi-path delete on keyless model $(model.name) is not supported; define a primary key"))
598-
_query = model |> object;
599-
or_object = Qor("$(pk_field)__@in" => keys[1][:objct])
600-
for (index, key) in enumerate(keys)
601-
if index == 1
602-
continue # already added
603-
end
604-
push!(or_object, "$(pk_field)__@in" => key[:objct])
605-
end
606-
_query.filter(or_object)
607-
deleted_counter[model.name] = show_query === :execute ? (_query |> _count) : 0
608-
_query.values(pk_field) # Ensure the query is built
609-
@pormg_debug false
610-
sql = "DELETE FROM $(safe_table_identifier(Models.model_table_name(model), connection)) WHERE $(safe_column_identifier(Models.model_column(model, pk_field), connection)) IN ($(query(_query, parameters=parameters)))"
697+
push!(_where, """$(safe_column_identifier(Models.model_column(model, pk_field), connection)) IN ($(subquery))""")
611698
end
612699

613-
sql == "" && error(_emsg("PormG internal error in delete(): the generated SQL is empty — this should not happen; please report it."))
614-
700+
sql::String = "DELETE FROM $(safe_table_identifier(Models.model_table_name(model), connection)) WHERE $(join(_where, " OR "))"
701+
615702
if show_query !== :execute
616703
return _show_query_result(show_query, sql, connection, model, :delete, parameters=parameters)
617704
end
618705
@pormg_debug false
619-
result, conn = with_transaction(connection, sql, conn=conn, params=parameters)
706+
result, _ = with_transaction(connection, sql, conn=conn, params=parameters)
707+
deleted_counter[model.name] = _affected_row_count(connection, result, conn)
620708
return deleted_counter # Return count of deleted objects
621709
end
622710

@@ -628,8 +716,14 @@ function update_field(connection::Union{PormGPostgres, PormGSQLite}, model::Porm
628716
_query = keys[:objct]
629717
parameters = get_parameter(connection)
630718
value_sql = value === nothing ? "NULL" : model.fields[field].formatter(value)
719+
# Same #432 wrap as delete_objects above. There is only one splice here, so the run is trivially in
720+
# text order today — the wrap is what makes that a property of the code instead of a property of
721+
# there happening to be only one.
722+
nested_mark = nested_parameter_mark(parameters)
723+
subquery = query(_query, parameters=parameters, own_contexts=true)
724+
reattach_parameters!(parameters, detach_nested_run!(parameters, nested_mark))
631725
# SET column and outer WHERE key both target the physical column (db_column) — #50.
632-
sql = "UPDATE $(safe_table_identifier(Models.model_table_name(model), connection)) SET $(safe_column_identifier(Models.model_column(model, field), connection)) = $(value_sql) WHERE $(safe_column_identifier(Models.model_column(model, pk_field), connection)) IN ($(query(_query, parameters=parameters)))"
726+
sql = "UPDATE $(safe_table_identifier(Models.model_table_name(model), connection)) SET $(safe_column_identifier(Models.model_column(model, field), connection)) = $(value_sql) WHERE $(safe_column_identifier(Models.model_column(model, pk_field), connection)) IN ($(subquery))"
633727
if show_query !== :execute
634728
return _show_query_result(show_query, sql, connection, model, :update, parameters=parameters)
635729
end

0 commit comments

Comments
 (0)