Ограничение PostgreSQL с использованием префиксов

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

id | key
---+--------
1  | 'a.b.c'

Мне нужно предотвратить вставку записей с помощью ключа, который является префиксом другого ключа. Например, я должен иметь возможность вставить:

  • 'a.b.b'

Но следующие ключи не должны приниматься:

  • 'a.b'
  • 'a.b.c'
  • 'a.b.c.d'

Есть ли способ достичь этого - либо ограничением, либо механизмом блокировки (проверьте наличие перед вставкой)?

Ответы

Ответ 1

Это решение основано на PostgreSQL пользовательских операторах и ограничениях исключения (базовый синтаксис, подробнее).

ПРИМЕЧАНИЕ: больше тестов показывает, что это решение не работает (пока). См. Внизу.

  • Создайте функцию has_common_prefix (текст, текст), которая будет логически вычислять то, что вам нужно. Отметьте функцию как IMMUTABLE.

    CREATE OR REPLACE FUNCTION
    has_common_prefix(text,text)
    RETURNS boolean
    IMMUTABLE STRICT
    LANGUAGE SQL AS $$
      SELECT position ($1 in $2) = 1 OR position ($2 in $1) = 1
    $$;
    
  • Создайте оператор для индекса

    CREATE OPERATOR <~> (
      PROCEDURE = has_common_prefix,
      LEFTARG   = text,
      RIGHTARG  = text,
      COMMUTATOR = <~>
    );
    
  • Создать ограничение исключения

    CREATE TABLE keys ( key text );
    
    ALTER TABLE keys
      ADD CONSTRAINT keys_cannot_have_common_prefix
      EXCLUDE ( key WITH <~> ); 
    

Однако последняя точка вызывает эту ошибку:

    ERROR:  operator <~>(text,text) is not a member of operator family "text_ops"
    DETAIL:  The exclusion operator must be related to the index operator class for the constraint.

Это связано с тем, что для создания индекса PostgreSQL нужны логические операторы для привязки к физическим методам индексирования, через сущности, вызывающие "классы операторов". Поэтому нам нужно предоставить эту логику:

CREATE OR REPLACE FUNCTION keycmp(text,text)
RETURNS integer IMMUTABLE STRICT
LANGUAGE SQL AS $$
  SELECT CASE
    WHEN $1 = $2 OR position ($1 in $2) = 1 OR position ($2 in $1) = 1 THEN 0
    WHEN $1 < $2 THEN -1
    ELSE 1
  END
$$;

CREATE OPERATOR CLASS key_ops FOR TYPE text USING btree AS
  OPERATOR 3 <~> (text, text),
  FUNCTION 1 keycmp (text, text)
;

ALTER TABLE keys
  ADD CONSTRAINT keys_cannot_have_common_prefix
  EXCLUDE ( key key_ops WITH <~> );

Теперь он работает:

INSERT INTO keys SELECT 'ara';
INSERT 0 1
INSERT INTO keys SELECT 'arka';
INSERT 0 1
INSERT INTO keys SELECT 'barka';
INSERT 0 1
INSERT INTO keys SELECT 'arak';
psql:test.sql:44: ERROR:  conflicting key value violates exclusion constraint "keys_cannot_have_common_prefix"
DETAIL:  Key (key)=(arak) conflicts with existing key (key)=(ara).
INSERT INTO keys SELECT 'bark';
psql:test.sql:45: ERROR:  conflicting key value violates exclusion constraint "keys_cannot_have_common_prefix"
DETAIL:  Key (key)=(bark) conflicts with existing key (key)=(barka).

ПРИМЕЧАНИЕ: больше тестов показывает, что это решение еще не работает: последний INSERT должен выйти из строя.

INSERT INTO keys SELECT 'a';
INSERT 0 1
INSERT INTO keys SELECT 'ac';
ERROR:  conflicting key value violates exclusion constraint "keys_cannot_have_common_prefix"
DETAIL:  Key (key)=(ac) conflicts with existing key (key)=(a).
INSERT INTO keys SELECT 'ab';
INSERT 0 1

Ответ 2

Вы можете использовать ltree модуль, чтобы он мог создать иерархические древовидные структуры. Также поможет вам не изобретать колесо, создавать сложные регулярные выражения и так далее. Вам просто нужно установить пакет postgresql-contrib. Посмотрите:

--Enabling extension
CREATE EXTENSION ltree;

--Creating our test table with a pre-loaded data
CREATE TABLE test_keys AS 
    SELECT 
        1 AS id, 
        'a.b.c'::ltree AS key_path;

--Now we'll do the trick with a before trigger
CREATE FUNCTION validate_key_path() RETURNS trigger AS $$
    BEGIN

        --This query will do our validation. 
        --It'll search if a key already exists in 'both' directions
        --LIMIT 1 because one match is enough for our validation :)    
        PERFORM * FROM test_keys WHERE key_path @> NEW.key_path OR key_path <@ NEW.key_path LIMIT 1;

        --If found a match then raise a error        
        IF FOUND THEN
            RAISE 'Duplicate key detected: %', NEW.key_path USING ERRCODE = 'unique_violation'; 
        END IF;

        --Great! Our new row is able to be inserted     
        RETURN NEW;
    END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER test_keys_validator BEFORE INSERT OR UPDATE ON test_keys
    FOR EACH ROW EXECUTE PROCEDURE validate_key_path();     

--Creating a index to speed up our validation...            
CREATE INDEX idx_test_keys_key_path ON test_keys USING GIST (key_path);

--The command below will work    
INSERT INTO test_keys VALUES (2, 'a.b.b');

--And the commands below will fail 
INSERT INTO test_keys VALUES (3, 'a.b');
INSERT INTO test_keys VALUES (4, 'a.b.c');
INSERT INTO test_keys VALUES (5, 'a.b.c.d');

Конечно, я не стал создавать первичный ключ и другие ограничения для этого теста. Но не забудьте сделать это. Кроме того, в модуле ltree есть намного больше, чем я показываю, если вам нужно что-то другое взглянуть на его документы, возможно, вы найдете там ответ.

Ответ 3

Вы можете попробовать выполнить триггер. Обратите внимание, что key является резервным словом sql. Поэтому я предлагаю вам избегать использования этого имени столбца в вашей таблице. Я добавил свой синтаксис таблицы создания также для целей тестирования:

CREATE TABLE my_table
(myid INTEGER, mykey VARCHAR(50));

CREATE FUNCTION check_key_prefix() RETURNS TRIGGER AS $check_key_prefix$
  DECLARE
    v_match_keys INTEGER;
  BEGIN
    v_match_keys = 0;
    SELECT COUNT(t.mykey) INTO v_match_keys
    FROM my_table t
    WHERE t.mykey LIKE CONCAT(NEW.mykey, '%')
     OR NEW.mykey LIKE CONCAT(t.mykey, '%');

    IF v_match_keys > 0 THEN 
      RAISE EXCEPTION 'Prefix Key Error occured.';
    END IF;

    RETURN NEW;
  END;
$check_key_prefix$ LANGUAGE plpgsql;

CREATE TRIGGER check_key_prefix
 BEFORE INSERT OR UPDATE ON my_table
 FOR EACH ROW
 EXECUTE PROCEDURE check_key_prefix();

Ответ 4

Вот решение на основе CHECK - оно может удовлетворить ваши потребности.

CREATE TABLE keys ( id serial primary key, key text );

CREATE OR REPLACE FUNCTION key_check(text)
RETURNS boolean
STABLE STRICT
LANGUAGE SQL AS $$
  SELECT NOT EXISTS (
    SELECT 1 FROM keys
      WHERE key ~ ( '^' || $1 )
         OR $1 ~ ( '^' || key )
  );
$$;

ALTER TABLE keys
  ADD CONSTRAINT keys_cannot_have_common_prefix
  CHECK ( key_check(key) );

PS. К сожалению, он не работает в одной точке (многострочные вставки).

Ответ 5

SQL - очень мощный язык. Обычно вы можете делать большинство вещей с помощью простых операторов выбора. То есть если вам не нравятся триггеры, вы можете использовать этот метод для своих вставок.

Единственное предположение состоит в том, что в таблице существует не менее 1 строки. (*)

Таблица:

create table my_table
(
    id integer primary key,
    key varchar(100)
);

Из-за предположения мы будем иметь как минимум 1 строку. (*)

insert into my_table (id, key) values (1, 'a.b.c');

Теперь волшебный sql. Трюк заменяет значение p_key значением вашего ключа для вставки. Я намеренно не помещаю это выражение в хранимую процедуру. Потому что я хочу, чтобы это было прямо, если вы хотите отнести его к своей стороне приложения. Но обычно класть sql в хранимую процедуру лучше.

insert into my_table (id, key)
    select (select max(id) + 1 from my_table), p_key
        from my_table
        where not exists (select 'p' from my_table where key like p_key || '%' or p_key like key || '%')
        limit 1;

Теперь тесты:

-- 'a.b.b' => Inserts
insert into my_table (id, key)
    select (select max(id) + 1 from my_table), 'a.b.b'
        from my_table
        where not exists (select 'p' from my_table where key like 'a.b.b' || '%' or 'a.b.b' like key || '%')
        limit 1;


-- 'a.b' => does not insert
insert into my_table (id, key)
    select (select max(id) + 1 from my_table), 'a.b'
        from my_table
        where not exists (select 'p' from my_table where key like 'a.b' || '%' or 'a.b' like key || '%')
        limit 1;


-- 'a.b.c' => does not insert
insert into my_table (id, key)
    select (select max(id) + 1 from my_table), 'a.b.c'
        from my_table
        where not exists (select 'p' from my_table where key like 'a.b.c' || '%' or 'a.b.c' like key || '%')
        limit 1;

-- 'a.b.c.d' does not insert
insert into my_table (id, key)
    select (select max(id) + 1 from my_table), 'a.b.c.d'
        from my_table
        where not exists (select 'p' from my_table where key like 'a.b.c.d' || '%' or 'a.b.c.d' like key || '%')
        limit 1;

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

Ответ 6

Одним из возможных решений является создание вторичной таблицы, содержащей префиксы ваших ключей, а затем использование комбинации уникальных ограничений и исключений с помощью триггера insert для обеспечения семантики уникальности, которую вы хотите.

На высоком уровне этот подход разбивает каждый ключ на список префиксов и применяет нечто похожее на семантику блокировки чтения-записи: любое количество ключей может делиться префиксом, если ни один из ключей не равен префиксу. Для этого список префиксов включает сам ключ с флагом, который маркирует его как конечный префикс.

Вторичная таблица выглядит так. Мы используем CHAR вместо BOOLEAN для флага, потому что позже следует добавить ограничение, которое не работает с булевыми столбцами.

CREATE TABLE prefixes (
    id INTEGER NOT NULL,
    prefix TEXT NOT NULL,
    is_terminal CHAR NOT NULL,

    CONSTRAINT prefixes_id_fk
    FOREIGN KEY (id)
    REFERENCES your_table (id)
    ON DELETE CASCADE,

    CONSTRAINT prefixes_is_terminal
    CHECK (is_terminal IN ('t', 'f'))
);

Теперь нам нужно определить триггер для вставки в your_table, чтобы также вставить строки в prefixes, чтобы

INSERT INTO your_table (id, key) VALUES (1, ‘abc');

Причины

INSERT INTO prefixes (id, prefix, is_terminal) VALUES (1, 'a', ‘f’);
INSERT INTO prefixes (id, prefix, is_terminal) VALUES (1, 'ab', ‘f’);
INSERT INTO prefixes (id, prefix, is_terminal) VALUES (1, 'abc', ’t’);

Функция триггера может выглядеть так. Im только покрывает случай INSERT здесь, но эту функцию можно было бы обработать с помощью UPDATE, удалив старые префиксы, а затем вставив новые. Случай DELETE покрывается каскадным ограничением внешнего ключа на prefixes.

CREATE OR REPLACE FUNCTION insert_prefixes() RETURNS TRIGGER AS $$
DECLARE
  is_terminal CHAR := 't';
  remaining_text TEXT := NEW.key;
BEGIN
  LOOP
    IF LENGTH(remaining_text) <= 0 THEN
      EXIT;
    END IF;

    INSERT INTO prefixes (id, prefix, is_terminal)
        VALUES (NEW.id, remaining_text, is_terminal);

    is_terminal := 'f';
    remaining_text := LEFT(remaining_text, -1);
  END LOOP;

  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

Мы добавляем эту функцию в таблицу как триггер обычным способом.

CREATE TRIGGER insert_prefixes
AFTER INSERT ON your_table
FOR EACH ROW
  EXECUTE PROCEDURE insert_prefixes();

Ограничение исключения и частичный уникальный индекс будут обеспечивать, чтобы строка, в которой is_terminal = ’t’ не могла столкнуться с другой строкой того же префикса, независимо от ее значения is_terminal, и что существует только одна строка с is_terminal = ’t’:

ALTER TABLE prefixes ADD CONSTRAINT prefixes_forbid_conflicts
  EXCLUDE USING gist (prefix WITH =, is_terminal WITH <>);

CREATE UNIQUE INDEX ON prefixes (prefix) WHERE is_terminal = 't';

Это позволяет создавать новые строки, которые не конфликтуют, но предотвращают конфликты, в том числе в многострочных INSERT.

db=# INSERT INTO your_table (id, key) VALUES (1, 'a.b.c');
INSERT 0 1

db=# INSERT INTO your_table (id, key) VALUES (2, 'a.b.b');
INSERT 0 1

db=# INSERT INTO your_table (id, key) VALUES (3, 'a.b');
ERROR:  conflicting key value violates exclusion constraint "prefixes_forbid_conflicts"

db=# INSERT INTO your_table (id, key) VALUES (4, 'a.b.c');
ERROR:  duplicate key value violates unique constraint "prefixes_prefix_idx"

db=# INSERT INTO your_table (id, key) VALUES (5, 'a.b.c.d');
ERROR:  conflicting key value violates exclusion constraint "prefixes_forbid_conflicts"

db=# INSERT INTO your_table (id, key) VALUES (6, 'a.b.d'), (7, 'a');
ERROR:  conflicting key value violates exclusion constraint "prefixes_forbid_conflicts"