Skip to content

Instantly share code, notes, and snippets.

@ephys
Last active February 25, 2023 11:36
Show Gist options
  • Save ephys/f054c6fd343d09403f92e2e133929e04 to your computer and use it in GitHub Desktop.
Save ephys/f054c6fd343d09403f92e2e133929e04 to your computer and use it in GitHub Desktop.

New JSON support in Sequelize

Sequelize supports a JSON extraction notation similar to the JS . operator:

User.findAll({
  where: {
    'jsonAttribute.address.country': 'Belgium',
  },
});

In Sequelize 6 and prior, the above produced the following where:

-- postgres
"jsonAttribute"#>>ARRAY['address','country'] = 'Belgium';

-- mysql & friends
`jsonAttribute`->>"$.address.country" = 'Belgium';

The ->> and #>> operators return the JSON value as unquoted text. A JSON string "belgium" is returned as belgium. This is convenient for comparing string values but a problem with other types (sequelize/sequelize#15238)

We also cast values automatically based on the right-hand side:

User.findAll({
  where: {
    'jsonAttribute.birthday': new Date(),
  },
});

produced

-- postgres
CAST("jsonAttribute"->>'birthday' AS timestamptz) = '2022-01-01 00:00:00 +00:00';

-- mysql & friends
CAST(`jsonAttribute`->>"$.birthday" AS timestamp) = '2022-01-01 00:00:00';

The new system

In Sequelize 7, we use the -> qnd #> operators by default instead. These operators do not unquote, they return a JSON value ("belgium", with the quotes, it's a JSON string).

We also don't cast automatically. Nested json properties are treated the same way as the json attribute itself.

This is really good for jsonb columns, as it makes it possible to use jsonb operators on nested attributes:

User.findAll({
  where: {
    'jsonAttribute.address': { [Op.anyKeyExists]: ['country', 'street'] },
  },
});

produces

-- postgres, jsonb does not exist in other dialects
"jsonAttribute"->'address' ?| ARRAY['country','street'];

Array Index Access

Most dialects use a different syntax for array index access vs object property access. In order to differentiate between the two, we use the [] syntax for array index access, and . for object property access:

User.findAll({
  where: {
    'jsonAttribute.passwords[0]': 0451,
  },
});

produces

-- postgres
"jsonAttribute"#>ARRAY['passwords','0'] = '0451';

-- mysql & friends
`jsonAttribute`->"$.passwords[0]" = '0451';

Comparing JSON values

Simple equality comparison are still possible, as we stringify the value as JSON:

User.findAll({
  where: {
    'jsonAttribute.address.street': 'Belgium',
  },
});

Produces

-- postgres. Note that this only works with JSONB as `JSON = string` does not exist in postgres
"jsonAttribute"#>ARRAY['address','street'] = '"Belgium"';

-- mysql & friends
`jsonAttribute`->"$.address.street" = '"Belgium"';

Casting JSON values

To compare the value, you can use the :: cast syntax to cast your value to the desired type:

User.findAll({
  where: {
    'jsonAttribute.age::integer': { [Op.gt]: 18 },
  },
});

Produces

-- postgres
-- ℹ️ in postgres, you can only cast JSONB columns, not JSON.
-- to compare JSON columns, you can either unquote first (see below) 
-- or cast to text then the desired type (::text::integer)
CAST("jsonAttribute"->'age' AS integer) > 18;
    
-- mysql & friends
CAST(`jsonAttribute`->"$.age" AS integer) > 18;

Unquoting JSON

You can still unquote like before by using the :unquote syntax. It looks like the cast operator (::), but it's not a cast, it's just a way to unquote the value.

User.findAll({
  where: {
    'jsonAddress.country:unquote': 'Belgium',
    'jsonPasswords[0]:unquote': '0451',
    'jsonString:unquote': 'abc',
  },
});

produces

-- postgres
"jsonAddress"->>'country' = 'Belgium';
"jsonPasswords"->>0 = '0451';
    
-- postgres does not have a JSON_UNQUOTE function, we use #>> which extracts & unquotes, 
-- but with an empty extraction path to get the entire value
"jsonString"#>>ARRAY[]::TEXT[] = 'abc';

-- mysql & friends
`jsonAddress`->>"$.country" = 'Belgium';
`jsonPasswords`->>"$[0]" = '0451';

-- mysql & friends do not have a JSON_UNQUOTE function
json_unquote(`jsonString`) = 'abc';

⚠️ You should only use this if the value is only ever a string, otherwise you will get unexpected results (sequelize/sequelize#15238).

You can of course also use the :: cast syntax to cast the unquoted value to the desired type.

Alternative syntax

You can also nest JSON objects as an alternative to the . syntax.

For instance, the two following queries are equivalent:

User.findAll({
  where: {
    jsonAttribute: {
      address: {
        country: 'Belgium',
        street: 'Rue de la Loi',
      },
      'age::integer': { [Op.gt]: 18 },
    },
  },
});

User.findAll({
  where: {
    'jsonAttribute.address.country': 'Belgium',
    'jsonAttribute.address.street': 'Rue de la Loi',
    'jsonAttribute.age::integer': { [Op.gt]: 18 },
  },
});

ℹ️ Nesting a JSON object uses the . operator by default, but you can use the ->> or [] operators at the start of the path to change the default operator:

User.findAll({
  where: {
    jsonAttribute: {
      age: 18,
      '->>country': 'Belgium',
      '[0]': '1'
    },
  },
});

produces

-- postgres
"jsonAttribute"#>>ARRAY['address','country'] = 'Belgium';

-- mysql & friends
`jsonAttribute`->>"$.address.country" = 'Belgium';

❓️ Future addition: JSONB predicates

If you don't use the unquote path syntax, using an operator on a JSONB column will use the JSON path predicate operator to compare your value to the JSONB value:

User.findAll({
  where: {
    jsonAttribute: { 
      [Op.matches]: or({
        age: { [Op.gt]: 18 },
        name: { [Op.regex]: '^ab.*c' },
      }),
    }
  }
});

produces

-- postgres
-- JSONB is only supported in postgres
"jsonAttribute" @@ '$.age > 18 or $.name like_regex "^ab.*c"';

❓️ Future addition: wildcard operator

We could also add a wildcard operator to match any key in a JSON object:

User.findAll({
  where: {
    'jsonAttribute.*.country': 'Belgium',
  },
});

produces

-- postgres
jsonb_path_query("jsonAttribute", '$[*].country') = 'Belgium';

-- mysql & friends
`jsonAttribute`->"$[*].country" = 'Belgium';

This would also work with the ->> operator:

User.findAll({
  where: {
    'jsonAttribute.*->>country': 'Belgium',
  },
});

produces

-- postgres
jsonb_path_query("jsonAttribute", '$[*].country')#>>'{}' = 'Belgium';

-- mysql & friends
`jsonAttribute`->>"$[*].country" = 'Belgium';

❓️ Would this be worth it?

  • We'd have to choose which function to use (jsonb_path_query, jsonb_path_query_array, or jsonb_path_query_first). It may discourage users from using the right one because it's not the default one.
@WikiRik
Copy link

WikiRik commented Feb 19, 2023

Clear write up. I'm a fan of the casting syntax you are proposing. For the unquoting I would prefer the second option, especially since we can use similar syntax for other functions in the future as well. And while it's a little bit more verbose it does increase the readability as well. It's easier to see what :unquote does instead of ->>

It would be nice if we could support the wildcard operator in the future as well, but I don't think we need to design that now. I do want to mention that we could also let the user input which function to use instead of deciding for them.

@WikiRik
Copy link

WikiRik commented Feb 19, 2023

Another thing for later is that it might also be nice if the left operand could be more of an object. So instead of 'jsonAttribute.age::integer' you could also use { attribute: 'jsonAttribute.age', cast: 'integer' }, for example. That way users can benefit from a bit stricter typing beforehand but also clearer intellisense from the types to see what options are available. Haven't taken the time for a design for this, and not something we need initially but might be nice eventually. Since internally we might use the same objects as well

@ephys
Copy link
Author

ephys commented Feb 19, 2023

That's not possible as object keys can only be strings or symbols

To use something else than a string as the left operand, the user would have to use one of the properties available in the sql object, but it's a lot more verbose. Example:

User.findAll({
  where: sql.where(
    sql.cast(sql.attribute('jsonAttribute.age'), 'integer'),
    Op.gt,
    18,
  ),
});

(sql is part of PR sequelize/sequelize#15598: link to code - https://github.com/sequelize/sequelize/blob/3baeea4fbf5a1da4fc493ccfbeb77ed4366d923b/packages/core/src/expression-builders/sql.ts)

The entire attribute syntax can be replicated using the sql object if the user desires strong typing. For instance, textAttr::json:unquote is equivalent to sql.unquote(sql.cast(sql.attribute('textAttr'), 'json'))

With TypeScript Template Literal Types, we can probably make the syntax strongly typed itself too :)

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