Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
feat(postgres): add TSVECTOR datatype and @@ operator (#12955)
Co-authored-by: Jano Kacer <jan@vestberry.com>
Co-authored-by: Sébastien BRAMILLE <2752200+oktapodia@users.noreply.github.com>
  • Loading branch information
3 people committed Jan 27, 2021
1 parent 9013836 commit e45df29
Show file tree
Hide file tree
Showing 11 changed files with 92 additions and 3 deletions.
1 change: 1 addition & 0 deletions docs/manual/core-concepts/model-basics.md
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/manual/core-concepts/model-querying-basics.md
Expand Up @@ -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']
Expand Down
18 changes: 17 additions & 1 deletion lib/data-types.js
Expand Up @@ -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
Expand Down Expand Up @@ -1023,7 +1038,8 @@ const DataTypes = module.exports = {
CIDR,
INET,
MACADDR,
CITEXT
CITEXT,
TSVECTOR
};

_.each(DataTypes, (dataType, name) => {
Expand Down
3 changes: 2 additions & 1 deletion lib/dialects/abstract/query-generator/operators.js
Expand Up @@ -42,7 +42,8 @@ const OperatorHelpers = {
[Op.and]: ' AND ',
[Op.or]: ' OR ',
[Op.col]: 'COL',
[Op.placeholder]: '$$PLACEHOLDER$$'
[Op.placeholder]: '$$PLACEHOLDER$$',
[Op.match]: '@@'
},

OperatorsAliasMap: {},
Expand Down
1 change: 1 addition & 0 deletions lib/dialects/postgres/data-types.js
Expand Up @@ -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'];
Expand Down
1 change: 1 addition & 0 deletions lib/dialects/postgres/index.js
Expand Up @@ -57,6 +57,7 @@ PostgresDialect.prototype.supports = _.merge(_.cloneDeep(AbstractDialect.prototy
JSON: true,
JSONB: true,
HSTORE: true,
TSVECTOR: true,
deferrableConstraints: true,
searchPath: true
});
Expand Down
3 changes: 2 additions & 1 deletion lib/operators.js
Expand Up @@ -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;
12 changes: 12 additions & 0 deletions test/integration/data-types.test.js
Expand Up @@ -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');

Expand Down
25 changes: 25 additions & 0 deletions test/integration/model/create.test.js
Expand Up @@ -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', {
Expand Down
16 changes: 16 additions & 0 deletions test/integration/model/findOne.test.js
Expand Up @@ -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', () => {
Expand Down
14 changes: 14 additions & 0 deletions test/unit/sql/where.test.js
Expand Up @@ -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') }), {
Expand Down

0 comments on commit e45df29

Please sign in to comment.