Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/runtime/src/client/helpers/schema-db-pusher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export class SchemaDbPusher<Schema extends SchemaDef> {
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);
}
}
Expand All @@ -54,6 +54,10 @@ export class SchemaDbPusher<Schema extends SchemaDef> {
return table;
}

private isComputedField(fieldDef: FieldDef) {
return fieldDef.attributes?.some((a) => a.name === '@computed');
}

private addPrimaryKeyConstraint(
table: CreateTableBuilder<string, any>,
model: GetModels<Schema>,
Expand Down
146 changes: 146 additions & 0 deletions packages/runtime/test/client-api/computed-fields.test.ts
Original file line number Diff line number Diff line change
@@ -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();
`,
},
},
);
});
});
6 changes: 5 additions & 1 deletion packages/runtime/test/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export type CreateTestClientOptions<Schema extends SchemaDef> = Omit<ClientOptio
provider?: 'sqlite' | 'postgresql';
dbName?: string;
usePrismaPush?: boolean;
extraSourceFiles?: Record<string, string>;
};

export async function createTestClient<Schema extends SchemaDef>(
Expand All @@ -79,11 +80,14 @@ export async function createTestClient<Schema extends SchemaDef>(
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 ?? {};
Expand Down
3 changes: 2 additions & 1 deletion packages/runtime/tsconfig.test.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
{
"extends": "@zenstackhq/typescript-config/base.json",
"compilerOptions": {
"noEmit": true
"noEmit": true,
"noImplicitAny": false
},

"include": ["test/**/*.ts"]
Expand Down
28 changes: 18 additions & 10 deletions packages/sdk/src/ts-schema-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
DataModelAttribute,
DataModelField,
DataModelFieldAttribute,
DataModelFieldType,
Enum,
Expression,
InvocationExpr,
Expand Down Expand Up @@ -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(
[
Expand All @@ -264,15 +265,22 @@ export class TsSchemaGenerator {
);
}

private mapTypeToTSSyntaxKeyword(type: string) {
return match<string, ts.KeywordTypeSyntaxKind>(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) {
Expand Down
1 change: 1 addition & 0 deletions packages/testtools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"build": "tsup-node",
"watch": "tsup-node --watch",
"lint": "eslint src --ext ts",
"pack": "pnpm pack"
},
Expand Down
11 changes: 11 additions & 0 deletions packages/testtools/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export async function generateTsSchema(
schemaText: string,
provider: 'sqlite' | 'postgresql' = 'sqlite',
dbName?: string,
extraSourceFiles?: Record<string, string>,
) {
const { name: workDir } = tmp.dirSync({ unsafeCleanup: true });
console.log(`Working directory: ${workDir}`);
Expand Down Expand Up @@ -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,
Expand Down