Skip to content

Commit 68c34a4

Browse files
authored
Merge pull request #454 from akshatdubeysf/GH-451
adds full text search using match and its supported search modifiers
2 parents 76e09ea + bb6010a commit 68c34a4

File tree

3 files changed

+216
-22
lines changed

3 files changed

+216
-22
lines changed

README.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,14 @@ that you require.
150150
<td>String</td>
151151
<td>Username to connect to database</td>
152152
</tr>
153+
<tr>
154+
<td>allowExtendedOperators</td>
155+
<td>Boolean</td>
156+
<td>Set to <code>true</code> to enable MySQL-specific operators
157+
such as <code>match</code>. Learn more in
158+
<a href="#extended-operators">Extended operators</a> below.
159+
</td>
160+
</tr>
153161
</tbody>
154162
</table>
155163

@@ -498,6 +506,55 @@ last_modified: Date;
498506
- GEOMETRY
499507
- JSON
500508
509+
## Extended operators
510+
MySQL connector supports the following MySQL-specific operators:
511+
- [`match`](#operator-match)
512+
Please note extended operators are disabled by default, you must enable
513+
them at datasource level or model level by setting `allowExtendedOperators` to
514+
`true`.
515+
### Operator `match`
516+
The `match` operator allows you to perform a full text search using the [MATCH() .. AGAINST()](https://dev.mysql.com/doc/refman/8.0/en/fulltext-search.html) operator in MySQL.
517+
518+
Three different modes of the `MATCH` clause are also available in the form of operators -
519+
520+
- `matchbool` for [Boolean Full Text Search](https://dev.mysql.com/doc/refman/8.0/en/fulltext-boolean.html)
521+
- `matchnl` for [Natural Language Full Text Search](https://dev.mysql.com/doc/refman/8.0/en/fulltext-natural-language.html)
522+
- `matchqe` for [Full-Text Searches with Query Expansion](https://dev.mysql.com/doc/refman/8.0/en/fulltext-query-expansion.html)
523+
- `matchnlqe` for [Full-Text Searches with Query Expansion](https://dev.mysql.com/doc/refman/8.0/en/fulltext-query-expansion.html) with the `IN NATURAL LANGUAGE MODE WITH QUERY EXPANSION` modifier.
524+
525+
By default, the `match` operator works in Natural Language mode.
526+
527+
**Note** The fields you are querying must be setup with a `FULLTEXT` index to perform full text search on them.
528+
Assuming a model such as this:
529+
```ts
530+
@model({
531+
settings: {
532+
allowExtendedOperators: true,
533+
}
534+
})
535+
class Post {
536+
@property({
537+
type: 'string',
538+
mysql: {
539+
index: {
540+
kind: 'FULLTEXT'
541+
}
542+
},
543+
})
544+
content: string;
545+
}
546+
```
547+
You can query the content field as follows:
548+
```ts
549+
const posts = await postRepository.find({
550+
where: {
551+
{
552+
content: {match: 'someString'},
553+
}
554+
}
555+
});
556+
```
557+
501558
## Discovery and auto-migration
502559
503560
### Model discovery

lib/mysql.js

Lines changed: 49 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ MySQL.prototype._modifyOrCreate = function(model, data, options, fields, cb) {
269269
for (let i = 0, n = fields.names.length; i < n; i++) {
270270
if (!fields.properties[i].id) {
271271
setValues.push(new ParameterizedSQL(fields.names[i] + '=' +
272-
columnValues[i].sql, columnValues[i].params));
272+
columnValues[i].sql, columnValues[i].params));
273273
}
274274
}
275275

@@ -313,10 +313,10 @@ MySQL.prototype.replaceOrCreate = function(model, data, options, cb) {
313313
* @param {Function} [cb] The callback function
314314
*/
315315
MySQL.prototype.save =
316-
MySQL.prototype.updateOrCreate = function(model, data, options, cb) {
317-
const fields = this.buildFields(model, data);
318-
this._modifyOrCreate(model, data, options, fields, cb);
319-
};
316+
MySQL.prototype.updateOrCreate = function(model, data, options, cb) {
317+
const fields = this.buildFields(model, data);
318+
this._modifyOrCreate(model, data, options, fields, cb);
319+
};
320320

321321
MySQL.prototype.getInsertedId = function(model, info) {
322322
const insertedId = info && typeof info.insertId === 'number' ?
@@ -550,25 +550,52 @@ MySQL.prototype.ping = function(cb) {
550550

551551
MySQL.prototype.buildExpression = function(columnName, operator, operatorValue,
552552
propertyDefinition) {
553-
if (operator === 'regexp') {
554-
let clause = columnName + ' REGEXP ?';
555-
// By default, MySQL regexp is not case sensitive. (https://dev.mysql.com/doc/refman/5.7/en/regexp.html)
556-
// To allow case sensitive regexp query, it has to be binded to a `BINARY` type.
557-
// If ignore case is not specified, search it as case sensitive.
558-
if (!operatorValue.ignoreCase) {
559-
clause = columnName + ' REGEXP BINARY ?';
560-
}
561-
562-
if (operatorValue.ignoreCase)
563-
g.warn('{{MySQL}} {{regex}} syntax does not respect the {{`i`}} flag');
564-
if (operatorValue.global)
565-
g.warn('{{MySQL}} {{regex}} syntax does not respect the {{`g`}} flag');
553+
let clause;
554+
switch (operator) {
555+
case 'regexp':
556+
clause = columnName + ' REGEXP ?';
557+
// By default, MySQL regexp is not case sensitive. (https://dev.mysql.com/doc/refman/5.7/en/regexp.html)
558+
// To allow case sensitive regexp query, it has to be binded to a `BINARY` type.
559+
// If ignore case is not specified, search it as case sensitive.
560+
if (!operatorValue.ignoreCase) {
561+
clause = columnName + ' REGEXP BINARY ?';
562+
}
566563

567-
if (operatorValue.multiline)
568-
g.warn('{{MySQL}} {{regex}} syntax does not respect the {{`m`}} flag');
564+
if (operatorValue.ignoreCase)
565+
g.warn('{{MySQL}} {{regex}} syntax does not respect the {{`i`}} flag');
566+
if (operatorValue.global)
567+
g.warn('{{MySQL}} {{regex}} syntax does not respect the {{`g`}} flag');
568+
569+
if (operatorValue.multiline)
570+
g.warn('{{MySQL}} {{regex}} syntax does not respect the {{`m`}} flag');
571+
572+
return new ParameterizedSQL(clause,
573+
[operatorValue.source]);
574+
case 'matchnl':
575+
case 'matchqe':
576+
case 'matchnlqe':
577+
case 'matchbool':
578+
case 'match':
579+
let mode;
580+
switch (operator) {
581+
case 'matchbool':
582+
mode = ' IN BOOLEAN MODE';
583+
break;
584+
case 'matchnl':
585+
mode = ' IN NATURAL LANGUAGE MODE';
586+
break;
587+
case 'matchqe':
588+
mode = ' WITH QUERY EXPANSION';
589+
break;
590+
case 'matchnlqe':
591+
mode = ' IN NATURAL LANGUAGE MODE WITH QUERY EXPANSION';
592+
break;
593+
default:
594+
mode = '';
595+
}
596+
clause = ` MATCH (${columnName}) AGAINST (?${mode})`;
569597

570-
return new ParameterizedSQL(clause,
571-
[operatorValue.source]);
598+
return new ParameterizedSQL(clause, [operatorValue]);
572599
}
573600

574601
// invoke the base implementation of `buildExpression`

test/mysql.test.js

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,17 @@ describe('mysql', function() {
3737
userId: ObjectID,
3838
}, {
3939
forceId: false,
40+
indexes: {
41+
content_fts_index: {
42+
kind: 'FULLTEXT',
43+
columns: 'content',
44+
},
45+
title_fts_index: {
46+
kind: 'FULLTEXT',
47+
columns: 'title',
48+
},
49+
},
50+
allowExtendedOperators: true,
4051
});
4152

4253
PostWithStringId = db.define('PostWithStringId', {
@@ -912,6 +923,105 @@ describe('mysql', function() {
912923
});
913924
});
914925

926+
context('match operator', function() {
927+
beforeEach(function deleteExistingTestFixtures(done) {
928+
Post.destroyAll(done);
929+
});
930+
beforeEach(function createTestFixtures(done) {
931+
Post.create([
932+
{title: 'About Redis', content: 'Redis is a Database'},
933+
{title: 'Usage', content: 'How To Use MySQL database Well'},
934+
{title: 'About Mysql', content: 'Mysql is a database'},
935+
], done);
936+
});
937+
after(function deleteTestFixtures(done) {
938+
Post.destroyAll(done);
939+
});
940+
941+
context('with one column and string', () => {
942+
it('should work', function(done) {
943+
Post.find({where: {content: {match: '+using MYSQL'}}}, (err, posts) => {
944+
should.not.exist(err);
945+
should.exist(posts);
946+
posts.length.should.equal(2);
947+
done();
948+
});
949+
});
950+
it('should work in boolean mode with empty result expected', function(done) {
951+
Post.find({where: {content: {matchbool: '+using MYSQL'}}}, (err, posts) => {
952+
should.not.exist(err);
953+
should.exist(posts);
954+
posts.length.should.equal(0);
955+
done();
956+
});
957+
});
958+
it('should work in boolean mode with one result expected', function(done) {
959+
Post.find({where: {content: {matchbool: '+use MYSQL'}}}, (err, posts) => {
960+
should.not.exist(err);
961+
should.exist(posts);
962+
posts.length.should.equal(1);
963+
done();
964+
});
965+
});
966+
it('should work with matchqe operator with expected result in first and second pass', function(done) {
967+
Post.find({where: {content: {match: 'redis'}}}, (err, posts) => {
968+
should.not.exist(err);
969+
should.exist(posts);
970+
posts.length.should.equal(1);
971+
Post.find({where: {content: {matchqe: 'redis'}}}, (err, expandedPosts) => {
972+
should.not.exist(err);
973+
should.exist(expandedPosts);
974+
expandedPosts.length.should.equal(3);
975+
done();
976+
});
977+
});
978+
});
979+
it('should work with matchnlqe operator with expected result in first and second pass', function(done) {
980+
Post.find({where: {content: {match: 'redis'}}}, (err, posts) => {
981+
should.not.exist(err);
982+
should.exist(posts);
983+
posts.length.should.equal(1);
984+
Post.find({where: {content: {matchnlqe: 'redis'}}}, (err, expandedPosts) => {
985+
should.not.exist(err);
986+
should.exist(expandedPosts);
987+
expandedPosts.length.should.equal(3);
988+
done();
989+
});
990+
});
991+
});
992+
});
993+
994+
context('with multiple column and one string', () => {
995+
it('should work', function(done) {
996+
const against = 'using MYSQL';
997+
Post.find(
998+
{
999+
where: {
1000+
or: [
1001+
{
1002+
content: {
1003+
match: against,
1004+
},
1005+
},
1006+
{
1007+
title: {
1008+
match: against,
1009+
},
1010+
},
1011+
],
1012+
},
1013+
},
1014+
(err, posts) => {
1015+
should.not.exist(err);
1016+
should.exist(posts);
1017+
posts.length.should.equal(2);
1018+
done();
1019+
},
1020+
);
1021+
});
1022+
});
1023+
});
1024+
9151025
function deleteAllModelInstances() {
9161026
const models = [
9171027
Post, PostWithStringId, PostWithUniqueTitle, PostWithNumId, Student,

0 commit comments

Comments
 (0)