1

Say I have a schema system something like this:

create table objects {
  uuid id;
  string type;
}

create table object_properties {
  uuid id;
  uuid source_id; // the object which has this property
  string name; // the property name
  uuid value_id; // the property value object
}

// ...and tables for each primitive data type
create table string_properties {
  uuid id;
  uuid source_id; // the object which has this property
  string name; // the property name
  string value; // the property value string
}

I then want to create this object:

{
  type: 'foo',
  bar: {
    type: 'bar',
    baz: {
      type: 'baz',
      slug: 'hello-world'
    }
  }
}

That is:

// objects
id  | type
123 | foo
234 | bar
345 | baz

// object_properties
source_id   | name  | value_id
123         | bar   | 234
234         | baz   | 345

// string_properties
source_id   | name  | value
345         | slug  | hello-world

I want to only create this "object tree" if the tree ending in slug: hello-world doesn't exist. How best can I do that? I can do it easily by first making a query, checking the object exists, and then creating it if not. But that is one query followed by one insert. There is a chance that two processes come in at the same time, both make the query, both succeed, and then both make the insert. How can I prevent that? Note, I am currently having each independent query+insert happen both in a transaction, so each transaction has the query followed by the insert.

Or will the update inside the first transaction be readable "outside" from the second transaction? I am using PostgreSQL / CockroachDB, is this a "read uncommitted" sort of setting?

1 Answer 1

2

Working PostgreSQL setup:

CREATE TABLE objects (
  id   uuid PRIMARY KEY DEFAULT gen_random_uuid()
, type text NOT NULL
);

CREATE TABLE object_properties (
  id        uuid PRIMARY KEY DEFAULT gen_random_uuid()  -- redundant
, source_id uuid NOT NULL REFERENCES objects    -- the object which has this property
, name      text NOT NULL                       -- the property name  -- redundant
, value_id  uuid NOT NULL REFERENCES objects    -- the property value object
, CONSTRAINT object_properties_uni UNIQUE (source_id, value_id)  -- unique?
);

CREATE TABLE string_properties (
  id        uuid PRIMARY KEY DEFAULT gen_random_uuid()  -- redundant
, source_id uuid NOT NULL REFERENCES objects    -- the object which has this property
, name      text NOT NULL                       -- the property name
, value     text NOT NULL                       -- the property value string
, CONSTRAINT string_properties_uni UNIQUE (value)  -- REQUIRED !!!
);

Proof of concept:

WITH ins_string_properties AS (
   INSERT INTO string_properties (source_id, name, value)
   VALUES (gen_random_uuid(), 'slug', 'hello-world')
   ON CONFLICT DO NOTHING  -- to silence unique violation errors
   RETURNING source_id
   )
, ins_objects AS (
   INSERT INTO objects (id, type)
   SELECT o.id, o.type
   FROM   ins_string_properties isp  -- always 0 or 1 rows
   CROSS  JOIN LATERAL (
      VALUES 
        (isp.source_id    , 'baz')
      , (gen_random_uuid(), 'foo')
      , (gen_random_uuid(), 'bar')
      ) o(id, type)
   RETURNING id, type
   )
INSERT INTO object_properties (source_id, name, value_id)
SELECT io1.id, io2.type, io2.id
FROM   ins_objects io1
JOIN   ins_objects io2 ON io1.type = 'foo' AND io2.type = 'bar'
                       OR io1.type = 'bar' AND io2.type = 'baz'
;

db<>fiddle here

This query only does the minimum work necessary. One INSERT command for each table.

The first core feature is to chain inserts in a single query with CTEs. See:

The second core feature is INSERT ... ON CONFLICT DO NOTHING in conjunction with the added UNIQUE constraint object_properties_uni. See:

If the first insert to string_properties does not insert a row, the rest does nothing, either. So the attempt to insert a tree with a "slug" that's already there does nothing. If you want an exception instead, drop the ON CONFLICT clause.

An important reason for a single command if there are FOREIGN KEY constraints like in my setup above (REFERENCES objects): Without expensively making the constraint DEFERRABLE or setting it DEFERRED, we can only insert the referencing row "before" the referenced row if done in a single command. Everything virtually happens at the same time this way. Again, that's the case in Postgres - according to standard SQL, though. See:

It's also safe under concurrent write load with default READ COMMITTED transaction isolation. The first transaction to insert the slug wins the race. Concurrent transactions trying to do the same wait till the first one commits (or rolls back) and then either do nothing or proceed in queued order. Just make sure that all transactions use the same query, and don't add any independent commands to the same transaction.

There are many subtleties to this. Note the modifications I made to your (pseudo-code) relational design. It can be adjusted to most every requirement. Can even be wrapped in a server side function taking the JSON object you show. But working out the details goes beyond the scope of a question on dba. That's work for a paid consultant.

4
  • Awesome, can you explain a little of "why" you went with this approach, and maybe what is actually happening with the multiple nested statements? I have never seen anything like this before :) The note on what will happen in concurrent situations is really helpful too.
    – Lance
    Aug 5 at 23:12
  • 1
    @Lance: You can find more explanation in the linked answers. And links to more, yet. A lot is going on here. Much depends on (undisclosed) details of your use case. That's beyond the scope of a dba question. If you are in over your head, consider hiring a consulter. Aug 5 at 23:33
  • Thank you for introducing me to the CTE concept, I have never seen that. It makes perfect sense now, very cool!
    – Lance
    Aug 6 at 3:56
  • Last piece for me (since I'm using CockroachDB in actuality, which has a PostgreSQL-like interface) is understanding why they are warning against READ COMMITTED transaction isolation, and saying you should use SERIALIZABLE instead. Added a follow-up question here about a SERIALIZABLE form, as this is a bit over my head / new to me atm.
    – Lance
    Aug 6 at 4:24

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.