From d426eefc3315d9881ccbef90ce44a00b6c26a79d Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Wed, 25 Jun 2025 11:15:35 +0800 Subject: [PATCH 1/2] fix: incorrect computed field typing for optional fields fixes #33 --- .../src/client/helpers/schema-db-pusher.ts | 6 +- .../test/client-api/computed-fields.test.ts | 146 ++++++++++++++++++ packages/runtime/test/utils.ts | 6 +- packages/sdk/src/ts-schema-generator.ts | 28 ++-- packages/testtools/package.json | 1 + packages/testtools/src/schema.ts | 11 ++ 6 files changed, 186 insertions(+), 12 deletions(-) create mode 100644 packages/runtime/test/client-api/computed-fields.test.ts diff --git a/packages/runtime/src/client/helpers/schema-db-pusher.ts b/packages/runtime/src/client/helpers/schema-db-pusher.ts index 7042d7ec..34974482 100644 --- a/packages/runtime/src/client/helpers/schema-db-pusher.ts +++ b/packages/runtime/src/client/helpers/schema-db-pusher.ts @@ -43,7 +43,7 @@ export class SchemaDbPusher { for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) { if (fieldDef.relation) { table = this.addForeignKeyConstraint(table, model, fieldName, fieldDef); - } else { + } else if (!this.isComputedField(fieldDef)) { table = this.createModelField(table, fieldName, fieldDef, modelDef); } } @@ -54,6 +54,10 @@ export class SchemaDbPusher { return table; } + private isComputedField(fieldDef: FieldDef) { + return fieldDef.attributes?.some((a) => a.name === '@computed'); + } + private addPrimaryKeyConstraint( table: CreateTableBuilder, model: GetModels, diff --git a/packages/runtime/test/client-api/computed-fields.test.ts b/packages/runtime/test/client-api/computed-fields.test.ts new file mode 100644 index 00000000..d81d2784 --- /dev/null +++ b/packages/runtime/test/client-api/computed-fields.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest'; +import { createTestClient } from '../utils'; + +describe('Computed fields tests', () => { + it('works with non-optional fields', async () => { + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + name String + upperName String @computed +} +`, + { + computedFields: { + User: { + upperName: (eb) => eb.fn('upper', ['name']), + }, + }, + } as any, + ); + + await expect( + db.user.create({ + data: { id: 1, name: 'Alex' }, + }), + ).resolves.toMatchObject({ + upperName: 'ALEX', + }); + + await expect( + db.user.findUnique({ + where: { id: 1 }, + select: { upperName: true }, + }), + ).resolves.toMatchObject({ + upperName: 'ALEX', + }); + }); + + it('is typed correctly for non-optional fields', async () => { + await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + name String + upperName String @computed +} +`, + { + extraSourceFiles: { + main: ` +import { ZenStackClient } from '@zenstackhq/runtime'; +import { schema } from './schema'; + +async function main() { + const client = new ZenStackClient(schema, { + dialectConfig: {} as any, + computedFields: { + User: { + upperName: (eb) => eb.fn('upper', ['name']), + }, + } + }); + + const user = await client.user.create({ + data: { id: 1, name: 'Alex' } + }); + console.log(user.upperName); + // @ts-expect-error + user.upperName = null; +} + +main(); +`, + }, + }, + ); + }); + + it('works with optional fields', async () => { + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + name String + upperName String? @computed +} +`, + { + computedFields: { + User: { + upperName: (eb) => eb.lit(null), + }, + }, + } as any, + ); + + await expect( + db.user.create({ + data: { id: 1, name: 'Alex' }, + }), + ).resolves.toMatchObject({ + upperName: null, + }); + }); + + it('is typed correctly for optional fields', async () => { + await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + name String + upperName String? @computed +} +`, + { + extraSourceFiles: { + main: ` +import { ZenStackClient } from '@zenstackhq/runtime'; +import { schema } from './schema'; + +async function main() { + const client = new ZenStackClient(schema, { + dialectConfig: {} as any, + computedFields: { + User: { + upperName: (eb) => eb.lit(null), + }, + } + }); + + const user = await client.user.create({ + data: { id: 1, name: 'Alex' } + }); + console.log(user.upperName); + user.upperName = null; +} + +main(); +`, + }, + }, + ); + }); +}); diff --git a/packages/runtime/test/utils.ts b/packages/runtime/test/utils.ts index ffce327e..88aa389c 100644 --- a/packages/runtime/test/utils.ts +++ b/packages/runtime/test/utils.ts @@ -61,6 +61,7 @@ export type CreateTestClientOptions = Omit; }; export async function createTestClient( @@ -79,11 +80,14 @@ export async function createTestClient( let _schema: Schema; if (typeof schema === 'string') { - const generated = await generateTsSchema(schema, options?.provider, options?.dbName); + const generated = await generateTsSchema(schema, options?.provider, options?.dbName, options?.extraSourceFiles); workDir = generated.workDir; _schema = generated.schema as Schema; } else { _schema = schema; + if (options?.extraSourceFiles) { + throw new Error('`extraSourceFiles` is not supported when schema is a SchemaDef object'); + } } const { plugins, ...rest } = options ?? {}; diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index 8183c5b6..c6e22a2e 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -8,6 +8,7 @@ import { DataModelAttribute, DataModelField, DataModelFieldAttribute, + DataModelFieldType, Enum, Expression, InvocationExpr, @@ -246,7 +247,7 @@ export class TsSchemaGenerator { undefined, [], ts.factory.createTypeReferenceNode('OperandExpression', [ - ts.factory.createKeywordTypeNode(this.mapTypeToTSSyntaxKeyword(field.type.type!)), + ts.factory.createTypeReferenceNode(this.mapFieldTypeToTSType(field.type)), ]), ts.factory.createBlock( [ @@ -264,15 +265,22 @@ export class TsSchemaGenerator { ); } - private mapTypeToTSSyntaxKeyword(type: string) { - return match(type) - .with('String', () => ts.SyntaxKind.StringKeyword) - .with('Boolean', () => ts.SyntaxKind.BooleanKeyword) - .with('Int', () => ts.SyntaxKind.NumberKeyword) - .with('Float', () => ts.SyntaxKind.NumberKeyword) - .with('BigInt', () => ts.SyntaxKind.BigIntKeyword) - .with('Decimal', () => ts.SyntaxKind.NumberKeyword) - .otherwise(() => ts.SyntaxKind.UnknownKeyword); + private mapFieldTypeToTSType(type: DataModelFieldType) { + let result = match(type.type) + .with('String', () => 'string') + .with('Boolean', () => 'boolean') + .with('Int', () => 'number') + .with('Float', () => 'number') + .with('BigInt', () => 'bigint') + .with('Decimal', () => 'number') + .otherwise(() => 'unknown'); + if (type.array) { + result = `${result}[]`; + } + if (type.optional) { + result = `${result} | null`; + } + return result; } private createDataModelFieldObject(field: DataModelField) { diff --git a/packages/testtools/package.json b/packages/testtools/package.json index 1260a67b..10e7cfe8 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "build": "tsup-node", + "watch": "tsup-node --watch", "lint": "eslint src --ext ts", "pack": "pnpm pack" }, diff --git a/packages/testtools/src/schema.ts b/packages/testtools/src/schema.ts index 8214c4ef..37b328bf 100644 --- a/packages/testtools/src/schema.ts +++ b/packages/testtools/src/schema.ts @@ -32,6 +32,7 @@ export async function generateTsSchema( schemaText: string, provider: 'sqlite' | 'postgresql' = 'sqlite', dbName?: string, + extraSourceFiles?: Record, ) { const { name: workDir } = tmp.dirSync({ unsafeCleanup: true }); console.log(`Working directory: ${workDir}`); @@ -90,10 +91,20 @@ export async function generateTsSchema( moduleResolution: 'Bundler', esModuleInterop: true, skipLibCheck: true, + strict: true, }, + include: ['**/*.ts'], }), ); + if (extraSourceFiles) { + for (const [fileName, content] of Object.entries(extraSourceFiles)) { + const filePath = path.resolve(workDir, `${fileName}.ts`); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); + } + } + // compile the generated TS schema execSync('npx tsc', { cwd: workDir, From c78d5ac113278d78200fc58f48809d83599dce16 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Wed, 25 Jun 2025 11:21:13 +0800 Subject: [PATCH 2/2] fix tests --- packages/runtime/tsconfig.test.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/runtime/tsconfig.test.json b/packages/runtime/tsconfig.test.json index e639b11b..faef15af 100644 --- a/packages/runtime/tsconfig.test.json +++ b/packages/runtime/tsconfig.test.json @@ -1,7 +1,8 @@ { "extends": "@zenstackhq/typescript-config/base.json", "compilerOptions": { - "noEmit": true + "noEmit": true, + "noImplicitAny": false }, "include": ["test/**/*.ts"]