diff --git a/package.json b/package.json index 34f12bc5..75e7d55f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-v3", - "version": "3.0.0-alpha.30", + "version": "3.0.0-alpha.31", "description": "ZenStack", "packageManager": "pnpm@10.12.1", "scripts": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 9c620c3b..f0632eca 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -3,7 +3,7 @@ "publisher": "zenstack", "displayName": "ZenStack CLI", "description": "FullStack database toolkit with built-in access control and automatic API generation.", - "version": "3.0.0-alpha.30", + "version": "3.0.0-alpha.31", "type": "module", "author": { "name": "ZenStack Team" diff --git a/packages/common-helpers/package.json b/packages/common-helpers/package.json index ad3d08b5..352eef25 100644 --- a/packages/common-helpers/package.json +++ b/packages/common-helpers/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/common-helpers", - "version": "3.0.0-alpha.30", + "version": "3.0.0-alpha.31", "description": "ZenStack Common Helpers", "type": "module", "scripts": { diff --git a/packages/create-zenstack/package.json b/packages/create-zenstack/package.json index fce02df8..887e8196 100644 --- a/packages/create-zenstack/package.json +++ b/packages/create-zenstack/package.json @@ -1,6 +1,6 @@ { "name": "create-zenstack", - "version": "3.0.0-alpha.30", + "version": "3.0.0-alpha.31", "description": "Create a new ZenStack project", "type": "module", "scripts": { diff --git a/packages/dialects/sql.js/package.json b/packages/dialects/sql.js/package.json index 7dcdfcb6..4ff98b82 100644 --- a/packages/dialects/sql.js/package.json +++ b/packages/dialects/sql.js/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/kysely-sql-js", - "version": "3.0.0-alpha.30", + "version": "3.0.0-alpha.31", "description": "Kysely dialect for sql.js", "type": "module", "scripts": { diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index c4a9019e..ad4086ad 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/eslint-config", - "version": "3.0.0-alpha.30", + "version": "3.0.0-alpha.31", "type": "module", "private": true, "license": "MIT" diff --git a/packages/ide/vscode/package.json b/packages/ide/vscode/package.json index a2b63079..d7107685 100644 --- a/packages/ide/vscode/package.json +++ b/packages/ide/vscode/package.json @@ -1,7 +1,7 @@ { "name": "zenstack", "publisher": "zenstack", - "version": "3.0.0-alpha.30", + "version": "3.0.0-alpha.31", "displayName": "ZenStack Language Tools", "description": "VSCode extension for ZenStack ZModel language", "private": true, diff --git a/packages/language/package.json b/packages/language/package.json index 9afa35a7..7537081b 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/language", "description": "ZenStack ZModel language specification", - "version": "3.0.0-alpha.30", + "version": "3.0.0-alpha.31", "license": "MIT", "author": "ZenStack Team", "files": [ diff --git a/packages/runtime/package.json b/packages/runtime/package.json index ba09c9a8..98d62196 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/runtime", - "version": "3.0.0-alpha.30", + "version": "3.0.0-alpha.31", "description": "ZenStack Runtime", "type": "module", "scripts": { diff --git a/packages/runtime/src/client/crud-types.ts b/packages/runtime/src/client/crud-types.ts index 73e5ed31..12c2a64e 100644 --- a/packages/runtime/src/client/crud-types.ts +++ b/packages/runtime/src/client/crud-types.ts @@ -221,7 +221,12 @@ export type WhereInput< ? ArrayFilter> : // enum GetModelFieldType extends GetEnums - ? EnumFilter, ModelFieldIsOptional> + ? EnumFilter< + Schema, + GetModelFieldType, + ModelFieldIsOptional, + WithAggregations + > : // primitive PrimitiveFilter< Schema, @@ -237,14 +242,25 @@ export type WhereInput< NOT?: OrArray>; }; -type EnumFilter, Nullable extends boolean> = +type EnumFilter< + Schema extends SchemaDef, + T extends GetEnums, + Nullable extends boolean, + WithAggregations extends boolean, +> = | NullableIf, Nullable> - | { + | ({ equals?: NullableIf, Nullable>; in?: (keyof GetEnum)[]; notIn?: (keyof GetEnum)[]; - not?: EnumFilter; - }; + not?: EnumFilter; + } & (WithAggregations extends true + ? { + _count?: NumberFilter; + _min?: EnumFilter; + _max?: EnumFilter; + } + : {})); type ArrayFilter = { equals?: MapScalarType[] | null; diff --git a/packages/runtime/src/client/crud/validator.ts b/packages/runtime/src/client/crud/validator.ts index 6d5f7ebf..f2eb732a 100644 --- a/packages/runtime/src/client/crud/validator.ts +++ b/packages/runtime/src/client/crud/validator.ts @@ -1256,11 +1256,15 @@ export class InputValidator { private makeGroupBySchema(model: GetModels) { const modelDef = requireModel(this.schema, model); const nonRelationFields = Object.keys(modelDef.fields).filter((field) => !modelDef.fields[field]?.relation); + const bySchema = + nonRelationFields.length > 0 + ? this.orArray(z.enum(nonRelationFields as [string, ...string[]]), true) + : z.never(); let schema: z.ZodSchema = z.strictObject({ where: this.makeWhereSchema(model, false).optional(), orderBy: this.orArray(this.makeOrderBySchema(model, false, true), true).optional(), - by: this.orArray(z.enum(nonRelationFields as [string, ...string[]]), true), + by: bySchema, having: this.makeHavingSchema(model).optional(), skip: this.makeSkipSchema().optional(), take: this.makeTakeSchema().optional(), diff --git a/packages/runtime/src/client/executor/name-mapper.ts b/packages/runtime/src/client/executor/name-mapper.ts index 814c1ba7..2124150c 100644 --- a/packages/runtime/src/client/executor/name-mapper.ts +++ b/packages/runtime/src/client/executor/name-mapper.ts @@ -1,10 +1,12 @@ +import { invariant } from '@zenstackhq/common-helpers'; import { AliasNode, ColumnNode, - CreateTableNode, DeleteQueryNode, + FromNode, IdentifierNode, InsertQueryNode, + JoinNode, OperationNodeTransformer, ReferenceNode, ReturningNode, @@ -16,13 +18,18 @@ import { type OperationNode, } from 'kysely'; import type { FieldDef, ModelDef, SchemaDef } from '../../schema'; -import { InternalError } from '../errors'; -import { requireModel } from '../query-utils'; +import { getModel, requireModel } from '../query-utils'; + +type Scope = { + model: string; + alias?: string; + namesMapped?: boolean; +}; export class QueryNameMapper extends OperationNodeTransformer { private readonly modelToTableMap = new Map(); private readonly fieldToColumnMap = new Map(); - private readonly modelStack: string[] = []; + private readonly modelScopes: Scope[] = []; constructor(private readonly schema: SchemaDef) { super(); @@ -41,192 +48,216 @@ export class QueryNameMapper extends OperationNodeTransformer { } } - private get currentModel() { - return this.modelStack[this.modelStack.length - 1]; - } + // #region overrides - protected override transformCreateTable(node: CreateTableNode) { - try { - this.modelStack.push(node.table.table.identifier.name); - return super.transformCreateTable(node); - } finally { - this.modelStack.pop(); + protected override transformSelectQuery(node: SelectQueryNode) { + if (!node.from?.froms) { + return super.transformSelectQuery(node); } + + // all table names in "from" are pushed as scopes, each "from" is expanded + // as nested query to apply column name mapping, so the scopes are marked + // "namesMapped" so no additional name mapping is applied when resolving + // columns + const scopes = this.createScopesFromFroms(node.from, true); + return this.withScopes(scopes, () => { + return { + ...super.transformSelectQuery(node), + // convert "from" to nested query as needed + from: this.processFrom(node.from!), + }; + }); } protected override transformInsertQuery(node: InsertQueryNode) { - try { - if (node.into?.table.identifier.name) { - this.modelStack.push(node.into.table.identifier.name); - } + if (!node.into) { return super.transformInsertQuery(node); - } finally { - if (node.into?.table.identifier.name) { - this.modelStack.pop(); - } } + + return this.withScope( + { model: node.into.table.identifier.name }, + () => + ({ + ...super.transformInsertQuery(node), + // map table name + into: this.processTableRef(node.into!), + }) satisfies InsertQueryNode, + ); } protected override transformReturning(node: ReturningNode) { - return ReturningNode.create(this.transformSelections(node.selections, node)); + return { + kind: node.kind, + // map column names in returning selections (include returningAll) + selections: this.processSelections(node.selections), + }; } - protected override transformUpdateQuery(node: UpdateQueryNode) { - let pushed = false; - if (node.table && TableNode.is(node.table)) { - this.modelStack.push(node.table.table.identifier.name); - pushed = true; - } - try { - return super.transformUpdateQuery(node); - } finally { - if (pushed) { - this.modelStack.pop(); + protected override transformJoin(node: JoinNode) { + const { alias, node: innerNode } = this.stripAlias(node.table); + if (TableNode.is(innerNode!)) { + const modelName = innerNode.table.identifier.name; + if (this.hasMappedColumns(modelName)) { + // create a nested query with all fields selected and names mapped + const select = this.createSelectAll(modelName); + return { ...super.transformJoin(node), table: this.wrapAlias(select, alias ?? modelName) }; } } + return super.transformJoin(node); } - protected override transformDeleteQuery(node: DeleteQueryNode): DeleteQueryNode { - let pushed = false; - if (node.from?.froms && node.from.froms.length === 1 && node.from.froms[0]) { - const from = node.from.froms[0]; - if (TableNode.is(from)) { - this.modelStack.push(from.table.identifier.name); - pushed = true; - } else if (AliasNode.is(from) && TableNode.is(from.node)) { - this.modelStack.push(from.node.table.identifier.name); - pushed = true; - } + protected override transformReference(node: ReferenceNode) { + if (!ColumnNode.is(node.column)) { + return super.transformReference(node); } - try { - return super.transformDeleteQuery(node); - } finally { - if (pushed) { - this.modelStack.pop(); + + // resolve the reference to a field from outer scopes + const { fieldDef, modelDef, scope } = this.resolveFieldFromScopes( + node.column.column.name, + node.table?.table.identifier.name, + ); + if (fieldDef && !scope.namesMapped) { + // map column name and table name as needed + const mappedFieldName = this.mapFieldName(modelDef.name, fieldDef.name); + + // map table name depending on how it is resolved + let mappedTableName = node.table?.table.identifier.name; + if (mappedTableName) { + if (scope.alias === mappedTableName) { + // table name is resolved to an alias, no mapping needed + } else if (scope.model === mappedTableName) { + // table name is resolved to a model, map the name as needed + mappedTableName = this.mapTableName(scope.model); + } } + + return ReferenceNode.create( + ColumnNode.create(mappedFieldName), + mappedTableName ? TableNode.create(mappedTableName) : undefined, + ); + } else { + return super.transformReference(node); } } - protected override transformSelectQuery(node: SelectQueryNode) { - if (!node.from?.froms || node.from.froms.length === 0) { - return super.transformSelectQuery(node); + protected override transformColumn(node: ColumnNode) { + const { modelDef, fieldDef, scope } = this.resolveFieldFromScopes(node.column.name); + if (!fieldDef || scope.namesMapped) { + return super.transformColumn(node); } + const mappedName = this.mapFieldName(modelDef.name, fieldDef.name); + return ColumnNode.create(mappedName); + } - if (node.from.froms.length > 1) { - throw new InternalError(`SelectQueryNode must have a single table in from clause`); + protected override transformUpdateQuery(node: UpdateQueryNode) { + const { alias, node: innerTable } = this.stripAlias(node.table); + if (!innerTable || !TableNode.is(innerTable)) { + return super.transformUpdateQuery(node); } - let pushed = false; - const from = node.from.froms[0]!; - if (TableNode.is(from)) { - this.modelStack.push(from.table.identifier.name); - pushed = true; - } else if (AliasNode.is(from) && TableNode.is(from.node)) { - this.modelStack.push(from.node.table.identifier.name); - pushed = true; - } + return this.withScope({ model: innerTable.table.identifier.name, alias }, () => { + return { + ...super.transformUpdateQuery(node), + // map table name + table: this.wrapAlias(this.processTableRef(innerTable), alias), + }; + }); + } - const selections = node.selections ? this.transformSelections(node.selections, node) : node.selections; + protected override transformDeleteQuery(node: DeleteQueryNode) { + // all "from" nodes are pushed as scopes + const scopes = this.createScopesFromFroms(node.from, false); - try { + // process name mapping in each "from" + const froms = node.from.froms.map((from) => { + const { alias, node: innerNode } = this.stripAlias(from); + if (TableNode.is(innerNode!)) { + // map table name + return this.wrapAlias(this.processTableRef(innerNode), alias); + } else { + return super.transformNode(from); + } + }); + + return this.withScopes(scopes, () => { return { - ...super.transformSelectQuery(node), - selections, + ...super.transformDeleteQuery(node), + from: FromNode.create(froms), }; - } finally { - if (pushed) { - this.modelStack.pop(); - } - } + }); } - private transformSelections(selections: readonly SelectionNode[], contextNode: OperationNode) { - const result: SelectionNode[] = []; + // #endregion - for (const selection of selections) { - let selectAllFromModel: string | undefined = undefined; - let isSelectAll = false; + // #region utils - if (SelectAllNode.is(selection.selection)) { - selectAllFromModel = this.currentModel; - isSelectAll = true; - } else if (ReferenceNode.is(selection.selection) && SelectAllNode.is(selection.selection.column)) { - selectAllFromModel = selection.selection.table?.table.identifier.name ?? this.currentModel; - isSelectAll = true; - } - - if (isSelectAll) { - if (!selectAllFromModel) { - continue; + private resolveFieldFromScopes(name: string, qualifier?: string) { + for (const scope of this.modelScopes.toReversed()) { + if (qualifier) { + if (scope.alias) { + if (qualifier !== scope.alias) { + continue; + } } else { - const scalarFields = this.getModelScalarFields(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(mappedTableName), - ); - return SelectionNode.create( - this.fieldHasMappedName(fieldName) - ? AliasNode.create(fieldRef, IdentifierNode.create(fieldName)) - : fieldRef, - ); - }), - ); + if (qualifier !== scope.model) { + continue; + } } - } else { - result.push(this.transformSelectionWithAlias(selection)); + } + const modelDef = getModel(this.schema, scope.model); + if (!modelDef) { + continue; + } + if (modelDef.fields[name]) { + return { modelDef, fieldDef: modelDef.fields[name], scope }; } } + return { modelDef: undefined, fieldDef: undefined, scope: undefined }; + } - return result; + private pushScope(scope: Scope) { + this.modelScopes.push(scope); } - private transformSelectionWithAlias(node: SelectionNode) { - if (ColumnNode.is(node.selection) && this.fieldHasMappedName(node.selection.column.name)) { - return SelectionNode.create( - AliasNode.create( - this.transformColumn(node.selection), - IdentifierNode.create(node.selection.column.name), - ), - ); - } else if ( - ReferenceNode.is(node.selection) && - this.fieldHasMappedName((node.selection.column as ColumnNode).column.name) - ) { - return SelectionNode.create( - AliasNode.create( - this.transformReference(node.selection), - IdentifierNode.create((node.selection.column as ColumnNode).column.name), - ), - ); - } else { - return this.transformSelection(node); + private withScope(scope: Scope, fn: (...args: unknown[]) => T): T { + this.pushScope(scope); + try { + return fn(); + } finally { + this.modelScopes.pop(); } } - private fieldHasMappedName(name: string) { - if (!this.currentModel) { - return false; + private withScopes(scopes: Scope[], fn: (...args: unknown[]) => T): T { + scopes.forEach((s) => this.pushScope(s)); + try { + return fn(); + } finally { + scopes.forEach(() => this.modelScopes.pop()); } - return this.fieldToColumnMap.has(`${this.currentModel}.${name}`); } - protected override transformTable(node: TableNode) { - const tableName = node.table.identifier.name; - const mappedName = this.modelToTableMap.get(tableName); - if (mappedName) { - // TODO: db schema? - return TableNode.create(mappedName); - } else { + private wrapAlias(node: T, alias: string | undefined) { + return alias ? AliasNode.create(node, IdentifierNode.create(alias)) : node; + } + + private ensureAlias(node: OperationNode, alias: string | undefined, fallbackName: string) { + if (!node) { return node; } + return alias + ? AliasNode.create(node, IdentifierNode.create(alias)) + : AliasNode.create(node, IdentifierNode.create(fallbackName)); } - protected override transformColumn(node: ColumnNode) { - return ColumnNode.create(this.mapFieldName(node.column.name)); + private processTableRef(node: TableNode) { + if (!node) { + return node; + } + if (!TableNode.is(node)) { + return super.transformNode(node); + } + return TableNode.create(this.mapTableName(node.table.identifier.name)); } private getMappedName(def: ModelDef | FieldDef) { @@ -240,31 +271,159 @@ export class QueryNameMapper extends OperationNodeTransformer { return undefined; } - private mapFieldName(fieldName: string): string { - if (!this.currentModel) { - return fieldName; + private mapFieldName(model: string, field: string): string { + const mappedName = this.fieldToColumnMap.get(`${model}.${field}`); + if (mappedName) { + return mappedName; + } else { + return field; } - const mappedName = this.fieldToColumnMap.get(`${this.currentModel}.${fieldName}`); + } + + private mapTableName(tableName: string): string { + const mappedName = this.modelToTableMap.get(tableName); if (mappedName) { return mappedName; } else { - return fieldName; + return tableName; + } + } + + private stripAlias(node: OperationNode | undefined) { + if (!node) { + return { alias: undefined, node }; + } + if (AliasNode.is(node)) { + invariant(IdentifierNode.is(node.alias), 'Expected identifier as alias'); + return { alias: node.alias.name, node: node.node }; + } + return { alias: undefined, node }; + } + + private hasMappedColumns(modelName: string) { + return [...this.fieldToColumnMap.keys()].some((key) => key.startsWith(modelName + '.')); + } + + private createScopesFromFroms(node: FromNode | undefined, namesMapped: boolean) { + if (!node) { + return []; + } + return node.froms + .map((from) => { + const { alias, node: innerNode } = this.stripAlias(from); + if (innerNode && TableNode.is(innerNode)) { + return { model: innerNode.table.identifier.name, alias, namesMapped }; + } else { + return undefined; + } + }) + .filter((s) => !!s); + } + + // convert a "from" node to a nested query if there are columns with name mapping + private processFrom(node: FromNode): FromNode { + return { + ...super.transformFrom(node), + froms: node.froms.map((from) => { + const { alias, node: innerNode } = this.stripAlias(from); + if (!innerNode) { + return super.transformNode(from); + } + if (TableNode.is(innerNode)) { + if (this.hasMappedColumns(innerNode.table.identifier.name)) { + // create a nested query with all fields selected and names mapped + const selectAll = this.createSelectAll(innerNode.table.identifier.name); + + // use the original alias or table name as the alias for the nested query + // so its transparent to the outer scope + return this.ensureAlias(selectAll, alias, innerNode.table.identifier.name); + } + } + return this.transformNode(from); + }), + }; + } + + // create a `SelectQueryNode` for the given model with all columns mapped + private createSelectAll(model: string): SelectQueryNode { + const modelDef = requireModel(this.schema, model); + const tableName = this.mapTableName(model); + return { + kind: 'SelectQueryNode', + from: FromNode.create([TableNode.create(tableName)]), + selections: this.getModelFields(modelDef).map((fieldDef) => { + const columnName = this.mapFieldName(model, fieldDef.name); + const columnRef = ReferenceNode.create(ColumnNode.create(columnName), TableNode.create(tableName)); + if (columnName !== fieldDef.name) { + const aliased = AliasNode.create(columnRef, IdentifierNode.create(fieldDef.name)); + return SelectionNode.create(aliased); + } else { + return SelectionNode.create(columnRef); + } + }), + }; + } + + private getModelFields(modelDef: ModelDef) { + return Object.values(modelDef.fields).filter((f) => !f.relation && !f.computed && !f.originModel); + } + + private processSelections(selections: readonly SelectionNode[]) { + const result: SelectionNode[] = []; + selections.forEach((selection) => { + if (SelectAllNode.is(selection.selection)) { + // expand "select *" to a list of selections if name mapping is needed + const processed = this.processSelectAll(selection.selection); + if (Array.isArray(processed)) { + // expanded and names mapped + result.push(...processed.map((s) => SelectionNode.create(s))); + } else { + // not expanded + result.push(SelectionNode.create(processed)); + } + } else { + result.push(SelectionNode.create(this.processSelection(selection.selection))); + } + }); + return result; + } + + private processSelection(node: AliasNode | ColumnNode | ReferenceNode) { + let alias: string | undefined; + if (!AliasNode.is(node)) { + alias = this.extractFieldName(node); } + const result = super.transformNode(node); + return this.wrapAlias(result, alias); } - private requireCurrentModel(node: OperationNode) { - if (!this.currentModel) { - throw new InternalError(`Missing model context for "${node}"`); + private processSelectAll(node: SelectAllNode) { + const scope = this.modelScopes[this.modelScopes.length - 1]; + invariant(scope); + + if (!this.hasMappedColumns(scope.model)) { + // no name mapping needed, preserve the select all + return super.transformSelectAll(node); } + + // expand select all to a list of selections with name mapping + const modelDef = requireModel(this.schema, scope.model); + return this.getModelFields(modelDef).map((fieldDef) => { + const columnName = this.mapFieldName(scope.model, fieldDef.name); + const columnRef = ReferenceNode.create(ColumnNode.create(columnName)); + return columnName !== fieldDef.name ? this.wrapAlias(columnRef, fieldDef.name) : columnRef; + }); } - private getModelScalarFields(contextNode: OperationNode, model: string | undefined) { - this.requireCurrentModel(contextNode); - model = model ?? this.currentModel; - const modelDef = requireModel(this.schema, model!); - const scalarFields = Object.entries(modelDef.fields) - .filter(([, fieldDef]) => !fieldDef.relation && !fieldDef.computed && !fieldDef.originModel) - .map(([fieldName]) => fieldName); - return scalarFields; + private extractFieldName(node: ReferenceNode | ColumnNode) { + if (ReferenceNode.is(node) && ColumnNode.is(node.column)) { + return node.column.column.name; + } else if (ColumnNode.is(node)) { + return node.column.name; + } else { + return undefined; + } } + + // #endregion } diff --git a/packages/runtime/test/client-api/name-mapping.test.ts b/packages/runtime/test/client-api/name-mapping.test.ts index 4ea3e7b1..36bbe13b 100644 --- a/packages/runtime/test/client-api/name-mapping.test.ts +++ b/packages/runtime/test/client-api/name-mapping.test.ts @@ -1,120 +1,234 @@ -import SQLite from 'better-sqlite3'; -import { SqliteDialect } from 'kysely'; -import { describe, expect, it } from 'vitest'; -import { ZenStackClient } from '../../src'; -import { type SchemaDef, ExpressionUtils } from '../../src/schema'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { ClientContract } from '../../src'; +import { schema, type SchemaType } from '../schemas/name-mapping/schema'; +import { createTestClient } from '../utils'; describe('Name mapping tests', () => { - const schema = { - provider: { - type: 'sqlite', - }, - models: { - Foo: { - name: 'Foo', - fields: { - id: { - name: 'id', - type: 'String', - id: true, - default: ExpressionUtils.call('uuid'), + let db: ClientContract; + + beforeEach(async () => { + db = await createTestClient( + schema, + { usePrismaPush: true }, + path.join(__dirname, '../schemas/name-mapping/schema.zmodel'), + ); + }); + + afterEach(async () => { + await db.$disconnect(); + }); + + it('works with create', async () => { + await expect( + db.user.create({ + data: { + email: 'u1@test.com', + posts: { + create: { + title: 'Post1', + }, }, - x: { - name: 'x', - type: 'Int', - attributes: [ - { - name: '@map', - args: [ - { - name: 'name', - value: { - kind: 'literal', - value: 'y', - }, - }, - ], - }, - ], + }, + }), + ).resolves.toMatchObject({ + id: expect.any(Number), + email: 'u1@test.com', + }); + + await expect( + db.$qb + .insertInto('User') + .values({ + email: 'u2@test.com', + }) + .returning(['id', 'email']) + .executeTakeFirst(), + ).resolves.toMatchObject({ + id: expect.any(Number), + email: 'u2@test.com', + }); + + await expect( + db.$qb + .insertInto('User') + .values({ + email: 'u3@test.com', + }) + .returning(['User.id', 'User.email']) + .executeTakeFirst(), + ).resolves.toMatchObject({ + id: expect.any(Number), + email: 'u3@test.com', + }); + + await expect( + db.$qb + .insertInto('User') + .values({ + email: 'u4@test.com', + }) + .returningAll() + .executeTakeFirst(), + ).resolves.toMatchObject({ + id: expect.any(Number), + email: 'u4@test.com', + }); + }); + + it('works with find', async () => { + const user = await db.user.create({ + data: { + email: 'u1@test.com', + posts: { + create: { + title: 'Post1', }, }, - idFields: ['id'], - uniqueFields: { - id: { type: 'String' }, + }, + }); + + await expect( + db.user.findFirst({ + where: { email: 'u1@test.com' }, + select: { + id: true, + email: true, + posts: { where: { title: { contains: 'Post1' } }, select: { title: true } }, }, - attributes: [ - { - name: '@@map', - args: [ - { - name: 'name', - value: { kind: 'literal', value: 'bar' }, - }, - ], + }), + ).resolves.toMatchObject({ + id: expect.any(Number), + email: 'u1@test.com', + posts: [{ title: 'Post1' }], + }); + + await expect( + db.$qb.selectFrom('User').selectAll().where('email', '=', 'u1@test.com').executeTakeFirst(), + ).resolves.toMatchObject({ + id: expect.any(Number), + email: 'u1@test.com', + }); + + await expect( + db.$qb.selectFrom('User').select(['User.email']).where('email', '=', 'u1@test.com').executeTakeFirst(), + ).resolves.toMatchObject({ + email: 'u1@test.com', + }); + + await expect( + db.$qb + .selectFrom('User') + .select(['email']) + .whereRef('email', '=', 'email') + .orderBy(['email']) + .executeTakeFirst(), + ).resolves.toMatchObject({ + email: 'u1@test.com', + }); + + await expect( + db.$qb + .selectFrom('Post') + .innerJoin('User', 'User.id', 'Post.authorId') + .select(['User.email', 'Post.authorId', 'Post.title']) + .whereRef('Post.authorId', '=', 'User.id') + .executeTakeFirst(), + ).resolves.toMatchObject({ + authorId: user.id, + title: 'Post1', + }); + + await expect( + db.$qb + .selectFrom('Post') + .select(['id', 'title']) + .select((eb) => + eb.selectFrom('User').select(['email']).whereRef('User.id', '=', 'Post.authorId').as('email'), + ) + .executeTakeFirst(), + ).resolves.toMatchObject({ + id: user.id, + title: 'Post1', + email: 'u1@test.com', + }); + }); + + it('works with update', async () => { + const user = await db.user.create({ + data: { + email: 'u1@test.com', + posts: { + create: { + id: 1, + title: 'Post1', }, - ], + }, }, - }, - plugins: {}, - } as const satisfies SchemaDef; - - it('works with model and implicit field mapping', async () => { - const client = new ZenStackClient(schema, { - dialect: new SqliteDialect({ database: new SQLite(':memory:') }), - }); - await client.$pushSchema(); - const r1 = await client.foo.create({ - data: { id: '1', x: 1 }, - }); - expect(r1.id).toBe('1'); - expect(r1.x).toBe(1); - expect((r1 as any).y).toBeUndefined(); - - const r2 = await client.foo.findUniqueOrThrow({ - where: { id: '1' }, - }); - expect(r2.id).toBe('1'); - expect(r2.x).toBe(1); - expect((r2 as any).y).toBeUndefined(); - - const r3 = await client.$qb - .insertInto('Foo') - .values({ id: '2', x: 2 }) - .returningAll() - .executeTakeFirstOrThrow(); - expect(r3.id).toBe('2'); - expect(r3.x).toBe(2); - expect((r3 as any).y).toBeUndefined(); - - const delResult = await client.foo.delete({ where: { id: '1' } }); - expect(delResult.x).toBe(1); - }); + }); - it('works with explicit field mapping', async () => { - const client = new ZenStackClient(schema, { - dialect: new SqliteDialect({ database: new SQLite(':memory:') }), + await expect( + db.user.update({ + where: { id: user.id }, + data: { + email: 'u2@test.com', + posts: { + update: { + where: { id: 1 }, + data: { title: 'Post2' }, + }, + }, + }, + include: { posts: true }, + }), + ).resolves.toMatchObject({ + id: user.id, + email: 'u2@test.com', + posts: [expect.objectContaining({ title: 'Post2' })], }); - await client.$pushSchema(); - const r1 = await client.foo.create({ - data: { id: '1', x: 1 }, - select: { x: true }, + + await expect( + db.$qb + .updateTable('User') + .set({ email: (eb) => eb.fn('concat', [eb.ref('email'), eb.val('_updated')]) }) + .where('email', '=', 'u2@test.com') + .returning(['email']) + .executeTakeFirst(), + ).resolves.toMatchObject({ email: 'u2@test.com_updated' }); + + await expect( + db.$qb.updateTable('User as u').set({ email: 'u3@test.com' }).returningAll().executeTakeFirst(), + ).resolves.toMatchObject({ id: expect.any(Number), email: 'u3@test.com' }); + }); + + it('works with delete', async () => { + const user = await db.user.create({ + data: { + email: 'u1@test.com', + posts: { + create: { + id: 1, + title: 'Post1', + }, + }, + }, }); - expect(r1.x).toBe(1); - expect((r1 as any).y).toBeUndefined(); - const r2 = await client.foo.findUniqueOrThrow({ - where: { id: '1' }, - select: { x: true }, + await expect( + db.$qb.deleteFrom('Post').where('title', '=', 'Post1').returning(['id', 'title']).executeTakeFirst(), + ).resolves.toMatchObject({ + id: user.id, + title: 'Post1', }); - expect(r2.x).toBe(1); - expect((r2 as any).y).toBeUndefined(); - const r3 = await client.$qb - .insertInto('Foo') - .values({ id: '2', x: 2 }) - .returning(['x']) - .executeTakeFirstOrThrow(); - expect(r3.x).toBe(2); - expect((r3 as any).y).toBeUndefined(); + await expect( + db.user.delete({ + where: { email: 'u1@test.com' }, + include: { posts: true }, + }), + ).resolves.toMatchObject({ + email: 'u1@test.com', + posts: [], + }); }); }); diff --git a/packages/runtime/test/schemas/name-mapping/input.ts b/packages/runtime/test/schemas/name-mapping/input.ts new file mode 100644 index 00000000..18b092b9 --- /dev/null +++ b/packages/runtime/test/schemas/name-mapping/input.ts @@ -0,0 +1,50 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + +/* eslint-disable */ + +import { type SchemaType as $Schema } from "./schema"; +import type { FindManyArgs as $FindManyArgs, FindUniqueArgs as $FindUniqueArgs, FindFirstArgs as $FindFirstArgs, CreateArgs as $CreateArgs, CreateManyArgs as $CreateManyArgs, CreateManyAndReturnArgs as $CreateManyAndReturnArgs, UpdateArgs as $UpdateArgs, UpdateManyArgs as $UpdateManyArgs, UpdateManyAndReturnArgs as $UpdateManyAndReturnArgs, UpsertArgs as $UpsertArgs, DeleteArgs as $DeleteArgs, DeleteManyArgs as $DeleteManyArgs, CountArgs as $CountArgs, AggregateArgs as $AggregateArgs, GroupByArgs as $GroupByArgs, WhereInput as $WhereInput, SelectInput as $SelectInput, IncludeInput as $IncludeInput, OmitInput as $OmitInput } from "@zenstackhq/runtime"; +import type { SimplifiedModelResult as $SimplifiedModelResult, SelectIncludeOmit as $SelectIncludeOmit } from "@zenstackhq/runtime"; +export type UserFindManyArgs = $FindManyArgs<$Schema, "User">; +export type UserFindUniqueArgs = $FindUniqueArgs<$Schema, "User">; +export type UserFindFirstArgs = $FindFirstArgs<$Schema, "User">; +export type UserCreateArgs = $CreateArgs<$Schema, "User">; +export type UserCreateManyArgs = $CreateManyArgs<$Schema, "User">; +export type UserCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "User">; +export type UserUpdateArgs = $UpdateArgs<$Schema, "User">; +export type UserUpdateManyArgs = $UpdateManyArgs<$Schema, "User">; +export type UserUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs<$Schema, "User">; +export type UserUpsertArgs = $UpsertArgs<$Schema, "User">; +export type UserDeleteArgs = $DeleteArgs<$Schema, "User">; +export type UserDeleteManyArgs = $DeleteManyArgs<$Schema, "User">; +export type UserCountArgs = $CountArgs<$Schema, "User">; +export type UserAggregateArgs = $AggregateArgs<$Schema, "User">; +export type UserGroupByArgs = $GroupByArgs<$Schema, "User">; +export type UserWhereInput = $WhereInput<$Schema, "User">; +export type UserSelect = $SelectInput<$Schema, "User">; +export type UserInclude = $IncludeInput<$Schema, "User">; +export type UserOmit = $OmitInput<$Schema, "User">; +export type UserGetPayload> = $SimplifiedModelResult<$Schema, "User", Args>; +export type PostFindManyArgs = $FindManyArgs<$Schema, "Post">; +export type PostFindUniqueArgs = $FindUniqueArgs<$Schema, "Post">; +export type PostFindFirstArgs = $FindFirstArgs<$Schema, "Post">; +export type PostCreateArgs = $CreateArgs<$Schema, "Post">; +export type PostCreateManyArgs = $CreateManyArgs<$Schema, "Post">; +export type PostCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "Post">; +export type PostUpdateArgs = $UpdateArgs<$Schema, "Post">; +export type PostUpdateManyArgs = $UpdateManyArgs<$Schema, "Post">; +export type PostUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs<$Schema, "Post">; +export type PostUpsertArgs = $UpsertArgs<$Schema, "Post">; +export type PostDeleteArgs = $DeleteArgs<$Schema, "Post">; +export type PostDeleteManyArgs = $DeleteManyArgs<$Schema, "Post">; +export type PostCountArgs = $CountArgs<$Schema, "Post">; +export type PostAggregateArgs = $AggregateArgs<$Schema, "Post">; +export type PostGroupByArgs = $GroupByArgs<$Schema, "Post">; +export type PostWhereInput = $WhereInput<$Schema, "Post">; +export type PostSelect = $SelectInput<$Schema, "Post">; +export type PostInclude = $IncludeInput<$Schema, "Post">; +export type PostOmit = $OmitInput<$Schema, "Post">; +export type PostGetPayload> = $SimplifiedModelResult<$Schema, "Post", Args>; diff --git a/packages/runtime/test/schemas/name-mapping/models.ts b/packages/runtime/test/schemas/name-mapping/models.ts new file mode 100644 index 00000000..b1309f7a --- /dev/null +++ b/packages/runtime/test/schemas/name-mapping/models.ts @@ -0,0 +1,11 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + +/* eslint-disable */ + +import { type SchemaType as $Schema } from "./schema"; +import { type ModelResult as $ModelResult } from "@zenstackhq/runtime"; +export type User = $ModelResult<$Schema, "User">; +export type Post = $ModelResult<$Schema, "Post">; diff --git a/packages/runtime/test/schemas/name-mapping/schema.ts b/packages/runtime/test/schemas/name-mapping/schema.ts new file mode 100644 index 00000000..a766ff99 --- /dev/null +++ b/packages/runtime/test/schemas/name-mapping/schema.ts @@ -0,0 +1,88 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + +/* eslint-disable */ + +import { type SchemaDef, ExpressionUtils } from "../../../dist/schema"; +export const schema = { + provider: { + type: "sqlite" + }, + models: { + User: { + name: "User", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }], + default: ExpressionUtils.call("autoincrement") + }, + email: { + name: "email", + type: "String", + unique: true, + attributes: [{ name: "@map", args: [{ name: "name", value: ExpressionUtils.literal("user_email") }] }, { name: "@unique" }] + }, + posts: { + name: "posts", + type: "Post", + array: true, + relation: { opposite: "author" } + } + }, + attributes: [ + { name: "@@map", args: [{ name: "name", value: ExpressionUtils.literal("users") }] } + ], + idFields: ["id"], + uniqueFields: { + id: { type: "Int" }, + email: { type: "String" } + } + }, + Post: { + name: "Post", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }], + default: ExpressionUtils.call("autoincrement") + }, + title: { + name: "title", + type: "String", + attributes: [{ name: "@map", args: [{ name: "name", value: ExpressionUtils.literal("post_title") }] }] + }, + author: { + name: "author", + type: "User", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array([ExpressionUtils.field("authorId")]) }, { name: "references", value: ExpressionUtils.array([ExpressionUtils.field("id")]) }] }], + relation: { opposite: "posts", fields: ["authorId"], references: ["id"] } + }, + authorId: { + name: "authorId", + type: "Int", + attributes: [{ name: "@map", args: [{ name: "name", value: ExpressionUtils.literal("author_id") }] }], + foreignKeyFor: [ + "author" + ] + } + }, + attributes: [ + { name: "@@map", args: [{ name: "name", value: ExpressionUtils.literal("posts") }] } + ], + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + } + }, + authType: "User", + plugins: {} +} as const satisfies SchemaDef; +export type SchemaType = typeof schema; diff --git a/packages/runtime/test/schemas/name-mapping/schema.zmodel b/packages/runtime/test/schemas/name-mapping/schema.zmodel new file mode 100644 index 00000000..baddc94f --- /dev/null +++ b/packages/runtime/test/schemas/name-mapping/schema.zmodel @@ -0,0 +1,19 @@ +datasource db { + provider = "sqlite" + url = "file:./dev.db" +} + +model User { + id Int @id @default(autoincrement()) + email String @map('user_email') @unique + posts Post[] + @@map('users') +} + +model Post { + id Int @id @default(autoincrement()) + title String @map('post_title') + author User @relation(fields: [authorId], references: [id]) + authorId Int @map('author_id') + @@map('posts') +} \ No newline at end of file diff --git a/packages/runtime/test/utils.ts b/packages/runtime/test/utils.ts index 6709ab57..4654fccc 100644 --- a/packages/runtime/test/utils.ts +++ b/packages/runtime/test/utils.ts @@ -133,9 +133,8 @@ export async function createTestClient( } } - if (workDir) { - console.log(`Work directory: ${workDir}`); - } + invariant(workDir); + console.log(`Work directory: ${workDir}`); const { plugins, ...rest } = options ?? {}; const _options: ClientOptions = { @@ -144,13 +143,13 @@ export async function createTestClient( if (options?.usePrismaPush) { invariant(typeof schema === 'string' || schemaFile, 'a schema file must be provided when using prisma db push'); - const r = await loadDocument(path.resolve(workDir, 'schema.zmodel')); + const r = await loadDocument(path.resolve(workDir!, 'schema.zmodel')); if (!r.success) { throw new Error(r.errors.join('\n')); } const prismaSchema = new PrismaSchemaGenerator(r.model); const prismaSchemaText = await prismaSchema.generate(); - fs.writeFileSync(path.resolve(workDir, 'schema.prisma'), prismaSchemaText); + fs.writeFileSync(path.resolve(workDir!, 'schema.prisma'), prismaSchemaText); execSync('npx prisma db push --schema ./schema.prisma --skip-generate --force-reset', { cwd: workDir, stdio: 'inherit', diff --git a/packages/sdk/package.json b/packages/sdk/package.json index a5b08eda..e1722ac5 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "3.0.0-alpha.30", + "version": "3.0.0-alpha.31", "description": "ZenStack SDK", "type": "module", "scripts": { diff --git a/packages/tanstack-query/package.json b/packages/tanstack-query/package.json index 2530d1e6..88fa53c8 100644 --- a/packages/tanstack-query/package.json +++ b/packages/tanstack-query/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/tanstack-query", - "version": "3.0.0-alpha.30", + "version": "3.0.0-alpha.31", "description": "", "main": "index.js", "type": "module", diff --git a/packages/testtools/package.json b/packages/testtools/package.json index 4a4c8d50..1de8559b 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "3.0.0-alpha.30", + "version": "3.0.0-alpha.31", "description": "ZenStack Test Tools", "type": "module", "scripts": { diff --git a/packages/typescript-config/package.json b/packages/typescript-config/package.json index f3e1dc28..9519da81 100644 --- a/packages/typescript-config/package.json +++ b/packages/typescript-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/typescript-config", - "version": "3.0.0-alpha.30", + "version": "3.0.0-alpha.31", "private": true, "license": "MIT" } diff --git a/packages/vitest-config/package.json b/packages/vitest-config/package.json index c7c8335d..ebc8c224 100644 --- a/packages/vitest-config/package.json +++ b/packages/vitest-config/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/vitest-config", "type": "module", - "version": "3.0.0-alpha.30", + "version": "3.0.0-alpha.31", "private": true, "license": "MIT", "exports": { diff --git a/packages/zod/package.json b/packages/zod/package.json index de0ef2a2..04ce056a 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/zod", - "version": "3.0.0-alpha.30", + "version": "3.0.0-alpha.31", "description": "", "type": "module", "main": "index.js", diff --git a/samples/blog/package.json b/samples/blog/package.json index 999c3597..ee86431f 100644 --- a/samples/blog/package.json +++ b/samples/blog/package.json @@ -1,6 +1,6 @@ { "name": "sample-blog", - "version": "3.0.0-alpha.30", + "version": "3.0.0-alpha.31", "description": "", "main": "index.js", "scripts": { diff --git a/tests/e2e/package.json b/tests/e2e/package.json index 80e79926..55a3b68c 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -1,6 +1,6 @@ { "name": "e2e", - "version": "3.0.0-alpha.30", + "version": "3.0.0-alpha.31", "private": true, "type": "module", "scripts": {