|
| 1 | +import { Inject, Injectable } from '@nestjs/common'; |
| 2 | +import { |
| 3 | + isNotFalsy, |
| 4 | + many, |
| 5 | + Many, |
| 6 | + mapKeys, |
| 7 | + Nil, |
| 8 | + setOf, |
| 9 | +} from '@seedcompany/common'; |
| 10 | +import { LazyGetter as Once } from 'lazy-get-decorator'; |
| 11 | +import { lowerCase } from 'lodash'; |
| 12 | +import { AbstractClass } from 'type-fest'; |
| 13 | +import { |
| 14 | + EnhancedResource, |
| 15 | + ID, |
| 16 | + NotFoundException, |
| 17 | + PaginatedListType, |
| 18 | + PaginationInput, |
| 19 | + ResourceShape, |
| 20 | + SortablePaginationInput, |
| 21 | +} from '~/common'; |
| 22 | +import { ResourceLike } from '~/core'; |
| 23 | +import { Privileges } from '../../components/authorization'; |
| 24 | +import { getChanges } from '../database/changes'; |
| 25 | +import { privileges } from '../database/dto.repository'; |
| 26 | +import { CommonRepository } from './common.repository'; |
| 27 | +import { InsertShape } from './generated-client/insert'; |
| 28 | +import { $expr_PathNode, $linkPropify } from './generated-client/path'; |
| 29 | +import { |
| 30 | + $expr_Select, |
| 31 | + objectTypeToSelectShape, |
| 32 | + SelectFilterExpression, |
| 33 | + SelectModifiers, |
| 34 | +} from './generated-client/select'; |
| 35 | +import { UpdateShape } from './generated-client/update'; |
| 36 | +import { $, e } from './reexports'; |
| 37 | + |
| 38 | +/** |
| 39 | + * A repository for a simple DTO. This provides a few methods out of the box. |
| 40 | + */ |
| 41 | +export const RepoFor = < |
| 42 | + TResourceStatic extends ResourceShape<any>, |
| 43 | + DBType extends (TResourceStatic['DB'] & {})['__element__'], |
| 44 | + HydratedShape extends objectTypeToSelectShape<DBType> = (TResourceStatic['DB'] & {})['*'], |
| 45 | +>( |
| 46 | + resourceIn: TResourceStatic, |
| 47 | + options: { |
| 48 | + hydrate?: ShapeFn<$.TypeSet<DBType>, HydratedShape>; |
| 49 | + } = {}, |
| 50 | +) => { |
| 51 | + type Dto = $.computeObjectShape<DBType['__pointers__'], HydratedShape>; |
| 52 | + |
| 53 | + const resource = EnhancedResource.of(resourceIn); |
| 54 | + |
| 55 | + const hydrate = e.shape( |
| 56 | + resource.db, |
| 57 | + (options.hydrate ?? ((obj: any) => obj['*'])) as any, |
| 58 | + ) as (scope: unknown) => HydratedShape; |
| 59 | + |
| 60 | + @Injectable() |
| 61 | + abstract class Repository extends CommonRepository { |
| 62 | + static customize<Customized extends Repository>( |
| 63 | + customizer: (cls: typeof Repository) => AbstractClass<Customized>, |
| 64 | + ): AbstractClass< |
| 65 | + Customized & Omit<DefaultDtoRepository, keyof Customized> |
| 66 | + > { |
| 67 | + const customizedClass = customizer(Repository); |
| 68 | + const customMethodNames = setOf( |
| 69 | + Object.getOwnPropertyNames(customizedClass.prototype), |
| 70 | + ); |
| 71 | + const nonDeclaredDefaults = mapKeys( |
| 72 | + Object.getOwnPropertyDescriptors(DefaultDtoRepository.prototype), |
| 73 | + (name, _, { SKIP }) => |
| 74 | + typeof name === 'string' && customMethodNames.has(name) ? SKIP : name, |
| 75 | + ).asRecord; |
| 76 | + Object.defineProperties(customizedClass.prototype, nonDeclaredDefaults); |
| 77 | + |
| 78 | + return customizedClass as any; |
| 79 | + } |
| 80 | + static withDefaults() { |
| 81 | + return DefaultDtoRepository; |
| 82 | + } |
| 83 | + |
| 84 | + @Inject(Privileges) |
| 85 | + protected readonly [privileges]: Privileges; |
| 86 | + protected readonly resource = resource; |
| 87 | + protected readonly hydrate = hydrate; |
| 88 | + |
| 89 | + @Once() |
| 90 | + get privileges() { |
| 91 | + return this[privileges].forResource(resource); |
| 92 | + } |
| 93 | + |
| 94 | + getActualChanges = getChanges(resource.type); |
| 95 | + |
| 96 | + // region List Helpers |
| 97 | + |
| 98 | + protected listFilters( |
| 99 | + _scope: ScopeOf<$.TypeSet<DBType>>, |
| 100 | + _input: any, |
| 101 | + ): Many<SelectFilterExpression | false | Nil> { |
| 102 | + return []; |
| 103 | + } |
| 104 | + |
| 105 | + protected orderBy<Scope extends $expr_PathNode>( |
| 106 | + scope: ScopeOf<$.TypeSet<DBType>>, |
| 107 | + input: SortablePaginationInput, |
| 108 | + ) { |
| 109 | + // TODO Validate this is a valid sort key |
| 110 | + const sortKey = input.sort as keyof Scope['*']; |
| 111 | + return { |
| 112 | + expression: scope[sortKey], |
| 113 | + direction: input.order, |
| 114 | + } as const; |
| 115 | + } |
| 116 | + |
| 117 | + protected async paginate( |
| 118 | + listOfAllQuery: $expr_Select< |
| 119 | + $.TypeSet<$.ObjectType<DBType['__name__']>, $.Cardinality.Many> |
| 120 | + >, |
| 121 | + input: PaginationInput, |
| 122 | + ) { |
| 123 | + const thisPage = e.select(listOfAllQuery as any, () => ({ |
| 124 | + offset: (input.page - 1) * input.count, |
| 125 | + limit: input.count + 1, |
| 126 | + })); |
| 127 | + const items = e.select(thisPage, (obj) => ({ |
| 128 | + ...this.hydrate(obj), |
| 129 | + limit: input.count, |
| 130 | + })); |
| 131 | + const query = e.select({ |
| 132 | + items, |
| 133 | + total: e.count(listOfAllQuery), |
| 134 | + hasMore: e.op(e.count(thisPage), '>', input.count), |
| 135 | + }); |
| 136 | + |
| 137 | + const result = await this.db.run(query); |
| 138 | + return result as PaginatedListType<Dto>; |
| 139 | + } |
| 140 | + |
| 141 | + // endregion |
| 142 | + |
| 143 | + /** |
| 144 | + * Here for compatibility with the Neo4j version. |
| 145 | + * @deprecated this should be replaced with just error handling from a |
| 146 | + * failed insert, after we finish migration. |
| 147 | + */ |
| 148 | + async isUnique(value: string, fqn?: string) { |
| 149 | + const res = fqn ? await this.resources.getByEdgeDB(fqn) : resource; |
| 150 | + const query = e.select(e.Mixin.Named, (obj) => ({ |
| 151 | + filter: e.op( |
| 152 | + e.op(obj.name, '=', value), |
| 153 | + 'and', |
| 154 | + e.op(obj.__type__.name, '=', res.dbFQN as string), |
| 155 | + ), |
| 156 | + limit: 1, |
| 157 | + })); |
| 158 | + |
| 159 | + const found = await this.db.run(query); |
| 160 | + return found.length === 0; |
| 161 | + } |
| 162 | + |
| 163 | + async getBaseNodes(ids: readonly ID[], fqn?: ResourceLike) { |
| 164 | + return await super.getBaseNodes(ids, fqn ?? resource); |
| 165 | + } |
| 166 | + } |
| 167 | + |
| 168 | + class DefaultDtoRepository extends Repository { |
| 169 | + async readOne(id: ID) { |
| 170 | + const rows = await this.readMany([id]); |
| 171 | + if (!rows[0]) { |
| 172 | + throw new NotFoundException( |
| 173 | + `Could not find ${lowerCase(this.resource.name)}`, |
| 174 | + ); |
| 175 | + } |
| 176 | + return rows[0]; |
| 177 | + } |
| 178 | + |
| 179 | + async readMany(ids: readonly ID[]): Promise<readonly Dto[]> { |
| 180 | + const query = e.params({ ids: e.array(e.uuid) }, ({ ids }) => |
| 181 | + e.select(this.resource.db, (obj: any) => ({ |
| 182 | + ...(this.hydrate(obj) as any), |
| 183 | + filter: e.op(obj.id, 'in', e.array_unpack(ids)), |
| 184 | + })), |
| 185 | + ); |
| 186 | + const rows = await this.db.run(query, { ids }); |
| 187 | + return rows as readonly Dto[]; |
| 188 | + } |
| 189 | + |
| 190 | + async list(input: PaginationInput) { |
| 191 | + const all = e.select(this.resource.db, (obj: any) => { |
| 192 | + const filters = many(this.listFilters(obj, input)).filter(isNotFalsy); |
| 193 | + const filter = |
| 194 | + filters.length === 0 |
| 195 | + ? null |
| 196 | + : filters.length === 1 |
| 197 | + ? filters[0] |
| 198 | + : e.all(e.set(...filters)); |
| 199 | + return { |
| 200 | + ...(filter ? { filter } : {}), |
| 201 | + ...(input instanceof SortablePaginationInput |
| 202 | + ? { order_by: this.orderBy(obj, input as SortablePaginationInput) } |
| 203 | + : {}), |
| 204 | + }; |
| 205 | + }); |
| 206 | + return await this.paginate(all as any, input); |
| 207 | + } |
| 208 | + |
| 209 | + async create(input: Omit<InsertShape<DBType>, `@${string}`>): Promise<Dto> { |
| 210 | + const query = e.select( |
| 211 | + e.insert(this.resource.db, input as any), |
| 212 | + this.hydrate as any, |
| 213 | + ); |
| 214 | + return (await this.db.run(query)) as Dto; |
| 215 | + } |
| 216 | + |
| 217 | + async update( |
| 218 | + existing: Pick<Dto, 'id'>, |
| 219 | + input: UpdateShape<TResourceStatic['DB'] & {}>, |
| 220 | + ): Promise<Dto> { |
| 221 | + const query = e.select( |
| 222 | + e.update(this.resource.db, () => ({ |
| 223 | + filter_single: { id: existing.id } as any, |
| 224 | + set: input, |
| 225 | + })), |
| 226 | + this.hydrate as any, |
| 227 | + ); |
| 228 | + return (await this.db.run(query)) as Dto; |
| 229 | + } |
| 230 | + |
| 231 | + async delete(id: ID): Promise<void> { |
| 232 | + const query = e.delete(this.resource.db, () => ({ |
| 233 | + filter_single: { id } as any, |
| 234 | + })); |
| 235 | + await this.db.run(query); |
| 236 | + } |
| 237 | + } |
| 238 | + |
| 239 | + return Repository; |
| 240 | +}; |
| 241 | + |
| 242 | +type ShapeFn< |
| 243 | + Expr extends $.ObjectTypeExpression, |
| 244 | + Shape extends objectTypeToSelectShape<Expr['__element__']> & |
| 245 | + SelectModifiers<Expr['__element__']>, |
| 246 | +> = (scope: ScopeOf<Expr>) => Readonly<Shape>; |
| 247 | + |
| 248 | +export type ScopeOf<Expr extends $.ObjectTypeExpression> = $.$scopify< |
| 249 | + Expr['__element__'] |
| 250 | +> & |
| 251 | + $linkPropify<{ |
| 252 | + [k in keyof Expr]: k extends '__cardinality__' |
| 253 | + ? $.Cardinality.One |
| 254 | + : Expr[k]; |
| 255 | + }>; |
0 commit comments