diff --git a/TODO.md b/TODO.md index 9c61a5f1..6ea6d0e8 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 + - [ ] Batch 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/packages/runtime/src/client/client-impl.ts b/packages/runtime/src/client/client-impl.ts index d17cd23f..983f7314 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 type { QueryExecutor, SqliteDialectConfig } from 'kysely'; import { CompiledQuery, DefaultConnectionProvider, @@ -60,6 +60,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 +74,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 +100,7 @@ export class ClientImpl { }, dialect, driver, - executor, + executor: executor ?? new ZenStackQueryExecutor(this, driver, compiler, adapter, connectionProvider), }; // raw kysely instance with default executor @@ -112,14 +115,21 @@ export class ClientImpl { return createClientProxy(this); } - public get $qb() { + get $qb() { return this.kysely; } - public get $qbRaw() { + get $qbRaw() { return this.kyselyRaw; } + /** + * 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()) @@ -136,11 +146,17 @@ export class ClientImpl { } async $transaction(callback: (tx: ClientContract) => Promise): Promise { - return this.kysely.transaction().execute((tx) => { - const txClient = new ClientImpl(this.schema, this.$options); - txClient.kysely = tx; - return callback(txClient as unknown as ClientContract); - }); + 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 + return this.kysely.transaction().execute((tx) => { + const txClient = new ClientImpl(this.schema, this.$options); + txClient.kysely = tx; + return callback(txClient as unknown as ClientContract); + }); + } } get $procedures() { diff --git a/packages/runtime/src/client/crud/operations/base.ts b/packages/runtime/src/client/crud/operations/base.ts index d436feb2..59a31ecf 100644 --- a/packages/runtime/src/client/crud/operations/base.ts +++ b/packages/runtime/src/client/crud/operations/base.ts @@ -6,10 +6,12 @@ import { ExpressionWrapper, sql, UpdateResult, + 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'; @@ -203,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) { @@ -1181,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; } } @@ -1900,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); } } diff --git a/packages/runtime/src/client/crud/validator.ts b/packages/runtime/src/client/crud/validator.ts index 9e443cf6..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,9 +20,8 @@ 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'; -import { invariant } from '@zenstackhq/common-helpers'; type GetSchemaFunc = (model: GetModels, options: Options) => ZodType; @@ -179,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; } @@ -233,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 = {}; 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/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..c8111c51 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>; 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/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 9423bd5a..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) { 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..35677477 --- /dev/null +++ b/packages/runtime/test/client-api/transaction.test.ts @@ -0,0 +1,105 @@ +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(); + }); + + 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.$transaction((tx2) => + 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.$transaction((tx2) => + tx2.user.create({ + data: { + email: 'u1@test.com', + }, + }), + ); + return [u1, u2]; + }), + ).rejects.toThrow(); + + await expect(client.user.findMany()).toResolveWithLength(0); + }); +}); diff --git a/packages/runtime/test/client-api/undefined-values.test.ts b/packages/runtime/test/client-api/undefined-values.test.ts index 20b9aa27..e9657b9e 100644 --- a/packages/runtime/test/client-api/undefined-values.test.ts +++ b/packages/runtime/test/client-api/undefined-values.test.ts @@ -6,40 +6,37 @@ import { createUser } from './utils'; const PG_DB_NAME = 'client-api-undefined-values-tests'; -describe.each(createClientSpecs(PG_DB_NAME, true))( - 'Client undefined values tests for $provider', - ({ createClient }) => { - let client: ClientContract; +describe.each(createClientSpecs(PG_DB_NAME))('Client undefined values tests for $provider', ({ createClient }) => { + let client: ClientContract; - beforeEach(async () => { - client = await createClient(); - }); + beforeEach(async () => { + client = await createClient(); + }); - afterEach(async () => { - await client?.$disconnect(); - }); + afterEach(async () => { + await client?.$disconnect(); + }); - it('works with toplevel undefined args', async () => { - await expect(client.user.findMany(undefined)).toResolveTruthy(); - }); + 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); + 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); - }); - }, -); + 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/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;