diff --git a/packages/orm/src/client/client-impl.ts b/packages/orm/src/client/client-impl.ts index fc8f92c7c..acf888f8a 100644 --- a/packages/orm/src/client/client-impl.ts +++ b/packages/orm/src/client/client-impl.ts @@ -75,6 +75,11 @@ export class ClientImpl { ...this.$options.functions, }; + if (!baseClient) { + // validate computed fields configuration once for the root client + this.validateComputedFieldsConfig(); + } + // here we use kysely's props constructor so we can pass a custom query executor if (baseClient) { this.kyselyProps = { @@ -139,6 +144,39 @@ export class ClientImpl { return new ClientImpl(this.schema, this.$options, this, executor); } + /** + * Validates that all computed fields in the schema have corresponding configurations. + */ + private validateComputedFieldsConfig() { + const computedFieldsConfig = + 'computedFields' in this.$options + ? (this.$options.computedFields as Record | undefined) + : undefined; + + for (const [modelName, modelDef] of Object.entries(this.$schema.models)) { + if (modelDef.computedFields) { + for (const fieldName of Object.keys(modelDef.computedFields)) { + const modelConfig = computedFieldsConfig?.[modelName]; + const fieldConfig = modelConfig?.[fieldName]; + // Check if the computed field has a configuration + if (fieldConfig === null || fieldConfig === undefined) { + throw createConfigError( + `Computed field "${fieldName}" in model "${modelName}" does not have a configuration. ` + + `Please provide an implementation in the computedFields option.`, + ); + } + // Check that the configuration is a function + if (typeof fieldConfig !== 'function') { + throw createConfigError( + `Computed field "${fieldName}" in model "${modelName}" has an invalid configuration: ` + + `expected a function but received ${typeof fieldConfig}.`, + ); + } + } + } + } + } + // overload for interactive transaction $transaction( callback: (tx: ClientContract) => Promise, diff --git a/packages/testtools/src/client.ts b/packages/testtools/src/client.ts index 89148c405..69513eeb4 100644 --- a/packages/testtools/src/client.ts +++ b/packages/testtools/src/client.ts @@ -104,6 +104,11 @@ type ExtraTestClientOptions = { globPattern: string; destination: string; }[]; + + /** + * Computed fields configuration for tests. + */ + computedFields?: import('@zenstackhq/orm').ComputedFieldsOptions; }; export type CreateTestClientOptions = Omit, 'dialect'> & diff --git a/tests/e2e/orm/client-api/computed-fields.test.ts b/tests/e2e/orm/client-api/computed-fields.test.ts index c6470a720..1816854b8 100644 --- a/tests/e2e/orm/client-api/computed-fields.test.ts +++ b/tests/e2e/orm/client-api/computed-fields.test.ts @@ -3,6 +3,94 @@ import { sql } from 'kysely'; import { describe, expect, it } from 'vitest'; describe('Computed fields tests', () => { + it('throws error when computed field configuration is missing', async () => { + await expect( + createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + name String + upperName String @computed +} +`, + { + // missing computedFields configuration + } as any, + ), + ).rejects.toThrow('Computed field "upperName" in model "User" does not have a configuration'); + }); + + it('throws error when computed field is missing from configuration', async () => { + await expect( + createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + name String + upperName String @computed + lowerName String @computed +} +`, + { + computedFields: { + User: { + // only providing one of two computed fields + upperName: (eb: any) => eb.fn('upper', ['name']), + }, + }, + } as any, + ), + ).rejects.toThrow('Computed field "lowerName" in model "User" does not have a configuration'); + }); + + it('throws error when computed field configuration is not a function', async () => { + await expect( + createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + name String + upperName String @computed +} +`, + { + computedFields: { + User: { + // providing a string instead of a function + upperName: 'not a function' as any, + }, + }, + } as any, + ), + ).rejects.toThrow( + 'Computed field "upperName" in model "User" has an invalid configuration: expected a function but received string', + ); + }); + + it('throws error when computed field configuration is a non-function object', async () => { + await expect( + createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + name String + computed1 String @computed +} +`, + { + computedFields: { + User: { + // providing an object instead of a function + computed1: { key: 'value' } as any, + }, + }, + } as any, + ), + ).rejects.toThrow( + 'Computed field "computed1" in model "User" has an invalid configuration: expected a function but received object', + ); + }); + it('works with non-optional fields', async () => { const db = await createTestClient( ` @@ -102,6 +190,11 @@ model User { } `, { + computedFields: { + User: { + upperName: (eb: any) => eb.fn('upper', ['name']), + }, + }, extraSourceFiles: { main: ` import { ZenStackClient } from '@zenstackhq/orm'; @@ -169,6 +262,11 @@ model User { } `, { + computedFields: { + User: { + upperName: (eb: any) => eb.lit(null), + }, + }, extraSourceFiles: { main: ` import { ZenStackClient } from '@zenstackhq/orm';