diff --git a/TODO.md b/TODO.md index 09d24659..c5a5e241 100644 --- a/TODO.md +++ b/TODO.md @@ -104,7 +104,7 @@ - [x] Inject "on conflict do update" - [x] `check` function - [ ] Custom functions - - [ ] Accessing tables not in the schema + - [x] Accessing tables not in the schema - [x] Migration - [ ] Databases - [x] SQLite diff --git a/package.json b/package.json index 95aa72c7..ce90997d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-v3", - "version": "3.0.0-beta.30", + "version": "3.0.0-beta.31", "description": "ZenStack", "packageManager": "pnpm@10.23.0", "type": "module", diff --git a/packages/auth-adapters/better-auth/package.json b/packages/auth-adapters/better-auth/package.json index b4018ce1..ed7c0294 100644 --- a/packages/auth-adapters/better-auth/package.json +++ b/packages/auth-adapters/better-auth/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/better-auth", - "version": "3.0.0-beta.30", + "version": "3.0.0-beta.31", "description": "ZenStack Better Auth Adapter. This adapter is modified from better-auth's Prisma adapter.", "type": "module", "scripts": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 3803497d..b00c0798 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -3,7 +3,7 @@ "publisher": "zenstack", "displayName": "ZenStack CLI", "description": "FullStack database toolkit with built-in access control and automatic API generation.", - "version": "3.0.0-beta.30", + "version": "3.0.0-beta.31", "type": "module", "author": { "name": "ZenStack Team" diff --git a/packages/cli/src/actions/templates.ts b/packages/cli/src/actions/templates.ts index 65ad278a..155e51db 100644 --- a/packages/cli/src/actions/templates.ts +++ b/packages/cli/src/actions/templates.ts @@ -27,17 +27,17 @@ model Post { `; export const STARTER_MAIN_TS = `import { ZenStackClient } from '@zenstackhq/orm'; +import { SqliteDialect } from '@zenstackhq/orm/dialects/sqlite'; import SQLite from 'better-sqlite3'; -import { SqliteDialect } from 'kysely'; import { schema } from './zenstack/schema'; async function main() { - const client = new ZenStackClient(schema, { + const db = new ZenStackClient(schema, { dialect: new SqliteDialect({ database: new SQLite('./zenstack/dev.db'), }), }); - const user = await client.user.create({ + const user = await db.user.create({ data: { email: 'test@zenstack.dev', posts: { diff --git a/packages/clients/tanstack-query/package.json b/packages/clients/tanstack-query/package.json index 2a32050c..4ed8c06f 100644 --- a/packages/clients/tanstack-query/package.json +++ b/packages/clients/tanstack-query/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/tanstack-query", - "version": "3.0.0-beta.30", + "version": "3.0.0-beta.31", "description": "TanStack Query Client for consuming ZenStack v3's CRUD service", "main": "index.js", "type": "module", diff --git a/packages/common-helpers/package.json b/packages/common-helpers/package.json index 9ac11a00..e7616d51 100644 --- a/packages/common-helpers/package.json +++ b/packages/common-helpers/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/common-helpers", - "version": "3.0.0-beta.30", + "version": "3.0.0-beta.31", "description": "ZenStack Common Helpers", "type": "module", "scripts": { diff --git a/packages/config/eslint-config/package.json b/packages/config/eslint-config/package.json index a126f18b..b2eaf81d 100644 --- a/packages/config/eslint-config/package.json +++ b/packages/config/eslint-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/eslint-config", - "version": "3.0.0-beta.30", + "version": "3.0.0-beta.31", "type": "module", "private": true, "license": "MIT" diff --git a/packages/config/typescript-config/package.json b/packages/config/typescript-config/package.json index 864cb27d..7898557b 100644 --- a/packages/config/typescript-config/package.json +++ b/packages/config/typescript-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/typescript-config", - "version": "3.0.0-beta.30", + "version": "3.0.0-beta.31", "private": true, "license": "MIT" } diff --git a/packages/config/vitest-config/package.json b/packages/config/vitest-config/package.json index b36ed6f1..78d8b455 100644 --- a/packages/config/vitest-config/package.json +++ b/packages/config/vitest-config/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/vitest-config", "type": "module", - "version": "3.0.0-beta.30", + "version": "3.0.0-beta.31", "private": true, "license": "MIT", "exports": { diff --git a/packages/create-zenstack/package.json b/packages/create-zenstack/package.json index 11b5f41f..a30ffadf 100644 --- a/packages/create-zenstack/package.json +++ b/packages/create-zenstack/package.json @@ -1,6 +1,6 @@ { "name": "create-zenstack", - "version": "3.0.0-beta.30", + "version": "3.0.0-beta.31", "description": "Create a new ZenStack project", "type": "module", "scripts": { diff --git a/packages/language/package.json b/packages/language/package.json index 5c0fb5a2..a0a11793 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/language", "description": "ZenStack ZModel language specification", - "version": "3.0.0-beta.30", + "version": "3.0.0-beta.31", "license": "MIT", "author": "ZenStack Team", "files": [ diff --git a/packages/language/src/validators/datasource-validator.ts b/packages/language/src/validators/datasource-validator.ts index b667d2b2..745d2773 100644 --- a/packages/language/src/validators/datasource-validator.ts +++ b/packages/language/src/validators/datasource-validator.ts @@ -1,6 +1,6 @@ import type { ValidationAcceptor } from 'langium'; import { SUPPORTED_PROVIDERS } from '../constants'; -import { DataSource, isConfigArrayExpr, isInvocationExpr, isLiteralExpr } from '../generated/ast'; +import { DataSource, isConfigArrayExpr, isDataModel, isEnum, isInvocationExpr, isLiteralExpr } from '../generated/ast'; import { getStringLiteral } from '../utils'; import { validateDuplicatedDeclarations, type AstValidator } from './common'; @@ -70,14 +70,28 @@ export default class DataSourceValidator implements AstValidator { accept('error', '"schemas" must be an array of string literals', { node: schemasField, }); - } else if ( - // validate `defaultSchema` is included in `schemas` - defaultSchemaValue && - !schemasValue.items.some((e) => getStringLiteral(e) === defaultSchemaValue) - ) { - accept('error', `"${defaultSchemaValue}" must be included in the "schemas" array`, { - node: schemasField, - }); + } else { + const schemasArray = schemasValue.items.map((e) => getStringLiteral(e)!); + + if (defaultSchemaValue) { + // validate `defaultSchema` is included in `schemas` + if (!schemasArray.includes(defaultSchemaValue)) { + accept('error', `"${defaultSchemaValue}" must be included in the "schemas" array`, { + node: schemasField, + }); + } + } else { + // if no explicit default schema is specified, and there are models or enums without '@@schema', + // "public" is implicitly used, so it must be included in the "schemas" array + const hasImplicitPublicSchema = ds.$container.declarations.some( + (d) => (isDataModel(d) || isEnum(d)) && !d.attributes.some((a) => a.decl.$refText === '@@schema'), + ); + if (hasImplicitPublicSchema && !schemasArray.includes('public')) { + accept('error', `"public" must be included in the "schemas" array`, { + node: schemasField, + }); + } + } } } } diff --git a/packages/orm/package.json b/packages/orm/package.json index 76228f7c..948f6f59 100644 --- a/packages/orm/package.json +++ b/packages/orm/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/orm", - "version": "3.0.0-beta.30", + "version": "3.0.0-beta.31", "description": "ZenStack ORM", "type": "module", "scripts": { diff --git a/packages/orm/src/client/crud/dialects/postgresql.ts b/packages/orm/src/client/crud/dialects/postgresql.ts index 733af920..92e570fe 100644 --- a/packages/orm/src/client/crud/dialects/postgresql.ts +++ b/packages/orm/src/client/crud/dialects/postgresql.ts @@ -22,6 +22,7 @@ import { getDelegateDescendantModels, getManyToManyRelation, isRelationField, + isTypeDef, requireField, requireIdFields, requireModel, @@ -52,13 +53,25 @@ export class PostgresCrudDialect extends BaseCrudDiale invariant(false, 'should not reach here: AnyNull is not a valid input value'); } - if (Array.isArray(value)) { + // node-pg incorrectly handles array values passed to non-array JSON fields, + // the workaround is to JSON stringify the value + // https://github.com/brianc/node-postgres/issues/374 + + if (isTypeDef(this.schema, type)) { + // type-def fields (regardless array or scalar) are stored as scalar `Json` and + // their input values need to be stringified if not already (i.e., provided in + // default values) + if (typeof value !== 'string') { + return JSON.stringify(value); + } else { + return value; + } + } else if (Array.isArray(value)) { if (type === 'Json' && !forArrayField) { - // node-pg incorrectly handles array values passed to non-array JSON fields, - // the workaround is to JSON stringify the value - // https://github.com/brianc/node-postgres/issues/374 + // scalar `Json` fields need their input stringified return JSON.stringify(value); } else { + // `Json[]` fields need their input as array (not stringified) return value.map((v) => this.transformPrimitive(v, type, false)); } } else { diff --git a/packages/plugins/policy/package.json b/packages/plugins/policy/package.json index b22a2c54..8a2b76e1 100644 --- a/packages/plugins/policy/package.json +++ b/packages/plugins/policy/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/plugin-policy", - "version": "3.0.0-beta.30", + "version": "3.0.0-beta.31", "description": "ZenStack Policy Plugin", "type": "module", "scripts": { diff --git a/packages/plugins/policy/src/policy-handler.ts b/packages/plugins/policy/src/policy-handler.ts index eeb4a8b8..3a772642 100644 --- a/packages/plugins/policy/src/policy-handler.ts +++ b/packages/plugins/policy/src/policy-handler.ts @@ -90,6 +90,8 @@ export class PolicyHandler extends OperationNodeTransf const { mutationModel } = this.getMutationModel(node); + this.tryRejectNonexistentModel(mutationModel); + // --- Pre mutation work --- if (InsertQueryNode.is(node)) { @@ -331,6 +333,8 @@ export class PolicyHandler extends OperationNodeTransf return super.transformJoin(node); } + this.tryRejectNonexistentModel(table.model); + // build a nested query with policy filter applied const filter = this.buildPolicyFilter(table.model, table.alias, 'read'); @@ -872,6 +876,7 @@ export class PolicyHandler extends OperationNodeTransf const extractResult = this.extractTableName(table); if (extractResult) { const { model, alias } = extractResult; + this.tryRejectNonexistentModel(model); const filter = this.buildPolicyFilter(model, alias, 'read'); return acc ? conjunction(this.dialect, [acc, filter]) : filter; } @@ -1011,5 +1016,11 @@ export class PolicyHandler extends OperationNodeTransf return eb.and([aQuery, bQuery]).toOperationNode(); } + private tryRejectNonexistentModel(model: string) { + if (!QueryUtils.hasModel(this.client.$schema, model) && !this.isManyToManyJoinTable(model)) { + throw createRejectedByPolicyError(model, RejectedByPolicyReason.NO_ACCESS); + } + } + // #endregion } diff --git a/packages/schema/package.json b/packages/schema/package.json index cbe28647..55cd411b 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/schema", - "version": "3.0.0-beta.30", + "version": "3.0.0-beta.31", "description": "ZenStack Runtime Schema", "type": "module", "scripts": { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 3483d78e..a5ef69c4 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "3.0.0-beta.30", + "version": "3.0.0-beta.31", "description": "ZenStack SDK", "type": "module", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index a6b2e1c0..ec3ce79a 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/server", - "version": "3.0.0-beta.30", + "version": "3.0.0-beta.31", "description": "ZenStack automatic CRUD API handlers and server adapters", "type": "module", "scripts": { diff --git a/packages/testtools/package.json b/packages/testtools/package.json index ff2f50ec..46cb5c85 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "3.0.0-beta.30", + "version": "3.0.0-beta.31", "description": "ZenStack Test Tools", "type": "module", "scripts": { diff --git a/packages/zod/package.json b/packages/zod/package.json index d18fcc0b..99c108da 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/zod", - "version": "3.0.0-beta.30", + "version": "3.0.0-beta.31", "description": "", "type": "module", "main": "index.js", diff --git a/samples/next.js/package.json b/samples/next.js/package.json index f4ebdf96..de1d5dcd 100644 --- a/samples/next.js/package.json +++ b/samples/next.js/package.json @@ -1,6 +1,6 @@ { "name": "next.js", - "version": "3.0.0-beta.30", + "version": "3.0.0-beta.31", "private": true, "scripts": { "generate": "zen generate --lite", diff --git a/samples/orm/package.json b/samples/orm/package.json index 42670944..01035a4b 100644 --- a/samples/orm/package.json +++ b/samples/orm/package.json @@ -1,6 +1,6 @@ { "name": "sample-blog", - "version": "3.0.0-beta.30", + "version": "3.0.0-beta.31", "description": "", "main": "index.js", "private": true, diff --git a/tests/e2e/orm/client-api/pg-custom-schema.test.ts b/tests/e2e/orm/client-api/pg-custom-schema.test.ts index 7f61f498..dfe6fe89 100644 --- a/tests/e2e/orm/client-api/pg-custom-schema.test.ts +++ b/tests/e2e/orm/client-api/pg-custom-schema.test.ts @@ -247,6 +247,75 @@ model Foo { ).rejects.toThrow('"mySchema" must be included in the "schemas" array'); }); + it('requires implicit public schema to be included in schemas', async () => { + await expect( + createTestClient( + ` +datasource db { + provider = 'postgresql' + schemas = ['mySchema'] + url = '$DB_URL' +} + +enum Role { + ADMIN + USER +} + +model Foo { + id Int @id + name String + role Role + @@schema('mySchema') +} + +model Bar { + id Int @id + name String +} +`, + ), + ).rejects.toThrow('"public" must be included in the "schemas" array'); + }); + + it('does not require public schema when all models and enums have explicit schema', async () => { + const db = await createTestClient( + ` +datasource db { + provider = 'postgresql' + schemas = ['mySchema'] + url = '$DB_URL' +} + +enum Role { + ADMIN + USER + @@schema('mySchema') +} + +model Foo { + id Int @id + name String + role Role + @@schema('mySchema') +} + +model Bar { + id Int @id + name String + @@schema('mySchema') +} +`, + { + provider: 'postgresql', + usePrismaPush: true, + }, + ); + + await expect(db.foo.create({ data: { id: 1, name: 'test', role: 'ADMIN' } })).toResolveTruthy(); + await expect(db.bar.create({ data: { id: 1, name: 'test' } })).toResolveTruthy(); + }); + it('allows specifying schema only on a few models', async () => { let fooQueriesVerified = false; let barQueriesVerified = false; diff --git a/tests/e2e/orm/policy/nonexistent-models.test.ts b/tests/e2e/orm/policy/nonexistent-models.test.ts new file mode 100644 index 00000000..70fd0ecb --- /dev/null +++ b/tests/e2e/orm/policy/nonexistent-models.test.ts @@ -0,0 +1,59 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +describe('Policy tests for nonexistent models and fields', () => { + it('rejects access to nonexistent model', async () => { + const db = await createPolicyTestClient( + ` + model Foo { + id String @id @default(cuid()) + string String + @@allow('all', true) + } + `, + ); + const dbRaw = db.$unuseAll(); + + // create a Bar table + await dbRaw.$executeRawUnsafe( + `CREATE TABLE "Bar" ("id" TEXT PRIMARY KEY, "string" TEXT, "fooId" TEXT, FOREIGN KEY ("fooId") REFERENCES "Foo" ("id"));`, + ); + + await dbRaw.$qb.insertInto('Foo').values({ id: '1', string: 'test' }).execute(); + await dbRaw.$qb.insertInto('Bar').values({ id: '1', string: 'test', fooId: '1' }).execute(); + + expect(db.bar).toBeUndefined(); + + // unknown relation + await expect(db.foo.findFirst({ include: { bar: true } })).toBeRejectedByValidation(); + + // read + await expect(db.$qb.selectFrom('Bar').selectAll().execute()).toBeRejectedByPolicy(); + + // join + await expect( + db.$qb.selectFrom('Foo').innerJoin('Bar', 'Bar.fooId', 'Foo.id').selectAll().execute(), + ).toBeRejectedByPolicy(); + + // create + await expect(db.$qb.insertInto('Bar').values({ id: '1', string: 'test' }).execute()).toBeRejectedByPolicy(); + + // update + await expect( + db.$qb.updateTable('Bar').set({ string: 'updated' }).where('id', '=', '1').execute(), + ).toBeRejectedByPolicy(); + + // update with from + await expect( + db.$qb + .updateTable('Foo') + .set({ string: 'updated' }) + .from('Bar') + .where('Bar.fooId', '=', 'Foo.id') + .execute(), + ).toBeRejectedByPolicy(); + + // delete + await expect(db.$qb.deleteFrom('Bar').where('id', '=', '1').execute()).toBeRejectedByPolicy(); + }); +}); diff --git a/tests/e2e/package.json b/tests/e2e/package.json index c6cf0009..19a99f77 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -1,6 +1,6 @@ { "name": "e2e", - "version": "3.0.0-beta.30", + "version": "3.0.0-beta.31", "private": true, "type": "module", "scripts": { diff --git a/tests/regression/package.json b/tests/regression/package.json index 442ee9a2..d750958f 100644 --- a/tests/regression/package.json +++ b/tests/regression/package.json @@ -1,6 +1,6 @@ { "name": "regression", - "version": "3.0.0-beta.30", + "version": "3.0.0-beta.31", "private": true, "type": "module", "scripts": { diff --git a/tests/regression/test/issue-493.test.ts b/tests/regression/test/issue-493.test.ts new file mode 100644 index 00000000..269a68f3 --- /dev/null +++ b/tests/regression/test/issue-493.test.ts @@ -0,0 +1,94 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +describe('Issue 493 regression tests', () => { + it('should correctly handle JSON and typed-JSON array fields for PostgreSQL', async () => { + const schema = ` +type InlineButton { + id String + text String + callback_data String? + url String? + message String? + type String? +} + +type BotButton { + id String + label String + action String + enabled Boolean + order_index Int + message String + inline_buttons InlineButton[]? // Nested custom type +} + +model bot_settings { + id Int @id @default(autoincrement()) + setting_key String @unique + menu_buttons BotButton[] @json // Array of custom type + meta Meta @json +} + +type Meta { + info String +} + +model Foo { + id Int @id @default(autoincrement()) + data Json +} +`; + + const db = await createTestClient(schema, { provider: 'postgresql', debug: true }); + + // plain JSON non-array + await expect( + db.foo.create({ + data: { + data: { hello: 'world' }, + }, + }), + ).resolves.toMatchObject({ + data: { hello: 'world' }, + }); + + // plain JSON array + await expect( + db.foo.create({ + data: { + data: [{ hello: 'world' }], + }, + }), + ).resolves.toMatchObject({ + data: [{ hello: 'world' }], + }); + + // typed-JSON array & non-array + const input = { + setting_key: 'abc', + menu_buttons: [ + { + id: '1', + label: 'Button 1', + action: 'action_1', + enabled: true, + order_index: 1, + message: 'msg', + inline_buttons: [ + { + id: 'ib1', + text: 'Inline 1', + }, + ], + }, + ], + meta: { info: 'some info' }, + }; + await expect( + db.bot_settings.create({ + data: input, + }), + ).resolves.toMatchObject(input); + }); +}); diff --git a/tests/runtimes/bun/package.json b/tests/runtimes/bun/package.json index 90c0e999..dc9c1ed6 100644 --- a/tests/runtimes/bun/package.json +++ b/tests/runtimes/bun/package.json @@ -1,6 +1,6 @@ { "name": "bun-e2e", - "version": "3.0.0-beta.30", + "version": "3.0.0-beta.31", "private": true, "type": "module", "scripts": { diff --git a/tests/runtimes/edge-runtime/package.json b/tests/runtimes/edge-runtime/package.json index 9e1de147..3b46fddc 100644 --- a/tests/runtimes/edge-runtime/package.json +++ b/tests/runtimes/edge-runtime/package.json @@ -1,6 +1,6 @@ { "name": "edge-runtime-e2e", - "version": "3.0.0-beta.30", + "version": "3.0.0-beta.31", "private": true, "type": "module", "scripts": {