From c2c49a47575d629aa20cf748e35ea8ad36ed9f8e Mon Sep 17 00:00:00 2001 From: Carson Full Date: Mon, 7 Apr 2025 07:47:14 -0500 Subject: [PATCH 1/4] [Neo4j] Switch to the new subquery import syntax --- .../budget/budget-record.repository.ts | 6 +- src/components/budget/budget.repository.ts | 3 +- .../engagement/engagement.repository.ts | 2 - ...nalized-changeset-to-engagement.handler.ts | 9 +- src/components/file/file.repository.ts | 5 +- .../language/language.repository.ts | 4 +- .../notifications/notification.repository.ts | 3 +- .../organization/organization.repository.ts | 4 +- src/components/partner/partner.repository.ts | 4 +- ...alized-changeset-to-partnership.handler.ts | 6 +- .../partnership/partnership.repository.ts | 4 +- .../periodic-report.repository.ts | 5 +- ...extra-for-periodic-interface.repository.ts | 1 - .../variance-explanation.repository.ts | 4 +- ...-finalized-changeset-to-project.handler.ts | 3 +- src/components/project/project.repository.ts | 2 - src/components/user/user.repository.ts | 6 +- .../database/query-augmentation/subquery.ts | 41 +++++---- src/core/database/query/filters.ts | 3 +- .../query/match-project-based-props.ts | 92 +++++++++---------- .../query/properties/update-property.ts | 4 +- 21 files changed, 90 insertions(+), 121 deletions(-) diff --git a/src/components/budget/budget-record.repository.ts b/src/components/budget/budget-record.repository.ts index 3d1d334e2c..c2c18faad9 100644 --- a/src/components/budget/budget-record.repository.ts +++ b/src/components/budget/budget-record.repository.ts @@ -155,9 +155,8 @@ export class BudgetRecordRepository extends DtoRepository< view, }: BudgetRecordHydrateArgs) { return (query: Query) => - query.subQuery((sub) => + query.subQuery([recordVar, projectVar], (sub) => sub - .with([recordVar, projectVar]) // import // rename to constant, only apply if making a change otherwise cypher breaks .apply((q) => recordVar !== 'node' || projectVar !== 'project' @@ -199,9 +198,8 @@ export class BudgetRecordRepository extends DtoRepository< outputVar?: string; }) { return (query: Query) => - query.subQuery((sub) => + query.subQuery(budgetVar, (sub) => sub - .with(budgetVar) .match([ node(budgetVar), relation('out', '', 'record', ACTIVE), diff --git a/src/components/budget/budget.repository.ts b/src/components/budget/budget.repository.ts index ebe11e1307..17a4c3fd82 100644 --- a/src/components/budget/budget.repository.ts +++ b/src/components/budget/budget.repository.ts @@ -188,9 +188,8 @@ export class BudgetRepository extends DtoRepository< const result = await this.db .query() .apply(this.currentBudgetForProject(projectId, changeset)) - .subQuery((sub) => + .subQuery(['project', 'budget'], (sub) => sub - .with('project, budget') .apply(this.records.recordsOfBudget({ view })) .apply(this.records.hydrate({ session, view })) .return('collect(dto) as records'), diff --git a/src/components/engagement/engagement.repository.ts b/src/components/engagement/engagement.repository.ts index 320dc70586..56d2a3ba29 100644 --- a/src/components/engagement/engagement.repository.ts +++ b/src/components/engagement/engagement.repository.ts @@ -904,7 +904,6 @@ export const engagementSorters = defineSorters(IEngagement, { .apply(sortWith(languageSorters, input)) // Use null for all internship engagements .union() - .with('node') .with('node as eng') .raw('where eng:InternshipEngagement') .return('null as sortValue'), @@ -930,7 +929,6 @@ export const engagementSorters = defineSorters(IEngagement, { .return('null as sortValue') .union() .with('reports') - .with('reports') .raw('where size(reports) <> 0') .raw('unwind reports as node') .apply(sortWith(progressReportSorters, input)), diff --git a/src/components/engagement/handlers/apply-finalized-changeset-to-engagement.handler.ts b/src/components/engagement/handlers/apply-finalized-changeset-to-engagement.handler.ts index 545b0583e7..82db20b656 100644 --- a/src/components/engagement/handlers/apply-finalized-changeset-to-engagement.handler.ts +++ b/src/components/engagement/handlers/apply-finalized-changeset-to-engagement.handler.ts @@ -36,9 +36,8 @@ export class ApplyFinalizedChangesetToEngagement relation('out', '', 'changeset', ACTIVE), node('changeset', 'Changeset', { id: changeset.id }), ]) - .subQuery((sub) => + .subQuery(['project', 'changeset'], (sub) => sub - .with('project, changeset') .match([ node('project'), relation('out', 'engagementRel', 'engagement', ACTIVE), @@ -63,9 +62,8 @@ export class ApplyFinalizedChangesetToEngagement relation('out', '', 'changeset', ACTIVE), node('changeset', 'Changeset', { id: changeset.id }), ]) - .subQuery((sub) => + .subQuery(['project', 'changeset'], (sub) => sub - .with('project, changeset') .match([ node('project'), relation('out', 'engagementRel', 'engagement', INACTIVE), @@ -95,9 +93,8 @@ export class ApplyFinalizedChangesetToEngagement relation('out', '', 'changeset', ACTIVE), node('changeset', 'Changeset', { id: changeset.id }), ]) - .subQuery((sub) => + .subQuery(['project', 'changeset'], (sub) => sub - .with('project, changeset') .match([ node('project'), relation('out', 'engagement', ACTIVE), diff --git a/src/components/file/file.repository.ts b/src/components/file/file.repository.ts index 4dcb29d1e8..94c430491c 100644 --- a/src/components/file/file.repository.ts +++ b/src/components/file/file.repository.ts @@ -135,20 +135,17 @@ export class FileRepository extends CommonRepository { hydrate() { return (query: Query) => query - .subQuery((sub) => + .subQuery('node', (sub) => sub - .with('node') .with('node') .where({ node: hasLabel(FileNodeType.File) }) .apply(this.hydrateFile()) .union() .with('node') - .with('node') .where({ node: hasLabel(FileNodeType.FileVersion) }) .apply(this.hydrateFileVersion()) .union() .with('node') - .with('node') .where({ node: hasLabel(FileNodeType.Directory) }) .apply(this.hydrateDirectory()), ) diff --git a/src/components/language/language.repository.ts b/src/components/language/language.repository.ts index b97cc28fd0..b256a7ce42 100644 --- a/src/components/language/language.repository.ts +++ b/src/components/language/language.repository.ts @@ -186,9 +186,8 @@ export class LanguageRepository extends DtoRepository< .apply(matchProps()) .apply(matchChangesetAndChangedProps(view?.changeset)) // get lowest sensitivity across all projects associated with each language. - .subQuery((sub) => + .subQuery(['projList', 'props'], (sub) => sub - .with('projList') .raw('UNWIND projList as project') .apply(matchProjectSens()) .with('sensitivity') @@ -197,7 +196,6 @@ export class LanguageRepository extends DtoRepository< .return('sensitivity as effectiveSensitivity') .union() .with('projList, props') - .with('projList, props') .raw('WHERE size(projList) = 0') .return(`props.sensitivity as effectiveSensitivity`), ) diff --git a/src/components/notifications/notification.repository.ts b/src/components/notifications/notification.repository.ts index 1c0befc4b9..79ce8b66fa 100644 --- a/src/components/notifications/notification.repository.ts +++ b/src/components/notifications/notification.repository.ts @@ -148,14 +148,13 @@ export class NotificationRepository extends CommonRepository { protected hydrate(session: Session) { return (query: Query) => query - .subQuery((q) => { + .subQuery('node', (q) => { const concreteHydrates = [...this.service.strategyMap].map( ([dtoCls, strategy]) => (q: Query) => { const type = this.getType(dtoCls); const hydrate = strategy.hydrateExtraForNeo4j('extra'); return q - .with('node') .with('node') .where({ 'node.type': type }) .apply(hydrate ?? ((q) => q.return('{} as extra'))); diff --git a/src/components/organization/organization.repository.ts b/src/components/organization/organization.repository.ts index d39ca7c58e..0a5fa506ac 100644 --- a/src/components/organization/organization.repository.ts +++ b/src/components/organization/organization.repository.ts @@ -91,9 +91,8 @@ export class OrganizationRepository extends DtoRepository< 'collect(project) as projList', 'keys(apoc.coll.frequenciesAsMap(apoc.coll.flatten(collect(scopedRoles)))) as scopedRoles', ]) - .subQuery((sub) => + .subQuery('projList', (sub) => sub - .with('projList') .raw('UNWIND projList as project') .apply(matchProjectSens()) .with('sensitivity') @@ -102,7 +101,6 @@ export class OrganizationRepository extends DtoRepository< .return('sensitivity') .union() .with('projList') - .with('projList') .raw('WHERE size(projList) = 0') .return(`'High' as sensitivity`), ) diff --git a/src/components/partner/partner.repository.ts b/src/components/partner/partner.repository.ts index d9f875c4de..ed58675fd8 100644 --- a/src/components/partner/partner.repository.ts +++ b/src/components/partner/partner.repository.ts @@ -209,9 +209,8 @@ export class PartnerRepository extends DtoRepository< 'collect(project) as projList', 'keys(apoc.coll.frequenciesAsMap(apoc.coll.flatten(collect(scopedRoles)))) as scopedRoles', ]) - .subQuery((sub) => + .subQuery('projList', (sub) => sub - .with('projList') .raw('UNWIND projList as project') .apply(matchProjectSens()) .with('sensitivity') @@ -220,7 +219,6 @@ export class PartnerRepository extends DtoRepository< .return('sensitivity') .union() .with('projList') - .with('projList') .raw('WHERE size(projList) = 0') .return(`'High' as sensitivity`), ) diff --git a/src/components/partnership/handlers/apply-finalized-changeset-to-partnership.handler.ts b/src/components/partnership/handlers/apply-finalized-changeset-to-partnership.handler.ts index 77db9b735f..eb7313fcdf 100644 --- a/src/components/partnership/handlers/apply-finalized-changeset-to-partnership.handler.ts +++ b/src/components/partnership/handlers/apply-finalized-changeset-to-partnership.handler.ts @@ -33,9 +33,8 @@ export class ApplyFinalizedChangesetToPartnership relation('out', '', 'changeset', ACTIVE), node('changeset', 'Changeset', { id: changeset.id }), ]) - .subQuery((sub) => + .subQuery(['project', 'changeset'], (sub) => sub - .with('project, changeset') .match([ node('project'), relation('out', 'partnershipRel', 'partnership', ACTIVE), @@ -58,9 +57,8 @@ export class ApplyFinalizedChangesetToPartnership relation('out', '', 'changeset', ACTIVE), node('changeset', 'Changeset', { id: changeset.id }), ]) - .subQuery((sub) => + .subQuery(['project', 'changeset'], (sub) => sub - .with('project, changeset') .match([ node('project'), relation('out', 'partnershipRel', 'partnership', INACTIVE), diff --git a/src/components/partnership/partnership.repository.ts b/src/components/partnership/partnership.repository.ts index 16ec395e1e..9537381ece 100644 --- a/src/components/partnership/partnership.repository.ts +++ b/src/components/partnership/partnership.repository.ts @@ -280,9 +280,8 @@ export class PartnershipRepository extends DtoRepository< .query() .optionalMatch(node('partner', 'Partner', { id: partnerId })) .optionalMatch(node('project', 'Project', { id: projectId })) - .subQuery((sub) => + .subQuery(['project', 'partner'], (sub) => sub - .with('project, partner') .optionalMatch([ node('project'), relation('out', '', 'partnership', ACTIVE), @@ -295,7 +294,6 @@ export class PartnershipRepository extends DtoRepository< changeset ? q .union() - .with('project, partner') .match([node('changeset', 'Changeset', { id: changeset })]) .optionalMatch([ node('project'), diff --git a/src/components/periodic-report/periodic-report.repository.ts b/src/components/periodic-report/periodic-report.repository.ts index f46ab60986..f974702d37 100644 --- a/src/components/periodic-report/periodic-report.repository.ts +++ b/src/components/periodic-report/periodic-report.repository.ts @@ -380,15 +380,13 @@ export class PeriodicReportRepository extends DtoRepository< protected hydrate(session: Session) { return (query: Query) => query - .subQuery((sub) => + .subQuery('node', (sub) => sub - .with('node') .with('node') .where({ node: hasLabel('ProgressReport') }) .apply(this.progressRepo.extraHydrate()) .union() .with('node') - .with('node') .where({ node: not(hasLabel('ProgressReport')) }) .return('{} as extra'), ) @@ -401,7 +399,6 @@ export class PeriodicReportRepository extends DtoRepository< ]) .return('project') .union() - .with('node') .match([ node('node'), relation('in', '', 'report', ACTIVE), diff --git a/src/components/progress-report/progress-report-extra-for-periodic-interface.repository.ts b/src/components/progress-report/progress-report-extra-for-periodic-interface.repository.ts index 5bb598f976..61fdc2d73a 100644 --- a/src/components/progress-report/progress-report-extra-for-periodic-interface.repository.ts +++ b/src/components/progress-report/progress-report-extra-for-periodic-interface.repository.ts @@ -90,7 +90,6 @@ export const progressReportExtrasSorters: DefinedSorters< ]) .apply(sortWith(progressSummarySorters, input)) .union() - .with('node') .with('node as report') .where( not( diff --git a/src/components/progress-report/variance-explanation/variance-explanation.repository.ts b/src/components/progress-report/variance-explanation/variance-explanation.repository.ts index 0bda1d70ff..ba2cbaa72e 100644 --- a/src/components/progress-report/variance-explanation/variance-explanation.repository.ts +++ b/src/components/progress-report/variance-explanation/variance-explanation.repository.ts @@ -39,12 +39,10 @@ export class ProgressReportVarianceExplanationRepository extends DtoRepository( reasons: [], comments: null, }; - const ctx = ['report', 'node']; return (query: Query) => query - .subQuery((sub) => + .subQuery(['report', 'node'], (sub) => sub - .with(ctx) .apply(matchProps({ optional: true, excludeBaseProps: true })) .return<{ dto: UnsecuredDto }>( merge(defaults, 'props').as('dto'), diff --git a/src/components/project/handlers/apply-finalized-changeset-to-project.handler.ts b/src/components/project/handlers/apply-finalized-changeset-to-project.handler.ts index c206daa1c5..7c99551ecc 100644 --- a/src/components/project/handlers/apply-finalized-changeset-to-project.handler.ts +++ b/src/components/project/handlers/apply-finalized-changeset-to-project.handler.ts @@ -36,10 +36,9 @@ export class ApplyFinalizedChangesetToProject changeset.applied ? commitChangesetProps() : rejectChangesetProps(), ) // Apply pending budget records - .subQuery((sub) => + .subQuery(['node', 'changeset'], (sub) => sub .comment('Apply pending budget records') - .with('node, changeset') .match([ node('node'), relation('out', '', 'budget', ACTIVE), diff --git a/src/components/project/project.repository.ts b/src/components/project/project.repository.ts index e7c400e711..58a9c40491 100644 --- a/src/components/project/project.repository.ts +++ b/src/components/project/project.repository.ts @@ -373,7 +373,6 @@ export const projectSorters = defineSorters(IProject, { .match(getPath()) .apply(sortWith(locationSorters, input)) .union() - .with('node') .with('node as project') .where(not(path(getPath(true)))) .return('null as sortValue'); @@ -392,7 +391,6 @@ export const projectSorters = defineSorters(IProject, { .match(getPath()) .apply(sortWith(partnershipSorters, input)) .union() - .with('node') .with('node as project') .where(not(path(getPath(true)))) .return('null as sortValue'); diff --git a/src/components/user/user.repository.ts b/src/components/user/user.repository.ts index b6b542e392..e5f57feff3 100644 --- a/src/components/user/user.repository.ts +++ b/src/components/user/user.repository.ts @@ -277,9 +277,8 @@ export class UserRepository extends DtoRepository( [node('user', 'User', { id: userId })], [node('org', 'Organization', { id: orgId })], ]) - .subQuery((sub) => + .subQuery(['user', 'org'], (sub) => sub - .with('user, org') .match([ node('user'), relation('out', 'oldRel', 'organization', ACTIVE), @@ -292,9 +291,8 @@ export class UserRepository extends DtoRepository( ) .apply((q) => { if (primary) { - q.subQuery((sub) => + q.subQuery(['user', 'org'], (sub) => sub - .with('user, org') .match([ node('user'), relation('out', 'oldRel', 'primaryOrganization', { diff --git a/src/core/database/query-augmentation/subquery.ts b/src/core/database/query-augmentation/subquery.ts index 6125071b5a..50898c1e24 100644 --- a/src/core/database/query-augmentation/subquery.ts +++ b/src/core/database/query-augmentation/subquery.ts @@ -7,16 +7,14 @@ import { SubClauseCollection } from './SubClauseCollection'; declare module 'cypher-query-builder/dist/typings/query' { interface Query { /** - * Creates a sub-query clause (`CALL { ... }`) and calls the given function + * Creates a sub-query clause (`CALL (...) { ... }`) and calls the given function * to define it. * * @example - * .unwind([0, 1, 2], 'x') * .subQuery((sub) => sub - * .with('x') - * .return('x * 10 as y') + * .match(node('user', 'User', { id })) + * .return('user') * ) - * .return(['x', 'y']) * * @example * .unwind([0, 1, 2], 'x') @@ -42,26 +40,35 @@ Query.prototype.subQuery = function subQuery( | ((query: Query) => void), maybeSub?: (query: Query) => void, ) { - const subClause = new SubQueryClause(); - const subQ = withParent(subClause.asQuery(), this); - if (typeof subOrImport === 'function') { - subOrImport(subQ); - } else { - const imports = setOf( - many(subOrImport) + const importsRaw = typeof subOrImport === 'function' ? [] : subOrImport!; + const sub = typeof subOrImport === 'function' ? subOrImport : maybeSub!; + + const imports = [ + ...setOf( + many(importsRaw) .flatMap((val) => (val instanceof Variable ? varInExp(val) : val)) .filter(isNotFalsy), - ); - subQ.with([...imports]); - maybeSub!(subQ); - } + ), + ]; + + const subClause = new SubQueryClause(imports); + const subQ = withParent(subClause.asQuery(), this); + sub(subQ); return this.continueChainClause(subClause); }; class SubQueryClause extends SubClauseCollection { + constructor(readonly scope: string[]) { + super(); + } + build() { - return this.wrapBuild('CALL { ', ' }', super.build()); + return this.wrapBuild( + `CALL (${this.scope.join(', ')}) { `, + ' }', + super.build(), + ); } } diff --git a/src/core/database/query/filters.ts b/src/core/database/query/filters.ts index 936f3837b9..dcf524d79d 100644 --- a/src/core/database/query/filters.ts +++ b/src/core/database/query/filters.ts @@ -242,9 +242,8 @@ export const sub = ({ key, value, query }) => { const input = [...many(extraInput ?? [])]; return query - .subQuery((sub) => + .subQuery(['node', ...input], (sub) => sub - .with(['node', ...input]) .with([`node as ${outerVar}`, ...input]) .apply(matchSubNode) .apply(subBuilder()(value)) diff --git a/src/core/database/query/match-project-based-props.ts b/src/core/database/query/match-project-based-props.ts index d6ab67b8e8..976bba6e73 100644 --- a/src/core/database/query/match-project-based-props.ts +++ b/src/core/database/query/match-project-based-props.ts @@ -22,30 +22,31 @@ export const matchPropsAndProjectSensAndScopedRoles = (query: Query) => query.comment` matchPropsAndProjectSensAndScopedRoles() - `.subQuery((sub) => - sub - .with([ - 'node', - 'project', - ...(session instanceof Variable ? [session.name] : []), - ]) - .apply(matchProjectSens('project')) - .apply( - matchProps( - propsOptions?.view?.deleted - ? propsOptions - : { ...propsOptions, view: { active: true } }, - ), - ) - .apply((q) => - !session ? q : q.apply(matchProjectScopedRoles({ session })), - ) - .return([ - merge(propsOptions?.outputVar ?? 'props', { - sensitivity: 'sensitivity', - scope: session ? `scopedRoles` : null, - }).as(propsOptions?.outputVar ?? 'props'), - ]), + `.subQuery( + [ + 'node', + 'project', + ...(session instanceof Variable ? [session.name] : []), + ], + (sub) => + sub + .apply(matchProjectSens('project')) + .apply( + matchProps( + propsOptions?.view?.deleted + ? propsOptions + : { ...propsOptions, view: { active: true } }, + ), + ) + .apply((q) => + !session ? q : q.apply(matchProjectScopedRoles({ session })), + ) + .return([ + merge(propsOptions?.outputVar ?? 'props', { + sensitivity: 'sensitivity', + scope: session ? `scopedRoles` : null, + }).as(propsOptions?.outputVar ?? 'props'), + ]), ); export const matchProjectScopedRoles = @@ -94,7 +95,6 @@ export const matchProjectScopedRoles = ) .union() .with('project') - .with('project') .raw('WHERE project IS NULL') .return(`[] as ${outputVar}`), ); @@ -105,9 +105,8 @@ export const matchProjectSens = output: Output = 'sensitivity' as Output, ) => (query: Query) => - query.comment`matchProjectSens()`.subQuery((sub) => + query.comment`matchProjectSens()`.subQuery(projectVar, (sub) => sub - .with(projectVar) // import .with(projectVar) // needed for where clause .raw( `WHERE ${projectVar} IS NOT NULL AND ${projectVar}.type = "${ProjectType.Internship}"`, @@ -119,7 +118,6 @@ export const matchProjectSens = ]) .return(`projSens.value as ${output}`) .union() - .with(projectVar) // import .with(projectVar) // needed for where clause .raw( `WHERE ${projectVar} IS NOT NULL AND ${projectVar}.type <> "${ProjectType.Internship}"`, @@ -143,7 +141,6 @@ export const matchProjectSens = // https://neo4j.com/developer/kb/conditional-cypher-execution/#_the_subquery_must_return_a_row_for_the_outer_query_to_continue .union() .with(projectVar) - .with(projectVar) .raw(`WHERE ${projectVar} IS NULL`) // TODO this doesn't work for languages without projects. They should use their own sensitivity not High. .return>(`"High" as ${output}`), @@ -155,24 +152,25 @@ export const matchUserGloballyScopedRoles = outputVar = 'globalRoles' as Output, ) => (query: Query) => - query.comment('matchUserGloballyScopedRoles()').subQuery((sub) => - sub - .with(userVar) - .match([ - node(userVar), - relation('out', '', 'roles', ACTIVE), - node('role', 'Property'), - ]) - .return<{ [K in Output]: readonly GlobalScopedRole[] }>( - reduce( - 'scopedRoles', - [], - apoc.coll.flatten(collect('role.value')), - 'role', - listConcat('scopedRoles', [`"global:" + role`]), - ).as(outputVar), - ), - ); + query + .comment('matchUserGloballyScopedRoles()') + .subQuery(userVar, (sub) => + sub + .match([ + node(userVar), + relation('out', '', 'roles', ACTIVE), + node('role', 'Property'), + ]) + .return<{ [K in Output]: readonly GlobalScopedRole[] }>( + reduce( + 'scopedRoles', + [], + apoc.coll.flatten(collect('role.value')), + 'role', + listConcat('scopedRoles', [`"global:" + role`]), + ).as(outputVar), + ), + ); // group by project so inner logic doesn't run multiple times for a single project export const oncePerProject = diff --git a/src/core/database/query/properties/update-property.ts b/src/core/database/query/properties/update-property.ts index 8f63f9f4bb..b89115fe4e 100644 --- a/src/core/database/query/properties/update-property.ts +++ b/src/core/database/query/properties/update-property.ts @@ -197,16 +197,14 @@ export const conditionalOn = ( ): QueryFragment => { const imports = [...new Set([conditionVar, ...scope])]; return (query) => - query.subQuery((sub) => + query.subQuery(imports, (sub) => sub - .with(imports) .with(imports) .raw(`WHERE ${conditionVar}`) .apply(trueQuery) .union() .with(imports) - .with(imports) .raw(`WHERE NOT ${conditionVar}`) .apply(falseQuery), ); From 29e1edb77db00343f93da60aba668c668ddab17f Mon Sep 17 00:00:00 2001 From: Carson Full Date: Tue, 8 Apr 2025 09:37:38 -0500 Subject: [PATCH 2/4] Adjust comment threads to not match on aliased import --- .../comments/comment-thread.repository.ts | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/components/comments/comment-thread.repository.ts b/src/components/comments/comment-thread.repository.ts index a6c69787fd..6fea5ab649 100644 --- a/src/components/comments/comment-thread.repository.ts +++ b/src/components/comments/comment-thread.repository.ts @@ -53,18 +53,22 @@ export class CommentThreadRepository extends DtoRepository(CommentThread) { .subQuery('node', (sub) => sub .with('node as thread') - .match([ - node('thread'), - relation('out', '', 'comment', ACTIVE), - node('comment', 'Comment'), - ]) - .with('comment') - .orderBy('comment.createdAt') - .with('collect(comment) as comments') - .with('[comments[0], comments[-1]] as comments') - .raw('unwind comments as node') - .subQuery('node', this.comments.hydrate()) - .return('collect(dto) as comments'), + .subQuery('thread', (sub2) => + sub2 + .match([ + node('thread'), + relation('out', '', 'comment', ACTIVE), + node('comment', 'Comment'), + ]) + .with('comment') + .orderBy('comment.createdAt') + .with('collect(comment) as comments') + .with('[comments[0], comments[-1]] as comments') + .raw('unwind comments as node') + .subQuery('node', this.comments.hydrate()) + .return('collect(dto) as comments'), + ) + .return('comments'), ) .return<{ dto: UnsecuredDto }>( merge('node', { From ef3114086b84a17be6fb03bf3c3e3a8fbfbc0dcd Mon Sep 17 00:00:00 2001 From: Carson Full Date: Tue, 8 Apr 2025 12:10:24 -0500 Subject: [PATCH 3/4] Adjust nested filters to not alias a subquery import --- src/core/database/query/filters.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/core/database/query/filters.ts b/src/core/database/query/filters.ts index dcf524d79d..0daa35c3fa 100644 --- a/src/core/database/query/filters.ts +++ b/src/core/database/query/filters.ts @@ -245,13 +245,17 @@ export const sub = .subQuery(['node', ...input], (sub) => sub .with([`node as ${outerVar}`, ...input]) - .apply(matchSubNode) - .apply(subBuilder()(value)) - .return(`true as ${key}FiltersApplied`) - // Prevent filter from increasing cardinality above 1. - // This happens with `1-Many` relationships matched in `matchSubNode`. - // Note they are allowed to reduce cardinality to 0. - .raw('limit 1'), + .subQuery([...outerVar, ...input], (sub2) => + sub2 + .apply(matchSubNode) + .apply(subBuilder()(value)) + .return(`true as ${key}FiltersApplied`) + // Prevent filter from increasing cardinality above 1. + // This happens with `1-Many` relationships matched in `matchSubNode`. + // Note they are allowed to reduce cardinality to 0. + .raw('limit 1'), + ) + .return(`${key}FiltersApplied`), ) .with('*'); }; From 7895dc54930c827625e9fcf89df9ddbb51d598bd Mon Sep 17 00:00:00 2001 From: Carson Full Date: Tue, 8 Apr 2025 11:47:46 -0500 Subject: [PATCH 4/4] Adjust nested sorters to use standardized `outer` and to not alias a subquery import --- .../engagement/engagement.repository.ts | 14 +++--- .../language/language.repository.ts | 3 +- src/components/partner/partner.repository.ts | 3 +- .../partnership/partnership.repository.ts | 3 +- ...extra-for-periodic-interface.repository.ts | 47 +++++++++---------- src/components/project/project.repository.ts | 10 ++-- src/core/database/query/sorting.ts | 7 ++- 7 files changed, 42 insertions(+), 45 deletions(-) diff --git a/src/components/engagement/engagement.repository.ts b/src/components/engagement/engagement.repository.ts index 56d2a3ba29..1d59a49833 100644 --- a/src/components/engagement/engagement.repository.ts +++ b/src/components/engagement/engagement.repository.ts @@ -899,26 +899,24 @@ export const engagementSorters = defineSorters(IEngagement, { // eslint-disable-next-line @typescript-eslint/naming-convention 'language.*': (query, input) => query - .with('node as eng') - .match([node('eng'), relation('out', '', 'language'), node('node')]) + .match([node('outer'), relation('out', '', 'language'), node('node')]) .apply(sortWith(languageSorters, input)) // Use null for all internship engagements .union() - .with('node as eng') - .raw('where eng:InternshipEngagement') + .with('outer') + .raw('where outer:InternshipEngagement') .return('null as sortValue'), // eslint-disable-next-line @typescript-eslint/naming-convention 'project.*': (query, input) => query - .with('node as eng') - .match([node('eng'), relation('in', '', 'engagement'), node('node')]) + .match([node('outer'), relation('in', '', 'engagement'), node('node')]) .apply(sortWith(projectSorters, input)), // eslint-disable-next-line @typescript-eslint/naming-convention 'currentProgressReportDue.*': (query, input) => query - .subQuery('node', (sub) => + .with('outer as parent') + .subQuery('parent', (sub) => sub - .with('node as parent') .apply(matchCurrentDue(undefined, 'Progress')) .return('collect(node) as reports'), ) diff --git a/src/components/language/language.repository.ts b/src/components/language/language.repository.ts index b256a7ce42..ea64539fc4 100644 --- a/src/components/language/language.repository.ts +++ b/src/components/language/language.repository.ts @@ -380,9 +380,8 @@ export const languageSorters = defineSorters(Language, { // eslint-disable-next-line @typescript-eslint/naming-convention 'ethnologue.*': (query, input) => query - .with('node as lang') .match([ - node('lang'), + node('outer'), relation('out', '', 'ethnologue'), node('node', 'EthnologueLanguage'), ]) diff --git a/src/components/partner/partner.repository.ts b/src/components/partner/partner.repository.ts index ed58675fd8..4bc8241bbe 100644 --- a/src/components/partner/partner.repository.ts +++ b/src/components/partner/partner.repository.ts @@ -350,9 +350,8 @@ export const partnerSorters = defineSorters(Partner, { // eslint-disable-next-line @typescript-eslint/naming-convention 'organization.*': (query, input) => query - .with('node as partner') .match([ - node('partner'), + 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 9537381ece..deb29247e0 100644 --- a/src/components/partnership/partnership.repository.ts +++ b/src/components/partnership/partnership.repository.ts @@ -439,9 +439,8 @@ export const partnershipSorters = defineSorters(Partnership, { // eslint-disable-next-line @typescript-eslint/naming-convention 'partner.*': (query, input) => query - .with('node as partnership') .match([ - node('partnership'), + node('outer'), relation('out', '', 'partner'), node('node', 'Partner'), ]) diff --git a/src/components/progress-report/progress-report-extra-for-periodic-interface.repository.ts b/src/components/progress-report/progress-report-extra-for-periodic-interface.repository.ts index 61fdc2d73a..6515351bb3 100644 --- a/src/components/progress-report/progress-report-extra-for-periodic-interface.repository.ts +++ b/src/components/progress-report/progress-report-extra-for-periodic-interface.repository.ts @@ -53,9 +53,8 @@ export const progressReportExtrasSorters: DefinedSorters< // eslint-disable-next-line @typescript-eslint/naming-convention 'pnpExtractionResult.*': (query, input) => query - .with('node as report') .match([ - node('report'), + node('outer'), relation('out', '', 'reportFileNode'), node('file', 'File'), relation('out', '', 'pnpExtractionResult'), @@ -65,9 +64,8 @@ export const progressReportExtrasSorters: DefinedSorters< // eslint-disable-next-line @typescript-eslint/naming-convention 'engagement.*': (query, input) => query - .with('node as report') .match([ - node('report'), + node('outer'), relation('in', '', 'report'), node('node', 'LanguageEngagement'), ]) @@ -81,26 +79,27 @@ export const progressReportExtrasSorters: DefinedSorters< ({ field, period }) => { const periodVar = { period: variable(`"${period}"`) }; const matcher: SortMatcher = (query, input) => - query - .with('node as report') - .match([ - node('report'), - relation('out', '', 'summary'), - node('node', 'ProgressSummary', periodVar), - ]) - .apply(sortWith(progressSummarySorters, input)) - .union() - .with('node as report') - .where( - not( - path([ - node('report'), - relation('out', '', 'summary'), - node('', 'ProgressSummary', periodVar), - ]), - ), - ) - .return('null as sortValue'); + query.with('node as report').subQuery('report', (sub) => + sub + .match([ + node('report'), + relation('out', '', 'summary'), + node('node', 'ProgressSummary', periodVar), + ]) + .apply(sortWith(progressSummarySorters, input)) + .union() + .with('node as report') + .where( + not( + path([ + node('report'), + relation('out', '', 'summary'), + node('', 'ProgressSummary', periodVar), + ]), + ), + ) + .return('null as sortValue'), + ); return [`${field}.*`, matcher]; }, ).asRecord, diff --git a/src/components/project/project.repository.ts b/src/components/project/project.repository.ts index 58a9c40491..29a3f06618 100644 --- a/src/components/project/project.repository.ts +++ b/src/components/project/project.repository.ts @@ -364,34 +364,32 @@ export const projectSorters = defineSorters(IProject, { // eslint-disable-next-line @typescript-eslint/naming-convention 'primaryLocation.*': (query, input) => { const getPath = (anon = false) => [ - node('project'), + node('outer'), relation('out', '', 'primaryLocation', ACTIVE), node(anon ? '' : 'node'), ]; return query - .with('node as project') .match(getPath()) .apply(sortWith(locationSorters, input)) .union() - .with('node as project') + .with('outer') .where(not(path(getPath(true)))) .return('null as sortValue'); }, // eslint-disable-next-line @typescript-eslint/naming-convention 'primaryPartnership.*': (query, input) => { const getPath = (anon = false) => [ - node('project'), + node('outer'), relation('out', '', 'partnership', ACTIVE), node(anon ? '' : 'node', 'Partnership'), relation('out', '', 'primary', ACTIVE), node('', 'Property', { value: variable('true') }), ]; return query - .with('node as project') .match(getPath()) .apply(sortWith(partnershipSorters, input)) .union() - .with('node as project') + .with('outer') .where(not(path(getPath(true)))) .return('null as sortValue'); }, diff --git a/src/core/database/query/sorting.ts b/src/core/database/query/sorting.ts index a45ea29bf6..5ad1b449da 100644 --- a/src/core/database/query/sorting.ts +++ b/src/core/database/query/sorting.ts @@ -141,7 +141,12 @@ export const defineSorters = >( ) ?? [null, null]; if (matchedPrefix && subCustom) { const subField = sort.slice(matchedPrefix.length - 1); - return { ...common, matcher: subCustom, sort: subField }; + const matcher: SortMatcher = (query, input) => + query + .with('node as outer') + .subQuery('outer', (sub) => sub.apply((q) => subCustom(q, input))) + .return('sortValue'); + return { ...common, matcher, sort: subField }; } const baseNodeProps = resource.BaseNodeProps ?? Resource.Props;