PostgreSQL с несколькими допустимыми столбцами в уникальном ограничении

У нас есть схема устаревшей базы данных, в которой есть несколько интересных дизайнерских решений. До недавнего времени мы поддерживали только Oracle и SQL Server, но мы пытаемся добавить поддержку PostgreSQL, что вызвало интересную проблему. Я искал Qaru и остальную часть Интернета, и я не считаю, что эта конкретная ситуация является дубликатом.

Oracle и SQL Server ведут себя одинаково, когда дело доходит до нулевых столбцов в уникальном ограничении, которое должно по существу игнорировать столбцы, которые являются NULL при выполнении уникальной проверки.

Скажем, у меня есть следующая таблица и ограничение:

CREATE TABLE EXAMPLE
(
    ID TEXT NOT NULL PRIMARY KEY,
    FIELD1 TEXT NULL,
    FIELD2 TEXT NULL,
    FIELD3 TEXT NULL,
    FIELD4 TEXT NULL,
    FIELD5 TEXT NULL,
    ...
);

CREATE UNIQUE INDEX EXAMPLE_INDEX ON EXAMPLE
(
    FIELD1 ASC,
    FIELD2 ASC,
    FIELD3 ASC,
    FIELD4 ASC,
    FIELD5 ASC
);

Как на Oracle, так и на SQL Server, любой из столбцов с нулевым значением NULL приведет только к выполнению проверки уникальности для столбцов, отличных от нуля. Таким образом, следующие вставки можно выполнить только один раз:

INSERT INTO EXAMPLE VALUES ('1','FIELD1_DATA', NULL, NULL, NULL, NULL );
INSERT INTO EXAMPLE VALUES ('2','FIELD1_DATA','FIELD2_DATA', NULL, NULL,'FIELD5_DATA');
-- These will succeed when they should violate the unique constraint:
INSERT INTO EXAMPLE VALUES ('3','FIELD1_DATA', NULL, NULL, NULL, NULL );
INSERT INTO EXAMPLE VALUES ('4','FIELD1_DATA','FIELD2_DATA', NULL, NULL,'FIELD5_DATA');

Однако, поскольку PostgreSQL (правильно) придерживается стандарта SQL, эти вставки (и любая другая комбинация значений до тех пор, пока один из них имеет значение NULL) не будут вызывать ошибку и быть вставлены правильно без проблем. К сожалению, из-за нашей устаревшей схемы и поддерживающего кода нам нужно, чтобы PostgreSQL вел себя так же, как SQL Server и Oracle.

Мне известно о следующем вопросе и его ответах: Создать уникальное ограничение с нулевыми столбцами. По моему мнению, для решения этой проблемы есть две стратегии:

  • Создайте частичные индексы, которые описывают индекс в тех случаях, когда нулевые столбцы являются как NULL, так и NOT NULL (что приводит к экспоненциальному росту числа частичных индексов)
  • Используйте COAELSCE со значением дозорного значения для столбцов с нулевым значением в индексе.

Проблема с (1) заключается в том, что количество частичных индексов, которые нам нужно создать, растет экспоненциально с каждым дополнительным столбцом с нулевым значением, который мы хотели бы добавить к ограничению (2 ^ N, если я не ошибаюсь). Проблемы с (2) состоят в том, что контрольные значения уменьшают количество доступных значений для этого столбца и все потенциальные проблемы с производительностью.

Мой вопрос: это единственные два решения этой проблемы? Если да, то каковы компромиссы между ними для данного конкретного случая использования? Хорошим ответом будет обсуждение производительности каждого решения, ремонтопригодность, как PostgreSQL будет использовать эти индексы в простых операциях SELECT и любых других "gotchas" или вещах, о которых нужно знать. Имейте в виду, что 5 нулевых столбцов были только для примера; у нас есть несколько таблиц в нашей схеме с до 10 (да, я плачу каждый раз, когда вижу это, но это то, что есть).

Ответ 1

Вы стремитесь к совместимости с существующими реализациями Oracle и SQL Server.
Вот презентация сравнивающая физические форматы хранения строк трех задействованных RDBS.

Так как Oracle не реализует значения NULL вообще в хранилище строк, он никак не может различить пустую строку и NULL. Так что было бы разумно использовать пустые строки ('') вместо значений NULL в Postgres, а также - для этого конкретного случая использования?

Определите столбцы, включенные в уникальное ограничение как NOT NULL DEFAULT '', проблема решена:

CREATE TABLE example (
   example_id serial PRIMARY KEY
 , field1 text NOT NULL DEFAULT ''
 , field2 text NOT NULL DEFAULT ''
 , field3 text NOT NULL DEFAULT ''
 , field4 text NOT NULL DEFAULT ''
 , field5 text NOT NULL DEFAULT ''
 , CONSTRAINT example_index UNIQUE (field1, field2, field3, field4, field5)
);

Примечания

  • То, что вы демонстрируете в вопросе, это уникальный индекс:

    CREATE UNIQUE INDEX ...
    

    не уникальное ограничение, о котором вы говорите. Есть тонкие, важные отличия!

    Я изменил это на фактическое ограничение, так как вы сделали его предметом сообщения.

  • Ключевое слово ASC - это просто шум, так как это порядок сортировки по умолчанию. Я оставил его.

  • Использование столбца serial PK для простоты, которое является полностью необязательным, но обычно лучше, чем числа, хранящиеся как text.

Работа с ним

Просто опустите пустые/нулевые поля из INSERT:

INSERT INTO example(field1) VALUES ('F1_DATA');
INSERT INTO example(field1, field2, field5) VALUES ('F1_DATA', 'F2_DATA', 'F5_DATA');

Повторение любой из вставок тезиса будет нарушать единственное ограничение.

Или, если вы настаиваете на исключении целевых столбцов (это немного антипаттерн в постоянных заявках INSERT):
Или для объемных вставок, где должны быть перечислены все столбцы:

INSERT INTO example VALUES
  ('1', 'F1_DATA', DEFAULT, DEFAULT, DEFAULT, DEFAULT)
, ('2', 'F1_DATA','F2_DATA', DEFAULT, DEFAULT,'F5_DATA');

Или просто:

INSERT INTO example VALUES
  ('1', 'F1_DATA', '', '', '', '')
, ('2', 'F1_DATA','F2_DATA', '', '','F5_DATA');

Или вы можете написать триггер BEFORE INSERT OR UPDATE, который преобразует NULL в ''.

Альтернативные решения

Если вам нужно использовать фактические значения NULL, я бы предложил уникальный индекс с COALESCE, как вы упомянули в качестве опции (2) и @wildplasser как его последний пример.

Индекс в массиве , например @Rudolfo, представлен просто, но значительно дороже. Обработка массивов в Postgres не очень дешева, и накладные расходы массива аналогичны строкам строки (24 байта):

Массивы ограничены столбцами одного и того же типа данных. Вы можете использовать все столбцы для text, если это не так, но это, как правило, дополнительно увеличивает требования к хранению. Или вы можете использовать известный тип строки для гетерогенных типов данных...

Угловой регистр: типы массива (или строки) со всеми значениями NULL считаются равными (!), поэтому может быть только 1 строка со всеми задействованными столбцами NULL. Может или не может быть желательным. Если вы хотите запретить все столбцы NULL:

Ответ 2

Третий метод: используйте IS NOT DISTINCT FROM insted of = для сравнения ключевых столбцов. (Это может использовать существующий индекс для естественного ключа кандидата) Пример (посмотрите на последний столбец)

SELECT *
    , EXISTS (SELECT * FROM example x
     WHERE x.FIELD1 IS NOT DISTINCT FROM e.FIELD1
     AND x.FIELD2 IS NOT DISTINCT FROM e.FIELD2
     AND x.FIELD3 IS NOT DISTINCT FROM e.FIELD3
     AND x.FIELD4 IS NOT DISTINCT FROM e.FIELD4
     AND x.FIELD5 IS NOT DISTINCT FROM e.FIELD5
     AND x.ID <> e.ID
    ) other_exists
FROM example e
    ;

Следующим шагом было бы поместить это в триггерную функцию и поставить на нее триггер. (у меня нет времени, может быть, позже)


И вот триггер-функция (которая еще не идеальна, но, похоже, работает):


CREATE FUNCTION example_check() RETURNS trigger AS $func$
BEGIN
    -- Check that empname and salary are given
    IF EXISTS (
     SELECT 666 FROM example x
     WHERE x.FIELD1 IS NOT DISTINCT FROM NEW.FIELD1
     AND x.FIELD2 IS NOT DISTINCT FROM NEW.FIELD2
     AND x.FIELD3 IS NOT DISTINCT FROM NEW.FIELD3
     AND x.FIELD4 IS NOT DISTINCT FROM NEW.FIELD4
     AND x.FIELD5 IS NOT DISTINCT FROM NEW.FIELD5
     AND x.ID <> NEW.ID
            ) THEN
        RAISE EXCEPTION 'MultiLul BV';
    END IF;


    RETURN NEW;
END;
$func$ LANGUAGE plpgsql;

CREATE TRIGGER example_check BEFORE INSERT OR UPDATE ON example
  FOR EACH ROW EXECUTE PROCEDURE example_check();

UPDATE: уникальный индекс иногда может быть обернут в ограничение (см. Postgres-9.4 docs, окончательный пример). Вам нужно изобрести значение дозорного; Я использовал пустую строку '' здесь.


CREATE UNIQUE INDEX ex_12345 ON example
        (coalesce(FIELD1, '')
        , coalesce(FIELD2, '')
        , coalesce(FIELD3, '')
        , coalesce(FIELD4, '')
        , coalesce(FIELD5, '')
        )
        ;

ALTER TABLE example
        ADD CONSTRAINT con_ex_12345
        USING INDEX ex_12345;

Но "функциональный" индекс на coalesce() в этой конструкции не допускается. Уникальный индекс (вариант OP 2) все еще работает, хотя:


ERROR:  index "ex_12345" contains expressions
LINE 2:  ADD CONSTRAINT con_ex_12345
             ^
DETAIL:  Cannot create a primary key or unique constraint using such an index.
INSERT 0 1
INSERT 0 1
ERROR:  duplicate key value violates unique constraint "ex_12345"

Ответ 3

Это действительно сработало для меня:

CREATE UNIQUE INDEX index_name ON table_name ((
   ARRAY[field1, field2, field3, field4]
));

Я не знаю, как влияет производительность, но она должна быть близка к идеальной (в зависимости от того, насколько хорошо оптимизированы массивы в postres)

Ответ 4

Вы можете создать правило для вставки ВСЕХ значений NULL вместо исходной таблицы в разделы, такие как partition_field1_nullable, partition_fiend2_nullable и т.д. Таким образом вы создаете уникальный индекс только в исходной таблице (без нулей). Это позволит вам вставлять не null только в исходную таблицу (с uniqness), так и столько же значений, но не уникальных (соответственно) значений для "нулевых разделов". И вы можете применять метод COALESCE или триггер только для нулевых разделов, чтобы избежать множества разбросанных частичных индексов и запускать против каждого DML в исходной таблице...