Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -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
53 changes: 53 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
52 changes: 33 additions & 19 deletions src/client/repository/query/aggregate.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { DateUtils } from "typeorm/util/DateUtils.js";
import { BigDecimal } from "../../fields/decimal";
import { ParseResult } from "../../parser";
import { OrmRepository } from "../types";

Expand Down Expand Up @@ -66,40 +68,52 @@ 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(".");
const operation = pathParts[0]; // count, avg, sum, min, max, groupBy

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
// 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;
}
Expand Down
8 changes: 4 additions & 4 deletions src/client/repository/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ export class EntityRepository<T extends Entity> implements Repository<T> {
}

@intercept()
async count(args?: QueryOptions<T>): Promise<ID> {
async count(args?: QueryOptions<T>): Promise<number> {
const client = this.#client;
const repo = this.#repo;
const opts = parseQuery(client, repo, args ?? {});
Expand Down Expand Up @@ -515,7 +515,7 @@ export class EntityRepository<T extends Entity> implements Repository<T> {
}

@intercept()
async delete(args: DeleteOptions<T>): Promise<ID> {
async delete(args: DeleteOptions<T>): Promise<number> {
const repo: any = this.#repo;
const { id, version } = args;

Expand Down Expand Up @@ -570,7 +570,7 @@ export class EntityRepository<T extends Entity> implements Repository<T> {
}

@intercept()
async updateAll(args: BulkUpdateOptions<T>): Promise<ID> {
async updateAll(args: BulkUpdateOptions<T>): Promise<number> {
const client = this.#client;
const repo = this.#repo;
const meta = repo.metadata;
Expand Down Expand Up @@ -603,7 +603,7 @@ export class EntityRepository<T extends Entity> implements Repository<T> {
}

@intercept()
async deleteAll(args?: BulkDeleteOptions<T>): Promise<ID> {
async deleteAll(args?: BulkDeleteOptions<T>): Promise<number> {
const client = this.#client;
const repo = this.#repo;
const { where } = args ?? {};
Expand Down
28 changes: 18 additions & 10 deletions src/client/types/aggregate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,26 +167,34 @@ export type OrderByFromSelection<S> = {
: 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<T, Op extends AggregateOperation> = Op extends "count"
? number
? string
: Op extends "avg" | "sum"
? number
? 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;

// groupBy passes column nulls through — the null group is a real bucket
type GroupByNull<V, Op extends AggregateOperation> = Op extends "groupBy"
? null extends V
? null
: never
: never;

export type AggregateValue<T, S, Op extends AggregateOperation = "groupBy"> = {
[K in keyof S]: S[K] extends true
? K extends keyof T
? AggregateResultType<NonNullable<T[K]>, Op>
? AggregateResultType<NonNullable<T[K]>, Op> | GroupByNull<T[K], Op>
: never
: S[K] extends object
? K extends keyof T
Expand Down
22 changes: 18 additions & 4 deletions src/client/types/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ export type JsonObject = {
[key: string]: JsonValue;
};

// Async data types
export type Json = Promise<JsonObject>;
export type Text = Promise<string>;
export type Binary = Promise<Buffer>;
// Async data types; the lazy loader resolves the column value or null
export type Json = Promise<JsonObject | null>;
export type Text = Promise<string | null>;
export type Binary = Promise<Buffer | null>;

// ============================================================================
// Entity Types
Expand All @@ -34,6 +34,20 @@ export type Entity = {

export type EntityClass<T extends Entity = Entity> = 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 extends Entity, MappedBy = never> = T[] & {
readonly [MAPPED_BY]?: MappedBy;
};

// Identity types
export type InputIdentity<T extends Entity> = {
id: ID;
Expand Down
37 changes: 30 additions & 7 deletions src/client/types/crud.ts
Original file line number Diff line number Diff line change
@@ -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, P extends Entity> =
T extends ToMany<P, infer K> ? (K extends keyof P ? K : never) : never;

// CreateArgs<T> 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<T> directly and keep requiring
// it — there is no owner to fill it from.
type NestedCreateArgs<T extends Entity, MappedBy extends keyof T> = Omit<
CreateArgs<T>,
MappedBy
> & {
[K in MappedBy]?: CreateArg<T[K]>;
};

interface NestedCreateArg<T extends Entity> {
select?: WhereOptions<T>;
create?: CreateArgs<T>;
}

interface NestedCreateManyArg<T extends Entity> {
interface NestedCreateManyArg<
T extends Entity,
MappedBy extends keyof T = never,
> {
select?: WhereOptions<T> | WhereOptions<T>[];
create?: CreateArgs<T> | CreateArgs<T>[];
create?: NestedCreateArgs<T, MappedBy> | NestedCreateArgs<T, MappedBy>[];
}

type AllowNull<T> = T extends undefined ? T | null : T;
Expand All @@ -25,7 +45,7 @@ type LeafArg<T> = T extends BigDecimal
export type CreateArg<T> =
T extends Array<infer P>
? P extends Entity
? NestedCreateManyArg<P>
? NestedCreateManyArg<P, MappedByKey<T, P>>
: T
: T extends Entity
? NestedCreateArg<T>
Expand All @@ -46,17 +66,20 @@ type NestedUpdateArg<T extends Entity> = {
update?: UpdateArgs<T>;
};

type NestedUpdateManyArg<T extends Entity> = {
type NestedUpdateManyArg<
T extends Entity,
MappedBy extends keyof T = never,
> = {
select?: WhereOptions<T> | WhereOptions<T>[];
create?: CreateArgs<T> | CreateArgs<T>[];
create?: NestedCreateArgs<T, MappedBy> | NestedCreateArgs<T, MappedBy>[];
update?: UpdateArgs<T> | UpdateArgs<T>[];
remove?: ID | ID[];
};

export type UpdateArg<T> =
T extends Array<infer P>
? P extends Entity
? NestedUpdateManyArg<P>
? NestedUpdateManyArg<P, MappedByKey<T, P>>
: T
: T extends Entity
? NestedUpdateArg<T>
Expand Down
30 changes: 25 additions & 5 deletions src/client/types/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,13 @@ export type SelectKeys<T, S> = Pick<

type PayloadSelect<T extends Entity, Select> = ResultIdentity<T> & {
[K in keyof SelectKeys<T, Select>]-?: K extends keyof Select & keyof T
? undefined extends T[K]
? PayloadSelectArg<NonNullable<T[K]>, Select[K]> | null
: PayloadSelectArg<T[K], Select[K]>
? NonNullable<T[K]> extends Array<any>
? PayloadSelectArg<NonNullable<T[K]>, Select[K]>
: NonNullable<T[K]> extends Promise<any>
? NonNullable<T[K]>
: undefined extends T[K]
? PayloadSelectArg<NonNullable<T[K]>, Select[K]> | null
: PayloadSelectArg<T[K], Select[K]>
: never;
};

Expand All @@ -154,8 +158,24 @@ type PayloadSelectArg<T, Arg> = 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<T extends Entity> = ResultIdentity<T> &
OmitByType<T, Json | Text | Binary | Entity | Entity[] | undefined>;
Pick<
T,
{
[K in keyof T]-?: NonNullable<T[K]> extends
| Json
| Text
| Binary
| Entity
| Entity[]
? never
: K;
}[keyof T]
>;

export type PayloadArg<
Type extends Entity,
Expand All @@ -174,7 +194,7 @@ export type Payload<
Query,
Empty = ResultIdentity<Type>,
> = PayloadArg<Type, Query, Empty> & {
_count?: string;
_count?: number;
_cursor?: string;
_hasNext?: boolean;
_hasPrev?: boolean;
Expand Down
Loading