Not that you were considering this, but note that based on the syntax for FOREIGN KEY
s, they by themselves do not offer a solution (as the members of the key must be columns) (see "Can a MySQL FOREIGN KEY CONSTRAINT reference a column value rather than a column name?").
Surrogates
I (and many others) would argue that a surrogate key (option 3) is not "unnecessary information". The short version is that surrogate keys add a bit of abstraction that buffers the data model from things like schema changes and supports more data integrity constraints (such as foreign key constraints). A surrogate key is not based on any attribute of what's modeled, but that's the source of its strength.
Generated Columns
MariaDB supports generated columns in some storage engines using basically the same syntax as SQL Server's computed columns. For them to be used in foreign keys in older versions of MariaDB, they must have PERSISTENT
storage (which stores them in the table). According to tests by dbdemon, newer versions also support VIRTUAL
storage (which generates the column values when the table is queried) for generated columns in foreign keys. With any version, you cannot use ON UPDATE CASCADE
, as that might cause a row to no longer be valid for the table, and INSERT
cascades aren't a thing, so a generated column in a foreign key only helps with deletion.
Triggers
If a surrogate key is truly not an option, you can implement various parts of constraints using triggers: update and delete (and even insert) cascades, fixing inconsistent data (where appropriate), and more general checks.
For both flexibility and readability, have the triggers call procedures for the actual table operations, assuming the columns are fairly consistent across tables. All the insertion and deletion triggers would then need to do is call the related procedures. The update trigger would need to determine whether the type is getting changed (in which case the row would need to be deleted from the old table and inserted into the new) or staying the same (in which case the corresponding row can simply be updated). Alternatively, the update trigger could prevent the column from being changed.
If triggers could execute dynamic SQL, a single prepared SQL statement would do for each DML statement (INSERT
, UPDATE
, DELETE
) in the procedures, but triggers in MySQL/MariaDB are limited so they can't execute dynamic SQL, not even indirectly by calling procedures. Consequently, the stored procedures below are a little more verbose by having a CASE
statement in each one that chooses the exact DML statement to execute. At least only one procedure for each DML statement is called for.
However, if the ancillary tables have few similar columns in common, stored procedures aren't useful, and the DML should instead go in the triggers.
Hopefully, the type
column is implemented as an ENUM
in the actual schema for greater data integrity. These enum values could then be treated as INT
s (as is done in the sample SQL) or as a CHAR
s (as is done below).
Only minimal error handling is done in this example. Transactions would be useful for error handling, but they aren't allowed in triggers; fortunately, savepoints are. The UPDATE
trigger sets a savepoint and defines an error handler that rolls back to the savepoint if there's any type of error.
The below has only been tested on MySQL 5.7, not MariaDB.
DELIMITER ;;
CREATE PROCEDURE signal_unknown_type
(type CHAR(32))
BEGIN
SET @msg = Concat('No known subtable for type: "', type, '"');
SIGNAL SQLSTATE '42S02'
SET MESSAGE_TEXT = @msg,
SCHEMA_NAME = '...',
TABLE_NAME = 'super',
...;
END;;
CREATE PROCEDURE super_insert_sub
(type CHAR(32), name VARCHAR(64), ...)
BEGIN
CASE type
WHEN 'a' THEN INSERT INTO sub_a (name, ...) VALUES (name);
WHEN 'b' THEN INSERT INTO sub_b (name, ...) VALUES (name);
ELSE CALL signal_unknown_type(type);
END CASE;
END;;
CREATE PROCEDURE super_update_sub
(type CHAR(32), old_name VARCHAR(16), new_name VARCHAR(16), ...)
BEGIN
CASE type
WHEN 'a' THEN UPDATE sub_a SET name = new_name, ... WHERE name = old_name;
WHEN 'b' THEN UPDATE sub_b SET name = new_name, ... WHERE name = old_name;
...
ELSE CALL signal_unknown_type(type);
END CASE;
END;;
CREATE PROCEDURE super_delete_sub
(type CHAR(32), name VARCHAR(16))
BEGIN
CASE type
WHEN 'a' THEN DELETE FROM sub_a WHERE name = name;
WHEN 'b' THEN DELETE FROM sub_b WHERE name = name;
...
ELSE CALL signal_unknown_type(type);
END CASE;
END;;
CREATE TRIGGER `insert_sub_from_super`
AFTER INSERT ON `super` FOR EACH ROW
BEGIN
CALL super_insert_sub(new.type, new.name);
END;;
CREATE TRIGGER `update_sub_from_super`
AFTER UPDATE ON `super` FOR EACH ROW
BEGIN
DECLARE EXIT HANDLER FOR SQLWARNING, NOT FOUND, SQLEXCEPTION
BEGIN
ROLLBACK TO `update_sub_from_super`;
RESIGNAL;
END;
SAVEPOINT `update_sub_from_super`;
IF old.name = new.name THEN
CALL super_update_sub(new.type, old.name, new.name, ...);
ELSE
CALL super_delete_sub(old.type, old.name);
CALL super_insert_sub(new.type, new.name, ...);
END IF;
RELEASE SAVEPOINT `update_sub_from_super`;
END;;
CREATE TRIGGER `delete_sub_from_super`
AFTER DELETE ON `super` FOR EACH ROW
BEGIN
CALL super_delete_sub(old.type, old.name);
END;;
DELIMITER ;
CREATE VIEW subtype1 AS SELECT <list_of_common_and_type1_columns> FROM supertable WHERE type = 1