From 17a3a40282ae2a44dfeb8bcde98d62739312461e Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Sat, 20 Jun 2026 23:28:41 +0200 Subject: [PATCH] refactor(cvg-176): delete dead named-entity-resolver factory (#83) --- .../named-entity-resolver.factory.spec.ts | 186 ------------- .../base/named-entity-resolver.factory.ts | 256 ------------------ 2 files changed, 442 deletions(-) delete mode 100644 apps/api/src/modules/base/__tests__/named-entity-resolver.factory.spec.ts delete mode 100644 apps/api/src/modules/base/named-entity-resolver.factory.ts diff --git a/apps/api/src/modules/base/__tests__/named-entity-resolver.factory.spec.ts b/apps/api/src/modules/base/__tests__/named-entity-resolver.factory.spec.ts deleted file mode 100644 index dff5f8a..0000000 --- a/apps/api/src/modules/base/__tests__/named-entity-resolver.factory.spec.ts +++ /dev/null @@ -1,186 +0,0 @@ -import "reflect-metadata"; -import { - ClockService, - type Factory, - NamedEntity, - type NamedEntityService, - type PaginationResult, - PaginationService, -} from "@cv/core"; -import { ArgsType } from "@nestjs/graphql"; -import { describe, expect, it } from "vitest"; -import { createNamedEntityResolver } from "../named-entity-resolver.factory"; -import { createNamedGraphQLType } from "../named-graphql-type.factory"; - -// Each test uses a distinct entity class so their @ObjectType-derived -// schema names don't collide in @nestjs/graphql's global type metadata -// storage. createNamedGraphQLType registers a singleton per name. -class FakeAlpha extends NamedEntity {} -class FakeBeta extends NamedEntity {} -class FakeCategory extends NamedEntity {} - -const Alpha = createNamedGraphQLType("FakeAlpha", FakeAlpha); -const Beta = createNamedGraphQLType("FakeBeta", FakeBeta); -const Category = createNamedGraphQLType( - "FakeCategory", - FakeCategory, -); - -@ArgsType() -class CustomConnectionArgs {} - -class FakeService { - findMany = async (): Promise => []; - count = async (): Promise => 0; - findByIdOrFail = async (id: string): Promise => - new FakeAlpha(id, "n", new Date(), new Date()); - save = async (entity: NamedEntity): Promise => entity; - destroy = async (_entity: NamedEntity): Promise => undefined; -} - -class FakeFactory implements Factory { - create(data: { name: string }): NamedEntity { - return new FakeAlpha("new-id", data.name, new Date(), new Date()); - } -} - -const RESOLVER_NAME = "graphql:resolver_name"; -const RESOLVER_TYPE = "graphql:resolver_type"; - -const collectResolverMethods = (cls: { - prototype: Record; -}): Record => { - const proto = cls.prototype; - const out: Record = {}; - for (const key of Object.getOwnPropertyNames(proto)) { - if (key === "constructor") { - continue; - } - const fn = proto[key]; - if (typeof fn !== "function") { - continue; - } - const type = - Reflect.getMetadata(RESOLVER_TYPE, fn) ?? - Reflect.getMetadata(RESOLVER_TYPE, proto, key); - const schemaName = - Reflect.getMetadata(RESOLVER_NAME, fn) ?? - Reflect.getMetadata(RESOLVER_NAME, proto, key); - if (typeof type === "string") { - out[key] = { type, schemaName }; - } - } - return out; -}; - -describe("createNamedEntityResolver", () => { - it("derives field names from `domainEntity.name` when `names` is omitted", () => { - const ResolverClass = createNamedEntityResolver({ - domainEntity: FakeAlpha, - gqlType: Alpha.Type, - gqlConnection: Alpha.Connection, - serviceToken: - FakeService as unknown as new () => NamedEntityService, - factoryToken: FakeFactory as unknown as new () => Factory< - FakeAlpha, - { name: string; description?: string } - >, - }) as unknown as { prototype: Record }; - - const methods = collectResolverMethods(ResolverClass); - - expect(methods).toMatchObject({ - list: { type: "Query", schemaName: "fakeAlphas" }, - byId: { type: "Query", schemaName: "fakeAlpha" }, - create: { type: "Mutation", schemaName: "createFakeAlpha" }, - update: { type: "Mutation", schemaName: "updateFakeAlpha" }, - delete: { type: "Mutation", schemaName: "deleteFakeAlpha" }, - }); - }); - - it("pluralises `y` endings as `ies` (FakeCategory => fakeCategories)", () => { - const ResolverClass = createNamedEntityResolver({ - domainEntity: FakeCategory, - gqlType: Category.Type, - gqlConnection: Category.Connection, - serviceToken: - FakeService as unknown as new () => NamedEntityService, - factoryToken: FakeFactory as unknown as new () => Factory< - FakeCategory, - { name: string; description?: string } - >, - }) as unknown as { prototype: Record }; - - const methods = collectResolverMethods(ResolverClass); - - expect(methods.list?.schemaName).toBe("fakeCategories"); - expect(methods.byId?.schemaName).toBe("fakeCategory"); - }); - - it("accepts per-field overrides when GraphQL name diverges from the domain class", () => { - const ResolverClass = createNamedEntityResolver({ - domainEntity: FakeBeta, - gqlType: Beta.Type, - gqlConnection: Beta.Connection, - gqlConnectionArgs: CustomConnectionArgs, - names: { - plural: "betaList", - }, - serviceToken: - FakeService as unknown as new () => NamedEntityService, - factoryToken: FakeFactory as unknown as new () => Factory< - FakeBeta, - { name: string; description?: string } - >, - }) as unknown as { prototype: Record }; - - const methods = collectResolverMethods(ResolverClass); - - expect(methods.list?.schemaName).toBe("betaList"); - expect(methods.byId?.schemaName).toBe("fakeBeta"); - expect(methods.create?.schemaName).toBe("createFakeBeta"); - }); - - it("instantiates and runs the generated list method against stubs", async () => { - const ResolverClass = createNamedEntityResolver({ - domainEntity: FakeAlpha, - gqlType: Alpha.Type, - gqlConnection: Alpha.Connection, - serviceToken: - FakeService as unknown as new () => NamedEntityService, - factoryToken: FakeFactory as unknown as new () => Factory< - FakeAlpha, - { name: string; description?: string } - >, - }); - - const buildResult: PaginationResult = { - edges: [], - pageInfo: { - hasNextPage: false, - hasPreviousPage: false, - startCursor: null, - endCursor: null, - }, - totalCount: 0, - }; - const paginationService = { - parsePaginationArgs: () => ({}), - buildPaginationResult: () => buildResult, - } as unknown as PaginationService; - const clock = { - now: () => new Date("2026-05-14T00:00:00Z"), - } as ClockService; - const service = new FakeService(); - const factory = new FakeFactory(); - - const instance = new ( - ResolverClass as new ( - ...args: unknown[] - ) => Record unknown> - )(service, factory, paginationService, clock); - - const result = await instance.list({}); - expect(result).toBeDefined(); - }); -}); diff --git a/apps/api/src/modules/base/named-entity-resolver.factory.ts b/apps/api/src/modules/base/named-entity-resolver.factory.ts deleted file mode 100644 index 0834225..0000000 --- a/apps/api/src/modules/base/named-entity-resolver.factory.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { JwtAuthGuard, VerifiedScopeGuard } from "@cv/auth"; -import { - Authorized, - AuthorizedResource, - type BaseEntity, - ClockService, - type User as DomainUser, - type Factory, - type NamedEntity, - type NamedEntityService, - type PaginationResult, - PaginationService, - SearchablePaginationArgs, -} from "@cv/core"; -import { Inject, type Type, UseGuards } from "@nestjs/common"; -import { Args, ArgsType, Mutation, Query, Resolver } from "@nestjs/graphql"; -import { CurrentUser } from "@/modules/current-user/current-user.decorator"; - -type NamedDto = { name: string; description?: string }; -type NamedUpdateDto = { name?: string; description?: string }; - -/** - * Default args shape for the factory's list query: `first`/`last`/`after`/ - * `before` from `BasePaginationArgs` plus `searchTerm` from - * `SearchablePaginationArgs`. Used when the caller doesn't pass a custom - * `gqlConnectionArgs`. Single shared concrete `@ArgsType` so the schema - * doesn't grow a distinct args type per `createNamedEntityResolver` call. - */ -@ArgsType() -export class DefaultNamedEntityConnectionArgs extends SearchablePaginationArgs {} - -/** - * Optional override for the update reconstruction step. Receives the loaded - * entity, the partial update DTO, and the clock so caller can produce a - * fresh domain instance. The default below handles the standard NamedEntity - * (id, name, createdAt, updatedAt, description). - */ -export type ReconstructFn = ( - current: TDomain, - dto: NamedUpdateDto, - clock: ClockService, -) => TDomain; - -/** - * GraphQL field names for the 5 generated methods. Every field is optional - * because the factory derives sensible defaults from `domainEntity.name`: - * - * - `domainEntity: Skill` => - * singular = "skill" - * plural = "skills" - * create* = "createSkill" - * update* = "updateSkill" - * delete* = "deleteSkill" - * - * Override per-field only when the GraphQL surface diverges from the domain - * class name. The repo convention is to keep them aligned, so the common - * call site omits `names` entirely. - */ -export interface NamedEntityNamesOverride { - singular?: string; - plural?: string; - createMutation?: string; - updateMutation?: string; - deleteMutation?: string; -} - -export interface NamedEntityResolverOptions< - TDomain extends NamedEntity, - TGqlType, - TGqlConnection, -> { - domainEntity: Type; - gqlType: Type & { fromDomain: (domain: TDomain) => TGqlType }; - gqlConnection: Type & { - fromPaginationResult: (result: PaginationResult) => TGqlConnection; - }; - serviceToken: Type>; - factoryToken: Type>; - /** Optional - defaults to `DefaultNamedEntityConnectionArgs`. */ - gqlConnectionArgs?: Type; - /** Optional - each field defaults to a camelCased `domainEntity.name`. */ - names?: NamedEntityNamesOverride; - reconstruct?: ReconstructFn; -} - -interface ResolvedNames { - singular: string; - plural: string; - createMutation: string; - updateMutation: string; - deleteMutation: string; -} - -const camelCase = (s: string): string => - s.length === 0 ? s : `${s[0]?.toLowerCase() ?? ""}${s.slice(1)}`; - -const pluralise = (s: string): string => - s.endsWith("y") ? `${s.slice(0, -1)}ies` : `${s}s`; - -const resolveNames = ( - entity: { name: string }, - overrides?: NamedEntityNamesOverride, -): ResolvedNames => { - const singularBase = camelCase(entity.name); - const pluralBase = pluralise(singularBase); - return { - singular: overrides?.singular ?? singularBase, - plural: overrides?.plural ?? pluralBase, - createMutation: overrides?.createMutation ?? `create${entity.name}`, - updateMutation: overrides?.updateMutation ?? `update${entity.name}`, - deleteMutation: overrides?.deleteMutation ?? `delete${entity.name}`, - }; -}; - -const defaultReconstruct = ( - DomainCtor: Type, -): ReconstructFn => { - return (current, dto, clock) => { - const next = new ( - DomainCtor as new ( - id: string, - name: string, - createdAt: Date, - updatedAt: Date, - description?: string, - ) => TDomain - )( - current.id, - dto.name ?? current.name, - current.createdAt, - clock.now(), - dto.description !== undefined ? dto.description : current.description, - ); - return next; - }; -}; - -/** - * Builds a `@Resolver(() => GqlType)`-decorated CRUD resolver for a - * `NamedEntity`. Replaces the 5-method boilerplate (list / byId+view / - * create / update / delete) shared by Skill / Role / Level / Company with a - * single factory call. - * - * The factory mirrors `createNamedGraphQLType` + `createConnection`: - * decorators are applied inside the factory body at definition time, so the - * resulting class participates in Nest's DI + GraphQL schema generation - * exactly like a hand-written resolver. Mutation / query field *names* are - * derived from `domainEntity.name` (camelCased / pluralised) so grep on the - * field name still hits the entity's home module. - * - * Auth uses the `@Authorized(...)` decorator path (interceptor-driven) for - * `view` / `update` / `delete` / `create`. Modules consuming the factory - * must register an `@AuthorizedEntityLoader(domainEntity)` provider. - */ -export function createNamedEntityResolver< - TDomain extends NamedEntity, - TGqlType, - TGqlConnection, ->( - opts: NamedEntityResolverOptions, -): Type { - const { domainEntity, gqlType, gqlConnection, serviceToken, factoryToken } = - opts; - const gqlConnectionArgs = - opts.gqlConnectionArgs ?? DefaultNamedEntityConnectionArgs; - const names = resolveNames(domainEntity, opts.names); - const reconstruct = opts.reconstruct ?? defaultReconstruct(domainEntity); - - @Resolver(() => gqlType) - @UseGuards(JwtAuthGuard, VerifiedScopeGuard) - class GeneratedNamedEntityResolver { - constructor( - @Inject(serviceToken) - readonly service: NamedEntityService, - @Inject(factoryToken) - readonly factory: Factory, - readonly paginationService: PaginationService, - readonly clock: ClockService, - ) {} - - @Query(() => gqlConnection, { name: names.plural }) - async list( - @Args({ type: () => gqlConnectionArgs }) - args: { searchTerm?: string | null } & Record = {}, - ): Promise { - const options = this.paginationService.parsePaginationArgs(args); - const filters = { searchTerm: args.searchTerm || undefined }; - const [items, totalCount] = await Promise.all([ - this.service.findMany(filters), - this.service.count(filters), - ]); - const result = this.paginationService.buildPaginationResult( - items, - totalCount, - options, - ); - return gqlConnection.fromPaginationResult(result); - } - - @Query(() => gqlType, { name: names.singular }) - @Authorized({ action: "view", entity: domainEntity }) - async byId(@AuthorizedResource() resource: TDomain): Promise { - return gqlType.fromDomain(resource); - } - - @Mutation(() => gqlType, { name: names.createMutation }) - @Authorized({ action: "create", entity: domainEntity }) - async create( - @Args("name") name: string, - @Args("description", { nullable: true }) description?: string, - ): Promise { - const dto: NamedDto = { name }; - if (description !== undefined) { - dto.description = description; - } - const entity = this.factory.create(dto); - const saved = await this.service.save(entity); - return gqlType.fromDomain(saved); - } - - @Mutation(() => gqlType, { name: names.updateMutation }) - @Authorized({ action: "update", entity: domainEntity }) - async update( - @AuthorizedResource() current: TDomain, - @Args("id") _id: string, - @Args("name", { nullable: true }) name?: string, - @Args("description", { nullable: true }) description?: string, - ): Promise { - const dto: NamedUpdateDto = {}; - if (name !== undefined) { - dto.name = name; - } - if (description !== undefined) { - dto.description = description; - } - const updated = reconstruct(current, dto, this.clock); - const saved = await this.service.save(updated); - return gqlType.fromDomain(saved); - } - - @Mutation(() => Boolean, { name: names.deleteMutation }) - @Authorized({ action: "delete", entity: domainEntity }) - async delete( - @AuthorizedResource() resource: TDomain, - @Args("id") _id: string, - @CurrentUser() _user: DomainUser, - ): Promise { - await this.service.destroy(resource); - return true; - } - } - - return GeneratedNamedEntityResolver as Type; -} - -export type { BaseEntity, NamedDto, NamedUpdateDto }; -- 2.51.2