Skip to content

Commit ed22b23

Browse files
authored
Merge pull request #3250 from SeedCompany/neo4j/full-text
2 parents 27aa955 + 4eb682a commit ed22b23

File tree

11 files changed

+309
-71
lines changed

11 files changed

+309
-71
lines changed

src/components/file/media/media.repository.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,10 @@ export class MediaRepository extends CommonRepository {
114114
// Update the labels if typename is given, and maybe changed.
115115
.apply((q) =>
116116
res
117-
? q.raw(
118-
'CALL apoc.create.setLabels(node, $newLabels) yield node as labelsAdded',
119-
{ newLabels: res.dbLabels },
117+
? q.call(
118+
apoc.create
119+
.setLabels('node', res.dbLabels)
120+
.yield({ node: 'labelsAdded' }),
120121
)
121122
: q,
122123
)

src/components/product/product.repository.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {
2727
deactivateProperty,
2828
escapeLuceneSyntax,
2929
filter,
30-
fullTextQuery,
30+
FullTextIndex,
3131
matchProps,
3232
matchPropsAndProjectSensAndScopedRoles,
3333
merge,
@@ -552,7 +552,9 @@ export class ProductRepository extends CommonRepository {
552552
.query()
553553
.apply((q) =>
554554
query
555-
? q.apply(fullTextQuery('ProductCompletionDescription', query))
555+
? q.call(
556+
ProductCompletionDescriptionIndex.search(query).yield('node'),
557+
)
556558
: q.matchNode('node', 'ProductCompletionDescription'),
557559
)
558560
.apply((q) =>
@@ -569,18 +571,21 @@ export class ProductRepository extends CommonRepository {
569571

570572
@OnIndex('schema')
571573
private async createCompletionDescriptionIndex() {
572-
await this.db.createFullTextIndex(
573-
'ProductCompletionDescription',
574-
['ProductCompletionDescription'],
575-
['value'],
576-
{
577-
analyzer: 'standard-folding',
578-
},
579-
);
574+
await this.db
575+
.query()
576+
.apply(ProductCompletionDescriptionIndex.create())
577+
.run();
580578
}
581579

582580
@OnIndex()
583581
private createResourceIndexes() {
584582
return this.getConstraintsFor(Product);
585583
}
586584
}
585+
586+
const ProductCompletionDescriptionIndex = FullTextIndex({
587+
indexName: 'ProductCompletionDescription',
588+
labels: 'ProductCompletionDescription',
589+
properties: 'value',
590+
analyzer: 'standard-folding',
591+
});

src/components/search/search.repository.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { CommonRepository, OnIndex, OnIndexParams } from '~/core/database';
44
import {
55
ACTIVE,
66
escapeLuceneSyntax,
7-
fullTextQuery,
7+
FullTextIndex,
88
} from '~/core/database/query';
99
import { BaseNode } from '~/core/database/results';
1010
import { SearchInput } from './dto';
@@ -13,9 +13,7 @@ import { SearchInput } from './dto';
1313
export class SearchRepository extends CommonRepository {
1414
@OnIndex('schema')
1515
protected async applyIndexes({ db }: OnIndexParams) {
16-
await db.createFullTextIndex('propValue', ['Property'], ['value'], {
17-
analyzer: 'standard-folding',
18-
});
16+
await db.query().apply(GlobalIndex.create()).run();
1917
}
2018

2119
/**
@@ -36,8 +34,7 @@ export class SearchRepository extends CommonRepository {
3634

3735
.union()
3836

39-
.raw('', { query: lucene })
40-
.apply(fullTextQuery('propValue', '$query', ['node as property']))
37+
.call(GlobalIndex.search(lucene).yield({ node: 'property' }))
4138
.match([node('node'), relation('out', 'r', ACTIVE), node('property')])
4239
.return(['node', 'collect(type(r)) as matchedProps'])
4340
// The input.count is going to be applied once the results are 'filtered'
@@ -62,3 +59,10 @@ export class SearchRepository extends CommonRepository {
6259
return await query.run();
6360
}
6461
}
62+
63+
const GlobalIndex = FullTextIndex({
64+
indexName: 'propValue',
65+
labels: 'Property',
66+
properties: 'value',
67+
analyzer: 'standard-folding',
68+
});

src/core/database/query-augmentation/SubClauseCollection.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,23 @@
1-
import { ClauseCollection, Query } from 'cypher-query-builder';
1+
import { Clause, ClauseCollection, Query } from 'cypher-query-builder';
2+
import type { ParameterBag } from 'cypher-query-builder/dist/typings/parameter-bag';
23

34
export class SubClauseCollection extends ClauseCollection {
5+
useParameterBag(newBag: ParameterBag) {
6+
super.useParameterBag(newBag);
7+
this.assignBagRecursive(this, newBag);
8+
}
9+
10+
private assignBagRecursive(clause: Clause, newBag: ParameterBag) {
11+
// @ts-expect-error protected, but we want it to reference the outer one
12+
// without having to import the parameters.
13+
clause.parameterBag = newBag;
14+
if (clause instanceof ClauseCollection) {
15+
for (const sub of clause.getClauses()) {
16+
this.assignBagRecursive(sub, newBag);
17+
}
18+
}
19+
}
20+
421
build() {
522
return this.clauses
623
.flatMap((clause) => {
Lines changed: 66 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,77 @@
1-
import { Query } from 'cypher-query-builder';
1+
import { entries } from '@seedcompany/common';
2+
import { Clause, Query } from 'cypher-query-builder';
3+
import { Parameter } from 'cypher-query-builder/dist/typings/parameter-bag';
4+
import { isExp, variable } from '../query';
5+
import type { YieldTerms } from './yield';
26

37
declare module 'cypher-query-builder/dist/typings/query' {
48
interface Query {
59
/**
6-
* Apply custom query modifications while maintaining the fluent chain.
10+
* Call a procedure.
711
*
8-
* @deprecated Use {@link apply}() instead.
9-
*
10-
* In the future this could be changed to utilize native neo4j call logic.
12+
* Args can be an array of positional args, or an object of named args.
13+
* Objects are still converted to positional args, so the order matters.
14+
* Objects allow the query parameters to be named for better readability.
1115
*/
12-
call<A extends any[], R extends this | Query<any> | void>(
13-
fn: (query: this, ...args: A) => R,
14-
...args: A
15-
): R extends void ? this : R;
16+
call(procedure: ProcedureCall): this;
17+
call(procedureName: string, args?: ProcedureArgs): this;
1618
}
1719
}
1820

19-
Query.prototype.call = function call<
20-
A extends any[],
21-
R extends Query<any> | void,
22-
>(
21+
Query.prototype.call = function call(
2322
this: Query,
24-
fn: (q: Query, ...args: A) => R,
25-
...args: A
26-
): R extends void ? Query : R {
27-
return (fn(this, ...args) || this) as Exclude<R, void>;
23+
procedure: ProcedureCall | string,
24+
args?: ProcedureArgs,
25+
) {
26+
const call =
27+
typeof procedure === 'string'
28+
? { procedureName: procedure, args: args ?? [] }
29+
: procedure;
30+
const clause = new Procedure(call.procedureName, call.args);
31+
const next = this.continueChainClause(clause);
32+
return call.yieldTerms ? next.yield(call.yieldTerms) : next;
2833
};
34+
35+
interface ProcedureCall<Y extends string = string> {
36+
procedureName: string;
37+
args: ProcedureArgs;
38+
yieldTerms?: YieldTerms<Y>;
39+
}
40+
type ProcedureArgs = Record<string, any> | any[];
41+
42+
class Procedure extends Clause {
43+
private readonly params: Parameter[];
44+
constructor(public name: string, public args: Record<string, any> | any[]) {
45+
super();
46+
this.params = (
47+
Array.isArray(args)
48+
? args.map((value) => [undefined, value] as const)
49+
: entries(this.args as Record<string, any>)
50+
).map(([key, value]) =>
51+
isExp(value) ? variable(value) : this.addParam(value, key),
52+
);
53+
}
54+
build() {
55+
return `CALL ${this.name}(${this.params.join(', ')})`;
56+
}
57+
}
58+
59+
export const procedure =
60+
<const Y extends string>(
61+
procedureName: string,
62+
// eslint-disable-next-line @seedcompany/no-unused-vars
63+
yieldDefs: readonly Y[],
64+
) =>
65+
(args: ProcedureArgs) => ({
66+
procedureName,
67+
args,
68+
yield: (yieldTerms: YieldTerms<Y>) =>
69+
Object.assign(
70+
(query: Query) => query.call(procedureName, args).yield(yieldTerms),
71+
{
72+
procedureName,
73+
args,
74+
yieldTerms,
75+
},
76+
),
77+
});

src/core/database/query-augmentation/condition-variables.ts

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
* This file patches pattern conditions to support referencing existing variables.
33
* This is achieved by wrapping the variable name in a `variable()` call.
44
*/
5-
import { mapValues } from '@seedcompany/common';
65
import { Clause, NodePattern } from 'cypher-query-builder';
76
import type { Pattern as TSPattern } from 'cypher-query-builder/dist/typings/clauses/pattern';
87
import type {
@@ -37,24 +36,15 @@ export class Variable extends Parameter {
3736
*/
3837
export const variable = (expression: string) => new Variable(expression);
3938

39+
const origAddParam = ParameterBag.prototype.addParam;
4040
ParameterBag.prototype.addParam = function addParam(
4141
this: TSParameterBag,
4242
value: any | Variable,
4343
name?: string,
4444
) {
45-
const actualName = this.getName(name);
46-
const param =
47-
value instanceof Variable
48-
? new Variable(value.value, actualName)
49-
: new Parameter(actualName, value);
50-
this.parameterMap[actualName] = param;
51-
return param;
52-
};
53-
54-
ParameterBag.prototype.getParams = function getParams(this: TSParameterBag) {
55-
return mapValues(this.parameterMap, (_, param, { SKIP }) =>
56-
param instanceof Variable ? SKIP : param.value,
57-
).asRecord;
45+
return value instanceof Variable
46+
? value
47+
: origAddParam.call(this, value, name);
5848
};
5949

6050
Pattern.prototype.setExpandedConditions = function (

src/core/database/query-augmentation/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ import './map';
1111
import './pattern-formatting';
1212
import './subquery';
1313
import './summary';
14+
import './yield';
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { isPlainObject } from '@nestjs/common/utils/shared.utils.js';
2+
import { isNotFalsy, many, Many, Nil } from '@seedcompany/common';
3+
import { Clause, Query } from 'cypher-query-builder';
4+
5+
declare module 'cypher-query-builder/dist/typings/query' {
6+
interface Query {
7+
yield(terms: YieldTerms): this;
8+
}
9+
}
10+
11+
export type YieldTerms<T extends string = string> =
12+
| Many<T | Nil>
13+
| Partial<Record<T, string | boolean | Nil>>;
14+
15+
Query.prototype.yield = function (this: Query, terms: YieldTerms) {
16+
const flattened = isPlainObject(terms)
17+
? Object.entries(terms).flatMap(([k, v]) =>
18+
v === false || v == null ? [] : v === true ? k : `${k} as ${v}`,
19+
)
20+
: many(terms).filter(isNotFalsy);
21+
if (flattened.length === 0) return this;
22+
return this.continueChainClause(new Yield(flattened));
23+
};
24+
25+
class Yield extends Clause {
26+
constructor(public terms: readonly string[]) {
27+
super();
28+
}
29+
build() {
30+
return `YIELD ${this.terms.join(', ')}`;
31+
}
32+
}

src/core/database/query/cypher-functions.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import { procedure } from '../query-augmentation/call';
12
import { exp, ExpressionInput } from './cypher-expression';
3+
import { IndexFullTextQueryNodes } from './full-text';
24

35
/** Create a function with a name that takes a variable number of arguments */
46
const fn =
@@ -75,6 +77,10 @@ export const apoc = {
7577
/** Converts Neo4j node to object/map of the node's properties */
7678
toMap: fn1('apoc.convert.toMap'),
7779
},
80+
create: {
81+
setLabels: (node: ExpressionInput, labels: readonly string[]) =>
82+
procedure('apoc.create.setLabels', ['node'])({ node: exp(node), labels }),
83+
},
7884
};
7985

8086
/**
@@ -111,3 +117,11 @@ export const any = (
111117
list: ExpressionInput,
112118
predicate: ExpressionInput,
113119
) => fn('any')(`${variable} IN ${exp(list)} WHERE ${exp(predicate)}`);
120+
121+
export const db = {
122+
index: {
123+
fulltext: {
124+
queryNodes: IndexFullTextQueryNodes,
125+
},
126+
},
127+
};

0 commit comments

Comments
 (0)