From 738983ed707967ec1c3f7c031bcd04a0346ce1cd Mon Sep 17 00:00:00 2001 From: Kauan Guesser Date: Sun, 30 Aug 2026 00:27:31 -0300 Subject: [PATCH] feat: fully type CRUD field vocabularies --- .changeset/typed-crud-field-vocabularies.md | 19 ++ .../scripts/consumer-fixtures/crud-memory.ts | 5 +- README.md | 11 +- packages/crud-drizzle/README.md | 2 - .../crud-drizzle/src/bind-drizzle-crud.ts | 57 +++- packages/crud-drizzle/src/drizzle-adapter.ts | 36 ++- .../crud-drizzle/src/drizzle-predicate.ts | 29 +- packages/crud-drizzle/src/index.ts | 7 +- .../tests/drizzle-predicate.spec.ts | 10 +- packages/crud-drizzle/tests/drizzle-types.ts | 81 +++++- packages/crud-memory/README.md | 5 +- packages/crud-memory/src/bind-memory-crud.ts | 66 +++-- packages/crud-memory/src/index.ts | 1 + .../src/memory-crud-adapter.spec.ts | 12 +- .../crud-memory/src/memory-crud-adapter.ts | 71 +++-- packages/crud-memory/tests/memory-types.ts | 41 +++ packages/crud-prisma/README.md | 1 - packages/crud-prisma/src/bind-prisma-crud.ts | 57 +++- packages/crud-prisma/src/index.ts | 4 + packages/crud-prisma/src/prisma-adapter.ts | 76 ++++- packages/crud-prisma/src/prisma-predicate.ts | 4 +- packages/crud-prisma/tests/prisma-types.ts | 83 +++++- packages/crud-typeorm/README.md | 3 +- .../crud-typeorm/src/bind-typeorm-crud.ts | 100 ++++--- packages/crud-typeorm/src/index.ts | 2 + packages/crud-typeorm/src/typeorm-adapter.ts | 115 ++++++-- .../crud-typeorm/src/typeorm-predicate.ts | 27 +- .../tests/typeorm-adapter.spec.ts | 10 +- packages/crud-typeorm/tests/typeorm-types.ts | 77 ++++- .../crud-typeorm/tests/typeorm-upsert.spec.ts | 13 +- packages/crud/src/adapter/adapter.types.ts | 93 ++++-- packages/crud/src/adapter/binding.types.ts | 106 ++++--- packages/crud/src/adapter/index.ts | 3 + .../crud/src/controller/controller.factory.ts | 8 +- packages/crud/src/cursor/cursor-predicate.ts | 10 +- packages/crud/src/cursor/cursor.ts | 17 +- packages/crud/src/cursor/cursor.types.ts | 14 +- packages/crud/src/index.ts | 6 + packages/crud/src/query/predicate.ts | 16 +- packages/crud/src/query/query-parser.spec.ts | 2 + packages/crud/src/query/query-parser.ts | 29 +- packages/crud/src/query/query.types.ts | 63 +++-- packages/crud/src/relation/relation.types.ts | 20 +- packages/crud/src/resource/define-resource.ts | 44 ++- packages/crud/src/resource/resource.types.ts | 206 +++++++++----- packages/crud/src/runtime/crud-registry.ts | 4 +- packages/crud/src/runtime/crud.service.ts | 38 ++- packages/crud/src/runtime/runtime.types.ts | 19 +- packages/crud/src/testing/conformance.ts | 37 ++- packages/crud/tests/controller.spec.ts | 2 + packages/crud/tests/http-advanced.spec.ts | 14 +- packages/crud/tests/http-contract.spec.ts | 2 +- .../crud/tests/http-nested-upsert.spec.ts | 2 +- packages/crud/tests/module.spec.ts | 4 +- packages/crud/tests/openapi-contract.spec.ts | 6 + packages/crud/tests/projection.spec.ts | 7 +- packages/crud/tests/resource.spec.ts | 114 ++++++-- packages/crud/tests/service.spec.ts | 24 +- packages/crud/tests/support/core-fixtures.ts | 4 +- packages/crud/tests/types.fixture.ts | 265 +++++++++++++++--- tests/postgres/adapters.postgres.spec.ts | 16 +- 61 files changed, 1637 insertions(+), 583 deletions(-) create mode 100644 .changeset/typed-crud-field-vocabularies.md create mode 100644 packages/crud-memory/tests/memory-types.ts diff --git a/.changeset/typed-crud-field-vocabularies.md b/.changeset/typed-crud-field-vocabularies.md new file mode 100644 index 0000000..002d6c3 --- /dev/null +++ b/.changeset/typed-crud-field-vocabularies.md @@ -0,0 +1,19 @@ +--- +"@nestm/crud": minor +"@nestm/crud-memory": minor +"@nestm/crud-typeorm": minor +"@nestm/crud-drizzle": minor +"@nestm/crud-prisma": minor +--- + +Require every resource to declare one authoritative `fields` tuple and remove +the duplicated `fields` option from bindings. IDs, nested path mappings, query +filters/search/sort, soft delete, relations, scopes, cursor bindings, mapping +callbacks, predicates, and ordering now autocomplete that vocabulary and reject +unknown names. + +Preserve persistence-field vocabularies across adapters and bindings. Upsert +conflict and overwrite tuples now autocomplete adapter fields, including typed +TypeORM property paths. Memory uniqueness constraints, TypeORM references, +Drizzle columns and record keys, and Prisma identity/model/record mappings are +derived from their corresponding persistence models. diff --git a/.github/scripts/consumer-fixtures/crud-memory.ts b/.github/scripts/consumer-fixtures/crud-memory.ts index 8468793..ebde0ce 100644 --- a/.github/scripts/consumer-fixtures/crud-memory.ts +++ b/.github/scripts/consumer-fixtures/crud-memory.ts @@ -62,6 +62,7 @@ const consumerOperationDecorator: MethodDecorator = (_target, _propertyKey, desc }; const resource = defineCrudResource({ + fields: ["id", "name"], name: "consumer-items", path: "/api/consumer-items/", itemPath: ":id", @@ -88,14 +89,12 @@ const resource = defineCrudResource({ version: "1", }); -const binding = bindMemoryCrud({ +const binding = bindMemoryCrud({ resource, - fields: ["id", "name"], initialRecords: [{ id: 1, name: "packed artifact" }], mappings: { create: (input) => input, update: (input) => input, - persistence: (values) => values, response: (record) => record, }, }); diff --git a/README.md b/README.md index be28e35..778531a 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ const UserResponse = z.object({ }); export const users = defineCrudResource({ + fields: ["id", "name"], name: "users", path: "users", itemPath: ":id", @@ -84,7 +85,6 @@ export const users = defineCrudResource({ const usersBinding = bindMemoryCrud({ resource: users, - fields: ["id", "name"], unique: [["id"]], mappings: { create: (input) => input, @@ -210,6 +210,7 @@ and cursor tie-breaking: ```ts const tenantUsers = defineCrudResource({ + fields: ["tenantId", "id"], name: "tenant-users", path: "tenant-users", itemPath: ":tenantId/:id", @@ -224,8 +225,8 @@ const tenantUsers = defineCrudResource({ }); ``` -The binding's `fields` must include both logical ID fields. ORM adapter column -maps can translate those logical names to physical database columns. +The resource's `fields` tuple is the authoritative logical vocabulary. ORM +adapter column maps can translate those logical names to physical database columns. ## Nested resources @@ -235,6 +236,7 @@ continues to describe the complete item route: ```ts const versions = defineCrudResource({ + fields: ["artifactId", "versionId"], name: "artifact-versions", path: "artifacts/:artifactId/versions", pathParams: { @@ -395,13 +397,13 @@ from a compatibility controller: ```ts const viewerBindings = defineCrudResource({ // ...path, complete ID, and ordinary contracts + fields: ["artifactId", "viewerUserId", "mcpServerId", "toolPrefix", "allowedTools"], contracts: { id, create, update, upsert: UpsertViewerBinding, response }, operations: crudOperations.only("upsert", "delete"), }); const binding = bindTypeOrmCrud({ resource: viewerBindings, - fields, adapter, scopeCreateFields: ["viewerUserId"], upsert: { @@ -452,6 +454,7 @@ export class ProjectArtifactCounts implements CrudProjection { defineCrudResource({ // … + fields: ["id"], projections: [ProjectArtifactCounts], }); ``` diff --git a/packages/crud-drizzle/README.md b/packages/crud-drizzle/README.md index d8e1932..4f6d046 100644 --- a/packages/crud-drizzle/README.md +++ b/packages/crud-drizzle/README.md @@ -26,7 +26,6 @@ const usersAdapter = createDrizzleCrudAdapter({ const usersBinding = bindDrizzleCrud({ resource: users, - fields: ["id", "email", "createdAt"], adapter: { useValue: usersAdapter }, mappings: { create: (input) => input, @@ -51,7 +50,6 @@ receives its complete inferred insert model: const documentsBinding = bindDrizzleCrud({ resource: documentsResource, adapter: { useValue: documentsAdapter }, - fields: ["id", "organizationId", "ownerId", "title"], scopeCreateFields: ["organizationId", "ownerId"], mappings: { create: (input) => ({ title: input.title }), diff --git a/packages/crud-drizzle/src/bind-drizzle-crud.ts b/packages/crud-drizzle/src/bind-drizzle-crud.ts index 963b372..13ac30e 100644 --- a/packages/crud-drizzle/src/bind-drizzle-crud.ts +++ b/packages/crud-drizzle/src/bind-drizzle-crud.ts @@ -4,6 +4,8 @@ import { type CrudBindingUpsertOptions, type CrudBindingMappings, type CompleteCrudFieldSelection, + type DefineCrudBindingOptions, + type CrudPersistenceField, type CrudScopeCreateField, type CrudResourceBinding, } from "@nestm/crud/adapter"; @@ -15,19 +17,25 @@ export type DrizzleCrudAdapterProvider< RecordType, CreateValues extends object = object, UpdateValues extends object = object, -> = CrudAdapterProvider; + QueryField extends string = string, +> = CrudAdapterProvider< + RecordType, + CreateValues, + UpdateValues, + CrudPersistenceField, + QueryField +>; interface BindDrizzleCrudOptionsBase< Resource extends BindableResource, RecordType, - Fields extends readonly string[], CreateValues extends object, UpdateValues extends object, ScopeCreateFields extends readonly CrudScopeCreateField[], + QueryField extends string, > { readonly resource: Resource; readonly imports?: ModuleMetadata["imports"]; - readonly fields: Fields; readonly mappings: CrudBindingMappings< Resource, NoInfer, @@ -38,54 +46,73 @@ interface BindDrizzleCrudOptionsBase< /** Insert fields supplied by CRUD scopes through `mappings.scopeCreate`. */ readonly scopeCreateFields?: ScopeCreateFields; /** Atomic-upsert persistence fields. The configured adapter must advertise that capability. */ - readonly upsert?: CrudBindingUpsertOptions; + readonly upsert?: CrudBindingUpsertOptions>; /** Standard Nest provider form for an adapter; injected databases remain application-owned. */ - readonly adapter: DrizzleCrudAdapterProvider; + readonly adapter: DrizzleCrudAdapterProvider; } export type BindDrizzleCrudOptions< Resource extends BindableResource, RecordType, - Fields extends readonly string[] = readonly string[], CreateValues extends object = object, UpdateValues extends object = object, ScopeCreateFields extends readonly CrudScopeCreateField[] = readonly [], + QueryField extends string = Resource["fields"][number], > = BindDrizzleCrudOptionsBase< Resource, RecordType, - Fields, CreateValues, UpdateValues, - ScopeCreateFields + ScopeCreateFields, + QueryField > & - CompleteCrudFieldSelection; + CompleteCrudFieldSelection; /** Creates a core binding without taking ownership of the application's Drizzle client. */ export function bindDrizzleCrud< const Resource extends BindableResource, RecordType, - const Fields extends readonly string[], CreateValues extends object = object, UpdateValues extends object = object, const ScopeCreateFields extends readonly CrudScopeCreateField[] = readonly [], + QueryField extends string = Resource["fields"][number], >( options: BindDrizzleCrudOptions< Resource, RecordType, - Fields, CreateValues, UpdateValues, - ScopeCreateFields + ScopeCreateFields, + QueryField >, ): CrudResourceBinding< Resource, RecordType, - Fields, CreateValues, UpdateValues, - ScopeCreateFields[number] + ScopeCreateFields[number], + CrudPersistenceField, + QueryField > { - return defineCrudBinding(options); + return defineCrudBinding< + Resource, + RecordType, + CreateValues, + UpdateValues, + ScopeCreateFields, + CrudPersistenceField, + QueryField + >( + options as DefineCrudBindingOptions< + Resource, + RecordType, + CreateValues, + UpdateValues, + ScopeCreateFields, + CrudPersistenceField, + QueryField + >, + ); } diff --git a/packages/crud-drizzle/src/drizzle-adapter.ts b/packages/crud-drizzle/src/drizzle-adapter.ts index d34a511..99f0378 100644 --- a/packages/crud-drizzle/src/drizzle-adapter.ts +++ b/packages/crud-drizzle/src/drizzle-adapter.ts @@ -8,6 +8,7 @@ import type { CrudFindManyInput, CrudFindManyResult, CrudFindOneInput, + CrudPersistenceField, CrudPredicate, CrudUpdateInput, } from "@nestm/crud/adapter"; @@ -28,6 +29,10 @@ import { compileDrizzlePredicate, type DrizzleCrudColumns } from "./drizzle-pred export type DrizzleCrudCreateValues = InferInsertModel
; export type DrizzleCrudUpdateValues
= Partial>; +export type DrizzleCrudLogicalField>> = Extract< + keyof Columns, + string +>; export type DrizzleCrudDatabase< QueryResult extends PgQueryResultHKT, @@ -100,14 +105,17 @@ export interface DrizzleCrudAdapterOptions< QueryResult extends PgQueryResultHKT, FullSchema extends Record, Schema extends TablesRelationalConfig, + Columns extends DrizzleCrudColumns
= DrizzleCrudColumns
, > { /** A Drizzle database owned and lifecycle-managed by the consuming application. */ readonly database: PgDatabase; readonly table: Table; /** Maps public logical field names to columns on `table`. */ - readonly columns: DrizzleCrudColumns; + readonly columns: Columns; /** Maps logical fields to keys in returned row objects; defaults to the logical field. */ - readonly recordKeys?: Readonly>; + readonly recordKeys?: Readonly< + Partial, Extract, string>>> + >; /** * Minimum isolation for the complete CRUD operation, including scopes, * lifecycle hooks, validators, mappings, projections, and persistence. @@ -278,10 +286,13 @@ export class DrizzleCrudAdapter< QueryResult extends PgQueryResultHKT, FullSchema extends Record, Schema extends TablesRelationalConfig, + Columns extends DrizzleCrudColumns
= DrizzleCrudColumns
, > implements CrudAdapter< InferSelectModel
, DrizzleCrudCreateValues
, - DrizzleCrudUpdateValues
+ DrizzleCrudUpdateValues
, + CrudPersistenceField>, + DrizzleCrudLogicalField > { readonly capabilities = Object.freeze({ transactions: true, @@ -293,7 +304,7 @@ export class DrizzleCrudAdapter< readonly #database: PgDatabase; readonly #table: Table; readonly #columns: DrizzleCrudColumns; - readonly #recordKeys: Readonly>; + readonly #recordKeys: Readonly>>; readonly #operationIsolationLevel: DrizzleCrudTransactionIsolationLevel | undefined; readonly #transactionRunner: DrizzleCrudTransactionRunner | undefined; @@ -308,7 +319,7 @@ export class DrizzleCrudAdapter< > >(); - constructor(options: DrizzleCrudAdapterOptions) { + constructor(options: DrizzleCrudAdapterOptions) { this.#database = options.database; this.#table = options.table; this.#columns = Object.freeze({ ...options.columns }); @@ -370,7 +381,7 @@ export class DrizzleCrudAdapter< } async findOne( - input: CrudFindOneInput, + input: CrudFindOneInput>, context: CrudAdapterContext, ): Promise | null> { try { @@ -404,7 +415,7 @@ export class DrizzleCrudAdapter< } async findMany( - input: CrudFindManyInput, + input: CrudFindManyInput>, context: CrudAdapterContext, ): Promise>> { try { @@ -443,7 +454,7 @@ export class DrizzleCrudAdapter< } async update( - input: CrudUpdateInput>, + input: CrudUpdateInput, DrizzleCrudLogicalField>, context: CrudAdapterContext, ): Promise | null> { try { @@ -474,7 +485,7 @@ export class DrizzleCrudAdapter< } async delete( - input: CrudDeleteInput, + input: CrudDeleteInput>, context: CrudAdapterContext, ): Promise | null> { try { @@ -500,7 +511,7 @@ export class DrizzleCrudAdapter< } } - getField(record: InferSelectModel
, field: string): unknown { + getField(record: InferSelectModel
, field: DrizzleCrudLogicalField): unknown { const key = this.#recordKeys[field] ?? field; return (record as Readonly>)[key]; } @@ -723,8 +734,9 @@ export function createDrizzleCrudAdapter< QueryResult extends PgQueryResultHKT, FullSchema extends Record, Schema extends TablesRelationalConfig, + const Columns extends DrizzleCrudColumns
, >( - options: DrizzleCrudAdapterOptions, -): DrizzleCrudAdapter { + options: DrizzleCrudAdapterOptions, +): DrizzleCrudAdapter { return new DrizzleCrudAdapter(options); } diff --git a/packages/crud-drizzle/src/drizzle-predicate.ts b/packages/crud-drizzle/src/drizzle-predicate.ts index 7d5c3e4..4e00a14 100644 --- a/packages/crud-drizzle/src/drizzle-predicate.ts +++ b/packages/crud-drizzle/src/drizzle-predicate.ts @@ -19,11 +19,19 @@ import { sql, type SQL, } from "drizzle-orm"; -import type { AnyPgColumn } from "drizzle-orm/pg-core"; +import type { AnyPgColumn, AnyPgTable } from "drizzle-orm/pg-core"; -export type DrizzleCrudColumns = Readonly>; +export type DrizzleCrudTableColumn
= + Table["_"]["columns"][keyof Table["_"]["columns"]]; -function columnFor(columns: DrizzleCrudColumns, field: string): AnyPgColumn { +export type DrizzleCrudColumns
= Readonly< + Record> +>; + +function columnFor( + columns: Columns, + field: Extract, +): AnyPgColumn { const column = columns[field]; if (column === undefined) { throw new TypeError(`The Drizzle adapter does not map CRUD field '${field}'.`); @@ -41,9 +49,12 @@ function escapeLike(value: string): string { return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_"); } -function comparison( - predicate: Extract, - columns: DrizzleCrudColumns, +function comparison( + predicate: Extract< + CrudPredicate>>, + { kind: "comparison" } + >, + columns: Columns, ): SQL { const column = columnFor(columns, predicate.field); const value = predicate.value; @@ -96,9 +107,9 @@ function comparison( } /** Compiles a neutral CRUD predicate into Drizzle's parameterized SQL expression tree. */ -export function compileDrizzlePredicate( - predicate: CrudPredicate, - columns: DrizzleCrudColumns, +export function compileDrizzlePredicate( + predicate: CrudPredicate>>, + columns: Columns, ): SQL { switch (predicate.kind) { case "comparison": diff --git a/packages/crud-drizzle/src/index.ts b/packages/crud-drizzle/src/index.ts index 8d4a1e9..18c94af 100644 --- a/packages/crud-drizzle/src/index.ts +++ b/packages/crud-drizzle/src/index.ts @@ -11,6 +11,7 @@ export { type DrizzleCrudAdapterOptions, type DrizzleCrudDatabase, type DrizzleCrudEffectiveTransaction, + type DrizzleCrudLogicalField, type DrizzleCrudOperationTransactionOptions, type DrizzleCrudRowPredicate, type DrizzleCrudRowPredicateContext, @@ -21,4 +22,8 @@ export { type DrizzleCrudTransactionRunner, type DrizzleCrudTransactionRunnerContext, } from "./drizzle-adapter.ts"; -export { compileDrizzlePredicate, type DrizzleCrudColumns } from "./drizzle-predicate.ts"; +export { + compileDrizzlePredicate, + type DrizzleCrudColumns, + type DrizzleCrudTableColumn, +} from "./drizzle-predicate.ts"; diff --git a/packages/crud-drizzle/tests/drizzle-predicate.spec.ts b/packages/crud-drizzle/tests/drizzle-predicate.spec.ts index 278fbc3..f328906 100644 --- a/packages/crud-drizzle/tests/drizzle-predicate.spec.ts +++ b/packages/crud-drizzle/tests/drizzle-predicate.spec.ts @@ -11,7 +11,7 @@ const users = pgTable("users", { describe("compileDrizzlePredicate", () => { it("uses placeholders for values and quoted configured columns", () => { - const predicate: CrudPredicate = { + const predicate: CrudPredicate<"id" | "name"> = { kind: "and", predicates: [ { kind: "comparison", field: "id", operator: "gte", value: 10 }, @@ -29,7 +29,13 @@ describe("compileDrizzlePredicate", () => { it("rejects unmapped fields", () => { expect(() => compileDrizzlePredicate( - { kind: "comparison", field: "email", operator: "eq", value: "x" }, + { + kind: "comparison", + // Deliberately bypass static field checking to verify the runtime guard. + field: "email" as "id", + operator: "eq", + value: "x", + }, { id: users.id }, ), ).toThrow("does not map"); diff --git a/packages/crud-drizzle/tests/drizzle-types.ts b/packages/crud-drizzle/tests/drizzle-types.ts index 4b43ea1..f852aa5 100644 --- a/packages/crud-drizzle/tests/drizzle-types.ts +++ b/packages/crud-drizzle/tests/drizzle-types.ts @@ -29,6 +29,25 @@ export const adapter = createDrizzleCrudAdapter({ transaction: { isolationLevel: "repeatable read" }, }); +createDrizzleCrudAdapter({ + database, + table: users, + columns: { + // @ts-expect-error mapped columns must belong to the configured table. + ownerId: scopedUsers.ownerId, + }, +}); + +createDrizzleCrudAdapter({ + database, + table: users, + columns: { id: users.id, name: users.name }, + recordKeys: { + // @ts-expect-error returned-row keys are inferred from the table select model. + id: "userId", + }, +}); + // Compile-only proof that application-owned transactions and native row predicates stay typed. export const securedAdapter = createDrizzleCrudAdapter({ database, @@ -66,7 +85,9 @@ export const snapshotSecuredAdapter = createDrizzleCrudAdapter({ const nativeAdapter: CrudAdapter< InferSelectModel, InferInsertModel, - Partial> + Partial>, + keyof InferInsertModel, + "id" | "name" > = adapter; void nativeAdapter; @@ -78,6 +99,20 @@ void adapter.update( }, { resource: "users", operation: "update" }, ); +void adapter.findMany( + { + order: [ + { + // @ts-expect-error query fields are inferred from the configured logical columns. + field: "email", + direction: "asc", + }, + ], + limit: 10, + count: false, + }, + { resource: "users", operation: "list" }, +); void adapter.create( { // @ts-expect-error Drizzle's inferred insert model requires a string name. @@ -87,6 +122,7 @@ void adapter.create( ); const resource = defineCrudResource({ + fields: ["id", "name"], name: "drizzle-users", path: "drizzle-users", itemPath: ":id", @@ -103,7 +139,6 @@ const resource = defineCrudResource({ export const binding = bindDrizzleCrud({ resource, adapter: { useValue: adapter }, - fields: ["id", "name"], mappings: { create: (input) => ({ id: 1, name: input.name }), update: (input) => (input.name === undefined ? {} : { name: input.name }), @@ -115,7 +150,6 @@ export const binding = bindDrizzleCrud({ export const invalidBinding = bindDrizzleCrud({ resource, adapter: { useValue: adapter }, - fields: ["id", "name"], mappings: { // @ts-expect-error binder mappings must return the table's inferred insert model. create: () => ({ id: 1, name: 123 }), @@ -125,6 +159,25 @@ export const invalidBinding = bindDrizzleCrud({ }, }); +const invalidLogicalFieldResource = defineCrudResource({ + ...resource, + fields: ["id", "name", "email"], + name: "invalid-drizzle-logical-field", + path: "invalid-drizzle-logical-field", +}); + +// @ts-expect-error every resource field must exist in the adapter's logical column map. +bindDrizzleCrud({ + resource: invalidLogicalFieldResource, + adapter: { useValue: adapter }, + mappings: { + create: (input) => ({ id: 1, name: input.name }), + update: (input) => (input.name === undefined ? {} : { name: input.name }), + persistence: () => ({}), + response: (record) => record, + }, +}); + const scopedAdapter = createDrizzleCrudAdapter({ database, table: scopedUsers, @@ -136,12 +189,21 @@ const scopedAdapter = createDrizzleCrudAdapter({ }, }); +const scopedResource = defineCrudResource({ + fields: ["id", "tenantId", "ownerId", "name"], + name: "scoped-drizzle-users", + path: "scoped-drizzle-users", + itemPath: ":id", + idFields: { id: "id" }, + contracts: resource.contracts, + operations: crudOperations.all(), +}); + // Scope-owned insert fields can be omitted by the API create mapper because persistence // supplies them after scope resolution and overwrites any mapper-provided values. export const scopedBinding = bindDrizzleCrud({ - resource, + resource: scopedResource, adapter: { useValue: scopedAdapter }, - fields: ["id", "tenantId", "ownerId", "name"], scopeCreateFields: ["tenantId", "ownerId"], mappings: { create: (input) => ({ id: 1, name: input.name }), @@ -158,9 +220,8 @@ export const scopedBinding = bindDrizzleCrud({ CrudModule.forFeature({ resources: [scopedBinding] }); export const missingRequiredUnscopedField = bindDrizzleCrud({ - resource, + resource: scopedResource, adapter: { useValue: scopedAdapter }, - fields: ["id", "tenantId", "ownerId", "name"], mappings: { // @ts-expect-error unscoped create mappings still require every required insert field. create: (input) => ({ id: 1, name: input.name }), @@ -171,9 +232,8 @@ export const missingRequiredUnscopedField = bindDrizzleCrud({ }); export const missingRequiredNonScopeField = bindDrizzleCrud({ - resource, + resource: scopedResource, adapter: { useValue: scopedAdapter }, - fields: ["id", "tenantId", "ownerId", "name"], scopeCreateFields: ["tenantId", "ownerId"], mappings: { // @ts-expect-error only declared scope fields become optional for the create mapper. @@ -185,9 +245,8 @@ export const missingRequiredNonScopeField = bindDrizzleCrud({ }); export const invalidScopeCreateField = bindDrizzleCrud({ - resource, + resource: scopedResource, adapter: { useValue: scopedAdapter }, - fields: ["id", "tenantId", "ownerId", "name"], // @ts-expect-error scope-owned fields must be keys accepted by create and persistence mappings. scopeCreateFields: ["notAColumn"], mappings: { diff --git a/packages/crud-memory/README.md b/packages/crud-memory/README.md index 2148f7f..cdba987 100644 --- a/packages/crud-memory/README.md +++ b/packages/crud-memory/README.md @@ -12,7 +12,6 @@ import { bindMemoryCrud } from "@nestm/crud-memory"; const usersBinding = bindMemoryCrud({ resource: usersResource, - fields: ["id", "name"], initialRecords: [{ id: "1", name: "Ada" }], unique: [["id"]], mappings: { @@ -37,8 +36,8 @@ Like the SQL binders, `bindMemoryCrud` accepts `scopeCreateFields` plus `mappings.scopeCreate`. Use them for required insert fields owned by a nested collection path or application scope rather than the request body. -`fields` names are logical API fields used by filters, ordering, IDs, scopes, -soft deletion, and relations. For non-object records, provide `createRecord`, +The resource's `fields` names are logical API fields used by filters, ordering, +IDs, scopes, soft deletion, and relations. For non-object records, provide `createRecord`, `updateRecord`, and `getField`. Declare `unique` logical-field tuples to exercise the same `409` conflict path used by SQL unique constraints. diff --git a/packages/crud-memory/src/bind-memory-crud.ts b/packages/crud-memory/src/bind-memory-crud.ts index bf238e9..30e6280 100644 --- a/packages/crud-memory/src/bind-memory-crud.ts +++ b/packages/crud-memory/src/bind-memory-crud.ts @@ -6,6 +6,7 @@ import { type CrudBindingMappings, type CompleteCrudFieldSelection, type DefineCrudBindingOptions, + type CrudPersistenceField, type CrudScopeCreateField, type CrudResourceBinding, type CrudValues, @@ -18,15 +19,18 @@ type MemoryCrudResource = CrudResourceBinding["resource"]; interface BindMemoryCrudOptionsBase< Resource extends MemoryCrudResource, RecordType = CrudValues, - Fields extends readonly string[] = readonly string[], CreateValues extends object = object, UpdateValues extends object = object, ScopeCreateFields extends readonly CrudScopeCreateField[] = readonly [], -> extends MemoryCrudAdapterOptions { +> extends MemoryCrudAdapterOptions< + RecordType, + CreateValues, + UpdateValues, + NoInfer +> { readonly resource: Resource; readonly imports?: ModuleMetadata["imports"]; - readonly fields: Fields; readonly mappings: CrudBindingMappings< Resource, NoInfer, @@ -37,34 +41,31 @@ interface BindMemoryCrudOptionsBase< /** Insert fields supplied by path parameters or CRUD scopes through `mappings.scopeCreate`. */ readonly scopeCreateFields?: ScopeCreateFields; /** Atomic-upsert persistence fields. The configured adapter must advertise that capability. */ - readonly upsert?: CrudBindingUpsertOptions; + readonly upsert?: CrudBindingUpsertOptions>; /** Overrides the convenient package-owned adapter with any standard Nest provider form. */ - readonly adapter?: CrudAdapterProvider; + readonly adapter?: CrudAdapterProvider< + RecordType, + CreateValues, + UpdateValues, + CrudPersistenceField, + Resource["fields"][number] + >; } export type BindMemoryCrudOptions< Resource extends MemoryCrudResource, RecordType = CrudValues, - Fields extends readonly string[] = readonly string[], CreateValues extends object = object, UpdateValues extends object = object, ScopeCreateFields extends readonly CrudScopeCreateField[] = readonly [], -> = BindMemoryCrudOptionsBase< - Resource, - RecordType, - Fields, - CreateValues, - UpdateValues, - ScopeCreateFields -> & - CompleteCrudFieldSelection; +> = BindMemoryCrudOptionsBase & + CompleteCrudFieldSelection; /** Creates a core binding without installing or owning any external dependency. */ export function bindMemoryCrud< const Resource extends MemoryCrudResource, RecordType = CrudValues, - const Fields extends readonly string[] = readonly string[], CreateValues extends object = object, UpdateValues extends object = object, const ScopeCreateFields extends readonly CrudScopeCreateField[] = @@ -73,7 +74,6 @@ export function bindMemoryCrud< options: BindMemoryCrudOptions< Resource, RecordType, - Fields, CreateValues, UpdateValues, ScopeCreateFields @@ -81,15 +81,15 @@ export function bindMemoryCrud< ): CrudResourceBinding< Resource, RecordType, - Fields, CreateValues, UpdateValues, - ScopeCreateFields[number] + ScopeCreateFields[number], + CrudPersistenceField, + Resource["fields"][number] > { const { resource, imports, - fields, mappings, scopeCreateFields, upsert, @@ -103,8 +103,19 @@ export function bindMemoryCrud< unique, } = options; - const resolvedAdapter: CrudAdapterProvider = adapter ?? { - useValue: new MemoryCrudAdapter({ + const resolvedAdapter: CrudAdapterProvider< + RecordType, + CreateValues, + UpdateValues, + CrudPersistenceField, + Resource["fields"][number] + > = adapter ?? { + useValue: new MemoryCrudAdapter< + RecordType, + CreateValues, + UpdateValues, + Resource["fields"][number] + >({ ...(store === undefined ? {} : { store }), ...(initialRecords === undefined ? {} : { initialRecords }), ...(clone === undefined ? {} : { clone }), @@ -118,7 +129,6 @@ export function bindMemoryCrud< const coreOptions = { resource, ...(imports === undefined ? {} : { imports }), - fields, mappings, ...(scopeCreateFields === undefined ? {} : { scopeCreateFields }), ...(upsert === undefined ? {} : { upsert }), @@ -126,17 +136,19 @@ export function bindMemoryCrud< } as unknown as DefineCrudBindingOptions< Resource, RecordType, - Fields, CreateValues, UpdateValues, - ScopeCreateFields + ScopeCreateFields, + CrudPersistenceField, + Resource["fields"][number] >; return defineCrudBinding< Resource, RecordType, - Fields, CreateValues, UpdateValues, - ScopeCreateFields + ScopeCreateFields, + CrudPersistenceField, + Resource["fields"][number] >(coreOptions); } diff --git a/packages/crud-memory/src/index.ts b/packages/crud-memory/src/index.ts index be36d7b..658d287 100644 --- a/packages/crud-memory/src/index.ts +++ b/packages/crud-memory/src/index.ts @@ -3,6 +3,7 @@ export { MemoryCrudAdapter, type MemoryCrudAdapterOptions, type MemoryCrudCreateRecord, + type MemoryCrudField, type MemoryCrudGetField, type MemoryCrudUpdateRecord, } from "./memory-crud-adapter.ts"; diff --git a/packages/crud-memory/src/memory-crud-adapter.spec.ts b/packages/crud-memory/src/memory-crud-adapter.spec.ts index 0fcbe87..b29fa84 100644 --- a/packages/crud-memory/src/memory-crud-adapter.spec.ts +++ b/packages/crud-memory/src/memory-crud-adapter.spec.ts @@ -34,7 +34,11 @@ function context(session?: CrudAdapterSession): CrudAdapterContext { }; } -function comparison(field: string, operator: CrudFilterOperator, value: unknown): CrudPredicate { +function comparison( + field: Field, + operator: CrudFilterOperator, + value: unknown, +): CrudPredicate { return { kind: "comparison", field, operator, value }; } @@ -137,7 +141,7 @@ describe("MemoryCrudAdapter", () => { it("supports the complete predicate AST and filter operator surface", async () => { const adapter = new MemoryCrudAdapter({ initialRecords: initialUsers }); - const cases: readonly [CrudPredicate, readonly number[]][] = [ + const cases: readonly [CrudPredicate, readonly number[]][] = [ [comparison("age", "eq", 36), [1, 3]], [comparison("age", "ne", 36), [2]], [comparison("age", "gt", 28), [1, 3]], @@ -181,7 +185,7 @@ describe("MemoryCrudAdapter", () => { it("uses SQL three-valued logic for nullable comparisons and boolean predicates", async () => { const adapter = new MemoryCrudAdapter({ initialRecords: initialUsers }); - const cases: readonly [CrudPredicate, readonly number[]][] = [ + const cases: readonly [CrudPredicate, readonly number[]][] = [ [comparison("teamId", "eq", null), [2]], [comparison("teamId", "ne", null), [1, 3]], [comparison("teamId", "ne", 10), [3]], @@ -302,7 +306,6 @@ describe("bindMemoryCrud", () => { it("creates a convenient owned adapter with seeded records", () => { const binding = bindMemoryCrud({ resource, - fields: ["id", "name", "age", "teamId"], initialRecords: initialUsers, mappings, }); @@ -320,7 +323,6 @@ describe("bindMemoryCrud", () => { const token = Symbol("custom-memory-adapter"); const binding = bindMemoryCrud({ resource, - fields: ["id", "name", "age", "teamId"], mappings, adapter: { useExisting: token }, // These would be an invalid owned-adapter combination, proving the override is lazy. diff --git a/packages/crud-memory/src/memory-crud-adapter.ts b/packages/crud-memory/src/memory-crud-adapter.ts index 4b6f157..24dd699 100644 --- a/packages/crud-memory/src/memory-crud-adapter.ts +++ b/packages/crud-memory/src/memory-crud-adapter.ts @@ -10,6 +10,8 @@ import { type CrudFindManyResult, type CrudFindOneInput, type CrudOrder, + type CrudPersistenceField, + type CrudPersistenceFieldTuple, type CrudPredicate, type CrudUpdateInput, type CrudValues, @@ -24,21 +26,29 @@ export type MemoryCrudUpdateRecord RecordType; -export type MemoryCrudGetField = (record: RecordType, field: string) => unknown; +export type MemoryCrudField = RecordType extends object + ? CrudPersistenceField + : string; + +export type MemoryCrudGetField> = ( + record: RecordType, + field: Field, +) => unknown; export interface MemoryCrudAdapterOptions< RecordType, CreateValues extends object = object, UpdateValues extends object = object, + Field extends string = MemoryCrudField, > { readonly store?: MemoryCrudStore; readonly initialRecords?: readonly RecordType[]; readonly clone?: MemoryCrudClone; readonly createRecord?: MemoryCrudCreateRecord; readonly updateRecord?: MemoryCrudUpdateRecord; - readonly getField?: MemoryCrudGetField; + readonly getField?: MemoryCrudGetField; /** Logical field tuples that must remain unique. */ - readonly unique?: readonly (readonly string[])[]; + readonly unique?: readonly CrudPersistenceFieldTuple[]; } interface MemoryTransaction { @@ -193,10 +203,10 @@ function comparisonTruth( } } -function predicateTruth( +function predicateTruth( record: RecordType, - predicate: CrudPredicate, - getField: MemoryCrudGetField, + predicate: CrudPredicate, + getField: MemoryCrudGetField, ): SqlTruth { switch (predicate.kind) { case "comparison": @@ -233,10 +243,10 @@ function predicateTruth( } } -function predicateMatches( +function predicateMatches( record: RecordType, - predicate: CrudPredicate, - getField: MemoryCrudGetField, + predicate: CrudPredicate, + getField: MemoryCrudGetField, ): boolean { return predicateTruth(record, predicate, getField) === true; } @@ -270,11 +280,11 @@ function stableValueString(value: unknown): string { } } -function compareRecords( +function compareRecords( left: RecordType, right: RecordType, - order: readonly CrudOrder[], - getField: MemoryCrudGetField, + order: readonly CrudOrder[], + getField: MemoryCrudGetField, ): number { for (const item of order) { const comparison = compareNullable(getField(left, item.field), getField(right, item.field)); @@ -290,7 +300,14 @@ export class MemoryCrudAdapter< RecordType = CrudValues, CreateValues extends object = object, UpdateValues extends object = object, -> implements CrudAdapter { + Field extends string = MemoryCrudField, +> implements CrudAdapter< + RecordType, + CreateValues, + UpdateValues, + CrudPersistenceField, + Field +> { readonly capabilities: CrudAdapterCapabilities = Object.freeze({ transactions: true, returning: true, @@ -301,13 +318,15 @@ export class MemoryCrudAdapter< readonly store: MemoryCrudStore; readonly #createRecord: MemoryCrudCreateRecord; readonly #updateRecord: MemoryCrudUpdateRecord; - readonly #getField: MemoryCrudGetField; - readonly #unique: readonly (readonly string[])[]; + readonly #getField: MemoryCrudGetField; + readonly #unique: readonly CrudPersistenceFieldTuple[]; readonly #sessionKey = Symbol("@nestm/crud-memory:session"); readonly #activeTransactions = new WeakSet(); #writeTail: Promise = Promise.resolve(); - constructor(options: MemoryCrudAdapterOptions = {}) { + constructor( + options: MemoryCrudAdapterOptions = {}, + ) { if ( options.store !== undefined && (options.initialRecords !== undefined || options.clone !== undefined) @@ -375,7 +394,10 @@ export class MemoryCrudAdapter< }); } - async findOne(input: CrudFindOneInput, context: CrudAdapterContext): Promise { + async findOne( + input: CrudFindOneInput, + context: CrudAdapterContext, + ): Promise { const records = this.#recordsFor(context); const matches = records.filter((record) => predicateMatches(record, input.predicate, this.#getField), @@ -390,7 +412,7 @@ export class MemoryCrudAdapter< } async findMany( - input: CrudFindManyInput, + input: CrudFindManyInput, context: CrudAdapterContext, ): Promise> { const records = this.#recordsFor(context) @@ -409,7 +431,7 @@ export class MemoryCrudAdapter< } async update( - input: CrudUpdateInput, + input: CrudUpdateInput, context: CrudAdapterContext, ): Promise { const transaction = this.#optionalTransaction(context); @@ -426,7 +448,10 @@ export class MemoryCrudAdapter< }); } - async delete(input: CrudDeleteInput, context: CrudAdapterContext): Promise { + async delete( + input: CrudDeleteInput, + context: CrudAdapterContext, + ): Promise { const transaction = this.#optionalTransaction(context); if (transaction !== undefined) { return this.#deleteIn(transaction.records, input); @@ -441,7 +466,7 @@ export class MemoryCrudAdapter< }); } - getField(record: RecordType, field: string): unknown { + getField(record: RecordType, field: Field): unknown { return this.#getField(record, field); } @@ -452,7 +477,7 @@ export class MemoryCrudAdapter< return this.store.clone(created); } - #updateIn(records: RecordType[], input: CrudUpdateInput): RecordType | null { + #updateIn(records: RecordType[], input: CrudUpdateInput): RecordType | null { const index = records.findIndex((record) => predicateMatches(record, input.predicate, this.#getField), ); @@ -466,7 +491,7 @@ export class MemoryCrudAdapter< return this.store.clone(updated); } - #deleteIn(records: RecordType[], input: CrudDeleteInput): RecordType | null { + #deleteIn(records: RecordType[], input: CrudDeleteInput): RecordType | null { const index = records.findIndex((record) => predicateMatches(record, input.predicate, this.#getField), ); diff --git a/packages/crud-memory/tests/memory-types.ts b/packages/crud-memory/tests/memory-types.ts new file mode 100644 index 0000000..0707c28 --- /dev/null +++ b/packages/crud-memory/tests/memory-types.ts @@ -0,0 +1,41 @@ +import { MemoryCrudAdapter } from "../src/index.ts"; + +interface UserRecord { + readonly id: number; + readonly name: string; +} + +export const typedMemoryAdapter = new MemoryCrudAdapter({ + unique: [["id"], ["name", "id"]], +}); + +void typedMemoryAdapter.findMany( + { + order: [ + { + // @ts-expect-error query fields are inferred from the record shape. + field: "email", + direction: "asc", + }, + ], + limit: 10, + count: false, + }, + { resource: "users", operation: "list" }, +); + +export const invalidMemoryFieldAdapter = new MemoryCrudAdapter({ + unique: [ + [ + // @ts-expect-error unique constraints must use record fields. + "email", + ], + ], +}); + +export const emptyMemoryConstraintAdapter = new MemoryCrudAdapter({ + unique: [ + // @ts-expect-error unique constraints must contain at least one field. + [], + ], +}); diff --git a/packages/crud-prisma/README.md b/packages/crud-prisma/README.md index 40a54da..10c7fac 100644 --- a/packages/crud-prisma/README.md +++ b/packages/crud-prisma/README.md @@ -23,7 +23,6 @@ const usersAdapter = createPrismaCrudAdapter({ const usersBinding = bindPrismaCrud({ resource: users, - fields: ["id", "email", "createdAt"], adapter: { useValue: usersAdapter }, mappings: { create: (input) => input, diff --git a/packages/crud-prisma/src/bind-prisma-crud.ts b/packages/crud-prisma/src/bind-prisma-crud.ts index 33839ab..f9ebf7d 100644 --- a/packages/crud-prisma/src/bind-prisma-crud.ts +++ b/packages/crud-prisma/src/bind-prisma-crud.ts @@ -4,6 +4,8 @@ import { type CrudBindingUpsertOptions, type CrudBindingMappings, type CompleteCrudFieldSelection, + type DefineCrudBindingOptions, + type CrudPersistenceField, type CrudScopeCreateField, type CrudResourceBinding, } from "@nestm/crud/adapter"; @@ -15,19 +17,25 @@ export type PrismaCrudAdapterProvider< RecordType, CreateValues extends object = object, UpdateValues extends object = object, -> = CrudAdapterProvider; + QueryField extends string = string, +> = CrudAdapterProvider< + RecordType, + CreateValues, + UpdateValues, + CrudPersistenceField, + QueryField +>; interface BindPrismaCrudOptionsBase< Resource extends BindableResource, RecordType, - Fields extends readonly string[], CreateValues extends object, UpdateValues extends object, ScopeCreateFields extends readonly CrudScopeCreateField[], + QueryField extends string, > { readonly resource: Resource; readonly imports?: ModuleMetadata["imports"]; - readonly fields: Fields; readonly mappings: CrudBindingMappings< Resource, NoInfer, @@ -38,54 +46,73 @@ interface BindPrismaCrudOptionsBase< /** Insert fields supplied by path parameters or CRUD scopes through `mappings.scopeCreate`. */ readonly scopeCreateFields?: ScopeCreateFields; /** Atomic-upsert persistence fields. The configured adapter must advertise that capability. */ - readonly upsert?: CrudBindingUpsertOptions; + readonly upsert?: CrudBindingUpsertOptions>; /** Standard Nest provider form for an adapter; injected Prisma clients remain application-owned. */ - readonly adapter: PrismaCrudAdapterProvider; + readonly adapter: PrismaCrudAdapterProvider; } export type BindPrismaCrudOptions< Resource extends BindableResource, RecordType, - Fields extends readonly string[] = readonly string[], CreateValues extends object = object, UpdateValues extends object = object, ScopeCreateFields extends readonly CrudScopeCreateField[] = readonly [], + QueryField extends string = Resource["fields"][number], > = BindPrismaCrudOptionsBase< Resource, RecordType, - Fields, CreateValues, UpdateValues, - ScopeCreateFields + ScopeCreateFields, + QueryField > & - CompleteCrudFieldSelection; + CompleteCrudFieldSelection; /** Creates a core binding without connecting or disconnecting the application's PrismaClient. */ export function bindPrismaCrud< const Resource extends BindableResource, RecordType, - const Fields extends readonly string[], CreateValues extends object = object, UpdateValues extends object = object, const ScopeCreateFields extends readonly CrudScopeCreateField[] = readonly [], + QueryField extends string = Resource["fields"][number], >( options: BindPrismaCrudOptions< Resource, RecordType, - Fields, CreateValues, UpdateValues, - ScopeCreateFields + ScopeCreateFields, + QueryField >, ): CrudResourceBinding< Resource, RecordType, - Fields, CreateValues, UpdateValues, - ScopeCreateFields[number] + ScopeCreateFields[number], + CrudPersistenceField, + QueryField > { - return defineCrudBinding(options); + return defineCrudBinding< + Resource, + RecordType, + CreateValues, + UpdateValues, + ScopeCreateFields, + CrudPersistenceField, + QueryField + >( + options as DefineCrudBindingOptions< + Resource, + RecordType, + CreateValues, + UpdateValues, + ScopeCreateFields, + CrudPersistenceField, + QueryField + >, + ); } diff --git a/packages/crud-prisma/src/index.ts b/packages/crud-prisma/src/index.ts index a66d85c..a124ea4 100644 --- a/packages/crud-prisma/src/index.ts +++ b/packages/crud-prisma/src/index.ts @@ -7,7 +7,11 @@ export { createPrismaCrudAdapter, PrismaCrudAdapter, type PrismaCrudCreateValues, + type PrismaCrudLogicalField, + type PrismaCrudModelField, + type PrismaCrudRecordField, type PrismaCrudUpdateValues, + type PrismaCrudWhereUnique, type PrismaCrudAdapterOptions, } from "./prisma-adapter.ts"; export { diff --git a/packages/crud-prisma/src/prisma-adapter.ts b/packages/crud-prisma/src/prisma-adapter.ts index 66d8ad8..abbb1f1 100644 --- a/packages/crud-prisma/src/prisma-adapter.ts +++ b/packages/crud-prisma/src/prisma-adapter.ts @@ -8,6 +8,7 @@ import type { CrudFindManyInput, CrudFindManyResult, CrudFindOneInput, + CrudPersistenceField, CrudUpdateInput, CrudValues, } from "@nestm/crud/adapter"; @@ -52,19 +53,47 @@ export type PrismaCrudUpdateValues = PersistenceValuesOrDefault< ObjectArgumentProperty, "data"> >; -export interface PrismaCrudAdapterOptions { +/** Native unique selector accepted by a generated Prisma model delegate. */ +export type PrismaCrudWhereUnique = PersistenceValuesOrDefault< + ObjectArgumentProperty, "where"> +>; + +export type PrismaCrudRecordField = RecordType extends object + ? CrudPersistenceField + : string; + +export type PrismaCrudModelField = RecordType extends object + ? CrudPersistenceField< + RecordType & PrismaCrudCreateValues & PrismaCrudUpdateValues + > + : CrudPersistenceField & PrismaCrudUpdateValues>; + +export type PrismaCrudLogicalField = + | PrismaCrudRecordField + | (string extends Extract ? never : Extract); + +export interface PrismaCrudAdapterOptions< + RecordType, + Client, + Delegate, + Fields extends PrismaCrudFields> = PrismaCrudFields< + PrismaCrudModelField + >, +> { /** A generated PrismaClient owned and lifecycle-managed by the consuming application. */ readonly client: Client; /** Selects a generated model delegate, for example `(client) => client.user`. */ readonly delegate: (client: Client) => Delegate; /** Returns the model's native `WhereUniqueInput`, including named compound selectors. */ - readonly identity: (record: RecordType) => Readonly>; + readonly identity: (record: RecordType) => Readonly>; /** Maps public logical field names to Prisma model field names. */ - readonly fields?: PrismaCrudFields; + readonly fields?: Fields; /** Maps logical fields to keys in returned records; defaults to `fields[field]` or `field`. */ - readonly recordKeys?: Readonly>; + readonly recordKeys?: Readonly< + Partial, PrismaCrudRecordField>> + >; /** Logical fields whose Prisma model columns are required (used to compile `isnull` safely). */ - readonly nonNullableFields?: readonly string[]; + readonly nonNullableFields?: readonly PrismaCrudLogicalField[]; } interface PrismaTransactionHost { @@ -145,7 +174,16 @@ export class PrismaCrudAdapter< Delegate, CreateValues extends object = PrismaCrudCreateValues, UpdateValues extends object = PrismaCrudUpdateValues, -> implements CrudAdapter { + Fields extends PrismaCrudFields> = PrismaCrudFields< + PrismaCrudModelField + >, +> implements CrudAdapter< + RecordType, + CreateValues, + UpdateValues, + CrudPersistenceField, + PrismaCrudLogicalField +> { readonly capabilities = Object.freeze({ transactions: true, returning: true, @@ -157,12 +195,12 @@ export class PrismaCrudAdapter< readonly #delegateFactory: (client: Client) => Delegate; readonly #identity: (record: RecordType) => Readonly>; readonly #fields: PrismaCrudFields; - readonly #recordKeys: Readonly>; + readonly #recordKeys: Readonly>>; readonly #nonNullableFields: ReadonlySet; readonly #sessionMarker = Symbol("@nestm/crud-prisma:session"); readonly #activeSessions = new WeakSet(); - constructor(options: PrismaCrudAdapterOptions) { + constructor(options: PrismaCrudAdapterOptions) { if ( typeof options.client !== "object" || options.client === null || @@ -220,7 +258,10 @@ export class PrismaCrudAdapter< } } - async findOne(input: CrudFindOneInput, context: CrudAdapterContext): Promise { + async findOne( + input: CrudFindOneInput>, + context: CrudAdapterContext, + ): Promise { try { return await this.#delegate(context).findFirst({ where: compilePrismaPredicate(input.predicate, this.#fields, this.#nonNullableFields), @@ -234,7 +275,7 @@ export class PrismaCrudAdapter< } async findMany( - input: CrudFindManyInput, + input: CrudFindManyInput>, context: CrudAdapterContext, ): Promise> { try { @@ -259,7 +300,7 @@ export class PrismaCrudAdapter< } async update( - input: CrudUpdateInput, + input: CrudUpdateInput>, context: CrudAdapterContext, ): Promise { try { @@ -281,7 +322,10 @@ export class PrismaCrudAdapter< } } - async delete(input: CrudDeleteInput, context: CrudAdapterContext): Promise { + async delete( + input: CrudDeleteInput>, + context: CrudAdapterContext, + ): Promise { try { const delegate = this.#delegate(context); const predicate = compilePrismaPredicate( @@ -300,7 +344,7 @@ export class PrismaCrudAdapter< } } - getField(record: RecordType, field: string): unknown { + getField(record: RecordType, field: PrismaCrudLogicalField): unknown { if (typeof record !== "object" || record === null) return undefined; const key = this.#recordKeys[field] ?? this.#fields[field] ?? field; return (record as Readonly>)[key]; @@ -339,8 +383,10 @@ export function createPrismaCrudAdapter< Delegate, CreateValues extends object = PrismaCrudCreateValues, UpdateValues extends object = PrismaCrudUpdateValues, + const Fields extends PrismaCrudFields> = + PrismaCrudFields>, >( - options: PrismaCrudAdapterOptions, -): PrismaCrudAdapter { + options: PrismaCrudAdapterOptions, +): PrismaCrudAdapter { return new PrismaCrudAdapter(options); } diff --git a/packages/crud-prisma/src/prisma-predicate.ts b/packages/crud-prisma/src/prisma-predicate.ts index b8b1ab8..d8d9d9c 100644 --- a/packages/crud-prisma/src/prisma-predicate.ts +++ b/packages/crud-prisma/src/prisma-predicate.ts @@ -2,7 +2,9 @@ import type { CrudOrder, CrudPredicate } from "@nestm/crud/adapter"; export type PrismaCrudWhere = Readonly>; export type PrismaCrudOrderBy = Readonly>; -export type PrismaCrudFields = Readonly>; +export type PrismaCrudFields = Readonly< + Record +>; function fieldFor(fields: PrismaCrudFields, field: string): string { const persistenceField = fields[field] ?? field; diff --git a/packages/crud-prisma/tests/prisma-types.ts b/packages/crud-prisma/tests/prisma-types.ts index 74b91c1..5685371 100644 --- a/packages/crud-prisma/tests/prisma-types.ts +++ b/packages/crud-prisma/tests/prisma-types.ts @@ -25,11 +25,19 @@ interface UserUpdateValues { readonly tenantId?: string | { readonly set: string }; } +interface UserWhereUnique { + readonly id?: number; + readonly tenantId_name?: { + readonly tenantId: string; + readonly name: string; + }; +} + interface UserDelegate { create( arguments_: Arguments, ): PromiseLike; - update( + update( arguments_: Arguments, ): PromiseLike; } @@ -54,13 +62,62 @@ export const adapter = createPrismaCrudAdapter identity: (record) => ({ id: record.id }), }); -const nativeAdapter: CrudAdapter = adapter; +createPrismaCrudAdapter({ + client, + delegate: (owner) => owner.user, + identity: (record) => ({ id: record.id }), + fields: { + // @ts-expect-error Prisma field mappings must target model fields. + displayName: "email", + }, +}); + +createPrismaCrudAdapter({ + client, + delegate: (owner) => owner.user, + identity: (record) => ({ id: record.id }), + fields: { displayName: "name" }, + recordKeys: { + // @ts-expect-error record key mappings must target returned-record keys. + displayName: "displayName", + }, +}); + +createPrismaCrudAdapter({ + client, + delegate: (owner) => owner.user, + // @ts-expect-error identity must return the delegate's native unique selector. + identity: () => ({ email: "ada@example.com" }), +}); + +const nativeAdapter: CrudAdapter< + UserRecord, + UserCreateValues, + UserUpdateValues, + keyof UserCreateValues, + keyof UserRecord +> = adapter; void nativeAdapter; void adapter.create( { values: { name: "Ada", tenantId: "tenant" } }, { resource: "users", operation: "create" }, ); +void adapter.findMany( + { + predicate: { + kind: "comparison", + // @ts-expect-error query fields are inferred from the Prisma record shape. + field: "email", + operator: "eq", + value: "ada@example.com", + }, + order: [], + limit: 10, + count: false, + }, + { resource: "users", operation: "list" }, +); void adapter.create( { // @ts-expect-error Prisma delegate create data requires a string name. @@ -70,6 +127,7 @@ void adapter.create( ); const resource = defineCrudResource({ + fields: ["id", "name", "tenantId"], name: "prisma-users", path: "prisma-users", itemPath: ":id", @@ -86,7 +144,6 @@ const resource = defineCrudResource({ export const binding = bindPrismaCrud({ resource, adapter: { useValue: adapter }, - fields: ["id", "name", "tenantId"], mappings: { create: (input) => ({ name: input.name, tenantId: "tenant" }), update: (input) => (input.name === undefined ? {} : { name: input.name }), @@ -99,7 +156,6 @@ export const binding = bindPrismaCrud({ export const invalidBinding = bindPrismaCrud({ resource, adapter: { useValue: adapter }, - fields: ["id", "name", "tenantId"], mappings: { // @ts-expect-error binder mappings must return the delegate's create data type. create: () => ({ name: 123, tenantId: "tenant" }), @@ -109,6 +165,25 @@ export const invalidBinding = bindPrismaCrud({ }, }); +const invalidLogicalFieldResource = defineCrudResource({ + ...resource, + fields: ["id", "name", "tenantId", "email"], + name: "invalid-prisma-logical-field", + path: "invalid-prisma-logical-field", +}); + +// @ts-expect-error every resource field must exist in the adapter's logical field map. +bindPrismaCrud({ + resource: invalidLogicalFieldResource, + adapter: { useValue: adapter }, + mappings: { + create: (input) => ({ name: input.name, tenantId: "tenant" }), + update: (input) => (input.name === undefined ? {} : { name: input.name }), + persistence: () => ({}), + response: (record) => ({ id: record.id, name: record.name }), + }, +}); + type Equal = (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 ? (() => Value extends Right ? 1 : 2) extends () => Value extends Left ? 1 : 2 diff --git a/packages/crud-typeorm/README.md b/packages/crud-typeorm/README.md index 302ece4..15ab32f 100644 --- a/packages/crud-typeorm/README.md +++ b/packages/crud-typeorm/README.md @@ -25,7 +25,6 @@ const usersAdapter = createTypeOrmCrudAdapter({ const usersBinding = bindTypeOrmCrud({ resource: users, - fields: ["id", "email", "createdAt"], adapter: { useValue: usersAdapter }, mappings: { create: (input) => input, @@ -50,7 +49,7 @@ columns: { This modern object form also defines the hydrated selection. Set `select: false` to keep a column available to predicates without reading it. Legacy string maps remain full-entity mappings unless `select` or `exclude` is also configured. It -must cover every field listed by the binding. Define `mappings.persistence` when scopes or soft +must cover every field listed by the resource. Define `mappings.persistence` when scopes or soft delete generate logical values outside create/update input mappings; it may be omitted when those generated values are always empty. CRUD removes explicitly undefined optional mapper properties before invoking the adapter. diff --git a/packages/crud-typeorm/src/bind-typeorm-crud.ts b/packages/crud-typeorm/src/bind-typeorm-crud.ts index 79b3069..0f900bd 100644 --- a/packages/crud-typeorm/src/bind-typeorm-crud.ts +++ b/packages/crud-typeorm/src/bind-typeorm-crud.ts @@ -4,6 +4,7 @@ import { type CrudBindingMappings, type CrudBindingUpsertOptions, type CompleteCrudFieldSelection, + type DefineCrudBindingOptions, type CrudScopeCreateField, type CrudResourceBinding, } from "@nestm/crud/adapter"; @@ -11,6 +12,7 @@ import type { ModuleMetadata } from "@nestjs/common"; import type { DeepPartial, ObjectLiteral } from "typeorm"; import type { TypeOrmCrudAdapter } from "./typeorm-adapter.ts"; +import type { TypeOrmCrudPropertyPath } from "./typeorm-adapter.ts"; type BindableResource = CrudResourceBinding["resource"]; @@ -29,18 +31,24 @@ type TypeOrmScopeCreateField = CrudScopeCreate export type TypeOrmCrudAdapterProvider< EntityType extends ObjectLiteral, RecordType extends ObjectLiteral = EntityType, -> = CrudAdapterProvider, DeepPartial>; + LogicalField extends string = TypeOrmCrudPropertyPath, +> = CrudAdapterProvider< + RecordType, + DeepPartial, + DeepPartial, + TypeOrmCrudPropertyPath, + LogicalField +>; interface BindTypeOrmCrudOptionsBase< Resource extends BindableResource, EntityType extends ObjectLiteral, RecordType extends ObjectLiteral, - Fields extends readonly string[], ScopeCreateFields extends readonly TypeOrmScopeCreateField[], + LogicalField extends string, > { readonly resource: Resource; readonly imports?: ModuleMetadata["imports"]; - readonly fields: Fields; readonly mappings: CrudBindingMappings< Resource, NoInfer, @@ -59,32 +67,36 @@ interface BindTypeOrmCrudOptionsBase< */ readonly scopeCreateFields?: ScopeCreateFields; /** Atomic upsert conflict-target and mutable-overwrite persistence paths. */ - readonly upsert?: CrudBindingUpsertOptions; + readonly upsert?: CrudBindingUpsertOptions>; /** Standard Nest provider form for an adapter; injected repositories remain application-owned. */ - readonly adapter: TypeOrmCrudAdapterProvider; + readonly adapter: TypeOrmCrudAdapterProvider; } export type BindTypeOrmCrudOptions< Resource extends BindableResource, EntityType extends ObjectLiteral, - Fields extends readonly string[] = readonly string[], ScopeCreateFields extends readonly TypeOrmScopeCreateField[] = readonly [], RecordType extends ObjectLiteral = EntityType, -> = BindTypeOrmCrudOptionsBase & - CompleteCrudFieldSelection; + LogicalField extends string = TypeOrmCrudPropertyPath, +> = BindTypeOrmCrudOptionsBase & + CompleteCrudFieldSelection; + +export type TypeOrmCrudAdapterLogicalField = + Adapter extends TypeOrmCrudAdapter + ? LogicalField + : never; type BindTypeOrmCrudValueOptions< Resource extends BindableResource, Adapter, - Fields extends readonly string[], ScopeCreateFields extends readonly TypeOrmScopeCreateField>[], > = Omit< BindTypeOrmCrudOptions< Resource, TypeOrmCrudAdapterEntity, - Fields, ScopeCreateFields, - TypeOrmCrudAdapterRecord + TypeOrmCrudAdapterRecord, + TypeOrmCrudAdapterLogicalField >, "adapter" > & { @@ -103,12 +115,8 @@ type BindTypeOrmCrudInjectedOptions< Resource extends BindableResource, EntityType extends ObjectLiteral, RecordType extends ObjectLiteral, - Fields extends readonly string[], ScopeCreateFields extends readonly TypeOrmScopeCreateField[], -> = Omit< - BindTypeOrmCrudOptions, - "adapter" -> & { +> = Omit, "adapter"> & { readonly adapter: Exclude< TypeOrmCrudAdapterProvider, { readonly useValue: unknown } @@ -119,60 +127,76 @@ type BindTypeOrmCrudInjectedOptions< export function bindTypeOrmCrud< const Resource extends BindableResource, const Adapter, - const Fields extends readonly string[], const ScopeCreateFields extends readonly TypeOrmScopeCreateField< TypeOrmCrudAdapterEntity >[] = readonly [], >( - options: BindTypeOrmCrudValueOptions, + options: BindTypeOrmCrudValueOptions, ): CrudResourceBinding< Resource, TypeOrmCrudAdapterRecord, - Fields, DeepPartial>, DeepPartial>, - ScopeCreateFields[number] + ScopeCreateFields[number], + TypeOrmCrudPropertyPath>, + TypeOrmCrudAdapterLogicalField >; export function bindTypeOrmCrud< const Resource extends BindableResource, EntityType extends ObjectLiteral, - const Fields extends readonly string[], const ScopeCreateFields extends readonly TypeOrmScopeCreateField[] = readonly [], RecordType extends ObjectLiteral = EntityType, >( - options: BindTypeOrmCrudInjectedOptions< - Resource, - EntityType, - RecordType, - Fields, - ScopeCreateFields - >, + options: BindTypeOrmCrudInjectedOptions, ): CrudResourceBinding< Resource, RecordType, - Fields, DeepPartial, DeepPartial, - ScopeCreateFields[number] + ScopeCreateFields[number], + TypeOrmCrudPropertyPath, + TypeOrmCrudPropertyPath >; export function bindTypeOrmCrud< const Resource extends BindableResource, EntityType extends ObjectLiteral, - const Fields extends readonly string[], const ScopeCreateFields extends readonly TypeOrmScopeCreateField[] = readonly [], RecordType extends ObjectLiteral = EntityType, + LogicalField extends string = string, >( - options: BindTypeOrmCrudOptions, + options: BindTypeOrmCrudOptions< + Resource, + EntityType, + ScopeCreateFields, + RecordType, + LogicalField + >, ): CrudResourceBinding< Resource, RecordType, - Fields, DeepPartial, DeepPartial, - ScopeCreateFields[number] + ScopeCreateFields[number], + TypeOrmCrudPropertyPath, + LogicalField > { - return defineCrudBinding({ - ...options, - adapter: options.adapter, - }); + return defineCrudBinding< + Resource, + RecordType, + DeepPartial, + DeepPartial, + ScopeCreateFields, + TypeOrmCrudPropertyPath, + LogicalField + >( + options as unknown as DefineCrudBindingOptions< + Resource, + RecordType, + DeepPartial, + DeepPartial, + ScopeCreateFields, + TypeOrmCrudPropertyPath, + LogicalField + >, + ); } diff --git a/packages/crud-typeorm/src/index.ts b/packages/crud-typeorm/src/index.ts index a65a395..2ddd5f7 100644 --- a/packages/crud-typeorm/src/index.ts +++ b/packages/crud-typeorm/src/index.ts @@ -1,6 +1,7 @@ export { bindTypeOrmCrud, type BindTypeOrmCrudOptions, + type TypeOrmCrudAdapterLogicalField, type TypeOrmCrudAdapterProvider, } from "./bind-typeorm-crud.ts"; export { @@ -18,6 +19,7 @@ export { type TypeOrmCrudConfiguredRecord, type TypeOrmCrudCreateValues, type TypeOrmCrudEffectiveTransaction, + type TypeOrmCrudLogicalField, type TypeOrmCrudOperationTransactionOptions, type TypeOrmCrudPropertyPath, type TypeOrmCrudRowPredicate, diff --git a/packages/crud-typeorm/src/typeorm-adapter.ts b/packages/crud-typeorm/src/typeorm-adapter.ts index 66f23b0..9a89c9c 100644 --- a/packages/crud-typeorm/src/typeorm-adapter.ts +++ b/packages/crud-typeorm/src/typeorm-adapter.ts @@ -241,6 +241,15 @@ export type TypeOrmCrudConfiguredRecord< : Omit> : TypeOrmCrudSelectedRecord & object>; +/** Public logical field names exposed by one literal TypeORM column selection. */ +export type TypeOrmCrudLogicalField< + Entity extends ObjectLiteral, + Columns extends Readonly>, + Excluded extends readonly TypeOrmCrudPropertyPath[] = readonly [], +> = "*" extends keyof Columns + ? Exclude, Excluded[number]> + : Exclude, "*">; + /** The alias every generated statement selects the resource's rows under. */ export const TYPEORM_CRUD_ALIAS = "crud_record"; /** The alias transaction-scoped reference lookups select their target rows under. */ @@ -344,9 +353,10 @@ export type TypeOrmCrudReferencePredicate< interface TypeOrmCrudReferenceInputBase< EntityType extends ObjectLiteral, Context extends TypeOrmCrudReferenceContext, + LogicalField extends string, > { /** Neutral predicate whose fields resolve through the checker's logical column map. */ - readonly predicate?: CrudFindOneInput["predicate"]; + readonly predicate?: CrudFindOneInput["predicate"]; /** Native constraint for target-specific SQL or an RLS companion predicate. */ readonly nativePredicate?: TypeOrmCrudReferencePredicate; } @@ -358,19 +368,25 @@ interface TypeOrmCrudReferenceInputBase< export type TypeOrmCrudReferenceInput< EntityType extends ObjectLiteral, Context extends TypeOrmCrudReferenceContext = TypeOrmCrudReferenceContext, + LogicalField extends string = string, > = - | (TypeOrmCrudReferenceInputBase & { - readonly predicate: CrudFindOneInput["predicate"]; + | (TypeOrmCrudReferenceInputBase & { + readonly predicate: CrudFindOneInput["predicate"]; }) - | (TypeOrmCrudReferenceInputBase & { + | (TypeOrmCrudReferenceInputBase & { readonly nativePredicate: TypeOrmCrudReferencePredicate; }); -export interface TypeOrmCrudReferenceCheckerOptions { +export interface TypeOrmCrudReferenceCheckerOptions< + EntityType extends ObjectLiteral, + Columns extends Readonly>> = Readonly< + Record> + >, +> { /** Target metadata only; lookups acquire its repository from the active source manager. */ readonly target: EntityTarget; /** Maps reference-predicate logical fields to target entity scalar property paths. */ - readonly columns: Readonly>>; + readonly columns: Columns; } export interface TypeOrmCrudAdapterOptions { @@ -697,18 +713,26 @@ function activeTypeOrmSession(context: { * row lock. The query selects only a constant and uses a raw result, so TypeORM never * hydrates the target entity or invokes its transformers and `@AfterLoad` lifecycle. */ -export class TypeOrmCrudReferenceChecker { +export class TypeOrmCrudReferenceChecker< + EntityType extends ObjectLiteral, + LogicalField extends string = string, +> { readonly #target: EntityTarget; readonly #columns: Readonly>; readonly #validatedMetadata = new WeakSet(); - constructor(options: TypeOrmCrudReferenceCheckerOptions) { + constructor( + options: TypeOrmCrudReferenceCheckerOptions< + EntityType, + Readonly>> + >, + ) { this.#target = options.target; this.#columns = Object.freeze({ ...options.columns }); } async exists( - input: TypeOrmCrudReferenceInput, + input: TypeOrmCrudReferenceInput, context: Context, ): Promise { try { @@ -780,7 +804,7 @@ export class TypeOrmCrudReferenceChecker { this.#validatedMetadata.add(metadata); } - #fieldExpression(query: SelectQueryBuilder, field: string): string { + #fieldExpression(query: SelectQueryBuilder, field: LogicalField): string { const propertyPath = this.#columns[field]; if (propertyPath === undefined) { throw new CrudAdapterError( @@ -792,16 +816,27 @@ export class TypeOrmCrudReferenceChecker { } } -export function createTypeOrmCrudReferenceChecker( - options: TypeOrmCrudReferenceCheckerOptions, -): TypeOrmCrudReferenceChecker { - return new TypeOrmCrudReferenceChecker(options); +export function createTypeOrmCrudReferenceChecker< + EntityType extends ObjectLiteral, + const Columns extends Readonly>>, +>( + options: TypeOrmCrudReferenceCheckerOptions, +): TypeOrmCrudReferenceChecker> { + return new TypeOrmCrudReferenceChecker>(options); } export class TypeOrmCrudAdapter< EntityType extends ObjectLiteral, RecordType extends ObjectLiteral = EntityType, -> implements CrudAdapter, DeepPartial> { + LogicalField extends string = string, +> implements CrudAdapter< + RecordType, + DeepPartial, + DeepPartial, + TypeOrmCrudPropertyPath, + LogicalField +> { + declare private readonly __logicalField: LogicalField; readonly capabilities = Object.freeze({ transactions: true, returning: true, @@ -920,7 +955,11 @@ export class TypeOrmCrudAdapter< } async upsert( - input: CrudUpsertInput>, + input: CrudUpsertInput< + DeepPartial, + TypeOrmCrudPropertyPath, + LogicalField + >, context: CrudAdapterContext, ): Promise { try { @@ -977,7 +1016,10 @@ export class TypeOrmCrudAdapter< } } - async findOne(input: CrudFindOneInput, context: CrudAdapterContext): Promise { + async findOne( + input: CrudFindOneInput, + context: CrudAdapterContext, + ): Promise { try { return await this.#withRepository( context, @@ -998,7 +1040,7 @@ export class TypeOrmCrudAdapter< } async findMany( - input: CrudFindManyInput, + input: CrudFindManyInput, context: CrudAdapterContext, ): Promise> { try { @@ -1037,7 +1079,7 @@ export class TypeOrmCrudAdapter< } async update( - input: CrudUpdateInput>, + input: CrudUpdateInput, LogicalField>, context: CrudAdapterContext, ): Promise { try { @@ -1091,7 +1133,10 @@ export class TypeOrmCrudAdapter< } } - async delete(input: CrudDeleteInput, context: CrudAdapterContext): Promise { + async delete( + input: CrudDeleteInput, + context: CrudAdapterContext, + ): Promise { try { return await this.#withRepository( context, @@ -1138,7 +1183,7 @@ export class TypeOrmCrudAdapter< } } - getField(record: RecordType, field: string): unknown { + getField(record: RecordType, field: LogicalField): unknown { const propertyPath = this.#property(field); if ( this.#selectedPropertyPaths !== undefined && @@ -1741,7 +1786,8 @@ export class TypeOrmCrudAdapter< class ConcreteTypeOrmCrudAdapter< EntityType extends ObjectLiteral, RecordType extends ObjectLiteral = EntityType, -> extends TypeOrmCrudAdapter { + LogicalField extends string = string, +> extends TypeOrmCrudAdapter { constructor(options: TypeOrmCrudAdapterOptions) { super(options, TYPEORM_CRUD_ADAPTER_FACTORY); } @@ -1764,20 +1810,29 @@ function isTypeOrmCrudAdapterOptions( export function createTypeOrmCrudAdapter< EntityType extends ObjectLiteral, + const Columns extends Readonly>, const Selection extends FindOptionsSelect = FindOptionsSelect, >( - options: Omit, "select" | "exclude"> & { + options: Omit, "columns" | "select" | "exclude"> & { + readonly columns: Columns & TypeOrmCrudValidatedColumns; readonly select: Selection; readonly exclude?: undefined; }, -): TypeOrmCrudAdapter>; -export function createTypeOrmCrudAdapter( +): TypeOrmCrudAdapter< + EntityType, + TypeOrmCrudSelectedRecord, + TypeOrmCrudLogicalField +>; +export function createTypeOrmCrudAdapter< + EntityType extends ObjectLiteral, + const Columns extends Readonly>>, +>( options: Omit, "columns" | "select" | "exclude"> & { - readonly columns: Readonly>>; + readonly columns: Columns; readonly select?: undefined; readonly exclude?: undefined; }, -): TypeOrmCrudAdapter; +): TypeOrmCrudAdapter>; export function createTypeOrmCrudAdapter< EntityType extends ObjectLiteral, const Columns extends Readonly>, @@ -1788,7 +1843,11 @@ export function createTypeOrmCrudAdapter< readonly select?: undefined; readonly exclude?: Excluded; }, -): TypeOrmCrudAdapter>; +): TypeOrmCrudAdapter< + EntityType, + TypeOrmCrudConfiguredRecord, + TypeOrmCrudLogicalField +>; export function createTypeOrmCrudAdapter( options: TypeOrmCrudAdapterOptions, ): TypeOrmCrudAdapter>>; diff --git a/packages/crud-typeorm/src/typeorm-predicate.ts b/packages/crud-typeorm/src/typeorm-predicate.ts index a8a50e0..32b74ac 100644 --- a/packages/crud-typeorm/src/typeorm-predicate.ts +++ b/packages/crud-typeorm/src/typeorm-predicate.ts @@ -5,15 +5,15 @@ export interface TypeOrmCompiledPredicate { readonly parameters: Readonly>; } -export type TypeOrmFieldResolver = (field: string) => string; +export type TypeOrmFieldResolver = (field: Field) => string; -interface CompileState { +interface CompileState { nextParameter: number; readonly parameters: Record; - readonly resolveField: TypeOrmFieldResolver; + readonly resolveField: TypeOrmFieldResolver; } -function nextParameter(state: CompileState, value: unknown): string { +function nextParameter(state: CompileState, value: unknown): string { const name = `crud_${state.nextParameter++}`; state.parameters[name] = value; return name; @@ -30,9 +30,9 @@ function requireArray(value: unknown, operator: string): readonly unknown[] { return value; } -function compileComparison( - predicate: Extract, - state: CompileState, +function compileComparison( + predicate: Extract, { kind: "comparison" }>, + state: CompileState, ): string { const field = state.resolveField(predicate.field); const value = predicate.value; @@ -88,7 +88,10 @@ function compileComparison( } } -function compileNode(predicate: CrudPredicate, state: CompileState): string { +function compileNode( + predicate: CrudPredicate, + state: CompileState, +): string { switch (predicate.kind) { case "comparison": return compileComparison(predicate, state); @@ -104,10 +107,10 @@ function compileNode(predicate: CrudPredicate, state: CompileState): string { } /** Compiles the neutral CRUD predicate to TypeORM QueryBuilder SQL and named parameters. */ -export function compileTypeOrmPredicate( - predicate: CrudPredicate, - resolveField: TypeOrmFieldResolver, +export function compileTypeOrmPredicate( + predicate: CrudPredicate, + resolveField: TypeOrmFieldResolver, ): TypeOrmCompiledPredicate { - const state: CompileState = { nextParameter: 0, parameters: {}, resolveField }; + const state: CompileState = { nextParameter: 0, parameters: {}, resolveField }; return { sql: compileNode(predicate, state), parameters: state.parameters }; } diff --git a/packages/crud-typeorm/tests/typeorm-adapter.spec.ts b/packages/crud-typeorm/tests/typeorm-adapter.spec.ts index ef1c5b1..1cd3692 100644 --- a/packages/crud-typeorm/tests/typeorm-adapter.spec.ts +++ b/packages/crud-typeorm/tests/typeorm-adapter.spec.ts @@ -264,7 +264,15 @@ describe("TypeOrmCrudAdapter construction", () => { }); await expect( adapter.findOne( - { predicate: { kind: "comparison", field: "secret", operator: "eq", value: 1 } }, + { + predicate: { + kind: "comparison", + // The cast deliberately bypasses the public field vocabulary to exercise runtime defense. + field: "secret" as "id", + operator: "eq", + value: 1, + }, + }, context("read"), ), ).rejects.toMatchObject({ code: "unsupported" }); diff --git a/packages/crud-typeorm/tests/typeorm-types.ts b/packages/crud-typeorm/tests/typeorm-types.ts index 7e0e165..2ea48b7 100644 --- a/packages/crud-typeorm/tests/typeorm-types.ts +++ b/packages/crud-typeorm/tests/typeorm-types.ts @@ -45,6 +45,18 @@ void referenceChecker.exists( }, typedValidationContext, ); +void referenceChecker.exists( + { + predicate: { + kind: "comparison", + // @ts-expect-error reference predicates use the checker's exact logical columns. + field: "name", + operator: "eq", + value: "Ada", + }, + }, + typedValidationContext, +); // @ts-expect-error Reference checks cannot be invoked without an active session. void referenceChecker.exists({ predicate: { kind: "and", predicates: [] } }, {}); // @ts-expect-error Reference checks must always declare an explicit scoped predicate. @@ -56,6 +68,21 @@ export const adapter = createTypeOrmCrudAdapter({ transaction: { isolationLevel: TypeOrmCrudTransactionIsolationLevel.RepeatableRead }, }); +void adapter.findMany( + { + order: [ + { + // @ts-expect-error query fields are inferred from the adapter's logical columns. + field: "email", + direction: "asc", + }, + ], + limit: 10, + count: false, + }, + { resource: "users", operation: "list" }, +); + export const selectedAdapter = createTypeOrmCrudAdapter({ repository, columns: { id: "id", name: "name", tenantId: "tenantId", secret: "secret" }, @@ -186,7 +213,9 @@ void widenedAdapter; const nativeAdapter: CrudAdapter< UserEntity, DeepPartial, - DeepPartial + DeepPartial, + keyof UserEntity, + "id" | "name" | "tenantId" | "secret" > = adapter; void nativeAdapter; @@ -209,6 +238,7 @@ void adapter.create( ); const resource = defineCrudResource({ + fields: ["id", "name", "tenantId"], name: "typeorm-users", path: "typeorm-users", itemPath: ":id", @@ -223,6 +253,7 @@ const resource = defineCrudResource({ }); const upsertResource = defineCrudResource({ + fields: ["id", "name"], name: "typeorm-user-upserts", path: "typeorm-user-upserts", itemPath: ":id", @@ -237,12 +268,9 @@ const upsertResource = defineCrudResource({ operations: crudOperations.only("upsert"), }); -const fields = ["id", "name", "tenantId"] as const; - const selectedBinding = bindTypeOrmCrud({ resource, adapter: { useValue: selectedAdapter }, - fields: ["id", "name"], mappings: { create: (input) => ({ name: input.name, tenantId: "tenant", secret: "encrypted" }), update: (input) => input, @@ -259,7 +287,6 @@ const selectedBinding = bindTypeOrmCrud({ const binding = bindTypeOrmCrud({ resource, adapter: { useValue: adapter }, - fields, mappings: { create: (input) => ({ name: input.name, tenantId: "tenant" }), update: (input) => (input.name === undefined ? {} : { name: input.name }), @@ -269,10 +296,9 @@ const binding = bindTypeOrmCrud({ }, }); -const invalidOptions: BindTypeOrmCrudOptions = { +const invalidOptions: BindTypeOrmCrudOptions = { resource, adapter: { useValue: adapter }, - fields, mappings: { // @ts-expect-error binder mappings must return DeepPartial values. create: () => ({ name: 123 }), @@ -295,7 +321,6 @@ const invalidOptions: BindTypeOrmCrudOptions ({ name: input.name }), @@ -308,7 +333,6 @@ const scopedBinding = bindTypeOrmCrud({ const upsertBinding = bindTypeOrmCrud({ resource: upsertResource, adapter: { useValue: adapter }, - fields, scopeCreateFields: ["tenantId"], upsert: { conflictFields: ["id"], @@ -323,10 +347,41 @@ const upsertBinding = bindTypeOrmCrud({ }, }); +const invalidLogicalFieldResource = defineCrudResource({ + ...resource, + fields: ["id", "name", "tenantId", "email"], + name: "invalid-logical-field-resource", + path: "invalid-logical-field-resource", +}); + +// @ts-expect-error every resource field must exist in the adapter's logical column vocabulary. +const invalidLogicalFieldBinding = bindTypeOrmCrud({ + resource: invalidLogicalFieldResource, + adapter: { useValue: adapter }, + mappings: { + create: (input) => ({ name: input.name, tenantId: "tenant" }), + update: (input) => (input.name === undefined ? {} : { name: input.name }), + persistence: () => ({}), + response: (record) => ({ id: record.id, name: record.name }), + }, +}); + +const invalidUpsertPersistenceFieldBinding = bindTypeOrmCrud({ + resource: upsertResource, + adapter: { useValue: adapter }, + upsert: { + conflictFields: [ + // @ts-expect-error conflict fields must be TypeORM entity property paths. + "workspaceId", + ], + overwriteFields: ["name"], + }, + mappings: upsertBinding.mappings, +}); + type InvalidScopeCreateField = BindTypeOrmCrudOptions< typeof resource, UserEntity, - typeof fields, // @ts-expect-error scope-owned fields must be properties of the entity's create values. readonly ["notAColumn"] >; @@ -336,5 +391,7 @@ void selectedBinding; void invalidOptions; void scopedBinding; void upsertBinding; +void invalidLogicalFieldBinding; +void invalidUpsertPersistenceFieldBinding; declare const invalidScopeCreateField: InvalidScopeCreateField; void invalidScopeCreateField; diff --git a/packages/crud-typeorm/tests/typeorm-upsert.spec.ts b/packages/crud-typeorm/tests/typeorm-upsert.spec.ts index b770514..f42fdf3 100644 --- a/packages/crud-typeorm/tests/typeorm-upsert.spec.ts +++ b/packages/crud-typeorm/tests/typeorm-upsert.spec.ts @@ -470,7 +470,7 @@ describe("TypeOrmCrudAdapter atomic upsert", () => { }); it("rejects partial, duplicated, non-unique, and absent conflict paths before DML", async () => { - const cases: readonly CrudUpsertInput>[] = [ + const cases: readonly Parameters["upsert"]>[0][] = [ { ...UPSERT_INPUT, conflictFields: ["tenantId"] }, { ...UPSERT_INPUT, conflictFields: ["tenantId", "tenantId"] }, { ...UPSERT_INPUT, conflictFields: ["tenantId", "name"] }, @@ -523,7 +523,16 @@ describe("TypeOrmCrudAdapter atomic upsert", () => { for (const overwriteFields of [["id"], ["immutable"], ["missing"], ["name", "name"]] as const) { const harness = createUpsertHarness(); await expect( - selectedAdapter(harness).upsert({ ...UPSERT_INPUT, overwriteFields }, context()), + selectedAdapter(harness).upsert( + { + ...UPSERT_INPUT, + // Deliberately bypass the public field vocabulary to exercise runtime defenses. + overwriteFields: overwriteFields as CrudUpsertInput< + DeepPartial + >["overwriteFields"], + }, + context(), + ), ).rejects.toMatchObject({ code: "unsupported" } satisfies Partial); expect(harness.capture.inserts).toHaveLength(0); } diff --git a/packages/crud/src/adapter/adapter.types.ts b/packages/crud/src/adapter/adapter.types.ts index c0db575..fc24338 100644 --- a/packages/crud/src/adapter/adapter.types.ts +++ b/packages/crud/src/adapter/adapter.types.ts @@ -5,6 +5,20 @@ import type { CrudOrder, CrudPredicate } from "../query/query.types.ts"; export type CrudValues = Readonly>; +/** String property names exposed by a statically known persistence value shape. */ +type CrudKnownPersistenceField = Values extends unknown + ? Extract + : never; + +export type CrudPersistenceField = [ + CrudKnownPersistenceField, +] extends [never] + ? string + : CrudKnownPersistenceField; + +/** A readonly tuple containing at least one persistence field. */ +export type CrudPersistenceFieldTuple = readonly [Field, ...Field[]]; + export interface CrudAdapterCapabilities { readonly transactions: boolean; readonly returning: boolean; @@ -28,14 +42,14 @@ export interface CrudAdapterContext { readonly pathParams?: CrudValues; } -export interface CrudFindOneInput { - readonly predicate: CrudPredicate; - readonly order?: readonly CrudOrder[]; +export interface CrudFindOneInput { + readonly predicate: CrudPredicate; + readonly order?: readonly CrudOrder[]; } -export interface CrudFindManyInput { - readonly predicate?: CrudPredicate; - readonly order: readonly CrudOrder[]; +export interface CrudFindManyInput { + readonly predicate?: CrudPredicate; + readonly order: readonly CrudOrder[]; readonly offset?: number; readonly limit: number; readonly count: boolean; @@ -50,30 +64,39 @@ export interface CrudCreateInput { readonly values: CreateValues; } -export interface CrudUpdateInput { - readonly predicate: CrudPredicate; +export interface CrudUpdateInput< + UpdateValues extends object = CrudValues, + QueryField extends string = string, +> { + readonly predicate: CrudPredicate; readonly values: UpdateValues; } -export interface CrudUpsertInput { +export interface CrudUpsertInput< + CreateValues extends object = CrudValues, + PersistenceField extends string = CrudPersistenceField, + QueryField extends string = string, +> { /** Complete, non-empty adapter persistence paths forming the conflict target. */ - readonly conflictFields: readonly [string, ...string[]]; + readonly conflictFields: CrudPersistenceFieldTuple; /** Predicate that must still match when the conflict branch updates an existing row. */ - readonly predicate: CrudPredicate; + readonly predicate: CrudPredicate; /** One proposed insert row. Conflict updates copy only `overwriteFields` from this row. */ readonly values: CreateValues; /** Non-empty adapter persistence paths copied from the proposed row on conflict. */ - readonly overwriteFields: readonly [string, ...string[]]; + readonly overwriteFields: CrudPersistenceFieldTuple; } -export interface CrudDeleteInput { - readonly predicate: CrudPredicate; +export interface CrudDeleteInput { + readonly predicate: CrudPredicate; } export interface CrudAdapter< RecordType = unknown, CreateValues extends object = object, UpdateValues extends object = object, + PersistenceField extends string = CrudPersistenceField, + QueryField extends string = string, > { readonly capabilities: CrudAdapterCapabilities; /** @@ -87,13 +110,16 @@ export interface CrudAdapter< context: CrudAdapterContext, ): Promise; create(input: CrudCreateInput, context: CrudAdapterContext): Promise; - findOne(input: CrudFindOneInput, context: CrudAdapterContext): Promise; + findOne( + input: CrudFindOneInput, + context: CrudAdapterContext, + ): Promise; findMany( - input: CrudFindManyInput, + input: CrudFindManyInput, context: CrudAdapterContext, ): Promise>; update( - input: CrudUpdateInput, + input: CrudUpdateInput, context: CrudAdapterContext, ): Promise; /** @@ -103,22 +129,39 @@ export interface CrudAdapter< * the conflict-update statement and return `null` when an existing row is not visible. */ upsert?( - input: CrudUpsertInput, + input: CrudUpsertInput, context: CrudAdapterContext, ): Promise; - delete(input: CrudDeleteInput, context: CrudAdapterContext): Promise; - getField(record: RecordType, field: string): unknown; + delete( + input: CrudDeleteInput, + context: CrudAdapterContext, + ): Promise; + getField(record: RecordType, field: QueryField): unknown; } +/** Logical query/order field vocabulary exposed by an adapter type. */ +export type CrudAdapterQueryField = + Adapter extends CrudAdapter< + infer _RecordType, + infer _CreateValues extends object, + infer _UpdateValues extends object, + infer _PersistenceField extends string, + infer QueryField extends string + > + ? QueryField + : never; + /** Adapter refinement for implementations certified for the atomic upsert contract. */ export interface CrudUpsertAdapter< RecordType = unknown, CreateValues extends object = object, UpdateValues extends object = object, -> extends CrudAdapter { + PersistenceField extends string = CrudPersistenceField, + QueryField extends string = string, +> extends CrudAdapter { readonly capabilities: CrudAdapterCapabilities & { readonly upsert: true }; upsert( - input: CrudUpsertInput, + input: CrudUpsertInput, context: CrudAdapterContext, ): Promise; } @@ -127,6 +170,8 @@ export type CrudAdapterFactory< RecordType = unknown, CreateValues extends object = object, UpdateValues extends object = object, + PersistenceField extends string = CrudPersistenceField, + QueryField extends string = string, > = () => - | CrudAdapter - | Promise>; + | CrudAdapter + | Promise>; diff --git a/packages/crud/src/adapter/binding.types.ts b/packages/crud/src/adapter/binding.types.ts index 9a86e81..2dc31e7 100644 --- a/packages/crud/src/adapter/binding.types.ts +++ b/packages/crud/src/adapter/binding.types.ts @@ -4,13 +4,18 @@ import type { CrudFactoryDependency } from "../module/factory-provider.types.ts" import type { AnyCrudResource, CrudCreate, + CrudField, + CrudFieldValues, CrudId, - CrudRequiredField, CrudResponseInput, CrudUpdate, CrudUpsert, } from "../resource/resource.types.ts"; -import type { CrudAdapter, CrudValues } from "./adapter.types.ts"; +import type { + CrudAdapter, + CrudPersistenceField, + CrudPersistenceFieldTuple, +} from "./adapter.types.ts"; export const CRUD_BINDING = Symbol.for("@nestm/crud:binding"); @@ -23,13 +28,31 @@ export type CrudAdapterProvider< RecordType = unknown, CreateValues extends object = object, UpdateValues extends object = object, + PersistenceField extends string = CrudPersistenceField, + QueryField extends string = string, > = - | { readonly useValue: CrudAdapter } - | { readonly useClass: Type> } | { - readonly useExisting: InjectionToken>; + readonly useValue: CrudAdapter< + RecordType, + CreateValues, + UpdateValues, + PersistenceField, + QueryField + >; + } + | { + readonly useClass: Type< + CrudAdapter + >; + } + | { + readonly useExisting: InjectionToken< + CrudAdapter + >; } - | CrudCompatibleFactoryProvider>; + | CrudCompatibleFactoryProvider< + CrudAdapter + >; /** Adapter insert fields that are materialized from scope-owned logical values. */ export type CrudScopeCreateField< @@ -83,7 +106,7 @@ export interface CrudBindingMappings< * `unknown` inputs because scopes are application code and may supply any value. */ scopeCreate?( - values: CrudValues, + values: CrudFieldValues, ): | CrudMappingValues>> | Promise>>>; @@ -102,7 +125,7 @@ export interface CrudBindingMappings< * and soft delete) to the adapter's update input. */ persistence?( - values: CrudValues, + values: CrudFieldValues, ): CrudMappingValues | Promise>; /** * `projected` carries the merged output of the resource's {@link CrudProjection}s for this @@ -116,25 +139,32 @@ export interface CrudBindingMappings< ): CrudResponseInput | Promise>; } -export interface CrudBindingUpsertOptions { +export interface CrudBindingUpsertOptions { /** Complete, non-empty adapter persistence paths forming the conflict target. */ - readonly conflictFields: readonly [string, ...string[]]; + readonly conflictFields: CrudPersistenceFieldTuple; /** Adapter persistence paths copied from the proposed insert row on conflict. */ - readonly overwriteFields: readonly [string, ...string[]]; + readonly overwriteFields: CrudPersistenceFieldTuple; } export interface CrudResourceBinding< Resource extends AnyCrudResource = AnyCrudResource, RecordType = unknown, - Fields extends readonly string[] = readonly string[], CreateValues extends object = object, UpdateValues extends object = object, ScopeCreateField extends keyof CreateValues = never, + PersistenceField extends string = CrudPersistenceField, + QueryField extends string = CrudField, > { readonly [CRUD_BINDING]: true; readonly resource: Resource; readonly imports?: ModuleMetadata["imports"]; - readonly adapter: CrudAdapterProvider; + readonly adapter: CrudAdapterProvider< + RecordType, + CreateValues, + UpdateValues, + PersistenceField, + QueryField + >; readonly mappings: CrudBindingMappings< Resource, NoInfer, @@ -143,43 +173,46 @@ export interface CrudResourceBinding< NoInfer >; /** Adapter insert fields supplied by scopes, with automatic same-name mapping. */ - readonly scopeCreateFields?: readonly string[]; + readonly scopeCreateFields?: readonly ([Extract] extends [never] + ? string + : ScopeCreateField)[]; /** Required adapter-level configuration when the resource enables atomic upsert. */ - readonly upsert?: CrudBindingUpsertOptions; - readonly fields: Fields; + readonly upsert?: CrudBindingUpsertOptions; } export type MissingCrudBindingFields< Resource extends AnyCrudResource, - Fields extends readonly string[], -> = Exclude, Fields[number]>; + QueryField extends string, +> = Exclude, QueryField>; export type CompleteCrudFieldSelection< Resource extends AnyCrudResource, - Fields extends readonly string[], -> = [MissingCrudBindingFields] extends [never] + QueryField extends string, +> = [MissingCrudBindingFields] extends [never] ? unknown : { /** @internal Compile-time diagnostic listing required logical fields. */ - readonly __missingCrudFields: MissingCrudBindingFields; + readonly __missingCrudFields: MissingCrudBindingFields; }; export type DefineCrudBindingOptions< Resource extends AnyCrudResource, RecordType, - Fields extends readonly string[], CreateValues extends object = object, UpdateValues extends object = object, ScopeCreateFields extends readonly CrudScopeCreateField[] = readonly [], + PersistenceField extends string = CrudPersistenceField, + QueryField extends string = CrudField, > = Omit< CrudResourceBinding< Resource, RecordType, - Fields, CreateValues, UpdateValues, - ScopeCreateFields[number] + ScopeCreateFields[number], + PersistenceField, + QueryField >, typeof CRUD_BINDING | "mappings" | "scopeCreateFields" | "upsert" > & { @@ -191,33 +224,36 @@ export type DefineCrudBindingOptions< NoInfer >; readonly scopeCreateFields?: ScopeCreateFields; - readonly upsert?: CrudBindingUpsertOptions; -} & CompleteCrudFieldSelection; + readonly upsert?: CrudBindingUpsertOptions>; +} & CompleteCrudFieldSelection; export function defineCrudBinding< Resource extends AnyCrudResource, RecordType, - const Fields extends readonly string[], CreateValues extends object = object, UpdateValues extends object = object, const ScopeCreateFields extends readonly CrudScopeCreateField[] = readonly [], + PersistenceField extends string = CrudPersistenceField, + QueryField extends string = CrudField, >( options: DefineCrudBindingOptions< Resource, RecordType, - Fields, CreateValues, UpdateValues, - ScopeCreateFields + ScopeCreateFields, + PersistenceField, + QueryField >, ): CrudResourceBinding< Resource, RecordType, - Fields, CreateValues, UpdateValues, - ScopeCreateFields[number] + ScopeCreateFields[number], + PersistenceField, + QueryField > { const { scopeCreateFields, upsert, ...bindingOptions } = options; const adapter = Object.freeze({ @@ -225,7 +261,7 @@ export function defineCrudBinding< ...("inject" in options.adapter && options.adapter.inject !== undefined ? { inject: Object.freeze([...options.adapter.inject]) } : {}), - }) as CrudAdapterProvider; + }) as CrudAdapterProvider; return Object.freeze({ ...bindingOptions, ...(options.imports === undefined ? {} : { imports: Object.freeze([...options.imports]) }), @@ -243,15 +279,15 @@ export function defineCrudBinding< overwriteFields: Object.freeze([...upsert.overwriteFields]), }), }), - fields: Object.freeze([...options.fields]) as unknown as Fields, [CRUD_BINDING]: true as const, }) as unknown as CrudResourceBinding< Resource, RecordType, - Fields, CreateValues, UpdateValues, - ScopeCreateFields[number] + ScopeCreateFields[number], + PersistenceField, + QueryField >; } diff --git a/packages/crud/src/adapter/index.ts b/packages/crud/src/adapter/index.ts index f61688a..e9052f4 100644 --- a/packages/crud/src/adapter/index.ts +++ b/packages/crud/src/adapter/index.ts @@ -22,12 +22,15 @@ export type { CrudAdapterCapabilities, CrudAdapterContext, CrudAdapterFactory, + CrudAdapterQueryField, CrudAdapterSession, CrudCreateInput, CrudDeleteInput, CrudFindManyInput, CrudFindManyResult, CrudFindOneInput, + CrudPersistenceField, + CrudPersistenceFieldTuple, CrudUpdateInput, CrudUpsertAdapter, CrudUpsertInput, diff --git a/packages/crud/src/controller/controller.factory.ts b/packages/crud/src/controller/controller.factory.ts index b0bbdaa..a0d2427 100644 --- a/packages/crud/src/controller/controller.factory.ts +++ b/packages/crud/src/controller/controller.factory.ts @@ -32,6 +32,7 @@ import type { CrudCreate, CrudId, CrudPathParams, + CrudRelationName, CrudUpdate, CrudUpsert, } from "../resource/resource.types.ts"; @@ -471,7 +472,10 @@ function collectionArguments( return args as unknown as CrudCollectionArgs; } -function readIncludes(resource: AnyCrudResource, query: CrudRawQuery): readonly string[] { +function readIncludes( + resource: Resource, + query: CrudRawQuery, +): readonly CrudRelationName[] { let raw: unknown; if (query instanceof URLSearchParams) { for (const key of query.keys()) { @@ -503,5 +507,5 @@ function readIncludes(resource: AnyCrudResource, query: CrudRawQuery): readonly if (new Set(includes).size !== includes.length) { throw new BadRequestException("The include query parameter contains a repeated relation."); } - return includes; + return includes as unknown as readonly CrudRelationName[]; } diff --git a/packages/crud/src/cursor/cursor-predicate.ts b/packages/crud/src/cursor/cursor-predicate.ts index 2100180..3225ddc 100644 --- a/packages/crud/src/cursor/cursor-predicate.ts +++ b/packages/crud/src/cursor/cursor-predicate.ts @@ -5,21 +5,21 @@ import { CrudCursorError } from "./cursor.error.ts"; * Produces `(a > A) OR (a = A AND b > B) ...`, reversing comparisons for * descending order, so adapters can compile a portable keyset predicate. */ -export function buildCrudCursorPredicate( - order: readonly CrudOrder[], +export function buildCrudCursorPredicate( + order: readonly CrudOrder[], values: readonly unknown[], -): CrudPredicate { +): CrudPredicate { if (order.length === 0 || order.length !== values.length) { throw new CrudCursorError("invalid_payload"); } - const branches: CrudPredicate[] = []; + const branches: CrudPredicate[] = []; for (let index = 0; index < order.length; index += 1) { const currentOrder = order[index]; const currentValue = values[index]; if (currentOrder === undefined || currentValue === null || currentValue === undefined) { throw new CrudCursorError("invalid_payload"); } - const comparisons: CrudPredicate[] = []; + const comparisons: CrudPredicate[] = []; for (let prefixIndex = 0; prefixIndex < index; prefixIndex += 1) { const prefixOrder = order[prefixIndex]; const prefixValue = values[prefixIndex]; diff --git a/packages/crud/src/cursor/cursor.ts b/packages/crud/src/cursor/cursor.ts index 280c2e0..f207f6b 100644 --- a/packages/crud/src/cursor/cursor.ts +++ b/packages/crud/src/cursor/cursor.ts @@ -6,9 +6,9 @@ import { type CrudCursorCodec, } from "./cursor.types.ts"; -export async function encodeCrudCursor( +export async function encodeCrudCursor( codec: CrudCursorCodec, - binding: CrudCursorBinding, + binding: CrudCursorBinding, values: readonly unknown[], ): Promise { assertFixedValues(binding, values); @@ -21,11 +21,11 @@ export async function encodeCrudCursor( } /** Decodes a cursor and verifies that it belongs to the requested resource and exact ordering. */ -export async function decodeCrudCursor( +export async function decodeCrudCursor( codec: CrudCursorCodec, token: string, - binding: CrudCursorBinding, -): Promise { + binding: CrudCursorBinding, +): Promise> { let cursor: CrudCursor; try { cursor = await codec.decode(token); @@ -44,10 +44,13 @@ export async function decodeCrudCursor( throw new CrudCursorError("binding_mismatch"); } assertFixedValues(binding, cursor.values); - return cursor; + return cursor as CrudCursor; } -function assertFixedValues(binding: CrudCursorBinding, values: readonly unknown[]): void { +function assertFixedValues( + binding: CrudCursorBinding, + values: readonly unknown[], +): void { for (const fixed of binding.fixed ?? []) { const indices = binding.order.flatMap((order, index) => order.field === fixed.field ? [index] : [], diff --git a/packages/crud/src/cursor/cursor.types.ts b/packages/crud/src/cursor/cursor.types.ts index 3dc41b3..abffc74 100644 --- a/packages/crud/src/cursor/cursor.types.ts +++ b/packages/crud/src/cursor/cursor.types.ts @@ -2,16 +2,16 @@ import type { CrudOrder } from "../query/query.types.ts"; export const CRUD_CURSOR_VERSION = 1 as const; -export interface CrudCursor { +export interface CrudCursor { readonly version: typeof CRUD_CURSOR_VERSION; readonly resource: string; - readonly order: readonly CrudOrder[]; + readonly order: readonly CrudOrder[]; readonly values: readonly unknown[]; } -export interface CrudCursorBinding { +export interface CrudCursorBinding { readonly resource: string; - readonly order: readonly CrudOrder[]; + readonly order: readonly CrudOrder[]; /** * Ordered fields whose values are fixed by the collection route rather than * chosen by the client, for example a nested resource's parent identity. @@ -20,11 +20,11 @@ export interface CrudCursorBinding { * a valid cursor issued for one parent collection from being replayed under * another parent with the same resource and ordering. */ - readonly fixed?: readonly CrudCursorFixedValue[]; + readonly fixed?: readonly CrudCursorFixedValue[]; } -export interface CrudCursorFixedValue { - readonly field: string; +export interface CrudCursorFixedValue { + readonly field: Field; readonly value: unknown; } diff --git a/packages/crud/src/index.ts b/packages/crud/src/index.ts index 8bde147..0be60ec 100644 --- a/packages/crud/src/index.ts +++ b/packages/crud/src/index.ts @@ -29,7 +29,11 @@ export type { AnyCrudResource, CrudContracts, CrudCreate, + CrudField, + CrudFieldTuple, + CrudFieldValues, CrudRequiredField, + CrudRelationName, DefinedCrudResource, CrudHookType, CrudId, @@ -62,6 +66,7 @@ export type { CrudProjection, CrudScope, CrudScopeResult, + CrudScopeValues, CrudValidationContext, } from "./runtime/runtime.types.ts"; export { defineCrudFact, provideCrudFact } from "./runtime/crud-facts.ts"; @@ -97,6 +102,7 @@ export type { CrudRawQuery, CrudSearchConfig, CrudSortConfig, + CrudSortExpression, CrudSortDirection, } from "./query/query.types.ts"; diff --git a/packages/crud/src/query/predicate.ts b/packages/crud/src/query/predicate.ts index 16c511b..86be9c1 100644 --- a/packages/crud/src/query/predicate.ts +++ b/packages/crud/src/query/predicate.ts @@ -1,11 +1,11 @@ import type { CrudPredicate } from "./query.types.ts"; /** Combines predicates without producing redundant one-element `and` nodes. */ -export function andCrudPredicates( - ...predicates: readonly (CrudPredicate | undefined)[] -): CrudPredicate | undefined { +export function andCrudPredicates( + ...predicates: readonly (CrudPredicate | undefined)[] +): CrudPredicate | undefined { const defined = predicates.filter( - (predicate): predicate is CrudPredicate => predicate !== undefined, + (predicate): predicate is CrudPredicate => predicate !== undefined, ); if (defined.length === 0) { return undefined; @@ -17,11 +17,11 @@ export function andCrudPredicates( } /** Combines predicates without producing redundant one-element `or` nodes. */ -export function orCrudPredicates( - ...predicates: readonly (CrudPredicate | undefined)[] -): CrudPredicate | undefined { +export function orCrudPredicates( + ...predicates: readonly (CrudPredicate | undefined)[] +): CrudPredicate | undefined { const defined = predicates.filter( - (predicate): predicate is CrudPredicate => predicate !== undefined, + (predicate): predicate is CrudPredicate => predicate !== undefined, ); if (defined.length === 0) { return undefined; diff --git a/packages/crud/src/query/query-parser.spec.ts b/packages/crud/src/query/query-parser.spec.ts index 173c2e0..2e82cfa 100644 --- a/packages/crud/src/query/query-parser.spec.ts +++ b/packages/crud/src/query/query-parser.spec.ts @@ -13,6 +13,7 @@ const SECRET = "a production-length cursor secret with 32+ bytes"; const codec = new HmacSha256CrudCursorCodec(SECRET); const userResource = defineCrudResource({ + fields: ["id", "name", "age", "createdAt", "deletedAt"], name: "users", path: "users", itemPath: ":id", @@ -57,6 +58,7 @@ const userResource = defineCrudResource({ }); const membershipResource = defineCrudResource({ + fields: ["tenant_id", "id", "createdAt"], name: "memberships", path: "memberships", itemPath: ":tenantId/:id", diff --git a/packages/crud/src/query/query-parser.ts b/packages/crud/src/query/query-parser.ts index cf3c439..a578878 100644 --- a/packages/crud/src/query/query-parser.ts +++ b/packages/crud/src/query/query-parser.ts @@ -1,7 +1,12 @@ import { buildCrudCursorPredicate } from "../cursor/cursor-predicate.ts"; import { decodeCrudCursor } from "../cursor/cursor.ts"; import { CrudCursorError } from "../cursor/cursor.error.ts"; -import type { AnyCrudResource } from "../resource/resource.types.ts"; +import type { + AnyCrudResource, + CrudField, + CrudRelationName, + CrudRequiredField, +} from "../resource/resource.types.ts"; import { parseCrudSchema, type CrudSchemaSource } from "../schema/schema.types.ts"; import { andCrudPredicates } from "./predicate.ts"; import { resolveCrudPaginationModes } from "./pagination.ts"; @@ -38,8 +43,8 @@ interface NormalizedQuery { export async function parseCrudListQuery( resource: Resource, rawQuery: CrudRawQuery, - options: CrudQueryParserOptions = {}, -): Promise { + options: CrudQueryParserOptions> = {}, +): Promise, CrudRelationName>> { const normalized = normalizeQuery(rawQuery); const pagination = resolvePagination(resource, normalized, options); const order = buildCrudOrder(resource, readOptionalString(normalized, "sort"), pagination.mode); @@ -59,7 +64,10 @@ export async function parseCrudListQuery( includes, deleted, }; - return query; + return query as unknown as CrudOffsetQuery< + CrudRequiredField, + CrudRelationName + >; } let cursorPredicate: CrudPredicate | undefined; @@ -100,15 +108,18 @@ export async function parseCrudListQuery( includes, deleted, }; - return query; + return query as unknown as CrudCursorQuery< + CrudRequiredField, + CrudRelationName + >; } /** Builds deterministic ordering and appends every mapped ID field as a tie-breaker. */ -export function buildCrudOrder( - resource: AnyCrudResource, +export function buildCrudOrder( + resource: Resource, rawSort: string | undefined, mode: "offset" | "cursor", -): readonly CrudOrder[] { +): readonly CrudOrder>[] { const config = resource.query?.sort; if (rawSort !== undefined && config === undefined) { throw queryError("unknown_sort_field", "Sorting is not enabled for this resource.", "sort"); @@ -152,7 +163,7 @@ export function buildCrudOrder( order.push({ field: idField, direction: "asc" }); } } - return order; + return order as unknown as readonly CrudOrder>[]; } async function parseFilters( diff --git a/packages/crud/src/query/query.types.ts b/packages/crud/src/query/query.types.ts index 3572e8a..c74c9dc 100644 --- a/packages/crud/src/query/query.types.ts +++ b/packages/crud/src/query/query.types.ts @@ -25,14 +25,16 @@ export interface CrudFilterFieldConfig = Field | `-${Field}`; + +export interface CrudSortConfig { + readonly fields: readonly Field[]; + readonly default?: readonly CrudSortExpression[]; + readonly cursor?: readonly Field[]; } -export interface CrudSearchConfig { - readonly fields: readonly string[]; +export interface CrudSearchConfig { + readonly fields: readonly Field[]; readonly minLength?: number; readonly maxLength?: number; } @@ -44,52 +46,57 @@ export interface CrudPaginationConfig { readonly maxLimit?: number; } -export interface CrudQueryConfig { - readonly filters?: Readonly>; - readonly sort?: CrudSortConfig; - readonly search?: CrudSearchConfig; +type CrudFilterConfigMap = string extends Field + ? Readonly> + : Readonly>>; + +export interface CrudQueryConfig { + readonly filters?: CrudFilterConfigMap; + readonly sort?: CrudSortConfig; + readonly search?: CrudSearchConfig; readonly pagination?: CrudPaginationConfig; } -export type CrudPredicate = +export type CrudPredicate = | { readonly kind: "comparison"; - readonly field: string; + readonly field: Field; readonly operator: CrudFilterOperator; readonly value: unknown; } - | { readonly kind: "and"; readonly predicates: readonly CrudPredicate[] } - | { readonly kind: "or"; readonly predicates: readonly CrudPredicate[] } - | { readonly kind: "not"; readonly predicate: CrudPredicate }; + | { readonly kind: "and"; readonly predicates: readonly CrudPredicate[] } + | { readonly kind: "or"; readonly predicates: readonly CrudPredicate[] } + | { readonly kind: "not"; readonly predicate: CrudPredicate }; -export interface CrudOrder { - readonly field: string; +export interface CrudOrder { + readonly field: Field; readonly direction: CrudSortDirection; } -export interface CrudOffsetQuery { +export interface CrudOffsetQuery { readonly mode: "offset"; readonly page: number; readonly limit: number; - readonly predicate?: CrudPredicate; - readonly order: readonly CrudOrder[]; + readonly predicate?: CrudPredicate; + readonly order: readonly CrudOrder[]; readonly search?: string; - readonly includes: readonly string[]; + readonly includes: readonly Include[]; readonly deleted: "exclude" | "include" | "only"; } -export interface CrudCursorQuery { +export interface CrudCursorQuery { readonly mode: "cursor"; readonly after?: string; readonly limit: number; - readonly predicate?: CrudPredicate; - readonly order: readonly CrudOrder[]; + readonly predicate?: CrudPredicate; + readonly order: readonly CrudOrder[]; readonly search?: string; - readonly includes: readonly string[]; + readonly includes: readonly Include[]; readonly deleted: "exclude" | "include" | "only"; } -export type CrudListQuery = CrudOffsetQuery | CrudCursorQuery; +export type CrudListQuery = + CrudOffsetQuery | CrudCursorQuery; export interface CrudOffsetMeta { readonly mode: "offset"; @@ -117,11 +124,11 @@ export interface CrudPage { export type CrudRawQuery = URLSearchParams | Readonly>; -export interface CrudQueryParserOptions { +export interface CrudQueryParserOptions { /** Codec used to verify and decode an `after` cursor. */ readonly cursorCodec?: CrudCursorCodec; /** Route-owned values that bind a cursor to one nested collection. */ - readonly cursorFixedValues?: readonly CrudCursorFixedValue[]; + readonly cursorFixedValues?: readonly CrudCursorFixedValue[]; /** Used when a resource does not configure a default limit. */ readonly defaultLimit?: number; /** Used when a resource does not configure a maximum limit. */ diff --git a/packages/crud/src/relation/relation.types.ts b/packages/crud/src/relation/relation.types.ts index e15a680..a89ff82 100644 --- a/packages/crud/src/relation/relation.types.ts +++ b/packages/crud/src/relation/relation.types.ts @@ -1,18 +1,22 @@ -import type { AnyCrudResource } from "../resource/resource.types.ts"; +import type { AnyCrudResource, CrudField, CrudFieldTuple } from "../resource/resource.types.ts"; export type CrudRelationType = "belongsTo" | "hasOne" | "hasMany"; -export interface CrudRelationConfig { +export interface CrudRelationConfig< + LocalField extends string = string, + Target extends AnyCrudResource = AnyCrudResource, +> { readonly type: CrudRelationType; - readonly target: () => AnyCrudResource; - readonly local: readonly string[]; - readonly foreign: readonly string[]; + readonly target: () => Target; + readonly local: CrudFieldTuple; + readonly foreign: CrudFieldTuple>; readonly maxItems?: number; } -export function defineCrudRelation( - relation: Relation, -): Relation { +export function defineCrudRelation< + const Target extends AnyCrudResource, + const Relation extends CrudRelationConfig, +>(relation: Relation): Relation { if (!(["belongsTo", "hasOne", "hasMany"] as const).includes(relation.type)) { throw new TypeError("A CRUD relation must declare a supported relation type."); } diff --git a/packages/crud/src/resource/define-resource.ts b/packages/crud/src/resource/define-resource.ts index 67895f8..42d55eb 100644 --- a/packages/crud/src/resource/define-resource.ts +++ b/packages/crud/src/resource/define-resource.ts @@ -15,8 +15,13 @@ import type { CrudRelationConfig } from "../relation/relation.types.ts"; const PARAMETER_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; -export function defineCrudResource( - definition: Definition & CrudResourceDefinitionConstraint>, +export function defineCrudResource< + const Fields extends readonly [string, ...string[]], + const Definition extends CrudResourceDefinition, +>( + definition: { readonly fields: Fields } & Definition & + CrudResourceDefinition> & + CrudResourceDefinitionConstraint>, ): DefinedCrudResource { assertResourceDefinition(definition); return Object.freeze({ @@ -105,11 +110,19 @@ function assertResourceDefinition(definition: CrudResourceDefinition): void { `CRUD resource "${definition.name}" cannot enable upsert without contracts.upsert.`, ); } + if (!Array.isArray(definition.fields) || definition.fields.length === 0) { + throw new TypeError(`CRUD resource "${definition.name}" must declare logical fields.`); + } + for (const field of definition.fields) { + assertNonEmptyString(definition.name, "fields", field); + } + assertUnique(definition.name, "fields", definition.fields); if (!isRecord(definition.idFields) || Object.keys(definition.idFields).length === 0) { throw new TypeError(`CRUD resource "${definition.name}" must declare idFields.`); } for (const field of Object.values(definition.idFields)) { assertNonEmptyString(definition.name, "idFields", field); + assertDeclaredField(definition, "idFields", field); } assertPathParamsConfiguration(definition, pathRouteParams); @@ -134,6 +147,7 @@ function assertResourceDefinition(definition: CrudResourceDefinition): void { } if (definition.softDelete !== undefined) { assertNonEmptyString(definition.name, "softDelete.field", definition.softDelete.field); + assertDeclaredField(definition, "softDelete.field", definition.softDelete.field); } assertQueryConfiguration(definition); assertRelationConfiguration(definition); @@ -167,6 +181,7 @@ function snapshotResourceDefinition( ) as Definition["relations"]); return { ...definition, + fields: Object.freeze([...definition.fields]), idFields: Object.freeze({ ...definition.idFields }), ...(definition.pathParams === undefined ? {} @@ -279,12 +294,12 @@ function snapshotSoftDelete(config: CrudSoftDeleteConfig): CrudSoftDeleteConfig }); } -function snapshotRelation(relation: CrudRelationConfig): CrudRelationConfig { +function snapshotRelation(relation: Relation): Relation { return Object.freeze({ ...relation, local: Object.freeze([...relation.local]), foreign: Object.freeze([...relation.foreign]), - }); + }) as Relation; } function assertCanonicalRoutePath(resource: string, label: string, path: string): string[] { @@ -344,6 +359,7 @@ function assertPathParamsConfiguration( const mappedFields: string[] = []; for (const field of Object.values(fields)) { assertNonEmptyString(definition.name, "pathParams.fields", field); + assertDeclaredField(definition, "pathParams.fields", field); mappedFields.push(field); } assertUnique(definition.name, "pathParams.fields mappings", mappedFields); @@ -360,6 +376,7 @@ function assertQueryConfiguration(definition: CrudResourceDefinition): void { const name = definition.name; for (const [field, config] of Object.entries(definition.query?.filters ?? {})) { assertNonEmptyString(name, `filter field`, field); + assertDeclaredField(definition, "query.filters", field); assertSchemaSource(name, `query.filters.${field}.schema`, config.schema); if (config.operators.length === 0) { throw new TypeError(`CRUD resource "${name}" filter "${field}" must enable an operator.`); @@ -377,6 +394,7 @@ function assertQueryConfiguration(definition: CrudResourceDefinition): void { const sort = definition.query?.sort; if (sort !== undefined) { assertStringList(name, "sort.fields", sort.fields); + for (const field of sort.fields) assertDeclaredField(definition, "query.sort.fields", field); assertUnique(name, "sort.fields", sort.fields); const fields = new Set(sort.fields); assertSortList(name, "sort.default", sort.default ?? [], fields); @@ -429,6 +447,9 @@ function assertQueryConfiguration(definition: CrudResourceDefinition): void { const search = definition.query?.search; if (search !== undefined) { assertStringList(name, "search.fields", search.fields); + for (const field of search.fields) { + assertDeclaredField(definition, "query.search.fields", field); + } assertUnique(name, "search.fields", search.fields); if (search.fields.length === 0) { throw new TypeError(`CRUD resource "${name}" search.fields cannot be empty.`); @@ -464,6 +485,9 @@ function assertRelationConfiguration(definition: CrudResourceDefinition): void { assertStringList(definition.name, `relation "${relationName}" local`, relation.local); assertStringList(definition.name, `relation "${relationName}" foreign`, relation.foreign); assertUnique(definition.name, `relation "${relationName}" local`, relation.local); + for (const field of relation.local) { + assertDeclaredField(definition, `relation "${relationName}" local`, field); + } assertUnique(definition.name, `relation "${relationName}" foreign`, relation.foreign); assertOptionalPositiveInteger( definition.name, @@ -483,6 +507,18 @@ function assertRelationConfiguration(definition: CrudResourceDefinition): void { } } +function assertDeclaredField( + definition: CrudResourceDefinition, + label: string, + field: string, +): void { + if (!definition.fields.includes(field)) { + throw new TypeError( + `CRUD resource "${definition.name}" ${label} references undeclared field "${field}".`, + ); + } +} + function assertVersionConfiguration(definition: CrudResourceDefinition): void { if (definition.version === undefined) return; const versions = Array.isArray(definition.version) ? definition.version : [definition.version]; diff --git a/packages/crud/src/resource/resource.types.ts b/packages/crud/src/resource/resource.types.ts index 37b8f98..55f955b 100644 --- a/packages/crud/src/resource/resource.types.ts +++ b/packages/crud/src/resource/resource.types.ts @@ -15,6 +15,7 @@ import type { CrudEnhancers, CrudOperations } from "./operations.ts"; export const CRUD_RESOURCE = Symbol.for("@nestm/crud:resource"); export type CrudVersion = string | typeof VERSION_NEUTRAL | Array; +export type CrudFieldTuple = readonly [Field, ...Field[]]; export interface CrudContracts< Id extends CrudSchemaSource = CrudSchemaSource, @@ -32,23 +33,36 @@ export interface CrudContracts< export interface CrudPathParamsConfig< Contract extends CrudSchemaSource = CrudSchemaSource, - Fields extends Readonly> = Readonly>, + Field extends string = string, + Fields extends Readonly> = Readonly>, > { readonly contract: Contract; readonly fields: Fields; } -export interface CrudSoftDeleteConfig { - readonly field: string; +export interface CrudSoftDeleteConfig { + readonly field: Field; readonly allowQueryDeleted?: boolean; readonly queryDeletedEnhancers?: CrudEnhancers; readonly deleteValue?: (context: CrudOperationContext) => unknown; readonly restoreValue?: (context: CrudOperationContext) => unknown; } +type CrudTypedRelations< + Fields extends CrudFieldTuple, + Relations extends Readonly>, +> = { + readonly [Name in keyof Relations]: Relations[Name] extends { + readonly target: () => infer Target extends AnyCrudResource; + } + ? CrudRelationConfig, Target> + : never; +}; + export interface CrudResourceDefinition< Name extends string = string, Path extends string = string, + Fields extends CrudFieldTuple = CrudFieldTuple, Id extends CrudSchemaSource = CrudSchemaSource, Create extends CrudSchemaSource = CrudSchemaSource, Update extends CrudSchemaSource = CrudSchemaSource, @@ -62,13 +76,17 @@ export interface CrudResourceDefinition< readonly name: Name; readonly path: Path; readonly itemPath: string; - readonly idFields: Readonly>; - readonly pathParams?: PathParams; + /** Complete logical field vocabulary used by this resource and its adapter binding. */ + readonly fields: Fields; + readonly idFields: Readonly>>; + readonly pathParams?: PathParams & { + readonly fields: Readonly>>; + }; readonly contracts: CrudContracts; readonly operations: CrudOperations; - readonly query?: CrudQueryConfig; - readonly softDelete?: CrudSoftDeleteConfig; - readonly relations?: Relations; + readonly query?: CrudQueryConfig>; + readonly softDelete?: CrudSoftDeleteConfig>; + readonly relations?: Relations & CrudTypedRelations; readonly hooks?: readonly InjectionToken[]; /** Transaction-bound mutation validators, executed in declaration order after before hooks. */ readonly validators?: readonly InjectionToken[]; @@ -87,6 +105,7 @@ export interface CrudResourceDefinition< export interface CrudResource< Name extends string = string, Path extends string = string, + Fields extends CrudFieldTuple = CrudFieldTuple, Id extends CrudSchemaSource = CrudSchemaSource, Create extends CrudSchemaSource = CrudSchemaSource, Update extends CrudSchemaSource = CrudSchemaSource, @@ -99,6 +118,7 @@ export interface CrudResource< > extends CrudResourceDefinition< Name, Path, + Fields, Id, Create, Update, @@ -255,6 +275,27 @@ type HasDuplicateValues = Value : HasDuplicateValues : false; +type CrudFieldVocabularyConstraint = + HasDuplicateValues extends true + ? CrudResourceTypeError<"fields must contain unique logical field names"> + : unknown; + +type CrudRelationTupleConstraint = Definition extends { + readonly relations: infer Relations extends Readonly>; +} + ? false extends { + [ + Name in keyof Relations + ]: Relations[Name]["local"]["length"] extends Relations[Name]["foreign"]["length"] + ? Relations[Name]["foreign"]["length"] extends Relations[Name]["local"]["length"] + ? true + : false + : false; + }[keyof Relations] + ? CrudResourceTypeError<"relation local and foreign tuples must have the same length"> + : unknown + : unknown; + type MappedFieldTuple< Parameters extends readonly string[], Fields extends Readonly>, @@ -347,34 +388,36 @@ type CrudPathParamTypesConstraint< : never; type CrudPathParamsObjectConstraint< - Definition extends CrudResourceDefinition, + Definition, Config extends CrudPathParamsConfig, Output extends object, -> = [Output] extends [readonly unknown[]] - ? CrudResourceTypeError<"pathParams.contract must output a parameter object, not an array"> - : string extends keyof Output - ? CrudResourceTypeError<"pathParams.contract must output an object with finite parameter keys"> - : OptionalKeys extends never - ? SameKeys extends true - ? string extends Definition["path"] - ? unknown - : SameKeys, keyof Config["fields"]> extends true - ? HasDuplicateValues< - MappedFieldTuple, Config["fields"]> - > extends true - ? CrudResourceTypeError<"pathParams.fields must map to unique fields"> - : false extends { - [ - Parameter in keyof Config["fields"] - ]: Parameter extends keyof Definition["idFields"] - ? SameValue - : false; - }[keyof Config["fields"]] - ? CrudResourceTypeError<"pathParams.fields must match parent idFields mappings"> - : CrudPathParamTypesConstraint - : CrudResourceTypeError<"path parameters must match pathParams.fields keys"> - : CrudResourceTypeError<"pathParams.contract output keys must match pathParams.fields keys"> - : CrudResourceTypeError<"pathParams.contract output parameters must all be required">; +> = Definition extends CrudResourceDefinition + ? [Output] extends [readonly unknown[]] + ? CrudResourceTypeError<"pathParams.contract must output a parameter object, not an array"> + : string extends keyof Output + ? CrudResourceTypeError<"pathParams.contract must output an object with finite parameter keys"> + : OptionalKeys extends never + ? SameKeys extends true + ? string extends Definition["path"] + ? unknown + : SameKeys, keyof Config["fields"]> extends true + ? HasDuplicateValues< + MappedFieldTuple, Config["fields"]> + > extends true + ? CrudResourceTypeError<"pathParams.fields must map to unique fields"> + : false extends { + [ + Parameter in keyof Config["fields"] + ]: Parameter extends keyof Definition["idFields"] + ? SameValue + : false; + }[keyof Config["fields"]] + ? CrudResourceTypeError<"pathParams.fields must match parent idFields mappings"> + : CrudPathParamTypesConstraint + : CrudResourceTypeError<"path parameters must match pathParams.fields keys"> + : CrudResourceTypeError<"pathParams.contract output keys must match pathParams.fields keys"> + : CrudResourceTypeError<"pathParams.contract output parameters must all be required"> + : never; type CrudPathParamsContractConstraint = Definition extends { readonly pathParams: infer Config extends CrudPathParamsConfig } @@ -391,6 +434,50 @@ type CrudPathParamsContractConstraint : never : unknown; +type CrudRelationFieldConstraint = Definition extends { + readonly relations: infer Relations extends Readonly>; +} + ? false extends { + [Name in keyof Relations]: Relations[Name] extends { + readonly local: readonly string[]; + readonly foreign: readonly string[]; + readonly target: () => infer Target extends AnyCrudResource; + } + ? Exclude extends never + ? [Target] extends [never] + ? true + : Exclude extends never + ? true + : false + : false + : false; + }[keyof Relations] + ? CrudResourceTypeError<"relation keys must use fields declared by their source and target resources"> + : unknown + : unknown; + +type CrudSortExpressionField = Value extends `-${infer Field}` ? Field : Value; + +type CrudSortSelectionConstraint = Definition extends { + readonly query: { + readonly sort: infer Sort extends { + readonly fields: readonly string[]; + readonly default?: readonly string[]; + readonly cursor?: readonly string[]; + }; + }; +} + ? Exclude< + | (Sort extends { readonly default: readonly (infer Item)[] } + ? CrudSortExpressionField + : never) + | (Sort extends { readonly cursor: readonly (infer Item)[] } ? Item : never), + Sort["fields"][number] + > extends never + ? unknown + : CrudResourceTypeError<"sort.default and sort.cursor must use fields enabled by sort.fields"> + : unknown; + type CrudUpsertContractConstraint = Definition["operations"] extends { readonly upsert: unknown } ? Definition["contracts"] extends { readonly upsert: CrudSchemaSource } @@ -400,15 +487,20 @@ type CrudUpsertContractConstraint = /** Compile-time constraints applied to literal definitions by `defineCrudResource`. */ export type CrudResourceDefinitionConstraint = - CrudIdContractConstraint & + CrudFieldVocabularyConstraint & + CrudIdContractConstraint & CrudRouteSyntaxConstraint & CrudPathParamPresenceConstraint & CrudPathParamsContractConstraint & + CrudRelationFieldConstraint & + CrudRelationTupleConstraint & + CrudSortSelectionConstraint & CrudUpsertContractConstraint; export type AnyCrudResource = CrudResource< string, string, + CrudFieldTuple, CrudSchemaSource, CrudSchemaSource, CrudSchemaSource, @@ -417,6 +509,13 @@ export type AnyCrudResource = CrudResource< CrudPathParamsConfig | undefined >; +/** Complete logical field vocabulary declared by a concrete resource. */ +export type CrudField = Resource["fields"][number]; +/** Logical values keyed only by fields declared on a concrete resource. */ +export type CrudFieldValues = Readonly< + Partial, unknown>> +>; + export type CrudId = SchemaOutput; export type CrudPathParams = [Resource] extends [ { @@ -470,35 +569,14 @@ export type CrudValidatorType = Type< >; export type CrudScopeType = Type>; -type TupleValue = Value extends readonly (infer Item extends string)[] ? Item : never; -type LiteralField = Value extends string ? (string extends Value ? never : Value) : never; -type RelationLocalFields = Resource extends { +/** Complete logical fields accepted by the core for this resource. */ +export type CrudRequiredField = CrudField; + +/** Relation names accepted by `include` for a concrete resource. */ +export type CrudRelationName = Resource extends { readonly relations: infer Relations extends Readonly>; } - ? { - [Name in keyof Relations]: TupleValue; - }[keyof Relations] + ? string extends keyof Relations + ? string + : Extract : never; - -/** Logical fields the core itself must be able to read or query. */ -export type CrudRequiredField = - | LiteralField> - | (Resource extends { readonly softDelete: { readonly field: infer Field extends string } } - ? LiteralField - : never) - | (Resource extends { - readonly query: { readonly filters: infer Filters extends Readonly> }; - } - ? LiteralField> - : never) - | (Resource extends { - readonly query: { readonly sort: { readonly fields: infer Fields } }; - } - ? LiteralField> - : never) - | (Resource extends { - readonly query: { readonly search: { readonly fields: infer Fields } }; - } - ? LiteralField> - : never) - | LiteralField>; diff --git a/packages/crud/src/runtime/crud-registry.ts b/packages/crud/src/runtime/crud-registry.ts index e38fe2c..5041074 100644 --- a/packages/crud/src/runtime/crud-registry.ts +++ b/packages/crud/src/runtime/crud-registry.ts @@ -95,14 +95,14 @@ export class CrudRegistry implements OnApplicationBootstrap { ); } for (const field of relation.local) { - if (!binding.fields.includes(field)) { + if (!binding.resource.fields.includes(field)) { throw new TypeError( `CRUD relation "${resource.name}.${name}" has unmapped local field "${field}".`, ); } } for (const field of relation.foreign) { - if (!targetEntry.binding.fields.includes(field)) { + if (!targetEntry.binding.resource.fields.includes(field)) { throw new TypeError( `CRUD relation "${resource.name}.${name}" has unmapped target field "${field}".`, ); diff --git a/packages/crud/src/runtime/crud.service.ts b/packages/crud/src/runtime/crud.service.ts index 80c5873..032ec7f 100644 --- a/packages/crud/src/runtime/crud.service.ts +++ b/packages/crud/src/runtime/crud.service.ts @@ -31,8 +31,11 @@ import type { CrudRelationConfig } from "../relation/relation.types.ts"; import type { AnyCrudResource, CrudCreate, + CrudField, + CrudFieldValues, CrudId, CrudPathParams, + CrudRelationName, CrudResponseInput, CrudUpdate, CrudUpsert, @@ -82,7 +85,10 @@ interface RelationReadResult { readonly responses: readonly unknown[]; } -interface ResolvedCrudScope extends Omit { +interface ResolvedCrudScope extends Omit< + CrudScopeResult, + "facts" +> { readonly facts: CrudFacts; } @@ -104,7 +110,6 @@ export class CrudService< readonly binding: CrudResourceBinding< Resource, RecordType, - readonly string[], CreateValues, UpdateValues, ScopeCreateField @@ -276,7 +281,7 @@ export class CrudService< async read( id: CrudId, executionContext?: ExecutionContext, - includes: readonly string[] = [], + includes: readonly CrudRelationName[] = [], ): Promise> { const pathParams = this.pathParamsFromId(id); return this.runAdapter(() => @@ -854,11 +859,13 @@ export class CrudService< } } - private async resolveScopes(context: CrudOperationContext): Promise { + private async resolveScopes( + context: CrudOperationContext, + ): Promise> { const resolved = await Promise.all(this.scopes.map(async (scope) => scope.resolve(context))); const predicate = andCrudPredicates(...resolved.map((result) => result.predicate)); - const createValues: Record = {}; - const updateValues: Record = {}; + const createValues: Partial, unknown>> = {}; + const updateValues: Partial, unknown>> = {}; const factEntries: CrudFactEntry[] = []; for (const result of resolved) { if (result.createValues !== undefined) Object.assign(createValues, result.createValues); @@ -937,9 +944,9 @@ export class CrudService< } private scopeCreateValues( - scope: Pick, + scope: Pick, "createValues">, pathParams: CrudPathParams | undefined, - ): Readonly> { + ): CrudFieldValues { const values: Record = { ...scope.createValues }; for (const { field, value } of this.pathFixedValues(pathParams)) { if (Object.hasOwn(values, field) && !persistenceValuesEqual(values[field], value)) { @@ -949,7 +956,7 @@ export class CrudService< } values[field] = value; } - return values; + return values as CrudFieldValues; } private async mapPersistenceValues(values: CrudValues): Promise { @@ -959,7 +966,7 @@ export class CrudService< ); } return normalizeCrudMappingValues( - await this.binding.mappings.persistence(values), + await this.binding.mappings.persistence(values as CrudFieldValues), ); } @@ -971,13 +978,13 @@ export class CrudService< ); } return normalizeCrudMappingValues( - await this.binding.mappings.persistence(values), + await this.binding.mappings.persistence(values as CrudFieldValues), ); } private async mapScopeCreateValues(values: CrudValues): Promise { if (this.binding.mappings.scopeCreate !== undefined) { - return this.binding.mappings.scopeCreate(values); + return this.binding.mappings.scopeCreate(values as CrudFieldValues); } const fields = this.binding.scopeCreateFields; if (fields === undefined || fields.length === 0) { @@ -1102,7 +1109,7 @@ export class CrudService< `CRUD resource "${this.resource.name}" pagination.maxLimit must be >= defaultLimit.`, ); } - const fields = new Set(this.binding.fields); + const fields = new Set(this.resource.fields); const required = new Set(Object.values(this.resource.idFields)); if (this.resource.softDelete !== undefined) required.add(this.resource.softDelete.field); for (const field of Object.keys(this.resource.query?.filters ?? {})) required.add(field); @@ -1172,7 +1179,10 @@ export class CrudService< `CRUD binding for "${this.resource.name}" cannot overwrite conflict field "${field}".`, ); } - if (this.binding.scopeCreateFields?.includes(field) === true) { + if ( + this.binding.scopeCreateFields?.some((scopeField: string) => scopeField === field) === + true + ) { throw new TypeError( `CRUD binding for "${this.resource.name}" cannot overwrite scope-owned create field "${field}".`, ); diff --git a/packages/crud/src/runtime/runtime.types.ts b/packages/crud/src/runtime/runtime.types.ts index acdfeca..df63ffb 100644 --- a/packages/crud/src/runtime/runtime.types.ts +++ b/packages/crud/src/runtime/runtime.types.ts @@ -1,10 +1,12 @@ import type { ExecutionContext } from "@nestjs/common"; -import type { CrudAdapterSession, CrudValues } from "../adapter/adapter.types.ts"; +import type { CrudAdapterSession } from "../adapter/adapter.types.ts"; import type { CrudPredicate } from "../query/query.types.ts"; import type { AnyCrudResource, CrudCreate, + CrudField, + CrudFieldValues, CrudId, CrudPathParams, CrudResponseInput, @@ -156,21 +158,26 @@ export interface CrudMutationEvent; } -export interface CrudScopeResult { - readonly predicate?: CrudPredicate; +export type CrudScopeValues = + CrudFieldValues; + +export interface CrudScopeResult { + readonly predicate?: CrudPredicate>; /** Logical persistence values applied only while creating a record. */ - readonly createValues?: CrudValues; + readonly createValues?: CrudScopeValues; /** * Logical persistence values applied only to an explicit update operation. * Unlike `createValues`, these values may overwrite API-mapped update fields. */ - readonly updateValues?: CrudValues; + readonly updateValues?: CrudScopeValues; /** Typed values made available to hooks and mutation validators in this transaction. */ readonly facts?: readonly CrudFactEntry[]; } export interface CrudScope { - resolve(context: CrudOperationContext): CrudScopeResult | Promise; + resolve( + context: CrudOperationContext, + ): CrudScopeResult | Promise>; } export interface CrudAfterCommitErrorContext { diff --git a/packages/crud/src/testing/conformance.ts b/packages/crud/src/testing/conformance.ts index c1b5ca0..9131e4e 100644 --- a/packages/crud/src/testing/conformance.ts +++ b/packages/crud/src/testing/conformance.ts @@ -6,21 +6,31 @@ import type { CrudValues, } from "../adapter/adapter.types.ts"; -export interface CrudAdapterConformanceFixture { +type CrudAdapterConformanceField = RecordType extends object + ? Extract + : string; + +export interface CrudAdapterConformanceFixture< + RecordType, + Field extends string = CrudAdapterConformanceField, +> { readonly adapter: CrudAdapter; readonly first: CrudValues; readonly second: CrudValues; readonly update: CrudValues; - readonly idField: string; - readonly sortField: string; + readonly idField: Field; + readonly sortField: Field; /** IDs in the exact order produced by sorting `sortField` ascending. */ readonly expectedAscendingIds: readonly [unknown, unknown]; readonly getId: (record: RecordType) => unknown; } -export interface CrudAdapterConformanceCase { +export interface CrudAdapterConformanceCase< + RecordType, + Field extends string = CrudAdapterConformanceField, +> { readonly name: string; - readonly run: (fixture: CrudAdapterConformanceFixture) => Promise; + readonly run: (fixture: CrudAdapterConformanceFixture) => Promise; } /** @@ -29,7 +39,8 @@ export interface CrudAdapterConformanceCase { */ export function createCrudAdapterConformanceCases< RecordType, ->(): readonly CrudAdapterConformanceCase[] { + Field extends string = CrudAdapterConformanceField, +>(): readonly CrudAdapterConformanceCase[] { return [ { name: "creates a record and reads it back by predicate", @@ -283,11 +294,15 @@ export function createCrudAdapterConformanceCases< ]; } -export async function runCrudAdapterConformance( +export async function runCrudAdapterConformance< + RecordType, + Field extends string = CrudAdapterConformanceField, +>( createFixture: () => - CrudAdapterConformanceFixture | Promise>, + | CrudAdapterConformanceFixture + | Promise>, ): Promise { - for (const testCase of createCrudAdapterConformanceCases()) { + for (const testCase of createCrudAdapterConformanceCases()) { await testCase.run(await createFixture()); } } @@ -300,8 +315,8 @@ function comparison(field: string, operator: "eq" | "isnull", value: unknown) { return { kind: "comparison" as const, field, operator, value }; } -async function seedPair( - fixture: CrudAdapterConformanceFixture, +async function seedPair( + fixture: CrudAdapterConformanceFixture, ): Promise { const first = await fixture.adapter.create({ values: fixture.first }, adapterContext("create")); const second = await fixture.adapter.create({ values: fixture.second }, adapterContext("create")); diff --git a/packages/crud/tests/controller.spec.ts b/packages/crud/tests/controller.spec.ts index e032265..57fef86 100644 --- a/packages/crud/tests/controller.spec.ts +++ b/packages/crud/tests/controller.spec.ts @@ -19,6 +19,7 @@ import { crudOperations } from "../src/resource/operations.ts"; import { CRUD_QUERY_OPENAPI_EXTENSION } from "../src/swagger-ui/query-extension.ts"; const routeResource = defineCrudResource({ + fields: ["tenantId", "id", "name", "deletedAt"], name: "billing-items", path: "/api/billing-items/", itemPath: ":tenantId/:id", @@ -41,6 +42,7 @@ const routeResource = defineCrudResource({ }); const nestedRouteResource = defineCrudResource({ + fields: ["artifactId", "versionId", "name", "active"], name: "artifact-versions", path: "/api/artifacts/:artifactId/versions/", itemPath: ":versionId", diff --git a/packages/crud/tests/http-advanced.spec.ts b/packages/crud/tests/http-advanced.spec.ts index 2d70474..0c890b9 100644 --- a/packages/crud/tests/http-advanced.spec.ts +++ b/packages/crud/tests/http-advanced.spec.ts @@ -88,6 +88,7 @@ class RollbackHook implements CrudLifecycleHook { class AdvancedHttpSupportModule {} const membershipResource = defineCrudResource({ + fields: ["tenantId", "id", "name", "createdAt"], name: "advanced-memberships", path: "advanced/memberships", itemPath: ":tenantId/:id", @@ -121,6 +122,7 @@ const membershipResource = defineCrudResource({ }); const childResource = defineCrudResource({ + fields: ["id", "parentId", "name"], name: "advanced-children", path: "advanced/children", itemPath: ":id", @@ -136,6 +138,7 @@ const childResource = defineCrudResource({ }); const parentResource = defineCrudResource({ + fields: ["id", "name"], name: "advanced-parents", path: "advanced/parents", itemPath: ":id", @@ -166,6 +169,7 @@ const parentResource = defineCrudResource({ }); const scopedResource = defineCrudResource({ + fields: ["id", "tenantId", "name"], name: "advanced-scoped-records", path: "advanced/scoped-records", itemPath: ":id", @@ -182,6 +186,7 @@ const scopedResource = defineCrudResource({ }); const rollbackResource = defineCrudResource({ + fields: ["id", "name"], name: "advanced-rollback-records", path: "advanced/rollback-records", itemPath: ":id", @@ -198,6 +203,7 @@ const rollbackResource = defineCrudResource({ }); const protectedResource = defineCrudResource({ + fields: ["id", "name"], name: "advanced-protected-records", path: "advanced/protected-records", itemPath: ":id", @@ -217,6 +223,7 @@ const protectedResource = defineCrudResource({ }); const failingResource = defineCrudResource({ + fields: ["id", "name"], name: "advanced-failing-records", path: "advanced/failing-records", itemPath: ":id", @@ -372,7 +379,6 @@ function membershipBinding() { return defineCrudBinding({ resource: membershipResource, adapter: { useValue: adapter }, - fields: ["tenantId", "id", "name", "createdAt"], mappings: { create: (input) => input, update: (input) => input, @@ -396,7 +402,6 @@ function childBinding() { return defineCrudBinding({ resource: childResource, adapter: { useValue: adapter }, - fields: ["id", "parentId", "name"], mappings: { create: (input) => input, update: (input) => input, @@ -418,7 +423,6 @@ function parentBinding() { return defineCrudBinding({ resource: parentResource, adapter: { useValue: adapter }, - fields: ["id", "name"], mappings: { create: (input) => input, update: (input) => input, @@ -442,7 +446,6 @@ function scopedBinding() { return defineCrudBinding({ resource: scopedResource, adapter: { useValue: adapter }, - fields: ["id", "tenantId", "name"], mappings: { create: (input) => input, update: (input) => input, @@ -460,7 +463,6 @@ function rollbackBinding(adapter: FakeCrudAdapter) { return defineCrudBinding({ resource: rollbackResource, adapter: { useValue: adapter }, - fields: ["id", "name"], mappings: basicMappings, }); } @@ -470,7 +472,6 @@ function protectedBinding() { return defineCrudBinding({ resource: protectedResource, adapter: { useValue: adapter }, - fields: ["id", "name"], mappings: basicMappings, }); } @@ -479,7 +480,6 @@ function failingBinding(adapter: FakeCrudAdapter) { return defineCrudBinding({ resource: failingResource, adapter: { useValue: adapter }, - fields: ["id", "name"], mappings: basicMappings, }); } diff --git a/packages/crud/tests/http-contract.spec.ts b/packages/crud/tests/http-contract.spec.ts index 1ceec92..039d20b 100644 --- a/packages/crud/tests/http-contract.spec.ts +++ b/packages/crud/tests/http-contract.spec.ts @@ -18,6 +18,7 @@ const platforms = [ ] as const; const idempotentDeleteResource = defineCrudResource({ + fields: ["id", "name"], name: "http-idempotent-records", path: "api/idempotent-records", itemPath: ":id", @@ -36,7 +37,6 @@ function idempotentDeleteBinding() { return defineCrudBinding({ resource: idempotentDeleteResource, adapter: { useValue: adapter }, - fields: ["id", "name"], mappings: { create: (input) => input, update: (input) => input, diff --git a/packages/crud/tests/http-nested-upsert.spec.ts b/packages/crud/tests/http-nested-upsert.spec.ts index 18a16fd..56f0998 100644 --- a/packages/crud/tests/http-nested-upsert.spec.ts +++ b/packages/crud/tests/http-nested-upsert.spec.ts @@ -18,6 +18,7 @@ const platforms = [ ] as const; const nestedItemResource = defineCrudResource({ + fields: ["projectId", "id", "name"], name: "http-nested-items", path: "http/projects/:projectId/items", itemPath: ":itemId", @@ -56,7 +57,6 @@ describe.each(platforms)("nested generated HTTP contract on %s", (_name, createA const binding = defineCrudBinding({ resource: nestedItemResource, adapter: { useValue: adapter }, - fields: ["projectId", "id", "name"], scopeCreateFields: ["projectId"], upsert: { conflictFields: ["projectId", "id"], diff --git a/packages/crud/tests/module.spec.ts b/packages/crud/tests/module.spec.ts index a982657..fd360e5 100644 --- a/packages/crud/tests/module.spec.ts +++ b/packages/crud/tests/module.spec.ts @@ -41,6 +41,7 @@ describe("CrudModule", () => { const afterCreate = vi.fn(); const resource = defineCrudResource({ + fields: ["id", "name"], name: "di-projected-records", path: "di-projected-records", itemPath: ":id", @@ -62,7 +63,6 @@ describe("CrudModule", () => { const binding = defineCrudBinding({ resource, adapter: { useValue: adapter }, - fields: ["id", "name"], mappings: { create: (input) => input, update: (input) => input, @@ -144,6 +144,7 @@ describe("CrudModule", () => { const adapter = new FakeCrudAdapter(); const binding = createUserBinding(adapter); const collidingResource = defineCrudResource({ + fields: ["id", "name"], name: "legacy-users", path: userResource.path, itemPath: userResource.itemPath, @@ -159,7 +160,6 @@ describe("CrudModule", () => { const collidingBinding = defineCrudBinding({ resource: collidingResource, adapter: { useValue: new FakeCrudAdapter() }, - fields: ["id", "name"], mappings: { create: (input) => input, update: (input) => input, diff --git a/packages/crud/tests/openapi-contract.spec.ts b/packages/crud/tests/openapi-contract.spec.ts index 85bf904..3c7ad4b 100644 --- a/packages/crud/tests/openapi-contract.spec.ts +++ b/packages/crud/tests/openapi-contract.spec.ts @@ -32,6 +32,7 @@ const contracts = { } as const; const offsetResource = defineCrudResource({ + fields: ["id", "name", "createdAt", "deletedAt"], name: "openapi-offset", path: "matrix/offset", itemPath: ":id", @@ -43,6 +44,7 @@ const offsetResource = defineCrudResource({ }); const cursorResource = defineCrudResource({ + fields: ["id", "name", "createdAt", "deletedAt"], name: "openapi-cursor", path: "matrix/cursor", itemPath: ":id", @@ -56,6 +58,7 @@ const cursorResource = defineCrudResource({ }); const dualResource = defineCrudResource({ + fields: ["id", "name", "createdAt", "deletedAt"], name: "openapi-dual", path: "matrix/dual", itemPath: ":id", @@ -69,6 +72,7 @@ const dualResource = defineCrudResource({ }); const compositeResource = defineCrudResource({ + fields: ["tenantId", "id", "name"], name: "openapi-composite", path: "matrix/composite", itemPath: ":tenantId/:id", @@ -84,6 +88,7 @@ const compositeResource = defineCrudResource({ }); const nestedResource = defineCrudResource({ + fields: ["parentId", "id", "name"], name: "openapi-nested", path: "matrix/parents/:parentId/children", itemPath: ":id", @@ -104,6 +109,7 @@ const nestedResource = defineCrudResource({ }); const relatedResource = defineCrudResource({ + fields: ["id", "name", "createdAt", "deletedAt"], name: "openapi-related", path: "matrix/related", itemPath: ":id", diff --git a/packages/crud/tests/projection.spec.ts b/packages/crud/tests/projection.spec.ts index e392e41..5eea2d4 100644 --- a/packages/crud/tests/projection.spec.ts +++ b/packages/crud/tests/projection.spec.ts @@ -15,6 +15,7 @@ import { FakeCrudAdapter } from "./support/fake-crud-adapter.ts"; * can produce, so the adapter cannot see it and only a batch query can resolve it cheaply. */ const articleResource = defineCrudResource({ + fields: ["id", "title"], name: "articles", path: "articles", itemPath: ":id", @@ -38,7 +39,6 @@ function articleBinding(adapter: FakeCrudAdapter) { return defineCrudBinding({ resource: articleResource, adapter: { useValue: adapter }, - fields: ["id", "title"], mappings: { create: (input) => ({ title: input.title }), update: (input) => (input.title === undefined ? {} : { title: input.title }), @@ -186,7 +186,6 @@ describe("batch projections", () => { const binding = defineCrudBinding({ resource: articleResource, adapter: { useValue: adapter }, - fields: ["id", "title"], mappings: { create: (input) => ({ title: input.title }), update: (input) => (input.title === undefined ? {} : { title: input.title }), @@ -239,6 +238,7 @@ describe("batch projections", () => { /* ── relation targets ─────────────────────────────────────────────────────────────────────── */ const commentResource = defineCrudResource({ + fields: ["id", "articleId", "body"], name: "comments", path: "comments", itemPath: ":id", @@ -258,6 +258,7 @@ const commentResource = defineCrudResource({ }); const articleWithComments = defineCrudResource({ + fields: ["id", "title"], name: "articles-with-comments", path: "articles-with-comments", itemPath: ":id", @@ -304,7 +305,6 @@ describe("projections on relation targets", () => { const commentBinding = defineCrudBinding({ resource: commentResource, adapter: { useValue: commentAdapter }, - fields: ["id", "articleId", "body"], mappings: { create: (input) => input, update: (input) => input, @@ -324,7 +324,6 @@ describe("projections on relation targets", () => { const parentBinding = defineCrudBinding({ resource: articleWithComments, adapter: { useValue: articleAdapter }, - fields: ["id", "title"], mappings: { create: (input) => input, update: (input) => input, diff --git a/packages/crud/tests/resource.spec.ts b/packages/crud/tests/resource.spec.ts index 22f7c03..069bf58 100644 --- a/packages/crud/tests/resource.spec.ts +++ b/packages/crud/tests/resource.spec.ts @@ -28,7 +28,7 @@ describe("defineCrudResource", () => { }); it("snapshots nested routing and query configuration", () => { - const fields = ["id", "name"]; + const fields: ("id" | "name")[] = ["id", "name"]; const operators = ["eq"] as const; const operations: CrudOperations = { list: {} }; const definition = { @@ -36,12 +36,12 @@ describe("defineCrudResource", () => { operations, query: { filters: { name: { schema: z.string(), operators } }, - sort: { fields, default: ["id"] }, + sort: { fields, default: ["id"] as const }, }, }; const resource = defineCrudResource(definition); - fields.push("mutated"); + (fields as string[]).push("mutated"); operations.read = {}; expect(resource.query?.sort?.fields).toEqual(["id", "name"]); expect(Object.keys(resource.operations)).toEqual(["list"]); @@ -51,6 +51,7 @@ describe("defineCrudResource", () => { it("snapshots and freezes nested path parameter configuration", () => { const resource = defineCrudResource({ + fields: ["artifactId", "versionId"], name: "artifact-versions", path: "artifacts/:artifactId/versions", itemPath: ":versionId", @@ -127,13 +128,12 @@ describe("defineCrudResource", () => { expect(Object.isFrozen(resource.validators)).toBe(true); }); - it("snapshots binding fields and provider metadata", () => { - const resource = defineCrudResource(validDefinition()); - const fields: string[] = ["id", "name"]; + it("snapshots resource fields and binding provider metadata", () => { + const fields: [string, ...string[]] = ["id", "name"]; + const resource = defineCrudResource({ ...validDefinition(), fields }); const inject = [Symbol("dependency")]; const binding = defineCrudBinding({ resource, - fields, adapter: { inject, useFactory: () => new FakeCrudAdapter(), @@ -148,13 +148,50 @@ describe("defineCrudResource", () => { fields.push("mutated"); inject.push(Symbol("mutated")); - expect(binding.fields).toEqual(["id", "name"]); + expect(resource.fields).toEqual(["id", "name"]); expect("inject" in binding.adapter ? binding.adapter.inject : undefined).toHaveLength(1); - expect(Object.isFrozen(binding.fields)).toBe(true); + expect(Object.isFrozen(resource.fields)).toBe(true); expect(Object.isFrozen(binding.adapter)).toBe(true); }); it.each([ + [ + "an empty logical field vocabulary", + () => + defineCrudResource({ + ...validDefinition(), + fields: [], + } as unknown as CrudResourceDefinition), + "must declare logical fields", + ], + [ + "duplicate logical fields", + () => + defineCrudResource({ + ...validDefinition(), + fields: ["id", "id"], + } as CrudResourceDefinition), + "fields must contain unique values", + ], + [ + "an ID mapping outside the logical field vocabulary", + () => + defineCrudResource({ + ...validDefinition(), + fields: ["name"], + } as CrudResourceDefinition), + 'idFields references undeclared field "id"', + ], + [ + "a query field outside the logical field vocabulary", + () => + defineCrudResource({ + ...validDefinition(), + fields: ["id"], + query: { filters: { name: { schema: z.string(), operators: ["eq"] } } }, + } as CrudResourceDefinition), + 'query.filters references undeclared field "name"', + ], [ "route parameters in the collection path without pathParams", () => @@ -269,7 +306,7 @@ describe("defineCrudResource", () => { defineCrudResource({ ...validDefinition(), idFields: { id: " " }, - }), + } as CrudResourceDefinition), "idFields must contain non-empty strings", ], [ @@ -278,7 +315,7 @@ describe("defineCrudResource", () => { defineCrudResource({ ...validDefinition(), softDelete: { field: "" }, - }), + } as CrudResourceDefinition), "softDelete.field must contain non-empty strings", ], [ @@ -426,7 +463,7 @@ describe("defineCrudResource", () => { sort: { fields: ["id"], cursor: ["createdAt"] }, pagination: { cursor: true }, }, - }), + } as CrudResourceDefinition), "must also appear in sort.fields", ], [ @@ -484,11 +521,11 @@ describe("defineCrudResource", () => { broken: { type: "hasMany", target: () => defineCrudResource(validDefinition()), - local: [], - foreign: [], + local: [] as unknown as [string, ...string[]], + foreign: [] as unknown as [string, ...string[]], }, }, - }), + } as CrudResourceDefinition), "equally-sized, non-empty key tuples", ], [ @@ -507,8 +544,8 @@ describe("defineCrudResource", () => { describe("defineCrudRelation", () => { it("snapshots key tuples and freezes the relation", () => { - const local = ["tenantId", "id"]; - const foreign = ["tenantId", "widgetId"]; + const local: ["tenantId", "id"] = ["tenantId", "id"]; + const foreign: ["tenantId", "widgetId"] = ["tenantId", "widgetId"]; const relation = defineCrudRelation({ type: "hasMany", target: () => defineCrudResource(validDefinition()), @@ -517,8 +554,8 @@ describe("defineCrudRelation", () => { maxItems: 25, }); - local.push("mutated"); - foreign.push("mutated"); + (local as string[]).push("mutated"); + (foreign as string[]).push("mutated"); expect(relation.local).toEqual(["tenantId", "id"]); expect(relation.foreign).toEqual(["tenantId", "widgetId"]); expect(Object.isFrozen(relation)).toBe(true); @@ -688,6 +725,32 @@ describe("CrudRegistry bootstrap validation", () => { expect(() => registry.onApplicationBootstrap()).toThrowError(/unregistered resource "targets"/); }); + it("rejects an erased relation foreign key outside the target vocabulary", () => { + const target = defineCrudResource({ + ...validDefinition(), + name: "targets", + path: "targets", + }); + const source = defineCrudResource({ + ...validDefinition(), + name: "sources", + path: "sources", + relations: { + targets: { + type: "hasMany", + target: () => target, + local: ["id"], + foreign: ["missing"], + }, + }, + } as CrudResourceDefinition); + const registry = new CrudRegistry(); + registry.register(bindingFor(source), fakeService()); + registry.register(bindingFor(target), fakeService()); + + expect(() => registry.onApplicationBootstrap()).toThrowError(/unmapped target field "missing"/); + }); + it("rejects a relation whose registered target is nested", () => { const target = defineCrudResource({ ...validDefinition(), @@ -761,6 +824,18 @@ describe("CrudRegistry bootstrap validation", () => { function validDefinition() { return { + fields: [ + "id", + "name", + "tenantId", + "organizationId", + "projectId", + "parentId", + "widgetId", + "deletedAt", + "nullableName", + "createdAt", + ], name: "widgets", path: "widgets", itemPath: ":id", @@ -779,7 +854,6 @@ function bindingFor(resource: AnyCrudResource) { return defineCrudBinding({ resource, adapter: { useValue: new FakeCrudAdapter() }, - fields: ["id", "name"], mappings: { create: () => ({}), update: () => ({}), diff --git a/packages/crud/tests/service.spec.ts b/packages/crud/tests/service.spec.ts index 5b9910f..bac956f 100644 --- a/packages/crud/tests/service.spec.ts +++ b/packages/crud/tests/service.spec.ts @@ -141,7 +141,6 @@ describe("CrudService visibility and identity", () => { const binding = defineCrudBinding({ resource: userResource, adapter: { useValue: typedAdapter }, - fields: ["id", "name", "tenantId", "deletedAt"], scopeCreateFields, mappings: { create: (input) => ({ name: input.name, deletedAt: null }), @@ -255,7 +254,6 @@ describe("CrudService transaction and lifecycle semantics", () => { const binding = defineCrudBinding({ resource: userResource, adapter: { useValue: adapter }, - fields: ["id", "name", "tenantId", "deletedAt"], mappings: { create: (input) => input, update: (input) => input, @@ -968,7 +966,6 @@ describe("CrudService nested resource context", () => { const binding = defineCrudBinding({ resource: nestedChildResource, adapter: { useValue: adapter }, - fields: ["parentId", "id", "name"], mappings: { create: (input) => input, update: (input) => input, @@ -992,6 +989,7 @@ describe("CrudService nested resource context", () => { it("requires explicit parent materialization for nested upsert-only resources", () => { const resource = defineCrudResource({ + fields: ["parentId", "id", "name"], name: "nested-upsert-only", path: "parents/:parentId/upsert-only", itemPath: ":id", @@ -1013,7 +1011,6 @@ describe("CrudService nested resource context", () => { const bindingOptions = { resource, adapter: { useValue: adapter }, - fields: ["parentId", "id", "name"] as const, upsert: { conflictFields: ["parentId", "id"], overwriteFields: ["name"] } as const, mappings: { create: (input: { name: string }) => input, @@ -1063,6 +1060,7 @@ describe("CrudService nested resource context", () => { describe("CrudService soft deletion", () => { it("supports explicitly idempotent deletes without running mutation hooks for absent rows", async () => { const resource = defineCrudResource({ + fields: ["id", "name"], name: "idempotent-records", path: "idempotent-records", itemPath: ":id", @@ -1079,7 +1077,6 @@ describe("CrudService soft deletion", () => { const binding = defineCrudBinding({ resource, adapter: { useValue: adapter }, - fields: ["id", "name"], mappings: { create: (input) => input, update: (input) => input, @@ -1146,6 +1143,7 @@ describe("CrudService soft deletion", () => { describe("CrudService persistence field mapping", () => { it("omits an empty persistence mapper and removes undefined mapper properties", async () => { const resource = defineCrudResource({ + fields: ["id", "name"], name: "direct-users", path: "direct-users", itemPath: ":id", @@ -1162,7 +1160,6 @@ describe("CrudService persistence field mapping", () => { const binding = defineCrudBinding({ resource, adapter: { useValue: adapter }, - fields: ["id", "name"], mappings: { create: (input) => input, update: (input) => input, @@ -1192,6 +1189,7 @@ describe("CrudService persistence field mapping", () => { it("fails closed before mutation when generated values lack a persistence mapper", async () => { const resource = defineCrudResource({ name: "unmapped-scope-users", + fields: ["id", "name", "tenantId"], path: "unmapped-scope-users", itemPath: ":id", idFields: { id: "id" }, @@ -1207,7 +1205,6 @@ describe("CrudService persistence field mapping", () => { const binding = defineCrudBinding({ resource, adapter: { useValue: adapter }, - fields: ["id", "name"], mappings: { create: (input) => input, update: (input) => input, @@ -1242,7 +1239,6 @@ describe("CrudService persistence field mapping", () => { const binding = defineCrudBinding({ resource: userResource, adapter: { useValue: adapter }, - fields: ["id", "name", "tenantId", "deletedAt"], mappings: { create: (input) => ({ name: input.name, tenantId: "tenant-a", deletedAt: null }), update: (input) => input, @@ -1272,6 +1268,7 @@ describe("CrudService persistence field mapping", () => { it("maps scoped and soft-delete logical values before every adapter write", async () => { const resource = defineCrudResource({ name: "aliased-users", + fields: ["id", "name", "tenantId", "deletedAt"], path: "aliased-users", itemPath: ":id", idFields: { id: "id" }, @@ -1303,7 +1300,6 @@ describe("CrudService persistence field mapping", () => { const binding = defineCrudBinding({ resource, adapter: { useValue: adapter }, - fields: ["id", "name", "tenantId", "deletedAt"], mappings: { create: (input) => ({ display_name: input.name, removed_on: null }), update: (input) => (input.name === undefined ? {} : { display_name: input.name }), @@ -1387,6 +1383,7 @@ describe("CrudService bounded relations", () => { }); const viewerBindingResource = defineCrudResource({ + fields: ["artifactId", "viewerUserId", "serverId", "alias", "toolPrefix", "visible"], name: "viewer-bindings", path: "viewer-bindings", itemPath: ":artifactId/:serverId", @@ -1419,7 +1416,6 @@ function createViewerBinding( return defineCrudBinding({ resource: viewerBindingResource, adapter: { useValue: adapter }, - fields: ["artifactId", "serverId"], scopeCreateFields: ["viewerUserId"], ...(options.configure === false ? {} @@ -1489,6 +1485,7 @@ function createViewerBindingService( } const nestedChildResource = defineCrudResource({ + fields: ["parentId", "id", "name"], name: "nested-children", path: "parents/:parentId/children", itemPath: ":id", @@ -1518,7 +1515,6 @@ function createNestedChildService( const binding = defineCrudBinding({ resource: nestedChildResource, adapter: { useValue: adapter }, - fields: ["parentId", "id", "name"], scopeCreateFields: ["parentId"], mappings: { create: (input) => input, @@ -1548,6 +1544,7 @@ function nestedChildResponse(record: Record) { } const childResource = defineCrudResource({ + fields: ["id", "parentId", "name"], name: "children", path: "children", itemPath: ":id", @@ -1563,6 +1560,7 @@ const childResource = defineCrudResource({ }); const cursorResource = defineCrudResource({ + fields: ["id", "rank"], name: "cursor-records", path: "cursor-records", itemPath: ":id", @@ -1585,7 +1583,6 @@ function createCursorService(records: readonly Record[]) { const binding = defineCrudBinding({ resource: cursorResource, adapter: { useValue: adapter }, - fields: ["id", "rank"], mappings: { create: (input) => input, update: (input) => input, @@ -1609,6 +1606,7 @@ function createCursorService(records: readonly Record[]) { } const parentResource = defineCrudResource({ + fields: ["id", "name"], name: "parents", path: "parents", itemPath: ":id", @@ -1643,7 +1641,6 @@ function createRelationServices(children: readonly Record[]) { const childBinding = defineCrudBinding({ resource: childResource, adapter: { useValue: childAdapter }, - fields: ["id", "parentId", "name"], mappings: { create: (input) => input, update: (input) => input, @@ -1658,7 +1655,6 @@ function createRelationServices(children: readonly Record[]) { const parentBinding = defineCrudBinding({ resource: parentResource, adapter: { useValue: parentAdapter }, - fields: ["id", "name"], mappings: { create: (input) => input, update: (input) => input, diff --git a/packages/crud/tests/support/core-fixtures.ts b/packages/crud/tests/support/core-fixtures.ts index e72c49d..06fb180 100644 --- a/packages/crud/tests/support/core-fixtures.ts +++ b/packages/crud/tests/support/core-fixtures.ts @@ -23,6 +23,7 @@ const childResponseSchema = z.object({ }); export const userResource = defineCrudResource({ + fields: ["id", "name", "tenantId", "deletedAt"], name: "users", path: "/api/users/", itemPath: ":id", @@ -64,7 +65,6 @@ export function createUserBinding(adapter: FakeCrudAdapter) { return defineCrudBinding({ resource: userResource, adapter: { useValue: adapter }, - fields: ["id", "name", "tenantId", "deletedAt"], mappings: { create: (input) => ({ name: input.name, @@ -123,6 +123,7 @@ export function createUserService(options: CreateUserServiceOptions = {}) { } export const compositeResource = defineCrudResource({ + fields: ["tenantId", "id", "role"], name: "memberships", path: "memberships", itemPath: ":tenantId/:id", @@ -141,7 +142,6 @@ export function createCompositeBinding(adapter: FakeCrudAdapter) { return defineCrudBinding({ resource: compositeResource, adapter: { useValue: adapter }, - fields: ["tenantId", "id", "role"], mappings: { create: (input) => input, update: (input) => input, diff --git a/packages/crud/tests/types.fixture.ts b/packages/crud/tests/types.fixture.ts index ce2a43c..529305d 100644 --- a/packages/crud/tests/types.fixture.ts +++ b/packages/crud/tests/types.fixture.ts @@ -2,16 +2,22 @@ import { z } from "zod"; import type { StandardSchemaV1 } from "@standard-schema/spec"; import type { ExecutionContext } from "@nestjs/common"; -import type { CrudAdapter } from "../src/adapter/adapter.types.ts"; -import { defineCrudBinding, type CrudResourceBinding } from "../src/adapter/binding.types.ts"; +import type { CrudAdapter, CrudUpsertInput } from "../src/adapter/adapter.types.ts"; +import { + defineCrudBinding, + type CrudBindingUpsertOptions, + type CrudResourceBinding, +} from "../src/adapter/binding.types.ts"; import { defineCrudFactoryProvider } from "../src/module/factory-provider.types.ts"; import { defineCrudResource } from "../src/resource/define-resource.ts"; +import type { CrudPredicate, CrudQueryConfig } from "../src/query/query.types.ts"; import { crudOperations, type CrudOperations } from "../src/resource/operations.ts"; import { CrudModule, type CrudModuleAsyncOptions } from "../src/module/crud.module.ts"; import { getCrudServiceToken, type CrudServiceToken } from "../src/module/crud.tokens.ts"; import type { AnyCrudResource, CrudCreate, + CrudField, CrudId, CrudPathParams, CrudResponse, @@ -25,9 +31,11 @@ import type { CrudScope, } from "../src/runtime/runtime.types.ts"; import { defineCrudFact, provideCrudFact } from "../src/runtime/crud-facts.ts"; +import type { CrudAdapterConformanceFixture } from "../src/testing/conformance.ts"; import { FakeCrudAdapter } from "./support/fake-crud-adapter.ts"; export const typedResource = defineCrudResource({ + fields: ["tenantId", "id", "label", "count", "ownerId"], name: "typed-records", path: "typed-records", itemPath: ":tenantId/:id", @@ -46,6 +54,81 @@ export const typedResource = defineCrudResource({ operations: crudOperations.all(), }); +export type FieldInference = Assert< + Equal, "tenantId" | "id" | "label" | "count" | "ownerId"> +>; + +type ConformanceRecord = { readonly id: string; readonly label: string }; +export type ConformanceFieldInference = Assert< + Equal["idField"], "id" | "label"> +>; +// @ts-expect-error conformance field names must be keys of the record by default. +const invalidConformanceField: CrudAdapterConformanceFixture["sortField"] = + "missing"; +void invalidConformanceField; + +const invalidIdFieldResource = defineCrudResource({ + ...typedResource, + name: "invalid-id-field", + path: "invalid-id-field", + // @ts-expect-error ID mappings must use the resource's logical field vocabulary. + idFields: { + tenantId: "tenant_id", + id: "id", + }, +}); + +const invalidSoftDeleteFieldResource = defineCrudResource({ + ...typedResource, + name: "invalid-soft-delete-field", + path: "invalid-soft-delete-field", + softDelete: { + // @ts-expect-error soft-delete fields must be declared by the resource. + field: "deletedAt", + }, +}); + +const invalidResourceQueryField = defineCrudResource({ + ...typedResource, + name: "invalid-query-field", + path: "invalid-query-field", + query: { + sort: { + // @ts-expect-error resource query fields use the declared logical vocabulary. + fields: ["missing"], + }, + }, +}); + +void invalidIdFieldResource; +void invalidSoftDeleteFieldResource; +void invalidResourceQueryField; + +export const typedQueryConfig = { + filters: { + label: { schema: z.string(), operators: ["eq", "contains"] }, + }, + sort: { fields: ["id", "label"], default: ["-label"], cursor: ["id"] }, + search: { fields: ["label"] }, +} as const satisfies CrudQueryConfig<"id" | "label">; + +export const invalidTypedQueryConfig = { + sort: { + fields: [ + // @ts-expect-error configured query fields use the declared logical vocabulary. + "missing", + ], + }, +} as const satisfies CrudQueryConfig<"id" | "label">; + +export const invalidTypedPredicate: CrudPredicate<"id" | "label"> = { + kind: "comparison", + // @ts-expect-error neutral predicates preserve their logical field vocabulary. + field: "missing", + operator: "eq", + value: 1, +}; + export const idempotentDeleteOperations: CrudOperations = { delete: { missing: "ignore" }, }; @@ -78,6 +161,7 @@ export type ErasedPathParamsInference = Assert< >; export const nestedTypedResource = defineCrudResource({ + fields: ["parentId", "childId", "label", "enabled"], name: "typed-children", path: "parents/:parentId/children", itemPath: ":childId", @@ -96,6 +180,84 @@ export const nestedTypedResource = defineCrudResource({ operations: crudOperations.only("create", "list", "read", "upsert"), }); +const typedRelationResource = defineCrudResource({ + ...typedResource, + name: "typed-relation-resource", + path: "typed-relation-resource", + relations: { + children: { + type: "hasMany", + target: () => nestedTypedResource, + local: ["id"], + foreign: ["childId"], + }, + }, +}); + +const invalidRelationTargetFieldDefinition = { + ...typedResource, + name: "invalid-relation-target-field", + path: "invalid-relation-target-field", + relations: { + children: { + type: "hasMany", + target: () => nestedTypedResource, + local: ["id"], + foreign: ["missing"], + }, + }, +} as const; +const invalidRelationTargetFieldResource = defineCrudResource( + // @ts-expect-error relation foreign keys must exist on the target resource. + invalidRelationTargetFieldDefinition, +); + +const mismatchedRelationTupleDefinition = { + ...typedResource, + name: "mismatched-relation-tuples", + path: "mismatched-relation-tuples", + relations: { + children: { + type: "hasMany", + target: () => nestedTypedResource, + local: ["tenantId", "id"], + foreign: ["childId"], + }, + }, +} as const; +const mismatchedRelationTupleResource = defineCrudResource( + // @ts-expect-error relation key tuples must have the same length. + mismatchedRelationTupleDefinition, +); + +const invalidSortSelectionDefinition = { + ...typedResource, + name: "invalid-sort-selection", + path: "invalid-sort-selection", + query: { sort: { fields: ["id"], default: ["label"] } }, +} as const; +const invalidSortSelectionResource = defineCrudResource( + // @ts-expect-error default sort fields must be enabled by sort.fields. + invalidSortSelectionDefinition, +); + +const duplicateFieldDefinition = { + ...typedResource, + name: "duplicate-field-vocabulary", + path: "duplicate-field-vocabulary", + fields: [...typedResource.fields, "id"], +} as const; +const duplicateFieldResource = defineCrudResource( + // @ts-expect-error the authoritative logical field vocabulary cannot contain duplicates. + duplicateFieldDefinition, +); + +void invalidRelationTargetFieldResource; +void typedRelationResource; +void mismatchedRelationTupleResource; +void invalidSortSelectionResource; +void duplicateFieldResource; + export type NestedPathParamsInference = Assert< Equal, { parentId: string }> >; @@ -145,6 +307,27 @@ export const typedHook: CrudLifecycleHook = { beforeUpdate: (input) => ({ ...input, label: input.label?.trim() }), }; +export const invalidPredicateScope: CrudScope = { + resolve: () => ({ + predicate: { + kind: "comparison", + // @ts-expect-error scope predicates use the resource's logical field vocabulary. + field: "missing", + operator: "eq", + value: 1, + }, + }), +}; + +export const invalidCreateValuesScope: CrudScope = { + resolve: () => ({ + createValues: { + // @ts-expect-error scope values are keyed by declared logical fields. + missing: "value", + }, + }), +}; + interface AuthorizedParent { readonly id: string; readonly organizationId: string | null; @@ -190,7 +373,6 @@ export const typedValidator: CrudMutationValidator = { export const typedBinding = defineCrudBinding({ resource: typedResource, adapter: { useValue: new FakeCrudAdapter() }, - fields: ["tenantId", "id", "label", "count"], mappings: { create: (input) => input, update: (input) => input, @@ -220,6 +402,29 @@ interface NativeUpdateValues { readonly deleted_at?: Date | null; } +export const nativeUpsertOptions = { + conflictFields: ["tenant_id"], + overwriteFields: ["display_label", "total_count"], +} as const satisfies CrudBindingUpsertOptions; + +export const invalidNativeUpsertOptions = { + conflictFields: [ + // @ts-expect-error conflict fields must be keys of the adapter create values. + "tenantId", + ], + overwriteFields: ["display_label"], +} as const satisfies CrudBindingUpsertOptions; + +export const invalidNativeUpsertInput: CrudUpsertInput = { + conflictFields: ["tenant_id"], + predicate: { kind: "and", predicates: [] }, + values: { tenant_id: "tenant", display_label: "Typed", total_count: 1 }, + overwriteFields: [ + // @ts-expect-error adapter upsert inputs preserve the persistence field vocabulary. + "label", + ], +}; + declare const nativeAdapter: CrudAdapter; interface ImmutableScopeRecord { @@ -247,7 +452,6 @@ declare const immutableScopeAdapter: CrudAdapter< export const immutableScopeBinding = defineCrudBinding({ resource: typedResource, adapter: { useValue: immutableScopeAdapter }, - fields: ["tenantId", "id", "label", "count"], scopeCreateFields: ["owner_id"], mappings: { create: (input) => ({ display_label: input.label }), @@ -267,7 +471,6 @@ export const immutableScopeBinding = defineCrudBinding({ export const persistenceTypedBinding = defineCrudBinding({ resource: typedResource, adapter: { useValue: nativeAdapter }, - fields: ["tenantId", "id", "label", "count"], mappings: { create: (input) => ({ tenant_id: "tenant", @@ -299,7 +502,6 @@ export type PersistenceUpdateInference = Assert< export const invalidPersistenceCreateBinding = defineCrudBinding({ resource: typedResource, adapter: { useValue: nativeAdapter }, - fields: ["tenantId", "id", "label", "count"], mappings: { // @ts-expect-error mapped creates must contain every required native create value. create: (input) => ({ display_label: input.label, total_count: input.count }), @@ -317,7 +519,6 @@ export const invalidPersistenceCreateBinding = defineCrudBinding({ export const invalidPersistenceUpdateBinding = defineCrudBinding({ resource: typedResource, adapter: { useValue: nativeAdapter }, - fields: ["tenantId", "id", "label", "count"], mappings: { create: (input) => ({ tenant_id: "tenant", @@ -339,7 +540,6 @@ export const invalidPersistenceUpdateBinding = defineCrudBinding({ export const invalidFrameworkPersistenceBinding = defineCrudBinding({ resource: typedResource, adapter: { useValue: nativeAdapter }, - fields: ["tenantId", "id", "label", "count"], mappings: { create: (input) => ({ tenant_id: "tenant", @@ -377,7 +577,6 @@ export type AdapterFactoryDependencyInference = Assert< export const factoryBackedBinding = defineCrudBinding({ resource: typedResource, adapter: typedAdapterFactory, - fields: ["tenantId", "id", "label", "count"], mappings: { create: (input) => input, update: (input) => input, @@ -489,25 +688,29 @@ const unexpectedPathParamsDefinition = { // @ts-expect-error flat collection paths cannot declare pathParams. const unexpectedPathParamsResource = defineCrudResource(unexpectedPathParamsDefinition); -// @ts-expect-error pathParams contract keys must exactly match its field mappings. -const mismatchedPathParamsContractResource = defineCrudResource({ +const mismatchedPathParamsContractDefinition = { ...nestedTypedResource, name: "mismatched-parent-contract", pathParams: { contract: z.object({ organizationId: z.string().uuid() }), fields: { parentId: "parentId" }, }, -}); +} as const; +const mismatchedPathParamsContractResource = defineCrudResource( + // @ts-expect-error pathParams contract keys must exactly match its field mappings. + mismatchedPathParamsContractDefinition, +); -// @ts-expect-error every path parameter must be required in the pathParams contract output. -const optionalPathParamResource = defineCrudResource({ +const optionalPathParamDefinition = { ...nestedTypedResource, name: "optional-parent-param", pathParams: { contract: z.object({ parentId: z.string().uuid().optional() }), fields: { parentId: "parentId" }, }, -}); +} as const; +// @ts-expect-error every path parameter must be required in the pathParams contract output. +const optionalPathParamResource = defineCrudResource(optionalPathParamDefinition); // @ts-expect-error parent and item route parameter names must be disjoint. const overlappingPathParamResource = defineCrudResource({ @@ -537,10 +740,10 @@ const duplicatePathParamDefinition = { // @ts-expect-error parent route parameter names must be unique. const duplicatePathParamResource = defineCrudResource(duplicatePathParamDefinition); -// @ts-expect-error parent mappings must be identical in pathParams.fields and idFields. const mismatchedPathParamMappingResource = defineCrudResource({ ...nestedTypedResource, name: "mismatched-parent-mapping", + // @ts-expect-error parent mappings must be identical in pathParams.fields and idFields. idFields: { parentId: "ownerId", childId: "childId" }, }); @@ -579,7 +782,6 @@ export const invalidCreate: CrudCreate = { label: "wrong", export const invalidBinding = defineCrudBinding({ resource: typedResource, adapter: { useValue: new FakeCrudAdapter() }, - fields: ["tenantId", "id", "label", "count"], mappings: { create: (input) => input, update: (input) => input, @@ -589,19 +791,6 @@ export const invalidBinding = defineCrudBinding({ }, }); -// @ts-expect-error every composite ID field must be declared by the binding. -export const incompleteFieldBinding = defineCrudBinding({ - resource: typedResource, - adapter: { useValue: new FakeCrudAdapter() }, - fields: ["id", "label", "count"], - mappings: { - create: (input) => input, - update: (input) => input, - persistence: (values) => values, - response: () => ({ tenantId: "tenant", id: 1, label: "typed", count: 1 }), - }, -}); - type Equal = (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 ? (() => Value extends Right ? 1 : 2) extends () => Value extends Left ? 1 : 2 @@ -615,23 +804,11 @@ type ServiceTokenResource = Token extends CrudServiceToken ? Resource : never; type BindingCreateValues = - Binding extends CrudResourceBinding< - AnyCrudResource, - unknown, - readonly string[], - infer CreateValues, - object - > + Binding extends CrudResourceBinding ? CreateValues : never; type BindingUpdateValues = - Binding extends CrudResourceBinding< - AnyCrudResource, - unknown, - readonly string[], - object, - infer UpdateValues - > + Binding extends CrudResourceBinding ? UpdateValues : never; diff --git a/tests/postgres/adapters.postgres.spec.ts b/tests/postgres/adapters.postgres.spec.ts index 1a787e5..5d172df 100644 --- a/tests/postgres/adapters.postgres.spec.ts +++ b/tests/postgres/adapters.postgres.spec.ts @@ -110,6 +110,7 @@ const RELATION_PARENT_TENANT = "relation-parents"; const RELATION_CHILD_TENANT = "relation-children-visible"; const scopedServiceResource = defineCrudResource({ + fields: ["tenantId", "id", "name", "score", "category", "createdAt"], name: "postgres-scoped-items", path: "postgres/scoped-items", itemPath: ":id", @@ -141,6 +142,7 @@ const scopedServiceResource = defineCrudResource({ }); const relationChildResource = defineCrudResource({ + fields: ["tenantId", "id", "name", "score", "category", "createdAt"], name: "postgres-relation-children", path: "postgres/relation-children", itemPath: ":id", @@ -156,6 +158,7 @@ const relationChildResource = defineCrudResource({ }); const relationParentResource = defineCrudResource({ + fields: ["tenantId", "id", "name", "score", "category", "createdAt"], name: "postgres-relation-parents", path: "postgres/relation-parents", itemPath: ":id", @@ -192,15 +195,15 @@ function context(operation: CrudAdapterContext["operation"]): CrudAdapterContext return { resource: "postgres-conformance", operation }; } -function comparison( - field: string, - operator: Extract["operator"], +function comparison( + field: Field, + operator: Extract, { kind: "comparison" }>["operator"], value: unknown, -): CrudPredicate { +): CrudPredicate { return { kind: "comparison", field, operator, value }; } -function identity(tenantId: string, id: string): CrudPredicate { +function identity(tenantId: string, id: string): CrudPredicate<"tenantId" | "id"> { return { kind: "and", predicates: [comparison("tenantId", "eq", tenantId), comparison("id", "eq", id)], @@ -491,7 +494,6 @@ function createScopedService(adapter: CrudAdapter) { const binding = defineCrudBinding({ resource: scopedServiceResource, adapter: { useValue: adapter }, - fields: ["tenantId", "id", "name", "score", "category", "createdAt"], mappings: { create: (input) => record({ @@ -534,7 +536,6 @@ function createRelationServices(adapter: CrudAdapter) { const childBinding = defineCrudBinding({ resource: relationChildResource, adapter: { useValue: adapter }, - fields: ["tenantId", "id", "name", "score", "category", "createdAt"], mappings: { create: (input) => record({ @@ -555,7 +556,6 @@ function createRelationServices(adapter: CrudAdapter) { const parentBinding = defineCrudBinding({ resource: relationParentResource, adapter: { useValue: adapter }, - fields: ["tenantId", "id", "name", "score", "category", "createdAt"], mappings: { create: (input) => record({