@ultimat3/entity 2.0.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +82 -2
- package/README.md +76 -5
- package/package.json +5 -4
- package/src/coalesce.ts +30 -16
- package/src/column.ts +67 -2
- package/src/columns-data.ts +205 -0
- package/src/columns.ts +47 -5
- package/src/describe.ts +53 -16
- package/src/entity.ts +25 -9
- package/src/index.ts +23 -3
- package/src/pg-driver.ts +2 -3
- package/src/pg-row.ts +63 -17
- package/src/pg-sql.ts +40 -6
- package/src/seed.ts +288 -19
- package/src/types.ts +51 -1
package/src/columns.ts
CHANGED
|
@@ -10,11 +10,20 @@ import {
|
|
|
10
10
|
isMoneyScale,
|
|
11
11
|
MAX_MONEY_SCALE,
|
|
12
12
|
} from '@ultimat3/schema';
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
assertColumnName,
|
|
15
|
+
BARE,
|
|
16
|
+
column,
|
|
17
|
+
GENERATED_UUID,
|
|
18
|
+
makeColumn,
|
|
19
|
+
makeTimestamp,
|
|
20
|
+
} from './column';
|
|
14
21
|
import { invariantViolated } from './errors';
|
|
15
22
|
import type {
|
|
16
23
|
Column,
|
|
17
24
|
ColumnMap,
|
|
25
|
+
ColumnMeta,
|
|
26
|
+
MoneyColumnNames,
|
|
18
27
|
MoneyInput,
|
|
19
28
|
MoneyValue,
|
|
20
29
|
TimestampColumn,
|
|
@@ -64,16 +73,22 @@ const parseBrandedUuid = <T extends string>(value: unknown): T => parseUuid(valu
|
|
|
64
73
|
* derivation — row, insert, `findById`, `update`, `delete` — so mixing two entities' ids is a
|
|
65
74
|
* compile error instead of a query that silently matches nothing.
|
|
66
75
|
*/
|
|
67
|
-
export const uuid = <T extends string = string>(): UuidColumn<T> =>
|
|
68
|
-
|
|
76
|
+
export const uuid = <T extends string = string>(): UuidColumn<T> =>
|
|
77
|
+
uuidWith({ ...BARE, kind: 'uuid' });
|
|
78
|
+
|
|
79
|
+
const uuidWith = <T extends string>(meta: ColumnMeta): UuidColumn<T> => ({
|
|
80
|
+
...makeColumn<T, false>(meta, parseBrandedUuid, false),
|
|
69
81
|
// Narrower than the generic chain: a uuid key is generated when omitted, so it is the one
|
|
70
82
|
// primary key an insert may leave out.
|
|
71
83
|
primaryKey: () =>
|
|
72
84
|
makeColumn<T, true>(
|
|
73
|
-
{ ...
|
|
85
|
+
{ ...meta, primaryKey: true, default: GENERATED_UUID },
|
|
74
86
|
parseBrandedUuid,
|
|
75
87
|
true,
|
|
76
88
|
),
|
|
89
|
+
// Overridden for the same reason `timestamp()`'s is: `uuid().column('user_id').primaryKey()`
|
|
90
|
+
// must still be the KEY form, which is optional on insert, rather than the general one.
|
|
91
|
+
column: (name) => uuidWith<T>({ ...meta, name: assertColumnName(name) }),
|
|
77
92
|
});
|
|
78
93
|
|
|
79
94
|
export interface TextOptions {
|
|
@@ -294,7 +309,34 @@ const parseMoney = (value: unknown): MoneyValue => {
|
|
|
294
309
|
* silent 10,000x reinterpretation of a value the type system, `t.money` and `@ultimat3/money` all
|
|
295
310
|
* carry. `null` in the column is "no explicit scale" and decodes to an ABSENT key, never to `0`.
|
|
296
311
|
*/
|
|
297
|
-
export
|
|
312
|
+
export interface MoneyOptions {
|
|
313
|
+
/**
|
|
314
|
+
* Where the three columns already live, for a table this framework did not create. Named per
|
|
315
|
+
* part and merged over `<name>_minor` / `<name>_currency` / `<name>_scale`, so a legacy
|
|
316
|
+
* `amount_cents` beside a `currency` is two words rather than three.
|
|
317
|
+
*
|
|
318
|
+
* `scale: null` says the table has no scale column at all. What that costs is stated where the
|
|
319
|
+
* option is declared (`MoneyColumnNames`): every amount is then at the currency's own minor
|
|
320
|
+
* unit. What it does NOT cost is correctness — an absent scale already means exactly that.
|
|
321
|
+
*/
|
|
322
|
+
readonly columns?: MoneyColumnNames;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export const money = (options: MoneyOptions = {}): Column<MoneyValue> =>
|
|
326
|
+
column<MoneyValue>(
|
|
327
|
+
'money',
|
|
328
|
+
parseMoney,
|
|
329
|
+
options.columns === undefined ? {} : { parts: checkedParts(options.columns) },
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
/** Every part named goes through the same identifier rule a `.column()` does. */
|
|
333
|
+
const checkedParts = (columns: MoneyColumnNames): MoneyColumnNames => ({
|
|
334
|
+
...(columns.minor === undefined ? {} : { minor: assertColumnName(columns.minor) }),
|
|
335
|
+
...(columns.currency === undefined ? {} : { currency: assertColumnName(columns.currency) }),
|
|
336
|
+
...(columns.scale === undefined || columns.scale === null
|
|
337
|
+
? { ...(columns.scale === null ? { scale: null } : {}) }
|
|
338
|
+
: { scale: assertColumnName(columns.scale) }),
|
|
339
|
+
});
|
|
298
340
|
|
|
299
341
|
/**
|
|
300
342
|
* Money is the one column whose write type is wider than its row type — `MoneyInput` takes a
|
package/src/describe.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// Money is the one place the projection is not one-to-one: one property becomes the two
|
|
6
6
|
// physical columns that back it.
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { columnName, moneyColumns, referenceBinding } from './column';
|
|
9
9
|
import { currencyCheck, scaleCheck } from './columns';
|
|
10
10
|
import type { Invariant } from './invariants';
|
|
11
11
|
import type { ColumnDescription, EntityDescription, ReferenceDescription } from './registry';
|
|
@@ -13,6 +13,8 @@ import type { AnyColumn, ColumnMeta, IndexDef } from './types';
|
|
|
13
13
|
|
|
14
14
|
export interface DescribeInput<Row> {
|
|
15
15
|
readonly name: string;
|
|
16
|
+
/** The physical table. The entity's own name unless `entity(name, { table })` said otherwise. */
|
|
17
|
+
readonly table: string;
|
|
16
18
|
readonly columns: readonly (readonly [string, AnyColumn])[];
|
|
17
19
|
readonly primaryKey: readonly string[];
|
|
18
20
|
readonly invariants: readonly Invariant<Row>[];
|
|
@@ -41,7 +43,7 @@ export const describeReferences = (
|
|
|
41
43
|
return [
|
|
42
44
|
{
|
|
43
45
|
property,
|
|
44
|
-
column:
|
|
46
|
+
column: columnName(property, meta),
|
|
45
47
|
nullable: !meta.notNull,
|
|
46
48
|
targetEntity: target.table,
|
|
47
49
|
targetProperty: target.property,
|
|
@@ -50,15 +52,38 @@ export const describeReferences = (
|
|
|
50
52
|
];
|
|
51
53
|
});
|
|
52
54
|
|
|
55
|
+
/**
|
|
56
|
+
* The Postgres type a column becomes. `kind` is what the migration generator reads and its table
|
|
57
|
+
* falls through to the kind itself for anything it does not name (`SQL_TYPES[kind] ?? kind`), so a
|
|
58
|
+
* precise type belongs HERE, where the precision, the element and the length are still in scope —
|
|
59
|
+
* the alternative is a second copy of the column vocabulary inside `@ultimat3/db`.
|
|
60
|
+
*/
|
|
61
|
+
export const sqlTypeOf = (meta: ColumnMeta): string => {
|
|
62
|
+
if (meta.kind === 'numeric') {
|
|
63
|
+
return meta.precision === undefined || meta.numericScale === undefined
|
|
64
|
+
? 'numeric'
|
|
65
|
+
: `numeric(${meta.precision}, ${meta.numericScale})`;
|
|
66
|
+
}
|
|
67
|
+
if (meta.kind === 'array') {
|
|
68
|
+
const element = meta.element?.$meta;
|
|
69
|
+
// `arrayOf` refuses an element that is not one scalar column, so this is total in practice;
|
|
70
|
+
// `text[]` is the answer that keeps a description renderable rather than throwing inside a
|
|
71
|
+
// projection, which is the one place an error has no caller to instruct.
|
|
72
|
+
return `${element === undefined ? 'text' : sqlTypeOf(element)}[]`;
|
|
73
|
+
}
|
|
74
|
+
return meta.kind;
|
|
75
|
+
};
|
|
76
|
+
|
|
53
77
|
const describeColumn = <Row>(
|
|
54
78
|
input: DescribeInput<Row>,
|
|
55
79
|
property: string,
|
|
56
80
|
meta: ColumnMeta,
|
|
57
81
|
reference: ReferenceDescription | undefined,
|
|
58
82
|
): readonly ColumnDescription[] => {
|
|
59
|
-
const physical =
|
|
83
|
+
const physical = columnName(property, meta);
|
|
60
84
|
if (meta.kind === 'money') {
|
|
61
|
-
const
|
|
85
|
+
const parts = moneyColumns(property, meta);
|
|
86
|
+
const currency = parts.currency;
|
|
62
87
|
const shared = {
|
|
63
88
|
notNull: meta.notNull,
|
|
64
89
|
primaryKey: false,
|
|
@@ -69,7 +94,7 @@ const describeColumn = <Row>(
|
|
|
69
94
|
return [
|
|
70
95
|
{
|
|
71
96
|
property: `${property}Minor`,
|
|
72
|
-
column:
|
|
97
|
+
column: parts.minor,
|
|
73
98
|
kind: 'bigint',
|
|
74
99
|
check: null,
|
|
75
100
|
...shared,
|
|
@@ -85,21 +110,29 @@ const describeColumn = <Row>(
|
|
|
85
110
|
// currency's own minor unit", which is every amount written before the column existed and
|
|
86
111
|
// every ordinary price after it. A NOT NULL here would demand a scale on values that have
|
|
87
112
|
// none, and `0` is not that value — it means whole units.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
113
|
+
//
|
|
114
|
+
// Absent entirely when the table has none: an adopted amount column predating scale is two
|
|
115
|
+
// physical columns, and describing a third would put a column in the DDL and in every
|
|
116
|
+
// statement that the table does not have.
|
|
117
|
+
...(parts.scale === null
|
|
118
|
+
? []
|
|
119
|
+
: [
|
|
120
|
+
{
|
|
121
|
+
property: `${property}Scale`,
|
|
122
|
+
column: parts.scale,
|
|
123
|
+
kind: 'integer',
|
|
124
|
+
check: scaleCheck(parts.scale),
|
|
125
|
+
...shared,
|
|
126
|
+
notNull: false,
|
|
127
|
+
},
|
|
128
|
+
]),
|
|
96
129
|
];
|
|
97
130
|
}
|
|
98
131
|
return [
|
|
99
132
|
{
|
|
100
133
|
property,
|
|
101
134
|
column: physical,
|
|
102
|
-
kind: meta
|
|
135
|
+
kind: sqlTypeOf(meta),
|
|
103
136
|
notNull: meta.notNull,
|
|
104
137
|
primaryKey: meta.primaryKey || input.primaryKey.includes(property),
|
|
105
138
|
unique: meta.unique,
|
|
@@ -114,6 +147,10 @@ const describeColumn = <Row>(
|
|
|
114
147
|
};
|
|
115
148
|
|
|
116
149
|
export const describeEntity = <Row>(input: DescribeInput<Row>): EntityDescription => {
|
|
150
|
+
const physicalOf = (property: string): string => {
|
|
151
|
+
const column = input.columns.find(([key]) => key === property)?.[1];
|
|
152
|
+
return column === undefined ? property : columnName(property, column.$meta);
|
|
153
|
+
};
|
|
117
154
|
const references = new Map(
|
|
118
155
|
describeReferences(input.name, input.columns).map((reference) => [
|
|
119
156
|
reference.property,
|
|
@@ -122,8 +159,8 @@ export const describeEntity = <Row>(input: DescribeInput<Row>): EntityDescriptio
|
|
|
122
159
|
);
|
|
123
160
|
return {
|
|
124
161
|
name: input.name,
|
|
125
|
-
table: input.
|
|
126
|
-
primaryKey: input.primaryKey.map(
|
|
162
|
+
table: input.table,
|
|
163
|
+
primaryKey: input.primaryKey.map(physicalOf),
|
|
127
164
|
columns: input.columns.flatMap(([property, column]) =>
|
|
128
165
|
describeColumn(input, property, column.$meta, references.get(property)),
|
|
129
166
|
),
|
package/src/entity.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { systemClock } from '@ultimat3/core';
|
|
7
7
|
import { describeValue, type StandardSchemaV1 } from '@ultimat3/schema';
|
|
8
|
-
import { bindColumn,
|
|
8
|
+
import { assertColumnName, bindColumn, columnName, moneyColumns } from './column';
|
|
9
9
|
import { newId } from './columns';
|
|
10
10
|
import { describeEntity, describeReferences } from './describe';
|
|
11
11
|
import { invariantViolated } from './errors';
|
|
@@ -33,6 +33,15 @@ export interface IndexInit<C extends ColumnMap> {
|
|
|
33
33
|
|
|
34
34
|
export interface EntityInit<C extends ColumnMap> {
|
|
35
35
|
readonly columns: C;
|
|
36
|
+
/**
|
|
37
|
+
* The physical table, when it is not the entity's own name. The half of adoption a column name
|
|
38
|
+
* cannot cover: `entity('user', { table: 'users', … })` reads and writes the table that is
|
|
39
|
+
* already there, and every statement, index name and foreign key follows it.
|
|
40
|
+
*
|
|
41
|
+
* The entity NAME stays the framework's key — the registry, the cache tag, `x entities describe`
|
|
42
|
+
* and every relation are keyed by it — so renaming a table never moves a cache tag or a policy.
|
|
43
|
+
*/
|
|
44
|
+
readonly table?: string;
|
|
36
45
|
/**
|
|
37
46
|
* The tenant column, said out loud. Omitted, it is inferred from `.tenant()` or a column named
|
|
38
47
|
* `orgId`, so silence never means unscoped.
|
|
@@ -117,6 +126,7 @@ export const entity = <const C extends ColumnMap>(
|
|
|
117
126
|
const entries: readonly (readonly [string, AnyColumn])[] = Object.entries(init.columns);
|
|
118
127
|
for (const [property, column] of entries) bindColumn(column, name, property);
|
|
119
128
|
|
|
129
|
+
const table = init.table === undefined ? name : assertColumnName(init.table);
|
|
120
130
|
const cacheTag = `entity:${name}`;
|
|
121
131
|
const softDelete = Object.hasOwn(init.columns, SOFT_DELETE_COLUMN);
|
|
122
132
|
const tenantColumn = resolveTenantColumn(name, init.columns, init.tenant);
|
|
@@ -147,19 +157,22 @@ export const entity = <const C extends ColumnMap>(
|
|
|
147
157
|
`${property} is money: name ${property}.minor or ${property}.currency`,
|
|
148
158
|
);
|
|
149
159
|
}
|
|
150
|
-
return
|
|
160
|
+
return columnName(property, column.$meta);
|
|
151
161
|
}
|
|
152
162
|
if (!isMoney || !MONEY_PARTS.has(part)) {
|
|
153
163
|
throw invariantViolated(name, property, `${property} has no part "${part}"`);
|
|
154
164
|
}
|
|
155
|
-
|
|
165
|
+
const parts = moneyColumns(property, column.$meta);
|
|
166
|
+
return part === 'minor' ? parts.minor : parts.currency;
|
|
156
167
|
};
|
|
157
168
|
|
|
158
169
|
const columnsExpr = invariantColumns<C>(
|
|
159
170
|
name,
|
|
160
171
|
entries.map(([property]) => property),
|
|
161
172
|
);
|
|
162
|
-
|
|
173
|
+
// Through the one resolver, so a soft-delete column the table spells differently is still the
|
|
174
|
+
// column every partial index excludes rows by.
|
|
175
|
+
const partialWhere = softDelete ? `${resolve([SOFT_DELETE_COLUMN])} is null` : undefined;
|
|
163
176
|
// Called once, here, so an unknown column throws while the entity is being declared.
|
|
164
177
|
const invariants: readonly Invariant<Row>[] = (init.invariants?.(columnsExpr) ?? []).map((def) =>
|
|
165
178
|
bindInvariant<Row>(def, resolve, partialWhere),
|
|
@@ -169,9 +182,11 @@ export const entity = <const C extends ColumnMap>(
|
|
|
169
182
|
...entries.flatMap(([property, column]) => {
|
|
170
183
|
const meta = column.$meta;
|
|
171
184
|
if (!meta.unique && !meta.index) return [];
|
|
172
|
-
const physical = [
|
|
185
|
+
const physical = [
|
|
186
|
+
meta.kind === 'money' ? moneyColumns(property, meta).minor : columnName(property, meta),
|
|
187
|
+
];
|
|
173
188
|
return [
|
|
174
|
-
{ name: indexName(
|
|
189
|
+
{ name: indexName(table, physical, meta.unique), columns: physical, unique: meta.unique },
|
|
175
190
|
];
|
|
176
191
|
}),
|
|
177
192
|
...(init.indexes ?? []).map((index) => {
|
|
@@ -191,7 +206,7 @@ export const entity = <const C extends ColumnMap>(
|
|
|
191
206
|
);
|
|
192
207
|
}
|
|
193
208
|
return {
|
|
194
|
-
name: indexName(
|
|
209
|
+
name: indexName(table, columns, unique),
|
|
195
210
|
columns,
|
|
196
211
|
unique,
|
|
197
212
|
...(index.order === undefined ? {} : { order: index.order }),
|
|
@@ -208,6 +223,7 @@ export const entity = <const C extends ColumnMap>(
|
|
|
208
223
|
const describe = (): EntityDescription =>
|
|
209
224
|
describeEntity({
|
|
210
225
|
name,
|
|
226
|
+
table,
|
|
211
227
|
columns: entries,
|
|
212
228
|
primaryKey,
|
|
213
229
|
invariants,
|
|
@@ -251,7 +267,7 @@ export const entity = <const C extends ColumnMap>(
|
|
|
251
267
|
|
|
252
268
|
const core: EntityCore<Row, C> = {
|
|
253
269
|
$name: name,
|
|
254
|
-
$table:
|
|
270
|
+
$table: table,
|
|
255
271
|
$columns: init.columns,
|
|
256
272
|
$primaryKey: primaryKey,
|
|
257
273
|
$indexes: indexes,
|
|
@@ -289,7 +305,7 @@ export const entity = <const C extends ColumnMap>(
|
|
|
289
305
|
$references: references,
|
|
290
306
|
};
|
|
291
307
|
|
|
292
|
-
registerEntity({ name, tableName:
|
|
308
|
+
registerEntity({ name, tableName: table, describe, references });
|
|
293
309
|
// The columns land on the entity itself so `orgs.id` is a column reference; every framework
|
|
294
310
|
// member is `$`-prefixed, which is why a column may be called `name`.
|
|
295
311
|
return Object.assign(core, init.columns);
|
package/src/index.ts
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
export type { Infer } from '@ultimat3/schema';
|
|
5
5
|
export { t } from '@ultimat3/schema';
|
|
6
6
|
export type { BatchIterator } from './batch';
|
|
7
|
-
export type {
|
|
7
|
+
export type { MoneyColumns } from './column';
|
|
8
|
+
export { columnName, moneyColumns, snake } from './column';
|
|
9
|
+
export type { MoneyOptions, TextOptions } from './columns';
|
|
8
10
|
export {
|
|
9
11
|
boolean,
|
|
10
12
|
enumerated,
|
|
@@ -18,11 +20,18 @@ export {
|
|
|
18
20
|
url,
|
|
19
21
|
uuid,
|
|
20
22
|
} from './columns';
|
|
23
|
+
// The vocabulary an EXISTING schema needs. Separate from the blessed builders on purpose: those
|
|
24
|
+
// are decisions this framework made for a table it was going to create, and these are the shapes
|
|
25
|
+
// a table already has (`docs`: Entities-And-Migrations, "Adopting an existing database").
|
|
26
|
+
export type { DecimalOptions } from './columns-data';
|
|
27
|
+
export { arrayOf, bigint, bytes, date, decimal, json } from './columns-data';
|
|
21
28
|
// `crossTenantReason` stays internal: an app that could read the flag would have a second way to
|
|
22
29
|
// reason about tenant scope — branch on it — next to the one way, which is entering the scope.
|
|
23
30
|
export { CROSS_TENANT_SCOPE, crossTenant } from './cross-tenant';
|
|
24
31
|
export type { Database, DatabaseOptions, Driver, EntitySet } from './database';
|
|
25
32
|
export { database, defaultDriver, memoryDriver } from './database';
|
|
33
|
+
export type { DescribeInput } from './describe';
|
|
34
|
+
export { sqlTypeOf } from './describe';
|
|
26
35
|
export type { Entity, EntityCore, EntityInit, IndexInit } from './entity';
|
|
27
36
|
export { entity, SOFT_DELETE_COLUMN } from './entity';
|
|
28
37
|
export type {
|
|
@@ -102,8 +111,18 @@ export type {
|
|
|
102
111
|
UpsertArgs,
|
|
103
112
|
} from './repo';
|
|
104
113
|
export { memoryRepo, memoryTransactor } from './repo';
|
|
105
|
-
export type {
|
|
106
|
-
|
|
114
|
+
export type {
|
|
115
|
+
Seed,
|
|
116
|
+
SeedContext,
|
|
117
|
+
SeedInit,
|
|
118
|
+
SeedKey,
|
|
119
|
+
SeedMetrics,
|
|
120
|
+
SeedOptions,
|
|
121
|
+
SeedRun,
|
|
122
|
+
SeedTier,
|
|
123
|
+
SeedWrite,
|
|
124
|
+
} from './seed';
|
|
125
|
+
export { defineSeed, isSeed, SEED_TIERS, seedId, seedTiersFor } from './seed';
|
|
107
126
|
export type { Operator, Predicate, QueryPlan, SortDirection, SortKey } from './tenancy';
|
|
108
127
|
export {
|
|
109
128
|
assertRowTenant,
|
|
@@ -127,6 +146,7 @@ export type {
|
|
|
127
146
|
IdOf,
|
|
128
147
|
IndexDef,
|
|
129
148
|
Insertable,
|
|
149
|
+
MoneyColumnNames,
|
|
130
150
|
MoneyInput,
|
|
131
151
|
MoneyValue,
|
|
132
152
|
OnDelete,
|
package/src/pg-driver.ts
CHANGED
|
@@ -25,7 +25,6 @@ import {
|
|
|
25
25
|
upsertPlan,
|
|
26
26
|
} from './bulk-write';
|
|
27
27
|
import { coalesceFindById } from './coalesce';
|
|
28
|
-
import { snake } from './column';
|
|
29
28
|
import { countsFrom, groupColumnOf, groupValue, MAX_GROUPS } from './count-by';
|
|
30
29
|
import { cursorFor, seekFrom, valueAt } from './cursor';
|
|
31
30
|
import type { Driver } from './database';
|
|
@@ -33,7 +32,7 @@ import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
|
|
|
33
32
|
import { notFound, repoClientPinned } from './errors';
|
|
34
33
|
import { assertedRowsTooMany, hasJsOnlyInvariant, MAX_ASSERTED_ROWS } from './invariants';
|
|
35
34
|
import { forgetPreloaded, tagSiblings } from './jit-preload';
|
|
36
|
-
import { bindValues, decodeRow, type PhysicalRow } from './pg-row';
|
|
35
|
+
import { bindValues, decodeRow, type PhysicalRow, physicalName } from './pg-row';
|
|
37
36
|
import {
|
|
38
37
|
type ConflictTarget,
|
|
39
38
|
countByStatement,
|
|
@@ -133,7 +132,7 @@ export const postgresRepo = <Row>(
|
|
|
133
132
|
? updateStatement(
|
|
134
133
|
entity,
|
|
135
134
|
plan,
|
|
136
|
-
new Map([[
|
|
135
|
+
new Map([[physicalName(entity, SOFT_DELETE_COLUMN), systemClock.now()]]),
|
|
137
136
|
shapeOf({}),
|
|
138
137
|
false,
|
|
139
138
|
)
|
package/src/pg-row.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// from the driver is re-parsed by the column that declared it rather than trusted — int8 arrives
|
|
5
5
|
// as a string, timestamptz may arrive as one, and a silent `NaN` is worse than a loud throw.
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { columnName, moneyColumns } from './column';
|
|
8
8
|
import { narrowMoney } from './columns';
|
|
9
9
|
import type { EntityCore } from './entity';
|
|
10
10
|
import { invariantViolated } from './errors';
|
|
@@ -23,10 +23,15 @@ const MONEY_PARTS = new Set(['minor', 'currency']);
|
|
|
23
23
|
* addressable as a predicate or a sort key (`MONEY_PARTS` below, and `cursor.ts`'s copy) — a scale
|
|
24
24
|
* says which units `minor` counts, so ordering or filtering by it compares two different questions.
|
|
25
25
|
*/
|
|
26
|
-
export const columnsOf = (property: string, column: AnyColumn): readonly string[] =>
|
|
27
|
-
column.$meta.kind
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
export const columnsOf = (property: string, column: AnyColumn): readonly string[] => {
|
|
27
|
+
if (column.$meta.kind !== 'money') return [columnName(property, column.$meta)];
|
|
28
|
+
const parts = moneyColumns(property, column.$meta);
|
|
29
|
+
// Two columns for an adopted amount that has no scale column: the list IS the projection, so a
|
|
30
|
+
// name here that the table does not have is a `42703` on the first select.
|
|
31
|
+
return parts.scale === null
|
|
32
|
+
? [parts.minor, parts.currency]
|
|
33
|
+
: [parts.minor, parts.currency, parts.scale];
|
|
34
|
+
};
|
|
30
35
|
|
|
31
36
|
/**
|
|
32
37
|
* A predicate or sort key names a property, never a physical column — so `orgId` becomes
|
|
@@ -44,7 +49,7 @@ export const physicalName = <Row>(entity: EntityCore<Row>, path: string): string
|
|
|
44
49
|
}
|
|
45
50
|
const isMoney = column.$meta.kind === 'money';
|
|
46
51
|
if (part === undefined) {
|
|
47
|
-
if (!isMoney) return
|
|
52
|
+
if (!isMoney) return columnName(property, column.$meta);
|
|
48
53
|
throw invariantViolated(
|
|
49
54
|
entity.$name,
|
|
50
55
|
property,
|
|
@@ -54,7 +59,8 @@ export const physicalName = <Row>(entity: EntityCore<Row>, path: string): string
|
|
|
54
59
|
if (!isMoney || !MONEY_PARTS.has(part)) {
|
|
55
60
|
throw invariantViolated(entity.$name, property, `${property} has no part "${part}"`);
|
|
56
61
|
}
|
|
57
|
-
|
|
62
|
+
const parts = moneyColumns(property, column.$meta);
|
|
63
|
+
return part === 'minor' ? parts.minor : parts.currency;
|
|
58
64
|
};
|
|
59
65
|
|
|
60
66
|
/** Every physical column of the entity, in declaration order. */
|
|
@@ -77,26 +83,63 @@ export const bindValues = <Row>(
|
|
|
77
83
|
if (!Object.hasOwn(record, property)) continue;
|
|
78
84
|
const value = record[property];
|
|
79
85
|
if (column.$meta.kind !== 'money') {
|
|
80
|
-
bound.set(
|
|
86
|
+
bound.set(columnName(property, column.$meta), bindable(column, value));
|
|
81
87
|
continue;
|
|
82
88
|
}
|
|
89
|
+
const parts = moneyColumns(property, column.$meta);
|
|
83
90
|
const money = value as MoneyValue | null | undefined;
|
|
84
|
-
bound.set(
|
|
85
|
-
bound.set(
|
|
91
|
+
bound.set(parts.minor, money?.minor ?? null);
|
|
92
|
+
bound.set(parts.currency, money?.currency ?? null);
|
|
86
93
|
// `?? null` and not `!== undefined`: an amount at the currency's own scale carries no key at
|
|
87
94
|
// all, and that absence is what the nullable column stores. A `0` written here for it would
|
|
88
95
|
// claim whole units — a 100x reinterpretation of every ordinary price.
|
|
89
|
-
bound.set(
|
|
96
|
+
if (parts.scale !== null) bound.set(parts.scale, money?.scale ?? null);
|
|
90
97
|
}
|
|
91
98
|
return bound;
|
|
92
99
|
};
|
|
93
100
|
|
|
94
|
-
|
|
101
|
+
/**
|
|
102
|
+
* One array element, as a Postgres array literal spells it. Quoted always: an unquoted element
|
|
103
|
+
* containing a comma, a brace or a backslash is a different array, and an empty string unquoted
|
|
104
|
+
* is nothing at all.
|
|
105
|
+
*/
|
|
106
|
+
const arrayElement = (value: unknown): string => {
|
|
107
|
+
if (value === null || value === undefined) return 'NULL';
|
|
108
|
+
const text =
|
|
109
|
+
value instanceof Date ? value.toISOString() : typeof value === 'object' ? '' : String(value);
|
|
110
|
+
return `"${text.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The value a parameter carries. Every column but one hands its row value straight over — the
|
|
115
|
+
* measured driver behaviour is that an object binds to `jsonb`, a string binds to `numeric`,
|
|
116
|
+
* `int8` and `date`, and a `Uint8Array` binds to `bytea`.
|
|
117
|
+
*
|
|
118
|
+
* An array is the one that cannot: Bun's `sql` serialises a JS array to `x,y`, which Postgres
|
|
119
|
+
* answers with `malformed array literal` (measured). What it accepts is the literal, so this is
|
|
120
|
+
* where a JS array becomes one.
|
|
121
|
+
*/
|
|
122
|
+
const bindable = (column: AnyColumn, value: unknown): unknown => {
|
|
123
|
+
if (value === null || value === undefined) return null;
|
|
124
|
+
// A plain object is not a bindable parameter (`X_SQL_UNSAFE`), so a `jsonb` value crosses as its
|
|
125
|
+
// TEXT and `pg-sql.ts`'s cell casts it back — see `cellCast` for why the cast is `::text::jsonb`.
|
|
126
|
+
if (column.$meta.kind === 'jsonb') return JSON.stringify(value);
|
|
127
|
+
if (column.$meta.kind !== 'array' || !Array.isArray(value)) return value;
|
|
128
|
+
return `{${value.map(arrayElement).join(',')}}`;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const moneyOf = (
|
|
132
|
+
source: PhysicalRow,
|
|
133
|
+
minor: string,
|
|
134
|
+
currency: string,
|
|
135
|
+
scale: string | undefined,
|
|
136
|
+
): unknown => {
|
|
95
137
|
const amount = source[minor];
|
|
96
138
|
if (amount === null || amount === undefined) return null;
|
|
97
139
|
// A column the projection left out is absent, not null — and absent must read as "no scale"
|
|
98
|
-
// exactly as a stored NULL does, so both take the same branch.
|
|
99
|
-
|
|
140
|
+
// exactly as a stored NULL does, so both take the same branch. So does a table that has no
|
|
141
|
+
// scale column at all, which is why the name itself may be `undefined`.
|
|
142
|
+
const declared = scale === undefined ? undefined : source[scale];
|
|
100
143
|
return {
|
|
101
144
|
minor: amount,
|
|
102
145
|
currency: String(source[currency] ?? '').trim(),
|
|
@@ -113,10 +156,13 @@ export const decodeRow = <Row>(entity: EntityCore<Row>, source: PhysicalRow): Ro
|
|
|
113
156
|
for (const [property, column] of Object.entries(entity.$columns)) {
|
|
114
157
|
const [head, currency, scale] = columnsOf(property, column);
|
|
115
158
|
if (head === undefined || !(head in source)) continue;
|
|
159
|
+
// Decided by the column's KIND and never by how many names came back: a money column whose
|
|
160
|
+
// table has no scale column projects two names, and reading that as a non-money column handed
|
|
161
|
+
// the caller a raw minor unit where a `Money` belongs.
|
|
116
162
|
const value =
|
|
117
|
-
|
|
118
|
-
? source
|
|
119
|
-
:
|
|
163
|
+
column.$meta.kind === 'money' && currency !== undefined
|
|
164
|
+
? moneyOf(source, head, currency, scale)
|
|
165
|
+
: source[head];
|
|
120
166
|
if (value !== null && value !== undefined) {
|
|
121
167
|
row[property] = column.$parse(value);
|
|
122
168
|
continue;
|
package/src/pg-sql.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// declared. That is the whole reason this file exists instead of a template literal per method.
|
|
5
5
|
|
|
6
6
|
import { identifier, join, raw, type SqlFragment, sql } from '@ultimat3/db';
|
|
7
|
-
import {
|
|
7
|
+
import { columnName } from './column';
|
|
8
8
|
import type { EntityCore } from './entity';
|
|
9
9
|
import { SOFT_DELETE_COLUMN } from './entity';
|
|
10
10
|
import { allColumns, columnsOf, physicalName } from './pg-row';
|
|
@@ -111,7 +111,7 @@ const conditions = <Row>(
|
|
|
111
111
|
): SqlFragment => {
|
|
112
112
|
const parts = plan.where.map((predicate) => predicateSql(entity, predicate));
|
|
113
113
|
if (entity.$softDelete && !shape.includeDeleted) {
|
|
114
|
-
parts.push(sql`${identifier(
|
|
114
|
+
parts.push(sql`${identifier(physicalName(entity, SOFT_DELETE_COLUMN))} is null`);
|
|
115
115
|
}
|
|
116
116
|
if (shape.seek !== undefined) parts.push(seekSql(entity, plan.orderBy, shape.seek));
|
|
117
117
|
return parts.length === 0 ? sql`true` : join(parts, ' and ');
|
|
@@ -224,6 +224,35 @@ const conflictSql = (conflict: ConflictTarget): SqlFragment => {
|
|
|
224
224
|
)}`;
|
|
225
225
|
};
|
|
226
226
|
|
|
227
|
+
/**
|
|
228
|
+
* The one column that cannot be bound as itself. A `jsonb` value is a plain object, and the
|
|
229
|
+
* driver seam refuses one as a parameter (`X_SQL_UNSAFE` — `isBoundValue` takes scalars, a `Date`,
|
|
230
|
+
* a `Uint8Array` and arrays of those); so `bindValues` hands over the JSON TEXT and the cell says
|
|
231
|
+
* what to do with it.
|
|
232
|
+
*
|
|
233
|
+
* `::text::jsonb` and not `::jsonb`, and the double cast is load-bearing rather than defensive.
|
|
234
|
+
* Measured against Postgres 17.10 through Bun's `sql`: with `$1::jsonb` the server describes the
|
|
235
|
+
* parameter as `jsonb`, the client JSON-ENCODES the string it was given, and `{"a":1}` is stored
|
|
236
|
+
* as the JSON *string* `"{\"a\":1}"` — `jsonb_typeof` says `string`. Pinning the parameter to
|
|
237
|
+
* `text` first makes the client send the characters and the server parse them, which is the one
|
|
238
|
+
* spelling that stores an object.
|
|
239
|
+
*/
|
|
240
|
+
/** Physical names of this entity's `jsonb` columns. Resolved ONCE per statement, never per cell. */
|
|
241
|
+
const jsonColumns = <Row>(entity: EntityCore<Row>): ReadonlySet<string> => {
|
|
242
|
+
const names = new Set<string>();
|
|
243
|
+
for (const [property, column] of Object.entries(entity.$columns)) {
|
|
244
|
+
if (column.$meta.kind === 'jsonb') names.add(columnName(property, column.$meta));
|
|
245
|
+
}
|
|
246
|
+
return names;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* `${value}`, plus the cast that column needs. The `raw()` argument is a literal written here and
|
|
251
|
+
* nowhere else — the audit point that call is stays a two-word constant, never a value.
|
|
252
|
+
*/
|
|
253
|
+
const cell = (json: ReadonlySet<string>, column: string, value: unknown): SqlFragment =>
|
|
254
|
+
json.has(column) ? sql`${value}${raw('::text::jsonb')}` : sql`${value}`;
|
|
255
|
+
|
|
227
256
|
/**
|
|
228
257
|
* One statement for any number of rows. A single row compiles to exactly the text it always did,
|
|
229
258
|
* which is the point: `insertAll([row])` and `insert(row)` are one code path, so there is no
|
|
@@ -234,10 +263,13 @@ export const insertStatement = <Row>(
|
|
|
234
263
|
rows: readonly ReadonlyMap<string, unknown>[],
|
|
235
264
|
shape: InsertShape,
|
|
236
265
|
): SqlFragment => {
|
|
266
|
+
const json = jsonColumns(entity);
|
|
237
267
|
const tuples = rows.map(
|
|
238
268
|
(row) =>
|
|
239
269
|
sql`(${join(
|
|
240
|
-
shape.columns.map((column) =>
|
|
270
|
+
shape.columns.map((column) =>
|
|
271
|
+
row.has(column) ? cell(json, column, row.get(column)) : DEFAULT_CELL,
|
|
272
|
+
),
|
|
241
273
|
)})`,
|
|
242
274
|
);
|
|
243
275
|
const conflict = shape.conflict === undefined ? sql`` : conflictSql(shape.conflict);
|
|
@@ -259,10 +291,12 @@ export const updateStatement = <Row>(
|
|
|
259
291
|
values: ReadonlyMap<string, unknown>,
|
|
260
292
|
shape: ReadShape,
|
|
261
293
|
returning: boolean,
|
|
262
|
-
): SqlFragment =>
|
|
263
|
-
|
|
264
|
-
|
|
294
|
+
): SqlFragment => {
|
|
295
|
+
const json = jsonColumns(entity);
|
|
296
|
+
return sql`update ${identifier(entity.$table)} set ${join(
|
|
297
|
+
[...values].map(([column, value]) => sql`${identifier(column)} = ${cell(json, column, value)}`),
|
|
265
298
|
)} where ${conditions(entity, plan, shape)}${returning ? sql` returning *` : sql``}`;
|
|
299
|
+
};
|
|
266
300
|
|
|
267
301
|
/** Only reached when the entity has no soft-delete column, so there is no filter to apply. */
|
|
268
302
|
export const deleteStatement = <Row>(entity: EntityCore<Row>, plan: QueryPlan): SqlFragment =>
|