Skip to content
This repository was archived by the owner on Mar 1, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
- [x] Nested to-one
- [x] Incremental update for numeric fields
- [x] Array update
- [ ] Strict typing for checked/unchecked input
- [x] Upsert
- [ ] Implement with "on conflict"
- [x] Delete
Expand Down
26 changes: 25 additions & 1 deletion packages/cli/src/actions/action-utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { findUp } from '@zenstackhq/common-helpers';
import { loadDocument } from '@zenstackhq/language';
import { PrismaSchemaGenerator } from '@zenstackhq/sdk';
import colors from 'colors';
Expand Down Expand Up @@ -86,3 +85,28 @@ export function getPkgJsonConfig(startPath: string) {

return result;
}

type FindUpResult<Multiple extends boolean> = Multiple extends true ? string[] | undefined : string | undefined;

function findUp<Multiple extends boolean = false>(
names: string[],
cwd: string = process.cwd(),
multiple: Multiple = false as Multiple,
result: string[] = [],
): FindUpResult<Multiple> {
if (!names.some((name) => !!name)) {
return undefined;
}
const target = names.find((name) => fs.existsSync(path.join(cwd, name)));
if (multiple === false && target) {
return path.join(cwd, target) as FindUpResult<Multiple>;
}
if (target) {
result.push(path.join(cwd, target));
}
const up = path.resolve(cwd, '..');
if (up === cwd) {
return (multiple && result.length > 0 ? result : undefined) as FindUpResult<Multiple>;
}
return findUp(names, up, multiple, result);
}
2 changes: 1 addition & 1 deletion packages/cli/src/actions/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export async function run(options: Options) {

// generate TS schema
const tsSchemaFile = path.join(outputPath, 'schema.ts');
await new TsSchemaGenerator().generate(schemaFile, [], tsSchemaFile);
await new TsSchemaGenerator().generate(schemaFile, [], outputPath);

await runPlugins(model, outputPath, tsSchemaFile);

Expand Down
34 changes: 0 additions & 34 deletions packages/common-helpers/src/find-up.ts

This file was deleted.

1 change: 0 additions & 1 deletion packages/common-helpers/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
export * from './find-up';
export * from './is-plain-object';
export * from './lower-case-first';
export * from './param-case';
Expand Down
6 changes: 3 additions & 3 deletions packages/runtime/src/client/crud/operations/find.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ import { BaseOperationHandler, type CrudOperation } from './base';
export class FindOperationHandler<Schema extends SchemaDef> extends BaseOperationHandler<Schema> {
async handle(operation: CrudOperation, args: unknown, validateArgs = true): Promise<unknown> {
// normalize args to strip `undefined` fields
const normalizeArgs = this.normalizeArgs(args);
const normalizedArgs = this.normalizeArgs(args);

// parse args
const parsedArgs = validateArgs
? this.inputValidator.validateFindArgs(this.model, operation === 'findUnique', normalizeArgs)
: normalizeArgs;
? this.inputValidator.validateFindArgs(this.model, operation === 'findUnique', normalizedArgs)
: normalizedArgs;

// run query
const result = await this.read(
Expand Down
19 changes: 12 additions & 7 deletions packages/runtime/src/client/crud/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -924,8 +924,8 @@ export class InputValidator<Schema extends SchemaDef> {
}

private makeUpdateDataSchema(model: string, withoutFields: string[] = [], withoutRelationFields = false) {
Comment thread
ymc9 marked this conversation as resolved.
const regularAndFkFields: any = {};
const regularAndRelationFields: any = {};
const uncheckedVariantFields: Record<string, ZodType> = {};
const checkedVariantFields: Record<string, ZodType> = {};
const modelDef = requireModel(this.schema, model);
const hasRelation = Object.entries(modelDef.fields).some(
([key, value]) => value.relation && !withoutFields.includes(key),
Expand Down Expand Up @@ -957,7 +957,11 @@ export class InputValidator<Schema extends SchemaDef> {
if (fieldDef.optional && !fieldDef.array) {
fieldSchema = fieldSchema.nullable();
}
regularAndRelationFields[field] = fieldSchema;
checkedVariantFields[field] = fieldSchema;
if (fieldDef.array || !fieldDef.relation.references) {
// non-owned relation
uncheckedVariantFields[field] = fieldSchema;
}
} else {
let fieldSchema: ZodType = this.makePrimitiveSchema(fieldDef.type).optional();

Expand Down Expand Up @@ -1000,17 +1004,18 @@ export class InputValidator<Schema extends SchemaDef> {
fieldSchema = fieldSchema.nullable();
}

regularAndFkFields[field] = fieldSchema;
uncheckedVariantFields[field] = fieldSchema;
if (!fieldDef.foreignKeyFor) {
regularAndRelationFields[field] = fieldSchema;
// non-fk field
checkedVariantFields[field] = fieldSchema;
}
}
});

if (!hasRelation) {
return z.object(regularAndFkFields).strict();
return z.object(uncheckedVariantFields).strict();
} else {
return z.union([z.object(regularAndFkFields).strict(), z.object(regularAndRelationFields).strict()]);
return z.union([z.object(uncheckedVariantFields).strict(), z.object(checkedVariantFields).strict()]);
}
}

Expand Down
43 changes: 43 additions & 0 deletions packages/runtime/test/client-api/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,49 @@ describe.each(createClientSpecs(PG_DB_NAME))('Client update tests', ({ createCli
}),
).resolves.toMatchObject({ age: null });
});

it('compiles with Prisma checked/unchecked typing', async () => {
const user = await client.user.create({
data: {
email: 'u1@test.com',
posts: {
create: {
id: '1',
title: 'title',
},
},
},
});

// fk and owned-relation are mutually exclusive
client.post.update({
where: { id: '1' },
data: {
authorId: user.id,
title: 'title',
author: { connect: { id: user.id } },
},
});
Comment thread
ymc9 marked this conversation as resolved.

// fk can work with non-owned relation
const comment = await client.comment.create({
data: {
content: 'comment',
},
});
await expect(
client.post.update({
where: { id: '1' },
data: {
authorId: user.id,
title: 'title',
comments: {
connect: { id: comment.id },
},
},
}),
).toResolveTruthy();
});
});

describe('nested to-many', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/runtime/test/typing/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ async function main() {
const dir = path.dirname(fileURLToPath(import.meta.url));
const zmodelPath = path.join(dir, 'typing-test.zmodel');
const tsPath = path.join(dir, 'schema.ts');
await generator.generate(zmodelPath, [], tsPath);
await generator.generate(zmodelPath, [], dir);

const content = fs.readFileSync(tsPath, 'utf-8');
fs.writeFileSync(tsPath, content.replace(/@zenstackhq\/runtime/g, '../../dist'));
Expand Down
121 changes: 111 additions & 10 deletions packages/sdk/src/ts-schema-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,27 +42,29 @@ import { ModelUtils } from '.';
import { getAttribute, getAuthDecl, hasAttribute, isIdField, isUniqueField } from './model-utils';

export class TsSchemaGenerator {
public async generate(schemaFile: string, pluginModelFiles: string[], outputFile: string) {
public async generate(schemaFile: string, pluginModelFiles: string[], outputDir: string) {
const loaded = await loadDocument(schemaFile, pluginModelFiles);
if (!loaded.success) {
throw new Error(`Error loading schema:${loaded.errors.join('\n')}`);
}

const { model, warnings } = loaded;
const statements: ts.Statement[] = [];
const { model } = loaded;

this.generateSchemaStatements(model, statements);
fs.mkdirSync(outputDir, { recursive: true });

this.generateSchema(model, outputDir);
this.generateModels(model, outputDir);
}
private generateSchema(model: Model, outputDir: string) {
const statements: ts.Statement[] = [];
this.generateSchemaStatements(model, statements);
this.generateBannerComments(statements);

const sourceFile = ts.createSourceFile(outputFile, '', ts.ScriptTarget.ESNext, false, ts.ScriptKind.TS);
const schemaOutputFile = path.join(outputDir, 'schema.ts');
const sourceFile = ts.createSourceFile(schemaOutputFile, '', ts.ScriptTarget.ESNext, false, ts.ScriptKind.TS);
const printer = ts.createPrinter();
const result = printer.printList(ts.ListFormat.MultiLine, ts.factory.createNodeArray(statements), sourceFile);

fs.mkdirSync(path.dirname(outputFile), { recursive: true });
fs.writeFileSync(outputFile, result);

return { model, warnings };
fs.writeFileSync(schemaOutputFile, result);
}

private generateSchemaStatements(model: Model, statements: ts.Statement[]) {
Expand Down Expand Up @@ -954,4 +956,103 @@ export class TsSchemaGenerator {
throw new Error(`Unsupported literal type: ${type}`);
});
}

private generateModels(model: Model, outputDir: string) {
const statements: ts.Statement[] = [];

// generate: import type { ModelResult } from '@zenstackhq/runtime';
statements.push(
ts.factory.createImportDeclaration(
undefined,
ts.factory.createImportClause(
false,
undefined,
ts.factory.createNamedImports([
ts.factory.createImportSpecifier(true, undefined, ts.factory.createIdentifier('ModelResult')),
]),
),
ts.factory.createStringLiteral('@zenstackhq/runtime'),
),
);

// generate: import { schema } from './schema';
statements.push(
ts.factory.createImportDeclaration(
undefined,
ts.factory.createImportClause(
false,
undefined,
ts.factory.createNamedImports([
ts.factory.createImportSpecifier(false, undefined, ts.factory.createIdentifier('schema')),
]),
),
ts.factory.createStringLiteral('./schema'),
),
);

// generate: type Schema = typeof schema;
statements.push(
ts.factory.createTypeAliasDeclaration(
[ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)],
'Schema',
undefined,
ts.factory.createTypeReferenceNode('typeof schema'),
),
);

const dataModels = model.declarations.filter(isDataModel);
for (const dm of dataModels) {
// generate: export type Model = ModelResult<Schema, 'Model'>;
let modelType = ts.factory.createTypeAliasDeclaration(
[ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)],
dm.name,
undefined,
ts.factory.createTypeReferenceNode('ModelResult', [
ts.factory.createTypeReferenceNode('Schema'),
ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral(dm.name)),
]),
);
if (dm.comments.length > 0) {
modelType = this.generateDocs(modelType, dm);
}
statements.push(modelType);
}

// generate enums
const enums = model.declarations.filter(isEnum);
for (const e of enums) {
// generate:
// export const enum Enum = {
// value1 = 'value1',
// value2 = 'value2',
// }
let enumDecl = ts.factory.createEnumDeclaration(
[ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)],
e.name,
e.fields.map((f) => ts.factory.createEnumMember(f.name, ts.factory.createStringLiteral(f.name))),
);
if (e.comments.length > 0) {
enumDecl = this.generateDocs(enumDecl, e);
}
statements.push(enumDecl);
}

this.generateBannerComments(statements);

// write to file
const outputFile = path.join(outputDir, 'models.ts');
const sourceFile = ts.createSourceFile(outputFile, '', ts.ScriptTarget.ESNext, false, ts.ScriptKind.TS);
const printer = ts.createPrinter();
const result = printer.printList(ts.ListFormat.MultiLine, ts.factory.createNodeArray(statements), sourceFile);
fs.writeFileSync(outputFile, result);
}

private generateDocs<T extends ts.TypeAliasDeclaration | ts.EnumDeclaration>(tsDecl: T, decl: DataModel | Enum): T {
return ts.addSyntheticLeadingComment(
tsDecl,
ts.SyntaxKind.MultiLineCommentTrivia,
`*\n * ${decl.comments.map((c) => c.replace(/^\s*\/*\s*/, '')).join('\n * ')}\n `,
true,
);
}
}
3 changes: 1 addition & 2 deletions packages/testtools/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,7 @@ export async function generateTsSchema(
const pluginModelFiles = glob.sync(path.resolve(__dirname, '../../runtime/src/plugins/**/plugin.zmodel'));

const generator = new TsSchemaGenerator();
const tsPath = path.join(workDir, 'schema.ts');
await generator.generate(zmodelPath, pluginModelFiles, tsPath);
await generator.generate(zmodelPath, pluginModelFiles, workDir);

if (extraSourceFiles) {
for (const [fileName, content] of Object.entries(extraSourceFiles)) {
Expand Down
31 changes: 31 additions & 0 deletions samples/blog/zenstack/models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//////////////////////////////////////////////////////////////////////////////////////////////
// DO NOT MODIFY THIS FILE //
// This file is automatically generated by ZenStack CLI and should not be manually updated. //
//////////////////////////////////////////////////////////////////////////////////////////////

/* eslint-disable */

import { type ModelResult } from "@zenstackhq/runtime";
import { schema } from "./schema";
export type Schema = typeof schema;
/**
* User model
*
* Represents a user of the blog.
*/
export type User = ModelResult<Schema, "User">;
/**
* Profile model
*/
export type Profile = ModelResult<Schema, "Profile">;
/**
* Post model
*/
export type Post = ModelResult<Schema, "Post">;
/**
* User roles
*/
export enum Role {
ADMIN = "ADMIN",
USER = "USER"
}
Loading
Loading