From c7c0cb5915c7cf586599b0eda427b6b1a80379bf Mon Sep 17 00:00:00 2001 From: kac-axelor <215158475+kac-axelor@users.noreply.github.com> Date: Fri, 12 Jun 2026 19:11:17 +0530 Subject: [PATCH 01/13] Add type-level test harness and client type expectations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pnpm test:types typechecks the *.test-d.ts suites via vitest typecheck mode; its global setup only generates the test client, no database needed. - The suites assert what the runtime should deliver: payload shapes from doQuery/ensureLazy, row counts as numbers, and aggregate values per their contract — computed aggregates (count/sum/avg) as the driver's exact strings, min/max/groupBy as the column's model type (Decimal stays BigDecimal). - 13 of 63 assertions fail on this commit by design: they are the expectations, and each following commit fixes one contract until the suite is green. --- .prettierignore | 4 + package.json | 1 + src/test/schema/Country.json | 4 + src/test/types-client-input.test-d.ts | 484 +++++++++++++++++++++++++ src/test/types-client-output.test-d.ts | 202 +++++++++++ src/test/types-setup.ts | 8 + tsconfig.typecheck.json | 10 + vitest.config.ts | 21 +- 8 files changed, 733 insertions(+), 1 deletion(-) create mode 100644 .prettierignore create mode 100644 src/test/types-client-input.test-d.ts create mode 100644 src/test/types-client-output.test-d.ts create mode 100644 src/test/types-setup.ts create mode 100644 tsconfig.typecheck.json diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..d4593b5 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +# Type-level test files use intentional long, single-line cases so that each +# `@ts-expect-error` attaches to the error on the line immediately below it. +# Prettier's line wrapping would move the error off that line and break them. +*.test-d.ts diff --git a/package.json b/package.json index 2fa569d..3575617 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "release": "pnpm publish --no-git-checks", "test": "vitest", "test:e2e": "vitest --mode e2e --reporter=verbose --test-timeout 10000", + "test:types": "vitest --run --mode types", "format": "prettier --write \"**/*.{ts,tsx,md,json,css,scss,html}\"" }, "sideEffects": false, diff --git a/src/test/schema/Country.json b/src/test/schema/Country.json index f0d0b5a..885fe98 100644 --- a/src/test/schema/Country.json +++ b/src/test/schema/Country.json @@ -21,6 +21,10 @@ { "type": "Decimal", "name": "population" + }, + { + "type": "Int", + "name": "rank" } ] } diff --git a/src/test/types-client-input.test-d.ts b/src/test/types-client-input.test-d.ts new file mode 100644 index 0000000..68bd464 --- /dev/null +++ b/src/test/types-client-input.test-d.ts @@ -0,0 +1,484 @@ +// Type-level tests for the ORM client API. Run: `pnpm test:types` +// POSITIVE cases must compile (regression surface). NEGATIVE cases use +// single-line `@ts-expect-error` — keep them one line so the directive +// attaches to the reported error. No DB needed; client is `declare`d. +import { describe, expectTypeOf, test } from "vitest"; + +import type { Contact, Country } from "./db/models"; +import { AddressType } from "./db/models"; +import type { BigDecimal, Payload, SelectOptions, WhereOptions } from "@goovee/orm"; +import type { TestClient } from "./client.utils"; + +declare const client: TestClient; +declare const big: BigDecimal; +const { contact, country, address } = client; + +// =========================================================================== +// POSITIVE — valid usage that MUST keep compiling (the regression surface) +// =========================================================================== + +describe("valid: where filters", () => { + test("operators + shorthand + null", () => { + contact.findOne({ where: { firstName: { eq: "a" }, lastName: { like: "A%", notLike: "Z%" } } }); + contact.findOne({ where: { firstName: "a", email: { ne: null } } }); + contact.findOne({ where: { email: null } }); + contact.findOne({ where: { id: { in: [1, "2", 3], notIn: [9] } } }); + contact.findOne({ where: { registeredOn: { between: [new Date(), new Date()], gt: new Date() } } }); + contact.findOne({ where: { registeredOn: { notBetween: [new Date(), new Date()] } } }); + contact.findOne({ where: { dateOfBirth: { ge: "2000-01-01", le: "2020-12-31" } } }); + contact.findOne({ where: { firstName: { notIn: ["a", "b"] } } }); + country.findOne({ where: { isMember: true } }); + country.findOne({ where: { isMember: { eq: false, ne: null } } }); + country.findOne({ where: { population: { gt: 1000 } } }); + country.findOne({ where: { population: { gt: "1000", le: 5000n, between: [0, 9] } } }); + country.findOne({ where: { population: { notBetween: ["0", "100"] } } }); + country.findOne({ where: { population: 42 } }); + }); + + test("decimal accepts all four representations: string, number, bigint, BigDecimal", () => { + country.findOne({ where: { population: { eq: big, ge: big } } }); + country.findOne({ where: { population: big } }); + country.findOne({ where: { population: { in: [1, "2", 3n, big] } } }); + country.findOne({ where: { population: { between: [big, big] } } }); + }); + + test("datetime filter accepts Date objects and strings", () => { + contact.findOne({ where: { registeredOn: { gt: "2020-01-01", lt: new Date() } } }); + contact.findOne({ where: { registeredOn: { between: ["2020-01-01", "2021-01-01"] } } }); + contact.findOne({ where: { registeredOn: { in: [new Date(), "2020-01-01"] } } }); + }); + + test("date and time fields filter as strings", () => { + contact.findOne({ where: { timeOfBirth: { like: "12:%" } } }); + contact.findOne({ where: { dateOfBirth: { between: ["1990-01-01", "2000-12-31"], notIn: ["1995-06-15"] } } }); + }); + + test("int field accepts numbers", () => { + country.findOne({ where: { rank: { gt: 1, between: [1, 10], in: [1, 2] } } }); + country.findOne({ where: { rank: 2 } }); + country.findOne({ where: { rank: null } }); + }); + + test("nested relation filters", () => { + contact.findOne({ where: { title: { name: { eq: "Mr" } } } }); + contact.findOne({ where: { addresses: { city: { like: "%NY%" } } } }); + contact.findOne({ where: { addresses: { country: { code: "US", isMember: true } } } }); + contact.findOne({ where: { addresses: { tags: { name: { ne: null } } } } }); + contact.findOne({ where: { circles: { contacts: { firstName: "x" } } } }); + contact.findOne({ where: { bio: { content: { like: "%hi%" } } } }); + }); + + test("OR / AND / NOT, mixed, deeply nested", () => { + contact.findOne({ where: { OR: [{ firstName: "a" }, { lastName: "b" }] } }); + contact.findOne({ where: { AND: [{ firstName: "a" }, { NOT: [{ email: null }] }] } }); + contact.findOne({ where: { firstName: { like: "a%" }, OR: [{ email: { ne: null } }, { phone: { ne: null } }] } }); + contact.findOne({ where: { OR: [{ email: null }, { AND: [{ firstName: "a" }, { OR: [{ phone: "1" }, { phone: "2" }] }] }] } }); + contact.findOne({ where: { title: { name: "Mr" }, AND: [{ addresses: { city: "NY" } }] } }); + }); + + test("JSON field filters", () => { + contact.findOne({ where: { attrs: { path: "level", type: "Int", eq: 3 } } }); + contact.findOne({ where: { attrs: { path: "name", type: "String", like: "Jo%" } } }); + contact.findOne({ where: { attrs: { path: "active", type: "Boolean", eq: true } } }); + contact.findOne({ where: { attrs: { path: "score", type: "Decimal", gt: "1.5", le: 100 } } }); + contact.findOne({ where: { attrs: { path: "joined", type: "Date", gt: "2020-01-01", lt: new Date() } } }); + }); + + test("enum field and IdFilter", () => { + address.findOne({ where: { type: AddressType.Home } }); + address.findOne({ where: { type: { eq: AddressType.Office, ne: null } } }); + address.findOne({ where: { type: null } }); + contact.findOne({ where: { id: { eq: "1" } } }); + contact.findOne({ where: { id: { in: [1, 2, "3"], notIn: [99] } } }); + contact.findOne({ where: { id: { ne: null } } }); + }); +}); + +describe("valid: select / orderBy / pagination", () => { + test("scalar / to-one / to-many / OneToOne / deep", () => { + contact.findOne({ select: { firstName: true, lastName: true } }); + contact.findOne({ select: { title: { name: true, code: true } } }); + contact.findOne({ select: { bio: { content: true } } }); + contact.findOne({ select: { addresses: { select: { street: true, city: true } } } }); + contact.findOne({ + select: { + firstName: true, + title: { name: true }, + bio: { content: true }, + addresses: { + where: { city: { ne: null } }, + orderBy: { street: "ASC" }, + take: 5, + skip: 1, + distinct: true, + select: { street: true, country: { code: true, name: true }, tags: { select: { name: true } } }, + }, + circles: { select: { code: true, contacts: { select: { firstName: true } } } }, + }, + }); + }); + + test("lazy + json field selection (input side)", () => { + contact.findOne({ select: { attrs: true, notes: true } }); + address.findOne({ select: { props: true, street: true } }); + }); + + test("orderBy simple / nested / JSON + pagination", () => { + contact.find({ orderBy: { firstName: "ASC", lastName: "DESC" } }); + contact.find({ orderBy: { title: { name: "DESC" } } }); + contact.find({ orderBy: { attrs: [{ path: "rank", type: "Int", order: "DESC" }] } }); + contact.find({ orderBy: { attrs: [{ path: "joined", type: "Date", order: "ASC" }, { path: "score", type: "Decimal", order: "DESC" }, { path: "active", type: "Boolean", order: "ASC" }] } }); + contact.find({ orderBy: { registeredOn: "DESC", dateOfBirth: "ASC", timeOfBirth: "ASC" } }); + country.find({ orderBy: { rank: "ASC", population: "DESC" } }); + contact.find({ take: 10, skip: 5, distinct: true, cursor: "abc" }); + }); +}); + +describe("valid: aggregate", () => { + test("avg/sum/min/max/count/groupBy + having/orderBy/where", () => { + country.aggregate({ avg: { population: true } }); + country.aggregate({ sum: { population: true }, count: { id: true } }); + country.aggregate({ groupBy: { isMember: true }, count: { code: true }, having: { count: { code: { gt: 1 } } } }); + country.aggregate({ min: { name: true }, max: { name: true }, where: { isMember: true } }); + country.aggregate({ groupBy: { isMember: true }, orderBy: { count: { code: "DESC" } }, take: 10, skip: 0 }); + }); + + test("all operations combined and nested-relation count", () => { + country.aggregate({ + avg: { population: true }, + sum: { population: true }, + min: { name: true }, + max: { name: true }, + count: { id: true }, + groupBy: { isMember: true }, + }); + contact.aggregate({ count: { addresses: { street: true } } }); + }); +}); + +describe("valid: mutations", () => { + test("create: scalars / link / nested create / bigdecimal", () => { + contact.create({ data: { firstName: "a", lastName: "b" } }); + contact.create({ data: { firstName: "a", lastName: "b", title: { select: { id: 1 } } } }); + contact.create({ data: { firstName: "a", lastName: "b", title: { create: { code: "MR", name: "Mr" } } } }); + contact.create({ data: { firstName: "a", lastName: "b", addresses: { create: [{ street: "1 Main", city: "NY", contact: { select: { id: 1 } } }] }, circles: { select: [{ code: "x" }] } } }); + address.create({ data: { street: "Main St", contact: { select: { id: "1" } } } }); + country.create({ data: { code: "US", name: "USA", population: "1000.5" } }); + country.create({ data: { code: "IN", name: "India", population: 200n } }); + country.create({ data: { code: "FR", name: "France", population: 67.5 }, select: { code: true } }); + country.create({ data: { code: "DE", name: "Germany", population: big, rank: 5 } }); + }); + + test("create/update: temporal, enum, json and int inputs", () => { + contact.create({ data: { firstName: "a", lastName: "b", registeredOn: new Date(), dateOfBirth: "2000-05-15", timeOfBirth: "14:30:45", attrs: Promise.resolve({ level: 3 }) } }); + address.create({ data: { street: "s", contact: { select: { id: 1 } }, type: AddressType.Office } }); + contact.update({ data: { id: "1", version: 0, registeredOn: new Date(), dateOfBirth: null, attrs: null } }); + address.update({ data: { id: "1", version: 0, type: null } }); + country.update({ data: { id: "1", version: 0, rank: 3 } }); + country.update({ data: { id: "1", version: 0, rank: null } }); + }); + + test("decimal create/update/set accept all four representations", () => { + country.update({ data: { id: "1", version: 0, population: 7n } }); + country.update({ data: { id: "1", version: 0, population: 1.5 } }); + country.update({ data: { id: "1", version: 0, population: big } }); + country.updateAll({ set: { population: big } }); + country.updateAll({ set: { population: 5n } }); + country.updateAll({ set: { population: 2.5 } }); + country.createAll({ data: [{ code: "X", name: "X", population: big }] }); + }); + + test("nested create auto-fills the required mappedBy field, so it must be omittable", () => { + contact.create({ data: { firstName: "a", lastName: "b", addresses: { create: [{ street: "x" }] } } }); + contact.update({ data: { id: "1", version: 0, addresses: { create: [{ street: "x" }] } } }); + }); + + test("nested create only relaxes the mappedBy field, nothing else", () => { + // @ts-expect-error unbranded (ManyToMany) nested creates keep all required fields + contact.create({ data: { firstName: "a", lastName: "b", circles: { create: [{}] } } }); + // @ts-expect-error update path: unbranded nested creates keep all required fields + contact.update({ data: { id: "1", version: 0, circles: { create: [{}] } } }); + }); + + test("OneToOne create / update / unlink (bio)", () => { + contact.create({ data: { firstName: "a", lastName: "b", bio: { create: { content: "hello" } } } }); + contact.update({ data: { id: "1", version: 0, bio: { create: { content: "updated" } } } }); + contact.update({ data: { id: "1", version: 0, bio: { select: { id: null } } } }); + }); + + test("unlink ManyToOne / OneToOne by setting id to null", () => { + contact.update({ data: { id: "1", version: 0, title: { select: { id: null } } } }); + address.update({ data: { id: "1", version: 0, country: { select: { id: null } } } }); + }); + + test("ManyToMany: create new / link existing / link multiple", () => { + contact.create({ data: { firstName: "a", lastName: "b", circles: { create: [{ code: "x", name: "y" }] } } }); + contact.update({ data: { id: "1", version: 0, circles: { select: [{ id: 1 }, { id: 2 }] } } }); + contact.update({ data: { id: "1", version: 0, circles: { create: [{ code: "z", name: "z" }], select: [{ id: 3 }] } } }); + }); + + test("update: identity + fields + bigdecimal + null + nested", () => { + contact.update({ data: { id: "1", version: 0, firstName: "a" } }); + country.update({ data: { id: "1", version: 2, population: "5" } }); + country.update({ data: { id: "1", version: 2, population: null } }); + contact.update({ data: { id: "1", version: 0, title: { select: { id: 2 } } } }); + contact.update({ data: { id: "1", version: 0, addresses: { create: [{ street: "x", contact: { select: { id: 1 } } }], remove: [9] } } }); + }); + + test("OneToMany full update — create + update + remove in one call", () => { + contact.update({ + data: { + id: "1", + version: 0, + addresses: { + create: [{ street: "New Rd" }], + update: [{ id: "2", version: 1, city: "Paris" }], + remove: [9, 10], + }, + }, + }); + }); + + test("bulk + count + delete", () => { + contact.count(); + country.count({ where: { isMember: true } }); + contact.delete({ id: 1, version: 0 }); + country.createAll({ data: [{ code: "A", name: "A" }, { code: "B", name: "B", population: 5 }] }); + country.updateAll({ set: { isMember: true }, where: { isMember: null } }); + country.updateAll({ set: { population: "9.99" } }); + contact.deleteAll({ where: { email: null } }); + contact.deleteAll({}); + contact.updateAll({ set: { title: { id: null } } }); + contact.updateAll({ set: { email: null } }); + }); +}); + +describe("valid: payload narrows for mutations and carries metadata", () => { + test("create with select narrows return type", () => { + const r = country.create({ data: { code: "US", name: "USA" }, select: { code: true } }); + type R = Awaited; + expectTypeOf().toHaveProperty("code"); + expectTypeOf().not.toHaveProperty("name"); + }); + + test("create without select returns PayloadSimple (all scalars, no relations)", () => { + const r = country.create({ data: { code: "US", name: "USA" } }); + type R = Awaited; + expectTypeOf().toHaveProperty("code"); + expectTypeOf().toHaveProperty("name"); + }); + + test("update with select narrows return type", () => { + const r = country.update({ data: { id: "1", version: 0 }, select: { name: true } }); + type R = Awaited; + expectTypeOf().toHaveProperty("name"); + expectTypeOf().not.toHaveProperty("code"); + }); + + test("find results carry cursor pagination metadata", () => { + const rs = contact.find({ take: 10 }); + type R = Awaited[number]; + expectTypeOf().toHaveProperty("_cursor"); + expectTypeOf().toHaveProperty("_hasNext"); + expectTypeOf().toHaveProperty("_hasPrev"); + expectTypeOf().toHaveProperty("_count"); + }); +}); + +describe("valid: payload narrows to selection (inference must be preserved)", () => { + test("findOne narrowing across scalar / to-one / to-many", () => { + const r = contact.findOne({ select: { firstName: true, title: { name: true }, addresses: { select: { street: true } } } }); + type R = NonNullable>; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toHaveProperty("id"); + expectTypeOf().toHaveProperty("version"); + expectTypeOf().toHaveProperty("title"); + expectTypeOf().not.toHaveProperty("lastName"); + expectTypeOf().not.toHaveProperty("email"); + type Ti = NonNullable; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().not.toHaveProperty("code"); + type Ad = NonNullable; + expectTypeOf().toBeArray(); + expectTypeOf["street"]>().toEqualTypeOf(); + }); + + test("find returns an array; aggregate result narrows to operations", () => { + const rs = contact.find({ select: { firstName: true } }); + expectTypeOf>().toBeArray(); + const agg = country.aggregate({ avg: { population: true }, count: { id: true } }); + type AG = Awaited; + expectTypeOf().toBeArray(); + expectTypeOf().toHaveProperty("avg"); + expectTypeOf().toHaveProperty("count"); + }); +}); + +describe("valid: Payload type utilities and satisfies pattern", () => { + test("satisfies SelectOptions enables pre-built selects and Payload type extraction", () => { + const sel = { firstName: true, title: { name: true } } satisfies SelectOptions; + contact.findOne({ select: sel }); + type Result = NonNullable>; + expectTypeOf().toHaveProperty("firstName"); + expectTypeOf().not.toHaveProperty("lastName"); + expectTypeOf().toHaveProperty("title"); + }); + + test("satisfies WhereOptions enables pre-built filters", () => { + const filter = { email: { ne: null }, firstName: { like: "A%" } } satisfies WhereOptions; + contact.findOne({ where: filter }); + contact.find({ where: filter, take: 5 }); + }); + + test("Payload narrows correctly for country", () => { + const sel = { code: true, isMember: true } satisfies SelectOptions; + type Result = NonNullable>; + expectTypeOf().toHaveProperty("code"); + expectTypeOf().toHaveProperty("isMember"); + expectTypeOf().not.toHaveProperty("name"); + expectTypeOf().not.toHaveProperty("population"); + }); +}); + +describe("valid: non-fresh values are only assignability-checked (no EPC)", () => { + test("variables bypass excess-property checking", () => { + const dynWhere = { firstName: { eq: "x" }, bogusVar: 1 }; + contact.findOne({ where: dynWhere }); + }); +}); + +// =========================================================================== +// NEGATIVE — invalid usage that must be rejected +// =========================================================================== + +describe("rejects excess keys at every depth", () => { + test("top-level options", () => { + // @ts-expect-error unknown top-level option in find + contact.find({ bogus: 1 }); + // @ts-expect-error unknown top-level option in findOne + contact.findOne({ bogusTop: 1 }); + // @ts-expect-error unknown top-level option in aggregate + country.aggregate({ bogusAgg: 1 }); + // @ts-expect-error unknown top-level option in create + contact.create({ data: { firstName: "a", lastName: "b" }, bogusOpt: 1 }); + // @ts-expect-error unknown top-level option in update + contact.update({ data: { id: 1, version: 0 }, bogusOpt: 1 }); + // @ts-expect-error unknown top-level option in createAll + country.createAll({ data: [{ code: "A", name: "A" }], bogus: 1 }); + }); + + test("inside where (every depth)", () => { + // @ts-expect-error unknown key in where + contact.findOne({ where: { firstName: "a", bogusW: 1 } }); + // @ts-expect-error unknown key in a to-one relation where + contact.findOne({ where: { title: { name: "x", bogusR: 1 } } }); + // @ts-expect-error unknown key in a to-many relation where + contact.findOne({ where: { addresses: { city: "x", bogusTM: 1 } } }); + // @ts-expect-error unknown key in a deeply nested relation where + contact.findOne({ where: { addresses: { country: { code: "US", bogusDeep: 1 } } } }); + // @ts-expect-error unknown key inside an OR[] branch + contact.findOne({ where: { OR: [{ firstName: "a" }, { bogusOr: 1 }] } }); + // @ts-expect-error unknown key inside a nested AND[]/NOT[] branch + contact.findOne({ where: { AND: [{ NOT: [{ bogusNot: 1 }] }] } }); + }); + + test("inside select (every depth)", () => { + // @ts-expect-error unknown key in select + contact.findOne({ select: { firstName: true, bogusS: true } }); + // @ts-expect-error unknown key in a to-one related select + contact.findOne({ select: { title: { name: true, bogusST: true } } }); + // @ts-expect-error unknown key in a to-many related select + contact.findOne({ select: { addresses: { select: { street: true, bogusAS: true } } } }); + // @ts-expect-error unknown key in a where nested under a to-many select + contact.findOne({ select: { addresses: { where: { bogusAW: 1 }, select: { street: true } } } }); + // @ts-expect-error unknown key in an orderBy nested under a to-many select + contact.findOne({ select: { addresses: { orderBy: { bogusAO: "ASC" }, select: { street: true } } } }); + // @ts-expect-error unknown query option in a to-many select + contact.findOne({ select: { addresses: { bogusTMopt: 1, select: { street: true } } } }); + }); + + test("inside orderBy", () => { + // @ts-expect-error unknown key in orderBy + contact.find({ orderBy: { firstName: "ASC", bogusO: "ASC" } }); + // @ts-expect-error unknown key in a nested relation orderBy + contact.find({ orderBy: { title: { bogusNO: "ASC" } } }); + // @ts-expect-error Text (lazy) fields have no orderable type + contact.find({ orderBy: { notes: "ASC" } }); + // @ts-expect-error Binary (lazy) fields have no orderable type + contact.find({ orderBy: { image: "ASC" } }); + // @ts-expect-error orderBy direction is case-sensitive ("ASC" | "DESC") + contact.find({ orderBy: { firstName: "asc" } }); + }); + + test("inside create / update data", () => { + // @ts-expect-error unknown key in create data + contact.create({ data: { firstName: "a", lastName: "b", bogusD: 1 } }); + // @ts-expect-error unknown key in a nested create + contact.create({ data: { firstName: "a", lastName: "b", title: { create: { code: "x", name: "y", bogusNC: 1 } } } }); + // @ts-expect-error unknown key in a nested relation select-link + contact.create({ data: { firstName: "a", lastName: "b", title: { select: { bogusSel: 1 } } } }); + // @ts-expect-error unknown key in update data + contact.update({ data: { id: 1, version: 0, bogusU: 1 } }); + }); + + test("update data must include both id and version", () => { + // @ts-expect-error update data requires id + contact.update({ data: { version: 0, firstName: "x" } }); + // @ts-expect-error update data requires version + contact.update({ data: { id: 1, firstName: "x" } }); + }); + + test("required mappedBy field cannot be omitted in a direct create", () => { + // a direct address.create has no parent to auto-fill the mappedBy field from + // @ts-expect-error missing required mappedBy field 'contact' + address.create({ data: { street: "Main St" } }); + }); + + test("nested create only relaxes the mappedBy field, nothing else", () => { + // @ts-expect-error street is still required in a nested create + contact.create({ data: { firstName: "a", lastName: "b", addresses: { create: [{ city: "NY" }] } } }); + // @ts-expect-error unknown key inside an explicitly-passed mappedBy link + contact.create({ data: { firstName: "a", lastName: "b", addresses: { create: [{ street: "x", contact: { select: { bogusBR: 1 } } }] } } }); + }); + + test("inside aggregate", () => { + // @ts-expect-error unknown field in an avg selection + country.aggregate({ avg: { bogusAvg: true } }); + // @ts-expect-error unknown field in a having clause + country.aggregate({ count: { code: true }, having: { count: { bogusH: { gt: 1 } } } }); + // @ts-expect-error unknown field in an aggregate orderBy + country.aggregate({ groupBy: { isMember: true }, orderBy: { count: { bogusOB: "ASC" } } }); + // @ts-expect-error unknown key in an aggregate where + country.aggregate({ groupBy: { isMember: true }, where: { bogusAW: 1 } }); + // @ts-expect-error string field is not numeric — cannot be used with avg + country.aggregate({ avg: { name: true } }); + // @ts-expect-error string field is not numeric — cannot be used with sum + country.aggregate({ sum: { code: true } }); + // @ts-expect-error boolean field is not numeric — cannot be used with avg + country.aggregate({ avg: { isMember: true } }); + }); + + test("wrong value representations are rejected", () => { + // @ts-expect-error int filters accept numbers only + country.findOne({ where: { rank: { gt: "1" } } }); + // @ts-expect-error int shorthand must be a number + country.findOne({ where: { rank: "2" } }); + // @ts-expect-error int create input must be a number + country.create({ data: { code: "X", name: "X", rank: "5" } }); + // @ts-expect-error boolean filter rejects strings + country.findOne({ where: { isMember: "true" } }); + // @ts-expect-error DateTime create input is a Date object — strings are filter-only + contact.create({ data: { firstName: "a", lastName: "b", registeredOn: "2020-01-01" } }); + // @ts-expect-error JSON create input is a Promise, not a plain object + contact.create({ data: { firstName: "a", lastName: "b", attrs: { level: 3 } } }); + }); + + test("concrete-typed methods still EPC via their param type (no guard needed)", () => { + // @ts-expect-error count uses a concrete QueryOptions param; fresh-literal EPC applies + country.count({ where: { isMember: true }, bogusCount: 1 }); + // @ts-expect-error updateAll set is concrete BulkSetOptions; fresh-literal EPC applies + country.updateAll({ set: { bogusSet: 1 }, where: { isMember: true } }); + // @ts-expect-error deleteAll where is concrete; fresh-literal EPC applies + contact.deleteAll({ where: { bogusDW: 1 } }); + }); +}); diff --git a/src/test/types-client-output.test-d.ts b/src/test/types-client-output.test-d.ts new file mode 100644 index 0000000..a96a1c4 --- /dev/null +++ b/src/test/types-client-output.test-d.ts @@ -0,0 +1,202 @@ +// Output-type tests: assert what repository methods RETURN, derived from the +// runtime implementation (EntityRepository, doQuery, runAggregate, ensureLazy, +// getSimpleSelect) — not from the declared types. Comments name the runtime +// source of truth for each expectation. Run: `pnpm test:types` +import { describe, expectTypeOf, test } from "vitest"; + +import { AddressType } from "./db/models"; +import type { BigDecimal, JsonObject } from "@goovee/orm"; +import type { TestClient } from "./client.utils"; + +declare const client: TestClient; +const { contact, country, address } = client; + +describe("query payload outputs", () => { + test("selected scalars carry their column types; nullable columns admit null", () => { + const r = contact.findOne({ + select: { firstName: true, email: true, registeredOn: true, dateOfBirth: true, timeOfBirth: true }, + }); + type R = NonNullable>; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + // timestamp columns come back as Date instances, date/time as strings + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + // identity is always loaded; the generated Model declares id as a string + // (PostgreSQL bigint columns come back as strings) + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + test("enum, decimal, int and boolean payloads", () => { + const r = address.findOne({ select: { type: true } }); + type R = NonNullable>; + expectTypeOf().toEqualTypeOf(); + const c = country.findOne({ select: { population: true, rank: true, isMember: true } }); + type C = NonNullable>; + // numeric columns use the BigDecimal transformer + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + test("to-one references resolve to the narrowed object or null", () => { + // doQuery: record[property] = related.length ? related[0] : null + const r = contact.findOne({ select: { title: { name: true } } }); + type T = NonNullable>["title"]; + expectTypeOf["name"]>().toEqualTypeOf(); + // null must stay in the type — a missing reference is assigned null + expectTypeOf().not.toEqualTypeOf>(); + }); + + test("selected collections are always arrays, never null", () => { + // doQuery assigns `record[property] = related` for collections — an array + // ([] when empty), never null + const r = contact.findOne({ select: { addresses: { select: { street: true } } } }); + type A = NonNullable>["addresses"]; + expectTypeOf().toEqualTypeOf>(); + }); + + test("selected lazy fields are promises resolving to value-or-null", () => { + // ensureLazy installs a getter that always returns a Promise; loadLazy + // resolves `res?.[name] ?? null` — the field is never null, the awaited + // value can be + const r = contact.findOne({ select: { attrs: true, notes: true } }); + type R = NonNullable>; + expectTypeOf().toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf>(); + }); + + test("cursor metadata: _cursor string, _hasNext/_hasPrev booleans", () => { + const rs = contact.find({ take: 10 }); + type R = Awaited[number]; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + test("_count metadata is a number", () => { + // doQuery: record._count = count, where count = await sq.getCount() + const rs = contact.find({ take: 10 }); + type R = Awaited[number]; + expectTypeOf().toEqualTypeOf(); + }); +}); + +describe("mutation outputs", () => { + test("create with select narrows and is non-null", () => { + const r = country.create({ data: { code: "US", name: "USA" }, select: { code: true } }); + type R = Awaited; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf>(); + }); + + test("create without select returns simple scalars only", () => { + // getSimpleSelect() picks non-relation columns that are not text/jsonb/oid, + // so relations and lazy fields are not loaded + const r = contact.create({ data: { firstName: "a", lastName: "b" } }); + type R = Awaited; + expectTypeOf().toHaveProperty("firstName"); + expectTypeOf().toHaveProperty("registeredOn"); + expectTypeOf().not.toHaveProperty("title"); + expectTypeOf().not.toHaveProperty("addresses"); + expectTypeOf().not.toHaveProperty("attrs"); + expectTypeOf().not.toHaveProperty("notes"); + }); + + test("update without select returns identity only", () => { + const r = country.update({ data: { id: "1", version: 0 } }); + type R = Awaited; + expectTypeOf().toHaveProperty("id"); + expectTypeOf().toHaveProperty("version"); + expectTypeOf().not.toHaveProperty("name"); + }); + + test("row-count results are numbers", () => { + // count() returns sq.getCount(); delete/updateAll/deleteAll return + // `affected ?? 0` — all numbers at runtime + const n = contact.count(); + expectTypeOf>().toEqualTypeOf(); + const d = contact.delete({ id: 1, version: 0 }); + expectTypeOf>().toEqualTypeOf(); + const u = country.updateAll({ set: { isMember: true } }); + expectTypeOf>().toEqualTypeOf(); + const x = contact.deleteAll({}); + expectTypeOf>().toEqualTypeOf(); + }); +}); + +describe("aggregate outputs", () => { + test("computed aggregates are the driver's exact strings", () => { + // pg widens computed aggregates (sum(int) → bigint, avg(int) → numeric) + // and the driver delivers them as exact strings — the client passes them + // through; converting to the right precision is the caller's call. + // count is never null (NULL → "0"); sum/avg are null over empty or + // all-null sets + const a = country.aggregate({ count: { id: true }, avg: { population: true }, sum: { population: true } }); + type A = Awaited[number]; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + test("min/max admit null for empty sets", () => { + // the database yields NULL for min/max over no rows or all-null columns + const a = country.aggregate({ min: { name: true }, max: { name: true } }); + type A = Awaited[number]; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + test("groupBy values of nullable columns admit null", () => { + // convertAggregateValue passes column nulls through for groupBy + const a = country.aggregate({ groupBy: { isMember: true }, count: { id: true } }); + type A = Awaited[number]; + expectTypeOf().toEqualTypeOf(); + }); + + test("groupBy decimal values stay BigDecimal", () => { + // a group key is a stored column value — it keeps the column's model + // type, the same BigDecimal a select would deliver + const a = country.aggregate({ groupBy: { population: true }, count: { id: true } }); + type A = Awaited[number]; + expectTypeOf().toEqualTypeOf(); + }); + + test("min/max and groupBy carry the column's model type", () => { + // min/max/groupBy return stored column values: ints are numbers, + // decimals are BigDecimal, timestamps are Dates, bigint ids are strings + const a = country.aggregate({ sum: { rank: true }, avg: { rank: true }, min: { rank: true }, max: { rank: true } }); + type A = Awaited[number]; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + const d = country.aggregate({ min: { population: true }, max: { population: true } }); + type D = Awaited[number]; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + const c = contact.aggregate({ min: { registeredOn: true }, groupBy: { id: true }, count: { id: true } }); + type C = Awaited[number]; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + test("temporal and enum aggregate values match their select types", () => { + // date/time columns aggregate as strings, timestamps as Dates, enums as + // their enum type — the same shapes a select would deliver + const c = contact.aggregate({ groupBy: { dateOfBirth: true, timeOfBirth: true, registeredOn: true }, count: { id: true } }); + type C = Awaited[number]; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + const m = contact.aggregate({ min: { dateOfBirth: true }, max: { timeOfBirth: true } }); + type M = Awaited[number]; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + const e = address.aggregate({ groupBy: { type: true }, count: { id: true } }); + type E = Awaited[number]; + expectTypeOf().toEqualTypeOf(); + }); +}); diff --git a/src/test/types-setup.ts b/src/test/types-setup.ts new file mode 100644 index 0000000..b27c3e2 --- /dev/null +++ b/src/test/types-setup.ts @@ -0,0 +1,8 @@ +import { generateCode } from "./client.utils"; + +// The type tests import the generated test client (`./db/models`), which is +// not committed — generate it up front, like the e2e setup does, but without +// the database container: only tsc consumes the result. +export function setup() { + generateCode(); +} diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json new file mode 100644 index 0000000..5bf7ef2 --- /dev/null +++ b/tsconfig.typecheck.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "incremental": false, + "composite": false + }, + "include": ["src/test/**/*.test-d.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/vitest.config.ts b/vitest.config.ts index 5b7b42f..5f2b735 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -17,8 +17,27 @@ const e2eTests: ViteUserConfig["test"] = { maxWorkers: 1, }; +// Type-level tests (`*.test-d.ts`), checked by tsc via Vitest's typecheck mode. +// No runtime / DB — the client is `declare`d in the test files; the global +// setup only generates the test client code the suite typechecks against. +const typeTests: ViteUserConfig["test"] = { + include: [], + globalSetup: ["src/test/types-setup.ts"], + typecheck: { + enabled: true, + only: true, + include: ["src/test/**/*.test-d.ts"], + tsconfig: "tsconfig.typecheck.json", + }, +}; + export default defineConfig((env) => { - const testConfig = env.mode === "e2e" ? e2eTests : unitTests; + const testConfig = + env.mode === "e2e" + ? e2eTests + : env.mode === "types" + ? typeTests + : unitTests; return { plugins: [ { From b5cb9f9fe5d02f8991b98740edb0cf27b459df62 Mon Sep 17 00:00:00 2001 From: kac-axelor <215158475+kac-axelor@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:09:31 +0530 Subject: [PATCH 02/13] Type row-count results as numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit count() returns getCount() and delete/updateAll/deleteAll return `affected ?? 0` — always numbers at runtime. Promise forced callers to handle a string case that never occurs. --- src/client/repository/repository.ts | 8 ++++---- src/client/types/repository.ts | 14 +++++++------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/client/repository/repository.ts b/src/client/repository/repository.ts index 52a9a26..3be6dfa 100644 --- a/src/client/repository/repository.ts +++ b/src/client/repository/repository.ts @@ -266,7 +266,7 @@ export class EntityRepository implements Repository { } @intercept() - async count(args?: QueryOptions): Promise { + async count(args?: QueryOptions): Promise { const client = this.#client; const repo = this.#repo; const opts = parseQuery(client, repo, args ?? {}); @@ -515,7 +515,7 @@ export class EntityRepository implements Repository { } @intercept() - async delete(args: DeleteOptions): Promise { + async delete(args: DeleteOptions): Promise { const repo: any = this.#repo; const { id, version } = args; @@ -570,7 +570,7 @@ export class EntityRepository implements Repository { } @intercept() - async updateAll(args: BulkUpdateOptions): Promise { + async updateAll(args: BulkUpdateOptions): Promise { const client = this.#client; const repo = this.#repo; const meta = repo.metadata; @@ -603,7 +603,7 @@ export class EntityRepository implements Repository { } @intercept() - async deleteAll(args?: BulkDeleteOptions): Promise { + async deleteAll(args?: BulkDeleteOptions): Promise { const client = this.#client; const repo = this.#repo; const { where } = args ?? {}; diff --git a/src/client/types/repository.ts b/src/client/types/repository.ts index 564d404..41ff2fd 100644 --- a/src/client/types/repository.ts +++ b/src/client/types/repository.ts @@ -1,5 +1,5 @@ import type { AggregateOptions, AggregatePayload } from "./aggregate"; -import type { Entity, ID, Options } from "./base"; +import type { Entity, Options } from "./base"; import type { BulkCreateOptions, BulkDeleteOptions, @@ -35,10 +35,10 @@ export interface Repository { args: Options>, ): Promise>; - delete(args: DeleteOptions): Promise; + delete(args: DeleteOptions): Promise; - count(): Promise; - count(args: QueryOptions): Promise; + count(): Promise; + count(args: QueryOptions): Promise; aggregate>( args: Options>, @@ -48,8 +48,8 @@ export interface Repository { args: Options>, ): Promise[]>; - updateAll(args: BulkUpdateOptions): Promise; + updateAll(args: BulkUpdateOptions): Promise; - deleteAll(): Promise; - deleteAll(args: BulkDeleteOptions): Promise; + deleteAll(): Promise; + deleteAll(args: BulkDeleteOptions): Promise; } From 9660506da9ae446784b67fd76ab841bb227fe3ee Mon Sep 17 00:00:00 2001 From: kac-axelor <215158475+kac-axelor@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:10:01 +0530 Subject: [PATCH 03/13] Type _count pagination metadata as a number doQuery assigns the result of getCount() to record._count. --- src/client/types/query.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/types/query.ts b/src/client/types/query.ts index aa10010..be2ff51 100644 --- a/src/client/types/query.ts +++ b/src/client/types/query.ts @@ -174,7 +174,7 @@ export type Payload< Query, Empty = ResultIdentity, > = PayloadArg & { - _count?: string; + _count?: number; _cursor?: string; _hasNext?: boolean; _hasPrev?: boolean; From 83403af85a43f25b635223f88dec19f817bb633e Mon Sep 17 00:00:00 2001 From: kac-axelor <215158475+kac-axelor@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:10:46 +0530 Subject: [PATCH 04/13] Drop null from selected collection payloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit doQuery assigns an array to collection properties — empty when there are no related records, never null. The null came from the model field's optionality, which does not apply to query results. --- src/client/types/query.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/client/types/query.ts b/src/client/types/query.ts index be2ff51..f3234d5 100644 --- a/src/client/types/query.ts +++ b/src/client/types/query.ts @@ -126,9 +126,11 @@ export type SelectKeys = Pick< type PayloadSelect = ResultIdentity & { [K in keyof SelectKeys]-?: K extends keyof Select & keyof T - ? undefined extends T[K] - ? PayloadSelectArg, Select[K]> | null - : PayloadSelectArg + ? NonNullable extends Array + ? PayloadSelectArg, Select[K]> + : undefined extends T[K] + ? PayloadSelectArg, Select[K]> | null + : PayloadSelectArg : never; }; From ec7173cc3c21f5f5ee00eddc53ed975b8dd6c30a Mon Sep 17 00:00:00 2001 From: kac-axelor <215158475+kac-axelor@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:50:28 +0530 Subject: [PATCH 05/13] Carry lazy value nullability inside the Json/Text/Binary aliases - loadLazy resolves the column value or null on every read, so the aliases declare Promise at the source instead of payload types patching it in. - PayloadSelect passes lazy fields through unchanged, dropping only the model field's optionality null: ensureLazy always installs the getter on selected fields. - The old payload type Json | null claimed the field itself could be null while hiding that the awaited value can be. --- src/client/types/base.ts | 8 ++++---- src/client/types/query.ts | 8 +++++--- src/test/json.test.ts | 4 ++-- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/client/types/base.ts b/src/client/types/base.ts index 109351f..7b3286a 100644 --- a/src/client/types/base.ts +++ b/src/client/types/base.ts @@ -16,10 +16,10 @@ export type JsonObject = { [key: string]: JsonValue; }; -// Async data types -export type Json = Promise; -export type Text = Promise; -export type Binary = Promise; +// Async data types; the lazy loader resolves the column value or null +export type Json = Promise; +export type Text = Promise; +export type Binary = Promise; // ============================================================================ // Entity Types diff --git a/src/client/types/query.ts b/src/client/types/query.ts index f3234d5..77a1f37 100644 --- a/src/client/types/query.ts +++ b/src/client/types/query.ts @@ -128,9 +128,11 @@ type PayloadSelect = ResultIdentity & { [K in keyof SelectKeys]-?: K extends keyof Select & keyof T ? NonNullable extends Array ? PayloadSelectArg, Select[K]> - : undefined extends T[K] - ? PayloadSelectArg, Select[K]> | null - : PayloadSelectArg + : NonNullable extends Promise + ? NonNullable + : undefined extends T[K] + ? PayloadSelectArg, Select[K]> | null + : PayloadSelectArg : never; }; diff --git a/src/test/json.test.ts b/src/test/json.test.ts index c81f8f8..e9faf12 100644 --- a/src/test/json.test.ts +++ b/src/test/json.test.ts @@ -105,8 +105,8 @@ describe("json tests", async () => { expect(lower).toBeDefined(); if (upper?.attrs && lower?.attrs) { - const upperDate = (await upper.attrs).dateOfBirth as string; - const lowerDate = (await lower.attrs).dateOfBirth as string; + const upperDate = (await upper.attrs)?.dateOfBirth as string; + const lowerDate = (await lower.attrs)?.dateOfBirth as string; const res = await client.contact.find({ where: { From d7073d7f052c8c205cbc0480020b3fca35c8d86f Mon Sep 17 00:00:00 2001 From: kac-axelor <215158475+kac-axelor@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:12:56 +0530 Subject: [PATCH 06/13] Exclude relations and lazy columns from PayloadSimple MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit create/createAll without a select load getSimpleSelect() — plain scalar columns only. OmitByType never matched optional fields (their null member fails the extends check), so every nullable relation and text/jsonb/oid column leaked into the declared result. --- src/client/types/query.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/client/types/query.ts b/src/client/types/query.ts index 77a1f37..524d763 100644 --- a/src/client/types/query.ts +++ b/src/client/types/query.ts @@ -158,8 +158,24 @@ type PayloadSelectArg = Arg extends undefined | null | false : never : T; +// Simple selection: plain scalar columns only — relations and lazy +// (text/jsonb/oid) columns are not loaded. The filter must test the +// NonNullable type: optional fields never extend the excluded set directly +// because of their null member. export type PayloadSimple = ResultIdentity & - OmitByType; + Pick< + T, + { + [K in keyof T]-?: NonNullable extends + | Json + | Text + | Binary + | Entity + | Entity[] + ? never + : K; + }[keyof T] + >; export type PayloadArg< Type extends Entity, From 029d437cd52b06f19023e90cf83d539ed2c6468a Mon Sep 17 00:00:00 2001 From: kac-axelor <215158475+kac-axelor@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:53:37 +0530 Subject: [PATCH 07/13] Return computed aggregates as the driver's exact strings - count/sum/avg have no column type to preserve: pg widens them (sum(int) is bigint, avg(int) is numeric) and the driver delivers exact numeric strings, which now pass through untouched. - Converting to the right precision becomes the caller's choice: Number() for display math, BigDecimal for exact arithmetic, BigInt for huge counts. - count is never null (NULL becomes "0"); sum/avg stay null over empty or all-null sets; parseInt truncation of decimal sums (3.75 read as 3) disappears along with the parsing. --- src/client/repository/query/aggregate.ts | 13 ++-- src/client/types/aggregate.ts | 9 ++- src/test/aggregate.test.ts | 76 +++++++++++++++--------- 3 files changed, 62 insertions(+), 36 deletions(-) diff --git a/src/client/repository/query/aggregate.ts b/src/client/repository/query/aggregate.ts index cf3174b..9c9b995 100644 --- a/src/client/repository/query/aggregate.ts +++ b/src/client/repository/query/aggregate.ts @@ -73,14 +73,15 @@ export const runAggregate = async ( switch (operation) { case "count": - // COUNT should always return a number, even if it's 0 - return value === null ? 0 : parseInt(value, 10); + // COUNT arrives as the driver's bigint string and stays exact; + // it is never null (no rows → "0") + return value === null ? "0" : value; case "sum": - // SUM can be null if there are no rows, or a number - return value === null ? null : parseInt(value, 10); case "avg": - // AVG can be null if there are no rows, or a float - return value === null ? null : parseFloat(value); + // pg widens sum/avg beyond the column type and the driver delivers + // exact numeric strings — pass them through untouched; null when + // there are no rows (or only nulls) + return value; case "min": case "max": // For min/max, we need to preserve the original type diff --git a/src/client/types/aggregate.ts b/src/client/types/aggregate.ts index d627011..ddd49dc 100644 --- a/src/client/types/aggregate.ts +++ b/src/client/types/aggregate.ts @@ -167,10 +167,15 @@ export type OrderByFromSelection = { : never; }; +// Computed aggregates have no column type to preserve — pg widens them +// (sum(int) → bigint, avg(int) → numeric) and the driver delivers exact +// strings; the client passes them through, leaving the precision of the +// conversion to the caller. count is never null (NULL → "0"); sum/avg are +// null over empty or all-null sets. type AggregateResultType = Op extends "count" - ? number + ? string : Op extends "avg" | "sum" - ? number + ? string | null : Op extends "min" | "max" ? T extends Date ? Date diff --git a/src/test/aggregate.test.ts b/src/test/aggregate.test.ts index f41319b..9d178b7 100644 --- a/src/test/aggregate.test.ts +++ b/src/test/aggregate.test.ts @@ -85,10 +85,10 @@ describe("aggregate e2e tests", async () => { const first = result[0]; expect(first.count).toBeDefined(); - expect(first.count.id).toBeTypeOf("number"); - expect(first.count.firstName).toBeTypeOf("number"); - expect(first.count.id).toBeGreaterThan(0); - expect(first.count.firstName).toBeGreaterThan(0); + expect(first.count.id).toBeTypeOf("string"); + expect(first.count.firstName).toBeTypeOf("string"); + expect(Number(first.count.id)).toBeGreaterThan(0); + expect(Number(first.count.firstName)).toBeGreaterThan(0); }); it("should perform count with relations", async () => { @@ -112,9 +112,9 @@ describe("aggregate e2e tests", async () => { const first = result[0]; expect(first.count).toBeDefined(); expect(first.count.title).toBeDefined(); - expect(first.count.title.id).toBeTypeOf("number"); + expect(first.count.title.id).toBeTypeOf("string"); expect(first.count.addresses).toBeDefined(); - expect(first.count.addresses.id).toBeTypeOf("number"); + expect(first.count.addresses.id).toBeTypeOf("string"); } }); @@ -133,7 +133,7 @@ describe("aggregate e2e tests", async () => { if (result.length > 0) { const first = result[0]; expect(first.avg).toBeDefined(); - expect(first.avg.version).toBeTypeOf("number"); + expect(first.avg.version).toBeTypeOf("string"); } }); @@ -154,11 +154,31 @@ describe("aggregate e2e tests", async () => { expect(first.sum).toBeDefined(); // Sum can be null if no valid values exist, or a number expect( - first.sum.version === null || typeof first.sum.version === "number", + first.sum.version === null || typeof first.sum.version === "string", ).toBe(true); } }); + it("should return decimal sums as exact strings", async () => { + await client.country.createAll({ + data: [ + { code: "d1", name: "Decimal One", population: "1.25" }, + { code: "d2", name: "Decimal Two", population: "2.5" }, + ], + }); + + const result = await client.country.aggregate({ + sum: { population: true }, + avg: { population: true }, + where: { code: { in: ["d1", "d2"] } }, + }); + + // pg sums numerics exactly and the value passes through untouched — + // no parseInt truncation (3), no float rounding + expect(result[0].sum.population).toBe("3.75"); + expect(Number(result[0].avg.population)).toBe(1.875); + }); + it("should perform min and max aggregations", async () => { await setupTestData(client); @@ -214,14 +234,14 @@ describe("aggregate e2e tests", async () => { if (result.length > 0) { const first = result[0]; expect(first.count).toBeDefined(); - expect(first.count.id).toBeTypeOf("number"); + expect(first.count.id).toBeTypeOf("string"); expect(first.avg).toBeDefined(); expect( - first.avg.version === null || typeof first.avg.version === "number", + first.avg.version === null || typeof first.avg.version === "string", ).toBe(true); expect(first.sum).toBeDefined(); expect( - first.sum.version === null || typeof first.sum.version === "number", + first.sum.version === null || typeof first.sum.version === "string", ).toBe(true); expect(first.min).toBeDefined(); expect(first.min.firstName).toBeTypeOf("string"); @@ -248,7 +268,7 @@ describe("aggregate e2e tests", async () => { if (result.length > 0) { const first = result[0]; expect(first.count).toBeDefined(); - expect(first.count.id).toBeTypeOf("number"); + expect(first.count.id).toBeTypeOf("string"); expect(first.groupBy).toBeDefined(); expect(first.groupBy.firstName).toBeDefined(); expect(first.groupBy.version).toBeDefined(); @@ -273,7 +293,7 @@ describe("aggregate e2e tests", async () => { if (result.length > 0) { const first = result[0]; expect(first.count).toBeDefined(); - expect(first.count.id).toBeTypeOf("number"); + expect(first.count.id).toBeTypeOf("string"); expect(first.groupBy).toBeDefined(); expect(first.groupBy.title).toBeDefined(); expect(first.groupBy.title.id).toBeDefined(); @@ -300,7 +320,7 @@ describe("aggregate e2e tests", async () => { if (result.length > 0) { const first = result[0]; expect(first.count).toBeDefined(); - expect(first.count.id).toBeTypeOf("number"); + expect(first.count.id).toBeTypeOf("string"); expect(first.groupBy).toBeDefined(); expect(first.groupBy.addresses).toBeDefined(); expect(first.groupBy.addresses.country).toBeDefined(); @@ -328,10 +348,10 @@ describe("aggregate e2e tests", async () => { if (result.length > 0) { const first = result[0]; expect(first.count).toBeDefined(); - expect(first.count.id).toBeTypeOf("number"); + expect(first.count.id).toBeTypeOf("string"); expect(first.avg).toBeDefined(); expect( - first.avg.version === null || typeof first.avg.version === "number", + first.avg.version === null || typeof first.avg.version === "string", ).toBe(true); } }); @@ -363,11 +383,11 @@ describe("aggregate e2e tests", async () => { if (result.length > 0) { const first = result[0]; expect(first.count).toBeDefined(); - expect(first.count.id).toBeTypeOf("number"); - expect(first.count.id).toBeGreaterThan(0); + expect(first.count.id).toBeTypeOf("string"); + expect(Number(first.count.id)).toBeGreaterThan(0); expect(first.avg).toBeDefined(); - expect(first.avg.version).toBeTypeOf("number"); - expect(first.avg.version).toBeGreaterThanOrEqual(1.0); + expect(first.avg.version).toBeTypeOf("string"); + expect(Number(first.avg.version)).toBeGreaterThanOrEqual(1.0); expect(first.groupBy).toBeDefined(); expect(first.groupBy.firstName).toBeDefined(); } @@ -412,12 +432,12 @@ describe("aggregate e2e tests", async () => { if (result.length > 0) { const first = result[0]; expect(first.count).toBeDefined(); - expect(first.count.id).toBeTypeOf("number"); + expect(first.count.id).toBeTypeOf("string"); expect(first.count.addresses).toBeDefined(); - expect(first.count.addresses.id).toBeTypeOf("number"); + expect(first.count.addresses.id).toBeTypeOf("string"); expect(first.avg).toBeDefined(); - expect(first.avg.version).toBeTypeOf("number"); - expect(first.avg.version).toBeLessThan(100); + expect(first.avg.version).toBeTypeOf("string"); + expect(Number(first.avg.version)).toBeLessThan(100); expect(first.max).toBeDefined(); expect(first.max.firstName).toBeTypeOf("string"); expect(first.groupBy).toBeDefined(); @@ -450,7 +470,7 @@ describe("aggregate e2e tests", async () => { expect(first.avg.addresses.country).toBeDefined(); expect( first.avg.addresses.country.version === null || - typeof first.avg.addresses.country.version === "number", + typeof first.avg.addresses.country.version === "string", ).toBe(true); } }); @@ -476,13 +496,13 @@ describe("aggregate e2e tests", async () => { const first = result[0]; expect(first.avg).toBeDefined(); expect( - first.avg.version === null || typeof first.avg.version === "number", + first.avg.version === null || typeof first.avg.version === "string", ).toBe(true); expect(first.avg.addresses).toBeDefined(); expect(first.avg.addresses.country).toBeDefined(); expect( first.avg.addresses.country.version === null || - typeof first.avg.addresses.country.version === "number", + typeof first.avg.addresses.country.version === "string", ).toBe(true); // They should be different values since they come from different tables (if both not null) @@ -513,6 +533,6 @@ describe("aggregate e2e tests", async () => { const first = result[0]; expect(first.count).toBeDefined(); - expect(first.count.id).toBe(0); + expect(first.count.id).toBe("0"); }); }); From bc037ead6687bce1d3325921eb96edef5d12e754 Mon Sep 17 00:00:00 2001 From: kac-axelor <215158475+kac-axelor@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:54:03 +0530 Subject: [PATCH 08/13] Type min/max results as the column's model type or null MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - min/max return stored column values, so the result keeps the column's type: ints are numbers, strings stay strings, timestamps are Dates, and Decimal columns are BigDecimal — the Date/string/boolean conditional chain with its number fallthrough collapses to T | null. - null covers the empty or all-null set, which the database reports as NULL. --- src/client/types/aggregate.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/client/types/aggregate.ts b/src/client/types/aggregate.ts index ddd49dc..202e9f3 100644 --- a/src/client/types/aggregate.ts +++ b/src/client/types/aggregate.ts @@ -177,13 +177,9 @@ type AggregateResultType = Op extends "count" : Op extends "avg" | "sum" ? string | null : Op extends "min" | "max" - ? T extends Date - ? Date - : T extends string - ? string - : T extends boolean - ? boolean - : number + ? // min/max return stored column values — they keep the column's model + // type; null when the set is empty or all-null + T | null : Op extends "groupBy" ? T : never; From e4f1105d81a4913af6ecd4f014df3a5cf0716a17 Mon Sep 17 00:00:00 2001 From: kac-axelor <215158475+kac-axelor@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:13:53 +0530 Subject: [PATCH 09/13] Admit null for groupBy values of nullable columns runAggregate passes column nulls through for groupBy; the null group is a real bucket. The declared type stripped it via NonNullable. --- src/client/types/aggregate.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/client/types/aggregate.ts b/src/client/types/aggregate.ts index 202e9f3..02cab5b 100644 --- a/src/client/types/aggregate.ts +++ b/src/client/types/aggregate.ts @@ -184,10 +184,17 @@ type AggregateResultType = Op extends "count" ? T : never; +// groupBy passes column nulls through — the null group is a real bucket +type GroupByNull = Op extends "groupBy" + ? null extends V + ? null + : never + : never; + export type AggregateValue = { [K in keyof S]: S[K] extends true ? K extends keyof T - ? AggregateResultType, Op> + ? AggregateResultType, Op> | GroupByNull : never : S[K] extends object ? K extends keyof T From a662625ccfed28f6d3b833d543038661eef07bf0 Mon Sep 17 00:00:00 2001 From: kac-axelor <215158475+kac-axelor@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:56:00 +0530 Subject: [PATCH 10/13] Convert min/max/groupBy aggregate values by column type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - min/max/groupBy carry stored column values, so each keeps its column's model type — the old value-shape sniffing returned negative decimals as raw strings, grouped numeric-looking string keys like "007" as the number 7, parsed bigint ids to numbers against their string typing, and let date columns through as driver Date objects. - The aggregate path now resolves to the column's metadata (walking relations) and converts only where the driver's shape differs from entity hydration: numeric columns become BigDecimal, date columns become date strings via TypeORM's own DateUtils; everything else passes through. - e2e covers negative decimal min/max/groupBy as BigDecimal, leading-zero string group keys, bigint id group keys, and date/time/timestamp aggregates. --- src/client/repository/query/aggregate.ts | 39 ++++++--- src/test/aggregate.test.ts | 105 +++++++++++++++++++++++ 2 files changed, 131 insertions(+), 13 deletions(-) diff --git a/src/client/repository/query/aggregate.ts b/src/client/repository/query/aggregate.ts index 9c9b995..a6003a9 100644 --- a/src/client/repository/query/aggregate.ts +++ b/src/client/repository/query/aggregate.ts @@ -1,3 +1,5 @@ +import { DateUtils } from "typeorm/util/DateUtils.js"; +import { BigDecimal } from "../../fields/decimal"; import { ParseResult } from "../../parser"; import { OrmRepository } from "../types"; @@ -66,6 +68,18 @@ export const runAggregate = async ( // Execute the query and get raw results const rawResults = await sq.getRawMany(); + // Resolve an aggregate path ("min.title.name") to its column metadata, + // walking relations; undefined when the path does not reach a plain column + const resolveColumn = (pathParts: string[]) => { + let meta = repo.metadata; + for (let i = 1; i < pathParts.length - 1; i++) { + const relation = meta.findRelationWithPropertyPath(pathParts[i]); + if (!relation) return undefined; + meta = relation.inverseEntityMetadata; + } + return meta.findColumnWithPropertyName(pathParts[pathParts.length - 1]); + }; + // Helper function to convert values based on aggregate operation const convertAggregateValue = (value: any, originalPath: string): any => { const pathParts = originalPath.split("."); @@ -84,23 +98,22 @@ export const runAggregate = async ( return value; case "min": case "max": - // For min/max, we need to preserve the original type - // Numbers should be converted, strings/dates should remain as-is + case "groupBy": { + // min/max/groupBy carry stored column values, which keep the + // column's model type. The driver already delivers most of them + // correctly (ints as numbers, strings/enums/bigints as strings, + // timestamps as Dates); only numeric and date columns arrive in + // shapes that differ from entity hydration and need converting. if (value === null) return null; - if (typeof value === "string" && /^\d+(\.\d+)?$/.test(value)) { - return parseFloat(value); + const column = resolveColumn(pathParts); + if (typeof value === "string" && String(column?.type) === "numeric") { + return new BigDecimal(value); } - return value; - case "groupBy": - // GroupBy values should preserve their original types - // Only convert if it's clearly a number - if (value === null) return null; - if (typeof value === "string" && /^\d+$/.test(value)) { - return parseInt(value, 10); - } else if (typeof value === "string" && /^\d+\.\d+$/.test(value)) { - return parseFloat(value); + if (value instanceof Date && String(column?.type) === "date") { + return DateUtils.mixedDateToDateString(value); } return value; + } default: return value; } diff --git a/src/test/aggregate.test.ts b/src/test/aggregate.test.ts index 9d178b7..5df5b1a 100644 --- a/src/test/aggregate.test.ts +++ b/src/test/aggregate.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { getTestClient } from "./client.utils"; +import { BigDecimal } from "@goovee/orm"; import { Contact, Title, Address, Country } from "./db/models"; describe("aggregate e2e tests", async () => { @@ -179,6 +180,110 @@ describe("aggregate e2e tests", async () => { expect(Number(result[0].avg.population)).toBe(1.875); }); + it("should return decimal min/max/groupBy values as BigDecimal, negatives included", async () => { + await client.country.createAll({ + data: [ + { code: "g1", name: "Negative One", population: "-1.25" }, + { code: "g2", name: "Negative Two", population: "-2.5" }, + ], + }); + + const minMax = await client.country.aggregate({ + min: { population: true }, + max: { population: true }, + where: { code: { in: ["g1", "g2"] } }, + }); + + expect(minMax[0].min.population).toBeInstanceOf(BigDecimal); + expect(minMax[0].min.population!.equals(new BigDecimal("-2.5"))).toBe(true); + expect(minMax[0].max.population!.equals(new BigDecimal("-1.25"))).toBe( + true, + ); + + const groups = await client.country.aggregate({ + groupBy: { population: true }, + count: { id: true }, + where: { code: { in: ["g1", "g2"] } }, + }); + const values = groups + .map((g) => Number(g.groupBy.population!.toString())) + .sort((a, b) => a - b); + + expect(values).toEqual([-2.5, -1.25]); + }); + + it("should keep numeric-looking string group keys as strings", async () => { + await client.country.create({ + data: { code: "007", name: "Bond", population: "1" }, + }); + + const result = await client.country.aggregate({ + groupBy: { code: true }, + count: { id: true }, + where: { code: { in: ["007"] } }, + }); + + expect(result[0].groupBy.code).toBe("007"); + }); + + it("should keep bigint id aggregate values as strings", async () => { + const c = await client.country.create({ + data: { code: "big1", name: "Big One" }, + }); + + const result = await client.country.aggregate({ + groupBy: { id: true }, + count: { id: true }, + where: { code: { in: ["big1"] } }, + }); + + expect(result[0].groupBy.id).toBe(String(c.id)); + }); + + it("should return temporal aggregate values per their column types", async () => { + await client.contact.createAll({ + data: [ + { + firstName: "T1", + lastName: "Temporal", + registeredOn: new Date("2024-03-01T10:00:00Z"), + dateOfBirth: "1990-05-15", + timeOfBirth: "14:30:45", + }, + { + firstName: "T2", + lastName: "Temporal", + registeredOn: new Date("2024-03-02T10:00:00Z"), + dateOfBirth: "1991-07-20", + timeOfBirth: "09:15:00", + }, + ], + }); + + const groups = await client.contact.aggregate({ + groupBy: { dateOfBirth: true, timeOfBirth: true, registeredOn: true }, + count: { id: true }, + where: { lastName: { in: ["Temporal"] } }, + }); + + // date columns group as date strings (matching the model type), time + // columns as time strings, timestamp columns as Date instances + const dates = groups.map((g) => g.groupBy.dateOfBirth).sort(); + expect(dates).toEqual(["1990-05-15", "1991-07-20"]); + expect(groups[0].groupBy.timeOfBirth).toMatch(/^\d{2}:\d{2}:\d{2}/); + expect(groups[0].groupBy.registeredOn).toBeInstanceOf(Date); + + const minMax = await client.contact.aggregate({ + min: { dateOfBirth: true, registeredOn: true }, + max: { dateOfBirth: true }, + where: { lastName: { in: ["Temporal"] } }, + }); + + expect(minMax[0].min.dateOfBirth).toBe("1990-05-15"); + expect(minMax[0].max.dateOfBirth).toBe("1991-07-20"); + expect(minMax[0].min.registeredOn).toBeInstanceOf(Date); + }); + it("should perform min and max aggregations", async () => { await setupTestData(client); From 00d01ca9f3c3e9ea3723e1c222f52e22289b0477 Mon Sep 17 00:00:00 2001 From: kac-axelor <215158475+kac-axelor@users.noreply.github.com> Date: Wed, 10 Jun 2026 20:21:58 +0530 Subject: [PATCH 11/13] Make mappedBy fields optional on nested creates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ORM auto-fills a collection's mappedBy field from the parent record, yet nested creates still demanded it, forcing callers to pass dummy links like `contact: {}`. Carry the schema's mappedBy key into the generated model types through a branded collection type — ToMany — emitted for OneToMany fields, and relax exactly that field in nested CreateArgs. Mirror the rule in the GraphQL schema with per-relation CreateWithoutInput variants, generated only when the target declares the field required. Direct creates keep requiring the field: there is no parent to fill it from. Runtime tests drop the now-unnecessary contact: {} workaround. --- src/client/types/base.ts | 14 ++++ src/client/types/crud.ts | 37 +++++++++-- src/graphql/graphql-schema.ts | 96 +++++++++++++++++++++------ src/schema/schema-generator.test.ts | 8 +-- src/schema/schema-generator.ts | 26 +++++++- src/test/client-create.test.ts | 2 - src/test/client-relationships.test.ts | 1 - src/test/client-update.test.ts | 2 - src/test/data-integrity.test.ts | 1 - src/test/fixture.ts | 1 - src/test/graphql.test.ts | 5 +- src/test/middleware.test.ts | 1 - 12 files changed, 150 insertions(+), 44 deletions(-) diff --git a/src/client/types/base.ts b/src/client/types/base.ts index 7b3286a..d122927 100644 --- a/src/client/types/base.ts +++ b/src/client/types/base.ts @@ -34,6 +34,20 @@ export type Entity = { export type EntityClass = new () => T; +// Type-level marker carrying a collection's `mappedBy` key; never set at runtime. +export const MAPPED_BY: unique symbol = Symbol("mappedBy"); + +// A to-many relation that records which field on the target maps back to the +// owning entity (the schema's `mappedBy`). Structurally still T[] — the marker +// is an optional phantom property — but it lets the create types know which +// foreign key the ORM auto-fills when targets are created nested under their +// owner. Generated by the schema generator; not meant to be written by hand. +// MappedBy stays unconstrained: a constraint would become the inference +// fallback for plain T[] in MappedByKey, branding every collection. +export type ToMany = T[] & { + readonly [MAPPED_BY]?: MappedBy; +}; + // Identity types export type InputIdentity = { id: ID; diff --git a/src/client/types/crud.ts b/src/client/types/crud.ts index cfbb77b..49e1e49 100644 --- a/src/client/types/crud.ts +++ b/src/client/types/crud.ts @@ -1,19 +1,39 @@ import type { BigDecimal } from "../fields/decimal"; -import type { Entity, ID, InputIdentity, OmitByType } from "./base"; +import type { Entity, ID, InputIdentity, OmitByType, ToMany } from "./base"; import type { SelectOptions, WhereOptions } from "./query"; // ============================================================================ // Create/Update/Delete Types // ============================================================================ +// The `mappedBy` key carried by a ToMany collection type — the field on the +// target entity that the ORM fills with the owning record on nested creates. +// Plain (unbranded) arrays yield never. +type MappedByKey = + T extends ToMany ? (K extends keyof P ? K : never) : never; + +// CreateArgs for targets created nested under the owning side of a +// collection: the mappedBy field is auto-filled by the ORM, so it becomes +// optional. Top-level creates use CreateArgs directly and keep requiring +// it — there is no owner to fill it from. +type NestedCreateArgs = Omit< + CreateArgs, + MappedBy +> & { + [K in MappedBy]?: CreateArg; +}; + interface NestedCreateArg { select?: WhereOptions; create?: CreateArgs; } -interface NestedCreateManyArg { +interface NestedCreateManyArg< + T extends Entity, + MappedBy extends keyof T = never, +> { select?: WhereOptions | WhereOptions[]; - create?: CreateArgs | CreateArgs[]; + create?: NestedCreateArgs | NestedCreateArgs[]; } type AllowNull = T extends undefined ? T | null : T; @@ -25,7 +45,7 @@ type LeafArg = T extends BigDecimal export type CreateArg = T extends Array ? P extends Entity - ? NestedCreateManyArg

+ ? NestedCreateManyArg> : T : T extends Entity ? NestedCreateArg @@ -46,9 +66,12 @@ type NestedUpdateArg = { update?: UpdateArgs; }; -type NestedUpdateManyArg = { +type NestedUpdateManyArg< + T extends Entity, + MappedBy extends keyof T = never, +> = { select?: WhereOptions | WhereOptions[]; - create?: CreateArgs | CreateArgs[]; + create?: NestedCreateArgs | NestedCreateArgs[]; update?: UpdateArgs | UpdateArgs[]; remove?: ID | ID[]; }; @@ -56,7 +79,7 @@ type NestedUpdateManyArg = { export type UpdateArg = T extends Array ? P extends Entity - ? NestedUpdateManyArg

+ ? NestedUpdateManyArg> : T : T extends Entity ? NestedUpdateArg diff --git a/src/graphql/graphql-schema.ts b/src/graphql/graphql-schema.ts index a7d1c3e..b697af1 100644 --- a/src/graphql/graphql-schema.ts +++ b/src/graphql/graphql-schema.ts @@ -14,7 +14,7 @@ import { } from "graphql"; import { toCamelCase } from "../schema/schema-utils"; -import { EntityOptions, EnumItem } from "../schema/types"; +import { EntityOptions, EnumItem, PropertyOptions } from "../schema/types"; import { connectionResolver, createResolver, @@ -187,15 +187,27 @@ export const buildGraphQLSchema = (entities: EntityOptions[]) => { const nestedCreateInputs: Record = {}; const nestedUpdateInputs: Record = {}; + const namePascal = (name: string) => + name.charAt(0).toUpperCase() + name.slice(1); + const nameNode = (name: string) => name; const nameEdge = (name: string) => `${name}Edge`; const nameConnection = (name: string) => `${name}Connection`; const nameFilter = (name: string) => `${name}Filter`; const nameOrder = (name: string) => `${name}Order`; - const nameCreateInput = (name: string) => `${name}CreateInput`; + const nameCreateInput = (name: string, without?: string) => + without + ? `${name}CreateWithout${namePascal(without)}Input` + : `${name}CreateInput`; const nameUpdateInput = (name: string) => `${name}UpdateInput`; - const nameNestedCreate = (name: string) => `${name}NestedCreateInput`; - const nameNestedUpdate = (name: string) => `${name}NestedUpdateInput`; + const nameNestedCreate = (name: string, without?: string) => + without + ? `${name}NestedCreateWithout${namePascal(without)}Input` + : `${name}NestedCreateInput`; + const nameNestedUpdate = (name: string, without?: string) => + without + ? `${name}NestedUpdateWithout${namePascal(without)}Input` + : `${name}NestedUpdateInput`; const findEnum = (name: string) => enums[name]; @@ -402,34 +414,56 @@ export const buildGraphQLSchema = (entities: EntityOptions[]) => { return (orders[name] = input); }; - const nestedCreateInput = (entity: EntityOptions) => { - const name = nameNestedCreate(entity.name); + // The mappedBy field of a relation, when the target declares it required. + // Nested creates through such a relation must not demand it — the ORM fills + // it from the parent record — so they use "Without" input variants. + const autoFilledMappedBy = (item: PropertyOptions) => { + if (!("mappedBy" in item) || !item.mappedBy) return undefined; + const { target, mappedBy } = item; + const field = entities + .find((x) => x.name === target) + ?.fields?.find((x) => x.name === mappedBy); + return field && "required" in field && field.required + ? mappedBy + : undefined; + }; + + const nestedCreateInput = (entity: EntityOptions, without?: string) => { + const name = nameNestedCreate(entity.name, without); const input = new GraphQLInputObjectType({ name, - fields: { - create: { type: new GraphQLList(findCreateInput(entity.name)) }, - select: { type: new GraphQLList(findFilter(entity.name)) }, + fields() { + return { + create: { + type: new GraphQLList(findCreateInput(nameCreateInput(entity.name, without))), + }, + select: { type: new GraphQLList(findFilter(entity.name)) }, + }; }, }); return (nestedCreateInputs[name] = input); }; - const nestedUpdateInput = (entity: EntityOptions) => { - const name = nameNestedUpdate(entity.name); + const nestedUpdateInput = (entity: EntityOptions, without?: string) => { + const name = nameNestedUpdate(entity.name, without); const input = new GraphQLInputObjectType({ name, - fields: { - create: { type: new GraphQLList(findCreateInput(entity.name)) }, - update: { type: new GraphQLList(findUpdateInput(entity.name)) }, - select: { type: new GraphQLList(findFilter(entity.name)) }, - remove: { type: new GraphQLList(GraphQLID) }, + fields() { + return { + create: { + type: new GraphQLList(findCreateInput(nameCreateInput(entity.name, without))), + }, + update: { type: new GraphQLList(findUpdateInput(entity.name)) }, + select: { type: new GraphQLList(findFilter(entity.name)) }, + remove: { type: new GraphQLList(GraphQLID) }, + }; }, }); return (nestedUpdateInputs[name] = input); }; - const createCreateInput = (entity: EntityOptions) => { - const name = nameCreateInput(entity.name); + const createCreateInput = (entity: EntityOptions, without?: string) => { + const name = nameCreateInput(entity.name, without); const input = new GraphQLInputObjectType({ name, fields() { @@ -438,14 +472,16 @@ export const buildGraphQLSchema = (entities: EntityOptions[]) => { for (const item of items as any[]) { let { type, target, required } = item; if (target) { - type = findNestedCreateInput(target); + type = findNestedCreateInput( + nameNestedCreate(target, autoFilledMappedBy(item)), + ); } else if (type === "Enum") { type = findEnum(item.enumType); } else { type = TypeMap[type]; } - if (required) { + if (required && item.name !== without) { type = new GraphQLNonNull(type); } @@ -472,7 +508,9 @@ export const buildGraphQLSchema = (entities: EntityOptions[]) => { for (const item of items as any[]) { let { type, target } = item; if (target) { - type = findNestedUpdateInput(target); + type = findNestedUpdateInput( + nameNestedUpdate(target, autoFilledMappedBy(item)), + ); } else if (type === "Enum") { type = findEnum(item.enumType); } else { @@ -539,6 +577,22 @@ export const buildGraphQLSchema = (entities: EntityOptions[]) => { nestedUpdateInput(entity); } + // generate "Without" input variants for relations whose required + // mappedBy field the ORM auto-fills on nested creates + for (const entity of entities) { + if (entity.abstract) continue; + for (const item of (entity.fields ?? []) as any[]) { + const without = autoFilledMappedBy(item); + if (!without) continue; + const target = entities.find((x) => x.name === item.target); + if (!target || findCreateInput(nameCreateInput(target.name, without))) + continue; + createCreateInput(target, without); + nestedCreateInput(target, without); + nestedUpdateInput(target, without); + } + } + const query = new GraphQLObjectType({ name: "Query", fields() { diff --git a/src/schema/schema-generator.test.ts b/src/schema/schema-generator.test.ts index d2286d5..7f80ea9 100644 --- a/src/schema/schema-generator.test.ts +++ b/src/schema/schema-generator.test.ts @@ -322,10 +322,10 @@ import { Entity, ManyToOne, type Relation, Column, OneToOne, JoinColumn, OneToMa import { AuditableModel } from "./AuditableModel"; import { Title } from "./Title"; import { Bio } from "./Bio"; +import { type ToMany, type Text, type Binary, type Json } from "@goovee/orm"; import { Address } from "./Address"; import { Circle } from "./Circle"; import { ContactType } from "./ContactType"; -import { type Text, type Binary, type Json } from "@goovee/orm"; @Entity("contacts") export class Contact extends AuditableModel { @@ -352,7 +352,7 @@ export class Contact extends AuditableModel { bio?: Relation | null; @OneToMany(() => Address, (x) => x.contact) - addresses?: Relation

[] | null; + addresses?: ToMany | null; @ManyToMany(() => Circle, (x) => x.contacts) @JoinTable() @@ -377,10 +377,10 @@ import { Entity, ManyToOne, type Relation, Column, OneToOne, JoinColumn, OneToMa import { AuditableModel } from "./AuditableModel"; import { Title } from "./Title"; import { Bio } from "./Bio"; +import { type ToMany, type Text, type Binary, type Json } from "@goovee/orm"; import { Address } from "./Address"; import { Circle } from "./Circle"; import { ContactType } from "./ContactType"; -import { type Text, type Binary, type Json } from "@goovee/orm"; @Entity("contacts") export class Contact extends AuditableModel { @@ -407,7 +407,7 @@ export class Contact extends AuditableModel { bio?: Relation | null; @OneToMany(() => Address, (x) => x.contact) - addresses?: Relation
[] | null; + addresses?: ToMany | null; @ManyToMany(() => Circle, (x) => x.contacts) @JoinTable({ name: "contacts_circles", joinColumn: { name: "contacts" }, inverseJoinColumn: { name: "circles" } }) diff --git a/src/schema/schema-generator.ts b/src/schema/schema-generator.ts index d747bee..96eba64 100644 --- a/src/schema/schema-generator.ts +++ b/src/schema/schema-generator.ts @@ -226,10 +226,24 @@ class RelationImportName extends ImportName { } } +class ToManyImportName extends ImportName { + private mappedBy; + constructor(name: string, module: string, mappedBy: string) { + super(name, module); + this.mappedBy = mappedBy; + } + emit(file: CodeFile): void { + const toManyType = new ImportName("type ToMany", "@goovee/orm"); + file.write(toManyType).write("<"); + super.emit(file); + file.write(`, "${this.mappedBy}">`); + } +} + class RelationalField< T extends PropertyOptions & IRelational, > extends FieldGenerator { - protected get actualType() { + protected get actualType(): ImportName { const { type, target } = this.options; const collection = type.endsWith("ToMany"); return new RelationImportName(collection, target, `./${target}`); @@ -285,6 +299,16 @@ class ManyToOneFieldGenerator extends RelationalField { } class OneToManyFieldGenerator extends RelationalField { + // Carry the mappedBy key in the type, so create types know which + // field the ORM auto-fills on nested creates. + protected get actualType() { + const { target, mappedBy } = this.options; + if (mappedBy) { + return new ToManyImportName(target, `./${target}`, mappedBy); + } + return super.actualType; + } + protected decorators(options: OneToManyProperty) { const { name, target, mappedBy } = options; if (mappedBy) { diff --git a/src/test/client-create.test.ts b/src/test/client-create.test.ts index 6acea89..456bec9 100644 --- a/src/test/client-create.test.ts +++ b/src/test/client-create.test.ts @@ -52,12 +52,10 @@ describe("client create tests", async () => { addresses: { create: [ { - contact: {}, street: "My HOME", type: AddressType.Home, }, { - contact: {}, street: "My Office", type: AddressType.Office, }, diff --git a/src/test/client-relationships.test.ts b/src/test/client-relationships.test.ts index 2d552a4..f5f8041 100644 --- a/src/test/client-relationships.test.ts +++ b/src/test/client-relationships.test.ts @@ -11,7 +11,6 @@ describe("client relationship tests", async () => { lastName: "Name", bio: { create: { - contact: {}, content: "It's Me!", }, }, diff --git a/src/test/client-update.test.ts b/src/test/client-update.test.ts index 60b5ffc..9de4021 100644 --- a/src/test/client-update.test.ts +++ b/src/test/client-update.test.ts @@ -189,7 +189,6 @@ describe("client update tests", async () => { lastName: "NAME", addresses: { create: { - contact: {}, street: "My HOME", }, }, @@ -214,7 +213,6 @@ describe("client update tests", async () => { addresses: { create: [ { - contact: {}, street: "My OFFICE", }, ], diff --git a/src/test/data-integrity.test.ts b/src/test/data-integrity.test.ts index a4f07e5..f4c0b65 100644 --- a/src/test/data-integrity.test.ts +++ b/src/test/data-integrity.test.ts @@ -207,7 +207,6 @@ describe("data integrity tests", async () => { lastName: "Doe", addresses: { create: { - contact: {}, street: "Main St", }, }, diff --git a/src/test/fixture.ts b/src/test/fixture.ts index 2aa9416..4817458 100644 --- a/src/test/fixture.ts +++ b/src/test/fixture.ts @@ -88,7 +88,6 @@ const createContact = async ( addresses: { create: [ { - contact: {}, street, city, country: { diff --git a/src/test/graphql.test.ts b/src/test/graphql.test.ts index b542b11..deecc61 100644 --- a/src/test/graphql.test.ts +++ b/src/test/graphql.test.ts @@ -89,7 +89,7 @@ describe("GraphQL tests", async () => { firstName: "Some" lastName: "NAME" title: { create: { code: "mr", name: "Mr." } } - addresses: { create: [{ contact: {}, street: "My HOME" }] } + addresses: { create: [{ street: "My HOME" }] } } ) { edges { @@ -176,7 +176,6 @@ describe("GraphQL tests", async () => { addresses: { create: [ { - contact: {}, street: "My Home", }, ], @@ -205,7 +204,7 @@ describe("GraphQL tests", async () => { lastName: "NAME" title: { select: { code: { eq: "mrs" } } } addresses: { - create: { contact: {}, street: "My Office" } + create: { street: "My Office" } remove: [ ${contact.addresses![0].id}] } } diff --git a/src/test/middleware.test.ts b/src/test/middleware.test.ts index 71f2773..8f22ad0 100644 --- a/src/test/middleware.test.ts +++ b/src/test/middleware.test.ts @@ -80,7 +80,6 @@ describe("middleware tests", async () => { addresses: { create: [ { - contact: {}, street: "My Home", }, ], From 1f170034d5bd626e95cf1522c9234afde63fbe66 Mon Sep 17 00:00:00 2001 From: kac-axelor <215158475+kac-axelor@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:56:53 +0530 Subject: [PATCH 12/13] Cover remaining aggregate scenarios in e2e tests - null group buckets for nullable columns, empty-set nulls and the "0" count, boolean and enum grouping, grouping through relations, and integer sum/avg as exact strings with min/max as numbers. --- src/test/aggregate.test.ts | 169 ++++++++++++++++++++++++++++++++++++- 1 file changed, 168 insertions(+), 1 deletion(-) diff --git a/src/test/aggregate.test.ts b/src/test/aggregate.test.ts index 5df5b1a..95cb259 100644 --- a/src/test/aggregate.test.ts +++ b/src/test/aggregate.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { getTestClient } from "./client.utils"; import { BigDecimal } from "@goovee/orm"; -import { Contact, Title, Address, Country } from "./db/models"; +import { AddressType } from "./db/models"; describe("aggregate e2e tests", async () => { const client = await getTestClient(); @@ -284,6 +284,173 @@ describe("aggregate e2e tests", async () => { expect(minMax[0].min.registeredOn).toBeInstanceOf(Date); }); + it("should include the null group as its own bucket for nullable columns", async () => { + await client.country.createAll({ + data: [ + { code: "nb1", name: "Null Bucket One" }, + { code: "nb2", name: "Null Bucket Two" }, + { code: "nb3", name: "Null Bucket Three", population: "5" }, + ], + }); + + const groups = await client.country.aggregate({ + groupBy: { population: true }, + count: { id: true }, + where: { code: { in: ["nb1", "nb2", "nb3"] } }, + }); + + const nullBucket = groups.find((g) => g.groupBy.population === null); + const fiveBucket = groups.find((g) => + g.groupBy.population?.equals(new BigDecimal("5")), + ); + + expect(nullBucket?.count.id).toBe("2"); + expect(fiveBucket?.count.id).toBe("1"); + }); + + it("should aggregate empty sets to null and count zero", async () => { + const result = await client.country.aggregate({ + count: { id: true }, + sum: { population: true }, + avg: { population: true }, + min: { population: true }, + max: { name: true }, + where: { code: { in: ["does-not-exist"] } }, + }); + + expect(result[0].count.id).toBe("0"); + expect(result[0].sum.population).toBeNull(); + expect(result[0].avg.population).toBeNull(); + expect(result[0].min.population).toBeNull(); + expect(result[0].max.name).toBeNull(); + + const groups = await client.country.aggregate({ + groupBy: { population: true }, + count: { id: true }, + where: { code: { in: ["does-not-exist"] } }, + }); + + expect(groups).toHaveLength(0); + }); + + it("should group by boolean and enum columns", async () => { + await client.country.createAll({ + data: [ + { code: "bool1", name: "Bool One", isMember: true }, + { code: "bool2", name: "Bool Two", isMember: true }, + { code: "bool3", name: "Bool Three", isMember: false }, + ], + }); + + const bools = await client.country.aggregate({ + groupBy: { isMember: true }, + count: { id: true }, + where: { code: { in: ["bool1", "bool2", "bool3"] } }, + }); + + expect(bools.find((g) => g.groupBy.isMember === true)?.count.id).toBe("2"); + expect(bools.find((g) => g.groupBy.isMember === false)?.count.id).toBe("1"); + + await client.contact.create({ + data: { + firstName: "E", + lastName: "Enum", + addresses: { + create: [ + { street: "A", type: AddressType.Office }, + { street: "B", type: AddressType.Office }, + ], + }, + }, + }); + + const enums = await client.address.aggregate({ + groupBy: { type: true }, + count: { id: true }, + where: { type: { in: [AddressType.Office] } }, + }); + + expect(enums[0].groupBy.type).toBe(AddressType.Office); + }); + + it("should group by columns reached through relations", async () => { + const title = await client.title.create({ + data: { code: "rw", name: "RW Title" }, + }); + await client.contact.createAll({ + data: [ + { + firstName: "R1", + lastName: "RelWalk", + title: { select: { id: title.id } }, + }, + { + firstName: "R2", + lastName: "RelWalk", + title: { select: { id: title.id } }, + }, + ], + }); + + const groups = await client.contact.aggregate({ + groupBy: { title: { name: true } }, + count: { id: true }, + where: { lastName: { in: ["RelWalk"] } }, + }); + + expect(groups[0].groupBy.title.name).toBe("RW Title"); + expect(groups[0].count.id).toBe("2"); + }); + + it("should sum and average integer columns as exact strings", async () => { + await client.country.createAll({ + data: [ + { code: "int1", name: "Int One", rank: 1 }, + { code: "int2", name: "Int Two", rank: 2 }, + ], + }); + + const result = await client.country.aggregate({ + sum: { rank: true }, + avg: { rank: true }, + min: { rank: true }, + max: { rank: true }, + where: { code: { in: ["int1", "int2"] } }, + }); + + expect(result[0].sum.rank).toBe("3"); + expect(Number(result[0].avg.rank)).toBe(1.5); + expect(result[0].min.rank).toBe(1); + expect(result[0].max.rank).toBe(2); + + const groups = await client.country.aggregate({ + groupBy: { rank: true }, + count: { id: true }, + where: { code: { in: ["int1", "int2"] } }, + }); + const keys = groups.map((g) => g.groupBy.rank).sort(); + + expect(keys).toEqual([1, 2]); + }); + + it("should min/max time columns as strings", async () => { + await client.contact.createAll({ + data: [ + { firstName: "TM1", lastName: "TimeMinMax", timeOfBirth: "09:15:00" }, + { firstName: "TM2", lastName: "TimeMinMax", timeOfBirth: "14:30:45" }, + ], + }); + + const result = await client.contact.aggregate({ + min: { timeOfBirth: true }, + max: { timeOfBirth: true }, + where: { lastName: { in: ["TimeMinMax"] } }, + }); + + expect(result[0].min.timeOfBirth).toBe("09:15:00"); + expect(result[0].max.timeOfBirth).toBe("14:30:45"); + }); + it("should perform min and max aggregations", async () => { await setupTestData(client); From 6cc1e004397a8403fc374fd0d5190b6a6301d861 Mon Sep 17 00:00:00 2001 From: kac-axelor <215158475+kac-axelor@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:12:45 +0530 Subject: [PATCH 13/13] Document the aggregate result type contract in the README - count/sum/avg arrive as the database's exact decimal strings; converting to the right precision (Number, BigDecimal, BigInt) is the caller's choice. - min/max/groupBy keep the column's model type, including BigDecimal for Decimal columns, with nulls for empty sets and the null group bucket. --- README.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/README.md b/README.md index 9f98678..e241174 100644 --- a/README.md +++ b/README.md @@ -674,6 +674,59 @@ const highValueCountries = await client.order.aggregate({ }); ``` +#### Aggregate result types + +**`count`, `sum` and `avg` are returned as exact decimal strings** — for +example `"42"` or `"1234.56"`. The value is always exact, no matter how large +or precise; convert it to whatever your use case needs: + +```javascript +const stats = await client.order.aggregate({ + count: { id: true }, + sum: { total: true }, // total is a Decimal field + avg: { total: true }, +}); + +const row = stats[0]; +row.count.id; //=> "42" — never null (no matching rows → "0") +row.sum.total; //=> "1234.56" — null when there are no matching rows +row.avg.total; //=> "411.52" — null when there are no matching rows + +Number(row.sum.total); // display, charts, simple math +new BigDecimal(row.sum.total); // exact arithmetic, e.g. monetary values +BigInt(row.count.id); // very large counts +``` + +**`min`, `max` and `groupBy` return the field's own type** — the same value a +`find` on that field would give you: + +| Field type | `min`/`max`/`groupBy` value | +| ----------- | --------------------------- | +| String/Enum | `string` / enum member | +| Int | `number` | +| Decimal | `BigDecimal` | +| Date / Time | date / time `string` | +| DateTime | `Date` | +| BigInt (id) | `string` | + +`min` and `max` are `null` when no rows match (or the field is null in all of +them). `groupBy` values of nullable fields include `null` — records without a +value form their own group: + +```javascript +const byPopulation = await client.country.aggregate({ + groupBy: { population: true }, // Decimal field + count: { id: true }, +}); + +for (const group of byPopulation) { + // group.groupBy.population is BigDecimal | null — the null group + // collects the countries with no population set + const label = group.groupBy.population?.toString() ?? "unknown"; + console.log(label, group.count.id); +} +``` + ### Transactions ```javascript