diff --git a/packages/orm/src/client/crud/dialects/mysql.ts b/packages/orm/src/client/crud/dialects/mysql.ts index 075f41e17..3b06c4589 100644 --- a/packages/orm/src/client/crud/dialects/mysql.ts +++ b/packages/orm/src/client/crud/dialects/mysql.ts @@ -120,11 +120,15 @@ export class MySqlCrudDialect extends LateralJoinDiale } } - override transformOutput(value: unknown, type: BuiltinType, array: boolean) { + override transformOutput(value: unknown, type: BuiltinType, array: boolean): unknown { if (value === null || value === undefined) { return value; } + if (isPrimitiveTypeDef(this.schema, type)) { + return this.transformOutput(value, this.schema.typeDefs![type]!.base!, array); + } + switch (type) { case 'Boolean': return this.transformOutputBoolean(value); @@ -185,7 +189,17 @@ export class MySqlCrudDialect extends LateralJoinDiale } private transformOutputBytes(value: unknown) { - return Buffer.isBuffer(value) ? Uint8Array.from(value) : value; + if (Buffer.isBuffer(value)) { + return Uint8Array.from(value); + } + // MySQL encodes binary values embedded in JSON as `base64:type:` + if (typeof value === 'string') { + const match = /^base64:type\d+:(.*)$/s.exec(value); + if (match) { + return Uint8Array.from(Buffer.from(match[1]!, 'base64')); + } + } + return value; } // #endregion diff --git a/packages/orm/src/client/crud/dialects/postgresql.ts b/packages/orm/src/client/crud/dialects/postgresql.ts index fb6780af0..fa66f6036 100644 --- a/packages/orm/src/client/crud/dialects/postgresql.ts +++ b/packages/orm/src/client/crud/dialects/postgresql.ts @@ -245,11 +245,15 @@ export class PostgresCrudDialect extends LateralJoinDi } } - override transformOutput(value: unknown, type: BuiltinType, array: boolean) { + override transformOutput(value: unknown, type: BuiltinType, array: boolean): unknown { if (value === null || value === undefined) { return value; } + if (isPrimitiveTypeDef(this.schema, type)) { + return this.transformOutput(value, this.schema.typeDefs![type]!.base!, array); + } + switch (type) { case 'DateTime': return this.transformOutputDate(value); diff --git a/tests/e2e/orm/client-api/custom-type-primitive-output.test.ts b/tests/e2e/orm/client-api/custom-type-primitive-output.test.ts new file mode 100644 index 000000000..10ac5ded0 --- /dev/null +++ b/tests/e2e/orm/client-api/custom-type-primitive-output.test.ts @@ -0,0 +1,110 @@ +import type { ClientContract } from '@zenstackhq/orm'; +import { createTestClient } from '@zenstackhq/testtools'; +import Decimal from 'decimal.js'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { schema } from '../schemas/custom-type-primitive-output/schema'; + +const seenAt = new Date('2026-01-02T03:04:05.000Z'); + +const itemData = { + label: 'hello', + active: true, + quantity: 3, + ratio: 1.5, + views: 12345678901234n, + price: new Decimal('19.99'), + seenAt, + payload: new Uint8Array([1, 2, 3]), + meta: { a: 1, tags: ['x'] }, +}; + +// asserts that each primitive type def field is returned in its base type's runtime representation +function expectConverted(item: Record, { withPayload = true } = {}) { + expect(item['label']).toBe('hello'); + expect(item['active']).toBe(true); + expect(item['quantity']).toBe(3); + expect(item['ratio']).toBe(1.5); + expect(item['views']).toBe(12345678901234n); + expect(Decimal.isDecimal(item['price'])).toBe(true); + expect((item['price'] as Decimal).toString()).toBe('19.99'); + expect(item['seenAt']).toBeInstanceOf(Date); + expect((item['seenAt'] as Date).toISOString()).toBe(seenAt.toISOString()); + if (withPayload) { + expect(item['payload']).toBeInstanceOf(Uint8Array); + expect(Buffer.isBuffer(item['payload'])).toBe(false); + expect(Array.from(item['payload'] as Uint8Array)).toEqual([1, 2, 3]); + } + expect(item['meta']).toEqual({ a: 1, tags: ['x'] }); + expect(item['note']).toBeNull(); +} + +describe('Custom type primitive output conversion tests', () => { + let client: ClientContract; + let ownerId: number; + + beforeEach(async () => { + client = await createTestClient(schema); + const owner = await client.owner.create({ data: {} }); + ownerId = owner.id; + }); + + afterEach(async () => { + await client?.$disconnect(); + }); + + it('converts fields returned from create', async () => { + const item = await client.item.create({ data: { ...itemData, ownerId } }); + expectConverted(item); + }); + + it('converts fields on direct reads', async () => { + const { id } = await client.item.create({ data: { ...itemData, ownerId } }); + + expectConverted(await client.item.findUniqueOrThrow({ where: { id } })); + + const [item] = await client.item.findMany(); + expectConverted(item!); + + const selected = await client.item.findFirstOrThrow({ select: { views: true, price: true, active: true } }); + expect(selected.views).toBe(12345678901234n); + expect(Decimal.isDecimal(selected.price)).toBe(true); + expect(selected.active).toBe(true); + }); + + it('converts fields of nested relations', async () => { + await client.item.create({ data: { ...itemData, ownerId } }); + + // SQLite can't put BLOB values into the JSON it builds for nested relations (same for plain `Bytes`) + const withPayload = client.$schema.provider.type !== 'sqlite'; + const omit = { payload: !withPayload }; + + const owner = await client.owner.findUniqueOrThrow({ where: { id: ownerId }, include: { items: { omit } } }); + expectConverted(owner.items[0]!, { withPayload }); + + const selected = await client.owner.findUniqueOrThrow({ + where: { id: ownerId }, + select: { + items: { select: { views: true, price: true, seenAt: true, payload: withPayload, active: true } }, + }, + }); + expect(selected.items[0]!.views).toBe(12345678901234n); + expect(Decimal.isDecimal(selected.items[0]!.price)).toBe(true); + expect(selected.items[0]!.seenAt).toBeInstanceOf(Date); + expect(selected.items[0]!.active).toBe(true); + if (withPayload) { + expect(Buffer.isBuffer(selected.items[0]!.payload)).toBe(false); + expect(Array.from(selected.items[0]!.payload as Uint8Array)).toEqual([1, 2, 3]); + } + + const item = await client.item.findFirstOrThrow({ include: { owner: { include: { items: { omit } } } } }); + expectConverted(item); + expectConverted(item.owner.items[0]!, { withPayload }); + }); + + it('converts fields returned from update', async () => { + const { id } = await client.item.create({ data: { ...itemData, ownerId } }); + const updated = await client.item.update({ where: { id }, data: { quantity: 4 } }); + expectConverted({ ...updated, quantity: 3 }); + expect(updated.quantity).toBe(4); + }); +}); diff --git a/tests/e2e/orm/client-api/custom-type-primitive.test.ts b/tests/e2e/orm/client-api/custom-type-primitive.test.ts index 67f745b7e..0b2f6743e 100644 --- a/tests/e2e/orm/client-api/custom-type-primitive.test.ts +++ b/tests/e2e/orm/client-api/custom-type-primitive.test.ts @@ -169,4 +169,20 @@ describe('Custom type primitive tests', () => { }), ).rejects.toThrow(/Too big/); }); + + it('converts array items to the base type', async () => { + const user = await client.user.create({ + data: { name: 'test', scores: [1n, 12345678901234n] }, + }); + expect(user.scores).toEqual([1n, 12345678901234n]); + + const found = await client.user.findUniqueOrThrow({ where: { id: user.id } }); + expect(found.scores).toEqual([1n, 12345678901234n]); + + const post = await client.post.create({ + data: { tags: ['abc', '123'], author: { connect: { id: user.id } } }, + include: { author: true }, + }); + expect(post.author.scores).toEqual([1n, 12345678901234n]); + }); }); diff --git a/tests/e2e/orm/schemas/custom-type-primitive-output/input.ts b/tests/e2e/orm/schemas/custom-type-primitive-output/input.ts new file mode 100644 index 000000000..70f0b1f1a --- /dev/null +++ b/tests/e2e/orm/schemas/custom-type-primitive-output/input.ts @@ -0,0 +1,60 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// 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, ExistsArgs as $ExistsArgs, 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, UncheckedCreateInput as $UncheckedCreateInput, CheckedCreateInput as $CheckedCreateInput, UncheckedUpdateInput as $UncheckedUpdateInput, CheckedUpdateInput as $CheckedUpdateInput, QueryOptions as $QueryOptions } from "@zenstackhq/orm"; +import type { SimplifiedPlainResult as $Result, SelectIncludeOmit as $SelectIncludeOmit } from "@zenstackhq/orm"; +export type OwnerFindManyArgs = $FindManyArgs<$Schema, "Owner">; +export type OwnerFindUniqueArgs = $FindUniqueArgs<$Schema, "Owner">; +export type OwnerFindFirstArgs = $FindFirstArgs<$Schema, "Owner">; +export type OwnerExistsArgs = $ExistsArgs<$Schema, "Owner">; +export type OwnerCreateArgs = $CreateArgs<$Schema, "Owner">; +export type OwnerCreateManyArgs = $CreateManyArgs<$Schema, "Owner">; +export type OwnerCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "Owner">; +export type OwnerUpdateArgs = $UpdateArgs<$Schema, "Owner">; +export type OwnerUpdateManyArgs = $UpdateManyArgs<$Schema, "Owner">; +export type OwnerUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs<$Schema, "Owner">; +export type OwnerUpsertArgs = $UpsertArgs<$Schema, "Owner">; +export type OwnerDeleteArgs = $DeleteArgs<$Schema, "Owner">; +export type OwnerDeleteManyArgs = $DeleteManyArgs<$Schema, "Owner">; +export type OwnerCountArgs = $CountArgs<$Schema, "Owner">; +export type OwnerAggregateArgs = $AggregateArgs<$Schema, "Owner">; +export type OwnerGroupByArgs = $GroupByArgs<$Schema, "Owner">; +export type OwnerWhereInput = $WhereInput<$Schema, "Owner">; +export type OwnerSelect = $SelectInput<$Schema, "Owner">; +export type OwnerInclude = $IncludeInput<$Schema, "Owner">; +export type OwnerOmit = $OmitInput<$Schema, "Owner">; +export type OwnerUncheckedCreateInput = $UncheckedCreateInput<$Schema, "Owner">; +export type OwnerCheckedCreateInput = $CheckedCreateInput<$Schema, "Owner">; +export type OwnerUncheckedUpdateInput = $UncheckedUpdateInput<$Schema, "Owner">; +export type OwnerCheckedUpdateInput = $CheckedUpdateInput<$Schema, "Owner">; +export type OwnerGetPayload, Options extends $QueryOptions<$Schema> = $QueryOptions<$Schema>> = $Result<$Schema, "Owner", Args, Options>; +export type ItemFindManyArgs = $FindManyArgs<$Schema, "Item">; +export type ItemFindUniqueArgs = $FindUniqueArgs<$Schema, "Item">; +export type ItemFindFirstArgs = $FindFirstArgs<$Schema, "Item">; +export type ItemExistsArgs = $ExistsArgs<$Schema, "Item">; +export type ItemCreateArgs = $CreateArgs<$Schema, "Item">; +export type ItemCreateManyArgs = $CreateManyArgs<$Schema, "Item">; +export type ItemCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "Item">; +export type ItemUpdateArgs = $UpdateArgs<$Schema, "Item">; +export type ItemUpdateManyArgs = $UpdateManyArgs<$Schema, "Item">; +export type ItemUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs<$Schema, "Item">; +export type ItemUpsertArgs = $UpsertArgs<$Schema, "Item">; +export type ItemDeleteArgs = $DeleteArgs<$Schema, "Item">; +export type ItemDeleteManyArgs = $DeleteManyArgs<$Schema, "Item">; +export type ItemCountArgs = $CountArgs<$Schema, "Item">; +export type ItemAggregateArgs = $AggregateArgs<$Schema, "Item">; +export type ItemGroupByArgs = $GroupByArgs<$Schema, "Item">; +export type ItemWhereInput = $WhereInput<$Schema, "Item">; +export type ItemSelect = $SelectInput<$Schema, "Item">; +export type ItemInclude = $IncludeInput<$Schema, "Item">; +export type ItemOmit = $OmitInput<$Schema, "Item">; +export type ItemUncheckedCreateInput = $UncheckedCreateInput<$Schema, "Item">; +export type ItemCheckedCreateInput = $CheckedCreateInput<$Schema, "Item">; +export type ItemUncheckedUpdateInput = $UncheckedUpdateInput<$Schema, "Item">; +export type ItemCheckedUpdateInput = $CheckedUpdateInput<$Schema, "Item">; +export type ItemGetPayload, Options extends $QueryOptions<$Schema> = $QueryOptions<$Schema>> = $Result<$Schema, "Item", Args, Options>; diff --git a/tests/e2e/orm/schemas/custom-type-primitive-output/models.ts b/tests/e2e/orm/schemas/custom-type-primitive-output/models.ts new file mode 100644 index 000000000..dbd86e316 --- /dev/null +++ b/tests/e2e/orm/schemas/custom-type-primitive-output/models.ts @@ -0,0 +1,20 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// 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, TypeDefResult as $TypeDefResult } from "@zenstackhq/orm"; +export type Owner = $ModelResult<$Schema, "Owner">; +export type Item = $ModelResult<$Schema, "Item">; +export type Label = $TypeDefResult<$Schema, "Label">; +export type Flag = $TypeDefResult<$Schema, "Flag">; +export type Quantity = $TypeDefResult<$Schema, "Quantity">; +export type Ratio = $TypeDefResult<$Schema, "Ratio">; +export type ViewCount = $TypeDefResult<$Schema, "ViewCount">; +export type Price = $TypeDefResult<$Schema, "Price">; +export type Timestamp = $TypeDefResult<$Schema, "Timestamp">; +export type Blob = $TypeDefResult<$Schema, "Blob">; +export type Meta = $TypeDefResult<$Schema, "Meta">; diff --git a/tests/e2e/orm/schemas/custom-type-primitive-output/schema.ts b/tests/e2e/orm/schemas/custom-type-primitive-output/schema.ts new file mode 100644 index 000000000..a07d53a2d --- /dev/null +++ b/tests/e2e/orm/schemas/custom-type-primitive-output/schema.ts @@ -0,0 +1,201 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + +/* eslint-disable */ + +import { type SchemaDef, type AttributeApplication, type FieldDefault, ExpressionUtils } from "@zenstackhq/schema"; +export class SchemaType implements SchemaDef { + provider = { + type: "sqlite" + } as const; + models = { + Owner: { + name: "Owner", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + items: { + name: "items", + type: "Item", + array: true, + relation: { opposite: "owner" } + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + }, + Item: { + name: "Item", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + ownerId: { + name: "ownerId", + type: "Int", + foreignKeyFor: [ + "owner" + ] as readonly string[] + }, + owner: { + name: "owner", + type: "Owner", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("ownerId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "items", fields: ["ownerId"], references: ["id"] } + }, + label: { + name: "label", + type: "Label" + }, + active: { + name: "active", + type: "Flag" + }, + quantity: { + name: "quantity", + type: "Quantity" + }, + ratio: { + name: "ratio", + type: "Ratio" + }, + views: { + name: "views", + type: "ViewCount" + }, + price: { + name: "price", + type: "Price" + }, + seenAt: { + name: "seenAt", + type: "Timestamp" + }, + payload: { + name: "payload", + type: "Blob" + }, + meta: { + name: "meta", + type: "Meta" + }, + note: { + name: "note", + type: "Label", + optional: true + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + } + } as const; + typeDefs = { + Label: { + name: "Label", + base: "String", + fields: { + this: { + name: "this", + type: "String" + } + } + }, + Flag: { + name: "Flag", + base: "Boolean", + fields: { + this: { + name: "this", + type: "Boolean" + } + } + }, + Quantity: { + name: "Quantity", + base: "Int", + fields: { + this: { + name: "this", + type: "Int" + } + } + }, + Ratio: { + name: "Ratio", + base: "Float", + fields: { + this: { + name: "this", + type: "Float" + } + } + }, + ViewCount: { + name: "ViewCount", + base: "BigInt", + fields: { + this: { + name: "this", + type: "BigInt" + } + } + }, + Price: { + name: "Price", + base: "Decimal", + fields: { + this: { + name: "this", + type: "Decimal" + } + } + }, + Timestamp: { + name: "Timestamp", + base: "DateTime", + fields: { + this: { + name: "this", + type: "DateTime" + } + } + }, + Blob: { + name: "Blob", + base: "Bytes", + fields: { + this: { + name: "this", + type: "Bytes" + } + } + }, + Meta: { + name: "Meta", + base: "Json", + fields: { + this: { + name: "this", + type: "Json" + } + } + } + } as const; + plugins = {}; +} +export const schema = new SchemaType(); diff --git a/tests/e2e/orm/schemas/custom-type-primitive-output/schema.zmodel b/tests/e2e/orm/schemas/custom-type-primitive-output/schema.zmodel new file mode 100644 index 000000000..ef2603562 --- /dev/null +++ b/tests/e2e/orm/schemas/custom-type-primitive-output/schema.zmodel @@ -0,0 +1,61 @@ +datasource db { + provider = "sqlite" + url = "file:./dev.db" +} + +model Owner { + id Int @id @default(autoincrement()) + items Item[] +} + +model Item { + id Int @id @default(autoincrement()) + ownerId Int + owner Owner @relation(fields: [ownerId], references: [id]) + label Label + active Flag + quantity Quantity + ratio Ratio + views ViewCount + price Price + seenAt Timestamp + payload Blob + meta Meta + note Label? +} + +type Label with String { + this String +} + +type Flag with Boolean { + this Boolean +} + +type Quantity with Int { + this Int +} + +type Ratio with Float { + this Float +} + +type ViewCount with BigInt { + this BigInt +} + +type Price with Decimal { + this Decimal +} + +type Timestamp with DateTime { + this DateTime +} + +type Blob with Bytes { + this Bytes +} + +type Meta with Json { + this Json +} diff --git a/tests/e2e/orm/schemas/custom-type-primitive/schema.ts b/tests/e2e/orm/schemas/custom-type-primitive/schema.ts index 92bdd74ce..89e832899 100644 --- a/tests/e2e/orm/schemas/custom-type-primitive/schema.ts +++ b/tests/e2e/orm/schemas/custom-type-primitive/schema.ts @@ -37,6 +37,11 @@ export class SchemaType implements SchemaDef { attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal(18) }] }] as readonly AttributeApplication[], default: 18 as FieldDefault }, + scores: { + name: "scores", + type: "Score", + array: true + }, posts: { name: "posts", type: "Post", @@ -129,6 +134,16 @@ export class SchemaType implements SchemaDef { attributes: [{ name: "@length", args: [{ name: "min", value: ExpressionUtils.literal(1) }, { name: "max", value: ExpressionUtils.literal(12) }] }] as readonly AttributeApplication[] } } + }, + Score: { + name: "Score", + base: "BigInt", + fields: { + this: { + name: "this", + type: "BigInt" + } + } } } as const; authType = "User" as const; diff --git a/tests/e2e/orm/schemas/custom-type-primitive/schema.zmodel b/tests/e2e/orm/schemas/custom-type-primitive/schema.zmodel index e08cf0305..fc185a366 100644 --- a/tests/e2e/orm/schemas/custom-type-primitive/schema.zmodel +++ b/tests/e2e/orm/schemas/custom-type-primitive/schema.zmodel @@ -8,6 +8,7 @@ model User { name UserName contacts Contact[] age Age? @default(18) + scores Score[] posts Post[] } @@ -33,3 +34,7 @@ type Age with Int { type PostTag with String { this String @length(1, 12) } + +type Score with BigInt { + this BigInt +}