Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add an option to control the behavior of null in JSON attributes #16861

Merged
merged 4 commits into from
Dec 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
13 changes: 12 additions & 1 deletion packages/core/src/dialects/abstract/data-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1618,10 +1618,21 @@ export class JSON extends AbstractDataType<any> {
* We stringify null too.
*/
acceptsNull(): boolean {
return true;
const sequelize = this._getDialect().sequelize;

return sequelize.options.nullJsonStringification !== 'sql';
}

toBindableValue(value: any): string {
if (value === null) {
const sequelize = this._getDialect().sequelize;

const isExplicit = sequelize.options.nullJsonStringification === 'explicit';
if (isExplicit) {
throw new Error(`Attempted to insert the JavaScript null into a JSON column, but the "nullJsonStringification" option is set to "explicit", so Sequelize cannot decide whether to use the SQL NULL or the JSON 'null'. Use the SQL_NULL or JSON_NULL variable instead, or set the option to a different value. See https://sequelize.org/docs/v7/querying/json/ for details.`);
}
}

return globalThis.JSON.stringify(value);
}

Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/dialects/abstract/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,11 @@ export abstract class AbstractDialect {
return `'${value}'`;
}

// Keep the logic of this class synchronized with the logic in the JSON DataType.
escapeJson(value: unknown): string {
return this.escapeString(JSON.stringify(value));
}

/**
* Whether this dialect can use \ in strings to escape string delimiters.
*
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/dialects/abstract/where-sql-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { BaseSqlExpression } from '../../expression-builders/base-sql-expression
import { Cast } from '../../expression-builders/cast.js';
import { Col } from '../../expression-builders/col.js';
import { JsonPath } from '../../expression-builders/json-path.js';
import { Literal } from '../../expression-builders/literal.js';
import { Literal, SQL_NULL } from '../../expression-builders/literal.js';
import { Value } from '../../expression-builders/value.js';
import { Where } from '../../expression-builders/where.js';
import type { Expression, ModelStatic, WhereOptions } from '../../index.js';
Expand Down Expand Up @@ -290,11 +290,11 @@ export class WhereSqlBuilder {

if (operator == null) {
if (right === null && leftDataType instanceof DataTypes.JSON) {
throw new Error('Because JSON has two possible null values, comparing a JSON/JSONB attribute to NULL requires an explicit comparison operator. Use the `Op.is` operator to compare to SQL NULL, or the `Op.eq` operator to compare to JSON null.');
throw new Error(`When comparing against a JSON column, the JavaScript null value can be represented using either the JSON 'null', or the SQL NULL. You must be explicit about which one you mean by using Op.is or SQL_NULL for the SQL NULL; or Op.eq or JSON_NULL for the JSON 'null'. Learn more at https://sequelize.org/docs/v7/querying/json/`);
}

operator = Array.isArray(right) && !(leftDataType instanceof DataTypes.ARRAY) ? Op.in
: right === null ? Op.is
: (right === null || right === SQL_NULL) ? Op.is
: Op.eq;
}

Expand Down
4 changes: 0 additions & 4 deletions packages/core/src/dialects/mssql/data-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,10 +200,6 @@ export class JSON extends BaseTypes.JSON {
// TODO: add constraint
// https://learn.microsoft.com/en-us/sql/t-sql/functions/isjson-transact-sql?view=sql-server-ver16

toBindableValue(value: any): string {
return globalThis.JSON.stringify(value);
}

parseDatabaseValue(value: unknown): unknown {
if (typeof value !== 'string') {

Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/dialects/mysql/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ export class MysqlDialect extends AbstractDialect {
return escapeMysqlString(value);
}

escapeJson(value: unknown): string {
return `CAST(${super.escapeJson(value)} AS JSON)`;
}

canBackslashEscape() {
return true;
}
Expand Down
6 changes: 0 additions & 6 deletions packages/core/src/dialects/snowflake/data-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,6 @@ export class TEXT extends BaseTypes.TEXT {
}
}

export class JSON extends BaseTypes.JSON {
escape(value: unknown) {
return globalThis.JSON.stringify(value);
}
}

/** @deprecated */
export class REAL extends BaseTypes.REAL {
toSql(): string {
Expand Down
4 changes: 0 additions & 4 deletions packages/core/src/dialects/sqlite/data-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,10 +218,6 @@ export class BLOB extends BaseTypes.BLOB {
}

export class JSON extends BaseTypes.JSON {
toBindableValue(value: any): string {
return globalThis.JSON.stringify(value);
}

parseDatabaseValue(value: unknown): unknown {
// sqlite3 being sqlite3, JSON numbers are returned as JS numbers, but everything else is returned as a JSON string
if (typeof value === 'number') {
Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/expression-builders/dialect-aware-fn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,19 @@ export class Unquote extends DialectAwareFn {
return dialect.queryGenerator.formatUnquoteJson(arg, options);
}
}

class JsonNullClass extends DialectAwareFn {
get maxArgCount() {
return 0;
}

get minArgCount() {
return 0;
}

apply(dialect: AbstractDialect): string {
return dialect.escapeJson(null);
}
}

export const JSON_NULL = JsonNullClass.build();
1 change: 1 addition & 0 deletions packages/core/src/expression-builders/literal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@ export function literal(val: string | Array<string | BaseSqlExpression>): Litera
return new Literal(val);
}

export const SQL_NULL = literal('NULL');
3 changes: 2 additions & 1 deletion packages/core/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,10 @@ export { Identifier } from './expression-builders/identifier.js';
export { Attribute } from './expression-builders/attribute.js';
export { JsonPath, jsonPath } from './expression-builders/json-path.js';
export { AssociationPath } from './expression-builders/association-path.js';
export { JSON_NULL } from './expression-builders/dialect-aware-fn.js';

// All functions are available on sql.x, but these are exported for backwards compatibility
export { literal, Literal } from './expression-builders/literal.js';
export { literal, Literal, SQL_NULL } from './expression-builders/literal.js';
export { fn, Fn } from './expression-builders/fn.js';
export { col, Col } from './expression-builders/col.js';
export { cast, Cast } from './expression-builders/cast.js';
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export const Value = Pkg.Value;
export const sql = Pkg.sql;
export const and = Pkg.and;
export const or = Pkg.or;
export const SQL_NULL = Pkg.SQL_NULL;
export const JSON_NULL = Pkg.JSON_NULL;

// export * from './lib/query-interface';
export const AbstractQueryInterface = Pkg.AbstractQueryInterface;
Expand Down
22 changes: 21 additions & 1 deletion packages/core/src/sequelize.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,12 +338,32 @@ export interface Options extends Logging {
models?: ModelStatic[];

/**
* A flag that defines if native library shall be used or not. Currently only has an effect for postgres
* A flag that defines if the native library shall be used or not.
* Currently only has an effect for postgres
*
* @default false
*/
native?: boolean;

/**
* When representing the JavaScript null primitive in a JSON column, Sequelize can
* use either the SQL NULL value, or a JSON 'null'.
*
* Set this to "json" if you want the null to be stored as a JSON 'null'.
* Set this to "sql" if you want the null to be stored as the SQL NULL value.
* Set this to "explicit" if you don't want Sequelize to make any assumptions.
* This means that you won't be able to use the JavaScript null primitive as the top level value of a JSON column,
* you will have to use {@link SQL_NULL} or {@link JSON_NULL} instead.
*
* This only impacts serialization when inserting or updating values.
* Comparing always requires to be explicit.
*
* Read more: https://sequelize.org/docs/v7/querying/json/
*
* @default json
*/
nullJsonStringification?: 'explicit' | 'json' | 'sql';

/**
* Use read / write replication. To enable replication, pass an object, with two properties, read and write.
* Write should be an object (a single server for handling writes), and read an array of object (several
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/sequelize.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import retry from 'retry-as-promised';
import { normalizeDataType } from './dialects/abstract/data-types-utils';
import { AssociationPath } from './expression-builders/association-path';
import { Attribute } from './expression-builders/attribute';
import { JSON_NULL } from './expression-builders/dialect-aware-fn.js';
import { Identifier } from './expression-builders/identifier';
import { JsonPath } from './expression-builders/json-path';
import { Value } from './expression-builders/value';
Expand All @@ -13,7 +14,7 @@ import { Cast, cast } from './expression-builders/cast.js';
import { Col, col } from './expression-builders/col.js';
import { Fn, fn } from './expression-builders/fn.js';
import { json } from './expression-builders/json.js';
import { Literal, literal } from './expression-builders/literal.js';
import { Literal, SQL_NULL, literal } from './expression-builders/literal.js';
import { Where, where } from './expression-builders/where.js';
import { setTransactionFromCls } from './model-internals.js';
import { SequelizeTypeScript } from './sequelize-typescript';
Expand Down Expand Up @@ -275,6 +276,7 @@ export class Sequelize extends SequelizeTypeScript {
disableClsTransactions: false,
defaultTransactionNestMode: TransactionNestMode.reuse,
defaultTimestampPrecision: 6,
nullJsonStringification: 'json',
...options,
pool: defaults(options.pool || {}, {
max: 5,
Expand Down Expand Up @@ -1197,6 +1199,9 @@ Sequelize.prototype.Association = Sequelize.Association = Association;
*/
Sequelize.useInflection = useInflection;

Sequelize.SQL_NULL = SQL_NULL;
Sequelize.JSON_NULL = JSON_NULL;

/**
* Expose various errors available
*/
Expand Down
2 changes: 1 addition & 1 deletion packages/core/test/integration/json.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,6 @@ describe('JSONB Casting', () => {
},
})).to.be.rejected;

expect(inlineErrorCause(error)).to.include('Because JSON has two possible null values, comparing a JSON/JSONB attribute to NULL requires an explicit comparison operator. Use the `Op.is` operator to compare to SQL NULL, or the `Op.eq` operator to compare to JSON null.');
expect(inlineErrorCause(error)).to.include('You must be explicit');
});
});
66 changes: 63 additions & 3 deletions packages/core/test/unit/data-types/misc-data-types.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import assert from 'node:assert';
import { expect } from 'chai';
import type { DataTypeInstance } from '@sequelize/core';
import { DataTypes, ValidationErrorItem } from '@sequelize/core';
import { DataTypes, JSON_NULL, SQL_NULL, ValidationErrorItem } from '@sequelize/core';
import type { ENUM } from '@sequelize/core/_non-semver-use-at-your-own-risk_/dialects/abstract/data-types.js';
import { expectsql, sequelize, typeTest } from '../../support';
import { createSequelizeInstance, expectsql, sequelize, typeTest } from '../../support';
import { testDataTypeSql } from './_utils';

const { queryGenerator, dialect } = sequelize;
Expand Down Expand Up @@ -215,7 +215,7 @@ describe('DataTypes.JSON', () => {
});
});

it('escapes NULL', () => {
it('escapes JS null as the JSON null', () => {
expectsql(queryGenerator.escape(null, { type: new DataTypes.JSON() }), {
default: `'null'`,
mysql: `CAST('null' AS JSON)`,
Expand All @@ -231,6 +231,66 @@ describe('DataTypes.JSON', () => {
});
});
});

describe('with nullJsonStringification = sql', () => {
if (!dialect.supports.dataTypes.JSON) {
return;
}

const sqlNullQueryGenerator = createSequelizeInstance({
nullJsonStringification: 'sql',
}).queryGenerator;

it('escapes JS null as the SQL null', () => {
expectsql(sqlNullQueryGenerator.escape(null, { type: new DataTypes.JSON() }), {
default: `NULL`,
});
});

it('escapes nested JS null as the JSON null', () => {
expectsql(sqlNullQueryGenerator.escape({ a: null }, { type: new DataTypes.JSON() }), {
default: `'{"a":null}'`,
mysql: `CAST('{"a":null}' AS JSON)`,
mssql: `N'{"a":null}'`,
});
});
});

describe('with nullJsonStringification = explicit', () => {
if (!dialect.supports.dataTypes.JSON) {
return;
}

const explicitNullQueryGenerator = createSequelizeInstance({
nullJsonStringification: 'explicit',
}).queryGenerator;

it('rejects the JS null when used as the top level value', () => {
expect(() => explicitNullQueryGenerator.escape(null, { type: new DataTypes.JSON() })).to.throw(/"nullJsonStringification" option is set to "explicit"/);
});

it('escapes nested JS null as the JSON null', () => {
expectsql(explicitNullQueryGenerator.escape({ a: null }, { type: new DataTypes.JSON() }), {
default: `'{"a":null}'`,
mysql: `CAST('{"a":null}' AS JSON)`,
mssql: `N'{"a":null}'`,
});
});

it('escapes SQL_NULL as NULL', () => {
expectsql(explicitNullQueryGenerator.escape(SQL_NULL, { type: new DataTypes.JSON() }), {
default: `NULL`,
});
});

it('escapes JSON_NULL as NULL', () => {
expectsql(explicitNullQueryGenerator.escape(JSON_NULL, { type: new DataTypes.JSON() }), {
default: `'null'`,
mysql: `CAST('null' AS JSON)`,
mssql: `N'null'`,
});
});
});
});

describe('DataTypes.JSONB', () => {
Expand Down