Skip to content

Commit df9f318

Browse files
authored
fix: incorrect computed field typing for optional fields (#40)
* fix: incorrect computed field typing for optional fields fixes #33 * fix tests
1 parent e81b8bf commit df9f318

File tree

7 files changed

+188
-13
lines changed

7 files changed

+188
-13
lines changed

packages/runtime/src/client/helpers/schema-db-pusher.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export class SchemaDbPusher<Schema extends SchemaDef> {
4343
for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) {
4444
if (fieldDef.relation) {
4545
table = this.addForeignKeyConstraint(table, model, fieldName, fieldDef);
46-
} else {
46+
} else if (!this.isComputedField(fieldDef)) {
4747
table = this.createModelField(table, fieldName, fieldDef, modelDef);
4848
}
4949
}
@@ -54,6 +54,10 @@ export class SchemaDbPusher<Schema extends SchemaDef> {
5454
return table;
5555
}
5656

57+
private isComputedField(fieldDef: FieldDef) {
58+
return fieldDef.attributes?.some((a) => a.name === '@computed');
59+
}
60+
5761
private addPrimaryKeyConstraint(
5862
table: CreateTableBuilder<string, any>,
5963
model: GetModels<Schema>,
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { createTestClient } from '../utils';
3+
4+
describe('Computed fields tests', () => {
5+
it('works with non-optional fields', async () => {
6+
const db = await createTestClient(
7+
`
8+
model User {
9+
id Int @id @default(autoincrement())
10+
name String
11+
upperName String @computed
12+
}
13+
`,
14+
{
15+
computedFields: {
16+
User: {
17+
upperName: (eb) => eb.fn('upper', ['name']),
18+
},
19+
},
20+
} as any,
21+
);
22+
23+
await expect(
24+
db.user.create({
25+
data: { id: 1, name: 'Alex' },
26+
}),
27+
).resolves.toMatchObject({
28+
upperName: 'ALEX',
29+
});
30+
31+
await expect(
32+
db.user.findUnique({
33+
where: { id: 1 },
34+
select: { upperName: true },
35+
}),
36+
).resolves.toMatchObject({
37+
upperName: 'ALEX',
38+
});
39+
});
40+
41+
it('is typed correctly for non-optional fields', async () => {
42+
await createTestClient(
43+
`
44+
model User {
45+
id Int @id @default(autoincrement())
46+
name String
47+
upperName String @computed
48+
}
49+
`,
50+
{
51+
extraSourceFiles: {
52+
main: `
53+
import { ZenStackClient } from '@zenstackhq/runtime';
54+
import { schema } from './schema';
55+
56+
async function main() {
57+
const client = new ZenStackClient(schema, {
58+
dialectConfig: {} as any,
59+
computedFields: {
60+
User: {
61+
upperName: (eb) => eb.fn('upper', ['name']),
62+
},
63+
}
64+
});
65+
66+
const user = await client.user.create({
67+
data: { id: 1, name: 'Alex' }
68+
});
69+
console.log(user.upperName);
70+
// @ts-expect-error
71+
user.upperName = null;
72+
}
73+
74+
main();
75+
`,
76+
},
77+
},
78+
);
79+
});
80+
81+
it('works with optional fields', async () => {
82+
const db = await createTestClient(
83+
`
84+
model User {
85+
id Int @id @default(autoincrement())
86+
name String
87+
upperName String? @computed
88+
}
89+
`,
90+
{
91+
computedFields: {
92+
User: {
93+
upperName: (eb) => eb.lit(null),
94+
},
95+
},
96+
} as any,
97+
);
98+
99+
await expect(
100+
db.user.create({
101+
data: { id: 1, name: 'Alex' },
102+
}),
103+
).resolves.toMatchObject({
104+
upperName: null,
105+
});
106+
});
107+
108+
it('is typed correctly for optional fields', async () => {
109+
await createTestClient(
110+
`
111+
model User {
112+
id Int @id @default(autoincrement())
113+
name String
114+
upperName String? @computed
115+
}
116+
`,
117+
{
118+
extraSourceFiles: {
119+
main: `
120+
import { ZenStackClient } from '@zenstackhq/runtime';
121+
import { schema } from './schema';
122+
123+
async function main() {
124+
const client = new ZenStackClient(schema, {
125+
dialectConfig: {} as any,
126+
computedFields: {
127+
User: {
128+
upperName: (eb) => eb.lit(null),
129+
},
130+
}
131+
});
132+
133+
const user = await client.user.create({
134+
data: { id: 1, name: 'Alex' }
135+
});
136+
console.log(user.upperName);
137+
user.upperName = null;
138+
}
139+
140+
main();
141+
`,
142+
},
143+
},
144+
);
145+
});
146+
});

packages/runtime/test/utils.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export type CreateTestClientOptions<Schema extends SchemaDef> = Omit<ClientOptio
6161
provider?: 'sqlite' | 'postgresql';
6262
dbName?: string;
6363
usePrismaPush?: boolean;
64+
extraSourceFiles?: Record<string, string>;
6465
};
6566

6667
export async function createTestClient<Schema extends SchemaDef>(
@@ -79,11 +80,14 @@ export async function createTestClient<Schema extends SchemaDef>(
7980
let _schema: Schema;
8081

8182
if (typeof schema === 'string') {
82-
const generated = await generateTsSchema(schema, options?.provider, options?.dbName);
83+
const generated = await generateTsSchema(schema, options?.provider, options?.dbName, options?.extraSourceFiles);
8384
workDir = generated.workDir;
8485
_schema = generated.schema as Schema;
8586
} else {
8687
_schema = schema;
88+
if (options?.extraSourceFiles) {
89+
throw new Error('`extraSourceFiles` is not supported when schema is a SchemaDef object');
90+
}
8791
}
8892

8993
const { plugins, ...rest } = options ?? {};

packages/runtime/tsconfig.test.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
{
22
"extends": "@zenstackhq/typescript-config/base.json",
33
"compilerOptions": {
4-
"noEmit": true
4+
"noEmit": true,
5+
"noImplicitAny": false
56
},
67

78
"include": ["test/**/*.ts"]

packages/sdk/src/ts-schema-generator.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
DataModelAttribute,
99
DataModelField,
1010
DataModelFieldAttribute,
11+
DataModelFieldType,
1112
Enum,
1213
Expression,
1314
InvocationExpr,
@@ -246,7 +247,7 @@ export class TsSchemaGenerator {
246247
undefined,
247248
[],
248249
ts.factory.createTypeReferenceNode('OperandExpression', [
249-
ts.factory.createKeywordTypeNode(this.mapTypeToTSSyntaxKeyword(field.type.type!)),
250+
ts.factory.createTypeReferenceNode(this.mapFieldTypeToTSType(field.type)),
250251
]),
251252
ts.factory.createBlock(
252253
[
@@ -264,15 +265,22 @@ export class TsSchemaGenerator {
264265
);
265266
}
266267

267-
private mapTypeToTSSyntaxKeyword(type: string) {
268-
return match<string, ts.KeywordTypeSyntaxKind>(type)
269-
.with('String', () => ts.SyntaxKind.StringKeyword)
270-
.with('Boolean', () => ts.SyntaxKind.BooleanKeyword)
271-
.with('Int', () => ts.SyntaxKind.NumberKeyword)
272-
.with('Float', () => ts.SyntaxKind.NumberKeyword)
273-
.with('BigInt', () => ts.SyntaxKind.BigIntKeyword)
274-
.with('Decimal', () => ts.SyntaxKind.NumberKeyword)
275-
.otherwise(() => ts.SyntaxKind.UnknownKeyword);
268+
private mapFieldTypeToTSType(type: DataModelFieldType) {
269+
let result = match(type.type)
270+
.with('String', () => 'string')
271+
.with('Boolean', () => 'boolean')
272+
.with('Int', () => 'number')
273+
.with('Float', () => 'number')
274+
.with('BigInt', () => 'bigint')
275+
.with('Decimal', () => 'number')
276+
.otherwise(() => 'unknown');
277+
if (type.array) {
278+
result = `${result}[]`;
279+
}
280+
if (type.optional) {
281+
result = `${result} | null`;
282+
}
283+
return result;
276284
}
277285

278286
private createDataModelFieldObject(field: DataModelField) {

packages/testtools/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"type": "module",
66
"scripts": {
77
"build": "tsup-node",
8+
"watch": "tsup-node --watch",
89
"lint": "eslint src --ext ts",
910
"pack": "pnpm pack"
1011
},

packages/testtools/src/schema.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export async function generateTsSchema(
3232
schemaText: string,
3333
provider: 'sqlite' | 'postgresql' = 'sqlite',
3434
dbName?: string,
35+
extraSourceFiles?: Record<string, string>,
3536
) {
3637
const { name: workDir } = tmp.dirSync({ unsafeCleanup: true });
3738
console.log(`Working directory: ${workDir}`);
@@ -90,10 +91,20 @@ export async function generateTsSchema(
9091
moduleResolution: 'Bundler',
9192
esModuleInterop: true,
9293
skipLibCheck: true,
94+
strict: true,
9395
},
96+
include: ['**/*.ts'],
9497
}),
9598
);
9699

100+
if (extraSourceFiles) {
101+
for (const [fileName, content] of Object.entries(extraSourceFiles)) {
102+
const filePath = path.resolve(workDir, `${fileName}.ts`);
103+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
104+
fs.writeFileSync(filePath, content);
105+
}
106+
}
107+
97108
// compile the generated TS schema
98109
execSync('npx tsc', {
99110
cwd: workDir,

0 commit comments

Comments
 (0)