3

I use Constraints in my MySQL Database. But now it gives me a headache when I try to delete an entry on which other entries have a foreign-key relationship. I always get this error:

Cannot delete or update a parent row: a foreign key constraint fails

Can I pass the delete-statement any parameter or anything, so that it recursivly deletes all rows that have a foreign-key relationship to the row I'm trying to delete?

Steve Chambers
  • 37,270
  • 24
  • 156
  • 208
Pascal Klein
  • 23,665
  • 24
  • 82
  • 119

2 Answers2

1

UPDATE: Have now made this into a blog post: https://stevettt.blogspot.co.uk/2018/02/how-to-automate-deletion-of-rows-in.html


I've written a stored procedure that will recursively delete from all foreign key - linked tables (without needing to turn off foreign key checks or turn on cascade deletes). The implementation has some complexity but can be treated as a "black box": Simply specify the name of the schema (database), table and a WHERE clause to restrict the records to be deleted and it will do the rest.

Demo

Rextester online demo: http://rextester.com/MDMRA15991

SQL

-- ------------------------------------------------------------------------------------
-- USAGE
-- ------------------------------------------------------------------------------------
-- CALL delete_recursive(<schema name>, <table name>, <WHERE clause>, <delete flag>);
-- where:
-- <schema name> is the name of the MySQL schema
-- <table name> is the name of the base table to delete records from
-- <WHERE clase> is a SQL WHERE clause to filter which records that are to be deleted
-- <delete flag> is either TRUE or FALSE: If TRUE, the records *will* be deleted.
--               If FALSE, the SQL will be output without actually deleting anything.
-- Example:
-- CALL delete_recursive('mydb', 'mytable', 'WHERE mypk IN (1, 2, 3)', TRUE);
DROP PROCEDURE IF EXISTS delete_recursive;
DELIMITER //
CREATE PROCEDURE delete_recursive(schema_name VARCHAR(64),
                                  tbl_name VARCHAR(64),
                                  where_clause TEXT,
                                  do_delete BIT)
BEGIN
  DECLARE next_schema_name, next_tbl_name VARCHAR(64);
  DECLARE from_clause, next_where_clause, next_col_names, ref_col_names TEXT;
  DECLARE done INT DEFAULT FALSE;
  DECLARE cursor1 CURSOR FOR
    SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAMES, REF_COLUMN_NAMES FROM temp_kcu;
  DECLARE cursor2 CURSOR FOR
    SELECT table_schema, table_name, where_sql FROM temp_deletes ORDER BY id;
  DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;

  -- Set maximum recursion depth
  SET @@SESSION.max_sp_recursion_depth = 255;

  -- Increment current recursion depth since the stored procedure has been entered.
  SET @recursion_depth = IFNULL(@recursion_depth + 1, 0);

  -- Create temporary table for storing the deletes if it doesn't already exist
  IF @recursion_depth = 0 THEN
    DROP TEMPORARY TABLE IF EXISTS temp_deletes;
    CREATE TEMPORARY TABLE temp_deletes (
      id INT NOT NULL AUTO_INCREMENT,
      table_schema VARCHAR(64),
      table_name VARCHAR(64),
      where_sql TEXT,
      Notes TEXT,
      PRIMARY KEY(id)
    );
  END IF;

  -- Construct FROM clause (including the WHERE clause) for this table.
  SET from_clause = 
    CONCAT(' FROM ', schema_name, '.', tbl_name, ' WHERE ', where_clause);

  -- Find out whether there are any foreign keys to this table
  SET @query = CONCAT('SELECT COUNT(*) INTO @count', from_clause);
  PREPARE stmt FROM @query;
  EXECUTE stmt;
  DEALLOCATE PREPARE stmt;

  IF @count > 0 THEN
    -- There are foriegn keys to this table so all linked rows must be deleted first:
    -- Firstly, fill a temporary table with the foreign key metadata.
    DROP TEMPORARY TABLE IF EXISTS temp_kcu;
    SET @query = CONCAT(
      'CREATE TEMPORARY TABLE temp_kcu AS ',
      'SELECT TABLE_SCHEMA, TABLE_NAME, ',
      'GROUP_CONCAT(CONCAT(COLUMN_NAME) SEPARATOR '', '') AS COLUMN_NAMES, ', 
      'GROUP_CONCAT(CONCAT(REFERENCED_COLUMN_NAME) SEPARATOR '', '')
        AS REF_COLUMN_NAMES ',
      'FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE ',
      'WHERE REFERENCED_TABLE_SCHEMA = ''', schema_name,
      ''' AND REFERENCED_TABLE_NAME = ''', tbl_name, ''' ',
      'GROUP BY CONSTRAINT_NAME');
    PREPARE stmt FROM @query;
    EXECUTE stmt;
    DEALLOCATE PREPARE stmt;

    -- Loop through all foreign keys to this table using a cursor.
    OPEN cursor1;
    read_loop: LOOP
      FETCH cursor1 INTO next_schema_name, next_tbl_name, next_col_names,
            ref_col_names;
      IF done THEN
        -- No more rows so exit the loop.
        LEAVE read_loop;
      END IF;

      -- Recursively call the stored procedure to delete linked rows
      -- for this foreign key.
      IF INSTR(next_col_names, ',') = 0 THEN
        SET next_where_clause = CONCAT(
          next_col_names, ' IN (SELECT ', ref_col_names, from_clause, ')');
      ELSE
        SET next_where_clause = CONCAT(
          '(', next_col_names, ') IN (SELECT ', ref_col_names, from_clause, ')');
      END IF;
      CALL delete_recursive(
        next_schema_name, next_tbl_name, next_where_clause, do_delete);
    END LOOP;
    CLOSE cursor1;
  END IF;

  -- Build the DELETE statement
  SET @query = CONCAT(
    'DELETE FROM ', schema_name, '.', tbl_name, ' WHERE ', where_clause);

  -- Get the number of primary key columns
  SET @pk_column_count = (SELECT COUNT(*)
                          FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
                          WHERE TABLE_SCHEMA = schema_name
                            AND TABLE_NAME = tbl_name
                            AND CONSTRAINT_NAME = 'PRIMARY');
  IF @pk_column_count = 0 THEN
    -- No primary key so just output the number of rows to be deleted
    SET @query = CONCAT(
      'SET @notes = CONCAT(''No primary key; number of rows to delete = '',
      (SELECT COUNT(*) FROM ', schema_name, '.', tbl_name, ' WHERE ',
      where_clause, '))');
    PREPARE stmt FROM @query;
    EXECUTE stmt;
    DEALLOCATE PREPARE stmt;
  ELSEIF @pk_column_count = 1 THEN
    -- 1 primary key column.
    -- Output the primary keys of the records to be deleted
    SET @pk_column = (SELECT COLUMN_NAME
                      FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
                      WHERE TABLE_SCHEMA = schema_name
                        AND TABLE_NAME = tbl_name
                        AND CONSTRAINT_NAME = 'PRIMARY');
    SET @pk_column_csv = CONCAT('CONCAT('''''''', ', @pk_column, ', '''''''')');
    SET @query = CONCAT(
      'SET @notes = (SELECT CONCAT(''', @pk_column, ' IN ('', GROUP_CONCAT(',
      @pk_column_csv, ' SEPARATOR '', ''), '')'') FROM ',
      schema_name, '.', tbl_name, ' WHERE ', where_clause, ')');
    PREPARE stmt FROM @query;
    EXECUTE stmt;
    DEALLOCATE PREPARE stmt;
  ELSE
    -- Multiple primary key columns.
    -- Output the primary keys of the records to be deleted.
    SET @pk_columns = (SELECT GROUP_CONCAT(COLUMN_NAME SEPARATOR ', ')
                       FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
                       WHERE TABLE_SCHEMA = schema_name
                         AND TABLE_NAME = tbl_name
                         AND CONSTRAINT_NAME = 'PRIMARY');
    SET @pk_columns_csv = (SELECT CONCAT('CONCAT(''('''''', ', GROUP_CONCAT(COLUMN_NAME
                             SEPARATOR ', '''''', '''''', '), ', '''''')'')')
                           FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
                           WHERE TABLE_SCHEMA = schema_name
                             AND TABLE_NAME = tbl_name
                             AND CONSTRAINT_NAME = 'PRIMARY');      
    SET @query = CONCAT(
     'SET @notes = (SELECT CONCAT(''(', @pk_columns,
     ') IN ('', GROUP_CONCAT(', @pk_columns_csv, ' SEPARATOR '', ''), '')'') FROM ',
      schema_name, '.', tbl_name, ' WHERE ', where_clause, ')');
    PREPARE stmt FROM @query;
    EXECUTE stmt;
    DEALLOCATE PREPARE stmt;
  END IF;

  IF @notes IS NULL THEN
    SET @notes = 'No affected rows.';
  END IF;

  -- Save details of the DELETE statement to be executed
  INSERT INTO temp_deletes (table_schema, table_name, where_sql, Notes)
  VALUES (schema_name, tbl_name, where_clause, @notes);

  IF @recursion_depth = 0 THEN
    -- Output the deletes.
    SELECT CONCAT('DELETE FROM ', schema_name, '.', table_name,
                  ' WHERE ', where_sql) `SQL`,
           Notes
    FROM temp_deletes ORDER BY id;

    IF do_delete THEN
      -- Perform the deletes: Loop through all delete queries using a cursor.
      SET done = FALSE;
      OPEN cursor2;
      read_loop: LOOP
        FETCH cursor2 INTO schema_name, tbl_name, where_clause;
        IF done THEN
          -- No more rows so exit the loop.
          LEAVE read_loop;
        END IF;

        SET @query = CONCAT(
          'DELETE FROM ', schema_name, '.', tbl_name, ' WHERE ', where_clause);

        PREPARE stmt FROM @query;
        EXECUTE stmt;
        DEALLOCATE PREPARE stmt;
      END LOOP;
      CLOSE cursor2;
    END IF;

    -- Tidy up
    DROP TEMPORARY TABLE IF EXISTS temp_deletes;
  END IF;

  -- Decrement current recursion depth since the stored procedure is being exited.
  SET @recursion_depth = @recursion_depth - 1;
END;//
DELIMITER ;

Limitations

  1. CREATE TEMPORARY TABLES permission is required for the user running the stored procedure for the schema(s) being used.
  2. MySQL only supports a maximum recursion depth of 255 so this method would fall over if there were a very large number of foreign key links (seems unlikely).
  3. "Circular" / "cyclic" foreign key references (e.g. table A has a foreign key to table B and table B has a foreign key back to table A) are not currently supported and would cause an infinite loop.
  4. It's not designed for use on a "live" system: Since data is deleted recursively, later deletions could fail if it happened that more data was added between deleting child and parent records.
Steve Chambers
  • 37,270
  • 24
  • 156
  • 208
  • Good script. But it would be nice to have support for cyclic key references as that use case is not really rare. Have you thought about it? If yes, can you tell me? – Saket Mehta Apr 19 '18 at 09:25
  • 1
    Expect this would be doable by creating another temporary table to store identifiers for all the foreign keys that have been processed so far and then make sure that those that have already been processed are not processed if come across again. (Haven't really got time to look into this at the moment but will put it on my backlog.) – Steve Chambers Apr 19 '18 at 10:04
0

Look at this:

In what order are ON DELETE CASCADE constraints processed?

But I think you can use ON DELETE CASCADE from my research. If I am wrong I am sure the community will let me know. I believe you will have to alter your tables, if possible.

Also see this:

Cannot delete or update a parent row: a foreign key constraint fails

Community
  • 1
  • 1
johnny
  • 19,272
  • 52
  • 157
  • 259