SQL HowTo: замена в строке по набору

Решим сегодня простую, казалось бы, задачу: как на PostgreSQL можно в строке провести замены по набору пар строк. То есть в исходной строке 'abcdaaabbbcccdcba' заменить, например, {'а' -> 'x', 'bb' -> 'y', 'ccc' -> 'z'} и получить 'xbcdxxxybzdcbx'.

Фактически, мы попробуем создать аналог str_replace или strtr.

Find and Replace
Find and Replace

Callback Hell

Первое, что приходит на ум - это сделать цепочку из вложенных вызовов replace:

SELECT
  replace( -- ... и так 100500 раз
    replace(
      replace(
        'abcdaaabbbcccdcba' -- исходная строка
      , 'a'
      , 'x'
      )
    , 'bb'
    , 'y'
    )
  , 'ccc'
  , 'z'
  );

Такой код настолько же эффективен, насколько и нерасширяем.

Рекурсия

Зайдем на проблему с другой стороны.

Мы хотим последовательно в цикле заменять одну подстроку на другую - а за "циклы" в SQL отвечает рекурсия:

WITH RECURSIVE rpl AS (
  SELECT
    row_number() OVER() i -- нумеруем наши замены
  , *
  FROM
    (
      VALUES -- список замен теперь легко расширяем
        ('a',   'x')
      , ('bb',  'y')
      , ('ccc', 'z')
    ) T(f, t)
)
, R AS (
  SELECT
    1::bigint i
  , 'abcdaaabbbcccdcba' s -- исходная строка
UNION ALL
  SELECT
    i + 1
  , replace(R.s, rpl.f, rpl.t) -- заменяем i-ю пару
  FROM
    R
  NATURAL JOIN -- USING(i)
    rpl
)
SELECT
  s
FROM
  R
ORDER BY
  i DESC -- возвращаем результат последнего шага
LIMIT 1;

strtr

Оба эти варианта обладают неприятной особенностью повторной замены - то есть каждый следующий шаг опирается на результат предыдущего, как в str_replace. Поэтому при замене исходной строки 'aaa' с набором {'a' -> 'b', 'b' -> 'c'} мы получим 'ccc', а вовсе не 'bbb'.

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

WITH src(s) AS (
  VALUES('abcdaaabbbcccdcba') -- исходная строка
)
, rpl AS (
  SELECT -- набор замен в виде json-объекта
    '{
       "a"   : "x"
     , "bb"  : "y"
     , "ccc" : "z"
     }'::json
)
, rpl_re AS (
  SELECT
    '(' || string_agg(k, '|') || ')' re -- '(a|bb|ccc)'
  FROM
    json_object_keys((TABLE rpl)) k -- получаем все ключи для замен
)
, spl AS (
  SELECT
    T.*
  FROM
    src
  , rpl_re
  , unnest( -- совместный unnest двух разноразмерных массивов
      regexp_split_to_array(s, re) -- тут части "между" ключами
    , ARRAY( -- тут сами ключи
        SELECT
          m[1]
        FROM
          regexp_matches(s, re, 'g') m
      )
    ) T(part, key)
)
SELECT
  string_agg(concat(part, (TABLE rpl) ->> key), '') -- подставляем найденные ключи по словарю замен
FROM
  spl;

В нашем примереunnest(regexp_split_to_array, ARRAY(regexp_matches[1])) вернет следующий результат:

part | key
 --- | a
 bcd | a
 --- | a
 --- | a
 --- | bb
   b | ccc
 dcb | a
 --- | ---

После чего нам только остается для каждого ключа выполнить подстановку на целевое значение и собрать строку обратно через string_agg.

Вот и все!

@Kilor
11.05.2023 19:40 UTC
Первоисточник

Комментарии

@Kuch
11.05.2023 20:31 UTC
+1
CREATE OR REPLACE FUNCTION replace_multiple(input_string TEXT, replacements TEXT[])
  RETURNS TEXT AS
$$
DECLARE
  i INT;
BEGIN
  IF cardinality(replacements) % 2 <> 0 THEN
    RAISE EXCEPTION 'Number of replacements must be even';
  END IF;
  
  FOR i IN 1..cardinality(replacements)/2 LOOP
    input_string := replace(input_string, replacements[(i-1)*2+1], replacements[i*2]);
  END LOOP;
  
  RETURN input_string;
END;
$$
LANGUAGE plpgsql;

@Kilor
11.05.2023 21:57 UTC
0

Да, но та же проблема с повторной заменой.

@AxelLx
12.05.2023 14:31 UTC
0

Надо уточнять начальные условия.


  1. Строка 'ab', подстановки {'а' -> 'x', 'b' -> 'y', 'ab' -> 'z'}. Какой результат ожидается?
  2. Строка 'ab', подстановка {'' -> 'x'}. Какой результат ожидается?
  3. Какие символы допустимы в изначальной строке? Какие символы допустимы в подстановках?
@Kilor
12.05.2023 14:36 UTC
0

А если еще вспомнить, что '.' в regexp совсем не то же самое, что в качестве подстроки...

Все правильно, и это все тоже можно обработать так же, но усложнения статьи это не стоило.

@asmm
22.04.2024 20:51 UTC
+1

Можно значительно проще и экономнее на regexp

Пример на MySQL (на PG думаю будет аналогично):

WITH d AS (
    SELECT 'abcdaaabbbcccdcba' s
    , 'a,x;bb,y;ccc,z;' repls
    , 'xbcdxxxybzdcbx' test_res
)
, d2 AS (
    SELECT *, CONCAT(s, '\n', repls) s_ex
    FROM d
)
, d3 AS (
    SELECT *
    , SUBSTRING_INDEX(REGEXP_REPLACE(s_ex, '(?<search>[^\n]+)(?=.*?(\n|\n.*?;)\\\k<search>,(?<repl>[^;]+);)', '${repl}', 1, 0, 'm'), '\n', 1) res
    FROM d2 d
)
SELECT s, res
, res = test_res is_correct
, VERSION() ver
FROM d3

+-----------------+--------------+----------+-----+
|s                |res           |is_correct|ver  |
+-----------------+--------------+----------+-----+
|abcdaaabbbcccdcba|xbcdxxxybzdcbx|1         |8.3.0|
+-----------------+--------------+----------+-----+

@Kilor
22.04.2024 22:02 UTC
0

Способ любопытный. Правда, опирается на отсутствие \n в исходной строке и ; в заменах.

23.04.2024 00:46 UTC
0

не проблема сгенерить 3 уникальных разделителя
- между строкой и всеми заменами
- между поиском и заменой
- между группами замен
и использовать их для построения регулярки

@asmm
24.04.2024 22:04 UTC
0

Нашёл подобное приемлемое решение на MySQL как закапиталайзить предложение

WITH d AS (
    SELECT 'Lorem Ipsum is simply dummy text of the printing and typesetting industry.' s
)
, d2 AS (
    SELECT *
    , CONCAT(LOWER(s), UPPER(s)) s_l_u
    , CONCAT('(?<=[[:space:]]|^)[a-z](?=.{', (CHAR_LENGTH(s) - 1), '}([A-Z]))') reg_exp
    , CHAR_LENGTH(s) len
    FROM d
)
SELECT version() ver, s, SUBSTR(REGEXP_REPLACE(s_l_u, reg_exp, '$1'), 1, len) cap
FROM d2

+------+--------------------------------------------------------------------------+--------------------------------------------------------------------------+
|ver   |s                                                                         |cap                                                                       |
+------+--------------------------------------------------------------------------+--------------------------------------------------------------------------+
|8.0.35|Lorem Ipsum is simply dummy text of the printing and typesetting industry.|Lorem Ipsum Is Simply Dummy Text Of The Printing And Typesetting Industry.|
+------+--------------------------------------------------------------------------+--------------------------------------------------------------------------+