From d07bdda955346297512bb0b5e14334875ed8134c Mon Sep 17 00:00:00 2001 From: Carson Full Date: Fri, 31 May 2024 22:32:52 -0500 Subject: [PATCH 01/13] Moving sorting functions to their own file --- src/core/database/query/index.ts | 1 + src/core/database/query/lists.ts | 68 ++---------------------------- src/core/database/query/sorting.ts | 62 +++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 65 deletions(-) create mode 100644 src/core/database/query/sorting.ts diff --git a/src/core/database/query/index.ts b/src/core/database/query/index.ts index c364f6167b..0f31f17a64 100644 --- a/src/core/database/query/index.ts +++ b/src/core/database/query/index.ts @@ -13,6 +13,7 @@ export * from './cypher-expression'; export * from './cypher-functions'; export * from './full-text'; export * from './lists'; +export * from './sorting'; export * from './matching'; export * from './deletes'; export * from './match-project-based-props'; diff --git a/src/core/database/query/lists.ts b/src/core/database/query/lists.ts index 86b9011385..cfa4c9e063 100644 --- a/src/core/database/query/lists.ts +++ b/src/core/database/query/lists.ts @@ -1,17 +1,6 @@ -import { node, Query, relation } from 'cypher-query-builder'; -import { identity } from 'rxjs'; -import { - getDbSortTransformer, - ID, - MadeEnum, - Order, - PaginatedListType, - PaginationInput, - Resource, - ResourceShape, -} from '~/common'; -import { apoc, collect } from './cypher-functions'; -import { ACTIVE } from './matching'; +import { Query } from 'cypher-query-builder'; +import { ID, PaginatedListType, PaginationInput } from '~/common'; +import { collect } from './cypher-functions'; /** * Adds pagination to a query based on input. @@ -70,57 +59,6 @@ export const paginate = ]); }; -/** - * Applies sorting to rows given the input. - * - * Optionally custom property matchers can be passed in that override the - * default property matching queries. - * These are given a query and are expected to have a return clause with a `sortValue` - */ -export const sorting = - >( - resource: TResourceStatic, - { sort, order }: { sort: string; order: Order }, - customPropMatchers: { - [SortKey in string]?: (query: Query) => Query<{ sortValue: unknown }>; - } = {}, - ) => - (query: Query) => { - const sortTransformer = getDbSortTransformer(resource, sort) ?? identity; - - const baseNodeProps = resource.BaseNodeProps ?? Resource.Props; - const isBaseNodeProp = baseNodeProps.includes(sort); - - const matcher = - customPropMatchers[sort] ?? - (isBaseNodeProp ? matchBasePropSort : matchPropSort)(sort); - - return query.comment`sorting(${sort})` - .subQuery('*', matcher) - .with('*') - .orderBy(`${sortTransformer('sortValue')}`, order); - }; - -const matchPropSort = (prop: string) => (query: Query) => - query - .match([ - node('node'), - relation('out', '', prop, ACTIVE), - node('sortProp', 'Property'), - ]) - .return('sortProp.value as sortValue'); - -const matchBasePropSort = (prop: string) => (query: Query) => - query.return(`node.${prop} as sortValue`); - -export const sortingForEnumIndex = - (theEnum: MadeEnum) => - (variable: string) => - apoc.coll.indexOf( - [...theEnum.values].map((v) => `"${v}"`), - variable, - ); - export const whereNotDeletedInChangeset = (changeset?: ID) => (query: Query) => changeset ? query.raw( diff --git a/src/core/database/query/sorting.ts b/src/core/database/query/sorting.ts new file mode 100644 index 0000000000..a717ae06a6 --- /dev/null +++ b/src/core/database/query/sorting.ts @@ -0,0 +1,62 @@ +import { node, Query, relation } from 'cypher-query-builder'; +import { identity } from 'rxjs'; +import { + getDbSortTransformer, + MadeEnum, + Order, + Resource, + ResourceShape, +} from '~/common'; +import { apoc } from './cypher-functions'; +import { ACTIVE } from './matching'; + +export const sortingForEnumIndex = + (theEnum: MadeEnum) => + (variable: string) => + apoc.coll.indexOf( + [...theEnum.values].map((v) => `"${v}"`), + variable, + ); + +/** + * Applies sorting to rows given the input. + * + * Optionally custom property matchers can be passed in that override the + * default property matching queries. + * These are given a query and are expected to have a return clause with a `sortValue` + */ +export const sorting = + >( + resource: TResourceStatic, + { sort, order }: { sort: string; order: Order }, + customPropMatchers: { + [SortKey in string]?: (query: Query) => Query<{ sortValue: unknown }>; + } = {}, + ) => + (query: Query) => { + const sortTransformer = getDbSortTransformer(resource, sort) ?? identity; + + const baseNodeProps = resource.BaseNodeProps ?? Resource.Props; + const isBaseNodeProp = baseNodeProps.includes(sort); + + const matcher = + customPropMatchers[sort] ?? + (isBaseNodeProp ? matchBasePropSort : matchPropSort)(sort); + + return query.comment`sorting(${sort})` + .subQuery('*', matcher) + .with('*') + .orderBy(`${sortTransformer('sortValue')}`, order); + }; + +const matchPropSort = (prop: string) => (query: Query) => + query + .match([ + node('node'), + relation('out', '', prop, ACTIVE), + node('sortProp', 'Property'), + ]) + .return('sortProp.value as sortValue'); + +const matchBasePropSort = (prop: string) => (query: Query) => + query.return(`node.${prop} as sortValue`); From 6eec3279f3233f5af6bbe9caa1db18c1ad810ea2 Mon Sep 17 00:00:00 2001 From: Carson Full Date: Fri, 31 May 2024 23:47:37 -0500 Subject: [PATCH 02/13] Split `sorting` into `sortWith` & `defineSorters` This allows sorters to be declared without a specific input, and then called in sortWith. This opens the door to nested sorting. --- src/common/db-sort.decorator.ts | 2 +- src/common/index.ts | 2 +- src/core/database/query/sorting.ts | 128 +++++++++++++++++++++++------ 3 files changed, 106 insertions(+), 26 deletions(-) diff --git a/src/common/db-sort.decorator.ts b/src/common/db-sort.decorator.ts index a61313742c..c9522b5c63 100644 --- a/src/common/db-sort.decorator.ts +++ b/src/common/db-sort.decorator.ts @@ -5,7 +5,7 @@ const DbSortSymbol = Symbol('DbSortSymbol'); /** * A function given a cypher variable will output cypher to transform it for sorting. */ -type SortTransformer = (value: string) => string; +export type SortTransformer = (value: string) => string; /** * Customize the way this field is sorted upon. diff --git a/src/common/index.ts b/src/common/index.ts index 5843e3884c..b3e68f7ad3 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -9,7 +9,7 @@ export * from './data-object'; export * from './date-filter.input'; export { DbLabel } from './db-label.decorator'; export * from './db-label.helpers'; -export * from './db-sort.decorator'; +export { DbSort } from './db-sort.decorator'; export * from './db-unique.decorator'; export * from './disabled.decorator'; export * from './mutation-placeholder.output'; diff --git a/src/core/database/query/sorting.ts b/src/core/database/query/sorting.ts index a717ae06a6..efe1f7fa0b 100644 --- a/src/core/database/query/sorting.ts +++ b/src/core/database/query/sorting.ts @@ -1,15 +1,23 @@ import { node, Query, relation } from 'cypher-query-builder'; import { identity } from 'rxjs'; +import { LiteralUnion } from 'type-fest'; +import { MadeEnum, Order, Resource, ResourceShape } from '~/common'; import { getDbSortTransformer, - MadeEnum, - Order, - Resource, - ResourceShape, -} from '~/common'; + SortTransformer, +} from '~/common/db-sort.decorator'; import { apoc } from './cypher-functions'; import { ACTIVE } from './matching'; +/** + * Declares a that an enum field, is to be sorted by the index of the enum members. + * + * @example + * ```ts + * @DbSort(sortingForEnumIndex(Status) + * status: Status + * ``` + */ export const sortingForEnumIndex = (theEnum: MadeEnum) => (variable: string) => @@ -21,32 +29,75 @@ export const sortingForEnumIndex = /** * Applies sorting to rows given the input. * - * Optionally custom property matchers can be passed in that override the + * Optionally, custom property matchers can be passed in to override the * default property matching queries. * These are given a query and are expected to have a return clause with a `sortValue` + * + * @example + * query.apply(sorting(User, input)) + */ +export const sorting = >( + resource: TResourceStatic, + input: Sort>, + matchers: SortMatchers> = {}, +) => sortWith(defineSorters(resource, matchers), input); + +/** + * Applies sorting to rows given the sorters & input. + * + * @example + * query.apply(sortWith(userSorters, input)) + */ +export const sortWith = ( + config: (input: Sort) => Sort & SortMatch, + input: Sort, +) => { + const { transformer, matcher, order } = config(input); + + return (query: Query) => + query.comment`sorting(${input.sort})` + .subQuery('*', (sub) => matcher(sub, input)) + .with('*') + .orderBy(`${transformer('sortValue')}`, order); +}; + +/** + * Declares sorters for the given type. + * + * ```ts + * const userSorters = defineSorters(User, { + * // The key is the sort field string that callers can pick + * // This is a loose string, so it can be an existing field + * // on the type or something completely new. + * name: (query) => query + * // Do whatever to calculate the sort value. + * // `node` can be assumed to be the current type. + * // One per row. + * .match(...) + * // This "matcher" function should end with a return clause that + * // emits `sortValue` + * .return('x as sortValue') + * }); + * ``` */ -export const sorting = +export const defineSorters = >( resource: TResourceStatic, - { sort, order }: { sort: string; order: Order }, - customPropMatchers: { - [SortKey in string]?: (query: Query) => Query<{ sortValue: unknown }>; - } = {}, + matchers: SortMatchers>, ) => - (query: Query) => { - const sortTransformer = getDbSortTransformer(resource, sort) ?? identity; + ({ sort, order }: Sort>) => { + const transformer = getDbSortTransformer(resource, sort) ?? identity; + const common = { sort, order, transformer }; + + const exactCustom = matchers[sort]; + if (exactCustom) { + return { ...common, matcher: exactCustom }; + } const baseNodeProps = resource.BaseNodeProps ?? Resource.Props; const isBaseNodeProp = baseNodeProps.includes(sort); - - const matcher = - customPropMatchers[sort] ?? - (isBaseNodeProp ? matchBasePropSort : matchPropSort)(sort); - - return query.comment`sorting(${sort})` - .subQuery('*', matcher) - .with('*') - .orderBy(`${sortTransformer('sortValue')}`, order); + const matcher = (isBaseNodeProp ? matchBasePropSort : matchPropSort)(sort); + return { ...common, matcher }; }; const matchPropSort = (prop: string) => (query: Query) => @@ -56,7 +107,36 @@ const matchPropSort = (prop: string) => (query: Query) => relation('out', '', prop, ACTIVE), node('sortProp', 'Property'), ]) - .return('sortProp.value as sortValue'); + .return('sortProp.value as sortValue'); const matchBasePropSort = (prop: string) => (query: Query) => - query.return(`node.${prop} as sortValue`); + query.return(`node.${prop} as sortValue`); + +export interface SortCol { + sortValue: unknown; +} + +// TODO stricter +type SortFieldOf> = LiteralUnion< + keyof TResourceStatic['prototype'] & string, + string +>; + +type SortMatcher = ( + query: Query, + input: Sort, +) => Query; + +type SortMatchers = Partial< + Record> +>; + +interface Sort { + sort: Field; + order: Order; +} + +interface SortMatch { + matcher: SortMatcher; + transformer: SortTransformer; +} From dd18d20ca290f5b1acf78725e44a161a62edf846 Mon Sep 17 00:00:00 2001 From: Carson Full Date: Fri, 31 May 2024 23:59:15 -0500 Subject: [PATCH 03/13] Support nested sorting with `x.*` keys --- src/common/pagination.input.ts | 2 +- src/core/database/query/sorting.ts | 47 ++++++++++++++++++++++++++---- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/common/pagination.input.ts b/src/common/pagination.input.ts index a7f9caf7f9..3ea1adbdc8 100644 --- a/src/common/pagination.input.ts +++ b/src/common/pagination.input.ts @@ -100,7 +100,7 @@ export const SortablePaginationInput = ({ description: 'The field in which to sort on', defaultValue: defaultSort, }) - @Matches(/^[A-Za-z0-9_]+$/) + @Matches(/^[A-Za-z0-9_.]+$/) readonly sort: SortKey = defaultSort; @Field(() => Order, { diff --git a/src/core/database/query/sorting.ts b/src/core/database/query/sorting.ts index efe1f7fa0b..e31b5b8f40 100644 --- a/src/core/database/query/sorting.ts +++ b/src/core/database/query/sorting.ts @@ -1,3 +1,4 @@ +import { entries } from '@seedcompany/common'; import { node, Query, relation } from 'cypher-query-builder'; import { identity } from 'rxjs'; import { LiteralUnion } from 'type-fest'; @@ -50,15 +51,23 @@ export const sorting = >( */ export const sortWith = ( config: (input: Sort) => Sort & SortMatch, - input: Sort, + input: Sort & SortInternals, ) => { - const { transformer, matcher, order } = config(input); + const { transformer, matcher, sort, order } = config(input); + + const transformerRef = input.transformerRef ?? { current: transformer }; + + const subInput = { sort, order, sub: true, transformerRef }; + if (input.sub) { + transformerRef.current = transformer; + return (query: Query) => matcher(query, subInput); + } return (query: Query) => query.comment`sorting(${input.sort})` - .subQuery('*', (sub) => matcher(sub, input)) + .subQuery('*', (sub) => matcher(sub, subInput)) .with('*') - .orderBy(`${transformer('sortValue')}`, order); + .orderBy(`${transformerRef.current('sortValue')}`, order); }; /** @@ -77,6 +86,19 @@ export const sortWith = ( * // This "matcher" function should end with a return clause that * // emits `sortValue` * .return('x as sortValue') + * + * // The ability to nest sorting into relationships is possible. + * // This is done by appending `.*` to the key. + * // For example, the sort field could be "parent.name" + * 'parent.*': (query, input) => query + * // Again match as needed + * .match(...) + * // Call sortWith with the sorters of the relationship type. + * // These matchers are also given the current sort _input_ + * // (second arg above) which can be passed down like this. + * // `sortWith` understands this nesting and will remove the `parent.` + * // prefix before matching the nested sorters. + * .apply(sortWith(userSorters, input)) * }); * ``` */ @@ -94,6 +116,14 @@ export const defineSorters = return { ...common, matcher: exactCustom }; } + const [matchedPrefix, subCustom] = entries(matchers).find( + ([key]) => key.endsWith('.*') && sort.startsWith(key.slice(0, -1)), + ) ?? [null, null]; + if (matchedPrefix && subCustom) { + const subField = sort.slice(matchedPrefix.length - 1); + return { ...common, matcher: subCustom, sort: subField }; + } + const baseNodeProps = resource.BaseNodeProps ?? Resource.Props; const isBaseNodeProp = baseNodeProps.includes(sort); const matcher = (isBaseNodeProp ? matchBasePropSort : matchPropSort)(sort); @@ -124,7 +154,7 @@ type SortFieldOf> = LiteralUnion< type SortMatcher = ( query: Query, - input: Sort, + input: Sort & SortInternals, ) => Query; type SortMatchers = Partial< @@ -136,6 +166,13 @@ interface Sort { order: Order; } +interface SortInternals { + /** @internal */ + sub?: boolean; + /** @internal */ + transformerRef?: { current: SortTransformer }; +} + interface SortMatch { matcher: SortMatcher; transformer: SortTransformer; From 44f65726955b612427a9340ca62c99a8b0ce3dd7 Mon Sep 17 00:00:00 2001 From: Carson Full Date: Sat, 1 Jun 2024 00:18:40 -0500 Subject: [PATCH 04/13] Migrate project sorters to new style --- src/components/project/project.repository.ts | 38 +++++++++---------- .../query/match-project-based-props.ts | 11 ++++-- 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/src/components/project/project.repository.ts b/src/components/project/project.repository.ts index 33e8ea10d0..adfb681f9d 100644 --- a/src/components/project/project.repository.ts +++ b/src/components/project/project.repository.ts @@ -16,13 +16,15 @@ import { ACTIVE, createNode, createRelationships, + defineSorters, matchChangesetAndChangedProps, matchProjectSens, matchPropsAndProjectSensAndScopedRoles, merge, paginate, requestingUser, - sorting, + SortCol, + sortWith, } from '~/core/database/query'; import { Privileges } from '../authorization'; import { @@ -287,24 +289,7 @@ export class ProjectRepository extends CommonRepository { .match(requestingUser(session)) .apply(projectListFilter(input)) .apply(this.privileges.for(session, IProject).filterToReadable()) - .apply( - sorting(IProject, input, { - sensitivity: (query) => - query - .apply( - input.filter.sensitivity ? undefined : matchProjectSens('node'), - ) - .return<{ sortValue: string }>('sensitivity as sortValue'), - engagements: (query) => - query - .match([ - node('node'), - relation('out', '', 'engagement'), - node('engagement', 'LanguageEngagement'), - ]) - .return<{ sortValue: number }>('count(engagement) as sortValue'), - }), - ) + .apply(sortWith(projectSorters, input)) .apply(paginate(input, this.hydrate(session.userId))) .first(); return result!; // result from paginate() will always have 1 row. @@ -315,3 +300,18 @@ export class ProjectRepository extends CommonRepository { return this.getConstraintsFor(IProject); } } + +export const projectSorters = defineSorters(IProject, { + sensitivity: (query) => + query + .apply(matchProjectSens('node', 'sortValue')) + .return('sortValue'), + engagements: (query) => + query + .match([ + node('node'), + relation('out', '', 'engagement'), + node('engagement', 'LanguageEngagement'), + ]) + .return('count(engagement) as sortValue'), +}); diff --git a/src/core/database/query/match-project-based-props.ts b/src/core/database/query/match-project-based-props.ts index 5f46ad7092..d6ab67b8e8 100644 --- a/src/core/database/query/match-project-based-props.ts +++ b/src/core/database/query/match-project-based-props.ts @@ -100,7 +100,10 @@ export const matchProjectScopedRoles = ); export const matchProjectSens = - (projectVar = 'project') => + ( + projectVar = 'project', + output: Output = 'sensitivity' as Output, + ) => (query: Query) => query.comment`matchProjectSens()`.subQuery((sub) => sub @@ -114,7 +117,7 @@ export const matchProjectSens = relation('out', '', 'sensitivity', ACTIVE), node('projSens', 'Property'), ]) - .return('projSens.value as sensitivity') + .return(`projSens.value as ${output}`) .union() .with(projectVar) // import .with(projectVar) // needed for where clause @@ -134,7 +137,7 @@ export const matchProjectSens = .orderBy(rankSens('langSens.value'), 'DESC') // Prevent single row with project from expanding to more here via multiple engagement matches .raw('LIMIT 1') - .return(coalesce('langSens.value', '"High"').as('sensitivity')) + .return(coalesce('langSens.value', '"High"').as(output)) // If the cardinality of this subquery is zero, no rows will be returned at all. // So, if no projects are matched (optional matching), we still need to have a cardinality > 0 in order to continue // https://neo4j.com/developer/kb/conditional-cypher-execution/#_the_subquery_must_return_a_row_for_the_outer_query_to_continue @@ -143,7 +146,7 @@ export const matchProjectSens = .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<{ sensitivity: Sensitivity }>('"High" as sensitivity'), + .return>(`"High" as ${output}`), ); export const matchUserGloballyScopedRoles = From 6f4975976aa0e53520b1c333a75869b48b47df51 Mon Sep 17 00:00:00 2001 From: Carson Full Date: Sat, 1 Jun 2024 00:26:25 -0500 Subject: [PATCH 05/13] Migrate more to new style --- src/components/engagement/engagement.repository.ts | 7 +++++-- src/components/language/language.repository.ts | 7 +++++-- src/components/location/location.repository.ts | 9 ++++++--- src/components/user/user.repository.ts | 7 +++++-- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/components/engagement/engagement.repository.ts b/src/components/engagement/engagement.repository.ts index 630d73401a..05a4a04e21 100644 --- a/src/components/engagement/engagement.repository.ts +++ b/src/components/engagement/engagement.repository.ts @@ -23,6 +23,7 @@ import { coalesce, createNode, createRelationships, + defineSorters, filter, INACTIVE, matchChangesetAndChangedProps, @@ -31,7 +32,7 @@ import { oncePerProject, paginate, requestingUser, - sorting, + sortWith, whereNotDeletedInChangeset, } from '~/core/database/query'; import { Privileges } from '../authorization'; @@ -379,7 +380,7 @@ export class EngagementRepository extends CommonRepository { wrapContext: oncePerProject, }), ) - .apply(sorting(IEngagement, input)) + .apply(sortWith(engagementSorters, input)) .apply(paginate(input, this.hydrate(session, viewOfChangeset(changeset)))) .first(); return result!; // result from paginate() will always have 1 row. @@ -514,3 +515,5 @@ export class EngagementRepository extends CommonRepository { return this.getConstraintsFor(IEngagement); } } + +export const engagementSorters = defineSorters(IEngagement, {}); diff --git a/src/components/language/language.repository.ts b/src/components/language/language.repository.ts index 41a5d1e5c5..699266be41 100644 --- a/src/components/language/language.repository.ts +++ b/src/components/language/language.repository.ts @@ -24,6 +24,7 @@ import { collect, createNode, createRelationships, + defineSorters, exp, filter, matchChangesetAndChangedProps, @@ -35,7 +36,7 @@ import { paginate, rankSens, requestingUser, - sorting, + sortWith, variable, } from '~/core/database/query'; import { ProjectStatus } from '../project/dto'; @@ -245,7 +246,7 @@ export class LanguageRepository extends DtoRepository< wrapContext: oncePerProject, }), ) - .apply(sorting(Language, input)) + .apply(sortWith(languageSorters, input)) .apply(paginate(input, this.hydrate(session))) .first(); return result!; // result from paginate() will always have 1 row. @@ -322,3 +323,5 @@ export class LanguageRepository extends DtoRepository< ); } } + +export const languageSorters = defineSorters(Language, {}); diff --git a/src/components/location/location.repository.ts b/src/components/location/location.repository.ts index 1070fff4f0..1d98277a0a 100644 --- a/src/components/location/location.repository.ts +++ b/src/components/location/location.repository.ts @@ -14,10 +14,11 @@ import { ACTIVE, createNode, createRelationships, + defineSorters, matchProps, merge, paginate, - sorting, + sortWith, } from '~/core/database/query'; import { FileService } from '../file'; import { FileId } from '../file/dto'; @@ -160,7 +161,7 @@ export class LocationRepository extends DtoRepository(Location) { const result = await this.db .query() .matchNode('node', 'Location') - .apply(sorting(Location, input)) + .apply(sortWith(locationSorters, input)) .apply(paginate(input, this.hydrate())) .first(); return result!; // result from paginate() will always have 1 row. @@ -216,7 +217,7 @@ export class LocationRepository extends DtoRepository(Location) { relation('in', '', rel, ACTIVE), node(`${label.toLowerCase()}`, label, { id }), ]) - .apply(sorting(Location, input)) + .apply(sortWith(locationSorters, input)) .apply(paginate(input, this.hydrate())) .first(); return result!; // result from paginate() will always have 1 row. @@ -231,3 +232,5 @@ export class LocationRepository extends DtoRepository(Location) { return !!result; } } + +export const locationSorters = defineSorters(Location, {}); diff --git a/src/components/user/user.repository.ts b/src/components/user/user.repository.ts index f87dc58009..99242e1d26 100644 --- a/src/components/user/user.repository.ts +++ b/src/components/user/user.repository.ts @@ -16,13 +16,14 @@ import { createNode, createProperty, deactivateProperty, + defineSorters, filter, matchProps, merge, paginate, property, requestingUser, - sorting, + sortWith, } from '~/core/database/query'; import { AssignOrganizationToUser, @@ -207,7 +208,7 @@ export class UserRepository extends DtoRepository( }), ) .apply(this.privileges.forUser(session).filterToReadable()) - .apply(sorting(User, input)) + .apply(sortWith(userSorters, input)) .apply(paginate(input, this.hydrate(session.userId))) .first(); return result!; // result from paginate() will always have 1 row. @@ -342,3 +343,5 @@ export class UserRepository extends DtoRepository( return this.hydrate(session); } } + +export const userSorters = defineSorters(User, {}); From cb7091c1c3b5af8256f1d850120a75c30e05ebda Mon Sep 17 00:00:00 2001 From: Carson Full Date: Sat, 1 Jun 2024 00:30:37 -0500 Subject: [PATCH 06/13] Add engagement sorters for sensitivity & nesting to its project & language --- .../engagement/engagement.repository.ts | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/components/engagement/engagement.repository.ts b/src/components/engagement/engagement.repository.ts index 05a4a04e21..923f6b451d 100644 --- a/src/components/engagement/engagement.repository.ts +++ b/src/components/engagement/engagement.repository.ts @@ -27,17 +27,21 @@ import { filter, INACTIVE, matchChangesetAndChangedProps, + matchProjectSens, matchPropsAndProjectSensAndScopedRoles, merge, oncePerProject, paginate, requestingUser, + SortCol, sortWith, whereNotDeletedInChangeset, } from '~/core/database/query'; import { Privileges } from '../authorization'; import { FileId } from '../file/dto'; +import { languageSorters } from '../language/language.repository'; import { ProjectType } from '../project/dto'; +import { projectSorters } from '../project/project.repository'; import { CreateInternshipEngagement, CreateLanguageEngagement, @@ -516,4 +520,28 @@ export class EngagementRepository extends CommonRepository { } } -export const engagementSorters = defineSorters(IEngagement, {}); +export const engagementSorters = defineSorters(IEngagement, { + sensitivity: (query) => + query + .match([node('project'), relation('out', '', 'engagement'), node('node')]) + .apply(matchProjectSens()) + .return<{ sortValue: unknown }>('sensitivity as sortValue'), + // eslint-disable-next-line @typescript-eslint/naming-convention + 'language.*': (query, input) => + query + .with('node as eng') + .match([node('eng'), relation('out', '', 'language'), node('node')]) + .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'), + // eslint-disable-next-line @typescript-eslint/naming-convention + 'project.*': (query, input) => + query + .with('node as eng') + .match([node('eng'), relation('in', '', 'engagement'), node('node')]) + .apply(sortWith(projectSorters, input)), +}); From 401efcd8d8426317574286750690600d8ac7b70d Mon Sep 17 00:00:00 2001 From: Carson Full Date: Sat, 1 Jun 2024 00:33:13 -0500 Subject: [PATCH 07/13] Add engagement sorter for project & language/intern name --- .../engagement/engagement.repository.ts | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/src/components/engagement/engagement.repository.ts b/src/components/engagement/engagement.repository.ts index 923f6b451d..6cf81682d6 100644 --- a/src/components/engagement/engagement.repository.ts +++ b/src/components/engagement/engagement.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { mapValues, simpleSwitch } from '@seedcompany/common'; +import { cleanJoin, mapValues, simpleSwitch } from '@seedcompany/common'; import { inArray, node, Node, Query, relation } from 'cypher-query-builder'; import { difference, pickBy } from 'lodash'; import { DateTime } from 'luxon'; @@ -521,6 +521,18 @@ export class EngagementRepository extends CommonRepository { } export const engagementSorters = defineSorters(IEngagement, { + nameProjectFirst: (query) => + query + .apply(matchNames) + .return( + multiPropsAsSortString(['projectName', 'languageName', 'dfn', 'dln']), + ), + nameProjectLast: (query) => + query + .apply(matchNames) + .return( + multiPropsAsSortString(['languageName', 'dfn', 'dln', 'projectName']), + ), sensitivity: (query) => query .match([node('project'), relation('out', '', 'engagement'), node('node')]) @@ -545,3 +557,37 @@ export const engagementSorters = defineSorters(IEngagement, { .match([node('eng'), relation('in', '', 'engagement'), node('node')]) .apply(sortWith(projectSorters, input)), }); + +const matchNames = (query: Query) => + query + .match([ + node('project'), + relation('out', '', 'name', ACTIVE), + node('projectName', 'Property'), + ]) + .optionalMatch([ + node('node'), + relation('out', '', 'language'), + node('', 'Language'), + relation('out', '', 'name', ACTIVE), + node('languageName', 'Property'), + ]) + .optionalMatch([ + [node('node'), relation('out', '', 'intern'), node('intern', 'User')], + [ + node('intern'), + relation('out', '', 'displayFirstName', ACTIVE), + node('dfn', 'Property'), + ], + [ + node('intern'), + relation('out', '', 'displayLastName', ACTIVE), + node('dln', 'Property'), + ], + ]); + +const multiPropsAsSortString = (props: string[]) => + cleanJoin( + ' + ', + props.map((prop) => `coalesce(${prop}.value, "")`), + ) + ' as sortValue'; From 4133c268bd4a95fe7af10f6da4c489661e9a81a7 Mon Sep 17 00:00:00 2001 From: Carson Full Date: Sat, 1 Jun 2024 00:43:18 -0500 Subject: [PATCH 08/13] Add language.ethnologue sorter --- src/components/language/language.repository.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/components/language/language.repository.ts b/src/components/language/language.repository.ts index 699266be41..f28f703426 100644 --- a/src/components/language/language.repository.ts +++ b/src/components/language/language.repository.ts @@ -42,6 +42,7 @@ import { import { ProjectStatus } from '../project/dto'; import { CreateLanguage, + EthnologueLanguage, Language, LanguageListInput, UpdateLanguage, @@ -324,4 +325,17 @@ export class LanguageRepository extends DtoRepository< } } -export const languageSorters = defineSorters(Language, {}); +export const languageSorters = defineSorters(Language, { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'ethnologue.*': (query, input) => + query + .with('node as lang') + .match([ + node('lang'), + relation('out', '', 'ethnologue'), + node('node', 'EthnologueLanguage'), + ]) + .apply(sortWith(ethnologueSorters, input)), +}); + +const ethnologueSorters = defineSorters(EthnologueLanguage, {}); From 11313b91632adc78bf213e8b22cd364d71ee5804 Mon Sep 17 00:00:00 2001 From: Carson Full Date: Sat, 1 Jun 2024 00:51:55 -0500 Subject: [PATCH 09/13] Add project.primaryLocation sorter --- src/components/project/project.repository.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/components/project/project.repository.ts b/src/components/project/project.repository.ts index adfb681f9d..de64be3384 100644 --- a/src/components/project/project.repository.ts +++ b/src/components/project/project.repository.ts @@ -27,6 +27,7 @@ import { sortWith, } from '~/core/database/query'; import { Privileges } from '../authorization'; +import { locationSorters } from '../location/location.repository'; import { CreateProject, IProject, @@ -314,4 +315,19 @@ export const projectSorters = defineSorters(IProject, { node('engagement', 'LanguageEngagement'), ]) .return('count(engagement) as sortValue'), + // eslint-disable-next-line @typescript-eslint/naming-convention + 'primaryLocation.*': (query, input) => + query + .with('node as proj') + .match([ + node('proj'), + relation('out', '', 'primaryLocation', ACTIVE), + node('node'), + ]) + .apply(sortWith(locationSorters, input)) + .union() + .with('node') + .with('node as proj') + .raw('where not exists((node)-[:primaryLocation { active: true }]->())') + .return('null as sortValue'), }); From b854bbe119f5949553d61a0578ee255cf291fcc1 Mon Sep 17 00:00:00 2001 From: Carson Full Date: Mon, 3 Jun 2024 08:43:55 -0500 Subject: [PATCH 10/13] Support merging/extending sorters --- src/core/database/query/sorting.ts | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/core/database/query/sorting.ts b/src/core/database/query/sorting.ts index e31b5b8f40..cc55fef0d7 100644 --- a/src/core/database/query/sorting.ts +++ b/src/core/database/query/sorting.ts @@ -100,14 +100,23 @@ export const sortWith = ( * // prefix before matching the nested sorters. * .apply(sortWith(userSorters, input)) * }); + * + * // Sorters can "extend" others too. + * // Defined sorters have their declared matchers exposed on the `matchers` property. + * // So they can be spread/picked/etc. into a new object. + * // For example, say Manager extends User, and we want to add some sorters + * // for that type but keep the User ones as well. + * const managerSorters = defineSorters(Manager, { + * ...userSorters.matchers, + * // Add some new ones + * }); * ``` */ -export const defineSorters = - >( - resource: TResourceStatic, - matchers: SortMatchers>, - ) => - ({ sort, order }: Sort>) => { +export const defineSorters = >( + resource: TResourceStatic, + matchers: SortMatchers>, +) => { + const fn = ({ sort, order }: Sort>) => { const transformer = getDbSortTransformer(resource, sort) ?? identity; const common = { sort, order, transformer }; @@ -129,6 +138,9 @@ export const defineSorters = const matcher = (isBaseNodeProp ? matchBasePropSort : matchPropSort)(sort); return { ...common, matcher }; }; + fn.matchers = matchers; + return fn; +}; const matchPropSort = (prop: string) => (query: Query) => query From 67739e208552cbd226c2175878187fcd9391907b Mon Sep 17 00:00:00 2001 From: Carson Full Date: Mon, 3 Jun 2024 08:44:14 -0500 Subject: [PATCH 11/13] Define sorters for PeriodicReports & ProgressReports --- .../periodic-report.repository.ts | 18 ++++++++++++++++-- ...-extra-for-periodic-interface.repository.ts | 8 +++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/components/periodic-report/periodic-report.repository.ts b/src/components/periodic-report/periodic-report.repository.ts index de460f0358..e552f7a28c 100644 --- a/src/components/periodic-report/periodic-report.repository.ts +++ b/src/components/periodic-report/periodic-report.repository.ts @@ -23,6 +23,7 @@ import { ACTIVE, createNode, createRelationships, + defineSorters, deleteBaseNode, filter, matchPropsAndProjectSensAndScopedRoles, @@ -33,8 +34,14 @@ import { Variable, } from '~/core/database/query'; import { File } from '../file/dto'; -import { ProgressReportStatus as ProgressStatus } from '../progress-report/dto'; -import { ProgressReportExtraForPeriodicInterfaceRepository } from '../progress-report/progress-report-extra-for-periodic-interface.repository'; +import { + ProgressReport, + ProgressReportStatus as ProgressStatus, +} from '../progress-report/dto'; +import { + ProgressReportExtraForPeriodicInterfaceRepository, + progressReportExtrasSorters, +} from '../progress-report/progress-report-extra-for-periodic-interface.repository'; import { IPeriodicReport, MergePeriodicReports, @@ -445,3 +452,10 @@ export class PeriodicReportRepository extends DtoRepository< ); } } + +export const periodicReportSorters = defineSorters(IPeriodicReport, {}); + +export const progressReportSorters = defineSorters(ProgressReport, { + ...periodicReportSorters.matchers, + ...progressReportExtrasSorters.matchers, +}); 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 53ed34899e..38aa4b44a6 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 @@ -1,5 +1,9 @@ import { Injectable } from '@nestjs/common'; -import { CreateNodeOptions, QueryFragment } from '~/core/database/query'; +import { + CreateNodeOptions, + defineSorters, + QueryFragment, +} from '~/core/database/query'; import { MergePeriodicReports } from '../periodic-report/dto'; import { ProgressReport, ProgressReportStatus as Status } from './dto'; @@ -23,3 +27,5 @@ export class ProgressReportExtraForPeriodicInterfaceRepository { return (query) => query.return('{} as extra'); } } + +export const progressReportExtrasSorters = defineSorters(ProgressReport, {}); From fb4e1fbdc2e3836d6ed89cd447fec85908bd4e2b Mon Sep 17 00:00:00 2001 From: Carson Full Date: Sat, 1 Jun 2024 10:32:23 -0500 Subject: [PATCH 12/13] Sort `ProgressReport.status` by index --- .../progress-report/dto/progress-report.entity.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/progress-report/dto/progress-report.entity.ts b/src/components/progress-report/dto/progress-report.entity.ts index 3cd21aeb93..605959cdda 100644 --- a/src/components/progress-report/dto/progress-report.entity.ts +++ b/src/components/progress-report/dto/progress-report.entity.ts @@ -2,11 +2,13 @@ import { Field, ObjectType } from '@nestjs/graphql'; import { keys as keysOf } from 'ts-transformer-keys'; import { Calculated, + DbSort, parentIdMiddleware, ResourceRelationsShape, SecuredProperty, SecuredProps, } from '~/common'; +import { sortingForEnumIndex } from '~/core/database/query'; import { BaseNode } from '~/core/database/results'; import { e } from '~/core/edgedb'; import { RegisterResource } from '~/core/resources'; @@ -15,7 +17,10 @@ import { DefinedFile } from '../../file/dto'; import { IPeriodicReport } from '../../periodic-report/dto/periodic-report.dto'; import { ProgressReportCommunityStory } from './community-stories.dto'; import { ProgressReportHighlight } from './highlights.dto'; -import { SecuredProgressReportStatus as SecuredStatus } from './progress-report-status.enum'; +import { + SecuredProgressReportStatus as SecuredStatus, + ProgressReportStatus as Status, +} from './progress-report-status.enum'; import { ProgressReportTeamNews } from './team-news.dto'; @RegisterResource({ db: e.ProgressReport }) @@ -46,6 +51,7 @@ export class ProgressReport extends IPeriodicReport { middleware: [parentIdMiddleware], }) @Calculated() + @DbSort(sortingForEnumIndex(Status)) readonly status: SecuredStatus; } From 91c1d3764dac50cf0937883bd8840701305db5ae Mon Sep 17 00:00:00 2001 From: Carson Full Date: Sat, 1 Jun 2024 10:34:13 -0500 Subject: [PATCH 13/13] Add engagement.currentProgressReportDue.* sorter --- .../engagement/engagement.repository.ts | 26 +++++++++++ .../periodic-report.repository.ts | 46 ++++++++++--------- 2 files changed, 51 insertions(+), 21 deletions(-) diff --git a/src/components/engagement/engagement.repository.ts b/src/components/engagement/engagement.repository.ts index 6cf81682d6..386c67f308 100644 --- a/src/components/engagement/engagement.repository.ts +++ b/src/components/engagement/engagement.repository.ts @@ -40,6 +40,10 @@ import { import { Privileges } from '../authorization'; import { FileId } from '../file/dto'; import { languageSorters } from '../language/language.repository'; +import { + matchCurrentDue, + progressReportSorters, +} from '../periodic-report/periodic-report.repository'; import { ProjectType } from '../project/dto'; import { projectSorters } from '../project/project.repository'; import { @@ -556,6 +560,28 @@ export const engagementSorters = defineSorters(IEngagement, { .with('node as eng') .match([node('eng'), relation('in', '', 'engagement'), node('node')]) .apply(sortWith(projectSorters, input)), + // eslint-disable-next-line @typescript-eslint/naming-convention + 'currentProgressReportDue.*': (query, input) => + query + .subQuery('node', (sub) => + sub + .with('node as parent') + .apply(matchCurrentDue(undefined, 'Progress')) + .return('collect(node) as reports'), + ) + .subQuery('reports', (sub) => + sub + .with('reports') + .raw('where size(reports) = 0') + .return('null as sortValue') + .union() + .with('reports') + .with('reports') + .raw('where size(reports) <> 0') + .raw('unwind reports as node') + .apply(sortWith(progressReportSorters, input)), + ) + .return('sortValue'), }); const matchNames = (query: Query) => diff --git a/src/components/periodic-report/periodic-report.repository.ts b/src/components/periodic-report/periodic-report.repository.ts index e552f7a28c..30bf69edce 100644 --- a/src/components/periodic-report/periodic-report.repository.ts +++ b/src/components/periodic-report/periodic-report.repository.ts @@ -209,26 +209,7 @@ export class PeriodicReportRepository extends DtoRepository< } matchCurrentDue(parentId: ID | Variable, reportType: ReportType) { - return (query: Query) => - query.comment`matchCurrentDue()` - .match([ - [ - node('baseNode', 'BaseNode', { id: parentId }), - relation('out', '', 'report', ACTIVE), - node('node', `${reportType}Report`), - relation('out', '', 'end', ACTIVE), - node('end', 'Property'), - ], - [ - node('node'), - relation('out', '', 'start', ACTIVE), - node('start', 'Property'), - ], - ]) - .raw(`WHERE end.value < date()`) - .with('node, start') - .orderBy('start.value', 'desc') - .limit(1); + return matchCurrentDue(parentId, reportType); } async getByDate( @@ -280,7 +261,7 @@ export class PeriodicReportRepository extends DtoRepository< const res = await this.db .query() .match([ - node('baseNode', 'BaseNode', { id: parentId }), + node('parent', 'BaseNode', { id: parentId }), relation('out', '', 'report', ACTIVE), node('node', `${reportType}Report`), relation('out', '', 'end', ACTIVE), @@ -453,6 +434,29 @@ export class PeriodicReportRepository extends DtoRepository< } } +export const matchCurrentDue = + (parentId: ID | Variable | undefined, reportType: ReportType) => + (query: Query) => + query.comment`matchCurrentDue()` + .match([ + [ + node('parent', 'BaseNode', parentId ? { id: parentId } : undefined), + relation('out', '', 'report', ACTIVE), + node('node', `${reportType}Report`), + relation('out', '', 'end', ACTIVE), + node('end', 'Property'), + ], + [ + node('node'), + relation('out', '', 'start', ACTIVE), + node('start', 'Property'), + ], + ]) + .raw(`WHERE end.value < date()`) + .with('node, start') + .orderBy('start.value', 'desc') + .limit(1); + export const periodicReportSorters = defineSorters(IPeriodicReport, {}); export const progressReportSorters = defineSorters(ProgressReport, {