From 761a549e65e999cd438571f6ee38b04e1114e1ec Mon Sep 17 00:00:00 2001 From: Dekel Barzilay Date: Sat, 16 Feb 2019 15:01:28 +0200 Subject: [PATCH] Added support for new query operators --- README.md | 38 ++++++++-- package-lock.json | 105 +++++++++++++++++++++++++-- package.json | 5 +- src/index.js | 42 ++++++++++- test/company.js | 4 +- test/index.test.js | 175 +++++++++++++++++++++++++++++++++++++++++---- 6 files changed, 339 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index e3485cd..c08df1b 100644 --- a/README.md +++ b/README.md @@ -154,18 +154,48 @@ Note that all this eager related options are optional. #### Query Operators -- **`$eager`** - parameter to eager load relations defined in models' +- **`$eager`** - eager load relations defined in models' `relationMappings` getter methods or in the `namedEagerFilters` option. See [`eager`](https://vincit.github.io/objection.js/#eager) documentation. -- **`$joinRelation`** - parameter to filter based on a relation's field. See +- **`$joinRelation`** - filter based on a relation's field. See [`joinRelation`](https://vincit.github.io/objection.js/#joinrelation) documentation. -- **`$joinEager`** - parameter to filter based on a relation's field using +- **`$joinEager`** - filter based on a relation's field using `JoinEagerAlgorithm`. See [`$joinEager`](https://vincit.github.io/objection.js/#joineager) documentation. -- **`$pick`** - parameter to pick properties from result models. See +- **`$pick`** - pick properties from result models. See [`pick`](https://vincit.github.io/objection.js/#pick) documentation. + +- **`$between`** - filter based on if a column value is between range of values + +- **`$notBetween`** - filter based on if a column value is not between range of values + +- **`$like`** - filter column value based on a LIKE pattern + +- **`$notLike`** - filter column value based on a NOT LIKE pattern + +- **`$regexp`** - filter column value based on a REGEXP pattern + +- **`$notRegexp`** - filter column value based on a NOT REGEXP pattern + +- **`$ilike`** - (Postgres) filter column value based on a case-insensitive LIKE pattern + +- **`$notILike`** - (Postgres) filter column value based on a case-insensitive NOT LIKE pattern + +- **`$iRegexp`** - (Postgres) filter column value based on a case-insensitive REGEXP pattern + +- **`$notIRegexp`** - (Postgres) filter column value based on a case-insensitive NOT REGEXP pattern + +- **`$containsKey`** (Postgres) - filter based on if a column contains a key + +- **`$any`** (Postgres) - filter based on if a column contains any key from array of strings + +- **`$all`** (Postgres) - filter based on if a column contains all keys from array of strings + +- **`$contains`** (Postgres) - filter based on if a column contains a value + +- **`$contained`** (Postgres) - filter based on if a column is contained in a value #### Params Operators diff --git a/package-lock.json b/package-lock.json index 0aae101..4330a71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "feathers-objection", - "version": "3.1.2", + "version": "3.2.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -1940,6 +1940,11 @@ "integrity": "sha512-c5mRlguI/Pe2dSZmpER62rSCu0ryKmWddzRYsuXc50U2/g8jMOulc31VZMa4mYx31U5xsmSOpDCgH88Vl9cDGQ==", "dev": true }, + "buffer-writer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz", + "integrity": "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==" + }, "builtin-modules": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz", @@ -6814,6 +6819,11 @@ "integrity": "sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=", "dev": true }, + "packet-reader": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-0.3.1.tgz", + "integrity": "sha1-zWLmCvjX/qinBexP+ZCHHEaHHyc=" + }, "parse-filepath": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz", @@ -6934,12 +6944,68 @@ "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=", "dev": true }, + "pg": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-7.8.0.tgz", + "integrity": "sha512-yS3C9YD+ft0H7G47uU0eKajgTieggCXdA+Fxhm5G+wionY6kPBa8BEVDwPLMxQvkRkv3/LXiFEqjZm9gfxdW+g==", + "requires": { + "buffer-writer": "2.0.0", + "packet-reader": "0.3.1", + "pg-connection-string": "0.1.3", + "pg-pool": "^2.0.4", + "pg-types": "~2.0.0", + "pgpass": "1.x", + "semver": "4.3.2" + }, + "dependencies": { + "pg-connection-string": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-0.1.3.tgz", + "integrity": "sha1-2hhHsglA5C7hSSvq9l1J2RskXfc=" + }, + "semver": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-4.3.2.tgz", + "integrity": "sha1-x6BxWKgL7dBSNVt3DYLWZA+AO+c=" + } + } + }, "pg-connection-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.0.0.tgz", "integrity": "sha1-Pu/lmX4G2Ugh5NUC5CtqHHP434I=", "dev": true }, + "pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==" + }, + "pg-pool": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-2.0.6.tgz", + "integrity": "sha512-hod2zYQxM8Gt482q+qONGTYcg/qVcV32VHVPtktbBJs0us3Dj7xibISw0BAAXVMCzt8A/jhfJvpZaxUlqtqs0g==" + }, + "pg-types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.0.0.tgz", + "integrity": "sha512-THUD7gQll5tys+5eQ8Rvs7DjHiIC3bLqixk3gMN9Hu8UrCBAOjf35FoI39rTGGc3lM2HU/R+Knpxvd11mCwOMA==", + "requires": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.0", + "postgres-interval": "^1.1.0" + } + }, + "pgpass": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.2.tgz", + "integrity": "sha1-Knu0G2BltnkH6R2hsHwYR8h3swY=", + "requires": { + "split": "^1.0.0" + } + }, "pify": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", @@ -7051,6 +7117,29 @@ "integrity": "sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=", "dev": true }, + "postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==" + }, + "postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha1-AntTPAqokOJtFy1Hz5zOzFIazTU=" + }, + "postgres-date": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.3.tgz", + "integrity": "sha1-4tiXAu/bJY/52c7g/pG9BpdSV6g=" + }, + "postgres-interval": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.1.2.tgz", + "integrity": "sha512-fC3xNHeTskCxL1dC8KOtxXt7YeFmlbTYtn7ul8MkVERuTmf7pI4DrkAxcw3kh1fQ9uz4wQmd03a1mRiXUZChfQ==", + "requires": { + "xtend": "^4.0.0" + } + }, "prelude-ls": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", @@ -7910,6 +7999,14 @@ "integrity": "sha512-uBIcIl3Ih6Phe3XHK1NqboJLdGfwr1UN3k6wSD1dZpmPsIkb8AGNbZYJ1fOBk834+Gxy8rpfDxrS6XLEMZMY2g==", "dev": true }, + "split": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz", + "integrity": "sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==", + "requires": { + "through": "2" + } + }, "split-string": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", @@ -8177,8 +8274,7 @@ "through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", - "dev": true + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=" }, "tildify": { "version": "1.2.0", @@ -8594,8 +8690,7 @@ "xtend": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz", - "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68=", - "dev": true + "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68=" }, "y18n": { "version": "4.0.0", diff --git a/package.json b/package.json index ecc48ee..7bdc269 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "feathers-objection", "description": "A service plugin for ObjectionJS an ORM based on KnexJS", - "version": "3.1.2", + "version": "3.2.0", "homepage": "https://github.com/feathersjs-ecosystem/feathers-objection", "keywords": [ "feathers", @@ -56,7 +56,8 @@ }, "dependencies": { "@feathersjs/adapter-commons": "^2.0.0", - "@feathersjs/errors": "^3.3.6" + "@feathersjs/errors": "^3.3.6", + "pg": "^7.8.0" }, "devDependencies": { "@babel/cli": "^7.2.3", diff --git a/src/index.js b/src/index.js index e5ea85e..161c947 100644 --- a/src/index.js +++ b/src/index.js @@ -37,9 +37,27 @@ const OPERATORS_MAP = { $like: 'like', $notLike: 'not like', $ilike: 'ilike', - $notILike: 'not ilike' + $notILike: 'not ilike', + $regexp: '~', + $notRegexp: '!~', + $iRegexp: '~*', + $notIRegexp: '!~*', + $between: 'between', + $notBetween: 'not between', + $contains: '@>', + $containsKey: '?', + $contained: '<@', + $any: '?|', + $all: '?&' }; +const RANGE_OPERATORS = [ + 'between', + 'not between', + '?|', + '?&' +]; + /** * Class representing an feathers adapter for Objection.js ORM. * @param {object} options @@ -134,7 +152,7 @@ class Service extends AdapterService { if (params.$pick) { delete params.$pick; } Object.keys(params || {}).forEach(key => { - const value = params[key]; + let value = params[key]; if (utils.isPlainObject(value)) { return this.objectify(query, value, key, parentKey); @@ -176,7 +194,25 @@ class Service extends AdapterService { let columnType = property && property.type; if (columnType) { if (Array.isArray(columnType)) { columnType = columnType[0]; } - if (columnType === 'object' || columnType === 'array') { return query.where(ref(`${this.Model.tableName}.${methodKey || column}:${(methodKey ? column : key).replace(/\(/g, '[').replace(/\)/g, ']')}`).castText(), operator, value); } + if (columnType === 'object' || columnType === 'array') { + let refColumn = null; + + if (!methodKey && key[0] === '$') { + refColumn = ref(`${this.Model.tableName}.${methodKey || column}`); + } else { + refColumn = ref(`${this.Model.tableName}.${methodKey || column}:${(methodKey ? column : key).replace(/\(/g, '[').replace(/\)/g, ']')}`); + } + + if (RANGE_OPERATORS.includes(operator) && typeof value === 'string' && value[0] === '[' && value[value.length - 1] === ']') { + value = JSON.parse(value); + } + + return query.where(refColumn, operator, value); + } + } + + if (RANGE_OPERATORS.includes(operator) && typeof value === 'string' && value[0] === '[' && value[value.length - 1] === ']') { + value = JSON.parse(value); } return operator === '=' ? query.where(column, value) : query.where(column, operator, value); diff --git a/test/company.js b/test/company.js index ac0a944..61ccfae 100644 --- a/test/company.js +++ b/test/company.js @@ -24,7 +24,9 @@ export default class Company extends Model { } } }, - jsonArray: { type: ['array', 'null'] } + jsonArray: { type: ['array', 'null'] }, + jsonbObject: { type: ['object', 'null'] }, + jsonbArray: { type: ['array', 'null'] } } } diff --git a/test/index.test.js b/test/index.test.js index 34e55dc..04ae034 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -97,6 +97,7 @@ const app = feathers() model: People, id: 'id', multi: ['create'], + whitelist: ['$and', '$like', '$between', '$notBetween'], events: ['testing'] }) ) @@ -133,7 +134,7 @@ const app = feathers() model: Company, id: 'id', multi: ['create', 'remove'], - whitelist: ['$eager', '$pick'], + whitelist: ['$eager', '$pick', '$between', '$notBetween', '$containsKey', '$contains', '$contained', '$any', '$all'], allowedEager: '[ceos, clients]', namedEagerFilters: { notSnoop (builder) { @@ -228,13 +229,19 @@ function clean (done) { }); }) .then(() => { - return db.schema.dropTableIfExists('companies').then(() => { - return db.schema.createTable('companies', table => { - table.increments('id'); - table.string('name'); - table.integer('ceo'); - table.json('jsonObject'); - table.json('jsonArray'); + return db.schema.dropTableIfExists('clients').then(() => { + return db.schema.dropTableIfExists('employees').then(() => { + return db.schema.dropTableIfExists('companies').then(() => { + return db.schema.createTable('companies', table => { + table.increments('id'); + table.string('name'); + table.integer('ceo'); + table.json('jsonObject'); + table.json('jsonArray'); + table.jsonb('jsonbObject'); + table.jsonb('jsonbArray'); + }); + }); }); }); }) @@ -890,7 +897,7 @@ describe('Feathers Objection Service', () => { }).catch(function (error) { expect(error).to.be.ok; expect(error instanceof errors.BadRequest).to.be.ok; - expect(error.message).to.equal('select `companies`.* from `companies` where CAST(`companies`.`jsonObject`#>>\'{numberField}\' AS text) = 1.5 - SQLITE_ERROR: unrecognized token: "#"'); + expect(error.message).to.equal('select `companies`.* from `companies` where `companies`.`jsonObject`#>\'{numberField}\' = 1.5 - SQLITE_ERROR: unrecognized token: "#"'); }); }); @@ -900,7 +907,7 @@ describe('Feathers Objection Service', () => { }).catch(function (error) { expect(error).to.be.ok; expect(error instanceof errors.BadRequest).to.be.ok; - expect(error.message).to.equal('select `companies`.* from `companies` where CAST(`companies`.`jsonObject`#>>\'{numberField}\' AS text) > 1.5 - SQLITE_ERROR: unrecognized token: "#"'); + expect(error.message).to.equal('select `companies`.* from `companies` where `companies`.`jsonObject`#>\'{numberField}\' > 1.5 - SQLITE_ERROR: unrecognized token: "#"'); }); }); @@ -910,7 +917,7 @@ describe('Feathers Objection Service', () => { }).catch(function (error) { expect(error).to.be.ok; expect(error instanceof errors.BadRequest).to.be.ok; - expect(error.message).to.equal('select `companies`.* from `companies` where CAST(`companies`.`jsonObject`#>>\'{objectField,object}\' AS text) = \'string in jsonObject.objectField.object\' - SQLITE_ERROR: unrecognized token: "#"'); + expect(error.message).to.equal('select `companies`.* from `companies` where `companies`.`jsonObject`#>\'{objectField,object}\' = \'string in jsonObject.objectField.object\' - SQLITE_ERROR: unrecognized token: "#"'); }); }); @@ -926,7 +933,7 @@ describe('Feathers Objection Service', () => { }).catch(function (error) { expect(error).to.be.ok; expect(error instanceof errors.BadRequest).to.be.ok; - expect(error.message).to.equal('select `companies`.* from `companies` where CAST(`companies`.`jsonArray`#>>\'{0,objectField,object}\' AS text) = \'I\'\'m string in jsonArray[0].objectField.object\' - SQLITE_ERROR: unrecognized token: "#"'); + expect(error.message).to.equal('select `companies`.* from `companies` where `companies`.`jsonArray`#>\'{0,objectField,object}\' = \'I\'\'m string in jsonArray[0].objectField.object\' - SQLITE_ERROR: unrecognized token: "#"'); }); }); }); @@ -1030,7 +1037,7 @@ describe('Feathers Objection Service', () => { }); describe('$like method', () => { - beforeEach(async () => { + before(async () => { await people .create({ name: 'Charlie Brown', @@ -1046,7 +1053,7 @@ describe('Feathers Objection Service', () => { }); describe('$and method', () => { - beforeEach(async () => { + before(async () => { await people .create([ { @@ -1072,7 +1079,7 @@ describe('Feathers Objection Service', () => { }); describe('$or method', () => { - beforeEach(async () => { + before(async () => { await people .create([ { @@ -1097,6 +1104,144 @@ describe('Feathers Objection Service', () => { }); }); + describe('between & not between operators', () => { + before(async () => { + await people + .create([ + { + name: 'Dave', + age: 1 + }, + { + name: 'John', + age: 101 + }, + { + name: 'Dada', + age: 15 + } + ]); + }); + + it('$between', () => { + return people.find({ query: { age: { $between: [100, 102] } } }).then(data => { + expect(data[0].name).to.be.equal('John'); + }); + }); + + it('$between - string', () => { + return people.find({ query: { age: { $between: '[100, 102]' } } }).then(data => { + expect(data[0].name).to.be.equal('John'); + }); + }); + + it('$notBetween', () => { + return people.find({ query: { age: { $notBetween: [0, 100] } } }).then(data => { + expect(data[0].name).to.be.equal('John'); + }); + }); + + it('$notBetween - string', () => { + return people.find({ query: { age: { $notBetween: '[0, 100]' } } }).then(data => { + expect(data[0].name).to.be.equal('John'); + }); + }); + }); + + describe.skip('JSON operators (Postgres)', () => { + before(async () => { + await companies + .create([ + { + name: 'Google', + jsonbObject: { a: 1, b: 2, c: { d: [3] }, e: [4] }, + jsonbArray: [1, 2] + }, + { + name: 'Apple', + jsonbObject: { z: 0 }, + jsonbArray: [0] + } + ]); + }); + + after(async () => { + await companies.remove(null); + }); + + it('$containsKey', () => { + return companies.find({ query: { jsonbObject: { $containsKey: 'a' } } }).then(data => { + expect(data[0].name).to.be.equal('Google'); + }); + }); + + it('$containsKey - nested', () => { + return companies.find({ query: { jsonbObject: { c: { $containsKey: 'd' } } } }).then(data => { + expect(data[0].name).to.be.equal('Google'); + }); + }); + + it('$contains', () => { + return companies.find({ query: { jsonbArray: { $contains: 1 } } }).then(data => { + expect(data[0].name).to.be.equal('Google'); + }); + }); + + it('$contains', () => { + return companies.find({ query: { jsonbArray: { $contains: 1 } } }).then(data => { + expect(data[0].name).to.be.equal('Google'); + }); + }); + + it('$contains - nested', () => { + return companies.find({ query: { jsonbObject: { e: { $contains: 4 } } } }).then(data => { + expect(data[0].name).to.be.equal('Google'); + }); + }); + + it('$contained', () => { + return companies.find({ query: { jsonbObject: { $contained: JSON.stringify({ a: 1, b: 2, c: { d: [3] }, e: [4], f: 5 }) } } }).then(data => { + expect(data[0].name).to.be.equal('Google'); + }); + }); + + it('$any', () => { + return companies.find({ query: { jsonbObject: { $any: ['a', 'aa'] } } }).then(data => { + expect(data[0].name).to.be.equal('Google'); + }); + }); + + it('$all', () => { + return companies.find({ query: { jsonbObject: { $all: ['a', 'b'] } } }).then(data => { + expect(data[0].name).to.be.equal('Google'); + }); + }); + + it('$between', () => { + return companies.find({ query: { jsonbObject: { b: { $between: [1, 3] } } } }).then(data => { + expect(data[0].name).to.be.equal('Google'); + }); + }); + + it('$between - string', () => { + return companies.find({ query: { jsonbObject: { b: { $between: '[1, 3]' } } } }).then(data => { + expect(data[0].name).to.be.equal('Google'); + }); + }); + + it('$notBetween', () => { + return companies.find({ query: { jsonbObject: { b: { $notBetween: [3, 5] } } } }).then(data => { + expect(data[0].name).to.be.equal('Google'); + }); + }); + + it('$notBetween - string', () => { + return companies.find({ query: { jsonbObject: { b: { $notBetween: '[3, 5]' } } } }).then(data => { + expect(data[0].name).to.be.equal('Google'); + }); + }); + }); + describe('Transactions', () => { let transaction;