From 6ad973db56fc10ddae1f6ed7f56c0094d54997b4 Mon Sep 17 00:00:00 2001 From: Yiming Cao Date: Thu, 17 Jul 2025 22:02:30 +0800 Subject: [PATCH 01/19] chore: add CLAUDE.md (#100) --- CLAUDE.md | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..d9dbd43b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,73 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +### Build System +- `pnpm build` - Build all packages using Turbo +- `pnpm watch` - Watch mode for all packages +- `pnpm lint` - Run ESLint across all packages +- `pnpm test` - Run tests for all packages + +### Package Management +- Uses `pnpm` with workspaces +- Package manager is pinned to `pnpm@10.12.1` +- Packages are located in `packages/`, `samples/`, and `tests/` + +### Testing +- Runtime package tests: `pnpm test` (includes vitest, typing generation, and typecheck) +- CLI tests: `pnpm test` +- E2E tests are in `tests/e2e/` directory + +### ZenStack CLI Commands +- `npx zenstack init` - Initialize ZenStack in a project +- `npx zenstack generate` - Compile ZModel schema to TypeScript +- `npx zenstack db push` - Sync schema to database (uses Prisma) +- `npx zenstack migrate dev` - Create and apply migrations +- `npx zenstack migrate deploy` - Deploy migrations to production + +## Architecture Overview + +### Core Components +- **@zenstackhq/runtime** - Main database client and ORM engine built on Kysely +- **@zenstackhq/cli** - Command line interface and project management +- **@zenstackhq/language** - ZModel language specification and parser (uses Langium) +- **@zenstackhq/sdk** - Code generation utilities and schema processing + +### Key Architecture Patterns +- **Monorepo Structure**: Uses pnpm workspaces with Turbo for build orchestration +- **Language-First Design**: ZModel DSL compiles to TypeScript, not runtime code generation +- **Kysely-Based ORM**: V3 uses Kysely as query builder instead of Prisma runtime dependency +- **Plugin Architecture**: Runtime plugins for query interception and entity mutation hooks + +### ZModel to TypeScript Flow +1. ZModel schema (`schema.zmodel`) defines database structure and policies +2. `zenstack generate` compiles ZModel to TypeScript schema (`schema.ts`) +3. Schema is used to instantiate `ZenStackClient` with type-safe CRUD operations +4. Client provides both high-level ORM API and low-level Kysely query builder + +### Package Dependencies +- **Runtime**: Depends on Kysely, Zod, and various utility libraries +- **CLI**: Depends on language package, Commander.js, and Prisma (for migrations) +- **Language**: Uses Langium for grammar parsing and AST generation +- **Database Support**: SQLite (better-sqlite3) and PostgreSQL (pg) only + +### Testing Strategy +- Runtime package has comprehensive client API tests and policy tests +- CLI has action-specific tests for commands +- E2E tests validate real-world schema compatibility (cal.com, formbricks, trigger.dev) +- Type coverage tests ensure TypeScript inference works correctly + +## Key Differences from Prisma +- No runtime dependency on @prisma/client +- Pure TypeScript implementation without Rust/WASM +- Built-in access control and validation (coming soon) +- Kysely query builder as escape hatch instead of raw SQL +- Schema-first approach with ZModel DSL extension of Prisma schema language + +## Development Notes +- Always run `zenstack generate` after modifying ZModel schemas +- Database migrations still use Prisma CLI under the hood +- Plugin system allows interception at ORM, Kysely, and entity mutation levels +- Computed fields are evaluated at database level for performance \ No newline at end of file From 8df3f88f55931f4f2c9a8138c2845b3bfdd5cc9c Mon Sep 17 00:00:00 2001 From: Yiming Cao Date: Thu, 17 Jul 2025 22:46:57 +0800 Subject: [PATCH 02/19] feat: cli validate command (#103) --- packages/cli/src/actions/index.ts | 3 +- packages/cli/src/actions/validate.ts | 22 ++++++ packages/cli/src/index.ts | 8 ++- packages/cli/test/validate.test.ts | 101 +++++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/actions/validate.ts create mode 100644 packages/cli/test/validate.test.ts diff --git a/packages/cli/src/actions/index.ts b/packages/cli/src/actions/index.ts index a30763bb..c8ce5ed9 100644 --- a/packages/cli/src/actions/index.ts +++ b/packages/cli/src/actions/index.ts @@ -3,5 +3,6 @@ import { run as generate } from './generate'; import { run as info } from './info'; import { run as init } from './init'; import { run as migrate } from './migrate'; +import { run as validate } from './validate'; -export { db, generate, info, init, migrate }; +export { db, generate, info, init, migrate, validate }; diff --git a/packages/cli/src/actions/validate.ts b/packages/cli/src/actions/validate.ts new file mode 100644 index 00000000..c04f9b11 --- /dev/null +++ b/packages/cli/src/actions/validate.ts @@ -0,0 +1,22 @@ +import colors from 'colors'; +import { getSchemaFile, loadSchemaDocument } from './action-utils'; + +type Options = { + schema?: string; +}; + +/** + * CLI action for validating schema without generation + */ +export async function run(options: Options) { + const schemaFile = getSchemaFile(options.schema); + + try { + await loadSchemaDocument(schemaFile); + console.log(colors.green('βœ“ Schema validation completed successfully.')); + } catch (error) { + console.error(colors.red('βœ— Schema validation failed.')); + // Re-throw to maintain CLI exit code behavior + throw error; + } +} \ No newline at end of file diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d75cff32..5e16d0b2 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -24,6 +24,10 @@ const initAction = async (projectPath: string): Promise => { await actions.init(projectPath); }; +const validateAction = async (options: Parameters[0]): Promise => { + await actions.validate(options); +}; + export function createProgram() { const program = new Command('zenstack'); @@ -35,7 +39,7 @@ export function createProgram() { .description( `${colors.bold.blue( 'ΞΆ', - )} ZenStack is a Prisma power pack for building full-stack apps.\n\nDocumentation: https://zenstack.dev.`, + )} ZenStack is a database access toolkit for TypeScript apps.\n\nDocumentation: https://zenstack.dev.`, ) .showHelpAfterError() .showSuggestionAfterError(); @@ -115,6 +119,8 @@ export function createProgram() { .argument('[path]', 'project path', '.') .action(initAction); + program.command('validate').description('Validate a ZModel schema.').addOption(schemaOption).action(validateAction); + return program; } diff --git a/packages/cli/test/validate.test.ts b/packages/cli/test/validate.test.ts new file mode 100644 index 00000000..5c7ec61e --- /dev/null +++ b/packages/cli/test/validate.test.ts @@ -0,0 +1,101 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { createProject, runCli } from './utils'; + +const validModel = ` +model User { + id String @id @default(cuid()) + email String @unique + name String? + posts Post[] +} + +model Post { + id String @id @default(cuid()) + title String + content String? + author User @relation(fields: [authorId], references: [id]) + authorId String +} +`; + +const invalidModel = ` +model User { + id String @id @default(cuid()) + email String @unique + posts Post[] +} + +model Post { + id String @id @default(cuid()) + title String + author User @relation(fields: [authorId], references: [id]) + // Missing authorId field - should cause validation error +} +`; + +describe('CLI validate command test', () => { + it('should validate a valid schema successfully', () => { + const workDir = createProject(validModel); + + // Should not throw an error + expect(() => runCli('validate', workDir)).not.toThrow(); + }); + + it('should fail validation for invalid schema', () => { + const workDir = createProject(invalidModel); + + // Should throw an error due to validation failure + expect(() => runCli('validate', workDir)).toThrow(); + }); + + it('should respect custom schema location', () => { + const workDir = createProject(validModel); + fs.renameSync(path.join(workDir, 'zenstack/schema.zmodel'), path.join(workDir, 'zenstack/custom.zmodel')); + + // Should not throw an error when using custom schema path + expect(() => runCli('validate --schema ./zenstack/custom.zmodel', workDir)).not.toThrow(); + }); + + it('should fail when schema file does not exist', () => { + const workDir = createProject(validModel); + + // Should throw an error when schema file doesn't exist + expect(() => runCli('validate --schema ./nonexistent.zmodel', workDir)).toThrow(); + }); + + it('should respect package.json config', () => { + const workDir = createProject(validModel); + fs.mkdirSync(path.join(workDir, 'foo')); + fs.renameSync(path.join(workDir, 'zenstack/schema.zmodel'), path.join(workDir, 'foo/schema.zmodel')); + fs.rmdirSync(path.join(workDir, 'zenstack')); + + const pkgJson = JSON.parse(fs.readFileSync(path.join(workDir, 'package.json'), 'utf8')); + pkgJson.zenstack = { + schema: './foo/schema.zmodel', + }; + fs.writeFileSync(path.join(workDir, 'package.json'), JSON.stringify(pkgJson, null, 2)); + + // Should not throw an error when using package.json config + expect(() => runCli('validate', workDir)).not.toThrow(); + }); + + it('should validate schema with syntax errors', () => { + const modelWithSyntaxError = ` +datasource db { + provider = "sqlite" + url = "file:./dev.db" +} + +model User { + id String @id @default(cuid()) + email String @unique + // Missing closing brace - syntax error + `; + const workDir = createProject(modelWithSyntaxError, false); + + // Should throw an error due to syntax error + expect(() => runCli('validate', workDir)).toThrow(); + }); +}); From a27a1320927d0c00175a8f160ef0554bef833bb3 Mon Sep 17 00:00:00 2001 From: Yiming Cao Date: Fri, 18 Jul 2025 09:44:03 +0800 Subject: [PATCH 03/19] Add claude GitHub actions (#104) * Claude PR Assistant workflow * Claude Code Review workflow --- .github/workflows/claude-code-review.yml | 78 ++++++++++++++++++++++++ .github/workflows/claude.yml | 64 +++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 .github/workflows/claude-code-review.yml create mode 100644 .github/workflows/claude.yml diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 00000000..5bf8ce59 --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,78 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize] + # Optional: Only run on specific file changes + # paths: + # - "src/**/*.ts" + # - "src/**/*.tsx" + # - "src/**/*.js" + # - "src/**/*.jsx" + +jobs: + claude-review: + # Optional: Filter by PR author + # if: | + # github.event.pull_request.user.login == 'external-contributor' || + # github.event.pull_request.user.login == 'new-developer' || + # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@beta + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) + # model: "claude-opus-4-20250514" + + # Direct prompt for automated review (no @claude mention needed) + direct_prompt: | + Please review this pull request and provide feedback on: + - Code quality and best practices + - Potential bugs or issues + - Performance considerations + - Security concerns + - Test coverage + + Be constructive and helpful in your feedback. + + # Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR + # use_sticky_comment: true + + # Optional: Customize review based on file types + # direct_prompt: | + # Review this PR focusing on: + # - For TypeScript files: Type safety and proper interface usage + # - For API endpoints: Security, input validation, and error handling + # - For React components: Performance, accessibility, and best practices + # - For tests: Coverage, edge cases, and test quality + + # Optional: Different prompts for different authors + # direct_prompt: | + # ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' && + # 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' || + # 'Please provide a thorough code review focusing on our coding standards and best practices.' }} + + # Optional: Add specific tools for running tests or linting + # allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)" + + # Optional: Skip review for certain conditions + # if: | + # !contains(github.event.pull_request.title, '[skip-review]') && + # !contains(github.event.pull_request.title, '[WIP]') + diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 00000000..64a3e5b1 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,64 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@beta + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) + # model: "claude-opus-4-20250514" + + # Optional: Customize the trigger phrase (default: @claude) + # trigger_phrase: "/claude" + + # Optional: Trigger when specific user is assigned to an issue + # assignee_trigger: "claude-bot" + + # Optional: Allow Claude to run specific commands + # allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)" + + # Optional: Add custom instructions for Claude to customize its behavior for your project + # custom_instructions: | + # Follow our coding standards + # Ensure all new code has tests + # Use TypeScript for new files + + # Optional: Custom environment variables for Claude + # claude_env: | + # NODE_ENV: test + From a61eb0c8de5783b1dc20ba4d3f43886f78765b8a Mon Sep 17 00:00:00 2001 From: Yiming Cao Date: Fri, 18 Jul 2025 14:07:48 +0800 Subject: [PATCH 04/19] fix: add a few missing zmodel validation checks (#101) * chore: add CLAUDE.md * fix: add a few missing zmodel validation checks * update * update * update --- TODO.md | 3 + packages/language/res/stdlib.zmodel | 9 +- .../attribute-application-validator.ts | 22 + .../src/validators/datamodel-validator.ts | 159 +-- pnpm-lock.yaml | 4 + tests/e2e/package.json | 3 + .../zmodel-validation.test.ts | 1012 +++++++++++++++++ 7 files changed, 1145 insertions(+), 67 deletions(-) create mode 100644 tests/e2e/prisma-consistency/zmodel-validation.test.ts diff --git a/TODO.md b/TODO.md index c63f4758..35c34c71 100644 --- a/TODO.md +++ b/TODO.md @@ -7,6 +7,9 @@ - [x] migrate - [x] info - [x] init + - [x] validate + - [ ] format + - [ ] db seed - [ ] ORM - [x] Create - [x] Input validation diff --git a/packages/language/res/stdlib.zmodel b/packages/language/res/stdlib.zmodel index ec144c90..f7841166 100644 --- a/packages/language/res/stdlib.zmodel +++ b/packages/language/res/stdlib.zmodel @@ -224,6 +224,11 @@ attribute @@@prisma() */ attribute @@@completionHint(_ values: String[]) +/** + * Indicates that the attribute can only be applied once to a declaration. + */ +attribute @@@once() + /** * Defines a single-field ID on the model. * @@ -232,7 +237,7 @@ attribute @@@completionHint(_ values: String[]) * @param sort: Allows you to specify in what order the entries of the ID are stored in the database. The available options are Asc and Desc. * @param clustered: Defines whether the ID is clustered or non-clustered. Defaults to true. */ -attribute @id(map: String?, length: Int?, sort: SortOrder?, clustered: Boolean?) @@@prisma @@@supportTypeDef +attribute @id(map: String?, length: Int?, sort: SortOrder?, clustered: Boolean?) @@@prisma @@@supportTypeDef @@@once /** * Defines a default value for a field. @@ -247,7 +252,7 @@ attribute @default(_ value: ContextType, map: String?) @@@prisma @@@supportTypeD * @param sort: Allows you to specify in what order the entries of the constraint are stored in the database. The available options are Asc and Desc. * @param clustered: Boolean Defines whether the constraint is clustered or non-clustered. Defaults to false. */ -attribute @unique(map: String?, length: Int?, sort: SortOrder?, clustered: Boolean?) @@@prisma +attribute @unique(map: String?, length: Int?, sort: SortOrder?, clustered: Boolean?) @@@prisma @@@once /** * Defines a multi-field ID (composite ID) on the model. diff --git a/packages/language/src/validators/attribute-application-validator.ts b/packages/language/src/validators/attribute-application-validator.ts index 285f917f..df6f5334 100644 --- a/packages/language/src/validators/attribute-application-validator.ts +++ b/packages/language/src/validators/attribute-application-validator.ts @@ -75,6 +75,8 @@ export default class AttributeApplicationValidator implements AstValidator(); for (const arg of attr.args) { @@ -131,6 +133,18 @@ export default class AttributeApplicationValidator implements AstValidator a.decl.ref?.name === '@@@once')) { + return; + } + + const duplicates = attr.$container.attributes.filter((a) => a.decl.ref === attrDecl && a !== attr); + if (duplicates.length > 0) { + accept('error', `Attribute "${attrDecl.name}" can only be applied once`, { node: attr }); + } + } + @check('@@allow') @check('@@deny') // @ts-expect-error @@ -197,13 +211,21 @@ export default class AttributeApplicationValidator implements AstValidator { if (!isReferenceExpr(item)) { accept('error', `Expecting a field reference`, { diff --git a/packages/language/src/validators/datamodel-validator.ts b/packages/language/src/validators/datamodel-validator.ts index fd23c290..f163d2a3 100644 --- a/packages/language/src/validators/datamodel-validator.ts +++ b/packages/language/src/validators/datamodel-validator.ts @@ -259,11 +259,22 @@ export default class DataModelValidator implements AstValidator { return; } + if (this.isSelfRelation(field)) { + if (!thisRelation.name) { + accept('error', 'Self-relation field must have a name in @relation attribute', { + node: field, + }); + return; + } + } + const oppositeModel = field.type.reference!.ref! as DataModel; // Use name because the current document might be updated let oppositeFields = getModelFieldsWithBases(oppositeModel, false).filter( - (f) => f.type.reference?.ref?.name === contextModel.name, + (f) => + f !== field && // exclude self in case of self relation + f.type.reference?.ref?.name === contextModel.name, ); oppositeFields = oppositeFields.filter((f) => { const fieldRel = this.parseRelation(f); @@ -322,27 +333,41 @@ export default class DataModelValidator implements AstValidator { let relationOwner: DataModelField; - if (thisRelation?.references?.length && thisRelation.fields?.length) { - if (oppositeRelation?.references || oppositeRelation?.fields) { - accept('error', '"fields" and "references" must be provided only on one side of relation field', { - node: oppositeField, - }); - return; - } else { - relationOwner = oppositeField; - } - } else if (oppositeRelation?.references?.length && oppositeRelation.fields?.length) { - if (thisRelation?.references || thisRelation?.fields) { - accept('error', '"fields" and "references" must be provided only on one side of relation field', { - node: field, - }); - return; - } else { - relationOwner = field; + if (field.type.array && oppositeField.type.array) { + // if both the field is array, then it's an implicit many-to-many relation, + // neither side should have fields/references + for (const r of [thisRelation, oppositeRelation]) { + if (r.fields?.length || r.references?.length) { + accept( + 'error', + 'Implicit many-to-many relation cannot have "fields" or "references" in @relation attribute', + { + node: r === thisRelation ? field : oppositeField, + }, + ); + } } } else { - // if both the field is array, then it's an implicit many-to-many relation - if (!(field.type.array && oppositeField.type.array)) { + if (thisRelation?.references?.length && thisRelation.fields?.length) { + if (oppositeRelation?.references || oppositeRelation?.fields) { + accept('error', '"fields" and "references" must be provided only on one side of relation field', { + node: oppositeField, + }); + return; + } else { + relationOwner = oppositeField; + } + } else if (oppositeRelation?.references?.length && oppositeRelation.fields?.length) { + if (thisRelation?.references || thisRelation?.fields) { + accept('error', '"fields" and "references" must be provided only on one side of relation field', { + node: field, + }); + return; + } else { + relationOwner = field; + } + } else { + // for non-M2M relations, one side must have fields/references [field, oppositeField].forEach((f) => { if (!this.isSelfRelation(f)) { accept( @@ -352,56 +377,60 @@ export default class DataModelValidator implements AstValidator { ); } }); + return; } - return; - } - - if (!relationOwner.type.array && !relationOwner.type.optional) { - accept('error', 'Relation field needs to be list or optional', { - node: relationOwner, - }); - return; - } - if (relationOwner !== field && !relationOwner.type.array) { - // one-to-one relation requires defining side's reference field to be @unique - // e.g.: - // model User { - // id String @id @default(cuid()) - // data UserData? - // } - // model UserData { - // id String @id @default(cuid()) - // user User @relation(fields: [userId], references: [id]) - // userId String - // } - // - // UserData.userId field needs to be @unique - - const containingModel = field.$container as DataModel; - const uniqueFieldList = getUniqueFields(containingModel); - - // field is defined in the abstract base model - if (containingModel !== contextModel) { - uniqueFieldList.push(...getUniqueFields(contextModel)); + if (!relationOwner.type.array && !relationOwner.type.optional) { + accept('error', 'Relation field needs to be list or optional', { + node: relationOwner, + }); + return; } - thisRelation.fields?.forEach((ref) => { - const refField = ref.target.ref as DataModelField; - if (refField) { - if (refField.attributes.find((a) => a.decl.ref?.name === '@id' || a.decl.ref?.name === '@unique')) { - return; - } - if (uniqueFieldList.some((list) => list.includes(refField))) { - return; - } - accept( - 'error', - `Field "${refField.name}" on model "${containingModel.name}" is part of a one-to-one relation and must be marked as @unique or be part of a model-level @@unique attribute`, - { node: refField }, - ); + if (relationOwner !== field && !relationOwner.type.array) { + // one-to-one relation requires defining side's reference field to be @unique + // e.g.: + // model User { + // id String @id @default(cuid()) + // data UserData? + // } + // model UserData { + // id String @id @default(cuid()) + // user User @relation(fields: [userId], references: [id]) + // userId String + // } + // + // UserData.userId field needs to be @unique + + const containingModel = field.$container as DataModel; + const uniqueFieldList = getUniqueFields(containingModel); + + // field is defined in the abstract base model + if (containingModel !== contextModel) { + uniqueFieldList.push(...getUniqueFields(contextModel)); } - }); + + thisRelation.fields?.forEach((ref) => { + const refField = ref.target.ref as DataModelField; + if (refField) { + if ( + refField.attributes.find( + (a) => a.decl.ref?.name === '@id' || a.decl.ref?.name === '@unique', + ) + ) { + return; + } + if (uniqueFieldList.some((list) => list.includes(refField))) { + return; + } + accept( + 'error', + `Field "${refField.name}" on model "${containingModel.name}" is part of a one-to-one relation and must be marked as @unique or be part of a model-level @@unique attribute`, + { node: refField }, + ); + } + }); + } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36af9eb9..6f3319d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -406,6 +406,10 @@ importers: '@zenstackhq/testtools': specifier: workspace:* version: link:../../packages/testtools + devDependencies: + '@zenstackhq/cli': + specifier: workspace:* + version: link:../../packages/cli packages: diff --git a/tests/e2e/package.json b/tests/e2e/package.json index 1ecc87b9..9cd3da90 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -7,5 +7,8 @@ }, "dependencies": { "@zenstackhq/testtools": "workspace:*" + }, + "devDependencies": { + "@zenstackhq/cli": "workspace:*" } } diff --git a/tests/e2e/prisma-consistency/zmodel-validation.test.ts b/tests/e2e/prisma-consistency/zmodel-validation.test.ts new file mode 100644 index 00000000..24730512 --- /dev/null +++ b/tests/e2e/prisma-consistency/zmodel-validation.test.ts @@ -0,0 +1,1012 @@ +import { execSync } from 'child_process'; +import { randomUUID } from 'crypto'; +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +interface ValidationResult { + success: boolean; + errors: string[]; +} + +class ZenStackValidationTester { + private testDir: string; + private schemaPath: string; + private cliPath: string; + + constructor(testDir: string) { + this.testDir = testDir; + this.schemaPath = join(testDir, 'zenstack', 'schema.zmodel'); + + // Get path relative to current test file + const currentDir = dirname(fileURLToPath(import.meta.url)); + this.cliPath = join(currentDir, '../node_modules/@zenstackhq/cli/bin/cli'); + } + + private setupTestDirectory() { + if (existsSync(this.testDir)) { + rmSync(this.testDir, { recursive: true, force: true }); + } + mkdirSync(this.testDir, { recursive: true }); + mkdirSync(join(this.testDir, 'zenstack'), { recursive: true }); + + // Create package.json + writeFileSync( + join(this.testDir, 'package.json'), + JSON.stringify( + { + name: 'zenstack-validation-test', + version: '1.0.0', + private: true, + }, + null, + 2, + ), + ); + } + + public runValidation(schema: string): ValidationResult { + this.setupTestDirectory(); + writeFileSync(this.schemaPath, schema); + + try { + execSync(`node ${this.cliPath} generate`, { + cwd: this.testDir, + stdio: 'pipe', + encoding: 'utf8', + }); + + return { + success: true, + errors: [], + }; + } catch (error: any) { + return { + success: false, + errors: this.extractErrors(error.stderr), + }; + } + } + + private extractErrors(output: string): string[] { + const lines = output.split('\n'); + const errors: string[] = []; + + for (const line of lines) { + if (line.includes('Error:') || line.includes('error:') || line.includes('βœ–')) { + errors.push(line.trim()); + } + } + + return errors; + } + + public cleanup() { + if (existsSync(this.testDir)) { + rmSync(this.testDir, { recursive: true, force: true }); + } + } +} + +describe('ZenStack validation consistency with Prisma', () => { + let tester: ZenStackValidationTester; + let tempDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), 'zenstack-validation-test-' + randomUUID()); + tester = new ZenStackValidationTester(tempDir); + }); + + afterEach(() => { + tester.cleanup(); + }); + + describe('basic_models', () => { + it('should accept valid basic model with id field', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + name String? +} + `); + + expect(result.success).toBe(true); + }); + + it('should reject model without any unique criterion', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + email String + name String? +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should reject model with multiple @id fields', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @id + name String? +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should reject model with both @id field and @@id', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + firstName String + lastName String + + @@id([firstName, lastName]) +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should reject optional ID field', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int? @id @default(autoincrement()) + email String @unique +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should reject array ID field', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int[] @id + email String @unique +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + }); + + describe('compound_ids', () => { + it('should accept valid compound ID with @@id', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + firstName String + lastName String + age Int + + @@id([firstName, lastName]) +} + `); + + expect(result.success).toBe(true); + }); + + it('should reject empty compound ID', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + firstName String + lastName String + + @@id([]) +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + }); + + describe('field_types', () => { + it('should reject optional array field', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + tags String[]? +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should reject array field with SQLite', () => { + const result = tester.runValidation(` +datasource db { + provider = "sqlite" + url = "file:./dev.db" +} + +model User { + id Int @id @default(autoincrement()) + tags String[] +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should accept array field with PostgreSQL', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + tags String[] +} + `); + + expect(result.success).toBe(true); + }); + }); + + describe('relations_one_to_one', () => { + it('should accept valid one-to-one relation', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + profile Profile? +} + +model Profile { + id Int @id @default(autoincrement()) + bio String + user User @relation(fields: [userId], references: [id]) + userId Int @unique +} + `); + + expect(result.success).toBe(true); + }); + + it('should reject one-to-one relation without @unique on FK', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + profile Profile? +} + +model Profile { + id Int @id @default(autoincrement()) + bio String + user User @relation(fields: [userId], references: [id]) + userId Int +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should reject one-to-one relation missing opposite field', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + profile Profile? +} + +model Profile { + id Int @id @default(autoincrement()) + bio String + userId Int @unique +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should reject one-to-one with both sides required', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + profile Profile +} + +model Profile { + id Int @id @default(autoincrement()) + bio String + user User @relation(fields: [userId], references: [id]) + userId Int @unique +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + }); + + describe('relations_one_to_many', () => { + it('should accept valid one-to-many relation', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId], references: [id]) + authorId Int +} + `); + + expect(result.success).toBe(true); + }); + + it('should reject one-to-many without @relation annotation', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User + authorId Int +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should reject one-to-many relation referencing non-existent FK field', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId], references: [id]) +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + }); + + describe('relations_many_to_many', () => { + it('should accept valid implicit many-to-many relation', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + authors User[] +} + `); + + expect(result.success).toBe(true); + }); + + it('should accept valid explicit many-to-many relation', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + posts PostUser[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + authors PostUser[] +} + +model PostUser { + user User @relation(fields: [userId], references: [id]) + post Post @relation(fields: [postId], references: [id]) + userId Int + postId Int + + @@id([userId, postId]) +} + `); + + expect(result.success).toBe(true); + }); + + it('should reject implicit many-to-many with explicit @relation', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + posts Post[] @relation(fields: [id], references: [id]) +} + +model Post { + id Int @id @default(autoincrement()) + title String + authors User[] +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + }); + + describe('relations_self', () => { + it('should accept valid self relation with proper naming', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + manager User? @relation("UserManager", fields: [managerId], references: [id]) + managerId Int? + employees User[] @relation("UserManager") +} + `); + + expect(result.success).toBe(true); + }); + + it('should reject self relation without relation name', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + manager User? @relation(fields: [managerId], references: [id]) + managerId Int? + employees User[] +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should accept self many-to-many relation', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + following User[] @relation("UserFollows") + followers User[] @relation("UserFollows") +} + `); + + expect(result.success).toBe(true); + }); + }); + + describe('relation_validation', () => { + it('should reject mismatched length of fields and references arrays', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId], references: [id, email]) + authorId Int +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should reject empty fields array', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [], references: [id]) + authorId Int +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should reject empty references array', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId], references: []) + authorId Int +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should reject partial relation specification with only fields', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId]) + authorId Int +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should reject partial relation specification with only references', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(references: [id]) + authorId Int +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should reject both sides of relation with fields/references', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + posts Post[] @relation(fields: [id], references: [authorId]) +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId], references: [id]) + authorId Int +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should reject type mismatch between fields and references', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id String @id @default(cuid()) + email String @unique + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId], references: [id]) + authorId Int +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + }); + + describe('unique_constraints', () => { + it('should accept valid compound unique constraint', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + firstName String + lastName String + email String @unique + + @@unique([firstName, lastName]) +} + `); + + expect(result.success).toBe(true); + }); + + it('should reject empty unique constraint', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + firstName String + lastName String + + @@unique([]) +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should accept unique constraint on optional field', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String? @unique + name String +} + `); + + expect(result.success).toBe(true); + }); + }); + + describe('enums', () => { + it('should accept valid enum definition and usage', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +enum Role { + USER + ADMIN + MODERATOR +} + +model User { + id Int @id @default(autoincrement()) + role Role @default(USER) + name String +} + `); + + expect(result.success).toBe(true); + }); + + it('should reject empty enum', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +enum Role { +} + +model User { + id Int @id @default(autoincrement()) + role Role @default(USER) + name String +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + }); + + describe('datasource', () => { + it('should reject multiple datasources', () => { + const result = tester.runValidation(` +datasource db1 { + provider = "postgresql" + url = env("DATABASE_URL") +} + +datasource db2 { + provider = "sqlite" + url = "file:./dev.db" +} + +model User { + id Int @id @default(autoincrement()) + name String +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should reject missing datasource', () => { + const result = tester.runValidation(` +model User { + id Int @id @default(autoincrement()) + name String +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should reject invalid provider', () => { + const result = tester.runValidation(` +datasource db { + provider = "nosql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + name String +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + }); + + describe('attributes', () => { + it('should reject duplicate field attributes', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique @unique + name String +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should reject invalid default value type', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @default(123) + name String +} + `); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should accept valid @map attribute', () => { + const result = tester.runValidation(` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique @map("email_address") + name String + + @@map("users") +} + `); + + expect(result.success).toBe(true); + }); + }); +}); From d94fdf3c83ad6d22842981b86f59c146171041b4 Mon Sep 17 00:00:00 2001 From: Yiming Cao Date: Fri, 18 Jul 2025 21:53:09 +0800 Subject: [PATCH 05/19] chore: update test schema generation (#105) * chore: update test schema generation * break down large test suite into smaller ones --- packages/runtime/package.json | 2 +- packages/runtime/test/scripts/generate.ts | 25 + packages/runtime/test/test-schema.ts | 319 ------ packages/runtime/test/test-schema/helper.ts | 11 + packages/runtime/test/test-schema/index.ts | 2 + packages/runtime/test/test-schema/input.ts | 90 ++ packages/runtime/test/test-schema/models.ts | 15 + packages/runtime/test/test-schema/schema.ts | 211 ++++ .../runtime/test/test-schema/schema.zmodel | 62 + packages/runtime/test/typing/generate.ts | 19 - packages/runtime/test/typing/input.ts | 130 +++ packages/runtime/test/typing/models.ts | 19 +- packages/runtime/tsup.config.ts | 6 +- .../e2e/prisma-consistency/attributes.test.ts | 60 + .../prisma-consistency/basic-models.test.ts | 99 ++ .../prisma-consistency/compound-ids.test.ts | 47 + .../e2e/prisma-consistency/datasource.test.ts | 64 ++ tests/e2e/prisma-consistency/enums.test.ts | 53 + .../prisma-consistency/field-types.test.ts | 55 + .../relation-validation.test.ts | 163 +++ .../relations-many-to-many.test.ts | 85 ++ .../relations-one-to-many.test.ts | 78 ++ .../relations-one-to-one.test.ts | 99 ++ .../prisma-consistency/relations-self.test.ts | 63 + tests/e2e/prisma-consistency/test-utils.ts | 118 ++ .../unique-constraints.test.ts | 63 + .../zmodel-validation.test.ts | 1012 ----------------- 27 files changed, 1608 insertions(+), 1362 deletions(-) create mode 100644 packages/runtime/test/scripts/generate.ts delete mode 100644 packages/runtime/test/test-schema.ts create mode 100644 packages/runtime/test/test-schema/helper.ts create mode 100644 packages/runtime/test/test-schema/index.ts create mode 100644 packages/runtime/test/test-schema/input.ts create mode 100644 packages/runtime/test/test-schema/models.ts create mode 100644 packages/runtime/test/test-schema/schema.ts create mode 100644 packages/runtime/test/test-schema/schema.zmodel delete mode 100644 packages/runtime/test/typing/generate.ts create mode 100644 packages/runtime/test/typing/input.ts create mode 100644 tests/e2e/prisma-consistency/attributes.test.ts create mode 100644 tests/e2e/prisma-consistency/basic-models.test.ts create mode 100644 tests/e2e/prisma-consistency/compound-ids.test.ts create mode 100644 tests/e2e/prisma-consistency/datasource.test.ts create mode 100644 tests/e2e/prisma-consistency/enums.test.ts create mode 100644 tests/e2e/prisma-consistency/field-types.test.ts create mode 100644 tests/e2e/prisma-consistency/relation-validation.test.ts create mode 100644 tests/e2e/prisma-consistency/relations-many-to-many.test.ts create mode 100644 tests/e2e/prisma-consistency/relations-one-to-many.test.ts create mode 100644 tests/e2e/prisma-consistency/relations-one-to-one.test.ts create mode 100644 tests/e2e/prisma-consistency/relations-self.test.ts create mode 100644 tests/e2e/prisma-consistency/test-utils.ts create mode 100644 tests/e2e/prisma-consistency/unique-constraints.test.ts delete mode 100644 tests/e2e/prisma-consistency/zmodel-validation.test.ts diff --git a/packages/runtime/package.json b/packages/runtime/package.json index b46b800f..2ef29296 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -8,7 +8,7 @@ "watch": "tsup-node --watch", "lint": "eslint src --ext ts", "test": "vitest run && pnpm test:generate && pnpm test:typecheck", - "test:generate": "tsx test/typing/generate.ts", + "test:generate": "tsx test/scripts/generate.ts", "test:typecheck": "tsc --project tsconfig.test.json", "pack": "pnpm pack" }, diff --git a/packages/runtime/test/scripts/generate.ts b/packages/runtime/test/scripts/generate.ts new file mode 100644 index 00000000..da402546 --- /dev/null +++ b/packages/runtime/test/scripts/generate.ts @@ -0,0 +1,25 @@ +import { glob } from 'glob'; +import { TsSchemaGenerator } from '@zenstackhq/sdk'; +import path from 'node:path'; +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const dir = path.dirname(fileURLToPath(import.meta.url)); + +async function main() { + await generate(path.resolve(dir, '../typing/typing-test.zmodel')); + await generate(path.resolve(dir, '../test-schema/schema.zmodel')); +} + +async function generate(schemaPath: string) { + const generator = new TsSchemaGenerator(); + const outputDir = path.dirname(schemaPath); + const tsPath = path.join(outputDir, 'schema.ts'); + const pluginModelFiles = glob.sync(path.resolve(dir, '../../dist/**/plugin.zmodel')); + await generator.generate(schemaPath, pluginModelFiles, outputDir); + const content = fs.readFileSync(tsPath, 'utf-8'); + fs.writeFileSync(tsPath, content.replace(/@zenstackhq\/runtime/g, '../../dist')); + console.log('TS schema generated at:', outputDir); +} + +main(); diff --git a/packages/runtime/test/test-schema.ts b/packages/runtime/test/test-schema.ts deleted file mode 100644 index 8102b20b..00000000 --- a/packages/runtime/test/test-schema.ts +++ /dev/null @@ -1,319 +0,0 @@ -import { ExpressionUtils, type DataSourceProviderType, type SchemaDef } from '../src/schema'; - -export const schema = { - provider: { - type: 'sqlite', - }, - models: { - User: { - fields: { - id: { - type: 'String', - id: true, - default: ExpressionUtils.call('cuid'), - attributes: [ - { name: '@id' }, - { - name: '@default', - args: [ - { - value: { - kind: 'call', - function: 'cuid', - }, - }, - ], - }, - ], - }, - email: { - type: 'String', - unique: true, - attributes: [ - { - name: '@unique', - }, - ], - }, - name: { - type: 'String', - optional: true, - }, - createdAt: { - type: 'DateTime', - default: ExpressionUtils.call('now'), - attributes: [ - { - name: '@default', - args: [ - { - value: { - kind: 'call', - function: 'now', - }, - }, - ], - }, - ], - }, - updatedAt: { - type: 'DateTime', - updatedAt: true, - attributes: [ - { - name: '@updatedAt', - }, - ], - }, - role: { - type: 'Role', - default: 'USER', - }, - posts: { - type: 'Post', - array: true, - relation: { - opposite: 'author', - }, - }, - profile: { - type: 'Profile', - relation: { - opposite: 'user', - }, - optional: true, - }, - }, - idFields: ['id'], - uniqueFields: { - id: { type: 'String' }, - email: { type: 'String' }, - }, - attributes: [ - // @@allow('all', auth() == this) - { - name: '@@allow', - args: [ - { - name: 'operation', - value: ExpressionUtils.literal('all'), - }, - { - name: 'condition', - value: ExpressionUtils.binary( - ExpressionUtils.member(ExpressionUtils.call('auth'), ['id']), - '==', - ExpressionUtils.field('id'), - ), - }, - ], - }, - // @@allow('read', auth() != null) - { - name: '@@allow', - args: [ - { - name: 'operation', - value: ExpressionUtils.literal('read'), - }, - { - name: 'condition', - value: ExpressionUtils.binary(ExpressionUtils.call('auth'), '!=', ExpressionUtils._null()), - }, - ], - }, - ], - }, - Post: { - fields: { - id: { - type: 'String', - id: true, - default: ExpressionUtils.call('cuid'), - }, - createdAt: { - type: 'DateTime', - default: ExpressionUtils.call('now'), - }, - updatedAt: { - type: 'DateTime', - updatedAt: true, - }, - title: { - type: 'String', - }, - content: { - type: 'String', - optional: true, - }, - published: { - type: 'Boolean', - default: false, - }, - author: { - type: 'User', - relation: { - fields: ['authorId'], - references: ['id'], - opposite: 'posts', - onUpdate: 'Cascade', - onDelete: 'Cascade', - }, - }, - authorId: { - type: 'String', - foreignKeyFor: ['author'], - }, - comments: { - type: 'Comment', - array: true, - relation: { - opposite: 'post', - }, - }, - }, - idFields: ['id'], - uniqueFields: { - id: { type: 'String' }, - }, - attributes: [ - // @@deny('all', auth() == null) - { - name: '@@deny', - args: [ - { - name: 'operation', - value: ExpressionUtils.literal('all'), - }, - { - name: 'condition', - value: ExpressionUtils.binary(ExpressionUtils.call('auth'), '==', ExpressionUtils._null()), - }, - ], - }, - // @@allow('all', auth() == author) - { - name: '@@allow', - args: [ - { - name: 'operation', - value: ExpressionUtils.literal('all'), - }, - { - name: 'condition', - value: ExpressionUtils.binary( - ExpressionUtils.member(ExpressionUtils.call('auth'), ['id']), - '==', - ExpressionUtils.field('authorId'), - ), - }, - ], - }, - // @@allow('read', published) - { - name: '@@allow', - args: [ - { - name: 'operation', - value: ExpressionUtils.literal('read'), - }, - { - name: 'condition', - value: ExpressionUtils.field('published'), - }, - ], - }, - ], - }, - Comment: { - fields: { - id: { - type: 'String', - id: true, - default: ExpressionUtils.call('cuid'), - }, - createdAt: { - type: 'DateTime', - default: ExpressionUtils.call('now'), - }, - updatedAt: { - type: 'DateTime', - updatedAt: true, - }, - content: { - type: 'String', - }, - post: { - type: 'Post', - optional: true, - relation: { - fields: ['postId'], - references: ['id'], - opposite: 'comments', - onUpdate: 'Cascade', - onDelete: 'Cascade', - }, - }, - postId: { - type: 'String', - foreignKeyFor: ['post'], - optional: true, - }, - }, - idFields: ['id'], - uniqueFields: { - id: { type: 'String' }, - }, - }, - Profile: { - fields: { - id: { - type: 'String', - id: true, - default: ExpressionUtils.call('cuid'), - }, - bio: { type: 'String' }, - age: { type: 'Int', optional: true }, - user: { - type: 'User', - optional: true, - relation: { - fields: ['userId'], - references: ['id'], - opposite: 'profile', - onUpdate: 'Cascade', - onDelete: 'Cascade', - }, - }, - userId: { - type: 'String', - optional: true, - unique: true, - foreignKeyFor: ['user'], - }, - }, - idFields: ['id'], - uniqueFields: { - id: { type: 'String' }, - userId: { type: 'String' }, - }, - }, - }, - authType: 'User', - enums: { - Role: { - ADMIN: 'ADMIN', - USER: 'USER', - }, - }, - plugins: {}, -} as const satisfies SchemaDef; - -export function getSchema(type: ProviderType) { - return { - ...schema, - provider: { - type, - }, - }; -} diff --git a/packages/runtime/test/test-schema/helper.ts b/packages/runtime/test/test-schema/helper.ts new file mode 100644 index 00000000..6eac1589 --- /dev/null +++ b/packages/runtime/test/test-schema/helper.ts @@ -0,0 +1,11 @@ +import type { DataSourceProviderType } from '@zenstackhq/sdk/schema'; +import { schema } from './schema'; + +export function getSchema(type: ProviderType) { + return { + ...schema, + provider: { + type, + }, + }; +} diff --git a/packages/runtime/test/test-schema/index.ts b/packages/runtime/test/test-schema/index.ts new file mode 100644 index 00000000..895d9ca2 --- /dev/null +++ b/packages/runtime/test/test-schema/index.ts @@ -0,0 +1,2 @@ +export { getSchema } from './helper'; +export { schema } from './schema'; diff --git a/packages/runtime/test/test-schema/input.ts b/packages/runtime/test/test-schema/input.ts new file mode 100644 index 00000000..e283e0c9 --- /dev/null +++ b/packages/runtime/test/test-schema/input.ts @@ -0,0 +1,90 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + +/* eslint-disable */ + +import { type SchemaType as $Schema } from "./schema"; +import type { FindManyArgs as $FindManyArgs, FindUniqueArgs as $FindUniqueArgs, FindFirstArgs as $FindFirstArgs, CreateArgs as $CreateArgs, CreateManyArgs as $CreateManyArgs, CreateManyAndReturnArgs as $CreateManyAndReturnArgs, UpdateArgs as $UpdateArgs, UpdateManyArgs as $UpdateManyArgs, UpdateManyAndReturnArgs as $UpdateManyAndReturnArgs, UpsertArgs as $UpsertArgs, DeleteArgs as $DeleteArgs, DeleteManyArgs as $DeleteManyArgs, CountArgs as $CountArgs, AggregateArgs as $AggregateArgs, GroupByArgs as $GroupByArgs, WhereInput as $WhereInput, SelectInput as $SelectInput, IncludeInput as $IncludeInput, OmitInput as $OmitInput } from "@zenstackhq/runtime"; +import type { SimplifiedModelResult as $SimplifiedModelResult, SelectIncludeOmit as $SelectIncludeOmit } from "@zenstackhq/runtime"; +export type UserFindManyArgs = $FindManyArgs<$Schema, "User">; +export type UserFindUniqueArgs = $FindUniqueArgs<$Schema, "User">; +export type UserFindFirstArgs = $FindFirstArgs<$Schema, "User">; +export type UserCreateArgs = $CreateArgs<$Schema, "User">; +export type UserCreateManyArgs = $CreateManyArgs<$Schema, "User">; +export type UserCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "User">; +export type UserUpdateArgs = $UpdateArgs<$Schema, "User">; +export type UserUpdateManyArgs = $UpdateManyArgs<$Schema, "User">; +export type UserUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs<$Schema, "User">; +export type UserUpsertArgs = $UpsertArgs<$Schema, "User">; +export type UserDeleteArgs = $DeleteArgs<$Schema, "User">; +export type UserDeleteManyArgs = $DeleteManyArgs<$Schema, "User">; +export type UserCountArgs = $CountArgs<$Schema, "User">; +export type UserAggregateArgs = $AggregateArgs<$Schema, "User">; +export type UserGroupByArgs = $GroupByArgs<$Schema, "User">; +export type UserWhereInput = $WhereInput<$Schema, "User">; +export type UserSelect = $SelectInput<$Schema, "User">; +export type UserInclude = $IncludeInput<$Schema, "User">; +export type UserOmit = $OmitInput<$Schema, "User">; +export type UserGetPayload> = $SimplifiedModelResult<$Schema, "User", Args>; +export type PostFindManyArgs = $FindManyArgs<$Schema, "Post">; +export type PostFindUniqueArgs = $FindUniqueArgs<$Schema, "Post">; +export type PostFindFirstArgs = $FindFirstArgs<$Schema, "Post">; +export type PostCreateArgs = $CreateArgs<$Schema, "Post">; +export type PostCreateManyArgs = $CreateManyArgs<$Schema, "Post">; +export type PostCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "Post">; +export type PostUpdateArgs = $UpdateArgs<$Schema, "Post">; +export type PostUpdateManyArgs = $UpdateManyArgs<$Schema, "Post">; +export type PostUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs<$Schema, "Post">; +export type PostUpsertArgs = $UpsertArgs<$Schema, "Post">; +export type PostDeleteArgs = $DeleteArgs<$Schema, "Post">; +export type PostDeleteManyArgs = $DeleteManyArgs<$Schema, "Post">; +export type PostCountArgs = $CountArgs<$Schema, "Post">; +export type PostAggregateArgs = $AggregateArgs<$Schema, "Post">; +export type PostGroupByArgs = $GroupByArgs<$Schema, "Post">; +export type PostWhereInput = $WhereInput<$Schema, "Post">; +export type PostSelect = $SelectInput<$Schema, "Post">; +export type PostInclude = $IncludeInput<$Schema, "Post">; +export type PostOmit = $OmitInput<$Schema, "Post">; +export type PostGetPayload> = $SimplifiedModelResult<$Schema, "Post", Args>; +export type CommentFindManyArgs = $FindManyArgs<$Schema, "Comment">; +export type CommentFindUniqueArgs = $FindUniqueArgs<$Schema, "Comment">; +export type CommentFindFirstArgs = $FindFirstArgs<$Schema, "Comment">; +export type CommentCreateArgs = $CreateArgs<$Schema, "Comment">; +export type CommentCreateManyArgs = $CreateManyArgs<$Schema, "Comment">; +export type CommentCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "Comment">; +export type CommentUpdateArgs = $UpdateArgs<$Schema, "Comment">; +export type CommentUpdateManyArgs = $UpdateManyArgs<$Schema, "Comment">; +export type CommentUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs<$Schema, "Comment">; +export type CommentUpsertArgs = $UpsertArgs<$Schema, "Comment">; +export type CommentDeleteArgs = $DeleteArgs<$Schema, "Comment">; +export type CommentDeleteManyArgs = $DeleteManyArgs<$Schema, "Comment">; +export type CommentCountArgs = $CountArgs<$Schema, "Comment">; +export type CommentAggregateArgs = $AggregateArgs<$Schema, "Comment">; +export type CommentGroupByArgs = $GroupByArgs<$Schema, "Comment">; +export type CommentWhereInput = $WhereInput<$Schema, "Comment">; +export type CommentSelect = $SelectInput<$Schema, "Comment">; +export type CommentInclude = $IncludeInput<$Schema, "Comment">; +export type CommentOmit = $OmitInput<$Schema, "Comment">; +export type CommentGetPayload> = $SimplifiedModelResult<$Schema, "Comment", Args>; +export type ProfileFindManyArgs = $FindManyArgs<$Schema, "Profile">; +export type ProfileFindUniqueArgs = $FindUniqueArgs<$Schema, "Profile">; +export type ProfileFindFirstArgs = $FindFirstArgs<$Schema, "Profile">; +export type ProfileCreateArgs = $CreateArgs<$Schema, "Profile">; +export type ProfileCreateManyArgs = $CreateManyArgs<$Schema, "Profile">; +export type ProfileCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "Profile">; +export type ProfileUpdateArgs = $UpdateArgs<$Schema, "Profile">; +export type ProfileUpdateManyArgs = $UpdateManyArgs<$Schema, "Profile">; +export type ProfileUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs<$Schema, "Profile">; +export type ProfileUpsertArgs = $UpsertArgs<$Schema, "Profile">; +export type ProfileDeleteArgs = $DeleteArgs<$Schema, "Profile">; +export type ProfileDeleteManyArgs = $DeleteManyArgs<$Schema, "Profile">; +export type ProfileCountArgs = $CountArgs<$Schema, "Profile">; +export type ProfileAggregateArgs = $AggregateArgs<$Schema, "Profile">; +export type ProfileGroupByArgs = $GroupByArgs<$Schema, "Profile">; +export type ProfileWhereInput = $WhereInput<$Schema, "Profile">; +export type ProfileSelect = $SelectInput<$Schema, "Profile">; +export type ProfileInclude = $IncludeInput<$Schema, "Profile">; +export type ProfileOmit = $OmitInput<$Schema, "Profile">; +export type ProfileGetPayload> = $SimplifiedModelResult<$Schema, "Profile", Args>; diff --git a/packages/runtime/test/test-schema/models.ts b/packages/runtime/test/test-schema/models.ts new file mode 100644 index 00000000..f9dbfc98 --- /dev/null +++ b/packages/runtime/test/test-schema/models.ts @@ -0,0 +1,15 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + +/* eslint-disable */ + +import { schema as $schema, type SchemaType as $Schema } from "./schema"; +import { type ModelResult as $ModelResult } from "@zenstackhq/runtime"; +export type User = $ModelResult<$Schema, "User">; +export type Post = $ModelResult<$Schema, "Post">; +export type Comment = $ModelResult<$Schema, "Comment">; +export type Profile = $ModelResult<$Schema, "Profile">; +export const Role = $schema.enums.Role; +export type Role = (typeof Role)[keyof typeof Role]; diff --git a/packages/runtime/test/test-schema/schema.ts b/packages/runtime/test/test-schema/schema.ts new file mode 100644 index 00000000..610cb4b0 --- /dev/null +++ b/packages/runtime/test/test-schema/schema.ts @@ -0,0 +1,211 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + +/* eslint-disable */ + +import { type SchemaDef, ExpressionUtils } from "../../dist/schema"; +export const schema = { + provider: { + type: "sqlite" + }, + models: { + User: { + fields: { + id: { + type: "String", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }], + default: ExpressionUtils.call("cuid") + }, + email: { + type: "String", + unique: true, + attributes: [{ name: "@unique" }] + }, + name: { + type: "String", + optional: true + }, + createdAt: { + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], + default: ExpressionUtils.call("now") + }, + updatedAt: { + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }] + }, + role: { + type: "Role", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal("USER") }] }], + default: "USER" + }, + posts: { + type: "Post", + array: true, + relation: { opposite: "author" } + }, + profile: { + type: "Profile", + optional: true, + relation: { opposite: "user" } + } + }, + attributes: [ + { name: "@@allow", args: [{ name: "operation", value: ExpressionUtils.literal("all") }, { name: "condition", value: ExpressionUtils.binary(ExpressionUtils.member(ExpressionUtils.call("auth"), ["id"]), "==", ExpressionUtils.field("id")) }] }, + { name: "@@allow", args: [{ name: "operation", value: ExpressionUtils.literal("read") }, { name: "condition", value: ExpressionUtils.binary(ExpressionUtils.call("auth"), "!=", ExpressionUtils._null()) }] } + ], + idFields: ["id"], + uniqueFields: { + id: { type: "String" }, + email: { type: "String" } + } + }, + Post: { + fields: { + id: { + type: "String", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }], + default: ExpressionUtils.call("cuid") + }, + createdAt: { + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], + default: ExpressionUtils.call("now") + }, + updatedAt: { + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }] + }, + title: { + type: "String" + }, + content: { + type: "String", + optional: true + }, + published: { + type: "Boolean", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal(false) }] }], + default: false + }, + author: { + type: "User", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array([ExpressionUtils.field("authorId")]) }, { name: "references", value: ExpressionUtils.array([ExpressionUtils.field("id")]) }, { name: "onUpdate", value: ExpressionUtils.literal("Cascade") }, { name: "onDelete", value: ExpressionUtils.literal("Cascade") }] }], + relation: { opposite: "posts", fields: ["authorId"], references: ["id"], onUpdate: "Cascade", onDelete: "Cascade" } + }, + authorId: { + type: "String", + foreignKeyFor: [ + "author" + ] + }, + comments: { + type: "Comment", + array: true, + relation: { opposite: "post" } + } + }, + attributes: [ + { name: "@@deny", args: [{ name: "operation", value: ExpressionUtils.literal("all") }, { name: "condition", value: ExpressionUtils.binary(ExpressionUtils.call("auth"), "==", ExpressionUtils._null()) }] }, + { name: "@@allow", args: [{ name: "operation", value: ExpressionUtils.literal("all") }, { name: "condition", value: ExpressionUtils.binary(ExpressionUtils.member(ExpressionUtils.call("auth"), ["id"]), "==", ExpressionUtils.field("authorId")) }] }, + { name: "@@allow", args: [{ name: "operation", value: ExpressionUtils.literal("read") }, { name: "condition", value: ExpressionUtils.field("published") }] } + ], + idFields: ["id"], + uniqueFields: { + id: { type: "String" } + } + }, + Comment: { + fields: { + id: { + type: "String", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }], + default: ExpressionUtils.call("cuid") + }, + createdAt: { + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], + default: ExpressionUtils.call("now") + }, + updatedAt: { + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }] + }, + content: { + type: "String" + }, + post: { + type: "Post", + optional: true, + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array([ExpressionUtils.field("postId")]) }, { name: "references", value: ExpressionUtils.array([ExpressionUtils.field("id")]) }, { name: "onUpdate", value: ExpressionUtils.literal("Cascade") }, { name: "onDelete", value: ExpressionUtils.literal("Cascade") }] }], + relation: { opposite: "comments", fields: ["postId"], references: ["id"], onUpdate: "Cascade", onDelete: "Cascade" } + }, + postId: { + type: "String", + optional: true, + foreignKeyFor: [ + "post" + ] + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "String" } + } + }, + Profile: { + fields: { + id: { + type: "String", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }], + default: ExpressionUtils.call("cuid") + }, + bio: { + type: "String" + }, + age: { + type: "Int", + optional: true + }, + user: { + type: "User", + optional: true, + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array([ExpressionUtils.field("userId")]) }, { name: "references", value: ExpressionUtils.array([ExpressionUtils.field("id")]) }, { name: "onUpdate", value: ExpressionUtils.literal("Cascade") }, { name: "onDelete", value: ExpressionUtils.literal("Cascade") }] }], + relation: { opposite: "profile", fields: ["userId"], references: ["id"], onUpdate: "Cascade", onDelete: "Cascade" } + }, + userId: { + type: "String", + unique: true, + optional: true, + attributes: [{ name: "@unique" }], + foreignKeyFor: [ + "user" + ] + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "String" }, + userId: { type: "String" } + } + } + }, + enums: { + Role: { + ADMIN: "ADMIN", + USER: "USER" + } + }, + authType: "User", + plugins: {} +} as const satisfies SchemaDef; +export type SchemaType = typeof schema; diff --git a/packages/runtime/test/test-schema/schema.zmodel b/packages/runtime/test/test-schema/schema.zmodel new file mode 100644 index 00000000..fb041b5a --- /dev/null +++ b/packages/runtime/test/test-schema/schema.zmodel @@ -0,0 +1,62 @@ +datasource db { + provider = "sqlite" + url = "file:./dev.db" +} + +plugin policy { + provider = "../../dist/plugins/policy" +} + +enum Role { + ADMIN + USER +} + +model User { + id String @id @default(cuid()) + email String @unique + name String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + role Role @default(USER) + posts Post[] + profile Profile? + + // Access policies + @@allow('all', auth().id == id) + @@allow('read', auth() != null) +} + +model Post { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + title String + content String? + published Boolean @default(false) + author User @relation(fields: [authorId], references: [id], onUpdate: Cascade, onDelete: Cascade) + authorId String + comments Comment[] + + // Access policies + @@deny('all', auth() == null) + @@allow('all', auth().id == authorId) + @@allow('read', published) +} + +model Comment { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + content String + post Post? @relation(fields: [postId], references: [id], onUpdate: Cascade, onDelete: Cascade) + postId String? +} + +model Profile { + id String @id @default(cuid()) + bio String + age Int? + user User? @relation(fields: [userId], references: [id], onUpdate: Cascade, onDelete: Cascade) + userId String? @unique +} diff --git a/packages/runtime/test/typing/generate.ts b/packages/runtime/test/typing/generate.ts deleted file mode 100644 index 3ac77565..00000000 --- a/packages/runtime/test/typing/generate.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { TsSchemaGenerator } from '@zenstackhq/sdk'; -import path from 'node:path'; -import fs from 'node:fs'; -import { fileURLToPath } from 'node:url'; - -async function main() { - const generator = new TsSchemaGenerator(); - 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, [], dir); - - const content = fs.readFileSync(tsPath, 'utf-8'); - fs.writeFileSync(tsPath, content.replace(/@zenstackhq\/runtime/g, '../../dist')); - - console.log('TS schema generated at:', tsPath); -} - -main(); diff --git a/packages/runtime/test/typing/input.ts b/packages/runtime/test/typing/input.ts new file mode 100644 index 00000000..c7fd0f5f --- /dev/null +++ b/packages/runtime/test/typing/input.ts @@ -0,0 +1,130 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + +/* eslint-disable */ + +import { type SchemaType as $Schema } from "./schema"; +import type { FindManyArgs as $FindManyArgs, FindUniqueArgs as $FindUniqueArgs, FindFirstArgs as $FindFirstArgs, CreateArgs as $CreateArgs, CreateManyArgs as $CreateManyArgs, CreateManyAndReturnArgs as $CreateManyAndReturnArgs, UpdateArgs as $UpdateArgs, UpdateManyArgs as $UpdateManyArgs, UpdateManyAndReturnArgs as $UpdateManyAndReturnArgs, UpsertArgs as $UpsertArgs, DeleteArgs as $DeleteArgs, DeleteManyArgs as $DeleteManyArgs, CountArgs as $CountArgs, AggregateArgs as $AggregateArgs, GroupByArgs as $GroupByArgs, WhereInput as $WhereInput, SelectInput as $SelectInput, IncludeInput as $IncludeInput, OmitInput as $OmitInput } from "@zenstackhq/runtime"; +import type { SimplifiedModelResult as $SimplifiedModelResult, SelectIncludeOmit as $SelectIncludeOmit } from "@zenstackhq/runtime"; +export type UserFindManyArgs = $FindManyArgs<$Schema, "User">; +export type UserFindUniqueArgs = $FindUniqueArgs<$Schema, "User">; +export type UserFindFirstArgs = $FindFirstArgs<$Schema, "User">; +export type UserCreateArgs = $CreateArgs<$Schema, "User">; +export type UserCreateManyArgs = $CreateManyArgs<$Schema, "User">; +export type UserCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "User">; +export type UserUpdateArgs = $UpdateArgs<$Schema, "User">; +export type UserUpdateManyArgs = $UpdateManyArgs<$Schema, "User">; +export type UserUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs<$Schema, "User">; +export type UserUpsertArgs = $UpsertArgs<$Schema, "User">; +export type UserDeleteArgs = $DeleteArgs<$Schema, "User">; +export type UserDeleteManyArgs = $DeleteManyArgs<$Schema, "User">; +export type UserCountArgs = $CountArgs<$Schema, "User">; +export type UserAggregateArgs = $AggregateArgs<$Schema, "User">; +export type UserGroupByArgs = $GroupByArgs<$Schema, "User">; +export type UserWhereInput = $WhereInput<$Schema, "User">; +export type UserSelect = $SelectInput<$Schema, "User">; +export type UserInclude = $IncludeInput<$Schema, "User">; +export type UserOmit = $OmitInput<$Schema, "User">; +export type UserGetPayload> = $SimplifiedModelResult<$Schema, "User", Args>; +export type PostFindManyArgs = $FindManyArgs<$Schema, "Post">; +export type PostFindUniqueArgs = $FindUniqueArgs<$Schema, "Post">; +export type PostFindFirstArgs = $FindFirstArgs<$Schema, "Post">; +export type PostCreateArgs = $CreateArgs<$Schema, "Post">; +export type PostCreateManyArgs = $CreateManyArgs<$Schema, "Post">; +export type PostCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "Post">; +export type PostUpdateArgs = $UpdateArgs<$Schema, "Post">; +export type PostUpdateManyArgs = $UpdateManyArgs<$Schema, "Post">; +export type PostUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs<$Schema, "Post">; +export type PostUpsertArgs = $UpsertArgs<$Schema, "Post">; +export type PostDeleteArgs = $DeleteArgs<$Schema, "Post">; +export type PostDeleteManyArgs = $DeleteManyArgs<$Schema, "Post">; +export type PostCountArgs = $CountArgs<$Schema, "Post">; +export type PostAggregateArgs = $AggregateArgs<$Schema, "Post">; +export type PostGroupByArgs = $GroupByArgs<$Schema, "Post">; +export type PostWhereInput = $WhereInput<$Schema, "Post">; +export type PostSelect = $SelectInput<$Schema, "Post">; +export type PostInclude = $IncludeInput<$Schema, "Post">; +export type PostOmit = $OmitInput<$Schema, "Post">; +export type PostGetPayload> = $SimplifiedModelResult<$Schema, "Post", Args>; +export type ProfileFindManyArgs = $FindManyArgs<$Schema, "Profile">; +export type ProfileFindUniqueArgs = $FindUniqueArgs<$Schema, "Profile">; +export type ProfileFindFirstArgs = $FindFirstArgs<$Schema, "Profile">; +export type ProfileCreateArgs = $CreateArgs<$Schema, "Profile">; +export type ProfileCreateManyArgs = $CreateManyArgs<$Schema, "Profile">; +export type ProfileCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "Profile">; +export type ProfileUpdateArgs = $UpdateArgs<$Schema, "Profile">; +export type ProfileUpdateManyArgs = $UpdateManyArgs<$Schema, "Profile">; +export type ProfileUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs<$Schema, "Profile">; +export type ProfileUpsertArgs = $UpsertArgs<$Schema, "Profile">; +export type ProfileDeleteArgs = $DeleteArgs<$Schema, "Profile">; +export type ProfileDeleteManyArgs = $DeleteManyArgs<$Schema, "Profile">; +export type ProfileCountArgs = $CountArgs<$Schema, "Profile">; +export type ProfileAggregateArgs = $AggregateArgs<$Schema, "Profile">; +export type ProfileGroupByArgs = $GroupByArgs<$Schema, "Profile">; +export type ProfileWhereInput = $WhereInput<$Schema, "Profile">; +export type ProfileSelect = $SelectInput<$Schema, "Profile">; +export type ProfileInclude = $IncludeInput<$Schema, "Profile">; +export type ProfileOmit = $OmitInput<$Schema, "Profile">; +export type ProfileGetPayload> = $SimplifiedModelResult<$Schema, "Profile", Args>; +export type TagFindManyArgs = $FindManyArgs<$Schema, "Tag">; +export type TagFindUniqueArgs = $FindUniqueArgs<$Schema, "Tag">; +export type TagFindFirstArgs = $FindFirstArgs<$Schema, "Tag">; +export type TagCreateArgs = $CreateArgs<$Schema, "Tag">; +export type TagCreateManyArgs = $CreateManyArgs<$Schema, "Tag">; +export type TagCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "Tag">; +export type TagUpdateArgs = $UpdateArgs<$Schema, "Tag">; +export type TagUpdateManyArgs = $UpdateManyArgs<$Schema, "Tag">; +export type TagUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs<$Schema, "Tag">; +export type TagUpsertArgs = $UpsertArgs<$Schema, "Tag">; +export type TagDeleteArgs = $DeleteArgs<$Schema, "Tag">; +export type TagDeleteManyArgs = $DeleteManyArgs<$Schema, "Tag">; +export type TagCountArgs = $CountArgs<$Schema, "Tag">; +export type TagAggregateArgs = $AggregateArgs<$Schema, "Tag">; +export type TagGroupByArgs = $GroupByArgs<$Schema, "Tag">; +export type TagWhereInput = $WhereInput<$Schema, "Tag">; +export type TagSelect = $SelectInput<$Schema, "Tag">; +export type TagInclude = $IncludeInput<$Schema, "Tag">; +export type TagOmit = $OmitInput<$Schema, "Tag">; +export type TagGetPayload> = $SimplifiedModelResult<$Schema, "Tag", Args>; +export type RegionFindManyArgs = $FindManyArgs<$Schema, "Region">; +export type RegionFindUniqueArgs = $FindUniqueArgs<$Schema, "Region">; +export type RegionFindFirstArgs = $FindFirstArgs<$Schema, "Region">; +export type RegionCreateArgs = $CreateArgs<$Schema, "Region">; +export type RegionCreateManyArgs = $CreateManyArgs<$Schema, "Region">; +export type RegionCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "Region">; +export type RegionUpdateArgs = $UpdateArgs<$Schema, "Region">; +export type RegionUpdateManyArgs = $UpdateManyArgs<$Schema, "Region">; +export type RegionUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs<$Schema, "Region">; +export type RegionUpsertArgs = $UpsertArgs<$Schema, "Region">; +export type RegionDeleteArgs = $DeleteArgs<$Schema, "Region">; +export type RegionDeleteManyArgs = $DeleteManyArgs<$Schema, "Region">; +export type RegionCountArgs = $CountArgs<$Schema, "Region">; +export type RegionAggregateArgs = $AggregateArgs<$Schema, "Region">; +export type RegionGroupByArgs = $GroupByArgs<$Schema, "Region">; +export type RegionWhereInput = $WhereInput<$Schema, "Region">; +export type RegionSelect = $SelectInput<$Schema, "Region">; +export type RegionInclude = $IncludeInput<$Schema, "Region">; +export type RegionOmit = $OmitInput<$Schema, "Region">; +export type RegionGetPayload> = $SimplifiedModelResult<$Schema, "Region", Args>; +export type MetaFindManyArgs = $FindManyArgs<$Schema, "Meta">; +export type MetaFindUniqueArgs = $FindUniqueArgs<$Schema, "Meta">; +export type MetaFindFirstArgs = $FindFirstArgs<$Schema, "Meta">; +export type MetaCreateArgs = $CreateArgs<$Schema, "Meta">; +export type MetaCreateManyArgs = $CreateManyArgs<$Schema, "Meta">; +export type MetaCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "Meta">; +export type MetaUpdateArgs = $UpdateArgs<$Schema, "Meta">; +export type MetaUpdateManyArgs = $UpdateManyArgs<$Schema, "Meta">; +export type MetaUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs<$Schema, "Meta">; +export type MetaUpsertArgs = $UpsertArgs<$Schema, "Meta">; +export type MetaDeleteArgs = $DeleteArgs<$Schema, "Meta">; +export type MetaDeleteManyArgs = $DeleteManyArgs<$Schema, "Meta">; +export type MetaCountArgs = $CountArgs<$Schema, "Meta">; +export type MetaAggregateArgs = $AggregateArgs<$Schema, "Meta">; +export type MetaGroupByArgs = $GroupByArgs<$Schema, "Meta">; +export type MetaWhereInput = $WhereInput<$Schema, "Meta">; +export type MetaSelect = $SelectInput<$Schema, "Meta">; +export type MetaInclude = $IncludeInput<$Schema, "Meta">; +export type MetaOmit = $OmitInput<$Schema, "Meta">; +export type MetaGetPayload> = $SimplifiedModelResult<$Schema, "Meta", Args>; diff --git a/packages/runtime/test/typing/models.ts b/packages/runtime/test/typing/models.ts index cb27df2a..e418ffad 100644 --- a/packages/runtime/test/typing/models.ts +++ b/packages/runtime/test/typing/models.ts @@ -5,14 +5,13 @@ /* 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; -export const Role = schema.enums.Role; +import { schema as $schema, type SchemaType as $Schema } from "./schema"; +import { type ModelResult as $ModelResult } from "@zenstackhq/runtime"; +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">; +export const Role = $schema.enums.Role; export type Role = (typeof Role)[keyof typeof Role]; diff --git a/packages/runtime/tsup.config.ts b/packages/runtime/tsup.config.ts index 9278c545..795f2b2b 100644 --- a/packages/runtime/tsup.config.ts +++ b/packages/runtime/tsup.config.ts @@ -1,11 +1,12 @@ import { defineConfig } from 'tsup'; +import fs from 'node:fs'; export default defineConfig({ entry: { index: 'src/index.ts', schema: 'src/schema/index.ts', helpers: 'src/helpers.ts', - 'plugins/policy': 'src/plugins/policy/index.ts', + 'plugins/policy/index': 'src/plugins/policy/index.ts', }, outDir: 'dist', splitting: false, @@ -13,4 +14,7 @@ export default defineConfig({ clean: true, dts: true, format: ['cjs', 'esm'], + async onSuccess() { + fs.cpSync('src/plugins/policy/plugin.zmodel', 'dist/plugins/policy/plugin.zmodel'); + }, }); diff --git a/tests/e2e/prisma-consistency/attributes.test.ts b/tests/e2e/prisma-consistency/attributes.test.ts new file mode 100644 index 00000000..be0eeb40 --- /dev/null +++ b/tests/e2e/prisma-consistency/attributes.test.ts @@ -0,0 +1,60 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ZenStackValidationTester, createTestDir, expectValidationSuccess, expectValidationFailure, baseSchema } from './test-utils'; + +describe('Attributes Validation', () => { + let tester: ZenStackValidationTester; + let tempDir: string; + + beforeEach(() => { + tempDir = createTestDir(); + tester = new ZenStackValidationTester(tempDir); + }); + + afterEach(() => { + tester.cleanup(); + }); + + it('should reject duplicate field attributes', () => { + const result = tester.runValidation(` +${baseSchema} + +model User { + id Int @id @default(autoincrement()) + email String @unique @unique + name String +} + `); + + expectValidationFailure(result); + }); + + it('should reject invalid default value type', () => { + const result = tester.runValidation(` +${baseSchema} + +model User { + id Int @id @default(autoincrement()) + email String @default(123) + name String +} + `); + + expectValidationFailure(result); + }); + + it('should accept valid @map attribute', () => { + const result = tester.runValidation(` +${baseSchema} + +model User { + id Int @id @default(autoincrement()) + email String @unique @map("email_address") + name String + + @@map("users") +} + `); + + expectValidationSuccess(result); + }); +}); \ No newline at end of file diff --git a/tests/e2e/prisma-consistency/basic-models.test.ts b/tests/e2e/prisma-consistency/basic-models.test.ts new file mode 100644 index 00000000..067ab9c9 --- /dev/null +++ b/tests/e2e/prisma-consistency/basic-models.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ZenStackValidationTester, createTestDir, expectValidationSuccess, expectValidationFailure, baseSchema } from './test-utils'; + +describe('Basic Models Validation', () => { + let tester: ZenStackValidationTester; + let tempDir: string; + + beforeEach(() => { + tempDir = createTestDir(); + tester = new ZenStackValidationTester(tempDir); + }); + + afterEach(() => { + tester.cleanup(); + }); + + it('should accept valid basic model with id field', () => { + const result = tester.runValidation(` +${baseSchema} + +model User { + id Int @id @default(autoincrement()) + email String @unique + name String? +} + `); + + expectValidationSuccess(result); + }); + + it('should reject model without any unique criterion', () => { + const result = tester.runValidation(` +${baseSchema} + +model User { + email String + name String? +} + `); + + expectValidationFailure(result); + }); + + it('should reject model with multiple @id fields', () => { + const result = tester.runValidation(` +${baseSchema} + +model User { + id Int @id @default(autoincrement()) + email String @id + name String? +} + `); + + expectValidationFailure(result); + }); + + it('should reject model with both @id field and @@id', () => { + const result = tester.runValidation(` +${baseSchema} + +model User { + id Int @id @default(autoincrement()) + firstName String + lastName String + + @@id([firstName, lastName]) +} + `); + + expectValidationFailure(result); + }); + + it('should reject optional ID field', () => { + const result = tester.runValidation(` +${baseSchema} + +model User { + id Int? @id @default(autoincrement()) + email String @unique +} + `); + + expectValidationFailure(result); + }); + + it('should reject array ID field', () => { + const result = tester.runValidation(` +${baseSchema} + +model User { + id Int[] @id + email String @unique +} + `); + + expectValidationFailure(result); + }); +}); \ No newline at end of file diff --git a/tests/e2e/prisma-consistency/compound-ids.test.ts b/tests/e2e/prisma-consistency/compound-ids.test.ts new file mode 100644 index 00000000..90b42f9b --- /dev/null +++ b/tests/e2e/prisma-consistency/compound-ids.test.ts @@ -0,0 +1,47 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ZenStackValidationTester, createTestDir, expectValidationSuccess, expectValidationFailure, baseSchema } from './test-utils'; + +describe('Compound IDs Validation', () => { + let tester: ZenStackValidationTester; + let tempDir: string; + + beforeEach(() => { + tempDir = createTestDir(); + tester = new ZenStackValidationTester(tempDir); + }); + + afterEach(() => { + tester.cleanup(); + }); + + it('should accept valid compound ID with @@id', () => { + const result = tester.runValidation(` +${baseSchema} + +model User { + firstName String + lastName String + age Int + + @@id([firstName, lastName]) +} + `); + + expectValidationSuccess(result); + }); + + it('should reject empty compound ID', () => { + const result = tester.runValidation(` +${baseSchema} + +model User { + firstName String + lastName String + + @@id([]) +} + `); + + expectValidationFailure(result); + }); +}); \ No newline at end of file diff --git a/tests/e2e/prisma-consistency/datasource.test.ts b/tests/e2e/prisma-consistency/datasource.test.ts new file mode 100644 index 00000000..7746dbf8 --- /dev/null +++ b/tests/e2e/prisma-consistency/datasource.test.ts @@ -0,0 +1,64 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ZenStackValidationTester, createTestDir, expectValidationSuccess, expectValidationFailure, baseSchema } from './test-utils'; + +describe('Datasource Validation', () => { + let tester: ZenStackValidationTester; + let tempDir: string; + + beforeEach(() => { + tempDir = createTestDir(); + tester = new ZenStackValidationTester(tempDir); + }); + + afterEach(() => { + tester.cleanup(); + }); + + it('should reject multiple datasources', () => { + const result = tester.runValidation(` +datasource db1 { + provider = "postgresql" + url = env("DATABASE_URL") +} + +datasource db2 { + provider = "sqlite" + url = "file:./dev.db" +} + +model User { + id Int @id @default(autoincrement()) + name String +} + `); + + expectValidationFailure(result); + }); + + it('should reject missing datasource', () => { + const result = tester.runValidation(` +model User { + id Int @id @default(autoincrement()) + name String +} + `); + + expectValidationFailure(result); + }); + + it('should reject invalid provider', () => { + const result = tester.runValidation(` +datasource db { + provider = "nosql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + name String +} + `); + + expectValidationFailure(result); + }); +}); \ No newline at end of file diff --git a/tests/e2e/prisma-consistency/enums.test.ts b/tests/e2e/prisma-consistency/enums.test.ts new file mode 100644 index 00000000..9a0719c3 --- /dev/null +++ b/tests/e2e/prisma-consistency/enums.test.ts @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ZenStackValidationTester, createTestDir, expectValidationSuccess, expectValidationFailure, baseSchema } from './test-utils'; + +describe('Enums Validation', () => { + let tester: ZenStackValidationTester; + let tempDir: string; + + beforeEach(() => { + tempDir = createTestDir(); + tester = new ZenStackValidationTester(tempDir); + }); + + afterEach(() => { + tester.cleanup(); + }); + + it('should accept valid enum definition and usage', () => { + const result = tester.runValidation(` +${baseSchema} + +enum Role { + USER + ADMIN + MODERATOR +} + +model User { + id Int @id @default(autoincrement()) + role Role @default(USER) + name String +} + `); + + expectValidationSuccess(result); + }); + + it('should reject empty enum', () => { + const result = tester.runValidation(` +${baseSchema} + +enum Role { +} + +model User { + id Int @id @default(autoincrement()) + role Role @default(USER) + name String +} + `); + + expectValidationFailure(result); + }); +}); \ No newline at end of file diff --git a/tests/e2e/prisma-consistency/field-types.test.ts b/tests/e2e/prisma-consistency/field-types.test.ts new file mode 100644 index 00000000..ac9a0081 --- /dev/null +++ b/tests/e2e/prisma-consistency/field-types.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ZenStackValidationTester, createTestDir, expectValidationSuccess, expectValidationFailure, baseSchema, sqliteSchema } from './test-utils'; + +describe('Field Types Validation', () => { + let tester: ZenStackValidationTester; + let tempDir: string; + + beforeEach(() => { + tempDir = createTestDir(); + tester = new ZenStackValidationTester(tempDir); + }); + + afterEach(() => { + tester.cleanup(); + }); + + it('should reject optional array field', () => { + const result = tester.runValidation(` +${baseSchema} + +model User { + id Int @id @default(autoincrement()) + tags String[]? +} + `); + + expectValidationFailure(result); + }); + + it('should reject array field with SQLite', () => { + const result = tester.runValidation(` +${sqliteSchema} + +model User { + id Int @id @default(autoincrement()) + tags String[] +} + `); + + expectValidationFailure(result); + }); + + it('should accept array field with PostgreSQL', () => { + const result = tester.runValidation(` +${baseSchema} + +model User { + id Int @id @default(autoincrement()) + tags String[] +} + `); + + expectValidationSuccess(result); + }); +}); \ No newline at end of file diff --git a/tests/e2e/prisma-consistency/relation-validation.test.ts b/tests/e2e/prisma-consistency/relation-validation.test.ts new file mode 100644 index 00000000..acf35ee3 --- /dev/null +++ b/tests/e2e/prisma-consistency/relation-validation.test.ts @@ -0,0 +1,163 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ZenStackValidationTester, createTestDir, expectValidationSuccess, expectValidationFailure, baseSchema } from './test-utils'; + +describe('Relation Validation Rules', () => { + let tester: ZenStackValidationTester; + let tempDir: string; + + beforeEach(() => { + tempDir = createTestDir(); + tester = new ZenStackValidationTester(tempDir); + }); + + afterEach(() => { + tester.cleanup(); + }); + + it('should reject mismatched length of fields and references arrays', () => { + const result = tester.runValidation(` +${baseSchema} + +model User { + id Int @id @default(autoincrement()) + email String @unique + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId], references: [id, email]) + authorId Int +} + `); + + expectValidationFailure(result); + }); + + it('should reject empty fields array', () => { + const result = tester.runValidation(` +${baseSchema} + +model User { + id Int @id @default(autoincrement()) + email String @unique + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [], references: [id]) + authorId Int +} + `); + + expectValidationFailure(result); + }); + + it('should reject empty references array', () => { + const result = tester.runValidation(` +${baseSchema} + +model User { + id Int @id @default(autoincrement()) + email String @unique + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId], references: []) + authorId Int +} + `); + + expectValidationFailure(result); + }); + + it('should reject partial relation specification with only fields', () => { + const result = tester.runValidation(` +${baseSchema} + +model User { + id Int @id @default(autoincrement()) + email String @unique + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId]) + authorId Int +} + `); + + expectValidationFailure(result); + }); + + it('should reject partial relation specification with only references', () => { + const result = tester.runValidation(` +${baseSchema} + +model User { + id Int @id @default(autoincrement()) + email String @unique + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(references: [id]) + authorId Int +} + `); + + expectValidationFailure(result); + }); + + it('should reject both sides of relation with fields/references', () => { + const result = tester.runValidation(` +${baseSchema} + +model User { + id Int @id @default(autoincrement()) + email String @unique + posts Post[] @relation(fields: [id], references: [authorId]) +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId], references: [id]) + authorId Int +} + `); + + expectValidationFailure(result); + }); + + it('should reject type mismatch between fields and references', () => { + const result = tester.runValidation(` +${baseSchema} + +model User { + id String @id @default(cuid()) + email String @unique + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId], references: [id]) + authorId Int +} + `); + + expectValidationFailure(result); + }); +}); \ No newline at end of file diff --git a/tests/e2e/prisma-consistency/relations-many-to-many.test.ts b/tests/e2e/prisma-consistency/relations-many-to-many.test.ts new file mode 100644 index 00000000..7a89de96 --- /dev/null +++ b/tests/e2e/prisma-consistency/relations-many-to-many.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ZenStackValidationTester, createTestDir, expectValidationSuccess, expectValidationFailure, baseSchema } from './test-utils'; + +describe('Many-to-Many Relations Validation', () => { + let tester: ZenStackValidationTester; + let tempDir: string; + + beforeEach(() => { + tempDir = createTestDir(); + tester = new ZenStackValidationTester(tempDir); + }); + + afterEach(() => { + tester.cleanup(); + }); + + it('should accept valid implicit many-to-many relation', () => { + const result = tester.runValidation(` +${baseSchema} + +model User { + id Int @id @default(autoincrement()) + email String @unique + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + authors User[] +} + `); + + expectValidationSuccess(result); + }); + + it('should accept valid explicit many-to-many relation', () => { + const result = tester.runValidation(` +${baseSchema} + +model User { + id Int @id @default(autoincrement()) + email String @unique + posts PostUser[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + authors PostUser[] +} + +model PostUser { + user User @relation(fields: [userId], references: [id]) + post Post @relation(fields: [postId], references: [id]) + userId Int + postId Int + + @@id([userId, postId]) +} + `); + + expectValidationSuccess(result); + }); + + it('should reject implicit many-to-many with explicit @relation', () => { + const result = tester.runValidation(` +${baseSchema} + +model User { + id Int @id @default(autoincrement()) + email String @unique + posts Post[] @relation(fields: [id], references: [id]) +} + +model Post { + id Int @id @default(autoincrement()) + title String + authors User[] +} + `); + + expectValidationFailure(result); + }); +}); \ No newline at end of file diff --git a/tests/e2e/prisma-consistency/relations-one-to-many.test.ts b/tests/e2e/prisma-consistency/relations-one-to-many.test.ts new file mode 100644 index 00000000..dc4048a8 --- /dev/null +++ b/tests/e2e/prisma-consistency/relations-one-to-many.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ZenStackValidationTester, createTestDir, expectValidationSuccess, expectValidationFailure, baseSchema } from './test-utils'; + +describe('One-to-Many Relations Validation', () => { + let tester: ZenStackValidationTester; + let tempDir: string; + + beforeEach(() => { + tempDir = createTestDir(); + tester = new ZenStackValidationTester(tempDir); + }); + + afterEach(() => { + tester.cleanup(); + }); + + it('should accept valid one-to-many relation', () => { + const result = tester.runValidation(` +${baseSchema} + +model User { + id Int @id @default(autoincrement()) + email String @unique + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId], references: [id]) + authorId Int +} + `); + + expectValidationSuccess(result); + }); + + it('should reject one-to-many without @relation annotation', () => { + const result = tester.runValidation(` +${baseSchema} + +model User { + id Int @id @default(autoincrement()) + email String @unique + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User + authorId Int +} + `); + + expectValidationFailure(result); + }); + + it('should reject one-to-many relation referencing non-existent FK field', () => { + const result = tester.runValidation(` +${baseSchema} + +model User { + id Int @id @default(autoincrement()) + email String @unique + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId], references: [id]) +} + `); + + expectValidationFailure(result); + }); +}); \ No newline at end of file diff --git a/tests/e2e/prisma-consistency/relations-one-to-one.test.ts b/tests/e2e/prisma-consistency/relations-one-to-one.test.ts new file mode 100644 index 00000000..b73726dd --- /dev/null +++ b/tests/e2e/prisma-consistency/relations-one-to-one.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ZenStackValidationTester, createTestDir, expectValidationSuccess, expectValidationFailure, baseSchema } from './test-utils'; + +describe('One-to-One Relations Validation', () => { + let tester: ZenStackValidationTester; + let tempDir: string; + + beforeEach(() => { + tempDir = createTestDir(); + tester = new ZenStackValidationTester(tempDir); + }); + + afterEach(() => { + tester.cleanup(); + }); + + it('should accept valid one-to-one relation', () => { + const result = tester.runValidation(` +${baseSchema} + +model User { + id Int @id @default(autoincrement()) + email String @unique + profile Profile? +} + +model Profile { + id Int @id @default(autoincrement()) + bio String + user User @relation(fields: [userId], references: [id]) + userId Int @unique +} + `); + + expectValidationSuccess(result); + }); + + it('should reject one-to-one relation without @unique on FK', () => { + const result = tester.runValidation(` +${baseSchema} + +model User { + id Int @id @default(autoincrement()) + email String @unique + profile Profile? +} + +model Profile { + id Int @id @default(autoincrement()) + bio String + user User @relation(fields: [userId], references: [id]) + userId Int +} + `); + + expectValidationFailure(result); + }); + + it('should reject one-to-one relation missing opposite field', () => { + const result = tester.runValidation(` +${baseSchema} + +model User { + id Int @id @default(autoincrement()) + email String @unique + profile Profile? +} + +model Profile { + id Int @id @default(autoincrement()) + bio String + userId Int @unique +} + `); + + expectValidationFailure(result); + }); + + it('should reject one-to-one with both sides required', () => { + const result = tester.runValidation(` +${baseSchema} + +model User { + id Int @id @default(autoincrement()) + email String @unique + profile Profile +} + +model Profile { + id Int @id @default(autoincrement()) + bio String + user User @relation(fields: [userId], references: [id]) + userId Int @unique +} + `); + + expectValidationFailure(result); + }); +}); \ No newline at end of file diff --git a/tests/e2e/prisma-consistency/relations-self.test.ts b/tests/e2e/prisma-consistency/relations-self.test.ts new file mode 100644 index 00000000..49077c6a --- /dev/null +++ b/tests/e2e/prisma-consistency/relations-self.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ZenStackValidationTester, createTestDir, expectValidationSuccess, expectValidationFailure, baseSchema } from './test-utils'; + +describe('Self Relations Validation', () => { + let tester: ZenStackValidationTester; + let tempDir: string; + + beforeEach(() => { + tempDir = createTestDir(); + tester = new ZenStackValidationTester(tempDir); + }); + + afterEach(() => { + tester.cleanup(); + }); + + it('should accept valid self relation with proper naming', () => { + const result = tester.runValidation(` +${baseSchema} + +model User { + id Int @id @default(autoincrement()) + email String @unique + manager User? @relation("UserManager", fields: [managerId], references: [id]) + managerId Int? + employees User[] @relation("UserManager") +} + `); + + expectValidationSuccess(result); + }); + + it('should reject self relation without relation name', () => { + const result = tester.runValidation(` +${baseSchema} + +model User { + id Int @id @default(autoincrement()) + email String @unique + manager User? @relation(fields: [managerId], references: [id]) + managerId Int? + employees User[] +} + `); + + expectValidationFailure(result); + }); + + it('should accept self many-to-many relation', () => { + const result = tester.runValidation(` +${baseSchema} + +model User { + id Int @id @default(autoincrement()) + email String @unique + following User[] @relation("UserFollows") + followers User[] @relation("UserFollows") +} + `); + + expectValidationSuccess(result); + }); +}); \ No newline at end of file diff --git a/tests/e2e/prisma-consistency/test-utils.ts b/tests/e2e/prisma-consistency/test-utils.ts new file mode 100644 index 00000000..727426a5 --- /dev/null +++ b/tests/e2e/prisma-consistency/test-utils.ts @@ -0,0 +1,118 @@ +import { execSync } from 'child_process'; +import { randomUUID } from 'crypto'; +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; +import { expect } from 'vitest'; + +export interface ValidationResult { + success: boolean; + errors: string[]; +} + +export class ZenStackValidationTester { + private testDir: string; + private schemaPath: string; + private cliPath: string; + + constructor(testDir: string) { + this.testDir = testDir; + this.schemaPath = join(testDir, 'zenstack', 'schema.zmodel'); + + // Get path relative to current test file + const currentDir = dirname(fileURLToPath(import.meta.url)); + this.cliPath = join(currentDir, '../node_modules/@zenstackhq/cli/bin/cli'); + } + + private setupTestDirectory() { + if (existsSync(this.testDir)) { + rmSync(this.testDir, { recursive: true, force: true }); + } + mkdirSync(this.testDir, { recursive: true }); + mkdirSync(join(this.testDir, 'zenstack'), { recursive: true }); + + // Create package.json + writeFileSync( + join(this.testDir, 'package.json'), + JSON.stringify( + { + name: 'zenstack-validation-test', + version: '1.0.0', + private: true, + }, + null, + 2, + ), + ); + } + + public runValidation(schema: string): ValidationResult { + this.setupTestDirectory(); + writeFileSync(this.schemaPath, schema); + + try { + execSync(`node ${this.cliPath} generate`, { + cwd: this.testDir, + stdio: 'pipe', + encoding: 'utf8', + }); + + return { + success: true, + errors: [], + }; + } catch (error: any) { + return { + success: false, + errors: this.extractErrors(error.stderr), + }; + } + } + + private extractErrors(output: string): string[] { + const lines = output.split('\n'); + const errors: string[] = []; + + for (const line of lines) { + if (line.includes('Error:') || line.includes('error:') || line.includes('βœ–')) { + errors.push(line.trim()); + } + } + + return errors; + } + + public cleanup() { + if (existsSync(this.testDir)) { + rmSync(this.testDir, { recursive: true, force: true }); + } + } +} + +export function createTestDir(): string { + return join(tmpdir(), 'zenstack-validation-test-' + randomUUID()); +} + +export function expectValidationSuccess(result: ValidationResult) { + expect(result.success).toBe(true); +} + +export function expectValidationFailure(result: ValidationResult) { + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); +} + +export const baseSchema = ` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} +`; + +export const sqliteSchema = ` +datasource db { + provider = "sqlite" + url = "file:./dev.db" +} +`; \ No newline at end of file diff --git a/tests/e2e/prisma-consistency/unique-constraints.test.ts b/tests/e2e/prisma-consistency/unique-constraints.test.ts new file mode 100644 index 00000000..c39d456e --- /dev/null +++ b/tests/e2e/prisma-consistency/unique-constraints.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ZenStackValidationTester, createTestDir, expectValidationSuccess, expectValidationFailure, baseSchema } from './test-utils'; + +describe('Unique Constraints Validation', () => { + let tester: ZenStackValidationTester; + let tempDir: string; + + beforeEach(() => { + tempDir = createTestDir(); + tester = new ZenStackValidationTester(tempDir); + }); + + afterEach(() => { + tester.cleanup(); + }); + + it('should accept valid compound unique constraint', () => { + const result = tester.runValidation(` +${baseSchema} + +model User { + id Int @id @default(autoincrement()) + firstName String + lastName String + email String @unique + + @@unique([firstName, lastName]) +} + `); + + expectValidationSuccess(result); + }); + + it('should reject empty unique constraint', () => { + const result = tester.runValidation(` +${baseSchema} + +model User { + id Int @id @default(autoincrement()) + firstName String + lastName String + + @@unique([]) +} + `); + + expectValidationFailure(result); + }); + + it('should accept unique constraint on optional field', () => { + const result = tester.runValidation(` +${baseSchema} + +model User { + id Int @id @default(autoincrement()) + email String? @unique + name String +} + `); + + expectValidationSuccess(result); + }); +}); \ No newline at end of file diff --git a/tests/e2e/prisma-consistency/zmodel-validation.test.ts b/tests/e2e/prisma-consistency/zmodel-validation.test.ts deleted file mode 100644 index 24730512..00000000 --- a/tests/e2e/prisma-consistency/zmodel-validation.test.ts +++ /dev/null @@ -1,1012 +0,0 @@ -import { execSync } from 'child_process'; -import { randomUUID } from 'crypto'; -import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'; -import { tmpdir } from 'os'; -import { dirname, join } from 'path'; -import { fileURLToPath } from 'url'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -interface ValidationResult { - success: boolean; - errors: string[]; -} - -class ZenStackValidationTester { - private testDir: string; - private schemaPath: string; - private cliPath: string; - - constructor(testDir: string) { - this.testDir = testDir; - this.schemaPath = join(testDir, 'zenstack', 'schema.zmodel'); - - // Get path relative to current test file - const currentDir = dirname(fileURLToPath(import.meta.url)); - this.cliPath = join(currentDir, '../node_modules/@zenstackhq/cli/bin/cli'); - } - - private setupTestDirectory() { - if (existsSync(this.testDir)) { - rmSync(this.testDir, { recursive: true, force: true }); - } - mkdirSync(this.testDir, { recursive: true }); - mkdirSync(join(this.testDir, 'zenstack'), { recursive: true }); - - // Create package.json - writeFileSync( - join(this.testDir, 'package.json'), - JSON.stringify( - { - name: 'zenstack-validation-test', - version: '1.0.0', - private: true, - }, - null, - 2, - ), - ); - } - - public runValidation(schema: string): ValidationResult { - this.setupTestDirectory(); - writeFileSync(this.schemaPath, schema); - - try { - execSync(`node ${this.cliPath} generate`, { - cwd: this.testDir, - stdio: 'pipe', - encoding: 'utf8', - }); - - return { - success: true, - errors: [], - }; - } catch (error: any) { - return { - success: false, - errors: this.extractErrors(error.stderr), - }; - } - } - - private extractErrors(output: string): string[] { - const lines = output.split('\n'); - const errors: string[] = []; - - for (const line of lines) { - if (line.includes('Error:') || line.includes('error:') || line.includes('βœ–')) { - errors.push(line.trim()); - } - } - - return errors; - } - - public cleanup() { - if (existsSync(this.testDir)) { - rmSync(this.testDir, { recursive: true, force: true }); - } - } -} - -describe('ZenStack validation consistency with Prisma', () => { - let tester: ZenStackValidationTester; - let tempDir: string; - - beforeEach(() => { - tempDir = join(tmpdir(), 'zenstack-validation-test-' + randomUUID()); - tester = new ZenStackValidationTester(tempDir); - }); - - afterEach(() => { - tester.cleanup(); - }); - - describe('basic_models', () => { - it('should accept valid basic model with id field', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - id Int @id @default(autoincrement()) - email String @unique - name String? -} - `); - - expect(result.success).toBe(true); - }); - - it('should reject model without any unique criterion', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - email String - name String? -} - `); - - expect(result.success).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); - - it('should reject model with multiple @id fields', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - id Int @id @default(autoincrement()) - email String @id - name String? -} - `); - - expect(result.success).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); - - it('should reject model with both @id field and @@id', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - id Int @id @default(autoincrement()) - firstName String - lastName String - - @@id([firstName, lastName]) -} - `); - - expect(result.success).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); - - it('should reject optional ID field', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - id Int? @id @default(autoincrement()) - email String @unique -} - `); - - expect(result.success).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); - - it('should reject array ID field', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - id Int[] @id - email String @unique -} - `); - - expect(result.success).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); - }); - - describe('compound_ids', () => { - it('should accept valid compound ID with @@id', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - firstName String - lastName String - age Int - - @@id([firstName, lastName]) -} - `); - - expect(result.success).toBe(true); - }); - - it('should reject empty compound ID', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - firstName String - lastName String - - @@id([]) -} - `); - - expect(result.success).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); - }); - - describe('field_types', () => { - it('should reject optional array field', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - id Int @id @default(autoincrement()) - tags String[]? -} - `); - - expect(result.success).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); - - it('should reject array field with SQLite', () => { - const result = tester.runValidation(` -datasource db { - provider = "sqlite" - url = "file:./dev.db" -} - -model User { - id Int @id @default(autoincrement()) - tags String[] -} - `); - - expect(result.success).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); - - it('should accept array field with PostgreSQL', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - id Int @id @default(autoincrement()) - tags String[] -} - `); - - expect(result.success).toBe(true); - }); - }); - - describe('relations_one_to_one', () => { - it('should accept valid one-to-one relation', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - id Int @id @default(autoincrement()) - email String @unique - profile Profile? -} - -model Profile { - id Int @id @default(autoincrement()) - bio String - user User @relation(fields: [userId], references: [id]) - userId Int @unique -} - `); - - expect(result.success).toBe(true); - }); - - it('should reject one-to-one relation without @unique on FK', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - id Int @id @default(autoincrement()) - email String @unique - profile Profile? -} - -model Profile { - id Int @id @default(autoincrement()) - bio String - user User @relation(fields: [userId], references: [id]) - userId Int -} - `); - - expect(result.success).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); - - it('should reject one-to-one relation missing opposite field', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - id Int @id @default(autoincrement()) - email String @unique - profile Profile? -} - -model Profile { - id Int @id @default(autoincrement()) - bio String - userId Int @unique -} - `); - - expect(result.success).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); - - it('should reject one-to-one with both sides required', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - id Int @id @default(autoincrement()) - email String @unique - profile Profile -} - -model Profile { - id Int @id @default(autoincrement()) - bio String - user User @relation(fields: [userId], references: [id]) - userId Int @unique -} - `); - - expect(result.success).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); - }); - - describe('relations_one_to_many', () => { - it('should accept valid one-to-many relation', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - id Int @id @default(autoincrement()) - email String @unique - posts Post[] -} - -model Post { - id Int @id @default(autoincrement()) - title String - author User @relation(fields: [authorId], references: [id]) - authorId Int -} - `); - - expect(result.success).toBe(true); - }); - - it('should reject one-to-many without @relation annotation', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - id Int @id @default(autoincrement()) - email String @unique - posts Post[] -} - -model Post { - id Int @id @default(autoincrement()) - title String - author User - authorId Int -} - `); - - expect(result.success).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); - - it('should reject one-to-many relation referencing non-existent FK field', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - id Int @id @default(autoincrement()) - email String @unique - posts Post[] -} - -model Post { - id Int @id @default(autoincrement()) - title String - author User @relation(fields: [authorId], references: [id]) -} - `); - - expect(result.success).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); - }); - - describe('relations_many_to_many', () => { - it('should accept valid implicit many-to-many relation', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - id Int @id @default(autoincrement()) - email String @unique - posts Post[] -} - -model Post { - id Int @id @default(autoincrement()) - title String - authors User[] -} - `); - - expect(result.success).toBe(true); - }); - - it('should accept valid explicit many-to-many relation', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - id Int @id @default(autoincrement()) - email String @unique - posts PostUser[] -} - -model Post { - id Int @id @default(autoincrement()) - title String - authors PostUser[] -} - -model PostUser { - user User @relation(fields: [userId], references: [id]) - post Post @relation(fields: [postId], references: [id]) - userId Int - postId Int - - @@id([userId, postId]) -} - `); - - expect(result.success).toBe(true); - }); - - it('should reject implicit many-to-many with explicit @relation', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - id Int @id @default(autoincrement()) - email String @unique - posts Post[] @relation(fields: [id], references: [id]) -} - -model Post { - id Int @id @default(autoincrement()) - title String - authors User[] -} - `); - - expect(result.success).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); - }); - - describe('relations_self', () => { - it('should accept valid self relation with proper naming', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - id Int @id @default(autoincrement()) - email String @unique - manager User? @relation("UserManager", fields: [managerId], references: [id]) - managerId Int? - employees User[] @relation("UserManager") -} - `); - - expect(result.success).toBe(true); - }); - - it('should reject self relation without relation name', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - id Int @id @default(autoincrement()) - email String @unique - manager User? @relation(fields: [managerId], references: [id]) - managerId Int? - employees User[] -} - `); - - expect(result.success).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); - - it('should accept self many-to-many relation', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - id Int @id @default(autoincrement()) - email String @unique - following User[] @relation("UserFollows") - followers User[] @relation("UserFollows") -} - `); - - expect(result.success).toBe(true); - }); - }); - - describe('relation_validation', () => { - it('should reject mismatched length of fields and references arrays', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - id Int @id @default(autoincrement()) - email String @unique - posts Post[] -} - -model Post { - id Int @id @default(autoincrement()) - title String - author User @relation(fields: [authorId], references: [id, email]) - authorId Int -} - `); - - expect(result.success).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); - - it('should reject empty fields array', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - id Int @id @default(autoincrement()) - email String @unique - posts Post[] -} - -model Post { - id Int @id @default(autoincrement()) - title String - author User @relation(fields: [], references: [id]) - authorId Int -} - `); - - expect(result.success).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); - - it('should reject empty references array', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - id Int @id @default(autoincrement()) - email String @unique - posts Post[] -} - -model Post { - id Int @id @default(autoincrement()) - title String - author User @relation(fields: [authorId], references: []) - authorId Int -} - `); - - expect(result.success).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); - - it('should reject partial relation specification with only fields', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - id Int @id @default(autoincrement()) - email String @unique - posts Post[] -} - -model Post { - id Int @id @default(autoincrement()) - title String - author User @relation(fields: [authorId]) - authorId Int -} - `); - - expect(result.success).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); - - it('should reject partial relation specification with only references', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - id Int @id @default(autoincrement()) - email String @unique - posts Post[] -} - -model Post { - id Int @id @default(autoincrement()) - title String - author User @relation(references: [id]) - authorId Int -} - `); - - expect(result.success).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); - - it('should reject both sides of relation with fields/references', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - id Int @id @default(autoincrement()) - email String @unique - posts Post[] @relation(fields: [id], references: [authorId]) -} - -model Post { - id Int @id @default(autoincrement()) - title String - author User @relation(fields: [authorId], references: [id]) - authorId Int -} - `); - - expect(result.success).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); - - it('should reject type mismatch between fields and references', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - id String @id @default(cuid()) - email String @unique - posts Post[] -} - -model Post { - id Int @id @default(autoincrement()) - title String - author User @relation(fields: [authorId], references: [id]) - authorId Int -} - `); - - expect(result.success).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); - }); - - describe('unique_constraints', () => { - it('should accept valid compound unique constraint', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - id Int @id @default(autoincrement()) - firstName String - lastName String - email String @unique - - @@unique([firstName, lastName]) -} - `); - - expect(result.success).toBe(true); - }); - - it('should reject empty unique constraint', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - id Int @id @default(autoincrement()) - firstName String - lastName String - - @@unique([]) -} - `); - - expect(result.success).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); - - it('should accept unique constraint on optional field', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - id Int @id @default(autoincrement()) - email String? @unique - name String -} - `); - - expect(result.success).toBe(true); - }); - }); - - describe('enums', () => { - it('should accept valid enum definition and usage', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -enum Role { - USER - ADMIN - MODERATOR -} - -model User { - id Int @id @default(autoincrement()) - role Role @default(USER) - name String -} - `); - - expect(result.success).toBe(true); - }); - - it('should reject empty enum', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -enum Role { -} - -model User { - id Int @id @default(autoincrement()) - role Role @default(USER) - name String -} - `); - - expect(result.success).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); - }); - - describe('datasource', () => { - it('should reject multiple datasources', () => { - const result = tester.runValidation(` -datasource db1 { - provider = "postgresql" - url = env("DATABASE_URL") -} - -datasource db2 { - provider = "sqlite" - url = "file:./dev.db" -} - -model User { - id Int @id @default(autoincrement()) - name String -} - `); - - expect(result.success).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); - - it('should reject missing datasource', () => { - const result = tester.runValidation(` -model User { - id Int @id @default(autoincrement()) - name String -} - `); - - expect(result.success).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); - - it('should reject invalid provider', () => { - const result = tester.runValidation(` -datasource db { - provider = "nosql" - url = env("DATABASE_URL") -} - -model User { - id Int @id @default(autoincrement()) - name String -} - `); - - expect(result.success).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); - }); - - describe('attributes', () => { - it('should reject duplicate field attributes', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - id Int @id @default(autoincrement()) - email String @unique @unique - name String -} - `); - - expect(result.success).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); - - it('should reject invalid default value type', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - id Int @id @default(autoincrement()) - email String @default(123) - name String -} - `); - - expect(result.success).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); - - it('should accept valid @map attribute', () => { - const result = tester.runValidation(` -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model User { - id Int @id @default(autoincrement()) - email String @unique @map("email_address") - name String - - @@map("users") -} - `); - - expect(result.success).toBe(true); - }); - }); -}); From 39cf629b0cdf1c6f26a6228cf415b3d91dae549f Mon Sep 17 00:00:00 2001 From: Yiming Cao Date: Mon, 21 Jul 2025 19:48:48 +0800 Subject: [PATCH 06/19] feat: implementing mixin (#106) * feat: implementing mixin * format * update --- .prettierignore | 7 +- CLAUDE.md | 15 +- TODO.md | 9 +- packages/cli/src/actions/generate.ts | 4 +- packages/cli/src/actions/validate.ts | 2 +- packages/cli/test/ts-schema-gen.test.ts | 85 ++++ packages/language/package.json | 11 + packages/language/res/stdlib.zmodel | 62 +-- packages/language/src/ast.ts | 9 +- packages/language/src/generated/ast.ts | 256 +++++------- packages/language/src/generated/grammar.ts | 384 +++++++----------- packages/language/src/utils.ts | 106 +++-- .../attribute-application-validator.ts | 74 ++-- packages/language/src/validators/common.ts | 6 +- .../src/validators/datamodel-validator.ts | 99 ++--- .../src/validators/expression-validator.ts | 14 +- .../function-invocation-validator.ts | 12 +- packages/language/src/zmodel-linker.ts | 32 +- packages/language/src/zmodel-scope.ts | 40 +- .../language/src/zmodel-workspace-manager.ts | 87 +++- packages/language/src/zmodel.langium | 41 +- packages/language/test/mixin.test.ts | 109 +++++ packages/language/test/utils.ts | 30 ++ packages/language/tsup.config.ts | 1 + packages/language/vitest.config.ts | 4 + packages/runtime/src/client/crud-types.ts | 98 +++-- .../src/client/helpers/schema-db-pusher.ts | 7 +- packages/runtime/src/client/query-builder.ts | 10 +- packages/runtime/test/client-api/find.test.ts | 12 +- .../runtime/test/client-api/mixin.test.ts | 80 ++++ packages/runtime/test/schemas/todo.zmodel | 2 +- packages/runtime/test/test-schema/models.ts | 3 +- packages/runtime/test/test-schema/schema.ts | 49 ++- .../runtime/test/test-schema/schema.zmodel | 22 +- packages/runtime/test/typing/models.ts | 4 +- packages/runtime/test/typing/schema.ts | 20 + .../runtime/test/typing/typing-test.zmodel | 9 + packages/runtime/test/typing/verify-typing.ts | 17 +- packages/runtime/test/utils.ts | 20 +- packages/sdk/src/model-utils.ts | 116 +----- .../sdk/src/prisma/prisma-schema-generator.ts | 50 +-- packages/sdk/src/schema/schema.ts | 81 ++-- packages/sdk/src/ts-schema-generator.ts | 153 +++++-- packages/sdk/src/zmodel-code-generator.ts | 33 +- pnpm-lock.yaml | 3 + samples/blog/zenstack/models.ts | 3 +- samples/blog/zenstack/schema.prisma | 44 -- samples/blog/zenstack/schema.ts | 31 ++ samples/blog/zenstack/schema.zmodel | 19 +- .../e2e/prisma-consistency/attributes.test.ts | 10 +- .../prisma-consistency/basic-models.test.ts | 10 +- .../prisma-consistency/compound-ids.test.ts | 10 +- .../e2e/prisma-consistency/datasource.test.ts | 10 +- tests/e2e/prisma-consistency/enums.test.ts | 10 +- .../prisma-consistency/field-types.test.ts | 11 +- .../relation-validation.test.ts | 10 +- .../relations-many-to-many.test.ts | 10 +- .../relations-one-to-many.test.ts | 10 +- .../relations-one-to-one.test.ts | 10 +- .../prisma-consistency/relations-self.test.ts | 10 +- tests/e2e/prisma-consistency/test-utils.ts | 2 +- .../unique-constraints.test.ts | 10 +- turbo.json | 2 +- 63 files changed, 1458 insertions(+), 1052 deletions(-) create mode 100644 packages/language/test/mixin.test.ts create mode 100644 packages/language/test/utils.ts create mode 100644 packages/language/vitest.config.ts create mode 100644 packages/runtime/test/client-api/mixin.test.ts delete mode 100644 samples/blog/zenstack/schema.prisma diff --git a/.prettierignore b/.prettierignore index 03571ca3..2f2b01e6 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,7 @@ packages/language/src/generated/** -**/schema.ts +**/test/**/schema.ts +**/test/**/models.ts +**/test/**/input.ts +samples/**/schema.ts +samples/**/models.ts +samples/**/input.ts diff --git a/CLAUDE.md b/CLAUDE.md index d9dbd43b..432af85c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,22 +5,26 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Development Commands ### Build System + - `pnpm build` - Build all packages using Turbo - `pnpm watch` - Watch mode for all packages - `pnpm lint` - Run ESLint across all packages - `pnpm test` - Run tests for all packages ### Package Management + - Uses `pnpm` with workspaces - Package manager is pinned to `pnpm@10.12.1` - Packages are located in `packages/`, `samples/`, and `tests/` ### Testing + - Runtime package tests: `pnpm test` (includes vitest, typing generation, and typecheck) -- CLI tests: `pnpm test` +- CLI tests: `pnpm test` - E2E tests are in `tests/e2e/` directory ### ZenStack CLI Commands + - `npx zenstack init` - Initialize ZenStack in a project - `npx zenstack generate` - Compile ZModel schema to TypeScript - `npx zenstack db push` - Sync schema to database (uses Prisma) @@ -30,36 +34,42 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Architecture Overview ### Core Components + - **@zenstackhq/runtime** - Main database client and ORM engine built on Kysely - **@zenstackhq/cli** - Command line interface and project management - **@zenstackhq/language** - ZModel language specification and parser (uses Langium) - **@zenstackhq/sdk** - Code generation utilities and schema processing ### Key Architecture Patterns + - **Monorepo Structure**: Uses pnpm workspaces with Turbo for build orchestration - **Language-First Design**: ZModel DSL compiles to TypeScript, not runtime code generation - **Kysely-Based ORM**: V3 uses Kysely as query builder instead of Prisma runtime dependency - **Plugin Architecture**: Runtime plugins for query interception and entity mutation hooks ### ZModel to TypeScript Flow + 1. ZModel schema (`schema.zmodel`) defines database structure and policies 2. `zenstack generate` compiles ZModel to TypeScript schema (`schema.ts`) 3. Schema is used to instantiate `ZenStackClient` with type-safe CRUD operations 4. Client provides both high-level ORM API and low-level Kysely query builder ### Package Dependencies + - **Runtime**: Depends on Kysely, Zod, and various utility libraries - **CLI**: Depends on language package, Commander.js, and Prisma (for migrations) - **Language**: Uses Langium for grammar parsing and AST generation - **Database Support**: SQLite (better-sqlite3) and PostgreSQL (pg) only ### Testing Strategy + - Runtime package has comprehensive client API tests and policy tests - CLI has action-specific tests for commands - E2E tests validate real-world schema compatibility (cal.com, formbricks, trigger.dev) - Type coverage tests ensure TypeScript inference works correctly ## Key Differences from Prisma + - No runtime dependency on @prisma/client - Pure TypeScript implementation without Rust/WASM - Built-in access control and validation (coming soon) @@ -67,7 +77,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - Schema-first approach with ZModel DSL extension of Prisma schema language ## Development Notes + - Always run `zenstack generate` after modifying ZModel schemas - Database migrations still use Prisma CLI under the hood - Plugin system allows interception at ORM, Kysely, and entity mutation levels -- Computed fields are evaluated at database level for performance \ No newline at end of file +- Computed fields are evaluated at database level for performance diff --git a/TODO.md b/TODO.md index 35c34c71..8303aa4b 100644 --- a/TODO.md +++ b/TODO.md @@ -10,6 +10,8 @@ - [x] validate - [ ] format - [ ] db seed +- [ ] ZModel + - [ ] View support - [ ] ORM - [x] Create - [x] Input validation @@ -56,13 +58,14 @@ - [x] Aggregate - [x] Group by - [x] Raw queries - - [ ] Transactions + - [x] Transactions - [x] Interactive transaction - [x] Sequential transaction - [ ] Extensions - [x] Query builder API - [x] Computed fields - [x] Prisma client extension + - [ ] Custom procedures - [ ] Misc - [x] JSDoc for CRUD methods - [x] Cache validation schemas @@ -71,7 +74,7 @@ - [x] Many-to-many relation - [ ] Empty AND/OR/NOT behavior - [?] Logging - - [ ] Error system + - [x] Error system - [x] Custom table name - [x] Custom field name - [ ] Strict undefined checks @@ -79,6 +82,8 @@ - [ ] Benchmark - [ ] Plugin - [ ] Post-mutation hooks should be called after transaction is committed +- [x] TypeDef and mixin +- [ ] Strongly typed JSON - [ ] Polymorphism - [ ] Validation - [ ] Access Policy diff --git a/packages/cli/src/actions/generate.ts b/packages/cli/src/actions/generate.ts index 1729bdf3..2a174cad 100644 --- a/packages/cli/src/actions/generate.ts +++ b/packages/cli/src/actions/generate.ts @@ -17,6 +17,8 @@ type Options = { * CLI action for generating code from schema */ export async function run(options: Options) { + const start = Date.now(); + const schemaFile = getSchemaFile(options.schema); const model = await loadSchemaDocument(schemaFile); @@ -40,7 +42,7 @@ export async function run(options: Options) { } if (!options.silent) { - console.log(colors.green('Generation completed successfully.')); + console.log(colors.green(`Generation completed successfully in ${Date.now() - start}ms.`)); console.log(`You can now create a ZenStack client with it. \`\`\`ts diff --git a/packages/cli/src/actions/validate.ts b/packages/cli/src/actions/validate.ts index c04f9b11..8ae9932c 100644 --- a/packages/cli/src/actions/validate.ts +++ b/packages/cli/src/actions/validate.ts @@ -19,4 +19,4 @@ export async function run(options: Options) { // Re-throw to maintain CLI exit code behavior throw error; } -} \ No newline at end of file +} diff --git a/packages/cli/test/ts-schema-gen.test.ts b/packages/cli/test/ts-schema-gen.test.ts index 48692d9a..2ec04048 100644 --- a/packages/cli/test/ts-schema-gen.test.ts +++ b/packages/cli/test/ts-schema-gen.test.ts @@ -181,4 +181,89 @@ model Post { }, }); }); + + it('merges fields and attributes from mixins', async () => { + const { schema } = await generateTsSchema(` +type Timestamped { + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +type Named { + name String + @@unique([name]) +} + +model User with Timestamped Named { + id String @id @default(uuid()) + email String @unique +} + `); + expect(schema).toMatchObject({ + models: { + User: { + fields: { + id: { type: 'String' }, + email: { type: 'String' }, + createdAt: { + type: 'DateTime', + default: expect.objectContaining({ function: 'now', kind: 'call' }), + }, + updatedAt: { type: 'DateTime', updatedAt: true }, + name: { type: 'String' }, + }, + uniqueFields: expect.objectContaining({ + name: { type: 'String' }, + }), + }, + }, + }); + }); + + it('generates type definitions', async () => { + const { schema } = await generateTsSchema(` +type Base { + name String + @@meta('foo', 'bar') +} + +type Address with Base { + street String + city String +} + `); + expect(schema).toMatchObject({ + typeDefs: { + Base: { + fields: { + name: { type: 'String' }, + }, + attributes: [ + { + name: '@@meta', + args: [ + { name: 'name', value: { kind: 'literal', value: 'foo' } }, + { name: 'value', value: { kind: 'literal', value: 'bar' } }, + ], + }, + ], + }, + Address: { + fields: { + street: { type: 'String' }, + city: { type: 'String' }, + }, + attributes: [ + { + name: '@@meta', + args: [ + { name: 'name', value: { kind: 'literal', value: 'foo' } }, + { name: 'value', value: { kind: 'literal', value: 'bar' } }, + ], + }, + ], + }, + }, + }); + }); }); diff --git a/packages/language/package.json b/packages/language/package.json index bb7da03f..84248adb 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -37,6 +37,16 @@ "default": "./dist/ast.cjs" } }, + "./utils": { + "import": { + "types": "./dist/utils.d.ts", + "default": "./dist/utils.js" + }, + "require": { + "types": "./dist/utils.d.cts", + "default": "./dist/utils.cjs" + } + }, "./package.json": { "import": "./package.json", "require": "./package.json" @@ -51,6 +61,7 @@ "@types/pluralize": "^0.0.33", "@zenstackhq/eslint-config": "workspace:*", "@zenstackhq/typescript-config": "workspace:*", + "@zenstackhq/common-helpers": "workspace:*", "langium-cli": "catalog:" }, "volta": { diff --git a/packages/language/res/stdlib.zmodel b/packages/language/res/stdlib.zmodel index f7841166..52f300bb 100644 --- a/packages/language/res/stdlib.zmodel +++ b/packages/language/res/stdlib.zmodel @@ -199,11 +199,6 @@ function currentOperation(casing: String?): String { */ attribute @@@targetField(_ targetField: AttributeTargetField[]) -/** - * Marks an attribute to be applicable to type defs and fields. - */ -attribute @@@supportTypeDef() - /** * Marks an attribute to be used for data validation. */ @@ -237,13 +232,13 @@ attribute @@@once() * @param sort: Allows you to specify in what order the entries of the ID are stored in the database. The available options are Asc and Desc. * @param clustered: Defines whether the ID is clustered or non-clustered. Defaults to true. */ -attribute @id(map: String?, length: Int?, sort: SortOrder?, clustered: Boolean?) @@@prisma @@@supportTypeDef @@@once +attribute @id(map: String?, length: Int?, sort: SortOrder?, clustered: Boolean?) @@@prisma @@@once /** * Defines a default value for a field. * @param value: An expression (e.g. 5, true, now(), auth()). */ -attribute @default(_ value: ContextType, map: String?) @@@prisma @@@supportTypeDef +attribute @default(_ value: ContextType, map: String?) @@@prisma /** * Defines a unique constraint for this field. @@ -264,7 +259,7 @@ attribute @unique(map: String?, length: Int?, sort: SortOrder?, clustered: Boole * @param sort: Allows you to specify in what order the entries of the ID are stored in the database. The available options are Asc and Desc. * @param clustered: Defines whether the ID is clustered or non-clustered. Defaults to true. */ -attribute @@id(_ fields: FieldReference[], name: String?, map: String?, length: Int?, sort: SortOrder?, clustered: Boolean?) @@@prisma +attribute @@id(_ fields: FieldReference[], name: String?, map: String?, length: Int?, sort: SortOrder?, clustered: Boolean?) @@@prisma @@@once /** * Defines a compound unique constraint for the specified fields. @@ -560,82 +555,82 @@ attribute @omit() /** * Validates length of a string field. */ -attribute @length(_ min: Int?, _ max: Int?, _ message: String?) @@@targetField([StringField]) @@@validation @@@supportTypeDef +attribute @length(_ min: Int?, _ max: Int?, _ message: String?) @@@targetField([StringField]) @@@validation /** * Validates a string field value starts with the given text. */ -attribute @startsWith(_ text: String, _ message: String?) @@@targetField([StringField]) @@@validation @@@supportTypeDef +attribute @startsWith(_ text: String, _ message: String?) @@@targetField([StringField]) @@@validation /** * Validates a string field value ends with the given text. */ -attribute @endsWith(_ text: String, _ message: String?) @@@targetField([StringField]) @@@validation @@@supportTypeDef +attribute @endsWith(_ text: String, _ message: String?) @@@targetField([StringField]) @@@validation /** * Validates a string field value contains the given text. */ -attribute @contains(_ text: String, _ message: String?) @@@targetField([StringField]) @@@validation @@@supportTypeDef +attribute @contains(_ text: String, _ message: String?) @@@targetField([StringField]) @@@validation /** * Validates a string field value matches a regex. */ -attribute @regex(_ regex: String, _ message: String?) @@@targetField([StringField]) @@@validation @@@supportTypeDef +attribute @regex(_ regex: String, _ message: String?) @@@targetField([StringField]) @@@validation /** * Validates a string field value is a valid email address. */ -attribute @email(_ message: String?) @@@targetField([StringField]) @@@validation @@@supportTypeDef +attribute @email(_ message: String?) @@@targetField([StringField]) @@@validation /** * Validates a string field value is a valid ISO datetime. */ -attribute @datetime(_ message: String?) @@@targetField([StringField]) @@@validation @@@supportTypeDef +attribute @datetime(_ message: String?) @@@targetField([StringField]) @@@validation /** * Validates a string field value is a valid url. */ -attribute @url(_ message: String?) @@@targetField([StringField]) @@@validation @@@supportTypeDef +attribute @url(_ message: String?) @@@targetField([StringField]) @@@validation /** * Trims whitespaces from the start and end of the string. */ -attribute @trim() @@@targetField([StringField]) @@@validation @@@supportTypeDef +attribute @trim() @@@targetField([StringField]) @@@validation /** * Transform entire string toLowerCase. */ -attribute @lower() @@@targetField([StringField]) @@@validation @@@supportTypeDef +attribute @lower() @@@targetField([StringField]) @@@validation /** * Transform entire string toUpperCase. */ -attribute @upper() @@@targetField([StringField]) @@@validation @@@supportTypeDef +attribute @upper() @@@targetField([StringField]) @@@validation /** * Validates a number field is greater than the given value. */ -attribute @gt(_ value: Int, _ message: String?) @@@targetField([IntField, FloatField, DecimalField]) @@@validation @@@supportTypeDef +attribute @gt(_ value: Int, _ message: String?) @@@targetField([IntField, FloatField, DecimalField]) @@@validation /** * Validates a number field is greater than or equal to the given value. */ -attribute @gte(_ value: Int, _ message: String?) @@@targetField([IntField, FloatField, DecimalField]) @@@validation @@@supportTypeDef +attribute @gte(_ value: Int, _ message: String?) @@@targetField([IntField, FloatField, DecimalField]) @@@validation /** * Validates a number field is less than the given value. */ -attribute @lt(_ value: Int, _ message: String?) @@@targetField([IntField, FloatField, DecimalField]) @@@validation @@@supportTypeDef +attribute @lt(_ value: Int, _ message: String?) @@@targetField([IntField, FloatField, DecimalField]) @@@validation /** * Validates a number field is less than or equal to the given value. */ -attribute @lte(_ value: Int, _ message: String?) @@@targetField([IntField, FloatField, DecimalField]) @@@validation @@@supportTypeDef +attribute @lte(_ value: Int, _ message: String?) @@@targetField([IntField, FloatField, DecimalField]) @@@validation /** * Validates the entity with a complex condition. */ -attribute @@validate(_ value: Boolean, _ message: String?, _ path: String[]?) @@@validation @@@supportTypeDef +attribute @@validate(_ value: Boolean, _ message: String?, _ path: String[]?) @@@validation /** * Validates length of a string field. @@ -706,7 +701,7 @@ function raw(value: String): Any { /** * Marks a field to be strong-typed JSON. */ -attribute @json() @@@targetField([TypeDefField]) +attribute @json() @@@targetField([TypeDefField]) @@@deprecated('The "@json" attribute is not needed anymore. ZenStack will automatically use JSON to store typed fields.') /** * Marks a field to be computed. @@ -723,4 +718,19 @@ function auth(): Any { * Used to specify the model for resolving `auth()` function call in access policies. A Zmodel * can have at most one model with this attribute. By default, the model named "User" is used. */ -attribute @@auth() @@@supportTypeDef +attribute @@auth() + +/** + * Attaches arbitrary metadata to a model or type def. + */ +attribute @@meta(_ name: String, _ value: Any) + +/** + * Attaches arbitrary metadata to a field. + */ +attribute @meta(_ name: String, _ value: Any) + +/** + * Marks an attribute as deprecated. + */ +attribute @@@deprecated(_ message: String) diff --git a/packages/language/src/ast.ts b/packages/language/src/ast.ts index 4ba343b7..c301eac4 100644 --- a/packages/language/src/ast.ts +++ b/packages/language/src/ast.ts @@ -46,7 +46,7 @@ declare module './ast' { $resolvedParam?: AttributeParam; } - interface DataModelField { + interface DataField { $inheritedFrom?: DataModel; } @@ -55,15 +55,10 @@ declare module './ast' { } export interface DataModel { - /** - * Indicates whether the model is already merged with the base types - */ - $baseMerged?: boolean; - /** * All fields including those marked with `@ignore` */ - $allFields?: DataModelField[]; + $allFields?: DataField[]; } } diff --git a/packages/language/src/generated/ast.ts b/packages/language/src/generated/ast.ts index 0555c16d..e759aa1f 100644 --- a/packages/language/src/generated/ast.ts +++ b/packages/language/src/generated/ast.ts @@ -77,6 +77,7 @@ export type ZModelKeywordNames = | "true" | "type" | "view" + | "with" | "{" | "||" | "}"; @@ -133,7 +134,7 @@ export function isLiteralExpr(item: unknown): item is LiteralExpr { return reflection.isInstance(item, LiteralExpr); } -export type MemberAccessTarget = DataModelField | TypeDefField; +export type MemberAccessTarget = DataField; export const MemberAccessTarget = 'MemberAccessTarget'; @@ -141,7 +142,7 @@ export function isMemberAccessTarget(item: unknown): item is MemberAccessTarget return reflection.isInstance(item, MemberAccessTarget); } -export type ReferenceTarget = DataModelField | EnumField | FunctionParam | TypeDefField; +export type ReferenceTarget = DataField | EnumField | FunctionParam; export const ReferenceTarget = 'ReferenceTarget'; @@ -169,14 +170,6 @@ export function isTypeDeclaration(item: unknown): item is TypeDeclaration { return reflection.isInstance(item, TypeDeclaration); } -export type TypeDefFieldTypes = Enum | TypeDef; - -export const TypeDefFieldTypes = 'TypeDefFieldTypes'; - -export function isTypeDefFieldTypes(item: unknown): item is TypeDefFieldTypes { - return reflection.isInstance(item, TypeDefFieldTypes); -} - export interface Argument extends langium.AstNode { readonly $container: InvocationExpr; readonly $type: 'Argument'; @@ -217,7 +210,7 @@ export function isAttribute(item: unknown): item is Attribute { } export interface AttributeArg extends langium.AstNode { - readonly $container: DataModelAttribute | DataModelFieldAttribute | InternalAttribute; + readonly $container: DataFieldAttribute | DataModelAttribute | InternalAttribute; readonly $type: 'AttributeArg'; name?: RegularID; value: Expression; @@ -337,79 +330,79 @@ export function isConfigInvocationExpr(item: unknown): item is ConfigInvocationE return reflection.isInstance(item, ConfigInvocationExpr); } -export interface DataModel extends langium.AstNode { - readonly $container: Model; - readonly $type: 'DataModel'; - attributes: Array; +export interface DataField extends langium.AstNode { + readonly $container: DataModel | TypeDef; + readonly $type: 'DataField'; + attributes: Array; comments: Array; - fields: Array; - isAbstract: boolean; - isView: boolean; - name: RegularID; - superTypes: Array>; + name: RegularIDWithTypeNames; + type: DataFieldType; } -export const DataModel = 'DataModel'; +export const DataField = 'DataField'; -export function isDataModel(item: unknown): item is DataModel { - return reflection.isInstance(item, DataModel); +export function isDataField(item: unknown): item is DataField { + return reflection.isInstance(item, DataField); } -export interface DataModelAttribute extends langium.AstNode { - readonly $container: DataModel | Enum | TypeDef; - readonly $type: 'DataModelAttribute'; +export interface DataFieldAttribute extends langium.AstNode { + readonly $container: DataField | EnumField; + readonly $type: 'DataFieldAttribute'; args: Array; decl: langium.Reference; } -export const DataModelAttribute = 'DataModelAttribute'; +export const DataFieldAttribute = 'DataFieldAttribute'; -export function isDataModelAttribute(item: unknown): item is DataModelAttribute { - return reflection.isInstance(item, DataModelAttribute); +export function isDataFieldAttribute(item: unknown): item is DataFieldAttribute { + return reflection.isInstance(item, DataFieldAttribute); } -export interface DataModelField extends langium.AstNode { - readonly $container: DataModel; - readonly $type: 'DataModelField'; - attributes: Array; - comments: Array; - name: RegularIDWithTypeNames; - type: DataModelFieldType; +export interface DataFieldType extends langium.AstNode { + readonly $container: DataField; + readonly $type: 'DataFieldType'; + array: boolean; + optional: boolean; + reference?: langium.Reference; + type?: BuiltinType; + unsupported?: UnsupportedFieldType; } -export const DataModelField = 'DataModelField'; +export const DataFieldType = 'DataFieldType'; -export function isDataModelField(item: unknown): item is DataModelField { - return reflection.isInstance(item, DataModelField); +export function isDataFieldType(item: unknown): item is DataFieldType { + return reflection.isInstance(item, DataFieldType); } -export interface DataModelFieldAttribute extends langium.AstNode { - readonly $container: DataModelField | EnumField | TypeDefField; - readonly $type: 'DataModelFieldAttribute'; - args: Array; - decl: langium.Reference; +export interface DataModel extends langium.AstNode { + readonly $container: Model; + readonly $type: 'DataModel'; + attributes: Array; + baseModel?: langium.Reference; + comments: Array; + fields: Array; + isView: boolean; + mixins: Array>; + name: RegularID; } -export const DataModelFieldAttribute = 'DataModelFieldAttribute'; +export const DataModel = 'DataModel'; -export function isDataModelFieldAttribute(item: unknown): item is DataModelFieldAttribute { - return reflection.isInstance(item, DataModelFieldAttribute); +export function isDataModel(item: unknown): item is DataModel { + return reflection.isInstance(item, DataModel); } -export interface DataModelFieldType extends langium.AstNode { - readonly $container: DataModelField; - readonly $type: 'DataModelFieldType'; - array: boolean; - optional: boolean; - reference?: langium.Reference; - type?: BuiltinType; - unsupported?: UnsupportedFieldType; +export interface DataModelAttribute extends langium.AstNode { + readonly $container: DataModel | Enum | TypeDef; + readonly $type: 'DataModelAttribute'; + args: Array; + decl: langium.Reference; } -export const DataModelFieldType = 'DataModelFieldType'; +export const DataModelAttribute = 'DataModelAttribute'; -export function isDataModelFieldType(item: unknown): item is DataModelFieldType { - return reflection.isInstance(item, DataModelFieldType); +export function isDataModelAttribute(item: unknown): item is DataModelAttribute { + return reflection.isInstance(item, DataModelAttribute); } export interface DataSource extends langium.AstNode { @@ -443,7 +436,7 @@ export function isEnum(item: unknown): item is Enum { export interface EnumField extends langium.AstNode { readonly $container: Enum; readonly $type: 'EnumField'; - attributes: Array; + attributes: Array; comments: Array; name: RegularIDWithTypeNames; } @@ -734,7 +727,8 @@ export interface TypeDef extends langium.AstNode { readonly $type: 'TypeDef'; attributes: Array; comments: Array; - fields: Array; + fields: Array; + mixins: Array>; name: RegularID; } @@ -744,36 +738,6 @@ export function isTypeDef(item: unknown): item is TypeDef { return reflection.isInstance(item, TypeDef); } -export interface TypeDefField extends langium.AstNode { - readonly $container: TypeDef; - readonly $type: 'TypeDefField'; - attributes: Array; - comments: Array; - name: RegularIDWithTypeNames; - type: TypeDefFieldType; -} - -export const TypeDefField = 'TypeDefField'; - -export function isTypeDefField(item: unknown): item is TypeDefField { - return reflection.isInstance(item, TypeDefField); -} - -export interface TypeDefFieldType extends langium.AstNode { - readonly $container: TypeDefField; - readonly $type: 'TypeDefFieldType'; - array: boolean; - optional: boolean; - reference?: langium.Reference; - type?: BuiltinType; -} - -export const TypeDefFieldType = 'TypeDefFieldType'; - -export function isTypeDefFieldType(item: unknown): item is TypeDefFieldType { - return reflection.isInstance(item, TypeDefFieldType); -} - export interface UnaryExpr extends langium.AstNode { readonly $container: Argument | ArrayExpr | AttributeArg | BinaryExpr | FieldInitializer | FunctionDecl | MemberAccessExpr | ReferenceArg | UnaryExpr; readonly $type: 'UnaryExpr'; @@ -788,7 +752,7 @@ export function isUnaryExpr(item: unknown): item is UnaryExpr { } export interface UnsupportedFieldType extends langium.AstNode { - readonly $container: DataModelFieldType; + readonly $container: DataFieldType; readonly $type: 'UnsupportedFieldType'; value: LiteralExpr; } @@ -814,11 +778,11 @@ export type ZModelAstType = { ConfigField: ConfigField ConfigInvocationArg: ConfigInvocationArg ConfigInvocationExpr: ConfigInvocationExpr + DataField: DataField + DataFieldAttribute: DataFieldAttribute + DataFieldType: DataFieldType DataModel: DataModel DataModelAttribute: DataModelAttribute - DataModelField: DataModelField - DataModelFieldAttribute: DataModelFieldAttribute - DataModelFieldType: DataModelFieldType DataSource: DataSource Enum: Enum EnumField: EnumField @@ -849,9 +813,6 @@ export type ZModelAstType = { ThisExpr: ThisExpr TypeDeclaration: TypeDeclaration TypeDef: TypeDef - TypeDefField: TypeDefField - TypeDefFieldType: TypeDefFieldType - TypeDefFieldTypes: TypeDefFieldTypes UnaryExpr: UnaryExpr UnsupportedFieldType: UnsupportedFieldType } @@ -859,7 +820,7 @@ export type ZModelAstType = { export class ZModelAstReflection extends langium.AbstractAstReflection { getAllTypes(): string[] { - return [AbstractDeclaration, Argument, ArrayExpr, Attribute, AttributeArg, AttributeParam, AttributeParamType, BinaryExpr, BooleanLiteral, ConfigArrayExpr, ConfigExpr, ConfigField, ConfigInvocationArg, ConfigInvocationExpr, DataModel, DataModelAttribute, DataModelField, DataModelFieldAttribute, DataModelFieldType, DataSource, Enum, EnumField, Expression, FieldInitializer, FunctionDecl, FunctionParam, FunctionParamType, GeneratorDecl, InternalAttribute, InvocationExpr, LiteralExpr, MemberAccessExpr, MemberAccessTarget, Model, ModelImport, NullExpr, NumberLiteral, ObjectExpr, Plugin, PluginField, Procedure, ProcedureParam, ReferenceArg, ReferenceExpr, ReferenceTarget, StringLiteral, ThisExpr, TypeDeclaration, TypeDef, TypeDefField, TypeDefFieldType, TypeDefFieldTypes, UnaryExpr, UnsupportedFieldType]; + return [AbstractDeclaration, Argument, ArrayExpr, Attribute, AttributeArg, AttributeParam, AttributeParamType, BinaryExpr, BooleanLiteral, ConfigArrayExpr, ConfigExpr, ConfigField, ConfigInvocationArg, ConfigInvocationExpr, DataField, DataFieldAttribute, DataFieldType, DataModel, DataModelAttribute, DataSource, Enum, EnumField, Expression, FieldInitializer, FunctionDecl, FunctionParam, FunctionParamType, GeneratorDecl, InternalAttribute, InvocationExpr, LiteralExpr, MemberAccessExpr, MemberAccessTarget, Model, ModelImport, NullExpr, NumberLiteral, ObjectExpr, Plugin, PluginField, Procedure, ProcedureParam, ReferenceArg, ReferenceExpr, ReferenceTarget, StringLiteral, ThisExpr, TypeDeclaration, TypeDef, UnaryExpr, UnsupportedFieldType]; } protected override computeIsSubtype(subtype: string, supertype: string): boolean { @@ -890,16 +851,13 @@ export class ZModelAstReflection extends langium.AbstractAstReflection { case ConfigArrayExpr: { return this.isSubtype(ConfigExpr, supertype); } - case DataModel: { - return this.isSubtype(AbstractDeclaration, supertype) || this.isSubtype(TypeDeclaration, supertype); - } - case DataModelField: - case TypeDefField: { + case DataField: { return this.isSubtype(MemberAccessTarget, supertype) || this.isSubtype(ReferenceTarget, supertype); } + case DataModel: case Enum: case TypeDef: { - return this.isSubtype(AbstractDeclaration, supertype) || this.isSubtype(TypeDeclaration, supertype) || this.isSubtype(TypeDefFieldTypes, supertype); + return this.isSubtype(AbstractDeclaration, supertype) || this.isSubtype(TypeDeclaration, supertype); } case EnumField: case FunctionParam: { @@ -919,18 +877,22 @@ export class ZModelAstReflection extends langium.AbstractAstReflection { const referenceId = `${refInfo.container.$type}:${refInfo.property}`; switch (referenceId) { case 'AttributeParamType:reference': - case 'DataModelFieldType:reference': + case 'DataFieldType:reference': case 'FunctionParamType:reference': { return TypeDeclaration; } - case 'DataModel:superTypes': { - return DataModel; - } + case 'DataFieldAttribute:decl': case 'DataModelAttribute:decl': - case 'DataModelFieldAttribute:decl': case 'InternalAttribute:decl': { return Attribute; } + case 'DataModel:baseModel': { + return DataModel; + } + case 'DataModel:mixins': + case 'TypeDef:mixins': { + return TypeDef; + } case 'InvocationExpr:function': { return FunctionDecl; } @@ -940,9 +902,6 @@ export class ZModelAstReflection extends langium.AbstractAstReflection { case 'ReferenceExpr:target': { return ReferenceTarget; } - case 'TypeDefFieldType:reference': { - return TypeDefFieldTypes; - } default: { throw new Error(`${referenceId} is not a valid reference id.`); } @@ -1063,58 +1022,58 @@ export class ZModelAstReflection extends langium.AbstractAstReflection { ] }; } - case DataModel: { + case DataField: { return { - name: DataModel, + name: DataField, properties: [ { name: 'attributes', defaultValue: [] }, { name: 'comments', defaultValue: [] }, - { name: 'fields', defaultValue: [] }, - { name: 'isAbstract', defaultValue: false }, - { name: 'isView', defaultValue: false }, { name: 'name' }, - { name: 'superTypes', defaultValue: [] } + { name: 'type' } ] }; } - case DataModelAttribute: { + case DataFieldAttribute: { return { - name: DataModelAttribute, + name: DataFieldAttribute, properties: [ { name: 'args', defaultValue: [] }, { name: 'decl' } ] }; } - case DataModelField: { + case DataFieldType: { return { - name: DataModelField, + name: DataFieldType, properties: [ - { name: 'attributes', defaultValue: [] }, - { name: 'comments', defaultValue: [] }, - { name: 'name' }, - { name: 'type' } + { name: 'array', defaultValue: false }, + { name: 'optional', defaultValue: false }, + { name: 'reference' }, + { name: 'type' }, + { name: 'unsupported' } ] }; } - case DataModelFieldAttribute: { + case DataModel: { return { - name: DataModelFieldAttribute, + name: DataModel, properties: [ - { name: 'args', defaultValue: [] }, - { name: 'decl' } + { name: 'attributes', defaultValue: [] }, + { name: 'baseModel' }, + { name: 'comments', defaultValue: [] }, + { name: 'fields', defaultValue: [] }, + { name: 'isView', defaultValue: false }, + { name: 'mixins', defaultValue: [] }, + { name: 'name' } ] }; } - case DataModelFieldType: { + case DataModelAttribute: { return { - name: DataModelFieldType, + name: DataModelAttribute, properties: [ - { name: 'array', defaultValue: false }, - { name: 'optional', defaultValue: false }, - { name: 'reference' }, - { name: 'type' }, - { name: 'unsupported' } + { name: 'args', defaultValue: [] }, + { name: 'decl' } ] }; } @@ -1347,32 +1306,11 @@ export class ZModelAstReflection extends langium.AbstractAstReflection { { name: 'attributes', defaultValue: [] }, { name: 'comments', defaultValue: [] }, { name: 'fields', defaultValue: [] }, + { name: 'mixins', defaultValue: [] }, { name: 'name' } ] }; } - case TypeDefField: { - return { - name: TypeDefField, - properties: [ - { name: 'attributes', defaultValue: [] }, - { name: 'comments', defaultValue: [] }, - { name: 'name' }, - { name: 'type' } - ] - }; - } - case TypeDefFieldType: { - return { - name: TypeDefFieldType, - properties: [ - { name: 'array', defaultValue: false }, - { name: 'optional', defaultValue: false }, - { name: 'reference' }, - { name: 'type' } - ] - }; - } case UnaryExpr: { return { name: UnaryExpr, diff --git a/packages/language/src/generated/grammar.ts b/packages/language/src/generated/grammar.ts index c2a61e5a..02260ccd 100644 --- a/packages/language/src/generated/grammar.ts +++ b/packages/language/src/generated/grammar.ts @@ -126,7 +126,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@40" + "$ref": "#/rules@42" }, "arguments": [] }, @@ -1920,16 +1920,6 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "Group", "elements": [ - { - "$type": "Assignment", - "feature": "isAbstract", - "operator": "?=", - "terminal": { - "$type": "Keyword", - "value": "abstract" - }, - "cardinality": "?" - }, { "$type": "Keyword", "value": "model" @@ -1947,45 +1937,59 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel } }, { - "$type": "Group", + "$type": "Alternatives", "elements": [ { - "$type": "Keyword", - "value": "extends" + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@38" + }, + "arguments": [] }, { - "$type": "Assignment", - "feature": "superTypes", - "operator": "+=", - "terminal": { - "$type": "CrossReference", - "type": { - "$ref": "#/rules@37" + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@39" + }, + "arguments": [] + }, + { + "$type": "Group", + "elements": [ + { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@39" + }, + "arguments": [] }, - "deprecatedSyntax": false - } + { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@38" + }, + "arguments": [] + } + ] }, { "$type": "Group", "elements": [ { - "$type": "Keyword", - "value": "," + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@38" + }, + "arguments": [] }, { - "$type": "Assignment", - "feature": "superTypes", - "operator": "+=", - "terminal": { - "$type": "CrossReference", - "type": { - "$ref": "#/rules@37" - }, - "deprecatedSyntax": false - } + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@39" + }, + "arguments": [] } - ], - "cardinality": "*" + ] } ], "cardinality": "?" @@ -2034,7 +2038,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@38" + "$ref": "#/rules@40" }, "arguments": [] } @@ -2069,7 +2073,92 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel }, { "$type": "ParserRule", - "name": "DataModelField", + "fragment": true, + "name": "WithClause", + "definition": { + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": "with" + }, + { + "$type": "Assignment", + "feature": "mixins", + "operator": "+=", + "terminal": { + "$type": "CrossReference", + "type": { + "$ref": "#/rules@42" + }, + "deprecatedSyntax": false + } + }, + { + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": ",", + "cardinality": "?" + }, + { + "$type": "Assignment", + "feature": "mixins", + "operator": "+=", + "terminal": { + "$type": "CrossReference", + "type": { + "$ref": "#/rules@42" + }, + "deprecatedSyntax": false + } + } + ], + "cardinality": "*" + } + ] + }, + "definesHiddenTokens": false, + "entry": false, + "hiddenTokens": [], + "parameters": [], + "wildcard": false + }, + { + "$type": "ParserRule", + "fragment": true, + "name": "ExtendsClause", + "definition": { + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": "extends" + }, + { + "$type": "Assignment", + "feature": "baseModel", + "operator": "=", + "terminal": { + "$type": "CrossReference", + "type": { + "$ref": "#/rules@37" + }, + "deprecatedSyntax": false + } + } + ] + }, + "definesHiddenTokens": false, + "entry": false, + "hiddenTokens": [], + "parameters": [], + "wildcard": false + }, + { + "$type": "ParserRule", + "name": "DataField", "definition": { "$type": "Group", "elements": [ @@ -2105,7 +2194,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@39" + "$ref": "#/rules@41" }, "arguments": [] } @@ -2134,7 +2223,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel }, { "$type": "ParserRule", - "name": "DataModelFieldType", + "name": "DataFieldType", "definition": { "$type": "Group", "elements": [ @@ -2172,7 +2261,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "CrossReference", "type": { - "$ref": "#/types@3" + "$ref": "#/types@2" }, "terminal": { "$type": "RuleCall", @@ -2259,6 +2348,14 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "arguments": [] } }, + { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@38" + }, + "arguments": [], + "cardinality": "?" + }, { "$type": "Keyword", "value": "{" @@ -2273,7 +2370,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@41" + "$ref": "#/rules@40" }, "arguments": [] } @@ -2306,151 +2403,6 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "parameters": [], "wildcard": false }, - { - "$type": "ParserRule", - "name": "TypeDefField", - "definition": { - "$type": "Group", - "elements": [ - { - "$type": "Assignment", - "feature": "comments", - "operator": "+=", - "terminal": { - "$type": "RuleCall", - "rule": { - "$ref": "#/rules@71" - }, - "arguments": [] - }, - "cardinality": "*" - }, - { - "$type": "Assignment", - "feature": "name", - "operator": "=", - "terminal": { - "$type": "RuleCall", - "rule": { - "$ref": "#/rules@52" - }, - "arguments": [] - } - }, - { - "$type": "Assignment", - "feature": "type", - "operator": "=", - "terminal": { - "$type": "RuleCall", - "rule": { - "$ref": "#/rules@42" - }, - "arguments": [] - } - }, - { - "$type": "Assignment", - "feature": "attributes", - "operator": "+=", - "terminal": { - "$type": "RuleCall", - "rule": { - "$ref": "#/rules@56" - }, - "arguments": [] - }, - "cardinality": "*" - } - ] - }, - "definesHiddenTokens": false, - "entry": false, - "fragment": false, - "hiddenTokens": [], - "parameters": [], - "wildcard": false - }, - { - "$type": "ParserRule", - "name": "TypeDefFieldType", - "definition": { - "$type": "Group", - "elements": [ - { - "$type": "Alternatives", - "elements": [ - { - "$type": "Assignment", - "feature": "type", - "operator": "=", - "terminal": { - "$type": "RuleCall", - "rule": { - "$ref": "#/rules@62" - }, - "arguments": [] - } - }, - { - "$type": "Assignment", - "feature": "reference", - "operator": "=", - "terminal": { - "$type": "CrossReference", - "type": { - "$ref": "#/types@2" - }, - "terminal": { - "$type": "RuleCall", - "rule": { - "$ref": "#/rules@51" - }, - "arguments": [] - }, - "deprecatedSyntax": false - } - } - ] - }, - { - "$type": "Group", - "elements": [ - { - "$type": "Assignment", - "feature": "array", - "operator": "?=", - "terminal": { - "$type": "Keyword", - "value": "[" - } - }, - { - "$type": "Keyword", - "value": "]" - } - ], - "cardinality": "?" - }, - { - "$type": "Assignment", - "feature": "optional", - "operator": "?=", - "terminal": { - "$type": "Keyword", - "value": "?" - }, - "cardinality": "?" - } - ] - }, - "definesHiddenTokens": false, - "entry": false, - "fragment": false, - "hiddenTokens": [], - "parameters": [], - "wildcard": false - }, { "$type": "ParserRule", "name": "UnsupportedFieldType", @@ -2851,7 +2803,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "CrossReference", "type": { - "$ref": "#/types@3" + "$ref": "#/types@2" }, "terminal": { "$type": "RuleCall", @@ -3465,7 +3417,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "CrossReference", "type": { - "$ref": "#/types@3" + "$ref": "#/types@2" }, "terminal": { "$type": "RuleCall", @@ -3519,7 +3471,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel }, { "$type": "ParserRule", - "name": "DataModelFieldAttribute", + "name": "DataFieldAttribute", "definition": { "$type": "Group", "elements": [ @@ -4036,13 +3988,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "SimpleType", "typeRef": { - "$ref": "#/rules@38" - } - }, - { - "$type": "SimpleType", - "typeRef": { - "$ref": "#/rules@41" + "$ref": "#/rules@40" } }, { @@ -4058,42 +4004,10 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "$type": "Type", "name": "MemberAccessTarget", "type": { - "$type": "UnionType", - "types": [ - { - "$type": "SimpleType", - "typeRef": { - "$ref": "#/rules@38" - } - }, - { - "$type": "SimpleType", - "typeRef": { - "$ref": "#/rules@41" - } - } - ] - } - }, - { - "$type": "Type", - "name": "TypeDefFieldTypes", - "type": { - "$type": "UnionType", - "types": [ - { - "$type": "SimpleType", - "typeRef": { - "$ref": "#/rules@40" - } - }, - { - "$type": "SimpleType", - "typeRef": { - "$ref": "#/rules@44" - } - } - ] + "$type": "SimpleType", + "typeRef": { + "$ref": "#/rules@40" + } } }, { @@ -4111,7 +4025,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "SimpleType", "typeRef": { - "$ref": "#/rules@40" + "$ref": "#/rules@42" } }, { diff --git a/packages/language/src/utils.ts b/packages/language/src/utils.ts index 224c89c3..f661bbc5 100644 --- a/packages/language/src/utils.ts +++ b/packages/language/src/utils.ts @@ -1,4 +1,6 @@ +import { invariant } from '@zenstackhq/common-helpers'; import { AstUtils, URI, type AstNode, type LangiumDocuments, type Reference } from 'langium'; +import fs from 'node:fs'; import path from 'path'; import { STD_LIB_MODULE_NAME, type ExpressionContext } from './constants'; import { @@ -7,8 +9,8 @@ import { isArrayExpr, isBinaryExpr, isConfigArrayExpr, + isDataField, isDataModel, - isDataModelField, isEnumField, isExpression, isInvocationExpr, @@ -19,32 +21,28 @@ import { isReferenceExpr, isStringLiteral, isTypeDef, - isTypeDefField, Model, ModelImport, ReferenceExpr, type Attribute, type AttributeParam, type BuiltinType, + type DataField, + type DataFieldAttribute, type DataModel, type DataModelAttribute, - type DataModelField, - type DataModelFieldAttribute, type Enum, type EnumField, type Expression, type ExpressionType, type FunctionDecl, type TypeDef, - type TypeDefField, } from './generated/ast'; -import fs from 'node:fs'; export type AttributeTarget = | DataModel | TypeDef - | DataModelField - | TypeDefField + | DataField | Enum | EnumField | FunctionDecl @@ -56,9 +54,7 @@ export function hasAttribute(decl: AttributeTarget, name: string) { } export function getAttribute(decl: AttributeTarget, name: string) { - return (decl.attributes as (DataModelAttribute | DataModelFieldAttribute)[]).find( - (attr) => attr.decl.$refText === name, - ); + return (decl.attributes as (DataModelAttribute | DataFieldAttribute)[]).find((attr) => attr.decl.$refText === name); } export function isFromStdlib(node: AstNode) { @@ -139,14 +135,14 @@ export function isEnumFieldReference(node: AstNode): node is ReferenceExpr { return isReferenceExpr(node) && isEnumField(node.target.ref); } -export function isDataModelFieldReference(node: AstNode): node is ReferenceExpr { - return isReferenceExpr(node) && isDataModelField(node.target.ref); +export function isDataFieldReference(node: AstNode): node is ReferenceExpr { + return isReferenceExpr(node) && isDataField(node.target.ref); } /** * Returns if the given field is a relation field. */ -export function isRelationshipField(field: DataModelField) { +export function isRelationshipField(field: DataField) { return isDataModel(field.type.reference?.ref); } @@ -165,42 +161,22 @@ export function resolved(ref: Reference): T { return ref.ref; } -/** - * Walk up the inheritance chain to find the path from the start model to the target model - */ -export function findUpInheritance(start: DataModel, target: DataModel): DataModel[] | undefined { - for (const base of start.superTypes) { - if (base.ref === target) { - return [base.ref]; - } - const path = findUpInheritance(base.ref as DataModel, target); - if (path) { - return [base.ref as DataModel, ...path]; - } - } - return undefined; -} - export function getModelFieldsWithBases(model: DataModel, includeDelegate = true) { - if (model.$baseMerged) { - return model.fields; - } else { - return [...model.fields, ...getRecursiveBases(model, includeDelegate).flatMap((base) => base.fields)]; - } + return [...model.fields, ...getRecursiveBases(model, includeDelegate).flatMap((base) => base.fields)]; } export function getRecursiveBases( - dataModel: DataModel, + decl: DataModel | TypeDef, includeDelegate = true, - seen = new Set(), -): DataModel[] { - const result: DataModel[] = []; - if (seen.has(dataModel)) { + seen = new Set(), +): (TypeDef | DataModel)[] { + const result: (TypeDef | DataModel)[] = []; + if (seen.has(decl)) { return result; } - seen.add(dataModel); - dataModel.superTypes.forEach((superType) => { - const baseDecl = superType.ref; + seen.add(decl); + decl.mixins.forEach((mixin) => { + const baseDecl = mixin.ref; if (baseDecl) { if (!includeDelegate && isDelegateModel(baseDecl)) { return; @@ -216,10 +192,11 @@ export function getRecursiveBases( * Gets `@@id` fields declared at the data model level (including search in base models) */ export function getModelIdFields(model: DataModel) { - const modelsToCheck = model.$baseMerged ? [model] : [model, ...getRecursiveBases(model)]; + const modelsToCheck = [model, ...getRecursiveBases(model)]; for (const modelToCheck of modelsToCheck) { - const idAttr = modelToCheck.attributes.find((attr) => attr.decl.$refText === '@@id'); + const allAttributes = getAllAttributes(modelToCheck); + const idAttr = allAttributes.find((attr) => attr.decl.$refText === '@@id'); if (!idAttr) { continue; } @@ -230,7 +207,7 @@ export function getModelIdFields(model: DataModel) { return fieldsArg.value.items .filter((item): item is ReferenceExpr => isReferenceExpr(item)) - .map((item) => resolved(item.target) as DataModelField); + .map((item) => resolved(item.target) as DataField); } return []; @@ -240,10 +217,11 @@ export function getModelIdFields(model: DataModel) { * Gets `@@unique` fields declared at the data model level (including search in base models) */ export function getModelUniqueFields(model: DataModel) { - const modelsToCheck = model.$baseMerged ? [model] : [model, ...getRecursiveBases(model)]; + const modelsToCheck = [model, ...getRecursiveBases(model)]; for (const modelToCheck of modelsToCheck) { - const uniqueAttr = modelToCheck.attributes.find((attr) => attr.decl.$refText === '@@unique'); + const allAttributes = getAllAttributes(modelToCheck); + const uniqueAttr = allAttributes.find((attr) => attr.decl.$refText === '@@unique'); if (!uniqueAttr) { continue; } @@ -254,7 +232,7 @@ export function getModelUniqueFields(model: DataModel) { return fieldsArg.value.items .filter((item): item is ReferenceExpr => isReferenceExpr(item)) - .map((item) => resolved(item.target) as DataModelField); + .map((item) => resolved(item.target) as DataField); } return []; @@ -277,7 +255,7 @@ export function getUniqueFields(model: DataModel) { return fieldsArg.value.items .filter((item): item is ReferenceExpr => isReferenceExpr(item)) - .map((item) => resolved(item.target) as DataModelField); + .map((item) => resolved(item.target) as DataField); }); } @@ -346,7 +324,7 @@ function getArray(expr: Expression | ConfigExpr | undefined) { } export function getAttributeArgLiteral( - attr: DataModelAttribute | DataModelFieldAttribute, + attr: DataModelAttribute | DataFieldAttribute, name: string, ): T | undefined { for (const arg of attr.args) { @@ -373,10 +351,10 @@ export function getFunctionExpressionContext(funcDecl: FunctionDecl) { return funcAllowedContext; } -export function getFieldReference(expr: Expression): DataModelField | TypeDefField | undefined { - if (isReferenceExpr(expr) && (isDataModelField(expr.target.ref) || isTypeDefField(expr.target.ref))) { +export function getFieldReference(expr: Expression): DataField | undefined { + if (isReferenceExpr(expr) && isDataField(expr.target.ref)) { return expr.target.ref; - } else if (isMemberAccessExpr(expr) && (isDataModelField(expr.member.ref) || isTypeDefField(expr.member.ref))) { + } else if (isMemberAccessExpr(expr) && isDataField(expr.member.ref)) { return expr.member.ref; } else { return undefined; @@ -554,3 +532,23 @@ export function getContainingDataModel(node: Expression): DataModel | undefined export function isMemberContainer(node: unknown): node is DataModel | TypeDef { return isDataModel(node) || isTypeDef(node); } + +export function getAllFields(decl: DataModel | TypeDef, includeIgnored = false): DataField[] { + const fields: DataField[] = []; + for (const mixin of decl.mixins) { + invariant(mixin.ref, `Mixin ${mixin.$refText} is not resolved`); + fields.push(...getAllFields(mixin.ref)); + } + fields.push(...decl.fields.filter((f) => includeIgnored || !hasAttribute(f, '@ignore'))); + return fields; +} + +export function getAllAttributes(decl: DataModel | TypeDef): DataModelAttribute[] { + const attributes: DataModelAttribute[] = []; + for (const mixin of decl.mixins) { + invariant(mixin.ref, `Mixin ${mixin.$refText} is not resolved`); + attributes.push(...getAllAttributes(mixin.ref)); + } + attributes.push(...decl.attributes); + return attributes; +} diff --git a/packages/language/src/validators/attribute-application-validator.ts b/packages/language/src/validators/attribute-application-validator.ts index df6f5334..c04666df 100644 --- a/packages/language/src/validators/attribute-application-validator.ts +++ b/packages/language/src/validators/attribute-application-validator.ts @@ -6,23 +6,23 @@ import { AttributeArg, AttributeParam, DataModelAttribute, - DataModelField, - DataModelFieldAttribute, + DataField, + DataFieldAttribute, InternalAttribute, ReferenceExpr, isArrayExpr, isAttribute, isDataModel, - isDataModelField, + isDataField, isEnum, isReferenceExpr, isTypeDef, - isTypeDefField, } from '../generated/ast'; import { + getAllAttributes, getStringLiteral, hasAttribute, - isDataModelFieldReference, + isDataFieldReference, isDelegateModel, isFutureExpr, isRelationshipField, @@ -31,6 +31,7 @@ import { typeAssignable, } from '../utils'; import type { AstValidator } from './common'; +import type { DataModel } from '../ast'; // a registry of function handlers marked with @check const attributeCheckers = new Map(); @@ -45,13 +46,13 @@ function check(name: string) { }; } -type AttributeApplication = DataModelAttribute | DataModelFieldAttribute | InternalAttribute; +type AttributeApplication = DataModelAttribute | DataFieldAttribute | InternalAttribute; /** * Validates function declarations. */ export default class AttributeApplicationValidator implements AstValidator { - validate(attr: AttributeApplication, accept: ValidationAcceptor) { + validate(attr: AttributeApplication, accept: ValidationAcceptor, contextDataModel?: DataModel) { const decl = attr.decl.ref; if (!decl) { return; @@ -63,19 +64,12 @@ export default class AttributeApplicationValidator implements AstValidator(); @@ -133,13 +127,27 @@ export default class AttributeApplicationValidator implements AstValidator a.decl.ref?.name === '@@@deprecated'); + if (deprecateAttr) { + const message = + getStringLiteral(deprecateAttr.args[0]?.value) ?? `Attribute "${attr.decl.ref?.name}" is deprecated`; + accept('warning', message, { node: attr }); + } + } + + private checkDuplicatedAttributes( + attr: AttributeApplication, + accept: ValidationAcceptor, + contextDataModel?: DataModel, + ) { const attrDecl = attr.decl.ref; if (!attrDecl?.attributes.some((a) => a.decl.ref?.name === '@@@once')) { return; } - const duplicates = attr.$container.attributes.filter((a) => a.decl.ref === attrDecl && a !== attr); + const allAttributes = contextDataModel ? getAllAttributes(contextDataModel) : attr.$container.attributes; + const duplicates = allAttributes.filter((a) => a.decl.ref === attrDecl && a !== attr); if (duplicates.length > 0) { accept('error', `Attribute "${attrDecl.name}" can only be applied once`, { node: attr }); } @@ -182,7 +190,7 @@ export default class AttributeApplicationValidator implements AstValidator isDataModelFieldReference(node) && isDataModel(node.$resolvedType?.decl), + (node) => isDataFieldReference(node) && isDataModel(node.$resolvedType?.decl), ) ) { accept('error', `\`@@validate\` condition cannot use relation fields`, { node: condition }); @@ -233,7 +241,7 @@ export default class AttributeApplicationValidator implements AstValidator { - if (isDataModelFieldReference(node) && hasAttribute(node.target.ref as DataModelField, '@encrypted')) { + if (isDataFieldReference(node) && hasAttribute(node.target.ref as DataField, '@encrypted')) { accept('error', `Encrypted fields cannot be used in policy rules`, { node }); } }); @@ -292,7 +300,7 @@ function assignableToAttributeParam(arg: AttributeArg, param: AttributeParam, at if (dstType === 'ContextType') { // ContextType is inferred from the attribute's container's type - if (isDataModelField(attr.$container)) { + if (isDataField(attr.$container)) { dstIsArray = attr.$container.type.array; } } @@ -320,10 +328,10 @@ function assignableToAttributeParam(arg: AttributeArg, param: AttributeParam, at if (dstIsArray) { return ( isArrayExpr(arg.value) && - !arg.value.items.find((item) => !isReferenceExpr(item) || !isDataModelField(item.target.ref)) + !arg.value.items.find((item) => !isReferenceExpr(item) || !isDataField(item.target.ref)) ); } else { - return isReferenceExpr(arg.value) && isDataModelField(arg.value.target.ref); + return isReferenceExpr(arg.value) && isDataField(arg.value.target.ref); } } @@ -331,7 +339,7 @@ function assignableToAttributeParam(arg: AttributeArg, param: AttributeParam, at // enum type let attrArgDeclType = dstRef?.ref; - if (dstType === 'ContextType' && isDataModelField(attr.$container) && attr.$container?.type?.reference) { + if (dstType === 'ContextType' && isDataField(attr.$container) && attr.$container?.type?.reference) { // attribute parameter type is ContextType, need to infer type from // the attribute's container attrArgDeclType = resolved(attr.$container.type.reference); @@ -349,7 +357,7 @@ function assignableToAttributeParam(arg: AttributeArg, param: AttributeParam, at if (dstType === 'ContextType') { // attribute parameter type is ContextType, need to infer type from // the attribute's container - if (isDataModelField(attr.$container)) { + if (isDataField(attr.$container)) { if (!attr.$container?.type?.type) { return false; } @@ -367,7 +375,7 @@ function assignableToAttributeParam(arg: AttributeArg, param: AttributeParam, at } } -function isValidAttributeTarget(attrDecl: Attribute, targetDecl: DataModelField) { +function isValidAttributeTarget(attrDecl: Attribute, targetDecl: DataField) { const targetField = attrDecl.attributes.find((attr) => attr.decl.ref?.name === '@@@targetField'); if (!targetField?.args[0]) { // no field type constraint @@ -425,6 +433,10 @@ function isValidAttributeTarget(attrDecl: Attribute, targetDecl: DataModelField) return allowed; } -export function validateAttributeApplication(attr: AttributeApplication, accept: ValidationAcceptor) { - new AttributeApplicationValidator().validate(attr, accept); +export function validateAttributeApplication( + attr: AttributeApplication, + accept: ValidationAcceptor, + contextDataModel?: DataModel, +) { + new AttributeApplicationValidator().validate(attr, accept, contextDataModel); } diff --git a/packages/language/src/validators/common.ts b/packages/language/src/validators/common.ts index 76bd41cc..257efc79 100644 --- a/packages/language/src/validators/common.ts +++ b/packages/language/src/validators/common.ts @@ -1,5 +1,5 @@ import type { AstNode, MaybePromise, ValidationAcceptor } from 'langium'; -import { isDataModelField } from '../generated/ast'; +import { isDataField } from '../generated/ast'; /** * AST validator contract @@ -28,8 +28,8 @@ export function validateDuplicatedDeclarations( for (const [name, decls] of Object.entries(groupByName)) { if (decls.length > 1) { let errorField = decls[1]!; - if (isDataModelField(decls[0])) { - const nonInheritedFields = decls.filter((x) => !(isDataModelField(x) && x.$container !== container)); + if (isDataField(decls[0])) { + const nonInheritedFields = decls.filter((x) => !(isDataField(x) && x.$container !== container)); if (nonInheritedFields.length > 0) { errorField = nonInheritedFields.slice(-1)[0]!; } diff --git a/packages/language/src/validators/datamodel-validator.ts b/packages/language/src/validators/datamodel-validator.ts index f163d2a3..5d7f9919 100644 --- a/packages/language/src/validators/datamodel-validator.ts +++ b/packages/language/src/validators/datamodel-validator.ts @@ -2,10 +2,11 @@ import { AstUtils, type AstNode, type DiagnosticInfo, type ValidationAcceptor } import { IssueCodes, SCALAR_TYPES } from '../constants'; import { ArrayExpr, + DataField, DataModel, - DataModelField, Model, ReferenceExpr, + TypeDef, isDataModel, isDataSource, isEnum, @@ -14,7 +15,7 @@ import { isTypeDef, } from '../generated/ast'; import { - findUpInheritance, + getAllAttributes, getLiteral, getModelFieldsWithBases, getModelIdFields, @@ -31,15 +32,13 @@ import { validateDuplicatedDeclarations, type AstValidator } from './common'; */ export default class DataModelValidator implements AstValidator { validate(dm: DataModel, accept: ValidationAcceptor): void { - this.validateBaseAbstractModel(dm, accept); - this.validateBaseDelegateModel(dm, accept); validateDuplicatedDeclarations(dm, getModelFieldsWithBases(dm), accept); this.validateAttributes(dm, accept); this.validateFields(dm, accept); - - if (dm.superTypes.length > 0) { - this.validateInheritance(dm, accept); + if (dm.mixins.length > 0) { + this.validateMixins(dm, accept); } + this.validateInherits(dm, accept); } private validateFields(dm: DataModel, accept: ValidationAcceptor) { @@ -50,7 +49,6 @@ export default class DataModelValidator implements AstValidator { const modelUniqueFields = getModelUniqueFields(dm); if ( - !dm.isAbstract && idFields.length === 0 && modelLevelIds.length === 0 && uniqueFields.length === 0 && @@ -89,17 +87,14 @@ export default class DataModelValidator implements AstValidator { } dm.fields.forEach((field) => this.validateField(field, accept)); - - if (!dm.isAbstract) { - allFields - .filter((x) => isDataModel(x.type.reference?.ref)) - .forEach((y) => { - this.validateRelationField(dm, y, accept); - }); - } + allFields + .filter((x) => isDataModel(x.type.reference?.ref)) + .forEach((y) => { + this.validateRelationField(dm, y, accept); + }); } - private validateField(field: DataModelField, accept: ValidationAcceptor): void { + private validateField(field: DataField, accept: ValidationAcceptor): void { if (field.type.array && field.type.optional) { accept('error', 'Optional lists are not supported. Use either `Type[]` or `Type?`', { node: field.type }); } @@ -137,10 +132,10 @@ export default class DataModelValidator implements AstValidator { } private validateAttributes(dm: DataModel, accept: ValidationAcceptor) { - dm.attributes.forEach((attr) => validateAttributeApplication(attr, accept)); + getAllAttributes(dm).forEach((attr) => validateAttributeApplication(attr, accept, dm)); } - private parseRelation(field: DataModelField, accept?: ValidationAcceptor) { + private parseRelation(field: DataField, accept?: ValidationAcceptor) { const relAttr = field.attributes.find((attr) => attr.decl.ref?.name === '@relation'); let name: string | undefined; @@ -244,17 +239,17 @@ export default class DataModelValidator implements AstValidator { return { attr: relAttr, name, fields, references, valid }; } - private isSelfRelation(field: DataModelField) { + private isSelfRelation(field: DataField) { return field.type.reference?.ref === field.$container; } - private validateRelationField(contextModel: DataModel, field: DataModelField, accept: ValidationAcceptor) { + private validateRelationField(contextModel: DataModel, field: DataField, accept: ValidationAcceptor) { const thisRelation = this.parseRelation(field, accept); if (!thisRelation.valid) { return; } - if (this.isFieldInheritedFromDelegateModel(field, contextModel)) { + if (this.isFieldInheritedFromDelegateModel(field)) { // relation fields inherited from delegate model don't need opposite relation return; } @@ -331,7 +326,7 @@ export default class DataModelValidator implements AstValidator { const oppositeField = oppositeFields[0]!; const oppositeRelation = this.parseRelation(oppositeField); - let relationOwner: DataModelField; + let relationOwner: DataField; if (field.type.array && oppositeField.type.array) { // if both the field is array, then it's an implicit many-to-many relation, @@ -411,7 +406,7 @@ export default class DataModelValidator implements AstValidator { } thisRelation.fields?.forEach((ref) => { - const refField = ref.target.ref as DataModelField; + const refField = ref.target.ref as DataField; if (refField) { if ( refField.attributes.find( @@ -435,59 +430,35 @@ export default class DataModelValidator implements AstValidator { } // checks if the given field is inherited directly or indirectly from a delegate model - private isFieldInheritedFromDelegateModel(field: DataModelField, contextModel: DataModel) { - const basePath = findUpInheritance(contextModel, field.$container as DataModel); - if (basePath && basePath.some(isDelegateModel)) { - return true; - } else { - return false; - } + private isFieldInheritedFromDelegateModel(field: DataField) { + return isDelegateModel(field.$container); } - private validateBaseAbstractModel(model: DataModel, accept: ValidationAcceptor) { - model.superTypes.forEach((superType, index) => { - if ( - !superType.ref?.isAbstract && - !superType.ref?.attributes.some((attr) => attr.decl.ref?.name === '@@delegate') - ) - accept( - 'error', - `Model ${superType.$refText} cannot be extended because it's neither abstract nor marked as "@@delegate"`, - { - node: model, - property: 'superTypes', - index, - }, - ); - }); - } - - private validateBaseDelegateModel(model: DataModel, accept: ValidationAcceptor) { - if (model.superTypes.filter((base) => base.ref && isDelegateModel(base.ref)).length > 1) { - accept('error', 'Extending from multiple delegate models is not supported', { + private validateInherits(model: DataModel, accept: ValidationAcceptor) { + if (!model.baseModel) { + return; + } + if (model.baseModel.ref && !isDelegateModel(model.baseModel.ref)) { + accept('error', `Model ${model.baseModel.$refText} cannot be extended because it's not a delegate model`, { node: model, - property: 'superTypes', + property: 'baseModel', }); } } - private validateInheritance(dm: DataModel, accept: ValidationAcceptor) { - const seen = [dm]; - const todo: DataModel[] = dm.superTypes.map((superType) => superType.ref!); + private validateMixins(dm: DataModel, accept: ValidationAcceptor) { + const seen: TypeDef[] = []; + const todo: TypeDef[] = dm.mixins.map((mixin) => mixin.ref!); while (todo.length > 0) { const current = todo.shift()!; if (seen.includes(current)) { - accept( - 'error', - `Circular inheritance detected: ${seen.map((m) => m.name).join(' -> ')} -> ${current.name}`, - { - node: dm, - }, - ); + accept('error', `Cyclic mixin detected: ${seen.map((m) => m.name).join(' -> ')} -> ${current.name}`, { + node: dm, + }); return; } seen.push(current); - todo.push(...current.superTypes.map((superType) => superType.ref!)); + todo.push(...current.mixins.map((mixin) => mixin.ref!)); } } } diff --git a/packages/language/src/validators/expression-validator.ts b/packages/language/src/validators/expression-validator.ts index 362aed2d..28c15fc6 100644 --- a/packages/language/src/validators/expression-validator.ts +++ b/packages/language/src/validators/expression-validator.ts @@ -18,7 +18,7 @@ import { findUpAst, isAuthInvocation, isAuthOrAuthMemberAccess, - isDataModelFieldReference, + isDataFieldReference, isEnumFieldReference, typeAssignable, } from '../utils'; @@ -149,8 +149,8 @@ export default class ExpressionValidator implements AstValidator { // in validation context, all fields are optional, so we should allow // comparing any field against null if ( - (isDataModelFieldReference(expr.left) && isNullExpr(expr.right)) || - (isDataModelFieldReference(expr.right) && isNullExpr(expr.left)) + (isDataFieldReference(expr.left) && isNullExpr(expr.right)) || + (isDataFieldReference(expr.right) && isNullExpr(expr.left)) ) { return; } @@ -204,13 +204,13 @@ export default class ExpressionValidator implements AstValidator { // - foo == bar // - foo == this if ( - isDataModelFieldReference(expr.left) && - (isThisExpr(expr.right) || isDataModelFieldReference(expr.right)) + isDataFieldReference(expr.left) && + (isThisExpr(expr.right) || isDataFieldReference(expr.right)) ) { accept('error', 'comparison between model-typed fields are not supported', { node: expr }); } else if ( - isDataModelFieldReference(expr.right) && - (isThisExpr(expr.left) || isDataModelFieldReference(expr.left)) + isDataFieldReference(expr.right) && + (isThisExpr(expr.left) || isDataFieldReference(expr.left)) ) { accept('error', 'comparison between model-typed fields are not supported', { node: expr }); } diff --git a/packages/language/src/validators/function-invocation-validator.ts b/packages/language/src/validators/function-invocation-validator.ts index ae59f5fd..b640ad1b 100644 --- a/packages/language/src/validators/function-invocation-validator.ts +++ b/packages/language/src/validators/function-invocation-validator.ts @@ -3,22 +3,22 @@ import { match, P } from 'ts-pattern'; import { ExpressionContext } from '../constants'; import { Argument, + DataFieldAttribute, DataModel, DataModelAttribute, - DataModelFieldAttribute, Expression, FunctionDecl, FunctionParam, InvocationExpr, + isDataFieldAttribute, isDataModel, isDataModelAttribute, - isDataModelFieldAttribute, } from '../generated/ast'; import { getFunctionExpressionContext, getLiteral, isCheckInvocation, - isDataModelFieldReference, + isDataFieldReference, isFromStdlib, typeAssignable, } from '../utils'; @@ -56,9 +56,9 @@ export default class FunctionInvocationValidator implements AstValidator, - extraScopes: ScopeProvider[], - ) { + private resolveDataField(node: DataField, document: LangiumDocument, extraScopes: ScopeProvider[]) { // Field declaration may contain enum references, and enum fields are pushed to the global // scope, so if there're enums with fields with the same name, an arbitrary one will be // used as resolution target. The correct behavior is to resolve to the enum that's used @@ -518,9 +512,9 @@ export class ZModelLinker extends DefaultLinker { //#region Utils - private resolveToDeclaredType(node: AstNode, type: FunctionParamType | DataModelFieldType | TypeDefFieldType) { + private resolveToDeclaredType(node: AstNode, type: FunctionParamType | DataFieldType) { let nullable = false; - if (isDataModelFieldType(type) || isTypeDefField(type)) { + if (isDataFieldType(type)) { nullable = type.optional; // referencing a field of 'Unsupported' type diff --git a/packages/language/src/zmodel-scope.ts b/packages/language/src/zmodel-scope.ts index c5c2968f..e95ac0b7 100644 --- a/packages/language/src/zmodel-scope.ts +++ b/packages/language/src/zmodel-scope.ts @@ -20,7 +20,7 @@ import { BinaryExpr, MemberAccessExpr, isDataModel, - isDataModelField, + isDataField, isEnumField, isInvocationExpr, isMemberAccessExpr, @@ -28,13 +28,13 @@ import { isReferenceExpr, isThisExpr, isTypeDef, - isTypeDefField, } from './ast'; import { PLUGIN_MODULE_NAME, STD_LIB_MODULE_NAME } from './constants'; import { getAllLoadedAndReachableDataModelsAndTypeDefs, getAuthDecl, getModelFieldsWithBases, + getRecursiveBases, isAuthInvocation, isCollectionPredicate, isFutureInvocation, @@ -75,22 +75,15 @@ export class ZModelScopeComputation extends DefaultScopeComputation { override processNode(node: AstNode, document: LangiumDocument, scopes: PrecomputedScopes) { super.processNode(node, document, scopes); - // TODO: merge base - // if (isDataModel(node) && !node.$baseMerged) { - // // add base fields to the scope recursively - // const bases = getRecursiveBases(node); - // for (const base of bases) { - // for (const field of base.fields) { - // scopes.add( - // node, - // this.descriptions.createDescription( - // field, - // this.nameProvider.getName(field) - // ) - // ); - // } - // } - // } + if (isDataModel(node)) { + // add base fields to the scope recursively + const bases = getRecursiveBases(node); + for (const base of bases) { + for (const field of base.fields) { + scopes.add(node, this.descriptions.createDescription(field, this.nameProvider.getName(field))); + } + } + } } } @@ -152,7 +145,7 @@ export class ZModelScopeProvider extends DefaultScopeProvider { .when(isReferenceExpr, (operand) => { // operand is a reference, it can only be a model/type-def field const ref = operand.target.ref; - if (isDataModelField(ref) || isTypeDefField(ref)) { + if (isDataField(ref)) { return this.createScopeForContainer(ref.type.reference?.ref, globalScope, allowTypeDefScope); } return EMPTY_SCOPE; @@ -160,10 +153,7 @@ export class ZModelScopeProvider extends DefaultScopeProvider { .when(isMemberAccessExpr, (operand) => { // operand is a member access, it must be resolved to a non-array model/typedef type const ref = operand.member.ref; - if (isDataModelField(ref) && !ref.type.array) { - return this.createScopeForContainer(ref.type.reference?.ref, globalScope, allowTypeDefScope); - } - if (isTypeDefField(ref) && !ref.type.array) { + if (isDataField(ref) && !ref.type.array) { return this.createScopeForContainer(ref.type.reference?.ref, globalScope, allowTypeDefScope); } return EMPTY_SCOPE; @@ -203,7 +193,7 @@ export class ZModelScopeProvider extends DefaultScopeProvider { .when(isReferenceExpr, (expr) => { // collection is a reference - model or typedef field const ref = expr.target.ref; - if (isDataModelField(ref) || isTypeDefField(ref)) { + if (isDataField(ref)) { return this.createScopeForContainer(ref.type.reference?.ref, globalScope, allowTypeDefScope); } return EMPTY_SCOPE; @@ -211,7 +201,7 @@ export class ZModelScopeProvider extends DefaultScopeProvider { .when(isMemberAccessExpr, (expr) => { // collection is a member access, it can only be resolved to a model or typedef field const ref = expr.member.ref; - if (isDataModelField(ref) || isTypeDefField(ref)) { + if (isDataField(ref)) { return this.createScopeForContainer(ref.type.reference?.ref, globalScope, allowTypeDefScope); } return EMPTY_SCOPE; diff --git a/packages/language/src/zmodel-workspace-manager.ts b/packages/language/src/zmodel-workspace-manager.ts index f21db797..7b21b56b 100644 --- a/packages/language/src/zmodel-workspace-manager.ts +++ b/packages/language/src/zmodel-workspace-manager.ts @@ -1,6 +1,7 @@ import { DefaultWorkspaceManager, URI, + UriUtils, type AstNode, type LangiumDocument, type LangiumDocumentFactory, @@ -10,7 +11,9 @@ import type { LangiumSharedServices } from 'langium/lsp'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { STD_LIB_MODULE_NAME } from './constants'; +import { isPlugin, type Model } from './ast'; +import { PLUGIN_MODULE_NAME, STD_LIB_MODULE_NAME } from './constants'; +import { getLiteral } from './utils'; export class ZModelWorkspaceManager extends DefaultWorkspaceManager { private documentFactory: LangiumDocumentFactory; @@ -68,5 +71,87 @@ export class ZModelWorkspaceManager extends DefaultWorkspaceManager { const stdlib = await this.documentFactory.fromUri(URI.file(stdLibPath)); collector(stdlib); + + const documents = this.langiumDocuments.all; + const pluginModels = new Set(); + + // find plugin models + documents.forEach((doc) => { + const parsed = doc.parseResult.value as Model; + parsed.declarations.forEach((decl) => { + if (isPlugin(decl)) { + const providerField = decl.fields.find((f) => f.name === 'provider'); + if (providerField) { + const provider = getLiteral(providerField.value); + if (provider) { + pluginModels.add(provider); + } + } + } + }); + }); + + if (pluginModels.size > 0) { + console.log(`Used plugin modules: ${Array.from(pluginModels)}`); + + // the loaded plugin models would be removed from the set + const pendingPluginModules = new Set(pluginModels); + + await Promise.all( + folders + .map((wf) => [wf, this.getRootFolder(wf)] as [WorkspaceFolder, URI]) + .map(async (entry) => this.loadPluginModels(...entry, pendingPluginModules, collector)), + ); + } + } + + protected async loadPluginModels( + workspaceFolder: WorkspaceFolder, + folderPath: URI, + pendingPluginModels: Set, + collector: (document: LangiumDocument) => void, + ): Promise { + const content = (await this.fileSystemProvider.readDirectory(folderPath)).sort((a, b) => { + // make sure the node_modules folder is always the first one to be checked + // so we can exit early if the plugin is found + if (a.isDirectory && b.isDirectory) { + const aName = UriUtils.basename(a.uri); + if (aName === 'node_modules') { + return -1; + } else { + return 1; + } + } else { + return 0; + } + }); + + for (const entry of content) { + if (entry.isDirectory) { + const name = UriUtils.basename(entry.uri); + if (name === 'node_modules') { + for (const plugin of Array.from(pendingPluginModels)) { + const path = UriUtils.joinPath(entry.uri, plugin, PLUGIN_MODULE_NAME); + try { + await this.fileSystemProvider.readFile(path); + const document = await this.langiumDocuments.getOrCreateDocument(path); + collector(document); + console.log(`Adding plugin document from ${path.path}`); + + pendingPluginModels.delete(plugin); + // early exit if all plugins are loaded + if (pendingPluginModels.size === 0) { + return; + } + } catch { + // no-op. The module might be found in another node_modules folder + // will show the warning message eventually if not found + } + } + } else { + await this.loadPluginModels(workspaceFolder, entry.uri, pendingPluginModels, collector); + } + } + } } } diff --git a/packages/language/src/zmodel.langium b/packages/language/src/zmodel.langium index 5f2bca5e..8d279787 100644 --- a/packages/language/src/zmodel.langium +++ b/packages/language/src/zmodel.langium @@ -66,7 +66,8 @@ ConfigArrayExpr: ConfigExpr: LiteralExpr | InvocationExpr | ConfigArrayExpr; -type ReferenceTarget = FunctionParam | DataModelField | TypeDefField | EnumField; +type ReferenceTarget = FunctionParam | DataField | EnumField; + ThisExpr: value='this'; @@ -94,7 +95,7 @@ FieldInitializer: InvocationExpr: function=[FunctionDecl] '(' ArgumentList? ')'; -type MemberAccessTarget = DataModelField | TypeDefField; +type MemberAccessTarget = DataField; MemberAccessExpr infers Expression: PrimaryExpr ( @@ -164,41 +165,37 @@ Argument: DataModel: (comments+=TRIPLE_SLASH_COMMENT)* ( - ((isAbstract?='abstract')? 'model' name=RegularID - ('extends' superTypes+=[DataModel] (',' superTypes+=[DataModel])*)?) | + ('model' name=RegularID (WithClause | ExtendsClause | (ExtendsClause WithClause) | (WithClause ExtendsClause))?) | ((isView?='view') name=RegularID) ) '{' ( - fields+=DataModelField + fields+=DataField | attributes+=DataModelAttribute )* '}'; -DataModelField: +fragment WithClause: + 'with' mixins+=[TypeDef] (','? mixins+=[TypeDef])*; + +fragment ExtendsClause: + 'extends' baseModel=[DataModel]; + +DataField: (comments+=TRIPLE_SLASH_COMMENT)* - name=RegularIDWithTypeNames type=DataModelFieldType (attributes+=DataModelFieldAttribute)*; + name=RegularIDWithTypeNames type=DataFieldType (attributes+=DataFieldAttribute)*; -DataModelFieldType: +DataFieldType: (type=BuiltinType | unsupported=UnsupportedFieldType | reference=[TypeDeclaration:RegularID]) (array?='[' ']')? (optional?='?')?; -// TODO: unify TypeDef and abstract DataModel TypeDef: (comments+=TRIPLE_SLASH_COMMENT)* - 'type' name=RegularID '{' ( - fields+=TypeDefField | + 'type' name=RegularID WithClause? + '{' ( + fields+=DataField | attributes+=DataModelAttribute )* '}'; -type TypeDefFieldTypes = TypeDef | Enum; - -TypeDefField: - (comments+=TRIPLE_SLASH_COMMENT)* - name=RegularIDWithTypeNames type=TypeDefFieldType (attributes+=DataModelFieldAttribute)*; - -TypeDefFieldType: - (type=BuiltinType | reference=[TypeDefFieldTypes:RegularID]) (array?='[' ']')? (optional?='?')?; - UnsupportedFieldType: 'Unsupported' '(' (value=LiteralExpr) ')'; @@ -213,7 +210,7 @@ Enum: EnumField: (comments+=TRIPLE_SLASH_COMMENT)* - name=RegularIDWithTypeNames (attributes+=DataModelFieldAttribute)*; + name=RegularIDWithTypeNames (attributes+=DataFieldAttribute)*; // function FunctionDecl: @@ -252,7 +249,7 @@ AttributeParamType: (type=(ExpressionType | 'FieldReference' | 'TransitiveFieldReference' | 'ContextType') | reference=[TypeDeclaration:RegularID]) (array?='[' ']')? (optional?='?')?; type TypeDeclaration = DataModel | TypeDef | Enum; -DataModelFieldAttribute: +DataFieldAttribute: decl=[Attribute:FIELD_ATTRIBUTE_NAME] ('(' AttributeArgList? ')')?; // TODO: need rename since it's for both DataModel and TypeDef diff --git a/packages/language/test/mixin.test.ts b/packages/language/test/mixin.test.ts new file mode 100644 index 00000000..3832148d --- /dev/null +++ b/packages/language/test/mixin.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; +import { loadSchema, loadSchemaWithError } from './utils'; +import { DataModel, TypeDef } from '../src/ast'; + +describe('Mixin Tests', () => { + it('supports model mixing types to Model', async () => { + const model = await loadSchema(` + type A { + x String + } + + type B { + y String + } + + model M with A B { + id String @id + } + `); + const m = model.declarations.find((d) => d.name === 'M') as DataModel; + expect(m.mixins.length).toBe(2); + expect(m.mixins[0].ref?.name).toBe('A'); + expect(m.mixins[1].ref?.name).toBe('B'); + }); + + it('supports model mixing types to type', async () => { + const model = await loadSchema(` + type A { + x String + } + + type B { + y String + } + + type C with A B { + z String + } + + model M with C { + id String @id + } + `); + const c = model.declarations.find((d) => d.name === 'C') as TypeDef; + expect(c?.mixins.length).toBe(2); + expect(c?.mixins[0].ref?.name).toBe('A'); + expect(c?.mixins[1].ref?.name).toBe('B'); + const m = model.declarations.find((d) => d.name === 'M') as DataModel; + expect(m.mixins[0].ref?.name).toBe('C'); + }); + + it('can detect cyclic mixins', async () => { + await loadSchemaWithError( + ` + type A with B { + x String + } + + type B with A { + y String + } + + model M with A { + id String @id + } + `, + 'cyclic', + ); + }); + + it('can detect duplicated fields from mixins', async () => { + await loadSchemaWithError( + ` + type A { + x String + } + + type B { + x String + } + + model M with A B { + id String @id + } + `, + 'duplicated', + ); + }); + + it('can detect duplicated attributes from mixins', async () => { + await loadSchemaWithError( + ` + type A { + x String + @@id([x]) + } + + type B { + y String + @@id([y]) + } + + model M with A B { + } + `, + 'can only be applied once', + ); + }); +}); diff --git a/packages/language/test/utils.ts b/packages/language/test/utils.ts new file mode 100644 index 00000000..fe558f41 --- /dev/null +++ b/packages/language/test/utils.ts @@ -0,0 +1,30 @@ +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs'; +import { loadDocument } from '../src'; +import { expect } from 'vitest'; +import { invariant } from '@zenstackhq/common-helpers'; + +export async function loadSchema(schema: string) { + // create a temp file + const tempFile = path.join(os.tmpdir(), `zenstack-schema-${crypto.randomUUID()}.zmodel`); + fs.writeFileSync(tempFile, schema); + const r = await loadDocument(tempFile); + expect(r.success).toBe(true); + invariant(r.success); + return r.model; +} + +export async function loadSchemaWithError(schema: string, error: string | RegExp) { + // create a temp file + const tempFile = path.join(os.tmpdir(), `zenstack-schema-${crypto.randomUUID()}.zmodel`); + fs.writeFileSync(tempFile, schema); + const r = await loadDocument(tempFile); + expect(r.success).toBe(false); + invariant(!r.success); + if (typeof error === 'string') { + expect(r.errors.some((e) => e.toString().toLowerCase().includes(error.toLowerCase()))).toBe(true); + } else { + expect(r.errors.some((e) => error.test(e))).toBe(true); + } +} diff --git a/packages/language/tsup.config.ts b/packages/language/tsup.config.ts index a6af92ce..0d5d2b6c 100644 --- a/packages/language/tsup.config.ts +++ b/packages/language/tsup.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ entry: { index: 'src/index.ts', ast: 'src/ast.ts', + utils: 'src/utils.ts', }, outDir: 'dist', splitting: false, diff --git a/packages/language/vitest.config.ts b/packages/language/vitest.config.ts new file mode 100644 index 00000000..04655403 --- /dev/null +++ b/packages/language/vitest.config.ts @@ -0,0 +1,4 @@ +import { defineConfig, mergeConfig } from 'vitest/config'; +import base from '../../vitest.base.config'; + +export default mergeConfig(base, defineConfig({})); diff --git a/packages/runtime/src/client/crud-types.ts b/packages/runtime/src/client/crud-types.ts index e97d2e29..c14cf401 100644 --- a/packages/runtime/src/client/crud-types.ts +++ b/packages/runtime/src/client/crud-types.ts @@ -11,11 +11,14 @@ import type { ForeignKeyFields, GetEnum, GetEnums, - GetField, - GetFields, - GetFieldType, GetModel, + GetModelField, + GetModelFields, + GetModelFieldType, GetModels, + GetTypeDefField, + GetTypeDefFields, + GetTypeDefs, NonRelationFields, RelationFields, RelationFieldType, @@ -51,7 +54,7 @@ type DefaultModelResult< ? Omit[Key] extends true ? never : Key - : Key]: MapFieldType; + : Key]: MapModelFieldType; }, Optional, Array @@ -71,7 +74,7 @@ type ModelSelectResult : Key]: Key extends '_count' ? SelectCountResult : Key extends NonRelationFields - ? MapFieldType + ? MapModelFieldType : Key extends RelationFields ? Select[Key] extends FindArgs< Schema, @@ -161,11 +164,15 @@ export type ModelResult< export type SimplifiedModelResult< Schema extends SchemaDef, Model extends GetModels, - Args extends SelectIncludeOmit, + Args extends SelectIncludeOmit = {}, Optional = false, Array = false, > = Simplify>; +export type TypeDefResult> = { + [Key in GetTypeDefFields]: MapTypeDefFieldType; +}; + export type BatchResult = { count: number }; //#endregion @@ -177,7 +184,7 @@ export type WhereInput< Model extends GetModels, ScalarOnly extends boolean = false, > = { - [Key in GetFields as ScalarOnly extends true + [Key in GetModelFields as ScalarOnly extends true ? Key extends RelationFields ? never : Key @@ -185,12 +192,12 @@ export type WhereInput< ? // relation RelationFilter : // enum - GetFieldType extends GetEnums - ? EnumFilter, FieldIsOptional> + GetModelFieldType extends GetEnums + ? EnumFilter, FieldIsOptional> : FieldIsArray extends true - ? ArrayFilter> + ? ArrayFilter> : // primitive - PrimitiveFilter, FieldIsOptional>; + PrimitiveFilter, FieldIsOptional>; } & { $expr?: (eb: ExpressionBuilder, Model>) => OperandExpression; } & { @@ -443,11 +450,24 @@ type RelationFilter< //#region Field utils -type MapFieldType< +type MapModelFieldType< Schema extends SchemaDef, Model extends GetModels, - Field extends GetFields, -> = MapFieldDefType>; + Field extends GetModelFields, +> = MapFieldDefType>; + +type MapTypeDefFieldType< + Schema extends SchemaDef, + TypeDef extends GetTypeDefs, + Field extends GetTypeDefFields, +> = + GetTypeDefField['type'] extends GetTypeDefs + ? WrapType< + TypeDefResult['type']>, + GetTypeDefField['optional'], + GetTypeDefField['array'] + > + : MapFieldDefType>; type MapFieldDefType> = WrapType< T['type'] extends GetEnums ? keyof GetEnum : MapBaseType, @@ -456,32 +476,32 @@ type MapFieldDefType; type OptionalFieldsForCreate> = keyof { - [Key in GetFields as FieldIsOptional extends true + [Key in GetModelFields as FieldIsOptional extends true ? Key : FieldHasDefault extends true ? Key - : GetField['updatedAt'] extends true + : GetModelField['updatedAt'] extends true ? Key : FieldIsRelationArray extends true ? Key - : never]: GetField; + : never]: GetModelField; }; type GetRelation< Schema extends SchemaDef, Model extends GetModels, - Field extends GetFields, -> = GetField['relation']; + Field extends GetModelFields, +> = GetModelField['relation']; type OppositeRelation< Schema extends SchemaDef, Model extends GetModels, - Field extends GetFields, + Field extends GetModelFields, FT = FieldType, > = FT extends GetModels ? GetRelation extends RelationInfo - ? GetRelation['opposite'] extends GetFields + ? GetRelation['opposite'] extends GetModelFields ? Schema['models'][FT]['fields'][GetRelation['opposite']]['relation'] : never : never @@ -490,20 +510,20 @@ type OppositeRelation< type OppositeRelationFields< Schema extends SchemaDef, Model extends GetModels, - Field extends GetFields, + Field extends GetModelFields, Opposite = OppositeRelation, > = Opposite extends RelationInfo ? (Opposite['fields'] extends string[] ? Opposite['fields'] : []) : []; type OppositeRelationAndFK< Schema extends SchemaDef, Model extends GetModels, - Field extends GetFields, + Field extends GetModelFields, FT = FieldType, - Relation = GetField['relation'], + Relation = GetModelField['relation'], Opposite = Relation extends RelationInfo ? Relation['opposite'] : never, > = FT extends GetModels - ? Opposite extends GetFields + ? Opposite extends GetModelFields ? Opposite | OppositeRelationFields[number] : never : never; @@ -579,10 +599,10 @@ type ScalarCreatePayload< Model extends GetModels, Field extends ScalarFields, > = - | MapFieldType + | MapModelFieldType | (FieldIsArray extends true ? { - set?: MapFieldType[]; + set?: MapModelFieldType[]; } : never); @@ -590,7 +610,7 @@ type CreateFKPayload> Schema, Model, { - [Key in ForeignKeyFields]: MapFieldType; + [Key in ForeignKeyFields]: MapModelFieldType; } >; @@ -729,7 +749,7 @@ type ScalarUpdatePayload< Model extends GetModels, Field extends NonRelationFields, > = - | MapFieldType + | MapModelFieldType | (Field extends NumericFields ? { set?: NullableIf>; @@ -741,8 +761,8 @@ type ScalarUpdatePayload< : never) | (FieldIsArray extends true ? { - set?: MapFieldType[]; - push?: OrArray, true>; + set?: MapModelFieldType[]; + push?: OrArray, true>; } : never); @@ -871,11 +891,15 @@ export type AggregateArgs> = keyof { - [Key in GetFields as GetFieldType extends 'Int' | 'Float' | 'BigInt' | 'Decimal' + [Key in GetModelFields as GetModelFieldType extends + | 'Int' + | 'Float' + | 'BigInt' + | 'Decimal' ? FieldIsArray extends true ? never : Key - : never]: GetField; + : never]: GetModelField; }; type SumAvgInput> = { @@ -883,7 +907,7 @@ type SumAvgInput> = { }; type MinMaxInput> = { - [Key in GetFields as FieldIsArray extends true + [Key in GetModelFields as FieldIsArray extends true ? never : FieldIsRelation extends true ? never @@ -957,7 +981,7 @@ export type GroupByResult< { [Key in NonRelationFields as Key extends ValueOfPotentialTuple ? Key - : never]: MapFieldType; + : never]: MapModelFieldType; } & (Args extends { _count: infer Count } ? { _count: AggCommonOutput; @@ -1110,7 +1134,9 @@ type NestedDeleteManyInput< // #region Utilities type NonOwnedRelationFields> = keyof { - [Key in RelationFields as GetField['relation'] extends { references: unknown[] } + [Key in RelationFields as GetModelField['relation'] extends { + references: unknown[]; + } ? never : Key]: Key; }; diff --git a/packages/runtime/src/client/helpers/schema-db-pusher.ts b/packages/runtime/src/client/helpers/schema-db-pusher.ts index 9bb5ba19..0f42d757 100644 --- a/packages/runtime/src/client/helpers/schema-db-pusher.ts +++ b/packages/runtime/src/client/helpers/schema-db-pusher.ts @@ -49,7 +49,7 @@ export class SchemaDbPusher { } table = this.addPrimaryKeyConstraint(table, model, modelDef); - table = this.addUniqueConstraint(table, modelDef); + table = this.addUniqueConstraint(table, model, modelDef); return table; } @@ -77,7 +77,7 @@ export class SchemaDbPusher { return table; } - private addUniqueConstraint(table: CreateTableBuilder, modelDef: ModelDef) { + private addUniqueConstraint(table: CreateTableBuilder, model: string, modelDef: ModelDef) { for (const [key, value] of Object.entries(modelDef.uniqueFields)) { invariant(typeof value === 'object', 'expecting an object'); if ('type' in value) { @@ -86,9 +86,10 @@ export class SchemaDbPusher { if (fieldDef.unique) { continue; } + table = table.addUniqueConstraint(`unique_${model}_${key}`, [key]); } else { // multi-field constraint - table = table.addUniqueConstraint(`unique_${key}`, Object.keys(value)); + table = table.addUniqueConstraint(`unique_${model}_${key}`, Object.keys(value)); } } return table; diff --git a/packages/runtime/src/client/query-builder.ts b/packages/runtime/src/client/query-builder.ts index f049727f..64b76ea0 100644 --- a/packages/runtime/src/client/query-builder.ts +++ b/packages/runtime/src/client/query-builder.ts @@ -4,8 +4,8 @@ import type { FieldHasDefault, FieldIsOptional, ForeignKeyFields, - GetFields, - GetFieldType, + GetModelFields, + GetModelFieldType, GetModels, ScalarFields, SchemaDef, @@ -44,13 +44,13 @@ type WrapNull = Null extends true ? T | null : T; type MapType< Schema extends SchemaDef, Model extends GetModels, - Field extends GetFields, -> = WrapNull>, FieldIsOptional>; + Field extends GetModelFields, +> = WrapNull>, FieldIsOptional>; type toKyselyFieldType< Schema extends SchemaDef, Model extends GetModels, - Field extends GetFields, + Field extends GetModelFields, > = FieldHasDefault extends true ? Generated> diff --git a/packages/runtime/test/client-api/find.test.ts b/packages/runtime/test/client-api/find.test.ts index 8bc60821..d239c76d 100644 --- a/packages/runtime/test/client-api/find.test.ts +++ b/packages/runtime/test/client-api/find.test.ts @@ -51,20 +51,12 @@ describe.each(createClientSpecs(PG_DB_NAME))('Client find tests for $provider', // take await expect(client.user.findMany({ take: 1 })).resolves.toHaveLength(1); - // default sorted by id - await expect(client.user.findFirst({ take: 1 })).resolves.toMatchObject({ - email: 'u1@test.com', - }); await expect(client.user.findMany({ take: 2 })).resolves.toHaveLength(2); await expect(client.user.findMany({ take: 4 })).resolves.toHaveLength(3); // skip await expect(client.user.findMany({ skip: 1 })).resolves.toHaveLength(2); await expect(client.user.findMany({ skip: 2 })).resolves.toHaveLength(1); - // default sorted by id - await expect(client.user.findFirst({ skip: 1, take: 1 })).resolves.toMatchObject({ - email: 'u02@test.com', - }); // explicit sort await expect( client.user.findFirst({ @@ -81,10 +73,10 @@ describe.each(createClientSpecs(PG_DB_NAME))('Client find tests for $provider', // negative take, default sort is negated await expect(client.user.findMany({ take: -2 })).toResolveWithLength(2); - await expect(client.user.findMany({ take: -2 })).resolves.toEqual( + await expect(client.user.findMany({ take: -2, orderBy: { id: 'asc' } })).resolves.toEqual( expect.arrayContaining([expect.objectContaining({ id: '3' }), expect.objectContaining({ id: '2' })]), ); - await expect(client.user.findMany({ skip: 1, take: -1 })).resolves.toEqual([ + await expect(client.user.findMany({ skip: 1, take: -1, orderBy: { id: 'asc' } })).resolves.toEqual([ expect.objectContaining({ id: '2' }), ]); diff --git a/packages/runtime/test/client-api/mixin.test.ts b/packages/runtime/test/client-api/mixin.test.ts new file mode 100644 index 00000000..8e888aac --- /dev/null +++ b/packages/runtime/test/client-api/mixin.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { createTestClient } from '../utils'; + +describe('Client API Mixins', () => { + const schema = ` +type TimeStamped { + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +type Named { + name String + @@unique([name]) +} + +type CommonFields with TimeStamped Named { + id String @id @default(cuid()) +} + +model Foo with TimeStamped { + id String @id @default(cuid()) + title String +} + +model Bar with CommonFields { + description String +} + `; + + it('includes fields and attributes from mixins', async () => { + const client = await createTestClient(schema, { + usePrismaPush: true, + }); + + await expect( + client.foo.create({ + data: { + title: 'Foo', + }, + }), + ).resolves.toMatchObject({ + id: expect.any(String), + title: 'Foo', + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + }); + + await expect( + client.bar.create({ + data: { + description: 'Bar', + }, + }), + ).rejects.toThrow('Invalid input'); + + await expect( + client.bar.create({ + data: { + name: 'Bar', + description: 'Bar', + }, + }), + ).resolves.toMatchObject({ + id: expect.any(String), + name: 'Bar', + description: 'Bar', + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + }); + + await expect( + client.bar.create({ + data: { + name: 'Bar', + description: 'Bar', + }, + }), + ).rejects.toThrow('constraint failed'); + }); +}); diff --git a/packages/runtime/test/schemas/todo.zmodel b/packages/runtime/test/schemas/todo.zmodel index f462d064..0adb21f6 100644 --- a/packages/runtime/test/schemas/todo.zmodel +++ b/packages/runtime/test/schemas/todo.zmodel @@ -73,7 +73,7 @@ model User { password String? @password @omit emailVerified DateTime? name String? - bio String @ignore + bio String? @ignore ownedSpaces Space[] spaces SpaceUser[] image String? @url diff --git a/packages/runtime/test/test-schema/models.ts b/packages/runtime/test/test-schema/models.ts index f9dbfc98..48b8dea5 100644 --- a/packages/runtime/test/test-schema/models.ts +++ b/packages/runtime/test/test-schema/models.ts @@ -6,10 +6,11 @@ /* eslint-disable */ import { schema as $schema, type SchemaType as $Schema } from "./schema"; -import { type ModelResult as $ModelResult } from "@zenstackhq/runtime"; +import { type ModelResult as $ModelResult, type TypeDefResult as $TypeDefResult } from "@zenstackhq/runtime"; export type User = $ModelResult<$Schema, "User">; export type Post = $ModelResult<$Schema, "Post">; export type Comment = $ModelResult<$Schema, "Comment">; export type Profile = $ModelResult<$Schema, "Profile">; +export type CommonFields = $TypeDefResult<$Schema, "CommonFields">; export const Role = $schema.enums.Role; export type Role = (typeof Role)[keyof typeof Role]; diff --git a/packages/runtime/test/test-schema/schema.ts b/packages/runtime/test/test-schema/schema.ts index 610cb4b0..db61c902 100644 --- a/packages/runtime/test/test-schema/schema.ts +++ b/packages/runtime/test/test-schema/schema.ts @@ -19,15 +19,6 @@ export const schema = { attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }], default: ExpressionUtils.call("cuid") }, - email: { - type: "String", - unique: true, - attributes: [{ name: "@unique" }] - }, - name: { - type: "String", - optional: true - }, createdAt: { type: "DateTime", attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], @@ -38,6 +29,15 @@ export const schema = { updatedAt: true, attributes: [{ name: "@updatedAt" }] }, + email: { + type: "String", + unique: true, + attributes: [{ name: "@unique" }] + }, + name: { + type: "String", + optional: true + }, role: { type: "Role", attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal("USER") }] }], @@ -169,6 +169,16 @@ export const schema = { attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }], default: ExpressionUtils.call("cuid") }, + createdAt: { + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], + default: ExpressionUtils.call("now") + }, + updatedAt: { + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }] + }, bio: { type: "String" }, @@ -199,6 +209,27 @@ export const schema = { } } }, + typeDefs: { + CommonFields: { + fields: { + id: { + type: "String", + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }], + default: ExpressionUtils.call("cuid") + }, + createdAt: { + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], + default: ExpressionUtils.call("now") + }, + updatedAt: { + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }] + } + } + } + }, enums: { Role: { ADMIN: "ADMIN", diff --git a/packages/runtime/test/test-schema/schema.zmodel b/packages/runtime/test/test-schema/schema.zmodel index fb041b5a..e65f74e6 100644 --- a/packages/runtime/test/test-schema/schema.zmodel +++ b/packages/runtime/test/test-schema/schema.zmodel @@ -12,12 +12,15 @@ enum Role { USER } -model User { +type CommonFields { id String @id @default(cuid()) - email String @unique - name String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt +} + +model User with CommonFields { + email String @unique + name String? role Role @default(USER) posts Post[] profile Profile? @@ -27,10 +30,7 @@ model User { @@allow('read', auth() != null) } -model Post { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt +model Post with CommonFields { title String content String? published Boolean @default(false) @@ -44,17 +44,13 @@ model Post { @@allow('read', published) } -model Comment { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt +model Comment with CommonFields { content String post Post? @relation(fields: [postId], references: [id], onUpdate: Cascade, onDelete: Cascade) postId String? } -model Profile { - id String @id @default(cuid()) +model Profile with CommonFields { bio String age Int? user User? @relation(fields: [userId], references: [id], onUpdate: Cascade, onDelete: Cascade) diff --git a/packages/runtime/test/typing/models.ts b/packages/runtime/test/typing/models.ts index e418ffad..a30c2d6e 100644 --- a/packages/runtime/test/typing/models.ts +++ b/packages/runtime/test/typing/models.ts @@ -6,12 +6,14 @@ /* eslint-disable */ import { schema as $schema, type SchemaType as $Schema } from "./schema"; -import { type ModelResult as $ModelResult } from "@zenstackhq/runtime"; +import { type ModelResult as $ModelResult, type TypeDefResult as $TypeDefResult } from "@zenstackhq/runtime"; 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">; +export type Identity = $TypeDefResult<$Schema, "Identity">; +export type IdentityProvider = $TypeDefResult<$Schema, "IdentityProvider">; export const Role = $schema.enums.Role; export type Role = (typeof Role)[keyof typeof Role]; diff --git a/packages/runtime/test/typing/schema.ts b/packages/runtime/test/typing/schema.ts index fb0db9e1..d529dbf3 100644 --- a/packages/runtime/test/typing/schema.ts +++ b/packages/runtime/test/typing/schema.ts @@ -246,6 +246,26 @@ export const schema = { } } }, + typeDefs: { + Identity: { + fields: { + providers: { + type: "IdentityProvider", + array: true + } + } + }, + IdentityProvider: { + fields: { + id: { + type: "String" + }, + name: { + type: "String" + } + } + } + }, enums: { Role: { ADMIN: "ADMIN", diff --git a/packages/runtime/test/typing/typing-test.zmodel b/packages/runtime/test/typing/typing-test.zmodel index 84e9d9b2..2aa9aa67 100644 --- a/packages/runtime/test/typing/typing-test.zmodel +++ b/packages/runtime/test/typing/typing-test.zmodel @@ -8,6 +8,15 @@ enum Role { USER } +type Identity { + providers IdentityProvider[] +} + +type IdentityProvider { + id String + name String +} + model User { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) diff --git a/packages/runtime/test/typing/verify-typing.ts b/packages/runtime/test/typing/verify-typing.ts index 86389616..e9410031 100644 --- a/packages/runtime/test/typing/verify-typing.ts +++ b/packages/runtime/test/typing/verify-typing.ts @@ -1,6 +1,6 @@ import SQLite from 'better-sqlite3'; import { ZenStackClient } from '../../dist'; -import { Role } from './models'; +import { Role, type Identity, type IdentityProvider } from './models'; import { schema } from './schema'; const client = new ZenStackClient(schema, { @@ -28,6 +28,7 @@ async function main() { await groupBy(); await queryBuilder(); enums(); + typeDefs(); } async function find() { @@ -612,4 +613,18 @@ function enums() { console.log(b); } +function typeDefs() { + const identityProvider: IdentityProvider = { + id: '123', + name: 'GitHub', + }; + console.log(identityProvider.id); + console.log(identityProvider.name); + + const identity: Identity = { + providers: [identityProvider], + }; + console.log(identity.providers[0]?.name); +} + main(); diff --git a/packages/runtime/test/utils.ts b/packages/runtime/test/utils.ts index 88aa389c..82b05e82 100644 --- a/packages/runtime/test/utils.ts +++ b/packages/runtime/test/utils.ts @@ -79,8 +79,14 @@ export async function createTestClient( let workDir: string | undefined; let _schema: Schema; + let dbName = options?.dbName; + const provider = options?.provider ?? 'sqlite'; + if (provider === 'sqlite' && options?.usePrismaPush && !dbName) { + dbName = 'file:./test.db'; + } + if (typeof schema === 'string') { - const generated = await generateTsSchema(schema, options?.provider, options?.dbName, options?.extraSourceFiles); + const generated = await generateTsSchema(schema, provider, dbName, options?.extraSourceFiles); workDir = generated.workDir; _schema = generated.schema as Schema; } else { @@ -110,21 +116,21 @@ export async function createTestClient( stdio: 'inherit', }); } else { - if (options?.provider === 'postgresql') { - invariant(options?.dbName, 'dbName is required'); + if (provider === 'postgresql') { + invariant(dbName, 'dbName is required'); const pgClient = new PGClient(TEST_PG_CONFIG); await pgClient.connect(); - await pgClient.query(`DROP DATABASE IF EXISTS "${options!.dbName}"`); - await pgClient.query(`CREATE DATABASE "${options!.dbName}"`); + await pgClient.query(`DROP DATABASE IF EXISTS "${dbName}"`); + await pgClient.query(`CREATE DATABASE "${dbName}"`); await pgClient.end(); } } - if (options?.provider === 'postgresql') { + if (provider === 'postgresql') { _options.dialectConfig = { pool: new Pool({ ...TEST_PG_CONFIG, - database: options!.dbName, + database: dbName, }), } as unknown as ClientOptions['dialectConfig']; } else { diff --git a/packages/sdk/src/model-utils.ts b/packages/sdk/src/model-utils.ts index f20de625..14532938 100644 --- a/packages/sdk/src/model-utils.ts +++ b/packages/sdk/src/model-utils.ts @@ -1,27 +1,25 @@ import { - isArrayExpr, isDataModel, isLiteralExpr, isModel, - isReferenceExpr, Model, - ReferenceExpr, type AstNode, type Attribute, type AttributeParam, + type DataField, + type DataFieldAttribute, type DataModel, type DataModelAttribute, - type DataModelField, - type DataModelFieldAttribute, type Enum, type EnumField, type FunctionDecl, type Reference, type TypeDef, - type TypeDefField, } from '@zenstackhq/language/ast'; -export function isIdField(field: DataModelField) { +import { getAllFields, getModelIdFields, getModelUniqueFields, type AttributeTarget } from '@zenstackhq/language/utils'; + +export function isIdField(field: DataField, contextModel: DataModel) { // field-level @id attribute if (hasAttribute(field, '@id')) { return true; @@ -30,27 +28,26 @@ export function isIdField(field: DataModelField) { // NOTE: we have to use name to match fields because the fields // may be inherited from an abstract base and have cloned identities - const model = field.$container as DataModel; - // model-level @@id attribute with a list of fields - const modelLevelIds = getModelIdFields(model); + const modelLevelIds = getModelIdFields(contextModel); if (modelLevelIds.map((f) => f.name).includes(field.name)) { return true; } - if (model.fields.some((f) => hasAttribute(f, '@id')) || modelLevelIds.length > 0) { + const allFields = getAllFields(contextModel); + if (allFields.some((f) => hasAttribute(f, '@id')) || modelLevelIds.length > 0) { // the model already has id field, don't check @unique and @@unique return false; } // then, the first field with @unique can be used as id - const firstUniqueField = model.fields.find((f) => hasAttribute(f, '@unique')); + const firstUniqueField = allFields.find((f) => hasAttribute(f, '@unique')); if (firstUniqueField) { return firstUniqueField.name === field.name; } // last, the first model level @@unique can be used as id - const modelLevelUnique = getModelUniqueFields(model); + const modelLevelUnique = getModelUniqueFields(contextModel); if (modelLevelUnique.map((f) => f.name).includes(field.name)) { return true; } @@ -59,106 +56,21 @@ export function isIdField(field: DataModelField) { } export function hasAttribute( - decl: DataModel | TypeDef | DataModelField | Enum | EnumField | FunctionDecl | Attribute | AttributeParam, + decl: DataModel | TypeDef | DataField | Enum | EnumField | FunctionDecl | Attribute | AttributeParam, name: string, ) { return !!getAttribute(decl, name); } -export function getAttribute( - decl: - | DataModel - | TypeDef - | DataModelField - | TypeDefField - | Enum - | EnumField - | FunctionDecl - | Attribute - | AttributeParam, - name: string, -) { - return (decl.attributes as (DataModelAttribute | DataModelFieldAttribute)[]).find( - (attr) => attr.decl.$refText === name, - ); -} - -/** - * Gets `@@id` fields declared at the data model level (including search in base models) - */ -export function getModelIdFields(model: DataModel) { - const modelsToCheck = model.$baseMerged ? [model] : [model, ...getRecursiveBases(model)]; - - for (const modelToCheck of modelsToCheck) { - const idAttr = modelToCheck.attributes.find((attr) => attr.decl.$refText === '@@id'); - if (!idAttr) { - continue; - } - const fieldsArg = idAttr.args.find((a) => a.$resolvedParam?.name === 'fields'); - if (!fieldsArg || !isArrayExpr(fieldsArg.value)) { - continue; - } - - return fieldsArg.value.items - .filter((item): item is ReferenceExpr => isReferenceExpr(item)) - .map((item) => item.target.ref as DataModelField); - } - - return []; -} - -/** - * Gets `@@unique` fields declared at the data model level (including search in base models) - */ -export function getModelUniqueFields(model: DataModel) { - const modelsToCheck = model.$baseMerged ? [model] : [model, ...getRecursiveBases(model)]; - - for (const modelToCheck of modelsToCheck) { - const uniqueAttr = modelToCheck.attributes.find((attr) => attr.decl.$refText === '@@unique'); - if (!uniqueAttr) { - continue; - } - const fieldsArg = uniqueAttr.args.find((a) => a.$resolvedParam?.name === 'fields'); - if (!fieldsArg || !isArrayExpr(fieldsArg.value)) { - continue; - } - - return fieldsArg.value.items - .filter((item): item is ReferenceExpr => isReferenceExpr(item)) - .map((item) => item.target.ref as DataModelField); - } - - return []; -} - -export function getRecursiveBases( - dataModel: DataModel, - includeDelegate = true, - seen = new Set(), -): DataModel[] { - const result: DataModel[] = []; - if (seen.has(dataModel)) { - return result; - } - seen.add(dataModel); - dataModel.superTypes.forEach((superType) => { - const baseDecl = superType.ref; - if (baseDecl) { - if (!includeDelegate && isDelegateModel(baseDecl)) { - return; - } - result.push(baseDecl); - result.push(...getRecursiveBases(baseDecl, includeDelegate, seen)); - } - }); - return result; +export function getAttribute(decl: AttributeTarget, name: string) { + return (decl.attributes as (DataModelAttribute | DataFieldAttribute)[]).find((attr) => attr.decl.$refText === name); } export function isDelegateModel(node: AstNode) { return isDataModel(node) && hasAttribute(node, '@@delegate'); } -export function isUniqueField(field: DataModelField) { +export function isUniqueField(field: DataField) { if (hasAttribute(field, '@unique')) { return true; } diff --git a/packages/sdk/src/prisma/prisma-schema-generator.ts b/packages/sdk/src/prisma/prisma-schema-generator.ts index b4edb776..aa4c9172 100644 --- a/packages/sdk/src/prisma/prisma-schema-generator.ts +++ b/packages/sdk/src/prisma/prisma-schema-generator.ts @@ -4,11 +4,11 @@ import { ConfigArrayExpr, ConfigExpr, ConfigInvocationArg, + DataField, + DataFieldAttribute, + DataFieldType, DataModel, DataModelAttribute, - DataModelField, - DataModelFieldAttribute, - DataModelFieldType, DataSource, Enum, EnumField, @@ -30,12 +30,11 @@ import { type AstNode, } from '@zenstackhq/language/ast'; import { AstUtils } from 'langium'; -import { match, P } from 'ts-pattern'; +import { match } from 'ts-pattern'; +import { getAllAttributes, getAllFields } from '@zenstackhq/language/utils'; import { ModelUtils, ZModelCodeGenerator } from '..'; import { - AttributeArgValue, - ModelField, ModelFieldType, AttributeArg as PrismaAttributeArg, AttributeArgValue as PrismaAttributeArgValue, @@ -151,15 +150,17 @@ export class PrismaSchemaGenerator { private generateModel(prisma: PrismaModel, decl: DataModel) { const model = decl.isView ? prisma.addView(decl.name) : prisma.addModel(decl.name); - for (const field of decl.fields) { + const allFields = getAllFields(decl, true); + for (const field of allFields) { if (ModelUtils.hasAttribute(field, '@computed')) { continue; // skip computed fields } // TODO: exclude fields inherited from delegate - this.generateModelField(model, field); + this.generateModelField(model, field, decl); } - for (const attr of decl.attributes.filter((attr) => this.isPrismaAttribute(attr))) { + const allAttributes = getAllAttributes(decl); + for (const attr of allAttributes.filter((attr) => this.isPrismaAttribute(attr))) { this.generateContainerAttribute(model, attr); } @@ -183,14 +184,14 @@ export class PrismaSchemaGenerator { // this.ensureRelationsInheritedFromDelegate(model, decl); } - private isPrismaAttribute(attr: DataModelAttribute | DataModelFieldAttribute) { + private isPrismaAttribute(attr: DataModelAttribute | DataFieldAttribute) { if (!attr.decl.ref) { return false; } return attr.decl.ref.attributes.some((a) => a.decl.ref?.name === '@@@prisma'); } - private getUnsupportedFieldType(fieldType: DataModelFieldType) { + private getUnsupportedFieldType(fieldType: DataFieldType) { if (fieldType.unsupported) { const value = this.getStringLiteral(fieldType.unsupported.value); if (value) { @@ -207,7 +208,7 @@ export class PrismaSchemaGenerator { return isStringLiteral(node) ? node.value : undefined; } - private generateModelField(model: PrismaDataModel, field: DataModelField, addToFront = false) { + private generateModelField(model: PrismaDataModel, field: DataField, contextModel: DataModel, addToFront = false) { let fieldType: string | undefined; if (field.type.type) { @@ -245,7 +246,7 @@ export class PrismaSchemaGenerator { (attr) => // when building physical schema, exclude `@default` for id fields inherited from delegate base !( - ModelUtils.isIdField(field) && + ModelUtils.isIdField(field, contextModel) && this.isInheritedFromDelegate(field) && attr.decl.$refText === '@default' ), @@ -257,7 +258,7 @@ export class PrismaSchemaGenerator { return result; } - private isDefaultWithPluginInvocation(attr: DataModelFieldAttribute) { + private isDefaultWithPluginInvocation(attr: DataFieldAttribute) { if (attr.decl.ref?.name !== '@default') { return false; } @@ -275,28 +276,11 @@ export class PrismaSchemaGenerator { return !!model && !!model.$document && model.$document.uri.path.endsWith('plugin.zmodel'); } - private setDummyDefault(result: ModelField, field: DataModelField) { - const dummyDefaultValue = match(field.type.type) - .with('String', () => new AttributeArgValue('String', '')) - .with(P.union('Int', 'BigInt', 'Float', 'Decimal'), () => new AttributeArgValue('Number', '0')) - .with('Boolean', () => new AttributeArgValue('Boolean', 'false')) - .with('DateTime', () => new AttributeArgValue('FunctionCall', new PrismaFunctionCall('now'))) - .with('Json', () => new AttributeArgValue('String', '{}')) - .with('Bytes', () => new AttributeArgValue('String', '')) - .otherwise(() => { - throw new Error(`Unsupported field type with default value: ${field.type.type}`); - }); - - result.attributes.push( - new PrismaFieldAttribute('@default', [new PrismaAttributeArg(undefined, dummyDefaultValue)]), - ); - } - - private isInheritedFromDelegate(field: DataModelField) { + private isInheritedFromDelegate(field: DataField) { return field.$inheritedFrom && ModelUtils.isDelegateModel(field.$inheritedFrom); } - private makeFieldAttribute(attr: DataModelFieldAttribute) { + private makeFieldAttribute(attr: DataFieldAttribute) { const attrName = attr.decl.ref!.name; return new PrismaFieldAttribute( attrName, diff --git a/packages/sdk/src/schema/schema.ts b/packages/sdk/src/schema/schema.ts index 91d333b5..7ef99516 100644 --- a/packages/sdk/src/schema/schema.ts +++ b/packages/sdk/src/schema/schema.ts @@ -11,6 +11,7 @@ export type SchemaDef = { provider: DataSourceProvider; models: Record; enums?: Record; + typeDefs?: Record; plugins: Record; procedures?: Record; authType?: GetModels; @@ -89,6 +90,11 @@ export type MappedBuiltinType = string | boolean | number | bigint | Decimal | D export type EnumDef = Record; +export type TypeDefDef = { + fields: Record; + attributes?: AttributeApplication[]; +}; + //#region Extraction export type GetModels = Extract; @@ -99,31 +105,47 @@ export type GetEnums = keyof Schema['enums']; export type GetEnum> = Schema['enums'][Enum]; -export type GetFields> = Extract< +export type GetTypeDefs = Extract; + +export type GetTypeDef> = + Schema['typeDefs'] extends Record ? Schema['typeDefs'][TypeDef] : never; + +export type GetModelFields> = Extract< keyof GetModel['fields'], string >; -export type GetField< +export type GetModelField< Schema extends SchemaDef, Model extends GetModels, - Field extends GetFields, -> = Schema['models'][Model]['fields'][Field]; + Field extends GetModelFields, +> = GetModel['fields'][Field]; -export type GetFieldType< +export type GetModelFieldType< Schema extends SchemaDef, Model extends GetModels, - Field extends GetFields, + Field extends GetModelFields, > = Schema['models'][Model]['fields'][Field]['type']; +export type GetTypeDefFields> = Extract< + keyof GetTypeDef['fields'], + string +>; + +export type GetTypeDefField< + Schema extends SchemaDef, + TypeDef extends GetTypeDefs, + Field extends GetTypeDefFields, +> = GetTypeDef['fields'][Field]; + export type ScalarFields< Schema extends SchemaDef, Model extends GetModels, IncludeComputed extends boolean = true, > = keyof { - [Key in GetFields as GetField['relation'] extends object + [Key in GetModelFields as GetModelField['relation'] extends object ? never - : GetField['foreignKeyFor'] extends string[] + : GetModelField['foreignKeyFor'] extends string[] ? never : IncludeComputed extends true ? Key @@ -133,69 +155,76 @@ export type ScalarFields< }; export type ForeignKeyFields> = keyof { - [Key in GetFields as GetField['foreignKeyFor'] extends string[] + [Key in GetModelFields as GetModelField['foreignKeyFor'] extends string[] ? Key : never]: Key; }; export type NonRelationFields> = keyof { - [Key in GetFields as GetField['relation'] extends object ? never : Key]: Key; + [Key in GetModelFields as GetModelField['relation'] extends object + ? never + : Key]: Key; }; export type RelationFields> = keyof { - [Key in GetFields as GetField['relation'] extends object ? Key : never]: Key; + [Key in GetModelFields as GetModelField['relation'] extends object + ? Key + : never]: Key; }; export type FieldType< Schema extends SchemaDef, Model extends GetModels, - Field extends GetFields, -> = GetField['type']; + Field extends GetModelFields, +> = GetModelField['type']; export type RelationFieldType< Schema extends SchemaDef, Model extends GetModels, Field extends RelationFields, -> = GetField['type'] extends GetModels ? GetField['type'] : never; +> = + GetModelField['type'] extends GetModels + ? GetModelField['type'] + : never; export type FieldIsOptional< Schema extends SchemaDef, Model extends GetModels, - Field extends GetFields, -> = GetField['optional'] extends true ? true : false; + Field extends GetModelFields, +> = GetModelField['optional'] extends true ? true : false; export type FieldIsRelation< Schema extends SchemaDef, Model extends GetModels, - Field extends GetFields, -> = GetField['relation'] extends object ? true : false; + Field extends GetModelFields, +> = GetModelField['relation'] extends object ? true : false; export type FieldIsArray< Schema extends SchemaDef, Model extends GetModels, - Field extends GetFields, -> = GetField['array'] extends true ? true : false; + Field extends GetModelFields, +> = GetModelField['array'] extends true ? true : false; export type FieldIsComputed< Schema extends SchemaDef, Model extends GetModels, - Field extends GetFields, -> = GetField['computed'] extends true ? true : false; + Field extends GetModelFields, +> = GetModelField['computed'] extends true ? true : false; export type FieldHasDefault< Schema extends SchemaDef, Model extends GetModels, - Field extends GetFields, -> = GetField['default'] extends object | number | string | boolean + Field extends GetModelFields, +> = GetModelField['default'] extends object | number | string | boolean ? true - : GetField['updatedAt'] extends true + : GetModelField['updatedAt'] extends true ? true : false; export type FieldIsRelationArray< Schema extends SchemaDef, Model extends GetModels, - Field extends GetFields, + Field extends GetModelFields, > = FieldIsRelation extends true ? FieldIsArray : false; //#endregion diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index d8c90934..91e04112 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -4,18 +4,18 @@ import { ArrayExpr, AttributeArg, BinaryExpr, + DataField, + DataFieldAttribute, + DataFieldType, DataModel, DataModelAttribute, - DataModelField, - DataModelFieldAttribute, - DataModelFieldType, Enum, Expression, InvocationExpr, isArrayExpr, isBinaryExpr, + isDataField, isDataModel, - isDataModelField, isDataSource, isEnum, isEnumField, @@ -26,20 +26,23 @@ import { isProcedure, isReferenceExpr, isThisExpr, + isTypeDef, isUnaryExpr, LiteralExpr, MemberAccessExpr, Procedure, ReferenceExpr, + TypeDef, UnaryExpr, type Model, } from '@zenstackhq/language/ast'; +import { getAllAttributes, getAllFields } from '@zenstackhq/language/utils'; import fs from 'node:fs'; import path from 'node:path'; import { match } from 'ts-pattern'; import * as ts from 'typescript'; import { ModelUtils } from '.'; -import { getAttribute, getAuthDecl, hasAttribute, isIdField, isUniqueField } from './model-utils'; +import { getAttribute, getAuthDecl, hasAttribute, isUniqueField } from './model-utils'; export class TsSchemaGenerator { public async generate(schemaFile: string, pluginModelFiles: string[], outputDir: string) { @@ -56,7 +59,7 @@ export class TsSchemaGenerator { this.generateSchema(model, outputDir); // the model types - this.generateModels(model, outputDir); + this.generateModelsAndTypeDefs(model, outputDir); // the input types this.generateInputTypes(model, outputDir); @@ -141,6 +144,11 @@ export class TsSchemaGenerator { // models ts.factory.createPropertyAssignment('models', this.createModelsObject(model)), + + // typeDefs + ...(model.declarations.some(isTypeDef) + ? [ts.factory.createPropertyAssignment('typeDefs', this.createTypeDefsObject(model))] + : []), ]; // enums @@ -194,28 +202,38 @@ export class TsSchemaGenerator { ); } + private createTypeDefsObject(model: Model): ts.Expression { + return ts.factory.createObjectLiteralExpression( + model.declarations + .filter((d): d is TypeDef => isTypeDef(d)) + .map((td) => ts.factory.createPropertyAssignment(td.name, this.createTypeDefObject(td))), + true, + ); + } + private createDataModelObject(dm: DataModel) { + const allFields = getAllFields(dm); + const allAttributes = getAllAttributes(dm); + const fields: ts.PropertyAssignment[] = [ // fields ts.factory.createPropertyAssignment( 'fields', ts.factory.createObjectLiteralExpression( - dm.fields - .filter((field) => !hasAttribute(field, '@ignore')) - .map((field) => - ts.factory.createPropertyAssignment(field.name, this.createDataModelFieldObject(field)), - ), + allFields.map((field) => + ts.factory.createPropertyAssignment(field.name, this.createDataFieldObject(field, dm)), + ), true, ), ), // attributes - ...(dm.attributes.length > 0 + ...(allAttributes.length > 0 ? [ ts.factory.createPropertyAssignment( 'attributes', ts.factory.createArrayLiteralExpression( - dm.attributes.map((attr) => this.createAttributeObject(attr)), + allAttributes.map((attr) => this.createAttributeObject(attr)), true, ), ), @@ -245,7 +263,40 @@ export class TsSchemaGenerator { return ts.factory.createObjectLiteralExpression(fields, true); } - private createComputedFieldsObject(fields: DataModelField[]) { + private createTypeDefObject(td: TypeDef): ts.Expression { + const allFields = getAllFields(td); + const allAttributes = getAllAttributes(td); + + const fields: ts.PropertyAssignment[] = [ + // fields + ts.factory.createPropertyAssignment( + 'fields', + ts.factory.createObjectLiteralExpression( + allFields.map((field) => + ts.factory.createPropertyAssignment(field.name, this.createDataFieldObject(field, undefined)), + ), + true, + ), + ), + + // attributes + ...(allAttributes.length > 0 + ? [ + ts.factory.createPropertyAssignment( + 'attributes', + ts.factory.createArrayLiteralExpression( + allAttributes.map((attr) => this.createAttributeObject(attr)), + true, + ), + ), + ] + : []), + ]; + + return ts.factory.createObjectLiteralExpression(fields, true); + } + + private createComputedFieldsObject(fields: DataField[]) { return ts.factory.createObjectLiteralExpression( fields.map((field) => ts.factory.createMethodDeclaration( @@ -274,7 +325,7 @@ export class TsSchemaGenerator { ); } - private mapFieldTypeToTSType(type: DataModelFieldType) { + private mapFieldTypeToTSType(type: DataFieldType) { let result = match(type.type) .with('String', () => 'string') .with('Boolean', () => 'boolean') @@ -292,10 +343,10 @@ export class TsSchemaGenerator { return result; } - private createDataModelFieldObject(field: DataModelField) { + private createDataFieldObject(field: DataField, contextModel: DataModel | undefined) { const objectFields = [ts.factory.createPropertyAssignment('type', this.generateFieldTypeLiteral(field))]; - if (isIdField(field)) { + if (contextModel && ModelUtils.isIdField(field, contextModel)) { objectFields.push(ts.factory.createPropertyAssignment('id', ts.factory.createTrue())); } @@ -445,7 +496,7 @@ export class TsSchemaGenerator { } private getFieldMappedDefault( - field: DataModelField, + field: DataField, ): string | number | boolean | unknown[] | { call: string; args: any[] } | { authMember: string[] } | undefined { const defaultAttr = getAttribute(field, '@default'); if (!defaultAttr) { @@ -458,7 +509,7 @@ export class TsSchemaGenerator { private getMappedValue( expr: Expression, - fieldType: DataModelFieldType, + fieldType: DataFieldType, ): string | number | boolean | unknown[] | { call: string; args: any[] } | { authMember: string[] } | undefined { if (isLiteralExpr(expr)) { const lit = (expr as LiteralExpr).value; @@ -507,7 +558,7 @@ export class TsSchemaGenerator { ); } - private createRelationObject(field: DataModelField) { + private createRelationObject(field: DataField) { const relationFields: ts.PropertyAssignment[] = []; const oppositeRelation = this.getOppositeRelationField(field); @@ -558,7 +609,7 @@ export class TsSchemaGenerator { return isArrayExpr(expr) && expr.items.map((item) => (item as ReferenceExpr).target.$refText); } - private getForeignKeyFor(field: DataModelField) { + private getForeignKeyFor(field: DataField) { const result: string[] = []; for (const f of field.$container.fields) { const relation = getAttribute(f, '@relation'); @@ -577,7 +628,7 @@ export class TsSchemaGenerator { return result; } - private getOppositeRelationField(field: DataModelField) { + private getOppositeRelationField(field: DataField) { if (!field.type.reference?.ref || !isDataModel(field.type.reference?.ref)) { return undefined; } @@ -605,7 +656,7 @@ export class TsSchemaGenerator { return undefined; } - private getRelationName(field: DataModelField) { + private getRelationName(field: DataField) { const relation = getAttribute(field, '@relation'); if (relation) { const nameArg = relation.args.find((arg) => arg.$resolvedParam.name === 'name'); @@ -618,14 +669,17 @@ export class TsSchemaGenerator { } private getIdFields(dm: DataModel) { - return dm.fields.filter(isIdField).map((f) => f.name); + return getAllFields(dm) + .filter((f) => ModelUtils.isIdField(f, dm)) + .map((f) => f.name); } private createUniqueFieldsObject(dm: DataModel) { const properties: ts.PropertyAssignment[] = []; // field-level id and unique - for (const field of dm.fields) { + const allFields = getAllFields(dm); + for (const field of allFields) { if (hasAttribute(field, '@id') || hasAttribute(field, '@unique')) { properties.push( ts.factory.createPropertyAssignment( @@ -639,11 +693,12 @@ export class TsSchemaGenerator { } // model-level id and unique + const allAttributes = getAllAttributes(dm); // it's possible to have the same set of fields in both `@@id` and `@@unique` // so we need to deduplicate them const seenKeys = new Set(); - for (const attr of dm.attributes) { + for (const attr of allAttributes) { if (attr.decl.$refText === '@@id' || attr.decl.$refText === '@@unique') { const fieldNames = this.getReferenceNames(attr.args[0]!.value); if (!fieldNames) { @@ -652,7 +707,7 @@ export class TsSchemaGenerator { if (fieldNames.length === 1) { // single-field unique - const fieldDef = dm.fields.find((f) => f.name === fieldNames[0])!; + const fieldDef = allFields.find((f) => f.name === fieldNames[0])!; properties.push( ts.factory.createPropertyAssignment( fieldNames[0]!, @@ -673,7 +728,7 @@ export class TsSchemaGenerator { fieldNames.join('_'), ts.factory.createObjectLiteralExpression( fieldNames.map((field) => { - const fieldDef = dm.fields.find((f) => f.name === field)!; + const fieldDef = allFields.find((f) => f.name === field)!; return ts.factory.createPropertyAssignment( field, ts.factory.createObjectLiteralExpression([ @@ -694,7 +749,7 @@ export class TsSchemaGenerator { return ts.factory.createObjectLiteralExpression(properties, true); } - private generateFieldTypeLiteral(field: DataModelField): ts.Expression { + private generateFieldTypeLiteral(field: DataField): ts.Expression { invariant( field.type.type || field.type.reference || field.type.unsupported, 'Field type must be a primitive, reference, or Unsupported', @@ -831,7 +886,7 @@ export class TsSchemaGenerator { ts.addSyntheticLeadingComment(statements[0]!, ts.SyntaxKind.SingleLineCommentTrivia, banner); } - private createAttributeObject(attr: DataModelAttribute | DataModelFieldAttribute): ts.Expression { + private createAttributeObject(attr: DataModelAttribute | DataFieldAttribute): ts.Expression { return ts.factory.createObjectLiteralExpression([ ts.factory.createPropertyAssignment('name', ts.factory.createStringLiteral(attr.decl.$refText)), ...(attr.args.length > 0 @@ -922,7 +977,7 @@ export class TsSchemaGenerator { } private createRefExpression(expr: ReferenceExpr): any { - if (isDataModelField(expr.target.ref)) { + if (isDataField(expr.target.ref)) { return ts.factory.createCallExpression(ts.factory.createIdentifier('ExpressionUtils.field'), undefined, [ this.createLiteralNode(expr.target.$refText), ]); @@ -964,7 +1019,7 @@ export class TsSchemaGenerator { }); } - private generateModels(model: Model, outputDir: string) { + private generateModelsAndTypeDefs(model: Model, outputDir: string) { const statements: ts.Statement[] = []; // generate: import { schema as $schema, type SchemaType as $Schema } from './schema'; @@ -983,15 +1038,24 @@ export class TsSchemaGenerator { undefined, ts.factory.createIdentifier(`ModelResult as $ModelResult`), ), + ...(model.declarations.some(isTypeDef) + ? [ + ts.factory.createImportSpecifier( + true, + undefined, + ts.factory.createIdentifier(`TypeDefResult as $TypeDefResult`), + ), + ] + : []), ]), ), ts.factory.createStringLiteral('@zenstackhq/runtime'), ), ); + // generate: export type Model = $ModelResult; 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, @@ -1007,10 +1071,27 @@ export class TsSchemaGenerator { statements.push(modelType); } - // generate enums + // generate: export type TypeDef = $TypeDefResult; + const typeDefs = model.declarations.filter(isTypeDef); + for (const td of typeDefs) { + let typeDef = ts.factory.createTypeAliasDeclaration( + [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], + td.name, + undefined, + ts.factory.createTypeReferenceNode('$TypeDefResult', [ + ts.factory.createTypeReferenceNode('$Schema'), + ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral(td.name)), + ]), + ); + if (td.comments.length > 0) { + typeDef = this.generateDocs(typeDef, td); + } + statements.push(typeDef); + } + + // generate: export const Enum = $schema.enums.Enum; const enums = model.declarations.filter(isEnum); for (const e of enums) { - // generate: export const Enum = $schema.enums.Enum; let enumDecl = ts.factory.createVariableStatement( [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], ts.factory.createVariableDeclarationList( @@ -1097,7 +1178,7 @@ export class TsSchemaGenerator { private generateDocs( tsDecl: T, - decl: DataModel | Enum, + decl: DataModel | TypeDef | Enum, ): T { return ts.addSyntheticLeadingComment( tsDecl, diff --git a/packages/sdk/src/zmodel-code-generator.ts b/packages/sdk/src/zmodel-code-generator.ts index ee115bee..2e010884 100644 --- a/packages/sdk/src/zmodel-code-generator.ts +++ b/packages/sdk/src/zmodel-code-generator.ts @@ -11,11 +11,11 @@ import { ConfigArrayExpr, ConfigField, ConfigInvocationExpr, + DataField, + DataFieldAttribute, + DataFieldType, DataModel, DataModelAttribute, - DataModelField, - DataModelFieldAttribute, - DataModelFieldType, DataSource, Enum, EnumField, @@ -38,8 +38,6 @@ import { StringLiteral, ThisExpr, TypeDef, - TypeDefField, - TypeDefFieldType, UnaryExpr, type AstNode, } from '@zenstackhq/language/ast'; @@ -162,8 +160,8 @@ ${ast.fields.map((x) => this.indent + this.generate(x)).join('\n')} @gen(DataModel) private _generateDataModel(ast: DataModel) { - return `${ast.isAbstract ? 'abstract ' : ''}${ast.isView ? 'view' : 'model'} ${ast.name}${ - ast.superTypes.length > 0 ? ' extends ' + ast.superTypes.map((x) => x.ref?.name).join(', ') : '' + return `${ast.isView ? 'view' : 'model'} ${ast.name}${ + ast.mixins.length > 0 ? ' mixes ' + ast.mixins.map((x) => x.ref?.name).join(', ') : '' } { ${ast.fields.map((x) => this.indent + this.generate(x)).join('\n')}${ ast.attributes.length > 0 @@ -173,17 +171,17 @@ ${ast.fields.map((x) => this.indent + this.generate(x)).join('\n')}${ }`; } - @gen(DataModelField) - private _generateDataModelField(ast: DataModelField) { + @gen(DataField) + private _generateDataField(ast: DataField) { return `${ast.name} ${this.fieldType(ast.type)}${ ast.attributes.length > 0 ? ' ' + ast.attributes.map((x) => this.generate(x)).join(' ') : '' }`; } - private fieldType(type: DataModelFieldType | TypeDefFieldType) { + private fieldType(type: DataFieldType) { const baseType = type.type ? type.type - : type.$type == 'DataModelFieldType' && type.unsupported + : type.$type == 'DataFieldType' && type.unsupported ? 'Unsupported(' + this.generate(type.unsupported.value) + ')' : type.reference?.$refText; return `${baseType}${type.array ? '[]' : ''}${type.optional ? '?' : ''}`; @@ -194,12 +192,12 @@ ${ast.fields.map((x) => this.indent + this.generate(x)).join('\n')}${ return this.attribute(ast); } - @gen(DataModelFieldAttribute) - private _generateDataModelFieldAttribute(ast: DataModelFieldAttribute) { + @gen(DataFieldAttribute) + private _generateDataFieldAttribute(ast: DataFieldAttribute) { return this.attribute(ast); } - private attribute(ast: DataModelAttribute | DataModelFieldAttribute) { + private attribute(ast: DataModelAttribute | DataFieldAttribute) { const args = ast.args.length ? `(${ast.args.map((x) => this.generate(x)).join(', ')})` : ''; return `${resolved(ast.decl).name}${args}`; } @@ -338,13 +336,6 @@ ${ast.fields.map((x) => this.indent + this.generate(x)).join('\n')}${ }`; } - @gen(TypeDefField) - private _generateTypeDefField(ast: TypeDefField) { - return `${ast.name} ${this.fieldType(ast.type)}${ - ast.attributes.length > 0 ? ' ' + ast.attributes.map((x) => this.generate(x)).join(' ') : '' - }`; - } - private argument(ast: Argument) { return this.generate(ast.value); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f3319d2..835af938 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -197,6 +197,9 @@ importers: '@types/pluralize': specifier: ^0.0.33 version: 0.0.33 + '@zenstackhq/common-helpers': + specifier: workspace:* + version: link:../common-helpers '@zenstackhq/eslint-config': specifier: workspace:* version: link:../eslint-config diff --git a/samples/blog/zenstack/models.ts b/samples/blog/zenstack/models.ts index a55d9275..86b941a1 100644 --- a/samples/blog/zenstack/models.ts +++ b/samples/blog/zenstack/models.ts @@ -6,7 +6,7 @@ /* eslint-disable */ import { schema as $schema, type SchemaType as $Schema } from "./schema"; -import { type ModelResult as $ModelResult } from "@zenstackhq/runtime"; +import { type ModelResult as $ModelResult, type TypeDefResult as $TypeDefResult } from "@zenstackhq/runtime"; /** * User model * @@ -21,6 +21,7 @@ export type Profile = $ModelResult<$Schema, "Profile">; * Post model */ export type Post = $ModelResult<$Schema, "Post">; +export type CommonFields = $TypeDefResult<$Schema, "CommonFields">; /** * User roles */ diff --git a/samples/blog/zenstack/schema.prisma b/samples/blog/zenstack/schema.prisma deleted file mode 100644 index 0dc12287..00000000 --- a/samples/blog/zenstack/schema.prisma +++ /dev/null @@ -1,44 +0,0 @@ -////////////////////////////////////////////////////////////////////////////////////////////// -// DO NOT MODIFY THIS FILE // -// This file is automatically generated by ZenStack CLI and should not be manually updated. // -////////////////////////////////////////////////////////////////////////////////////////////// - -datasource db { - provider = "sqlite" - url = "file:./dev.db" -} - -enum Role { - ADMIN - USER -} - -model User { - id String @id() @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt() - email String @unique() - name String? - role Role @default(USER) - posts Post[] - profile Profile? -} - -model Profile { - id String @id() @default(cuid()) - bio String? - age Int? - user User? @relation(fields: [userId], references: [id]) - userId String? @unique() -} - -model Post { - id String @id() @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt() - title String - content String - published Boolean @default(false) - author User @relation(fields: [authorId], references: [id]) - authorId String -} \ No newline at end of file diff --git a/samples/blog/zenstack/schema.ts b/samples/blog/zenstack/schema.ts index 18211550..c1ad9a73 100644 --- a/samples/blog/zenstack/schema.ts +++ b/samples/blog/zenstack/schema.ts @@ -78,6 +78,16 @@ export const schema = { attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }], default: ExpressionUtils.call("cuid") }, + createdAt: { + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], + default: ExpressionUtils.call("now") + }, + updatedAt: { + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }] + }, bio: { type: "String", optional: true @@ -155,6 +165,27 @@ export const schema = { } } }, + typeDefs: { + CommonFields: { + fields: { + id: { + type: "String", + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }], + default: ExpressionUtils.call("cuid") + }, + createdAt: { + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], + default: ExpressionUtils.call("now") + }, + updatedAt: { + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }] + } + } + } + }, enums: { Role: { ADMIN: "ADMIN", diff --git a/samples/blog/zenstack/schema.zmodel b/samples/blog/zenstack/schema.zmodel index bc4d3ed3..aeccb56f 100644 --- a/samples/blog/zenstack/schema.zmodel +++ b/samples/blog/zenstack/schema.zmodel @@ -9,13 +9,16 @@ enum Role { USER } -/// User model -/// -/// Represents a user of the blog. -model User { +type CommonFields { id String @id @default(cuid()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt +} + +/// User model +/// +/// Represents a user of the blog. +model User with CommonFields { email String @unique name String? postCount Int @computed @@ -25,8 +28,7 @@ model User { } /// Profile model -model Profile { - id String @id @default(cuid()) +model Profile with CommonFields { bio String? age Int? user User? @relation(fields: [userId], references: [id]) @@ -34,10 +36,7 @@ model Profile { } /// Post model -model Post { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt +model Post with CommonFields { title String content String published Boolean @default(false) diff --git a/tests/e2e/prisma-consistency/attributes.test.ts b/tests/e2e/prisma-consistency/attributes.test.ts index be0eeb40..0e1b8044 100644 --- a/tests/e2e/prisma-consistency/attributes.test.ts +++ b/tests/e2e/prisma-consistency/attributes.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { ZenStackValidationTester, createTestDir, expectValidationSuccess, expectValidationFailure, baseSchema } from './test-utils'; +import { + ZenStackValidationTester, + createTestDir, + expectValidationSuccess, + expectValidationFailure, + baseSchema, +} from './test-utils'; describe('Attributes Validation', () => { let tester: ZenStackValidationTester; @@ -57,4 +63,4 @@ model User { expectValidationSuccess(result); }); -}); \ No newline at end of file +}); diff --git a/tests/e2e/prisma-consistency/basic-models.test.ts b/tests/e2e/prisma-consistency/basic-models.test.ts index 067ab9c9..bb075dbe 100644 --- a/tests/e2e/prisma-consistency/basic-models.test.ts +++ b/tests/e2e/prisma-consistency/basic-models.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { ZenStackValidationTester, createTestDir, expectValidationSuccess, expectValidationFailure, baseSchema } from './test-utils'; +import { + ZenStackValidationTester, + createTestDir, + expectValidationSuccess, + expectValidationFailure, + baseSchema, +} from './test-utils'; describe('Basic Models Validation', () => { let tester: ZenStackValidationTester; @@ -96,4 +102,4 @@ model User { expectValidationFailure(result); }); -}); \ No newline at end of file +}); diff --git a/tests/e2e/prisma-consistency/compound-ids.test.ts b/tests/e2e/prisma-consistency/compound-ids.test.ts index 90b42f9b..017d1f53 100644 --- a/tests/e2e/prisma-consistency/compound-ids.test.ts +++ b/tests/e2e/prisma-consistency/compound-ids.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { ZenStackValidationTester, createTestDir, expectValidationSuccess, expectValidationFailure, baseSchema } from './test-utils'; +import { + ZenStackValidationTester, + createTestDir, + expectValidationSuccess, + expectValidationFailure, + baseSchema, +} from './test-utils'; describe('Compound IDs Validation', () => { let tester: ZenStackValidationTester; @@ -44,4 +50,4 @@ model User { expectValidationFailure(result); }); -}); \ No newline at end of file +}); diff --git a/tests/e2e/prisma-consistency/datasource.test.ts b/tests/e2e/prisma-consistency/datasource.test.ts index 7746dbf8..2c02c0de 100644 --- a/tests/e2e/prisma-consistency/datasource.test.ts +++ b/tests/e2e/prisma-consistency/datasource.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { ZenStackValidationTester, createTestDir, expectValidationSuccess, expectValidationFailure, baseSchema } from './test-utils'; +import { + ZenStackValidationTester, + createTestDir, + expectValidationSuccess, + expectValidationFailure, + baseSchema, +} from './test-utils'; describe('Datasource Validation', () => { let tester: ZenStackValidationTester; @@ -61,4 +67,4 @@ model User { expectValidationFailure(result); }); -}); \ No newline at end of file +}); diff --git a/tests/e2e/prisma-consistency/enums.test.ts b/tests/e2e/prisma-consistency/enums.test.ts index 9a0719c3..c1e6cbc2 100644 --- a/tests/e2e/prisma-consistency/enums.test.ts +++ b/tests/e2e/prisma-consistency/enums.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { ZenStackValidationTester, createTestDir, expectValidationSuccess, expectValidationFailure, baseSchema } from './test-utils'; +import { + ZenStackValidationTester, + createTestDir, + expectValidationSuccess, + expectValidationFailure, + baseSchema, +} from './test-utils'; describe('Enums Validation', () => { let tester: ZenStackValidationTester; @@ -50,4 +56,4 @@ model User { expectValidationFailure(result); }); -}); \ No newline at end of file +}); diff --git a/tests/e2e/prisma-consistency/field-types.test.ts b/tests/e2e/prisma-consistency/field-types.test.ts index ac9a0081..d6a51931 100644 --- a/tests/e2e/prisma-consistency/field-types.test.ts +++ b/tests/e2e/prisma-consistency/field-types.test.ts @@ -1,5 +1,12 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { ZenStackValidationTester, createTestDir, expectValidationSuccess, expectValidationFailure, baseSchema, sqliteSchema } from './test-utils'; +import { + ZenStackValidationTester, + createTestDir, + expectValidationSuccess, + expectValidationFailure, + baseSchema, + sqliteSchema, +} from './test-utils'; describe('Field Types Validation', () => { let tester: ZenStackValidationTester; @@ -52,4 +59,4 @@ model User { expectValidationSuccess(result); }); -}); \ No newline at end of file +}); diff --git a/tests/e2e/prisma-consistency/relation-validation.test.ts b/tests/e2e/prisma-consistency/relation-validation.test.ts index acf35ee3..7ad878be 100644 --- a/tests/e2e/prisma-consistency/relation-validation.test.ts +++ b/tests/e2e/prisma-consistency/relation-validation.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { ZenStackValidationTester, createTestDir, expectValidationSuccess, expectValidationFailure, baseSchema } from './test-utils'; +import { + ZenStackValidationTester, + createTestDir, + expectValidationSuccess, + expectValidationFailure, + baseSchema, +} from './test-utils'; describe('Relation Validation Rules', () => { let tester: ZenStackValidationTester; @@ -160,4 +166,4 @@ model Post { expectValidationFailure(result); }); -}); \ No newline at end of file +}); diff --git a/tests/e2e/prisma-consistency/relations-many-to-many.test.ts b/tests/e2e/prisma-consistency/relations-many-to-many.test.ts index 7a89de96..9322948b 100644 --- a/tests/e2e/prisma-consistency/relations-many-to-many.test.ts +++ b/tests/e2e/prisma-consistency/relations-many-to-many.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { ZenStackValidationTester, createTestDir, expectValidationSuccess, expectValidationFailure, baseSchema } from './test-utils'; +import { + ZenStackValidationTester, + createTestDir, + expectValidationSuccess, + expectValidationFailure, + baseSchema, +} from './test-utils'; describe('Many-to-Many Relations Validation', () => { let tester: ZenStackValidationTester; @@ -82,4 +88,4 @@ model Post { expectValidationFailure(result); }); -}); \ No newline at end of file +}); diff --git a/tests/e2e/prisma-consistency/relations-one-to-many.test.ts b/tests/e2e/prisma-consistency/relations-one-to-many.test.ts index dc4048a8..506fc2d4 100644 --- a/tests/e2e/prisma-consistency/relations-one-to-many.test.ts +++ b/tests/e2e/prisma-consistency/relations-one-to-many.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { ZenStackValidationTester, createTestDir, expectValidationSuccess, expectValidationFailure, baseSchema } from './test-utils'; +import { + ZenStackValidationTester, + createTestDir, + expectValidationSuccess, + expectValidationFailure, + baseSchema, +} from './test-utils'; describe('One-to-Many Relations Validation', () => { let tester: ZenStackValidationTester; @@ -75,4 +81,4 @@ model Post { expectValidationFailure(result); }); -}); \ No newline at end of file +}); diff --git a/tests/e2e/prisma-consistency/relations-one-to-one.test.ts b/tests/e2e/prisma-consistency/relations-one-to-one.test.ts index b73726dd..10459241 100644 --- a/tests/e2e/prisma-consistency/relations-one-to-one.test.ts +++ b/tests/e2e/prisma-consistency/relations-one-to-one.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { ZenStackValidationTester, createTestDir, expectValidationSuccess, expectValidationFailure, baseSchema } from './test-utils'; +import { + ZenStackValidationTester, + createTestDir, + expectValidationSuccess, + expectValidationFailure, + baseSchema, +} from './test-utils'; describe('One-to-One Relations Validation', () => { let tester: ZenStackValidationTester; @@ -96,4 +102,4 @@ model Profile { expectValidationFailure(result); }); -}); \ No newline at end of file +}); diff --git a/tests/e2e/prisma-consistency/relations-self.test.ts b/tests/e2e/prisma-consistency/relations-self.test.ts index 49077c6a..6a283685 100644 --- a/tests/e2e/prisma-consistency/relations-self.test.ts +++ b/tests/e2e/prisma-consistency/relations-self.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { ZenStackValidationTester, createTestDir, expectValidationSuccess, expectValidationFailure, baseSchema } from './test-utils'; +import { + ZenStackValidationTester, + createTestDir, + expectValidationSuccess, + expectValidationFailure, + baseSchema, +} from './test-utils'; describe('Self Relations Validation', () => { let tester: ZenStackValidationTester; @@ -60,4 +66,4 @@ model User { expectValidationSuccess(result); }); -}); \ No newline at end of file +}); diff --git a/tests/e2e/prisma-consistency/test-utils.ts b/tests/e2e/prisma-consistency/test-utils.ts index 727426a5..ba6ba3a2 100644 --- a/tests/e2e/prisma-consistency/test-utils.ts +++ b/tests/e2e/prisma-consistency/test-utils.ts @@ -115,4 +115,4 @@ datasource db { provider = "sqlite" url = "file:./dev.db" } -`; \ No newline at end of file +`; diff --git a/tests/e2e/prisma-consistency/unique-constraints.test.ts b/tests/e2e/prisma-consistency/unique-constraints.test.ts index c39d456e..dbb0cbf4 100644 --- a/tests/e2e/prisma-consistency/unique-constraints.test.ts +++ b/tests/e2e/prisma-consistency/unique-constraints.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { ZenStackValidationTester, createTestDir, expectValidationSuccess, expectValidationFailure, baseSchema } from './test-utils'; +import { + ZenStackValidationTester, + createTestDir, + expectValidationSuccess, + expectValidationFailure, + baseSchema, +} from './test-utils'; describe('Unique Constraints Validation', () => { let tester: ZenStackValidationTester; @@ -60,4 +66,4 @@ model User { expectValidationSuccess(result); }); -}); \ No newline at end of file +}); diff --git a/turbo.json b/turbo.json index 31aad504..203466fc 100644 --- a/turbo.json +++ b/turbo.json @@ -3,7 +3,7 @@ "tasks": { "build": { "dependsOn": ["^build"], - "inputs": ["src/**"], + "inputs": ["src/**", "zenstack/*.zmodel"], "outputs": ["dist/**"] }, "lint": { From 55d4e773d9bc385688ff78b0e69cf7b31713d325 Mon Sep 17 00:00:00 2001 From: Yiming Cao Date: Mon, 21 Jul 2025 23:50:35 +0800 Subject: [PATCH 07/19] feat: strongly typed JSON fields (#107) --- packages/language/res/stdlib.zmodel | 2 +- packages/runtime/src/client/crud-types.ts | 62 ++--- .../src/client/crud/dialects/sqlite.ts | 19 +- .../src/client/crud/operations/base.ts | 3 +- packages/runtime/src/client/crud/validator.ts | 59 ++++- packages/runtime/src/client/query-builder.ts | 4 +- .../runtime/src/client/result-processor.ts | 21 +- .../test/client-api/typed-json-fields.test.ts | 222 ++++++++++++++++++ packages/runtime/test/typing/schema.ts | 8 +- .../runtime/test/typing/typing-test.zmodel | 15 +- packages/runtime/test/typing/verify-typing.ts | 19 ++ packages/sdk/src/schema/schema.ts | 8 +- 12 files changed, 376 insertions(+), 66 deletions(-) create mode 100644 packages/runtime/test/client-api/typed-json-fields.test.ts diff --git a/packages/language/res/stdlib.zmodel b/packages/language/res/stdlib.zmodel index 52f300bb..8f91957f 100644 --- a/packages/language/res/stdlib.zmodel +++ b/packages/language/res/stdlib.zmodel @@ -701,7 +701,7 @@ function raw(value: String): Any { /** * Marks a field to be strong-typed JSON. */ -attribute @json() @@@targetField([TypeDefField]) @@@deprecated('The "@json" attribute is not needed anymore. ZenStack will automatically use JSON to store typed fields.') +attribute @json() @@@targetField([TypeDefField]) /** * Marks a field to be computed. diff --git a/packages/runtime/src/client/crud-types.ts b/packages/runtime/src/client/crud-types.ts index c14cf401..821dac06 100644 --- a/packages/runtime/src/client/crud-types.ts +++ b/packages/runtime/src/client/crud-types.ts @@ -4,7 +4,6 @@ import type { FieldDef, FieldHasDefault, FieldIsArray, - FieldIsOptional, FieldIsRelation, FieldIsRelationArray, FieldType, @@ -19,12 +18,14 @@ import type { GetTypeDefField, GetTypeDefFields, GetTypeDefs, + ModelFieldIsOptional, NonRelationFields, RelationFields, RelationFieldType, RelationInfo, ScalarFields, SchemaDef, + TypeDefFieldIsOptional, } from '../schema'; import type { AtLeast, @@ -86,21 +87,21 @@ type ModelSelectResult Schema, RelationFieldType, Pick, - FieldIsOptional, + ModelFieldIsOptional, FieldIsArray > : ModelResult< Schema, RelationFieldType, Pick, - FieldIsOptional, + ModelFieldIsOptional, FieldIsArray > : DefaultModelResult< Schema, RelationFieldType, Omit, - FieldIsOptional, + ModelFieldIsOptional, FieldIsArray > : never; @@ -143,14 +144,14 @@ export type ModelResult< Schema, RelationFieldType, I[Key], - FieldIsOptional, + ModelFieldIsOptional, FieldIsArray > : DefaultModelResult< Schema, RelationFieldType, undefined, - FieldIsOptional, + ModelFieldIsOptional, FieldIsArray >; } @@ -169,9 +170,17 @@ export type SimplifiedModelResult< Array = false, > = Simplify>; -export type TypeDefResult> = { - [Key in GetTypeDefFields]: MapTypeDefFieldType; -}; +export type TypeDefResult> = Optional< + { + [Key in GetTypeDefFields]: MapTypeDefFieldType; + }, + // optionality + keyof { + [Key in GetTypeDefFields as TypeDefFieldIsOptional extends true + ? Key + : never]: Key; + } +>; export type BatchResult = { count: number }; @@ -193,11 +202,11 @@ export type WhereInput< RelationFilter : // enum GetModelFieldType extends GetEnums - ? EnumFilter, FieldIsOptional> + ? EnumFilter, ModelFieldIsOptional> : FieldIsArray extends true ? ArrayFilter> : // primitive - PrimitiveFilter, FieldIsOptional>; + PrimitiveFilter, ModelFieldIsOptional>; } & { $expr?: (eb: ExpressionBuilder, Model>) => OperandExpression; } & { @@ -290,7 +299,7 @@ export type OrderBy< WithRelation extends boolean, WithAggregation extends boolean, > = { - [Key in NonRelationFields]?: FieldIsOptional extends true + [Key in NonRelationFields]?: ModelFieldIsOptional extends true ? | SortOrder | { @@ -391,7 +400,7 @@ export type IncludeInput extends true ? true - : FieldIsOptional extends true + : ModelFieldIsOptional extends true ? true : false >; @@ -427,14 +436,14 @@ type ToOneRelationFilter< WhereInput> & { is?: NullableIf< WhereInput>, - FieldIsOptional + ModelFieldIsOptional >; isNot?: NullableIf< WhereInput>, - FieldIsOptional + ModelFieldIsOptional >; }, - FieldIsOptional + ModelFieldIsOptional >; type RelationFilter< @@ -460,23 +469,20 @@ type MapTypeDefFieldType< Schema extends SchemaDef, TypeDef extends GetTypeDefs, Field extends GetTypeDefFields, -> = - GetTypeDefField['type'] extends GetTypeDefs - ? WrapType< - TypeDefResult['type']>, - GetTypeDefField['optional'], - GetTypeDefField['array'] - > - : MapFieldDefType>; +> = MapFieldDefType>; type MapFieldDefType> = WrapType< - T['type'] extends GetEnums ? keyof GetEnum : MapBaseType, + T['type'] extends GetEnums + ? keyof GetEnum + : T['type'] extends GetTypeDefs + ? TypeDefResult & Record + : MapBaseType, T['optional'], T['array'] >; type OptionalFieldsForCreate> = keyof { - [Key in GetModelFields as FieldIsOptional extends true + [Key in GetModelFields as ModelFieldIsOptional extends true ? Key : FieldHasDefault extends true ? Key @@ -752,7 +758,7 @@ type ScalarUpdatePayload< | MapModelFieldType | (Field extends NumericFields ? { - set?: NullableIf>; + set?: NullableIf>; increment?: number; decrement?: number; multiply?: number; @@ -820,7 +826,7 @@ type ToOneRelationUpdateInput< connectOrCreate?: ConnectOrCreateInput; update?: NestedUpdateInput; upsert?: NestedUpsertInput; -} & (FieldIsOptional extends true +} & (ModelFieldIsOptional extends true ? { disconnect?: DisconnectInput; delete?: NestedDeleteInput; diff --git a/packages/runtime/src/client/crud/dialects/sqlite.ts b/packages/runtime/src/client/crud/dialects/sqlite.ts index 7fa67905..c27cd7de 100644 --- a/packages/runtime/src/client/crud/dialects/sqlite.ts +++ b/packages/runtime/src/client/crud/dialects/sqlite.ts @@ -34,13 +34,18 @@ export class SqliteCrudDialect extends BaseCrudDialect if (Array.isArray(value)) { return value.map((v) => this.transformPrimitive(v, type, false)); } else { - return match(type) - .with('Boolean', () => (value ? 1 : 0)) - .with('DateTime', () => (value instanceof Date ? value.toISOString() : value)) - .with('Decimal', () => (value as Decimal).toString()) - .with('Bytes', () => Buffer.from(value as Uint8Array)) - .with('Json', () => JSON.stringify(value)) - .otherwise(() => value); + if (this.schema.typeDefs && type in this.schema.typeDefs) { + // typed JSON field + return JSON.stringify(value); + } else { + return match(type) + .with('Boolean', () => (value ? 1 : 0)) + .with('DateTime', () => (value instanceof Date ? value.toISOString() : value)) + .with('Decimal', () => (value as Decimal).toString()) + .with('Bytes', () => Buffer.from(value as Uint8Array)) + .with('Json', () => JSON.stringify(value)) + .otherwise(() => value); + } } } diff --git a/packages/runtime/src/client/crud/operations/base.ts b/packages/runtime/src/client/crud/operations/base.ts index 32775ef8..64e4efee 100644 --- a/packages/runtime/src/client/crud/operations/base.ts +++ b/packages/runtime/src/client/crud/operations/base.ts @@ -493,7 +493,8 @@ export abstract class BaseOperationHandler { const idFields = getIdFields(this.schema, model); const query = kysely .insertInto(model) - .values(updatedData) + .$if(Object.keys(updatedData).length === 0, (qb) => qb.defaultValues()) + .$if(Object.keys(updatedData).length > 0, (qb) => qb.values(updatedData)) .returning(idFields as any) .modifyEnd( this.makeContextComment({ diff --git a/packages/runtime/src/client/crud/validator.ts b/packages/runtime/src/client/crud/validator.ts index 00dc4f2c..cad8e953 100644 --- a/packages/runtime/src/client/crud/validator.ts +++ b/packages/runtime/src/client/crud/validator.ts @@ -218,16 +218,46 @@ export class InputValidator { } private makePrimitiveSchema(type: string) { - return match(type) - .with('String', () => z.string()) - .with('Int', () => z.number()) - .with('Float', () => z.number()) - .with('Boolean', () => z.boolean()) - .with('BigInt', () => z.union([z.number(), z.bigint()])) - .with('Decimal', () => z.union([z.number(), z.instanceof(Decimal), z.string()])) - .with('DateTime', () => z.union([z.date(), z.string().datetime()])) - .with('Bytes', () => z.instanceof(Uint8Array)) - .otherwise(() => z.unknown()); + if (this.schema.typeDefs && type in this.schema.typeDefs) { + return this.makeTypeDefSchema(type); + } else { + return match(type) + .with('String', () => z.string()) + .with('Int', () => z.number()) + .with('Float', () => z.number()) + .with('Boolean', () => z.boolean()) + .with('BigInt', () => z.union([z.number(), z.bigint()])) + .with('Decimal', () => z.union([z.number(), z.instanceof(Decimal), z.string()])) + .with('DateTime', () => z.union([z.date(), z.string().datetime()])) + .with('Bytes', () => z.instanceof(Uint8Array)) + .otherwise(() => z.unknown()); + } + } + + private makeTypeDefSchema(type: string): z.ZodType { + const key = `$typedef-${type}`; + let schema = this.schemaCache.get(key); + if (schema) { + return schema; + } + const typeDef = this.schema.typeDefs?.[type]; + invariant(typeDef, `Type definition "${type}" not found in schema`); + schema = z.looseObject( + Object.fromEntries( + Object.entries(typeDef.fields).map(([field, def]) => { + let fieldSchema = this.makePrimitiveSchema(def.type); + if (def.array) { + fieldSchema = fieldSchema.array(); + } + if (def.optional) { + fieldSchema = fieldSchema.optional(); + } + return [field, fieldSchema]; + }), + ), + ); + this.schemaCache.set(key, schema); + return schema; } private makeWhereSchema(model: string, unique: boolean, withoutRelationFields = false): ZodType { @@ -396,6 +426,10 @@ export class InputValidator { } private makePrimitiveFilterSchema(type: BuiltinType, optional: boolean) { + if (this.schema.typeDefs && type in this.schema.typeDefs) { + // typed JSON field + return this.makeTypeDefFilterSchema(type, optional); + } return ( match(type) .with('String', () => this.makeStringFilterSchema(optional)) @@ -412,6 +446,11 @@ export class InputValidator { ); } + private makeTypeDefFilterSchema(_type: string, _optional: boolean) { + // TODO: strong typed JSON filtering + return z.never(); + } + private makeDateTimeFilterSchema(optional: boolean): ZodType { return this.makeCommonPrimitiveFilterSchema(z.union([z.string().datetime(), z.date()]), optional, () => z.lazy(() => this.makeDateTimeFilterSchema(optional)), diff --git a/packages/runtime/src/client/query-builder.ts b/packages/runtime/src/client/query-builder.ts index 64b76ea0..19997017 100644 --- a/packages/runtime/src/client/query-builder.ts +++ b/packages/runtime/src/client/query-builder.ts @@ -2,11 +2,11 @@ import type Decimal from 'decimal.js'; import type { Generated, Kysely } from 'kysely'; import type { FieldHasDefault, - FieldIsOptional, ForeignKeyFields, GetModelFields, GetModelFieldType, GetModels, + ModelFieldIsOptional, ScalarFields, SchemaDef, } from '../schema'; @@ -45,7 +45,7 @@ type MapType< Schema extends SchemaDef, Model extends GetModels, Field extends GetModelFields, -> = WrapNull>, FieldIsOptional>; +> = WrapNull>, ModelFieldIsOptional>; type toKyselyFieldType< Schema extends SchemaDef, diff --git a/packages/runtime/src/client/result-processor.ts b/packages/runtime/src/client/result-processor.ts index 25a2a4df..a43e4648 100644 --- a/packages/runtime/src/client/result-processor.ts +++ b/packages/runtime/src/client/result-processor.ts @@ -84,14 +84,19 @@ export class ResultProcessor { } private transformScalar(value: unknown, type: BuiltinType) { - return match(type) - .with('Boolean', () => this.transformBoolean(value)) - .with('DateTime', () => this.transformDate(value)) - .with('Bytes', () => this.transformBytes(value)) - .with('Decimal', () => this.transformDecimal(value)) - .with('BigInt', () => this.transformBigInt(value)) - .with('Json', () => this.transformJson(value)) - .otherwise(() => value); + if (this.schema.typeDefs && type in this.schema.typeDefs) { + // typed JSON field + return this.transformJson(value); + } else { + return match(type) + .with('Boolean', () => this.transformBoolean(value)) + .with('DateTime', () => this.transformDate(value)) + .with('Bytes', () => this.transformBytes(value)) + .with('Decimal', () => this.transformDecimal(value)) + .with('BigInt', () => this.transformBigInt(value)) + .with('Json', () => this.transformJson(value)) + .otherwise(() => value); + } } private transformDecimal(value: unknown) { diff --git a/packages/runtime/test/client-api/typed-json-fields.test.ts b/packages/runtime/test/client-api/typed-json-fields.test.ts new file mode 100644 index 00000000..fdf01f81 --- /dev/null +++ b/packages/runtime/test/client-api/typed-json-fields.test.ts @@ -0,0 +1,222 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createTestClient } from '../utils'; + +const PG_DB_NAME = 'client-api-typed-json-fields-tests'; + +describe.each([{ provider: 'sqlite' as const }, { provider: 'postgresql' as const }])( + 'Typed JSON fields', + ({ provider }) => { + const schema = ` +type Identity { + providers IdentityProvider[] +} + +type IdentityProvider { + id String + name String? +} + +model User { + id Int @id @default(autoincrement()) + identity Identity? @json +} + `; + + let client: any; + + beforeEach(async () => { + client = await createTestClient(schema, { + usePrismaPush: true, + provider, + dbName: provider === 'postgresql' ? PG_DB_NAME : undefined, + log: ['query'], + }); + }); + + afterEach(async () => { + await client?.$disconnect(); + }); + + it('works with create', async () => { + await expect( + client.user.create({ + data: {}, + }), + ).resolves.toMatchObject({ + identity: null, + }); + + await expect( + client.user.create({ + data: { + identity: { + providers: [ + { + id: '123', + name: 'Google', + }, + ], + }, + }, + }), + ).resolves.toMatchObject({ + identity: { + providers: [ + { + id: '123', + name: 'Google', + }, + ], + }, + }); + + await expect( + client.user.create({ + data: { + identity: { + providers: [ + { + id: '123', + }, + ], + }, + }, + }), + ).resolves.toMatchObject({ + identity: { + providers: [ + { + id: '123', + }, + ], + }, + }); + + await expect( + client.user.create({ + data: { + identity: { + providers: [ + { + id: '123', + foo: 1, + }, + ], + }, + }, + }), + ).resolves.toMatchObject({ + identity: { + providers: [ + { + id: '123', + foo: 1, + }, + ], + }, + }); + + await expect( + client.user.create({ + data: { + identity: { + providers: [ + { + name: 'Google', + }, + ], + }, + }, + }), + ).rejects.toThrow('Invalid input'); + }); + + it('works with find', async () => { + await expect( + client.user.create({ + data: { id: 1 }, + }), + ).toResolveTruthy(); + await expect(client.user.findUnique({ where: { id: 1 } })).resolves.toMatchObject({ + identity: null, + }); + + await expect( + client.user.create({ + data: { + id: 2, + identity: { + providers: [ + { + id: '123', + name: 'Google', + }, + ], + }, + }, + }), + ).toResolveTruthy(); + + await expect(client.user.findUnique({ where: { id: 2 } })).resolves.toMatchObject({ + identity: { + providers: [ + { + id: '123', + name: 'Google', + }, + ], + }, + }); + }); + + it('works with update', async () => { + await expect( + client.user.create({ + data: { id: 1 }, + }), + ).toResolveTruthy(); + + await expect( + client.user.update({ + where: { id: 1 }, + data: { + identity: { + providers: [ + { + id: '123', + name: 'Google', + foo: 1, + }, + ], + }, + }, + }), + ).resolves.toMatchObject({ + identity: { + providers: [ + { + id: '123', + name: 'Google', + foo: 1, + }, + ], + }, + }); + + await expect( + client.user.update({ + where: { id: 1 }, + data: { + identity: { + providers: [ + { + name: 'GitHub', + }, + ], + }, + }, + }), + ).rejects.toThrow('Invalid input'); + }); + }, +); diff --git a/packages/runtime/test/typing/schema.ts b/packages/runtime/test/typing/schema.ts index d529dbf3..49bf584e 100644 --- a/packages/runtime/test/typing/schema.ts +++ b/packages/runtime/test/typing/schema.ts @@ -56,6 +56,11 @@ export const schema = { type: "Int", attributes: [{ name: "@computed" }], computed: true + }, + identity: { + type: "Identity", + optional: true, + attributes: [{ name: "@json" }] } }, idFields: ["id"], @@ -261,7 +266,8 @@ export const schema = { type: "String" }, name: { - type: "String" + type: "String", + optional: true } } } diff --git a/packages/runtime/test/typing/typing-test.zmodel b/packages/runtime/test/typing/typing-test.zmodel index 2aa9aa67..2cb789d7 100644 --- a/packages/runtime/test/typing/typing-test.zmodel +++ b/packages/runtime/test/typing/typing-test.zmodel @@ -14,19 +14,20 @@ type Identity { type IdentityProvider { id String - name String + name String? } model User { - id Int @id @default(autoincrement()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt name String - email String @unique - role Role @default(USER) + email String @unique + role Role @default(USER) posts Post[] profile Profile? - postCount Int @computed + postCount Int @computed + identity Identity? @json } model Post { diff --git a/packages/runtime/test/typing/verify-typing.ts b/packages/runtime/test/typing/verify-typing.ts index e9410031..e815758f 100644 --- a/packages/runtime/test/typing/verify-typing.ts +++ b/packages/runtime/test/typing/verify-typing.ts @@ -40,6 +40,7 @@ async function find() { }); console.log(user1?.name); console.log(user1?.postCount); + console.log(user1?.identity?.providers[0]?.name); const users = await client.user.findMany({ include: { posts: true }, @@ -206,6 +207,24 @@ async function create() { userId: 1, }, }, + identity: { + providers: [ + { + id: '123', + name: 'GitHub', + // undeclared fields are allowed + otherField: 123, + }, + { + id: '234', + // name is optional + }, + // @ts-expect-error id is required + { + name: 'Google', + }, + ], + }, }, }); diff --git a/packages/sdk/src/schema/schema.ts b/packages/sdk/src/schema/schema.ts index 7ef99516..208024a8 100644 --- a/packages/sdk/src/schema/schema.ts +++ b/packages/sdk/src/schema/schema.ts @@ -187,12 +187,18 @@ export type RelationFieldType< ? GetModelField['type'] : never; -export type FieldIsOptional< +export type ModelFieldIsOptional< Schema extends SchemaDef, Model extends GetModels, Field extends GetModelFields, > = GetModelField['optional'] extends true ? true : false; +export type TypeDefFieldIsOptional< + Schema extends SchemaDef, + TypeDef extends GetTypeDefs, + Field extends GetTypeDefFields, +> = GetTypeDefField['optional'] extends true ? true : false; + export type FieldIsRelation< Schema extends SchemaDef, Model extends GetModels, From ca2a4288b4fde64271770fbfbddde65ff96a9d03 Mon Sep 17 00:00:00 2001 From: Yiming Cao Date: Tue, 22 Jul 2025 13:31:18 +0800 Subject: [PATCH 08/19] test: more cases for mixins (#108) --- .../runtime/test/client-api/mixin.test.ts | 73 ++++++++++++++++++- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/packages/runtime/test/client-api/mixin.test.ts b/packages/runtime/test/client-api/mixin.test.ts index 8e888aac..23655f05 100644 --- a/packages/runtime/test/client-api/mixin.test.ts +++ b/packages/runtime/test/client-api/mixin.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'; import { createTestClient } from '../utils'; describe('Client API Mixins', () => { - const schema = ` + it('includes fields and attributes from mixins', async () => { + const schema = ` type TimeStamped { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -27,7 +28,6 @@ model Bar with CommonFields { } `; - it('includes fields and attributes from mixins', async () => { const client = await createTestClient(schema, { usePrismaPush: true, }); @@ -77,4 +77,73 @@ model Bar with CommonFields { }), ).rejects.toThrow('constraint failed'); }); + + it('supports multiple-level mixins', async () => { + const schema = ` + type Base1 { + id String @id @default(cuid()) + } + + type Base2 with Base1 { + fieldA String + } + + model A with Base2 { + field String + b B[] + } + + model B { + id String @id @default(cuid()) + a A @relation(fields: [aId], references: [id]) + aId String + } + `; + + const client = await createTestClient(schema); + await expect( + client.b.create({ + data: { + a: { + create: { + field: 'test', + fieldA: 'testA', + }, + }, + }, + include: { a: true }, + }), + ).resolves.toMatchObject({ + a: { + id: expect.any(String), + field: 'test', + fieldA: 'testA', + }, + }); + }); + + it('works with multiple id fields from base', async () => { + const schema = ` + type Base { + id1 String + id2 String + value String + @@id([id1, id2]) + } + + model Item with Base { + x String + } + `; + + const client = await createTestClient(schema); + await expect( + client.item.create({ + data: { id1: '1', id2: '2', value: 'test', x: 'x' }, + }), + ).resolves.toMatchObject({ + id1: '1', + id2: '2', + }); + }); }); From c80e7249e0c48f6b6c6ebae50ed3925603293c95 Mon Sep 17 00:00:00 2001 From: Yiming Cao Date: Tue, 22 Jul 2025 13:34:38 +0800 Subject: [PATCH 09/19] chore: change zod to peer dependency (#109) --- packages/runtime/package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 2ef29296..1b23dc63 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -73,12 +73,12 @@ "nanoid": "^5.0.9", "ts-pattern": "catalog:", "ulid": "^3.0.0", - "uuid": "^11.0.5", - "zod": "catalog:" + "uuid": "^11.0.5" }, "peerDependencies": { "better-sqlite3": "^11.8.1", - "pg": "^8.13.1" + "pg": "^8.13.1", + "zod": "catalog:" }, "peerDependenciesMeta": { "better-sqlite3": { From cbf1ce3366efae3b603749b2dea5d5fa1fef4590 Mon Sep 17 00:00:00 2001 From: Yiming Cao Date: Wed, 23 Jul 2025 22:54:28 +0800 Subject: [PATCH 10/19] feat: implement delegate models (create + read) (#110) * feat: implement delegate models (create + read) * update * update * update --- packages/cli/test/ts-schema-gen.test.ts | 59 +++++ packages/language/src/ast.ts | 10 +- packages/language/src/utils.ts | 41 +++- .../src/validators/datamodel-validator.ts | 38 +++- packages/language/src/zmodel-linker.ts | 9 +- packages/language/src/zmodel-scope.ts | 6 +- packages/language/test/delegate.test.ts | 92 ++++++++ packages/runtime/src/client/constants.ts | 5 + .../runtime/src/client/crud/dialects/base.ts | 6 +- .../src/client/crud/operations/base.ts | 137 +++++++++++- .../src/client/crud/operations/create.ts | 6 + packages/runtime/src/client/crud/validator.ts | 26 ++- packages/runtime/src/client/query-utils.ts | 39 +++- .../runtime/src/client/result-processor.ts | 26 ++- .../test/client-api/default-values.test.ts | 9 + .../runtime/test/client-api/delegate.test.ts | 206 ++++++++++++++++++ .../runtime/test/client-api/mixin.test.ts | 2 +- .../test/client-api/name-mapping.test.ts | 3 + packages/runtime/test/test-schema/schema.ts | 38 ++++ packages/runtime/test/typing/schema.ts | 47 ++++ packages/sdk/src/model-utils.ts | 11 + .../sdk/src/prisma/prisma-schema-generator.ts | 140 ++++++++++-- packages/sdk/src/schema/schema.ts | 6 + packages/sdk/src/ts-schema-generator.ts | 70 +++++- samples/blog/zenstack/schema.ts | 31 +++ 25 files changed, 983 insertions(+), 80 deletions(-) create mode 100644 packages/language/test/delegate.test.ts create mode 100644 packages/runtime/test/client-api/delegate.test.ts diff --git a/packages/cli/test/ts-schema-gen.test.ts b/packages/cli/test/ts-schema-gen.test.ts index 2ec04048..cd34de58 100644 --- a/packages/cli/test/ts-schema-gen.test.ts +++ b/packages/cli/test/ts-schema-gen.test.ts @@ -266,4 +266,63 @@ type Address with Base { }, }); }); + + it('merges fields and attributes from base models', async () => { + const { schema } = await generateTsSchema(` +model Base { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + type String + @@delegate(type) +} + +model User extends Base { + email String @unique +} + `); + expect(schema).toMatchObject({ + models: { + Base: { + fields: { + id: { + type: 'String', + id: true, + default: expect.objectContaining({ function: 'uuid', kind: 'call' }), + }, + createdAt: { + type: 'DateTime', + default: expect.objectContaining({ function: 'now', kind: 'call' }), + }, + updatedAt: { type: 'DateTime', updatedAt: true }, + type: { type: 'String' }, + }, + attributes: [ + { + name: '@@delegate', + args: [{ name: 'discriminator', value: { kind: 'field', field: 'type' } }], + }, + ], + isDelegate: true, + }, + User: { + baseModel: 'Base', + fields: { + id: { type: 'String' }, + createdAt: { + type: 'DateTime', + default: expect.objectContaining({ function: 'now', kind: 'call' }), + originModel: 'Base', + }, + updatedAt: { type: 'DateTime', updatedAt: true, originModel: 'Base' }, + type: { type: 'String', originModel: 'Base' }, + email: { type: 'String' }, + }, + uniqueFields: expect.objectContaining({ + email: { type: 'String' }, + }), + }, + }, + }); + }); }); diff --git a/packages/language/src/ast.ts b/packages/language/src/ast.ts index c301eac4..71d31d4d 100644 --- a/packages/language/src/ast.ts +++ b/packages/language/src/ast.ts @@ -1,5 +1,5 @@ import type { AstNode } from 'langium'; -import { AbstractDeclaration, BinaryExpr, DataModel, type ExpressionType } from './generated/ast'; +import { AbstractDeclaration, BinaryExpr, DataField, DataModel, type ExpressionType } from './generated/ast'; export type { AstNode, Reference } from 'langium'; export * from './generated/ast'; @@ -46,14 +46,6 @@ declare module './ast' { $resolvedParam?: AttributeParam; } - interface DataField { - $inheritedFrom?: DataModel; - } - - interface DataModelAttribute { - $inheritedFrom?: DataModel; - } - export interface DataModel { /** * All fields including those marked with `@ignore` diff --git a/packages/language/src/utils.ts b/packages/language/src/utils.ts index f661bbc5..adb4f78f 100644 --- a/packages/language/src/utils.ts +++ b/packages/language/src/utils.ts @@ -161,10 +161,6 @@ export function resolved(ref: Reference): T { return ref.ref; } -export function getModelFieldsWithBases(model: DataModel, includeDelegate = true) { - return [...model.fields, ...getRecursiveBases(model, includeDelegate).flatMap((base) => base.fields)]; -} - export function getRecursiveBases( decl: DataModel | TypeDef, includeDelegate = true, @@ -533,22 +529,51 @@ export function isMemberContainer(node: unknown): node is DataModel | TypeDef { return isDataModel(node) || isTypeDef(node); } -export function getAllFields(decl: DataModel | TypeDef, includeIgnored = false): DataField[] { +export function getAllFields( + decl: DataModel | TypeDef, + includeIgnored = false, + seen: Set = new Set(), +): DataField[] { + if (seen.has(decl)) { + return []; + } + seen.add(decl); + const fields: DataField[] = []; for (const mixin of decl.mixins) { invariant(mixin.ref, `Mixin ${mixin.$refText} is not resolved`); - fields.push(...getAllFields(mixin.ref)); + fields.push(...getAllFields(mixin.ref, includeIgnored, seen)); + } + + if (isDataModel(decl) && decl.baseModel) { + invariant(decl.baseModel.ref, `Base model ${decl.baseModel.$refText} is not resolved`); + fields.push(...getAllFields(decl.baseModel.ref, includeIgnored, seen)); } + fields.push(...decl.fields.filter((f) => includeIgnored || !hasAttribute(f, '@ignore'))); return fields; } -export function getAllAttributes(decl: DataModel | TypeDef): DataModelAttribute[] { +export function getAllAttributes( + decl: DataModel | TypeDef, + seen: Set = new Set(), +): DataModelAttribute[] { + if (seen.has(decl)) { + return []; + } + seen.add(decl); + const attributes: DataModelAttribute[] = []; for (const mixin of decl.mixins) { invariant(mixin.ref, `Mixin ${mixin.$refText} is not resolved`); - attributes.push(...getAllAttributes(mixin.ref)); + attributes.push(...getAllAttributes(mixin.ref, seen)); + } + + if (isDataModel(decl) && decl.baseModel) { + invariant(decl.baseModel.ref, `Base model ${decl.baseModel.$refText} is not resolved`); + attributes.push(...getAllAttributes(decl.baseModel.ref, seen)); } + attributes.push(...decl.attributes); return attributes; } diff --git a/packages/language/src/validators/datamodel-validator.ts b/packages/language/src/validators/datamodel-validator.ts index 5d7f9919..49c8dfc7 100644 --- a/packages/language/src/validators/datamodel-validator.ts +++ b/packages/language/src/validators/datamodel-validator.ts @@ -1,3 +1,4 @@ +import { invariant } from '@zenstackhq/common-helpers'; import { AstUtils, type AstNode, type DiagnosticInfo, type ValidationAcceptor } from 'langium'; import { IssueCodes, SCALAR_TYPES } from '../constants'; import { @@ -16,8 +17,8 @@ import { } from '../generated/ast'; import { getAllAttributes, + getAllFields, getLiteral, - getModelFieldsWithBases, getModelIdFields, getModelUniqueFields, getUniqueFields, @@ -32,7 +33,7 @@ import { validateDuplicatedDeclarations, type AstValidator } from './common'; */ export default class DataModelValidator implements AstValidator { validate(dm: DataModel, accept: ValidationAcceptor): void { - validateDuplicatedDeclarations(dm, getModelFieldsWithBases(dm), accept); + validateDuplicatedDeclarations(dm, getAllFields(dm), accept); this.validateAttributes(dm, accept); this.validateFields(dm, accept); if (dm.mixins.length > 0) { @@ -42,7 +43,7 @@ export default class DataModelValidator implements AstValidator { } private validateFields(dm: DataModel, accept: ValidationAcceptor) { - const allFields = getModelFieldsWithBases(dm); + const allFields = getAllFields(dm); const idFields = allFields.filter((f) => f.attributes.find((attr) => attr.decl.ref?.name === '@id')); const uniqueFields = allFields.filter((f) => f.attributes.find((attr) => attr.decl.ref?.name === '@unique')); const modelLevelIds = getModelIdFields(dm); @@ -266,7 +267,7 @@ export default class DataModelValidator implements AstValidator { const oppositeModel = field.type.reference!.ref! as DataModel; // Use name because the current document might be updated - let oppositeFields = getModelFieldsWithBases(oppositeModel, false).filter( + let oppositeFields = getAllFields(oppositeModel, false).filter( (f) => f !== field && // exclude self in case of self relation f.type.reference?.ref?.name === contextModel.name, @@ -438,11 +439,38 @@ export default class DataModelValidator implements AstValidator { if (!model.baseModel) { return; } - if (model.baseModel.ref && !isDelegateModel(model.baseModel.ref)) { + + invariant(model.baseModel.ref, 'baseModel must be resolved'); + + // check if the base model is a delegate model + if (!isDelegateModel(model.baseModel.ref)) { accept('error', `Model ${model.baseModel.$refText} cannot be extended because it's not a delegate model`, { node: model, property: 'baseModel', }); + return; + } + + // check for cyclic inheritance + const seen: DataModel[] = []; + const todo = [model.baseModel.ref]; + while (todo.length > 0) { + const current = todo.shift()!; + if (seen.includes(current)) { + accept( + 'error', + `Cyclic inheritance detected: ${seen.map((m) => m.name).join(' -> ')} -> ${current.name}`, + { + node: model, + }, + ); + return; + } + seen.push(current); + if (current.baseModel) { + invariant(current.baseModel.ref, 'baseModel must be resolved'); + todo.push(current.baseModel.ref); + } } } diff --git a/packages/language/src/zmodel-linker.ts b/packages/language/src/zmodel-linker.ts index 1867b368..65a2cb84 100644 --- a/packages/language/src/zmodel-linker.ts +++ b/packages/language/src/zmodel-linker.ts @@ -20,9 +20,9 @@ import { AttributeParam, BinaryExpr, BooleanLiteral, - DataModel, DataField, DataFieldType, + DataModel, Enum, EnumField, type ExpressionType, @@ -43,19 +43,19 @@ import { UnaryExpr, isArrayExpr, isBooleanLiteral, - isDataModel, isDataField, isDataFieldType, + isDataModel, isEnum, isNumberLiteral, isReferenceExpr, isStringLiteral, } from './ast'; import { + getAllFields, getAllLoadedAndReachableDataModelsAndTypeDefs, getAuthDecl, getContainingDataModel, - getModelFieldsWithBases, isAuthInvocation, isFutureExpr, isMemberContainer, @@ -397,8 +397,7 @@ export class ZModelLinker extends DefaultLinker { const transitiveDataModel = attrAppliedOn.type.reference?.ref as DataModel; if (transitiveDataModel) { // resolve references in the context of the transitive data model - const scopeProvider = (name: string) => - getModelFieldsWithBases(transitiveDataModel).find((f) => f.name === name); + const scopeProvider = (name: string) => getAllFields(transitiveDataModel).find((f) => f.name === name); if (isArrayExpr(node.value)) { node.value.items.forEach((item) => { if (isReferenceExpr(item)) { diff --git a/packages/language/src/zmodel-scope.ts b/packages/language/src/zmodel-scope.ts index e95ac0b7..e2b58f02 100644 --- a/packages/language/src/zmodel-scope.ts +++ b/packages/language/src/zmodel-scope.ts @@ -19,8 +19,8 @@ import { match } from 'ts-pattern'; import { BinaryExpr, MemberAccessExpr, - isDataModel, isDataField, + isDataModel, isEnumField, isInvocationExpr, isMemberAccessExpr, @@ -31,9 +31,9 @@ import { } from './ast'; import { PLUGIN_MODULE_NAME, STD_LIB_MODULE_NAME } from './constants'; import { + getAllFields, getAllLoadedAndReachableDataModelsAndTypeDefs, getAuthDecl, - getModelFieldsWithBases, getRecursiveBases, isAuthInvocation, isCollectionPredicate, @@ -231,7 +231,7 @@ export class ZModelScopeProvider extends DefaultScopeProvider { private createScopeForContainer(node: AstNode | undefined, globalScope: Scope, includeTypeDefScope = false) { if (isDataModel(node)) { - return this.createScopeForNodes(getModelFieldsWithBases(node), globalScope); + return this.createScopeForNodes(getAllFields(node), globalScope); } else if (includeTypeDefScope && isTypeDef(node)) { return this.createScopeForNodes(node.fields, globalScope); } else { diff --git a/packages/language/test/delegate.test.ts b/packages/language/test/delegate.test.ts new file mode 100644 index 00000000..185be2bc --- /dev/null +++ b/packages/language/test/delegate.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +import { DataModel } from '../src/ast'; +import { loadSchema, loadSchemaWithError } from './utils'; + +describe('Delegate Tests', () => { + it('supports inheriting from delegate', async () => { + const model = await loadSchema(` + model A { + id Int @id @default(autoincrement()) + x String + @@delegate(x) + } + + model B extends A { + y String + } + `); + const a = model.declarations.find((d) => d.name === 'A') as DataModel; + expect(a.baseModel).toBeUndefined(); + const b = model.declarations.find((d) => d.name === 'B') as DataModel; + expect(b.baseModel?.ref).toBe(a); + }); + + it('rejects inheriting from non-delegate models', async () => { + await loadSchemaWithError( + ` + model A { + id Int @id @default(autoincrement()) + x String + } + + model B extends A { + y String + } + `, + 'not a delegate model', + ); + }); + + it('can detect cyclic inherits', async () => { + await loadSchemaWithError( + ` + model A extends B { + x String + @@delegate(x) + } + + model B extends A { + y String + @@delegate(y) + } + `, + 'cyclic', + ); + }); + + it('can detect duplicated fields from base model', async () => { + await loadSchemaWithError( + ` + model A { + id String @id + x String + @@delegate(x) + } + + model B extends A { + x String + } + `, + 'duplicated', + ); + }); + + it('can detect duplicated attributes from base model', async () => { + await loadSchemaWithError( + ` + model A { + id String @id + x String + @@id([x]) + @@delegate(x) + } + + model B extends A { + y String + @@id([y]) + } + `, + 'can only be applied once', + ); + }); +}); diff --git a/packages/runtime/src/client/constants.ts b/packages/runtime/src/client/constants.ts index c80a247a..746cb900 100644 --- a/packages/runtime/src/client/constants.ts +++ b/packages/runtime/src/client/constants.ts @@ -12,3 +12,8 @@ export const NUMERIC_FIELD_TYPES = ['Int', 'Float', 'BigInt', 'Decimal']; * Client API methods that are not supported in transactions. */ export const TRANSACTION_UNSUPPORTED_METHODS = ['$transaction', '$disconnect', '$use'] as const; + +/** + * Prefix for JSON field used to store joined delegate rows. + */ +export const DELEGATE_JOINED_FIELD_PREFIX = '$delegate$'; diff --git a/packages/runtime/src/client/crud/dialects/base.ts b/packages/runtime/src/client/crud/dialects/base.ts index daa154e9..7d4e6fb2 100644 --- a/packages/runtime/src/client/crud/dialects/base.ts +++ b/packages/runtime/src/client/crud/dialects/base.ts @@ -578,7 +578,7 @@ export abstract class BaseCrudDialect { private buildNumberFilter( eb: ExpressionBuilder, model: string, - table: string, + modelAlias: string, field: string, type: BuiltinType, payload: any, @@ -587,9 +587,9 @@ export abstract class BaseCrudDialect { eb, type, payload, - buildFieldRef(this.schema, model, field, this.options, eb), + buildFieldRef(this.schema, model, field, this.options, eb, modelAlias), (value) => this.transformPrimitive(value, type, false), - (value) => this.buildNumberFilter(eb, model, table, field, type, value), + (value) => this.buildNumberFilter(eb, model, modelAlias, field, type, value), ); return this.and(eb, ...conditions); } diff --git a/packages/runtime/src/client/crud/operations/base.ts b/packages/runtime/src/client/crud/operations/base.ts index 64e4efee..a4bb5c11 100644 --- a/packages/runtime/src/client/crud/operations/base.ts +++ b/packages/runtime/src/client/crud/operations/base.ts @@ -22,7 +22,7 @@ import { ExpressionUtils, type GetModels, type ModelDef, type SchemaDef } from ' import { clone } from '../../../utils/clone'; import { enumerate } from '../../../utils/enumerate'; import { extractFields, fieldsToSelectObject } from '../../../utils/object-utils'; -import { CONTEXT_COMMENT_PREFIX, NUMERIC_FIELD_TYPES } from '../../constants'; +import { CONTEXT_COMMENT_PREFIX, DELEGATE_JOINED_FIELD_PREFIX, NUMERIC_FIELD_TYPES } from '../../constants'; import type { CRUD } from '../../contract'; import type { FindArgs, SelectIncludeOmit, SortOrder, WhereInput } from '../../crud-types'; import { InternalError, NotFoundError, QueryError } from '../../errors'; @@ -31,7 +31,9 @@ import { buildFieldRef, buildJoinPairs, ensureArray, + extractIdFields, flattenCompoundUniqueFilters, + getDiscriminatorField, getField, getIdFields, getIdValues, @@ -39,6 +41,7 @@ import { getModel, getRelationForeignKeyFieldPairs, isForeignKeyField, + isInheritedField, isRelationField, isScalarField, makeDefaultOrderBy, @@ -245,6 +248,7 @@ export abstract class BaseOperationHandler { parentAlias: string, ) { let result = query; + const joinedBases: string[] = []; for (const [field, payload] of Object.entries(selectOrInclude)) { if (!payload) { @@ -258,7 +262,7 @@ export abstract class BaseOperationHandler { const fieldDef = this.requireField(model, field); if (!fieldDef.relation) { - result = this.selectField(result, model, parentAlias, field); + result = this.selectField(result, model, parentAlias, field, joinedBases); } else { if (!fieldDef.array && !fieldDef.optional && payload.where) { throw new QueryError(`Field "${field}" doesn't support filtering`); @@ -334,21 +338,95 @@ export abstract class BaseOperationHandler { omit?: Record, ) { const modelDef = this.requireModel(model); - return Object.keys(modelDef.fields) - .filter((f) => !isRelationField(this.schema, model, f)) - .filter((f) => omit?.[f] !== true) - .reduce((acc, f) => this.selectField(acc, model, model, f), query); + let result = query; + const joinedBases: string[] = []; + + for (const field of Object.keys(modelDef.fields)) { + if (isRelationField(this.schema, model, field)) { + continue; + } + if (omit?.[field] === true) { + continue; + } + result = this.selectField(result, model, model, field, joinedBases); + } + + // select all fields from delegate descendants and pack into a JSON field `$delegate$Model` + const descendants = this.getDelegateDescendantModels(model); + for (const subModel of descendants) { + if (!joinedBases.includes(subModel.name)) { + joinedBases.push(subModel.name); + result = this.buildDelegateJoin(model, subModel.name, result); + } + result = result.select((eb) => { + const jsonObject: Record> = {}; + for (const field of Object.keys(subModel.fields)) { + if ( + isRelationField(this.schema, subModel.name, field) || + isInheritedField(this.schema, subModel.name, field) + ) { + continue; + } + jsonObject[field] = eb.ref(`${subModel.name}.${field}`); + } + return this.dialect + .buildJsonObject(eb, jsonObject) + .as(`${DELEGATE_JOINED_FIELD_PREFIX}${subModel.name}`); + }); + } + + return result; + } + + private getDelegateDescendantModels(model: string, collected: Set = new Set()): ModelDef[] { + const subModels = Object.values(this.schema.models).filter((m) => m.baseModel === model); + subModels.forEach((def) => { + if (!collected.has(def)) { + collected.add(def); + this.getDelegateDescendantModels(def.name, collected); + } + }); + return [...collected]; } - private selectField(query: SelectQueryBuilder, model: string, modelAlias: string, field: string) { + private selectField( + query: SelectQueryBuilder, + model: string, + modelAlias: string, + field: string, + joinedBases: string[], + ) { const fieldDef = this.requireField(model, field); - if (!fieldDef.computed) { + + if (fieldDef.computed) { + // TODO: computed field from delegate base? + return query.select((eb) => buildFieldRef(this.schema, model, field, this.options, eb).as(field)); + } else if (!fieldDef.originModel) { + // regular field return query.select(sql.ref(`${modelAlias}.${field}`).as(field)); } else { - return query.select((eb) => buildFieldRef(this.schema, model, field, this.options, eb).as(field)); + // field from delegate base, build a join + let result = query; + if (!joinedBases.includes(fieldDef.originModel)) { + joinedBases.push(fieldDef.originModel); + result = this.buildDelegateJoin(model, fieldDef.originModel, result); + } + result = this.selectField(result, fieldDef.originModel, fieldDef.originModel, field, joinedBases); + return result; } } + private buildDelegateJoin(thisModel: string, otherModel: string, query: SelectQueryBuilder) { + const idFields = getIdFields(this.schema, thisModel); + query = query.leftJoin(otherModel, (qb) => { + for (const idField of idFields) { + qb = qb.onRef(`${thisModel}.${idField}`, '=', `${otherModel}.${idField}`); + } + return qb; + }); + return query; + } + private buildCursorFilter( model: string, query: SelectQueryBuilder, @@ -399,7 +477,7 @@ export abstract class BaseOperationHandler { fromRelation?: FromRelationContext, ): Promise { const modelDef = this.requireModel(model); - const createFields: any = {}; + let createFields: any = {}; let parentUpdateTask: ((entity: any) => Promise) | undefined = undefined; let m2m: ReturnType = undefined; @@ -489,6 +567,12 @@ export abstract class BaseOperationHandler { } } + // create delegate base model entity + if (modelDef.baseModel) { + const baseCreateResult = await this.processBaseModelCreate(kysely, modelDef.baseModel, createFields, model); + createFields = baseCreateResult.remainingFields; + } + const updatedData = this.fillGeneratedValues(modelDef, createFields); const idFields = getIdFields(this.schema, model); const query = kysely @@ -547,6 +631,33 @@ export abstract class BaseOperationHandler { return createdEntity; } + private async processBaseModelCreate(kysely: ToKysely, model: string, createFields: any, forModel: string) { + const thisCreateFields: any = {}; + const remainingFields: any = {}; + + Object.entries(createFields).forEach(([field, value]) => { + const fieldDef = this.getField(model, field); + if (fieldDef) { + thisCreateFields[field] = value; + } else { + remainingFields[field] = value; + } + }); + + const discriminatorField = getDiscriminatorField(this.schema, model); + invariant(discriminatorField, `Base model "${model}" must have a discriminator field`); + thisCreateFields[discriminatorField] = forModel; + + // create base model entity + const createResult = await this.create(kysely, model as GetModels, thisCreateFields); + + // copy over id fields from base model + const idValues = extractIdFields(createResult, this.schema, model); + Object.assign(remainingFields, idValues); + + return { baseEntity: createResult, remainingFields }; + } + private buildFkAssignments(model: string, relationField: string, entity: any) { const parentFkFields: any = {}; @@ -848,7 +959,11 @@ export abstract class BaseOperationHandler { private fillGeneratedValues(modelDef: ModelDef, data: object) { const fields = modelDef.fields; const values: any = clone(data); - for (const field in fields) { + for (const [field, fieldDef] of Object.entries(fields)) { + if (fieldDef.originModel) { + // skip fields from delegate base + continue; + } if (!(field in data)) { if (typeof fields[field]?.default === 'object' && 'kind' in fields[field].default) { const generated = this.evalGenerator(fields[field].default); diff --git a/packages/runtime/src/client/crud/operations/create.ts b/packages/runtime/src/client/crud/operations/create.ts index bc15bb36..e097d475 100644 --- a/packages/runtime/src/client/crud/operations/create.ts +++ b/packages/runtime/src/client/crud/operations/create.ts @@ -4,9 +4,15 @@ import type { GetModels, SchemaDef } from '../../../schema'; import type { CreateArgs, CreateManyAndReturnArgs, CreateManyArgs, WhereInput } from '../../crud-types'; import { getIdValues } from '../../query-utils'; import { BaseOperationHandler } from './base'; +import { QueryError } from '../../errors'; export class CreateOperationHandler extends BaseOperationHandler { async handle(operation: 'create' | 'createMany' | 'createManyAndReturn', args: unknown | undefined) { + const modelDef = this.requireModel(this.model); + if (modelDef.isDelegate) { + throw new QueryError(`Model "${this.model}" is a delegate and cannot be created directly.`); + } + // normalize args to strip `undefined` fields const normalizedArgs = this.normalizeArgs(args); diff --git a/packages/runtime/src/client/crud/validator.ts b/packages/runtime/src/client/crud/validator.ts index cad8e953..c4c7a9d1 100644 --- a/packages/runtime/src/client/crud/validator.ts +++ b/packages/runtime/src/client/crud/validator.ts @@ -3,7 +3,7 @@ import Decimal from 'decimal.js'; import stableStringify from 'json-stable-stringify'; import { match, P } from 'ts-pattern'; import { z, ZodType } from 'zod'; -import type { BuiltinType, EnumDef, FieldDef, GetModels, SchemaDef } from '../../schema'; +import { type BuiltinType, type EnumDef, type FieldDef, type GetModels, type SchemaDef } from '../../schema'; import { NUMERIC_FIELD_TYPES } from '../constants'; import { type AggregateArgs, @@ -21,7 +21,15 @@ import { type UpsertArgs, } from '../crud-types'; import { InputValidationError, InternalError, QueryError } from '../errors'; -import { fieldHasDefaultValue, getEnum, getModel, getUniqueFields, requireField, requireModel } from '../query-utils'; +import { + fieldHasDefaultValue, + getDiscriminatorField, + getEnum, + getModel, + getUniqueFields, + requireField, + requireModel, +} from '../query-utils'; type GetSchemaFunc = (model: GetModels, options: Options) => ZodType; @@ -705,6 +713,11 @@ export class InputValidator { return; } + if (this.isDelegateDiscriminator(fieldDef)) { + // discriminator field is auto-assigned + return; + } + if (fieldDef.relation) { if (withoutRelationFields) { return; @@ -791,6 +804,15 @@ export class InputValidator { } } + private isDelegateDiscriminator(fieldDef: FieldDef) { + if (!fieldDef.originModel) { + // not inherited from a delegate + return false; + } + const discriminatorField = getDiscriminatorField(this.schema, fieldDef.originModel); + return discriminatorField === fieldDef.name; + } + private makeRelationManipulationSchema(fieldDef: FieldDef, withoutFields: string[], mode: 'create' | 'update') { const fieldType = fieldDef.type; const array = !!fieldDef.array; diff --git a/packages/runtime/src/client/query-utils.ts b/packages/runtime/src/client/query-utils.ts index 2f341673..8c47b895 100644 --- a/packages/runtime/src/client/query-utils.ts +++ b/packages/runtime/src/client/query-utils.ts @@ -1,5 +1,5 @@ import type { ExpressionBuilder, ExpressionWrapper } from 'kysely'; -import type { FieldDef, GetModels, SchemaDef } from '../schema'; +import { ExpressionUtils, type FieldDef, type GetModels, type SchemaDef } from '../schema'; import type { OrderBy } from './crud-types'; import { InternalError, QueryError } from './errors'; import type { ClientOptions } from './options'; @@ -111,6 +111,11 @@ export function isRelationField(schema: SchemaDef, model: string, field: string) return !!fieldDef.relation; } +export function isInheritedField(schema: SchemaDef, model: string, field: string): boolean { + const fieldDef = requireField(schema, model, field); + return !!fieldDef.originModel; +} + export function getUniqueFields(schema: SchemaDef, model: string) { const modelDef = requireModel(schema, model); const result: Array< @@ -276,3 +281,35 @@ export function safeJSONStringify(value: unknown) { } }); } + +export function extractFields(object: any, fields: string[]) { + return fields.reduce((acc: any, field) => { + if (field in object) { + acc[field] = object[field]; + } + return acc; + }, {}); +} + +export function extractIdFields(entity: any, schema: SchemaDef, model: string) { + const idFields = getIdFields(schema, model); + return idFields.reduce((acc: any, field) => { + if (field in entity) { + acc[field] = entity[field]; + } + return acc; + }, {}); +} + +export function getDiscriminatorField(schema: SchemaDef, model: string) { + const modelDef = requireModel(schema, model); + const delegateAttr = modelDef.attributes?.find((attr) => attr.name === '@@delegate'); + if (!delegateAttr) { + return undefined; + } + const discriminator = delegateAttr.args?.find((arg) => arg.name === 'discriminator'); + if (!discriminator || !ExpressionUtils.isField(discriminator.value)) { + throw new InternalError(`Discriminator field not defined for model "${model}"`); + } + return discriminator.value.field; +} diff --git a/packages/runtime/src/client/result-processor.ts b/packages/runtime/src/client/result-processor.ts index a43e4648..c7aa230a 100644 --- a/packages/runtime/src/client/result-processor.ts +++ b/packages/runtime/src/client/result-processor.ts @@ -2,7 +2,8 @@ import { invariant } from '@zenstackhq/common-helpers'; import Decimal from 'decimal.js'; import { match } from 'ts-pattern'; import type { BuiltinType, FieldDef, GetModels, SchemaDef } from '../schema'; -import { ensureArray, getField } from './query-utils'; +import { DELEGATE_JOINED_FIELD_PREFIX } from './constants'; +import { ensureArray, getField, getIdValues } from './query-utils'; export class ResultProcessor { constructor(private readonly schema: Schema) {} @@ -38,6 +39,29 @@ export class ResultProcessor { continue; } + if (key.startsWith(DELEGATE_JOINED_FIELD_PREFIX)) { + // merge delegate descendant fields + if (value) { + // descendant fields are packed as JSON + const subRow = this.transformJson(value); + + // process the sub-row + const subModel = key.slice(DELEGATE_JOINED_FIELD_PREFIX.length) as GetModels; + const idValues = getIdValues(this.schema, subModel, subRow); + if (Object.values(idValues).some((v) => v === null || v === undefined)) { + // if the row doesn't have a valid id, the joined row doesn't exist + delete data[key]; + continue; + } + const processedSubRow = this.processRow(subRow, subRow); + + // merge the sub-row into the main row + Object.assign(data, processedSubRow); + } + delete data[key]; + continue; + } + const fieldDef = getField(this.schema, model, key); if (!fieldDef) { continue; diff --git a/packages/runtime/test/client-api/default-values.test.ts b/packages/runtime/test/client-api/default-values.test.ts index d5c93fbd..cafedad1 100644 --- a/packages/runtime/test/client-api/default-values.test.ts +++ b/packages/runtime/test/client-api/default-values.test.ts @@ -12,37 +12,46 @@ const schema = { }, models: { Model: { + name: 'Model', fields: { uuid: { + name: 'uuid', type: 'String', id: true, default: ExpressionUtils.call('uuid'), }, uuid7: { + name: 'uuid7', type: 'String', default: ExpressionUtils.call('uuid', [ExpressionUtils.literal(7)]), }, cuid: { + name: 'cuid', type: 'String', default: ExpressionUtils.call('cuid'), }, cuid2: { + name: 'cuid2', type: 'String', default: ExpressionUtils.call('cuid', [ExpressionUtils.literal(2)]), }, nanoid: { + name: 'nanoid', type: 'String', default: ExpressionUtils.call('nanoid'), }, nanoid8: { + name: 'nanoid8', type: 'String', default: ExpressionUtils.call('nanoid', [ExpressionUtils.literal(8)]), }, ulid: { + name: 'ulid', type: 'String', default: ExpressionUtils.call('ulid'), }, dt: { + name: 'dt', type: 'DateTime', default: ExpressionUtils.call('now'), }, diff --git a/packages/runtime/test/client-api/delegate.test.ts b/packages/runtime/test/client-api/delegate.test.ts new file mode 100644 index 00000000..b1e4fc83 --- /dev/null +++ b/packages/runtime/test/client-api/delegate.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from 'vitest'; +import { createTestClient } from '../utils'; + +describe('Delegate model tests', () => { + const POLYMORPHIC_SCHEMA = ` +model User { + id Int @id @default(autoincrement()) + email String? @unique + level Int @default(0) + assets Asset[] + ratedVideos RatedVideo[] @relation('direct') +} + +model Asset { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + viewCount Int @default(0) + owner User? @relation(fields: [ownerId], references: [id]) + ownerId Int? + assetType String + + @@delegate(assetType) +} + +model Video extends Asset { + duration Int + url String + videoType String + + @@delegate(videoType) +} + +model RatedVideo extends Video { + rating Int + user User? @relation(name: 'direct', fields: [userId], references: [id]) + userId Int? +} + +model Image extends Asset { + format String + gallery Gallery? @relation(fields: [galleryId], references: [id]) + galleryId Int? +} + +model Gallery { + id Int @id @default(autoincrement()) + images Image[] +} +`; + + it('works with create', async () => { + const client = await createTestClient(POLYMORPHIC_SCHEMA, { + usePrismaPush: true, + }); + + // delegate model cannot be created directly + await expect( + client.video.create({ + data: { + duration: 100, + url: 'abc', + videoType: 'MyVideo', + }, + }), + ).rejects.toThrow('is a delegate'); + + // create entity with two levels of delegation + await expect( + client.ratedVideo.create({ + data: { + duration: 100, + url: 'abc', + rating: 5, + }, + }), + ).resolves.toMatchObject({ + id: expect.any(Number), + duration: 100, + url: 'abc', + rating: 5, + assetType: 'Video', + videoType: 'RatedVideo', + }); + + // create entity with relation + await expect( + client.ratedVideo.create({ + data: { + duration: 50, + url: 'bcd', + rating: 5, + user: { create: { email: 'u1@example.com' } }, + }, + include: { user: true }, + }), + ).resolves.toMatchObject({ + userId: expect.any(Number), + user: { + email: 'u1@example.com', + }, + }); + + // create entity with one level of delegation + await expect( + client.image.create({ + data: { + format: 'png', + gallery: { + create: {}, + }, + }, + }), + ).resolves.toMatchObject({ + id: expect.any(Number), + format: 'png', + galleryId: expect.any(Number), + assetType: 'Image', + }); + }); + + it('works with find', async () => { + const client = await createTestClient(POLYMORPHIC_SCHEMA, { + usePrismaPush: true, + log: ['query'], + }); + + const u = await client.user.create({ + data: { + email: 'u1@example.com', + }, + }); + const v = await client.ratedVideo.create({ + data: { + duration: 100, + url: 'abc', + rating: 5, + user: { connect: { id: u.id } }, + }, + include: { user: true }, + }); + + const ratedVideoContent = { + id: v.id, + createdAt: expect.any(Date), + duration: 100, + rating: 5, + assetType: 'Video', + videoType: 'RatedVideo', + }; + + // include all base fields + await expect( + client.ratedVideo.findUnique({ + where: { id: v.id }, + include: { user: true }, + }), + ).resolves.toMatchObject({ ...ratedVideoContent, user: expect.any(Object) }); + + // select fields + await expect( + client.ratedVideo.findUnique({ + where: { id: v.id }, + select: { + id: true, + viewCount: true, + url: true, + rating: true, + }, + }), + ).resolves.toEqual({ + id: v.id, + viewCount: 0, + url: 'abc', + rating: 5, + }); + + // omit fields + const r = await client.ratedVideo.findUnique({ + where: { id: v.id }, + omit: { + viewCount: true, + url: true, + rating: true, + }, + }); + expect(r.viewCount).toBeUndefined(); + expect(r.url).toBeUndefined(); + expect(r.rating).toBeUndefined(); + expect(r.duration).toEqual(expect.any(Number)); + + // include all sub fields + await expect( + client.video.findUnique({ + where: { id: v.id }, + }), + ).resolves.toMatchObject(ratedVideoContent); + + // include all sub fields + await expect( + client.asset.findUnique({ + where: { id: v.id }, + }), + ).resolves.toMatchObject(ratedVideoContent); + }); +}); diff --git a/packages/runtime/test/client-api/mixin.test.ts b/packages/runtime/test/client-api/mixin.test.ts index 23655f05..ffbdbf2f 100644 --- a/packages/runtime/test/client-api/mixin.test.ts +++ b/packages/runtime/test/client-api/mixin.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { createTestClient } from '../utils'; -describe('Client API Mixins', () => { +describe('Mixin tests', () => { it('includes fields and attributes from mixins', async () => { const schema = ` type TimeStamped { diff --git a/packages/runtime/test/client-api/name-mapping.test.ts b/packages/runtime/test/client-api/name-mapping.test.ts index 7c7ca42d..ded45ad0 100644 --- a/packages/runtime/test/client-api/name-mapping.test.ts +++ b/packages/runtime/test/client-api/name-mapping.test.ts @@ -10,13 +10,16 @@ describe('Name mapping tests', () => { }, models: { Foo: { + name: 'Foo', fields: { id: { + name: 'id', type: 'String', id: true, default: ExpressionUtils.call('uuid'), }, x: { + name: 'x', type: 'Int', attributes: [ { diff --git a/packages/runtime/test/test-schema/schema.ts b/packages/runtime/test/test-schema/schema.ts index db61c902..e2c8fb52 100644 --- a/packages/runtime/test/test-schema/schema.ts +++ b/packages/runtime/test/test-schema/schema.ts @@ -12,43 +12,52 @@ export const schema = { }, models: { User: { + name: "User", fields: { id: { + name: "id", type: "String", id: true, attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }], default: ExpressionUtils.call("cuid") }, createdAt: { + name: "createdAt", type: "DateTime", attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], default: ExpressionUtils.call("now") }, updatedAt: { + name: "updatedAt", type: "DateTime", updatedAt: true, attributes: [{ name: "@updatedAt" }] }, email: { + name: "email", type: "String", unique: true, attributes: [{ name: "@unique" }] }, name: { + name: "name", type: "String", optional: true }, role: { + name: "role", type: "Role", attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal("USER") }] }], default: "USER" }, posts: { + name: "posts", type: "Post", array: true, relation: { opposite: "author" } }, profile: { + name: "profile", type: "Profile", optional: true, relation: { opposite: "user" } @@ -65,47 +74,57 @@ export const schema = { } }, Post: { + name: "Post", fields: { id: { + name: "id", type: "String", id: true, attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }], default: ExpressionUtils.call("cuid") }, createdAt: { + name: "createdAt", type: "DateTime", attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], default: ExpressionUtils.call("now") }, updatedAt: { + name: "updatedAt", type: "DateTime", updatedAt: true, attributes: [{ name: "@updatedAt" }] }, title: { + name: "title", type: "String" }, content: { + name: "content", type: "String", optional: true }, published: { + name: "published", type: "Boolean", attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal(false) }] }], default: false }, author: { + name: "author", type: "User", attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array([ExpressionUtils.field("authorId")]) }, { name: "references", value: ExpressionUtils.array([ExpressionUtils.field("id")]) }, { name: "onUpdate", value: ExpressionUtils.literal("Cascade") }, { name: "onDelete", value: ExpressionUtils.literal("Cascade") }] }], relation: { opposite: "posts", fields: ["authorId"], references: ["id"], onUpdate: "Cascade", onDelete: "Cascade" } }, authorId: { + name: "authorId", type: "String", foreignKeyFor: [ "author" ] }, comments: { + name: "comments", type: "Comment", array: true, relation: { opposite: "post" } @@ -122,33 +141,40 @@ export const schema = { } }, Comment: { + name: "Comment", fields: { id: { + name: "id", type: "String", id: true, attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }], default: ExpressionUtils.call("cuid") }, createdAt: { + name: "createdAt", type: "DateTime", attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], default: ExpressionUtils.call("now") }, updatedAt: { + name: "updatedAt", type: "DateTime", updatedAt: true, attributes: [{ name: "@updatedAt" }] }, content: { + name: "content", type: "String" }, post: { + name: "post", type: "Post", optional: true, attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array([ExpressionUtils.field("postId")]) }, { name: "references", value: ExpressionUtils.array([ExpressionUtils.field("id")]) }, { name: "onUpdate", value: ExpressionUtils.literal("Cascade") }, { name: "onDelete", value: ExpressionUtils.literal("Cascade") }] }], relation: { opposite: "comments", fields: ["postId"], references: ["id"], onUpdate: "Cascade", onDelete: "Cascade" } }, postId: { + name: "postId", type: "String", optional: true, foreignKeyFor: [ @@ -162,37 +188,45 @@ export const schema = { } }, Profile: { + name: "Profile", fields: { id: { + name: "id", type: "String", id: true, attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }], default: ExpressionUtils.call("cuid") }, createdAt: { + name: "createdAt", type: "DateTime", attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], default: ExpressionUtils.call("now") }, updatedAt: { + name: "updatedAt", type: "DateTime", updatedAt: true, attributes: [{ name: "@updatedAt" }] }, bio: { + name: "bio", type: "String" }, age: { + name: "age", type: "Int", optional: true }, user: { + name: "user", type: "User", optional: true, attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array([ExpressionUtils.field("userId")]) }, { name: "references", value: ExpressionUtils.array([ExpressionUtils.field("id")]) }, { name: "onUpdate", value: ExpressionUtils.literal("Cascade") }, { name: "onDelete", value: ExpressionUtils.literal("Cascade") }] }], relation: { opposite: "profile", fields: ["userId"], references: ["id"], onUpdate: "Cascade", onDelete: "Cascade" } }, userId: { + name: "userId", type: "String", unique: true, optional: true, @@ -211,18 +245,22 @@ export const schema = { }, typeDefs: { CommonFields: { + name: "CommonFields", fields: { id: { + name: "id", type: "String", attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }], default: ExpressionUtils.call("cuid") }, createdAt: { + name: "createdAt", type: "DateTime", attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], default: ExpressionUtils.call("now") }, updatedAt: { + name: "updatedAt", type: "DateTime", updatedAt: true, attributes: [{ name: "@updatedAt" }] diff --git a/packages/runtime/test/typing/schema.ts b/packages/runtime/test/typing/schema.ts index 49bf584e..de56a9dc 100644 --- a/packages/runtime/test/typing/schema.ts +++ b/packages/runtime/test/typing/schema.ts @@ -12,52 +12,63 @@ export const schema = { }, models: { User: { + name: "User", fields: { id: { + name: "id", type: "Int", id: true, attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }], default: ExpressionUtils.call("autoincrement") }, createdAt: { + name: "createdAt", type: "DateTime", attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], default: ExpressionUtils.call("now") }, updatedAt: { + name: "updatedAt", type: "DateTime", updatedAt: true, attributes: [{ name: "@updatedAt" }] }, name: { + name: "name", type: "String" }, email: { + name: "email", type: "String", unique: true, attributes: [{ name: "@unique" }] }, role: { + name: "role", type: "Role", attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal("USER") }] }], default: "USER" }, posts: { + name: "posts", type: "Post", array: true, relation: { opposite: "author" } }, profile: { + name: "profile", type: "Profile", optional: true, relation: { opposite: "user" } }, postCount: { + name: "postCount", type: "Int", attributes: [{ name: "@computed" }], computed: true }, identity: { + name: "identity", type: "Identity", optional: true, attributes: [{ name: "@json" }] @@ -75,36 +86,44 @@ export const schema = { } }, Post: { + name: "Post", fields: { id: { + name: "id", type: "Int", id: true, attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }], default: ExpressionUtils.call("autoincrement") }, title: { + name: "title", type: "String" }, content: { + name: "content", type: "String" }, author: { + name: "author", type: "User", attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array([ExpressionUtils.field("authorId")]) }, { name: "references", value: ExpressionUtils.array([ExpressionUtils.field("id")]) }] }], relation: { opposite: "posts", fields: ["authorId"], references: ["id"] } }, authorId: { + name: "authorId", type: "Int", foreignKeyFor: [ "author" ] }, tags: { + name: "tags", type: "Tag", array: true, relation: { opposite: "posts" } }, meta: { + name: "meta", type: "Meta", optional: true, relation: { opposite: "post" } @@ -116,23 +135,28 @@ export const schema = { } }, Profile: { + name: "Profile", fields: { id: { + name: "id", type: "Int", id: true, attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }], default: ExpressionUtils.call("autoincrement") }, age: { + name: "age", type: "Int" }, region: { + name: "region", type: "Region", optional: true, attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array([ExpressionUtils.field("regionCountry"), ExpressionUtils.field("regionCity")]) }, { name: "references", value: ExpressionUtils.array([ExpressionUtils.field("country"), ExpressionUtils.field("city")]) }] }], relation: { opposite: "profiles", fields: ["regionCountry", "regionCity"], references: ["country", "city"] } }, regionCountry: { + name: "regionCountry", type: "String", optional: true, foreignKeyFor: [ @@ -140,6 +164,7 @@ export const schema = { ] }, regionCity: { + name: "regionCity", type: "String", optional: true, foreignKeyFor: [ @@ -147,11 +172,13 @@ export const schema = { ] }, user: { + name: "user", type: "User", attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array([ExpressionUtils.field("userId")]) }, { name: "references", value: ExpressionUtils.array([ExpressionUtils.field("id")]) }] }], relation: { opposite: "profile", fields: ["userId"], references: ["id"] } }, userId: { + name: "userId", type: "Int", unique: true, attributes: [{ name: "@unique" }], @@ -167,17 +194,21 @@ export const schema = { } }, Tag: { + name: "Tag", fields: { id: { + name: "id", type: "Int", id: true, attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }], default: ExpressionUtils.call("autoincrement") }, name: { + name: "name", type: "String" }, posts: { + name: "posts", type: "Post", array: true, relation: { opposite: "tags" } @@ -189,20 +220,25 @@ export const schema = { } }, Region: { + name: "Region", fields: { country: { + name: "country", type: "String", id: true }, city: { + name: "city", type: "String", id: true }, zip: { + name: "zip", type: "String", optional: true }, profiles: { + name: "profiles", type: "Profile", array: true, relation: { opposite: "region" } @@ -217,25 +253,31 @@ export const schema = { } }, Meta: { + name: "Meta", fields: { id: { + name: "id", type: "Int", id: true, attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }], default: ExpressionUtils.call("autoincrement") }, reviewed: { + name: "reviewed", type: "Boolean" }, published: { + name: "published", type: "Boolean" }, post: { + name: "post", type: "Post", attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array([ExpressionUtils.field("postId")]) }, { name: "references", value: ExpressionUtils.array([ExpressionUtils.field("id")]) }] }], relation: { opposite: "meta", fields: ["postId"], references: ["id"] } }, postId: { + name: "postId", type: "Int", unique: true, attributes: [{ name: "@unique" }], @@ -253,19 +295,24 @@ export const schema = { }, typeDefs: { Identity: { + name: "Identity", fields: { providers: { + name: "providers", type: "IdentityProvider", array: true } } }, IdentityProvider: { + name: "IdentityProvider", fields: { id: { + name: "id", type: "String" }, name: { + name: "name", type: "String", optional: true } diff --git a/packages/sdk/src/model-utils.ts b/packages/sdk/src/model-utils.ts index 14532938..3ab4a01e 100644 --- a/packages/sdk/src/model-utils.ts +++ b/packages/sdk/src/model-utils.ts @@ -109,3 +109,14 @@ export function getAuthDecl(model: Model) { } return found; } + +export function getIdFields(dm: DataModel) { + return getAllFields(dm) + .filter((f) => isIdField(f, dm)) + .map((f) => f.name); +} + +/** + * Prefix for auxiliary relation fields generated for delegated models + */ +export const DELEGATE_AUX_RELATION_PREFIX = 'delegate_aux'; diff --git a/packages/sdk/src/prisma/prisma-schema-generator.ts b/packages/sdk/src/prisma/prisma-schema-generator.ts index aa4c9172..116ee872 100644 --- a/packages/sdk/src/prisma/prisma-schema-generator.ts +++ b/packages/sdk/src/prisma/prisma-schema-generator.ts @@ -1,3 +1,4 @@ +import { lowerCaseFirst } from '@zenstackhq/common-helpers'; import { AttributeArg, BooleanLiteral, @@ -16,6 +17,7 @@ import { GeneratorDecl, InvocationExpr, isArrayExpr, + isDataModel, isInvocationExpr, isLiteralExpr, isModel, @@ -29,12 +31,13 @@ import { StringLiteral, type AstNode, } from '@zenstackhq/language/ast'; +import { getAllAttributes, getAllFields, isDelegateModel } from '@zenstackhq/language/utils'; import { AstUtils } from 'langium'; import { match } from 'ts-pattern'; - -import { getAllAttributes, getAllFields } from '@zenstackhq/language/utils'; import { ModelUtils, ZModelCodeGenerator } from '..'; +import { DELEGATE_AUX_RELATION_PREFIX, getIdFields } from '../model-utils'; import { + AttributeArgValue, ModelFieldType, AttributeArg as PrismaAttributeArg, AttributeArgValue as PrismaAttributeArgValue, @@ -51,6 +54,10 @@ import { type SimpleField, } from './prisma-builder'; +// Some database providers like postgres and mysql have default limit to the length of identifiers +// Here we use a conservative value that should work for most cases, and truncate names if needed +const IDENTIFIER_NAME_MAX_LENGTH = 50 - DELEGATE_AUX_RELATION_PREFIX.length; + /** * Generates Prisma schema file */ @@ -62,6 +69,9 @@ export class PrismaSchemaGenerator { `; + // a mapping from full names to shortened names + private shortNameMap = new Map(); + constructor(private readonly zmodel: Model) {} async generate() { @@ -155,8 +165,10 @@ export class PrismaSchemaGenerator { if (ModelUtils.hasAttribute(field, '@computed')) { continue; // skip computed fields } - // TODO: exclude fields inherited from delegate - this.generateModelField(model, field, decl); + // exclude non-id fields inherited from delegate + if (ModelUtils.isIdField(field, decl) || !this.isInheritedFromDelegate(field, decl)) { + this.generateModelField(model, field, decl); + } } const allAttributes = getAllAttributes(decl); @@ -167,21 +179,11 @@ export class PrismaSchemaGenerator { // user defined comments pass-through decl.comments.forEach((c) => model.addComment(c)); - // TODO: delegate model handling - // // physical: generate relation fields on base models linking to concrete models - // this.generateDelegateRelationForBase(model, decl); - - // TODO: delegate model handling - // // physical: generate reverse relation fields on concrete models - // this.generateDelegateRelationForConcrete(model, decl); + // generate relation fields on base models linking to concrete models + this.generateDelegateRelationForBase(model, decl); - // TODO: delegate model handling - // // logical: expand relations on other models that reference delegated models to concrete models - // this.expandPolymorphicRelations(model, decl); - - // TODO: delegate model handling - // // logical: ensure relations inherited from delegate models - // this.ensureRelationsInheritedFromDelegate(model, decl); + // generate reverse relation fields on concrete models + this.generateDelegateRelationForConcrete(model, decl); } private isPrismaAttribute(attr: DataModelAttribute | DataFieldAttribute) { @@ -247,7 +249,7 @@ export class PrismaSchemaGenerator { // when building physical schema, exclude `@default` for id fields inherited from delegate base !( ModelUtils.isIdField(field, contextModel) && - this.isInheritedFromDelegate(field) && + this.isInheritedFromDelegate(field, contextModel) && attr.decl.$refText === '@default' ), ) @@ -276,8 +278,8 @@ export class PrismaSchemaGenerator { return !!model && !!model.$document && model.$document.uri.path.endsWith('plugin.zmodel'); } - private isInheritedFromDelegate(field: DataField) { - return field.$inheritedFrom && ModelUtils.isDelegateModel(field.$inheritedFrom); + private isInheritedFromDelegate(field: DataField, contextModel: DataModel) { + return field.$container !== contextModel && ModelUtils.isDelegateModel(field.$container); } private makeFieldAttribute(attr: DataFieldAttribute) { @@ -375,4 +377,100 @@ export class PrismaSchemaGenerator { const docs = [...field.comments]; _enum.addField(field.name, attributes, docs); } + + private generateDelegateRelationForBase(model: PrismaDataModel, decl: DataModel) { + if (!isDelegateModel(decl)) { + return; + } + + // collect concrete models inheriting this model + const concreteModels = this.getConcreteModels(decl); + + // generate an optional relation field in delegate base model to each concrete model + concreteModels.forEach((concrete) => { + const auxName = this.truncate(`${DELEGATE_AUX_RELATION_PREFIX}_${lowerCaseFirst(concrete.name)}`); + model.addField(auxName, new ModelFieldType(concrete.name, false, true)); + }); + } + + private generateDelegateRelationForConcrete(model: PrismaDataModel, concreteDecl: DataModel) { + // generate a relation field for each delegated base model + const base = concreteDecl.baseModel?.ref; + if (!base) { + return; + } + + const idFields = getIdFields(base); + + // add relation fields + const relationField = this.truncate(`${DELEGATE_AUX_RELATION_PREFIX}_${lowerCaseFirst(base.name)}`); + model.addField(relationField, base.name, [ + new PrismaFieldAttribute('@relation', [ + new PrismaAttributeArg( + 'fields', + new AttributeArgValue( + 'Array', + idFields.map( + (idField) => new AttributeArgValue('FieldReference', new PrismaFieldReference(idField)), + ), + ), + ), + new PrismaAttributeArg( + 'references', + new AttributeArgValue( + 'Array', + idFields.map( + (idField) => new AttributeArgValue('FieldReference', new PrismaFieldReference(idField)), + ), + ), + ), + new PrismaAttributeArg( + 'onDelete', + new AttributeArgValue('FieldReference', new PrismaFieldReference('Cascade')), + ), + new PrismaAttributeArg( + 'onUpdate', + new AttributeArgValue('FieldReference', new PrismaFieldReference('Cascade')), + ), + ]), + ]); + } + + private getConcreteModels(dataModel: DataModel): DataModel[] { + if (!isDelegateModel(dataModel)) { + return []; + } + return dataModel.$container.declarations.filter( + (d): d is DataModel => isDataModel(d) && d !== dataModel && d.baseModel?.ref === dataModel, + ); + } + + private truncate(name: string) { + if (name.length <= IDENTIFIER_NAME_MAX_LENGTH) { + return name; + } + + const existing = this.shortNameMap.get(name); + if (existing) { + return existing; + } + + const baseName = name.slice(0, IDENTIFIER_NAME_MAX_LENGTH); + let index = 0; + let shortName = `${baseName}_${index}`; + + while (true) { + const conflict = Array.from(this.shortNameMap.values()).find((v) => v === shortName); + if (!conflict) { + this.shortNameMap.set(name, shortName); + break; + } + + // try next index + index++; + shortName = `${baseName}_${index}`; + } + + return shortName; + } } diff --git a/packages/sdk/src/schema/schema.ts b/packages/sdk/src/schema/schema.ts index 208024a8..d7a38f9e 100644 --- a/packages/sdk/src/schema/schema.ts +++ b/packages/sdk/src/schema/schema.ts @@ -18,6 +18,8 @@ export type SchemaDef = { }; export type ModelDef = { + name: string; + baseModel?: string; fields: Record; attributes?: AttributeApplication[]; uniqueFields: Record< @@ -29,6 +31,7 @@ export type ModelDef = { >; idFields: string[]; computedFields?: Record; + isDelegate?: boolean; }; export type AttributeApplication = { @@ -53,6 +56,7 @@ export type RelationInfo = { }; export type FieldDef = { + name: string; type: string; id?: boolean; array?: boolean; @@ -64,6 +68,7 @@ export type FieldDef = { relation?: RelationInfo; foreignKeyFor?: string[]; computed?: boolean; + originModel?: string; }; export type ProcedureParam = { name: string; type: string; optional?: boolean }; @@ -91,6 +96,7 @@ export type MappedBuiltinType = string | boolean | number | bigint | Decimal | D export type EnumDef = Record; export type TypeDefDef = { + name: string; fields: Record; attributes?: AttributeApplication[]; }; diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index 91e04112..e5202b32 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -42,7 +42,15 @@ import path from 'node:path'; import { match } from 'ts-pattern'; import * as ts from 'typescript'; import { ModelUtils } from '.'; -import { getAttribute, getAuthDecl, hasAttribute, isUniqueField } from './model-utils'; +import { + getAttribute, + getAuthDecl, + getIdFields, + hasAttribute, + isDelegateModel, + isIdField, + isUniqueField, +} from './model-utils'; export class TsSchemaGenerator { public async generate(schemaFile: string, pluginModelFiles: string[], outputDir: string) { @@ -213,9 +221,28 @@ export class TsSchemaGenerator { private createDataModelObject(dm: DataModel) { const allFields = getAllFields(dm); - const allAttributes = getAllAttributes(dm); + const allAttributes = getAllAttributes(dm).filter((attr) => { + // exclude `@@delegate` attribute from base model + if (attr.decl.$refText === '@@delegate' && attr.$container !== dm) { + return false; + } + return true; + }); const fields: ts.PropertyAssignment[] = [ + // name + ts.factory.createPropertyAssignment('name', ts.factory.createStringLiteral(dm.name)), + + // baseModel + ...(dm.baseModel + ? [ + ts.factory.createPropertyAssignment( + 'baseModel', + ts.factory.createStringLiteral(dm.baseModel.$refText), + ), + ] + : []), + // fields ts.factory.createPropertyAssignment( 'fields', @@ -244,12 +271,17 @@ export class TsSchemaGenerator { ts.factory.createPropertyAssignment( 'idFields', ts.factory.createArrayLiteralExpression( - this.getIdFields(dm).map((idField) => ts.factory.createStringLiteral(idField)), + getIdFields(dm).map((idField) => ts.factory.createStringLiteral(idField)), ), ), // uniqueFields ts.factory.createPropertyAssignment('uniqueFields', this.createUniqueFieldsObject(dm)), + + // isDelegate + ...(isDelegateModel(dm) + ? [ts.factory.createPropertyAssignment('isDelegate', ts.factory.createTrue())] + : []), ]; const computedFields = dm.fields.filter((f) => hasAttribute(f, '@computed')); @@ -268,6 +300,9 @@ export class TsSchemaGenerator { const allAttributes = getAllAttributes(td); const fields: ts.PropertyAssignment[] = [ + // name + ts.factory.createPropertyAssignment('name', ts.factory.createStringLiteral(td.name)), + // fields ts.factory.createPropertyAssignment( 'fields', @@ -344,7 +379,28 @@ export class TsSchemaGenerator { } private createDataFieldObject(field: DataField, contextModel: DataModel | undefined) { - const objectFields = [ts.factory.createPropertyAssignment('type', this.generateFieldTypeLiteral(field))]; + const objectFields = [ + // name + ts.factory.createPropertyAssignment('name', ts.factory.createStringLiteral(field.name)), + // type + ts.factory.createPropertyAssignment('type', this.generateFieldTypeLiteral(field)), + ]; + + if ( + contextModel && + // id fields are duplicated in inherited models + !isIdField(field, contextModel) && + field.$container !== contextModel && + isDelegateModel(field.$container) + ) { + // field is inherited from delegate + objectFields.push( + ts.factory.createPropertyAssignment( + 'originModel', + ts.factory.createStringLiteral(field.$container.name), + ), + ); + } if (contextModel && ModelUtils.isIdField(field, contextModel)) { objectFields.push(ts.factory.createPropertyAssignment('id', ts.factory.createTrue())); @@ -668,12 +724,6 @@ export class TsSchemaGenerator { return undefined; } - private getIdFields(dm: DataModel) { - return getAllFields(dm) - .filter((f) => ModelUtils.isIdField(f, dm)) - .map((f) => f.name); - } - private createUniqueFieldsObject(dm: DataModel) { const properties: ts.PropertyAssignment[] = []; diff --git a/samples/blog/zenstack/schema.ts b/samples/blog/zenstack/schema.ts index c1ad9a73..64515be7 100644 --- a/samples/blog/zenstack/schema.ts +++ b/samples/blog/zenstack/schema.ts @@ -12,48 +12,58 @@ export const schema = { }, models: { User: { + name: "User", fields: { id: { + name: "id", type: "String", id: true, attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }], default: ExpressionUtils.call("cuid") }, createdAt: { + name: "createdAt", type: "DateTime", attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], default: ExpressionUtils.call("now") }, updatedAt: { + name: "updatedAt", type: "DateTime", updatedAt: true, attributes: [{ name: "@updatedAt" }] }, email: { + name: "email", type: "String", unique: true, attributes: [{ name: "@unique" }] }, name: { + name: "name", type: "String", optional: true }, postCount: { + name: "postCount", type: "Int", attributes: [{ name: "@computed" }], computed: true }, role: { + name: "role", type: "Role", attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal("USER") }] }], default: "USER" }, posts: { + name: "posts", type: "Post", array: true, relation: { opposite: "author" } }, profile: { + name: "profile", type: "Profile", optional: true, relation: { opposite: "user" } @@ -71,38 +81,46 @@ export const schema = { } }, Profile: { + name: "Profile", fields: { id: { + name: "id", type: "String", id: true, attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }], default: ExpressionUtils.call("cuid") }, createdAt: { + name: "createdAt", type: "DateTime", attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], default: ExpressionUtils.call("now") }, updatedAt: { + name: "updatedAt", type: "DateTime", updatedAt: true, attributes: [{ name: "@updatedAt" }] }, bio: { + name: "bio", type: "String", optional: true }, age: { + name: "age", type: "Int", optional: true }, user: { + name: "user", type: "User", optional: true, attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array([ExpressionUtils.field("userId")]) }, { name: "references", value: ExpressionUtils.array([ExpressionUtils.field("id")]) }] }], relation: { opposite: "profile", fields: ["userId"], references: ["id"] } }, userId: { + name: "userId", type: "String", unique: true, optional: true, @@ -119,40 +137,49 @@ export const schema = { } }, Post: { + name: "Post", fields: { id: { + name: "id", type: "String", id: true, attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }], default: ExpressionUtils.call("cuid") }, createdAt: { + name: "createdAt", type: "DateTime", attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], default: ExpressionUtils.call("now") }, updatedAt: { + name: "updatedAt", type: "DateTime", updatedAt: true, attributes: [{ name: "@updatedAt" }] }, title: { + name: "title", type: "String" }, content: { + name: "content", type: "String" }, published: { + name: "published", type: "Boolean", attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal(false) }] }], default: false }, author: { + name: "author", type: "User", attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array([ExpressionUtils.field("authorId")]) }, { name: "references", value: ExpressionUtils.array([ExpressionUtils.field("id")]) }] }], relation: { opposite: "posts", fields: ["authorId"], references: ["id"] } }, authorId: { + name: "authorId", type: "String", foreignKeyFor: [ "author" @@ -167,18 +194,22 @@ export const schema = { }, typeDefs: { CommonFields: { + name: "CommonFields", fields: { id: { + name: "id", type: "String", attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }], default: ExpressionUtils.call("cuid") }, createdAt: { + name: "createdAt", type: "DateTime", attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], default: ExpressionUtils.call("now") }, updatedAt: { + name: "updatedAt", type: "DateTime", updatedAt: true, attributes: [{ name: "@updatedAt" }] From 04959ad9a28c183c595b542004fab918e4eadf05 Mon Sep 17 00:00:00 2001 From: Yiming Cao Date: Thu, 24 Jul 2025 16:25:47 +0800 Subject: [PATCH 11/19] fix(delegate): relation selection (#111) --- .../runtime/src/client/crud/dialects/base.ts | 135 +++++++- .../src/client/crud/dialects/postgresql.ts | 26 +- .../src/client/crud/dialects/sqlite.ts | 26 +- .../src/client/crud/operations/base.ts | 130 ++------ packages/runtime/src/client/query-utils.ts | 17 +- .../runtime/test/client-api/delegate.test.ts | 314 ++++++++++-------- 6 files changed, 390 insertions(+), 258 deletions(-) diff --git a/packages/runtime/src/client/crud/dialects/base.ts b/packages/runtime/src/client/crud/dialects/base.ts index 7d4e6fb2..3ce8e0f7 100644 --- a/packages/runtime/src/client/crud/dialects/base.ts +++ b/packages/runtime/src/client/crud/dialects/base.ts @@ -5,6 +5,7 @@ import { match, P } from 'ts-pattern'; import type { BuiltinType, DataSourceProviderType, FieldDef, GetModels, SchemaDef } from '../../../schema'; import { enumerate } from '../../../utils/enumerate'; import type { OrArray } from '../../../utils/type-utils'; +import { DELEGATE_JOINED_FIELD_PREFIX } from '../../constants'; import type { BooleanFilter, BytesFilter, @@ -20,13 +21,17 @@ import { buildFieldRef, buildJoinPairs, flattenCompoundUniqueFilters, + getDelegateDescendantModels, getField, getIdFields, getManyToManyRelation, getRelationForeignKeyFieldPairs, isEnum, + isInheritedField, + isRelationField, makeDefaultOrderBy, requireField, + requireModel, } from '../../query-utils'; export abstract class BaseCrudDialect { @@ -35,25 +40,11 @@ export abstract class BaseCrudDialect { protected readonly options: ClientOptions, ) {} - abstract get provider(): DataSourceProviderType; - transformPrimitive(value: unknown, _type: BuiltinType, _forArrayField: boolean) { return value; } - abstract buildRelationSelection( - query: SelectQueryBuilder, - model: string, - relationField: string, - parentAlias: string, - payload: true | FindArgs, true>, - ): SelectQueryBuilder; - - abstract buildSkipTake( - query: SelectQueryBuilder, - skip: number | undefined, - take: number | undefined, - ): SelectQueryBuilder; + // #region common query builders buildFilter( eb: ExpressionBuilder, @@ -788,6 +779,92 @@ export abstract class BaseCrudDialect { return result; } + buildSelectAllFields( + model: string, + query: SelectQueryBuilder, + omit?: Record, + joinedBases: string[] = [], + ) { + const modelDef = requireModel(this.schema, model); + let result = query; + + for (const field of Object.keys(modelDef.fields)) { + if (isRelationField(this.schema, model, field)) { + continue; + } + if (omit?.[field] === true) { + continue; + } + result = this.buildSelectField(result, model, model, field, joinedBases); + } + + // select all fields from delegate descendants and pack into a JSON field `$delegate$Model` + const descendants = getDelegateDescendantModels(this.schema, model); + for (const subModel of descendants) { + if (!joinedBases.includes(subModel.name)) { + joinedBases.push(subModel.name); + result = this.buildDelegateJoin(model, subModel.name, result); + } + result = result.select((eb) => { + const jsonObject: Record> = {}; + for (const field of Object.keys(subModel.fields)) { + if ( + isRelationField(this.schema, subModel.name, field) || + isInheritedField(this.schema, subModel.name, field) + ) { + continue; + } + jsonObject[field] = eb.ref(`${subModel.name}.${field}`); + } + return this.buildJsonObject(eb, jsonObject).as(`${DELEGATE_JOINED_FIELD_PREFIX}${subModel.name}`); + }); + } + + return result; + } + + buildSelectField( + query: SelectQueryBuilder, + model: string, + modelAlias: string, + field: string, + joinedBases: string[], + ) { + const fieldDef = requireField(this.schema, model, field); + + if (fieldDef.computed) { + // TODO: computed field from delegate base? + return query.select((eb) => buildFieldRef(this.schema, model, field, this.options, eb).as(field)); + } else if (!fieldDef.originModel) { + // regular field + return query.select(sql.ref(`${modelAlias}.${field}`).as(field)); + } else { + // field from delegate base, build a join + let result = query; + if (!joinedBases.includes(fieldDef.originModel)) { + joinedBases.push(fieldDef.originModel); + result = this.buildDelegateJoin(model, fieldDef.originModel, result); + } + result = this.buildSelectField(result, fieldDef.originModel, fieldDef.originModel, field, joinedBases); + return result; + } + } + + buildDelegateJoin(thisModel: string, otherModel: string, query: SelectQueryBuilder) { + const idFields = getIdFields(this.schema, thisModel); + query = query.leftJoin(otherModel, (qb) => { + for (const idField of idFields) { + qb = qb.onRef(`${thisModel}.${idField}`, '=', `${otherModel}.${idField}`); + } + return qb; + }); + return query; + } + + // #endregion + + // #region utils + private negateSort(sort: SortOrder, negated: boolean) { return negated ? (sort === 'asc' ? 'desc' : 'asc') : sort; } @@ -842,6 +919,32 @@ export abstract class BaseCrudDialect { return eb.not(this.and(eb, ...args)); } + // #endregion + + // #region abstract methods + + abstract get provider(): DataSourceProviderType; + + /** + * Builds selection for a relation field. + */ + abstract buildRelationSelection( + query: SelectQueryBuilder, + model: string, + relationField: string, + parentAlias: string, + payload: true | FindArgs, true>, + ): SelectQueryBuilder; + + /** + * Builds skip and take clauses. + */ + abstract buildSkipTake( + query: SelectQueryBuilder, + skip: number | undefined, + take: number | undefined, + ): SelectQueryBuilder; + /** * Builds an Kysely expression that returns a JSON object for the given key-value pairs. */ @@ -877,4 +980,6 @@ export abstract class BaseCrudDialect { * Whether the dialect supports DISTINCT ON. */ abstract get supportsDistinctOn(): boolean; + + // #endregion } diff --git a/packages/runtime/src/client/crud/dialects/postgresql.ts b/packages/runtime/src/client/crud/dialects/postgresql.ts index c73b4bb1..be273e81 100644 --- a/packages/runtime/src/client/crud/dialects/postgresql.ts +++ b/packages/runtime/src/client/crud/dialects/postgresql.ts @@ -9,10 +9,12 @@ import { } from 'kysely'; import { match } from 'ts-pattern'; import type { BuiltinType, FieldDef, GetModels, SchemaDef } from '../../../schema'; +import { DELEGATE_JOINED_FIELD_PREFIX } from '../../constants'; import type { FindArgs } from '../../crud-types'; import { buildFieldRef, buildJoinPairs, + getDelegateDescendantModels, getIdFields, getManyToManyRelation, isRelationField, @@ -79,10 +81,18 @@ export class PostgresCrudDialect extends BaseCrudDiale // simple select by default let result = eb.selectFrom(`${relationModel} as ${joinTableName}`); + const joinBases: string[] = []; + // however if there're filter/orderBy/take/skip, // we need to build a subquery to handle them before aggregation result = eb.selectFrom(() => { - let subQuery = eb.selectFrom(`${relationModel}`).selectAll(); + let subQuery = eb.selectFrom(relationModel); + subQuery = this.buildSelectAllFields( + relationModel, + subQuery, + typeof payload === 'object' ? payload?.omit : undefined, + joinBases, + ); if (payload && typeof payload === 'object') { if (payload.where) { @@ -200,6 +210,20 @@ export class PostgresCrudDialect extends BaseCrudDiale string | ExpressionWrapper | SelectQueryBuilder | RawBuilder > = []; + // TODO: descendant JSON shouldn't be joined and selected if none of its fields are selected + const descendantModels = getDelegateDescendantModels(this.schema, relationModel); + if (descendantModels.length > 0) { + // select all JSONs built from delegate descendants + objArgs.push( + ...descendantModels + .map((subModel) => [ + sql.lit(`${DELEGATE_JOINED_FIELD_PREFIX}${subModel.name}`), + eb.ref(`${DELEGATE_JOINED_FIELD_PREFIX}${subModel.name}`), + ]) + .flatMap((v) => v), + ); + } + if (payload === true || !payload.select) { // select all scalar fields objArgs.push( diff --git a/packages/runtime/src/client/crud/dialects/sqlite.ts b/packages/runtime/src/client/crud/dialects/sqlite.ts index c27cd7de..2961b864 100644 --- a/packages/runtime/src/client/crud/dialects/sqlite.ts +++ b/packages/runtime/src/client/crud/dialects/sqlite.ts @@ -10,9 +10,11 @@ import { } from 'kysely'; import { match } from 'ts-pattern'; import type { BuiltinType, GetModels, SchemaDef } from '../../../schema'; +import { DELEGATE_JOINED_FIELD_PREFIX } from '../../constants'; import type { FindArgs } from '../../crud-types'; import { buildFieldRef, + getDelegateDescendantModels, getIdFields, getManyToManyRelation, getRelationForeignKeyFieldPairs, @@ -75,7 +77,15 @@ export class SqliteCrudDialect extends BaseCrudDialect const subQueryName = `${parentName}$${relationField}`; let tbl = eb.selectFrom(() => { - let subQuery = eb.selectFrom(relationModel).selectAll(); + let subQuery = eb.selectFrom(relationModel); + + const joinBases: string[] = []; + subQuery = this.buildSelectAllFields( + relationModel, + subQuery, + typeof payload === 'object' ? payload?.omit : undefined, + joinBases, + ); if (payload && typeof payload === 'object') { if (payload.where) { @@ -143,6 +153,20 @@ export class SqliteCrudDialect extends BaseCrudDialect type ArgsType = Expression | RawBuilder | SelectQueryBuilder; const objArgs: ArgsType[] = []; + // TODO: descendant JSON shouldn't be joined and selected if none of its fields are selected + const descendantModels = getDelegateDescendantModels(this.schema, relationModel); + if (descendantModels.length > 0) { + // select all JSONs built from delegate descendants + objArgs.push( + ...descendantModels + .map((subModel) => [ + sql.lit(`${DELEGATE_JOINED_FIELD_PREFIX}${subModel.name}`), + eb.ref(`${DELEGATE_JOINED_FIELD_PREFIX}${subModel.name}`), + ]) + .flatMap((v) => v), + ); + } + if (payload === true || !payload.select) { // select all scalar fields objArgs.push( diff --git a/packages/runtime/src/client/crud/operations/base.ts b/packages/runtime/src/client/crud/operations/base.ts index a4bb5c11..ca77245a 100644 --- a/packages/runtime/src/client/crud/operations/base.ts +++ b/packages/runtime/src/client/crud/operations/base.ts @@ -22,7 +22,7 @@ import { ExpressionUtils, type GetModels, type ModelDef, type SchemaDef } from ' import { clone } from '../../../utils/clone'; import { enumerate } from '../../../utils/enumerate'; import { extractFields, fieldsToSelectObject } from '../../../utils/object-utils'; -import { CONTEXT_COMMENT_PREFIX, DELEGATE_JOINED_FIELD_PREFIX, NUMERIC_FIELD_TYPES } from '../../constants'; +import { CONTEXT_COMMENT_PREFIX, NUMERIC_FIELD_TYPES } from '../../constants'; import type { CRUD } from '../../contract'; import type { FindArgs, SelectIncludeOmit, SortOrder, WhereInput } from '../../crud-types'; import { InternalError, NotFoundError, QueryError } from '../../errors'; @@ -41,7 +41,6 @@ import { getModel, getRelationForeignKeyFieldPairs, isForeignKeyField, - isInheritedField, isRelationField, isScalarField, makeDefaultOrderBy, @@ -183,19 +182,22 @@ export abstract class BaseOperationHandler { } } + // for deduplicating base joins + const joinedBases: string[] = []; + // select if (args && 'select' in args && args.select) { // select is mutually exclusive with omit - query = this.buildFieldSelection(model, query, args.select, model); + query = this.buildFieldSelection(model, query, args.select, model, joinedBases); } else { // include all scalar fields except those in omit - query = this.buildSelectAllScalarFields(model, query, (args as any)?.omit); + query = this.dialect.buildSelectAllFields(model, query, (args as any)?.omit, joinedBases); } // include if (args && 'include' in args && args.include) { // note that 'omit' is handled above already - query = this.buildFieldSelection(model, query, args.include, model); + query = this.buildFieldSelection(model, query, args.include, model, joinedBases); } if (args?.cursor) { @@ -246,9 +248,9 @@ export abstract class BaseOperationHandler { query: SelectQueryBuilder, selectOrInclude: Record, parentAlias: string, + joinedBases: string[], ) { let result = query; - const joinedBases: string[] = []; for (const [field, payload] of Object.entries(selectOrInclude)) { if (!payload) { @@ -262,12 +264,29 @@ export abstract class BaseOperationHandler { const fieldDef = this.requireField(model, field); if (!fieldDef.relation) { - result = this.selectField(result, model, parentAlias, field, joinedBases); + // scalar field + result = this.dialect.buildSelectField(result, model, parentAlias, field, joinedBases); } else { if (!fieldDef.array && !fieldDef.optional && payload.where) { throw new QueryError(`Field "${field}" doesn't support filtering`); } - result = this.dialect.buildRelationSelection(result, model, field, parentAlias, payload); + if (fieldDef.originModel) { + // relation is inherited from a delegate base model, need to build a join + if (!joinedBases.includes(fieldDef.originModel)) { + joinedBases.push(fieldDef.originModel); + result = this.dialect.buildDelegateJoin(parentAlias, fieldDef.originModel, result); + } + result = this.dialect.buildRelationSelection( + result, + fieldDef.originModel, + field, + fieldDef.originModel, + payload, + ); + } else { + // regular relation + result = this.dialect.buildRelationSelection(result, model, field, parentAlias, payload); + } } } @@ -332,101 +351,6 @@ export abstract class BaseOperationHandler { return query; } - private buildSelectAllScalarFields( - model: string, - query: SelectQueryBuilder, - omit?: Record, - ) { - const modelDef = this.requireModel(model); - let result = query; - const joinedBases: string[] = []; - - for (const field of Object.keys(modelDef.fields)) { - if (isRelationField(this.schema, model, field)) { - continue; - } - if (omit?.[field] === true) { - continue; - } - result = this.selectField(result, model, model, field, joinedBases); - } - - // select all fields from delegate descendants and pack into a JSON field `$delegate$Model` - const descendants = this.getDelegateDescendantModels(model); - for (const subModel of descendants) { - if (!joinedBases.includes(subModel.name)) { - joinedBases.push(subModel.name); - result = this.buildDelegateJoin(model, subModel.name, result); - } - result = result.select((eb) => { - const jsonObject: Record> = {}; - for (const field of Object.keys(subModel.fields)) { - if ( - isRelationField(this.schema, subModel.name, field) || - isInheritedField(this.schema, subModel.name, field) - ) { - continue; - } - jsonObject[field] = eb.ref(`${subModel.name}.${field}`); - } - return this.dialect - .buildJsonObject(eb, jsonObject) - .as(`${DELEGATE_JOINED_FIELD_PREFIX}${subModel.name}`); - }); - } - - return result; - } - - private getDelegateDescendantModels(model: string, collected: Set = new Set()): ModelDef[] { - const subModels = Object.values(this.schema.models).filter((m) => m.baseModel === model); - subModels.forEach((def) => { - if (!collected.has(def)) { - collected.add(def); - this.getDelegateDescendantModels(def.name, collected); - } - }); - return [...collected]; - } - - private selectField( - query: SelectQueryBuilder, - model: string, - modelAlias: string, - field: string, - joinedBases: string[], - ) { - const fieldDef = this.requireField(model, field); - - if (fieldDef.computed) { - // TODO: computed field from delegate base? - return query.select((eb) => buildFieldRef(this.schema, model, field, this.options, eb).as(field)); - } else if (!fieldDef.originModel) { - // regular field - return query.select(sql.ref(`${modelAlias}.${field}`).as(field)); - } else { - // field from delegate base, build a join - let result = query; - if (!joinedBases.includes(fieldDef.originModel)) { - joinedBases.push(fieldDef.originModel); - result = this.buildDelegateJoin(model, fieldDef.originModel, result); - } - result = this.selectField(result, fieldDef.originModel, fieldDef.originModel, field, joinedBases); - return result; - } - } - - private buildDelegateJoin(thisModel: string, otherModel: string, query: SelectQueryBuilder) { - const idFields = getIdFields(this.schema, thisModel); - query = query.leftJoin(otherModel, (qb) => { - for (const idField of idFields) { - qb = qb.onRef(`${thisModel}.${idField}`, '=', `${otherModel}.${idField}`); - } - return qb; - }); - return query; - } - private buildCursorFilter( model: string, query: SelectQueryBuilder, diff --git a/packages/runtime/src/client/query-utils.ts b/packages/runtime/src/client/query-utils.ts index 8c47b895..47b91a00 100644 --- a/packages/runtime/src/client/query-utils.ts +++ b/packages/runtime/src/client/query-utils.ts @@ -1,5 +1,5 @@ import type { ExpressionBuilder, ExpressionWrapper } from 'kysely'; -import { ExpressionUtils, type FieldDef, type GetModels, type SchemaDef } from '../schema'; +import { ExpressionUtils, type FieldDef, type GetModels, type ModelDef, type SchemaDef } from '../schema'; import type { OrderBy } from './crud-types'; import { InternalError, QueryError } from './errors'; import type { ClientOptions } from './options'; @@ -313,3 +313,18 @@ export function getDiscriminatorField(schema: SchemaDef, model: string) { } return discriminator.value.field; } + +export function getDelegateDescendantModels( + schema: SchemaDef, + model: string, + collected: Set = new Set(), +): ModelDef[] { + const subModels = Object.values(schema.models).filter((m) => m.baseModel === model); + subModels.forEach((def) => { + if (!collected.has(def)) { + collected.add(def); + getDelegateDescendantModels(schema, def.name, collected); + } + }); + return [...collected]; +} diff --git a/packages/runtime/test/client-api/delegate.test.ts b/packages/runtime/test/client-api/delegate.test.ts index b1e4fc83..eb55ae22 100644 --- a/packages/runtime/test/client-api/delegate.test.ts +++ b/packages/runtime/test/client-api/delegate.test.ts @@ -1,8 +1,12 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createTestClient } from '../utils'; -describe('Delegate model tests', () => { - const POLYMORPHIC_SCHEMA = ` +const DB_NAME = `client-api-delegate-tests`; + +describe.each([{ provider: 'sqlite' as const }, { provider: 'postgresql' as const }])( + 'Delegate model tests for $provider', + ({ provider }) => { + const POLYMORPHIC_SCHEMA = ` model User { id Int @id @default(autoincrement()) email String? @unique @@ -49,158 +53,194 @@ model Gallery { } `; - it('works with create', async () => { - const client = await createTestClient(POLYMORPHIC_SCHEMA, { - usePrismaPush: true, + let client: any; + + beforeEach(async () => { + client = await createTestClient(POLYMORPHIC_SCHEMA, { + usePrismaPush: true, + provider, + dbName: provider === 'postgresql' ? DB_NAME : undefined, + }); }); - // delegate model cannot be created directly - await expect( - client.video.create({ - data: { - duration: 100, - url: 'abc', - videoType: 'MyVideo', - }, - }), - ).rejects.toThrow('is a delegate'); + afterEach(async () => { + await client.$disconnect(); + }); - // create entity with two levels of delegation - await expect( - client.ratedVideo.create({ - data: { - duration: 100, - url: 'abc', - rating: 5, + it('works with create', async () => { + // delegate model cannot be created directly + await expect( + client.video.create({ + data: { + duration: 100, + url: 'abc', + videoType: 'MyVideo', + }, + }), + ).rejects.toThrow('is a delegate'); + + // create entity with two levels of delegation + await expect( + client.ratedVideo.create({ + data: { + duration: 100, + url: 'abc', + rating: 5, + }, + }), + ).resolves.toMatchObject({ + id: expect.any(Number), + duration: 100, + url: 'abc', + rating: 5, + assetType: 'Video', + videoType: 'RatedVideo', + }); + + // create entity with relation + await expect( + client.ratedVideo.create({ + data: { + duration: 50, + url: 'bcd', + rating: 5, + user: { create: { email: 'u1@example.com' } }, + }, + include: { user: true }, + }), + ).resolves.toMatchObject({ + userId: expect.any(Number), + user: { + email: 'u1@example.com', }, - }), - ).resolves.toMatchObject({ - id: expect.any(Number), - duration: 100, - url: 'abc', - rating: 5, - assetType: 'Video', - videoType: 'RatedVideo', + }); + + // create entity with one level of delegation + await expect( + client.image.create({ + data: { + format: 'png', + gallery: { + create: {}, + }, + }, + }), + ).resolves.toMatchObject({ + id: expect.any(Number), + format: 'png', + galleryId: expect.any(Number), + assetType: 'Image', + }); }); - // create entity with relation - await expect( - client.ratedVideo.create({ + it('works with find', async () => { + const u = await client.user.create({ data: { - duration: 50, - url: 'bcd', - rating: 5, - user: { create: { email: 'u1@example.com' } }, + email: 'u1@example.com', }, - include: { user: true }, - }), - ).resolves.toMatchObject({ - userId: expect.any(Number), - user: { - email: 'u1@example.com', - }, - }); - - // create entity with one level of delegation - await expect( - client.image.create({ + }); + const v = await client.ratedVideo.create({ data: { - format: 'png', - gallery: { - create: {}, - }, + duration: 100, + url: 'abc', + rating: 5, + owner: { connect: { id: u.id } }, + user: { connect: { id: u.id } }, }, - }), - ).resolves.toMatchObject({ - id: expect.any(Number), - format: 'png', - galleryId: expect.any(Number), - assetType: 'Image', - }); - }); - - it('works with find', async () => { - const client = await createTestClient(POLYMORPHIC_SCHEMA, { - usePrismaPush: true, - log: ['query'], - }); + }); - const u = await client.user.create({ - data: { - email: 'u1@example.com', - }, - }); - const v = await client.ratedVideo.create({ - data: { + const ratedVideoContent = { + id: v.id, + createdAt: expect.any(Date), duration: 100, + rating: 5, + assetType: 'Video', + videoType: 'RatedVideo', + }; + + // include all base fields + await expect( + client.ratedVideo.findUnique({ + where: { id: v.id }, + include: { user: true, owner: true }, + }), + ).resolves.toMatchObject({ ...ratedVideoContent, user: expect.any(Object), owner: expect.any(Object) }); + + // select fields + await expect( + client.ratedVideo.findUnique({ + where: { id: v.id }, + select: { + id: true, + viewCount: true, + url: true, + rating: true, + }, + }), + ).resolves.toEqual({ + id: v.id, + viewCount: 0, url: 'abc', rating: 5, - user: { connect: { id: u.id } }, - }, - include: { user: true }, - }); + }); - const ratedVideoContent = { - id: v.id, - createdAt: expect.any(Date), - duration: 100, - rating: 5, - assetType: 'Video', - videoType: 'RatedVideo', - }; - - // include all base fields - await expect( - client.ratedVideo.findUnique({ + // omit fields + const r = await client.ratedVideo.findUnique({ where: { id: v.id }, - include: { user: true }, - }), - ).resolves.toMatchObject({ ...ratedVideoContent, user: expect.any(Object) }); - - // select fields - await expect( - client.ratedVideo.findUnique({ - where: { id: v.id }, - select: { - id: true, + omit: { viewCount: true, url: true, rating: true, }, - }), - ).resolves.toEqual({ - id: v.id, - viewCount: 0, - url: 'abc', - rating: 5, - }); - - // omit fields - const r = await client.ratedVideo.findUnique({ - where: { id: v.id }, - omit: { - viewCount: true, - url: true, - rating: true, - }, + }); + expect(r.viewCount).toBeUndefined(); + expect(r.url).toBeUndefined(); + expect(r.rating).toBeUndefined(); + expect(r.duration).toEqual(expect.any(Number)); + + // include all sub fields + await expect( + client.video.findUnique({ + where: { id: v.id }, + }), + ).resolves.toMatchObject(ratedVideoContent); + + // include all sub fields + await expect( + client.asset.findUnique({ + where: { id: v.id }, + }), + ).resolves.toMatchObject(ratedVideoContent); + + // find as a relation + await expect( + client.user.findUnique({ + where: { id: u.id }, + include: { assets: true, ratedVideos: true }, + }), + ).resolves.toMatchObject({ + assets: [ratedVideoContent], + ratedVideos: [ratedVideoContent], + }); + + // find as a relation with selection + await expect( + client.user.findUnique({ + where: { id: u.id }, + include: { + assets: { + select: { id: true, assetType: true }, + }, + ratedVideos: { + url: true, + rating: true, + }, + }, + }), + ).resolves.toMatchObject({ + assets: [{ id: v.id, assetType: 'Video' }], + ratedVideos: [{ url: 'abc', rating: 5 }], + }); }); - expect(r.viewCount).toBeUndefined(); - expect(r.url).toBeUndefined(); - expect(r.rating).toBeUndefined(); - expect(r.duration).toEqual(expect.any(Number)); - - // include all sub fields - await expect( - client.video.findUnique({ - where: { id: v.id }, - }), - ).resolves.toMatchObject(ratedVideoContent); - - // include all sub fields - await expect( - client.asset.findUnique({ - where: { id: v.id }, - }), - ).resolves.toMatchObject(ratedVideoContent); - }); -}); + }, +); From c7f02eadb33933077e0485c2b4753d5e1819dab8 Mon Sep 17 00:00:00 2001 From: Yiming Cao Date: Thu, 24 Jul 2025 23:52:12 +0800 Subject: [PATCH 12/19] fix(delegate): filter fixes (#112) --- .../runtime/src/client/crud/dialects/base.ts | 169 ++++++------ .../src/client/crud/dialects/postgresql.ts | 5 +- .../src/client/crud/dialects/sqlite.ts | 4 +- .../src/client/crud/operations/base.ts | 38 +-- .../src/client/crud/operations/create.ts | 6 - .../runtime/test/client-api/delegate.test.ts | 261 ++++++++++++++++++ .../test/client-api/typed-json-fields.test.ts | 1 - 7 files changed, 359 insertions(+), 125 deletions(-) diff --git a/packages/runtime/src/client/crud/dialects/base.ts b/packages/runtime/src/client/crud/dialects/base.ts index 3ce8e0f7..c253b905 100644 --- a/packages/runtime/src/client/crud/dialects/base.ts +++ b/packages/runtime/src/client/crud/dialects/base.ts @@ -22,7 +22,6 @@ import { buildJoinPairs, flattenCompoundUniqueFilters, getDelegateDescendantModels, - getField, getIdFields, getManyToManyRelation, getRelationForeignKeyFieldPairs, @@ -46,6 +45,18 @@ export abstract class BaseCrudDialect { // #region common query builders + buildSelectModel(eb: ExpressionBuilder, model: string) { + const modelDef = requireModel(this.schema, model); + let result = eb.selectFrom(model); + // join all delegate bases + let joinBase = modelDef.baseModel; + while (joinBase) { + result = this.buildDelegateJoin(model, joinBase, result); + joinBase = requireModel(this.schema, joinBase).baseModel; + } + return result; + } + buildFilter( eb: ExpressionBuilder, model: string, @@ -78,12 +89,24 @@ export abstract class BaseCrudDialect { } const fieldDef = requireField(this.schema, model, key); + if (fieldDef.relation) { result = this.and(eb, result, this.buildRelationFilter(eb, model, modelAlias, key, fieldDef, payload)); - } else if (fieldDef.array) { - result = this.and(eb, result, this.buildArrayFilter(eb, model, modelAlias, key, fieldDef, payload)); } else { - result = this.and(eb, result, this.buildPrimitiveFilter(eb, model, modelAlias, key, fieldDef, payload)); + // if the field is from a base model, build a reference from that model + const fieldRef = buildFieldRef( + this.schema, + fieldDef.originModel ?? model, + key, + this.options, + eb, + fieldDef.originModel ?? modelAlias, + ); + if (fieldDef.array) { + result = this.and(eb, result, this.buildArrayFilter(eb, fieldRef, fieldDef, payload)); + } else { + result = this.and(eb, result, this.buildPrimitiveFilter(eb, fieldRef, fieldDef, payload)); + } } } @@ -137,7 +160,7 @@ export abstract class BaseCrudDialect { private buildToOneRelationFilter( eb: ExpressionBuilder, model: string, - table: string, + modelAlias: string, field: string, fieldDef: FieldDef, payload: any, @@ -145,17 +168,24 @@ export abstract class BaseCrudDialect { if (payload === null) { const { ownedByModel, keyPairs } = getRelationForeignKeyFieldPairs(this.schema, model, field); - if (ownedByModel) { + if (ownedByModel && !fieldDef.originModel) { // can be short-circuited to FK null check - return this.and(eb, ...keyPairs.map(({ fk }) => eb(sql.ref(`${table}.${fk}`), 'is', null))); + return this.and(eb, ...keyPairs.map(({ fk }) => eb(sql.ref(`${modelAlias}.${fk}`), 'is', null))); } else { // translate it to `{ is: null }` filter - return this.buildToOneRelationFilter(eb, model, table, field, fieldDef, { is: null }); + return this.buildToOneRelationFilter(eb, model, modelAlias, field, fieldDef, { is: null }); } } - const joinAlias = `${table}$${field}`; - const joinPairs = buildJoinPairs(this.schema, model, table, field, joinAlias); + const joinAlias = `${modelAlias}$${field}`; + const joinPairs = buildJoinPairs( + this.schema, + model, + // if field is from a base, use the base model to join + fieldDef.originModel ?? modelAlias, + field, + joinAlias, + ); const filterResultField = `${field}$filter`; const joinSelect = eb @@ -284,9 +314,8 @@ export abstract class BaseCrudDialect { eb, result, eb( - eb - .selectFrom(relationModel) - .select((eb1) => eb1.fn.count(eb1.lit(1)).as('count')) + this.buildSelectModel(eb, relationModel) + .select((eb1) => eb1.fn.count(eb1.lit(1)).as('$count')) .where(buildPkFkWhereRefs(eb)) .where((eb1) => this.buildFilter(eb1, relationModel, relationModel, subPayload)), '>', @@ -301,9 +330,8 @@ export abstract class BaseCrudDialect { eb, result, eb( - eb - .selectFrom(relationModel) - .select((eb1) => eb1.fn.count(eb1.lit(1)).as('count')) + this.buildSelectModel(eb, relationModel) + .select((eb1) => eb1.fn.count(eb1.lit(1)).as('$count')) .where(buildPkFkWhereRefs(eb)) .where((eb1) => eb1.not(this.buildFilter(eb1, relationModel, relationModel, subPayload)), @@ -320,9 +348,8 @@ export abstract class BaseCrudDialect { eb, result, eb( - eb - .selectFrom(relationModel) - .select((eb1) => eb1.fn.count(eb1.lit(1)).as('count')) + this.buildSelectModel(eb, relationModel) + .select((eb1) => eb1.fn.count(eb1.lit(1)).as('$count')) .where(buildPkFkWhereRefs(eb)) .where((eb1) => this.buildFilter(eb1, relationModel, relationModel, subPayload)), '=', @@ -339,15 +366,12 @@ export abstract class BaseCrudDialect { private buildArrayFilter( eb: ExpressionBuilder, - model: string, - modelAlias: string, - field: string, + fieldRef: Expression, fieldDef: FieldDef, payload: any, ) { const clauses: Expression[] = []; const fieldType = fieldDef.type as BuiltinType; - const fieldRef = buildFieldRef(this.schema, model, field, this.options, eb, modelAlias); for (const [key, _value] of Object.entries(payload)) { if (_value === undefined) { @@ -391,31 +415,24 @@ export abstract class BaseCrudDialect { return this.and(eb, ...clauses); } - buildPrimitiveFilter( - eb: ExpressionBuilder, - model: string, - modelAlias: string, - field: string, - fieldDef: FieldDef, - payload: any, - ) { + buildPrimitiveFilter(eb: ExpressionBuilder, fieldRef: Expression, fieldDef: FieldDef, payload: any) { if (payload === null) { - return eb(sql.ref(`${modelAlias}.${field}`), 'is', null); + return eb(fieldRef, 'is', null); } if (isEnum(this.schema, fieldDef.type)) { - return this.buildEnumFilter(eb, modelAlias, field, fieldDef, payload); + return this.buildEnumFilter(eb, fieldRef, fieldDef, payload); } return ( match(fieldDef.type as BuiltinType) - .with('String', () => this.buildStringFilter(eb, modelAlias, field, payload)) + .with('String', () => this.buildStringFilter(eb, fieldRef, payload)) .with(P.union('Int', 'Float', 'Decimal', 'BigInt'), (type) => - this.buildNumberFilter(eb, model, modelAlias, field, type, payload), + this.buildNumberFilter(eb, fieldRef, type, payload), ) - .with('Boolean', () => this.buildBooleanFilter(eb, modelAlias, field, payload)) - .with('DateTime', () => this.buildDateTimeFilter(eb, modelAlias, field, payload)) - .with('Bytes', () => this.buildBytesFilter(eb, modelAlias, field, payload)) + .with('Boolean', () => this.buildBooleanFilter(eb, fieldRef, payload)) + .with('DateTime', () => this.buildDateTimeFilter(eb, fieldRef, payload)) + .with('Bytes', () => this.buildBytesFilter(eb, fieldRef, payload)) // TODO: JSON filters .with('Json', () => { throw new InternalError('JSON filters are not supported yet'); @@ -496,15 +513,7 @@ export abstract class BaseCrudDialect { return { conditions, consumedKeys }; } - private buildStringFilter( - eb: ExpressionBuilder, - table: string, - field: string, - payload: StringFilter, - ) { - const fieldDef = getField(this.schema, table, field); - let fieldRef: Expression = fieldDef?.computed ? sql.ref(field) : sql.ref(`${table}.${field}`); - + private buildStringFilter(eb: ExpressionBuilder, fieldRef: Expression, payload: StringFilter) { let insensitive = false; if (payload && typeof payload === 'object' && 'mode' in payload && payload.mode === 'insensitive') { insensitive = true; @@ -517,7 +526,7 @@ export abstract class BaseCrudDialect { payload, fieldRef, (value) => this.prepStringCasing(eb, value, insensitive), - (value) => this.buildStringFilter(eb, table, field, value as StringFilter), + (value) => this.buildStringFilter(eb, fieldRef, value as StringFilter), ); if (payload && typeof payload === 'object') { @@ -568,9 +577,7 @@ export abstract class BaseCrudDialect { private buildNumberFilter( eb: ExpressionBuilder, - model: string, - modelAlias: string, - field: string, + fieldRef: Expression, type: BuiltinType, payload: any, ) { @@ -578,26 +585,25 @@ export abstract class BaseCrudDialect { eb, type, payload, - buildFieldRef(this.schema, model, field, this.options, eb, modelAlias), + fieldRef, (value) => this.transformPrimitive(value, type, false), - (value) => this.buildNumberFilter(eb, model, modelAlias, field, type, value), + (value) => this.buildNumberFilter(eb, fieldRef, type, value), ); return this.and(eb, ...conditions); } private buildBooleanFilter( eb: ExpressionBuilder, - table: string, - field: string, + fieldRef: Expression, payload: BooleanFilter, ) { const { conditions } = this.buildStandardFilter( eb, 'Boolean', payload, - sql.ref(`${table}.${field}`), + fieldRef, (value) => this.transformPrimitive(value, 'Boolean', false), - (value) => this.buildBooleanFilter(eb, table, field, value as BooleanFilter), + (value) => this.buildBooleanFilter(eb, fieldRef, value as BooleanFilter), true, ['equals', 'not'], ); @@ -606,35 +612,29 @@ export abstract class BaseCrudDialect { private buildDateTimeFilter( eb: ExpressionBuilder, - table: string, - field: string, + fieldRef: Expression, payload: DateTimeFilter, ) { const { conditions } = this.buildStandardFilter( eb, 'DateTime', payload, - sql.ref(`${table}.${field}`), + fieldRef, (value) => this.transformPrimitive(value, 'DateTime', false), - (value) => this.buildDateTimeFilter(eb, table, field, value as DateTimeFilter), + (value) => this.buildDateTimeFilter(eb, fieldRef, value as DateTimeFilter), true, ); return this.and(eb, ...conditions); } - private buildBytesFilter( - eb: ExpressionBuilder, - table: string, - field: string, - payload: BytesFilter, - ) { + private buildBytesFilter(eb: ExpressionBuilder, fieldRef: Expression, payload: BytesFilter) { const conditions = this.buildStandardFilter( eb, 'Bytes', payload, - sql.ref(`${table}.${field}`), + fieldRef, (value) => this.transformPrimitive(value, 'Bytes', false), - (value) => this.buildBytesFilter(eb, table, field, value as BytesFilter), + (value) => this.buildBytesFilter(eb, fieldRef, value as BytesFilter), true, ['equals', 'in', 'notIn', 'not'], ); @@ -643,8 +643,7 @@ export abstract class BaseCrudDialect { private buildEnumFilter( eb: ExpressionBuilder, - table: string, - field: string, + fieldRef: Expression, fieldDef: FieldDef, payload: any, ) { @@ -652,9 +651,9 @@ export abstract class BaseCrudDialect { eb, 'String', payload, - sql.ref(`${table}.${field}`), + fieldRef, (value) => value, - (value) => this.buildEnumFilter(eb, table, field, fieldDef, value), + (value) => this.buildEnumFilter(eb, fieldRef, fieldDef, value), true, ['equals', 'in', 'notIn', 'not'], ); @@ -747,7 +746,7 @@ export abstract class BaseCrudDialect { ); const sort = this.negateSort(value._count, negated); result = result.orderBy((eb) => { - let subQuery = eb.selectFrom(relationModel); + let subQuery = this.buildSelectModel(eb, relationModel); const joinPairs = buildJoinPairs(this.schema, model, modelAlias, field, relationModel); subQuery = subQuery.where(() => this.and( @@ -783,7 +782,6 @@ export abstract class BaseCrudDialect { model: string, query: SelectQueryBuilder, omit?: Record, - joinedBases: string[] = [], ) { const modelDef = requireModel(this.schema, model); let result = query; @@ -795,16 +793,13 @@ export abstract class BaseCrudDialect { if (omit?.[field] === true) { continue; } - result = this.buildSelectField(result, model, model, field, joinedBases); + result = this.buildSelectField(result, model, model, field); } // select all fields from delegate descendants and pack into a JSON field `$delegate$Model` const descendants = getDelegateDescendantModels(this.schema, model); for (const subModel of descendants) { - if (!joinedBases.includes(subModel.name)) { - joinedBases.push(subModel.name); - result = this.buildDelegateJoin(model, subModel.name, result); - } + result = this.buildDelegateJoin(model, subModel.name, result); result = result.select((eb) => { const jsonObject: Record> = {}; for (const field of Object.keys(subModel.fields)) { @@ -823,13 +818,7 @@ export abstract class BaseCrudDialect { return result; } - buildSelectField( - query: SelectQueryBuilder, - model: string, - modelAlias: string, - field: string, - joinedBases: string[], - ) { + buildSelectField(query: SelectQueryBuilder, model: string, modelAlias: string, field: string) { const fieldDef = requireField(this.schema, model, field); if (fieldDef.computed) { @@ -841,11 +830,7 @@ export abstract class BaseCrudDialect { } else { // field from delegate base, build a join let result = query; - if (!joinedBases.includes(fieldDef.originModel)) { - joinedBases.push(fieldDef.originModel); - result = this.buildDelegateJoin(model, fieldDef.originModel, result); - } - result = this.buildSelectField(result, fieldDef.originModel, fieldDef.originModel, field, joinedBases); + result = this.buildSelectField(result, fieldDef.originModel, fieldDef.originModel, field); return result; } } diff --git a/packages/runtime/src/client/crud/dialects/postgresql.ts b/packages/runtime/src/client/crud/dialects/postgresql.ts index be273e81..f91c7aad 100644 --- a/packages/runtime/src/client/crud/dialects/postgresql.ts +++ b/packages/runtime/src/client/crud/dialects/postgresql.ts @@ -81,17 +81,14 @@ export class PostgresCrudDialect extends BaseCrudDiale // simple select by default let result = eb.selectFrom(`${relationModel} as ${joinTableName}`); - const joinBases: string[] = []; - // however if there're filter/orderBy/take/skip, // we need to build a subquery to handle them before aggregation result = eb.selectFrom(() => { - let subQuery = eb.selectFrom(relationModel); + let subQuery = this.buildSelectModel(eb, relationModel); subQuery = this.buildSelectAllFields( relationModel, subQuery, typeof payload === 'object' ? payload?.omit : undefined, - joinBases, ); if (payload && typeof payload === 'object') { diff --git a/packages/runtime/src/client/crud/dialects/sqlite.ts b/packages/runtime/src/client/crud/dialects/sqlite.ts index 2961b864..3d74f82c 100644 --- a/packages/runtime/src/client/crud/dialects/sqlite.ts +++ b/packages/runtime/src/client/crud/dialects/sqlite.ts @@ -77,14 +77,12 @@ export class SqliteCrudDialect extends BaseCrudDialect const subQueryName = `${parentName}$${relationField}`; let tbl = eb.selectFrom(() => { - let subQuery = eb.selectFrom(relationModel); + let subQuery = this.buildSelectModel(eb, relationModel); - const joinBases: string[] = []; subQuery = this.buildSelectAllFields( relationModel, subQuery, typeof payload === 'object' ? payload?.omit : undefined, - joinBases, ); if (payload && typeof payload === 'object') { diff --git a/packages/runtime/src/client/crud/operations/base.ts b/packages/runtime/src/client/crud/operations/base.ts index ca77245a..43884f6b 100644 --- a/packages/runtime/src/client/crud/operations/base.ts +++ b/packages/runtime/src/client/crud/operations/base.ts @@ -143,7 +143,7 @@ export abstract class BaseOperationHandler { args: FindArgs, true> | undefined, ): Promise { // table - let query = kysely.selectFrom(model); + let query = this.dialect.buildSelectModel(expressionBuilder(), model); // where if (args?.where) { @@ -182,22 +182,19 @@ export abstract class BaseOperationHandler { } } - // for deduplicating base joins - const joinedBases: string[] = []; - // select if (args && 'select' in args && args.select) { // select is mutually exclusive with omit - query = this.buildFieldSelection(model, query, args.select, model, joinedBases); + query = this.buildFieldSelection(model, query, args.select, model); } else { // include all scalar fields except those in omit - query = this.dialect.buildSelectAllFields(model, query, (args as any)?.omit, joinedBases); + query = this.dialect.buildSelectAllFields(model, query, (args as any)?.omit); } // include if (args && 'include' in args && args.include) { // note that 'omit' is handled above already - query = this.buildFieldSelection(model, query, args.include, model, joinedBases); + query = this.buildFieldSelection(model, query, args.include, model); } if (args?.cursor) { @@ -207,13 +204,15 @@ export abstract class BaseOperationHandler { query = query.modifyEnd(this.makeContextComment({ model, operation: 'read' })); let result: any[] = []; + const queryId = { queryId: `zenstack-${createId()}` }; + const compiled = kysely.getExecutor().compileQuery(query.toOperationNode(), queryId); try { - result = await query.execute(); + const r = await kysely.getExecutor().executeQuery(compiled, queryId); + result = r.rows; } catch (err) { - const { sql, parameters } = query.compile(); - let message = `Failed to execute query: ${err}, sql: ${sql}`; + let message = `Failed to execute query: ${err}, sql: ${compiled.sql}`; if (this.options.debug) { - message += `, parameters: \n${parameters.map((p) => inspect(p)).join('\n')}`; + message += `, parameters: \n${compiled.parameters.map((p) => inspect(p)).join('\n')}`; } throw new QueryError(message, err); } @@ -248,7 +247,6 @@ export abstract class BaseOperationHandler { query: SelectQueryBuilder, selectOrInclude: Record, parentAlias: string, - joinedBases: string[], ) { let result = query; @@ -265,17 +263,12 @@ export abstract class BaseOperationHandler { const fieldDef = this.requireField(model, field); if (!fieldDef.relation) { // scalar field - result = this.dialect.buildSelectField(result, model, parentAlias, field, joinedBases); + result = this.dialect.buildSelectField(result, model, parentAlias, field); } else { if (!fieldDef.array && !fieldDef.optional && payload.where) { throw new QueryError(`Field "${field}" doesn't support filtering`); } if (fieldDef.originModel) { - // relation is inherited from a delegate base model, need to build a join - if (!joinedBases.includes(fieldDef.originModel)) { - joinedBases.push(fieldDef.originModel); - result = this.dialect.buildDelegateJoin(parentAlias, fieldDef.originModel, result); - } result = this.dialect.buildRelationSelection( result, fieldDef.originModel, @@ -399,8 +392,15 @@ export abstract class BaseOperationHandler { model: GetModels, data: any, fromRelation?: FromRelationContext, + creatingForDelegate = false, ): Promise { const modelDef = this.requireModel(model); + + // additional validations + if (modelDef.isDelegate && !creatingForDelegate) { + throw new QueryError(`Model "${this.model}" is a delegate and cannot be created directly.`); + } + let createFields: any = {}; let parentUpdateTask: ((entity: any) => Promise) | undefined = undefined; @@ -573,7 +573,7 @@ export abstract class BaseOperationHandler { thisCreateFields[discriminatorField] = forModel; // create base model entity - const createResult = await this.create(kysely, model as GetModels, thisCreateFields); + const createResult = await this.create(kysely, model as GetModels, thisCreateFields, undefined, true); // copy over id fields from base model const idValues = extractIdFields(createResult, this.schema, model); diff --git a/packages/runtime/src/client/crud/operations/create.ts b/packages/runtime/src/client/crud/operations/create.ts index e097d475..bc15bb36 100644 --- a/packages/runtime/src/client/crud/operations/create.ts +++ b/packages/runtime/src/client/crud/operations/create.ts @@ -4,15 +4,9 @@ import type { GetModels, SchemaDef } from '../../../schema'; import type { CreateArgs, CreateManyAndReturnArgs, CreateManyArgs, WhereInput } from '../../crud-types'; import { getIdValues } from '../../query-utils'; import { BaseOperationHandler } from './base'; -import { QueryError } from '../../errors'; export class CreateOperationHandler extends BaseOperationHandler { async handle(operation: 'create' | 'createMany' | 'createManyAndReturn', args: unknown | undefined) { - const modelDef = this.requireModel(this.model); - if (modelDef.isDelegate) { - throw new QueryError(`Model "${this.model}" is a delegate and cannot be created directly.`); - } - // normalize args to strip `undefined` fields const normalizedArgs = this.normalizeArgs(args); diff --git a/packages/runtime/test/client-api/delegate.test.ts b/packages/runtime/test/client-api/delegate.test.ts index eb55ae22..8c3a7906 100644 --- a/packages/runtime/test/client-api/delegate.test.ts +++ b/packages/runtime/test/client-api/delegate.test.ts @@ -15,6 +15,13 @@ model User { ratedVideos RatedVideo[] @relation('direct') } +model Comment { + id Int @id @default(autoincrement()) + content String + asset Asset? @relation(fields: [assetId], references: [id]) + assetId Int? +} + model Asset { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) @@ -22,6 +29,7 @@ model Asset { viewCount Int @default(0) owner User? @relation(fields: [ownerId], references: [id]) ownerId Int? + comments Comment[] assetType String @@delegate(assetType) @@ -78,6 +86,15 @@ model Gallery { }, }), ).rejects.toThrow('is a delegate'); + await expect( + client.user.create({ + data: { + assets: { + create: { assetType: 'Video' }, + }, + }, + }), + ).rejects.toThrow('is a delegate'); // create entity with two levels of delegation await expect( @@ -242,5 +259,249 @@ model Gallery { ratedVideos: [{ url: 'abc', rating: 5 }], }); }); + + describe('Delegate filter tests', async () => { + beforeEach(async () => { + const u = await client.user.create({ + data: { + email: 'u1@example.com', + }, + }); + await client.ratedVideo.create({ + data: { + viewCount: 0, + duration: 100, + url: 'v1', + rating: 5, + owner: { connect: { id: u.id } }, + user: { connect: { id: u.id } }, + comments: { create: { content: 'c1' } }, + }, + }); + await client.ratedVideo.create({ + data: { + viewCount: 1, + duration: 200, + url: 'v2', + rating: 4, + owner: { connect: { id: u.id } }, + user: { connect: { id: u.id } }, + comments: { create: { content: 'c2' } }, + }, + }); + }); + + it('works with toplevel filters', async () => { + await expect( + client.asset.findMany({ + where: { viewCount: { gt: 0 } }, + }), + ).toResolveWithLength(1); + + await expect( + client.video.findMany({ + where: { viewCount: { gt: 0 }, url: 'v1' }, + }), + ).toResolveWithLength(0); + + await expect( + client.video.findMany({ + where: { viewCount: { gt: 0 }, url: 'v2' }, + }), + ).toResolveWithLength(1); + + await expect( + client.ratedVideo.findMany({ + where: { viewCount: { gt: 0 }, rating: 5 }, + }), + ).toResolveWithLength(0); + + await expect( + client.ratedVideo.findMany({ + where: { viewCount: { gt: 0 }, rating: 4 }, + }), + ).toResolveWithLength(1); + }); + + it('works with filtering relations', async () => { + await expect( + client.user.findFirst({ + include: { + assets: { + where: { viewCount: { gt: 0 } }, + }, + }, + }), + ).resolves.toSatisfy((user) => user.assets.length === 1); + + await expect( + client.user.findFirst({ + include: { + ratedVideos: { + where: { viewCount: { gt: 0 }, url: 'v1' }, + }, + }, + }), + ).resolves.toSatisfy((user) => user.ratedVideos.length === 0); + + await expect( + client.user.findFirst({ + include: { + ratedVideos: { + where: { viewCount: { gt: 0 }, url: 'v2' }, + }, + }, + }), + ).resolves.toSatisfy((user) => user.ratedVideos.length === 1); + + await expect( + client.user.findFirst({ + include: { + ratedVideos: { + where: { viewCount: { gt: 0 }, rating: 5 }, + }, + }, + }), + ).resolves.toSatisfy((user) => user.ratedVideos.length === 0); + + await expect( + client.user.findFirst({ + include: { + ratedVideos: { + where: { viewCount: { gt: 0 }, rating: 4 }, + }, + }, + }), + ).resolves.toSatisfy((user) => user.ratedVideos.length === 1); + }); + + it('works with filtering parents', async () => { + await expect( + client.user.findFirst({ + where: { + assets: { + some: { viewCount: { gt: 0 } }, + }, + }, + }), + ).toResolveTruthy(); + + await expect( + client.user.findFirst({ + where: { + assets: { + some: { viewCount: { gt: 1 } }, + }, + }, + }), + ).toResolveFalsy(); + + await expect( + client.user.findFirst({ + where: { + ratedVideos: { + some: { viewCount: { gt: 0 }, url: 'v1' }, + }, + }, + }), + ).toResolveFalsy(); + + await expect( + client.user.findFirst({ + where: { + ratedVideos: { + some: { viewCount: { gt: 0 }, url: 'v2' }, + }, + }, + }), + ).toResolveTruthy(); + }); + + it('works with filtering with relations from base', async () => { + await expect( + client.video.findFirst({ + where: { + owner: { + email: 'u1@example.com', + }, + }, + }), + ).toResolveTruthy(); + + await expect( + client.video.findFirst({ + where: { + owner: { + email: 'u2@example.com', + }, + }, + }), + ).toResolveFalsy(); + + await expect( + client.video.findFirst({ + where: { + owner: null, + }, + }), + ).toResolveFalsy(); + + await expect( + client.video.findFirst({ + where: { + owner: { is: null }, + }, + }), + ).toResolveFalsy(); + + await expect( + client.video.findFirst({ + where: { + owner: { isNot: null }, + }, + }), + ).toResolveTruthy(); + + await expect( + client.video.findFirst({ + where: { + comments: { + some: { content: 'c1' }, + }, + }, + }), + ).toResolveTruthy(); + + await expect( + client.video.findFirst({ + where: { + comments: { + all: { content: 'c2' }, + }, + }, + }), + ).toResolveTruthy(); + + await expect( + client.video.findFirst({ + where: { + comments: { + none: { content: 'c1' }, + }, + }, + }), + ).toResolveTruthy(); + + await expect( + client.video.findFirst({ + where: { + comments: { + none: { content: { startsWith: 'c' } }, + }, + }, + }), + ).toResolveFalsy(); + }); + }); }, ); diff --git a/packages/runtime/test/client-api/typed-json-fields.test.ts b/packages/runtime/test/client-api/typed-json-fields.test.ts index fdf01f81..53ca43e0 100644 --- a/packages/runtime/test/client-api/typed-json-fields.test.ts +++ b/packages/runtime/test/client-api/typed-json-fields.test.ts @@ -29,7 +29,6 @@ model User { usePrismaPush: true, provider, dbName: provider === 'postgresql' ? PG_DB_NAME : undefined, - log: ['query'], }); }); From 05b0c51b340e2dac98d80ac4ff7253f7d804d1e2 Mon Sep 17 00:00:00 2001 From: Yiming Cao Date: Sat, 26 Jul 2025 15:09:29 +0800 Subject: [PATCH 13/19] feat: implement update/upsert for delegate models (#113) --- .../src/client/crud/operations/base.ts | 322 +++++++++-- .../src/client/crud/operations/update.ts | 59 +- .../runtime/test/client-api/delegate.test.ts | 529 +++++++++++++++--- 3 files changed, 767 insertions(+), 143 deletions(-) diff --git a/packages/runtime/src/client/crud/operations/base.ts b/packages/runtime/src/client/crud/operations/base.ts index 43884f6b..f2c5769a 100644 --- a/packages/runtime/src/client/crud/operations/base.ts +++ b/packages/runtime/src/client/crud/operations/base.ts @@ -573,13 +573,19 @@ export abstract class BaseOperationHandler { thisCreateFields[discriminatorField] = forModel; // create base model entity - const createResult = await this.create(kysely, model as GetModels, thisCreateFields, undefined, true); + const baseEntity: any = await this.create( + kysely, + model as GetModels, + thisCreateFields, + undefined, + true, + ); // copy over id fields from base model - const idValues = extractIdFields(createResult, this.schema, model); + const idValues = extractIdFields(baseEntity, this.schema, model); Object.assign(remainingFields, idValues); - return { baseEntity: createResult, remainingFields }; + return { baseEntity, remainingFields }; } private buildFkAssignments(model: string, relationField: string, entity: any) { @@ -844,7 +850,7 @@ export abstract class BaseOperationHandler { relationKeyPairs = keyPairs; } - const createData = enumerate(input.data).map((item) => { + let createData = enumerate(input.data).map((item) => { const newItem: any = {}; for (const [name, value] of Object.entries(item)) { const fieldDef = this.requireField(model, name); @@ -859,6 +865,22 @@ export abstract class BaseOperationHandler { return this.fillGeneratedValues(modelDef, newItem); }); + if (modelDef.baseModel) { + if (input.skipDuplicates) { + // TODO: simulate createMany with create in this case + throw new QueryError('"skipDuplicates" options is not supported for polymorphic models'); + } + // create base hierarchy + const baseCreateResult = await this.processBaseModelCreateMany( + kysely, + modelDef.baseModel, + createData, + !!input.skipDuplicates, + model, + ); + createData = baseCreateResult.remainingFieldRows; + } + const query = kysely .insertInto(model) .values(createData) @@ -880,6 +902,50 @@ export abstract class BaseOperationHandler { } } + private async processBaseModelCreateMany( + kysely: ToKysely, + model: string, + createRows: any[], + skipDuplicates: boolean, + forModel: GetModels, + ) { + const thisCreateRows: any[] = []; + const remainingFieldRows: any[] = []; + const discriminatorField = getDiscriminatorField(this.schema, model); + invariant(discriminatorField, `Base model "${model}" must have a discriminator field`); + + for (const createFields of createRows) { + const thisCreateFields: any = {}; + const remainingFields: any = {}; + Object.entries(createFields).forEach(([field, value]) => { + const fieldDef = this.getField(model, field); + if (fieldDef) { + thisCreateFields[field] = value; + } else { + remainingFields[field] = value; + } + }); + thisCreateFields[discriminatorField] = forModel; + thisCreateRows.push(thisCreateFields); + remainingFieldRows.push(remainingFields); + } + + // create base model entity + const baseEntities = await this.createMany( + kysely, + model as GetModels, + { data: thisCreateRows, skipDuplicates }, + true, + ); + + // copy over id fields from base model + for (let i = 0; i < baseEntities.length; i++) { + const idValues = extractIdFields(baseEntities[i], this.schema, model); + Object.assign(remainingFieldRows[i], idValues); + } + return { baseEntities, remainingFieldRows }; + } + private fillGeneratedValues(modelDef: ModelDef, data: object) { const fields = modelDef.fields; const values: any = clone(data); @@ -947,7 +1013,7 @@ export abstract class BaseOperationHandler { fromRelation?: FromRelationContext, allowRelationUpdate = true, throwIfNotFound = true, - ) { + ): Promise { if (!data || typeof data !== 'object') { throw new InternalError('data must be an object'); } @@ -1004,14 +1070,41 @@ export abstract class BaseOperationHandler { } if (Object.keys(finalData).length === 0) { - // update without data, simply return - const r = await this.readUnique(kysely, model, { + // nothing to update, return the original filter so that caller can identify the entity + return combinedWhere; + } + + let needIdRead = false; + if (modelDef.baseModel && !this.isIdFilter(model, combinedWhere)) { + // when updating a model with delegate base, base fields may be referenced in the filter, + // so we read the id out if the filter is not ready an id filter, and and use it as the + // update filter instead + needIdRead = true; + } + + if (needIdRead) { + const readResult = await this.readUnique(kysely, model, { where: combinedWhere, - } as FindArgs, true>); - if (!r && throwIfNotFound) { + select: this.makeIdSelect(model), + }); + if (!readResult && throwIfNotFound) { throw new NotFoundError(model); } - return r; + combinedWhere = readResult; + } + + if (modelDef.baseModel) { + const baseUpdateResult = await this.processBaseModelUpdate( + kysely, + modelDef.baseModel, + combinedWhere, + finalData, + throwIfNotFound, + ); + // only fields not consumed by base update will be used for this model + finalData = baseUpdateResult.remainingFields; + // base update may change entity ids, update the filter + combinedWhere = baseUpdateResult.baseEntity; } const updateFields: any = {}; @@ -1020,28 +1113,7 @@ export abstract class BaseOperationHandler { for (const field in finalData) { const fieldDef = this.requireField(model, field); if (isScalarField(this.schema, model, field) || isForeignKeyField(this.schema, model, field)) { - if (this.isNumericField(fieldDef) && typeof finalData[field] === 'object' && finalData[field]) { - // numeric fields incremental updates - updateFields[field] = this.transformIncrementalUpdate(model, field, fieldDef, finalData[field]); - continue; - } - - if ( - fieldDef.array && - typeof finalData[field] === 'object' && - !Array.isArray(finalData[field]) && - finalData[field] - ) { - // scalar list updates - updateFields[field] = this.transformScalarListUpdate(model, field, fieldDef, finalData[field]); - continue; - } - - updateFields[field] = this.dialect.transformPrimitive( - finalData[field], - fieldDef.type as BuiltinType, - !!fieldDef.array, - ); + updateFields[field] = this.processScalarFieldUpdateData(model, field, finalData); } else { if (!allowRelationUpdate) { throw new QueryError(`Relation update not allowed for field "${field}"`); @@ -1072,8 +1144,8 @@ export abstract class BaseOperationHandler { } if (Object.keys(updateFields).length === 0) { - // nothing to update, simply read back - return thisEntity ?? (await this.readUnique(kysely, model, { where: combinedWhere })); + // nothing to update, return the filter so that the caller can identify the entity + return combinedWhere; } else { const idFields = getIdFields(this.schema, model); const query = kysely @@ -1111,6 +1183,71 @@ export abstract class BaseOperationHandler { } } + private processScalarFieldUpdateData(model: GetModels, field: string, data: any): any { + const fieldDef = this.requireField(model, field); + if (this.isNumericIncrementalUpdate(fieldDef, data[field])) { + // numeric fields incremental updates + return this.transformIncrementalUpdate(model, field, fieldDef, data[field]); + } + + if (fieldDef.array && typeof data[field] === 'object' && !Array.isArray(data[field]) && data[field]) { + // scalar list updates + return this.transformScalarListUpdate(model, field, fieldDef, data[field]); + } + + return this.dialect.transformPrimitive(data[field], fieldDef.type as BuiltinType, !!fieldDef.array); + } + + private isNumericIncrementalUpdate(fieldDef: FieldDef, value: any) { + if (!this.isNumericField(fieldDef)) { + return false; + } + if (typeof value !== 'object' || !value) { + return false; + } + return ['increment', 'decrement', 'multiply', 'divide', 'set'].some((key) => key in value); + } + + private isIdFilter(model: GetModels, filter: any) { + if (!filter || typeof filter !== 'object') { + return false; + } + const idFields = getIdFields(this.schema, model); + return idFields.length === Object.keys(filter).length && idFields.every((field) => field in filter); + } + + private async processBaseModelUpdate( + kysely: ToKysely, + model: string, + where: any, + updateFields: any, + throwIfNotFound: boolean, + ) { + const thisUpdateFields: any = {}; + const remainingFields: any = {}; + + Object.entries(updateFields).forEach(([field, value]) => { + const fieldDef = this.getField(model, field); + if (fieldDef) { + thisUpdateFields[field] = value; + } else { + remainingFields[field] = value; + } + }); + + // update base model entity + const baseEntity: any = await this.update( + kysely, + model as GetModels, + where, + thisUpdateFields, + undefined, + undefined, + throwIfNotFound, + ); + return { baseEntity, remainingFields }; + } + private transformIncrementalUpdate( model: GetModels, field: string, @@ -1178,6 +1315,7 @@ export abstract class BaseOperationHandler { data: any, limit: number | undefined, returnData: ReturnData, + filterModel?: GetModels, ): Promise { if (typeof data !== 'object') { throw new InternalError('data must be an object'); @@ -1187,43 +1325,74 @@ export abstract class BaseOperationHandler { return (returnData ? [] : { count: 0 }) as Result; } - const updateFields: any = {}; + filterModel ??= model; + let updateFields: any = {}; for (const field in data) { - const fieldDef = this.requireField(model, field); if (isRelationField(this.schema, model, field)) { continue; } - updateFields[field] = this.dialect.transformPrimitive( - data[field], - fieldDef.type as BuiltinType, - !!fieldDef.array, + updateFields[field] = this.processScalarFieldUpdateData(model, field, data); + } + + const modelDef = this.requireModel(model); + let shouldFallbackToIdFilter = false; + + if (limit !== undefined && !this.dialect.supportsUpdateWithLimit) { + // if the dialect doesn't support update with limit natively, we'll + // simulate it by filtering by id with a limit + shouldFallbackToIdFilter = true; + } + + if (modelDef.isDelegate || modelDef.baseModel) { + // if the model is in a delegate hierarchy, we'll need to filter by + // id because the filter may involve fields in different models in + // the hierarchy + shouldFallbackToIdFilter = true; + } + + let resultFromBaseModel: any = undefined; + if (modelDef.baseModel) { + const baseResult = await this.processBaseModelUpdateMany( + kysely, + modelDef.baseModel, + where, + updateFields, + limit, + filterModel, ); + updateFields = baseResult.remainingFields; + resultFromBaseModel = baseResult.baseResult; + } + + // check again if we don't have anything to update for this model + if (Object.keys(updateFields).length === 0) { + // return result from base model if it exists, otherwise return empty result + return resultFromBaseModel ?? ((returnData ? [] : { count: 0 }) as Result); } let query = kysely.updateTable(model).set(updateFields); - if (limit === undefined) { - query = query.where((eb) => this.dialect.buildFilter(eb, model, model, where)); + if (!shouldFallbackToIdFilter) { + // simple filter + query = query + .where((eb) => this.dialect.buildFilter(eb, model, model, where)) + .$if(limit !== undefined, (qb) => qb.limit(limit!)); } else { - if (this.dialect.supportsUpdateWithLimit) { - query = query.where((eb) => this.dialect.buildFilter(eb, model, model, where)).limit(limit!); - } else { - query = query.where((eb) => - eb( - eb.refTuple( - // @ts-expect-error - ...this.buildIdFieldRefs(kysely, model), - ), - 'in', - kysely - .selectFrom(model) - .where((eb) => this.dialect.buildFilter(eb, model, model, where)) - .select(this.buildIdFieldRefs(kysely, model)) - .limit(limit!), + query = query.where((eb) => + eb( + eb.refTuple( + // @ts-expect-error + ...this.buildIdFieldRefs(kysely, model), ), - ); - } + 'in', + this.dialect + .buildSelectModel(eb, filterModel) + .where(this.dialect.buildFilter(eb, filterModel, filterModel, where)) + .select(this.buildIdFieldRefs(kysely, filterModel)) + .$if(limit !== undefined, (qb) => qb.limit(limit!)), + ), + ); } query = query.modifyEnd(this.makeContextComment({ model, operation: 'update' })); @@ -1238,9 +1407,42 @@ export abstract class BaseOperationHandler { } } + private async processBaseModelUpdateMany( + kysely: ToKysely, + model: string, + where: any, + updateFields: any, + limit: number | undefined, + filterModel: GetModels, + ) { + const thisUpdateFields: any = {}; + const remainingFields: any = {}; + + Object.entries(updateFields).forEach(([field, value]) => { + const fieldDef = this.getField(model, field); + if (fieldDef) { + thisUpdateFields[field] = value; + } else { + remainingFields[field] = value; + } + }); + + // update base model entity + const baseResult: any = await this.updateMany( + kysely, + model as GetModels, + where, + thisUpdateFields, + limit, + false, + filterModel, + ); + return { baseResult, remainingFields }; + } + private buildIdFieldRefs(kysely: ToKysely, model: GetModels) { const idFields = getIdFields(this.schema, model); - return idFields.map((f) => kysely.dynamic.ref(f)); + return idFields.map((f) => kysely.dynamic.ref(`${model}.${f}`)); } private async processRelationUpdates( diff --git a/packages/runtime/src/client/crud/operations/update.ts b/packages/runtime/src/client/crud/operations/update.ts index ab0c086f..ea22c773 100644 --- a/packages/runtime/src/client/crud/operations/update.ts +++ b/packages/runtime/src/client/crud/operations/update.ts @@ -25,28 +25,45 @@ export class UpdateOperationHandler extends BaseOperat } private async runUpdate(args: UpdateArgs>) { - const result = await this.safeTransaction(async (tx) => { - const updated = await this.update(tx, this.model, args.where, args.data); - return this.readUnique(tx, this.model, { - select: args.select, - include: args.include, - omit: args.omit, - where: getIdValues(this.schema, this.model, updated) as WhereInput, false>, - }); + const readBackResult = await this.safeTransaction(async (tx) => { + const updateResult = await this.update(tx, this.model, args.where, args.data); + // updated can be undefined if there's nothing to update, in that case we'll use the original + // filter to read back the entity + const readFilter = updateResult ?? args.where; + let readBackResult: any = undefined; + try { + readBackResult = await this.readUnique(tx, this.model, { + select: args.select, + include: args.include, + omit: args.omit, + where: readFilter as WhereInput, false>, + }); + } catch { + // commit the update even if read-back failed + } + return readBackResult; }); - if (!result && this.hasPolicyEnabled) { - throw new RejectedByPolicyError(this.model, 'result is not allowed to be read back'); + if (!readBackResult) { + // update succeeded but result cannot be read back + if (this.hasPolicyEnabled) { + // if access policy is enabled, we assume it's due to read violation (not guaranteed though) + throw new RejectedByPolicyError(this.model, 'result is not allowed to be read back'); + } else { + // this can happen if the entity is cascade deleted during the update, return null to + // be consistent with Prisma even though it doesn't comply with the method signature + return null; + } + } else { + return readBackResult; } - - // NOTE: update can actually return null if the entity being updated is deleted - // due to cascade when a relation is deleted during update. This doesn't comply - // with `update`'s method signature, but we'll allow it to be consistent with Prisma. - return result; } private async runUpdateMany(args: UpdateManyArgs>) { - return this.updateMany(this.kysely, this.model, args.where, args.data, args.limit, false); + // TODO: avoid using transaction for simple update + return this.safeTransaction(async (tx) => { + return this.updateMany(tx, this.model, args.where, args.data, args.limit, false); + }); } private async runUpdateManyAndReturn(args: UpdateManyAndReturnArgs> | undefined) { @@ -68,7 +85,15 @@ export class UpdateOperationHandler extends BaseOperat private async runUpsert(args: UpsertArgs>) { const result = await this.safeTransaction(async (tx) => { - let mutationResult = await this.update(tx, this.model, args.where, args.update, undefined, true, false); + let mutationResult: unknown = await this.update( + tx, + this.model, + args.where, + args.update, + undefined, + true, + false, + ); if (!mutationResult) { // non-existing, create diff --git a/packages/runtime/test/client-api/delegate.test.ts b/packages/runtime/test/client-api/delegate.test.ts index 8c3a7906..6c8ece12 100644 --- a/packages/runtime/test/client-api/delegate.test.ts +++ b/packages/runtime/test/client-api/delegate.test.ts @@ -37,7 +37,7 @@ model Asset { model Video extends Asset { duration Int - url String + url String @unique videoType String @@delegate(videoType) @@ -75,78 +75,144 @@ model Gallery { await client.$disconnect(); }); - it('works with create', async () => { - // delegate model cannot be created directly - await expect( - client.video.create({ - data: { - duration: 100, - url: 'abc', - videoType: 'MyVideo', - }, - }), - ).rejects.toThrow('is a delegate'); - await expect( - client.user.create({ - data: { - assets: { - create: { assetType: 'Video' }, + describe('Delegate create tests', () => { + it('works with create', async () => { + // delegate model cannot be created directly + await expect( + client.video.create({ + data: { + duration: 100, + url: 'abc', + videoType: 'MyVideo', }, - }, - }), - ).rejects.toThrow('is a delegate'); + }), + ).rejects.toThrow('is a delegate'); + await expect( + client.user.create({ + data: { + assets: { + create: { assetType: 'Video' }, + }, + }, + }), + ).rejects.toThrow('is a delegate'); - // create entity with two levels of delegation - await expect( - client.ratedVideo.create({ - data: { - duration: 100, - url: 'abc', - rating: 5, + // create entity with two levels of delegation + await expect( + client.ratedVideo.create({ + data: { + duration: 100, + url: 'abc', + rating: 5, + }, + }), + ).resolves.toMatchObject({ + id: expect.any(Number), + duration: 100, + url: 'abc', + rating: 5, + assetType: 'Video', + videoType: 'RatedVideo', + }); + + // create entity with relation + await expect( + client.ratedVideo.create({ + data: { + duration: 50, + url: 'bcd', + rating: 5, + user: { create: { email: 'u1@example.com' } }, + }, + include: { user: true }, + }), + ).resolves.toMatchObject({ + userId: expect.any(Number), + user: { + email: 'u1@example.com', }, - }), - ).resolves.toMatchObject({ - id: expect.any(Number), - duration: 100, - url: 'abc', - rating: 5, - assetType: 'Video', - videoType: 'RatedVideo', + }); + + // create entity with one level of delegation + await expect( + client.image.create({ + data: { + format: 'png', + gallery: { + create: {}, + }, + }, + }), + ).resolves.toMatchObject({ + id: expect.any(Number), + format: 'png', + galleryId: expect.any(Number), + assetType: 'Image', + }); }); - // create entity with relation - await expect( - client.ratedVideo.create({ - data: { - duration: 50, - url: 'bcd', - rating: 5, - user: { create: { email: 'u1@example.com' } }, - }, - include: { user: true }, - }), - ).resolves.toMatchObject({ - userId: expect.any(Number), - user: { - email: 'u1@example.com', - }, + it('works with createMany', async () => { + await expect( + client.ratedVideo.createMany({ + data: [ + { viewCount: 1, duration: 100, url: 'abc', rating: 5 }, + { viewCount: 2, duration: 200, url: 'def', rating: 4 }, + ], + }), + ).resolves.toEqual({ count: 2 }); + + await expect(client.ratedVideo.findMany()).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + viewCount: 1, + duration: 100, + url: 'abc', + rating: 5, + }), + expect.objectContaining({ + viewCount: 2, + duration: 200, + url: 'def', + rating: 4, + }), + ]), + ); + + await expect( + client.ratedVideo.createMany({ + data: [ + { viewCount: 1, duration: 100, url: 'abc', rating: 5 }, + { viewCount: 2, duration: 200, url: 'def', rating: 4 }, + ], + skipDuplicates: true, + }), + ).rejects.toThrow('not supported'); }); - // create entity with one level of delegation - await expect( - client.image.create({ - data: { - format: 'png', - gallery: { - create: {}, - }, - }, - }), - ).resolves.toMatchObject({ - id: expect.any(Number), - format: 'png', - galleryId: expect.any(Number), - assetType: 'Image', + it('works with createManyAndReturn', async () => { + await expect( + client.ratedVideo.createManyAndReturn({ + data: [ + { viewCount: 1, duration: 100, url: 'abc', rating: 5 }, + { viewCount: 2, duration: 200, url: 'def', rating: 4 }, + ], + }), + ).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + viewCount: 1, + duration: 100, + url: 'abc', + rating: 5, + }), + expect.objectContaining({ + viewCount: 2, + duration: 200, + url: 'def', + rating: 4, + }), + ]), + ); }); }); @@ -503,5 +569,336 @@ model Gallery { ).toResolveFalsy(); }); }); + + describe('Delegate update tests', async () => { + beforeEach(async () => { + const u = await client.user.create({ + data: { + id: 1, + email: 'u1@example.com', + }, + }); + await client.ratedVideo.create({ + data: { + id: 1, + viewCount: 0, + duration: 100, + url: 'v1', + rating: 5, + owner: { connect: { id: u.id } }, + user: { connect: { id: u.id } }, + }, + }); + }); + + it('works with toplevel update', async () => { + // id filter + await expect( + client.ratedVideo.update({ + where: { id: 1 }, + data: { viewCount: { increment: 1 }, duration: 200, rating: { set: 4 } }, + }), + ).resolves.toMatchObject({ + viewCount: 1, + duration: 200, + rating: 4, + }); + await expect( + client.video.update({ + where: { id: 1 }, + data: { viewCount: { decrement: 1 }, duration: 100 }, + }), + ).resolves.toMatchObject({ + viewCount: 0, + duration: 100, + }); + await expect( + client.asset.update({ + where: { id: 1 }, + data: { viewCount: { increment: 1 } }, + }), + ).resolves.toMatchObject({ + viewCount: 1, + }); + + // unique field filter + await expect( + client.ratedVideo.update({ + where: { url: 'v1' }, + data: { viewCount: 2, duration: 300, rating: 3 }, + }), + ).resolves.toMatchObject({ + viewCount: 2, + duration: 300, + rating: 3, + }); + await expect( + client.video.update({ + where: { url: 'v1' }, + data: { viewCount: 3 }, + }), + ).resolves.toMatchObject({ + viewCount: 3, + }); + + // not found + await expect( + client.ratedVideo.update({ + where: { url: 'v2' }, + data: { viewCount: 4 }, + }), + ).toBeRejectedNotFound(); + + // update id + await expect( + client.ratedVideo.update({ + where: { id: 1 }, + data: { id: 2 }, + }), + ).resolves.toMatchObject({ + id: 2, + viewCount: 3, + }); + }); + + it('works with nested update', async () => { + await expect( + client.user.update({ + where: { id: 1 }, + data: { + assets: { + update: { + where: { id: 1 }, + data: { viewCount: { increment: 1 } }, + }, + }, + }, + include: { assets: true }, + }), + ).resolves.toMatchObject({ + assets: [{ viewCount: 1 }], + }); + + await expect( + client.user.update({ + where: { id: 1 }, + data: { + ratedVideos: { + update: { + where: { id: 1 }, + data: { viewCount: 2, rating: 4, duration: 200 }, + }, + }, + }, + include: { ratedVideos: true }, + }), + ).resolves.toMatchObject({ + ratedVideos: [{ viewCount: 2, rating: 4, duration: 200 }], + }); + + // unique filter + await expect( + client.user.update({ + where: { id: 1 }, + data: { + ratedVideos: { + update: { + where: { url: 'v1' }, + data: { viewCount: 3 }, + }, + }, + }, + include: { ratedVideos: true }, + }), + ).resolves.toMatchObject({ + ratedVideos: [{ viewCount: 3 }], + }); + + // deep nested + await expect( + client.user.update({ + where: { id: 1 }, + data: { + assets: { + update: { + where: { id: 1 }, + data: { comments: { create: { content: 'c1' } } }, + }, + }, + }, + include: { assets: { include: { comments: true } } }, + }), + ).resolves.toMatchObject({ + assets: [{ comments: [{ content: 'c1' }] }], + }); + }); + + it('works with updating a base relation', async () => { + await expect( + client.video.update({ + where: { id: 1 }, + data: { + owner: { update: { level: { increment: 1 } } }, + }, + include: { owner: true }, + }), + ).resolves.toMatchObject({ + owner: { level: 1 }, + }); + }); + + it('works with updateMany', async () => { + await client.ratedVideo.create({ + data: { id: 2, viewCount: 1, duration: 200, url: 'abc', rating: 5 }, + }); + + // update from sub model + await expect( + client.ratedVideo.updateMany({ + where: { duration: { gt: 100 } }, + data: { viewCount: { increment: 1 }, duration: { increment: 1 }, rating: { set: 3 } }, + }), + ).resolves.toEqual({ count: 1 }); + + await expect(client.ratedVideo.findMany()).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + viewCount: 2, + duration: 201, + rating: 3, + }), + ]), + ); + + await expect( + client.ratedVideo.updateMany({ + where: { viewCount: { gt: 1 } }, + data: { viewCount: { increment: 1 } }, + }), + ).resolves.toEqual({ count: 1 }); + + await expect( + client.ratedVideo.updateMany({ + where: { rating: 3 }, + data: { viewCount: { increment: 1 } }, + }), + ).resolves.toEqual({ count: 1 }); + + // update from delegate model + await expect( + client.asset.updateMany({ + where: { viewCount: { gt: 0 } }, + data: { viewCount: 100 }, + }), + ).resolves.toEqual({ count: 1 }); + await expect( + client.video.updateMany({ + where: { duration: { gt: 200 } }, + data: { viewCount: 200, duration: 300 }, + }), + ).resolves.toEqual({ count: 1 }); + await expect(client.ratedVideo.findMany()).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + viewCount: 200, + duration: 300, + }), + ]), + ); + }); + + it('works with updateManyAndReturn', async () => { + await client.ratedVideo.create({ + data: { id: 2, viewCount: 1, duration: 200, url: 'abc', rating: 5 }, + }); + + // update from sub model + await expect( + client.ratedVideo.updateManyAndReturn({ + where: { duration: { gt: 100 } }, + data: { viewCount: { increment: 1 }, duration: { increment: 1 }, rating: { set: 3 } }, + }), + ).resolves.toEqual([ + expect.objectContaining({ + viewCount: 2, + duration: 201, + rating: 3, + }), + ]); + + // update from delegate model + await expect( + client.asset.updateManyAndReturn({ + where: { viewCount: { gt: 0 } }, + data: { viewCount: 100 }, + }), + ).resolves.toEqual([ + expect.objectContaining({ + viewCount: 100, + duration: 201, + rating: 3, + }), + ]); + }); + + it('works with upsert', async () => { + await expect( + client.asset.upsert({ + where: { id: 2 }, + create: { + viewCount: 10, + assetType: 'Video', + }, + update: { + viewCount: { increment: 1 }, + }, + }), + ).rejects.toThrow('is a delegate'); + + // create case + await expect( + client.ratedVideo.upsert({ + where: { id: 2 }, + create: { + id: 2, + viewCount: 2, + duration: 200, + url: 'v2', + rating: 3, + }, + update: { + viewCount: { increment: 1 }, + }, + }), + ).resolves.toMatchObject({ + id: 2, + viewCount: 2, + }); + + // update case + await expect( + client.ratedVideo.upsert({ + where: { id: 2 }, + create: { + id: 2, + viewCount: 2, + duration: 200, + url: 'v2', + rating: 3, + }, + update: { + viewCount: 3, + duration: 300, + rating: 2, + }, + }), + ).resolves.toMatchObject({ + id: 2, + viewCount: 3, + duration: 300, + rating: 2, + }); + }); + }); }, ); From cefe223c0b49d29d7f0bd6e5798270d8c5e4c983 Mon Sep 17 00:00:00 2001 From: Yiming Cao Date: Sat, 26 Jul 2025 23:05:36 +0800 Subject: [PATCH 14/19] feat: implement delegate delete (#114) --- .../src/client/crud/operations/base.ts | 209 +++++++++++------- .../src/client/crud/operations/delete.ts | 19 +- .../runtime/test/client-api/delegate.test.ts | 178 ++++++++++++++- 3 files changed, 310 insertions(+), 96 deletions(-) diff --git a/packages/runtime/src/client/crud/operations/base.ts b/packages/runtime/src/client/crud/operations/base.ts index f2c5769a..926bb907 100644 --- a/packages/runtime/src/client/crud/operations/base.ts +++ b/packages/runtime/src/client/crud/operations/base.ts @@ -1325,6 +1325,11 @@ export abstract class BaseOperationHandler { return (returnData ? [] : { count: 0 }) as Result; } + const modelDef = this.requireModel(model); + if (modelDef.baseModel && limit !== undefined) { + throw new QueryError('Updating with a limit is not supported for polymorphic models'); + } + filterModel ??= model; let updateFields: any = {}; @@ -1335,7 +1340,6 @@ export abstract class BaseOperationHandler { updateFields[field] = this.processScalarFieldUpdateData(model, field, data); } - const modelDef = this.requireModel(model); let shouldFallbackToIdFilter = false; if (limit !== undefined && !this.dialect.supportsUpdateWithLimit) { @@ -1358,7 +1362,6 @@ export abstract class BaseOperationHandler { modelDef.baseModel, where, updateFields, - limit, filterModel, ); updateFields = baseResult.remainingFields; @@ -1412,7 +1415,6 @@ export abstract class BaseOperationHandler { model: string, where: any, updateFields: any, - limit: number | undefined, filterModel: GetModels, ) { const thisUpdateFields: any = {}; @@ -1433,7 +1435,7 @@ export abstract class BaseOperationHandler { model as GetModels, where, thisUpdateFields, - limit, + undefined, false, filterModel, ); @@ -1983,24 +1985,18 @@ export abstract class BaseOperationHandler { const fieldDef = this.requireField(fromRelation.model, fromRelation.field); invariant(fieldDef.relation?.opposite); - deleteResult = await this.delete( - kysely, - model, - { - AND: [ - { - [fieldDef.relation.opposite]: { - some: fromRelation.ids, - }, - }, - { - OR: deleteConditions, + deleteResult = await this.delete(kysely, model, { + AND: [ + { + [fieldDef.relation.opposite]: { + some: fromRelation.ids, }, - ], - }, - undefined, - false, - ); + }, + { + OR: deleteConditions, + }, + ], + }); } else { const { ownedByModel, keyPairs } = getRelationForeignKeyFieldPairs( this.schema, @@ -2018,36 +2014,24 @@ export abstract class BaseOperationHandler { const fieldDef = this.requireField(fromRelation.model, fromRelation.field); invariant(fieldDef.relation?.opposite); - deleteResult = await this.delete( - kysely, - model, - { - AND: [ - // filter for parent - Object.fromEntries(keyPairs.map(({ fk, pk }) => [pk, fromEntity[fk]])), - { - OR: deleteConditions, - }, - ], - }, - undefined, - false, - ); + deleteResult = await this.delete(kysely, model, { + AND: [ + // filter for parent + Object.fromEntries(keyPairs.map(({ fk, pk }) => [pk, fromEntity[fk]])), + { + OR: deleteConditions, + }, + ], + }); } else { - deleteResult = await this.delete( - kysely, - model, - { - AND: [ - Object.fromEntries(keyPairs.map(({ fk, pk }) => [fk, fromRelation.ids[pk]])), - { - OR: deleteConditions, - }, - ], - }, - undefined, - false, - ); + deleteResult = await this.delete(kysely, model, { + AND: [ + Object.fromEntries(keyPairs.map(({ fk, pk }) => [fk, fromRelation.ids[pk]])), + { + OR: deleteConditions, + }, + ], + }); } } @@ -2064,54 +2048,109 @@ export abstract class BaseOperationHandler { // #endregion - protected async delete< - ReturnData extends boolean, - Result = ReturnData extends true ? unknown[] : { count: number }, - >( + protected async delete( kysely: ToKysely, model: GetModels, where: any, - limit: number | undefined, - returnData: ReturnData, - ): Promise { + limit?: number, + filterModel?: GetModels, + ): Promise<{ count: number }> { + filterModel ??= model; + + const modelDef = this.requireModel(model); + + if (modelDef.baseModel) { + if (limit !== undefined) { + throw new QueryError('Deleting with a limit is not supported for polymorphic models'); + } + // just delete base and it'll cascade back to this model + return this.processBaseModelDelete(kysely, modelDef.baseModel, where, limit, filterModel); + } + let query = kysely.deleteFrom(model); + let needIdFilter = false; + + if (limit !== undefined && !this.dialect.supportsDeleteWithLimit) { + // if the dialect doesn't support delete with limit natively, we'll + // simulate it by filtering by id with a limit + needIdFilter = true; + } - if (limit === undefined) { + if (modelDef.isDelegate || modelDef.baseModel) { + // if the model is in a delegate hierarchy, we'll need to filter by + // id because the filter may involve fields in different models in + // the hierarchy + needIdFilter = true; + } + + if (!needIdFilter) { query = query.where((eb) => this.dialect.buildFilter(eb, model, model, where)); } else { - if (this.dialect.supportsDeleteWithLimit) { - query = query.where((eb) => this.dialect.buildFilter(eb, model, model, where)).limit(limit!); - } else { - query = query.where((eb) => - eb( - eb.refTuple( - // @ts-expect-error - ...this.buildIdFieldRefs(kysely, model), - ), - 'in', - kysely - .selectFrom(model) - .where((eb) => this.dialect.buildFilter(eb, model, model, where)) - .select(this.buildIdFieldRefs(kysely, model)) - .limit(limit!), + query = query.where((eb) => + eb( + eb.refTuple( + // @ts-expect-error + ...this.buildIdFieldRefs(kysely, model), ), - ); - } + 'in', + this.dialect + .buildSelectModel(eb, filterModel) + .where((eb) => this.dialect.buildFilter(eb, filterModel, filterModel, where)) + .select(this.buildIdFieldRefs(kysely, filterModel)) + .$if(limit !== undefined, (qb) => qb.limit(limit!)), + ), + ); } + // if the model being deleted has a relation to a model that extends a delegate model, and if that + // relation is set to trigger a cascade delete from this model, the deletion will not automatically + // clean up the base hierarchy of the relation side (because polymorphic model's cascade deletion + // works downward not upward). We need to take care of the base deletions manually here. + await this.processDelegateRelationDelete(kysely, modelDef, where, limit); + query = query.modifyEnd(this.makeContextComment({ model, operation: 'delete' })); + const result = await query.executeTakeFirstOrThrow(); + return { count: Number(result.numDeletedRows) }; + } - if (returnData) { - const result = await query.execute(); - return result as Result; - } else { - const result = (await query.executeTakeFirstOrThrow()) as DeleteResult; - return { - count: Number(result.numDeletedRows), - } as Result; + private async processDelegateRelationDelete( + kysely: ToKysely, + modelDef: ModelDef, + where: any, + limit: number | undefined, + ) { + for (const fieldDef of Object.values(modelDef.fields)) { + if (fieldDef.relation && fieldDef.relation.opposite) { + const oppositeModelDef = this.requireModel(fieldDef.type); + const oppositeRelation = this.requireField(fieldDef.type, fieldDef.relation.opposite); + if (oppositeModelDef.baseModel && oppositeRelation.relation?.onDelete === 'Cascade') { + if (limit !== undefined) { + throw new QueryError('Deleting with a limit is not supported for polymorphic models'); + } + // the deletion will propagate upward to the base model chain + await this.delete( + kysely, + fieldDef.type as GetModels, + { + [fieldDef.relation.opposite]: where, + }, + undefined, + ); + } + } } } + private async processBaseModelDelete( + kysely: ToKysely, + model: string, + where: any, + limit: number | undefined, + filterModel: GetModels, + ) { + return this.delete(kysely, model as GetModels, where, limit, filterModel); + } + protected makeIdSelect(model: string) { const modelDef = this.requireModel(model); return modelDef.idFields.reduce((acc, f) => { @@ -2154,9 +2193,7 @@ export abstract class BaseOperationHandler { } else { // otherwise, create a new transaction and execute the callback let txBuilder = this.kysely.transaction(); - if (isolationLevel) { - txBuilder = txBuilder.setIsolationLevel(isolationLevel); - } + txBuilder = txBuilder.setIsolationLevel(isolationLevel ?? 'repeatable read'); return txBuilder.execute(callback); } } diff --git a/packages/runtime/src/client/crud/operations/delete.ts b/packages/runtime/src/client/crud/operations/delete.ts index e20c48be..a33c2179 100644 --- a/packages/runtime/src/client/crud/operations/delete.ts +++ b/packages/runtime/src/client/crud/operations/delete.ts @@ -27,15 +27,22 @@ export class DeleteOperationHandler extends BaseOperat if (!existing) { throw new NotFoundError(this.model); } - const result = await this.delete(this.kysely, this.model, args.where, undefined, false); - if (result.count === 0) { - throw new NotFoundError(this.model); - } + + // TODO: avoid using transaction for simple delete + await this.safeTransaction(async (tx) => { + const result = await this.delete(tx, this.model, args.where, undefined); + if (result.count === 0) { + throw new NotFoundError(this.model); + } + }); + return existing; } async runDeleteMany(args: DeleteManyArgs> | undefined) { - const result = await this.delete(this.kysely, this.model, args?.where, args?.limit, false); - return result; + return await this.safeTransaction(async (tx) => { + const result = await this.delete(tx, this.model, args?.where, args?.limit); + return result; + }); } } diff --git a/packages/runtime/test/client-api/delegate.test.ts b/packages/runtime/test/client-api/delegate.test.ts index 6c8ece12..791cbf45 100644 --- a/packages/runtime/test/client-api/delegate.test.ts +++ b/packages/runtime/test/client-api/delegate.test.ts @@ -18,7 +18,7 @@ model User { model Comment { id Int @id @default(autoincrement()) content String - asset Asset? @relation(fields: [assetId], references: [id]) + asset Asset? @relation(fields: [assetId], references: [id], onDelete: Cascade) assetId Int? } @@ -27,7 +27,7 @@ model Asset { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt viewCount Int @default(0) - owner User? @relation(fields: [ownerId], references: [id]) + owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade) ownerId Int? comments Comment[] assetType String @@ -45,13 +45,13 @@ model Video extends Asset { model RatedVideo extends Video { rating Int - user User? @relation(name: 'direct', fields: [userId], references: [id]) + user User? @relation(name: 'direct', fields: [userId], references: [id], onDelete: Cascade) userId Int? } model Image extends Asset { format String - gallery Gallery? @relation(fields: [galleryId], references: [id]) + gallery Gallery? @relation(fields: [galleryId], references: [id], onDelete: Cascade) galleryId Int? } @@ -214,6 +214,32 @@ model Gallery { ]), ); }); + + it('ensures create is atomic', async () => { + // create with a relation that fails + await expect( + client.ratedVideo.create({ + data: { + duration: 100, + url: 'abc', + rating: 5, + }, + }), + ).toResolveTruthy(); + await expect( + client.ratedVideo.create({ + data: { + duration: 200, + url: 'abc', + rating: 3, + }, + }), + ).rejects.toThrow('constraint'); + + await expect(client.ratedVideo.findMany()).toResolveWithLength(1); + await expect(client.video.findMany()).toResolveWithLength(1); + await expect(client.asset.findMany()).toResolveWithLength(1); + }); }); it('works with find', async () => { @@ -805,6 +831,15 @@ model Gallery { }), ]), ); + + // updateMany with limit unsupported + await expect( + client.ratedVideo.updateMany({ + where: { duration: { gt: 200 } }, + data: { viewCount: 200, duration: 300 }, + limit: 1, + }), + ).rejects.toThrow('Updating with a limit is not supported for polymorphic models'); }); it('works with updateManyAndReturn', async () => { @@ -900,5 +935,140 @@ model Gallery { }); }); }); + + describe('Delegate delete tests', () => { + it('works with delete', async () => { + // delete from sub model + await client.ratedVideo.create({ + data: { + id: 1, + duration: 100, + url: 'abc', + rating: 5, + }, + }); + await expect( + client.ratedVideo.delete({ + where: { url: 'abc' }, + }), + ).resolves.toMatchObject({ + id: 1, + duration: 100, + url: 'abc', + rating: 5, + }); + await expect(client.ratedVideo.findMany()).toResolveWithLength(0); + await expect(client.video.findMany()).toResolveWithLength(0); + await expect(client.asset.findMany()).toResolveWithLength(0); + + // delete from base model + await client.ratedVideo.create({ + data: { + id: 1, + duration: 100, + url: 'abc', + rating: 5, + }, + }); + await expect( + client.asset.delete({ + where: { id: 1 }, + }), + ).resolves.toMatchObject({ + id: 1, + duration: 100, + url: 'abc', + rating: 5, + }); + await expect(client.ratedVideo.findMany()).toResolveWithLength(0); + await expect(client.video.findMany()).toResolveWithLength(0); + await expect(client.asset.findMany()).toResolveWithLength(0); + + // nested delete + await client.user.create({ + data: { + id: 1, + email: 'abc', + }, + }); + await client.ratedVideo.create({ + data: { + id: 1, + duration: 100, + url: 'abc', + rating: 5, + owner: { connect: { id: 1 } }, + }, + }); + await expect( + client.user.update({ + where: { id: 1 }, + data: { + assets: { + delete: { id: 1 }, + }, + }, + include: { assets: true }, + }), + ).resolves.toMatchObject({ assets: [] }); + await expect(client.ratedVideo.findMany()).toResolveWithLength(0); + await expect(client.video.findMany()).toResolveWithLength(0); + await expect(client.asset.findMany()).toResolveWithLength(0); + + // delete user should cascade to ratedVideo and in turn delete its bases + await client.ratedVideo.create({ + data: { + id: 1, + duration: 100, + url: 'abc', + rating: 5, + user: { connect: { id: 1 } }, + }, + }); + await expect( + client.user.delete({ + where: { id: 1 }, + }), + ).toResolveTruthy(); + await expect(client.ratedVideo.findMany()).toResolveWithLength(0); + await expect(client.video.findMany()).toResolveWithLength(0); + await expect(client.asset.findMany()).toResolveWithLength(0); + }); + + it('works with deleteMany', async () => { + await client.ratedVideo.createMany({ + data: [ + { + id: 1, + viewCount: 1, + duration: 100, + url: 'abc', + rating: 5, + }, + { + id: 2, + viewCount: 2, + duration: 200, + url: 'def', + rating: 4, + }, + ], + }); + + await expect( + client.video.deleteMany({ + where: { duration: { gt: 150 }, viewCount: 1 }, + }), + ).resolves.toMatchObject({ count: 0 }); + await expect( + client.video.deleteMany({ + where: { duration: { gt: 150 }, viewCount: 2 }, + }), + ).resolves.toMatchObject({ count: 1 }); + await expect(client.ratedVideo.findMany()).toResolveWithLength(1); + await expect(client.video.findMany()).toResolveWithLength(1); + await expect(client.asset.findMany()).toResolveWithLength(1); + }); + }); }, ); From 36e1b779bd7b44912f6d833b0c0d7960d7e56339 Mon Sep 17 00:00:00 2001 From: Yiming Cao Date: Mon, 28 Jul 2025 16:31:17 +0800 Subject: [PATCH 15/19] feat: count and aggregate for delegate models (#115) * feat: count and aggregate for delegate models * fixes --- .../runtime/src/client/crud/dialects/base.ts | 13 +- .../src/client/crud/operations/aggregate.ts | 30 +++- .../src/client/crud/operations/count.ts | 25 +++- .../src/client/executor/name-mapper.ts | 2 +- .../runtime/test/client-api/delegate.test.ts | 141 ++++++++++++++++++ 5 files changed, 195 insertions(+), 16 deletions(-) diff --git a/packages/runtime/src/client/crud/dialects/base.ts b/packages/runtime/src/client/crud/dialects/base.ts index c253b905..a8e5ee39 100644 --- a/packages/runtime/src/client/crud/dialects/base.ts +++ b/packages/runtime/src/client/crud/dialects/base.ts @@ -818,9 +818,13 @@ export abstract class BaseCrudDialect { return result; } - buildSelectField(query: SelectQueryBuilder, model: string, modelAlias: string, field: string) { + buildSelectField( + query: SelectQueryBuilder, + model: string, + modelAlias: string, + field: string, + ): SelectQueryBuilder { const fieldDef = requireField(this.schema, model, field); - if (fieldDef.computed) { // TODO: computed field from delegate base? return query.select((eb) => buildFieldRef(this.schema, model, field, this.options, eb).as(field)); @@ -828,10 +832,7 @@ export abstract class BaseCrudDialect { // regular field return query.select(sql.ref(`${modelAlias}.${field}`).as(field)); } else { - // field from delegate base, build a join - let result = query; - result = this.buildSelectField(result, fieldDef.originModel, fieldDef.originModel, field); - return result; + return this.buildSelectField(query, fieldDef.originModel, fieldDef.originModel, field); } } diff --git a/packages/runtime/src/client/crud/operations/aggregate.ts b/packages/runtime/src/client/crud/operations/aggregate.ts index 2bcd2014..5d309dda 100644 --- a/packages/runtime/src/client/crud/operations/aggregate.ts +++ b/packages/runtime/src/client/crud/operations/aggregate.ts @@ -1,3 +1,4 @@ +import type { ExpressionBuilder } from 'kysely'; import { sql } from 'kysely'; import { match } from 'ts-pattern'; import type { SchemaDef } from '../../../schema'; @@ -15,12 +16,33 @@ export class AggregateOperationHandler extends BaseOpe let query = this.kysely.selectFrom((eb) => { // nested query for filtering and pagination - // where - let subQuery = eb - .selectFrom(this.model) - .selectAll(this.model as any) // TODO: check typing + // table and where + let subQuery = this.dialect + .buildSelectModel(eb as ExpressionBuilder, this.model) .where((eb1) => this.dialect.buildFilter(eb1, this.model, this.model, parsedArgs?.where)); + // select fields: collect fields from aggregation body + const selectedFields: string[] = []; + for (const [key, value] of Object.entries(parsedArgs)) { + if (key.startsWith('_') && value && typeof value === 'object') { + // select fields + Object.entries(value) + .filter(([field]) => field !== '_all') + .filter(([, val]) => val === true) + .forEach(([field]) => { + if (!selectedFields.includes(field)) selectedFields.push(field); + }); + } + } + if (selectedFields.length > 0) { + for (const field of selectedFields) { + subQuery = this.dialect.buildSelectField(subQuery, this.model, this.model, field); + } + } else { + // if no field is explicitly selected, just do a `select 1` so `_count` works + subQuery = subQuery.select(() => eb.lit(1).as('_all')); + } + // skip & take const skip = parsedArgs?.skip; let take = parsedArgs?.take; diff --git a/packages/runtime/src/client/crud/operations/count.ts b/packages/runtime/src/client/crud/operations/count.ts index e44a5897..fc22c2ec 100644 --- a/packages/runtime/src/client/crud/operations/count.ts +++ b/packages/runtime/src/client/crud/operations/count.ts @@ -1,3 +1,4 @@ +import type { ExpressionBuilder } from 'kysely'; import { sql } from 'kysely'; import type { SchemaDef } from '../../../schema'; import { BaseOperationHandler } from './base'; @@ -9,15 +10,29 @@ export class CountOperationHandler extends BaseOperati // parse args const parsedArgs = this.inputValidator.validateCountArgs(this.model, normalizedArgs); + const subQueryName = '$sub'; let query = this.kysely.selectFrom((eb) => { // nested query for filtering and pagination - let subQuery = eb - .selectFrom(this.model) - .selectAll() + + let subQuery = this.dialect + .buildSelectModel(eb as ExpressionBuilder, this.model) .where((eb1) => this.dialect.buildFilter(eb1, this.model, this.model, parsedArgs?.where)); + + if (parsedArgs?.select && typeof parsedArgs.select === 'object') { + // select fields + for (const [key, value] of Object.entries(parsedArgs.select)) { + if (key !== '_all' && value === true) { + subQuery = this.dialect.buildSelectField(subQuery, this.model, this.model, key); + } + } + } else { + // no field selection, just build a `select 1` + subQuery = subQuery.select(() => eb.lit(1).as('_all')); + } + subQuery = this.dialect.buildSkipTake(subQuery, parsedArgs?.skip, parsedArgs?.take); - return subQuery.as('$sub'); + return subQuery.as(subQueryName); }); if (parsedArgs?.select && typeof parsedArgs.select === 'object') { @@ -26,7 +41,7 @@ export class CountOperationHandler extends BaseOperati Object.keys(parsedArgs.select!).map((key) => key === '_all' ? eb.cast(eb.fn.countAll(), 'integer').as('_all') - : eb.cast(eb.fn.count(sql.ref(`$sub.${key}`)), 'integer').as(key), + : eb.cast(eb.fn.count(sql.ref(`${subQueryName}.${key}`)), 'integer').as(key), ), ); diff --git a/packages/runtime/src/client/executor/name-mapper.ts b/packages/runtime/src/client/executor/name-mapper.ts index d5a893d5..814c1ba7 100644 --- a/packages/runtime/src/client/executor/name-mapper.ts +++ b/packages/runtime/src/client/executor/name-mapper.ts @@ -263,7 +263,7 @@ export class QueryNameMapper extends OperationNodeTransformer { model = model ?? this.currentModel; const modelDef = requireModel(this.schema, model!); const scalarFields = Object.entries(modelDef.fields) - .filter(([, fieldDef]) => !fieldDef.relation && !fieldDef.computed) + .filter(([, fieldDef]) => !fieldDef.relation && !fieldDef.computed && !fieldDef.originModel) .map(([fieldName]) => fieldName); return scalarFields; } diff --git a/packages/runtime/test/client-api/delegate.test.ts b/packages/runtime/test/client-api/delegate.test.ts index 791cbf45..51749af2 100644 --- a/packages/runtime/test/client-api/delegate.test.ts +++ b/packages/runtime/test/client-api/delegate.test.ts @@ -1070,5 +1070,146 @@ model Gallery { await expect(client.asset.findMany()).toResolveWithLength(1); }); }); + + describe('Delegate aggregation tests', () => { + beforeEach(async () => { + const u = await client.user.create({ + data: { + id: 1, + email: 'u1@example.com', + }, + }); + await client.ratedVideo.create({ + data: { + id: 1, + viewCount: 0, + duration: 100, + url: 'v1', + rating: 5, + owner: { connect: { id: u.id } }, + user: { connect: { id: u.id } }, + comments: { create: [{ content: 'c1' }, { content: 'c2' }] }, + }, + }); + await client.ratedVideo.create({ + data: { + id: 2, + viewCount: 2, + duration: 200, + url: 'v2', + rating: 3, + }, + }); + }); + + it('works with count', async () => { + await expect( + client.ratedVideo.count({ + where: { rating: 5 }, + }), + ).resolves.toEqual(1); + await expect( + client.ratedVideo.count({ + where: { duration: 100 }, + }), + ).resolves.toEqual(1); + await expect( + client.ratedVideo.count({ + where: { viewCount: 2 }, + }), + ).resolves.toEqual(1); + + await expect( + client.video.count({ + where: { duration: 100 }, + }), + ).resolves.toEqual(1); + await expect( + client.asset.count({ + where: { viewCount: { gt: 0 } }, + }), + ).resolves.toEqual(1); + + // field selection + await expect( + client.ratedVideo.count({ + select: { _all: true, viewCount: true, url: true, rating: true }, + }), + ).resolves.toMatchObject({ + _all: 2, + viewCount: 2, + url: 2, + rating: 2, + }); + await expect( + client.video.count({ + select: { _all: true, viewCount: true, url: true }, + }), + ).resolves.toMatchObject({ + _all: 2, + viewCount: 2, + url: 2, + }); + await expect( + client.asset.count({ + select: { _all: true, viewCount: true }, + }), + ).resolves.toMatchObject({ + _all: 2, + viewCount: 2, + }); + }); + + it('works with aggregate', async () => { + await expect( + client.ratedVideo.aggregate({ + where: { viewCount: { gte: 0 }, duration: { gt: 0 }, rating: { gt: 0 } }, + _avg: { viewCount: true, duration: true, rating: true }, + _count: true, + }), + ).resolves.toMatchObject({ + _avg: { + viewCount: 1, + duration: 150, + rating: 4, + }, + _count: 2, + }); + await expect( + client.video.aggregate({ + where: { viewCount: { gte: 0 }, duration: { gt: 0 } }, + _avg: { viewCount: true, duration: true }, + _count: true, + }), + ).resolves.toMatchObject({ + _avg: { + viewCount: 1, + duration: 150, + }, + _count: 2, + }); + await expect( + client.asset.aggregate({ + where: { viewCount: { gte: 0 } }, + _avg: { viewCount: true }, + _count: true, + }), + ).resolves.toMatchObject({ + _avg: { + viewCount: 1, + }, + _count: 2, + }); + + // just count + await expect( + client.ratedVideo.aggregate({ + _count: true, + }), + ).resolves.toMatchObject({ + _count: 2, + }); + }); + }); }, ); From 3f1af0683cd6a636ba48b9873f52414373e8a697 Mon Sep 17 00:00:00 2001 From: Yiming Cao Date: Mon, 28 Jul 2025 18:19:34 +0800 Subject: [PATCH 16/19] chore: reorg test schemas (#116) --- packages/runtime/package.json | 4 +- .../runtime/test/client-api/aggregate.test.ts | 2 +- .../runtime/test/client-api/client-specs.ts | 2 +- .../runtime/test/client-api/count.test.ts | 2 +- .../client-api/create-many-and-return.test.ts | 2 +- .../test/client-api/create-many.test.ts | 2 +- .../runtime/test/client-api/create.test.ts | 2 +- .../test/client-api/delete-many.test.ts | 2 +- .../runtime/test/client-api/delete.test.ts | 2 +- .../runtime/test/client-api/filter.test.ts | 2 +- packages/runtime/test/client-api/find.test.ts | 2 +- .../runtime/test/client-api/group-by.test.ts | 2 +- .../runtime/test/client-api/raw-query.test.ts | 2 +- .../test/client-api/transaction.test.ts | 2 +- .../test/client-api/undefined-values.test.ts | 2 +- .../test/client-api/update-many.test.ts | 2 +- .../runtime/test/client-api/update.test.ts | 2 +- .../runtime/test/client-api/upsert.test.ts | 2 +- packages/runtime/test/client-api/utils.ts | 2 +- .../test/plugin/kysely-on-query.test.ts | 2 +- .../test/plugin/mutation-hooks.test.ts | 2 +- .../test/plugin/query-lifecycle.test.ts | 2 +- packages/runtime/test/policy/read.test.ts | 2 +- .../runtime/test/policy/todo-sample.test.ts | 17 +- packages/runtime/test/policy/utils.ts | 14 +- .../test/query-builder/query-builder.test.ts | 2 +- .../{test-schema => schemas/basic}/helper.ts | 0 .../{test-schema => schemas/basic}/index.ts | 0 .../{test-schema => schemas/basic}/input.ts | 0 .../{test-schema => schemas/basic}/models.ts | 0 .../{test-schema => schemas/basic}/schema.ts | 2 +- .../basic}/schema.zmodel | 0 packages/runtime/test/schemas/todo/input.ts | 110 +++++ packages/runtime/test/schemas/todo/models.ts | 14 + packages/runtime/test/schemas/todo/schema.ts | 395 ++++++++++++++++++ .../test/schemas/{ => todo}/todo.zmodel | 0 .../test/{ => schemas}/typing/input.ts | 0 .../test/{ => schemas}/typing/models.ts | 0 .../test/{ => schemas}/typing/schema.ts | 2 +- .../typing/schema.zmodel} | 0 .../{ => schemas}/typing/verify-typing.ts | 2 +- packages/runtime/test/scripts/generate.ts | 14 +- packages/runtime/test/utils.ts | 4 +- packages/sdk/src/ts-schema-generator.ts | 23 +- 44 files changed, 588 insertions(+), 59 deletions(-) rename packages/runtime/test/{test-schema => schemas/basic}/helper.ts (100%) rename packages/runtime/test/{test-schema => schemas/basic}/index.ts (100%) rename packages/runtime/test/{test-schema => schemas/basic}/input.ts (100%) rename packages/runtime/test/{test-schema => schemas/basic}/models.ts (100%) rename packages/runtime/test/{test-schema => schemas/basic}/schema.ts (99%) rename packages/runtime/test/{test-schema => schemas/basic}/schema.zmodel (100%) create mode 100644 packages/runtime/test/schemas/todo/input.ts create mode 100644 packages/runtime/test/schemas/todo/models.ts create mode 100644 packages/runtime/test/schemas/todo/schema.ts rename packages/runtime/test/schemas/{ => todo}/todo.zmodel (100%) rename packages/runtime/test/{ => schemas}/typing/input.ts (100%) rename packages/runtime/test/{ => schemas}/typing/models.ts (100%) rename packages/runtime/test/{ => schemas}/typing/schema.ts (99%) rename packages/runtime/test/{typing/typing-test.zmodel => schemas/typing/schema.zmodel} (100%) rename packages/runtime/test/{ => schemas}/typing/verify-typing.ts (99%) diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 1b23dc63..eaaafa35 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -4,10 +4,10 @@ "description": "ZenStack Runtime", "type": "module", "scripts": { - "build": "tsup-node", + "build": "tsup-node && pnpm test:generate", "watch": "tsup-node --watch", "lint": "eslint src --ext ts", - "test": "vitest run && pnpm test:generate && pnpm test:typecheck", + "test": "vitest run && pnpm test:typecheck", "test:generate": "tsx test/scripts/generate.ts", "test:typecheck": "tsc --project tsconfig.test.json", "pack": "pnpm pack" diff --git a/packages/runtime/test/client-api/aggregate.test.ts b/packages/runtime/test/client-api/aggregate.test.ts index baf5b8e7..0c8ffd27 100644 --- a/packages/runtime/test/client-api/aggregate.test.ts +++ b/packages/runtime/test/client-api/aggregate.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '../../src/client'; -import { schema } from '../test-schema'; +import { schema } from '../schemas/basic'; import { createClientSpecs } from './client-specs'; import { createUser } from './utils'; diff --git a/packages/runtime/test/client-api/client-specs.ts b/packages/runtime/test/client-api/client-specs.ts index 6a14ab43..fef380b9 100644 --- a/packages/runtime/test/client-api/client-specs.ts +++ b/packages/runtime/test/client-api/client-specs.ts @@ -1,5 +1,5 @@ import type { LogEvent } from 'kysely'; -import { getSchema, schema } from '../test-schema'; +import { getSchema, schema } from '../schemas/basic'; import { makePostgresClient, makeSqliteClient } from '../utils'; import type { ClientContract } from '../../src'; diff --git a/packages/runtime/test/client-api/count.test.ts b/packages/runtime/test/client-api/count.test.ts index 86e22264..743b4169 100644 --- a/packages/runtime/test/client-api/count.test.ts +++ b/packages/runtime/test/client-api/count.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '../../src/client'; -import { schema } from '../test-schema'; +import { schema } from '../schemas/basic'; import { createClientSpecs } from './client-specs'; const PG_DB_NAME = 'client-api-count-tests'; diff --git a/packages/runtime/test/client-api/create-many-and-return.test.ts b/packages/runtime/test/client-api/create-many-and-return.test.ts index 258194b2..29d5887e 100644 --- a/packages/runtime/test/client-api/create-many-and-return.test.ts +++ b/packages/runtime/test/client-api/create-many-and-return.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '../../src/client'; -import { schema } from '../test-schema'; +import { schema } from '../schemas/basic'; import { createClientSpecs } from './client-specs'; const PG_DB_NAME = 'client-api-create-many-and-return-tests'; diff --git a/packages/runtime/test/client-api/create-many.test.ts b/packages/runtime/test/client-api/create-many.test.ts index 1c2e119e..d25d8587 100644 --- a/packages/runtime/test/client-api/create-many.test.ts +++ b/packages/runtime/test/client-api/create-many.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '../../src/client'; -import { schema } from '../test-schema'; +import { schema } from '../schemas/basic'; import { createClientSpecs } from './client-specs'; const PG_DB_NAME = 'client-api-create-many-tests'; diff --git a/packages/runtime/test/client-api/create.test.ts b/packages/runtime/test/client-api/create.test.ts index 6ac87637..8cd692fd 100644 --- a/packages/runtime/test/client-api/create.test.ts +++ b/packages/runtime/test/client-api/create.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '../../src/client'; -import { schema } from '../test-schema'; +import { schema } from '../schemas/basic'; import { createClientSpecs } from './client-specs'; const PG_DB_NAME = 'client-api-create-tests'; diff --git a/packages/runtime/test/client-api/delete-many.test.ts b/packages/runtime/test/client-api/delete-many.test.ts index 86bc8a59..df8f3ac4 100644 --- a/packages/runtime/test/client-api/delete-many.test.ts +++ b/packages/runtime/test/client-api/delete-many.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '../../src/client'; -import { schema } from '../test-schema'; +import { schema } from '../schemas/basic'; import { createClientSpecs } from './client-specs'; const PG_DB_NAME = 'client-api-delete-many-tests'; diff --git a/packages/runtime/test/client-api/delete.test.ts b/packages/runtime/test/client-api/delete.test.ts index 57d70c60..b67216ae 100644 --- a/packages/runtime/test/client-api/delete.test.ts +++ b/packages/runtime/test/client-api/delete.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '../../src/client'; -import { schema } from '../test-schema'; +import { schema } from '../schemas/basic'; import { createClientSpecs } from './client-specs'; const PG_DB_NAME = 'client-api-delete-tests'; diff --git a/packages/runtime/test/client-api/filter.test.ts b/packages/runtime/test/client-api/filter.test.ts index cda36583..e9f49ce1 100644 --- a/packages/runtime/test/client-api/filter.test.ts +++ b/packages/runtime/test/client-api/filter.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '../../src/client'; -import { schema } from '../test-schema'; +import { schema } from '../schemas/basic'; import { createClientSpecs } from './client-specs'; const PG_DB_NAME = 'client-api-filter-tests'; diff --git a/packages/runtime/test/client-api/find.test.ts b/packages/runtime/test/client-api/find.test.ts index d239c76d..e1a05be1 100644 --- a/packages/runtime/test/client-api/find.test.ts +++ b/packages/runtime/test/client-api/find.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '../../src/client'; import { NotFoundError } from '../../src/client/errors'; -import { schema } from '../test-schema'; +import { schema } from '../schemas/basic'; import { createClientSpecs } from './client-specs'; import { createPosts, createUser } from './utils'; diff --git a/packages/runtime/test/client-api/group-by.test.ts b/packages/runtime/test/client-api/group-by.test.ts index 0e0256e2..98c155fc 100644 --- a/packages/runtime/test/client-api/group-by.test.ts +++ b/packages/runtime/test/client-api/group-by.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '../../src/client'; -import { schema } from '../test-schema'; +import { schema } from '../schemas/basic'; import { createClientSpecs } from './client-specs'; import { createUser } from './utils'; diff --git a/packages/runtime/test/client-api/raw-query.test.ts b/packages/runtime/test/client-api/raw-query.test.ts index 05049ba7..b1754b67 100644 --- a/packages/runtime/test/client-api/raw-query.test.ts +++ b/packages/runtime/test/client-api/raw-query.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '../../src/client'; -import { schema } from '../test-schema'; +import { schema } from '../schemas/basic'; import { createClientSpecs } from './client-specs'; const PG_DB_NAME = 'client-api-raw-query-tests'; diff --git a/packages/runtime/test/client-api/transaction.test.ts b/packages/runtime/test/client-api/transaction.test.ts index 60d58178..1daac7c5 100644 --- a/packages/runtime/test/client-api/transaction.test.ts +++ b/packages/runtime/test/client-api/transaction.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '../../src/client'; -import { schema } from '../test-schema'; +import { schema } from '../schemas/basic'; import { createClientSpecs } from './client-specs'; const PG_DB_NAME = 'client-api-transaction-tests'; diff --git a/packages/runtime/test/client-api/undefined-values.test.ts b/packages/runtime/test/client-api/undefined-values.test.ts index e9657b9e..07037be7 100644 --- a/packages/runtime/test/client-api/undefined-values.test.ts +++ b/packages/runtime/test/client-api/undefined-values.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '../../src/client'; -import { schema } from '../test-schema'; +import { schema } from '../schemas/basic'; import { createClientSpecs } from './client-specs'; import { createUser } from './utils'; diff --git a/packages/runtime/test/client-api/update-many.test.ts b/packages/runtime/test/client-api/update-many.test.ts index e8d08cd9..eaef7e63 100644 --- a/packages/runtime/test/client-api/update-many.test.ts +++ b/packages/runtime/test/client-api/update-many.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '../../src/client'; -import { schema } from '../test-schema'; +import { schema } from '../schemas/basic'; import { createClientSpecs } from './client-specs'; const PG_DB_NAME = 'client-api-update-many-tests'; diff --git a/packages/runtime/test/client-api/update.test.ts b/packages/runtime/test/client-api/update.test.ts index 8c6ec359..2fc75fb8 100644 --- a/packages/runtime/test/client-api/update.test.ts +++ b/packages/runtime/test/client-api/update.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '../../src/client'; -import { schema } from '../test-schema'; +import { schema } from '../schemas/basic'; import { createClientSpecs } from './client-specs'; import { createUser } from './utils'; diff --git a/packages/runtime/test/client-api/upsert.test.ts b/packages/runtime/test/client-api/upsert.test.ts index 34e402f9..02ede41c 100644 --- a/packages/runtime/test/client-api/upsert.test.ts +++ b/packages/runtime/test/client-api/upsert.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '../../src/client'; -import { schema } from '../test-schema'; +import { schema } from '../schemas/basic'; import { createClientSpecs } from './client-specs'; const PG_DB_NAME = 'client-api-upsert-tests'; diff --git a/packages/runtime/test/client-api/utils.ts b/packages/runtime/test/client-api/utils.ts index 78646e36..2f1b85d0 100644 --- a/packages/runtime/test/client-api/utils.ts +++ b/packages/runtime/test/client-api/utils.ts @@ -1,5 +1,5 @@ import type { ClientContract } from '../../src/client'; -import type { schema } from '../test-schema'; +import type { schema } from '../schemas/basic'; type ClientType = ClientContract; diff --git a/packages/runtime/test/plugin/kysely-on-query.test.ts b/packages/runtime/test/plugin/kysely-on-query.test.ts index b098c8a4..90c47bb8 100644 --- a/packages/runtime/test/plugin/kysely-on-query.test.ts +++ b/packages/runtime/test/plugin/kysely-on-query.test.ts @@ -2,7 +2,7 @@ import SQLite from 'better-sqlite3'; import { InsertQueryNode, Kysely, PrimitiveValueListNode, ValuesNode, type QueryResult } from 'kysely'; import { beforeEach, describe, expect, it } from 'vitest'; import { ZenStackClient, type ClientContract } from '../../src/client'; -import { schema } from '../test-schema'; +import { schema } from '../schemas/basic'; describe('Kysely onQuery tests', () => { let _client: ClientContract; diff --git a/packages/runtime/test/plugin/mutation-hooks.test.ts b/packages/runtime/test/plugin/mutation-hooks.test.ts index c6bd08c7..08023e28 100644 --- a/packages/runtime/test/plugin/mutation-hooks.test.ts +++ b/packages/runtime/test/plugin/mutation-hooks.test.ts @@ -2,7 +2,7 @@ import SQLite from 'better-sqlite3'; import { DeleteQueryNode, InsertQueryNode, UpdateQueryNode } from 'kysely'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { ZenStackClient, type ClientContract } from '../../src'; -import { schema } from '../test-schema'; +import { schema } from '../schemas/basic'; describe('Entity lifecycle tests', () => { let _client: ClientContract; diff --git a/packages/runtime/test/plugin/query-lifecycle.test.ts b/packages/runtime/test/plugin/query-lifecycle.test.ts index 15bc85a7..70658c55 100644 --- a/packages/runtime/test/plugin/query-lifecycle.test.ts +++ b/packages/runtime/test/plugin/query-lifecycle.test.ts @@ -1,7 +1,7 @@ import SQLite from 'better-sqlite3'; import { beforeEach, describe, expect, it } from 'vitest'; import { definePlugin, ZenStackClient, type ClientContract } from '../../src/client'; -import { schema } from '../test-schema'; +import { schema } from '../schemas/basic'; describe('Query interception tests', () => { let _client: ClientContract; diff --git a/packages/runtime/test/policy/read.test.ts b/packages/runtime/test/policy/read.test.ts index b6cad7f4..eb1ccb41 100644 --- a/packages/runtime/test/policy/read.test.ts +++ b/packages/runtime/test/policy/read.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { type ClientContract } from '../../src/client'; import { PolicyPlugin } from '../../src/plugins/policy/plugin'; import { createClientSpecs } from '../client-api/client-specs'; -import { schema } from '../test-schema'; +import { schema } from '../schemas/basic'; const PG_DB_NAME = 'policy-read-tests'; diff --git a/packages/runtime/test/policy/todo-sample.test.ts b/packages/runtime/test/policy/todo-sample.test.ts index 4246969f..83c812b5 100644 --- a/packages/runtime/test/policy/todo-sample.test.ts +++ b/packages/runtime/test/policy/todo-sample.test.ts @@ -1,17 +1,8 @@ -import { generateTsSchemaFromFile } from '@zenstackhq/testtools'; -import path from 'node:path'; -import { beforeAll, describe, expect, it } from 'vitest'; -import type { SchemaDef } from '../../src/schema'; +import { describe, expect, it } from 'vitest'; +import { schema } from '../schemas/todo/schema'; import { createPolicyTestClient } from './utils'; describe('todo sample tests', () => { - let schema: SchemaDef; - - beforeAll(async () => { - const r = await generateTsSchemaFromFile(path.join(__dirname, '../schemas/todo.zmodel')); - schema = r.schema; - }); - it('works with user CRUD', async () => { const user1 = { id: 'user1', @@ -383,13 +374,13 @@ describe('todo sample tests', () => { where: { id: 'space1' }, include: { lists: true }, }); - expect(r.lists).toHaveLength(2); + expect(r?.lists).toHaveLength(2); const r1 = await user2Db.space.findFirst({ where: { id: 'space1' }, include: { lists: true }, }); - expect(r1.lists).toHaveLength(1); + expect(r1?.lists).toHaveLength(1); }); // TODO: `future()` support diff --git a/packages/runtime/test/policy/utils.ts b/packages/runtime/test/policy/utils.ts index dc599ce2..2fecc72b 100644 --- a/packages/runtime/test/policy/utils.ts +++ b/packages/runtime/test/policy/utils.ts @@ -1,8 +1,20 @@ +import type { ClientContract } from '../../src'; import { PolicyPlugin } from '../../src/plugins/policy'; import type { SchemaDef } from '../../src/schema'; import { createTestClient, type CreateTestClientOptions } from '../utils'; -export function createPolicyTestClient(schema: string | SchemaDef, options?: CreateTestClientOptions) { +export async function createPolicyTestClient( + schema: Schema, + options?: CreateTestClientOptions, +): Promise>; +export async function createPolicyTestClient( + schema: string, + options?: CreateTestClientOptions, +): Promise; +export async function createPolicyTestClient( + schema: Schema | string, + options?: CreateTestClientOptions, +): Promise { return createTestClient( schema as any, { diff --git a/packages/runtime/test/query-builder/query-builder.test.ts b/packages/runtime/test/query-builder/query-builder.test.ts index 5e7754e9..2eb3de5d 100644 --- a/packages/runtime/test/query-builder/query-builder.test.ts +++ b/packages/runtime/test/query-builder/query-builder.test.ts @@ -2,7 +2,7 @@ import { createId } from '@paralleldrive/cuid2'; import SQLite from 'better-sqlite3'; import { describe, expect, it } from 'vitest'; import { ZenStackClient } from '../../src'; -import { getSchema } from '../test-schema'; +import { getSchema } from '../schemas/basic'; describe('Client API tests', () => { const schema = getSchema('sqlite'); diff --git a/packages/runtime/test/test-schema/helper.ts b/packages/runtime/test/schemas/basic/helper.ts similarity index 100% rename from packages/runtime/test/test-schema/helper.ts rename to packages/runtime/test/schemas/basic/helper.ts diff --git a/packages/runtime/test/test-schema/index.ts b/packages/runtime/test/schemas/basic/index.ts similarity index 100% rename from packages/runtime/test/test-schema/index.ts rename to packages/runtime/test/schemas/basic/index.ts diff --git a/packages/runtime/test/test-schema/input.ts b/packages/runtime/test/schemas/basic/input.ts similarity index 100% rename from packages/runtime/test/test-schema/input.ts rename to packages/runtime/test/schemas/basic/input.ts diff --git a/packages/runtime/test/test-schema/models.ts b/packages/runtime/test/schemas/basic/models.ts similarity index 100% rename from packages/runtime/test/test-schema/models.ts rename to packages/runtime/test/schemas/basic/models.ts diff --git a/packages/runtime/test/test-schema/schema.ts b/packages/runtime/test/schemas/basic/schema.ts similarity index 99% rename from packages/runtime/test/test-schema/schema.ts rename to packages/runtime/test/schemas/basic/schema.ts index e2c8fb52..d594432e 100644 --- a/packages/runtime/test/test-schema/schema.ts +++ b/packages/runtime/test/schemas/basic/schema.ts @@ -5,7 +5,7 @@ /* eslint-disable */ -import { type SchemaDef, ExpressionUtils } from "../../dist/schema"; +import { type SchemaDef, ExpressionUtils } from "../../../dist/schema"; export const schema = { provider: { type: "sqlite" diff --git a/packages/runtime/test/test-schema/schema.zmodel b/packages/runtime/test/schemas/basic/schema.zmodel similarity index 100% rename from packages/runtime/test/test-schema/schema.zmodel rename to packages/runtime/test/schemas/basic/schema.zmodel diff --git a/packages/runtime/test/schemas/todo/input.ts b/packages/runtime/test/schemas/todo/input.ts new file mode 100644 index 00000000..27c268fc --- /dev/null +++ b/packages/runtime/test/schemas/todo/input.ts @@ -0,0 +1,110 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + +/* eslint-disable */ + +import { type SchemaType as $Schema } from "./schema"; +import type { FindManyArgs as $FindManyArgs, FindUniqueArgs as $FindUniqueArgs, FindFirstArgs as $FindFirstArgs, CreateArgs as $CreateArgs, CreateManyArgs as $CreateManyArgs, CreateManyAndReturnArgs as $CreateManyAndReturnArgs, UpdateArgs as $UpdateArgs, UpdateManyArgs as $UpdateManyArgs, UpdateManyAndReturnArgs as $UpdateManyAndReturnArgs, UpsertArgs as $UpsertArgs, DeleteArgs as $DeleteArgs, DeleteManyArgs as $DeleteManyArgs, CountArgs as $CountArgs, AggregateArgs as $AggregateArgs, GroupByArgs as $GroupByArgs, WhereInput as $WhereInput, SelectInput as $SelectInput, IncludeInput as $IncludeInput, OmitInput as $OmitInput } from "@zenstackhq/runtime"; +import type { SimplifiedModelResult as $SimplifiedModelResult, SelectIncludeOmit as $SelectIncludeOmit } from "@zenstackhq/runtime"; +export type SpaceFindManyArgs = $FindManyArgs<$Schema, "Space">; +export type SpaceFindUniqueArgs = $FindUniqueArgs<$Schema, "Space">; +export type SpaceFindFirstArgs = $FindFirstArgs<$Schema, "Space">; +export type SpaceCreateArgs = $CreateArgs<$Schema, "Space">; +export type SpaceCreateManyArgs = $CreateManyArgs<$Schema, "Space">; +export type SpaceCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "Space">; +export type SpaceUpdateArgs = $UpdateArgs<$Schema, "Space">; +export type SpaceUpdateManyArgs = $UpdateManyArgs<$Schema, "Space">; +export type SpaceUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs<$Schema, "Space">; +export type SpaceUpsertArgs = $UpsertArgs<$Schema, "Space">; +export type SpaceDeleteArgs = $DeleteArgs<$Schema, "Space">; +export type SpaceDeleteManyArgs = $DeleteManyArgs<$Schema, "Space">; +export type SpaceCountArgs = $CountArgs<$Schema, "Space">; +export type SpaceAggregateArgs = $AggregateArgs<$Schema, "Space">; +export type SpaceGroupByArgs = $GroupByArgs<$Schema, "Space">; +export type SpaceWhereInput = $WhereInput<$Schema, "Space">; +export type SpaceSelect = $SelectInput<$Schema, "Space">; +export type SpaceInclude = $IncludeInput<$Schema, "Space">; +export type SpaceOmit = $OmitInput<$Schema, "Space">; +export type SpaceGetPayload> = $SimplifiedModelResult<$Schema, "Space", Args>; +export type SpaceUserFindManyArgs = $FindManyArgs<$Schema, "SpaceUser">; +export type SpaceUserFindUniqueArgs = $FindUniqueArgs<$Schema, "SpaceUser">; +export type SpaceUserFindFirstArgs = $FindFirstArgs<$Schema, "SpaceUser">; +export type SpaceUserCreateArgs = $CreateArgs<$Schema, "SpaceUser">; +export type SpaceUserCreateManyArgs = $CreateManyArgs<$Schema, "SpaceUser">; +export type SpaceUserCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "SpaceUser">; +export type SpaceUserUpdateArgs = $UpdateArgs<$Schema, "SpaceUser">; +export type SpaceUserUpdateManyArgs = $UpdateManyArgs<$Schema, "SpaceUser">; +export type SpaceUserUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs<$Schema, "SpaceUser">; +export type SpaceUserUpsertArgs = $UpsertArgs<$Schema, "SpaceUser">; +export type SpaceUserDeleteArgs = $DeleteArgs<$Schema, "SpaceUser">; +export type SpaceUserDeleteManyArgs = $DeleteManyArgs<$Schema, "SpaceUser">; +export type SpaceUserCountArgs = $CountArgs<$Schema, "SpaceUser">; +export type SpaceUserAggregateArgs = $AggregateArgs<$Schema, "SpaceUser">; +export type SpaceUserGroupByArgs = $GroupByArgs<$Schema, "SpaceUser">; +export type SpaceUserWhereInput = $WhereInput<$Schema, "SpaceUser">; +export type SpaceUserSelect = $SelectInput<$Schema, "SpaceUser">; +export type SpaceUserInclude = $IncludeInput<$Schema, "SpaceUser">; +export type SpaceUserOmit = $OmitInput<$Schema, "SpaceUser">; +export type SpaceUserGetPayload> = $SimplifiedModelResult<$Schema, "SpaceUser", Args>; +export type UserFindManyArgs = $FindManyArgs<$Schema, "User">; +export type UserFindUniqueArgs = $FindUniqueArgs<$Schema, "User">; +export type UserFindFirstArgs = $FindFirstArgs<$Schema, "User">; +export type UserCreateArgs = $CreateArgs<$Schema, "User">; +export type UserCreateManyArgs = $CreateManyArgs<$Schema, "User">; +export type UserCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "User">; +export type UserUpdateArgs = $UpdateArgs<$Schema, "User">; +export type UserUpdateManyArgs = $UpdateManyArgs<$Schema, "User">; +export type UserUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs<$Schema, "User">; +export type UserUpsertArgs = $UpsertArgs<$Schema, "User">; +export type UserDeleteArgs = $DeleteArgs<$Schema, "User">; +export type UserDeleteManyArgs = $DeleteManyArgs<$Schema, "User">; +export type UserCountArgs = $CountArgs<$Schema, "User">; +export type UserAggregateArgs = $AggregateArgs<$Schema, "User">; +export type UserGroupByArgs = $GroupByArgs<$Schema, "User">; +export type UserWhereInput = $WhereInput<$Schema, "User">; +export type UserSelect = $SelectInput<$Schema, "User">; +export type UserInclude = $IncludeInput<$Schema, "User">; +export type UserOmit = $OmitInput<$Schema, "User">; +export type UserGetPayload> = $SimplifiedModelResult<$Schema, "User", Args>; +export type ListFindManyArgs = $FindManyArgs<$Schema, "List">; +export type ListFindUniqueArgs = $FindUniqueArgs<$Schema, "List">; +export type ListFindFirstArgs = $FindFirstArgs<$Schema, "List">; +export type ListCreateArgs = $CreateArgs<$Schema, "List">; +export type ListCreateManyArgs = $CreateManyArgs<$Schema, "List">; +export type ListCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "List">; +export type ListUpdateArgs = $UpdateArgs<$Schema, "List">; +export type ListUpdateManyArgs = $UpdateManyArgs<$Schema, "List">; +export type ListUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs<$Schema, "List">; +export type ListUpsertArgs = $UpsertArgs<$Schema, "List">; +export type ListDeleteArgs = $DeleteArgs<$Schema, "List">; +export type ListDeleteManyArgs = $DeleteManyArgs<$Schema, "List">; +export type ListCountArgs = $CountArgs<$Schema, "List">; +export type ListAggregateArgs = $AggregateArgs<$Schema, "List">; +export type ListGroupByArgs = $GroupByArgs<$Schema, "List">; +export type ListWhereInput = $WhereInput<$Schema, "List">; +export type ListSelect = $SelectInput<$Schema, "List">; +export type ListInclude = $IncludeInput<$Schema, "List">; +export type ListOmit = $OmitInput<$Schema, "List">; +export type ListGetPayload> = $SimplifiedModelResult<$Schema, "List", Args>; +export type TodoFindManyArgs = $FindManyArgs<$Schema, "Todo">; +export type TodoFindUniqueArgs = $FindUniqueArgs<$Schema, "Todo">; +export type TodoFindFirstArgs = $FindFirstArgs<$Schema, "Todo">; +export type TodoCreateArgs = $CreateArgs<$Schema, "Todo">; +export type TodoCreateManyArgs = $CreateManyArgs<$Schema, "Todo">; +export type TodoCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "Todo">; +export type TodoUpdateArgs = $UpdateArgs<$Schema, "Todo">; +export type TodoUpdateManyArgs = $UpdateManyArgs<$Schema, "Todo">; +export type TodoUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs<$Schema, "Todo">; +export type TodoUpsertArgs = $UpsertArgs<$Schema, "Todo">; +export type TodoDeleteArgs = $DeleteArgs<$Schema, "Todo">; +export type TodoDeleteManyArgs = $DeleteManyArgs<$Schema, "Todo">; +export type TodoCountArgs = $CountArgs<$Schema, "Todo">; +export type TodoAggregateArgs = $AggregateArgs<$Schema, "Todo">; +export type TodoGroupByArgs = $GroupByArgs<$Schema, "Todo">; +export type TodoWhereInput = $WhereInput<$Schema, "Todo">; +export type TodoSelect = $SelectInput<$Schema, "Todo">; +export type TodoInclude = $IncludeInput<$Schema, "Todo">; +export type TodoOmit = $OmitInput<$Schema, "Todo">; +export type TodoGetPayload> = $SimplifiedModelResult<$Schema, "Todo", Args>; diff --git a/packages/runtime/test/schemas/todo/models.ts b/packages/runtime/test/schemas/todo/models.ts new file mode 100644 index 00000000..131c09d2 --- /dev/null +++ b/packages/runtime/test/schemas/todo/models.ts @@ -0,0 +1,14 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + +/* eslint-disable */ + +import { type SchemaType as $Schema } from "./schema"; +import { type ModelResult as $ModelResult } from "@zenstackhq/runtime"; +export type Space = $ModelResult<$Schema, "Space">; +export type SpaceUser = $ModelResult<$Schema, "SpaceUser">; +export type User = $ModelResult<$Schema, "User">; +export type List = $ModelResult<$Schema, "List">; +export type Todo = $ModelResult<$Schema, "Todo">; diff --git a/packages/runtime/test/schemas/todo/schema.ts b/packages/runtime/test/schemas/todo/schema.ts new file mode 100644 index 00000000..25a8290c --- /dev/null +++ b/packages/runtime/test/schemas/todo/schema.ts @@ -0,0 +1,395 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + +/* eslint-disable */ + +import { type SchemaDef, ExpressionUtils } from "../../../dist/schema"; +export const schema = { + provider: { + type: "sqlite" + }, + models: { + Space: { + name: "Space", + fields: { + id: { + name: "id", + type: "String", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("uuid") }] }], + default: ExpressionUtils.call("uuid") + }, + createdAt: { + name: "createdAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], + default: ExpressionUtils.call("now") + }, + updatedAt: { + name: "updatedAt", + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }] + }, + name: { + name: "name", + type: "String", + attributes: [{ name: "@length", args: [{ name: "min", value: ExpressionUtils.literal(4) }, { name: "max", value: ExpressionUtils.literal(50) }] }] + }, + slug: { + name: "slug", + type: "String", + unique: true, + attributes: [{ name: "@unique" }, { name: "@length", args: [{ name: "min", value: ExpressionUtils.literal(4) }, { name: "max", value: ExpressionUtils.literal(16) }] }] + }, + owner: { + name: "owner", + type: "User", + optional: true, + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array([ExpressionUtils.field("ownerId")]) }, { name: "references", value: ExpressionUtils.array([ExpressionUtils.field("id")]) }, { name: "onDelete", value: ExpressionUtils.literal("Cascade") }] }], + relation: { opposite: "ownedSpaces", fields: ["ownerId"], references: ["id"], onDelete: "Cascade" } + }, + ownerId: { + name: "ownerId", + type: "String", + optional: true, + foreignKeyFor: [ + "owner" + ] + }, + members: { + name: "members", + type: "SpaceUser", + array: true, + relation: { opposite: "space" } + }, + lists: { + name: "lists", + type: "List", + array: true, + relation: { opposite: "space" } + } + }, + attributes: [ + { name: "@@deny", args: [{ name: "operation", value: ExpressionUtils.literal("all") }, { name: "condition", value: ExpressionUtils.binary(ExpressionUtils.call("auth"), "==", ExpressionUtils._null()) }] }, + { name: "@@allow", args: [{ name: "operation", value: ExpressionUtils.literal("create") }, { name: "condition", value: ExpressionUtils.literal(true) }] }, + { name: "@@allow", args: [{ name: "operation", value: ExpressionUtils.literal("read") }, { name: "condition", value: ExpressionUtils.binary(ExpressionUtils.field("members"), "?", ExpressionUtils.binary(ExpressionUtils.field("userId"), "==", ExpressionUtils.member(ExpressionUtils.call("auth"), ["id"]))) }] }, + { name: "@@allow", args: [{ name: "operation", value: ExpressionUtils.literal("update,delete") }, { name: "condition", value: ExpressionUtils.binary(ExpressionUtils.field("members"), "?", ExpressionUtils.binary(ExpressionUtils.binary(ExpressionUtils.field("userId"), "==", ExpressionUtils.member(ExpressionUtils.call("auth"), ["id"])), "&&", ExpressionUtils.binary(ExpressionUtils.field("role"), "==", ExpressionUtils.literal("ADMIN")))) }] } + ], + idFields: ["id"], + uniqueFields: { + id: { type: "String" }, + slug: { type: "String" } + } + }, + SpaceUser: { + name: "SpaceUser", + fields: { + id: { + name: "id", + type: "String", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("uuid") }] }], + default: ExpressionUtils.call("uuid") + }, + createdAt: { + name: "createdAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], + default: ExpressionUtils.call("now") + }, + updatedAt: { + name: "updatedAt", + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }] + }, + space: { + name: "space", + type: "Space", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array([ExpressionUtils.field("spaceId")]) }, { name: "references", value: ExpressionUtils.array([ExpressionUtils.field("id")]) }, { name: "onDelete", value: ExpressionUtils.literal("Cascade") }] }], + relation: { opposite: "members", fields: ["spaceId"], references: ["id"], onDelete: "Cascade" } + }, + spaceId: { + name: "spaceId", + type: "String", + foreignKeyFor: [ + "space" + ] + }, + user: { + name: "user", + type: "User", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array([ExpressionUtils.field("userId")]) }, { name: "references", value: ExpressionUtils.array([ExpressionUtils.field("id")]) }, { name: "onDelete", value: ExpressionUtils.literal("Cascade") }] }], + relation: { opposite: "spaces", fields: ["userId"], references: ["id"], onDelete: "Cascade" } + }, + userId: { + name: "userId", + type: "String", + foreignKeyFor: [ + "user" + ] + }, + role: { + name: "role", + type: "String" + } + }, + attributes: [ + { name: "@@unique", args: [{ name: "fields", value: ExpressionUtils.array([ExpressionUtils.field("userId"), ExpressionUtils.field("spaceId")]) }] }, + { name: "@@deny", args: [{ name: "operation", value: ExpressionUtils.literal("all") }, { name: "condition", value: ExpressionUtils.binary(ExpressionUtils.call("auth"), "==", ExpressionUtils._null()) }] }, + { name: "@@allow", args: [{ name: "operation", value: ExpressionUtils.literal("create,update,delete") }, { name: "condition", value: ExpressionUtils.binary(ExpressionUtils.binary(ExpressionUtils.member(ExpressionUtils.field("space"), ["ownerId"]), "==", ExpressionUtils.member(ExpressionUtils.call("auth"), ["id"])), "||", ExpressionUtils.binary(ExpressionUtils.member(ExpressionUtils.field("space"), ["members"]), "?", ExpressionUtils.binary(ExpressionUtils.binary(ExpressionUtils.field("userId"), "==", ExpressionUtils.member(ExpressionUtils.call("auth"), ["id"])), "&&", ExpressionUtils.binary(ExpressionUtils.field("role"), "==", ExpressionUtils.literal("ADMIN"))))) }] }, + { name: "@@allow", args: [{ name: "operation", value: ExpressionUtils.literal("read") }, { name: "condition", value: ExpressionUtils.binary(ExpressionUtils.member(ExpressionUtils.field("space"), ["members"]), "?", ExpressionUtils.binary(ExpressionUtils.field("userId"), "==", ExpressionUtils.member(ExpressionUtils.call("auth"), ["id"]))) }] } + ], + idFields: ["id"], + uniqueFields: { + id: { type: "String" }, + userId_spaceId: { userId: { type: "String" }, spaceId: { type: "String" } } + } + }, + User: { + name: "User", + fields: { + id: { + name: "id", + type: "String", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("uuid") }] }], + default: ExpressionUtils.call("uuid") + }, + createdAt: { + name: "createdAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], + default: ExpressionUtils.call("now") + }, + updatedAt: { + name: "updatedAt", + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }] + }, + email: { + name: "email", + type: "String", + unique: true, + attributes: [{ name: "@unique" }, { name: "@email" }] + }, + password: { + name: "password", + type: "String", + optional: true, + attributes: [{ name: "@password" }, { name: "@omit" }] + }, + emailVerified: { + name: "emailVerified", + type: "DateTime", + optional: true + }, + name: { + name: "name", + type: "String", + optional: true + }, + ownedSpaces: { + name: "ownedSpaces", + type: "Space", + array: true, + relation: { opposite: "owner" } + }, + spaces: { + name: "spaces", + type: "SpaceUser", + array: true, + relation: { opposite: "user" } + }, + image: { + name: "image", + type: "String", + optional: true, + attributes: [{ name: "@url" }] + }, + lists: { + name: "lists", + type: "List", + array: true, + relation: { opposite: "owner" } + }, + todos: { + name: "todos", + type: "Todo", + array: true, + relation: { opposite: "owner" } + } + }, + attributes: [ + { name: "@@allow", args: [{ name: "operation", value: ExpressionUtils.literal("create") }, { name: "condition", value: ExpressionUtils.literal(true) }] }, + { name: "@@allow", args: [{ name: "operation", value: ExpressionUtils.literal("read") }, { name: "condition", value: ExpressionUtils.binary(ExpressionUtils.field("spaces"), "?", ExpressionUtils.binary(ExpressionUtils.member(ExpressionUtils.field("space"), ["members"]), "?", ExpressionUtils.binary(ExpressionUtils.field("userId"), "==", ExpressionUtils.member(ExpressionUtils.call("auth"), ["id"])))) }] }, + { name: "@@allow", args: [{ name: "operation", value: ExpressionUtils.literal("all") }, { name: "condition", value: ExpressionUtils.binary(ExpressionUtils.member(ExpressionUtils.call("auth"), ["id"]), "==", ExpressionUtils.field("id")) }] } + ], + idFields: ["id"], + uniqueFields: { + id: { type: "String" }, + email: { type: "String" } + } + }, + List: { + name: "List", + fields: { + id: { + name: "id", + type: "String", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("uuid") }] }], + default: ExpressionUtils.call("uuid") + }, + createdAt: { + name: "createdAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], + default: ExpressionUtils.call("now") + }, + updatedAt: { + name: "updatedAt", + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }] + }, + space: { + name: "space", + type: "Space", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array([ExpressionUtils.field("spaceId")]) }, { name: "references", value: ExpressionUtils.array([ExpressionUtils.field("id")]) }, { name: "onDelete", value: ExpressionUtils.literal("Cascade") }] }], + relation: { opposite: "lists", fields: ["spaceId"], references: ["id"], onDelete: "Cascade" } + }, + spaceId: { + name: "spaceId", + type: "String", + foreignKeyFor: [ + "space" + ] + }, + owner: { + name: "owner", + type: "User", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array([ExpressionUtils.field("ownerId")]) }, { name: "references", value: ExpressionUtils.array([ExpressionUtils.field("id")]) }, { name: "onDelete", value: ExpressionUtils.literal("Cascade") }] }], + relation: { opposite: "lists", fields: ["ownerId"], references: ["id"], onDelete: "Cascade" } + }, + ownerId: { + name: "ownerId", + type: "String", + foreignKeyFor: [ + "owner" + ] + }, + title: { + name: "title", + type: "String", + attributes: [{ name: "@length", args: [{ name: "min", value: ExpressionUtils.literal(1) }, { name: "max", value: ExpressionUtils.literal(100) }] }] + }, + private: { + name: "private", + type: "Boolean", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal(false) }] }], + default: false + }, + todos: { + name: "todos", + type: "Todo", + array: true, + relation: { opposite: "list" } + }, + revision: { + name: "revision", + type: "Int", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal(0) }] }], + default: 0 + } + }, + attributes: [ + { name: "@@deny", args: [{ name: "operation", value: ExpressionUtils.literal("all") }, { name: "condition", value: ExpressionUtils.binary(ExpressionUtils.call("auth"), "==", ExpressionUtils._null()) }] }, + { name: "@@allow", args: [{ name: "operation", value: ExpressionUtils.literal("read") }, { name: "condition", value: ExpressionUtils.binary(ExpressionUtils.binary(ExpressionUtils.field("ownerId"), "==", ExpressionUtils.member(ExpressionUtils.call("auth"), ["id"])), "||", ExpressionUtils.binary(ExpressionUtils.binary(ExpressionUtils.member(ExpressionUtils.field("space"), ["members"]), "?", ExpressionUtils.binary(ExpressionUtils.field("userId"), "==", ExpressionUtils.member(ExpressionUtils.call("auth"), ["id"]))), "&&", ExpressionUtils.unary("!", ExpressionUtils.field("private")))) }] }, + { name: "@@allow", args: [{ name: "operation", value: ExpressionUtils.literal("create") }, { name: "condition", value: ExpressionUtils.binary(ExpressionUtils.binary(ExpressionUtils.field("ownerId"), "==", ExpressionUtils.member(ExpressionUtils.call("auth"), ["id"])), "&&", ExpressionUtils.binary(ExpressionUtils.member(ExpressionUtils.field("space"), ["members"]), "?", ExpressionUtils.binary(ExpressionUtils.field("userId"), "==", ExpressionUtils.member(ExpressionUtils.call("auth"), ["id"])))) }] }, + { name: "@@allow", args: [{ name: "operation", value: ExpressionUtils.literal("update") }, { name: "condition", value: ExpressionUtils.binary(ExpressionUtils.binary(ExpressionUtils.field("ownerId"), "==", ExpressionUtils.member(ExpressionUtils.call("auth"), ["id"])), "&&", ExpressionUtils.binary(ExpressionUtils.member(ExpressionUtils.field("space"), ["members"]), "?", ExpressionUtils.binary(ExpressionUtils.field("userId"), "==", ExpressionUtils.member(ExpressionUtils.call("auth"), ["id"])))) }] }, + { name: "@@allow", args: [{ name: "operation", value: ExpressionUtils.literal("delete") }, { name: "condition", value: ExpressionUtils.binary(ExpressionUtils.field("ownerId"), "==", ExpressionUtils.member(ExpressionUtils.call("auth"), ["id"])) }] } + ], + idFields: ["id"], + uniqueFields: { + id: { type: "String" } + } + }, + Todo: { + name: "Todo", + fields: { + id: { + name: "id", + type: "String", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("uuid") }] }], + default: ExpressionUtils.call("uuid") + }, + createdAt: { + name: "createdAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], + default: ExpressionUtils.call("now") + }, + updatedAt: { + name: "updatedAt", + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }] + }, + owner: { + name: "owner", + type: "User", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array([ExpressionUtils.field("ownerId")]) }, { name: "references", value: ExpressionUtils.array([ExpressionUtils.field("id")]) }, { name: "onDelete", value: ExpressionUtils.literal("Cascade") }] }], + relation: { opposite: "todos", fields: ["ownerId"], references: ["id"], onDelete: "Cascade" } + }, + ownerId: { + name: "ownerId", + type: "String", + foreignKeyFor: [ + "owner" + ] + }, + list: { + name: "list", + type: "List", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array([ExpressionUtils.field("listId")]) }, { name: "references", value: ExpressionUtils.array([ExpressionUtils.field("id")]) }, { name: "onDelete", value: ExpressionUtils.literal("Cascade") }] }], + relation: { opposite: "todos", fields: ["listId"], references: ["id"], onDelete: "Cascade" } + }, + listId: { + name: "listId", + type: "String", + foreignKeyFor: [ + "list" + ] + }, + title: { + name: "title", + type: "String", + attributes: [{ name: "@length", args: [{ name: "min", value: ExpressionUtils.literal(1) }, { name: "max", value: ExpressionUtils.literal(100) }] }] + }, + completedAt: { + name: "completedAt", + type: "DateTime", + optional: true + } + }, + attributes: [ + { name: "@@deny", args: [{ name: "operation", value: ExpressionUtils.literal("all") }, { name: "condition", value: ExpressionUtils.binary(ExpressionUtils.call("auth"), "==", ExpressionUtils._null()) }] }, + { name: "@@allow", args: [{ name: "operation", value: ExpressionUtils.literal("all") }, { name: "condition", value: ExpressionUtils.binary(ExpressionUtils.member(ExpressionUtils.field("list"), ["ownerId"]), "==", ExpressionUtils.member(ExpressionUtils.call("auth"), ["id"])) }] }, + { name: "@@allow", args: [{ name: "operation", value: ExpressionUtils.literal("all") }, { name: "condition", value: ExpressionUtils.binary(ExpressionUtils.binary(ExpressionUtils.member(ExpressionUtils.field("list"), ["space", "members"]), "?", ExpressionUtils.binary(ExpressionUtils.field("userId"), "==", ExpressionUtils.member(ExpressionUtils.call("auth"), ["id"]))), "&&", ExpressionUtils.unary("!", ExpressionUtils.member(ExpressionUtils.field("list"), ["private"]))) }] } + ], + idFields: ["id"], + uniqueFields: { + id: { type: "String" } + } + } + }, + authType: "User", + plugins: {} +} as const satisfies SchemaDef; +export type SchemaType = typeof schema; diff --git a/packages/runtime/test/schemas/todo.zmodel b/packages/runtime/test/schemas/todo/todo.zmodel similarity index 100% rename from packages/runtime/test/schemas/todo.zmodel rename to packages/runtime/test/schemas/todo/todo.zmodel diff --git a/packages/runtime/test/typing/input.ts b/packages/runtime/test/schemas/typing/input.ts similarity index 100% rename from packages/runtime/test/typing/input.ts rename to packages/runtime/test/schemas/typing/input.ts diff --git a/packages/runtime/test/typing/models.ts b/packages/runtime/test/schemas/typing/models.ts similarity index 100% rename from packages/runtime/test/typing/models.ts rename to packages/runtime/test/schemas/typing/models.ts diff --git a/packages/runtime/test/typing/schema.ts b/packages/runtime/test/schemas/typing/schema.ts similarity index 99% rename from packages/runtime/test/typing/schema.ts rename to packages/runtime/test/schemas/typing/schema.ts index de56a9dc..a86dda22 100644 --- a/packages/runtime/test/typing/schema.ts +++ b/packages/runtime/test/schemas/typing/schema.ts @@ -5,7 +5,7 @@ /* eslint-disable */ -import { type SchemaDef, type OperandExpression, ExpressionUtils } from "../../dist/schema"; +import { type SchemaDef, type OperandExpression, ExpressionUtils } from "../../../dist/schema"; export const schema = { provider: { type: "sqlite" diff --git a/packages/runtime/test/typing/typing-test.zmodel b/packages/runtime/test/schemas/typing/schema.zmodel similarity index 100% rename from packages/runtime/test/typing/typing-test.zmodel rename to packages/runtime/test/schemas/typing/schema.zmodel diff --git a/packages/runtime/test/typing/verify-typing.ts b/packages/runtime/test/schemas/typing/verify-typing.ts similarity index 99% rename from packages/runtime/test/typing/verify-typing.ts rename to packages/runtime/test/schemas/typing/verify-typing.ts index e815758f..912486db 100644 --- a/packages/runtime/test/typing/verify-typing.ts +++ b/packages/runtime/test/schemas/typing/verify-typing.ts @@ -1,5 +1,5 @@ import SQLite from 'better-sqlite3'; -import { ZenStackClient } from '../../dist'; +import { ZenStackClient } from '../../../dist'; import { Role, type Identity, type IdentityProvider } from './models'; import { schema } from './schema'; diff --git a/packages/runtime/test/scripts/generate.ts b/packages/runtime/test/scripts/generate.ts index da402546..a5cf10e9 100644 --- a/packages/runtime/test/scripts/generate.ts +++ b/packages/runtime/test/scripts/generate.ts @@ -1,14 +1,18 @@ -import { glob } from 'glob'; import { TsSchemaGenerator } from '@zenstackhq/sdk'; -import path from 'node:path'; +import { glob } from 'glob'; import fs from 'node:fs'; +import path from 'node:path'; import { fileURLToPath } from 'node:url'; const dir = path.dirname(fileURLToPath(import.meta.url)); async function main() { - await generate(path.resolve(dir, '../typing/typing-test.zmodel')); - await generate(path.resolve(dir, '../test-schema/schema.zmodel')); + // glob all zmodel files in "e2e" directory + const zmodelFiles = glob.sync(path.resolve(dir, '../schemas/**/*.zmodel')); + for (const file of zmodelFiles) { + console.log(`Generating TS schema for: ${file}`); + await generate(file); + } } async function generate(schemaPath: string) { @@ -18,7 +22,7 @@ async function generate(schemaPath: string) { const pluginModelFiles = glob.sync(path.resolve(dir, '../../dist/**/plugin.zmodel')); await generator.generate(schemaPath, pluginModelFiles, outputDir); const content = fs.readFileSync(tsPath, 'utf-8'); - fs.writeFileSync(tsPath, content.replace(/@zenstackhq\/runtime/g, '../../dist')); + fs.writeFileSync(tsPath, content.replace(/@zenstackhq\/runtime/g, '../../../dist')); console.log('TS schema generated at:', outputDir); } diff --git a/packages/runtime/test/utils.ts b/packages/runtime/test/utils.ts index 82b05e82..7c2e02d4 100644 --- a/packages/runtime/test/utils.ts +++ b/packages/runtime/test/utils.ts @@ -7,7 +7,7 @@ import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { Client as PGClient, Pool } from 'pg'; -import type { ClientOptions } from '../src/client'; +import type { ClientContract, ClientOptions } from '../src/client'; import { ZenStackClient } from '../src/client'; import type { SchemaDef } from '../src/schema'; @@ -67,7 +67,7 @@ export type CreateTestClientOptions = Omit( schema: Schema, options?: CreateTestClientOptions, -): Promise; +): Promise>; export async function createTestClient( schema: string, options?: CreateTestClientOptions, diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index e5202b32..ba4f31a5 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -1073,7 +1073,7 @@ export class TsSchemaGenerator { const statements: ts.Statement[] = []; // generate: import { schema as $schema, type SchemaType as $Schema } from './schema'; - statements.push(this.generateSchemaTypeImport(true, true)); + statements.push(this.generateSchemaImport(model, true, true)); // generate: import type { ModelResult as $ModelResult } from '@zenstackhq/runtime'; statements.push( @@ -1196,17 +1196,20 @@ export class TsSchemaGenerator { fs.writeFileSync(outputFile, result); } - private generateSchemaTypeImport(schemaObject: boolean, schemaType: boolean) { + private generateSchemaImport(model: Model, schemaObject: boolean, schemaType: boolean) { const importSpecifiers = []; if (schemaObject) { - importSpecifiers.push( - ts.factory.createImportSpecifier( - false, - ts.factory.createIdentifier('schema'), - ts.factory.createIdentifier('$schema'), - ), - ); + if (model.declarations.some(isEnum)) { + // enums require referencing the schema object + importSpecifiers.push( + ts.factory.createImportSpecifier( + false, + ts.factory.createIdentifier('schema'), + ts.factory.createIdentifier('$schema'), + ), + ); + } } if (schemaType) { @@ -1243,7 +1246,7 @@ export class TsSchemaGenerator { const statements: ts.Statement[] = []; // generate: import { SchemaType as $Schema } from './schema'; - statements.push(this.generateSchemaTypeImport(false, true)); + statements.push(this.generateSchemaImport(model, false, true)); // generate: import { CreateArgs as $CreateArgs, ... } from '@zenstackhq/runtime'; const inputTypes = [ From b9bee5df75f5b4079dd61c1b8fe60f12d968beb0 Mon Sep 17 00:00:00 2001 From: Yiming Cao Date: Mon, 28 Jul 2025 22:34:09 +0800 Subject: [PATCH 17/19] feat: delegate client typing (#117) * feat: delegate client typing * update --- .vscode/tasks.json | 12 +- packages/runtime/src/client/client-impl.ts | 8 +- packages/runtime/src/client/contract.ts | 1112 +++++++++-------- packages/runtime/src/client/crud-types.ts | 110 +- packages/runtime/src/client/plugin.ts | 2 +- packages/runtime/src/client/query-builder.ts | 8 +- .../runtime/test/schemas/delegate/input.ts | 150 +++ .../runtime/test/schemas/delegate/models.ts | 16 + .../runtime/test/schemas/delegate/schema.ts | 465 +++++++ .../test/schemas/delegate/schema.zmodel | 57 + .../test/schemas/delegate/typecheck.ts | 183 +++ .../typing/{verify-typing.ts => typecheck.ts} | 0 packages/sdk/src/schema/schema.ts | 41 + packages/sdk/src/ts-schema-generator.ts | 70 +- turbo.json | 5 + 15 files changed, 1625 insertions(+), 614 deletions(-) create mode 100644 packages/runtime/test/schemas/delegate/input.ts create mode 100644 packages/runtime/test/schemas/delegate/models.ts create mode 100644 packages/runtime/test/schemas/delegate/schema.ts create mode 100644 packages/runtime/test/schemas/delegate/schema.zmodel create mode 100644 packages/runtime/test/schemas/delegate/typecheck.ts rename packages/runtime/test/schemas/typing/{verify-typing.ts => typecheck.ts} (100%) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 6097f870..9b9e40bf 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -18,7 +18,7 @@ }, { "label": "Build all - watch", - "command": "turbo watch build", + "command": "pnpm watch", "type": "shell", "group": { "kind": "build" @@ -50,6 +50,16 @@ "color": "terminal.ansiMagenta", "id": "server-process" } + }, + { + "label": "Submit PR", + "command": "pnpm pr", + "type": "shell", + "icon": { + "color": "terminal.ansiWhite", + "id": "server-process" + }, + "problemMatcher": [] } ] } diff --git a/packages/runtime/src/client/client-impl.ts b/packages/runtime/src/client/client-impl.ts index d646dd30..d2c5bfba 100644 --- a/packages/runtime/src/client/client-impl.ts +++ b/packages/runtime/src/client/client-impl.ts @@ -397,7 +397,13 @@ function createModelCrudHandler - opHooks({ client, model, operation, args, query: _proceed }); + opHooks({ + client, + model, + operation, + args, + query: _proceed, + }) as Promise; } } } diff --git a/packages/runtime/src/client/contract.ts b/packages/runtime/src/client/contract.ts index 53c8a36f..f9182845 100644 --- a/packages/runtime/src/client/contract.ts +++ b/packages/runtime/src/client/contract.ts @@ -1,5 +1,5 @@ import type { Decimal } from 'decimal.js'; -import { type GetModels, type ProcedureDef, type SchemaDef } from '../schema'; +import { type GetModels, type IsDelegateModel, type ProcedureDef, type SchemaDef } from '../schema'; import type { AuthType } from '../schema/auth'; import type { OrUndefinedIf, Simplify, UnwrapTuplePromises } from '../utils/type-utils'; import type { TRANSACTION_UNSUPPORTED_METHODS } from './constants'; @@ -215,558 +215,562 @@ export type CRUD = 'create' | 'read' | 'update' | 'delete'; //#region Model operations -export interface ModelOperations> { - /** - * Returns a list of entities. - * @param args - query args - * @returns a list of entities - * - * @example - * ```ts - * // find all users and return all scalar fields - * await client.user.findMany(); - * - * // find all users with name 'Alex' - * await client.user.findMany({ - * where: { - * name: 'Alex' - * } - * }); - * - * // select fields - * await client.user.findMany({ - * select: { - * name: true, - * email: true, - * } - * }); // result: `Array<{ name: string, email: string }>` - * - * // omit fields - * await client.user.findMany({ - * omit: { - * name: true, - * } - * }); // result: `Array<{ id: number; email: string; ... }>` - * - * // include relations (and all scalar fields) - * await client.user.findMany({ - * include: { - * posts: true, - * } - * }); // result: `Array<{ ...; posts: Post[] }>` - * - * // include relations with filter - * await client.user.findMany({ - * include: { - * posts: { - * where: { - * published: true - * } - * } - * } - * }); - * - * // pagination and sorting - * await client.user.findMany({ - * skip: 10, - * take: 10, - * orderBy: [{ name: 'asc' }, { email: 'desc' }], - * }); - * - * // pagination with cursor (https://www.prisma.io/docs/orm/prisma-client/queries/pagination#cursor-based-pagination) - * await client.user.findMany({ - * cursor: { id: 10 }, - * skip: 1, - * take: 10, - * orderBy: { id: 'asc' }, - * }); - * - * // distinct - * await client.user.findMany({ - * distinct: ['name'] - * }); - * - * // count all relations - * await client.user.findMany({ - * _count: true, - * }); // result: `{ _count: { posts: number; ... } }` - * - * // count selected relations - * await client.user.findMany({ - * _count: { select: { posts: true } }, - * }); // result: `{ _count: { posts: number } }` - * ``` - */ - findMany>( - args?: SelectSubset>, - ): ZenStackPromise>[]>; - - /** - * Returns a uniquely identified entity. - * @param args - query args - * @returns a single entity or null if not found - * @see {@link findMany} - */ - findUnique>( - args?: SelectSubset>, - ): ZenStackPromise> | null>; - - /** - * Returns a uniquely identified entity or throws `NotFoundError` if not found. - * @param args - query args - * @returns a single entity - * @see {@link findMany} - */ - findUniqueOrThrow>( - args?: SelectSubset>, - ): ZenStackPromise>>; - - /** - * Returns the first entity. - * @param args - query args - * @returns a single entity or null if not found - * @see {@link findMany} - */ - findFirst>( - args?: SelectSubset>, - ): ZenStackPromise> | null>; - - /** - * Returns the first entity or throws `NotFoundError` if not found. - * @param args - query args - * @returns a single entity - * @see {@link findMany} - */ - findFirstOrThrow>( - args?: SelectSubset>, - ): ZenStackPromise>>; - - /** - * Creates a new entity. - * @param args - create args - * @returns the created entity - * - * @example - * ```ts - * // simple create - * await client.user.create({ - * data: { name: 'Alex', email: 'alex@zenstack.dev' } - * }); - * - * // nested create with relation - * await client.user.create({ - * data: { - * email: 'alex@zenstack.dev', - * posts: { create: { title: 'Hello World' } } - * } - * }); - * - * // you can use `select`, `omit`, and `include` to control - * // the fields returned by the query, as with `findMany` - * await client.user.create({ - * data: { - * email: 'alex@zenstack.dev', - * posts: { create: { title: 'Hello World' } } - * }, - * include: { posts: true } - * }); // result: `{ id: number; posts: Post[] }` - * - * // connect relations - * await client.user.create({ - * data: { - * email: 'alex@zenstack.dev', - * posts: { connect: { id: 1 } } - * } - * }); - * - * // connect relations, and create if not found - * await client.user.create({ - * data: { - * email: 'alex@zenstack.dev', - * posts: { - * connectOrCreate: { - * where: { id: 1 }, - * create: { title: 'Hello World' } - * } - * } - * } - * }); - * ``` - */ - create>( - args: SelectSubset>, - ): ZenStackPromise>>; - - /** - * Creates multiple entities. Only scalar fields are allowed. - * @param args - create args - * @returns count of created entities: `{ count: number }` - * - * @example - * ```ts - * // create multiple entities - * await client.user.createMany({ - * data: [ - * { name: 'Alex', email: 'alex@zenstack.dev' }, - * { name: 'John', email: 'john@zenstack.dev' } - * ] - * }); - * - * // skip items that cause unique constraint violation - * await client.user.createMany({ - * data: [ - * { name: 'Alex', email: 'alex@zenstack.dev' }, - * { name: 'John', email: 'john@zenstack.dev' } - * ], - * skipDuplicates: true - * }); - * ``` - */ - createMany>( - args?: SelectSubset>, - ): ZenStackPromise; - - /** - * Creates multiple entities and returns them. - * @param args - create args. See {@link createMany} for input. Use - * `select` and `omit` to control the fields returned. - * @returns the created entities - * - * @example - * ```ts - * // create multiple entities and return selected fields - * await client.user.createManyAndReturn({ - * data: [ - * { name: 'Alex', email: 'alex@zenstack.dev' }, - * { name: 'John', email: 'john@zenstack.dev' } - * ], - * select: { id: true, email: true } - * }); - * ``` - */ - createManyAndReturn>( - args?: SelectSubset>, - ): ZenStackPromise>[]>; - - /** - * Updates a uniquely identified entity. - * @param args - update args. See {@link findMany} for how to control - * fields and relations returned. - * @returns the updated entity. Throws `NotFoundError` if the entity is not found. - * - * @example - * ```ts - * // update fields - * await client.user.update({ - * where: { id: 1 }, - * data: { name: 'Alex' } - * }); - * - * // connect a relation - * await client.user.update({ - * where: { id: 1 }, - * data: { posts: { connect: { id: 1 } } } - * }); - * - * // connect relation, and create if not found - * await client.user.update({ - * where: { id: 1 }, - * data: { - * posts: { - * connectOrCreate: { - * where: { id: 1 }, - * create: { title: 'Hello World' } - * } - * } - * } - * }); - * - * // create many related entities (only available for one-to-many relations) - * await client.user.update({ - * where: { id: 1 }, - * data: { - * posts: { - * createMany: { - * data: [{ title: 'Hello World' }, { title: 'Hello World 2' }], - * } - * } - * } - * }); - * - * // disconnect a one-to-many relation - * await client.user.update({ - * where: { id: 1 }, - * data: { posts: { disconnect: { id: 1 } } } - * }); - * - * // disconnect a one-to-one relation - * await client.user.update({ - * where: { id: 1 }, - * data: { profile: { disconnect: true } } - * }); - * - * // replace a relation (only available for one-to-many relations) - * await client.user.update({ - * where: { id: 1 }, - * data: { - * posts: { - * set: [{ id: 1 }, { id: 2 }] - * } - * } - * }); - * - * // update a relation - * await client.user.update({ - * where: { id: 1 }, - * data: { - * posts: { - * update: { where: { id: 1 }, data: { title: 'Hello World' } } - * } - * } - * }); - * - * // upsert a relation - * await client.user.update({ - * where: { id: 1 }, - * data: { - * posts: { - * upsert: { - * where: { id: 1 }, - * create: { title: 'Hello World' }, - * update: { title: 'Hello World' } - * } - * } - * } - * }); - * - * // update many related entities (only available for one-to-many relations) - * await client.user.update({ - * where: { id: 1 }, - * data: { - * posts: { - * updateMany: { - * where: { published: true }, - * data: { title: 'Hello World' } - * } - * } - * } - * }); - * - * // delete a one-to-many relation - * await client.user.update({ - * where: { id: 1 }, - * data: { posts: { delete: { id: 1 } } } - * }); - * - * // delete a one-to-one relation - * await client.user.update({ - * where: { id: 1 }, - * data: { profile: { delete: true } } - * }); - * ``` - */ - update>( - args: SelectSubset>, - ): ZenStackPromise>>; - - /** - * Updates multiple entities. - * @param args - update args. Only scalar fields are allowed for data. - * @returns count of updated entities: `{ count: number }` - * - * @example - * ```ts - * // update many entities - * await client.user.updateMany({ - * where: { email: { endsWith: '@zenstack.dev' } }, - * data: { role: 'ADMIN' } - * }); - * - * // limit the number of updated entities - * await client.user.updateMany({ - * where: { email: { endsWith: '@zenstack.dev' } }, - * data: { role: 'ADMIN' }, - * limit: 10 - * }); - */ - updateMany>( - args: Subset>, - ): ZenStackPromise; - - /** - * Updates multiple entities and returns them. - * @param args - update args. Only scalar fields are allowed for data. - * @returns the updated entities - * - * @example - * ```ts - * // update many entities and return selected fields - * await client.user.updateManyAndReturn({ - * where: { email: { endsWith: '@zenstack.dev' } }, - * data: { role: 'ADMIN' }, - * select: { id: true, email: true } - * }); // result: `Array<{ id: string; email: string }>` - * - * // limit the number of updated entities - * await client.user.updateManyAndReturn({ - * where: { email: { endsWith: '@zenstack.dev' } }, - * data: { role: 'ADMIN' }, - * limit: 10 - * }); - * ``` - */ - updateManyAndReturn>( - args: Subset>, - ): ZenStackPromise>[]>; - - /** - * Creates or updates an entity. - * @param args - upsert args - * @returns the upserted entity - * - * @example - * ```ts - * // upsert an entity - * await client.user.upsert({ - * // `where` clause is used to find the entity - * where: { id: 1 }, - * // `create` clause is used if the entity is not found - * create: { email: 'alex@zenstack.dev', name: 'Alex' }, - * // `update` clause is used if the entity is found - * update: { name: 'Alex-new' }, - * // `select` and `omit` can be used to control the returned fields - * ... - * }); - * ``` - */ - upsert>( - args: SelectSubset>, - ): ZenStackPromise>>; - - /** - * Deletes a uniquely identifiable entity. - * @param args - delete args - * @returns the deleted entity. Throws `NotFoundError` if the entity is not found. - * - * @example - * ```ts - * // delete an entity - * await client.user.delete({ - * where: { id: 1 } - * }); - * - * // delete an entity and return selected fields - * await client.user.delete({ - * where: { id: 1 }, - * select: { id: true, email: true } - * }); // result: `{ id: string; email: string }` - * ``` - */ - delete>( - args: SelectSubset>, - ): ZenStackPromise>>; - - /** - * Deletes multiple entities. - * @param args - delete args - * @returns count of deleted entities: `{ count: number }` - * - * @example - * ```ts - * // delete many entities - * await client.user.deleteMany({ - * where: { email: { endsWith: '@zenstack.dev' } } - * }); - * - * // limit the number of deleted entities - * await client.user.deleteMany({ - * where: { email: { endsWith: '@zenstack.dev' } }, - * limit: 10 - * }); - * ``` - */ - deleteMany>( - args?: Subset>, - ): ZenStackPromise; - - /** - * Counts rows or field values. - * @param args - count args - * @returns `number`, or an object containing count of selected relations - * - * @example - * ```ts - * // count all - * await client.user.count(); - * - * // count with a filter - * await client.user.count({ where: { email: { endsWith: '@zenstack.dev' } } }); - * - * // count rows and field values - * await client.user.count({ - * select: { _all: true, email: true } - * }); // result: `{ _all: number, email: number }` - */ - count>( - args?: Subset>, - ): ZenStackPromise>>; - - /** - * Aggregates rows. - * @param args - aggregation args - * @returns an object containing aggregated values - * - * @example - * ```ts - * // aggregate rows - * await client.profile.aggregate({ - * where: { email: { endsWith: '@zenstack.dev' } }, - * _count: true, - * _avg: { age: true }, - * _sum: { age: true }, - * _min: { age: true }, - * _max: { age: true } - * }); // result: `{ _count: number, _avg: { age: number }, ... }` - */ - aggregate>( - args: Subset>, - ): ZenStackPromise>>; - - /** - * Groups rows by columns. - * @param args - groupBy args - * @returns an object containing grouped values - * - * @example - * ```ts - * // group by a field - * await client.profile.groupBy({ - * by: 'country', - * _count: true - * }); // result: `Array<{ country: string, _count: number }>` - * - * // group by multiple fields - * await client.profile.groupBy({ - * by: ['country', 'city'], - * _count: true - * }); // result: `Array<{ country: string, city: string, _count: number }>` - * - * // group by with sorting, the `orderBy` fields must be in the `by` list - * await client.profile.groupBy({ - * by: 'country', - * orderBy: { country: 'desc' } - * }); - * - * // group by with having (post-aggregation filter), the `having` fields must - * // be in the `by` list - * await client.profile.groupBy({ - * by: 'country', - * having: { country: 'US' } - * }); - */ - groupBy>( - args: Subset>, - ): ZenStackPromise>>; -} +export type ModelOperations> = Omit< + { + /** + * Returns a list of entities. + * @param args - query args + * @returns a list of entities + * + * @example + * ```ts + * // find all users and return all scalar fields + * await client.user.findMany(); + * + * // find all users with name 'Alex' + * await client.user.findMany({ + * where: { + * name: 'Alex' + * } + * }); + * + * // select fields + * await client.user.findMany({ + * select: { + * name: true, + * email: true, + * } + * }); // result: `Array<{ name: string, email: string }>` + * + * // omit fields + * await client.user.findMany({ + * omit: { + * name: true, + * } + * }); // result: `Array<{ id: number; email: string; ... }>` + * + * // include relations (and all scalar fields) + * await client.user.findMany({ + * include: { + * posts: true, + * } + * }); // result: `Array<{ ...; posts: Post[] }>` + * + * // include relations with filter + * await client.user.findMany({ + * include: { + * posts: { + * where: { + * published: true + * } + * } + * } + * }); + * + * // pagination and sorting + * await client.user.findMany({ + * skip: 10, + * take: 10, + * orderBy: [{ name: 'asc' }, { email: 'desc' }], + * }); + * + * // pagination with cursor (https://www.prisma.io/docs/orm/prisma-client/queries/pagination#cursor-based-pagination) + * await client.user.findMany({ + * cursor: { id: 10 }, + * skip: 1, + * take: 10, + * orderBy: { id: 'asc' }, + * }); + * + * // distinct + * await client.user.findMany({ + * distinct: ['name'] + * }); + * + * // count all relations + * await client.user.findMany({ + * _count: true, + * }); // result: `{ _count: { posts: number; ... } }` + * + * // count selected relations + * await client.user.findMany({ + * _count: { select: { posts: true } }, + * }); // result: `{ _count: { posts: number } }` + * ``` + */ + findMany>( + args?: SelectSubset>, + ): ZenStackPromise>[]>; + + /** + * Returns a uniquely identified entity. + * @param args - query args + * @returns a single entity or null if not found + * @see {@link findMany} + */ + findUnique>( + args?: SelectSubset>, + ): ZenStackPromise> | null>; + + /** + * Returns a uniquely identified entity or throws `NotFoundError` if not found. + * @param args - query args + * @returns a single entity + * @see {@link findMany} + */ + findUniqueOrThrow>( + args?: SelectSubset>, + ): ZenStackPromise>>; + + /** + * Returns the first entity. + * @param args - query args + * @returns a single entity or null if not found + * @see {@link findMany} + */ + findFirst>( + args?: SelectSubset>, + ): ZenStackPromise> | null>; + + /** + * Returns the first entity or throws `NotFoundError` if not found. + * @param args - query args + * @returns a single entity + * @see {@link findMany} + */ + findFirstOrThrow>( + args?: SelectSubset>, + ): ZenStackPromise>>; + + /** + * Creates a new entity. + * @param args - create args + * @returns the created entity + * + * @example + * ```ts + * // simple create + * await client.user.create({ + * data: { name: 'Alex', email: 'alex@zenstack.dev' } + * }); + * + * // nested create with relation + * await client.user.create({ + * data: { + * email: 'alex@zenstack.dev', + * posts: { create: { title: 'Hello World' } } + * } + * }); + * + * // you can use `select`, `omit`, and `include` to control + * // the fields returned by the query, as with `findMany` + * await client.user.create({ + * data: { + * email: 'alex@zenstack.dev', + * posts: { create: { title: 'Hello World' } } + * }, + * include: { posts: true } + * }); // result: `{ id: number; posts: Post[] }` + * + * // connect relations + * await client.user.create({ + * data: { + * email: 'alex@zenstack.dev', + * posts: { connect: { id: 1 } } + * } + * }); + * + * // connect relations, and create if not found + * await client.user.create({ + * data: { + * email: 'alex@zenstack.dev', + * posts: { + * connectOrCreate: { + * where: { id: 1 }, + * create: { title: 'Hello World' } + * } + * } + * } + * }); + * ``` + */ + create>( + args: SelectSubset>, + ): ZenStackPromise>>; + + /** + * Creates multiple entities. Only scalar fields are allowed. + * @param args - create args + * @returns count of created entities: `{ count: number }` + * + * @example + * ```ts + * // create multiple entities + * await client.user.createMany({ + * data: [ + * { name: 'Alex', email: 'alex@zenstack.dev' }, + * { name: 'John', email: 'john@zenstack.dev' } + * ] + * }); + * + * // skip items that cause unique constraint violation + * await client.user.createMany({ + * data: [ + * { name: 'Alex', email: 'alex@zenstack.dev' }, + * { name: 'John', email: 'john@zenstack.dev' } + * ], + * skipDuplicates: true + * }); + * ``` + */ + createMany>( + args?: SelectSubset>, + ): ZenStackPromise; + + /** + * Creates multiple entities and returns them. + * @param args - create args. See {@link createMany} for input. Use + * `select` and `omit` to control the fields returned. + * @returns the created entities + * + * @example + * ```ts + * // create multiple entities and return selected fields + * await client.user.createManyAndReturn({ + * data: [ + * { name: 'Alex', email: 'alex@zenstack.dev' }, + * { name: 'John', email: 'john@zenstack.dev' } + * ], + * select: { id: true, email: true } + * }); + * ``` + */ + createManyAndReturn>( + args?: SelectSubset>, + ): ZenStackPromise>[]>; + + /** + * Updates a uniquely identified entity. + * @param args - update args. See {@link findMany} for how to control + * fields and relations returned. + * @returns the updated entity. Throws `NotFoundError` if the entity is not found. + * + * @example + * ```ts + * // update fields + * await client.user.update({ + * where: { id: 1 }, + * data: { name: 'Alex' } + * }); + * + * // connect a relation + * await client.user.update({ + * where: { id: 1 }, + * data: { posts: { connect: { id: 1 } } } + * }); + * + * // connect relation, and create if not found + * await client.user.update({ + * where: { id: 1 }, + * data: { + * posts: { + * connectOrCreate: { + * where: { id: 1 }, + * create: { title: 'Hello World' } + * } + * } + * } + * }); + * + * // create many related entities (only available for one-to-many relations) + * await client.user.update({ + * where: { id: 1 }, + * data: { + * posts: { + * createMany: { + * data: [{ title: 'Hello World' }, { title: 'Hello World 2' }], + * } + * } + * } + * }); + * + * // disconnect a one-to-many relation + * await client.user.update({ + * where: { id: 1 }, + * data: { posts: { disconnect: { id: 1 } } } + * }); + * + * // disconnect a one-to-one relation + * await client.user.update({ + * where: { id: 1 }, + * data: { profile: { disconnect: true } } + * }); + * + * // replace a relation (only available for one-to-many relations) + * await client.user.update({ + * where: { id: 1 }, + * data: { + * posts: { + * set: [{ id: 1 }, { id: 2 }] + * } + * } + * }); + * + * // update a relation + * await client.user.update({ + * where: { id: 1 }, + * data: { + * posts: { + * update: { where: { id: 1 }, data: { title: 'Hello World' } } + * } + * } + * }); + * + * // upsert a relation + * await client.user.update({ + * where: { id: 1 }, + * data: { + * posts: { + * upsert: { + * where: { id: 1 }, + * create: { title: 'Hello World' }, + * update: { title: 'Hello World' } + * } + * } + * } + * }); + * + * // update many related entities (only available for one-to-many relations) + * await client.user.update({ + * where: { id: 1 }, + * data: { + * posts: { + * updateMany: { + * where: { published: true }, + * data: { title: 'Hello World' } + * } + * } + * } + * }); + * + * // delete a one-to-many relation + * await client.user.update({ + * where: { id: 1 }, + * data: { posts: { delete: { id: 1 } } } + * }); + * + * // delete a one-to-one relation + * await client.user.update({ + * where: { id: 1 }, + * data: { profile: { delete: true } } + * }); + * ``` + */ + update>( + args: SelectSubset>, + ): ZenStackPromise>>; + + /** + * Updates multiple entities. + * @param args - update args. Only scalar fields are allowed for data. + * @returns count of updated entities: `{ count: number }` + * + * @example + * ```ts + * // update many entities + * await client.user.updateMany({ + * where: { email: { endsWith: '@zenstack.dev' } }, + * data: { role: 'ADMIN' } + * }); + * + * // limit the number of updated entities + * await client.user.updateMany({ + * where: { email: { endsWith: '@zenstack.dev' } }, + * data: { role: 'ADMIN' }, + * limit: 10 + * }); + */ + updateMany>( + args: Subset>, + ): ZenStackPromise; + + /** + * Updates multiple entities and returns them. + * @param args - update args. Only scalar fields are allowed for data. + * @returns the updated entities + * + * @example + * ```ts + * // update many entities and return selected fields + * await client.user.updateManyAndReturn({ + * where: { email: { endsWith: '@zenstack.dev' } }, + * data: { role: 'ADMIN' }, + * select: { id: true, email: true } + * }); // result: `Array<{ id: string; email: string }>` + * + * // limit the number of updated entities + * await client.user.updateManyAndReturn({ + * where: { email: { endsWith: '@zenstack.dev' } }, + * data: { role: 'ADMIN' }, + * limit: 10 + * }); + * ``` + */ + updateManyAndReturn>( + args: Subset>, + ): ZenStackPromise>[]>; + + /** + * Creates or updates an entity. + * @param args - upsert args + * @returns the upserted entity + * + * @example + * ```ts + * // upsert an entity + * await client.user.upsert({ + * // `where` clause is used to find the entity + * where: { id: 1 }, + * // `create` clause is used if the entity is not found + * create: { email: 'alex@zenstack.dev', name: 'Alex' }, + * // `update` clause is used if the entity is found + * update: { name: 'Alex-new' }, + * // `select` and `omit` can be used to control the returned fields + * ... + * }); + * ``` + */ + upsert>( + args: SelectSubset>, + ): ZenStackPromise>>; + + /** + * Deletes a uniquely identifiable entity. + * @param args - delete args + * @returns the deleted entity. Throws `NotFoundError` if the entity is not found. + * + * @example + * ```ts + * // delete an entity + * await client.user.delete({ + * where: { id: 1 } + * }); + * + * // delete an entity and return selected fields + * await client.user.delete({ + * where: { id: 1 }, + * select: { id: true, email: true } + * }); // result: `{ id: string; email: string }` + * ``` + */ + delete>( + args: SelectSubset>, + ): ZenStackPromise>>; + + /** + * Deletes multiple entities. + * @param args - delete args + * @returns count of deleted entities: `{ count: number }` + * + * @example + * ```ts + * // delete many entities + * await client.user.deleteMany({ + * where: { email: { endsWith: '@zenstack.dev' } } + * }); + * + * // limit the number of deleted entities + * await client.user.deleteMany({ + * where: { email: { endsWith: '@zenstack.dev' } }, + * limit: 10 + * }); + * ``` + */ + deleteMany>( + args?: Subset>, + ): ZenStackPromise; + + /** + * Counts rows or field values. + * @param args - count args + * @returns `number`, or an object containing count of selected relations + * + * @example + * ```ts + * // count all + * await client.user.count(); + * + * // count with a filter + * await client.user.count({ where: { email: { endsWith: '@zenstack.dev' } } }); + * + * // count rows and field values + * await client.user.count({ + * select: { _all: true, email: true } + * }); // result: `{ _all: number, email: number }` + */ + count>( + args?: Subset>, + ): ZenStackPromise>>; + + /** + * Aggregates rows. + * @param args - aggregation args + * @returns an object containing aggregated values + * + * @example + * ```ts + * // aggregate rows + * await client.profile.aggregate({ + * where: { email: { endsWith: '@zenstack.dev' } }, + * _count: true, + * _avg: { age: true }, + * _sum: { age: true }, + * _min: { age: true }, + * _max: { age: true } + * }); // result: `{ _count: number, _avg: { age: number }, ... }` + */ + aggregate>( + args: Subset>, + ): ZenStackPromise>>; + + /** + * Groups rows by columns. + * @param args - groupBy args + * @returns an object containing grouped values + * + * @example + * ```ts + * // group by a field + * await client.profile.groupBy({ + * by: 'country', + * _count: true + * }); // result: `Array<{ country: string, _count: number }>` + * + * // group by multiple fields + * await client.profile.groupBy({ + * by: ['country', 'city'], + * _count: true + * }); // result: `Array<{ country: string, city: string, _count: number }>` + * + * // group by with sorting, the `orderBy` fields must be in the `by` list + * await client.profile.groupBy({ + * by: 'country', + * orderBy: { country: 'desc' } + * }); + * + * // group by with having (post-aggregation filter), the `having` fields must + * // be in the `by` list + * await client.profile.groupBy({ + * by: 'country', + * having: { country: 'US' } + * }); + */ + groupBy>( + args: Subset>, + ): ZenStackPromise>>; + }, + // exclude operations not applicable to delegate models + IsDelegateModel extends true ? 'create' | 'createMany' | 'createManyAndReturn' | 'upsert' : never +>; //#endregion diff --git a/packages/runtime/src/client/crud-types.ts b/packages/runtime/src/client/crud-types.ts index 821dac06..abe011f4 100644 --- a/packages/runtime/src/client/crud-types.ts +++ b/packages/runtime/src/client/crud-types.ts @@ -4,6 +4,8 @@ import type { FieldDef, FieldHasDefault, FieldIsArray, + FieldIsDelegateDiscriminator, + FieldIsDelegateRelation, FieldIsRelation, FieldIsRelationArray, FieldType, @@ -11,13 +13,16 @@ import type { GetEnum, GetEnums, GetModel, + GetModelDiscriminator, GetModelField, GetModelFields, GetModelFieldType, GetModels, + GetSubModels, GetTypeDefField, GetTypeDefFields, GetTypeDefs, + IsDelegateModel, ModelFieldIsOptional, NonRelationFields, RelationFields, @@ -50,17 +55,29 @@ type DefaultModelResult< Optional = false, Array = false, > = WrapType< - { - [Key in NonRelationFields as Key extends keyof Omit - ? Omit[Key] extends true - ? never - : Key - : Key]: MapModelFieldType; - }, + IsDelegateModel extends true + ? // delegate model's selection result is a union of all sub-models + DelegateUnionResult, Omit> + : { + [Key in NonRelationFields as Key extends keyof Omit + ? Omit[Key] extends true + ? never + : Key + : Key]: MapModelFieldType; + }, Optional, Array >; +type DelegateUnionResult< + Schema extends SchemaDef, + Model extends GetModels, + SubModel extends GetModels, + Omit = undefined, +> = SubModel extends string // typescript union distribution + ? DefaultModelResult & { [K in GetModelDiscriminator]: SubModel } // fixate discriminated field + : never; + type ModelSelectResult, Select, Omit> = { [Key in keyof Select as Select[Key] extends false | undefined ? never @@ -178,7 +195,7 @@ export type TypeDefResult as TypeDefFieldIsOptional extends true ? Key - : never]: Key; + : never]: true; } >; @@ -596,7 +613,10 @@ type CreateScalarPayload]: ScalarCreatePayload; + [Key in ScalarFields as FieldIsDelegateDiscriminator extends true + ? // discriminator fields cannot be assigned + never + : Key]: ScalarCreatePayload; } >; @@ -626,13 +646,15 @@ type CreateRelationFieldPayload< Field extends RelationFields, > = Omit< { + connectOrCreate?: ConnectOrCreateInput; create?: NestedCreateInput; createMany?: NestedCreateManyInput; connect?: ConnectInput; - connectOrCreate?: ConnectOrCreateInput; }, // no "createMany" for non-array fields - FieldIsArray extends true ? never : 'createMany' + | (FieldIsArray extends true ? never : 'createMany') + // exclude operations not applicable to delegate models + | (FieldIsDelegateRelation extends true ? 'create' | 'createMany' | 'connectOrCreate' : never) >; type CreateRelationPayload> = OptionalWrap< @@ -745,7 +767,10 @@ type UpdateScalarInput< Without extends string = never, > = Omit< { - [Key in NonRelationFields]?: ScalarUpdatePayload; + [Key in NonRelationFields as FieldIsDelegateDiscriminator extends true + ? // discriminator fields cannot be assigned + never + : Key]?: ScalarUpdatePayload; }, Without >; @@ -802,36 +827,45 @@ type ToManyRelationUpdateInput< Schema extends SchemaDef, Model extends GetModels, Field extends RelationFields, -> = { - create?: NestedCreateInput; - createMany?: NestedCreateManyInput; - connect?: ConnectInput; - connectOrCreate?: ConnectOrCreateInput; - disconnect?: DisconnectInput; - update?: NestedUpdateInput; - upsert?: NestedUpsertInput; - updateMany?: NestedUpdateManyInput; - delete?: NestedDeleteInput; - deleteMany?: NestedDeleteManyInput; - set?: SetRelationInput; -}; +> = Omit< + { + create?: NestedCreateInput; + createMany?: NestedCreateManyInput; + connect?: ConnectInput; + connectOrCreate?: ConnectOrCreateInput; + disconnect?: DisconnectInput; + update?: NestedUpdateInput; + upsert?: NestedUpsertInput; + updateMany?: NestedUpdateManyInput; + delete?: NestedDeleteInput; + deleteMany?: NestedDeleteManyInput; + set?: SetRelationInput; + }, + // exclude + FieldIsDelegateRelation extends true + ? 'create' | 'createMany' | 'connectOrCreate' | 'upsert' + : never +>; type ToOneRelationUpdateInput< Schema extends SchemaDef, Model extends GetModels, Field extends RelationFields, -> = { - create?: NestedCreateInput; - connect?: ConnectInput; - connectOrCreate?: ConnectOrCreateInput; - update?: NestedUpdateInput; - upsert?: NestedUpsertInput; -} & (ModelFieldIsOptional extends true - ? { - disconnect?: DisconnectInput; - delete?: NestedDeleteInput; - } - : {}); +> = Omit< + { + create?: NestedCreateInput; + connect?: ConnectInput; + connectOrCreate?: ConnectOrCreateInput; + update?: NestedUpdateInput; + upsert?: NestedUpsertInput; + } & (ModelFieldIsOptional extends true + ? { + disconnect?: DisconnectInput; + delete?: NestedDeleteInput; + } + : {}), + FieldIsDelegateRelation extends true ? 'create' | 'connectOrCreate' | 'upsert' : never +>; // #endregion @@ -1144,7 +1178,7 @@ type NonOwnedRelationFields Promise; + client: ClientContract; }) => MaybePromise; }; @@ -195,7 +196,6 @@ type OnQueryHookContext< */ query: ( args: Parameters[Operation]>[0], - // tx?: ClientContract, ) => ReturnType[Operation]>; /** diff --git a/packages/runtime/src/client/query-builder.ts b/packages/runtime/src/client/query-builder.ts index 19997017..91ec4dfa 100644 --- a/packages/runtime/src/client/query-builder.ts +++ b/packages/runtime/src/client/query-builder.ts @@ -3,6 +3,7 @@ import type { Generated, Kysely } from 'kysely'; import type { FieldHasDefault, ForeignKeyFields, + GetModelField, GetModelFields, GetModelFieldType, GetModels, @@ -18,11 +19,14 @@ export type ToKyselySchema = { export type ToKysely = Kysely>; type ToKyselyTable> = { - [Field in ScalarFields | ForeignKeyFields]: toKyselyFieldType< + [Field in ScalarFields | ForeignKeyFields as GetModelField< Schema, Model, Field - >; + >['originModel'] extends string + ? // query builder should not see fields inherited from delegate base model + never + : Field]: toKyselyFieldType; }; export type MapBaseType = T extends 'String' diff --git a/packages/runtime/test/schemas/delegate/input.ts b/packages/runtime/test/schemas/delegate/input.ts new file mode 100644 index 00000000..b8df49e4 --- /dev/null +++ b/packages/runtime/test/schemas/delegate/input.ts @@ -0,0 +1,150 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + +/* eslint-disable */ + +import { type SchemaType as $Schema } from "./schema"; +import type { FindManyArgs as $FindManyArgs, FindUniqueArgs as $FindUniqueArgs, FindFirstArgs as $FindFirstArgs, CreateArgs as $CreateArgs, CreateManyArgs as $CreateManyArgs, CreateManyAndReturnArgs as $CreateManyAndReturnArgs, UpdateArgs as $UpdateArgs, UpdateManyArgs as $UpdateManyArgs, UpdateManyAndReturnArgs as $UpdateManyAndReturnArgs, UpsertArgs as $UpsertArgs, DeleteArgs as $DeleteArgs, DeleteManyArgs as $DeleteManyArgs, CountArgs as $CountArgs, AggregateArgs as $AggregateArgs, GroupByArgs as $GroupByArgs, WhereInput as $WhereInput, SelectInput as $SelectInput, IncludeInput as $IncludeInput, OmitInput as $OmitInput } from "@zenstackhq/runtime"; +import type { SimplifiedModelResult as $SimplifiedModelResult, SelectIncludeOmit as $SelectIncludeOmit } from "@zenstackhq/runtime"; +export type UserFindManyArgs = $FindManyArgs<$Schema, "User">; +export type UserFindUniqueArgs = $FindUniqueArgs<$Schema, "User">; +export type UserFindFirstArgs = $FindFirstArgs<$Schema, "User">; +export type UserCreateArgs = $CreateArgs<$Schema, "User">; +export type UserCreateManyArgs = $CreateManyArgs<$Schema, "User">; +export type UserCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "User">; +export type UserUpdateArgs = $UpdateArgs<$Schema, "User">; +export type UserUpdateManyArgs = $UpdateManyArgs<$Schema, "User">; +export type UserUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs<$Schema, "User">; +export type UserUpsertArgs = $UpsertArgs<$Schema, "User">; +export type UserDeleteArgs = $DeleteArgs<$Schema, "User">; +export type UserDeleteManyArgs = $DeleteManyArgs<$Schema, "User">; +export type UserCountArgs = $CountArgs<$Schema, "User">; +export type UserAggregateArgs = $AggregateArgs<$Schema, "User">; +export type UserGroupByArgs = $GroupByArgs<$Schema, "User">; +export type UserWhereInput = $WhereInput<$Schema, "User">; +export type UserSelect = $SelectInput<$Schema, "User">; +export type UserInclude = $IncludeInput<$Schema, "User">; +export type UserOmit = $OmitInput<$Schema, "User">; +export type UserGetPayload> = $SimplifiedModelResult<$Schema, "User", Args>; +export type CommentFindManyArgs = $FindManyArgs<$Schema, "Comment">; +export type CommentFindUniqueArgs = $FindUniqueArgs<$Schema, "Comment">; +export type CommentFindFirstArgs = $FindFirstArgs<$Schema, "Comment">; +export type CommentCreateArgs = $CreateArgs<$Schema, "Comment">; +export type CommentCreateManyArgs = $CreateManyArgs<$Schema, "Comment">; +export type CommentCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "Comment">; +export type CommentUpdateArgs = $UpdateArgs<$Schema, "Comment">; +export type CommentUpdateManyArgs = $UpdateManyArgs<$Schema, "Comment">; +export type CommentUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs<$Schema, "Comment">; +export type CommentUpsertArgs = $UpsertArgs<$Schema, "Comment">; +export type CommentDeleteArgs = $DeleteArgs<$Schema, "Comment">; +export type CommentDeleteManyArgs = $DeleteManyArgs<$Schema, "Comment">; +export type CommentCountArgs = $CountArgs<$Schema, "Comment">; +export type CommentAggregateArgs = $AggregateArgs<$Schema, "Comment">; +export type CommentGroupByArgs = $GroupByArgs<$Schema, "Comment">; +export type CommentWhereInput = $WhereInput<$Schema, "Comment">; +export type CommentSelect = $SelectInput<$Schema, "Comment">; +export type CommentInclude = $IncludeInput<$Schema, "Comment">; +export type CommentOmit = $OmitInput<$Schema, "Comment">; +export type CommentGetPayload> = $SimplifiedModelResult<$Schema, "Comment", Args>; +export type AssetFindManyArgs = $FindManyArgs<$Schema, "Asset">; +export type AssetFindUniqueArgs = $FindUniqueArgs<$Schema, "Asset">; +export type AssetFindFirstArgs = $FindFirstArgs<$Schema, "Asset">; +export type AssetCreateArgs = $CreateArgs<$Schema, "Asset">; +export type AssetCreateManyArgs = $CreateManyArgs<$Schema, "Asset">; +export type AssetCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "Asset">; +export type AssetUpdateArgs = $UpdateArgs<$Schema, "Asset">; +export type AssetUpdateManyArgs = $UpdateManyArgs<$Schema, "Asset">; +export type AssetUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs<$Schema, "Asset">; +export type AssetUpsertArgs = $UpsertArgs<$Schema, "Asset">; +export type AssetDeleteArgs = $DeleteArgs<$Schema, "Asset">; +export type AssetDeleteManyArgs = $DeleteManyArgs<$Schema, "Asset">; +export type AssetCountArgs = $CountArgs<$Schema, "Asset">; +export type AssetAggregateArgs = $AggregateArgs<$Schema, "Asset">; +export type AssetGroupByArgs = $GroupByArgs<$Schema, "Asset">; +export type AssetWhereInput = $WhereInput<$Schema, "Asset">; +export type AssetSelect = $SelectInput<$Schema, "Asset">; +export type AssetInclude = $IncludeInput<$Schema, "Asset">; +export type AssetOmit = $OmitInput<$Schema, "Asset">; +export type AssetGetPayload> = $SimplifiedModelResult<$Schema, "Asset", Args>; +export type VideoFindManyArgs = $FindManyArgs<$Schema, "Video">; +export type VideoFindUniqueArgs = $FindUniqueArgs<$Schema, "Video">; +export type VideoFindFirstArgs = $FindFirstArgs<$Schema, "Video">; +export type VideoCreateArgs = $CreateArgs<$Schema, "Video">; +export type VideoCreateManyArgs = $CreateManyArgs<$Schema, "Video">; +export type VideoCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "Video">; +export type VideoUpdateArgs = $UpdateArgs<$Schema, "Video">; +export type VideoUpdateManyArgs = $UpdateManyArgs<$Schema, "Video">; +export type VideoUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs<$Schema, "Video">; +export type VideoUpsertArgs = $UpsertArgs<$Schema, "Video">; +export type VideoDeleteArgs = $DeleteArgs<$Schema, "Video">; +export type VideoDeleteManyArgs = $DeleteManyArgs<$Schema, "Video">; +export type VideoCountArgs = $CountArgs<$Schema, "Video">; +export type VideoAggregateArgs = $AggregateArgs<$Schema, "Video">; +export type VideoGroupByArgs = $GroupByArgs<$Schema, "Video">; +export type VideoWhereInput = $WhereInput<$Schema, "Video">; +export type VideoSelect = $SelectInput<$Schema, "Video">; +export type VideoInclude = $IncludeInput<$Schema, "Video">; +export type VideoOmit = $OmitInput<$Schema, "Video">; +export type VideoGetPayload> = $SimplifiedModelResult<$Schema, "Video", Args>; +export type RatedVideoFindManyArgs = $FindManyArgs<$Schema, "RatedVideo">; +export type RatedVideoFindUniqueArgs = $FindUniqueArgs<$Schema, "RatedVideo">; +export type RatedVideoFindFirstArgs = $FindFirstArgs<$Schema, "RatedVideo">; +export type RatedVideoCreateArgs = $CreateArgs<$Schema, "RatedVideo">; +export type RatedVideoCreateManyArgs = $CreateManyArgs<$Schema, "RatedVideo">; +export type RatedVideoCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "RatedVideo">; +export type RatedVideoUpdateArgs = $UpdateArgs<$Schema, "RatedVideo">; +export type RatedVideoUpdateManyArgs = $UpdateManyArgs<$Schema, "RatedVideo">; +export type RatedVideoUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs<$Schema, "RatedVideo">; +export type RatedVideoUpsertArgs = $UpsertArgs<$Schema, "RatedVideo">; +export type RatedVideoDeleteArgs = $DeleteArgs<$Schema, "RatedVideo">; +export type RatedVideoDeleteManyArgs = $DeleteManyArgs<$Schema, "RatedVideo">; +export type RatedVideoCountArgs = $CountArgs<$Schema, "RatedVideo">; +export type RatedVideoAggregateArgs = $AggregateArgs<$Schema, "RatedVideo">; +export type RatedVideoGroupByArgs = $GroupByArgs<$Schema, "RatedVideo">; +export type RatedVideoWhereInput = $WhereInput<$Schema, "RatedVideo">; +export type RatedVideoSelect = $SelectInput<$Schema, "RatedVideo">; +export type RatedVideoInclude = $IncludeInput<$Schema, "RatedVideo">; +export type RatedVideoOmit = $OmitInput<$Schema, "RatedVideo">; +export type RatedVideoGetPayload> = $SimplifiedModelResult<$Schema, "RatedVideo", Args>; +export type ImageFindManyArgs = $FindManyArgs<$Schema, "Image">; +export type ImageFindUniqueArgs = $FindUniqueArgs<$Schema, "Image">; +export type ImageFindFirstArgs = $FindFirstArgs<$Schema, "Image">; +export type ImageCreateArgs = $CreateArgs<$Schema, "Image">; +export type ImageCreateManyArgs = $CreateManyArgs<$Schema, "Image">; +export type ImageCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "Image">; +export type ImageUpdateArgs = $UpdateArgs<$Schema, "Image">; +export type ImageUpdateManyArgs = $UpdateManyArgs<$Schema, "Image">; +export type ImageUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs<$Schema, "Image">; +export type ImageUpsertArgs = $UpsertArgs<$Schema, "Image">; +export type ImageDeleteArgs = $DeleteArgs<$Schema, "Image">; +export type ImageDeleteManyArgs = $DeleteManyArgs<$Schema, "Image">; +export type ImageCountArgs = $CountArgs<$Schema, "Image">; +export type ImageAggregateArgs = $AggregateArgs<$Schema, "Image">; +export type ImageGroupByArgs = $GroupByArgs<$Schema, "Image">; +export type ImageWhereInput = $WhereInput<$Schema, "Image">; +export type ImageSelect = $SelectInput<$Schema, "Image">; +export type ImageInclude = $IncludeInput<$Schema, "Image">; +export type ImageOmit = $OmitInput<$Schema, "Image">; +export type ImageGetPayload> = $SimplifiedModelResult<$Schema, "Image", Args>; +export type GalleryFindManyArgs = $FindManyArgs<$Schema, "Gallery">; +export type GalleryFindUniqueArgs = $FindUniqueArgs<$Schema, "Gallery">; +export type GalleryFindFirstArgs = $FindFirstArgs<$Schema, "Gallery">; +export type GalleryCreateArgs = $CreateArgs<$Schema, "Gallery">; +export type GalleryCreateManyArgs = $CreateManyArgs<$Schema, "Gallery">; +export type GalleryCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "Gallery">; +export type GalleryUpdateArgs = $UpdateArgs<$Schema, "Gallery">; +export type GalleryUpdateManyArgs = $UpdateManyArgs<$Schema, "Gallery">; +export type GalleryUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs<$Schema, "Gallery">; +export type GalleryUpsertArgs = $UpsertArgs<$Schema, "Gallery">; +export type GalleryDeleteArgs = $DeleteArgs<$Schema, "Gallery">; +export type GalleryDeleteManyArgs = $DeleteManyArgs<$Schema, "Gallery">; +export type GalleryCountArgs = $CountArgs<$Schema, "Gallery">; +export type GalleryAggregateArgs = $AggregateArgs<$Schema, "Gallery">; +export type GalleryGroupByArgs = $GroupByArgs<$Schema, "Gallery">; +export type GalleryWhereInput = $WhereInput<$Schema, "Gallery">; +export type GallerySelect = $SelectInput<$Schema, "Gallery">; +export type GalleryInclude = $IncludeInput<$Schema, "Gallery">; +export type GalleryOmit = $OmitInput<$Schema, "Gallery">; +export type GalleryGetPayload> = $SimplifiedModelResult<$Schema, "Gallery", Args>; diff --git a/packages/runtime/test/schemas/delegate/models.ts b/packages/runtime/test/schemas/delegate/models.ts new file mode 100644 index 00000000..044f5d60 --- /dev/null +++ b/packages/runtime/test/schemas/delegate/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 SchemaType as $Schema } from "./schema"; +import { type ModelResult as $ModelResult } from "@zenstackhq/runtime"; +export type User = $ModelResult<$Schema, "User">; +export type Comment = $ModelResult<$Schema, "Comment">; +export type Asset = $ModelResult<$Schema, "Asset">; +export type Video = $ModelResult<$Schema, "Video">; +export type RatedVideo = $ModelResult<$Schema, "RatedVideo">; +export type Image = $ModelResult<$Schema, "Image">; +export type Gallery = $ModelResult<$Schema, "Gallery">; diff --git a/packages/runtime/test/schemas/delegate/schema.ts b/packages/runtime/test/schemas/delegate/schema.ts new file mode 100644 index 00000000..a15d4f46 --- /dev/null +++ b/packages/runtime/test/schemas/delegate/schema.ts @@ -0,0 +1,465 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + +/* eslint-disable */ + +import { type SchemaDef, ExpressionUtils } from "../../../dist/schema"; +export const schema = { + provider: { + type: "sqlite" + }, + models: { + User: { + name: "User", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }], + default: ExpressionUtils.call("autoincrement") + }, + email: { + name: "email", + type: "String", + unique: true, + optional: true, + attributes: [{ name: "@unique" }] + }, + level: { + name: "level", + type: "Int", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal(0) }] }], + default: 0 + }, + assets: { + name: "assets", + type: "Asset", + array: true, + relation: { opposite: "owner" } + }, + ratedVideos: { + name: "ratedVideos", + type: "RatedVideo", + array: true, + attributes: [{ name: "@relation", args: [{ name: "name", value: ExpressionUtils.literal("direct") }] }], + relation: { opposite: "user", name: "direct" } + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" }, + email: { type: "String" } + } + }, + Comment: { + name: "Comment", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }], + default: ExpressionUtils.call("autoincrement") + }, + content: { + name: "content", + type: "String" + }, + asset: { + name: "asset", + type: "Asset", + optional: true, + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array([ExpressionUtils.field("assetId")]) }, { name: "references", value: ExpressionUtils.array([ExpressionUtils.field("id")]) }, { name: "onDelete", value: ExpressionUtils.literal("Cascade") }] }], + relation: { opposite: "comments", fields: ["assetId"], references: ["id"], onDelete: "Cascade" } + }, + assetId: { + name: "assetId", + type: "Int", + optional: true, + foreignKeyFor: [ + "asset" + ] + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + }, + Asset: { + name: "Asset", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }], + default: ExpressionUtils.call("autoincrement") + }, + createdAt: { + name: "createdAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], + default: ExpressionUtils.call("now") + }, + updatedAt: { + name: "updatedAt", + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }] + }, + viewCount: { + name: "viewCount", + type: "Int", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal(0) }] }], + default: 0 + }, + owner: { + name: "owner", + type: "User", + optional: true, + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array([ExpressionUtils.field("ownerId")]) }, { name: "references", value: ExpressionUtils.array([ExpressionUtils.field("id")]) }, { name: "onDelete", value: ExpressionUtils.literal("Cascade") }] }], + relation: { opposite: "assets", fields: ["ownerId"], references: ["id"], onDelete: "Cascade" } + }, + ownerId: { + name: "ownerId", + type: "Int", + optional: true, + foreignKeyFor: [ + "owner" + ] + }, + comments: { + name: "comments", + type: "Comment", + array: true, + relation: { opposite: "asset" } + }, + assetType: { + name: "assetType", + type: "String", + isDiscriminator: true + } + }, + attributes: [ + { name: "@@delegate", args: [{ name: "discriminator", value: ExpressionUtils.field("assetType") }] } + ], + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + }, + isDelegate: true, + subModels: ["Video", "Image"] + }, + Video: { + name: "Video", + baseModel: "Asset", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }], + default: ExpressionUtils.call("autoincrement") + }, + createdAt: { + name: "createdAt", + type: "DateTime", + originModel: "Asset", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], + default: ExpressionUtils.call("now") + }, + updatedAt: { + name: "updatedAt", + type: "DateTime", + updatedAt: true, + originModel: "Asset", + attributes: [{ name: "@updatedAt" }] + }, + viewCount: { + name: "viewCount", + type: "Int", + originModel: "Asset", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal(0) }] }], + default: 0 + }, + owner: { + name: "owner", + type: "User", + optional: true, + originModel: "Asset", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array([ExpressionUtils.field("ownerId")]) }, { name: "references", value: ExpressionUtils.array([ExpressionUtils.field("id")]) }, { name: "onDelete", value: ExpressionUtils.literal("Cascade") }] }], + relation: { opposite: "assets", fields: ["ownerId"], references: ["id"], onDelete: "Cascade" } + }, + ownerId: { + name: "ownerId", + type: "Int", + optional: true, + originModel: "Asset", + foreignKeyFor: [ + "owner" + ] + }, + comments: { + name: "comments", + type: "Comment", + array: true, + originModel: "Asset", + relation: { opposite: "asset" } + }, + assetType: { + name: "assetType", + type: "String", + originModel: "Asset", + isDiscriminator: true + }, + duration: { + name: "duration", + type: "Int" + }, + url: { + name: "url", + type: "String", + unique: true, + attributes: [{ name: "@unique" }] + }, + videoType: { + name: "videoType", + type: "String", + isDiscriminator: true + } + }, + attributes: [ + { name: "@@delegate", args: [{ name: "discriminator", value: ExpressionUtils.field("videoType") }] } + ], + idFields: ["id"], + uniqueFields: { + id: { type: "Int" }, + url: { type: "String" } + }, + isDelegate: true, + subModels: ["RatedVideo"] + }, + RatedVideo: { + name: "RatedVideo", + baseModel: "Video", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }], + default: ExpressionUtils.call("autoincrement") + }, + createdAt: { + name: "createdAt", + type: "DateTime", + originModel: "Asset", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], + default: ExpressionUtils.call("now") + }, + updatedAt: { + name: "updatedAt", + type: "DateTime", + updatedAt: true, + originModel: "Asset", + attributes: [{ name: "@updatedAt" }] + }, + viewCount: { + name: "viewCount", + type: "Int", + originModel: "Asset", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal(0) }] }], + default: 0 + }, + owner: { + name: "owner", + type: "User", + optional: true, + originModel: "Asset", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array([ExpressionUtils.field("ownerId")]) }, { name: "references", value: ExpressionUtils.array([ExpressionUtils.field("id")]) }, { name: "onDelete", value: ExpressionUtils.literal("Cascade") }] }], + relation: { opposite: "assets", fields: ["ownerId"], references: ["id"], onDelete: "Cascade" } + }, + ownerId: { + name: "ownerId", + type: "Int", + optional: true, + originModel: "Asset", + foreignKeyFor: [ + "owner" + ] + }, + comments: { + name: "comments", + type: "Comment", + array: true, + originModel: "Asset", + relation: { opposite: "asset" } + }, + assetType: { + name: "assetType", + type: "String", + originModel: "Asset", + isDiscriminator: true + }, + duration: { + name: "duration", + type: "Int", + originModel: "Video" + }, + url: { + name: "url", + type: "String", + unique: true, + originModel: "Video", + attributes: [{ name: "@unique" }] + }, + videoType: { + name: "videoType", + type: "String", + originModel: "Video", + isDiscriminator: true + }, + rating: { + name: "rating", + type: "Int" + }, + user: { + name: "user", + type: "User", + optional: true, + attributes: [{ name: "@relation", args: [{ name: "name", value: ExpressionUtils.literal("direct") }, { name: "fields", value: ExpressionUtils.array([ExpressionUtils.field("userId")]) }, { name: "references", value: ExpressionUtils.array([ExpressionUtils.field("id")]) }, { name: "onDelete", value: ExpressionUtils.literal("Cascade") }] }], + relation: { opposite: "ratedVideos", name: "direct", fields: ["userId"], references: ["id"], onDelete: "Cascade" } + }, + userId: { + name: "userId", + type: "Int", + optional: true, + foreignKeyFor: [ + "user" + ] + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" }, + url: { type: "String" } + } + }, + Image: { + name: "Image", + baseModel: "Asset", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }], + default: ExpressionUtils.call("autoincrement") + }, + createdAt: { + name: "createdAt", + type: "DateTime", + originModel: "Asset", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], + default: ExpressionUtils.call("now") + }, + updatedAt: { + name: "updatedAt", + type: "DateTime", + updatedAt: true, + originModel: "Asset", + attributes: [{ name: "@updatedAt" }] + }, + viewCount: { + name: "viewCount", + type: "Int", + originModel: "Asset", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal(0) }] }], + default: 0 + }, + owner: { + name: "owner", + type: "User", + optional: true, + originModel: "Asset", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array([ExpressionUtils.field("ownerId")]) }, { name: "references", value: ExpressionUtils.array([ExpressionUtils.field("id")]) }, { name: "onDelete", value: ExpressionUtils.literal("Cascade") }] }], + relation: { opposite: "assets", fields: ["ownerId"], references: ["id"], onDelete: "Cascade" } + }, + ownerId: { + name: "ownerId", + type: "Int", + optional: true, + originModel: "Asset", + foreignKeyFor: [ + "owner" + ] + }, + comments: { + name: "comments", + type: "Comment", + array: true, + originModel: "Asset", + relation: { opposite: "asset" } + }, + assetType: { + name: "assetType", + type: "String", + originModel: "Asset", + isDiscriminator: true + }, + format: { + name: "format", + type: "String" + }, + gallery: { + name: "gallery", + type: "Gallery", + optional: true, + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array([ExpressionUtils.field("galleryId")]) }, { name: "references", value: ExpressionUtils.array([ExpressionUtils.field("id")]) }, { name: "onDelete", value: ExpressionUtils.literal("Cascade") }] }], + relation: { opposite: "images", fields: ["galleryId"], references: ["id"], onDelete: "Cascade" } + }, + galleryId: { + name: "galleryId", + type: "Int", + optional: true, + foreignKeyFor: [ + "gallery" + ] + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + }, + Gallery: { + name: "Gallery", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }], + default: ExpressionUtils.call("autoincrement") + }, + images: { + name: "images", + type: "Image", + array: true, + relation: { opposite: "gallery" } + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + } + }, + authType: "User", + plugins: {} +} as const satisfies SchemaDef; +export type SchemaType = typeof schema; diff --git a/packages/runtime/test/schemas/delegate/schema.zmodel b/packages/runtime/test/schemas/delegate/schema.zmodel new file mode 100644 index 00000000..9ecc5a83 --- /dev/null +++ b/packages/runtime/test/schemas/delegate/schema.zmodel @@ -0,0 +1,57 @@ +datasource db { + provider = "sqlite" + url = "file:./dev.db" +} + +model User { + id Int @id @default(autoincrement()) + email String? @unique + level Int @default(0) + assets Asset[] + ratedVideos RatedVideo[] @relation('direct') +} + +model Comment { + id Int @id @default(autoincrement()) + content String + asset Asset? @relation(fields: [assetId], references: [id], onDelete: Cascade) + assetId Int? +} + +model Asset { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + viewCount Int @default(0) + owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade) + ownerId Int? + comments Comment[] + assetType String + + @@delegate(assetType) +} + +model Video extends Asset { + duration Int + url String @unique + videoType String + + @@delegate(videoType) +} + +model RatedVideo extends Video { + rating Int + user User? @relation(name: 'direct', fields: [userId], references: [id], onDelete: Cascade) + userId Int? +} + +model Image extends Asset { + format String + gallery Gallery? @relation(fields: [galleryId], references: [id], onDelete: Cascade) + galleryId Int? +} + +model Gallery { + id Int @id @default(autoincrement()) + images Image[] +} diff --git a/packages/runtime/test/schemas/delegate/typecheck.ts b/packages/runtime/test/schemas/delegate/typecheck.ts new file mode 100644 index 00000000..847289f2 --- /dev/null +++ b/packages/runtime/test/schemas/delegate/typecheck.ts @@ -0,0 +1,183 @@ +import SQLite from 'better-sqlite3'; +import { ZenStackClient } from '../../../dist'; +import { schema } from './schema'; + +const client = new ZenStackClient(schema, { + dialectConfig: { + database: new SQLite('./zenstack/test.db'), + }, +}); + +async function find() { + // delegate find should result in a discriminated union type + const r = await client.asset.findFirstOrThrow(); + console.log(r.assetType); + console.log(r.viewCount); + // @ts-expect-error + console.log(r.duration); + // @ts-expect-error + console.log(r.rating); + if (r.assetType === 'Video') { + // video + console.log(r.duration); + // only one choice `RatedVideo` + console.log(r.rating); + } else { + // image + console.log(r.format); + } + + // if fields are explicitly selected, then no sub-model fields are available + const r1 = await client.asset.findFirstOrThrow({ + select: { + id: true, + viewCount: true, + assetType: true, + }, + }); + // @ts-expect-error + console.log(r1.duration); + if (r1.assetType === 'Video') { + // @ts-expect-error + console.log(r1.duration); + } + + // same behavior when queried as a relation + const r2 = await client.user.findFirstOrThrow({ include: { assets: true } }); + console.log(r2.assets[0]?.assetType); + console.log(r2.assets[0]?.viewCount); + // @ts-expect-error + console.log(r2.assets[0]?.duration); + // @ts-expect-error + console.log(r2.assets[0]?.rating); + if (r2.assets[0]?.assetType === 'Video') { + // video + console.log(r2.assets[0]?.duration); + // only one choice `RatedVideo` + console.log(r2.assets[0]?.rating); + } else { + // image + console.log(r2.assets[0]?.format); + } + + // sub model behavior + const r3 = await client.ratedVideo.findFirstOrThrow(); + console.log(r3.assetType); + console.log(r3.viewCount); + console.log(r3.videoType); + console.log(r3.duration); + console.log(r3.rating); +} + +async function create() { + // delegate creation is not allowed + // @ts-expect-error + client.asset.create({ data: { assetType: 'Video' } }); + // @ts-expect-error + client.asset.createMany({ data: [{ assetType: 'Video' }] }); + // @ts-expect-error + client.asset.upsert({ where: { id: 1 }, create: { assetType: 'Video' }, update: { assetType: 'Video' } }); + + // nested creation is not allowed either + // @ts-expect-error + client.user.create({ data: { assets: { create: { assetType: 'Video' } } } }); + // @ts-expect-error + client.user.create({ data: { assets: { connectOrCreate: { where: { id: 1 }, create: { assetType: 'Video' } } } } }); + // @ts-expect-error + client.user.update({ where: { id: 1 }, data: { assets: { create: { assetType: 'Video' } } } }); + client.user.update({ + where: { id: 1 }, + // @ts-expect-error + data: { assets: { connectOrCreate: { where: { id: 1 }, create: { assetType: 'Video' } } } }, + }); + client.user.update({ + where: { id: 1 }, + data: { + // @ts-expect-error + assets: { upsert: { where: { id: 1 }, create: { assetType: 'Video' }, update: { assetType: 'Video' } } }, + }, + }); + + // discriminator fields cannot be assigned in create + await client.ratedVideo.create({ + data: { + url: 'abc', + rating: 5, + duration: 100, + // @ts-expect-error + assetType: 'Video', + }, + }); +} + +async function update() { + // delegate models can be updated normally + await client.ratedVideo.update({ + where: { id: 1 }, + data: { url: 'new-url', rating: 4, duration: 200 }, + }); + + await client.video.update({ + where: { id: 1 }, + data: { duration: 300, url: 'another-url' }, + }); + + // discriminator fields cannot be set in updates + await client.ratedVideo.update({ + where: { id: 1 }, + data: { + url: 'valid-update', + // @ts-expect-error + assetType: 'Video', + }, + }); + + await client.image.update({ + where: { id: 1 }, + data: { + format: 'jpg', + // @ts-expect-error + assetType: 'Image', + }, + }); + + // updateMany also cannot set discriminator fields + await client.ratedVideo.updateMany({ + where: { rating: { gt: 3 } }, + data: { + // @ts-expect-error + assetType: 'Video', + }, + }); + + // upsert cannot set discriminator fields in update clause + await client.ratedVideo.upsert({ + where: { id: 1 }, + create: { url: 'create-url', rating: 5, duration: 100 }, + update: { + rating: 4, + // @ts-expect-error + assetType: 'Video', + }, + }); +} + +async function queryBuilder() { + // query builder API should see the raw table fields + + client.$qb.selectFrom('Asset').select(['id', 'viewCount']).execute(); + // @ts-expect-error + client.$qb.selectFrom('Asset').select(['duration']).execute(); + client.$qb.selectFrom('Video').select(['id', 'duration']).execute(); + // @ts-expect-error + client.$qb.selectFrom('Video').select(['viewCount']).execute(); +} + +async function main() { + await create(); + await update(); + await find(); + await queryBuilder(); +} + +main(); diff --git a/packages/runtime/test/schemas/typing/verify-typing.ts b/packages/runtime/test/schemas/typing/typecheck.ts similarity index 100% rename from packages/runtime/test/schemas/typing/verify-typing.ts rename to packages/runtime/test/schemas/typing/typecheck.ts diff --git a/packages/sdk/src/schema/schema.ts b/packages/sdk/src/schema/schema.ts index d7a38f9e..c6ea4d9b 100644 --- a/packages/sdk/src/schema/schema.ts +++ b/packages/sdk/src/schema/schema.ts @@ -32,6 +32,7 @@ export type ModelDef = { idFields: string[]; computedFields?: Record; isDelegate?: boolean; + subModels?: string[]; }; export type AttributeApplication = { @@ -69,6 +70,7 @@ export type FieldDef = { foreignKeyFor?: string[]; computed?: boolean; originModel?: string; + isDiscriminator?: boolean; }; export type ProcedureParam = { name: string; type: string; optional?: boolean }; @@ -105,6 +107,17 @@ export type TypeDefDef = { export type GetModels = Extract; +export type GetDelegateModels = keyof { + [Key in GetModels as Schema['models'][Key]['isDelegate'] extends true ? Key : never]: true; +}; + +export type GetSubModels> = GetModel< + Schema, + Model +>['subModels'] extends string[] + ? Extract['subModels'][number], GetModels> + : never; + export type GetModel> = Schema['models'][Model]; export type GetEnums = keyof Schema['enums']; @@ -127,6 +140,14 @@ export type GetModelField< Field extends GetModelFields, > = GetModel['fields'][Field]; +export type GetModelDiscriminator> = keyof { + [Key in GetModelFields as FieldIsDelegateDiscriminator extends true + ? GetModelField['originModel'] extends string + ? never + : Key + : never]: true; +}; + export type GetModelFieldType< Schema extends SchemaDef, Model extends GetModels, @@ -239,4 +260,24 @@ export type FieldIsRelationArray< Field extends GetModelFields, > = FieldIsRelation extends true ? FieldIsArray : false; +export type IsDelegateModel< + Schema extends SchemaDef, + Model extends GetModels, +> = Schema['models'][Model]['isDelegate'] extends true ? true : false; + +export type FieldIsDelegateRelation< + Schema extends SchemaDef, + Model extends GetModels, + Field extends RelationFields, +> = + GetModelFieldType extends GetModels + ? IsDelegateModel> + : false; + +export type FieldIsDelegateDiscriminator< + Schema extends SchemaDef, + Model extends GetModels, + Field extends GetModelFields, +> = GetModelField['isDiscriminator'] extends true ? true : false; + //#endregion diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index ba4f31a5..1f55db3e 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -36,7 +36,7 @@ import { UnaryExpr, type Model, } from '@zenstackhq/language/ast'; -import { getAllAttributes, getAllFields } from '@zenstackhq/language/utils'; +import { getAllAttributes, getAllFields, isDataFieldReference } from '@zenstackhq/language/utils'; import fs from 'node:fs'; import path from 'node:path'; import { match } from 'ts-pattern'; @@ -228,6 +228,7 @@ export class TsSchemaGenerator { } return true; }); + const subModels = this.getSubModels(dm); const fields: ts.PropertyAssignment[] = [ // name @@ -282,6 +283,18 @@ export class TsSchemaGenerator { ...(isDelegateModel(dm) ? [ts.factory.createPropertyAssignment('isDelegate', ts.factory.createTrue())] : []), + + // subModels + ...(subModels.length > 0 + ? [ + ts.factory.createPropertyAssignment( + 'subModels', + ts.factory.createArrayLiteralExpression( + subModels.map((subModel) => ts.factory.createStringLiteral(subModel)), + ), + ), + ] + : []), ]; const computedFields = dm.fields.filter((f) => hasAttribute(f, '@computed')); @@ -295,6 +308,13 @@ export class TsSchemaGenerator { return ts.factory.createObjectLiteralExpression(fields, true); } + private getSubModels(dm: DataModel) { + return dm.$container.declarations + .filter(isDataModel) + .filter((d) => d.baseModel?.ref === dm) + .map((d) => d.name); + } + private createTypeDefObject(td: TypeDef): ts.Expression { const allFields = getAllFields(td); const allAttributes = getAllAttributes(td); @@ -386,22 +406,6 @@ export class TsSchemaGenerator { ts.factory.createPropertyAssignment('type', this.generateFieldTypeLiteral(field)), ]; - if ( - contextModel && - // id fields are duplicated in inherited models - !isIdField(field, contextModel) && - field.$container !== contextModel && - isDelegateModel(field.$container) - ) { - // field is inherited from delegate - objectFields.push( - ts.factory.createPropertyAssignment( - 'originModel', - ts.factory.createStringLiteral(field.$container.name), - ), - ); - } - if (contextModel && ModelUtils.isIdField(field, contextModel)) { objectFields.push(ts.factory.createPropertyAssignment('id', ts.factory.createTrue())); } @@ -422,6 +426,28 @@ export class TsSchemaGenerator { objectFields.push(ts.factory.createPropertyAssignment('updatedAt', ts.factory.createTrue())); } + // originModel + if ( + contextModel && + // id fields are duplicated in inherited models + !isIdField(field, contextModel) && + field.$container !== contextModel && + isDelegateModel(field.$container) + ) { + // field is inherited from delegate + objectFields.push( + ts.factory.createPropertyAssignment( + 'originModel', + ts.factory.createStringLiteral(field.$container.name), + ), + ); + } + + // discriminator + if (this.isDiscriminatorField(field)) { + objectFields.push(ts.factory.createPropertyAssignment('isDiscriminator', ts.factory.createTrue())); + } + // attributes if (field.attributes.length > 0) { objectFields.push( @@ -523,6 +549,16 @@ export class TsSchemaGenerator { return ts.factory.createObjectLiteralExpression(objectFields, true); } + private isDiscriminatorField(field: DataField) { + const origin = field.$container; + return getAttribute(origin, '@@delegate')?.args.some( + (arg) => + arg.$resolvedParam.name === 'discriminator' && + isDataFieldReference(arg.value) && + arg.value.target.ref === field, + ); + } + private getDataSourceProvider( model: Model, ): { type: string; env: undefined; url: string } | { type: string; env: string; url: undefined } { diff --git a/turbo.json b/turbo.json index 203466fc..ab312f31 100644 --- a/turbo.json +++ b/turbo.json @@ -6,6 +6,11 @@ "inputs": ["src/**", "zenstack/*.zmodel"], "outputs": ["dist/**"] }, + "watch": { + "dependsOn": ["^build"], + "inputs": ["src/**", "zenstack/*.zmodel"], + "outputs": [] + }, "lint": { "dependsOn": ["^lint"] }, From 33ac58e372e758e0361fe95271be579c9586df2c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 28 Jul 2025 23:21:07 +0800 Subject: [PATCH 18/19] chore: bump version 3.0.0-alpha.13 (#118) Co-authored-by: ymc9 <104139426+ymc9@users.noreply.github.com> --- package.json | 2 +- packages/cli/package.json | 2 +- packages/common-helpers/package.json | 2 +- packages/create-zenstack/package.json | 2 +- packages/eslint-config/package.json | 2 +- packages/ide/vscode/package.json | 2 +- packages/language/package.json | 2 +- packages/runtime/package.json | 2 +- packages/sdk/package.json | 2 +- packages/tanstack-query/package.json | 2 +- packages/testtools/package.json | 2 +- packages/typescript-config/package.json | 2 +- packages/zod/package.json | 2 +- samples/blog/package.json | 2 +- tests/e2e/package.json | 2 +- 15 files changed, 15 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index 45f00b07..8585db98 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-v3", - "version": "3.0.0-alpha.12", + "version": "3.0.0-alpha.13", "description": "ZenStack", "packageManager": "pnpm@10.12.1", "scripts": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 3eca5dff..e9338d68 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -3,7 +3,7 @@ "publisher": "zenstack", "displayName": "ZenStack CLI", "description": "FullStack database toolkit with built-in access control and automatic API generation.", - "version": "3.0.0-alpha.12", + "version": "3.0.0-alpha.13", "type": "module", "author": { "name": "ZenStack Team" diff --git a/packages/common-helpers/package.json b/packages/common-helpers/package.json index 48a19c0d..b893635a 100644 --- a/packages/common-helpers/package.json +++ b/packages/common-helpers/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/common-helpers", - "version": "3.0.0-alpha.12", + "version": "3.0.0-alpha.13", "description": "ZenStack Common Helpers", "type": "module", "scripts": { diff --git a/packages/create-zenstack/package.json b/packages/create-zenstack/package.json index 6fe18eeb..694538d9 100644 --- a/packages/create-zenstack/package.json +++ b/packages/create-zenstack/package.json @@ -1,6 +1,6 @@ { "name": "create-zenstack", - "version": "3.0.0-alpha.12", + "version": "3.0.0-alpha.13", "description": "Create a new ZenStack project", "type": "module", "scripts": { diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index fc9465ad..7e215d89 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/eslint-config", - "version": "3.0.0-alpha.12", + "version": "3.0.0-alpha.13", "type": "module", "private": true, "license": "MIT" diff --git a/packages/ide/vscode/package.json b/packages/ide/vscode/package.json index b28ee350..1b71ee55 100644 --- a/packages/ide/vscode/package.json +++ b/packages/ide/vscode/package.json @@ -1,7 +1,7 @@ { "name": "zenstack", "publisher": "zenstack", - "version": "3.0.0-alpha.12", + "version": "3.0.0-alpha.13", "displayName": "ZenStack Language Tools", "description": "VSCode extension for ZenStack ZModel language", "private": true, diff --git a/packages/language/package.json b/packages/language/package.json index 84248adb..17bee086 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/language", "description": "ZenStack ZModel language specification", - "version": "3.0.0-alpha.12", + "version": "3.0.0-alpha.13", "license": "MIT", "author": "ZenStack Team", "files": [ diff --git a/packages/runtime/package.json b/packages/runtime/package.json index eaaafa35..92196c49 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/runtime", - "version": "3.0.0-alpha.12", + "version": "3.0.0-alpha.13", "description": "ZenStack Runtime", "type": "module", "scripts": { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 6f00f213..17179b00 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "3.0.0-alpha.12", + "version": "3.0.0-alpha.13", "description": "ZenStack SDK", "type": "module", "scripts": { diff --git a/packages/tanstack-query/package.json b/packages/tanstack-query/package.json index 2e9dd4ad..de16e278 100644 --- a/packages/tanstack-query/package.json +++ b/packages/tanstack-query/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/tanstack-query", - "version": "3.0.0-alpha.12", + "version": "3.0.0-alpha.13", "description": "", "main": "index.js", "type": "module", diff --git a/packages/testtools/package.json b/packages/testtools/package.json index 93c7fd54..7eb62eb4 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "3.0.0-alpha.12", + "version": "3.0.0-alpha.13", "description": "ZenStack Test Tools", "type": "module", "scripts": { diff --git a/packages/typescript-config/package.json b/packages/typescript-config/package.json index 836d1b55..cc30a871 100644 --- a/packages/typescript-config/package.json +++ b/packages/typescript-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/typescript-config", - "version": "3.0.0-alpha.12", + "version": "3.0.0-alpha.13", "private": true, "license": "MIT" } diff --git a/packages/zod/package.json b/packages/zod/package.json index a370f728..bff673df 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/zod", - "version": "3.0.0-alpha.12", + "version": "3.0.0-alpha.13", "description": "", "type": "module", "main": "index.js", diff --git a/samples/blog/package.json b/samples/blog/package.json index d1486aac..3fe5e902 100644 --- a/samples/blog/package.json +++ b/samples/blog/package.json @@ -1,6 +1,6 @@ { "name": "sample-blog", - "version": "3.0.0-alpha.12", + "version": "3.0.0-alpha.13", "description": "", "main": "index.js", "scripts": { diff --git a/tests/e2e/package.json b/tests/e2e/package.json index 9cd3da90..2e0f89ed 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -1,6 +1,6 @@ { "name": "e2e", - "version": "3.0.0-alpha.12", + "version": "3.0.0-alpha.13", "private": true, "scripts": { "test": "vitest run" From a0c79b09431baaf2962674e3bac13f2a17e77767 Mon Sep 17 00:00:00 2001 From: Yiming Cao Date: Mon, 28 Jul 2025 23:54:49 +0800 Subject: [PATCH 19/19] chore: addressing PR comments (#120) --- .github/workflows/claude-code-review.yml | 137 +++++++++--------- .github/workflows/claude.yml | 111 +++++++------- TODO.md | 9 +- packages/runtime/src/client/query-utils.ts | 7 +- .../runtime/src/client/result-processor.ts | 2 +- 5 files changed, 131 insertions(+), 135 deletions(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 5bf8ce59..3334ebc4 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -1,78 +1,77 @@ name: Claude Code Review on: - pull_request: - types: [opened, synchronize] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" + pull_request: + types: [opened, synchronize] + # Optional: Only run on specific file changes + # paths: + # - "src/**/*.ts" + # - "src/**/*.tsx" + # - "src/**/*.js" + # - "src/**/*.jsx" jobs: - claude-review: - # Optional: Filter by PR author - # if: | - # github.event.pull_request.user.login == 'external-contributor' || - # github.event.pull_request.user.login == 'new-developer' || - # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' - - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 + claude-review: + # Optional: Filter by PR author + # if: | + # github.event.pull_request.user.login == 'external-contributor' || + # github.event.pull_request.user.login == 'new-developer' || + # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' - - name: Run Claude Code Review - id: claude-review - uses: anthropics/claude-code-action@beta - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + issues: read + id-token: write - # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) - # model: "claude-opus-4-20250514" - - # Direct prompt for automated review (no @claude mention needed) - direct_prompt: | - Please review this pull request and provide feedback on: - - Code quality and best practices - - Potential bugs or issues - - Performance considerations - - Security concerns - - Test coverage - - Be constructive and helpful in your feedback. + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 - # Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR - # use_sticky_comment: true - - # Optional: Customize review based on file types - # direct_prompt: | - # Review this PR focusing on: - # - For TypeScript files: Type safety and proper interface usage - # - For API endpoints: Security, input validation, and error handling - # - For React components: Performance, accessibility, and best practices - # - For tests: Coverage, edge cases, and test quality - - # Optional: Different prompts for different authors - # direct_prompt: | - # ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' && - # 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' || - # 'Please provide a thorough code review focusing on our coding standards and best practices.' }} - - # Optional: Add specific tools for running tests or linting - # allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)" - - # Optional: Skip review for certain conditions - # if: | - # !contains(github.event.pull_request.title, '[skip-review]') && - # !contains(github.event.pull_request.title, '[WIP]') + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@beta + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) + # model: "claude-opus-4-20250514" + + # Direct prompt for automated review (no @claude mention needed) + direct_prompt: | + Please review this pull request and provide feedback on: + - Code quality and best practices + - Potential bugs or issues + - Performance considerations + - Security concerns + - Test coverage + + Be constructive and helpful in your feedback. + + # Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR + # use_sticky_comment: true + + # Optional: Customize review based on file types + # direct_prompt: | + # Review this PR focusing on: + # - For TypeScript files: Type safety and proper interface usage + # - For API endpoints: Security, input validation, and error handling + # - For React components: Performance, accessibility, and best practices + # - For tests: Coverage, edge cases, and test quality + + # Optional: Different prompts for different authors + # direct_prompt: | + # ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' && + # 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' || + # 'Please provide a thorough code review focusing on our coding standards and best practices.' }} + + # Optional: Add specific tools for running tests or linting + # allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)" + + # Optional: Skip review for certain conditions + # if: | + # !contains(github.event.pull_request.title, '[skip-review]') && + # !contains(github.event.pull_request.title, '[WIP]') diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 64a3e5b1..38466bff 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -1,64 +1,63 @@ name: Claude Code on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - issues: - types: [opened, assigned] - pull_request_review: - types: [submitted] + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] jobs: - claude: - if: | - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || - (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - actions: read # Required for Claude to read CI results on PRs - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 - - name: Run Claude Code - id: claude - uses: anthropics/claude-code-action@beta - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@beta + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - # This is an optional setting that allows Claude to read CI results on PRs - additional_permissions: | - actions: read - - # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) - # model: "claude-opus-4-20250514" - - # Optional: Customize the trigger phrase (default: @claude) - # trigger_phrase: "/claude" - - # Optional: Trigger when specific user is assigned to an issue - # assignee_trigger: "claude-bot" - - # Optional: Allow Claude to run specific commands - # allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)" - - # Optional: Add custom instructions for Claude to customize its behavior for your project - # custom_instructions: | - # Follow our coding standards - # Ensure all new code has tests - # Use TypeScript for new files - - # Optional: Custom environment variables for Claude - # claude_env: | - # NODE_ENV: test + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) + # model: "claude-opus-4-20250514" + + # Optional: Customize the trigger phrase (default: @claude) + # trigger_phrase: "/claude" + + # Optional: Trigger when specific user is assigned to an issue + # assignee_trigger: "claude-bot" + + # Optional: Allow Claude to run specific commands + # allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)" + + # Optional: Add custom instructions for Claude to customize its behavior for your project + # custom_instructions: | + # Follow our coding standards + # Ensure all new code has tests + # Use TypeScript for new files + + # Optional: Custom environment variables for Claude + # claude_env: | + # NODE_ENV: test diff --git a/TODO.md b/TODO.md index 8303aa4b..faededb7 100644 --- a/TODO.md +++ b/TODO.md @@ -49,7 +49,7 @@ - [x] Nested to-one - [x] Incremental update for numeric fields - [x] Array update - - [ ] Strict typing for checked/unchecked input + - [x] Strict typing for checked/unchecked input - [x] Upsert - [ ] Implement with "on conflict" - [x] Delete @@ -73,7 +73,7 @@ - [ ] Cross field comparison - [x] Many-to-many relation - [ ] Empty AND/OR/NOT behavior - - [?] Logging + - [x] Logging - [x] Error system - [x] Custom table name - [x] Custom field name @@ -84,7 +84,10 @@ - [ ] Post-mutation hooks should be called after transaction is committed - [x] TypeDef and mixin - [ ] Strongly typed JSON -- [ ] Polymorphism +- [x] Polymorphism + - [x] ZModel + - [x] Runtime + - [x] Typing - [ ] Validation - [ ] Access Policy - [ ] Short-circuit pre-create check for scalar-field only policies diff --git a/packages/runtime/src/client/query-utils.ts b/packages/runtime/src/client/query-utils.ts index 47b91a00..c4cd78f5 100644 --- a/packages/runtime/src/client/query-utils.ts +++ b/packages/runtime/src/client/query-utils.ts @@ -293,12 +293,7 @@ export function extractFields(object: any, fields: string[]) { export function extractIdFields(entity: any, schema: SchemaDef, model: string) { const idFields = getIdFields(schema, model); - return idFields.reduce((acc: any, field) => { - if (field in entity) { - acc[field] = entity[field]; - } - return acc; - }, {}); + return extractFields(entity, idFields); } export function getDiscriminatorField(schema: SchemaDef, model: string) { diff --git a/packages/runtime/src/client/result-processor.ts b/packages/runtime/src/client/result-processor.ts index c7aa230a..96b3de64 100644 --- a/packages/runtime/src/client/result-processor.ts +++ b/packages/runtime/src/client/result-processor.ts @@ -53,7 +53,7 @@ export class ResultProcessor { delete data[key]; continue; } - const processedSubRow = this.processRow(subRow, subRow); + const processedSubRow = this.processRow(subRow, subModel); // merge the sub-row into the main row Object.assign(data, processedSubRow);