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
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) {
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
44 changes: 44 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,50 @@ 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: '[email protected]',
posts: {
create: {
id: '1',
title: 'title',
},
},
},
});

// fk and owned-relation are mutually exclusive
// TODO: @ts-expect-error
client.post.update({
where: { id: '1' },
data: {
authorId: user.id,
title: 'title',
author: { connect: { id: user.id } },
},
});

// 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
16 changes: 16 additions & 0 deletions packages/runtime/test/typing/models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//////////////////////////////////////////////////////////////////////////////////////////////
// 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;
export type User = ModelResult<Schema, "User">;
export type Post = ModelResult<Schema, "Post">;
export type Profile = ModelResult<Schema, "Profile">;
export type Tag = ModelResult<Schema, "Tag">;
export type Region = ModelResult<Schema, "Region">;
export type Meta = ModelResult<Schema, "Meta">;
3 changes: 2 additions & 1 deletion packages/runtime/tsconfig.test.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
"extends": "@zenstackhq/typescript-config/base.json",
"compilerOptions": {
"noEmit": true,
"noImplicitAny": false
"noImplicitAny": false,
"rootDir": "."
},
"include": ["test/**/*.ts"]
}
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
Loading