bazis 0.116.0 → 0.117.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/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/orm/ownedStoreContributions.ts +2 -0
- 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 +15 -5
- 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 +7 -2
- package/library/orm/Schema/SafeAdditiveSchema.ts +5 -1
- package/package.json +1 -1
|
@@ -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.0" }, 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");
|
|
@@ -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
|
}
|
|
@@ -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
|
}
|
|
@@ -3,6 +3,7 @@ import type { ForeignKeyConstraint, RowLockMode, SqlDialect, SqlParam } from "./
|
|
|
3
3
|
import { projectCheckAstIdentifiers, renderCheck } from "../Schema/CheckExpression";
|
|
4
4
|
import { resolvedForeignKey } from "./resolvedForeignKey";
|
|
5
5
|
import { physicalTableIdentity } 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":
|
|
@@ -137,7 +145,7 @@ export class PostgresDialect implements SqlDialect {
|
|
|
137
145
|
return `${this.quoteId(property.columnName)} uuid NOT NULL PRIMARY KEY`;
|
|
138
146
|
}
|
|
139
147
|
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))];
|
|
148
|
+
const parts = [this.quoteId(property.columnName), this.ddlColumnType(this.foreignKeyColumnType(foreignKey, property), property)];
|
|
141
149
|
if (property.required) {
|
|
142
150
|
parts.push("NOT NULL");
|
|
143
151
|
}
|
|
@@ -175,7 +183,7 @@ export class PostgresDialect implements SqlDialect {
|
|
|
175
183
|
const parts = [
|
|
176
184
|
`ALTER TABLE ${this.qualifyTable(model)} ADD COLUMN IF NOT EXISTS`,
|
|
177
185
|
this.quoteId(property.columnName),
|
|
178
|
-
this.ddlColumnType(this.foreignKeyColumnType(foreignKey, property)),
|
|
186
|
+
this.ddlColumnType(this.foreignKeyColumnType(foreignKey, property), property),
|
|
179
187
|
];
|
|
180
188
|
if (property.required) {
|
|
181
189
|
// An invented FK value (0/empty UUID) cannot safely backfill existing rows.
|
|
@@ -197,6 +205,7 @@ export class PostgresDialect implements SqlDialect {
|
|
|
197
205
|
switch (type) {
|
|
198
206
|
case "integer":
|
|
199
207
|
case "real":
|
|
208
|
+
case "decimal":
|
|
200
209
|
return "0";
|
|
201
210
|
case "boolean":
|
|
202
211
|
return "false";
|
|
@@ -209,8 +218,8 @@ export class PostgresDialect implements SqlDialect {
|
|
|
209
218
|
}
|
|
210
219
|
}
|
|
211
220
|
|
|
212
|
-
private ddlColumnType(type: ColumnType | "uuid"): string {
|
|
213
|
-
return type === "uuid" ? "uuid" : this.columnType(type);
|
|
221
|
+
private ddlColumnType(type: ColumnType | "uuid", property?: PropertyModel): string {
|
|
222
|
+
return type === "uuid" ? "uuid" : this.columnType(type, property);
|
|
214
223
|
}
|
|
215
224
|
|
|
216
225
|
private foreignKeyColumnType(fk: ForeignKeyConstraint | undefined, property: PropertyModel): ColumnType | "uuid" {
|
|
@@ -233,3 +242,4 @@ export function renderPostgresSkipLocked(dialect: SqlDialect): string {
|
|
|
233
242
|
}
|
|
234
243
|
return " SKIP LOCKED";
|
|
235
244
|
}
|
|
245
|
+
|
|
@@ -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,4 +1,4 @@
|
|
|
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";
|
|
@@ -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,
|
|
@@ -43,3 +43,8 @@ function compileTable(model: EntityModel, byCtor: ReadonlyMap<EntityModel["ctor"
|
|
|
43
43
|
});
|
|
44
44
|
return Object.freeze({ schema, table, columns: Object.freeze(columns), primaryKey: Object.freeze({ name: model.keyName ?? `pk_${table}`, 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
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})`;
|
|
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(); }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bazis",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.117.0",
|
|
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",
|