diff --git a/TODO.md b/TODO.md index 2f808b99..55d64a6c 100644 --- a/TODO.md +++ b/TODO.md @@ -45,7 +45,7 @@ - [ ] Array update - [x] Upsert - [x] Delete - - [ ] Aggregation + - [x] Aggregation - [x] Count - [x] Aggregate - [x] Group by @@ -62,6 +62,8 @@ - [ ] Error system - [x] Custom table name - [x] Custom field name + - [ ] Empty AND/OR/NOT behavior + - [ ] Strict undefined check - [ ] Access Policy - [ ] Short-circuit pre-create check for scalar-field only policies - [ ] Polymorphism diff --git a/packages/cli/src/actions/generate.ts b/packages/cli/src/actions/generate.ts index 855746bb..edd01a8b 100644 --- a/packages/cli/src/actions/generate.ts +++ b/packages/cli/src/actions/generate.ts @@ -38,10 +38,10 @@ export async function run(options: Options) { console.log(`You can now create a ZenStack client with it. \`\`\` -import { createClient } from '@zenstackhq/runtime'; +import { ZenStackClient } from '@zenstackhq/runtime'; import { schema } from '${outputPath}/schema'; -const db = createClient(schema); +const db = new ZenStackClient(schema); \`\`\` `); } diff --git a/packages/language/src/validators/datamodel-validator.ts b/packages/language/src/validators/datamodel-validator.ts index 9c5f61b0..2c44c729 100644 --- a/packages/language/src/validators/datamodel-validator.ts +++ b/packages/language/src/validators/datamodel-validator.ts @@ -4,18 +4,23 @@ import { type DiagnosticInfo, type ValidationAcceptor, } from 'langium'; +import { IssueCodes, SCALAR_TYPES } from '../constants'; import { ArrayExpr, DataModel, DataModelField, + Model, ReferenceExpr, isDataModel, + isDataSource, isEnum, + isModel, isStringLiteral, isTypeDef, } from '../generated/ast'; import { findUpInheritance, + getLiteral, getModelFieldsWithBases, getModelIdFields, getModelUniqueFields, @@ -25,7 +30,6 @@ import { } from '../utils'; import { validateAttributeApplication } from './attribute-application-validator'; import { validateDuplicatedDeclarations, type AstValidator } from './common'; -import { IssueCodes, SCALAR_TYPES } from '../constants'; /** * Validates data model declarations. @@ -147,6 +151,19 @@ export default class DataModelValidator implements AstValidator { ); } + if (field.type.array && !isDataModel(field.type.reference?.ref)) { + const provider = this.getDataSourceProvider( + AstUtils.getContainerOfType(field, isModel)! + ); + if (provider === 'sqlite') { + accept( + 'error', + `Array type is not supported for "${provider}" provider.`, + { node: field.type } + ); + } + } + field.attributes.forEach((attr) => validateAttributeApplication(attr, accept) ); @@ -162,6 +179,18 @@ export default class DataModelValidator implements AstValidator { } } + private getDataSourceProvider(model: Model) { + const dataSource = model.declarations.find(isDataSource); + if (!dataSource) { + return undefined; + } + const provider = dataSource?.fields.find((f) => f.name === 'provider'); + if (!provider) { + return undefined; + } + return getLiteral(provider.value); + } + private validateAttributes(dm: DataModel, accept: ValidationAcceptor) { dm.attributes.forEach((attr) => validateAttributeApplication(attr, accept) diff --git a/packages/runtime/package.json b/packages/runtime/package.json index f7498707..d5a6b9e7 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -57,6 +57,26 @@ "default": "./dist/plugins/policy.cjs" } }, + "./utils/pg-utils": { + "import": { + "types": "./dist/utils/pg-utils.d.ts", + "default": "./dist/utils/pg-utils.js" + }, + "require": { + "types": "./dist/utils/pg-utils.d.cts", + "default": "./dist/utils/pg-utils.cjs" + } + }, + "./utils/sqlite-utils": { + "import": { + "types": "./dist/utils/sqlite-utils.d.ts", + "default": "./dist/utils/sqlite-utils.js" + }, + "require": { + "types": "./dist/utils/sqlite-utils.d.cts", + "default": "./dist/utils/sqlite-utils.cjs" + } + }, "./package.json": { "import": "./package.json", "require": "./package.json" @@ -67,6 +87,7 @@ "decimal.js": "^10.4.3", "kysely": "^0.27.5", "nanoid": "^5.0.9", + "pg-connection-string": "^2.9.0", "tiny-invariant": "^1.3.3", "ts-pattern": "^5.6.0", "ulid": "^3.0.0", diff --git a/packages/runtime/src/client/crud/dialects/base.ts b/packages/runtime/src/client/crud/dialects/base.ts index 30ab5103..230023f0 100644 --- a/packages/runtime/src/client/crud/dialects/base.ts +++ b/packages/runtime/src/client/crud/dialects/base.ts @@ -1023,6 +1023,8 @@ export abstract class BaseCrudDialect { array: Expression ): ExpressionWrapper; + abstract buildArrayLiteralSQL(values: unknown[]): string; + get supportsUpdateWithLimit() { return true; } diff --git a/packages/runtime/src/client/crud/dialects/postgresql.ts b/packages/runtime/src/client/crud/dialects/postgresql.ts index ec8cf22b..a33b4cca 100644 --- a/packages/runtime/src/client/crud/dialects/postgresql.ts +++ b/packages/runtime/src/client/crud/dialects/postgresql.ts @@ -335,4 +335,10 @@ export class PostgresCrudDialect< ): ExpressionWrapper { return eb.fn('array_length', [array]); } + + override buildArrayLiteralSQL(values: unknown[]): string { + return `ARRAY[${values.map((v) => + typeof v === 'string' ? `'${v}'` : v + )}]`; + } } diff --git a/packages/runtime/src/client/crud/dialects/sqlite.ts b/packages/runtime/src/client/crud/dialects/sqlite.ts index 10be424c..59697514 100644 --- a/packages/runtime/src/client/crud/dialects/sqlite.ts +++ b/packages/runtime/src/client/crud/dialects/sqlite.ts @@ -289,4 +289,8 @@ export class SqliteCrudDialect< ): ExpressionWrapper { return eb.fn('json_array_length', [array]); } + + override buildArrayLiteralSQL(_values: unknown[]): string { + throw new Error('SQLite does not support array literals'); + } } diff --git a/packages/runtime/src/client/crud/validator.ts b/packages/runtime/src/client/crud/validator.ts index b64567ec..88ed433f 100644 --- a/packages/runtime/src/client/crud/validator.ts +++ b/packages/runtime/src/client/crud/validator.ts @@ -730,6 +730,10 @@ export class InputValidator { fieldDef.type ); + if (fieldDef.array) { + fieldSchema = z.array(fieldSchema).optional(); + } + if (fieldDef.optional || fieldHasDefaultValue(fieldDef)) { fieldSchema = fieldSchema.optional(); } @@ -1200,7 +1204,9 @@ export class InputValidator { const bys = typeof value.by === 'string' ? [value.by] : value.by; if ( value.having && - Object.keys(value.having).some((key) => !bys.includes(key)) + Object.keys(value.having) + .filter((f) => !f.startsWith('_')) + .some((key) => !bys.includes(key)) ) { return false; } else { @@ -1212,7 +1218,9 @@ export class InputValidator { const bys = typeof value.by === 'string' ? [value.by] : value.by; if ( value.orderBy && - Object.keys(value.orderBy).some((key) => !bys.includes(key)) + Object.keys(value.orderBy) + .filter((f) => !f.startsWith('_')) + .some((key) => !bys.includes(key)) ) { return false; } else { diff --git a/packages/runtime/src/client/executor/name-mapper.ts b/packages/runtime/src/client/executor/name-mapper.ts index 8a15f44a..efde7582 100644 --- a/packages/runtime/src/client/executor/name-mapper.ts +++ b/packages/runtime/src/client/executor/name-mapper.ts @@ -166,7 +166,6 @@ export class QueryNameMapper extends OperationNodeTransformer { for (const selection of selections) { let selectAllFromModel: string | undefined = undefined; let isSelectAll = false; - let selectAllWithAlias = false; if (SelectAllNode.is(selection.selection)) { selectAllFromModel = this.currentModel; @@ -179,7 +178,6 @@ export class QueryNameMapper extends OperationNodeTransformer { selection.selection.table?.table.identifier.name ?? this.currentModel; isSelectAll = true; - selectAllWithAlias = true; } if (isSelectAll) { @@ -190,11 +188,17 @@ export class QueryNameMapper extends OperationNodeTransformer { contextNode, selectAllFromModel ); + const fromModelDef = requireModel( + this.schema, + selectAllFromModel + ); + const mappedTableName = + this.getMappedName(fromModelDef) ?? selectAllFromModel; result.push( ...scalarFields.map((fieldName) => { const fieldRef = ReferenceNode.create( ColumnNode.create(this.mapFieldName(fieldName)), - TableNode.create(selectAllFromModel) + TableNode.create(mappedTableName) ); return SelectionNode.create( this.fieldHasMappedName(fieldName) diff --git a/packages/runtime/src/client/helpers/schema-db-pusher.ts b/packages/runtime/src/client/helpers/schema-db-pusher.ts index 547d705f..5171d2ee 100644 --- a/packages/runtime/src/client/helpers/schema-db-pusher.ts +++ b/packages/runtime/src/client/helpers/schema-db-pusher.ts @@ -178,7 +178,7 @@ export class SchemaDbPusher { } const type = fieldDef.type as BuiltinType; - let result = match(type) + let result = match(type) .with('String', () => 'text') .with('Boolean', () => 'boolean') .with('Int', () => 'integer') @@ -192,10 +192,13 @@ export class SchemaDbPusher { .otherwise(() => { throw new Error(`Unsupported field type: ${type}`); }); + if (fieldDef.array) { - result = `${result}[]`; + // Kysely doesn't support array type natively + return sql.raw(`${result}[]`); + } else { + return result as ColumnDataType; } - return result as ColumnDataType; } private addForeignKeyConstraint( diff --git a/packages/runtime/src/plugins/policy/expression-transformer.ts b/packages/runtime/src/plugins/policy/expression-transformer.ts index 3144e1f7..23d7345c 100644 --- a/packages/runtime/src/plugins/policy/expression-transformer.ts +++ b/packages/runtime/src/plugins/policy/expression-transformer.ts @@ -51,7 +51,8 @@ export type ExpressionTransformerContext = { model: GetModels; alias?: string; operation: CRUD; - thisEntity?: Record; + thisEntity?: Record; + thisEntityRaw?: Record; auth?: any; memberFilter?: OperationNode; memberSelect?: SelectionNode; @@ -210,18 +211,23 @@ export class ExpressionTransformer { const right = this.transform(expr.right, context); if (op === 'in') { - invariant( - ValueListNode.is(right), - '"in" operation requires right operand to be a value list' - ); if (this.isNullNode(left)) { return this.transformValue(false, 'Boolean'); } else { - return BinaryOperationNode.create( - left, - OperatorNode.create('in'), - right - ); + if (ValueListNode.is(right)) { + return BinaryOperationNode.create( + left, + OperatorNode.create('in'), + right + ); + } else { + // array contains + return BinaryOperationNode.create( + left, + OperatorNode.create('='), + FunctionNode.create('any', [right]) + ); + } } } @@ -444,8 +450,8 @@ export class ExpressionTransformer { } if (Expression.isField(arg)) { - return context.thisEntity - ? eb.val(context.thisEntity[arg.field]?.value) + return context.thisEntityRaw + ? eb.val(context.thisEntityRaw[arg.field]) : eb.ref(arg.field); } diff --git a/packages/runtime/src/plugins/policy/policy-handler.ts b/packages/runtime/src/plugins/policy/policy-handler.ts index c467e998..8621e20a 100644 --- a/packages/runtime/src/plugins/policy/policy-handler.ts +++ b/packages/runtime/src/plugins/policy/policy-handler.ts @@ -9,6 +9,7 @@ import { OperationNodeTransformer, OperatorNode, PrimitiveValueListNode, + RawNode, ReturningNode, SelectionNode, SelectQueryNode, @@ -185,7 +186,8 @@ export class PolicyHandler< await this.enforcePreCreatePolicyForOne( model, fields, - values, + values.map((v) => v.node), + values.map((v) => v.raw), proceed ); } @@ -194,19 +196,23 @@ export class PolicyHandler< private async enforcePreCreatePolicyForOne( model: GetModels, fields: string[], - values: ValueNode[], + values: OperationNode[], + valuesRaw: unknown[], proceed: ProceedKyselyQueryFunction ) { - const thisEntity: Record = {}; + const thisEntity: Record = {}; + const thisEntityRaw: Record = {}; for (let i = 0; i < fields.length; i++) { thisEntity[fields[i]!] = values[i]!; + thisEntityRaw[fields[i]!] = valuesRaw[i]!; } const filter = this.buildPolicyFilter( model, undefined, 'create', - thisEntity + thisEntity, + thisEntityRaw ); const preCreateCheck: SelectQueryNode = { kind: 'SelectQueryNode', @@ -252,7 +258,7 @@ export class PolicyHandler< data.length === fields.length, 'data length must match fields length' ); - const result: ValueNode[] = []; + const result: { node: OperationNode; raw: unknown }[] = []; for (let i = 0; i < data.length; i++) { const item = data[i]!; const fieldDef = requireField( @@ -262,23 +268,30 @@ export class PolicyHandler< ); if (typeof item === 'object' && item && 'kind' in item) { invariant(item.kind === 'ValueNode', 'expecting a ValueNode'); - result.push( - ValueNode.create( + result.push({ + node: ValueNode.create( this.dialect.transformPrimitive( (item as ValueNode).value, fieldDef.type as BuiltinType ) - ) - ); + ), + raw: (item as ValueNode).value, + }); } else { - result.push( - ValueNode.create( - this.dialect.transformPrimitive( - item, - fieldDef.type as BuiltinType - ) - ) + let value = this.dialect.transformPrimitive( + item, + fieldDef.type as BuiltinType ); + if (Array.isArray(value)) { + result.push({ + node: RawNode.createWithSql( + this.dialect.buildArrayLiteralSQL(value) + ), + raw: value, + }); + } else { + result.push({ node: ValueNode.create(value), raw: value }); + } } } return result; @@ -423,7 +436,8 @@ export class PolicyHandler< model: GetModels, alias: string | undefined, operation: CRUD, - thisEntity?: Record + thisEntity?: Record, + thisEntityRaw?: Record ) { const policies = this.getModelPolicies(model, operation); if (policies.length === 0) { @@ -438,7 +452,8 @@ export class PolicyHandler< alias, operation, policy, - thisEntity + thisEntity, + thisEntityRaw ) ); @@ -450,7 +465,8 @@ export class PolicyHandler< alias, operation, policy, - thisEntity + thisEntity, + thisEntityRaw ) ); @@ -595,7 +611,8 @@ export class PolicyHandler< alias: string | undefined, operation: CRUD, policy: Policy, - thisEntity?: Record + thisEntity?: Record, + thisEntityRaw?: Record ) { return new ExpressionTransformer( this.client.$schema, @@ -606,6 +623,7 @@ export class PolicyHandler< alias, operation, thisEntity, + thisEntityRaw, auth: this.client.$auth, }); } diff --git a/packages/runtime/src/utils/pg-utils.ts b/packages/runtime/src/utils/pg-utils.ts new file mode 100644 index 00000000..f4dd33eb --- /dev/null +++ b/packages/runtime/src/utils/pg-utils.ts @@ -0,0 +1,12 @@ +import type { PostgresDialectConfig } from 'kysely'; +import { Pool } from 'pg'; +import { parseIntoClientConfig } from 'pg-connection-string'; + +/** + * Convert a PostgreSQL connection string to a Kysely dialect config. + */ +export function toDialectConfig(url: string): PostgresDialectConfig { + return { + pool: new Pool(parseIntoClientConfig(url)), + }; +} diff --git a/packages/runtime/src/utils/sqlite-utils.ts b/packages/runtime/src/utils/sqlite-utils.ts new file mode 100644 index 00000000..407bdcb1 --- /dev/null +++ b/packages/runtime/src/utils/sqlite-utils.ts @@ -0,0 +1,21 @@ +import SQLite from 'better-sqlite3'; +import type { SqliteDialectConfig } from 'kysely'; +import path from 'node:path'; + +/** + * Convert a SQLite connection string to a Kysely dialect config. + */ +export function toDialectConfig( + url: string, + baseDir: string +): SqliteDialectConfig { + if (url === ':memory:') { + return { + database: new SQLite(':memory:'), + }; + } + const filePath = path.resolve(baseDir, url); + return { + database: new SQLite(filePath), + }; +} diff --git a/packages/runtime/test/client-api/aggregate.test.ts b/packages/runtime/test/client-api/aggregate.test.ts index 63bf7d35..1677654c 100644 --- a/packages/runtime/test/client-api/aggregate.test.ts +++ b/packages/runtime/test/client-api/aggregate.test.ts @@ -4,14 +4,13 @@ import { schema } from '../test-schema'; import { createClientSpecs } from './client-specs'; import { createUser } from './utils'; -describe.each(createClientSpecs(__filename, true))( +describe.each(createClientSpecs(__filename))( 'Client aggregate tests', ({ createClient }) => { let client: ClientContract; beforeEach(async () => { client = await createClient(); - await client.$pushSchema(); }); afterEach(async () => { diff --git a/packages/runtime/test/client-api/client-specs.ts b/packages/runtime/test/client-api/client-specs.ts index 2e5d7a77..de2296b2 100644 --- a/packages/runtime/test/client-api/client-specs.ts +++ b/packages/runtime/test/client-api/client-specs.ts @@ -1,9 +1,13 @@ import type { LogEvent } from 'kysely'; import { getSchema, schema } from '../test-schema'; import { makePostgresClient, makeSqliteClient } from '../utils'; -import type { ClientContract } from '../../src/client'; +import type { ClientContract } from '../../src'; -export function createClientSpecs(dbName: string, logQueries = false) { +export function createClientSpecs( + dbName: string, + logQueries = false, + providers = ['sqlite', 'postgresql'] as const +) { const logger = (provider: string) => (event: LogEvent) => { if (event.level === 'query') { console.log( @@ -14,21 +18,46 @@ export function createClientSpecs(dbName: string, logQueries = false) { } }; return [ - { - provider: 'sqlite' as const, - schema: getSchema('sqlite'), - createClient: async () => - makeSqliteClient(getSchema('sqlite'), { - log: logQueries ? logger('sqlite') : undefined, - }) as Promise>, - }, - { - provider: 'postgresql' as const, - schema: getSchema('postgresql'), - createClient: async () => - makePostgresClient(getSchema('postgresql'), dbName, { - log: logQueries ? logger('postgresql') : undefined, - }) as unknown as Promise>, - }, + ...(providers.includes('sqlite') + ? [ + { + provider: 'sqlite' as const, + schema: getSchema('sqlite'), + createClient: async () => { + const client = await makeSqliteClient( + getSchema('sqlite'), + { + log: logQueries + ? logger('sqlite') + : undefined, + } + ); + return client as ClientContract; + }, + }, + ] + : []), + ...(providers.includes('postgresql') + ? [ + { + provider: 'postgresql' as const, + schema: getSchema('postgresql'), + createClient: async () => { + const client = await makePostgresClient( + getSchema('postgresql'), + dbName, + { + log: logQueries + ? logger('postgresql') + : undefined, + } + ); + return client as unknown as ClientContract< + typeof schema + >; + }, + }, + ] + : []), ] as const; } diff --git a/packages/runtime/test/client-api/count.test.ts b/packages/runtime/test/client-api/count.test.ts index ba61f027..e2b580e9 100644 --- a/packages/runtime/test/client-api/count.test.ts +++ b/packages/runtime/test/client-api/count.test.ts @@ -12,7 +12,6 @@ describe.each(createClientSpecs(PG_DB_NAME))( beforeEach(async () => { client = await createClient(); - await client.$pushSchema(); }); afterEach(async () => { diff --git a/packages/runtime/test/client-api/create-many-and-return.test.ts b/packages/runtime/test/client-api/create-many-and-return.test.ts index 4781ef0f..db66a957 100644 --- a/packages/runtime/test/client-api/create-many-and-return.test.ts +++ b/packages/runtime/test/client-api/create-many-and-return.test.ts @@ -12,7 +12,6 @@ describe.each(createClientSpecs(PG_DB_NAME))( beforeEach(async () => { client = await createClient(); - await client.$pushSchema(); }); afterEach(async () => { diff --git a/packages/runtime/test/client-api/create-many.test.ts b/packages/runtime/test/client-api/create-many.test.ts index 74a63cd6..5af5b4da 100644 --- a/packages/runtime/test/client-api/create-many.test.ts +++ b/packages/runtime/test/client-api/create-many.test.ts @@ -12,7 +12,6 @@ describe.each(createClientSpecs(PG_DB_NAME))( beforeEach(async () => { client = await createClient(); - await client.$pushSchema(); }); afterEach(async () => { diff --git a/packages/runtime/test/client-api/create.test.ts b/packages/runtime/test/client-api/create.test.ts index 40d89bfc..95074c32 100644 --- a/packages/runtime/test/client-api/create.test.ts +++ b/packages/runtime/test/client-api/create.test.ts @@ -13,7 +13,6 @@ describe.each(createClientSpecs(PG_DB_NAME))( beforeEach(async () => { client = await createClient(); - await client.$pushSchema(); }); afterEach(async () => { diff --git a/packages/runtime/test/client-api/delete-many.test.ts b/packages/runtime/test/client-api/delete-many.test.ts index c37f53cb..fd54a76e 100644 --- a/packages/runtime/test/client-api/delete-many.test.ts +++ b/packages/runtime/test/client-api/delete-many.test.ts @@ -12,7 +12,6 @@ describe.each(createClientSpecs(PG_DB_NAME))( beforeEach(async () => { client = await createClient(); - await client.$pushSchema(); }); afterEach(async () => { diff --git a/packages/runtime/test/client-api/delete.test.ts b/packages/runtime/test/client-api/delete.test.ts index 393dddfb..17bb2b64 100644 --- a/packages/runtime/test/client-api/delete.test.ts +++ b/packages/runtime/test/client-api/delete.test.ts @@ -12,7 +12,6 @@ describe.each(createClientSpecs(PG_DB_NAME))( beforeEach(async () => { client = await createClient(); - await client.$pushSchema(); }); afterEach(async () => { diff --git a/packages/runtime/test/client-api/filter.test.ts b/packages/runtime/test/client-api/filter.test.ts index 3ec61ea9..f4925442 100644 --- a/packages/runtime/test/client-api/filter.test.ts +++ b/packages/runtime/test/client-api/filter.test.ts @@ -12,7 +12,6 @@ describe.each(createClientSpecs(PG_DB_NAME))( beforeEach(async () => { client = await createClient(); - await client.$pushSchema(); }); afterEach(async () => { diff --git a/packages/runtime/test/client-api/find.test.ts b/packages/runtime/test/client-api/find.test.ts index 0bc9bb4e..4e568f4b 100644 --- a/packages/runtime/test/client-api/find.test.ts +++ b/packages/runtime/test/client-api/find.test.ts @@ -14,7 +14,6 @@ describe.each(createClientSpecs(PG_DB_NAME))( beforeEach(async () => { client = await createClient(); - await client.$pushSchema(); }); afterEach(async () => { diff --git a/packages/runtime/test/client-api/group-by.test.ts b/packages/runtime/test/client-api/group-by.test.ts index 8945c2ec..0202c702 100644 --- a/packages/runtime/test/client-api/group-by.test.ts +++ b/packages/runtime/test/client-api/group-by.test.ts @@ -4,14 +4,13 @@ import { schema } from '../test-schema'; import { createClientSpecs } from './client-specs'; import { createUser } from './utils'; -describe.each(createClientSpecs(__filename, true))( +describe.each(createClientSpecs(__filename))( 'Client groupBy tests', ({ createClient }) => { let client: ClientContract; beforeEach(async () => { client = await createClient(); - await client.$pushSchema(); }); afterEach(async () => { @@ -75,7 +74,7 @@ describe.each(createClientSpecs(__filename, true))( await expect( client.user.groupBy({ - by: ['name'], + by: ['name', 'role'], orderBy: { _count: { role: 'desc', @@ -84,8 +83,8 @@ describe.each(createClientSpecs(__filename, true))( _count: true, }) ).resolves.toEqual([ - { name: 'User', _count: 2 }, - { name: 'Admin', _count: 1 }, + { name: 'User', role: 'USER', _count: 2 }, + { name: 'Admin', role: 'ADMIN', _count: 1 }, ]); }); diff --git a/packages/runtime/test/client-api/update-many.test.ts b/packages/runtime/test/client-api/update-many.test.ts index 1239f176..6cd65187 100644 --- a/packages/runtime/test/client-api/update-many.test.ts +++ b/packages/runtime/test/client-api/update-many.test.ts @@ -12,7 +12,6 @@ describe.each(createClientSpecs(PG_DB_NAME))( beforeEach(async () => { client = await createClient(); - await client.$pushSchema(); }); afterEach(async () => { diff --git a/packages/runtime/test/client-api/update.test.ts b/packages/runtime/test/client-api/update.test.ts index 1be6cac3..e6df2e20 100644 --- a/packages/runtime/test/client-api/update.test.ts +++ b/packages/runtime/test/client-api/update.test.ts @@ -13,7 +13,6 @@ describe.each(createClientSpecs(PG_DB_NAME))( beforeEach(async () => { client = await createClient(); - await client.$pushSchema(); }); afterEach(async () => { diff --git a/packages/runtime/test/client-api/upsert.test.ts b/packages/runtime/test/client-api/upsert.test.ts index 8055d0e2..212c90eb 100644 --- a/packages/runtime/test/client-api/upsert.test.ts +++ b/packages/runtime/test/client-api/upsert.test.ts @@ -12,7 +12,6 @@ describe.each(createClientSpecs(PG_DB_NAME))( beforeEach(async () => { client = await createClient(); - await client.$pushSchema(); }); afterEach(async () => { diff --git a/packages/runtime/test/policy/auth.test.ts b/packages/runtime/test/policy/auth.test.ts index 5eabaee0..4d91d4b7 100644 --- a/packages/runtime/test/policy/auth.test.ts +++ b/packages/runtime/test/policy/auth.test.ts @@ -366,8 +366,12 @@ model Post { const r = await userDb.post.createManyAndReturn({ data: [{ title: 'xxx' }, { title: 'yyy' }], }); - expect(r[0]).toMatchObject({ title: 'xxx', score: 10 }); - expect(r[1]).toMatchObject({ title: 'yyy', score: 10 }); + expect(r).toEqual( + expect.arrayContaining([ + expect.objectContaining({ title: 'xxx', score: 10 }), + expect.objectContaining({ title: 'yyy', score: 10 }), + ]) + ); }); it('respects explicitly passed field values even when default is set', async () => { diff --git a/packages/runtime/test/policy/empty-policy.test.ts b/packages/runtime/test/policy/empty-policy.test.ts new file mode 100644 index 00000000..2aa55c3f --- /dev/null +++ b/packages/runtime/test/policy/empty-policy.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import { createPolicyTestClient } from './utils'; + +describe('empty policy tests', () => { + it('works with simple operations', async () => { + const db = await createPolicyTestClient( + ` + model Model { + id String @id @default(uuid()) + value Int + } + ` + ); + + const rawDb = db.$unuseAll(); + await rawDb.model.create({ data: { id: '1', value: 0 } }); + + expect(await db.model.findMany()).toHaveLength(0); + expect(await db.model.findUnique({ where: { id: '1' } })).toBeNull(); + expect(await db.model.findFirst({ where: { id: '1' } })).toBeNull(); + await expect( + db.model.findUniqueOrThrow({ where: { id: '1' } }) + ).toBeRejectedNotFound(); + await expect( + db.model.findFirstOrThrow({ where: { id: '1' } }) + ).toBeRejectedNotFound(); + + await expect( + db.model.create({ data: { value: 1 } }) + ).toBeRejectedByPolicy(); + await expect( + db.model.createMany({ data: [{ value: 1 }] }) + ).toBeRejectedByPolicy(); + + await expect( + db.model.update({ where: { id: '1' }, data: { value: 1 } }) + ).toBeRejectedNotFound(); + await expect( + db.model.updateMany({ data: { value: 1 } }) + ).resolves.toMatchObject({ count: 0 }); + await expect( + db.model.upsert({ + where: { id: '1' }, + create: { value: 1 }, + update: { value: 1 }, + }) + ).toBeRejectedByPolicy(); + + await expect( + db.model.delete({ where: { id: '1' } }) + ).toBeRejectedNotFound(); + await expect(db.model.deleteMany()).resolves.toMatchObject({ + count: 0, + }); + + await expect( + db.model.aggregate({ _avg: { value: true } }) + ).resolves.toEqual(expect.objectContaining({ _avg: { value: null } })); + await expect( + db.model.groupBy({ by: ['id'], _avg: { value: true } }) + ).resolves.toHaveLength(0); + await expect(db.model.count()).resolves.toEqual(0); + }); + + it('to-many write', async () => { + const db = await createPolicyTestClient( + ` + model M1 { + id String @id @default(uuid()) + m2 M2[] + + @@allow('all', true) + } + + model M2 { + id String @id @default(uuid()) + m1 M1 @relation(fields: [m1Id], references:[id]) + m1Id String + } + ` + ); + + await expect( + db.m1.create({ + data: { + m2: { + create: [{}], + }, + }, + }) + ).toBeRejectedByPolicy(); + }); + + it('to-one write', async () => { + const db = await createPolicyTestClient( + ` + model M1 { + id String @id @default(uuid()) + m2 M2? + + @@allow('all', true) + } + + model M2 { + id String @id @default(uuid()) + m1 M1 @relation(fields: [m1Id], references:[id]) + m1Id String @unique + } + ` + ); + + await expect( + db.m1.create({ + data: { + m2: { + create: {}, + }, + }, + }) + ).toBeRejectedByPolicy(); + }); +}); diff --git a/packages/runtime/test/policy/field-comparison.test.ts b/packages/runtime/test/policy/field-comparison.test.ts new file mode 100644 index 00000000..42f755dc --- /dev/null +++ b/packages/runtime/test/policy/field-comparison.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; +import { createPolicyTestClient } from './utils'; + +describe('field comparison tests', () => { + it('works with policies involving field comparison', async () => { + const db = await createPolicyTestClient( + ` + model Model { + id String @id @default(uuid()) + x Int + y Int + + @@allow('create', x > y) + @@allow('read', true) + } + ` + ); + + await expect( + db.model.create({ data: { x: 1, y: 2 } }) + ).toBeRejectedByPolicy(); + await expect( + db.model.create({ data: { x: 2, y: 1 } }) + ).toResolveTruthy(); + }); + + it('works with "in" operator', async () => { + const db = await createPolicyTestClient( + ` + model Model { + id String @id @default(uuid()) + x String + y String[] + @@allow('create', x in y) + @@allow('read', x in y) + } + `, + { + provider: 'postgresql', + dbName: 'field-comparison-tests-operator', + } + ); + + try { + await expect( + db.model.create({ data: { x: 'a', y: ['b', 'c'] } }) + ).toBeRejectedByPolicy(); + await expect( + db.model.create({ data: { x: 'a', y: ['a', 'c'] } }) + ).toResolveTruthy(); + } finally { + await db.$disconnect(); + } + }); + + it('field in operator success with policy check', async () => { + const db = await createPolicyTestClient( + ` + model Model { + id String @id @default(uuid()) + x String @default('x') + y String[] + @@allow('create', x in y) + @@allow('read', x in y) + } + `, + { + provider: 'postgresql', + dbName: 'field-comparison-tests-operator-2', + } + ); + + try { + await expect( + db.model.create({ data: { x: 'a', y: ['b', 'c'] } }) + ).toBeRejectedByPolicy(); + await expect( + db.model.create({ data: { x: 'a', y: ['a', 'c'] } }) + ).toResolveTruthy(); + } finally { + await db.$disconnect(); + } + }); + + it('field comparison type error', async () => { + await expect( + createPolicyTestClient( + ` + model Model { + id String @id @default(uuid()) + x Int + y String + + @@allow('create', x > y) + @@allow('read', true) + } + ` + ) + ).rejects.toThrow(/invalid operand type/); + }); +}); diff --git a/packages/runtime/test/policy/read.test.ts b/packages/runtime/test/policy/read.test.ts index 12ec87d0..4cd68be9 100644 --- a/packages/runtime/test/policy/read.test.ts +++ b/packages/runtime/test/policy/read.test.ts @@ -13,7 +13,6 @@ describe.each(createClientSpecs(PG_DB_NAME))( beforeEach(async () => { client = await createClient(); - await client.$pushSchema(); }); afterEach(async () => { diff --git a/packages/runtime/test/policy/utils.ts b/packages/runtime/test/policy/utils.ts index c5e1607f..2c63da5c 100644 --- a/packages/runtime/test/policy/utils.ts +++ b/packages/runtime/test/policy/utils.ts @@ -1,11 +1,10 @@ -import type { ClientOptions } from '../../src/client/options'; import { PolicyPlugin } from '../../src/plugins/policy'; import type { SchemaDef } from '../../src/schema'; -import { createTestClient } from '../utils'; +import { createTestClient, type CreateTestClientOptions } from '../utils'; export function createPolicyTestClient( schema: string | SchemaDef, - options?: ClientOptions + options?: CreateTestClientOptions ) { return createTestClient(schema as any, { ...options, diff --git a/packages/runtime/test/utils.ts b/packages/runtime/test/utils.ts index 9adc82e9..e71f3d68 100644 --- a/packages/runtime/test/utils.ts +++ b/packages/runtime/test/utils.ts @@ -1,6 +1,7 @@ import { generateTsSchema } from '@zenstackhq/testtools'; import Sqlite from 'better-sqlite3'; import { Client as PGClient, Pool } from 'pg'; +import invariant from 'tiny-invariant'; import { ZenStackClient } from '../src/client'; import type { ClientOptions } from '../src/client/options'; import type { SchemaDef } from '../src/schema/schema'; @@ -12,41 +13,52 @@ export async function makeSqliteClient( schema: Schema, extraOptions?: Partial> ) { - return new ZenStackClient(schema, { + const client = new ZenStackClient(schema, { ...extraOptions, dialectConfig: { database: new Sqlite(':memory:') }, } as unknown as ClientOptions); + await client.$pushSchema(); + return client; } +const TEST_PG_CONFIG = { + host: process.env['TEST_PG_HOST'] ?? 'localhost', + port: process.env['TEST_PG_PORT'] + ? parseInt(process.env['TEST_PG_PORT']) + : 5432, + user: process.env['TEST_PG_USER'] ?? 'postgres', + password: process.env['TEST_PG_PASSWORD'] ?? 'abc123', +}; + export async function makePostgresClient( schema: Schema, dbName: string, extraOptions?: Partial> ) { - const pgConfig = { - host: 'localhost', - port: 5432, - user: 'postgres', - password: 'abc123', - }; - - const pgClient = new PGClient(pgConfig); + invariant(dbName, 'dbName is required'); + const pgClient = new PGClient(TEST_PG_CONFIG); await pgClient.connect(); await pgClient.query(`DROP DATABASE IF EXISTS "${dbName}"`); await pgClient.query(`CREATE DATABASE "${dbName}"`); - return new ZenStackClient(schema, { + const client = new ZenStackClient(schema, { ...extraOptions, dialectConfig: { pool: new Pool({ - ...pgConfig, + ...TEST_PG_CONFIG, database: dbName, }), }, } as unknown as ClientOptions); + await client.$pushSchema(); + return client; } -type CreateTestClientOptions = ClientOptions; +export type CreateTestClientOptions = + ClientOptions & { + provider?: 'sqlite' | 'postgresql'; + dbName?: string; + }; export async function createTestClient( schema: Schema, @@ -62,12 +74,26 @@ export async function createTestClient( ): Promise { let _schema = typeof schema === 'string' - ? ((await generateTsSchema(schema)) as Schema) + ? ((await generateTsSchema( + schema, + options?.provider, + options?.dbName + )) as Schema) : schema; const { plugins, ...rest } = options ?? {}; let client = new ZenStackClient(_schema, rest as ClientOptions); + + if (options?.provider === 'postgresql') { + invariant(options?.dbName, 'dbName is required'); + const pgClient = new PGClient(TEST_PG_CONFIG); + await pgClient.connect(); + await pgClient.query(`DROP DATABASE IF EXISTS "${options!.dbName}"`); + await pgClient.query(`CREATE DATABASE "${options!.dbName}"`); + await pgClient.end(); + } + await client.$pushSchema(); if (options?.plugins) { diff --git a/packages/runtime/tsup.config.ts b/packages/runtime/tsup.config.ts index 114183c4..cfc1ffe7 100644 --- a/packages/runtime/tsup.config.ts +++ b/packages/runtime/tsup.config.ts @@ -6,6 +6,8 @@ export default defineConfig({ client: 'src/client/index.ts', schema: 'src/schema/index.ts', 'plugins/policy': 'src/plugins/policy/index.ts', + 'utils/pg-utils': 'src/utils/pg-utils.ts', + 'utils/sqlite-utils': 'src/utils/sqlite-utils.ts', }, outDir: 'dist', splitting: false, diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index 0ae8521b..fb27cb1d 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -125,61 +125,85 @@ export class TsSchemaGenerator { statements.push(runtimeImportDecl); const { type: providerType } = this.getDataSourceProvider(model); - if (providerType === 'sqlite') { - // add imports for calculating the path of sqlite database file + switch (providerType) { + case 'sqlite': { + // add imports for calculating the path of sqlite database file - // `import path from 'node:path';` - const pathImportDecl = ts.factory.createImportDeclaration( - undefined, - ts.factory.createImportClause( - false, - ts.factory.createIdentifier('path'), - undefined - ), - ts.factory.createStringLiteral('node:path') - ); - statements.push(pathImportDecl); + // `import path from 'node:path';` + const pathImportDecl = ts.factory.createImportDeclaration( + undefined, + ts.factory.createImportClause( + false, + ts.factory.createIdentifier('path'), + undefined + ), + ts.factory.createStringLiteral('node:path') + ); + statements.push(pathImportDecl); - // `import url from 'node:url';` - const urlImportDecl = ts.factory.createImportDeclaration( - undefined, - ts.factory.createImportClause( - false, - ts.factory.createIdentifier('url'), - undefined - ), - ts.factory.createStringLiteral('node:url') - ); - statements.push(urlImportDecl); - } + // `import url from 'node:url';` + const urlImportDecl = ts.factory.createImportDeclaration( + undefined, + ts.factory.createImportClause( + false, + ts.factory.createIdentifier('url'), + undefined + ), + ts.factory.createStringLiteral('node:url') + ); + statements.push(urlImportDecl); - const { type: dsType } = this.getDataSourceProvider(model); - const dbImportDecl = ts.factory.createImportDeclaration( - undefined, - dsType === 'sqlite' - ? // `import SQLite from 'better-sqlite3';` - ts.factory.createImportClause( - false, - ts.factory.createIdentifier('SQLite'), - undefined - ) - : // `import { Pool } from 'pg';` - ts.factory.createImportClause( - false, - undefined, - ts.factory.createNamedImports([ - ts.factory.createImportSpecifier( - false, - undefined, - ts.factory.createIdentifier('Pool') - ), - ]) - ), - ts.factory.createStringLiteral( - dsType === 'sqlite' ? 'better-sqlite3' : 'pg' - ) - ); - statements.push(dbImportDecl); + // `import { toDialectConfig } from '@zenstackhq/runtime/utils/sqlite-utils';` + const dialectConfigImportDecl = + ts.factory.createImportDeclaration( + undefined, + ts.factory.createImportClause( + false, + undefined, + ts.factory.createNamedImports([ + ts.factory.createImportSpecifier( + false, + undefined, + ts.factory.createIdentifier( + 'toDialectConfig' + ) + ), + ]) + ), + ts.factory.createStringLiteral( + '@zenstackhq/runtime/utils/sqlite-utils' + ) + ); + statements.push(dialectConfigImportDecl); + break; + } + + case 'postgresql': { + // `import { toDialectConfig } from '@zenstackhq/runtime/utils/pg-utils';` + const dialectConfigImportDecl = + ts.factory.createImportDeclaration( + undefined, + ts.factory.createImportClause( + false, + undefined, + ts.factory.createNamedImports([ + ts.factory.createImportSpecifier( + false, + undefined, + ts.factory.createIdentifier( + 'toDialectConfig' + ) + ), + ]) + ), + ts.factory.createStringLiteral( + '@zenstackhq/runtime/utils/pg-utils' + ) + ); + statements.push(dialectConfigImportDecl); + break; + } + } const declaration = ts.factory.createVariableStatement( [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], @@ -281,16 +305,16 @@ export class TsSchemaGenerator { } private createProviderObject(model: Model): ts.Expression { - const { type, url } = this.getDataSourceProvider(model); + const dsProvider = this.getDataSourceProvider(model); return ts.factory.createObjectLiteralExpression( [ ts.factory.createPropertyAssignment( 'type', - ts.factory.createStringLiteral(type) + ts.factory.createStringLiteral(dsProvider.type) ), ts.factory.createPropertyAssignment( 'dialectConfigProvider', - this.createDialectConfigProvider(type, url) + this.createDialectConfigProvider(dsProvider) ), ], true @@ -608,7 +632,11 @@ export class TsSchemaGenerator { return ts.factory.createObjectLiteralExpression(objectFields, true); } - private getDataSourceProvider(model: Model) { + private getDataSourceProvider( + model: Model + ): + | { type: string; url: string; env: undefined } + | { type: string; env: string; url: undefined } { const dataSource = model.declarations.find(isDataSource); invariant(dataSource, 'No data source found in the model'); @@ -623,9 +651,10 @@ export class TsSchemaGenerator { isLiteralExpr(urlExpr) || isInvocationExpr(urlExpr), 'URL must be a literal or env function' ); + let url: string; if (isLiteralExpr(urlExpr)) { - url = urlExpr.value as string; + return { type, url: urlExpr.value as string, env: undefined }; } else if (isInvocationExpr(urlExpr)) { invariant( urlExpr.function.$refText === 'env', @@ -638,11 +667,14 @@ export class TsSchemaGenerator { url = `env(${ (urlExpr.args[0]!.value as LiteralExpr).value as string })`; + return { + type, + env: (urlExpr.args[0]!.value as LiteralExpr).value as string, + url: undefined, + }; } else { throw new Error('Unsupported URL type'); } - - return { type, url }; } private getMappedDefault( @@ -953,13 +985,26 @@ export class TsSchemaGenerator { : undefined; } - private createDialectConfigProvider(type: string, url: string) { - return match(type) - .with('sqlite', () => { - let dbPath = url; + private createDialectConfigProvider( + dsProvider: + | { type: string; env: undefined; url: string } + | { type: string; env: string; url: undefined } + ) { + const type = dsProvider.type; + + let urlExpr: ts.Expression; + if (dsProvider.env !== undefined) { + urlExpr = ts.factory.createIdentifier( + `process.env['${dsProvider.env}']` + ); + } else { + urlExpr = ts.factory.createStringLiteral(dsProvider.url); + + if (type === 'sqlite') { + // convert file: URL to a regular path let parsedUrl: URL | undefined; try { - parsedUrl = new URL(url); + parsedUrl = new URL(dsProvider.url); } catch {} if (parsedUrl) { @@ -968,52 +1013,37 @@ export class TsSchemaGenerator { 'Invalid SQLite URL: only file protocol is supported' ); } - dbPath = url.replace(/^file:/, ''); + urlExpr = ts.factory.createStringLiteral( + dsProvider.url.replace(/^file:/, '') + ); } + } + } + return match(type) + .with('sqlite', () => { return ts.factory.createFunctionExpression( undefined, undefined, undefined, undefined, undefined, - ts.factory.createTypeReferenceNode('any'), + undefined, ts.factory.createBlock( [ ts.factory.createReturnStatement( - ts.factory.createObjectLiteralExpression([ - ts.factory.createPropertyAssignment( - 'database', - ts.factory.createNewExpression( - ts.factory.createIdentifier( - 'SQLite' - ), - undefined, - [ - dbPath === ':memory:' - ? ts.factory.createStringLiteral( - dbPath - ) - : ts.factory.createCallExpression( - ts.factory.createIdentifier( - 'path.resolve' - ), - undefined, - [ - // isomorphic __dirname for CJS and import.meta.url for ESM - ts.factory - .createIdentifier(`typeof __dirname !== 'undefined' - ? __dirname - : path.dirname(url.fileURLToPath(import.meta.url))`), - ts.factory.createStringLiteral( - dbPath - ), - ] - ), - ] - ) + ts.factory.createCallExpression( + ts.factory.createIdentifier( + 'toDialectConfig' ), - ]) + undefined, + [ + urlExpr, + ts.factory.createIdentifier( + `typeof __dirname !== 'undefined' ? __dirname : path.dirname(url.fileURLToPath(import.meta.url))` + ), + ] + ) ), ], true @@ -1031,20 +1061,13 @@ export class TsSchemaGenerator { ts.factory.createBlock( [ ts.factory.createReturnStatement( - ts.factory.createObjectLiteralExpression([ - ts.factory.createPropertyAssignment( - 'database', - ts.factory.createNewExpression( - ts.factory.createIdentifier('Pool'), - undefined, - [ - ts.factory.createStringLiteral( - url - ), - ] - ) + ts.factory.createCallExpression( + ts.factory.createIdentifier( + 'toDialectConfig' ), - ]) + undefined, + [urlExpr] + ) ), ], true diff --git a/packages/testtools/package.json b/packages/testtools/package.json index 9575caab..f83ba902 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -34,6 +34,7 @@ "@zenstackhq/sdk": "workspace:*", "glob": "^11.0.2", "tmp": "^0.2.3", + "ts-pattern": "^5.7.1", "typescript": "^5.8.3" }, "peerDependencies": { diff --git a/packages/testtools/src/schema.ts b/packages/testtools/src/schema.ts index 6996f5ea..ebfb9494 100644 --- a/packages/testtools/src/schema.ts +++ b/packages/testtools/src/schema.ts @@ -5,21 +5,42 @@ import fs from 'node:fs'; import path from 'node:path'; import tmp from 'tmp'; import { glob } from 'glob'; +import { match } from 'ts-pattern'; -const ZMODEL_PRELUDE = ` +function makePrelude(provider: 'sqlite' | 'postgresql', dbName?: string) { + return match(provider) + .with('sqlite', () => { + return ` datasource db { provider = 'sqlite' url = ':memory:' } `; + }) + .with('postgresql', () => { + return ` +datasource db { + provider = 'postgresql' + url = 'postgres://postgres:abc123@localhost:5432/${dbName}' +} +`; + }) + .exhaustive(); +} -export async function generateTsSchema(schemaText: string, noPrelude = false) { +export async function generateTsSchema( + schemaText: string, + provider: 'sqlite' | 'postgresql' = 'sqlite', + dbName?: string +) { const { name: workDir } = tmp.dirSync({ unsafeCleanup: true }); console.log(`Working directory: ${workDir}`); + const zmodelPath = path.join(workDir, 'schema.zmodel'); + const noPrelude = schemaText.includes('datasource '); fs.writeFileSync( zmodelPath, - `${noPrelude ? '' : ZMODEL_PRELUDE}\n\n${schemaText}` + `${noPrelude ? '' : makePrelude(provider, dbName)}\n\n${schemaText}` ); const pluginModelFiles = glob.sync( @@ -71,5 +92,5 @@ export async function generateTsSchema(schemaText: string, noPrelude = false) { export function generateTsSchemaFromFile(filePath: string) { const schemaText = fs.readFileSync(filePath, 'utf8'); - return generateTsSchema(schemaText, true); + return generateTsSchema(schemaText); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e65835c0..471155e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -148,6 +148,9 @@ importers: pg: specifier: ^8.13.1 version: 8.13.1 + pg-connection-string: + specifier: ^2.9.0 + version: 2.9.0 tiny-invariant: specifier: ^1.3.3 version: 1.3.3 @@ -249,6 +252,9 @@ importers: tmp: specifier: ^0.2.3 version: 0.2.3 + ts-pattern: + specifier: ^5.7.1 + version: 5.7.1 typescript: specifier: ^5.8.3 version: 5.8.3 @@ -2144,6 +2150,9 @@ packages: pg-connection-string@2.7.0: resolution: {integrity: sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==} + pg-connection-string@2.9.0: + resolution: {integrity: sha512-P2DEBKuvh5RClafLngkAuGe9OUlFV7ebu8w1kmaaOgPcpJd1RIFh7otETfI6hAR8YupOLFTY7nuvvIn7PLciUQ==} + pg-int8@1.0.1: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} engines: {node: '>=4.0.0'} @@ -2627,6 +2636,9 @@ packages: ts-pattern@5.7.0: resolution: {integrity: sha512-0/FvIG4g3kNkYgbNwBBW5pZBkfpeYQnH+2AA3xmjkCAit/DSDPKmgwC3fKof4oYUq6gupClVOJlFl+939VRBMg==} + ts-pattern@5.7.1: + resolution: {integrity: sha512-EGs8PguQqAAUIcQfK4E9xdXxB6s2GK4sJfT/vcc9V1ELIvC4LH/zXu2t/5fajtv6oiRCxdv7BgtVK3vWgROxag==} + tsup@8.3.5: resolution: {integrity: sha512-Tunf6r6m6tnZsG9GYWndg0z8dEV7fD733VBFzFJ5Vcm1FtlXB8xBD/rtrBi2a3YKEV7hHtxiZtW5EAVADoe1pA==} engines: {node: '>=18'} @@ -4700,6 +4712,8 @@ snapshots: pg-connection-string@2.7.0: {} + pg-connection-string@2.9.0: {} + pg-int8@1.0.1: {} pg-numeric@1.0.2: {} @@ -5213,6 +5227,8 @@ snapshots: ts-pattern@5.7.0: {} + ts-pattern@5.7.1: {} + tsup@8.3.5(@swc/core@1.10.15)(postcss@8.5.1)(tsx@4.19.2)(typescript@5.7.3): dependencies: bundle-require: 5.1.0(esbuild@0.24.2) diff --git a/samples/blog/README.md b/samples/blog/README.md index faf2573c..35dbb397 100644 --- a/samples/blog/README.md +++ b/samples/blog/README.md @@ -20,9 +20,9 @@ - A Prisma schema [zenstack/schema.prisma](./zenstack/schema.prisma) is also generated. It's used for generating and running database migrations, and you can also use it for other purposes as needed. - You can create a database client with the TypeScript schema like: ```ts - import { createClient } from '@zenstackhq/runtime'; + import { ZenStackClient } from '@zenstackhq/runtime'; import { schema } from './zenstack/schema'; - const db = createClient(schema); + const db = ZenStackClient(schema); ``` - Run `zenstack migrate dev` to generate and apply database migrations. It internally calls `prisma migrate dev`. Same for `zenstack migrate deploy`. - ZenStack v3 doesn't generate into "node_modules" anymore. The generated TypeScript schema file can be checked in to source control, and you decide how to build or bundle it with your application. diff --git a/samples/blog/zenstack/schema.ts b/samples/blog/zenstack/schema.ts index 84eb5329..994c5309 100644 --- a/samples/blog/zenstack/schema.ts +++ b/samples/blog/zenstack/schema.ts @@ -6,14 +6,12 @@ import { type SchemaDef, type OperandExpression, Expression } from "@zenstackhq/runtime/schema"; import path from "node:path"; import url from "node:url"; -import SQLite from "better-sqlite3"; +import { toDialectConfig } from "@zenstackhq/runtime/utils/sqlite-utils"; export const schema = { provider: { type: "sqlite", dialectConfigProvider: function (): any { - return { database: new SQLite(path.resolve(typeof __dirname !== 'undefined' - ? __dirname - : path.dirname(url.fileURLToPath(import.meta.url)), "./dev.db")) }; + return toDialectConfig("./dev.db", typeof __dirname !== 'undefined' ? __dirname : path.dirname(url.fileURLToPath(import.meta.url))); } }, models: {