diff --git a/src/components/engagement/engagement.repository.ts b/src/components/engagement/engagement.repository.ts index 9c4bb4cc04..4775da2a70 100644 --- a/src/components/engagement/engagement.repository.ts +++ b/src/components/engagement/engagement.repository.ts @@ -699,41 +699,6 @@ export const engagementFilters = filter.define(() => EngagementFilters, { ), }), status: filter.stringListProp(), - name: filter.fullText({ - index: () => NameIndex, - matchToNode: (q) => - q.match([ - node('node', 'Engagement'), - relation('either', '', undefined, ACTIVE), - node('', 'BaseNode'), - relation('out', '', undefined, ACTIVE), - node('match'), - ]), - // UI joins project & language/intern names with dash - // Remove it from search if users type it - normalizeInput: (v) => v.replaceAll(/ -/g, ''), - // Treat each word as a separate search term - // Each word could point to a different node - // i.e. "project - language" - separateQueryForEachWord: true, - minScore: 0.9, - }), - engagedName: filter.fullText({ - index: () => EngagedNameIndex, - matchToNode: (q) => - q.match([ - node('node', 'Engagement'), - relation('out', '', undefined, ACTIVE), - node('', 'BaseNode'), - relation('out', '', undefined, ACTIVE), - node('match'), - ]), - // Treat each word as a separate search term - // Each word could point to a different node - // i.e. "first - last" - separateQueryForEachWord: true, - minScore: 0.9, - }), projectId: filter.pathExists((id) => [ node('node'), relation('in', '', 'engagement'), @@ -753,36 +718,6 @@ export const engagementFilters = filter.define(() => EngagementFilters, { relation('out', '', 'language'), node('', 'Language', { id }), ]), - project: filter.sub( - () => projectFilters, - 'requestingUser', - )((sub) => - sub - .with('node as eng, requestingUser') - .match([ - node('eng'), - relation('in', '', 'engagement'), - node('node', 'Project'), - ]), - ), - language: filter.sub(() => languageFilters)((sub) => - sub - .with('node as eng') - .match([ - node('eng'), - relation('out', '', 'language'), - node('node', 'Language'), - ]), - ), - intern: filter.sub(() => userFilters)((sub) => - sub - .with('node as eng') - .match([ - node('eng'), - relation('out', '', 'intern'), - node('node', 'User'), - ]), - ), startDate: filter.dateTime(({ query }) => { query.optionalMatch([ [ @@ -817,6 +752,65 @@ export const engagementFilters = filter.define(() => EngagementFilters, { ]); return coalesce('endDateOverride.value', 'mouEnd.value'); }), + name: filter.fullText({ + index: () => NameIndex, + matchToNode: (q) => + q.match([ + node('node', 'Engagement'), + relation('either', '', undefined, ACTIVE), + node('', 'BaseNode'), + relation('out', '', undefined, ACTIVE), + node('match'), + ]), + // UI joins project & language/intern names with dash + // Remove it from search if users type it + normalizeInput: (v) => v.replaceAll(/ -/g, ''), + // Treat each word as a separate search term + // Each word could point to a different node + // i.e. "project - language" + separateQueryForEachWord: true, + minScore: 0.9, + }), + engagedName: filter.fullText({ + index: () => EngagedNameIndex, + matchToNode: (q) => + q.match([ + node('node', 'Engagement'), + relation('out', '', undefined, ACTIVE), + node('', 'BaseNode'), + relation('out', '', undefined, ACTIVE), + node('match'), + ]), + // Treat each word as a separate search term + // Each word could point to a different node + // i.e. "first - last" + separateQueryForEachWord: true, + minScore: 0.9, + }), + project: filter.sub( + () => projectFilters, + 'requestingUser', + )((sub) => + sub.match([ + node('outer'), + relation('in', '', 'engagement'), + node('node', 'Project'), + ]), + ), + language: filter.sub(() => languageFilters)((sub) => + sub.match([ + node('outer'), + relation('out', '', 'language'), + node('node', 'Language'), + ]), + ), + intern: filter.sub(() => userFilters)((sub) => + sub.match([ + node('outer'), + relation('out', '', 'intern'), + node('node', 'User'), + ]), + ), }); export const engagementSorters = defineSorters(IEngagement, { diff --git a/src/components/language/language.repository.ts b/src/components/language/language.repository.ts index 99f9796954..f1fdc83a42 100644 --- a/src/components/language/language.repository.ts +++ b/src/components/language/language.repository.ts @@ -280,15 +280,7 @@ export class LanguageRepository extends DtoRepository< } export const languageFilters = filter.define(() => LanguageFilters, { - name: filter.fullText({ - index: () => NameIndex, - matchToNode: (q) => - q.match([ - node('node', 'Language'), - relation('out', '', undefined, ACTIVE), - node('match'), - ]), - }), + pinned: filter.isPinned, sensitivity: filter.stringListProp(), leastOfThese: filter.propVal(), isSignLanguage: filter.propVal(), @@ -308,21 +300,27 @@ export const languageFilters = filter.define(() => LanguageFilters, { relation('out', '', 'partner', ACTIVE), node('', 'Partner', { id }), ]), + name: filter.fullText({ + index: () => NameIndex, + matchToNode: (q) => + q.match([ + node('node', 'Language'), + relation('out', '', undefined, ACTIVE), + node('match'), + ]), + }), + ethnologue: filter.sub(() => ethnologueFilters)((sub) => + sub.match([ + node('outer'), + relation('out', '', 'ethnologue'), + node('node', 'EthnologueLanguage'), + ]), + ), presetInventory: ({ value, query }) => { query.apply(isPresetInventory).with('*'); const condition = equals('true', true); return { presetInventory: value ? condition : not(condition) }; }, - pinned: filter.isPinned, - ethnologue: filter.sub(() => ethnologueFilters)((sub) => - sub - .with('node as lang') - .match([ - node('lang'), - relation('out', '', 'ethnologue'), - node('node', 'EthnologueLanguage'), - ]), - ), }); const ethnologueFilters = filter.define(() => EthnologueLanguageFilters, { diff --git a/src/components/location/location.repository.ts b/src/components/location/location.repository.ts index 69818a38a7..bb91303277 100644 --- a/src/components/location/location.repository.ts +++ b/src/components/location/location.repository.ts @@ -245,6 +245,11 @@ export class LocationRepository extends DtoRepository(Location) { export const locationSorters = defineSorters(Location, {}); export const locationFilters = filter.define(() => LocationFilters, { + fundingAccountId: filter.pathExists((id) => [ + node('node'), + relation('out', '', 'fundingAccount', ACTIVE), + node('', 'FundingAccount', { id }), + ]), name: filter.fullText({ index: () => NameIndex, matchToNode: (q) => @@ -254,11 +259,6 @@ export const locationFilters = filter.define(() => LocationFilters, { node('match'), ]), }), - fundingAccountId: filter.pathExists((id) => [ - node('node'), - relation('out', '', 'fundingAccount', ACTIVE), - node('', 'FundingAccount', { id }), - ]), }); const NameIndex = FullTextIndex({ diff --git a/src/components/partner/partner.repository.ts b/src/components/partner/partner.repository.ts index 5c5ba886a1..8c341f1d6b 100644 --- a/src/components/partner/partner.repository.ts +++ b/src/components/partner/partner.repository.ts @@ -312,13 +312,11 @@ export const partnerFilters = filters.define(() => PartnerFilters, { node('', 'User', { id }), ]), organization: filter.sub(() => organizationFilters)((sub) => - sub - .with('node as partner') - .match([ - node('partner'), - relation('out', '', 'organization'), - node('node', 'Organization'), - ]), + sub.match([ + node('outer'), + relation('out', '', 'organization'), + node('node', 'Organization'), + ]), ), }); diff --git a/src/components/partnership/partnership.repository.ts b/src/components/partnership/partnership.repository.ts index cd07e5b255..16ec395e1e 100644 --- a/src/components/partnership/partnership.repository.ts +++ b/src/components/partnership/partnership.repository.ts @@ -429,13 +429,11 @@ export const partnershipFilters = filter.define(() => PartnershipFilters, { projectId: filter.skip, types: filter.intersectsProp(), partner: filter.sub(() => partnerFilters)((sub) => - sub - .with('node as partnership') - .match([ - node('partnership'), - relation('out', '', 'partner'), - node('node', 'Partner'), - ]), + sub.match([ + node('outer'), + relation('out', '', 'partner'), + node('node', 'Partner'), + ]), ), }); diff --git a/src/components/periodic-report/periodic-report.repository.ts b/src/components/periodic-report/periodic-report.repository.ts index 567f2f63ac..f46ab60986 100644 --- a/src/components/periodic-report/periodic-report.repository.ts +++ b/src/components/periodic-report/periodic-report.repository.ts @@ -190,18 +190,7 @@ export class PeriodicReportRepository extends DtoRepository< const result = await this.db .query() .matchNode('node', 'PeriodicReport') - .apply( - filter.builder(filters, { - parent: filter.pathExists((id) => [ - node('', 'BaseNode', { id }), - relation('out', '', 'report', ACTIVE), - node('node'), - ]), - start: filter.dateTimeProp(), - end: filter.dateTimeProp(), - type: ({ value }) => ({ node: hasLabel(`${value}Report`) }), - }), - ) + .apply(periodicReportFilters(filters)) .apply(sorting(resource, input)) .apply(paginate(input, this.hydrate(session))) .first(); @@ -460,6 +449,19 @@ export const matchCurrentDue = ]) .limit(1); +export const periodicReportFilters = filter.define< + Pick +>(() => undefined as any, { + type: ({ value }) => ({ node: hasLabel(`${value}Report`) }), + parent: filter.pathExists((id) => [ + node('', 'BaseNode', { id }), + relation('out', '', 'report', ACTIVE), + node('node'), + ]), + start: filter.dateTimeProp(), + end: filter.dateTimeProp(), +}); + export const periodicReportSorters = defineSorters(IPeriodicReport, {}); export const progressReportSorters = defineSorters(ProgressReport, { diff --git a/src/components/product/product.repository.ts b/src/components/product/product.repository.ts index 3672242a19..27aa837c91 100644 --- a/src/components/product/product.repository.ts +++ b/src/components/product/product.repository.ts @@ -54,6 +54,7 @@ import { ProducibleType, Product, ProductCompletionDescriptionSuggestionsInput, + ProductFilters, ProductListInput, ProgressMeasurement, UpdateDirectScriptureProduct, @@ -505,14 +506,10 @@ export class ProductRepository extends CommonRepository { ...(approach ? ApproachToMethodologies[approach] : []), ...(methodology ? [methodology] : []), ]; - filter.builder( - { ...rest, ...(merged.length ? { methodology: merged } : {}) }, - { - engagementId: filter.skip, - placeholder: filter.isPropNotNull('placeholderDescription'), - methodology: filter.propVal(), - }, - )(q); + productFilters({ + ...rest, + ...(merged.length ? { methodology: merged } : {}), + })(q); }) .apply(sorting(Product, input)) .apply(paginate(input, this.hydrate(session))) @@ -583,6 +580,16 @@ export class ProductRepository extends CommonRepository { } } +export const productFilters = filter.define< + Omit & { + methodology?: Methodology[]; + } +>(() => undefined as any, { + engagementId: filter.skip, + placeholder: filter.isPropNotNull('placeholderDescription'), + methodology: filter.stringListProp(), +}); + const ProductCompletionDescriptionIndex = FullTextIndex({ indexName: 'ProductCompletionDescription', labels: 'ProductCompletionDescription', diff --git a/src/components/progress-report/media/progress-report-media.repository.ts b/src/components/progress-report/media/progress-report-media.repository.ts index e4cb47dafd..815ea65b26 100644 --- a/src/components/progress-report/media/progress-report-media.repository.ts +++ b/src/components/progress-report/media/progress-report-media.repository.ts @@ -48,16 +48,7 @@ export class ProgressReportMediaRepository extends DtoRepository< relation('out', '', 'child', ACTIVE), node('node', this.resource.dbLabel), ]) - .apply( - filter.builder( - { variants: args.variants }, - { - variants: ({ value }) => ({ - 'node.variant': inArray(value.map((v) => v.key)), - }), - }, - ), - ) + .apply(progressReportMediaFilters({ variants: args.variants })) .match(requestingUser(session)) .apply(projectFromProgressReportChild) .apply( @@ -298,3 +289,11 @@ export class ProgressReportMediaRepository extends DtoRepository< ); } } + +export const progressReportMediaFilters = filter.define< + Pick +>(() => ListArgs, { + variants: ({ value }) => ({ + 'node.variant': inArray(value.map((v) => v.key)), + }), +}); diff --git a/src/components/progress-report/progress-report.repository.ts b/src/components/progress-report/progress-report.repository.ts index 218b2265c5..f63a92ec7a 100644 --- a/src/components/progress-report/progress-report.repository.ts +++ b/src/components/progress-report/progress-report.repository.ts @@ -42,6 +42,7 @@ export class ProgressReportRepository extends DtoRepository< relation('in', '', 'engagement'), node('project', 'Project'), ]) + .logIt() .match(requestingUser(session)) .apply(progressReportFilters(input.filter)) .apply( @@ -88,13 +89,11 @@ export const progressReportFilters = filter.define( () => engagementFilters, 'requestingUser', )((sub) => - sub - .with('node as report, requestingUser') - .match([ - node('report'), - relation('in', '', 'report'), - node('node', 'Engagement'), - ]), + sub.match([ + node('outer'), + relation('in', '', 'report'), + node('node', 'Engagement'), + ]), ), }, ); diff --git a/src/components/project/project-filters.query.ts b/src/components/project/project-filters.query.ts index c0fe93fa54..cd258236c3 100644 --- a/src/components/project/project-filters.query.ts +++ b/src/components/project/project-filters.query.ts @@ -1,4 +1,10 @@ -import { greaterThan, inArray, node, relation } from 'cypher-query-builder'; +import { + equals, + greaterThan, + inArray, + node, + relation, +} from 'cypher-query-builder'; import { ACTIVE, filter, @@ -12,32 +18,11 @@ import { ProjectFilters } from './dto'; import { ProjectNameIndex } from './project.repository'; export const projectFilters = filter.define(() => ProjectFilters, { - name: filter.fullText({ - index: () => ProjectNameIndex, - matchToNode: (q) => - q.match([ - node('node', 'Project'), - relation('out', '', 'name', ACTIVE), - node('match'), - ]), - minScore: 0.8, - }), type: filter.stringListBaseNodeProp(), + pinned: filter.isPinned, status: filter.stringListProp(), - onlyMultipleEngagements: - ({ value, query }) => - () => - value - ? query - .match([ - node('node'), - relation('out', '', 'engagement', ACTIVE), - node('engagement', 'Engagement'), - ]) - .with('node, count(engagement) as engagementCount') - .where({ engagementCount: greaterThan(1) }) - : null, step: filter.stringListProp(), + presetInventory: filter.propVal(), createdAt: filter.dateTimeBaseNodeProp(), modifiedAt: filter.dateTimeProp(), mouStart: filter.dateTimeProp(), @@ -49,15 +34,13 @@ export const projectFilters = filter.define(() => ProjectFilters, { relation('in', '', 'member'), node('node'), ]), - isMember: filter.pathExistsWhenTrue([ - node('requestingUser'), - relation('in', '', 'user'), - node('', 'ProjectMember'), - relation('in', '', 'member'), + languageId: filter.pathExists((id) => [ node('node'), + relation('out', '', 'engagement', ACTIVE), + node('', 'LanguageEngagement'), + relation('out', '', 'language', ACTIVE), + node('', 'Language', { id }), ]), - pinned: filter.isPinned, - presetInventory: filter.propVal(), partnerId: filter.pathExists((id) => [ node('node'), relation('out', '', 'partnership', ACTIVE), @@ -65,6 +48,13 @@ export const projectFilters = filter.define(() => ProjectFilters, { relation('out', '', 'partner', ACTIVE), node('', 'Partner', { id }), ]), + isMember: filter.pathExistsWhenTrue([ + node('requestingUser'), + relation('in', '', 'user'), + node('', 'ProjectMember'), + relation('in', '', 'member'), + node('node'), + ]), userId: ({ value }) => ({ userId: [ // TODO We can leak if the project includes this person, if the @@ -86,49 +76,51 @@ export const projectFilters = filter.define(() => ProjectFilters, { ]), ], }), - languageId: filter.pathExists((id) => [ - node('node'), - relation('out', '', 'engagement', ACTIVE), - node('', 'LanguageEngagement'), - relation('out', '', 'language', ACTIVE), - node('', 'Language', { id }), - ]), - sensitivity: - ({ value, query }) => - () => - value - ? query - .apply(matchProjectSens('node')) - .with('*') - .where({ sensitivity: inArray(value) }) - : query, - partnerships: filter.sub(() => partnershipFilters)((sub) => - sub - .with('node as project') + onlyMultipleEngagements: ({ value, query }) => + query .match([ - node('project'), - relation('out', '', 'partnership', ACTIVE), - node('node', 'Partnership'), + node('node'), + relation('out', '', 'engagement', ACTIVE), + node('engagement', 'Engagement'), + ]) + .with('node, count(engagement) as engagementCount') + .where({ engagementCount: value ? greaterThan(1) : equals(1) }), + name: filter.fullText({ + index: () => ProjectNameIndex, + matchToNode: (q) => + q.match([ + node('node', 'Project'), + relation('out', '', 'name', ACTIVE), + node('match'), ]), + minScore: 0.8, + }), + primaryLocation: filter.sub(() => locationFilters)((sub) => + sub.match([ + node('outer'), + relation('out', '', 'primaryLocation', ACTIVE), + node('node', 'Location'), + ]), ), + sensitivity: ({ value, query }) => + query + .apply(matchProjectSens('node')) + .with('*') + .where({ sensitivity: inArray(value) }), primaryPartnership: filter.sub(() => partnershipFilters)((sub) => - sub - .with('node as project') - .match([ - node('project'), - relation('out', '', 'partnership', ACTIVE), - node('node', 'Partnership'), - relation('out', '', 'primary', ACTIVE), - node('', 'Property', { value: variable('true') }), - ]), + sub.match([ + node('outer'), + relation('out', '', 'partnership', ACTIVE), + node('node', 'Partnership'), + relation('out', '', 'primary', ACTIVE), + node('', 'Property', { value: variable('true') }), + ]), ), - primaryLocation: filter.sub(() => locationFilters)((sub) => - sub - .with('node as project') - .match([ - node('project'), - relation('out', '', 'primaryLocation', ACTIVE), - node('node', 'Location'), - ]), + partnerships: filter.sub(() => partnershipFilters)((sub) => + sub.match([ + node('outer'), + relation('out', '', 'partnership', ACTIVE), + node('node', 'Partnership'), + ]), ), }); diff --git a/src/core/database/query-augmentation/pattern-formatting.ts b/src/core/database/query-augmentation/pattern-formatting.ts index 8e77d9553a..c99ed4fff8 100644 --- a/src/core/database/query-augmentation/pattern-formatting.ts +++ b/src/core/database/query-augmentation/pattern-formatting.ts @@ -11,12 +11,14 @@ import { Raw, Return, TermListClause, + Where, With, } from 'cypher-query-builder'; import type { Term } from 'cypher-query-builder/dist/typings/clauses/term-list-clause'; import type { Parameter } from 'cypher-query-builder/dist/typings/parameter-bag'; import { camelCase, isPlainObject } from 'lodash'; import { isExp } from '../query'; +import { WhereAndList } from '../query/where-and-list'; // Add line breaks for each pattern when there's multiple per statement // And ignore empty patterns @@ -93,6 +95,21 @@ for (const Cls of [Match, Create, Merge]) { }; } +ClauseCollection.prototype.addClause = function addClause( + this: ClauseCollection, + clause: Clause, +) { + // Merge sibling where clauses into a single where clause + if (clause instanceof Where && this.clauses.at(-1) instanceof Where) { + const prev = this.clauses.at(-1) as Where; + prev.conditions = new WhereAndList([prev.conditions, clause.conditions]); + return; + } + + clause.useParameterBag(this.parameterBag); + this.clauses.push(clause); +}; + // Remove extra line breaks from empty clauses ClauseCollection.prototype.build = function build(this: ClauseCollection) { const clauses = this.clauses.map((c) => c.build()).filter(isNotFalsy); diff --git a/src/core/database/query/filters.ts b/src/core/database/query/filters.ts index 024f364bde..936f3837b9 100644 --- a/src/core/database/query/filters.ts +++ b/src/core/database/query/filters.ts @@ -20,12 +20,11 @@ import { intersects } from './comparators'; import { collect } from './cypher-functions'; import { escapeLuceneSyntax, FullTextIndex } from './full-text'; import { ACTIVE } from './matching'; -import { WhereAndList } from './where-and-list'; import { path as pathPattern } from './where-path'; export type Builder = ( args: BuilderArgs, -) => Query | null | Record | void | ((query: Query) => Query); +) => Record | Query | Nil; export interface BuilderArgs { key: K & string; value: NonNullable; @@ -54,14 +53,12 @@ export const define = * Functions can do nothing, adjust query, return an object to add conditions to * the where clause, or return a function which will be called after the where clause. */ -export const builder = +const builder = >(filters: T, builders: Builders) => (query: Query) => { const type = filters.constructor === Object ? null : filters.constructor; query.comment(type?.name ?? 'Filters'); - const conditions = []; - const afters: Array<(query: Query) => Query> = []; for (const key of Object.keys(builders)) { const value = filters[key]; if (value == null) { @@ -71,18 +68,7 @@ export const builder = if (!res || res instanceof Query) { continue; } - if (isFunction(res)) { - afters.push(res); - continue; - } - conditions.push(res); - } - - if (conditions.length > 0) { - query.where(new WhereAndList(conditions)); - } - for (const after of afters) { - after(query); + query.where(res); } }; @@ -244,6 +230,7 @@ export const sub = >( subBuilder: () => (input: Partial) => (q: Query) => void, extraInput?: Many, + outerVar = 'outer', ) => < // TODO this doesn't enforce Input type on Outer property @@ -252,10 +239,13 @@ export const sub = >( matchSubNode: (sub: Query) => Query, ): Builder => - ({ key, value, query }) => - query - .subQuery(['node', ...many(extraInput ?? [])], (sub) => + ({ key, value, query }) => { + const input = [...many(extraInput ?? [])]; + return query + .subQuery((sub) => sub + .with(['node', ...input]) + .with([`node as ${outerVar}`, ...input]) .apply(matchSubNode) .apply(subBuilder()(value)) .return(`true as ${key}FiltersApplied`) @@ -265,6 +255,7 @@ export const sub = .raw('limit 1'), ) .with('*'); + }; export const fullText = ({