Skip to content

Instantly share code, notes, and snippets.

@villelahdenvuo
Created June 14, 2016 12:24
Show Gist options
  • Save villelahdenvuo/5b1fbbf3f22853475bba14f8833faada to your computer and use it in GitHub Desktop.
Save villelahdenvuo/5b1fbbf3f22853475bba14f8833faada to your computer and use it in GitHub Desktop.
PostgreSQL jsonb_set is nice, but it does't create missing objects so I made this little helper.
CREATE OR REPLACE FUNCTION jsonb_set_deep(target jsonb, path text[], val jsonb)
RETURNS jsonb AS $$
DECLARE
k text;
p text[];
BEGIN
-- Create missing objects in the path.
FOREACH k IN ARRAY path LOOP
p := p || k;
IF (target #> p IS NULL) THEN
target := jsonb_set(target, p, '{}'::jsonb);
END IF;
END LOOP;
-- Set the value like normal.
RETURN jsonb_set(target, path, val);
END;
$$ LANGUAGE plpgsql;
@lightoze
Copy link

Great, this is exactly what I was expecting from jsonb_set until reading specification fine print.

@zeiler
Copy link

zeiler commented Jun 10, 2020

This doesn't work on postgrest 9.6 if the target is null originally, here is a version that does:

CREATE OR REPLACE FUNCTION jsonb_set_deep(target jsonb, path text[], val jsonb)
  RETURNS jsonb AS $$
    DECLARE
      k text;
      p text[];
    BEGIN
      -- Create missing objects in the path.
      IF (target IS NULL) THEN
        target = '{}'::jsonb;
      END IF;
      FOREACH k IN ARRAY path LOOP
        p := p || k;
        IF (target #> p IS NULL) THEN
          target := jsonb_set(target, p, '{}'::jsonb);
        END IF;
      END LOOP;
      -- Set the value like normal.
      RETURN jsonb_set(target, path, val);
    END;
  $$ LANGUAGE plpgsql;

@Venryx
Copy link

Venryx commented Oct 12, 2021

For those wanting to avoid creating a new PostgreSQL function, there is a way you can emulate the same sort of behavior within the query itself, using the || operator (as seen here).

Example:

update "myTable" set "myField" = jsonb_set(
  '{"mySubField": {}}' || "myField",
  '{mySubField,newEntryKey}',
  'newEntryValue'
) where "id" = 'myRowID' returning *

If your target path is deeper than that, it's probably best to stop providing jsonb_set with deep-paths, instead using a nested approach like so:

update "myTable" set "myField" = jsonb_set(
  COALESCE("myField", '{}'),
  '{"depth1"}',
  jsonb_set(
    COALESCE("myField"->'depth1', '{}'),
    '{"depth2"}',
    jsonb_set(
      COALESCE("myField"->'depth1'->'depth2', '{}'),
      '{"depth3"}',
      jsonb_set(
        COALESCE("myField"->'depth1'->'depth2'->'depth3', '{}'),
        '{"depth4"}',
        '"newValue"'
      )
    )
  )
) where "id" = 'myRowID' returning *

Or compacted:

update "myTable" set "myField" =
  jsonb_set(COALESCE("myField", '{}'), '{"depth1"}',
  jsonb_set(COALESCE("myField"->'depth1', '{}'), '{"depth2"}',
  jsonb_set(COALESCE("myField"->'depth1'->'depth2', '{}'), '{"depth3"}',
  jsonb_set(COALESCE("myField"->'depth1'->'depth2'->'depth3', '{}'), '{"depth4"}',
  '"newValue"'
)))) where "id" = 'myRowID' returning *

Of course, this is somewhat of a pain if you're writing the query by hand; but it's useful to know about, in cases where you don't want to (or aren't able to) add a native PostgreSQL function to the database.

EDIT: In place of:

jsonb_set(XXX, '{"depth1"}', XXX)

... you can instead do:

jsonb_set(XXX, array['depth1'], XXX)

Which I prefer actually, since it has one fewer set of quotes, and works with query parameters, eg:

UPDATE "myTable" SET "myField" =
  jsonb_set(COALESCE("myField", '{}'), array[$1::text],
  jsonb_set(COALESCE("myField"->$2::text, '{}'), array[$3::text],
  jsonb_set(COALESCE("myField"->$4::text->$5::text, '{}'), array[$6::text],
  jsonb_set(COALESCE("myField"->$7::text->$8::text->$9::text, '{}'), array[$10::text],
  $11::jsonb
)))) WHERE id = $12::text

@Venryx
Copy link

Venryx commented Oct 12, 2021

For those who may dislike the deeply-nested nature of the calls above, here is an alternative:

update "myTable" set "myField" = jsonb_set(
  CASE
    WHEN "myField" IS NULL THEN '{"depth1": {"depth2": {"depth3": {}}}}'
    WHEN "myField"->'depth1' IS NULL THEN jsonb_set("myField", array['depth1'], '{"depth2": {"depth3": {}}}')
    WHEN "myField"->'depth1'->'depth2' IS NULL THEN jsonb_set("myField", array['depth1','depth2'], '{"depth3": {}}')
    WHEN "myField"->'depth1'->'depth2'->'depth3' IS NULL THEN jsonb_set("myField", array['depth1','depth2','depth3'], '{}')
    ELSE "myField"
  END,
  array['depth1','depth2','depth3','depth4'],
  '"newValue"'
) where "id" = 'myRowID' returning *

@GuyKomari
Copy link

GuyKomari commented Nov 3, 2021

Following @Venryx solution, I have created a function (in Kotlin) that creates this jsonb_set_deep statement:

https://gist.github.com/GuyKomari/d81ab763fc38bf88595f1cc29e5566ac

@wmwart
Copy link

wmwart commented Jul 20, 2022

Version that handles conditions:

  • if the target is initially null,
  • if path is null (insert to root)
CREATE OR REPLACE FUNCTION jsonb_set_deep(target jsonb, path text[], val jsonb)
  RETURNS jsonb AS $$
    DECLARE
      k text;
      p text[];
    BEGIN
      IF (path = '{}') THEN
        RETURN val;
      ELSE 
        IF (target IS NULL) THEN
          target = '{}'::jsonb;
        END IF;
        
        FOREACH k IN ARRAY path LOOP
          p := p || k;
          IF (target #> p IS NULL) THEN
            target := jsonb_set(target, p, '{}'::jsonb);
          ELSE 
            target := jsonb_set(target, p, target #> p);
          END IF;
        END LOOP;

        -- Set the value like normal.
        RETURN jsonb_set(target, path, val);
      END IF;
    END;
  $$ LANGUAGE plpgsql;

@jcalfee
Copy link

jcalfee commented Aug 31, 2023

It was pretty useful to include the ability to concatenate or merge objects (the || operator). Without that, the calling code is pretty verbose. This one should support arrays and act more like json_set. I'm not sure about older versions like pg9 but there are some short example SQLs included below.

/* 
  https://gist.github.com/villelahdenvuo/5b1fbbf3f22853475bba14f8833faada

  Handles conditions:
  * Accept array index in path
  * Concatenation (using the || operator) when target path matches a type in the "concat" array parameter like ARRAY['array', 'object', 'string'] (array append, object merge, strings into an array of strings, etc.).
  * If target is null, return null (like jsonb_set)
  * If path is null return null (like jsonb_set)
  * If path is empty return target (like jsonb_set)
  * If concat is null return null
*/
CREATE FUNCTION jsonb_set_deep(
  target jsonb, path text[], val jsonb, concat text[] = ARRAY['object']
)
  RETURNS jsonb AS $$
    DECLARE
      k text;
      p text[];
      i int = 1;
    BEGIN
      IF target IS NULL OR path IS NULL OR concat IS NULL THEN
        RETURN NULL; -- strict
      END IF;

      FOREACH k IN ARRAY path LOOP
        p = p || k;
        IF target #> p IS NULL THEN
          IF path[i + 1] ~ '^[0-9]+$' THEN
            target = jsonb_set(target, p, '[]'::jsonb);
          ELSE
            target = jsonb_set(target, p, '{}'::jsonb);
          END IF;
        END IF;
        i = i + 1;
      END LOOP;

      IF jsonb_typeof(target #> path) = ANY(concat) THEN
        RETURN jsonb_set(target, path, target #> path || val);
      ELSE
        RETURN jsonb_set(target, path, val);
      END IF;
    END;
  $$ LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE;

/*
  Accept array index in path
  concat is true:  {"a":{"b":1},"c":[1,2,{"d":3}]}
  concat is false: {"a":{"b":1},"c":[2,{"d":3}]}
*/
SELECT jsonb_set_deep(
  '{"a": {"b": 1}, "c":[1]}'::jsonb, ARRAY['c']::TEXT[],
  '[2, {"d": 3}]'::jsonb,
  ARRAY['object', 'array']
);

-- {"a": {"b": 1}, "c": ["abc", "def"]}
SELECT jsonb_set_deep(
  '{"a": {"b": 1}, "c": "abc"}'::jsonb,
  ARRAY['c']::TEXT[],
  '"def"'::jsonb,
  ARRAY['string']
);

/*
If target is null, return null (like jsonb_set)

?column?|?column?|
--------+--------+
true    |true    |
*/
SELECT jsonb_set(null, array['a']::text[], '1'::jsonb) IS NULL, 
  jsonb_set_deep(null, array['a']::text[], '1'::jsonb) IS NULL;

/*
If path is null return null (like jsonb_set)
 
 ?column?|?column?|
--------+--------+
true    |true    |
*/
SELECT jsonb_set('{}'::jsonb, null::text[], '1'::jsonb) IS NULL, 
  jsonb_set_deep('{}'::jsonb, null::text[], '1'::jsonb) IS NULL;

/*
If path is empty return target (like jsonb_set)

 jsonb_set|jsonb_set_deep|
---------+--------------+
{"a": 1} |{"a": 1}      |
*/
SELECT jsonb_set('{"a": 1}'::jsonb, array[]::text[], '{"a": 2}'::jsonb), 
  jsonb_set_deep('{"a": 1}'::jsonb, array[]::text[], '{"a": 2}'::jsonb);

/*
If concat is null return null
 
?column?|
--------+
true    |
*/
SELECT jsonb_set_deep('{}'::jsonb, array['a']::text[], '1'::jsonb, NULL) IS NULL;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment