PostgreSQL UPSERT проблема со значениями NULL

13

У меня проблема с использованием новой функции UPSERT в Postgres 9.5

У меня есть таблица, которая используется для агрегирования данных из другой таблицы. Составной ключ состоит из 20 столбцов, 10 из которых могут быть обнуляемыми. Ниже я создал уменьшенную версию проблемы, в частности, со значениями NULL.

CREATE TABLE public.test_upsert (
upsert_id serial,
name character varying(32) NOT NULL,
status integer NOT NULL,
test_field text,
identifier character varying(255),
count integer,
CONSTRAINT upsert_id_pkey PRIMARY KEY (upsert_id),
CONSTRAINT test_upsert_name_status_test_field_key UNIQUE (name, status, test_field)
);

Выполнение этого запроса работает по мере необходимости (сначала вставка, затем последующие вставки просто увеличивают счетчик):

INSERT INTO test_upsert as tu(name,status,test_field,identifier, count) 
VALUES ('shaun',1,'test value','ident', 1)
ON CONFLICT (name,status,test_field) DO UPDATE set count = tu.count + 1 
where tu.name = 'shaun' AND tu.status = 1 AND tu.test_field = 'test value';

Однако, если я выполню этот запрос, каждый раз вставляется 1 строка, а не увеличивается счетчик для начальной строки:

INSERT INTO test_upsert as tu(name,status,test_field,identifier, count) 
VALUES ('shaun',1,null,'ident', 1)
ON CONFLICT (name,status,test_field) DO UPDATE set count = tu.count + 1  
where tu.name = 'shaun' AND tu.status = 1 AND tu.test_field = null;

Это моя проблема. Мне нужно просто увеличить значение счетчика, а не создавать несколько одинаковых строк с нулевыми значениями.

Попытка добавить частичный уникальный индекс:

CREATE UNIQUE INDEX test_upsert_upsert_id_idx
ON public.test_upsert
USING btree
(name COLLATE pg_catalog."default", status, test_field, identifier);

Однако это дает те же результаты, либо вставляются несколько пустых строк, либо это сообщение об ошибке при попытке вставить:

ОШИБКА: нет уникального или исключающего ограничения, соответствующего спецификации ON CONFLICT

Я уже пытался добавить дополнительные детали в частичный индекс, такие как WHERE test_field is not null OR identifier is not null. Однако при вставке я получаю сообщение об ошибке ограничения.

Шон МакКриди
источник

Ответы:

14

Уточнить ON CONFLICT DO UPDATEповедение

Рассмотрите руководство здесь :

Для каждой отдельной строки, предлагаемой для вставки, либо вставка продолжается, либо, если ограничение арбитра или индекс, заданный параметром, conflict_targetнарушается, используется альтернатива conflict_action.

Жирный акцент мой. Таким образом, вам не нужно повторять предикаты для столбцов, включенных в уникальный индекс в WHEREпредложении UPDATE(the conflict_action):

INSERT INTO test_upsert AS tu
       (name   , status, test_field  , identifier, count) 
VALUES ('shaun', 1     , 'test value', 'ident'   , 1)
ON CONFLICT (name, status, test_field) DO UPDATE
SET count = tu.count + 1;
WHERE tu.name = 'shaun' AND tu.status = 1 AND tu.test_field = 'test value'

Уникальное нарушение уже устанавливает то, что добавленное вами WHEREусловие будет принудительно исполнено.

Уточнить частичный индекс

Добавьте WHEREпредложение, чтобы сделать его фактическим частичным индексом, как вы упомянули сами (но с перевернутой логикой):

CREATE UNIQUE INDEX test_upsert_partial_idx
ON public.test_upsert (name, status)
WHERE test_field IS NULL;  -- not: "is not null"

Чтобы использовать этот частичный индекс в вашем UPSERT, вам нужно соответствие типа @ypercube, демонстрирующее :conflict_target

ON CONFLICT (name, status) WHERE test_field IS NULL

Теперь приведенный выше частичный индекс выведен. Тем не менее , как руководство также отмечает :

[...] неполный уникальный индекс (уникальный индекс без предиката) будет выведен (и, следовательно, использован ON CONFLICT), если такой индекс, удовлетворяющий всем остальным критериям, доступен.

Если у вас есть дополнительный (или единственный) индекс только для (name, status)него, он будет (также) использоваться. Индекс на (name, status, test_field)явно не будет выведен. Это не объясняет вашу проблему, но может привести к путанице во время тестирования.

Решение

AIUI, ничто из перечисленного не решает твою проблему , пока. С частичным индексом будут обнаружены только особые случаи с совпадающими значениями NULL. И другие повторяющиеся строки будут либо вставлены, если у вас нет других совпадающих уникальных индексов / ограничений, либо вызовут исключение, если вы это сделаете. Я полагаю, это не то, что вы хотите. Ты пишешь:

Составной ключ состоит из 20 столбцов, 10 из которых могут быть обнуляемыми.

Что именно вы считаете дубликатом? Postgres (согласно стандарту SQL) не считает два значения NULL равными. Руководство:

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

Связанный:

Я предполагаю, что вы хотите, чтобыNULLзначения во всех 10 обнуляемых столбцах считались равными. Элегантно и практично покрывать один столбец, который может быть пустым, с дополнительным частичным индексом, как показано здесь:

Но это быстро выходит из-под контроля для более обнуляемых столбцов. Вам понадобится частичный индекс для каждой отдельной комбинации столбцов, допускающих значение NULL. Только для 2 из них это 3 частных индекса (a), (b)и (a,b). Число растет в геометрической прогрессии с 2^n - 1. Для того чтобы ваши 10 столбцов, допускающих значение NULL, покрывали все возможные комбинации значений NULL, вам уже понадобится 1023 частичных индекса. Нет идти

Простое решение: замените значения NULL и определите соответствующие столбцы NOT NULL, и все будет прекрасно работать с простым UNIQUEограничением.

Если это не вариант, я предлагаю использовать индекс индекса COALESCEдля замены NULL в индексе:

CREATE UNIQUE INDEX test_upsert_solution_idx
    ON test_upsert (name, status, COALESCE(test_field, ''));

Пустая строка ( '') является очевидным кандидатом для типов символов, но вы можете использовать любое допустимое значение, которое либо никогда не появляется, либо может быть свернуто с помощью NULL в соответствии с вашим определением «уникального».

Тогда используйте это утверждение:

INSERT INTO test_upsert as tu(name,status,test_field,identifier, count) 
VALUES ('shaun', 1, null        , 'ident', 11)  -- works with
     , ('bob'  , 2, 'test value', 'ident', 22)  -- and without NULL
ON     CONFLICT (name, status, COALESCE(test_field, '')) DO UPDATE  -- match expr. index
SET    count = COALESCE(tu.count + EXCLUDED.count, EXCLUDED.count, tu.count);

Как и @ypercube, я предполагаю, что вы действительно хотите добавить countк существующему количеству. Поскольку столбец может быть NULL, добавление NULL установит столбец NULL. Если вы определите count NOT NULL, вы можете упростить.


Другая идея заключается в том, чтобы просто исключить объект конфликта из заявления, чтобы охватить все уникальные нарушения . Затем вы можете определить различные уникальные индексы для более сложного определения того, что должно быть «уникальным». Но это не сработает ON CONFLICT DO UPDATE. Руководство еще раз:

Для ON CONFLICT DO NOTHING, это необязательно, чтобы указать конфликта_target; если опущено, обрабатываются конфликты со всеми используемыми ограничениями (и уникальными индексами). Для ON CONFLICT DO UPDATE, конфликт_ цель должен быть предоставлен.

Эрвин Брандштеттер
источник
1
Ницца. Я пропустил часть из 20-10 столбцов в первый раз, когда прочитал вопрос, и у меня не было времени закончить позже. count = CASE WHEN EXCLUDED.count IS NULL THEN tu.count ELSE COALESCE(tu.count, 0) + COALESCE(EXCLUDED.count, 0) ENDМожет быть упрощенаcount = COALESCE(tu.count+EXCLUDED.count, EXCLUDED.count, tu.count)
ypercubeᵀᴹ
Посмотрим еще раз, моя «упрощенная» версия не так самодокументирована.
ypercubeᵀᴹ
@ ypercubeᵀᴹ: я применил предложенное вами обновление. Это проще, спасибо.
Эрвин Брандштеттер
@ErwinBrandstetter ты лучший
Симус Абшер
7

Я думаю, проблема в том, что у вас нет частичного индекса, а ON CONFLICTсинтаксис не совпадает с test_upsert_upsert_id_idxиндексом, а с другим уникальным ограничением.

Если вы определите индекс как частичный (с WHERE test_field IS NULL):

CREATE UNIQUE INDEX test_upsert_upsert_id_idx
ON public.test_upsert
USING btree
(name COLLATE pg_catalog."default", status)
WHERE test_field IS NULL ;

и эти строки уже в таблице:

INSERT INTO test_upsert as tu
    (name, status, test_field, identifier, count) 
VALUES 
    ('shaun', 1, null, 'ident', 1),
    ('maria', 1, null, 'ident', 1) ;

тогда запрос будет выполнен успешно:

INSERT INTO test_upsert as tu
    (name, status, test_field, identifier, count) 
VALUES 
    ('peter', 1,   17, 'ident', 1),
    ('shaun', 1, null, 'ident', 3),
    ('maria', 1, null, 'ident', 7)
ON CONFLICT 
    (name, status) WHERE test_field IS NULL   -- the conflicting condition
DO UPDATE SET
    count = tu.count + EXCLUDED.count 
WHERE                                         -- when to update
    tu.name = 'shaun' AND tu.status = 1 ;     -- if you don't want all of the
                                              -- updates to happen

со следующими результатами:

('peter', 1,   17, 'ident', 1)  -- no conflict: row inserted

('shaun', 1, null, 'ident', 3)  -- conflict: no insert
                           -- matches where: row updated with count = 1+3 = 4

('maria', 1, null, 'ident', 1)  -- conflict: no insert
                     -- doesn't match where: no update
ypercubeᵀᴹ
источник
Это разъясняет, как использовать частичный индекс. Но (я думаю) это еще не решает проблему.
Эрвин Брандштеттер
не должен ли счет 'maria' оставаться на 1, поскольку обновления не происходит?
mpprdev
@mpprdev да, ты прав.
ypercubeᵀᴹ