@rebasepro/server-postgres 0.12.1-canary.gf5f1d39 → 0.13.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/dist/PostgresBackendDriver.d.ts +1 -1
- package/dist/PostgresBootstrapper.d.ts +25 -1
- package/dist/auth/services.d.ts +21 -0
- package/dist/backup/backup-service.d.ts +10 -1
- package/dist/backup/pg-tools.d.ts +47 -0
- package/dist/backup-service-CD8o_1Sl.js +8999 -0
- package/dist/backup-service-CD8o_1Sl.js.map +1 -0
- package/dist/cli-helpers.d.ts +39 -0
- package/dist/connection-BuZ97wsr.js +250 -0
- package/dist/connection-BuZ97wsr.js.map +1 -0
- package/dist/connection.d.ts +42 -0
- package/dist/ensure-collection-policies-ViG8XiPn.js +57 -0
- package/dist/ensure-collection-policies-ViG8XiPn.js.map +1 -0
- package/dist/ensure-collection-tables-CBQdOETu.js +650 -0
- package/dist/ensure-collection-tables-CBQdOETu.js.map +1 -0
- package/dist/index.es.js +1231 -10031
- package/dist/index.es.js.map +1 -1
- package/dist/policy-CeA1JcxP.js +105 -0
- package/dist/policy-CeA1JcxP.js.map +1 -0
- package/dist/schema/auth-schema.d.ts +83 -144
- package/dist/schema/ensure-collection-policies.d.ts +60 -0
- package/dist/schema/ensure-collection-tables.d.ts +44 -2
- package/dist/schema/generate-postgres-ddl-logic.d.ts +135 -1
- package/dist/schema/introspect-db-constraints.d.ts +57 -0
- package/dist/schema/introspect-db-logic.d.ts +94 -5
- package/dist/schema/introspect-db-queries.d.ts +119 -0
- package/dist/schema/introspect-db-structure.d.ts +263 -0
- package/dist/schema/introspect-db-types.d.ts +11 -0
- package/dist/services/FetchService.d.ts +4 -1
- package/dist/services/RelationService.d.ts +24 -1
- package/dist/services/channel-bus/index.d.ts +1 -7
- package/dist/services/collection-helpers.d.ts +36 -2
- package/dist/services/dataService.d.ts +3 -1
- package/dist/services/row-pipeline.d.ts +1 -1
- package/dist/{src-BbFOPJ1S.js → src-DlPBctw_.js} +299 -173
- package/dist/src-DlPBctw_.js.map +1 -0
- package/dist/{src-Zqwaw3P5.js → src-DoU9yPqq.js} +3 -159
- package/dist/src-DoU9yPqq.js.map +1 -0
- package/dist/utils/connection-string.d.ts +29 -0
- package/dist/utils/drizzle-conditions.d.ts +5 -4
- package/dist/utils/pg-error-utils.d.ts +35 -0
- package/dist/websocket-B2LsrINK.js +530 -0
- package/dist/websocket-B2LsrINK.js.map +1 -0
- package/package.json +14 -14
- package/src/PostgresAdapter.ts +21 -2
- package/src/PostgresBackendDriver.ts +4 -0
- package/src/PostgresBootstrapper.ts +192 -33
- package/src/auth/ensure-tables.ts +164 -9
- package/src/auth/services.ts +24 -2
- package/src/backup/backup-cli.ts +41 -2
- package/src/backup/backup-service.ts +38 -5
- package/src/backup/pg-tools.ts +96 -3
- package/src/cli-helpers.ts +70 -0
- package/src/cli.ts +44 -26
- package/src/collections/validate-relations.ts +15 -0
- package/src/connection.ts +73 -0
- package/src/data-transformer.ts +9 -3
- package/src/databasePoolManager.ts +5 -2
- package/src/schema/auth-schema.ts +30 -19
- package/src/schema/ensure-collection-policies.ts +105 -0
- package/src/schema/ensure-collection-tables.test.ts +105 -9
- package/src/schema/ensure-collection-tables.ts +220 -32
- package/src/schema/generate-drizzle-schema-logic.ts +42 -6
- package/src/schema/generate-postgres-ddl-logic.ts +382 -19
- package/src/schema/introspect-db-constraints.ts +385 -0
- package/src/schema/introspect-db-inference.ts +18 -8
- package/src/schema/introspect-db-logic.ts +385 -71
- package/src/schema/introspect-db-queries.ts +326 -0
- package/src/schema/introspect-db-structure.ts +670 -0
- package/src/schema/introspect-db-types.ts +56 -0
- package/src/schema/introspect-db.ts +37 -80
- package/src/schema/introspect-runtime.test.ts +56 -8
- package/src/schema/introspect-runtime.ts +31 -9
- package/src/security/policy-drift.test.ts +11 -3
- package/src/services/BranchService.ts +66 -28
- package/src/services/FetchService.ts +90 -14
- package/src/services/PersistService.ts +20 -6
- package/src/services/RelationService.ts +249 -48
- package/src/services/channel-bus/index.ts +0 -9
- package/src/services/collection-helpers.ts +69 -3
- package/src/services/dataService.ts +3 -1
- package/src/services/realtimeService.ts +3 -3
- package/src/services/row-pipeline.ts +1 -1
- package/src/utils/connection-string.ts +58 -0
- package/src/utils/drizzle-conditions.ts +31 -6
- package/src/utils/pg-error-utils.ts +65 -0
- package/src/websocket.ts +18 -9
- package/dist/chunk-DSJWtz9O.js +0 -40
- package/dist/ensure-collection-tables-CNTcZGvn.js +0 -304
- package/dist/ensure-collection-tables-CNTcZGvn.js.map +0 -1
- package/dist/src-BbFOPJ1S.js.map +0 -1
- package/dist/src-Zqwaw3P5.js.map +0 -1
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading validation rules out of CHECK constraints.
|
|
3
|
+
*
|
|
4
|
+
* A CHECK constraint is the schema author stating a rule the database already
|
|
5
|
+
* enforces. Introspection has never read them, so a generated form let the user
|
|
6
|
+
* type a value the database was always going to reject — the rule was written
|
|
7
|
+
* down, in the schema, and the UI asked anyway.
|
|
8
|
+
*
|
|
9
|
+
* Everything here parses the *normalized* text `pg_get_constraintdef` produces,
|
|
10
|
+
* not what the author typed: Postgres re-renders the expression from its parse
|
|
11
|
+
* tree, so `CHECK (price > 0)` on a `numeric` column always comes back as
|
|
12
|
+
* `CHECK ((price > (0)::numeric))`. That normalization is what makes a
|
|
13
|
+
* string-level parser tractable — the input is generated, and the shapes are
|
|
14
|
+
* few.
|
|
15
|
+
*
|
|
16
|
+
* Constraints this cannot read are skipped in full. A partially-understood
|
|
17
|
+
* constraint is worse than an unread one: it would produce validation that
|
|
18
|
+
* *narrows* differently from the database, so a value the UI accepts still
|
|
19
|
+
* fails on write, or — worse — a value the database allows is refused in the
|
|
20
|
+
* form with no way to see why.
|
|
21
|
+
*
|
|
22
|
+
* Pure module: no I/O, no logging.
|
|
23
|
+
*/
|
|
24
|
+
import type { CheckConstraintRow } from "./introspect-db-logic";
|
|
25
|
+
|
|
26
|
+
/** What a table's CHECK constraints say about one column. */
|
|
27
|
+
export interface ColumnCheckFacts {
|
|
28
|
+
/** Allowed values, from `IN (…)` / `= ANY (ARRAY[…])` / `= 'literal'`. */
|
|
29
|
+
enumValues?: string[];
|
|
30
|
+
/** `x >= n` */
|
|
31
|
+
min?: number;
|
|
32
|
+
/** `x <= n` */
|
|
33
|
+
max?: number;
|
|
34
|
+
/** `x > n` */
|
|
35
|
+
moreThan?: number;
|
|
36
|
+
/** `x < n` */
|
|
37
|
+
lessThan?: number;
|
|
38
|
+
/** `length(x) >= n` */
|
|
39
|
+
lengthMin?: number;
|
|
40
|
+
/** `length(x) <= n` */
|
|
41
|
+
lengthMax?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Per-table, per-column facts. */
|
|
45
|
+
export type CheckFactsByTable = Map<string, Map<string, ColumnCheckFacts>>;
|
|
46
|
+
|
|
47
|
+
/** The length functions whose argument is the column being constrained. */
|
|
48
|
+
const LENGTH_FUNCTIONS = new Set(["length", "char_length", "character_length", "octet_length"]);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Splits on a separator that appears at paren depth 0 and outside string
|
|
52
|
+
* literals.
|
|
53
|
+
*
|
|
54
|
+
* Depth tracking is the whole job: `ARRAY['a', 'b']` contains a comma that must
|
|
55
|
+
* not split the enclosing argument list, and `'it''s'` contains a quote that
|
|
56
|
+
* must not end the literal.
|
|
57
|
+
*/
|
|
58
|
+
function splitTopLevel(input: string, separator: string): string[] {
|
|
59
|
+
const parts: string[] = [];
|
|
60
|
+
let depth = 0;
|
|
61
|
+
let inString = false;
|
|
62
|
+
let current = "";
|
|
63
|
+
|
|
64
|
+
for (let i = 0; i < input.length; i++) {
|
|
65
|
+
const ch = input[i];
|
|
66
|
+
|
|
67
|
+
if (inString) {
|
|
68
|
+
current += ch;
|
|
69
|
+
if (ch === "'") {
|
|
70
|
+
// '' inside a literal is an escaped quote, not the end of it.
|
|
71
|
+
if (input[i + 1] === "'") {
|
|
72
|
+
current += input[++i];
|
|
73
|
+
} else {
|
|
74
|
+
inString = false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (ch === "'") {
|
|
81
|
+
inString = true;
|
|
82
|
+
current += ch;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (ch === "(" || ch === "[") depth++;
|
|
86
|
+
if (ch === ")" || ch === "]") depth--;
|
|
87
|
+
|
|
88
|
+
if (depth === 0 && input.startsWith(separator, i)) {
|
|
89
|
+
// A word separator must not match inside an identifier: "AND" is a
|
|
90
|
+
// separator in `a AND b` but not in `brand = 1`.
|
|
91
|
+
const before = input[i - 1];
|
|
92
|
+
const after = input[i + separator.length];
|
|
93
|
+
const wordish = /[A-Za-z]/.test(separator[0]);
|
|
94
|
+
const boundedOk = !wordish || ((before === undefined || !/[A-Za-z0-9_]/.test(before)) &&
|
|
95
|
+
(after === undefined || !/[A-Za-z0-9_]/.test(after)));
|
|
96
|
+
if (boundedOk) {
|
|
97
|
+
parts.push(current);
|
|
98
|
+
current = "";
|
|
99
|
+
i += separator.length - 1;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
current += ch;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
parts.push(current);
|
|
108
|
+
return parts.map((p) => p.trim()).filter((p) => p.length > 0);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Removes one layer of wrapping parentheses, repeatedly, when balanced. */
|
|
112
|
+
export function unwrapParens(input: string): string {
|
|
113
|
+
let s = input.trim();
|
|
114
|
+
while (s.startsWith("(") && s.endsWith(")")) {
|
|
115
|
+
let depth = 0;
|
|
116
|
+
let balanced = true;
|
|
117
|
+
let inString = false;
|
|
118
|
+
for (let i = 0; i < s.length; i++) {
|
|
119
|
+
const ch = s[i];
|
|
120
|
+
if (inString) {
|
|
121
|
+
if (ch === "'") {
|
|
122
|
+
if (s[i + 1] === "'") i++;
|
|
123
|
+
else inString = false;
|
|
124
|
+
}
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (ch === "'") { inString = true; continue; }
|
|
128
|
+
if (ch === "(") depth++;
|
|
129
|
+
else if (ch === ")") {
|
|
130
|
+
depth--;
|
|
131
|
+
// The opening paren closed before the end, so the outer pair is
|
|
132
|
+
// not a wrapper: `(a) AND (b)`.
|
|
133
|
+
if (depth === 0 && i < s.length - 1) { balanced = false; break; }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (!balanced) break;
|
|
137
|
+
s = s.slice(1, -1).trim();
|
|
138
|
+
}
|
|
139
|
+
return s;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Drops trailing `::type` casts, including array and quoted forms. */
|
|
143
|
+
export function stripCasts(input: string): string {
|
|
144
|
+
let s = unwrapParens(input);
|
|
145
|
+
let previous: string;
|
|
146
|
+
do {
|
|
147
|
+
previous = s;
|
|
148
|
+
s = unwrapParens(
|
|
149
|
+
s.replace(/::\s*"?[A-Za-z_][A-Za-z0-9_ ]*"?(\s*\(\s*\d+\s*(,\s*\d+\s*)?\))?(\s*\[\s*\])*\s*$/, "").trim()
|
|
150
|
+
);
|
|
151
|
+
} while (s !== previous);
|
|
152
|
+
return s;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** A bare, unquoted column reference — anything else is an expression. */
|
|
156
|
+
function asColumnName(input: string): string | null {
|
|
157
|
+
const s = stripCasts(input);
|
|
158
|
+
if (/^[a-z_][a-z0-9_$]*$/i.test(s)) return s;
|
|
159
|
+
// A quoted identifier keeps its case: "userId".
|
|
160
|
+
const quoted = s.match(/^"([^"]+)"$/);
|
|
161
|
+
return quoted ? quoted[1] : null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** `length(col)` and friends → the column they measure. */
|
|
165
|
+
function asLengthOfColumn(input: string): string | null {
|
|
166
|
+
const s = stripCasts(input);
|
|
167
|
+
const match = s.match(/^([a-z_]+)\s*\((.*)\)$/is);
|
|
168
|
+
if (!match || !LENGTH_FUNCTIONS.has(match[1].toLowerCase())) return null;
|
|
169
|
+
return asColumnName(match[2]);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** A numeric literal, after casts are removed. Rejects anything else. */
|
|
173
|
+
function asNumber(input: string): number | null {
|
|
174
|
+
const s = stripCasts(input).replace(/^'(.*)'$/s, "$1");
|
|
175
|
+
if (!/^[+-]?(\d+\.?\d*|\.\d+)$/.test(s)) return null;
|
|
176
|
+
const n = Number(s);
|
|
177
|
+
return Number.isFinite(n) ? n : null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** A single-quoted string literal, with `''` unescaped. */
|
|
181
|
+
function asStringLiteral(input: string): string | null {
|
|
182
|
+
const s = stripCasts(input);
|
|
183
|
+
if (!s.startsWith("'") || !s.endsWith("'") || s.length < 2) return null;
|
|
184
|
+
const body = s.slice(1, -1);
|
|
185
|
+
// Any unescaped quote inside means this was not one literal.
|
|
186
|
+
if (/(^|[^'])'($|[^'])/.test(body)) return null;
|
|
187
|
+
return body.replace(/''/g, "'");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The element list of `ARRAY[…]`, or of a parenthesised `IN (…)` list. */
|
|
191
|
+
function asValueList(input: string): string[] | null {
|
|
192
|
+
const s = stripCasts(input);
|
|
193
|
+
const arrayMatch = s.match(/^ARRAY\s*\[(.*)\]$/is);
|
|
194
|
+
const body = arrayMatch ? arrayMatch[1] : (s.startsWith("(") && s.endsWith(")") ? s.slice(1, -1) : null);
|
|
195
|
+
if (body === null) return null;
|
|
196
|
+
if (body.trim() === "") return null;
|
|
197
|
+
|
|
198
|
+
const values: string[] = [];
|
|
199
|
+
for (const element of splitTopLevel(body, ",")) {
|
|
200
|
+
const literal = asStringLiteral(element);
|
|
201
|
+
if (literal !== null) {
|
|
202
|
+
values.push(literal);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const numeric = asNumber(element);
|
|
206
|
+
if (numeric !== null) {
|
|
207
|
+
values.push(String(numeric));
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
return null; // A non-literal element means this is not an enumeration.
|
|
211
|
+
}
|
|
212
|
+
return values;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** One understood comparison, already resolved to the column it constrains. */
|
|
216
|
+
interface CheckTerm {
|
|
217
|
+
column: string;
|
|
218
|
+
apply: (facts: ColumnCheckFacts) => void;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Parses one conjunct. Returns null when the shape is not one of the few this
|
|
223
|
+
* understands, which is the signal to abandon the whole constraint.
|
|
224
|
+
*/
|
|
225
|
+
function parseTerm(rawTerm: string): CheckTerm | null {
|
|
226
|
+
const term = unwrapParens(rawTerm);
|
|
227
|
+
|
|
228
|
+
// ── Membership: `x = ANY (ARRAY[…])`, `x IN (…)` ──────────────────
|
|
229
|
+
const anyMatch = term.match(/^(.*?)\s*=\s*ANY\s*(\(.*\))$/is);
|
|
230
|
+
const inMatch = anyMatch ? null : term.match(/^(.*?)\s+IN\s*(\(.*\))$/is);
|
|
231
|
+
const membership = anyMatch ?? inMatch;
|
|
232
|
+
if (membership) {
|
|
233
|
+
const column = asColumnName(membership[1]);
|
|
234
|
+
const values = asValueList(membership[2]);
|
|
235
|
+
if (!column || !values || values.length === 0) return null;
|
|
236
|
+
return {
|
|
237
|
+
column,
|
|
238
|
+
apply: (facts) => {
|
|
239
|
+
// Two membership checks on one column mean both must hold.
|
|
240
|
+
facts.enumValues = facts.enumValues
|
|
241
|
+
? facts.enumValues.filter((v) => values.includes(v))
|
|
242
|
+
: values;
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── Comparison: `x >= n`, `length(x) <= n`, `x = 'literal'` ───────
|
|
248
|
+
// Longest operators first so `>=` is not read as `>`.
|
|
249
|
+
for (const operator of [">=", "<=", "<>", "!=", "=", ">", "<"] as const) {
|
|
250
|
+
const parts = splitTopLevel(term, operator);
|
|
251
|
+
if (parts.length !== 2) continue;
|
|
252
|
+
const [rawLeft, rawRight] = parts;
|
|
253
|
+
|
|
254
|
+
const lengthColumn = asLengthOfColumn(rawLeft);
|
|
255
|
+
if (lengthColumn) {
|
|
256
|
+
const bound = asNumber(rawRight);
|
|
257
|
+
if (bound === null) return null;
|
|
258
|
+
switch (operator) {
|
|
259
|
+
case ">=": return { column: lengthColumn, apply: (f) => { f.lengthMin = Math.max(f.lengthMin ?? bound, bound); } };
|
|
260
|
+
case ">": return { column: lengthColumn, apply: (f) => { f.lengthMin = Math.max(f.lengthMin ?? bound + 1, bound + 1); } };
|
|
261
|
+
case "<=": return { column: lengthColumn, apply: (f) => { f.lengthMax = Math.min(f.lengthMax ?? bound, bound); } };
|
|
262
|
+
case "<": return { column: lengthColumn, apply: (f) => { f.lengthMax = Math.min(f.lengthMax ?? bound - 1, bound - 1); } };
|
|
263
|
+
case "=": return { column: lengthColumn, apply: (f) => { f.lengthMin = bound; f.lengthMax = bound; } };
|
|
264
|
+
default: return null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const column = asColumnName(rawLeft);
|
|
269
|
+
if (!column) continue;
|
|
270
|
+
|
|
271
|
+
if (operator === "=") {
|
|
272
|
+
const literal = asStringLiteral(rawRight);
|
|
273
|
+
if (literal !== null) {
|
|
274
|
+
return {
|
|
275
|
+
column,
|
|
276
|
+
apply: (facts) => {
|
|
277
|
+
facts.enumValues = facts.enumValues
|
|
278
|
+
? facts.enumValues.filter((v) => v === literal)
|
|
279
|
+
: [literal];
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
// `x = 5` pins a number. Expressed as both bounds rather than as a
|
|
284
|
+
// one-value enum, which for a number would render as a dropdown.
|
|
285
|
+
const pinned = asNumber(rawRight);
|
|
286
|
+
if (pinned === null) return null;
|
|
287
|
+
return { column, apply: (f) => { f.min = pinned; f.max = pinned; } };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const bound = asNumber(rawRight);
|
|
291
|
+
if (bound === null) return null;
|
|
292
|
+
switch (operator) {
|
|
293
|
+
case ">=": return { column, apply: (f) => { f.min = Math.max(f.min ?? bound, bound); } };
|
|
294
|
+
case ">": return { column, apply: (f) => { f.moreThan = Math.max(f.moreThan ?? bound, bound); } };
|
|
295
|
+
case "<=": return { column, apply: (f) => { f.max = Math.min(f.max ?? bound, bound); } };
|
|
296
|
+
case "<": return { column, apply: (f) => { f.lessThan = Math.min(f.lessThan ?? bound, bound); } };
|
|
297
|
+
default: return null; // `<>` / `!=` exclude rather than bound.
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Reads one constraint definition into per-column facts.
|
|
306
|
+
*
|
|
307
|
+
* Returns an empty map for anything not understood — including a constraint
|
|
308
|
+
* that is understood but spans two columns (`start_date < end_date`), which no
|
|
309
|
+
* per-property validation rule can express.
|
|
310
|
+
*/
|
|
311
|
+
export function parseCheckDefinition(definition: string): Map<string, ColumnCheckFacts> {
|
|
312
|
+
const result = new Map<string, ColumnCheckFacts>();
|
|
313
|
+
|
|
314
|
+
const body = definition
|
|
315
|
+
.trim()
|
|
316
|
+
.replace(/\s+NOT\s+VALID\s*$/i, "")
|
|
317
|
+
.replace(/^CHECK\s*/i, "")
|
|
318
|
+
.trim();
|
|
319
|
+
if (body === definition.trim()) return result; // Not a CHECK definition.
|
|
320
|
+
|
|
321
|
+
const expression = unwrapParens(body);
|
|
322
|
+
if (!expression) return result;
|
|
323
|
+
|
|
324
|
+
// Only conjunctions. A disjunction (`a OR b`) does not narrow a column: each
|
|
325
|
+
// branch may allow what the other forbids, so no single bound follows.
|
|
326
|
+
if (splitTopLevel(expression, " OR ").length > 1) return result;
|
|
327
|
+
|
|
328
|
+
const terms = splitTopLevel(expression, " AND ");
|
|
329
|
+
const parsed: CheckTerm[] = [];
|
|
330
|
+
for (const term of terms) {
|
|
331
|
+
const one = parseTerm(term);
|
|
332
|
+
if (!one) return result; // One unreadable conjunct voids the constraint.
|
|
333
|
+
parsed.push(one);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// A CHECK naming two columns constrains their *relationship*; keeping the
|
|
337
|
+
// half that mentions one column would assert something the database does not.
|
|
338
|
+
const columns = new Set(parsed.map((t) => t.column));
|
|
339
|
+
if (columns.size !== 1) return result;
|
|
340
|
+
|
|
341
|
+
const facts: ColumnCheckFacts = {};
|
|
342
|
+
for (const term of parsed) term.apply(facts);
|
|
343
|
+
if (Object.keys(facts).length === 0) return result;
|
|
344
|
+
|
|
345
|
+
result.set(parsed[0].column, facts);
|
|
346
|
+
return result;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Merges every readable CHECK in the schema into per-table, per-column facts. */
|
|
350
|
+
export function parseCheckConstraints(rows: CheckConstraintRow[]): CheckFactsByTable {
|
|
351
|
+
const byTable: CheckFactsByTable = new Map();
|
|
352
|
+
|
|
353
|
+
for (const row of rows) {
|
|
354
|
+
const parsed = parseCheckDefinition(row.definition);
|
|
355
|
+
if (parsed.size === 0) continue;
|
|
356
|
+
|
|
357
|
+
let table = byTable.get(row.table_name);
|
|
358
|
+
if (!table) {
|
|
359
|
+
table = new Map();
|
|
360
|
+
byTable.set(row.table_name, table);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
for (const [column, facts] of parsed) {
|
|
364
|
+
const existing = table.get(column);
|
|
365
|
+
if (!existing) {
|
|
366
|
+
table.set(column, facts);
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
// Separate constraints on the same column all hold at once.
|
|
370
|
+
if (facts.enumValues) {
|
|
371
|
+
existing.enumValues = existing.enumValues
|
|
372
|
+
? existing.enumValues.filter((v) => facts.enumValues!.includes(v))
|
|
373
|
+
: facts.enumValues;
|
|
374
|
+
}
|
|
375
|
+
if (facts.min !== undefined) existing.min = Math.max(existing.min ?? facts.min, facts.min);
|
|
376
|
+
if (facts.max !== undefined) existing.max = Math.min(existing.max ?? facts.max, facts.max);
|
|
377
|
+
if (facts.moreThan !== undefined) existing.moreThan = Math.max(existing.moreThan ?? facts.moreThan, facts.moreThan);
|
|
378
|
+
if (facts.lessThan !== undefined) existing.lessThan = Math.min(existing.lessThan ?? facts.lessThan, facts.lessThan);
|
|
379
|
+
if (facts.lengthMin !== undefined) existing.lengthMin = Math.max(existing.lengthMin ?? facts.lengthMin, facts.lengthMin);
|
|
380
|
+
if (facts.lengthMax !== undefined) existing.lengthMax = Math.min(existing.lengthMax ?? facts.lengthMax, facts.lengthMax);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return byTable;
|
|
385
|
+
}
|
|
@@ -19,6 +19,10 @@ export function inferPropertyFromData(
|
|
|
19
19
|
): InferenceResult {
|
|
20
20
|
const result: InferenceResult = {};
|
|
21
21
|
const extraLines: string[] = [];
|
|
22
|
+
// Collected separately and emitted as one block at the end. Pushed inline,
|
|
23
|
+
// two admin-owned options from different branches produced two `admin: {`
|
|
24
|
+
// blocks in one property — a duplicate key, which is also a compile error.
|
|
25
|
+
const adminOptions: string[] = [];
|
|
22
26
|
|
|
23
27
|
// Filter out null/undefined for analysis
|
|
24
28
|
const validValues = sampleValues.filter(v => v !== null && v !== undefined && v !== "");
|
|
@@ -189,10 +193,14 @@ export function inferPropertyFromData(
|
|
|
189
193
|
}
|
|
190
194
|
}
|
|
191
195
|
|
|
196
|
+
// `multiline` and `markdown` are *admin* property options, so they have
|
|
197
|
+
// to sit inside the `admin` block. Emitted at the top of the property —
|
|
198
|
+
// where they were — `PostgresCollectionConfig` does not declare them and
|
|
199
|
+
// the generated file does not compile.
|
|
192
200
|
if (hasMarkdown) {
|
|
193
|
-
|
|
201
|
+
adminOptions.push("multiline: true", "markdown: true");
|
|
194
202
|
} else if (hasNewlines || maxLength > 100) {
|
|
195
|
-
|
|
203
|
+
adminOptions.push("multiline: true");
|
|
196
204
|
}
|
|
197
205
|
|
|
198
206
|
if (maxLength > 0 && maxLength < 10000) { // arbitrary cap to avoid huge limits
|
|
@@ -212,7 +220,8 @@ export function inferPropertyFromData(
|
|
|
212
220
|
if (allAbsoluteUrls) {
|
|
213
221
|
const isImage = validValues.some(v => typeof v === "string" && v.match(/\.(jpeg|jpg|gif|png|webp|svg)/i));
|
|
214
222
|
if (isImage || isMedia) {
|
|
215
|
-
extraLines.push(" url: true
|
|
223
|
+
extraLines.push(" url: true");
|
|
224
|
+
adminOptions.push("urlPreview: \"image\"");
|
|
216
225
|
} else {
|
|
217
226
|
extraLines.push(" url: true");
|
|
218
227
|
}
|
|
@@ -224,15 +233,16 @@ export function inferPropertyFromData(
|
|
|
224
233
|
const inferredStoragePath = lastSlash > 0 ? firstVal.substring(0, lastSlash) : "files";
|
|
225
234
|
extraLines.push(` storage: {\n storagePath: "${inferredStoragePath}"\n }`);
|
|
226
235
|
} else if (isUrl) {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
} else {
|
|
230
|
-
extraLines.push(" url: true");
|
|
231
|
-
}
|
|
236
|
+
extraLines.push(" url: true");
|
|
237
|
+
if (isMedia) adminOptions.push("urlPreview: \"image\"");
|
|
232
238
|
}
|
|
233
239
|
}
|
|
234
240
|
}
|
|
235
241
|
|
|
242
|
+
if (adminOptions.length > 0) {
|
|
243
|
+
extraLines.push(` admin: {\n${adminOptions.map((o) => ` ${o}`).join(",\n")}\n }`);
|
|
244
|
+
}
|
|
245
|
+
|
|
236
246
|
result.extra = extraLines.length > 0 ? "\n" + extraLines.join(",\n") + "," : "";
|
|
237
247
|
return result;
|
|
238
248
|
}
|