bazis 0.116.0 → 0.117.1
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/core/http/HttpContext/responseLifetime.ts +3 -1
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/kernel/startupFailure.ts +4 -1
- package/core/orm/ownedStoreContributions.ts +2 -0
- package/library/orm/DatabaseFacade.ts +2 -2
- package/library/orm/Metadata/DynamicModelBuilder.ts +1 -1
- package/library/orm/Metadata/ModelBuilder.ts +17 -0
- package/library/orm/Metadata/decorators.ts +11 -0
- package/library/orm/Metadata/types.ts +4 -1
- package/library/orm/Providers/PostgresDialect.ts +22 -10
- package/library/orm/Providers/PostgresSchema.decoder.ts +6 -2
- package/library/orm/Providers/decimalValue.ts +37 -0
- package/library/orm/Providers/types.ts +1 -1
- package/library/orm/Query/ImmediateMutations.ts +6 -3
- package/library/orm/Saving/CommandBuilder.ts +9 -2
- package/library/orm/Schema/ExpectedSchema.ts +10 -5
- package/library/orm/Schema/SafeAdditiveSchema.ts +5 -1
- package/library/orm/Schema/tableKey.ts +10 -0
- package/package.json +1 -1
|
@@ -31,7 +31,9 @@ export function holdResponseScope(
|
|
|
31
31
|
if (signal.aborted && !body.locked) void body.cancel(signal.reason).catch(() => undefined);
|
|
32
32
|
void Promise.resolve().then(dispose).then(complete, reject);
|
|
33
33
|
};
|
|
34
|
-
|
|
34
|
+
// Types only: with the DOM lib (TypeScript's default) Response.body is the
|
|
35
|
+
// DOM ReadableStream, which node:stream's typings do not list.
|
|
36
|
+
cleanup = finished(body as unknown as Parameters<typeof finished>[0], { signal }, finish);
|
|
35
37
|
|
|
36
38
|
// Qualified Bun 1.4 consumes native Blob/file bodies directly: bodyUsed is
|
|
37
39
|
// true, but no JS reader is acquired and its stream never emits finished.
|
|
@@ -232,7 +232,7 @@ export class CodexAppServerClient implements CodexClient {
|
|
|
232
232
|
if (method === "account/updated") this.modelCache = undefined;
|
|
233
233
|
}, () => { this.clearLogin(); this.modelCache = undefined; });
|
|
234
234
|
const initialized = await client.request("initialize", {
|
|
235
|
-
clientInfo: { name: "bazis", title: "Bazis", version: "0.
|
|
235
|
+
clientInfo: { name: "bazis", title: "Bazis", version: "0.117.1" }, capabilities: { experimentalApi: true },
|
|
236
236
|
}, 15_000, signal);
|
|
237
237
|
if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
|
|
238
238
|
client.notify("initialized");
|
|
@@ -5,6 +5,8 @@ import { ShutdownTimeoutError, StartupFailuresError, StartupTimeoutError } from
|
|
|
5
5
|
const MAX_CAUSES = 5;
|
|
6
6
|
/** Properties already printed in the header, the frames or the cause chain. */
|
|
7
7
|
const SHOWN = new Set(["name", "message", "stack", "cause", "code", "errno", "errors", "failures"]);
|
|
8
|
+
/** Where in PostgreSQL's own sources the error was raised: nothing to act on. Hint, detail and the object names stay. */
|
|
9
|
+
const POSTGRES_INTERNALS = new Set(["file", "line", "routine", "severity", "severity_local"]);
|
|
8
10
|
const INSPECT = { depth: null, colors: false, customInspect: false, getters: false, maxArrayLength: null, maxStringLength: null } as const;
|
|
9
11
|
|
|
10
12
|
/**
|
|
@@ -34,7 +36,8 @@ export function formatStartupFailure(error: unknown): string {
|
|
|
34
36
|
if (!(current instanceof StartupFailuresError)) lines.push(...(current.stack ?? "").split("\n").filter((line) => /^\s+at /.test(line)));
|
|
35
37
|
// Timeouts already say their limit and what was pending in the message.
|
|
36
38
|
const restated = current instanceof StartupTimeoutError || current instanceof ShutdownTimeoutError;
|
|
37
|
-
const
|
|
39
|
+
const internals = current.name === "PostgresError" ? POSTGRES_INTERNALS : undefined;
|
|
40
|
+
const details = restated ? {} : Object.fromEntries(Object.entries(current).filter(([name]) => !SHOWN.has(name) && !internals?.has(name)));
|
|
38
41
|
if (Object.keys(details).length > 0) lines.push(inspect(redactSensitive(details), INSPECT));
|
|
39
42
|
current = current.cause;
|
|
40
43
|
}
|
|
@@ -58,6 +58,8 @@ function snapshot<TContext extends DbContext>(context: ContextClass<TContext>, e
|
|
|
58
58
|
const expected = compileExpectedSchema(new OrmModel(entities));
|
|
59
59
|
if (expected.tables.length === 0 || expected.tables.length > 512) fail("ORM_OWNED_STORE_IDENTITY_MISMATCH", `${storeName(definition)} needs 1 to 512 entities, got ${expected.tables.length}`);
|
|
60
60
|
for (const table of expected.tables) {
|
|
61
|
+
const decimal = table.columns.find((column) => column.physicalType.startsWith("decimal"));
|
|
62
|
+
if (decimal) fail("ORM_OWNED_STORE_IDENTITY_MISMATCH", `${storeName(definition)}: column "${decimal.column}" of "${table.table}" is decimal; owned stores do not support decimal columns`);
|
|
61
63
|
const partial = table.indexes.find((index) => index.predicate !== undefined);
|
|
62
64
|
if (partial) fail("ORM_OWNED_STORE_IDENTITY_MISMATCH", `${storeName(definition)}: index "${partial.name}" on "${table.table}" has a where condition; owned stores do not support partial indexes`);
|
|
63
65
|
}
|
|
@@ -5,7 +5,7 @@ import { Migrator, type MigrationResult } from "./Schema/Migrator";
|
|
|
5
5
|
import { MigrationRunner, type Migration, type VersionedMigrationResult } from "./Schema/MigrationRunner";
|
|
6
6
|
import { SchemaDiffer } from "./Schema/SchemaDiffer";
|
|
7
7
|
import { SchemaAdmissionEngine, type EnsureCreatedResult } from "./Schema/SchemaAdmission";
|
|
8
|
-
import { entityStorageKey } from "./Schema/tableKey";
|
|
8
|
+
import { entityStorageKey, foreignKeyName } from "./Schema/tableKey";
|
|
9
9
|
|
|
10
10
|
/** Result of `ensureCreated` together with versioned migrations. */
|
|
11
11
|
export interface EnsureCreatedWithMigrationsResult extends EnsureCreatedResult {
|
|
@@ -166,7 +166,7 @@ export class DatabaseFacade {
|
|
|
166
166
|
const constraint: ForeignKeyConstraint = {
|
|
167
167
|
column: columns[0]!.columnName,
|
|
168
168
|
columns: columns.map((column) => column!.columnName),
|
|
169
|
-
name: fk
|
|
169
|
+
name: foreignKeyName(model, fk),
|
|
170
170
|
referencedTable: this.referencedTableName(target),
|
|
171
171
|
referencedColumn: target.key[0].columnName,
|
|
172
172
|
referencedColumns: target.key.map((property) => property.columnName),
|
|
@@ -118,7 +118,7 @@ const LOGICAL_TO_STORAGE: Record<ScalarLogicalFieldType, StorageColumnType> = {
|
|
|
118
118
|
string: "text",
|
|
119
119
|
int: "integer",
|
|
120
120
|
bigint: "integer",
|
|
121
|
-
decimal: "
|
|
121
|
+
decimal: "decimal",
|
|
122
122
|
bool: "boolean",
|
|
123
123
|
datetime: "datetime",
|
|
124
124
|
uuid: "text",
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
CheckModel,
|
|
10
10
|
KeyGeneration,
|
|
11
11
|
PropertyModel,
|
|
12
|
+
StorageColumnType,
|
|
12
13
|
RelationModel,
|
|
13
14
|
} from "./types";
|
|
14
15
|
import type { CanonicalDefault } from "../Schema/introspection";
|
|
@@ -261,6 +262,7 @@ export class ModelBuilder {
|
|
|
261
262
|
raw.nullable === false ||
|
|
262
263
|
resolved.impliedRequired === true;
|
|
263
264
|
const databaseDefault = ModelBuilder.defaultFor(entityName, raw, storageType, isKey, generated, convention, required);
|
|
265
|
+
const digits = ModelBuilder.decimalDigits(entityName, raw, storageType);
|
|
264
266
|
return {
|
|
265
267
|
propertyName: raw.propertyName,
|
|
266
268
|
columnName: raw.columnName ?? raw.propertyName,
|
|
@@ -272,9 +274,24 @@ export class ModelBuilder {
|
|
|
272
274
|
convention,
|
|
273
275
|
uuidVersion: raw.uuidVersion,
|
|
274
276
|
databaseDefault,
|
|
277
|
+
...digits,
|
|
275
278
|
};
|
|
276
279
|
}
|
|
277
280
|
|
|
281
|
+
/** `precision`/`scale` belong to `decimal`: 1–1000 digits, scale 0–precision, scale needs precision. */
|
|
282
|
+
private static decimalDigits(entityName: string, raw: RawProperty, type: StorageColumnType): { precision?: number; scale?: number } {
|
|
283
|
+
if (type === "decimal" && raw.isKey === true) throw new ModelBuildError(`Entity "${entityName}": key "${raw.propertyName}" cannot be decimal; use an integer, text or UUID key.`);
|
|
284
|
+
const { precision, scale } = raw;
|
|
285
|
+
if (precision === undefined && scale === undefined) return {};
|
|
286
|
+
const where = `Entity "${entityName}": property "${raw.propertyName}"`;
|
|
287
|
+
if (type !== "decimal") throw new ModelBuildError(`${where}: precision and scale need @Column({ type: "decimal" }).`);
|
|
288
|
+
if (precision === undefined) throw new ModelBuildError(`${where}: scale needs precision, for example { precision: 12, scale: 2 }.`);
|
|
289
|
+
if (!Number.isSafeInteger(precision) || precision < 1 || precision > 1000) throw new ModelBuildError(`${where}: precision must be an integer from 1 to 1000.`);
|
|
290
|
+
const digitsAfter = scale ?? 0;
|
|
291
|
+
if (!Number.isSafeInteger(digitsAfter) || digitsAfter < 0 || digitsAfter > precision) throw new ModelBuildError(`${where}: scale must be an integer from 0 to precision (${precision}).`);
|
|
292
|
+
return { precision, scale: digitsAfter };
|
|
293
|
+
}
|
|
294
|
+
|
|
278
295
|
private static primaryKeyFor(
|
|
279
296
|
ctor: new () => object,
|
|
280
297
|
raw: NonNullable<ReturnType<typeof readRawEntity>>,
|
|
@@ -34,6 +34,13 @@ export interface ColumnOptions {
|
|
|
34
34
|
readonly nullable?: boolean;
|
|
35
35
|
/** Closed physical PostgreSQL default. It is not an application initializer or SQL fragment. */
|
|
36
36
|
readonly default?: ColumnDefaultValue;
|
|
37
|
+
/**
|
|
38
|
+
* `type: "decimal"` only: the exact `numeric(precision, scale)` column. The
|
|
39
|
+
* value is a string (`"19.90"`); a value with more digits than declared is
|
|
40
|
+
* refused instead of being rounded by the database.
|
|
41
|
+
*/
|
|
42
|
+
readonly precision?: number;
|
|
43
|
+
readonly scale?: number;
|
|
37
44
|
}
|
|
38
45
|
export type ColumnDefaultValue = null | boolean | number | string;
|
|
39
46
|
|
|
@@ -95,6 +102,8 @@ export interface RawProperty {
|
|
|
95
102
|
required?: boolean;
|
|
96
103
|
nullable?: boolean;
|
|
97
104
|
default?: ColumnDefaultValue;
|
|
105
|
+
precision?: number;
|
|
106
|
+
scale?: number;
|
|
98
107
|
index?: { unique: boolean; name?: string; where?: import("../Schema/CheckExpression").CheckAst };
|
|
99
108
|
/** `@ForeignKey(() => Principal)` on a scalar column, for the DDL FK. */
|
|
100
109
|
fkTarget?: () => EntityClass;
|
|
@@ -286,6 +295,8 @@ export function Column(options: ColumnOptions = {}) {
|
|
|
286
295
|
prop.columnName = options.name;
|
|
287
296
|
prop.type = options.type;
|
|
288
297
|
prop.default = options.default;
|
|
298
|
+
prop.precision = options.precision;
|
|
299
|
+
prop.scale = options.scale;
|
|
289
300
|
if (options.nullable !== undefined) {
|
|
290
301
|
prop.nullable = options.nullable;
|
|
291
302
|
}
|
|
@@ -7,7 +7,7 @@ import type { CanonicalDefault } from "../Schema/introspection";
|
|
|
7
7
|
* Physical column type in the database (DDL, encode/decode). The provider
|
|
8
8
|
* dialect maps it to the types of a concrete DBMS (`SqlDialect.columnType`).
|
|
9
9
|
*/
|
|
10
|
-
export type StorageColumnType = "integer" | "real" | "text" | "boolean" | "datetime" | "json";
|
|
10
|
+
export type StorageColumnType = "integer" | "real" | "decimal" | "text" | "boolean" | "datetime" | "json";
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Semantic `@Column({ type })` types with automatic behavior on `SaveChanges`.
|
|
@@ -63,6 +63,9 @@ export interface PropertyModel {
|
|
|
63
63
|
readonly convention?: PropertyConvention;
|
|
64
64
|
/** UUID version for `@UUID` (default v4). */
|
|
65
65
|
readonly uuidVersion?: "v4" | "v7";
|
|
66
|
+
/** `decimal`: total digits and digits after the point (`numeric(precision, scale)`); absent for plain `numeric`. */
|
|
67
|
+
readonly precision?: number;
|
|
68
|
+
readonly scale?: number;
|
|
66
69
|
/** Closed physical database default; it never affects entity initialization. */
|
|
67
70
|
readonly databaseDefault: CanonicalDefault;
|
|
68
71
|
}
|
|
@@ -2,7 +2,8 @@ import type { ColumnType, EntityModel, IndexModel, PropertyModel } from "../Meta
|
|
|
2
2
|
import type { ForeignKeyConstraint, RowLockMode, SqlDialect, SqlParam } from "./types";
|
|
3
3
|
import { projectCheckAstIdentifiers, renderCheck } from "../Schema/CheckExpression";
|
|
4
4
|
import { resolvedForeignKey } from "./resolvedForeignKey";
|
|
5
|
-
import { physicalTableIdentity } from "../Schema/tableKey";
|
|
5
|
+
import { physicalTableIdentity, primaryKeyName } from "../Schema/tableKey";
|
|
6
|
+
import { decimalParameter } from "./decimalValue";
|
|
6
7
|
|
|
7
8
|
const jsonScalarParameters = new WeakSet<object>();
|
|
8
9
|
|
|
@@ -55,6 +56,8 @@ export class PostgresDialect implements SqlDialect {
|
|
|
55
56
|
case "integer":
|
|
56
57
|
case "real":
|
|
57
58
|
return typeof value === "bigint" ? value : Number(value);
|
|
59
|
+
case "decimal":
|
|
60
|
+
return decimalParameter(value);
|
|
58
61
|
case "text":
|
|
59
62
|
return typeof value === "string" ? value : String(value);
|
|
60
63
|
}
|
|
@@ -78,6 +81,9 @@ export class PostgresDialect implements SqlDialect {
|
|
|
78
81
|
return typeof value === "bigint" ? value : Number(value);
|
|
79
82
|
case "real":
|
|
80
83
|
return Number(value);
|
|
84
|
+
case "decimal":
|
|
85
|
+
// Exact: PostgreSQL numeric stays a string, never a float.
|
|
86
|
+
return typeof value === "string" ? value : String(value);
|
|
81
87
|
case "text":
|
|
82
88
|
return typeof value === "string" ? value : String(value);
|
|
83
89
|
}
|
|
@@ -106,12 +112,14 @@ export class PostgresDialect implements SqlDialect {
|
|
|
106
112
|
return `$${index + 1}`;
|
|
107
113
|
}
|
|
108
114
|
|
|
109
|
-
columnType(type: ColumnType): string {
|
|
115
|
+
columnType(type: ColumnType, property?: PropertyModel): string {
|
|
110
116
|
switch (type) {
|
|
111
117
|
case "integer":
|
|
112
118
|
return "bigint";
|
|
113
119
|
case "real":
|
|
114
120
|
return "double precision";
|
|
121
|
+
case "decimal":
|
|
122
|
+
return property?.precision === undefined ? "numeric" : `numeric(${property.precision}, ${property.scale ?? 0})`;
|
|
115
123
|
case "boolean":
|
|
116
124
|
return "boolean";
|
|
117
125
|
case "datetime":
|
|
@@ -124,27 +132,29 @@ export class PostgresDialect implements SqlDialect {
|
|
|
124
132
|
}
|
|
125
133
|
|
|
126
134
|
createTableSql(model: EntityModel, foreignKeys: readonly ForeignKeyConstraint[]): string {
|
|
135
|
+
// The same name ensureCreated expects, not PostgreSQL's "<table>_pkey".
|
|
136
|
+
const primaryKey = `CONSTRAINT ${this.quoteId(primaryKeyName(model))} PRIMARY KEY`;
|
|
127
137
|
const columns = model.properties.map((property) => {
|
|
128
138
|
if (model.key.length === 1 && property.isKey && property.generation === "identity") {
|
|
129
139
|
// BY DEFAULT allows inserting an explicit key when needed.
|
|
130
|
-
return `${this.quoteId(property.columnName)} bigint GENERATED BY DEFAULT AS IDENTITY
|
|
140
|
+
return `${this.quoteId(property.columnName)} bigint GENERATED BY DEFAULT AS IDENTITY ${primaryKey}`;
|
|
131
141
|
}
|
|
132
142
|
if (model.key.length === 1 && property.isKey && property.generation === "uuid") {
|
|
133
|
-
return `${this.quoteId(property.columnName)} uuid NOT NULL DEFAULT gen_random_uuid()
|
|
143
|
+
return `${this.quoteId(property.columnName)} uuid NOT NULL DEFAULT gen_random_uuid() ${primaryKey}`;
|
|
134
144
|
}
|
|
135
145
|
if (model.key.length === 1 && property.isKey && property.generation === "uuidV7") {
|
|
136
146
|
// The ORM assigns the v7 value before INSERT, so the column has no default.
|
|
137
|
-
return `${this.quoteId(property.columnName)} uuid NOT NULL
|
|
147
|
+
return `${this.quoteId(property.columnName)} uuid NOT NULL ${primaryKey}`;
|
|
138
148
|
}
|
|
139
149
|
const foreignKey = foreignKeys.find((candidate) => (candidate.columns ?? [candidate.column]).includes(property.columnName));
|
|
140
|
-
const parts = [this.quoteId(property.columnName), this.ddlColumnType(this.foreignKeyColumnType(foreignKey, property))];
|
|
150
|
+
const parts = [this.quoteId(property.columnName), this.ddlColumnType(this.foreignKeyColumnType(foreignKey, property), property)];
|
|
141
151
|
if (property.required) {
|
|
142
152
|
parts.push("NOT NULL");
|
|
143
153
|
}
|
|
144
154
|
return parts.join(" ");
|
|
145
155
|
});
|
|
146
156
|
if (model.key.length > 1 || model.key[0].generation === "none") {
|
|
147
|
-
columns.push(
|
|
157
|
+
columns.push(`${primaryKey} (${model.key.map((key) => this.quoteId(key.columnName)).join(", ")})`);
|
|
148
158
|
}
|
|
149
159
|
for (const check of model.checks) columns.push(`CONSTRAINT ${this.quoteId(check.name)} CHECK (${renderCheck(projectCheckAstIdentifiers(check.expression, (name) => model.propertyByName(name)!.columnName), (name) => this.quoteId(name))})`);
|
|
150
160
|
for (const fk of foreignKeys) {
|
|
@@ -175,7 +185,7 @@ export class PostgresDialect implements SqlDialect {
|
|
|
175
185
|
const parts = [
|
|
176
186
|
`ALTER TABLE ${this.qualifyTable(model)} ADD COLUMN IF NOT EXISTS`,
|
|
177
187
|
this.quoteId(property.columnName),
|
|
178
|
-
this.ddlColumnType(this.foreignKeyColumnType(foreignKey, property)),
|
|
188
|
+
this.ddlColumnType(this.foreignKeyColumnType(foreignKey, property), property),
|
|
179
189
|
];
|
|
180
190
|
if (property.required) {
|
|
181
191
|
// An invented FK value (0/empty UUID) cannot safely backfill existing rows.
|
|
@@ -197,6 +207,7 @@ export class PostgresDialect implements SqlDialect {
|
|
|
197
207
|
switch (type) {
|
|
198
208
|
case "integer":
|
|
199
209
|
case "real":
|
|
210
|
+
case "decimal":
|
|
200
211
|
return "0";
|
|
201
212
|
case "boolean":
|
|
202
213
|
return "false";
|
|
@@ -209,8 +220,8 @@ export class PostgresDialect implements SqlDialect {
|
|
|
209
220
|
}
|
|
210
221
|
}
|
|
211
222
|
|
|
212
|
-
private ddlColumnType(type: ColumnType | "uuid"): string {
|
|
213
|
-
return type === "uuid" ? "uuid" : this.columnType(type);
|
|
223
|
+
private ddlColumnType(type: ColumnType | "uuid", property?: PropertyModel): string {
|
|
224
|
+
return type === "uuid" ? "uuid" : this.columnType(type, property);
|
|
214
225
|
}
|
|
215
226
|
|
|
216
227
|
private foreignKeyColumnType(fk: ForeignKeyConstraint | undefined, property: PropertyModel): ColumnType | "uuid" {
|
|
@@ -233,3 +244,4 @@ export function renderPostgresSkipLocked(dialect: SqlDialect): string {
|
|
|
233
244
|
}
|
|
234
245
|
return " SKIP LOCKED";
|
|
235
246
|
}
|
|
247
|
+
|
|
@@ -9,10 +9,12 @@ export function canonicalPostgresColumn(type: string, defaultExpression: string
|
|
|
9
9
|
return { physicalType, default: defaultValue.value, generation, unsupported: physicalType === "unknown" || defaultValue.unsupported || (identity !== "" && identity !== "d") };
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
export function canonicalPostgresType(type: string): "integer" | "real" | "text" | "boolean" | "datetime" | "json" | "uuid" | "unknown" {
|
|
12
|
+
export function canonicalPostgresType(type: string): "integer" | "real" | "text" | "boolean" | "datetime" | "json" | "uuid" | "decimal" | `decimal(${number},${number})` | "unknown" {
|
|
13
|
+
const numeric = type.trim().toLowerCase().match(/^numeric(?:\((\d+),\s*(\d+)\))?$/u);
|
|
14
|
+
if (numeric) return numeric[1] === undefined ? "decimal" : `decimal(${Number(numeric[1])},${Number(numeric[2])})`;
|
|
13
15
|
switch (type.trim().toLowerCase()) {
|
|
14
16
|
case "smallint": case "integer": case "bigint": return "integer";
|
|
15
|
-
case "real": case "double precision":
|
|
17
|
+
case "real": case "double precision": return "real";
|
|
16
18
|
case "text": case "character varying": case "character": return "text";
|
|
17
19
|
case "boolean": return "boolean";
|
|
18
20
|
case "timestamp with time zone": return "datetime";
|
|
@@ -25,6 +27,8 @@ export function canonicalPostgresType(type: string): "integer" | "real" | "text"
|
|
|
25
27
|
/** Exact admission accepts only the physical spellings rendered by PostgresDialect. */
|
|
26
28
|
export function exactPostgresType(type: string, canonical: IntrospectedColumn["physicalType"]): IntrospectedColumn["physicalType"] {
|
|
27
29
|
const raw = type.trim().toLowerCase();
|
|
30
|
+
// numeric and numeric(p,s) are the spellings PostgresDialect renders for decimal.
|
|
31
|
+
if (/^numeric(?:\(\d+,\s*\d+\))?$/u.test(raw)) return canonical;
|
|
28
32
|
switch (raw) {
|
|
29
33
|
case "bigint": case "double precision": case "text": case "boolean": case "timestamp with time zone": case "jsonb": case "uuid": return canonical;
|
|
30
34
|
default: return `${canonical ?? "unknown"}:${raw}`;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { OrmError } from "../errors";
|
|
2
|
+
import type { PropertyModel } from "../Metadata/types";
|
|
3
|
+
|
|
4
|
+
const DECIMAL_TEXT = /^([+-]?)(\d+)(?:\.(\d+))?$/u;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A decimal is written as text so no digit is lost: a string such as "19.90"
|
|
8
|
+
* or "-0.5", a bigint, or a safe integer. A float like 0.1 is refused because
|
|
9
|
+
* it is already inexact.
|
|
10
|
+
*/
|
|
11
|
+
export function decimalText(value: unknown): string | undefined {
|
|
12
|
+
if (typeof value === "bigint" || (typeof value === "number" && Number.isSafeInteger(value))) return String(value);
|
|
13
|
+
return typeof value === "string" && DECIMAL_TEXT.test(value) ? value : undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The parameter for a decimal column; anything that is not an exact decimal is refused. */
|
|
17
|
+
export function decimalParameter(value: unknown): string {
|
|
18
|
+
const text = decimalText(value);
|
|
19
|
+
if (text !== undefined) return text;
|
|
20
|
+
throw new OrmError(`A decimal value must be a string such as "19.90" (or an integer), got ${typeof value === "string" ? JSON.stringify(value.slice(0, 40)) : typeof value}.`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Before a write: a value with more digits than `numeric(precision, scale)`
|
|
25
|
+
* allows is refused, because PostgreSQL would round the fraction silently.
|
|
26
|
+
* Trailing zeros after the point do not count ("1.500" fits scale 2).
|
|
27
|
+
*/
|
|
28
|
+
export function assertDecimalFits(owner: string, property: PropertyModel, value: unknown): void {
|
|
29
|
+
if (property.type !== "decimal" || property.precision === undefined || value === null || value === undefined) return;
|
|
30
|
+
const match = decimalParameter(value).match(DECIMAL_TEXT)!;
|
|
31
|
+
const whole = match[2]!.replace(/^0+(?=\d)/u, "");
|
|
32
|
+
const fraction = (match[3] ?? "").replace(/0+$/u, "");
|
|
33
|
+
const scale = property.scale ?? 0;
|
|
34
|
+
const where = `${owner}.${property.propertyName}`;
|
|
35
|
+
if (fraction.length > scale) throw new OrmError(`${where}: "${value}" has ${fraction.length} digits after the point; numeric(${property.precision}, ${scale}) keeps ${scale}. Round it in the code.`);
|
|
36
|
+
if ((whole === "0" ? 0 : whole.length) > property.precision - scale) throw new OrmError(`${where}: "${value}" does not fit numeric(${property.precision}, ${scale}): at most ${property.precision - scale} digits before the point.`);
|
|
37
|
+
}
|
|
@@ -91,7 +91,7 @@ export interface SqlDialect {
|
|
|
91
91
|
/** PostgreSQL parameter placeholder by index (0-based): `$1`, `$2`, …. */
|
|
92
92
|
parameter(index: number): string;
|
|
93
93
|
/** Column type in DDL. */
|
|
94
|
-
columnType(type: ColumnType): string;
|
|
94
|
+
columnType(type: ColumnType, property?: PropertyModel): string;
|
|
95
95
|
/** Whether `INSERT ... RETURNING` is supported. */
|
|
96
96
|
readonly supportsReturning: boolean;
|
|
97
97
|
/** Encodes a property value into a query parameter for this DBMS. */
|
|
@@ -2,6 +2,7 @@ import { types } from "node:util";
|
|
|
2
2
|
import { OrmError, OrmTrackedMutationConflictError, OrmUndeclaredConflictTargetError, OrmUnsafeImmediateMutationError } from "../errors";
|
|
3
3
|
import { isDatabaseGenerated, type EntityModel, type PropertyModel } from "../Metadata/types";
|
|
4
4
|
import { convertsValue } from "../Providers/propertyConversion";
|
|
5
|
+
import { assertDecimalFits, decimalText } from "../Providers/decimalValue";
|
|
5
6
|
import type { DbContextRuntime } from "../runtime";
|
|
6
7
|
import { hasTrackedEntriesForModel } from "../Tracking/ChangeTracker";
|
|
7
8
|
import { isPostgresJsonScalarParameter } from "../Providers/PostgresDialect";
|
|
@@ -19,7 +20,7 @@ export type OrmUniqueKeySelectorV1<T extends object> = (entity: FieldSelector<T>
|
|
|
19
20
|
export function executeImmediateUpdate<T extends object>(model: EntityModel, runtime: DbContextRuntime, plan: QueryPlan, values: OrmUpdateValuesV1<T>): Promise<OrmMutationResultV1> {
|
|
20
21
|
return runtime.runImmediateOperation(async () => {
|
|
21
22
|
guard(model, runtime); const admittedPlan = admitPlan(plan, "executeUpdate");
|
|
22
|
-
const conditions = effectiveConditions(model, admittedPlan, runtime); const assignments = encodeAssignments(runtime, admitUpdateValues(model, values));
|
|
23
|
+
const conditions = effectiveConditions(model, admittedPlan, runtime); const assignments = encodeAssignments(runtime, model, admitUpdateValues(model, values));
|
|
23
24
|
const compiled = new SqlTranslator(model, runtime.provider.dialect).immediateUpdate(assignments, conditions, (property, value) => encode(runtime, property, snapshot(value), true));
|
|
24
25
|
Object.freeze(compiled.params);
|
|
25
26
|
guard(model, runtime);
|
|
@@ -42,7 +43,7 @@ export function executeImmediateDelete(model: EntityModel, runtime: DbContextRun
|
|
|
42
43
|
export function insertIfAbsent<T extends object>(model: EntityModel, runtime: DbContextRuntime, entity: T, options: { readonly conflictBy: OrmUniqueKeySelectorV1<T> }): Promise<OrmInsertIfAbsentResultV1> {
|
|
43
44
|
return runtime.runImmediateOperation(async () => {
|
|
44
45
|
guard(model, runtime); if (runtime.provider.dialect.name !== "postgres") unsafe();
|
|
45
|
-
const admitted = admitInsertValues(model, entity); const columns = conflictTarget(model, options); const values = encodeAssignments(runtime, admitted);
|
|
46
|
+
const admitted = admitInsertValues(model, entity); const columns = conflictTarget(model, options); const values = encodeAssignments(runtime, model, admitted);
|
|
46
47
|
const compiled = new SqlTranslator(model, runtime.provider.dialect).immediateInsert(values, columns);
|
|
47
48
|
Object.freeze(compiled.params);
|
|
48
49
|
guard(model, runtime);
|
|
@@ -136,7 +137,8 @@ function admitInsertValues<T extends object>(model: EntityModel, entity: T): rea
|
|
|
136
137
|
return Object.freeze(out.map((value) => Object.freeze(value)));
|
|
137
138
|
}
|
|
138
139
|
function unsetKey(value: unknown): boolean { return value === undefined || value === null || value === "" || value === 0; }
|
|
139
|
-
function encodeAssignments(runtime: DbContextRuntime, assignments: readonly AdmittedAssignment[]): readonly { readonly property: PropertyModel; readonly value: SqlParam }[] {
|
|
140
|
+
function encodeAssignments(runtime: DbContextRuntime, model: EntityModel, assignments: readonly AdmittedAssignment[]): readonly { readonly property: PropertyModel; readonly value: SqlParam }[] {
|
|
141
|
+
for (const { property, value } of assignments) if (!property.converter && decimalText(value) !== undefined) assertDecimalFits(model.name, property, value);
|
|
140
142
|
return Object.freeze(assignments.map(({ property, value }) => Object.freeze({ property, value: value === null && !convertsValue(property, null) ? null : encode(runtime, property, value) })));
|
|
141
143
|
}
|
|
142
144
|
function encode(runtime: DbContextRuntime, property: PropertyModel, input: unknown, allowNull = false): SqlParam {
|
|
@@ -248,6 +250,7 @@ function storageValue(value: unknown, type: PropertyModel["type"], allowNull = f
|
|
|
248
250
|
if (type === "boolean" && typeof safe !== "boolean") unsafe();
|
|
249
251
|
if (type === "integer" && !(typeof safe === "bigint" ? safe >= -9223372036854775808n && safe <= 9223372036854775807n : typeof safe === "number" && Number.isSafeInteger(safe))) unsafe();
|
|
250
252
|
if (type === "real" && !(typeof safe === "number" && Number.isFinite(safe))) unsafe();
|
|
253
|
+
if (type === "decimal" && decimalText(safe) === undefined) unsafe();
|
|
251
254
|
if (type === "datetime" && (typeof safe !== "object" || safe === null || Object.getPrototypeOf(safe) !== capturedDatePrototype)) unsafe();
|
|
252
255
|
if (type === "json" && !jsonValue(safe)) unsafe();
|
|
253
256
|
return safe as SqlParam;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isDatabaseGenerated, type EntityModel, type PropertyModel } from "../Metadata/types";
|
|
2
2
|
import type { SqlDialect, SqlParam } from "../Providers/types";
|
|
3
3
|
import { encodeProperty } from "../Providers/propertyConversion";
|
|
4
|
+
import { assertDecimalFits } from "../Providers/decimalValue";
|
|
4
5
|
import { EntityState } from "../Tracking/EntityState";
|
|
5
6
|
import type { TrackedEntry } from "../Tracking/ChangeTracker";
|
|
6
7
|
|
|
@@ -56,7 +57,7 @@ export class CommandBuilder {
|
|
|
56
57
|
const tuples = entities.map((entity) => {
|
|
57
58
|
const placeholders = columns.map((property) => {
|
|
58
59
|
const placeholder = this.dialect.parameter(params.length);
|
|
59
|
-
params.push(
|
|
60
|
+
params.push(this.written(model, property, entity[property.propertyName]));
|
|
60
61
|
return placeholder;
|
|
61
62
|
});
|
|
62
63
|
return `(${placeholders.join(", ")})`;
|
|
@@ -99,7 +100,7 @@ export class CommandBuilder {
|
|
|
99
100
|
const params: SqlParam[] = [];
|
|
100
101
|
const assignments = columns.map((property) => {
|
|
101
102
|
const placeholder = this.dialect.parameter(params.length);
|
|
102
|
-
params.push(
|
|
103
|
+
params.push(this.written(model, property, entity[property.propertyName]));
|
|
103
104
|
return `${this.dialect.quoteId(property.columnName)} = ${placeholder}`;
|
|
104
105
|
});
|
|
105
106
|
const where = this.keyPredicate(model, entity, params);
|
|
@@ -109,6 +110,12 @@ export class CommandBuilder {
|
|
|
109
110
|
return { sql, params };
|
|
110
111
|
}
|
|
111
112
|
|
|
113
|
+
/** A written value; a decimal that would lose digits is refused before SQL. */
|
|
114
|
+
private written(model: EntityModel, property: PropertyModel, value: unknown): SqlParam {
|
|
115
|
+
if (!property.converter) assertDecimalFits(model.name, property, value);
|
|
116
|
+
return encodeProperty(property, value, this.dialect);
|
|
117
|
+
}
|
|
118
|
+
|
|
112
119
|
private delete(entity: Record<string, unknown>, model: EntityModel): DbCommand {
|
|
113
120
|
const params: SqlParam[] = [];
|
|
114
121
|
const sql = `DELETE FROM ${this.dialect.qualifyTable(model)} WHERE ${this.keyPredicate(model, entity, params)}`;
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import type { EntityModel } from "../Metadata/types";
|
|
1
|
+
import type { EntityModel, PropertyModel } from "../Metadata/types";
|
|
2
2
|
import type { OrmModel } from "../Metadata/OrmModel";
|
|
3
3
|
import type { CanonicalDefault, GenerationStrategy } from "./introspection";
|
|
4
4
|
import { projectCheckAstIdentifiers, type CheckAst } from "./CheckExpression";
|
|
5
5
|
import { crossUnitForeignKey, physicalColumnTypes } from "./physicalColumnTypes";
|
|
6
6
|
import { SchemaAdmissionError } from "../errors";
|
|
7
|
-
import { physicalTableIdentity } from "./tableKey";
|
|
7
|
+
import { foreignKeyName, physicalTableIdentity, primaryKeyName } from "./tableKey";
|
|
8
8
|
|
|
9
9
|
export interface ExpectedColumn { readonly property: string; readonly column: string; readonly physicalType: string; readonly nullable: boolean; readonly default: CanonicalDefault; readonly generation: GenerationStrategy }
|
|
10
10
|
export interface ExpectedTable { readonly schema: string; readonly table: string; readonly columns: readonly ExpectedColumn[]; readonly primaryKey: { readonly name: string; readonly columns: readonly string[] }; readonly indexes: readonly { readonly name: string; readonly columns: readonly string[]; readonly unique: boolean; readonly method: "btree"; readonly predicate?: CheckAst }[]; readonly foreignKeys: readonly { readonly name: string; readonly columns: readonly string[]; readonly target: { readonly schema: string; readonly table: string }; readonly targetColumns: readonly string[]; readonly onDelete: string; readonly onUpdate: string }[]; readonly checks: readonly { readonly name: string; readonly expression: unknown }[] }
|
|
@@ -31,7 +31,7 @@ function compileTable(model: EntityModel, byCtor: ReadonlyMap<EntityModel["ctor"
|
|
|
31
31
|
const columns = model.properties.map((property) => ({
|
|
32
32
|
property: property.propertyName,
|
|
33
33
|
column: property.columnName,
|
|
34
|
-
physicalType: types.get(property)!,
|
|
34
|
+
physicalType: property.type === "decimal" ? decimalPhysicalType(property) : types.get(property)!,
|
|
35
35
|
nullable: !property.required,
|
|
36
36
|
default: property.databaseDefault,
|
|
37
37
|
generation: property.generation === "identity" ? "identityByDefault" as const : property.generation === "uuid" ? "uuidDefault" as const : "none" as const,
|
|
@@ -39,7 +39,12 @@ function compileTable(model: EntityModel, byCtor: ReadonlyMap<EntityModel["ctor"
|
|
|
39
39
|
const foreignKeys = model.foreignKeys.map((foreignKey) => {
|
|
40
40
|
const target = byCtor.get(foreignKey.target());
|
|
41
41
|
if (!target) throw crossUnitForeignKey(model, foreignKey.properties, foreignKey.target());
|
|
42
|
-
return { name:
|
|
42
|
+
return { name: foreignKeyName(model, foreignKey), columns: foreignKey.properties.map((name) => model.propertyByName(name)!.columnName), target: { schema: schemaOf(target), table: physicalTableIdentity(target).table }, targetColumns: target.key.map((property) => property.columnName), onDelete: foreignKey.onDelete, onUpdate: foreignKey.onUpdate };
|
|
43
43
|
});
|
|
44
|
-
return Object.freeze({ schema, table, columns: Object.freeze(columns), primaryKey: Object.freeze({ name: model
|
|
44
|
+
return Object.freeze({ schema, table, columns: Object.freeze(columns), primaryKey: Object.freeze({ name: primaryKeyName(model), columns: Object.freeze(model.key.map((property) => property.columnName)) }), indexes: Object.freeze(model.indexes.map((index) => Object.freeze({ name: index.name, columns: Object.freeze([...index.columns]), unique: index.unique, method: "btree" as const, ...(index.where === undefined ? {} : { predicate: projectCheckAstIdentifiers(index.where, (name) => model.propertyByName(name)!.columnName) }) }))), foreignKeys: Object.freeze(foreignKeys), checks: Object.freeze(model.checks.map((check) => Object.freeze({ name: check.name, expression: projectCheckAstIdentifiers(check.expression, (name) => model.propertyByName(name)!.columnName) }))) });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `decimal(12,2)` for numeric(12, 2), `decimal` for an unbounded numeric: the catalog reports the same label. */
|
|
48
|
+
export function decimalPhysicalType(property: Pick<PropertyModel, "precision" | "scale">): string {
|
|
49
|
+
return property.precision === undefined ? "decimal" : `decimal(${property.precision},${property.scale ?? 0})`;
|
|
45
50
|
}
|
|
@@ -94,7 +94,11 @@ function literal(value: ExpectedColumn["default"], physicalType: string): string
|
|
|
94
94
|
}
|
|
95
95
|
switch (value.kind) { case "null": return "NULL"; case "boolean": return value.value ? "TRUE" : "FALSE"; case "number": return String(value.value); case "string": return `'${value.value.replaceAll("'", "''")}'`; case "uuidV4": return "gen_random_uuid()"; default: throw new Error("Unrenderable PostgreSQL schema default."); }
|
|
96
96
|
}
|
|
97
|
-
function type(value: string): string {
|
|
97
|
+
function type(value: string): string {
|
|
98
|
+
const decimal = value.match(/^decimal(?:\((\d+),(\d+)\))?$/u);
|
|
99
|
+
if (decimal) return decimal[1] === undefined ? "numeric" : `numeric(${decimal[1]}, ${decimal[2]})`;
|
|
100
|
+
return value === "integer" ? "bigint" : value === "real" ? "double precision" : value === "datetime" ? "timestamptz" : value === "json" ? "jsonb" : value;
|
|
101
|
+
}
|
|
98
102
|
function q(value: string): string { return `"${value.replaceAll('"', '""')}"`; }
|
|
99
103
|
function tableName(table: Pick<ExpectedTable, "schema" | "table">): string { return `${q(table.schema)}.${q(table.table)}`; }
|
|
100
104
|
function action(value: string): string { return value === "noAction" ? "NO ACTION" : value === "setNull" ? "SET NULL" : value.toUpperCase(); }
|
|
@@ -24,3 +24,13 @@ export function entityStorageKey(model: EntityModel): string {
|
|
|
24
24
|
export function introspectedTableKey(schema: string, table: string): string {
|
|
25
25
|
return schema === "public" ? table : `${schema}.${table}`;
|
|
26
26
|
}
|
|
27
|
+
|
|
28
|
+
/** `pk_<table>` unless the key is named; ensureCreated and migrate() name the primary key the same way. */
|
|
29
|
+
export function primaryKeyName(model: EntityModel): string {
|
|
30
|
+
return model.keyName ?? `pk_${physicalTableIdentity(model).table}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** `fk_<table>_<properties>` unless the foreign key is named, for every way a table is created. */
|
|
34
|
+
export function foreignKeyName(model: EntityModel, foreignKey: { readonly name?: string; readonly properties: readonly string[] }): string {
|
|
35
|
+
return foreignKey.name ?? `fk_${physicalTableIdentity(model).table}_${foreignKey.properties.join("_")}`;
|
|
36
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bazis",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.117.1",
|
|
4
4
|
"description": "Modular backend framework for Bun in TypeScript: DI wired by codegen, HTTP, a PostgreSQL ORM, JWT, WebSocket, agents and a CLI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Alexander Alenin",
|