@@ -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
559583end
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+
561622function 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
621709end
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