Skip to content

Commit 78aaaf9

Browse files
Merge pull request #105 from bcgsc/task/KBDEV-1426-containstext-special-characters-handling-2
Task/kbdev 1426 containstext special characters handling 2
2 parents 88aa278 + 0b4b5e4 commit 78aaaf9

9 files changed

Lines changed: 228 additions & 14 deletions

File tree

.github/workflows/npm-test.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ jobs:
99
build:
1010
runs-on: ubuntu-latest
1111
strategy:
12+
fail-fast: false
1213
matrix:
1314
node: [16, 18.18.2, 20] # KBDEV-1292. Forcing v18.18.2 because having issues with checks using 18.20.7 (default v18 on GH)
1415
orientdb: ['3.0', '3.1', '3.2.13']
@@ -37,6 +38,7 @@ jobs:
3738
GKB_DB_HOST: localhost
3839
GKB_DBS_PASS: root # default password for new db
3940
GKB_LOG_LEVEL: ERROR
41+
ORIENTDB_VERSION: ${{ matrix.orientdb }} # KBDEV-1426. Skipping some tests on OrientDB 3.1+
4042
- uses: EnricoMi/publish-unit-test-result-action@v1.6
4143
with:
4244
github_token: ${{ secrets.GITHUB_TOKEN }}

src/repo/query_builder/constants.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ const SIMILARITY_EDGES = [
6868
'GeneralizationOf',
6969
];
7070

71+
const SEPARATOR_CHARS = ':;,.|+*/\\=!?[]()';
72+
7173
module.exports = {
7274
DEFAULT_NEIGHBORS: 3,
7375
DIRECTIONS,
@@ -77,6 +79,7 @@ module.exports = {
7779
MIN_WORD_SIZE: 3,
7880
OPERATORS,
7981
PARAM_PREFIX,
82+
SEPARATOR_CHARS,
8083
SIMILARITY_EDGES,
8184
TREE_EDGES,
8285
};

src/repo/query_builder/fixed.js

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ const {
2525
SIMILARITY_EDGES,
2626
TREE_EDGES,
2727
} = require('./constants');
28-
const { castRangeInt } = require('./util');
28+
const { castRangeInt, splitIntoKeywords } = require('./util');
2929

3030
const disambiguationClause = (cond, edges = SIMILARITY_EDGES) => `TRAVERSE both(${edges.map((e) => `'${e}'`).join(', ')}) FROM ${cond} MAXDEPTH ${MAX_NEIGHBORS}`;
3131

@@ -456,19 +456,25 @@ const keywordSearch = ({
456456
if (model.isEdge) {
457457
throw new ValidationError(`Cannot keyword search edge classes (${target})`);
458458
}
459-
460459
if (!keyword) {
461460
throw new ValidationError('Missing required keyword parameter');
462461
}
463462

464-
// remove any duplicate words
465-
const wordList = operator === OPERATORS.CONTAINSTEXT
466-
? keyword.split(/\s+/).map((word) => word.trim().toLowerCase())
463+
let wordList = operator === OPERATORS.CONTAINSTEXT
464+
? splitIntoKeywords(keyword)
467465
: [keyword.trim().toLowerCase()];
468466

467+
// words needs to be 3 letters or more
468+
wordList = operator === OPERATORS.CONTAINSTEXT
469+
? wordList.filter((word) => word.length >= 3)
470+
: wordList;
471+
472+
// need at least one word
469473
if (wordList.length < 1) {
470474
throw new ValidationError('missing keywords');
471475
}
476+
477+
// remove any duplicate words
472478
const keywords = Array.from(new Set(wordList)).filter((k) => k).sort();
473479

474480
const params = {};
@@ -569,4 +575,4 @@ class FixedSubquery {
569575
}
570576
}
571577

572-
module.exports = { FixedSubquery };
578+
module.exports = { FixedSubquery, keywordSearch };

src/repo/query_builder/fragment.js

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ const { RecordID: RID } = require('orientjs');
33

44
const { ValidationError, schema, util } = require('@bcgsc-pori/graphkb-schema');
55

6-
const { OPERATORS, PARAM_PREFIX } = require('./constants');
6+
const { OPERATORS, PARAM_PREFIX, SEPARATOR_CHARS } = require('./constants');
77
const { FixedSubquery } = require('./fixed');
8-
const { getQueryableProps } = require('./util');
8+
const { getQueryableProps, hasSeparatorChars } = require('./util');
99

1010
const NUMBER_ONLY_OPERATORS = [OPERATORS.GT, OPERATORS.GTE, OPERATORS.LT, OPERATORS.LTE];
1111

@@ -85,6 +85,26 @@ class Comparison {
8585
);
8686
}
8787

88+
if (
89+
this.operator === OPERATORS.CONTAINSTEXT
90+
&& !['CategoryVariant', 'PositionalVariant', 'Variant'].includes(this.name)
91+
) {
92+
const v = String(this.value);
93+
94+
if (v.includes(' ')) {
95+
throw new ValidationError(
96+
'CONTAINSTEXT should not be used in conjunction with whitespaces',
97+
);
98+
}
99+
if (hasSeparatorChars(v)) {
100+
throw new ValidationError(
101+
`CONTAINSTEXT should not be used in conjunction with index separator characters ( ${
102+
SEPARATOR_CHARS
103+
} )`,
104+
);
105+
}
106+
}
107+
88108
if (this.valueIsIterable) {
89109
if (this.operator === OPERATORS.CONTAINS) {
90110
throw new ValidationError(
@@ -470,4 +490,4 @@ class Subquery {
470490
}
471491
}
472492

473-
module.exports = { Subquery };
493+
module.exports = { Comparison, Subquery };

src/repo/query_builder/util.js

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ const {
44
util,
55
schema,
66
} = require('@bcgsc-pori/graphkb-schema');
7-
const { MAX_LIMIT, MAX_NEIGHBORS } = require('./constants');
7+
const { MAX_LIMIT, MAX_NEIGHBORS, SEPARATOR_CHARS } = require('./constants');
88

99
/**
1010
* Format a value as an Integer. Throw an error if it is not an integer or does not
@@ -133,10 +133,40 @@ const displayQuery = ({ query: statement, params = {} }) => {
133133
return result;
134134
};
135135

136+
/**
137+
* Check if a keyword contains any separatorChars
138+
*
139+
* @param {string} kw a keyword to test for separatorChars
140+
* @param {string} sep a string of all separatorChars to test against
141+
* @returns {boolean}
142+
*/
143+
const hasSeparatorChars = (kw, sep = SEPARATOR_CHARS) => [...kw].some((chr) => sep.includes(chr));
144+
145+
/**
146+
* Split a string into an array of keywords based on separatorChars and whitespaces
147+
* Also convert to lowercase
148+
*
149+
* @param {string} s a string to split into an array of keywords
150+
* @param {string} sep a string of all separatorChars
151+
* @returns {Array.<string>}
152+
*/
153+
const splitIntoKeywords = (s, sep = SEPARATOR_CHARS) => {
154+
const pattern = new RegExp(
155+
// the regex itself needs some escaping. also adding whitespace support
156+
`[\\s${sep.replace(/[-\\\]^[]/g, '\\$&')}]+`,
157+
);
158+
return s
159+
.trim()
160+
.split(pattern)
161+
.map((word) => word.toLowerCase());
162+
};
163+
136164
module.exports = {
137165
castBoolean,
138166
castRangeInt,
139167
checkStandardOptions,
140168
displayQuery,
141169
getQueryableProps,
170+
hasSeparatorChars,
171+
splitIntoKeywords,
142172
};

test/db_integration/queries.test.js

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,11 @@ describeWithAuth('query builder', () => {
112112
});
113113
});
114114

115-
describe('selectByKeyword', () => {
115+
// KBDEV-1426. Skipping keyword queryType tests for OrientDB != 3.0
116+
(process.env.ORIENTDB_VERSION === '3.0' || process.env.ORIENTDB_VERSION === undefined
117+
? describe
118+
: describe.skip
119+
)('selectByKeyword', () => {
116120
test('get from related variant reference', async () => {
117121
const query = parse({ keyword: 'kras', queryType: 'keyword', target: 'Statement' });
118122
const result = await select(session, query);
@@ -133,6 +137,24 @@ describeWithAuth('query builder', () => {
133137
),
134138
).toHaveProperty('length', 1);
135139
});
140+
141+
test('short terms are discarded without errors when long terms are present', async () => {
142+
expect(
143+
await select(
144+
session,
145+
parse({ keyword: 'kras gain of function', queryType: 'keyword', target: 'Statement' }),
146+
),
147+
).toHaveProperty('length', 1);
148+
});
149+
150+
test('short terms throw error when long terms are not present', async () => {
151+
await expect(
152+
select(
153+
session,
154+
parse({ keyword: 'of', queryType: 'keyword', target: 'Statement' }),
155+
),
156+
).rejects.toThrow(ValidationError);
157+
});
136158
});
137159

138160
test('custom projection', async () => {

test/db_integration/query_routes.test.js

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,11 @@ describeWithAuth('api read-only routes', () => {
111111
});
112112
});
113113

114-
describe('/query search statements by keyword', () => {
114+
// KBDEV-1426. Skipping keyword queryType tests for OrientDB != 3.0
115+
(process.env.ORIENTDB_VERSION === '3.0' || process.env.ORIENTDB_VERSION === undefined
116+
? describe
117+
: describe.skip
118+
)('/query search statements by keyword', () => {
115119
test('count ignores limit', async () => {
116120
const response = await request({
117121
body: {
@@ -146,10 +150,10 @@ describeWithAuth('api read-only routes', () => {
146150
expect(response.body.result).toHaveProperty('length', 2);
147151
});
148152

149-
test('multiple keywords are co-required', async () => {
153+
test('multiple keywords are co-required; without match', async () => {
150154
const response = await request({
151155
body: {
152-
keyword: 'kras,resistance',
156+
keyword: 'kras resistance',
153157
queryType: 'keyword',
154158
target: 'Statement',
155159
},
@@ -162,6 +166,22 @@ describeWithAuth('api read-only routes', () => {
162166
expect(response.body.result).toHaveProperty('length', 0);
163167
});
164168

169+
test('multiple keywords are co-required; with match', async () => {
170+
const response = await request({
171+
body: {
172+
keyword: 'kras gain of function',
173+
queryType: 'keyword',
174+
target: 'Statement',
175+
},
176+
headers: { Authorization: mockToken },
177+
method: 'POST',
178+
uri,
179+
});
180+
expect(response.statusCode).toBe(HTTP_STATUS.OK);
181+
expect(response.body).toHaveProperty('result');
182+
expect(response.body.result).toHaveProperty('length', 1);
183+
});
184+
165185
test('error on no body', async () => {
166186
try {
167187
await request({
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
const { ValidationError } = require('@bcgsc-pori/graphkb-schema');
2+
3+
const { Comparison } = require('../../../src/repo/query_builder/fragment');
4+
5+
describe('Comparison', () => {
6+
// Ontology
7+
const OntologyComparison = new Comparison({
8+
name: 'Ontology',
9+
operator: 'CONTAINSTEXT',
10+
prop: 'name',
11+
value: 'abcdef',
12+
});
13+
14+
test('not to throw error on valid CONTAINSTEXT value', () => {
15+
expect(() => OntologyComparison.validate()).not.toThrow(ValidationError);
16+
});
17+
18+
test('throw error on CONTAINSTEXT value containing whitespaces', () => {
19+
OntologyComparison.value = 'abc def';
20+
expect(() => OntologyComparison.validate()).toThrow(ValidationError);
21+
});
22+
23+
test('throw error on CONTAINSTEXT value containing separator chars', () => {
24+
OntologyComparison.value = 'abc:def';
25+
expect(() => OntologyComparison.validate()).toThrow(ValidationError);
26+
});
27+
28+
// Variant
29+
const VariantComparison = new Comparison({
30+
name: 'Variant',
31+
operator: 'CONTAINSTEXT',
32+
prop: 'displayName',
33+
value: 'abc:def',
34+
});
35+
36+
test('Variant special case: not to throw error', () => {
37+
expect(() => VariantComparison.validate()).not.toThrow(ValidationError);
38+
});
39+
40+
test('CategoryVariant special case: not to throw error', () => {
41+
VariantComparison.name = 'CategoryVariant';
42+
VariantComparison.value = 'gene1:gene2 fusion';
43+
expect(() => VariantComparison.validate()).not.toThrow(ValidationError);
44+
});
45+
46+
test('PositionalVariant special case: not to throw error', () => {
47+
VariantComparison.name = 'PositionalVariant';
48+
VariantComparison.value = 'gene:p.123del';
49+
expect(() => VariantComparison.validate()).not.toThrow(ValidationError);
50+
});
51+
});
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
const { hasSeparatorChars, splitIntoKeywords } = require('../../../src/repo/query_builder/util');
2+
3+
describe('hasSeparatorChars', () => {
4+
test.each([
5+
['abc def', false], // control
6+
['abc:def', true],
7+
['abc;def', true],
8+
['abc,def', true],
9+
['abc.def', true],
10+
['abc|def', true],
11+
['abc+def', true],
12+
['abc*def', true],
13+
['abc/def', true],
14+
['abc=def', true],
15+
['abc!def', true],
16+
['abc?def', true],
17+
['abc[def', true],
18+
['abc]def', true],
19+
['abc(def', true],
20+
['abc)def', true],
21+
['abc\u005Cdef', true],
22+
['abc\\def', true],
23+
])('hasSeparatorChars(%s) === %s', (kw, expected) => {
24+
expect(hasSeparatorChars(kw)).toBe(expected);
25+
});
26+
});
27+
28+
describe('splitIntoKeywords', () => {
29+
test.each([
30+
['abcdef', 1], // control
31+
['abc def', 2],
32+
['abc:def', 2],
33+
['abc;def', 2],
34+
['abc,def', 2],
35+
['abc.def', 2],
36+
['abc|def', 2],
37+
['abc+def', 2],
38+
['abc*def', 2],
39+
['abc/def', 2],
40+
['abc=def', 2],
41+
['abc!def', 2],
42+
['abc?def', 2],
43+
['abc[def', 2],
44+
['abc]def', 2],
45+
['abc(def', 2],
46+
['abc)def', 2],
47+
['abc\u005Cdef', 2],
48+
['abc\\def', 2],
49+
])('splitIntoKeywords(%s) === %s', (kw, expected) => {
50+
expect(splitIntoKeywords(kw).length).toBe(expected);
51+
});
52+
53+
test.each([
54+
['Abc', ['abc']], // lowercase
55+
[' abc ', ['abc']], // trimmed
56+
])('splitIntoKeywords(%s) === %s', (kw, expected) => {
57+
expect(splitIntoKeywords(kw).length).toBe(1);
58+
expect(splitIntoKeywords(kw)[0]).toBe(expected[0]);
59+
});
60+
});

0 commit comments

Comments
 (0)