Skip to main content

radixdb_executor/mutation/
foreign_key.rs

1// Copyright 2026 RadixDB Contributors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Foreign Key Constraint Enforcement
16//!
17//! This module provides helpers for checking referential integrity:
18//! - On INSERT/UPDATE: verify parent rows exist (index-based O(log n) / O(1))
19//! - On DELETE/UPDATE of parent: enforce RESTRICT/CASCADE/SET NULL
20//!
21//! All operations participate in the caller's transaction (via `txn_id`), ensuring:
22//! - CASCADE effects are atomic with the parent operation
23//! - FK checks see uncommitted rows from the current transaction
24//! - No independent transactions are created (no resource leaks)
25//!
26//! Performance guarantees:
27//! - Zero cost for non-FK tables (all checks short-circuit on empty foreign_keys)
28//! - Cached reverse FK mapping (rebuilt only on schema_epoch change)
29//! - Index-based parent lookups (no table scans when index exists)
30
31use std::sync::Arc;
32
33use radixdb_core::{
34    DataType, Error, ForeignKeyAction, ForeignKeyConstraint, Result, Row, Schema, Value,
35};
36use radixdb_storage::expression::Expression as StorageExpression;
37use radixdb_storage::mvcc::engine::MVCCEngine;
38use radixdb_storage::traits::Engine;
39
40fn validate_cascade_row(
41    schema: &Schema,
42    row: &Row,
43    compiled_checks: &[(String, crate::expression::SharedProgram)],
44    check_vm: &mut crate::expression::ExprVM,
45) -> Result<()> {
46    crate::mutation::validation::validate_resulting_row_constraints(
47        schema,
48        compiled_checks,
49        row,
50        check_vm,
51    )
52}
53
54/// Check that all FK values in a row reference existing parent rows.
55/// Called on INSERT and UPDATE (when FK columns change).
56///
57/// Uses `txn_id` to check within the caller's transaction, so uncommitted
58/// parent rows (inserted in the same transaction) are visible.
59///
60/// Short-circuits immediately if schema has no FKs (zero cost for non-FK tables).
61pub fn check_parent_exists(
62    engine: &MVCCEngine,
63    txn_id: i64,
64    schema: &Schema,
65    row: &radixdb_core::Row,
66) -> Result<()> {
67    for fk in &schema.foreign_keys {
68        let fk_value = match row.get(fk.column_index) {
69            Some(v) if !v.is_null() => v,
70            _ => continue, // NULL FK is allowed (no reference)
71        };
72
73        if !parent_row_exists(
74            engine,
75            txn_id,
76            &fk.referenced_table,
77            &fk.referenced_column,
78            fk_value,
79        )? {
80            return Err(Error::foreign_key_violation(
81                &schema.table_name,
82                &fk.column_name,
83                &fk.referenced_table,
84                &fk.referenced_column,
85                format!(
86                    "referenced row with {} = {} does not exist",
87                    fk.referenced_column, fk_value
88                ),
89            ));
90        }
91    }
92    Ok(())
93}
94
95/// Pre-validate a single FK value against its parent table.
96/// Used for early validation of constant SET values in UPDATE statements
97/// to prevent dirty state in explicit transactions.
98///
99/// NULL values are allowed (no reference) and should be skipped by the caller.
100pub fn validate_fk_value(
101    engine: &MVCCEngine,
102    txn_id: i64,
103    fk: &ForeignKeyConstraint,
104    value: &Value,
105    child_table: &str,
106) -> Result<()> {
107    if !parent_row_exists(
108        engine,
109        txn_id,
110        &fk.referenced_table,
111        &fk.referenced_column,
112        value,
113    )? {
114        return Err(Error::foreign_key_violation(
115            child_table,
116            &fk.column_name,
117            &fk.referenced_table,
118            &fk.referenced_column,
119            format!(
120                "referenced row with {} = {} does not exist",
121                fk.referenced_column, value
122            ),
123        ));
124    }
125    Ok(())
126}
127
128/// Check if a value exists in the parent table's referenced column.
129///
130/// Integer primary keys are identical to physical row IDs, so their common FK
131/// path uses the transaction-aware membership probe without materializing row
132/// payloads. Other referenced domains use the filtered lookup fallback.
133fn parent_row_exists(
134    engine: &MVCCEngine,
135    txn_id: i64,
136    parent_table: &str,
137    parent_column: &str,
138    value: &Value,
139) -> Result<bool> {
140    let parent_schema = engine
141        .get_table_schema_for_txn(txn_id, parent_table)
142        .map_err(|_| {
143            Error::internal(format!(
144                "foreign key references non-existent table '{}'",
145                parent_table
146            ))
147        })?;
148
149    let (_, ref_col) = parent_schema.find_column(parent_column).ok_or_else(|| {
150        Error::internal(format!(
151            "foreign key references non-existent column '{}' in table '{}'",
152            parent_column, parent_table
153        ))
154    })?;
155
156    let parent = engine.get_table_for_txn(txn_id, parent_table)?;
157    if ref_col.primary_key && ref_col.data_type == DataType::Integer {
158        if let Value::Integer(row_id) = value {
159            let mut matches = [false];
160            let hits = parent.probe_visible_row_ids(&[*row_id], &mut matches)?;
161            return Ok(hits == 1 && matches[0]);
162        }
163    }
164
165    // Generic fallback for UUID/TEXT/other unique referenced domains.
166    let mut expr = radixdb_storage::expression::ComparisonExpr::new(
167        ref_col.name.as_str(),
168        radixdb_core::Operator::Eq,
169        value.clone(),
170    );
171    expr.prepare_for_schema(&parent_schema);
172
173    let rows = parent.collect_rows_with_limit_unordered(Some(&expr), 1, 0)?;
174    Ok(!rows.is_empty())
175}
176
177/// Find all foreign key constraints in other tables that reference the given parent table.
178/// Delegates to the engine's cached reverse mapping (rebuilt only on schema_epoch change).
179/// Returns Arc-wrapped Vec (ref-count bump only, no cloning).
180pub fn find_referencing_fks(
181    engine: &MVCCEngine,
182    parent_table: &str,
183) -> Arc<Vec<(String, ForeignKeyConstraint)>> {
184    engine.find_referencing_fks(parent_table)
185}
186
187pub fn find_referencing_fks_for_txn(
188    engine: &MVCCEngine,
189    txn_id: i64,
190    parent_table: &str,
191) -> Arc<Vec<(String, ForeignKeyConstraint)>> {
192    engine.find_referencing_fks_for_txn(txn_id, parent_table)
193}
194
195/// Enforce referential actions for DELETE from a parent table.
196/// Accepts an iterator of PK values to avoid allocating a separate Vec.
197///
198/// All CASCADE/SET NULL operations use `txn_id` to participate in the caller's
199/// transaction, ensuring atomicity (rollback undoes cascade effects).
200///
201/// For each deleted PK value, checks all child tables:
202/// - RESTRICT/NO ACTION: error if child rows exist
203/// - CASCADE: delete matching child rows (batched per child table)
204/// - SET NULL: set FK column to NULL in matching child rows (batched per child table)
205///
206/// Returns the total count of cascaded/affected child rows.
207pub fn enforce_delete_actions_iter<'a>(
208    engine: &MVCCEngine,
209    txn_id: i64,
210    parent_table: &str,
211    parent_schema: &radixdb_core::Schema,
212    deleted_rows: impl Iterator<Item = &'a Row>,
213    referencing_fks: &[(String, ForeignKeyConstraint)],
214) -> Result<i32> {
215    if referencing_fks.is_empty() {
216        return Ok(0);
217    }
218
219    let mut total_affected = 0i32;
220
221    for row in deleted_rows {
222        for (child_table_name, fk) in referencing_fks {
223            let (referenced_index, _) = parent_schema
224                .find_column(&fk.referenced_column)
225                .ok_or_else(|| {
226                    Error::internal(format!(
227                        "foreign key references missing column '{}.{}'",
228                        parent_table, fk.referenced_column
229                    ))
230                })?;
231            let referenced_value = row.get(referenced_index).ok_or_else(|| {
232                Error::internal(format!(
233                    "deleted row is missing referenced column '{}.{}'",
234                    parent_table, fk.referenced_column
235                ))
236            })?;
237            let action = fk.on_delete;
238
239            match action {
240                ForeignKeyAction::Restrict | ForeignKeyAction::NoAction => {
241                    // Check if any child rows reference this PK value
242                    if child_rows_exist(engine, txn_id, child_table_name, fk, referenced_value)? {
243                        return Err(Error::foreign_key_violation(
244                            child_table_name,
245                            &fk.column_name,
246                            parent_table,
247                            &fk.referenced_column,
248                            format!(
249                                "cannot delete row with {} = {} — still referenced by table '{}'",
250                                fk.referenced_column, referenced_value, child_table_name
251                            ),
252                        ));
253                    }
254                }
255                ForeignKeyAction::Cascade => {
256                    // Delete matching child rows within the caller's transaction
257                    let affected =
258                        cascade_delete(engine, txn_id, child_table_name, fk, referenced_value)?;
259                    total_affected = total_affected.saturating_add(affected);
260                }
261                ForeignKeyAction::SetNull => {
262                    // Set FK column to NULL in matching child rows
263                    let affected =
264                        set_null_on_delete(engine, txn_id, child_table_name, fk, referenced_value)?;
265                    total_affected = total_affected.saturating_add(affected);
266                }
267            }
268        }
269    }
270
271    Ok(total_affected)
272}
273
274/// Pre-check RESTRICT constraints and CASCADE depth before writing parent rows.
275/// Walks the full FK tree (including recursive grandchild RESTRICT behind CASCADE/SET NULL)
276/// to detect violations before any rows are modified, preserving statement atomicity.
277/// Returns true if the tree has constraints that need row-level pre-checking.
278pub fn pre_check_restrict_for_update(
279    engine: &MVCCEngine,
280    txn_id: i64,
281    parent_table: &str,
282    old_value: &Value,
283    referencing_fks: &[(String, ForeignKeyConstraint)],
284) -> Result<()> {
285    pre_check_restrict_recursive(engine, txn_id, parent_table, old_value, referencing_fks, 0)
286}
287
288/// Check if the FK tree rooted at these referencing FKs needs row-level pre-checking.
289/// Returns true if any path contains RESTRICT/NoAction or exceeds CASCADE depth.
290/// This is a metadata-only walk (no row scans) used to skip the expensive pre-scan
291/// when the tree is pure CASCADE/SET NULL within depth limits.
292pub fn fk_tree_needs_precheck(
293    engine: &MVCCEngine,
294    txn_id: i64,
295    referencing_fks: &[(String, ForeignKeyConstraint)],
296) -> bool {
297    fk_tree_needs_precheck_recursive(engine, txn_id, referencing_fks, 0)
298}
299
300fn fk_tree_needs_precheck_recursive(
301    engine: &MVCCEngine,
302    txn_id: i64,
303    referencing_fks: &[(String, ForeignKeyConstraint)],
304    depth: usize,
305) -> bool {
306    if depth >= MAX_CASCADE_DEPTH {
307        return true; // depth limit will be hit → needs pre-check
308    }
309    for (child_table_name, fk) in referencing_fks {
310        match fk.on_update {
311            ForeignKeyAction::Restrict | ForeignKeyAction::NoAction => {
312                return true;
313            }
314            ForeignKeyAction::Cascade | ForeignKeyAction::SetNull => {
315                let grandchild_fks = find_referencing_fks_for_txn(engine, txn_id, child_table_name);
316                let child_fk_col = &fk.column_name;
317                let relevant: Vec<_> = grandchild_fks
318                    .iter()
319                    .filter(|(_, gfk)| gfk.referenced_column == *child_fk_col)
320                    .cloned()
321                    .collect();
322                if !relevant.is_empty()
323                    && fk_tree_needs_precheck_recursive(engine, txn_id, &relevant, depth + 1)
324                {
325                    return true;
326                }
327            }
328        }
329    }
330    false
331}
332
333fn pre_check_restrict_recursive(
334    engine: &MVCCEngine,
335    txn_id: i64,
336    parent_table: &str,
337    old_value: &Value,
338    referencing_fks: &[(String, ForeignKeyConstraint)],
339    depth: usize,
340) -> Result<()> {
341    for (child_table_name, fk) in referencing_fks {
342        // Check if child rows actually reference old_value before doing anything.
343        // If no matching rows exist, neither RESTRICT nor CASCADE applies.
344        if !child_rows_exist(engine, txn_id, child_table_name, fk, old_value)? {
345            continue;
346        }
347        match fk.on_update {
348            ForeignKeyAction::Restrict | ForeignKeyAction::NoAction => {
349                return Err(Error::foreign_key_violation(
350                    child_table_name,
351                    &fk.column_name,
352                    parent_table,
353                    &fk.referenced_column,
354                    format!(
355                        "cannot update row with {} = {} — still referenced by table '{}'",
356                        fk.referenced_column, old_value, child_table_name
357                    ),
358                ));
359            }
360            ForeignKeyAction::Cascade | ForeignKeyAction::SetNull => {
361                // Child rows exist and will be cascaded. Check depth limit
362                // now — if exceeded, the actual cascade would fail too.
363                if depth >= MAX_CASCADE_DEPTH {
364                    return Err(Error::internal(format!(
365                        "foreign key CASCADE depth limit ({}) exceeded — possible circular reference",
366                        MAX_CASCADE_DEPTH
367                    )));
368                }
369                let grandchild_fks = find_referencing_fks_for_txn(engine, txn_id, child_table_name);
370                let child_fk_col = &fk.column_name;
371                let relevant: Vec<_> = grandchild_fks
372                    .iter()
373                    .filter(|(_, gfk)| gfk.referenced_column == *child_fk_col)
374                    .cloned()
375                    .collect();
376                if !relevant.is_empty() {
377                    pre_check_restrict_recursive(
378                        engine,
379                        txn_id,
380                        child_table_name,
381                        old_value,
382                        &relevant,
383                        depth + 1,
384                    )?;
385                }
386            }
387        }
388    }
389    Ok(())
390}
391
392/// Enforce referential actions for UPDATE of a referenced column.
393/// RESTRICT is already handled by pre_check_restrict_for_update before
394/// the parent row is written. This function only dispatches CASCADE/SET NULL.
395pub fn enforce_update_actions(
396    engine: &MVCCEngine,
397    txn_id: i64,
398    old_pk_value: &Value,
399    new_pk_value: &Value,
400    referencing_fks: &[(String, ForeignKeyConstraint)],
401) -> Result<i32> {
402    if referencing_fks.is_empty() {
403        return Ok(0);
404    }
405
406    let mut total_affected = 0i32;
407
408    for (child_table_name, fk) in referencing_fks {
409        let action = fk.on_update;
410
411        match action {
412            ForeignKeyAction::Restrict | ForeignKeyAction::NoAction => {
413                // RESTRICT is already enforced by pre_check_restrict_for_update
414                // before the parent row is written. Nothing to do here.
415            }
416            ForeignKeyAction::Cascade => {
417                let affected = cascade_update(
418                    engine,
419                    txn_id,
420                    child_table_name,
421                    fk,
422                    old_pk_value,
423                    new_pk_value,
424                )?;
425                total_affected = total_affected.saturating_add(affected);
426            }
427            ForeignKeyAction::SetNull => {
428                let affected =
429                    set_null_on_delete(engine, txn_id, child_table_name, fk, old_pk_value)?;
430                total_affected = total_affected.saturating_add(affected);
431            }
432        }
433    }
434
435    Ok(total_affected)
436}
437
438/// Check if any child rows in the child table reference the given parent PK value.
439///
440/// Uses `collect_rows_with_limit_unordered(limit=1)` with a ComparisonExpr filter
441/// for both correctness and performance:
442/// - O(log N) via secondary index when an index exists on the FK column
443/// - Falls back to filtered scan with early termination otherwise
444/// - Always txn-aware: sees uncommitted INSERTs, respects uncommitted DELETEs
445fn child_rows_exist(
446    engine: &MVCCEngine,
447    txn_id: i64,
448    child_table: &str,
449    fk: &ForeignKeyConstraint,
450    parent_pk_value: &Value,
451) -> Result<bool> {
452    let child = engine.get_table_for_txn(txn_id, child_table)?;
453    let child_schema = child.schema();
454
455    // Build a ComparisonExpr for `fk_column = parent_pk_value`
456    let col_name = &child_schema.columns[fk.column_index].name;
457    let mut expr = radixdb_storage::expression::ComparisonExpr::new(
458        col_name.as_str(),
459        radixdb_core::Operator::Eq,
460        parent_pk_value.clone(),
461    );
462    expr.prepare_for_schema(child_schema);
463
464    let rows = child.collect_rows_with_limit_unordered(Some(&expr), 1, 0)?;
465    Ok(!rows.is_empty())
466}
467
468/// Maximum CASCADE recursion depth to prevent infinite loops from circular FK references.
469const MAX_CASCADE_DEPTH: usize = 16;
470
471/// CASCADE DELETE: delete all child rows referencing the given parent PK value.
472/// Operates within the caller's transaction (no independent commit).
473/// Recursively cascades to grandchild tables (up to MAX_CASCADE_DEPTH).
474fn cascade_delete(
475    engine: &MVCCEngine,
476    txn_id: i64,
477    child_table: &str,
478    fk: &ForeignKeyConstraint,
479    parent_pk_value: &Value,
480) -> Result<i32> {
481    cascade_delete_recursive(engine, txn_id, child_table, fk, parent_pk_value, 0)
482}
483
484fn cascade_delete_recursive(
485    engine: &MVCCEngine,
486    txn_id: i64,
487    child_table: &str,
488    fk: &ForeignKeyConstraint,
489    parent_pk_value: &Value,
490    depth: usize,
491) -> Result<i32> {
492    if depth >= MAX_CASCADE_DEPTH {
493        return Err(Error::internal(format!(
494            "foreign key CASCADE depth limit ({}) exceeded — possible circular reference",
495            MAX_CASCADE_DEPTH
496        )));
497    }
498
499    // Before deleting child rows, collect their PK values for recursive CASCADE.
500    // This is needed because the child table may itself be a parent with CASCADE children.
501    let grandchild_fks = find_referencing_fks_for_txn(engine, txn_id, child_table);
502    let mut deleted_child_rows: Vec<Row> = Vec::new();
503    let child_schema = engine.get_table_schema(child_table)?;
504
505    if !grandchild_fks.is_empty() {
506        // Preserve complete logical rows so every grandchild FK can extract
507        // its own referenced UUID/UNIQUE column. No INTEGER-only PK helper is
508        // involved in recursive identity propagation.
509        let child_handle = engine.get_table_for_txn(txn_id, child_table)?;
510        let col_name = &child_schema.columns[fk.column_index].name;
511        let mut filter = radixdb_storage::expression::ComparisonExpr::new(
512            col_name.as_str(),
513            radixdb_core::Operator::Eq,
514            parent_pk_value.clone(),
515        );
516        filter.prepare_for_schema(&child_schema);
517        deleted_child_rows = child_handle
518            .collect_all_rows(Some(&filter))?
519            .into_iter()
520            .map(|(_, row)| row)
521            .collect();
522    }
523
524    // Pre-check: verify grandchild RESTRICT constraints BEFORE deleting child rows.
525    // If we deleted children first and a grandchild RESTRICT check fails, the child
526    // deletions would remain in the transaction state (orphaning data in explicit txns).
527    if !grandchild_fks.is_empty() && !deleted_child_rows.is_empty() {
528        for child_row in &deleted_child_rows {
529            for (grandchild_table, grandchild_fk) in grandchild_fks.iter() {
530                let (referenced_index, _) = child_schema
531                    .find_column(&grandchild_fk.referenced_column)
532                    .ok_or_else(|| {
533                        Error::internal(format!(
534                            "foreign key references missing column '{}.{}'",
535                            child_table, grandchild_fk.referenced_column
536                        ))
537                    })?;
538                let child_key = child_row.get(referenced_index).ok_or_else(|| {
539                    Error::internal(format!(
540                        "cascaded row is missing referenced column '{}.{}'",
541                        child_table, grandchild_fk.referenced_column
542                    ))
543                })?;
544                if matches!(
545                    grandchild_fk.on_delete,
546                    ForeignKeyAction::Restrict | ForeignKeyAction::NoAction
547                ) && child_rows_exist(
548                    engine,
549                    txn_id,
550                    grandchild_table,
551                    grandchild_fk,
552                    child_key,
553                )? {
554                    return Err(Error::foreign_key_violation(
555                        grandchild_table,
556                        &grandchild_fk.column_name,
557                        child_table,
558                        &grandchild_fk.referenced_column,
559                        format!(
560                            "cannot cascade-delete row with {} = {} — still referenced by table '{}'",
561                            grandchild_fk.referenced_column, child_key, grandchild_table
562                        ),
563                    ));
564                }
565            }
566        }
567    }
568
569    // Now delete the matching child rows (safe — RESTRICT checks passed above)
570    let mut child = engine.get_table_for_txn(txn_id, child_table)?;
571    let col_name = &child_schema.columns[fk.column_index].name;
572    let mut expr = radixdb_storage::expression::ComparisonExpr::new(
573        col_name.as_str(),
574        radixdb_core::Operator::Eq,
575        parent_pk_value.clone(),
576    );
577    expr.prepare_for_schema(&child_schema);
578
579    let count = child.delete(Some(&expr))?;
580    // Do NOT commit here — changes stay in TransactionVersionStore and are committed
581    // atomically when the parent transaction's all-table publisher runs.
582
583    let mut total = count;
584
585    // Recursively enforce CASCADE/SET NULL on grandchild tables (RESTRICT already checked above)
586    if !grandchild_fks.is_empty() && !deleted_child_rows.is_empty() {
587        for child_row in &deleted_child_rows {
588            for (grandchild_table, grandchild_fk) in grandchild_fks.iter() {
589                let (referenced_index, _) = child_schema
590                    .find_column(&grandchild_fk.referenced_column)
591                    .ok_or_else(|| {
592                        Error::internal(format!(
593                            "foreign key references missing column '{}.{}'",
594                            child_table, grandchild_fk.referenced_column
595                        ))
596                    })?;
597                let child_key = child_row.get(referenced_index).ok_or_else(|| {
598                    Error::internal(format!(
599                        "cascaded row is missing referenced column '{}.{}'",
600                        child_table, grandchild_fk.referenced_column
601                    ))
602                })?;
603                match grandchild_fk.on_delete {
604                    ForeignKeyAction::Restrict | ForeignKeyAction::NoAction => {
605                        // Already checked above — skip
606                    }
607                    ForeignKeyAction::Cascade => {
608                        let affected = cascade_delete_recursive(
609                            engine,
610                            txn_id,
611                            grandchild_table,
612                            grandchild_fk,
613                            child_key,
614                            depth + 1,
615                        )?;
616                        total = total.saturating_add(affected);
617                    }
618                    ForeignKeyAction::SetNull => {
619                        let affected = set_null_on_delete(
620                            engine,
621                            txn_id,
622                            grandchild_table,
623                            grandchild_fk,
624                            child_key,
625                        )?;
626                        total = total.saturating_add(affected);
627                    }
628                }
629            }
630        }
631    }
632
633    Ok(total)
634}
635
636/// CASCADE UPDATE: update FK column in all child rows from old to new value.
637/// Recursively cascades to grandchild tables (up to MAX_CASCADE_DEPTH).
638/// Operates within the caller's transaction (no independent commit).
639fn cascade_update(
640    engine: &MVCCEngine,
641    txn_id: i64,
642    child_table: &str,
643    fk: &ForeignKeyConstraint,
644    old_value: &Value,
645    new_value: &Value,
646) -> Result<i32> {
647    cascade_update_recursive(engine, txn_id, child_table, fk, old_value, new_value, 0)
648}
649
650fn cascade_update_recursive(
651    engine: &MVCCEngine,
652    txn_id: i64,
653    child_table: &str,
654    fk: &ForeignKeyConstraint,
655    old_value: &Value,
656    new_value: &Value,
657    depth: usize,
658) -> Result<i32> {
659    // Early exit: if no child rows reference old_value, cascade stops here.
660    // No depth check, RESTRICT check, or writes needed.
661    if !child_rows_exist(engine, txn_id, child_table, fk, old_value)? {
662        return Ok(0);
663    }
664
665    // Child rows exist. Check depth limit before doing any work.
666    if depth >= MAX_CASCADE_DEPTH {
667        return Err(Error::internal(format!(
668            "foreign key CASCADE depth limit ({}) exceeded — possible circular reference",
669            MAX_CASCADE_DEPTH
670        )));
671    }
672
673    // Find grandchild FKs that reference the column being updated
674    let grandchild_fks = find_referencing_fks_for_txn(engine, txn_id, child_table);
675    let child_fk_col = &fk.column_name;
676
677    let relevant_grandchild_fks: Vec<_> = grandchild_fks
678        .iter()
679        .filter(|(_, gfk)| gfk.referenced_column == *child_fk_col)
680        .collect();
681
682    // Pre-check: verify grandchild RESTRICT constraints BEFORE updating child rows
683    if !relevant_grandchild_fks.is_empty() {
684        for (grandchild_table, grandchild_fk) in &relevant_grandchild_fks {
685            if matches!(
686                grandchild_fk.on_update,
687                ForeignKeyAction::Restrict | ForeignKeyAction::NoAction
688            ) && child_rows_exist(engine, txn_id, grandchild_table, grandchild_fk, old_value)?
689            {
690                return Err(Error::foreign_key_violation(
691                    grandchild_table,
692                    &grandchild_fk.column_name,
693                    child_table,
694                    &grandchild_fk.referenced_column,
695                    format!(
696                        "cannot cascade-update row with {} = {} — still referenced by table '{}'",
697                        grandchild_fk.referenced_column, old_value, grandchild_table
698                    ),
699                ));
700            }
701        }
702    }
703
704    // Now update the matching child rows (safe — RESTRICT checks passed above)
705    let mut child = engine.get_table_for_txn(txn_id, child_table)?;
706    let col_idx = fk.column_index;
707    let new_val = new_value.clone();
708
709    let child_schema = child.schema().clone();
710    let compiled_checks =
711        crate::mutation::validation::compile_table_check_constraints(&child_schema)?;
712    let mut check_vm = crate::expression::ExprVM::new();
713    let col_name = &child_schema.columns[col_idx].name;
714    let mut expr = radixdb_storage::expression::ComparisonExpr::new(
715        col_name.as_str(),
716        radixdb_core::Operator::Eq,
717        old_value.clone(),
718    );
719    expr.prepare_for_schema(&child_schema);
720
721    let count = child.update(Some(&expr), &mut |mut row| {
722        let _ = row.set(col_idx, new_val.clone());
723        validate_cascade_row(&child_schema, &row, &compiled_checks, &mut check_vm)?;
724        Ok((row, true))
725    })?;
726
727    let mut total = count;
728
729    if !relevant_grandchild_fks.is_empty() && count > 0 {
730        for (grandchild_table, grandchild_fk) in &relevant_grandchild_fks {
731            match grandchild_fk.on_update {
732                ForeignKeyAction::Restrict | ForeignKeyAction::NoAction => {
733                    // Already checked above
734                }
735                ForeignKeyAction::Cascade => {
736                    let affected = cascade_update_recursive(
737                        engine,
738                        txn_id,
739                        grandchild_table,
740                        grandchild_fk,
741                        old_value,
742                        new_value,
743                        depth + 1,
744                    )?;
745                    total = total.saturating_add(affected);
746                }
747                ForeignKeyAction::SetNull => {
748                    let affected = set_null_recursive(
749                        engine,
750                        txn_id,
751                        grandchild_table,
752                        grandchild_fk,
753                        old_value,
754                        depth + 1,
755                    )?;
756                    total = total.saturating_add(affected);
757                }
758            }
759        }
760    }
761
762    Ok(total)
763}
764
765/// SET NULL: set FK column to NULL in all child rows referencing the given parent PK value.
766/// Operates within the caller's transaction (no independent commit).
767fn set_null_on_delete(
768    engine: &MVCCEngine,
769    txn_id: i64,
770    child_table: &str,
771    fk: &ForeignKeyConstraint,
772    parent_pk_value: &Value,
773) -> Result<i32> {
774    set_null_recursive(engine, txn_id, child_table, fk, parent_pk_value, 0)
775}
776
777fn set_null_recursive(
778    engine: &MVCCEngine,
779    txn_id: i64,
780    child_table: &str,
781    fk: &ForeignKeyConstraint,
782    parent_pk_value: &Value,
783    depth: usize,
784) -> Result<i32> {
785    if !child_rows_exist(engine, txn_id, child_table, fk, parent_pk_value)? {
786        return Ok(0);
787    }
788    if depth >= MAX_CASCADE_DEPTH {
789        return Err(Error::internal(format!(
790            "foreign key CASCADE depth limit ({}) exceeded — possible circular reference",
791            MAX_CASCADE_DEPTH
792        )));
793    }
794
795    let mut child = engine.get_table_for_txn(txn_id, child_table)?;
796
797    let col_idx = fk.column_index;
798
799    // Check that the FK column is nullable
800    let child_schema = child.schema().clone();
801    if !child_schema.columns[col_idx].nullable {
802        return Err(Error::foreign_key_violation(
803            child_table,
804            &fk.column_name,
805            &fk.referenced_table,
806            &fk.referenced_column,
807            format!(
808                "cannot SET NULL on non-nullable column '{}'",
809                fk.column_name
810            ),
811        ));
812    }
813
814    let null_val = Value::null(child_schema.columns[col_idx].data_type);
815    let grandchild_fks = find_referencing_fks_for_txn(engine, txn_id, child_table);
816    let relevant_grandchild_fks: Vec<_> = grandchild_fks
817        .iter()
818        .filter(|(_, grandchild_fk)| grandchild_fk.referenced_column == fk.column_name)
819        .cloned()
820        .collect();
821    if !relevant_grandchild_fks.is_empty() {
822        pre_check_restrict_recursive(
823            engine,
824            txn_id,
825            child_table,
826            parent_pk_value,
827            &relevant_grandchild_fks,
828            depth + 1,
829        )?;
830    }
831
832    let compiled_checks =
833        crate::mutation::validation::compile_table_check_constraints(&child_schema)?;
834    let mut check_vm = crate::expression::ExprVM::new();
835    let col_name = &child_schema.columns[col_idx].name;
836    let mut expr = radixdb_storage::expression::ComparisonExpr::new(
837        col_name.as_str(),
838        radixdb_core::Operator::Eq,
839        parent_pk_value.clone(),
840    );
841    expr.prepare_for_schema(&child_schema);
842
843    let count = child.update(Some(&expr), &mut |mut row| {
844        let _ = row.set(col_idx, null_val.clone());
845        validate_cascade_row(&child_schema, &row, &compiled_checks, &mut check_vm)?;
846        Ok((row, true))
847    })?;
848    // Do NOT commit here — changes committed atomically with parent transaction.
849
850    let mut total = count;
851    if count > 0 {
852        for (grandchild_table, grandchild_fk) in &relevant_grandchild_fks {
853            let affected = match grandchild_fk.on_update {
854                ForeignKeyAction::Restrict | ForeignKeyAction::NoAction => 0,
855                ForeignKeyAction::Cascade => cascade_update_recursive(
856                    engine,
857                    txn_id,
858                    grandchild_table,
859                    grandchild_fk,
860                    parent_pk_value,
861                    &null_val,
862                    depth + 1,
863                )?,
864                ForeignKeyAction::SetNull => set_null_recursive(
865                    engine,
866                    txn_id,
867                    grandchild_table,
868                    grandchild_fk,
869                    parent_pk_value,
870                    depth + 1,
871                )?,
872            };
873            total = total.saturating_add(affected);
874        }
875    }
876
877    Ok(total)
878}
879
880/// Check if any child tables have rows that actually reference the given parent table.
881/// Used by DROP TABLE and TRUNCATE to ensure no referencing rows exist.
882/// Only counts rows where the FK column is non-NULL (NULL means "no reference").
883///
884/// Blocks for ALL FK action types (RESTRICT, CASCADE, SET NULL, NO ACTION) because
885/// DROP TABLE/TRUNCATE are DDL operations that don't cascade to child rows — they
886/// would leave orphaned references. The user must delete child rows first.
887///
888/// When `txn_id` is provided, uses the caller's transaction for visibility (sees
889/// uncommitted deletes within an explicit transaction). Otherwise creates a fresh
890/// read-only transaction.
891pub fn check_no_referencing_rows(
892    engine: &MVCCEngine,
893    parent_table: &str,
894    txn_id: Option<i64>,
895) -> Result<()> {
896    let referencing = if let Some(txn_id) = txn_id {
897        find_referencing_fks_for_txn(engine, txn_id, parent_table)
898    } else {
899        find_referencing_fks(engine, parent_table)
900    };
901    if referencing.is_empty() {
902        return Ok(());
903    }
904
905    for (child_table, fk) in referencing.iter() {
906        // Build IS NOT NULL filter on the FK column — pushed down to storage layer
907        // so indexes can be used and we stop after the first match (limit=1)
908        let child_schema = engine.get_table_schema(child_table)?;
909        let col_name = &child_schema.columns[fk.column_index].name;
910        let mut not_null_expr =
911            radixdb_storage::expression::NullCheckExpr::is_not_null(col_name.as_str());
912        not_null_expr.prepare_for_schema(&child_schema);
913
914        let has_ref = if let Some(tid) = txn_id {
915            let child = engine.get_table_for_txn(tid, child_table)?;
916            !child
917                .collect_rows_with_limit_unordered(Some(&not_null_expr), 1, 0)?
918                .is_empty()
919        } else {
920            let tx = engine.begin_transaction()?;
921            let child = tx.get_table(child_table)?;
922            !child
923                .collect_rows_with_limit_unordered(Some(&not_null_expr), 1, 0)?
924                .is_empty()
925        };
926
927        if has_ref {
928            return Err(Error::foreign_key_violation(
929                child_table,
930                &fk.column_name,
931                parent_table,
932                &fk.referenced_column,
933                format!(
934                    "cannot drop/truncate table '{}' — rows in '{}' still reference it",
935                    parent_table, child_table
936                ),
937            ));
938        }
939    }
940
941    Ok(())
942}