1

How to form a unique constraint with multiple fields in keystonejs?

const Redemption = list({
  access: allowAll,
  fields: {
    program: relationship({ ref: 'Program', many: false }),
    type: text({ label: 'Type', validation: { isRequired: true }, isIndexed: 'unique' }),
    name: text({ label: 'name', validation: { isRequired: true }, isIndexed: 'unique' }),
  },
  //TODO: validation to check that program, type, name form a unique constraint
})
Palak Jadav
  • 1,202
  • 1
  • 11
  • 23

1 Answers1

1

The best way I can think to do this currently is by adding another field to the list and concatenating your other values into it using a hook. This lets you enforces uniqueness across these three values (combine) at the DB-level.

The list config (and hook) might look like this:

const Redemption = list({
  access: allowAll,
  fields: {
    program: relationship({ ref: 'Program', many: false }),
    type: text({ validation: { isRequired: true } }),
    name: text({ validation: { isRequired: true } }),
    compoundKey: text({
      isIndexed: 'unique',
      ui: {
        createView: { fieldMode: 'hidden' },
        itemView: { fieldMode: 'read' },
        listView: { fieldMode: 'hidden' },
      },
      graphql: { omit: ['create', 'update'] },
    }),
  },
  hooks: {
    resolveInput: async ({ item, resolvedData }) => {
      const program = resolvedData.program?.connect.id || ( item ? item?.programId : 'none');
      const type = resolvedData.type || item?.type;
      const name = resolvedData.name || item?.name;
      resolvedData.compoundKey = `${program}-${type}-${name}`;
      return resolvedData;
    },
  }
});

Few things to note here:

  • I've removed the isIndexed: 'unique' config for the main three fields. If I understand the problem you're trying to solve correctly, you actually don't want these values (on their own) to be distinct.
  • I've also remove the label config from your example. The label defaults to the field key so, in your example, that config is redundant.
  • As you can see, I've added the compoundKey field to store our composite values:
    • The ui settings make the field appear as uneditable in the UI
    • The graphql settings block updates on the API too (you could do the same thing with access control but I think just omitting the field is a bit cleaner)
    • And of course the unique index, which will be enforced by the DB
  • I've used a resolveInput hook as it lets you modify data before it's saved. To account for both create and update operations we need to consult both the resolvedData and item arguments - resolvedData gives us new/updated values (but undefined for any fields not being updated) and item give us the existing values in the DB. By combining values from both we can build the correct compound key each time and add it to the returned object.

And it works! When creating a redemption we'll be prompted for the 3 main fields (the compound key is hidden):

create item form

And the compound key is correctly set from the values entered:

update item form with initial values

Editing any of the values also updates the compound key:

update item form with updated values

Note that the compound key field is read-only for clarity.

And if we check the resultant DB structure, we can see our unique constraint being enforced:

CREATE TABLE "Redemption" (
    id text PRIMARY KEY,
    program text REFERENCES "Program"(id) ON DELETE SET NULL ON UPDATE CASCADE,
    type text NOT NULL DEFAULT ''::text,
    name text NOT NULL DEFAULT ''::text,
    "compoundKey" text NOT NULL DEFAULT ''::text
);

CREATE UNIQUE INDEX "Redemption_pkey" ON "Redemption"(id text_ops);
CREATE INDEX "Redemption_program_idx" ON "Redemption"(program text_ops);
CREATE UNIQUE INDEX "Redemption_compoundKey_key" ON "Redemption"("compoundKey" text_ops);

Attempting to violate the constraint will produce an error:

unique constraint validation error

If you wanted to customise this behaviour you could implement a validateInput hook and return a custom ValidationFailureError message.

Molomby
  • 5,859
  • 2
  • 34
  • 27
  • can we hide the "compoundKey" into form UI? – Palak Jadav Dec 02 '22 at 05:26
  • Sure, if you want to hide the field entirely in the item view, you can set `ui.itemView.fieldMode: 'hidden'` instead of `read`. See the common field config options in the docs: https://keystonejs.com/docs/fields/overview#common-configuration – Molomby Dec 04 '22 at 21:54
  • Also I don't want to store it into database and just wanted to fire validation in UI, is that possible? – Palak Jadav Dec 06 '22 at 01:41
  • 1
    You could replace this solution with a `validateInput` hook (https://keystonejs.com/docs/config/hooks#validate-input) that checked the values being saved against those existing in the table. That would work most of the time but creates the potential that duplicate keys could be inserted if multiple items were being created simultaneously via the standard create mutations. To ensure no dupes you'd either need to delegate the constraint to the DB (as shown above) or perform all your updates and creates using custom mutations that used locking to prevent the kind of race condition described. – Molomby Dec 06 '22 at 04:11