Files
zoneminder/db/zm_update-1.39.19.sql
T
Isaac Connor f3658e6673 fix(db): make User_Preferences unique per (UserId, Name) refs #5021
Nothing enforced uniqueness, but every reader already assumed one row per
name: User::Preferences() hashes the rows by Name, montagereview.php and
User_Preference::find_one() take the first match, and TagOrder::load()
reads a single Value. With duplicates present those pick an arbitrary
row, so a write could land on one row while reads returned another --
the tag order would appear to stop updating.

Replace the UserId-only index with UNIQUE (UserId, Name) and make Name
NOT NULL. The unique index still serves UserId-only lookups and the
UserId foreign key as a leftmost prefix, so the old index is redundant
and is dropped. Name has to be NOT NULL for the constraint to mean
anything, since a unique index permits any number of NULLs; a NULL-named
row is unreachable regardless, as a preference is only ever looked up by
name.

zm_update-1.39.19.sql discards NULL-named rows, makes the column NOT
NULL, collapses existing duplicates keeping the highest Id (the most
recently inserted), adds the unique index, then drops the old one. The
index is added before the old one is dropped so the foreign key is never
left without a usable index. Each schema change is guarded against
INFORMATION_SCHEMA so re-running is a no-op. db/User_Preferences.sql
gets the same shape for fresh installs.

With the constraint in place, TagOrder::recordUsage() replaces its
SELECT-then-INSERT-or-UPDATE with a single INSERT ... ON DUPLICATE KEY
UPDATE. That drops a query and closes the race where two concurrent tag
additions by the same user both see no row and both insert.

Verified on MariaDB 11.8.8 against a seeded table: duplicate rows
collapse to the expected survivors, NULL-named rows are removed, a
second run is a clean no-op, NULL/duplicate/bad-UserId inserts are
rejected (1048/1062/1452, so the foreign key survives losing its index),
and mysqldump of a fresh install matches a migrated database exactly.

Bump version.txt and the redhat spec Version to 1.39.19.
2026-08-08 11:33:29 -04:00

78 lines
2.8 KiB
SQL

--
-- Make User_Preferences unique per (UserId, Name).
--
-- Nothing enforced this, but every reader already assumes one row per name:
-- User::Preferences() hashes the rows by Name, montagereview.php and
-- User_Preference::find_one() take the first match, and TagOrder::load()
-- reads a single Value. With duplicates present those pick an arbitrary row,
-- so a write could land on one row while reads returned another.
--
-- Make Name NOT NULL, collapse any existing duplicates keeping the highest Id
-- (the most recently inserted), then replace the UserId-only index with a
-- UNIQUE (UserId, Name) index. The unique index still serves UserId-only
-- lookups and the UserId foreign key as a leftmost prefix, so the old one is
-- redundant.
--
-- A preference is only ever reached by name, so a NULL-named row is
-- unreachable: User::Preferences() can't hash it and find_one(['Name'=>...])
-- can't match it. Discard them rather than folding them into '', which would
-- just manufacture duplicates. NULLs must go before the unique index is added
-- anyway -- a unique index permits any number of NULLs, so it would not
-- actually constrain them.
DELETE FROM User_Preferences WHERE Name IS NULL;
SET @s = (SELECT IF(
(SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE table_name = 'User_Preferences'
AND table_schema = DATABASE()
AND column_name = 'Name'
AND is_nullable = 'YES'
) > 0,
"ALTER TABLE `User_Preferences` MODIFY `Name` varchar(64) NOT NULL",
"SELECT 'User_Preferences.Name is already NOT NULL'"
));
PREPARE stmt FROM @s;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
DELETE up FROM User_Preferences AS up
INNER JOIN (
SELECT UserId, Name, MAX(Id) AS KeepId
FROM User_Preferences
GROUP BY UserId, Name
) AS keep
ON up.UserId = keep.UserId AND up.Name = keep.Name
WHERE up.Id <> keep.KeepId;
-- Add the unique index before dropping the old one, so the UserId foreign key
-- always has an index it can use.
SET @s = (SELECT IF(
(SELECT COUNT(*)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE table_name = 'User_Preferences'
AND table_schema = DATABASE()
AND index_name = 'User_Preferences_UserId_Name_idx'
) > 0,
"SELECT 'User_Preferences_UserId_Name_idx already exists on User_Preferences table'",
"ALTER TABLE `User_Preferences` ADD UNIQUE INDEX `User_Preferences_UserId_Name_idx` (`UserId`, `Name`)"
));
PREPARE stmt FROM @s;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @s = (SELECT IF(
(SELECT COUNT(*)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE table_name = 'User_Preferences'
AND table_schema = DATABASE()
AND index_name = 'User_Preferences_UserID_idx'
) > 0,
"ALTER TABLE `User_Preferences` DROP INDEX `User_Preferences_UserID_idx`",
"SELECT 'User_Preferences_UserID_idx already removed from User_Preferences table'"
));
PREPARE stmt FROM @s;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;