From 002eca9bfa4bc40f4ac917053badb84488508d18 Mon Sep 17 00:00:00 2001 From: coinkits <163612553+coinkits@users.noreply.github.com> Date: Fri, 23 Aug 2024 15:08:24 +0800 Subject: [PATCH] refactor: move json_merge_patch() handling to the spell prepartion phase (#422) --- src/bone.js | 19 +++++++++- src/drivers/abstract/spellbook.js | 7 ++-- src/spell.js | 9 +++++ src/types/abstract_bone.d.ts | 9 +++++ test/integration/mysql.test.js | 1 + test/integration/mysql2.test.js | 1 + test/integration/sqlite.test.js | 14 ++++++- test/integration/suite/json.test.js | 57 +++++++++++++++++++++++++++++ 8 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 test/integration/suite/json.test.js diff --git a/src/bone.js b/src/bone.js index 6577c9e2..5d331a85 100644 --- a/src/bone.js +++ b/src/bone.js @@ -673,6 +673,21 @@ class Bone { }); } + /** + * @public + * @param {String} name + * @param {Object} jsonValue + * @param {Object?} options + * @returns {Promise} + * @memberof Bone + */ + async jsonMerge(name, jsonValue, options = {}) { + const raw = new Raw(`JSON_MERGE_PATCH(${name}, '${JSON.stringify(jsonValue)}')`); + const rows = await this.update({ [name]: raw }, options); + return rows; + + } + /** * Persist changes on current instance back to database with `UPDATE`. * @public @@ -696,6 +711,9 @@ class Bone { } try { const res = await this._update(Object.keys(changes).length? changes : values, options); + if (typeof values === 'object' && Object.values(values).some(v => v instanceof Raw)) { + await this.reload(); + } return res; } catch (error) { // revert value in case update failed @@ -1158,7 +1176,6 @@ class Bone { } return name; } - /** * Load attribute definition to merge default getter/setter and custom descriptor on prototype * @param {string} name attribute name diff --git a/src/drivers/abstract/spellbook.js b/src/drivers/abstract/spellbook.js index 5b2bc0c0..32acc308 100644 --- a/src/drivers/abstract/spellbook.js +++ b/src/drivers/abstract/spellbook.js @@ -363,13 +363,14 @@ class SpellBook { const { escapeId } = Model.driver; for (const name in sets) { const value = sets[name]; + const columnName = escapeId(Model.unalias(name)); if (value && value.__expr) { - assigns.push(`${escapeId(Model.unalias(name))} = ${formatExpr(spell, value)}`); + assigns.push(`${columnName} = ${formatExpr(spell, value)}`); collectLiteral(spell, value, values); } else if (value instanceof Raw) { - assigns.push(`${escapeId(Model.unalias(name))} = ${value.value}`); + assigns.push(`${columnName} = ${value.value}`); } else { - assigns.push(`${escapeId(Model.unalias(name))} = ?`); + assigns.push(`${columnName} = ?`); values.push(sets[name]); } } diff --git a/src/spell.js b/src/spell.js index 5731ad21..a75a0f4f 100644 --- a/src/spell.js +++ b/src/spell.js @@ -108,6 +108,15 @@ function formatValueSet(spell, obj, strict = true) { // raw sql don't need to uncast if (value instanceof Raw) { + try { + const expr = parseExpr(value.value); + if (expr.type === 'func' && ['json_merge_patch', 'json_merge_preserve'].includes(expr.name)) { + sets[name] = { ...expr, __expr: true }; + continue; + } + } catch { + // ignored + } sets[name] = value; } else { sets[name] = attribute.uncast(value); diff --git a/src/types/abstract_bone.d.ts b/src/types/abstract_bone.d.ts index f59da4dc..c80356a6 100644 --- a/src/types/abstract_bone.d.ts +++ b/src/types/abstract_bone.d.ts @@ -379,6 +379,15 @@ export class AbstractBone { */ update(changes?: { [key: string]: Literal } | { [Property in keyof Extract]?: Literal }, opts?: QueryOptions): Promise; + /** + * UPDATE JSONB column with JSON_MERGE_PATCH function + * @example + * /// before: bone.extra equals { name: 'zhangsan', url: 'https://alibaba.com' } + * bone.jsonMerge('extra', { url: 'https://taobao.com' }) + * /// after: bone.extra equals { name: 'zhangsan', url: 'https://taobao.com' } + */ + jsonMerge>(name: Key, jsonValue: Record | Array, opts?: QueryOptions): Promise; + /** * create instance * @param opts query options diff --git a/test/integration/mysql.test.js b/test/integration/mysql.test.js index fd696922..c7187f9a 100644 --- a/test/integration/mysql.test.js +++ b/test/integration/mysql.test.js @@ -19,6 +19,7 @@ before(async function() { require('./suite/index.test'); require('./suite/dates.test'); +require('./suite/json.test'); describe('=> Date functions (mysql)', function() { const Post = require('../models/post'); diff --git a/test/integration/mysql2.test.js b/test/integration/mysql2.test.js index 772675f0..04925315 100644 --- a/test/integration/mysql2.test.js +++ b/test/integration/mysql2.test.js @@ -16,3 +16,4 @@ before(async function() { require('./suite/index.test'); require('./suite/dates.test'); +require('./suite/json.test'); diff --git a/test/integration/sqlite.test.js b/test/integration/sqlite.test.js index 6d61d8ef..dc42c39f 100644 --- a/test/integration/sqlite.test.js +++ b/test/integration/sqlite.test.js @@ -4,7 +4,7 @@ const assert = require('assert').strict; const path = require('path'); const sinon = require('sinon'); -const { connect, raw, Bone } = require('../../src'); +const { connect, raw, Bone, Raw } = require('../../src'); const { checkDefinitions } = require('./helpers'); before(async function() { @@ -104,3 +104,15 @@ describe('=> upsert (sqlite)', function () { ); }); }); + +describe('=> update(sqlite) & jsonMerge(sqlite)', () => { + const Post = require('../models/post'); + it('JSON_MERGE_PATCH can not work in sqlite', async () => { + const post = await Post.create({ title: 'new post', extra: { uid: 2200 }}); + assert.equal(post.extra.uid, 2200); + await assert.rejects(async () => await post.jsonMerge('extra', { uid: 9527 })); + assert.equal(post.extra.uid, 2200); + await assert.rejects(async () => await post.update({ extra: new Raw(`JSON_MERGE_PATCH(extra, '${JSON.stringify({ uid: 4396 })}')`)})); + assert.equal(post.extra.uid, 2200); + }); +}); diff --git a/test/integration/suite/json.test.js b/test/integration/suite/json.test.js new file mode 100644 index 00000000..20bc6bf5 --- /dev/null +++ b/test/integration/suite/json.test.js @@ -0,0 +1,57 @@ +'use strict'; + +const assert = require('assert').strict; + +const { Bone, Raw } = require('../../../src'); + +describe('=> Basic', () => { + + describe('=> JSON Functions', ()=>{ + + class Gen extends Bone { } + Gen.init({ + id: { type: Bone.DataTypes.INTEGER, primaryKey: true }, + name: Bone.DataTypes.STRING, + extra: Bone.DataTypes.JSONB, + deleted_at: Bone.DataTypes.DATE, + }); + + before(async () => { + await Bone.driver.dropTable('gens'); + await Gen.sync(); + }); + + after(async () => { + await Bone.driver.dropTable('gens'); + }); + + beforeEach(async () => { + await Gen.remove({}, true); + }); + + it('bone.jsonMerge(name, values, options) should work', async () => { + const gen = await Gen.create({ name: '章3️⃣疯' }); + assert.equal(gen.name, '章3️⃣疯'); + await gen.update({ extra: { a: 1 } }); + assert.equal(gen.extra.a, 1); + await gen.jsonMerge('extra', { b: 2, a: 3 }); + assert.equal(gen.extra.a, 3); + assert.equal(gen.extra.b, 2); + + const gen2 = await Gen.create({ name: 'gen2', extra: { test: 1 }}); + assert.equal(gen2.extra.test, 1); + await gen2.jsonMerge('extra', { url: 'https://www.wanxiang.art/?foo=' }); + assert.equal(gen2.extra.url, 'https://www.wanxiang.art/?foo='); + }); + + it('bone.update(values,options) with JSON_MERGE_PATCH func should work', async () => { + const gen = await Gen.create({ name: 'testUpdateGen', extra: { test: 'gen' }}); + assert.equal(gen.extra.test, 'gen'); + assert.equal(gen.name, 'testUpdateGen'); + + const sql = new Raw(`JSON_MERGE_PATCH(extra, '${JSON.stringify({ url: 'https://www.taobao.com/?id=1' })}')`); + await gen.update({extra: sql}); + assert.equal(gen.extra.url, 'https://www.taobao.com/?id=1'); + }); + }); +});