diff --git a/TODO.md b/TODO.md index 7d9aa7b9..7aa1136c 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/packages/cli/src/actions/action-utils.ts b/packages/cli/src/actions/action-utils.ts index 6bc0f46e..b11b671b 100644 --- a/packages/cli/src/actions/action-utils.ts +++ b/packages/cli/src/actions/action-utils.ts @@ -1,4 +1,3 @@ -import { findUp } from '@zenstackhq/common-helpers'; import { loadDocument } from '@zenstackhq/language'; import { PrismaSchemaGenerator } from '@zenstackhq/sdk'; import colors from 'colors'; @@ -86,3 +85,28 @@ export function getPkgJsonConfig(startPath: string) { return result; } + +type FindUpResult = Multiple extends true ? string[] | undefined : string | undefined; + +function findUp( + names: string[], + cwd: string = process.cwd(), + multiple: Multiple = false as Multiple, + result: string[] = [], +): FindUpResult { + 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; + } + 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; + } + return findUp(names, up, multiple, result); +} diff --git a/packages/cli/src/actions/generate.ts b/packages/cli/src/actions/generate.ts index 3355d462..1729bdf3 100644 --- a/packages/cli/src/actions/generate.ts +++ b/packages/cli/src/actions/generate.ts @@ -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); diff --git a/packages/common-helpers/src/find-up.ts b/packages/common-helpers/src/find-up.ts deleted file mode 100644 index afb2fed9..00000000 --- a/packages/common-helpers/src/find-up.ts +++ /dev/null @@ -1,34 +0,0 @@ -import fs from 'fs'; -import path from 'path'; - -/** - * A type named FindUp that takes a type parameter e which extends boolean. - */ -export type FindUpResult = Multiple extends true ? string[] | undefined : string | undefined; - -/** - * Find and return file paths by searching parent directories based on the given names list and current working directory (cwd) path. - * Optionally return a single path or multiple paths. - * If multiple allowed, return all paths found. - * If no paths are found, return undefined. - * - * @param names An array of strings representing names to search for within the directory - * @param cwd A string representing the current working directory - * @param multiple A boolean flag indicating whether to search for multiple levels. Useful for finding node_modules directories... - * @param An array of strings representing the accumulated results used in multiple results - * @returns Path(s) to a specific file or folder within the directory or parent directories - */ -export function findUp( - names: string[], - cwd: string = process.cwd(), - multiple: Multiple = false as Multiple, - result: string[] = [], -): FindUpResult { - 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; - 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; // it'll fail anyway - return findUp(names, up, multiple, result); -} diff --git a/packages/common-helpers/src/index.ts b/packages/common-helpers/src/index.ts index 3b0d2d3c..7f9c421b 100644 --- a/packages/common-helpers/src/index.ts +++ b/packages/common-helpers/src/index.ts @@ -1,4 +1,3 @@ -export * from './find-up'; export * from './is-plain-object'; export * from './lower-case-first'; export * from './param-case'; diff --git a/packages/runtime/src/client/crud/operations/find.ts b/packages/runtime/src/client/crud/operations/find.ts index 7834e58b..77bbb615 100644 --- a/packages/runtime/src/client/crud/operations/find.ts +++ b/packages/runtime/src/client/crud/operations/find.ts @@ -5,12 +5,12 @@ import { BaseOperationHandler, type CrudOperation } from './base'; export class FindOperationHandler extends BaseOperationHandler { async handle(operation: CrudOperation, args: unknown, validateArgs = true): Promise { // 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( diff --git a/packages/runtime/src/client/crud/validator.ts b/packages/runtime/src/client/crud/validator.ts index 7d1aed7d..00dc4f2c 100644 --- a/packages/runtime/src/client/crud/validator.ts +++ b/packages/runtime/src/client/crud/validator.ts @@ -924,8 +924,8 @@ export class InputValidator { } private makeUpdateDataSchema(model: string, withoutFields: string[] = [], withoutRelationFields = false) { - const regularAndFkFields: any = {}; - const regularAndRelationFields: any = {}; + const uncheckedVariantFields: Record = {}; + const checkedVariantFields: Record = {}; const modelDef = requireModel(this.schema, model); const hasRelation = Object.entries(modelDef.fields).some( ([key, value]) => value.relation && !withoutFields.includes(key), @@ -957,7 +957,11 @@ export class InputValidator { 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(); @@ -1000,17 +1004,18 @@ export class InputValidator { 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()]); } } diff --git a/packages/runtime/test/client-api/update.test.ts b/packages/runtime/test/client-api/update.test.ts index b65aa501..8c6ec359 100644 --- a/packages/runtime/test/client-api/update.test.ts +++ b/packages/runtime/test/client-api/update.test.ts @@ -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: 'u1@test.com', + 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', () => { diff --git a/packages/runtime/test/typing/generate.ts b/packages/runtime/test/typing/generate.ts index 4e7c9b10..3ac77565 100644 --- a/packages/runtime/test/typing/generate.ts +++ b/packages/runtime/test/typing/generate.ts @@ -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')); diff --git a/packages/runtime/test/typing/models.ts b/packages/runtime/test/typing/models.ts new file mode 100644 index 00000000..ef232234 --- /dev/null +++ b/packages/runtime/test/typing/models.ts @@ -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; +export type Post = ModelResult; +export type Profile = ModelResult; +export type Tag = ModelResult; +export type Region = ModelResult; +export type Meta = ModelResult; diff --git a/packages/runtime/tsconfig.test.json b/packages/runtime/tsconfig.test.json index 014b54e4..de56945b 100644 --- a/packages/runtime/tsconfig.test.json +++ b/packages/runtime/tsconfig.test.json @@ -2,7 +2,8 @@ "extends": "@zenstackhq/typescript-config/base.json", "compilerOptions": { "noEmit": true, - "noImplicitAny": false + "noImplicitAny": false, + "rootDir": "." }, "include": ["test/**/*.ts"] } diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index 79c1ec4c..61f7cd67 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -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[]) { @@ -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; + 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(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, + ); + } } diff --git a/packages/testtools/src/schema.ts b/packages/testtools/src/schema.ts index b5a6f7d3..fb02835c 100644 --- a/packages/testtools/src/schema.ts +++ b/packages/testtools/src/schema.ts @@ -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)) { diff --git a/samples/blog/zenstack/models.ts b/samples/blog/zenstack/models.ts new file mode 100644 index 00000000..2e6ee65f --- /dev/null +++ b/samples/blog/zenstack/models.ts @@ -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; +/** + * Profile model + */ +export type Profile = ModelResult; +/** + * Post model + */ +export type Post = ModelResult; +/** + * User roles + */ +export enum Role { + ADMIN = "ADMIN", + USER = "USER" +} diff --git a/samples/blog/zenstack/schema.zmodel b/samples/blog/zenstack/schema.zmodel index c9cb02ee..bc4d3ed3 100644 --- a/samples/blog/zenstack/schema.zmodel +++ b/samples/blog/zenstack/schema.zmodel @@ -3,11 +3,15 @@ datasource db { url = 'file:./dev.db' } +/// User roles enum Role { ADMIN USER } +/// User model +/// +/// Represents a user of the blog. model User { id String @id @default(cuid()) createdAt DateTime @default(now()) @@ -20,6 +24,7 @@ model User { profile Profile? } +/// Profile model model Profile { id String @id @default(cuid()) bio String? @@ -28,6 +33,7 @@ model Profile { userId String? @unique } +/// Post model model Post { id String @id @default(cuid()) createdAt DateTime @default(now())