diff --git a/.circleci/config.yml b/.circleci/config.yml index a3ea91e..2d298e6 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -25,7 +25,7 @@ executors: # Orbs orbs: - node: circleci/node@7.1.0 + node: circleci/node@7.1.1 ################################ # Jobs diff --git a/CHANGELOG.md b/CHANGELOG.md index efb986f..bfb8d7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,24 @@ +## [0.98.1](https://github.com/5app/dare/compare/v0.98.0...v0.98.1) (2025-08-08) + + +### Bug Fixes + +* **datetime:** always allow a range ([#415](https://github.com/5app/dare/issues/415)) ([63072c8](https://github.com/5app/dare/commit/63072c86530a2d0ebae65ba7e9a95f5bb5ac45f2)) + +# [0.98.0](https://github.com/5app/dare/compare/v0.97.2...v0.98.0) (2025-07-18) + + +### Features + +* **field.setFunction:** enable JSON_MERGE_PATCH definition ([#412](https://github.com/5app/dare/issues/412)) ([2e5d363](https://github.com/5app/dare/commit/2e5d363f79cebc043bb6f4e044e5ac77975c5604)) + +## [0.97.2](https://github.com/5app/dare/compare/v0.97.1...v0.97.2) (2025-07-17) + + +### Bug Fixes + +* **field_expression:** support [FUNCNAME](field AS [UPPERCASE|String|Number]) ([#385](https://github.com/5app/dare/issues/385)) ([93dd70e](https://github.com/5app/dare/commit/93dd70e2507a8ad8ca5879b221ff898539c82009)) + ## [0.97.1](https://github.com/5app/dare/compare/v0.97.0...v0.97.1) (2025-07-03) diff --git a/package-lock.json b/package-lock.json index 10af5ec..3d33eaa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dare", - "version": "0.97.1", + "version": "0.98.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dare", - "version": "0.97.1", + "version": "0.98.1", "license": "MIT", "dependencies": { "semver-compare": "^1.0.0", @@ -15,7 +15,7 @@ }, "devDependencies": { "@5app/prettier-config": "^1.0.4", - "@5app/semantic-release-config": "^1.1.0", + "@5app/semantic-release-config": "^2.0.0", "@commitlint/cli": "^19.6.1", "@commitlint/config-conventional": "^19.6.0", "@types/mocha": "^10.0.10", @@ -51,7 +51,9 @@ "license": "ISC" }, "node_modules/@5app/semantic-release-config": { - "version": "1.1.0", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@5app/semantic-release-config/-/semantic-release-config-2.0.0.tgz", + "integrity": "sha512-DWEDLMo+ppIh1Ce3zpdF1gv+/udAeQw/hTo5/X9A3gii7n8NCr2ORYVQCUFvQsNGy3DhfqxY1Gd0PTFMdJCCUw==", "dev": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index aaf8506..cecb51e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dare", - "version": "0.97.1", + "version": "0.98.1", "description": "Database to REST, REST to Database", "type": "module", "main": "./src/index.js", @@ -54,7 +54,7 @@ ], "devDependencies": { "@5app/prettier-config": "^1.0.4", - "@5app/semantic-release-config": "^1.1.0", + "@5app/semantic-release-config": "^2.0.0", "@commitlint/cli": "^19.6.1", "@commitlint/config-conventional": "^19.6.0", "@types/mocha": "^10.0.10", diff --git a/src/format/reducer_conditions.js b/src/format/reducer_conditions.js index 2f93c7e..a39ae03 100644 --- a/src/format/reducer_conditions.js +++ b/src/format/reducer_conditions.js @@ -118,17 +118,17 @@ function prepCondition({ value, sql_alias, table_schema, - operators, + operators = '', conditional_operators_in_value, dareInstance, }) { const {engine} = dareInstance; // Does it have a negative comparison operator? - const negate = operators?.includes('-'); + const negate = operators.includes('-'); // Does it have a FullText comparison operator - const isFullText = operators?.includes('*'); + const isFullText = operators.includes('*'); // Set a handly NOT value const NOT = negate ? raw('NOT ') : empty; @@ -171,7 +171,7 @@ function prepCondition({ value, sql_alias, table_schema, - operators: operators?.replace('-', ''), + operators: operators.replace('-', ''), conditional_operators_in_value, dareInstance, }) @@ -190,6 +190,11 @@ function prepCondition({ // Format date time values if (type === 'datetime') { value = formatDateTime(value); + + // NOTE: Could we just return SQL from formatDateTime instead of ensuring an implicit range? + if (!operators.includes('~')) { + operators += '~'; // Add range operator + } } // @ts-ignore else if (type === 'date' && value instanceof Date) { @@ -255,16 +260,16 @@ function sqlCondition({ const IS_POSTGRES = engine.startsWith('postgres'); // Does it have a negative comparison operator? - const negate = operators?.includes('-'); + const negate = operators.includes('-'); // Set a handly NOT value const NOT = negate ? raw('NOT ') : empty; // Does it have a Likey comparison operator - const isLikey = operators?.includes('%'); + const isLikey = operators.includes('%'); // Does it have a Range comparison operator - const isRange = operators?.includes('~'); + const isRange = operators.includes('~'); // Allow conditional likey operator in value const allow_conditional_likey_operator_in_value = diff --git a/src/index.js b/src/index.js index 331d837..9fed867 100644 --- a/src/index.js +++ b/src/index.js @@ -60,6 +60,7 @@ import response_handler, {responseRowHandler} from './response_handler.js'; * @property {boolean} [writeable=true] - Whether this field is writeable * @property {boolean} [required=false] - Whether this field is required * @property {Handler} [handler] - Handler to generate the field value + * @property {(arg: {sql_field: string, field: string, value: any}) => * | SQL} [setFunction] - String defining a SQL function to wrap the value in when setting it * @property {FieldAttributes} [get] - The get definition of this field * @property {FieldAttributes} [post] - The post definition of this field * @property {FieldAttributes} [patch] - The patch definition of this field @@ -977,7 +978,8 @@ function prepareSQLSet({ * Get the real field in the db, * And formatted value... */ - const {field, value} = formatInputValue({ + const {sql_field, value} = formatInputValue({ + sql_alias, tableSchema, field: label, value: body[label], @@ -987,7 +989,7 @@ function prepareSQLSet({ // Replace value with a question using any mapped fieldName assignments.push( - SQL`${raw((sql_alias ? `${sql_alias}.` : '') + dareInstance.identifierWrapper(field))} = ${value}` + SQL`${raw(sql_field)} = ${value}` ); } @@ -1043,15 +1045,17 @@ Dare.prototype.onDuplicateKeysUpdate = function onDuplicateKeysUpdate( * For a given field definition, return the db key (alias) and format the input it required * @param {object} obj - Object * @param {Schema} [obj.tableSchema={}] - An object containing the table schema + * @param {string} [obj.sql_alias=null] - SQL Alias for the table * @param {string} obj.field - field identifier * @param {*} obj.value - Given value * @param {Function} [obj.validateInput] - Custom validation function * @param {Dare} obj.dareInstance - Dare Instance * @throws Will throw an error if the field is not writable - * @returns {{field: string, value: *}} A singular value which can be inserted + * @returns {{field: string, sql_field: string, value: *}} A singular value which can be inserted */ function formatInputValue({ tableSchema = {}, + sql_alias = null, field, value, validateInput, @@ -1073,7 +1077,7 @@ function formatInputValue({ fieldAttributes = null; } - const {alias, writeable, type} = fieldAttributes || {}; + const {alias, writeable, type, setFunction} = fieldAttributes || {}; // Execute custom field validation validateInput?.(fieldAttributes, field, value); @@ -1135,7 +1139,18 @@ function formatInputValue({ field = alias; } - return {field, value}; + // Format the field + const sql_field = (sql_alias ? `${sql_alias}.` : '') + dareInstance.identifierWrapper(field); + + /** + * Format the set value + */ + if (setFunction) { + // If the insertWrapper is defined, use it to format the value + value = setFunction({value, field, sql_field}); + } + + return {field, sql_field, value}; } /** diff --git a/src/utils/unwrap_field.js b/src/utils/unwrap_field.js index b562d21..2555964 100644 --- a/src/utils/unwrap_field.js +++ b/src/utils/unwrap_field.js @@ -46,7 +46,7 @@ export default function unwrap_field(expression, allowValue = true) { // Split out comma variables while ( (int_m = str.match( - /^(.*)((,|AS)\s*(?(?["'])?[\s\w%./-]*\k))$/ + /^(.*)((,|\sAS)\s*(?(?["'])?[\s\w%./-]*\k))$/ )) ) { /* diff --git a/test/integration/json.spec.js b/test/integration/json.spec.js index 28962c8..c2eb344 100644 --- a/test/integration/json.spec.js +++ b/test/integration/json.spec.js @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import SQL from 'sql-template-tag'; +import SQL, {raw} from 'sql-template-tag'; import defaultAPI from './helpers/api.js'; // Connect to db @@ -162,4 +162,41 @@ describe('Working with JSON DataType', () => { assert.strictEqual(noMatch, null); }); + + it('JSON fields should be patchable with a setFunction definition', async function () { + + if (DB_ENGINE?.startsWith('postgres')) { + this.skip(); + return; + } + + // Update the user settings with a setFunction + dare.options.models.users.schema.settings.patch = { + setFunction({sql_field, value}) { + return SQL`JSON_MERGE_PATCH(${raw(sql_field)}, ${value})`; + } + }; + + // Insert intial settings + const settings = {a: 1, b: 0}; + + await dare.post('users', {username, settings}); + + // Patch the settings + const newSettings = {b: 2, c: 3}; + + await dare.patch({ + table: 'users', + filter: {username}, + body: {settings: newSettings}, + }); + + const resp = await dare.get({ + table: 'users', + fields: ['settings'], + filter: {username}, + }); + + assert.deepStrictEqual(resp.settings, {...settings, ...newSettings}); + }); }); diff --git a/test/integration/run.sh b/test/integration/run.sh index de5272d..ac36360 100644 --- a/test/integration/run.sh +++ b/test/integration/run.sh @@ -25,7 +25,7 @@ cd "$INTEGRATION_TEST_DIR" || exit 1 DB_ROOT_USER="root" DB_ROOT_PASSWORD="test_pass" -DB_ENGINE=${DB_ENGINE:-mysql:5.7.40} +DB_ENGINE=${DB_ENGINE:-mysql:8.0.23} DB_ENGINE_NAME=$(echo $DB_ENGINE | cut -d: -f1) diff --git a/test/specs/field_format.spec.js b/test/specs/field_format.spec.js index b75753e..4677b82 100644 --- a/test/specs/field_format.spec.js +++ b/test/specs/field_format.spec.js @@ -36,6 +36,12 @@ describe('utils/field_format', () => { ['nested.field', 'label'], ], + // Function: CAST + [ + ['CAST(field AS CHAR)', 'label', 'tbl'], + ['CAST(tbl.field AS CHAR)', 'label'], + ], + // If the expression defines a nested field, take that away from the prefix label address [ ['SUM(nested.field)', 'count', 'nested', 'nested.'], diff --git a/test/specs/format_request.spec.js b/test/specs/format_request.spec.js index 705c30a..abb54d8 100644 --- a/test/specs/format_request.spec.js +++ b/test/specs/format_request.spec.js @@ -449,6 +449,13 @@ describe('format_request', () => { '(NOT a.datetime > ? OR a.datetime IS NULL)', ['1981-12-05T00:00:00'], ], + [ + // Should always expand datetime fields + {'datetime': '1981-12-05..1981-12-06'}, + 'a.datetime BETWEEN ? AND ?', + ['1981-12-05T00:00:00', '1981-12-06T23:59:59'], + noCondOperators, + ], [{prop: '1981-12-05..'}, 'a.prop > ?', ['1981-12-05']], [ {prop: '1970-01-01..1981-12-05'}, diff --git a/test/specs/patch.spec.js b/test/specs/patch.spec.js index 61524c2..b7d65f6 100644 --- a/test/specs/patch.spec.js +++ b/test/specs/patch.spec.js @@ -5,6 +5,7 @@ import Dare from '../../src/index.js'; import sqlEqual from '../lib/sql-equal.js'; import DareError from '../../src/utils/error.js'; +import SQL, {raw} from 'sql-template-tag'; const id = 1; const name = 'name'; @@ -170,6 +171,40 @@ describe('patch', () => { }); }); }); + + it('should apply schema.field.setFunction', () => { + + dare.options.models = { + test: { + schema: { + meta: { + type: 'json', + setFunction({sql_field, value}) { + return SQL`JSON_MERGE_PATCH(${raw(sql_field)}, ${value})`; + }, + }, + }, + }, + }; + + const meta = {key: 'value'}; + + dare.execute = async ({sql, values}) => { + // Limit: 1 + sqlEqual( + sql, + 'UPDATE test a SET a.`meta` = JSON_MERGE_PATCH(a.`meta`, ?) WHERE a.id = ? LIMIT ?' + ); + expect(values).to.deep.equal([JSON.stringify(meta), id, 1]); + return {success: true}; + }; + + return dare.patch({ + table: 'test', + filter: {id}, + body: {meta}, + }); + }); }); it('should apply the request.limit', async () => {