diff --git a/.coderabbit.yaml b/.coderabbit.yaml index c50c3b9e..15eddbc7 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -4,5 +4,6 @@ early_access: false reviews: auto_review: enabled: true + sequence_diagrams: false chat: auto_reply: true diff --git a/TODO.md b/TODO.md index 9c61a5f1..7d9aa7b9 100644 --- a/TODO.md +++ b/TODO.md @@ -51,7 +51,10 @@ - [x] Count - [x] Aggregate - [x] Group by - - [ ] Raw queries + - [x] Raw queries + - [ ] Transactions + - [x] Interactive transaction + - [x] Sequential transaction - [ ] Extensions - [x] Query builder API - [x] Computed fields @@ -69,6 +72,8 @@ - [x] Custom field name - [ ] Strict undefined checks - [ ] Benchmark +- [ ] Plugin + - [ ] Post-mutation hooks should be called after transaction is committed - [ ] Polymorphism - [ ] Validation - [ ] Access Policy diff --git a/package.json b/package.json index 0ebc1fc6..605d8adb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-v3", - "version": "3.0.0-alpha.8", + "version": "3.0.0-alpha.9", "description": "ZenStack", "packageManager": "pnpm@10.12.1", "scripts": { diff --git a/packages/cli/bin/cli b/packages/cli/bin/cli index 594ed051..0277db32 100755 --- a/packages/cli/bin/cli +++ b/packages/cli/bin/cli @@ -1,3 +1,3 @@ -#!/usr/bin/env node --no-warnings +#!/usr/bin/env node import '../dist/index.js'; diff --git a/packages/cli/package.json b/packages/cli/package.json index 30c30071..710c4ed3 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.8", + "version": "3.0.0-alpha.9", "type": "module", "author": { "name": "ZenStack Team" @@ -18,6 +18,7 @@ "data modeling" ], "bin": { + "zen": "bin/cli", "zenstack": "bin/cli" }, "scripts": { diff --git a/packages/common-helpers/package.json b/packages/common-helpers/package.json index c4888538..c0511eb3 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.8", + "version": "3.0.0-alpha.9", "description": "ZenStack Common Helpers", "type": "module", "scripts": { diff --git a/packages/create-zenstack/bin/cli b/packages/create-zenstack/bin/cli index 594ed051..0277db32 100755 --- a/packages/create-zenstack/bin/cli +++ b/packages/create-zenstack/bin/cli @@ -1,3 +1,3 @@ -#!/usr/bin/env node --no-warnings +#!/usr/bin/env node import '../dist/index.js'; diff --git a/packages/create-zenstack/package.json b/packages/create-zenstack/package.json index 7ad47cb4..024c9450 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.8", + "version": "3.0.0-alpha.9", "description": "Create a new ZenStack project", "type": "module", "scripts": { diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index e61e7356..99326811 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.8", + "version": "3.0.0-alpha.9", "type": "module", "private": true, "license": "MIT" diff --git a/packages/ide/vscode/package.json b/packages/ide/vscode/package.json index c26fbf98..c354442f 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.8", + "version": "3.0.0-alpha.9", "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 b10bb8fd..6a7590de 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.8", + "version": "3.0.0-alpha.9", "license": "MIT", "author": "ZenStack Team", "files": [ diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 8ef02401..5d02759c 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/runtime", - "version": "3.0.0-alpha.8", + "version": "3.0.0-alpha.9", "description": "ZenStack Runtime", "type": "module", "scripts": { @@ -29,16 +29,6 @@ "default": "./dist/index.cjs" } }, - "./client": { - "import": { - "types": "./dist/client.d.ts", - "default": "./dist/client.js" - }, - "require": { - "types": "./dist/client.d.cts", - "default": "./dist/client.cjs" - } - }, "./schema": { "import": { "types": "./dist/schema.d.ts", diff --git a/packages/runtime/src/client/client-impl.ts b/packages/runtime/src/client/client-impl.ts index d17cd23f..08f94a2f 100644 --- a/packages/runtime/src/client/client-impl.ts +++ b/packages/runtime/src/client/client-impl.ts @@ -1,5 +1,5 @@ -import { lowerCaseFirst } from '@zenstackhq/common-helpers'; -import type { SqliteDialectConfig } from 'kysely'; +import { invariant, lowerCaseFirst } from '@zenstackhq/common-helpers'; +import type { QueryExecutor, SqliteDialectConfig } from 'kysely'; import { CompiledQuery, DefaultConnectionProvider, @@ -15,7 +15,8 @@ import { import { match } from 'ts-pattern'; import type { GetModels, ProcedureDef, SchemaDef } from '../schema'; import type { AuthType } from '../schema/auth'; -import type { ClientConstructor, ClientContract, ModelOperations } from './contract'; +import type { UnwrapTuplePromises } from '../utils/type-utils'; +import type { ClientConstructor, ClientContract, ModelOperations, TransactionIsolationLevel } from './contract'; import { AggregateOperationHandler } from './crud/operations/aggregate'; import type { CrudOperation } from './crud/operations/base'; import { BaseOperationHandler } from './crud/operations/base'; @@ -33,7 +34,7 @@ import * as BuiltinFunctions from './functions'; import { SchemaDbPusher } from './helpers/schema-db-pusher'; import type { ClientOptions, ProceduresOptions } from './options'; import type { RuntimePlugin } from './plugin'; -import { createDeferredPromise } from './promise'; +import { createZenStackPromise, type ZenStackPromise } from './promise'; import type { ToKysely } from './query-builder'; import { ResultProcessor } from './result-processor'; @@ -60,6 +61,7 @@ export class ClientImpl { private readonly schema: Schema, private options: ClientOptions, baseClient?: ClientImpl, + executor?: QueryExecutor, ) { this.$schema = schema; this.$options = options ?? ({} as ClientOptions); @@ -73,22 +75,24 @@ export class ClientImpl { if (baseClient) { this.kyselyProps = { ...baseClient.kyselyProps, - executor: new ZenStackQueryExecutor( - this, - baseClient.kyselyProps.driver as ZenStackDriver, - baseClient.kyselyProps.dialect.createQueryCompiler(), - baseClient.kyselyProps.dialect.createAdapter(), - new DefaultConnectionProvider(baseClient.kyselyProps.driver), - ), + executor: + executor ?? + new ZenStackQueryExecutor( + this, + baseClient.kyselyProps.driver as ZenStackDriver, + baseClient.kyselyProps.dialect.createQueryCompiler(), + baseClient.kyselyProps.dialect.createAdapter(), + new DefaultConnectionProvider(baseClient.kyselyProps.driver), + ), }; this.kyselyRaw = baseClient.kyselyRaw; + this.auth = baseClient.auth; } else { const dialect = this.getKyselyDialect(); const driver = new ZenStackDriver(dialect.createDriver(), new Log(this.$options.log ?? [])); const compiler = dialect.createQueryCompiler(); const adapter = dialect.createAdapter(); const connectionProvider = new DefaultConnectionProvider(driver); - const executor = new ZenStackQueryExecutor(this, driver, compiler, adapter, connectionProvider); this.kyselyProps = { config: { @@ -97,7 +101,7 @@ export class ClientImpl { }, dialect, driver, - executor, + executor: executor ?? new ZenStackQueryExecutor(this, driver, compiler, adapter, connectionProvider), }; // raw kysely instance with default executor @@ -112,14 +116,25 @@ export class ClientImpl { return createClientProxy(this); } - public get $qb() { + get $qb() { return this.kysely; } - public get $qbRaw() { + get $qbRaw() { return this.kyselyRaw; } + get isTransaction() { + return this.kysely.isTransaction; + } + + /** + * Create a new client with a new query executor. + */ + withExecutor(executor: QueryExecutor) { + return new ClientImpl(this.schema, this.$options, this, executor); + } + private getKyselyDialect() { return match(this.schema.provider.type) .with('sqlite', () => this.makeSqliteKyselyDialect()) @@ -135,12 +150,76 @@ export class ClientImpl { return new SqliteDialect(this.options.dialectConfig as SqliteDialectConfig); } - async $transaction(callback: (tx: ClientContract) => Promise): Promise { - return this.kysely.transaction().execute((tx) => { - const txClient = new ClientImpl(this.schema, this.$options); + // overload for interactive transaction + $transaction( + callback: (tx: ClientContract) => Promise, + options?: { isolationLevel?: TransactionIsolationLevel }, + ): Promise; + + // overload for sequential transaction + $transaction

[]>( + arg: [...P], + options?: { isolationLevel?: TransactionIsolationLevel }, + ): Promise>; + + // implementation + async $transaction(input: any, options?: { isolationLevel?: TransactionIsolationLevel }) { + invariant( + typeof input === 'function' || (Array.isArray(input) && input.every((p) => p.then && p.cb)), + 'Invalid transaction input, expected a function or an array of ZenStackPromise', + ); + if (typeof input === 'function') { + return this.interactiveTransaction(input, options); + } else { + return this.sequentialTransaction(input, options); + } + } + + private async interactiveTransaction( + callback: (tx: ClientContract) => Promise, + options?: { isolationLevel?: TransactionIsolationLevel }, + ): Promise { + if (this.kysely.isTransaction) { + // proceed directly if already in a transaction + return callback(this as unknown as ClientContract); + } else { + // otherwise, create a new transaction, clone the client, and execute the callback + let txBuilder = this.kysely.transaction(); + if (options?.isolationLevel) { + txBuilder = txBuilder.setIsolationLevel(options.isolationLevel); + } + return txBuilder.execute((tx) => { + const txClient = new ClientImpl(this.schema, this.$options, this); + txClient.kysely = tx; + return callback(txClient as unknown as ClientContract); + }); + } + } + + private async sequentialTransaction( + arg: ZenStackPromise[], + options?: { isolationLevel?: TransactionIsolationLevel }, + ) { + const execute = async (tx: Kysely) => { + const txClient = new ClientImpl(this.schema, this.$options, this); txClient.kysely = tx; - return callback(txClient as unknown as ClientContract); - }); + const result: any[] = []; + for (const promise of arg) { + result.push(await promise.cb(txClient as unknown as ClientContract)); + } + return result; + }; + if (this.kysely.isTransaction) { + // proceed directly if already in a transaction + return execute(this.kysely); + } else { + // otherwise, create a new transaction, clone the client, and execute the callback + let txBuilder = this.kysely.transaction(); + if (options?.isolationLevel) { + txBuilder = txBuilder.setIsolationLevel(options.isolationLevel); + } + return txBuilder.execute((tx) => execute(tx as Kysely)); + } } get $procedures() { @@ -213,14 +292,14 @@ export class ClientImpl { } $executeRaw(query: TemplateStringsArray, ...values: any[]) { - return createDeferredPromise(async () => { + return createZenStackPromise(async () => { const result = await sql(query, ...values).execute(this.kysely); return Number(result.numAffectedRows ?? 0); }); } $executeRawUnsafe(query: string, ...values: any[]) { - return createDeferredPromise(async () => { + return createZenStackPromise(async () => { const compiledQuery = this.createRawCompiledQuery(query, values); const result = await this.kysely.executeQuery(compiledQuery); return Number(result.numAffectedRows ?? 0); @@ -228,14 +307,14 @@ export class ClientImpl { } $queryRaw(query: TemplateStringsArray, ...values: any[]) { - return createDeferredPromise(async () => { + return createZenStackPromise(async () => { const result = await sql(query, ...values).execute(this.kysely); return result.rows as T; }); } $queryRawUnsafe(query: string, ...values: any[]) { - return createDeferredPromise(async () => { + return createZenStackPromise(async () => { const compiledQuery = this.createRawCompiledQuery(query, values); const result = await this.kysely.executeQuery(compiledQuery); return result.rows as T; @@ -262,7 +341,7 @@ function createClientProxy(client: ClientImpl) const model = Object.keys(client.$schema.models).find((m) => m.toLowerCase() === prop.toLowerCase()); if (model) { return createModelCrudHandler( - client as ClientContract, + client as unknown as ClientContract, model as GetModels, inputValidator, resultProcessor, @@ -288,9 +367,9 @@ function createModelCrudHandler { - return createDeferredPromise(async () => { - let proceed = async (_args?: unknown, tx?: ClientContract) => { - const _handler = tx ? handler.withClient(tx) : handler; + return createZenStackPromise(async (txClient?: ClientContract) => { + let proceed = async (_args?: unknown) => { + const _handler = txClient ? handler.withClient(txClient) : handler; const r = await _handler.handle(operation, _args ?? args); if (!r && throwIfNoResult) { throw new NotFoundError(model); diff --git a/packages/runtime/src/client/constants.ts b/packages/runtime/src/client/constants.ts index 217e3bf3..c80a247a 100644 --- a/packages/runtime/src/client/constants.ts +++ b/packages/runtime/src/client/constants.ts @@ -7,3 +7,8 @@ export const CONTEXT_COMMENT_PREFIX = '-- $$context:'; * The types of fields that are numeric. */ 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; diff --git a/packages/runtime/src/client/contract.ts b/packages/runtime/src/client/contract.ts index 1a10a421..ce58c5d0 100644 --- a/packages/runtime/src/client/contract.ts +++ b/packages/runtime/src/client/contract.ts @@ -1,7 +1,8 @@ import type { Decimal } from 'decimal.js'; import { type GetModels, type ProcedureDef, type SchemaDef } from '../schema'; import type { AuthType } from '../schema/auth'; -import type { OrUndefinedIf } from '../utils/type-utils'; +import type { OrUndefinedIf, UnwrapTuplePromises } from '../utils/type-utils'; +import type { TRANSACTION_UNSUPPORTED_METHODS } from './constants'; import type { AggregateArgs, AggregateResult, @@ -27,8 +28,22 @@ import type { } from './crud-types'; import type { ClientOptions } from './options'; import type { RuntimePlugin } from './plugin'; +import type { ZenStackPromise } from './promise'; import type { ToKysely } from './query-builder'; +type TransactionUnsupportedMethods = (typeof TRANSACTION_UNSUPPORTED_METHODS)[number]; + +/** + * Transaction isolation levels. + */ +export enum TransactionIsolationLevel { + ReadUncommitted = 'read uncommitted', + ReadCommitted = 'read committed', + RepeatableRead = 'repeatable read', + Serializable = 'serializable', + Snapshot = 'snapshot', +} + /** * ZenStack client interface. */ @@ -47,7 +62,7 @@ export type ClientContract = { * const result = await client.$executeRaw`UPDATE User SET cool = ${true} WHERE email = ${'user@email.com'};` * ``` */ - $executeRaw(query: TemplateStringsArray, ...values: any[]): Promise; + $executeRaw(query: TemplateStringsArray, ...values: any[]): ZenStackPromise; /** * Executes a raw query and returns the number of affected rows. @@ -57,7 +72,7 @@ export type ClientContract = { * const result = await client.$executeRawUnsafe('UPDATE User SET cool = $1 WHERE email = $2 ;', true, 'user@email.com') * ``` */ - $executeRawUnsafe(query: string, ...values: any[]): Promise; + $executeRawUnsafe(query: string, ...values: any[]): ZenStackPromise; /** * Performs a prepared raw query and returns the `SELECT` data. @@ -66,7 +81,7 @@ export type ClientContract = { * const result = await client.$queryRaw`SELECT * FROM User WHERE id = ${1} OR email = ${'user@email.com'};` * ``` */ - $queryRaw(query: TemplateStringsArray, ...values: any[]): Promise; + $queryRaw(query: TemplateStringsArray, ...values: any[]): ZenStackPromise; /** * Performs a raw query and returns the `SELECT` data. @@ -76,7 +91,7 @@ export type ClientContract = { * const result = await client.$queryRawUnsafe('SELECT * FROM User WHERE id = $1 OR email = $2;', 1, 'user@email.com') * ``` */ - $queryRawUnsafe(query: string, ...values: any[]): Promise; + $queryRawUnsafe(query: string, ...values: any[]): ZenStackPromise; /** * The current user identity. @@ -99,9 +114,20 @@ export type ClientContract = { readonly $qbRaw: ToKysely; /** - * Starts a transaction. + * Starts an interactive transaction. + */ + $transaction( + callback: (tx: Omit, TransactionUnsupportedMethods>) => Promise, + options?: { isolationLevel?: TransactionIsolationLevel }, + ): Promise; + + /** + * Starts a sequential transaction. */ - $transaction(callback: (tx: ClientContract) => Promise): Promise; + $transaction

[]>( + arg: [...P], + options?: { isolationLevel?: TransactionIsolationLevel }, + ): Promise>; /** * Returns a new client with the specified plugin installed. @@ -265,7 +291,7 @@ export interface ModelOperations>( args?: SelectSubset>, - ): Promise[]>; + ): ZenStackPromise[]>; /** * Returns a uniquely identified entity. @@ -275,7 +301,7 @@ export interface ModelOperations>( args?: SelectSubset>, - ): Promise | null>; + ): ZenStackPromise | null>; /** * Returns a uniquely identified entity or throws `NotFoundError` if not found. @@ -285,7 +311,7 @@ export interface ModelOperations>( args?: SelectSubset>, - ): Promise>; + ): ZenStackPromise>; /** * Returns the first entity. @@ -295,7 +321,7 @@ export interface ModelOperations>( args?: SelectSubset>, - ): Promise | null>; + ): ZenStackPromise | null>; /** * Returns the first entity or throws `NotFoundError` if not found. @@ -305,7 +331,7 @@ export interface ModelOperations>( args?: SelectSubset>, - ): Promise>; + ): ZenStackPromise>; /** * Creates a new entity. @@ -361,7 +387,7 @@ export interface ModelOperations>( args: SelectSubset>, - ): Promise>; + ): ZenStackPromise>; /** * Creates multiple entities. Only scalar fields are allowed. @@ -390,7 +416,7 @@ export interface ModelOperations>( args?: SelectSubset>, - ): Promise; + ): ZenStackPromise; /** * Creates multiple entities and returns them. @@ -412,7 +438,7 @@ export interface ModelOperations>( args?: SelectSubset>, - ): Promise[]>; + ): ZenStackPromise[]>; /** * Updates a uniquely identified entity. @@ -533,7 +559,7 @@ export interface ModelOperations>( args: SelectSubset>, - ): Promise>; + ): ZenStackPromise>; /** * Updates multiple entities. @@ -557,7 +583,7 @@ export interface ModelOperations>( args: Subset>, - ): Promise; + ): ZenStackPromise; /** * Updates multiple entities and returns them. @@ -583,7 +609,7 @@ export interface ModelOperations>( args: Subset>, - ): Promise[]>; + ): ZenStackPromise[]>; /** * Creates or updates an entity. @@ -607,7 +633,7 @@ export interface ModelOperations>( args: SelectSubset>, - ): Promise>; + ): ZenStackPromise>; /** * Deletes a uniquely identifiable entity. @@ -630,7 +656,7 @@ export interface ModelOperations>( args: SelectSubset>, - ): Promise>; + ): ZenStackPromise>; /** * Deletes multiple entities. @@ -653,7 +679,7 @@ export interface ModelOperations>( args?: Subset>, - ): Promise; + ): ZenStackPromise; /** * Counts rows or field values. @@ -675,7 +701,7 @@ export interface ModelOperations>( args?: Subset>, - ): Promise>; + ): ZenStackPromise>; /** * Aggregates rows. @@ -696,7 +722,7 @@ export interface ModelOperations>( args: Subset>, - ): Promise>; + ): ZenStackPromise>; /** * Groups rows by columns. @@ -732,7 +758,7 @@ export interface ModelOperations>( args: Subset>, - ): Promise>; + ): ZenStackPromise>; } //#endregion diff --git a/packages/runtime/src/client/crud-types.ts b/packages/runtime/src/client/crud-types.ts index ada75549..4643991d 100644 --- a/packages/runtime/src/client/crud-types.ts +++ b/packages/runtime/src/client/crud-types.ts @@ -528,12 +528,9 @@ export type CreateArgs omit?: OmitFields; }; -export type CreateManyArgs> = CreateManyPayload< - Schema, - Model ->; +export type CreateManyArgs> = CreateManyInput; -export type CreateManyAndReturnArgs> = CreateManyPayload< +export type CreateManyAndReturnArgs> = CreateManyInput< Schema, Model > & { @@ -597,8 +594,13 @@ type CreateRelationPayload; -type CreateWithFKInput> = CreateScalarPayload & - CreateFKPayload; +type CreateWithFKInput> = + // scalar fields + CreateScalarPayload & + // fk fields + CreateFKPayload & + // non-owned relations + CreateWithNonOwnedRelationPayload; type CreateWithRelationInput> = CreateScalarPayload< Schema, @@ -606,6 +608,14 @@ type CreateWithRelationInput & CreateRelationPayload; +type CreateWithNonOwnedRelationPayload> = OptionalWrap< + Schema, + Model, + { + [Key in NonOwnedRelationFields]: CreateRelationFieldPayload; + } +>; + type ConnectOrCreatePayload< Schema extends SchemaDef, Model extends GetModels, @@ -615,7 +625,7 @@ type ConnectOrCreatePayload< create: CreateInput; }; -export type CreateManyPayload< +export type CreateManyInput< Schema extends SchemaDef, Model extends GetModels, Without extends string = never, @@ -643,7 +653,7 @@ type NestedCreateManyInput< Schema extends SchemaDef, Model extends GetModels, Field extends RelationFields, -> = CreateManyPayload, OppositeRelationAndFK>; +> = CreateManyInput, OppositeRelationAndFK>; //#endregion @@ -1078,3 +1088,13 @@ type NestedDeleteManyInput< > = OrArray, true>>; // #endregion + +// #region Utilities + +type NonOwnedRelationFields> = keyof { + [Key in RelationFields as GetField['relation'] extends { references: unknown[] } + ? never + : Key]: Key; +}; + +// #endregion diff --git a/packages/runtime/src/client/crud/dialects/base.ts b/packages/runtime/src/client/crud/dialects/base.ts index 3b14d637..daa154e9 100644 --- a/packages/runtime/src/client/crud/dialects/base.ts +++ b/packages/runtime/src/client/crud/dialects/base.ts @@ -37,7 +37,7 @@ export abstract class BaseCrudDialect { abstract get provider(): DataSourceProviderType; - transformPrimitive(value: unknown, _type: BuiltinType) { + transformPrimitive(value: unknown, _type: BuiltinType, _forArrayField: boolean) { return value; } @@ -363,7 +363,7 @@ export abstract class BaseCrudDialect { continue; } - const value = this.transformPrimitive(_value, fieldType); + const value = this.transformPrimitive(_value, fieldType, !!fieldDef.array); switch (key) { case 'equals': { @@ -416,19 +416,28 @@ export abstract class BaseCrudDialect { return this.buildEnumFilter(eb, modelAlias, field, fieldDef, payload); } - return match(fieldDef.type as BuiltinType) - .with('String', () => this.buildStringFilter(eb, modelAlias, field, payload)) - .with(P.union('Int', 'Float', 'Decimal', 'BigInt'), (type) => - this.buildNumberFilter(eb, model, modelAlias, field, 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)) - .exhaustive(); + return ( + match(fieldDef.type as BuiltinType) + .with('String', () => this.buildStringFilter(eb, modelAlias, field, payload)) + .with(P.union('Int', 'Float', 'Decimal', 'BigInt'), (type) => + this.buildNumberFilter(eb, model, modelAlias, field, 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)) + // TODO: JSON filters + .with('Json', () => { + throw new InternalError('JSON filters are not supported yet'); + }) + .with('Unsupported', () => { + throw new QueryError(`Unsupported field cannot be used in filters`); + }) + .exhaustive() + ); } private buildLiteralFilter(eb: ExpressionBuilder, lhs: Expression, type: BuiltinType, rhs: unknown) { - return eb(lhs, '=', rhs !== null && rhs !== undefined ? this.transformPrimitive(rhs, type) : rhs); + return eb(lhs, '=', rhs !== null && rhs !== undefined ? this.transformPrimitive(rhs, type, false) : rhs); } private buildStandardFilter( @@ -579,7 +588,7 @@ export abstract class BaseCrudDialect { type, payload, buildFieldRef(this.schema, model, field, this.options, eb), - (value) => this.transformPrimitive(value, type), + (value) => this.transformPrimitive(value, type, false), (value) => this.buildNumberFilter(eb, model, table, field, type, value), ); return this.and(eb, ...conditions); @@ -596,7 +605,7 @@ export abstract class BaseCrudDialect { 'Boolean', payload, sql.ref(`${table}.${field}`), - (value) => this.transformPrimitive(value, 'Boolean'), + (value) => this.transformPrimitive(value, 'Boolean', false), (value) => this.buildBooleanFilter(eb, table, field, value as BooleanFilter), true, ['equals', 'not'], @@ -615,7 +624,7 @@ export abstract class BaseCrudDialect { 'DateTime', payload, sql.ref(`${table}.${field}`), - (value) => this.transformPrimitive(value, 'DateTime'), + (value) => this.transformPrimitive(value, 'DateTime', false), (value) => this.buildDateTimeFilter(eb, table, field, value as DateTimeFilter), true, ); @@ -633,7 +642,7 @@ export abstract class BaseCrudDialect { 'Bytes', payload, sql.ref(`${table}.${field}`), - (value) => this.transformPrimitive(value, 'Bytes'), + (value) => this.transformPrimitive(value, 'Bytes', false), (value) => this.buildBytesFilter(eb, table, field, value as BytesFilter), true, ['equals', 'in', 'notIn', 'not'], @@ -784,11 +793,11 @@ export abstract class BaseCrudDialect { } public true(eb: ExpressionBuilder): Expression { - return eb.lit(this.transformPrimitive(true, 'Boolean') as boolean); + return eb.lit(this.transformPrimitive(true, 'Boolean', false) as boolean); } public false(eb: ExpressionBuilder): Expression { - return eb.lit(this.transformPrimitive(false, 'Boolean') as boolean); + return eb.lit(this.transformPrimitive(false, 'Boolean', false) as boolean); } public isTrue(expression: Expression) { diff --git a/packages/runtime/src/client/crud/dialects/postgresql.ts b/packages/runtime/src/client/crud/dialects/postgresql.ts index 63573819..c73b4bb1 100644 --- a/packages/runtime/src/client/crud/dialects/postgresql.ts +++ b/packages/runtime/src/client/crud/dialects/postgresql.ts @@ -15,6 +15,7 @@ import { buildJoinPairs, getIdFields, getManyToManyRelation, + isRelationField, requireField, requireModel, } from '../../query-utils'; @@ -25,18 +26,26 @@ export class PostgresCrudDialect extends BaseCrudDiale return 'postgresql' as const; } - override transformPrimitive(value: unknown, type: BuiltinType): unknown { + override transformPrimitive(value: unknown, type: BuiltinType, forArrayField: boolean): unknown { if (value === undefined) { return value; } if (Array.isArray(value)) { - return value.map((v) => this.transformPrimitive(v, type)); + if (type === 'Json' && !forArrayField) { + // node-pg incorrectly handles array values passed to non-array JSON fields, + // the workaround is to JSON stringify the value + // https://github.com/brianc/node-postgres/issues/374 + return JSON.stringify(value); + } else { + return value.map((v) => this.transformPrimitive(v, type, false)); + } } else { return match(type) .with('DateTime', () => value instanceof Date ? value : typeof value === 'string' ? new Date(value) : value, ) + .with('Decimal', () => (value !== null ? value.toString() : value)) .otherwise(() => value); } } @@ -208,10 +217,15 @@ export class PostgresCrudDialect extends BaseCrudDiale objArgs.push( ...Object.entries(payload.select) .filter(([, value]) => value) - .map(([field]) => [ - sql.lit(field), - buildFieldRef(this.schema, relationModel, field, this.options, eb), - ]) + .map(([field]) => { + const fieldDef = requireField(this.schema, relationModel, field); + const fieldValue = fieldDef.relation + ? // reference the synthesized JSON field + eb.ref(`${parentName}$${relationField}$${field}.$j`) + : // reference a plain field + buildFieldRef(this.schema, relationModel, field, this.options, eb); + return [sql.lit(field), fieldValue]; + }) .flatMap((v) => v), ); } @@ -221,7 +235,11 @@ export class PostgresCrudDialect extends BaseCrudDiale objArgs.push( ...Object.entries(payload.include) .filter(([, value]) => value) - .map(([field]) => [sql.lit(field), eb.ref(`${parentName}$${relationField}$${field}.$j`)]) + .map(([field]) => [ + sql.lit(field), + // reference the synthesized JSON field + eb.ref(`${parentName}$${relationField}$${field}.$j`), + ]) .flatMap((v) => v), ); } @@ -229,19 +247,29 @@ export class PostgresCrudDialect extends BaseCrudDiale } private buildRelationJoins( - model: string, + relationModel: string, relationField: string, qb: SelectQueryBuilder, payload: true | FindArgs, true>, parentName: string, ) { let result = qb; - if (typeof payload === 'object' && payload.include && typeof payload.include === 'object') { - Object.entries(payload.include) - .filter(([, value]) => value) - .forEach(([field, value]) => { - result = this.buildRelationJSON(model, result, field, `${parentName}$${relationField}`, value); - }); + if (typeof payload === 'object') { + const selectInclude = payload.include ?? payload.select; + if (selectInclude && typeof selectInclude === 'object') { + Object.entries(selectInclude) + .filter(([, value]) => value) + .filter(([field]) => isRelationField(this.schema, relationModel, field)) + .forEach(([field, value]) => { + result = this.buildRelationJSON( + relationModel, + result, + field, + `${parentName}$${relationField}`, + value, + ); + }); + } } return result; } diff --git a/packages/runtime/src/client/crud/dialects/sqlite.ts b/packages/runtime/src/client/crud/dialects/sqlite.ts index c8f20173..7fa67905 100644 --- a/packages/runtime/src/client/crud/dialects/sqlite.ts +++ b/packages/runtime/src/client/crud/dialects/sqlite.ts @@ -26,19 +26,20 @@ export class SqliteCrudDialect extends BaseCrudDialect return 'sqlite' as const; } - override transformPrimitive(value: unknown, type: BuiltinType): unknown { + override transformPrimitive(value: unknown, type: BuiltinType, _forArrayField: boolean): unknown { if (value === undefined) { return value; } if (Array.isArray(value)) { - return value.map((v) => this.transformPrimitive(v, type)); + 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); } } diff --git a/packages/runtime/src/client/crud/operations/aggregate.ts b/packages/runtime/src/client/crud/operations/aggregate.ts index 05392250..03b1ae6d 100644 --- a/packages/runtime/src/client/crud/operations/aggregate.ts +++ b/packages/runtime/src/client/crud/operations/aggregate.ts @@ -6,7 +6,11 @@ import { BaseOperationHandler } from './base'; export class AggregateOperationHandler extends BaseOperationHandler { async handle(_operation: 'aggregate', args: unknown | undefined) { - const validatedArgs = this.inputValidator.validateAggregateArgs(this.model, args); + // normalize args to strip `undefined` fields + const normalizeArgs = this.normalizeArgs(args); + + // parse args + const parsedArgs = this.inputValidator.validateAggregateArgs(this.model, normalizeArgs); let query = this.kysely.selectFrom((eb) => { // nested query for filtering and pagination @@ -15,11 +19,11 @@ export class AggregateOperationHandler extends BaseOpe let subQuery = eb .selectFrom(this.model) .selectAll(this.model as any) // TODO: check typing - .where((eb1) => this.dialect.buildFilter(eb1, this.model, this.model, validatedArgs?.where)); + .where((eb1) => this.dialect.buildFilter(eb1, this.model, this.model, parsedArgs?.where)); // skip & take - const skip = validatedArgs?.skip; - let take = validatedArgs?.take; + const skip = parsedArgs?.skip; + let take = parsedArgs?.take; let negateOrderBy = false; if (take !== undefined && take < 0) { negateOrderBy = true; @@ -32,7 +36,7 @@ export class AggregateOperationHandler extends BaseOpe subQuery, this.model, this.model, - validatedArgs.orderBy, + parsedArgs.orderBy, skip !== undefined || take !== undefined, negateOrderBy, ); @@ -41,7 +45,7 @@ export class AggregateOperationHandler extends BaseOpe }); // aggregations - for (const [key, value] of Object.entries(validatedArgs)) { + for (const [key, value] of Object.entries(parsedArgs)) { switch (key) { case '_count': { if (value === true) { diff --git a/packages/runtime/src/client/crud/operations/base.ts b/packages/runtime/src/client/crud/operations/base.ts index 7dfbd43c..59a31ecf 100644 --- a/packages/runtime/src/client/crud/operations/base.ts +++ b/packages/runtime/src/client/crud/operations/base.ts @@ -1,16 +1,17 @@ import { createId } from '@paralleldrive/cuid2'; -import { invariant } from '@zenstackhq/common-helpers'; +import { invariant, isPlainObject } from '@zenstackhq/common-helpers'; import { DeleteResult, expressionBuilder, ExpressionWrapper, sql, UpdateResult, - type ExpressionBuilder, + type IsolationLevel, type Expression as KyselyExpression, type SelectQueryBuilder, } from 'kysely'; import { nanoid } from 'nanoid'; +import { inspect } from 'node:util'; import { match } from 'ts-pattern'; import { ulid } from 'ulid'; import * as uuid from 'uuid'; @@ -204,7 +205,11 @@ export abstract class BaseOperationHandler { result = await query.execute(); } catch (err) { const { sql, parameters } = query.compile(); - throw new QueryError(`Failed to execute query: ${err}, sql: ${sql}, parameters: ${parameters}`); + let message = `Failed to execute query: ${err}, sql: ${sql}`; + if (this.options.debug) { + message += `, parameters: \n${parameters.map((p) => inspect(p)).join('\n')}`; + } + throw new QueryError(message, err); } if (inMemoryDistinct) { @@ -292,33 +297,29 @@ export abstract class BaseOperationHandler { for (const [field, value] of Object.entries(selections.select)) { const fieldDef = requireField(this.schema, model, field); const fieldModel = fieldDef.type; - const jointTable = `${parentAlias}$${field}$count`; - const joinPairs = buildJoinPairs(this.schema, model, parentAlias, field, jointTable); - - query = query.leftJoin( - (eb) => { - let result = eb.selectFrom(fieldModel).selectAll(); - if ( - value && - typeof value === 'object' && - 'where' in value && - value.where && - typeof value.where === 'object' - ) { - const filter = this.dialect.buildFilter(eb, fieldModel, fieldModel, value.where); - result = result.where(filter); - } - return result.as(jointTable); - }, - (join) => { - for (const [left, right] of joinPairs) { - join = join.onRef(left, '=', right); - } - return join; - }, - ); + const joinPairs = buildJoinPairs(this.schema, model, parentAlias, field, fieldModel); - jsonObject[field] = this.countIdDistinct(eb, fieldDef.type, jointTable); + // build a nested query to count the number of records in the relation + let fieldCountQuery = eb.selectFrom(fieldModel).select(eb.fn.countAll().as(`_count$${field}`)); + + // join conditions + for (const [left, right] of joinPairs) { + fieldCountQuery = fieldCountQuery.whereRef(left, '=', right); + } + + // merge _count filter + if ( + value && + typeof value === 'object' && + 'where' in value && + value.where && + typeof value.where === 'object' + ) { + const filter = this.dialect.buildFilter(eb, fieldModel, fieldModel, value.where); + fieldCountQuery = fieldCountQuery.where(filter); + } + + jsonObject[field] = fieldCountQuery; } query = query.select((eb) => this.dialect.buildJsonObject(eb, jsonObject).as('_count')); @@ -326,11 +327,6 @@ export abstract class BaseOperationHandler { return query; } - private countIdDistinct(eb: ExpressionBuilder, model: string, table: string) { - const idFields = getIdFields(this.schema, model); - return eb.fn.count(sql.join(idFields.map((f) => sql.ref(`${table}.${f}`)))).distinct(); - } - private buildSelectAllScalarFields( model: string, query: SelectQueryBuilder, @@ -464,14 +460,22 @@ export abstract class BaseOperationHandler { Array.isArray(value.set) ) { // deal with nested "set" for scalar lists - createFields[field] = this.dialect.transformPrimitive(value.set, fieldDef.type as BuiltinType); + createFields[field] = this.dialect.transformPrimitive( + value.set, + fieldDef.type as BuiltinType, + true, + ); } else { - createFields[field] = this.dialect.transformPrimitive(value, fieldDef.type as BuiltinType); + createFields[field] = this.dialect.transformPrimitive( + value, + fieldDef.type as BuiltinType, + !!fieldDef.array, + ); } } else { const subM2M = getManyToManyRelation(this.schema, model, field); if (!subM2M && fieldDef.relation?.fields && fieldDef.relation?.references) { - const fkValues = await this.processOwnedRelation(kysely, fieldDef, value); + const fkValues = await this.processOwnedRelationForCreate(kysely, fieldDef, value); for (let i = 0; i < fieldDef.relation.fields.length; i++) { createFields[fieldDef.relation.fields[i]!] = fkValues[fieldDef.relation.references[i]!]; } @@ -511,7 +515,7 @@ export abstract class BaseOperationHandler { if (Object.keys(postCreateRelations).length > 0) { // process nested creates that need to happen after the current entity is created const relationPromises = Object.entries(postCreateRelations).map(([field, subPayload]) => { - return this.processNoneOwnedRelation(kysely, model, field, subPayload, createdEntity); + return this.processNoneOwnedRelationForCreate(kysely, model, field, subPayload, createdEntity); }); // await relation creation @@ -625,7 +629,7 @@ export abstract class BaseOperationHandler { .execute(); } - private async processOwnedRelation(kysely: ToKysely, relationField: FieldDef, payload: any) { + private async processOwnedRelationForCreate(kysely: ToKysely, relationField: FieldDef, payload: any) { if (!payload) { return; } @@ -688,7 +692,7 @@ export abstract class BaseOperationHandler { return result; } - private processNoneOwnedRelation( + private processNoneOwnedRelationForCreate( kysely: ToKysely, contextModel: GetModels, relationFieldName: string, @@ -698,6 +702,11 @@ export abstract class BaseOperationHandler { const relationFieldDef = this.requireField(contextModel, relationFieldName); const relationModel = relationFieldDef.type as GetModels; const tasks: Promise[] = []; + const fromRelationContext = { + model: contextModel, + field: relationFieldName, + ids: parentEntity, + }; for (const [action, subPayload] of Object.entries(payload)) { if (!subPayload) { @@ -708,11 +717,21 @@ export abstract class BaseOperationHandler { // create with a parent entity tasks.push( ...enumerate(subPayload).map((item) => - this.create(kysely, relationModel, item, { - model: contextModel, - field: relationFieldName, - ids: parentEntity, - }), + this.create(kysely, relationModel, item, fromRelationContext), + ), + ); + break; + } + + case 'createMany': { + invariant(relationFieldDef.array, 'relation must be an array for createMany'); + tasks.push( + this.createMany( + kysely, + relationModel, + subPayload as { data: any; skipDuplicates: boolean }, + false, + fromRelationContext, ), ); break; @@ -768,6 +787,11 @@ export abstract class BaseOperationHandler { returnData: ReturnData, fromRelation?: FromRelationContext, ): Promise { + if (!input.data || (Array.isArray(input.data) && input.data.length === 0)) { + // nothing todo + return returnData ? ([] as Result) : ({ count: 0 } as Result); + } + const modelDef = this.requireModel(model); let relationKeyPairs: { fk: string; pk: string }[] = []; @@ -788,7 +812,7 @@ export abstract class BaseOperationHandler { for (const [name, value] of Object.entries(item)) { const fieldDef = this.requireField(model, name); invariant(!fieldDef.relation, 'createMany does not support relations'); - newItem[name] = this.dialect.transformPrimitive(value, fieldDef.type as BuiltinType); + newItem[name] = this.dialect.transformPrimitive(value, fieldDef.type as BuiltinType, !!fieldDef.array); } if (fromRelation) { for (const { fk, pk } of relationKeyPairs) { @@ -831,7 +855,7 @@ export abstract class BaseOperationHandler { } } else if (fields[field]?.updatedAt) { // TODO: should this work at kysely level instead? - values[field] = this.dialect.transformPrimitive(new Date(), 'DateTime'); + values[field] = this.dialect.transformPrimitive(new Date(), 'DateTime', false); } } } @@ -934,7 +958,7 @@ export abstract class BaseOperationHandler { if (finalData === data) { finalData = clone(data); } - finalData[fieldName] = this.dialect.transformPrimitive(new Date(), 'DateTime'); + finalData[fieldName] = this.dialect.transformPrimitive(new Date(), 'DateTime', false); } } @@ -972,7 +996,11 @@ export abstract class BaseOperationHandler { continue; } - updateFields[field] = this.dialect.transformPrimitive(finalData[field], fieldDef.type as BuiltinType); + updateFields[field] = this.dialect.transformPrimitive( + finalData[field], + fieldDef.type as BuiltinType, + !!fieldDef.array, + ); } else { if (!allowRelationUpdate) { throw new QueryError(`Relation update not allowed for field "${field}"`); @@ -1054,7 +1082,7 @@ export abstract class BaseOperationHandler { ); const key = Object.keys(payload)[0]; - const value = this.dialect.transformPrimitive(payload[key!], fieldDef.type as BuiltinType); + const value = this.dialect.transformPrimitive(payload[key!], fieldDef.type as BuiltinType, false); const eb = expressionBuilder(); const fieldRef = buildFieldRef(this.schema, model, field, this.options, eb); @@ -1077,7 +1105,7 @@ export abstract class BaseOperationHandler { ) { invariant(Object.keys(payload).length === 1, 'Only one of "set", "push" can be provided'); const key = Object.keys(payload)[0]; - const value = this.dialect.transformPrimitive(payload[key!], fieldDef.type as BuiltinType); + const value = this.dialect.transformPrimitive(payload[key!], fieldDef.type as BuiltinType, true); const eb = expressionBuilder(); const fieldRef = buildFieldRef(this.schema, model, field, this.options, eb); @@ -1125,7 +1153,11 @@ export abstract class BaseOperationHandler { if (isRelationField(this.schema, model, field)) { continue; } - updateFields[field] = this.dialect.transformPrimitive(data[field], fieldDef.type as BuiltinType); + updateFields[field] = this.dialect.transformPrimitive( + data[field], + fieldDef.type as BuiltinType, + !!fieldDef.array, + ); } let query = kysely.updateTable(model).set(updateFields); @@ -1155,18 +1187,13 @@ export abstract class BaseOperationHandler { query = query.modifyEnd(this.makeContextComment({ model, operation: 'update' })); - try { - if (!returnData) { - const result = await query.executeTakeFirstOrThrow(); - return { count: Number(result.numUpdatedRows) } as Result; - } else { - const idFields = getIdFields(this.schema, model); - const result = await query.returning(idFields as any).execute(); - return result as Result; - } - } catch (err) { - const { sql, parameters } = query.compile(); - throw new QueryError(`Error during updateMany: ${err}, sql: ${sql}, parameters: ${parameters}`); + if (!returnData) { + const result = await query.executeTakeFirstOrThrow(); + return { count: Number(result.numUpdatedRows) } as Result; + } else { + const idFields = getIdFields(this.schema, model); + const result = await query.returning(idFields as any).execute(); + return result as Result; } } @@ -1874,11 +1901,20 @@ export abstract class BaseOperationHandler { return returnRelation; } - protected async safeTransaction(callback: (tx: ToKysely) => Promise) { + protected async safeTransaction( + callback: (tx: ToKysely) => Promise, + isolationLevel?: IsolationLevel, + ) { if (this.kysely.isTransaction) { + // proceed directly if already in a transaction return callback(this.kysely); } else { - return this.kysely.transaction().setIsolationLevel('repeatable read').execute(callback); + // otherwise, create a new transaction and execute the callback + let txBuilder = this.kysely.transaction(); + if (isolationLevel) { + txBuilder = txBuilder.setIsolationLevel(isolationLevel); + } + return txBuilder.execute(callback); } } @@ -1900,4 +1936,28 @@ export abstract class BaseOperationHandler { where: uniqueFilter, }); } + + /** + * Normalize input args to strip `undefined` fields + */ + protected normalizeArgs(args: unknown) { + if (!args) { + return; + } + const newArgs = clone(args); + this.doNormalizeArgs(newArgs); + return newArgs; + } + + private doNormalizeArgs(args: unknown) { + if (args && typeof args === 'object') { + for (const [key, value] of Object.entries(args)) { + if (value === undefined) { + delete args[key as keyof typeof args]; + } else if (value && isPlainObject(value)) { + this.doNormalizeArgs(value); + } + } + } + } } diff --git a/packages/runtime/src/client/crud/operations/count.ts b/packages/runtime/src/client/crud/operations/count.ts index f454762b..9a8cc315 100644 --- a/packages/runtime/src/client/crud/operations/count.ts +++ b/packages/runtime/src/client/crud/operations/count.ts @@ -4,22 +4,26 @@ import { BaseOperationHandler } from './base'; export class CountOperationHandler extends BaseOperationHandler { async handle(_operation: 'count', args: unknown | undefined) { - const validatedArgs = this.inputValidator.validateCountArgs(this.model, args); + // normalize args to strip `undefined` fields + const normalizeArgs = this.normalizeArgs(args); + + // parse args + const parsedArgs = this.inputValidator.validateCountArgs(this.model, normalizeArgs); let query = this.kysely.selectFrom((eb) => { // nested query for filtering and pagination let subQuery = eb .selectFrom(this.model) .selectAll() - .where((eb1) => this.dialect.buildFilter(eb1, this.model, this.model, validatedArgs?.where)); - subQuery = this.dialect.buildSkipTake(subQuery, validatedArgs?.skip, validatedArgs?.take); + .where((eb1) => this.dialect.buildFilter(eb1, this.model, this.model, parsedArgs?.where)); + subQuery = this.dialect.buildSkipTake(subQuery, parsedArgs?.skip, parsedArgs?.take); return subQuery.as('$sub'); }); - if (validatedArgs?.select && typeof validatedArgs.select === 'object') { + if (parsedArgs?.select && typeof parsedArgs.select === 'object') { // count with field selection query = query.select((eb) => - Object.keys(validatedArgs.select!).map((key) => + 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), diff --git a/packages/runtime/src/client/crud/operations/create.ts b/packages/runtime/src/client/crud/operations/create.ts index 1b6c9288..a908346b 100644 --- a/packages/runtime/src/client/crud/operations/create.ts +++ b/packages/runtime/src/client/crud/operations/create.ts @@ -7,14 +7,17 @@ import { BaseOperationHandler } from './base'; export class CreateOperationHandler extends BaseOperationHandler { async handle(operation: 'create' | 'createMany' | 'createManyAndReturn', args: unknown | undefined) { + // normalize args to strip `undefined` fields + const normalizeArgs = this.normalizeArgs(args); + return match(operation) - .with('create', () => this.runCreate(this.inputValidator.validateCreateArgs(this.model, args))) + .with('create', () => this.runCreate(this.inputValidator.validateCreateArgs(this.model, normalizeArgs))) .with('createMany', () => { - return this.runCreateMany(this.inputValidator.validateCreateManyArgs(this.model, args)); + return this.runCreateMany(this.inputValidator.validateCreateManyArgs(this.model, normalizeArgs)); }) .with('createManyAndReturn', () => { return this.runCreateManyAndReturn( - this.inputValidator.validateCreateManyAndReturnArgs(this.model, args), + this.inputValidator.validateCreateManyAndReturnArgs(this.model, normalizeArgs), ); }) .exhaustive(); diff --git a/packages/runtime/src/client/crud/operations/delete.ts b/packages/runtime/src/client/crud/operations/delete.ts index 6933b1a8..7ee821c6 100644 --- a/packages/runtime/src/client/crud/operations/delete.ts +++ b/packages/runtime/src/client/crud/operations/delete.ts @@ -6,9 +6,14 @@ import { BaseOperationHandler } from './base'; export class DeleteOperationHandler extends BaseOperationHandler { async handle(operation: 'delete' | 'deleteMany', args: unknown | undefined) { + // normalize args to strip `undefined` fields + const normalizeArgs = this.normalizeArgs(args); + return match(operation) - .with('delete', () => this.runDelete(this.inputValidator.validateDeleteArgs(this.model, args))) - .with('deleteMany', () => this.runDeleteMany(this.inputValidator.validateDeleteManyArgs(this.model, args))) + .with('delete', () => this.runDelete(this.inputValidator.validateDeleteArgs(this.model, normalizeArgs))) + .with('deleteMany', () => + this.runDeleteMany(this.inputValidator.validateDeleteManyArgs(this.model, normalizeArgs)), + ) .exhaustive(); } diff --git a/packages/runtime/src/client/crud/operations/find.ts b/packages/runtime/src/client/crud/operations/find.ts index 8a868fad..7834e58b 100644 --- a/packages/runtime/src/client/crud/operations/find.ts +++ b/packages/runtime/src/client/crud/operations/find.ts @@ -4,10 +4,13 @@ import { BaseOperationHandler, type CrudOperation } from './base'; export class FindOperationHandler extends BaseOperationHandler { async handle(operation: CrudOperation, args: unknown, validateArgs = true): Promise { + // normalize args to strip `undefined` fields + const normalizeArgs = this.normalizeArgs(args); + // parse args const parsedArgs = validateArgs - ? this.inputValidator.validateFindArgs(this.model, operation === 'findUnique', args) - : args; + ? this.inputValidator.validateFindArgs(this.model, operation === 'findUnique', normalizeArgs) + : normalizeArgs; // run query const result = await this.read( diff --git a/packages/runtime/src/client/crud/operations/group-by.ts b/packages/runtime/src/client/crud/operations/group-by.ts index c59b1f7f..f1630c82 100644 --- a/packages/runtime/src/client/crud/operations/group-by.ts +++ b/packages/runtime/src/client/crud/operations/group-by.ts @@ -6,7 +6,11 @@ import { BaseOperationHandler } from './base'; export class GroupByeOperationHandler extends BaseOperationHandler { async handle(_operation: 'groupBy', args: unknown | undefined) { - const validatedArgs = this.inputValidator.validateGroupByArgs(this.model, args); + // normalize args to strip `undefined` fields + const normalizeArgs = this.normalizeArgs(args); + + // parse args + const parsedArgs = this.inputValidator.validateGroupByArgs(this.model, normalizeArgs); let query = this.kysely.selectFrom((eb) => { // nested query for filtering and pagination @@ -15,11 +19,11 @@ export class GroupByeOperationHandler extends BaseOper let subQuery = eb .selectFrom(this.model) .selectAll() - .where((eb1) => this.dialect.buildFilter(eb1, this.model, this.model, validatedArgs?.where)); + .where((eb1) => this.dialect.buildFilter(eb1, this.model, this.model, parsedArgs?.where)); // skip & take - const skip = validatedArgs?.skip; - let take = validatedArgs?.take; + const skip = parsedArgs?.skip; + let take = parsedArgs?.take; let negateOrderBy = false; if (take !== undefined && take < 0) { negateOrderBy = true; @@ -40,17 +44,17 @@ export class GroupByeOperationHandler extends BaseOper return subQuery.as('$sub'); }); - const bys = typeof validatedArgs.by === 'string' ? [validatedArgs.by] : (validatedArgs.by as string[]); + const bys = typeof parsedArgs.by === 'string' ? [parsedArgs.by] : (parsedArgs.by as string[]); query = query.groupBy(bys as any); // orderBy - if (validatedArgs.orderBy) { - query = this.dialect.buildOrderBy(query, this.model, '$sub', validatedArgs.orderBy, false, false); + if (parsedArgs.orderBy) { + query = this.dialect.buildOrderBy(query, this.model, '$sub', parsedArgs.orderBy, false, false); } - if (validatedArgs.having) { - query = query.having((eb1) => this.dialect.buildFilter(eb1, this.model, '$sub', validatedArgs.having)); + if (parsedArgs.having) { + query = query.having((eb1) => this.dialect.buildFilter(eb1, this.model, '$sub', parsedArgs.having)); } // select all by fields @@ -59,7 +63,7 @@ export class GroupByeOperationHandler extends BaseOper } // aggregations - for (const [key, value] of Object.entries(validatedArgs)) { + for (const [key, value] of Object.entries(parsedArgs)) { switch (key) { case '_count': { if (value === true) { diff --git a/packages/runtime/src/client/crud/operations/update.ts b/packages/runtime/src/client/crud/operations/update.ts index 4771b071..577646ef 100644 --- a/packages/runtime/src/client/crud/operations/update.ts +++ b/packages/runtime/src/client/crud/operations/update.ts @@ -7,13 +7,20 @@ import { BaseOperationHandler } from './base'; export class UpdateOperationHandler extends BaseOperationHandler { async handle(operation: 'update' | 'updateMany' | 'updateManyAndReturn' | 'upsert', args: unknown) { + // normalize args to strip `undefined` fields + const normalizeArgs = this.normalizeArgs(args); + return match(operation) - .with('update', () => this.runUpdate(this.inputValidator.validateUpdateArgs(this.model, args))) - .with('updateMany', () => this.runUpdateMany(this.inputValidator.validateUpdateManyArgs(this.model, args))) + .with('update', () => this.runUpdate(this.inputValidator.validateUpdateArgs(this.model, normalizeArgs))) + .with('updateMany', () => + this.runUpdateMany(this.inputValidator.validateUpdateManyArgs(this.model, normalizeArgs)), + ) .with('updateManyAndReturn', () => - this.runUpdateManyAndReturn(this.inputValidator.validateUpdateManyAndReturnArgs(this.model, args)), + this.runUpdateManyAndReturn( + this.inputValidator.validateUpdateManyAndReturnArgs(this.model, normalizeArgs), + ), ) - .with('upsert', () => this.runUpsert(this.inputValidator.validateUpsertArgs(this.model, args))) + .with('upsert', () => this.runUpsert(this.inputValidator.validateUpsertArgs(this.model, normalizeArgs))) .exhaustive(); } diff --git a/packages/runtime/src/client/crud/validator.ts b/packages/runtime/src/client/crud/validator.ts index cfda876d..d8eea71e 100644 --- a/packages/runtime/src/client/crud/validator.ts +++ b/packages/runtime/src/client/crud/validator.ts @@ -1,3 +1,4 @@ +import { invariant } from '@zenstackhq/common-helpers'; import Decimal from 'decimal.js'; import stableStringify from 'json-stable-stringify'; import { match, P } from 'ts-pattern'; @@ -19,7 +20,7 @@ import { type UpdateManyArgs, type UpsertArgs, } from '../crud-types'; -import { InternalError, QueryError } from '../errors'; +import { InputValidationError, InternalError, QueryError } from '../errors'; import { fieldHasDefaultValue, getEnum, getModel, getUniqueFields, requireField, requireModel } from '../query-utils'; type GetSchemaFunc = (model: GetModels, options: Options) => ZodType; @@ -178,7 +179,7 @@ export class InputValidator { } const { error } = schema.safeParse(args); if (error) { - throw new QueryError(`Invalid ${operation} args: ${error.message}`); + throw new InputValidationError(`Invalid ${operation} args: ${error.message}`, error); } return args as T; } @@ -232,7 +233,7 @@ export class InputValidator { private makeWhereSchema(model: string, unique: boolean, withoutRelationFields = false): ZodType { const modelDef = getModel(this.schema, model); if (!modelDef) { - throw new QueryError(`Model "${model}" not found`); + throw new QueryError(`Model "${model}" not found in schema`); } const fields: Record = {}; @@ -298,10 +299,26 @@ export class InputValidator { fields[uniqueField.name] = z .object( Object.fromEntries( - Object.entries(uniqueField.defs).map(([key, def]) => [ - key, - this.makePrimitiveFilterSchema(def.type as BuiltinType, !!def.optional), - ]), + Object.entries(uniqueField.defs).map(([key, def]) => { + invariant(!def.relation, 'unique field cannot be a relation'); + let fieldSchema: ZodType; + const enumDef = getEnum(this.schema, def.type); + if (enumDef) { + // enum + if (Object.keys(enumDef).length > 0) { + fieldSchema = this.makeEnumFilterSchema(enumDef, !!def.optional); + } else { + fieldSchema = z.never(); + } + } else { + // regular field + fieldSchema = this.makePrimitiveFilterSchema( + def.type as BuiltinType, + !!def.optional, + ); + } + return [key, fieldSchema]; + }), ), ) .optional(); @@ -379,15 +396,20 @@ export class InputValidator { } private makePrimitiveFilterSchema(type: BuiltinType, optional: boolean) { - return match(type) - .with('String', () => this.makeStringFilterSchema(optional)) - .with(P.union('Int', 'Float', 'Decimal', 'BigInt'), (type) => - this.makeNumberFilterSchema(this.makePrimitiveSchema(type), optional), - ) - .with('Boolean', () => this.makeBooleanFilterSchema(optional)) - .with('DateTime', () => this.makeDateTimeFilterSchema(optional)) - .with('Bytes', () => this.makeBytesFilterSchema(optional)) - .exhaustive(); + return ( + match(type) + .with('String', () => this.makeStringFilterSchema(optional)) + .with(P.union('Int', 'Float', 'Decimal', 'BigInt'), (type) => + this.makeNumberFilterSchema(this.makePrimitiveSchema(type), optional), + ) + .with('Boolean', () => this.makeBooleanFilterSchema(optional)) + .with('DateTime', () => this.makeDateTimeFilterSchema(optional)) + .with('Bytes', () => this.makeBytesFilterSchema(optional)) + // TODO: JSON filters + .with('Json', () => z.any()) + .with('Unsupported', () => z.never()) + .exhaustive() + ); } private makeDateTimeFilterSchema(optional: boolean): ZodType { @@ -628,8 +650,8 @@ export class InputValidator { withoutFields: string[] = [], withoutRelationFields = false, ) { - const regularAndFkFields: any = {}; - const regularAndRelationFields: any = {}; + const uncheckedVariantFields: Record = {}; + const checkedVariantFields: Record = {}; const modelDef = requireModel(this.schema, model); const hasRelation = !withoutRelationFields && @@ -683,7 +705,11 @@ export class InputValidator { if (fieldDef.optional && !fieldDef.array) { fieldSchema = fieldSchema.nullable(); } - regularAndRelationFields[field] = fieldSchema; + checkedVariantFields[field] = fieldSchema; + if (fieldDef.array || !fieldDef.relation.references) { + // non-owned relation + uncheckedVariantFields[field] = fieldSchema; + } } else { let fieldSchema: ZodType = this.makePrimitiveSchema(fieldDef.type); @@ -706,21 +732,22 @@ export class InputValidator { fieldSchema = fieldSchema.nullable(); } - regularAndFkFields[field] = fieldSchema; + uncheckedVariantFields[field] = fieldSchema; if (!fieldDef.foreignKeyFor) { - regularAndRelationFields[field] = fieldSchema; + // non-fk field + checkedVariantFields[field] = fieldSchema; } } }); if (!hasRelation) { - return this.orArray(z.object(regularAndFkFields).strict(), canBeArray); + return this.orArray(z.object(uncheckedVariantFields).strict(), canBeArray); } else { return z.union([ - z.object(regularAndFkFields).strict(), - z.object(regularAndRelationFields).strict(), - ...(canBeArray ? [z.array(z.object(regularAndFkFields).strict())] : []), - ...(canBeArray ? [z.array(z.object(regularAndRelationFields).strict())] : []), + z.object(uncheckedVariantFields).strict(), + z.object(checkedVariantFields).strict(), + ...(canBeArray ? [z.array(z.object(uncheckedVariantFields).strict())] : []), + ...(canBeArray ? [z.array(z.object(checkedVariantFields).strict())] : []), ]); } } @@ -791,10 +818,7 @@ export class InputValidator { } } - return z - .object(fields) - .strict() - .refine((v) => Object.keys(v).length > 0, 'At least one action is required'); + return z.object(fields).strict(); } private makeSetDataSchema(model: string, canBeArray: boolean) { diff --git a/packages/runtime/src/client/errors.ts b/packages/runtime/src/client/errors.ts index f58a32f8..0ec57b40 100644 --- a/packages/runtime/src/client/errors.ts +++ b/packages/runtime/src/client/errors.ts @@ -1,15 +1,33 @@ +/** + * Error thrown when input validation fails. + */ +export class InputValidationError extends Error { + constructor(message: string, cause?: unknown) { + super(message, { cause }); + } +} + +/** + * Error thrown when a query fails. + */ export class QueryError extends Error { - constructor(message: string) { - super(message); + constructor(message: string, cause?: unknown) { + super(message, { cause }); } } +/** + * Error thrown when an internal error occurs. + */ export class InternalError extends Error { constructor(message: string) { super(message); } } +/** + * Error thrown when an entity is not found. + */ export class NotFoundError extends Error { constructor(model: string) { super(`Entity not found for model "${model}"`); diff --git a/packages/runtime/src/client/executor/zenstack-query-executor.ts b/packages/runtime/src/client/executor/zenstack-query-executor.ts index bb9a5472..440b7e1f 100644 --- a/packages/runtime/src/client/executor/zenstack-query-executor.ts +++ b/packages/runtime/src/client/executor/zenstack-query-executor.ts @@ -4,11 +4,9 @@ import { DefaultQueryExecutor, DeleteQueryNode, InsertQueryNode, - Kysely, ReturningNode, SelectionNode, SelectQueryNode, - SingleConnectionProvider, UpdateQueryNode, WhereNode, type ConnectionProvider, @@ -21,12 +19,13 @@ import { type TableNode, } from 'kysely'; import { nanoid } from 'nanoid'; +import { inspect } from 'node:util'; import { match } from 'ts-pattern'; import type { GetModels, SchemaDef } from '../../schema'; -import type { ClientImpl } from '../client-impl'; +import { type ClientImpl } from '../client-impl'; import type { ClientContract } from '../contract'; import { InternalError, QueryError } from '../errors'; -import type { MutationInterceptionFilterResult, OnKyselyQueryTransactionCallback } from '../plugin'; +import type { MutationInterceptionFilterResult } from '../plugin'; import { QueryNameMapper } from './name-mapper'; import type { ZenStackDriver } from './zenstack-driver'; @@ -36,7 +35,7 @@ export class ZenStackQueryExecutor extends DefaultQuer private readonly nameMapper: QueryNameMapper; constructor( - private readonly client: ClientImpl, + private client: ClientImpl, private readonly driver: ZenStackDriver, private readonly compiler: QueryCompiler, adapter: DialectAdapter, @@ -64,7 +63,9 @@ export class ZenStackQueryExecutor extends DefaultQuer const task = async () => { // call before mutation hooks - await this.callBeforeMutationHooks(queryNode, mutationInterceptionInfo); + if (this.isMutationNode(queryNode)) { + await this.callBeforeMutationHooks(queryNode, mutationInterceptionInfo); + } // TODO: make sure insert and delete return rows const oldQueryNode = queryNode; @@ -86,7 +87,9 @@ export class ZenStackQueryExecutor extends DefaultQuer const result = await this.proceedQueryWithKyselyInterceptors(queryNode, queryParams, queryId); // call after mutation hooks - await this.callAfterQueryInterceptionFilters(result, queryNode, mutationInterceptionInfo); + if (this.isMutationNode(queryNode)) { + await this.callAfterQueryInterceptionFilters(result, queryNode, mutationInterceptionInfo); + } if (oldQueryNode !== queryNode) { // TODO: trim the result to the original query node @@ -95,7 +98,7 @@ export class ZenStackQueryExecutor extends DefaultQuer return result; }; - return this.executeWithTransaction(task, !!mutationInterceptionInfo?.useTransactionForMutation); + return task(); } private proceedQueryWithKyselyInterceptors( @@ -105,9 +108,10 @@ export class ZenStackQueryExecutor extends DefaultQuer ) { let proceed = (q: RootOperationNode) => this.proceedQuery(q, parameters, queryId); - const makeTx = (p: typeof proceed) => (callback: OnKyselyQueryTransactionCallback) => { - return this.executeWithTransaction(() => callback(p)); - }; + // TODO: transactional hooks + // const makeTx = (p: typeof proceed) => (callback: OnKyselyQueryTransactionCallback) => { + // return this.executeWithTransaction(() => callback(p)); + // }; const hooks = this.options.plugins @@ -123,7 +127,8 @@ export class ZenStackQueryExecutor extends DefaultQuer kysely: this.kysely, query, proceed: _proceed, - transaction: makeTx(_proceed), + // TODO: transactional hooks + // transaction: makeTx(_proceed), }); }; } @@ -138,16 +143,22 @@ export class ZenStackQueryExecutor extends DefaultQuer if (parameters) { compiled = { ...compiled, parameters }; } + try { - return this.driver.txConnection - ? await super - .withConnectionProvider(new SingleConnectionProvider(this.driver.txConnection)) - .executeQuery(compiled, queryId) - : await super.executeQuery(compiled, queryId); + return await super.executeQuery(compiled, queryId); + + // TODO: transaction hooks + // return this.driver.txConnection + // ? await super + // .withConnectionProvider(new SingleConnectionProvider(this.driver.txConnection)) + // .executeQuery(compiled, queryId) + // : await super.executeQuery(compiled, queryId); } catch (err) { - throw new QueryError( - `Failed to execute query: ${err}, sql: ${compiled.sql}, parameters: ${compiled.parameters}`, - ); + let message = `Failed to execute query: ${err}, sql: ${compiled.sql}`; + if (this.options.debug) { + message += `, parameters: \n${compiled.parameters.map((p) => inspect(p)).join('\n')}`; + } + throw new QueryError(message, err); } } @@ -199,25 +210,16 @@ export class ZenStackQueryExecutor extends DefaultQuer } override withConnectionProvider(connectionProvider: ConnectionProvider) { - return new ZenStackQueryExecutor(this.client, this.driver, this.compiler, this.adapter, connectionProvider); - } - - private async executeWithTransaction(callback: () => Promise, useTransaction = true) { - if (!useTransaction || this.driver.txConnection) { - return callback(); - } else { - return this.provideConnection(async (connection) => { - try { - await this.driver.beginTransaction(connection, {}); - const result = await callback(); - await this.driver.commitTransaction(connection); - return result; - } catch (error) { - await this.driver.rollbackTransaction(connection); - throw error; - } - }); - } + const newExecutor = new ZenStackQueryExecutor( + this.client, + this.driver, + this.compiler, + this.adapter, + connectionProvider, + ); + // replace client with a new one associated with the new executor + newExecutor.client = this.client.withExecutor(newExecutor); + return newExecutor; } private get hasMutationHooks() { @@ -274,7 +276,6 @@ export class ZenStackQueryExecutor extends DefaultQuer queryNode, }); result.intercept ||= filterResult.intercept; - result.useTransactionForMutation ||= filterResult.useTransactionForMutation; result.loadBeforeMutationEntity ||= filterResult.loadBeforeMutationEntity; result.loadAfterMutationEntity ||= filterResult.loadAfterMutationEntity; } @@ -282,7 +283,7 @@ export class ZenStackQueryExecutor extends DefaultQuer let beforeMutationEntities: Record[] | undefined; if (result.loadBeforeMutationEntity && (UpdateQueryNode.is(queryNode) || DeleteQueryNode.is(queryNode))) { - beforeMutationEntities = await this.loadEntities(this.kysely, mutationModel, where); + beforeMutationEntities = await this.loadEntities(mutationModel, where); } return { @@ -297,7 +298,7 @@ export class ZenStackQueryExecutor extends DefaultQuer } } - private callBeforeMutationHooks( + private async callBeforeMutationHooks( queryNode: OperationNode, mutationInterceptionInfo: Awaited>, ) { @@ -308,8 +309,7 @@ export class ZenStackQueryExecutor extends DefaultQuer if (this.options.plugins) { for (const plugin of this.options.plugins) { if (plugin.beforeEntityMutation) { - plugin.beforeEntityMutation({ - // context: this.queryContext, + await plugin.beforeEntityMutation({ model: this.getMutationModel(queryNode), action: mutationInterceptionInfo.action, queryNode, @@ -337,7 +337,6 @@ export class ZenStackQueryExecutor extends DefaultQuer if (mutationInterceptionInfo.loadAfterMutationEntity) { if (UpdateQueryNode.is(queryNode)) { afterMutationEntities = await this.loadEntities( - this.kysely, mutationModel, mutationInterceptionInfo.where, ); @@ -346,7 +345,7 @@ export class ZenStackQueryExecutor extends DefaultQuer } } - plugin.afterEntityMutation({ + await plugin.afterEntityMutation({ model: this.getMutationModel(queryNode), action: mutationInterceptionInfo.action, queryNode, @@ -359,18 +358,17 @@ export class ZenStackQueryExecutor extends DefaultQuer } private async loadEntities( - kysely: Kysely, model: GetModels, where: WhereNode | undefined, ): Promise[]> { - const selectQuery = kysely.selectFrom(model).selectAll(); + const selectQuery = this.kysely.selectFrom(model).selectAll(); let selectQueryNode = selectQuery.toOperationNode() as SelectQueryNode; selectQueryNode = { ...selectQueryNode, where: this.andNodes(selectQueryNode.where, where), }; - const compiled = kysely.getExecutor().compileQuery(selectQueryNode, { queryId: `zenstack-${nanoid()}` }); - const result = await kysely.executeQuery(compiled); + const compiled = this.compileQuery(selectQueryNode); + const result = await this.executeQuery(compiled, { queryId: `zenstack-${nanoid()}` }); return result.rows as Record[]; } diff --git a/packages/runtime/src/client/helpers/schema-db-pusher.ts b/packages/runtime/src/client/helpers/schema-db-pusher.ts index 34974482..9bb5ba19 100644 --- a/packages/runtime/src/client/helpers/schema-db-pusher.ts +++ b/packages/runtime/src/client/helpers/schema-db-pusher.ts @@ -154,6 +154,7 @@ export class SchemaDbPusher { .with('Decimal', () => 'decimal') .with('DateTime', () => 'timestamp') .with('Bytes', () => (this.schema.provider.type === 'postgresql' ? 'bytea' : 'blob')) + .with('Json', () => 'jsonb') .otherwise(() => { throw new Error(`Unsupported field type: ${type}`); }); diff --git a/packages/runtime/src/client/options.ts b/packages/runtime/src/client/options.ts index 150c8e16..a2603537 100644 --- a/packages/runtime/src/client/options.ts +++ b/packages/runtime/src/client/options.ts @@ -47,6 +47,11 @@ export type ClientOptions = { * Logging configuration. */ log?: KyselyConfig['log']; + + /** + * Debug mode. + */ + debug?: boolean; } & (HasComputedFields extends true ? { /** diff --git a/packages/runtime/src/client/plugin.ts b/packages/runtime/src/client/plugin.ts index 6141c4e9..717a02a9 100644 --- a/packages/runtime/src/client/plugin.ts +++ b/packages/runtime/src/client/plugin.ts @@ -36,11 +36,6 @@ export type MutationInterceptionFilterResult = { */ intercept: boolean; - /** - * Whether to use a transaction for the mutation. - */ - useTransactionForMutation?: boolean; - /** * Whether entities should be loaded before the mutation. */ @@ -97,7 +92,6 @@ export type OnKyselyQueryArgs = { client: ClientContract; query: RootOperationNode; proceed: ProceedKyselyQueryFunction; - transaction: OnKyselyQueryTransaction; }; export type ProceedKyselyQueryFunction = (query: RootOperationNode) => Promise>; @@ -163,7 +157,7 @@ type OnQueryHooks = { type OnQueryOperationHooks> = { [Operation in keyof ModelOperations]?: ( ctx: OnQueryHookContext, - ) => ReturnType[Operation]>; + ) => Promise[Operation]>>>; } & { $allOperations?: (ctx: { model: Model; @@ -198,11 +192,10 @@ type OnQueryHookContext< * It takes the same arguments as the operation method. * * @param args The query arguments. - * @param tx Optional transaction client to use for the query. */ query: ( args: Parameters[Operation]>[0], - tx?: ClientContract, + // tx?: ClientContract, ) => ReturnType[Operation]>; /** diff --git a/packages/runtime/src/client/promise.ts b/packages/runtime/src/client/promise.ts index 00e4f5c2..f3c261a1 100644 --- a/packages/runtime/src/client/promise.ts +++ b/packages/runtime/src/client/promise.ts @@ -1,12 +1,28 @@ +import type { SchemaDef } from '../schema'; +import type { ClientContract } from './contract'; + +/** + * A promise that only executes when it's awaited or .then() is called. + */ +export type ZenStackPromise = Promise & { + /** + * @private + * Callable to get a plain promise. + */ + cb: (txClient?: ClientContract) => Promise; +}; + /** * Creates a promise that only executes when it's awaited or .then() is called. * @see https://github.com/prisma/prisma/blob/main/packages/client/src/runtime/core/request/createPrismaPromise.ts */ -export function createDeferredPromise(callback: () => Promise): Promise { +export function createZenStackPromise( + callback: (txClient?: ClientContract) => Promise, +): ZenStackPromise { let promise: Promise | undefined; - const cb = () => { + const cb = (txClient?: ClientContract) => { try { - return (promise ??= valueToPromise(callback())); + return (promise ??= valueToPromise(callback(txClient))); } catch (err) { // deal with synchronous errors return Promise.reject(err); @@ -23,6 +39,7 @@ export function createDeferredPromise(callback: () => Promise): Promise finally(onFinally) { return cb().finally(onFinally); }, + cb, [Symbol.toStringTag]: 'ZenStackPromise', }; } diff --git a/packages/runtime/src/client/query-utils.ts b/packages/runtime/src/client/query-utils.ts index f47ac1e7..2f341673 100644 --- a/packages/runtime/src/client/query-utils.ts +++ b/packages/runtime/src/client/query-utils.ts @@ -17,7 +17,7 @@ export function getModel(schema: SchemaDef, model: string) { export function requireModel(schema: SchemaDef, model: string) { const matchedName = Object.keys(schema.models).find((k) => k.toLowerCase() === model.toLowerCase()); if (!matchedName) { - throw new QueryError(`Model "${model}" not found`); + throw new QueryError(`Model "${model}" not found in schema`); } return schema.models[matchedName]!; } @@ -164,7 +164,7 @@ export function buildFieldRef( computer = computedFields?.[model]?.[field]; } if (!computer) { - throw new QueryError(`Computed field "${field}" implementation not provided`); + throw new QueryError(`Computed field "${field}" implementation not provided for model "${model}"`); } return computer(eb); } diff --git a/packages/runtime/src/client/result-processor.ts b/packages/runtime/src/client/result-processor.ts index 6a922c18..25a2a4df 100644 --- a/packages/runtime/src/client/result-processor.ts +++ b/packages/runtime/src/client/result-processor.ts @@ -90,6 +90,7 @@ export class ResultProcessor { .with('Bytes', () => this.transformBytes(value)) .with('Decimal', () => this.transformDecimal(value)) .with('BigInt', () => this.transformBigInt(value)) + .with('Json', () => this.transformJson(value)) .otherwise(() => value); } @@ -134,6 +135,10 @@ export class ResultProcessor { } private fixReversedResult(data: any, model: GetModels, args: any) { + if (!data) { + return; + } + if (Array.isArray(data) && typeof args === 'object' && args && args.take !== undefined && args.take < 0) { data.reverse(); } @@ -149,11 +154,21 @@ export class ResultProcessor { continue; } const fieldDef = getField(this.schema, model, field); - if (!fieldDef?.relation) { + if (!fieldDef || !fieldDef.relation || !fieldDef.array) { continue; } this.fixReversedResult(row[field], fieldDef.type as GetModels, value); } } } + + private transformJson(value: unknown) { + return match(this.schema.provider.type) + .with('sqlite', () => { + // better-sqlite3 returns JSON as string + invariant(typeof value === 'string', 'Expected string, got ' + typeof value); + return JSON.parse(value as string); + }) + .otherwise(() => value); + } } diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 2fd331c1..4f1cce44 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -1 +1 @@ -export { ZenStackClient, type ClientContract } from './client'; +export * from './client'; diff --git a/packages/runtime/src/plugins/policy/expression-transformer.ts b/packages/runtime/src/plugins/policy/expression-transformer.ts index 23725791..bbc98881 100644 --- a/packages/runtime/src/plugins/policy/expression-transformer.ts +++ b/packages/runtime/src/plugins/policy/expression-transformer.ts @@ -275,7 +275,7 @@ export class ExpressionTransformer { } private transformValue(value: unknown, type: BuiltinType) { - return ValueNode.create(this.dialect.transformPrimitive(value, type) ?? null); + return ValueNode.create(this.dialect.transformPrimitive(value, type, false) ?? null); } @expr('unary') diff --git a/packages/runtime/src/plugins/policy/plugin.ts b/packages/runtime/src/plugins/policy/plugin.ts index 15b35454..e5b914d5 100644 --- a/packages/runtime/src/plugins/policy/plugin.ts +++ b/packages/runtime/src/plugins/policy/plugin.ts @@ -15,8 +15,8 @@ export class PolicyPlugin implements RuntimePlugin) { + onKyselyQuery({ query, client, proceed /*, transaction*/ }: OnKyselyQueryArgs) { const handler = new PolicyHandler(client); - return handler.handle(query, proceed, transaction); + return handler.handle(query, proceed /*, transaction*/); } } diff --git a/packages/runtime/src/plugins/policy/policy-handler.ts b/packages/runtime/src/plugins/policy/policy-handler.ts index 31b7174c..7cb672c2 100644 --- a/packages/runtime/src/plugins/policy/policy-handler.ts +++ b/packages/runtime/src/plugins/policy/policy-handler.ts @@ -29,7 +29,7 @@ import type { CRUD } from '../../client/contract'; import { getCrudDialect } from '../../client/crud/dialects'; import type { BaseCrudDialect } from '../../client/crud/dialects/base'; import { InternalError } from '../../client/errors'; -import type { OnKyselyQueryTransaction, ProceedKyselyQueryFunction } from '../../client/plugin'; +import type { ProceedKyselyQueryFunction } from '../../client/plugin'; import { getIdFields, requireField, requireModel } from '../../client/query-utils'; import { ExpressionUtils, type BuiltinType, type Expression, type GetModels, type SchemaDef } from '../../schema'; import { ColumnCollector } from './column-collector'; @@ -54,9 +54,12 @@ export class PolicyHandler extends OperationNodeTransf return this.client.$qb; } - async handle(node: RootOperationNode, proceed: ProceedKyselyQueryFunction, transaction: OnKyselyQueryTransaction) { + async handle( + node: RootOperationNode, + proceed: ProceedKyselyQueryFunction /*, transaction: OnKyselyQueryTransaction*/, + ) { if (!this.isCrudQueryNode(node)) { - // non CRUD queries are not allowed + // non-CRUD queries are not allowed throw new RejectedByPolicyError(undefined, 'non-CRUD queries are not allowed'); } @@ -83,32 +86,49 @@ export class PolicyHandler extends OperationNodeTransf return proceed(this.transformNode(node)); } - let readBackError = false; - - // transform and post-process in a transaction - const result = await transaction(async (txProceed) => { - if (InsertQueryNode.is(node)) { - await this.enforcePreCreatePolicy(node, txProceed); - } - const transformedNode = this.transformNode(node); - const result = await txProceed(transformedNode); + if (InsertQueryNode.is(node)) { + await this.enforcePreCreatePolicy(node, proceed); + } + const transformedNode = this.transformNode(node); + const result = await proceed(transformedNode); - if (!this.onlyReturningId(node)) { - const readBackResult = await this.processReadBack(node, result, txProceed); - if (readBackResult.rows.length !== result.rows.length) { - readBackError = true; - } - return readBackResult; - } else { - return result; + if (!this.onlyReturningId(node)) { + const readBackResult = await this.processReadBack(node, result, proceed); + if (readBackResult.rows.length !== result.rows.length) { + throw new RejectedByPolicyError(mutationModel, 'result is not allowed to be read back'); } - }); - - if (readBackError) { - throw new RejectedByPolicyError(mutationModel, 'result is not allowed to be read back'); + return readBackResult; + } else { + return result; } - return result; + // TODO: run in transaction + //let readBackError = false; + + // transform and post-process in a transaction + // const result = await transaction(async (txProceed) => { + // if (InsertQueryNode.is(node)) { + // await this.enforcePreCreatePolicy(node, txProceed); + // } + // const transformedNode = this.transformNode(node); + // const result = await txProceed(transformedNode); + + // if (!this.onlyReturningId(node)) { + // const readBackResult = await this.processReadBack(node, result, txProceed); + // if (readBackResult.rows.length !== result.rows.length) { + // readBackError = true; + // } + // return readBackResult; + // } else { + // return result; + // } + // }); + + // if (readBackError) { + // throw new RejectedByPolicyError(mutationModel, 'result is not allowed to be read back'); + // } + + // return result; } private onlyReturningId(node: MutationQueryNode) { @@ -185,12 +205,16 @@ export class PolicyHandler extends OperationNodeTransf invariant(item.kind === 'ValueNode', 'expecting a ValueNode'); result.push({ node: ValueNode.create( - this.dialect.transformPrimitive((item as ValueNode).value, fieldDef.type as BuiltinType), + this.dialect.transformPrimitive( + (item as ValueNode).value, + fieldDef.type as BuiltinType, + !!fieldDef.array, + ), ), raw: (item as ValueNode).value, }); } else { - const value = this.dialect.transformPrimitive(item, fieldDef.type as BuiltinType); + const value = this.dialect.transformPrimitive(item, fieldDef.type as BuiltinType, !!fieldDef.array); if (Array.isArray(value)) { result.push({ node: RawNode.createWithSql(this.dialect.buildArrayLiteralSQL(value)), diff --git a/packages/runtime/src/plugins/policy/utils.ts b/packages/runtime/src/plugins/policy/utils.ts index 01960533..3c2e641d 100644 --- a/packages/runtime/src/plugins/policy/utils.ts +++ b/packages/runtime/src/plugins/policy/utils.ts @@ -19,14 +19,14 @@ import type { SchemaDef } from '../../schema'; * Creates a `true` value node. */ export function trueNode(dialect: BaseCrudDialect) { - return ValueNode.createImmediate(dialect.transformPrimitive(true, 'Boolean')); + return ValueNode.createImmediate(dialect.transformPrimitive(true, 'Boolean', false)); } /** * Creates a `false` value node. */ export function falseNode(dialect: BaseCrudDialect) { - return ValueNode.createImmediate(dialect.transformPrimitive(false, 'Boolean')); + return ValueNode.createImmediate(dialect.transformPrimitive(false, 'Boolean', false)); } /** diff --git a/packages/runtime/src/utils/type-utils.ts b/packages/runtime/src/utils/type-utils.ts index c1bd0d01..abd963a5 100644 --- a/packages/runtime/src/utils/type-utils.ts +++ b/packages/runtime/src/utils/type-utils.ts @@ -68,3 +68,7 @@ export type PrependParameter = Func extends (...args: any[]) => inf : never; export type OrUndefinedIf = Condition extends true ? T | undefined : T; + +export type UnwrapTuplePromises = { + [K in keyof T]: Awaited; +}; diff --git a/packages/runtime/test/client-api/create.test.ts b/packages/runtime/test/client-api/create.test.ts index 4004d7cc..6ac87637 100644 --- a/packages/runtime/test/client-api/create.test.ts +++ b/packages/runtime/test/client-api/create.test.ts @@ -1,6 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '../../src/client'; -import { QueryError } from '../../src/client/errors'; import { schema } from '../test-schema'; import { createClientSpecs } from './client-specs'; @@ -290,20 +289,32 @@ describe.each(createClientSpecs(PG_DB_NAME))('Client create tests', ({ createCli expect(u3.posts.map((p) => p.title)).toEqual(expect.arrayContaining(['Post1', 'Post2', 'Post4'])); }); - it('rejects empty relation payload', async () => { - await expect( - client.post.create({ - data: { title: 'Post1', author: {} }, - }), - ).rejects.toThrow('At least one action is required'); + it('complies with Prisma checked/unchecked typing', async () => { + const user = await client.user.create({ + data: { email: 'u1@test.com' }, + }); + // fk and owned-relation are mutually exclusive + client.post.create({ + // @ts-expect-error + data: { + authorId: user.id, + title: 'title', + author: { connect: { id: user.id } }, + }, + }); + + // fk can work with non-owned relation await expect( - client.user.create({ + client.post.create({ data: { - email: 'u1@test.com', - posts: {}, + authorId: user.id, + title: 'title', + comments: { + create: { content: 'comment' }, + }, }, }), - ).rejects.toThrow(QueryError); + ).toResolveTruthy(); }); }); diff --git a/packages/runtime/test/client-api/find.test.ts b/packages/runtime/test/client-api/find.test.ts index 6d70c769..8bc60821 100644 --- a/packages/runtime/test/client-api/find.test.ts +++ b/packages/runtime/test/client-api/find.test.ts @@ -645,6 +645,56 @@ describe.each(createClientSpecs(PG_DB_NAME))('Client find tests for $provider', email: 'u1@test.com', createdAt: expect.any(Date), }); + + const r2 = await client.user.findUnique({ + where: { id: user.id }, + select: { + id: true, + posts: { + select: { + id: true, + author: { + select: { email: true }, + }, + }, + }, + }, + }); + expect(r2).toMatchObject({ + id: user.id, + posts: expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + author: { + email: 'u1@test.com', + }, + }), + ]), + }); + + const r3 = await client.user.findUnique({ + where: { id: user.id }, + include: { + posts: { + include: { + author: { + select: { email: true }, + }, + }, + }, + }, + }); + expect(r3).toMatchObject({ + id: user.id, + posts: expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + author: { + email: 'u1@test.com', + }, + }), + ]), + }); }); it('allows field omission', async () => { @@ -775,7 +825,7 @@ describe.each(createClientSpecs(PG_DB_NAME))('Client find tests for $provider', await expect( client.user.findUnique({ where: { id: user1.id }, - select: { _count: true }, + select: { id: true, _count: true }, }), ).resolves.toMatchObject({ _count: { posts: 2 }, @@ -784,12 +834,21 @@ describe.each(createClientSpecs(PG_DB_NAME))('Client find tests for $provider', await expect( client.user.findUnique({ where: { id: user1.id }, - select: { _count: { select: { posts: true } } }, + select: { id: true, _count: { select: { posts: true } } }, }), ).resolves.toMatchObject({ _count: { posts: 2 }, }); + await expect( + client.user.findUnique({ + where: { id: user1.id }, + select: { id: true, _count: { select: { posts: { where: { published: true } } } } }, + }), + ).resolves.toMatchObject({ + _count: { posts: 1 }, + }); + await expect( client.user.findUnique({ where: { id: user1.id }, diff --git a/packages/runtime/test/client-api/raw-query.test.ts b/packages/runtime/test/client-api/raw-query.test.ts index f8ad6d41..05049ba7 100644 --- a/packages/runtime/test/client-api/raw-query.test.ts +++ b/packages/runtime/test/client-api/raw-query.test.ts @@ -5,7 +5,7 @@ import { createClientSpecs } from './client-specs'; const PG_DB_NAME = 'client-api-raw-query-tests'; -describe.each(createClientSpecs(PG_DB_NAME, true))('Client raw query tests', ({ createClient, provider }) => { +describe.each(createClientSpecs(PG_DB_NAME))('Client raw query tests', ({ createClient, provider }) => { let client: ClientContract; beforeEach(async () => { diff --git a/packages/runtime/test/client-api/transaction.test.ts b/packages/runtime/test/client-api/transaction.test.ts new file mode 100644 index 00000000..60d58178 --- /dev/null +++ b/packages/runtime/test/client-api/transaction.test.ts @@ -0,0 +1,158 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { ClientContract } from '../../src/client'; +import { schema } from '../test-schema'; +import { createClientSpecs } from './client-specs'; + +const PG_DB_NAME = 'client-api-transaction-tests'; + +describe.each(createClientSpecs(PG_DB_NAME))('Client raw query tests', ({ createClient }) => { + let client: ClientContract; + + beforeEach(async () => { + client = await createClient(); + }); + + afterEach(async () => { + await client?.$disconnect(); + }); + + describe('interactive transaction', () => { + it('works with simple successful transaction', async () => { + const users = await client.$transaction(async (tx) => { + const u1 = await tx.user.create({ + data: { + email: 'u1@test.com', + }, + }); + const u2 = await tx.user.create({ + data: { + email: 'u2@test.com', + }, + }); + return [u1, u2]; + }); + + expect(users).toEqual( + expect.arrayContaining([ + expect.objectContaining({ email: 'u1@test.com' }), + expect.objectContaining({ email: 'u2@test.com' }), + ]), + ); + + await expect(client.user.findMany()).toResolveWithLength(2); + }); + + it('works with simple failed transaction', async () => { + await expect( + client.$transaction(async (tx) => { + const u1 = await tx.user.create({ + data: { + email: 'u1@test.com', + }, + }); + const u2 = await tx.user.create({ + data: { + email: 'u1@test.com', + }, + }); + return [u1, u2]; + }), + ).rejects.toThrow(); + + await expect(client.user.findMany()).toResolveWithLength(0); + }); + + it('works with nested successful transactions', async () => { + await client.$transaction(async (tx) => { + const u1 = await tx.user.create({ + data: { + email: 'u1@test.com', + }, + }); + const u2 = await (tx as any).$transaction((tx2: any) => + tx2.user.create({ + data: { + email: 'u2@test.com', + }, + }), + ); + return [u1, u2]; + }); + + await expect(client.user.findMany()).toResolveWithLength(2); + }); + + it('works with nested failed transaction', async () => { + await expect( + client.$transaction(async (tx) => { + const u1 = await tx.user.create({ + data: { + email: 'u1@test.com', + }, + }); + const u2 = await (tx as any).$transaction((tx2: any) => + tx2.user.create({ + data: { + email: 'u1@test.com', + }, + }), + ); + return [u1, u2]; + }), + ).rejects.toThrow(); + + await expect(client.user.findMany()).toResolveWithLength(0); + }); + }); + + describe('sequential transaction', () => { + it('works with empty array', async () => { + const users = await client.$transaction([]); + expect(users).toEqual([]); + }); + + it('does not execute promises directly', async () => { + const promises = [ + client.user.create({ data: { email: 'u1@test.com' } }), + client.user.create({ data: { email: 'u2@test.com' } }), + ]; + await expect(client.user.findMany()).toResolveWithLength(0); + await client.$transaction(promises); + await expect(client.user.findMany()).toResolveWithLength(2); + }); + + it('works with simple successful transaction', async () => { + const users = await client.$transaction([ + client.user.create({ data: { email: 'u1@test.com' } }), + client.user.create({ data: { email: 'u2@test.com' } }), + client.user.count(), + ]); + expect(users).toEqual([ + expect.objectContaining({ email: 'u1@test.com' }), + expect.objectContaining({ email: 'u2@test.com' }), + 2, + ]); + }); + + it('preserves execution order', async () => { + const users = await client.$transaction([ + client.user.create({ data: { id: '1', email: 'u1@test.com' } }), + client.user.update({ where: { id: '1' }, data: { email: 'u2@test.com' } }), + ]); + expect(users).toEqual([ + expect.objectContaining({ email: 'u1@test.com' }), + expect.objectContaining({ email: 'u2@test.com' }), + ]); + }); + + it('rolls back on error', async () => { + await expect( + client.$transaction([ + client.user.create({ data: { id: '1', email: 'u1@test.com' } }), + client.user.create({ data: { id: '1', email: 'u2@test.com' } }), + ]), + ).rejects.toThrow(); + await expect(client.user.findMany()).toResolveWithLength(0); + }); + }); +}); diff --git a/packages/runtime/test/client-api/type-coverage.test.ts b/packages/runtime/test/client-api/type-coverage.test.ts index f525026b..50f3e2bb 100644 --- a/packages/runtime/test/client-api/type-coverage.test.ts +++ b/packages/runtime/test/client-api/type-coverage.test.ts @@ -2,13 +2,30 @@ import Decimal from 'decimal.js'; import { describe, expect, it } from 'vitest'; import { createTestClient } from '../utils'; -describe('zmodel type coverage tests', () => { - it('supports all types', async () => { - const db = await createTestClient( - ` +const PG_DB_NAME = 'client-api-type-coverage-tests'; + +describe.each(['sqlite', 'postgresql'] as const)('zmodel type coverage tests', (provider) => { + it('supports all types - plain', async () => { + const date = new Date(); + const data = { + id: '1', + String: 'string', + Int: 100, + BigInt: BigInt(9007199254740991), + DateTime: date, + Float: 1.23, + Decimal: new Decimal(1.2345), + Boolean: true, + Bytes: new Uint8Array([1, 2, 3, 4]), + Json: { foo: 'bar' }, + }; + + let db: any; + try { + db = await createTestClient( + ` model Foo { id String @id @default(cuid()) - String String Int Int BigInt BigInt @@ -17,28 +34,128 @@ describe('zmodel type coverage tests', () => { Decimal Decimal Boolean Boolean Bytes Bytes + Json Json + } + `, + { provider, dbName: PG_DB_NAME }, + ); + + await db.foo.create({ data }); + await expect(db.foo.findUnique({ where: { id: '1' } })).resolves.toMatchObject(data); + } finally { + await db?.$disconnect(); + } + }); - @@allow('all', true) + it('supports all types - default values', async () => { + let db: any; + try { + db = await createTestClient( + ` + model Foo { + id String @id @default(cuid()) + String String @default("default") + Int Int @default(100) + BigInt BigInt @default(9007199254740991) + DateTime DateTime @default("2021-01-01T00:00:00.000Z") + Float Float @default(1.23) + Decimal Decimal @default(1.2345) + Boolean Boolean @default(true) + Json Json @default("{\\"foo\\":\\"bar\\"}") } `, - ); + { provider, dbName: PG_DB_NAME }, + ); + + await db.foo.create({ data: { id: '1' } }); + await expect(db.foo.findUnique({ where: { id: '1' } })).resolves.toMatchObject({ + String: 'default', + Int: 100, + BigInt: BigInt(9007199254740991), + DateTime: expect.any(Date), + Float: 1.23, + Decimal: new Decimal(1.2345), + Boolean: true, + Json: { foo: 'bar' }, + }); + } finally { + await db?.$disconnect(); + } + }); + + it('supports all types - array', async () => { + if (provider === 'sqlite') { + return; + } const date = new Date(); const data = { id: '1', - String: 'string', - Int: 100, - BigInt: BigInt(9007199254740991), - DateTime: date, - Float: 1.23, - Decimal: new Decimal(1.2345), - Boolean: true, - Bytes: new Uint8Array([1, 2, 3, 4]), + String: ['string'], + Int: [100], + BigInt: [BigInt(9007199254740991)], + DateTime: [date], + Float: [1.23], + Decimal: [new Decimal(1.2345)], + Boolean: [true], + Bytes: [new Uint8Array([1, 2, 3, 4])], + Json: [{ hello: 'world' }], + }; + + let db: any; + try { + db = await createTestClient( + ` + model Foo { + id String @id @default(cuid()) + + String String[] + Int Int[] + BigInt BigInt[] + DateTime DateTime[] + Float Float[] + Decimal Decimal[] + Boolean Boolean[] + Bytes Bytes[] + Json Json[] + } + `, + { provider, dbName: PG_DB_NAME }, + ); + + await db.foo.create({ data }); + await expect(db.foo.findUnique({ where: { id: '1' } })).resolves.toMatchObject(data); + } finally { + await db?.$disconnect(); + } + }); + + it('supports all types - array for plain json field', async () => { + if (provider === 'sqlite') { + return; + } + + const data = { + id: '1', + Json: [{ hello: 'world' }], }; - await db.foo.create({ data }); + let db: any; + try { + db = await createTestClient( + ` + model Foo { + id String @id @default(cuid()) + Json Json + } + `, + { provider, dbName: PG_DB_NAME }, + ); - const r = await db.foo.findUnique({ where: { id: '1' } }); - expect(r.Bytes).toEqual(data.Bytes); + await db.foo.create({ data }); + await expect(db.foo.findUnique({ where: { id: '1' } })).resolves.toMatchObject(data); + } finally { + await db?.$disconnect(); + } }); }); diff --git a/packages/runtime/test/client-api/undefined-values.test.ts b/packages/runtime/test/client-api/undefined-values.test.ts new file mode 100644 index 00000000..e9657b9e --- /dev/null +++ b/packages/runtime/test/client-api/undefined-values.test.ts @@ -0,0 +1,42 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { ClientContract } from '../../src/client'; +import { schema } from '../test-schema'; +import { createClientSpecs } from './client-specs'; +import { createUser } from './utils'; + +const PG_DB_NAME = 'client-api-undefined-values-tests'; + +describe.each(createClientSpecs(PG_DB_NAME))('Client undefined values tests for $provider', ({ createClient }) => { + let client: ClientContract; + + beforeEach(async () => { + client = await createClient(); + }); + + afterEach(async () => { + await client?.$disconnect(); + }); + + it('works with toplevel undefined args', async () => { + await expect(client.user.findMany(undefined)).toResolveTruthy(); + }); + + it('ignored with undefined filter values', async () => { + const user = await createUser(client, 'u1@test.com'); + await expect( + client.user.findFirst({ + where: { + id: undefined, + }, + }), + ).resolves.toMatchObject(user); + + await expect( + client.user.findFirst({ + where: { + email: undefined, + }, + }), + ).resolves.toMatchObject(user); + }); +}); diff --git a/packages/runtime/test/plugin/kysely-on-query.test.ts b/packages/runtime/test/plugin/kysely-on-query.test.ts index d18f43c0..b098c8a4 100644 --- a/packages/runtime/test/plugin/kysely-on-query.test.ts +++ b/packages/runtime/test/plugin/kysely-on-query.test.ts @@ -112,41 +112,42 @@ describe('Kysely onQuery tests', () => { }); }); - it('rolls back on error when a transaction is used', async () => { - const client = _client.$use({ - id: 'test-plugin', - async onKyselyQuery({ kysely, proceed, transaction, query }) { - if (query.kind !== 'InsertQueryNode') { - return proceed(query); - } - - return transaction(async (txProceed) => { - const result = await txProceed(query); - - // create a post for the user - const now = new Date().toISOString(); - const createPost = kysely.insertInto('Post').values({ - id: '1', - title: 'Post1', - authorId: 'none-exist', - updatedAt: now, - }); - await txProceed(createPost.toOperationNode()); - - return result; - }); - }, - }); - - await expect( - client.user.create({ - data: { id: '1', email: 'u1@test.com' }, - }), - ).rejects.toThrow('constraint failed'); - - await expect(client.user.findFirst()).toResolveNull(); - await expect(client.post.findFirst()).toResolveNull(); - }); + // TODO: revisit transactions + // it('rolls back on error when a transaction is used', async () => { + // const client = _client.$use({ + // id: 'test-plugin', + // async onKyselyQuery({ kysely, proceed, transaction, query }) { + // if (query.kind !== 'InsertQueryNode') { + // return proceed(query); + // } + + // return transaction(async (txProceed) => { + // const result = await txProceed(query); + + // // create a post for the user + // const now = new Date().toISOString(); + // const createPost = kysely.insertInto('Post').values({ + // id: '1', + // title: 'Post1', + // authorId: 'none-exist', + // updatedAt: now, + // }); + // await txProceed(createPost.toOperationNode()); + + // return result; + // }); + // }, + // }); + + // await expect( + // client.user.create({ + // data: { id: '1', email: 'u1@test.com' }, + // }), + // ).rejects.toThrow('constraint failed'); + + // await expect(client.user.findFirst()).toResolveNull(); + // await expect(client.post.findFirst()).toResolveNull(); + // }); it('works with multiple interceptors', async () => { let called1 = false; @@ -204,104 +205,106 @@ describe('Kysely onQuery tests', () => { await expect(called2).toBe(true); }); - it('works with multiple transactional interceptors - order 1', async () => { - let called1 = false; - let called2 = false; - - const client = _client - .$use({ - id: 'test-plugin', - async onKyselyQuery({ query, proceed }) { - if (query.kind !== 'InsertQueryNode') { - return proceed(query); - } - called1 = true; - await proceed(query); - throw new Error('test error'); - }, - }) - .$use({ - id: 'test-plugin2', - onKyselyQuery({ query, proceed, transaction }) { - if (query.kind !== 'InsertQueryNode') { - return proceed(query); - } - called2 = true; - return transaction(async (txProceed) => { - const valueList = [ - ...(((query as InsertQueryNode).values as ValuesNode).values[0] as PrimitiveValueListNode) - .values, - ]; - valueList[0] = 'u2@test.com'; - valueList[1] = 'Marvin1'; - const newQuery = InsertQueryNode.cloneWith(query as InsertQueryNode, { - values: ValuesNode.create([PrimitiveValueListNode.create(valueList)]), - }); - return txProceed(newQuery); - }); - }, - }); - - await expect( - client.user.create({ - data: { email: 'u1@test.com', name: 'Marvin' }, - }), - ).rejects.toThrow('test error'); - - await expect(called1).toBe(true); - await expect(called2).toBe(true); - await expect(client.user.findFirst()).toResolveNull(); - }); - - it('works with multiple transactional interceptors - order 2', async () => { - let called1 = false; - let called2 = false; - - const client = _client - .$use({ - id: 'test-plugin', - async onKyselyQuery({ query, proceed, transaction }) { - if (query.kind !== 'InsertQueryNode') { - return proceed(query); - } - called1 = true; - - return transaction(async (txProceed) => { - await txProceed(query); - throw new Error('test error'); - }); - }, - }) - .$use({ - id: 'test-plugin2', - onKyselyQuery({ query, proceed }) { - if (query.kind !== 'InsertQueryNode') { - return proceed(query); - } - called2 = true; - const valueList = [ - ...(((query as InsertQueryNode).values as ValuesNode).values[0] as PrimitiveValueListNode) - .values, - ]; - valueList[0] = 'u2@test.com'; - valueList[1] = 'Marvin1'; - const newQuery = InsertQueryNode.cloneWith(query as InsertQueryNode, { - values: ValuesNode.create([PrimitiveValueListNode.create(valueList)]), - }); - return proceed(newQuery); - }, - }); - - await expect( - client.user.create({ - data: { email: 'u1@test.com', name: 'Marvin' }, - }), - ).rejects.toThrow('test error'); - - await expect(called1).toBe(true); - await expect(called2).toBe(true); - await expect(client.user.findFirst()).toResolveNull(); - }); + // TODO: revisit transactions + // it('works with multiple transactional interceptors - order 1', async () => { + // let called1 = false; + // let called2 = false; + + // const client = _client + // .$use({ + // id: 'test-plugin', + // async onKyselyQuery({ query, proceed }) { + // if (query.kind !== 'InsertQueryNode') { + // return proceed(query); + // } + // called1 = true; + // await proceed(query); + // throw new Error('test error'); + // }, + // }) + // .$use({ + // id: 'test-plugin2', + // onKyselyQuery({ query, proceed, transaction }) { + // if (query.kind !== 'InsertQueryNode') { + // return proceed(query); + // } + // called2 = true; + // return transaction(async (txProceed) => { + // const valueList = [ + // ...(((query as InsertQueryNode).values as ValuesNode).values[0] as PrimitiveValueListNode) + // .values, + // ]; + // valueList[0] = 'u2@test.com'; + // valueList[1] = 'Marvin1'; + // const newQuery = InsertQueryNode.cloneWith(query as InsertQueryNode, { + // values: ValuesNode.create([PrimitiveValueListNode.create(valueList)]), + // }); + // return txProceed(newQuery); + // }); + // }, + // }); + + // await expect( + // client.user.create({ + // data: { email: 'u1@test.com', name: 'Marvin' }, + // }), + // ).rejects.toThrow('test error'); + + // await expect(called1).toBe(true); + // await expect(called2).toBe(true); + // await expect(client.user.findFirst()).toResolveNull(); + // }); + + // TODO: revisit transactions + // it('works with multiple transactional interceptors - order 2', async () => { + // let called1 = false; + // let called2 = false; + + // const client = _client + // .$use({ + // id: 'test-plugin', + // async onKyselyQuery({ query, proceed, transaction }) { + // if (query.kind !== 'InsertQueryNode') { + // return proceed(query); + // } + // called1 = true; + + // return transaction(async (txProceed) => { + // await txProceed(query); + // throw new Error('test error'); + // }); + // }, + // }) + // .$use({ + // id: 'test-plugin2', + // onKyselyQuery({ query, proceed }) { + // if (query.kind !== 'InsertQueryNode') { + // return proceed(query); + // } + // called2 = true; + // const valueList = [ + // ...(((query as InsertQueryNode).values as ValuesNode).values[0] as PrimitiveValueListNode) + // .values, + // ]; + // valueList[0] = 'u2@test.com'; + // valueList[1] = 'Marvin1'; + // const newQuery = InsertQueryNode.cloneWith(query as InsertQueryNode, { + // values: ValuesNode.create([PrimitiveValueListNode.create(valueList)]), + // }); + // return proceed(newQuery); + // }, + // }); + + // await expect( + // client.user.create({ + // data: { email: 'u1@test.com', name: 'Marvin' }, + // }), + // ).rejects.toThrow('test error'); + + // await expect(called1).toBe(true); + // await expect(called2).toBe(true); + // await expect(client.user.findFirst()).toResolveNull(); + // }); it('works with multiple interceptors with outer transaction', async () => { let called1 = false; @@ -352,127 +355,129 @@ describe('Kysely onQuery tests', () => { await expect(client.user.findFirst()).toResolveNull(); }); - it('works with nested transactional interceptors success', async () => { - let called1 = false; - let called2 = false; - - const client = _client - .$use({ - id: 'test-plugin', - onKyselyQuery({ query, proceed, transaction }) { - if (query.kind !== 'InsertQueryNode') { - return proceed(query); - } - called1 = true; - return transaction(async (txProceed) => { - const valueList = [ - ...(((query as InsertQueryNode).values as ValuesNode).values[0] as PrimitiveValueListNode) - .values, - ]; - valueList[1] = 'Marvin2'; - const newQuery = InsertQueryNode.cloneWith(query as InsertQueryNode, { - values: ValuesNode.create([PrimitiveValueListNode.create(valueList)]), - }); - return txProceed(newQuery); - }); - }, - }) - .$use({ - id: 'test-plugin2', - onKyselyQuery({ query, proceed, transaction }) { - if (query.kind !== 'InsertQueryNode') { - return proceed(query); - } - called2 = true; - return transaction(async (txProceed) => { - const valueList = [ - ...(((query as InsertQueryNode).values as ValuesNode).values[0] as PrimitiveValueListNode) - .values, - ]; - valueList[0] = 'u2@test.com'; - valueList[1] = 'Marvin1'; - const newQuery = InsertQueryNode.cloneWith(query as InsertQueryNode, { - values: ValuesNode.create([PrimitiveValueListNode.create(valueList)]), - }); - return txProceed(newQuery); - }); - }, - }); - - await expect( - client.user.create({ - data: { email: 'u1@test.com', name: 'Marvin' }, - }), - ).resolves.toMatchObject({ - email: 'u2@test.com', - name: 'Marvin2', - }); - await expect(called1).toBe(true); - await expect(called2).toBe(true); - }); - - it('works with nested transactional interceptors roll back', async () => { - let called1 = false; - let called2 = false; - - const client = _client - .$use({ - id: 'test-plugin', - onKyselyQuery({ kysely, query, proceed, transaction }) { - if (query.kind !== 'InsertQueryNode') { - return proceed(query); - } - called1 = true; - return transaction(async (txProceed) => { - const valueList = [ - ...(((query as InsertQueryNode).values as ValuesNode).values[0] as PrimitiveValueListNode) - .values, - ]; - valueList[1] = 'Marvin2'; - const newQuery = InsertQueryNode.cloneWith(query as InsertQueryNode, { - values: ValuesNode.create([PrimitiveValueListNode.create(valueList)]), - }); - const result = await txProceed(newQuery); - - // create a post for the user - await txProceed(createPost(kysely, result)); - - throw new Error('test error'); - }); - }, - }) - .$use({ - id: 'test-plugin2', - onKyselyQuery({ query, proceed, transaction }) { - if (query.kind !== 'InsertQueryNode') { - return proceed(query); - } - called2 = true; - return transaction(async (txProceed) => { - const valueList = [ - ...(((query as InsertQueryNode).values as ValuesNode).values[0] as PrimitiveValueListNode) - .values, - ]; - valueList[0] = 'u2@test.com'; - valueList[1] = 'Marvin1'; - const newQuery = InsertQueryNode.cloneWith(query as InsertQueryNode, { - values: ValuesNode.create([PrimitiveValueListNode.create(valueList)]), - }); - return txProceed(newQuery); - }); - }, - }); - - await expect( - client.user.create({ - data: { email: 'u1@test.com', name: 'Marvin' }, - }), - ).rejects.toThrow('test error'); - await expect(called1).toBe(true); - await expect(called2).toBe(true); - await expect(client.user.findFirst()).toResolveNull(); - await expect(client.post.findFirst()).toResolveNull(); - }); + // TODO: revisit transactions + // it('works with nested transactional interceptors success', async () => { + // let called1 = false; + // let called2 = false; + + // const client = _client + // .$use({ + // id: 'test-plugin', + // onKyselyQuery({ query, proceed, transaction }) { + // if (query.kind !== 'InsertQueryNode') { + // return proceed(query); + // } + // called1 = true; + // return transaction(async (txProceed) => { + // const valueList = [ + // ...(((query as InsertQueryNode).values as ValuesNode).values[0] as PrimitiveValueListNode) + // .values, + // ]; + // valueList[1] = 'Marvin2'; + // const newQuery = InsertQueryNode.cloneWith(query as InsertQueryNode, { + // values: ValuesNode.create([PrimitiveValueListNode.create(valueList)]), + // }); + // return txProceed(newQuery); + // }); + // }, + // }) + // .$use({ + // id: 'test-plugin2', + // onKyselyQuery({ query, proceed, transaction }) { + // if (query.kind !== 'InsertQueryNode') { + // return proceed(query); + // } + // called2 = true; + // return transaction(async (txProceed) => { + // const valueList = [ + // ...(((query as InsertQueryNode).values as ValuesNode).values[0] as PrimitiveValueListNode) + // .values, + // ]; + // valueList[0] = 'u2@test.com'; + // valueList[1] = 'Marvin1'; + // const newQuery = InsertQueryNode.cloneWith(query as InsertQueryNode, { + // values: ValuesNode.create([PrimitiveValueListNode.create(valueList)]), + // }); + // return txProceed(newQuery); + // }); + // }, + // }); + + // await expect( + // client.user.create({ + // data: { email: 'u1@test.com', name: 'Marvin' }, + // }), + // ).resolves.toMatchObject({ + // email: 'u2@test.com', + // name: 'Marvin2', + // }); + // await expect(called1).toBe(true); + // await expect(called2).toBe(true); + // }); + + // TODO: revisit transactions + // it('works with nested transactional interceptors roll back', async () => { + // let called1 = false; + // let called2 = false; + + // const client = _client + // .$use({ + // id: 'test-plugin', + // onKyselyQuery({ kysely, query, proceed, transaction }) { + // if (query.kind !== 'InsertQueryNode') { + // return proceed(query); + // } + // called1 = true; + // return transaction(async (txProceed) => { + // const valueList = [ + // ...(((query as InsertQueryNode).values as ValuesNode).values[0] as PrimitiveValueListNode) + // .values, + // ]; + // valueList[1] = 'Marvin2'; + // const newQuery = InsertQueryNode.cloneWith(query as InsertQueryNode, { + // values: ValuesNode.create([PrimitiveValueListNode.create(valueList)]), + // }); + // const result = await txProceed(newQuery); + + // // create a post for the user + // await txProceed(createPost(kysely, result)); + + // throw new Error('test error'); + // }); + // }, + // }) + // .$use({ + // id: 'test-plugin2', + // onKyselyQuery({ query, proceed, transaction }) { + // if (query.kind !== 'InsertQueryNode') { + // return proceed(query); + // } + // called2 = true; + // return transaction(async (txProceed) => { + // const valueList = [ + // ...(((query as InsertQueryNode).values as ValuesNode).values[0] as PrimitiveValueListNode) + // .values, + // ]; + // valueList[0] = 'u2@test.com'; + // valueList[1] = 'Marvin1'; + // const newQuery = InsertQueryNode.cloneWith(query as InsertQueryNode, { + // values: ValuesNode.create([PrimitiveValueListNode.create(valueList)]), + // }); + // return txProceed(newQuery); + // }); + // }, + // }); + + // await expect( + // client.user.create({ + // data: { email: 'u1@test.com', name: 'Marvin' }, + // }), + // ).rejects.toThrow('test error'); + // await expect(called1).toBe(true); + // await expect(called2).toBe(true); + // await expect(client.user.findFirst()).toResolveNull(); + // await expect(client.post.findFirst()).toResolveNull(); + // }); }); function createPost(kysely: Kysely, userRows: QueryResult) { diff --git a/packages/runtime/test/plugin/mutation-hooks.test.ts b/packages/runtime/test/plugin/mutation-hooks.test.ts index 8958afb0..c6bd08c7 100644 --- a/packages/runtime/test/plugin/mutation-hooks.test.ts +++ b/packages/runtime/test/plugin/mutation-hooks.test.ts @@ -1,6 +1,6 @@ import SQLite from 'better-sqlite3'; import { DeleteQueryNode, InsertQueryNode, UpdateQueryNode } from 'kysely'; -import { beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { ZenStackClient, type ClientContract } from '../../src'; import { schema } from '../test-schema'; @@ -16,6 +16,10 @@ describe('Entity lifecycle tests', () => { await _client.$pushSchema(); }); + afterEach(async () => { + await _client?.$disconnect(); + }); + it('can intercept all mutations', async () => { const beforeCalled = { create: false, update: false, delete: false }; const afterCalled = { create: false, update: false, delete: false }; @@ -296,45 +300,39 @@ describe('Entity lifecycle tests', () => { expect(post2Intercepted).toBe(true); }); - // TODO: revisit mutation hooks and transactions - it.skip('proceeds with mutation even when hooks throw', async () => { - let userIntercepted = false; + // // TODO: revisit mutation hooks and transactions + // it.skip('proceeds with mutation even when hooks throw', async () => { + // let userIntercepted = false; - const client = _client.$use({ - id: 'test', - afterEntityMutation() { - userIntercepted = true; - throw new Error('trigger error'); - }, - }); + // const client = _client.$use({ + // id: 'test', + // afterEntityMutation() { + // userIntercepted = true; + // throw new Error('trigger error'); + // }, + // }); - let gotError = false; - try { - await client.user.create({ - data: { email: 'u1@test.com' }, - }); - } catch (err) { - gotError = true; - expect((err as Error).message).toContain('trigger error'); - } + // let gotError = false; + // try { + // await client.user.create({ + // data: { email: 'u1@test.com' }, + // }); + // } catch (err) { + // gotError = true; + // expect((err as Error).message).toContain('trigger error'); + // } - expect(userIntercepted).toBe(true); - expect(gotError).toBe(true); - console.log(await client.user.findMany()); - await expect(client.user.findMany()).toResolveWithLength(1); - }); + // expect(userIntercepted).toBe(true); + // expect(gotError).toBe(true); + // console.log(await client.user.findMany()); + // await expect(client.user.findMany()).toResolveWithLength(1); + // }); it('rolls back when hooks throw if transaction is used', async () => { let userIntercepted = false; const client = _client.$use({ id: 'test', - mutationInterceptionFilter: () => { - return { - intercept: true, - useTransactionForMutation: true, - }; - }, afterEntityMutation() { userIntercepted = true; throw new Error('trigger rollback'); diff --git a/packages/runtime/test/plugin/query-lifecycle.test.ts b/packages/runtime/test/plugin/query-lifecycle.test.ts index af8d40c9..15bc85a7 100644 --- a/packages/runtime/test/plugin/query-lifecycle.test.ts +++ b/packages/runtime/test/plugin/query-lifecycle.test.ts @@ -254,7 +254,8 @@ describe('Query interception tests', () => { ).toResolveTruthy(); }); - it('rolls back the effect with transaction', async () => { + // TODO: revisit transactional hooks + it.skip('rolls back the effect with transaction', async () => { let hooksCalled = false; const client = _client.$use({ id: 'test-plugin', @@ -262,8 +263,8 @@ describe('Query interception tests', () => { user: { create: async (ctx) => { hooksCalled = true; - return ctx.client.$transaction(async (tx) => { - await ctx.query(ctx.args, tx); + return ctx.client.$transaction(async (_tx) => { + await ctx.query(ctx.args /*, tx*/); throw new Error('trigger error'); }); }, diff --git a/packages/runtime/test/typing/schema.ts b/packages/runtime/test/typing/schema.ts index 1a6ddc45..05d6166e 100644 --- a/packages/runtime/test/typing/schema.ts +++ b/packages/runtime/test/typing/schema.ts @@ -3,307 +3,245 @@ // This file is automatically generated by ZenStack CLI and should not be manually updated. // ////////////////////////////////////////////////////////////////////////////////////////////// -import { type SchemaDef, type OperandExpression, ExpressionUtils } from '../../dist/schema'; +/* eslint-disable */ + +import { type SchemaDef, type OperandExpression, ExpressionUtils } from "../../dist/schema"; export const schema = { provider: { - type: 'sqlite', + type: "sqlite" }, models: { User: { fields: { id: { - type: 'Int', + type: "Int", id: true, - attributes: [ - { name: '@id' }, - { name: '@default', args: [{ name: 'value', value: ExpressionUtils.call('autoincrement') }] }, - ], - default: ExpressionUtils.call('autoincrement'), + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }], + default: ExpressionUtils.call("autoincrement") }, createdAt: { - type: 'DateTime', - attributes: [{ name: '@default', args: [{ name: 'value', value: ExpressionUtils.call('now') }] }], - default: ExpressionUtils.call('now'), + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], + default: ExpressionUtils.call("now") }, updatedAt: { - type: 'DateTime', + type: "DateTime", updatedAt: true, - attributes: [{ name: '@updatedAt' }], + attributes: [{ name: "@updatedAt" }] }, name: { - type: 'String', + type: "String" }, email: { - type: 'String', + type: "String", unique: true, - attributes: [{ name: '@unique' }], + attributes: [{ name: "@unique" }] }, posts: { - type: 'Post', + type: "Post", array: true, - relation: { opposite: 'author' }, + relation: { opposite: "author" } }, profile: { - type: 'Profile', + type: "Profile", optional: true, - relation: { opposite: 'user' }, + relation: { opposite: "user" } }, postCount: { - type: 'Int', - attributes: [{ name: '@computed' }], - computed: true, - }, + type: "Int", + attributes: [{ name: "@computed" }], + computed: true + } }, - idFields: ['id'], + idFields: ["id"], uniqueFields: { - id: { type: 'Int' }, - email: { type: 'String' }, + id: { type: "Int" }, + email: { type: "String" } }, computedFields: { postCount(): OperandExpression { - throw new Error('This is a stub for computed field'); - }, - }, + throw new Error("This is a stub for computed field"); + } + } }, Post: { fields: { id: { - type: 'Int', + type: "Int", id: true, - attributes: [ - { name: '@id' }, - { name: '@default', args: [{ name: 'value', value: ExpressionUtils.call('autoincrement') }] }, - ], - default: ExpressionUtils.call('autoincrement'), + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }], + default: ExpressionUtils.call("autoincrement") }, title: { - type: 'String', + type: "String" }, content: { - type: 'String', + type: "String" }, 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'] }, + 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: { - type: 'Int', - foreignKeyFor: ['author'], + type: "Int", + foreignKeyFor: [ + "author" + ] }, tags: { - type: 'Tag', + type: "Tag", array: true, - relation: { opposite: 'posts' }, + relation: { opposite: "posts" } }, meta: { - type: 'Meta', + type: "Meta", optional: true, - relation: { opposite: 'post' }, - }, + relation: { opposite: "post" } + } }, - idFields: ['id'], + idFields: ["id"], uniqueFields: { - id: { type: 'Int' }, - }, + id: { type: "Int" } + } }, Profile: { fields: { id: { - type: 'Int', + type: "Int", id: true, - attributes: [ - { name: '@id' }, - { name: '@default', args: [{ name: 'value', value: ExpressionUtils.call('autoincrement') }] }, - ], - default: ExpressionUtils.call('autoincrement'), + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }], + default: ExpressionUtils.call("autoincrement") }, age: { - type: 'Int', + type: "Int" }, region: { - type: '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'], - }, + 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: { - type: 'String', + type: "String", optional: true, - foreignKeyFor: ['region'], + foreignKeyFor: [ + "region" + ] }, regionCity: { - type: 'String', + type: "String", optional: true, - foreignKeyFor: ['region'], + foreignKeyFor: [ + "region" + ] }, 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'] }, + 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: { - type: 'Int', + type: "Int", unique: true, - attributes: [{ name: '@unique' }], - foreignKeyFor: ['user'], - }, + attributes: [{ name: "@unique" }], + foreignKeyFor: [ + "user" + ] + } }, - idFields: ['id'], + idFields: ["id"], uniqueFields: { - id: { type: 'Int' }, - userId: { type: 'Int' }, - }, + id: { type: "Int" }, + userId: { type: "Int" } + } }, Tag: { fields: { id: { - type: 'Int', + type: "Int", id: true, - attributes: [ - { name: '@id' }, - { name: '@default', args: [{ name: 'value', value: ExpressionUtils.call('autoincrement') }] }, - ], - default: ExpressionUtils.call('autoincrement'), + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }], + default: ExpressionUtils.call("autoincrement") }, name: { - type: 'String', + type: "String" }, posts: { - type: 'Post', + type: "Post", array: true, - relation: { opposite: 'tags' }, - }, + relation: { opposite: "tags" } + } }, - idFields: ['id'], + idFields: ["id"], uniqueFields: { - id: { type: 'Int' }, - }, + id: { type: "Int" } + } }, Region: { fields: { country: { - type: 'String', - id: true, + type: "String", + id: true }, city: { - type: 'String', - id: true, + type: "String", + id: true }, zip: { - type: 'String', - optional: true, + type: "String", + optional: true }, profiles: { - type: 'Profile', + type: "Profile", array: true, - relation: { opposite: 'region' }, - }, + relation: { opposite: "region" } + } }, attributes: [ - { - name: '@@id', - args: [ - { - name: 'fields', - value: ExpressionUtils.array([ - ExpressionUtils.field('country'), - ExpressionUtils.field('city'), - ]), - }, - ], - }, + { name: "@@id", args: [{ name: "fields", value: ExpressionUtils.array([ExpressionUtils.field("country"), ExpressionUtils.field("city")]) }] } ], - idFields: ['country', 'city'], + idFields: ["country", "city"], uniqueFields: { - country_city: { country: { type: 'String' }, city: { type: 'String' } }, - }, + country_city: { country: { type: "String" }, city: { type: "String" } } + } }, Meta: { fields: { id: { - type: 'Int', + type: "Int", id: true, - attributes: [ - { name: '@id' }, - { name: '@default', args: [{ name: 'value', value: ExpressionUtils.call('autoincrement') }] }, - ], - default: ExpressionUtils.call('autoincrement'), + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }], + default: ExpressionUtils.call("autoincrement") }, reviewed: { - type: 'Boolean', + type: "Boolean" }, published: { - type: 'Boolean', + type: "Boolean" }, 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'] }, + 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: { - type: 'Int', + type: "Int", unique: true, - attributes: [{ name: '@unique' }], - foreignKeyFor: ['post'], - }, + attributes: [{ name: "@unique" }], + foreignKeyFor: [ + "post" + ] + } }, - idFields: ['id'], + idFields: ["id"], uniqueFields: { - id: { type: 'Int' }, - postId: { type: 'Int' }, - }, - }, + id: { type: "Int" }, + postId: { type: "Int" } + } + } }, - authType: 'User', - plugins: {}, + authType: "User", + plugins: {} } as const satisfies SchemaDef; export type SchemaType = typeof schema; diff --git a/packages/runtime/tsconfig.json b/packages/runtime/tsconfig.json index 7b9efb7a..2125902f 100644 --- a/packages/runtime/tsconfig.json +++ b/packages/runtime/tsconfig.json @@ -3,5 +3,5 @@ "compilerOptions": { "outDir": "dist" }, - "include": ["src/**/*.ts"] + "include": ["src/**/*", "test/**/*"] } diff --git a/packages/runtime/tsconfig.test.json b/packages/runtime/tsconfig.test.json index faef15af..014b54e4 100644 --- a/packages/runtime/tsconfig.test.json +++ b/packages/runtime/tsconfig.test.json @@ -4,6 +4,5 @@ "noEmit": true, "noImplicitAny": false }, - "include": ["test/**/*.ts"] } diff --git a/packages/runtime/tsup.config.ts b/packages/runtime/tsup.config.ts index 114183c4..0f8a9d6f 100644 --- a/packages/runtime/tsup.config.ts +++ b/packages/runtime/tsup.config.ts @@ -3,7 +3,6 @@ import { defineConfig } from 'tsup'; export default defineConfig({ entry: { index: 'src/index.ts', - client: 'src/client/index.ts', schema: 'src/schema/index.ts', 'plugins/policy': 'src/plugins/policy/index.ts', }, diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 0acfaebe..7d5d79dd 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "3.0.0-alpha.8", + "version": "3.0.0-alpha.9", "description": "ZenStack SDK", "type": "module", "scripts": { diff --git a/packages/sdk/src/schema/schema.ts b/packages/sdk/src/schema/schema.ts index 8ef02fb6..91d333b5 100644 --- a/packages/sdk/src/schema/schema.ts +++ b/packages/sdk/src/schema/schema.ts @@ -73,7 +73,17 @@ export type ProcedureDef = { mutation?: boolean; }; -export type BuiltinType = 'String' | 'Boolean' | 'Int' | 'Float' | 'BigInt' | 'Decimal' | 'DateTime' | 'Bytes'; +export type BuiltinType = + | 'String' + | 'Boolean' + | 'Int' + | 'Float' + | 'BigInt' + | 'Decimal' + | 'DateTime' + | 'Bytes' + | 'Json' + | 'Unsupported'; export type MappedBuiltinType = string | boolean | number | bigint | Decimal | Date; diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index c1cc020b..79c1ec4c 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -695,7 +695,8 @@ export class TsSchemaGenerator { ? ts.factory.createStringLiteral(field.type.type) : field.type.reference ? ts.factory.createStringLiteral(field.type.reference.$refText) - : ts.factory.createStringLiteral('unknown'); + : // `Unsupported` type + ts.factory.createStringLiteral('Unsupported'); } private createEnumObject(e: Enum) { diff --git a/packages/tanstack-query/package.json b/packages/tanstack-query/package.json index d06bcbb2..493ce35f 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.8", + "version": "3.0.0-alpha.9", "description": "", "main": "index.js", "type": "module", diff --git a/packages/tanstack-query/src/react.ts b/packages/tanstack-query/src/react.ts index 2b9bb819..c87115d5 100644 --- a/packages/tanstack-query/src/react.ts +++ b/packages/tanstack-query/src/react.ts @@ -5,7 +5,7 @@ import type { UseQueryOptions, UseQueryResult, } from '@tanstack/react-query'; -import type { CreateArgs, FindArgs, ModelResult, SelectSubset } from '@zenstackhq/runtime/client'; +import type { CreateArgs, FindArgs, ModelResult, SelectSubset } from '@zenstackhq/runtime'; import type { GetModels, SchemaDef } from '@zenstackhq/runtime/schema'; export type toHooks = { diff --git a/packages/testtools/package.json b/packages/testtools/package.json index c9d0a878..98f03ef0 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "3.0.0-alpha.8", + "version": "3.0.0-alpha.9", "description": "ZenStack Test Tools", "type": "module", "scripts": { diff --git a/packages/typescript-config/package.json b/packages/typescript-config/package.json index 7f21a7db..232e65f0 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.8", + "version": "3.0.0-alpha.9", "private": true, "license": "MIT" } diff --git a/packages/zod/package.json b/packages/zod/package.json index 32069191..cc737a13 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/zod", - "version": "3.0.0-alpha.8", + "version": "3.0.0-alpha.9", "description": "", "type": "module", "main": "index.js", diff --git a/samples/blog/package.json b/samples/blog/package.json index 516cd048..fd486174 100644 --- a/samples/blog/package.json +++ b/samples/blog/package.json @@ -1,6 +1,6 @@ { "name": "sample-blog", - "version": "3.0.0-alpha.8", + "version": "3.0.0-alpha.9", "description": "", "main": "index.js", "scripts": { diff --git a/tests/e2e/package.json b/tests/e2e/package.json index 8c0ca98e..1b3268f2 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -1,6 +1,6 @@ { "name": "e2e", - "version": "3.0.0-alpha.8", + "version": "3.0.0-alpha.9", "private": true, "scripts": { "test": "vitest run"