From e45df29dd8a65fcb9d11b654e43f8553924b0d8d Mon Sep 17 00:00:00 2001 From: Pedro Augusto de Paula Barbosa Date: Wed, 27 Jan 2021 00:37:49 -0300 Subject: [PATCH] feat(postgres): add TSVECTOR datatype and `@@` operator (#12955) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jano Kacer Co-authored-by: Sébastien BRAMILLE <2752200+oktapodia@users.noreply.github.com> --- docs/manual/core-concepts/model-basics.md | 1 + .../core-concepts/model-querying-basics.md | 1 + lib/data-types.js | 18 ++++++++++++- .../abstract/query-generator/operators.js | 3 ++- lib/dialects/postgres/data-types.js | 1 + lib/dialects/postgres/index.js | 1 + lib/operators.js | 3 ++- test/integration/data-types.test.js | 12 +++++++++ test/integration/model/create.test.js | 25 +++++++++++++++++++ test/integration/model/findOne.test.js | 16 ++++++++++++ test/unit/sql/where.test.js | 14 +++++++++++ 11 files changed, 92 insertions(+), 3 deletions(-) diff --git a/docs/manual/core-concepts/model-basics.md b/docs/manual/core-concepts/model-basics.md index 99b2c209a64b..db4075280dec 100644 --- a/docs/manual/core-concepts/model-basics.md +++ b/docs/manual/core-concepts/model-basics.md @@ -270,6 +270,7 @@ DataTypes.STRING.BINARY // VARCHAR BINARY DataTypes.TEXT // TEXT DataTypes.TEXT('tiny') // TINYTEXT DataTypes.CITEXT // CITEXT PostgreSQL and SQLite only. +DataTypes.TSVECTOR // TSVECTOR PostgreSQL only. ``` ### Boolean diff --git a/docs/manual/core-concepts/model-querying-basics.md b/docs/manual/core-concepts/model-querying-basics.md index 71adc7657853..c10cf75d10d6 100644 --- a/docs/manual/core-concepts/model-querying-basics.md +++ b/docs/manual/core-concepts/model-querying-basics.md @@ -261,6 +261,7 @@ Post.findAll({ [Op.notIRegexp]: '^[h|a|t]', // !~* '^[h|a|t]' (PG only) [Op.any]: [2, 3], // ANY ARRAY[2, 3]::INTEGER (PG only) + [Op.match]: Sequelize.fn('to_tsquery', 'fat & rat') // match text search for strings 'fat' and 'rat' (PG only) // In Postgres, Op.like/Op.iLike/Op.notLike can be combined to Op.any: [Op.like]: { [Op.any]: ['cat', 'hat'] } // LIKE ANY ARRAY['cat', 'hat'] diff --git a/lib/data-types.js b/lib/data-types.js index 655162fe2008..36e520f5f2f7 100644 --- a/lib/data-types.js +++ b/lib/data-types.js @@ -940,6 +940,21 @@ class MACADDR extends ABSTRACT { } } +/** + * The TSVECTOR type stores text search vectors. + * + * Only available for Postgres + * + */ +class TSVECTOR extends ABSTRACT { + validate(value) { + if (typeof value !== 'string') { + throw new sequelizeErrors.ValidationError(util.format('%j is not a valid string', value)); + } + return true; + } +} + /** * A convenience class holding commonly used data types. The data types are used when defining a new model using `Sequelize.define`, like this: * ```js @@ -1023,7 +1038,8 @@ const DataTypes = module.exports = { CIDR, INET, MACADDR, - CITEXT + CITEXT, + TSVECTOR }; _.each(DataTypes, (dataType, name) => { diff --git a/lib/dialects/abstract/query-generator/operators.js b/lib/dialects/abstract/query-generator/operators.js index baef21654810..91d3caa2bcc0 100644 --- a/lib/dialects/abstract/query-generator/operators.js +++ b/lib/dialects/abstract/query-generator/operators.js @@ -42,7 +42,8 @@ const OperatorHelpers = { [Op.and]: ' AND ', [Op.or]: ' OR ', [Op.col]: 'COL', - [Op.placeholder]: '$$PLACEHOLDER$$' + [Op.placeholder]: '$$PLACEHOLDER$$', + [Op.match]: '@@' }, OperatorsAliasMap: {}, diff --git a/lib/dialects/postgres/data-types.js b/lib/dialects/postgres/data-types.js index 70411f7a95c8..17eaa70a1a65 100644 --- a/lib/dialects/postgres/data-types.js +++ b/lib/dialects/postgres/data-types.js @@ -36,6 +36,7 @@ module.exports = BaseTypes => { BaseTypes.CIDR.types.postgres = ['cidr']; BaseTypes.INET.types.postgres = ['inet']; BaseTypes.MACADDR.types.postgres = ['macaddr']; + BaseTypes.TSVECTOR.types.postgres = ['tsvector']; BaseTypes.JSON.types.postgres = ['json']; BaseTypes.JSONB.types.postgres = ['jsonb']; BaseTypes.TIME.types.postgres = ['time']; diff --git a/lib/dialects/postgres/index.js b/lib/dialects/postgres/index.js index d813c19cae96..3d23f6ed6c6d 100644 --- a/lib/dialects/postgres/index.js +++ b/lib/dialects/postgres/index.js @@ -57,6 +57,7 @@ PostgresDialect.prototype.supports = _.merge(_.cloneDeep(AbstractDialect.prototy JSON: true, JSONB: true, HSTORE: true, + TSVECTOR: true, deferrableConstraints: true, searchPath: true }); diff --git a/lib/operators.js b/lib/operators.js index a5b55f9b377a..7e8bb7cf9cdb 100644 --- a/lib/operators.js +++ b/lib/operators.js @@ -84,7 +84,8 @@ const Op = { values: Symbol.for('values'), col: Symbol.for('col'), placeholder: Symbol.for('placeholder'), - join: Symbol.for('join') + join: Symbol.for('join'), + match: Symbol.for('match') }; module.exports = Op; diff --git a/test/integration/data-types.test.js b/test/integration/data-types.test.js index b18aa58d4072..ddd8e3a5b52d 100644 --- a/test/integration/data-types.test.js +++ b/test/integration/data-types.test.js @@ -362,6 +362,18 @@ describe(Support.getTestDialectTeaser('DataTypes'), () => { }); + if (current.dialect.supports.TSVECTOR) { + it('calls parse and stringify for TSVECTOR', async () => { + const Type = new Sequelize.TSVECTOR(); + + if (['postgres'].includes(dialect)) { + await testSuccess(Type, 'swagger'); + } else { + testFailure(Type); + } + }); + } + it('calls parse and stringify for ENUM', async () => { const Type = new Sequelize.ENUM('hat', 'cat'); diff --git a/test/integration/model/create.test.js b/test/integration/model/create.test.js index 53bc8f3bd150..d495ceb7a933 100644 --- a/test/integration/model/create.test.js +++ b/test/integration/model/create.test.js @@ -1007,6 +1007,31 @@ describe(Support.getTestDialectTeaser('Model'), () => { }); } + if (dialect === 'postgres') { + it('allows the creation of a TSVECTOR field', async function() { + const User = this.sequelize.define('UserWithTSVECTOR', { + name: Sequelize.TSVECTOR + }); + + await User.sync({ force: true }); + await User.create({ name: 'John Doe' }); + }); + + it('TSVECTOR only allow string', async function() { + const User = this.sequelize.define('UserWithTSVECTOR', { + username: { type: Sequelize.TSVECTOR } + }); + + try { + await User.sync({ force: true }); + await User.create({ username: 42 }); + } catch (err) { + if (!(err instanceof Sequelize.ValidationError)) throw err; + expect(err).to.be.ok; + } + }); + } + if (current.dialect.supports.index.functionBased) { it("doesn't allow duplicated records with unique function based indexes", async function() { const User = this.sequelize.define('UserWithUniqueUsernameFunctionIndex', { diff --git a/test/integration/model/findOne.test.js b/test/integration/model/findOne.test.js index a2b6d38fa023..07351f9a9da6 100644 --- a/test/integration/model/findOne.test.js +++ b/test/integration/model/findOne.test.js @@ -273,6 +273,22 @@ describe(Support.getTestDialectTeaser('Model'), () => { expect(user.username).to.equal('longUserNAME'); }); } + + if (dialect === 'postgres') { + it('should allow case-sensitive find on TSVECTOR type', async function() { + const User = this.sequelize.define('UserWithCaseInsensitiveName', { + username: Sequelize.TSVECTOR + }); + + await User.sync({ force: true }); + await User.create({ username: 'longUserNAME' }); + const user = await User.findOne({ + where: { username: 'longUserNAME' } + }); + expect(user).to.exist; + expect(user.username).to.equal("'longUserNAME'"); + }); + } }); describe('eager loading', () => { diff --git a/test/unit/sql/where.test.js b/test/unit/sql/where.test.js index 98ee3d3c8f8f..779d7828c85b 100644 --- a/test/unit/sql/where.test.js +++ b/test/unit/sql/where.test.js @@ -1206,6 +1206,20 @@ describe(Support.getTestDialectTeaser('SQL'), () => { } } + if (current.dialect.supports.TSVESCTOR) { + describe('Op.match', () => { + testsql( + 'username', + { + [Op.match]: Support.sequelize.fn('to_tsvector', 'swagger') + }, + { + postgres: "[username] @@ to_tsvector('swagger')" + } + ); + }); + } + describe('fn', () => { it('{name: this.sequelize.fn(\'LOWER\', \'DERP\')}', function() { expectsql(sql.whereQuery({ name: this.sequelize.fn('LOWER', 'DERP') }), {