diff --git a/src/expression/expression-builder.ts b/src/expression/expression-builder.ts index 4a810cc32..c4625c2f4 100644 --- a/src/expression/expression-builder.ts +++ b/src/expression/expression-builder.ts @@ -50,13 +50,12 @@ import { OperatorNode, UnaryOperator, } from '../operation-node/operator-node.js' -import { SqlBool } from '../util/type-utils.js' +import { IsNever, SqlBool } from '../util/type-utils.js' import { parseUnaryOperation } from '../parser/unary-operation-parser.js' import { ExtractTypeFromValueExpression, parseSafeImmediateValue, parseValueExpression, - parseValueExpressionOrList, } from '../parser/value-parser.js' import { NOOP_QUERY_EXECUTOR } from '../query-executor/noop-query-executor.js' import { CaseBuilder } from '../query-builder/case-builder.js' @@ -86,6 +85,8 @@ import { } from '../parser/tuple-parser.js' import { TupleNode } from '../operation-node/tuple-node.js' import { Selectable } from '../util/column-type.js' +import { JSONPathNode } from '../operation-node/json-path-node.js' +import { KyselyTypeError } from '../util/type-error.js' export interface ExpressionBuilder { /** @@ -385,7 +386,8 @@ export interface ExpressionBuilder { * a non-type-safe version of this method see {@link sql}'s version. * * Additionally, this method can be used to reference nested JSON properties or - * array elements. See {@link JSONPathBuilder} for more information. + * array elements. See {@link JSONPathBuilder} for more information. For regular + * JSON path expressions you can use {@link jsonPath}. * * ### Examples * @@ -470,6 +472,36 @@ export interface ExpressionBuilder { op: JSONOperatorWith$ ): JSONPathBuilder> + /** + * Creates a JSON path expression with provided column as root document (the $). + * + * For a JSON reference expression, see {@link ref}. + * + * ### Examples + * + * ```ts + * db.updateTable('person') + * .set('experience', (eb) => eb.fn('json_set', [ + * 'experience', + * eb.jsonPath<'experience'>().at('last').key('title'), + * eb.val('CEO') + * ])) + * .where('id', '=', id) + * .execute() + * ``` + * + * The generated SQL (MySQL): + * + * ```sql + * update `person` + * set `experience` = json_set(`experience`, '$[last].title', ?) + * where `id` = ? + * ``` + */ + jsonPath<$ extends StringReference = never>(): IsNever<$> extends true + ? KyselyTypeError<"You must provide a column reference as this method's $ generic"> + : JSONPathBuilder> + /** * Creates a table reference. * @@ -1175,6 +1207,14 @@ export function createExpressionBuilder( return new JSONPathBuilder(parseJSONReference(reference, op)) }, + jsonPath< + $ extends StringReference = never + >(): IsNever<$> extends true + ? KyselyTypeError<"You must provide a column reference as this method's $ generic"> + : JSONPathBuilder> { + return new JSONPathBuilder(JSONPathNode.create()) as any + }, + table( table: T ): ExpressionWrapper> { diff --git a/src/query-builder/json-path-builder.ts b/src/query-builder/json-path-builder.ts index 6dc327c65..4d4abe873 100644 --- a/src/query-builder/json-path-builder.ts +++ b/src/query-builder/json-path-builder.ts @@ -17,9 +17,9 @@ import { OperationNode } from '../operation-node/operation-node.js' import { ValueNode } from '../operation-node/value-node.js' export class JSONPathBuilder { - readonly #node: JSONReferenceNode + readonly #node: JSONReferenceNode | JSONPathNode - constructor(node: JSONReferenceNode) { + constructor(node: JSONReferenceNode | JSONPathNode) { this.#node = node } @@ -167,18 +167,27 @@ export class JSONPathBuilder { legType: JSONPathLegType, value: string | number ): TraversedJSONPathBuilder { + if (JSONReferenceNode.is(this.#node)) { + return new TraversedJSONPathBuilder( + JSONReferenceNode.cloneWithTraversal( + this.#node, + JSONPathNode.is(this.#node.traversal) + ? JSONPathNode.cloneWithLeg( + this.#node.traversal, + JSONPathLegNode.create(legType, value) + ) + : JSONOperatorChainNode.cloneWithValue( + this.#node.traversal, + ValueNode.createImmediate(value) + ) + ) + ) + } + return new TraversedJSONPathBuilder( - JSONReferenceNode.cloneWithTraversal( + JSONPathNode.cloneWithLeg( this.#node, - JSONPathNode.is(this.#node.traversal) - ? JSONPathNode.cloneWithLeg( - this.#node.traversal, - JSONPathLegNode.create(legType, value) - ) - : JSONOperatorChainNode.cloneWithValue( - this.#node.traversal, - ValueNode.createImmediate(value) - ) + JSONPathLegNode.create(legType, value) ) ) } @@ -188,9 +197,9 @@ export class TraversedJSONPathBuilder extends JSONPathBuilder implements AliasableExpression { - readonly #node: JSONReferenceNode + readonly #node: JSONReferenceNode | JSONPathNode - constructor(node: JSONReferenceNode) { + constructor(node: JSONReferenceNode | JSONPathNode) { super(node) this.#node = node } diff --git a/test/node/src/json-traversal.test.ts b/test/node/src/json-traversal.test.ts index b629ea557..ab1acd2df 100644 --- a/test/node/src/json-traversal.test.ts +++ b/test/node/src/json-traversal.test.ts @@ -40,7 +40,7 @@ for (const dialect of DIALECTS.filter((dialect) => dialect !== 'mssql')) { }) if (dialect === 'mysql' || dialect === 'sqlite') { - describe('JSON Path syntax ($)', () => { + describe('JSON reference using JSON Path syntax ($)', () => { const jsonOperator = dialect === 'mysql' ? '->$' : '->>$' it(`should execute a query with column${jsonOperator}.key in select clause`, async () => { @@ -377,10 +377,42 @@ for (const dialect of DIALECTS.filter((dialect) => dialect !== 'mssql')) { expect(results[2].profile.auth.login_count).to.equal(12) }) }) + + describe('Standalone JSON path syntax ($)', () => { + it('should execute a query with json_set function', async () => { + const lastItem = dialect === 'mysql' ? 'last' : '#-1' + + const query = ctx.db + .updateTable('person_metadata') + .set('experience', (eb) => + eb.fn('json_set', [ + 'experience', + eb.jsonPath<'experience'>().at(lastItem).key('establishment'), + eb.val('Papa Johns'), + ]) + ) + .where('person_id', '=', 911) + + testSql(query, dialect, { + postgres: NOT_SUPPORTED, + mysql: { + parameters: ['Papa Johns', 911], + sql: "update `person_metadata` set `experience` = json_set(`experience`, '$[last].establishment', ?) where `person_id` = ?", + }, + mssql: NOT_SUPPORTED, + sqlite: { + parameters: ['Papa Johns', 911], + sql: `update "person_metadata" set "experience" = json_set("experience", '$[#-1].establishment', ?) where "person_id" = ?`, + }, + }) + + await query.execute() + }) + }) } if (dialect === 'postgres' || dialect === 'sqlite') { - describe('PostgreSQL-style syntax (->->->>)', () => { + describe('JSON reference using PostgreSQL-style syntax (->->->>)', () => { const jsonOperator = dialect === 'postgres' ? '->' : '->>' it(`should execute a query with column${jsonOperator}key in select clause`, async () => { diff --git a/test/typings/test-d/json-traversal.test-d.ts b/test/typings/test-d/json-traversal.test-d.ts index 0bd5771d9..a0b000e4e 100644 --- a/test/typings/test-d/json-traversal.test-d.ts +++ b/test/typings/test-d/json-traversal.test-d.ts @@ -1,8 +1,10 @@ import { expectError, expectType } from 'tsd' -import { Kysely } from '..' -import { Database } from '../shared' +import { ExpressionBuilder, JSONPathBuilder, Kysely } from '..' +import { Database, PersonMetadata } from '../shared' +import { expect } from 'chai' +import { KyselyTypeError } from '../../../dist/cjs/util/type-error' -async function testJSONTraversal(db: Kysely) { +async function testJSONReference(db: Kysely) { const [r1] = await db .selectFrom('person_metadata') .select((eb) => eb.ref('website', '->>$').key('url').as('website_url')) @@ -193,3 +195,20 @@ async function testJSONTraversal(db: Kysely) { .execute() ) } + +async function testJSONPath(eb: ExpressionBuilder) { + expectType>( + eb.jsonPath<'experience'>() + ) + + expectType>( + eb.jsonPath<'person_metadata.experience'>() + ) + + expectError(eb.jsonPath('experience')) + expectError(eb.jsonPath('person_metadata.experience')) + expectType< + KyselyTypeError<"You must provide a column reference as this method's $ generic"> + >(eb.jsonPath()) + expectError(eb.jsonPath<'NO_SUCH_COLUMN'>()) +}