bazis 0.114.0 → 0.115.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 +4 -0
- package/library/orm/Metadata/ModelBuilder.ts +25 -2
- package/library/orm/Metadata/decorators.ts +20 -8
- package/library/orm/Metadata/types.ts +2 -0
- package/library/orm/Providers/PostgresDialect.ts +2 -1
- package/library/orm/Providers/PostgresProvider.ts +1 -1
- package/library/orm/Providers/PostgresSchema.decoder.ts +5 -5
- package/library/orm/Schema/CheckExpression.ts +54 -7
- package/library/orm/Schema/ExactSchemaVerifier.ts +17 -4
- package/library/orm/Schema/ExpectedSchema.ts +3 -3
- package/library/orm/Schema/SafeAdditiveSchema.ts +1 -1
- package/library/orm/Schema/SchemaAdmission.ts +4 -3
- package/library/orm/Schema/SchemaDiffer.ts +8 -1
- package/library/orm/Schema/formatSchemaDifferences.ts +1 -0
- package/library/orm/Schema/introspection.ts +3 -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.115.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");
|
|
@@ -57,6 +57,10 @@ function snapshot<TContext extends DbContext>(context: ContextClass<TContext>, e
|
|
|
57
57
|
const definition = isDefinedOrmOwnedStoreV1(source) ? source : defineOrmOwnedStoreV1(source as OrmOwnedStoreDefinitionV1);
|
|
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
|
+
for (const table of expected.tables) {
|
|
61
|
+
const partial = table.indexes.find((index) => index.predicate !== undefined);
|
|
62
|
+
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
|
+
}
|
|
60
64
|
return Object.freeze({ identity: Object.freeze({}), context, entities: Object.freeze([...entities]), definition, expected, source });
|
|
61
65
|
}
|
|
62
66
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ModelBuildError } from "../errors";
|
|
2
2
|
import { readRawEntity, type RawProperty } from "./decorators";
|
|
3
3
|
import { defaultTableName, isConventionalKey, resolveColumnType } from "./conventions";
|
|
4
|
-
import { validateCheckAst } from "../Schema/CheckExpression";
|
|
4
|
+
import { validateCheckAst, type CheckAst } from "../Schema/CheckExpression";
|
|
5
5
|
import type {
|
|
6
6
|
EntityModel,
|
|
7
7
|
ForeignKeyModel,
|
|
@@ -57,6 +57,7 @@ export class ModelBuilder {
|
|
|
57
57
|
name: rawProp.index.name ?? `ix_${(raw.table ?? defaultTableName(ctor.name)).toLowerCase()}_${property.columnName}`,
|
|
58
58
|
columns: [property.columnName],
|
|
59
59
|
unique: rawProp.index.unique,
|
|
60
|
+
...(rawProp.index.where === undefined ? {} : { where: rawProp.index.where }),
|
|
60
61
|
});
|
|
61
62
|
}
|
|
62
63
|
if (rawProp.fkTarget) {
|
|
@@ -177,12 +178,13 @@ export class ModelBuilder {
|
|
|
177
178
|
return property.columnName;
|
|
178
179
|
});
|
|
179
180
|
if (new Set(columns).size !== columns.length) throw new ModelBuildError(`Entity "${ctor.name}": index contains duplicate properties.`);
|
|
180
|
-
indexes.push({ name: declared.name ?? `ix_${(raw.table ?? defaultTableName(ctor.name)).toLowerCase()}_${columns.join("_")}`, columns, unique: declared.unique });
|
|
181
|
+
indexes.push({ name: declared.name ?? `ix_${(raw.table ?? defaultTableName(ctor.name)).toLowerCase()}_${columns.join("_")}`, columns, unique: declared.unique, ...(declared.where === undefined ? {} : { where: declared.where }) });
|
|
181
182
|
}
|
|
182
183
|
const indexNames = new Set<string>();
|
|
183
184
|
for (const index of indexes) {
|
|
184
185
|
if (indexNames.has(index.name)) throw new ModelBuildError(`Entity "${ctor.name}": duplicate index name "${index.name}".`);
|
|
185
186
|
indexNames.add(index.name);
|
|
187
|
+
if (index.where !== undefined) validateIndexCondition(ctor.name, index.name, index.where, properties);
|
|
186
188
|
}
|
|
187
189
|
// Encryption with a random nonce stores equal values differently: such a
|
|
188
190
|
// column cannot identify a row or be unique.
|
|
@@ -346,3 +348,24 @@ function isCanonicalRfc3339(value: string): boolean {
|
|
|
346
348
|
}
|
|
347
349
|
return true;
|
|
348
350
|
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* A partial index condition uses the `@Check` operators. A date literal is
|
|
354
|
+
* refused: PostgreSQL stores it in its own time zone form, so the declared
|
|
355
|
+
* and the stored conditions could never be compared.
|
|
356
|
+
*/
|
|
357
|
+
function validateIndexCondition(entity: string, index: string, where: CheckAst, properties: readonly PropertyModel[]): void {
|
|
358
|
+
validateCheckAst(where, properties, `Entity "${entity}": index "${index}" condition`);
|
|
359
|
+
const byName = new Map(properties.map((property) => [property.propertyName, property]));
|
|
360
|
+
const visit = (node: CheckAst): void => {
|
|
361
|
+
switch (node.kind) {
|
|
362
|
+
case "and":
|
|
363
|
+
case "or": visit(node.left); visit(node.right); return;
|
|
364
|
+
case "not": visit(node.inner); return;
|
|
365
|
+
case "null": return;
|
|
366
|
+
}
|
|
367
|
+
if (byName.get(node.left)?.type !== "datetime") return;
|
|
368
|
+
if (node.kind === "in" || !(typeof node.right === "string" && node.right.startsWith("\0"))) throw new ModelBuildError(`Entity "${entity}": index "${index}" condition compares "${node.left}" with a date literal; use isNull()/isNotNull() or compare with another column.`);
|
|
369
|
+
};
|
|
370
|
+
visit(where);
|
|
371
|
+
}
|
|
@@ -62,10 +62,18 @@ export interface UUIDOptions {
|
|
|
62
62
|
readonly name?: string;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
/** `@Index` options
|
|
66
|
-
|
|
65
|
+
/** `@Index` options. */
|
|
66
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
67
|
+
export interface IndexOptions<T extends object = any> {
|
|
67
68
|
readonly unique?: boolean;
|
|
68
69
|
readonly name?: string;
|
|
70
|
+
/**
|
|
71
|
+
* Partial index: only rows that match the condition are indexed. With
|
|
72
|
+
* `@SoftDelete` a unique index keeps deleted rows out:
|
|
73
|
+
* `@Index<User>(["email"], { unique: true, where: (u) => u.deletedAt.isNull() })`.
|
|
74
|
+
* The condition uses the same operators as `@Check`.
|
|
75
|
+
*/
|
|
76
|
+
readonly where?: CheckPredicate<T>;
|
|
69
77
|
}
|
|
70
78
|
export interface CompositeKeyOptions { readonly name?: string }
|
|
71
79
|
export type ReferentialAction = "noAction" | "restrict" | "cascade" | "setNull";
|
|
@@ -87,7 +95,7 @@ export interface RawProperty {
|
|
|
87
95
|
required?: boolean;
|
|
88
96
|
nullable?: boolean;
|
|
89
97
|
default?: ColumnDefaultValue;
|
|
90
|
-
index?: { unique: boolean; name?: string };
|
|
98
|
+
index?: { unique: boolean; name?: string; where?: import("../Schema/CheckExpression").CheckAst };
|
|
91
99
|
/** `@ForeignKey(() => Principal)` on a scalar column, for the DDL FK. */
|
|
92
100
|
fkTarget?: () => EntityClass;
|
|
93
101
|
/** `@HasConversion(...)`: conversion before/after the dialect. */
|
|
@@ -119,7 +127,7 @@ export interface RawEntity {
|
|
|
119
127
|
/** Soft-delete property name (see `@SoftDelete` / `@Entity({ softDelete })`). */
|
|
120
128
|
softDeleteProperty?: string;
|
|
121
129
|
keyDeclaration?: { properties: readonly string[]; name?: string; anchor: string; composite: boolean };
|
|
122
|
-
indexes?: Array<{ properties: readonly string[]; unique: boolean; name?: string }>;
|
|
130
|
+
indexes?: Array<{ properties: readonly string[]; unique: boolean; name?: string; where?: import("../Schema/CheckExpression").CheckAst }>;
|
|
123
131
|
foreignKeys?: Array<{ properties: readonly string[]; target: () => EntityClass; name?: string; onDelete?: ReferentialAction; onUpdate?: ReferentialAction }>;
|
|
124
132
|
checks?: Array<{ name: string; expression: import("../Schema/CheckExpression").CheckAst }>;
|
|
125
133
|
}
|
|
@@ -292,17 +300,21 @@ export function Required() {
|
|
|
292
300
|
}
|
|
293
301
|
|
|
294
302
|
/** Index on the property's column. */
|
|
295
|
-
|
|
296
|
-
export function Index
|
|
303
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
304
|
+
export function Index<T extends object = any>(options?: IndexOptions<T>): (value: undefined, context: ClassFieldDecoratorContext) => void;
|
|
305
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
306
|
+
export function Index<T extends object = any>(properties: readonly [string, ...string[]], options?: IndexOptions<T>): (value: abstract new (...args: never[]) => unknown, context: ClassDecoratorContext) => void;
|
|
297
307
|
export function Index(first: IndexOptions | readonly [string, ...string[]] = {}, options: IndexOptions = {}) {
|
|
298
308
|
if (Array.isArray(first)) {
|
|
309
|
+
const where = options.where === undefined ? undefined : compileCheck(options.where);
|
|
299
310
|
return (_value: abstract new (...args: never[]) => unknown, context: ClassDecoratorContext): void => {
|
|
300
|
-
(ownRaw(context.metadata).indexes ??= []).push({ properties: [...first], unique: options.unique === true, name: options.name });
|
|
311
|
+
(ownRaw(context.metadata).indexes ??= []).push({ properties: [...first], unique: options.unique === true, name: options.name, ...(where === undefined ? {} : { where }) });
|
|
301
312
|
};
|
|
302
313
|
}
|
|
303
314
|
const propertyOptions = first as IndexOptions;
|
|
315
|
+
const where = propertyOptions.where === undefined ? undefined : compileCheck(propertyOptions.where);
|
|
304
316
|
return (_value: undefined, context: ClassFieldDecoratorContext): void => {
|
|
305
|
-
ownProperty(context.metadata, fieldName(context)).index = { unique: propertyOptions.unique === true, name: propertyOptions.name };
|
|
317
|
+
ownProperty(context.metadata, fieldName(context)).index = { unique: propertyOptions.unique === true, name: propertyOptions.name, ...(where === undefined ? {} : { where }) };
|
|
306
318
|
};
|
|
307
319
|
}
|
|
308
320
|
|
|
@@ -72,6 +72,8 @@ export interface IndexModel {
|
|
|
72
72
|
readonly name: string;
|
|
73
73
|
readonly columns: readonly string[];
|
|
74
74
|
readonly unique: boolean;
|
|
75
|
+
/** Partial index condition over property names (`@Index({ where })`). */
|
|
76
|
+
readonly where?: CheckAst;
|
|
75
77
|
}
|
|
76
78
|
export interface CheckModel { readonly name: string; readonly expression: CheckAst }
|
|
77
79
|
|
|
@@ -165,9 +165,10 @@ export class PostgresDialect implements SqlDialect {
|
|
|
165
165
|
createIndexSqlOne(model: EntityModel, index: IndexModel): string {
|
|
166
166
|
const unique = index.unique ? "UNIQUE " : "";
|
|
167
167
|
const cols = index.columns.map((column) => this.quoteId(column)).join(", ");
|
|
168
|
+
const where = index.where === undefined ? "" : ` WHERE ${renderCheck(projectCheckAstIdentifiers(index.where, (name) => model.propertyByName(name)!.columnName), (name) => this.quoteId(name))}`;
|
|
168
169
|
return `CREATE ${unique}INDEX IF NOT EXISTS ${this.quoteId(index.name)} ON ${this.qualifyTable(
|
|
169
170
|
model,
|
|
170
|
-
)} (${cols})`;
|
|
171
|
+
)} (${cols})${where}`;
|
|
171
172
|
}
|
|
172
173
|
|
|
173
174
|
addColumnSql(model: EntityModel, property: PropertyModel, foreignKey?: ForeignKeyConstraint): string {
|
|
@@ -476,7 +476,7 @@ export class PostgresProvider implements DatabaseProvider {
|
|
|
476
476
|
const column = String(row.column_name);
|
|
477
477
|
return [column, { name: column, notNull: row.not_null === true, isPrimaryKey: primaryColumns.has(column), ...normalized, physicalType: exact ? exactPostgresType(String(row.type_name), normalized.physicalType) : normalized.physicalType }];
|
|
478
478
|
}));
|
|
479
|
-
const indexRows = await executor.query(`SELECT ic.relname AS index_name, ix.indisunique AS is_unique, am.amname AS method, ix.indpred IS NOT NULL AS has_predicate, ix.indisvalid AS is_valid, ix.indisready AS is_ready, ix.indnkeyatts AS key_count, k.ord, a.attname AS column_name, k.attnum = 0 AS is_expression, k.ord > ix.indnkeyatts AS is_include,
|
|
479
|
+
const indexRows = await executor.query(`SELECT ic.relname AS index_name, ix.indisunique AS is_unique, am.amname AS method, ix.indpred IS NOT NULL AS has_predicate, pg_get_expr(ix.indpred, ix.indrelid, true) AS predicate, ix.indisvalid AS is_valid, ix.indisready AS is_ready, ix.indnkeyatts AS key_count, k.ord, a.attname AS column_name, k.attnum = 0 AS is_expression, k.ord > ix.indnkeyatts AS is_include,
|
|
480
480
|
EXISTS (SELECT 1 FROM pg_constraint c WHERE c.conindid = ix.indexrelid AND c.contype IN ('p', 'u')) AS backing_constraint
|
|
481
481
|
FROM pg_index ix JOIN pg_class ic ON ic.oid = ix.indexrelid JOIN pg_class tc ON tc.oid = ix.indrelid JOIN pg_namespace n ON n.oid = tc.relnamespace JOIN pg_am am ON am.oid = ic.relam JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY k(attnum, ord) ON true LEFT JOIN pg_attribute a ON a.attrelid = tc.oid AND a.attnum = k.attnum
|
|
482
482
|
WHERE n.nspname = $1 AND tc.relname = $2 ORDER BY ic.relname, k.ord`, [schema, name]);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Row } from "./types";
|
|
2
2
|
import type { IntrospectedColumn, IntrospectedIndex, IntrospectedForeignKey } from "../Schema/introspection";
|
|
3
|
-
import {
|
|
3
|
+
import { parsePostgresPredicate, type CheckAst } from "../Schema/CheckExpression";
|
|
4
4
|
|
|
5
5
|
export function canonicalPostgresColumn(type: string, defaultExpression: string | null, identity: string): Pick<IntrospectedColumn, "physicalType" | "default" | "generation" | "unsupported"> {
|
|
6
6
|
const physicalType = canonicalPostgresType(type);
|
|
@@ -78,10 +78,10 @@ function stripPostgresCasts(value: string): string { return value.replace(/::(?:
|
|
|
78
78
|
function stripPostgresOuter(value: string): string { while (value.startsWith("(") && value.endsWith(")")) value = value.slice(1, -1).trim(); return value; }
|
|
79
79
|
|
|
80
80
|
export function collectPostgresIndexes(rows: readonly Row[], exact = false): IntrospectedIndex[] {
|
|
81
|
-
const byName = new Map<string, { unique: boolean; columns: string[]; method: "btree" | undefined; backingConstraint: boolean; unsupported: boolean; predicate?:
|
|
81
|
+
const byName = new Map<string, { unique: boolean; columns: string[]; method: "btree" | undefined; backingConstraint: boolean; unsupported: boolean; predicate?: CheckAst }>();
|
|
82
82
|
for (const row of rows) {
|
|
83
83
|
const name = String(row.index_name); let index = byName.get(name);
|
|
84
|
-
if (!index) { index = { unique: row.is_unique === true, columns: [], method: String(row.method) === "btree" ? "btree" : undefined, backingConstraint: row.backing_constraint === true, unsupported: row.is_expression === true || row.is_valid !== true || row.is_ready !== true }; if (row.has_predicate === true)
|
|
84
|
+
if (!index) { index = { unique: row.is_unique === true, columns: [], method: String(row.method) === "btree" ? "btree" : undefined, backingConstraint: row.backing_constraint === true, unsupported: row.is_expression === true || row.is_valid !== true || row.is_ready !== true }; if (row.has_predicate === true) { const predicate = typeof row.predicate === "string" ? parsePostgresPredicate(row.predicate) : undefined; if (predicate === undefined) index.unsupported = true; else index.predicate = predicate; } byName.set(name, index); }
|
|
85
85
|
if (row.column_name == null || (exact && row.is_include === true)) index.unsupported = true; else index.columns.push(String(row.column_name));
|
|
86
86
|
}
|
|
87
87
|
return [...byName.entries()].map(([name, value]) => ({ name, unique: value.unique, columns: value.columns, method: value.method, backingConstraint: value.backingConstraint, predicate: value.predicate, unsupported: value.unsupported }));
|
|
@@ -100,8 +100,8 @@ export function collectPostgresForeignKeys(rows: readonly Row[], exact = false):
|
|
|
100
100
|
function postgresAction(code: string): { readonly value: string; readonly unsupported: boolean } { switch (code) { case "a": return { value: "noAction", unsupported: false }; case "r": return { value: "restrict", unsupported: false }; case "c": return { value: "cascade", unsupported: false }; case "n": return { value: "setNull", unsupported: false }; default: return { value: "unknown", unsupported: true }; } }
|
|
101
101
|
|
|
102
102
|
export function canonicalPostgresCheck(name: string, definition: string): { readonly name: string; readonly expression: unknown; readonly unsupported: boolean } {
|
|
103
|
-
const body = definition.trim().match(/^CHECK\s*\((.*)\)$/isu)?.[1]
|
|
104
|
-
const expression = body === undefined ? undefined :
|
|
103
|
+
const body = definition.trim().match(/^CHECK\s*\((.*)\)$/isu)?.[1];
|
|
104
|
+
const expression = body === undefined ? undefined : parsePostgresPredicate(body);
|
|
105
105
|
return { name, expression: expression ?? { kind: "unsupported" }, unsupported: expression === undefined };
|
|
106
106
|
}
|
|
107
107
|
|
|
@@ -125,17 +125,17 @@ export function projectCheckAstIdentifiers(ast: CheckAst, resolve: (propertyName
|
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
127
|
/** Validates the complete closed AST against the compiled entity columns before DDL/snapshot rendering. */
|
|
128
|
-
export function validateCheckAst(ast: CheckAst, properties: readonly PropertyModel[]): void {
|
|
128
|
+
export function validateCheckAst(ast: CheckAst, properties: readonly PropertyModel[], subject = "CHECK"): void {
|
|
129
129
|
const byName = new Map(properties.map((property) => [property.propertyName, property]));
|
|
130
130
|
const property = (name: string): PropertyModel => {
|
|
131
131
|
const value = byName.get(name);
|
|
132
132
|
if (!value)
|
|
133
|
-
throw new ModelBuildError(
|
|
133
|
+
throw new ModelBuildError(`${subject} references unknown property "${name}".`);
|
|
134
134
|
return value;
|
|
135
135
|
};
|
|
136
136
|
const validateLiteral = (column: PropertyModel, value: CheckScalar): void => {
|
|
137
137
|
if (value === null)
|
|
138
|
-
throw new ModelBuildError(
|
|
138
|
+
throw new ModelBuildError(`${subject} comparison for "${column.propertyName}" must use isNull()/isNotNull().`);
|
|
139
139
|
const compatible = (column.type === "integer" || column.type === "real")
|
|
140
140
|
? typeof value === "number"
|
|
141
141
|
: (column.type === "text" || column.type === "datetime")
|
|
@@ -144,7 +144,7 @@ export function validateCheckAst(ast: CheckAst, properties: readonly PropertyMod
|
|
|
144
144
|
? typeof value === "boolean"
|
|
145
145
|
: false;
|
|
146
146
|
if (!compatible)
|
|
147
|
-
throw new ModelBuildError(
|
|
147
|
+
throw new ModelBuildError(`${subject} literal is incompatible with property "${column.propertyName}".`);
|
|
148
148
|
};
|
|
149
149
|
const visit = (node: CheckAst): void => {
|
|
150
150
|
switch (node.kind) {
|
|
@@ -162,7 +162,7 @@ export function validateCheckAst(ast: CheckAst, properties: readonly PropertyMod
|
|
|
162
162
|
case "in": {
|
|
163
163
|
const left = property(node.left);
|
|
164
164
|
if (!node.values.length || node.values.length > MAX_CHECK_IN_VALUES)
|
|
165
|
-
throw new ModelBuildError(
|
|
165
|
+
throw new ModelBuildError(`${subject} IN requires between 1 and ${MAX_CHECK_IN_VALUES} literals.`);
|
|
166
166
|
for (const value of node.values)
|
|
167
167
|
validateLiteral(left, value);
|
|
168
168
|
return;
|
|
@@ -173,13 +173,13 @@ export function validateCheckAst(ast: CheckAst, properties: readonly PropertyMod
|
|
|
173
173
|
if (rightName !== undefined) {
|
|
174
174
|
const right = property(rightName);
|
|
175
175
|
if (left.type !== right.type)
|
|
176
|
-
throw new ModelBuildError(
|
|
176
|
+
throw new ModelBuildError(`${subject} column comparison requires identical column types ("${left.propertyName}", "${right.propertyName}").`);
|
|
177
177
|
}
|
|
178
178
|
else {
|
|
179
179
|
validateLiteral(left, node.right as CheckScalar);
|
|
180
180
|
}
|
|
181
181
|
if ([">", ">=", "<", "<="].includes(node.op) && !["integer", "real", "text", "datetime"].includes(left.type)) {
|
|
182
|
-
throw new ModelBuildError(
|
|
182
|
+
throw new ModelBuildError(`${subject} ordered comparison is not supported for property "${left.propertyName}".`);
|
|
183
183
|
}
|
|
184
184
|
}
|
|
185
185
|
}
|
|
@@ -215,6 +215,13 @@ export function parseRenderedCheck(value: string): CheckAst | undefined {
|
|
|
215
215
|
const left = parseIdentifier(nullable[1]!);
|
|
216
216
|
return left ? { kind: "null", left, not: Boolean(nullable[2]) } : undefined;
|
|
217
217
|
}
|
|
218
|
+
// PostgreSQL stores IN (...) as = ANY (ARRAY[...]).
|
|
219
|
+
const anyMatch = source.match(/^(.+?)\s*=\s*ANY\s*\(\s*ARRAY\[(.*)\]\s*\)$/isu);
|
|
220
|
+
if (anyMatch) {
|
|
221
|
+
const left = parseIdentifier(anyMatch[1]!);
|
|
222
|
+
const values = splitLiterals(anyMatch[2]!);
|
|
223
|
+
return left && values ? { kind: "in", left, values } : undefined;
|
|
224
|
+
}
|
|
218
225
|
const inMatch = source.match(/^(.+?)\s+IN\s*\((.*)\)$/isu);
|
|
219
226
|
if (inMatch) {
|
|
220
227
|
const left = parseIdentifier(inMatch[1]!);
|
|
@@ -228,6 +235,46 @@ export function parseRenderedCheck(value: string): CheckAst | undefined {
|
|
|
228
235
|
const right = parseOperand(comparison[3]!);
|
|
229
236
|
return left && right !== undefined ? { kind: "compare", left, op: comparison[2]! as "=" | "<>" | ">" | ">=" | "<" | "<=", right } : undefined;
|
|
230
237
|
}
|
|
238
|
+
/**
|
|
239
|
+
* Reads an expression PostgreSQL deparsed (`pg_get_expr`, a partial index
|
|
240
|
+
* predicate): casts like `'x'::text` are dropped outside string literals.
|
|
241
|
+
* Unknown syntax returns undefined.
|
|
242
|
+
*/
|
|
243
|
+
export function parsePostgresPredicate(value: string): CheckAst | undefined {
|
|
244
|
+
let result = "";
|
|
245
|
+
let quoted = false;
|
|
246
|
+
for (let i = 0; i < value.length; i++) {
|
|
247
|
+
const char = value[i]!;
|
|
248
|
+
if (char === "'") quoted = !quoted;
|
|
249
|
+
if (!quoted && char === ":" && value[i + 1] === ":") {
|
|
250
|
+
const cast = value.slice(i).match(/^::(?:timestamp with(?:out)? time zone|time with(?:out)? time zone|double precision|character varying|[A-Za-z_][A-Za-z0-9_]*)(?:\[\])?/u);
|
|
251
|
+
if (cast) { i += cast[0].length - 1; continue; }
|
|
252
|
+
}
|
|
253
|
+
result += char;
|
|
254
|
+
}
|
|
255
|
+
return parseRenderedCheck(result);
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* One form for equal conditions: `(a AND b) AND c` and `a AND (b AND c)` are
|
|
259
|
+
* the same chain, as PostgreSQL prints it. Used to compare a declared
|
|
260
|
+
* condition with the one read from the catalog.
|
|
261
|
+
*/
|
|
262
|
+
export function normalizeCheckAst(ast: CheckAst): CheckAst {
|
|
263
|
+
switch (ast.kind) {
|
|
264
|
+
case "and":
|
|
265
|
+
case "or": {
|
|
266
|
+
const items: CheckAst[] = [];
|
|
267
|
+
const collect = (node: CheckAst): void => {
|
|
268
|
+
if (node.kind === ast.kind) { collect(node.left); collect(node.right); }
|
|
269
|
+
else items.push(normalizeCheckAst(node));
|
|
270
|
+
};
|
|
271
|
+
collect(ast);
|
|
272
|
+
return items.reduce((left, right) => ({ kind: ast.kind, left, right }));
|
|
273
|
+
}
|
|
274
|
+
case "not": return { kind: "not", inner: normalizeCheckAst(ast.inner) };
|
|
275
|
+
default: return ast;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
231
278
|
function stripOuter(value: string): string {
|
|
232
279
|
while (value.startsWith("(") && value.endsWith(")") && balancedOuter(value))
|
|
233
280
|
value = value.slice(1, -1).trim();
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { OrmExpectedSchema } from "./ExpectedSchema";
|
|
2
2
|
import type { IntrospectedSchema, IntrospectedTable } from "./introspection";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
|
-
|
|
4
|
+
import { normalizeCheckAst, type CheckAst } from "./CheckExpression";
|
|
5
|
+
export type SchemaDifferenceCode = "table.missing" | "column.missing" | "column.unexpected" | "column.type" | "column.nullability" | "column.default" | "column.generation" | "primaryKey.missing" | "primaryKey.unexpected" | "primaryKey.name" | "primaryKey.columns" | "index.missing" | "index.unexpected" | "index.name" | "index.columns" | "index.uniqueness" | "index.method" | "index.predicate" | "index.unsupportedShape" | "foreignKey.missing" | "foreignKey.unexpected" | "foreignKey.name" | "foreignKey.columns" | "foreignKey.target" | "foreignKey.actions" | "foreignKey.deferrable" | "foreignKey.match" | "check.missing" | "check.unexpected" | "check.name" | "check.expression" | "catalog.unsupported";
|
|
5
6
|
export interface SafeSchemaDescriptor {
|
|
6
7
|
readonly kind: "absent" | "present" | "canonicalType" | "canonicalDefault" | "canonicalGeneration" | "canonicalExpression" | "orderedColumns" | "referentialActions" | "unsupported";
|
|
7
8
|
readonly value?: string;
|
|
@@ -85,7 +86,7 @@ export class ExactSchemaVerifier {
|
|
|
85
86
|
const ordinary = actual.indexes.filter((index) => !index.backingConstraint);
|
|
86
87
|
const expectedIndexes = new Map(expected.indexes.map((index) => [index.name, index]));
|
|
87
88
|
for (const index of ordinary) {
|
|
88
|
-
if (index.unsupported || index.method !== "btree" || index.
|
|
89
|
+
if (index.unsupported || index.method !== "btree" || index.columns.length === 0)
|
|
89
90
|
add("index.unsupportedShape", table, index.name, undefined, unsupported);
|
|
90
91
|
else if (!expectedIndexes.has(index.name))
|
|
91
92
|
add("index.unexpected", table, index.name, absent, present);
|
|
@@ -98,7 +99,7 @@ export class ExactSchemaVerifier {
|
|
|
98
99
|
}
|
|
99
100
|
if (found.name !== name)
|
|
100
101
|
add("index.name", table, name, value("present", name), value("present", found.name));
|
|
101
|
-
if (found.unsupported || found.
|
|
102
|
+
if (found.unsupported || found.columns.length === 0) {
|
|
102
103
|
add("catalog.unsupported", table, name, undefined, unsupported);
|
|
103
104
|
continue;
|
|
104
105
|
}
|
|
@@ -108,6 +109,8 @@ export class ExactSchemaVerifier {
|
|
|
108
109
|
add("index.columns", table, name, value("orderedColumns", index.columns), value("orderedColumns", found.columns));
|
|
109
110
|
if (found.method !== "btree")
|
|
110
111
|
add("index.method", table, name, value("present", "btree"), value("present", found.method ?? "unknown"));
|
|
112
|
+
if (!samePredicate(found.predicate, index.predicate))
|
|
113
|
+
add("index.predicate", table, name, value("canonicalExpression", index.predicate ?? null), value("canonicalExpression", found.predicate ?? null));
|
|
111
114
|
}
|
|
112
115
|
const foreignKeys = actual.foreignKeys ?? [];
|
|
113
116
|
const expectedForeignKeys = new Map(expected.foreignKeys.map((foreignKey) => [foreignKey.name, foreignKey]));
|
|
@@ -148,12 +151,22 @@ export class ExactSchemaVerifier {
|
|
|
148
151
|
else {
|
|
149
152
|
if (found.name !== name)
|
|
150
153
|
add("check.name", table, name, value("present", name), value("present", found.name));
|
|
151
|
-
if (
|
|
154
|
+
if (!sameExpression(found.expression, check.expression))
|
|
152
155
|
add("check.expression", table, name, value("canonicalExpression", check.expression), value("canonicalExpression", found.expression));
|
|
153
156
|
}
|
|
154
157
|
}
|
|
155
158
|
}
|
|
156
159
|
}
|
|
160
|
+
/** A CHECK read from the catalog against the declared one; AND/OR chains compare flat. */
|
|
161
|
+
function sameExpression(found: unknown, declared: unknown): boolean {
|
|
162
|
+
const known = (value: unknown): value is CheckAst => typeof value === "object" && value !== null && (value as { kind?: unknown }).kind !== "unsupported";
|
|
163
|
+
return known(found) && known(declared) ? stable(normalizeCheckAst(found)) === stable(normalizeCheckAst(declared)) : stable(found) === stable(declared);
|
|
164
|
+
}
|
|
165
|
+
/** @internal Two partial index conditions (or their absence) are the same. */
|
|
166
|
+
export function samePredicate(a: CheckAst | undefined, b: CheckAst | undefined): boolean {
|
|
167
|
+
if (a === undefined || b === undefined) return a === b;
|
|
168
|
+
return stable(normalizeCheckAst(a)) === stable(normalizeCheckAst(b));
|
|
169
|
+
}
|
|
157
170
|
function same<T>(a: readonly T[], b: readonly T[]): boolean {
|
|
158
171
|
return a.length === b.length && a.every((entry, index) => stable(entry) === stable(b[index]));
|
|
159
172
|
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import type { EntityModel } from "../Metadata/types";
|
|
2
2
|
import type { OrmModel } from "../Metadata/OrmModel";
|
|
3
3
|
import type { CanonicalDefault, GenerationStrategy } from "./introspection";
|
|
4
|
-
import { projectCheckAstIdentifiers } from "./CheckExpression";
|
|
4
|
+
import { projectCheckAstIdentifiers, type CheckAst } from "./CheckExpression";
|
|
5
5
|
import { crossUnitForeignKey, physicalColumnTypes } from "./physicalColumnTypes";
|
|
6
6
|
import { SchemaAdmissionError } from "../errors";
|
|
7
7
|
import { physicalTableIdentity } 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
|
-
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 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 }[] }
|
|
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 }[] }
|
|
11
11
|
export interface OrmExpectedSchema { readonly tables: readonly ExpectedTable[] }
|
|
12
12
|
|
|
13
13
|
const schemaOf = (model: EntityModel) => physicalTableIdentity(model).schema?.trim() || "public";
|
|
@@ -41,5 +41,5 @@ function compileTable(model: EntityModel, byCtor: ReadonlyMap<EntityModel["ctor"
|
|
|
41
41
|
if (!target) throw crossUnitForeignKey(model, foreignKey.properties, foreignKey.target());
|
|
42
42
|
return { name: foreignKey.name ?? `fk_${table}_${foreignKey.properties.join("_")}`, 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.keyName ?? `pk_${table}`, columns: Object.freeze(model.key.map((property) => property.columnName)) }), indexes: Object.freeze(model.indexes.map((index) => Object.freeze({
|
|
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
|
}
|
|
@@ -70,7 +70,7 @@ export function renderSafeAdditivePostgres(operation: SafeAdditiveSchemaOperatio
|
|
|
70
70
|
case "addColumn": return `ALTER TABLE ${tableName(operation.table)} ADD COLUMN ${column(operation.column)}`;
|
|
71
71
|
case "addCheck": return `ALTER TABLE ${tableName(operation.table)} ADD CONSTRAINT ${q(operation.check.name)} CHECK (${renderCheck(operation.check.expression as never, q)})`;
|
|
72
72
|
case "addForeignKey": return `ALTER TABLE ${tableName(operation.table)} ADD CONSTRAINT ${q(operation.foreignKey.name)} FOREIGN KEY (${operation.foreignKey.columns.map(q).join(", ")}) REFERENCES ${q(operation.foreignKey.target.schema)}.${q(operation.foreignKey.target.table)} (${operation.foreignKey.targetColumns.map(q).join(", ")}) ON DELETE ${action(operation.foreignKey.onDelete)} ON UPDATE ${action(operation.foreignKey.onUpdate)}`;
|
|
73
|
-
case "createIndex": return `CREATE ${operation.index.unique ? "UNIQUE " : ""}INDEX ${q(operation.index.name)} ON ${tableName(operation.table)} (${operation.index.columns.map(q).join(", ")})`;
|
|
73
|
+
case "createIndex": return `CREATE ${operation.index.unique ? "UNIQUE " : ""}INDEX ${q(operation.index.name)} ON ${tableName(operation.table)} (${operation.index.columns.map(q).join(", ")})${operation.index.predicate === undefined ? "" : ` WHERE ${renderCheck(operation.index.predicate, q)}`}`;
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
function createTable(table: ExpectedTable): string {
|
|
@@ -2,7 +2,8 @@ import type { DatabaseProvider, SchemaAdmissionScope } from "../Providers/types"
|
|
|
2
2
|
import { SchemaAdmissionError, SchemaMigrationRequiredError, SchemaVerificationError } from "../errors";
|
|
3
3
|
import type { OrmModel } from "../Metadata/OrmModel";
|
|
4
4
|
import { compileExpectedSchema, type OrmExpectedSchema } from "./ExpectedSchema";
|
|
5
|
-
import { ExactSchemaVerifier, type SchemaVerificationResult } from "./ExactSchemaVerifier";
|
|
5
|
+
import { ExactSchemaVerifier, samePredicate, type SchemaVerificationResult } from "./ExactSchemaVerifier";
|
|
6
|
+
import { renderCheck } from "./CheckExpression";
|
|
6
7
|
import { classifySafeAdditive, renderSafeAdditivePostgres, type SafeAdditiveSchemaOperation } from "./SafeAdditiveSchema";
|
|
7
8
|
import type { IntrospectedSchema } from "./introspection";
|
|
8
9
|
|
|
@@ -113,7 +114,7 @@ function describeOperation(operation: SafeAdditiveSchemaOperation): string {
|
|
|
113
114
|
case "addColumn": return `add column ${tableName(operation.table.schema, operation.table.table)}.${operation.column.column}`;
|
|
114
115
|
case "addCheck": return `add check ${operation.check.name} on ${tableName(operation.table.schema, operation.table.table)}`;
|
|
115
116
|
case "addForeignKey": return `add foreign key ${operation.foreignKey.name} on ${tableName(operation.table.schema, operation.table.table)}`;
|
|
116
|
-
case "createIndex": return `create ${operation.index.unique ? "unique " : ""}index ${operation.index.name} on ${tableName(operation.table.schema, operation.table.table)} (${operation.index.columns.join(", ")})`;
|
|
117
|
+
case "createIndex": return `create ${operation.index.unique ? "unique " : ""}index ${operation.index.name} on ${tableName(operation.table.schema, operation.table.table)} (${operation.index.columns.join(", ")})${operation.index.predicate === undefined ? "" : ` where ${renderCheck(operation.index.predicate, (name) => name)}`}`;
|
|
117
118
|
}
|
|
118
119
|
}
|
|
119
120
|
|
|
@@ -135,7 +136,7 @@ function alignIndexNames(expected: OrmExpectedSchema, actual: IntrospectedSchema
|
|
|
135
136
|
let indexes = found.indexes;
|
|
136
137
|
for (const index of table.indexes) {
|
|
137
138
|
if (indexes.some((item) => item.name === index.name)) continue;
|
|
138
|
-
const match = indexes.find((item) => !item.backingConstraint && !item.unsupported &&
|
|
139
|
+
const match = indexes.find((item) => !item.backingConstraint && !item.unsupported && samePredicate(item.predicate, index.predicate) && item.method === "btree" && !expectedNames.has(item.name)
|
|
139
140
|
&& item.unique === index.unique && item.columns.length === index.columns.length && item.columns.every((column, position) => column === index.columns[position]));
|
|
140
141
|
if (!match) continue;
|
|
141
142
|
warnings.push(`table ${quote(table.schema)}.${quote(table.table)}: index is named ${quote(match.name)}, the model expects ${quote(index.name)}. `
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { entityStorageKey } from "./tableKey";
|
|
2
2
|
import type { ColumnType, EntityModel, IndexModel, PropertyModel } from "../Metadata/types";
|
|
3
3
|
import type { IntrospectedSchema, IntrospectedTable } from "./introspection";
|
|
4
|
+
import { projectCheckAstIdentifiers, type CheckAst } from "./CheckExpression";
|
|
5
|
+
import { samePredicate } from "./ExactSchemaVerifier";
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Additive schema change operation. Destructive changes (drop/type change) never
|
|
@@ -89,7 +91,7 @@ export class SchemaDiffer {
|
|
|
89
91
|
for (const index of model.indexes) {
|
|
90
92
|
if (existing.has(index.name)) continue;
|
|
91
93
|
// The same index under another name (for example from before 0.98.20) is not duplicated.
|
|
92
|
-
const renamed = table.indexes.find((item) => !declared.has(item.name) && !item.backingConstraint && !item.predicate && item.unique === index.unique
|
|
94
|
+
const renamed = table.indexes.find((item) => !declared.has(item.name) && !item.backingConstraint && !item.unsupported && samePredicate(item.predicate, projectedWhere(model, index)) && item.unique === index.unique
|
|
93
95
|
&& item.columns.length === index.columns.length && item.columns.every((column, position) => column === index.columns[position]));
|
|
94
96
|
if (renamed) {
|
|
95
97
|
warnings.push(`table "${model.tableName}": index "${renamed.name}" matches "${index.name}" from the model except for its name (left untouched; to align it run: ALTER INDEX "${renamed.name}" RENAME TO "${index.name}";).`);
|
|
@@ -99,3 +101,8 @@ export class SchemaDiffer {
|
|
|
99
101
|
}
|
|
100
102
|
}
|
|
101
103
|
}
|
|
104
|
+
|
|
105
|
+
/** The index condition over column names, as the catalog reports it. */
|
|
106
|
+
function projectedWhere(model: EntityModel, index: EntityModel["indexes"][number]): CheckAst | undefined {
|
|
107
|
+
return index.where === undefined ? undefined : projectCheckAstIdentifiers(index.where, (name) => model.propertyByName(name)!.columnName);
|
|
108
|
+
}
|
|
@@ -18,6 +18,7 @@ const descriptions: Record<SchemaDifferenceCode, string> = {
|
|
|
18
18
|
"index.columns": "index columns or their order differ",
|
|
19
19
|
"index.uniqueness": "index uniqueness differs",
|
|
20
20
|
"index.method": "index method differs",
|
|
21
|
+
"index.predicate": "partial index condition (where) differs",
|
|
21
22
|
"index.unsupportedShape": "index shape is unsupported by exact schema admission",
|
|
22
23
|
"foreignKey.missing": "foreign key is missing",
|
|
23
24
|
"foreignKey.unexpected": "foreign key is not declared in the ORM model",
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CheckAst } from "./CheckExpression";
|
|
1
2
|
/**
|
|
2
3
|
* Snapshot of the actual database schema obtained by introspection. The source
|
|
3
4
|
* of the "current" state for the additive auto-migration (the target is the
|
|
@@ -22,7 +23,8 @@ export interface IntrospectedIndex {
|
|
|
22
23
|
readonly unique: boolean;
|
|
23
24
|
readonly method?: "btree";
|
|
24
25
|
readonly backingConstraint?: boolean;
|
|
25
|
-
|
|
26
|
+
/** Partial index condition read from the catalog; unreadable ones mark the index `unsupported`. */
|
|
27
|
+
readonly predicate?: CheckAst;
|
|
26
28
|
readonly unsupported?: boolean;
|
|
27
29
|
}
|
|
28
30
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bazis",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.115.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",
|