@atscript/db 0.1.140 → 0.1.142
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/agg.d.cts +1 -1
- package/dist/agg.d.mts +1 -1
- package/dist/{buckets-C-27xmtq.d.cts → buckets-Bv4pah66.d.cts} +225 -22
- package/dist/{buckets-BFG2RYRW.d.mts → buckets-CjL7F-hp.d.mts} +225 -22
- package/dist/{column-diff-CgxgFKzx.cjs → column-diff-CfPNcP6e.cjs} +663 -239
- package/dist/{column-diff-BwOA5101.mjs → column-diff-CmFNXV8C.mjs} +638 -220
- package/dist/column-diff-DiBbXyLA.d.cts +211 -0
- package/dist/column-diff-n-k5KY0u.d.mts +211 -0
- package/dist/derived-rules-0sKn4f5C.mjs +44 -0
- package/dist/derived-rules-YstgIxG-.cjs +67 -0
- package/dist/index.cjs +38 -4
- package/dist/index.d.cts +23 -6
- package/dist/index.d.mts +23 -6
- package/dist/index.mjs +34 -4
- package/dist/{nested-writer-FWD5oOYh.mjs → nested-writer-BO3vhbkP.mjs} +8 -4
- package/dist/{nested-writer-BZNCuqI6.cjs → nested-writer-DYsRxZ5f.cjs} +8 -4
- package/dist/object-DSN0h9lB.d.cts +30 -0
- package/dist/object-DSN0h9lB.d.mts +30 -0
- package/dist/plugin.cjs +392 -139
- package/dist/plugin.mjs +392 -139
- package/dist/rel.cjs +2 -2
- package/dist/rel.d.cts +2 -2
- package/dist/rel.d.mts +2 -2
- package/dist/rel.mjs +2 -2
- package/dist/{relation-helpers-D3Zu0Mta.d.mts → relation-helpers-B59to_dG.d.mts} +5 -4
- package/dist/{relation-helpers-DxrvS6ar.d.cts → relation-helpers-DQ_nRsV9.d.cts} +5 -4
- package/dist/{relation-loader-6ZB_5KFq.cjs → relation-loader-CgJ8bK6X.cjs} +1 -1
- package/dist/{relation-loader-CTFaZpVa.mjs → relation-loader-CuhEBzFU.mjs} +1 -1
- package/dist/shared.cjs +6 -1
- package/dist/shared.d.cts +48 -9
- package/dist/shared.d.mts +48 -9
- package/dist/shared.mjs +2 -2
- package/dist/sync.cjs +331 -105
- package/dist/sync.d.cts +62 -163
- package/dist/sync.d.mts +62 -163
- package/dist/sync.mjs +331 -105
- package/dist/{validation-utils-B4h-GW4d.mjs → validation-utils-CMR4fe2M.mjs} +99 -34
- package/dist/{validation-utils-Dg0hW6dn.cjs → validation-utils-DOsB4e6G.cjs} +128 -33
- package/dist/{validator-Drb2N-YL.d.cts → validator-Bw6ks9Hy.d.cts} +1 -11
- package/dist/{validator-Drb2N-YL.d.mts → validator-Bw6ks9Hy.d.mts} +1 -11
- package/dist/{validator-Ch7UIQl9.mjs → validator-D8bPsXPN.mjs} +54 -2
- package/dist/{validator-BtZbcLN2.cjs → validator-DASnXf1j.cjs} +77 -1
- package/dist/validator.cjs +1 -1
- package/dist/validator.d.cts +2 -1
- package/dist/validator.d.mts +2 -1
- package/dist/validator.mjs +1 -1
- package/package.json +6 -6
- package/dist/column-diff-BmqvgBWw.d.cts +0 -24
- package/dist/column-diff-DPkbZIVE.d.mts +0 -24
package/dist/plugin.cjs
CHANGED
|
@@ -34,21 +34,14 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
34
34
|
}) : target, mod));
|
|
35
35
|
//#endregion
|
|
36
36
|
const require_aggregate_fns = require("./aggregate-fns-CGBv3E8S.cjs");
|
|
37
|
+
const require_derived_rules = require("./derived-rules-YstgIxG-.cjs");
|
|
37
38
|
require("./consts-BzRfCcH2.cjs");
|
|
38
|
-
const require_validation_utils = require("./validation-utils-
|
|
39
|
+
const require_validation_utils = require("./validation-utils-DOsB4e6G.cjs");
|
|
39
40
|
let node_path = require("node:path");
|
|
40
41
|
node_path = __toESM(node_path, 1);
|
|
41
42
|
let node_url = require("node:url");
|
|
42
43
|
let _atscript_core = require("@atscript/core");
|
|
43
44
|
//#region src/plugin/manifest.ts
|
|
44
|
-
const DB_ENTITY_ANNOTATIONS = [
|
|
45
|
-
"db.table",
|
|
46
|
-
"db.view",
|
|
47
|
-
"db.view.for"
|
|
48
|
-
];
|
|
49
|
-
function isDbEntity(node) {
|
|
50
|
-
return DB_ENTITY_ANNOTATIONS.some((name) => node.countAnnotations(name) > 0);
|
|
51
|
-
}
|
|
52
45
|
/**
|
|
53
46
|
* Renders the model-manifest module: an inventory of every exported
|
|
54
47
|
* `@db.table` / `@db.view` entity in the project, grouped by `@db.space`.
|
|
@@ -77,7 +70,7 @@ async function generateModelManifest(options, output, format, repo) {
|
|
|
77
70
|
let specifier = node_path.default.relative(manifestDir, docPath).split(node_path.default.sep).join("/");
|
|
78
71
|
if (!specifier.startsWith(".")) specifier = `./${specifier}`;
|
|
79
72
|
for (const [exportName, node] of [...doc.exports.entries()].toSorted(([a], [b]) => a.localeCompare(b))) {
|
|
80
|
-
if (!
|
|
73
|
+
if (!require_validation_utils.isDbSourceDecl(node)) continue;
|
|
81
74
|
let alias = exportName;
|
|
82
75
|
for (let n = 1; usedAliases.has(alias); n++) alias = `${exportName}_${n}`;
|
|
83
76
|
usedAliases.add(alias);
|
|
@@ -131,6 +124,113 @@ async function generateModelManifest(options, output, format, repo) {
|
|
|
131
124
|
});
|
|
132
125
|
}
|
|
133
126
|
//#endregion
|
|
127
|
+
//#region src/plugin/lsp-scopes.ts
|
|
128
|
+
/**
|
|
129
|
+
* Editor scopes of the `@db.*` query / field-path arguments and the type
|
|
130
|
+
* filters of the ref arguments — one function per rule. The validators in
|
|
131
|
+
* `annotations/*` derive their in-scope types from these SAME functions, so a
|
|
132
|
+
* diagnostic and what the editor completes, hovers and jumps to never diverge.
|
|
133
|
+
* @since 0.1.141
|
|
134
|
+
*/
|
|
135
|
+
/** The `@db.view.for` entry name of a view node. */
|
|
136
|
+
function viewEntry(view) {
|
|
137
|
+
return require_validation_utils.getAnnotationAlias(view, "db.view.for");
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* `@db.view.filter` — and a conditional `@db.agg.*` of the same view: the
|
|
141
|
+
* entry table and every `@db.view.joins` target (`@db.alias` names included),
|
|
142
|
+
* in declaration order; an unqualified field belongs to the entry.
|
|
143
|
+
*/
|
|
144
|
+
function viewFilterScope(view) {
|
|
145
|
+
const entry = viewEntry(view);
|
|
146
|
+
return entry ? {
|
|
147
|
+
allowedTypes: require_validation_utils.viewScopeTypes(view),
|
|
148
|
+
unqualifiedTarget: entry
|
|
149
|
+
} : void 0;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* `@db.view.joins` condition: the join target, the entry table and the joins
|
|
153
|
+
* declared before this one (chained joins — `earlier`, computed from the
|
|
154
|
+
* view's {@link viewJoins} when the caller has not); an unqualified field
|
|
155
|
+
* belongs to the entry.
|
|
156
|
+
*/
|
|
157
|
+
function viewJoinScope(view, join, earlier = require_validation_utils.earlierJoinTargets(require_validation_utils.viewJoins(view), join)) {
|
|
158
|
+
const entry = viewEntry(view);
|
|
159
|
+
const target = join.args[0]?.text;
|
|
160
|
+
if (!entry || !target) return;
|
|
161
|
+
return {
|
|
162
|
+
allowedTypes: [
|
|
163
|
+
target,
|
|
164
|
+
entry,
|
|
165
|
+
...earlier
|
|
166
|
+
],
|
|
167
|
+
unqualifiedTarget: entry
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
/** `@db.view.having`: the view's own fields, unqualified — no source type is in scope. */
|
|
171
|
+
function viewHavingScope(view) {
|
|
172
|
+
return view.id ? {
|
|
173
|
+
allowedTypes: [],
|
|
174
|
+
unqualifiedTarget: view.id
|
|
175
|
+
} : void 0;
|
|
176
|
+
}
|
|
177
|
+
/** The condition of a `@db.agg.*` on a view field: the scope of the view's `@db.view.filter`. */
|
|
178
|
+
function aggConditionScope(propToken) {
|
|
179
|
+
const view = require_validation_utils.getDbTableOwner(propToken);
|
|
180
|
+
return view ? viewFilterScope(view) : void 0;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* The field of a `@db.agg.*` (`'amount'`, `'settings.level'`): a field path
|
|
184
|
+
* of the prop's chain-ref type, else of the view's `@db.view.for` entry —
|
|
185
|
+
* where the runtime reads the aggregate's source column from.
|
|
186
|
+
*/
|
|
187
|
+
function aggFieldScope(propToken) {
|
|
188
|
+
const def = propToken.parentNode?.getDefinition();
|
|
189
|
+
const refType = def && (0, _atscript_core.isRef)(def) && def.hasChain ? def.id : void 0;
|
|
190
|
+
const view = refType ? void 0 : require_validation_utils.getDbTableOwner(propToken);
|
|
191
|
+
const target = refType ?? (view ? viewEntry(view) : void 0);
|
|
192
|
+
return target ? {
|
|
193
|
+
allowedTypes: [],
|
|
194
|
+
unqualifiedTarget: target
|
|
195
|
+
} : void 0;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* `@db.rel.filter` on a navigational field: the related type (`Post` of
|
|
199
|
+
* `posts: Post[]`) and, for `@db.rel.via`, the junction table; an
|
|
200
|
+
* unqualified field belongs to the related type.
|
|
201
|
+
*/
|
|
202
|
+
function relFilterScope(field) {
|
|
203
|
+
const target = require_validation_utils.getNavTargetTypeName(field);
|
|
204
|
+
if (!target) return;
|
|
205
|
+
const junction = require_validation_utils.getAnnotationAlias(field, "db.rel.via");
|
|
206
|
+
return {
|
|
207
|
+
allowedTypes: junction ? [target, junction] : [target],
|
|
208
|
+
unqualifiedTarget: target
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
/** The `fieldScope` hooks (argument token → scope) the annotation specs declare. */
|
|
212
|
+
const fieldScopes = {
|
|
213
|
+
viewFilter: (arg) => arg.parentNode ? viewFilterScope(arg.parentNode) : void 0,
|
|
214
|
+
viewJoin: (arg) => {
|
|
215
|
+
const view = arg.parentNode;
|
|
216
|
+
const joins = view ? require_validation_utils.viewJoins(view) : [];
|
|
217
|
+
const join = joins.find((a) => a.args.includes(arg));
|
|
218
|
+
return view && join ? viewJoinScope(view, join, require_validation_utils.earlierJoinTargets(joins, join)) : void 0;
|
|
219
|
+
},
|
|
220
|
+
viewHaving: (arg) => arg.parentNode ? viewHavingScope(arg.parentNode) : void 0,
|
|
221
|
+
aggCondition: aggConditionScope,
|
|
222
|
+
aggField: aggFieldScope,
|
|
223
|
+
relFilter: (arg) => arg.parentNode ? relFilterScope(arg.parentNode) : void 0
|
|
224
|
+
};
|
|
225
|
+
/** `refFilter` of the `@db.view.joins` target: a view source or a `@db.alias` of one. */
|
|
226
|
+
function isJoinTarget(decl) {
|
|
227
|
+
return require_validation_utils.isDbSourceDecl(decl) || require_validation_utils.isAliasDecl(decl);
|
|
228
|
+
}
|
|
229
|
+
/** `refFilter` of `@db.rel.via`: a `@db.table`. */
|
|
230
|
+
function isDbTable(decl) {
|
|
231
|
+
return decl.countAnnotations("db.table") > 0;
|
|
232
|
+
}
|
|
233
|
+
//#endregion
|
|
134
234
|
//#region src/plugin/annotations/agg.ts
|
|
135
235
|
/**
|
|
136
236
|
* A conditional aggregate that is NULL when no row matches (so its field
|
|
@@ -145,7 +245,8 @@ const CONDITION_ARG = {
|
|
|
145
245
|
name: "condition",
|
|
146
246
|
type: "query",
|
|
147
247
|
optional: true,
|
|
148
|
-
description: "Row predicate of a conditional aggregate: only rows where it holds are aggregated (SQL `FN(CASE WHEN … THEN field END)`). May reference the entry table and every join; unqualified fields resolve to the entry table."
|
|
248
|
+
description: "Row predicate of a conditional aggregate: only rows where it holds are aggregated (SQL `FN(CASE WHEN … THEN field END)`). May reference the entry table and every join; unqualified fields resolve to the entry table.",
|
|
249
|
+
fieldScope: fieldScopes.aggCondition
|
|
149
250
|
};
|
|
150
251
|
/**
|
|
151
252
|
* The rules every `@db.agg.*` shares: `'*'` is `count`'s only, and a
|
|
@@ -163,9 +264,8 @@ function validateAggArgs(name, token, args, doc) {
|
|
|
163
264
|
});
|
|
164
265
|
const condition = args[1];
|
|
165
266
|
if (!condition?.queryNode) return errors;
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
if (!owner || !entryTypeName) {
|
|
267
|
+
const scope = aggConditionScope(token);
|
|
268
|
+
if (!scope) {
|
|
169
269
|
errors.push({
|
|
170
270
|
message: `A conditional ${annotation} requires @db.view.for on the view`,
|
|
171
271
|
severity: 1,
|
|
@@ -173,7 +273,7 @@ function validateAggArgs(name, token, args, doc) {
|
|
|
173
273
|
});
|
|
174
274
|
return errors;
|
|
175
275
|
}
|
|
176
|
-
errors.push(...require_validation_utils.validateQueryScope(condition,
|
|
276
|
+
errors.push(...require_validation_utils.validateQueryScope(condition, scope, doc));
|
|
177
277
|
const prop = token.parentNode;
|
|
178
278
|
if (conditionalIsNullable(name) && prop && !prop.has("optional")) {
|
|
179
279
|
const field = prop.id ?? "field";
|
|
@@ -205,7 +305,8 @@ function aggSpec(name, description, field, types, extra = "") {
|
|
|
205
305
|
name: "field",
|
|
206
306
|
type: "string",
|
|
207
307
|
optional: field.optional,
|
|
208
|
-
description: field.description
|
|
308
|
+
description: field.description,
|
|
309
|
+
fieldScope: fieldScopes.aggField
|
|
209
310
|
}, CONDITION_ARG],
|
|
210
311
|
validate(token, args, doc) {
|
|
211
312
|
const errors = validateAggArgs(name, token, args, doc);
|
|
@@ -226,6 +327,51 @@ const dbAggAnnotations = { agg: {
|
|
|
226
327
|
max: aggSpec("max", "Declares a view field as MAX of a source column.", { description: "Source column name." })
|
|
227
328
|
} };
|
|
228
329
|
//#endregion
|
|
330
|
+
//#region src/plugin/annotations/alias.ts
|
|
331
|
+
/** The `accept` rule of a view-source ref argument (`@db.view.for`, `@db.alias`). */
|
|
332
|
+
const VIEW_SOURCE_ARGUMENT = {
|
|
333
|
+
accept: require_validation_utils.isDbSourceDecl,
|
|
334
|
+
expected: "must be a @db.table or a @db.view."
|
|
335
|
+
};
|
|
336
|
+
/**
|
|
337
|
+
* `@db.alias <Target>` — a named join scope over a table or view, so a view
|
|
338
|
+
* can join the same table twice or join its own entry table (self-join).
|
|
339
|
+
* @since 0.1.141
|
|
340
|
+
*/
|
|
341
|
+
const dbAliasAnnotations = { alias: new _atscript_core.AnnotationSpec({
|
|
342
|
+
description: "Declares a **join alias**: a type alias of a `@db.table` or `@db.view` that a view can join under its own name — to join the same table twice or to self-join the entry table. Only valid on `export type X = Target`, where `Target` is the argument. The alias is a scope name inside view definitions, not a table: it is never synced or registered on a `DbSpace`, and it cannot be the `@db.view.for` entry.\n\n**Example:**\n```atscript\n@db.alias Employee\nexport type Manager = Employee\n\n@db.view.for Employee\n@db.view.joins Manager, `Manager.id = Employee.managerId`, 'left'\nexport interface Staff {\n id: Employee.id\n managerName?: Manager.name\n}\n```\n",
|
|
343
|
+
nodeType: ["type"],
|
|
344
|
+
passedWhenReferred: false,
|
|
345
|
+
argument: {
|
|
346
|
+
name: "target",
|
|
347
|
+
type: "ref",
|
|
348
|
+
description: "The aliased table or view type (must have @db.table or @db.view).",
|
|
349
|
+
refFilter: require_validation_utils.isDbSourceDecl
|
|
350
|
+
},
|
|
351
|
+
validate(token, args, doc) {
|
|
352
|
+
const errors = [];
|
|
353
|
+
const owner = token.parentNode;
|
|
354
|
+
const target = args[0]?.text;
|
|
355
|
+
for (const name of require_derived_rules.DB_ENTITY_ANNOTATIONS) if (owner.countAnnotations(name) > 0) {
|
|
356
|
+
errors.push({
|
|
357
|
+
message: `A @db.alias type cannot carry @${name} — it names a join scope over "${target ?? "…"}", not a table or view`,
|
|
358
|
+
severity: 1,
|
|
359
|
+
range: token.range
|
|
360
|
+
});
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
if (!target) return errors;
|
|
364
|
+
const def = owner.getDefinition();
|
|
365
|
+
if ((def && (0, _atscript_core.isRef)(def) && !def.hasChain ? def.id : void 0) !== target) errors.push({
|
|
366
|
+
message: `@db.alias ${target} must be declared on 'export type ${owner.id ?? "X"} = ${target}' — the type must be a plain reference to the aliased ${target}`,
|
|
367
|
+
severity: 1,
|
|
368
|
+
range: token.range
|
|
369
|
+
});
|
|
370
|
+
errors.push(...require_validation_utils.validateRefArgument(args[0], doc, VIEW_SOURCE_ARGUMENT));
|
|
371
|
+
return errors;
|
|
372
|
+
}
|
|
373
|
+
}) };
|
|
374
|
+
//#endregion
|
|
229
375
|
//#region src/plugin/annotations/amount.ts
|
|
230
376
|
const CURRENCY_CODE_PATTERN = /^[A-Z0-9]{2,10}$/;
|
|
231
377
|
const dbAmountAnnotations = { amount: { currency: {
|
|
@@ -280,6 +426,100 @@ const dbAmountAnnotations = { amount: { currency: {
|
|
|
280
426
|
})
|
|
281
427
|
} } };
|
|
282
428
|
//#endregion
|
|
429
|
+
//#region src/shared/view-validation.ts
|
|
430
|
+
/** `@db.agg.*` annotations that are never NULL (the counts: 0 over no value). */
|
|
431
|
+
const NULL_SAFE_AGG_ANNOTATIONS = require_aggregate_fns.SUPPORTED_AGGREGATE_FNS.filter((fn) => !require_aggregate_fns.NULL_WHEN_EMPTY_AGGREGATE_FNS.has(fn)).map((fn) => `db.agg.${fn}`);
|
|
432
|
+
/**
|
|
433
|
+
* Walks a chain ref step by step (`Order.payload`, `Order.payload.customer`, …)
|
|
434
|
+
* and reports the JSON root, arrays, encryption, optionality and the leaf type.
|
|
435
|
+
* A chain shorter than 2 is not a field path (`resolved: false`).
|
|
436
|
+
* @since 0.1.141
|
|
437
|
+
*/
|
|
438
|
+
function jsonChainInfo(ref, doc) {
|
|
439
|
+
const typeName = ref.id ?? "";
|
|
440
|
+
const chain = ref.chain.map((t) => t.text);
|
|
441
|
+
const info = {
|
|
442
|
+
typeName,
|
|
443
|
+
chain,
|
|
444
|
+
resolved: false,
|
|
445
|
+
viaArray: false,
|
|
446
|
+
viaEncrypted: false,
|
|
447
|
+
optional: false
|
|
448
|
+
};
|
|
449
|
+
if (!typeName || chain.length < 2) return info;
|
|
450
|
+
for (let i = 1; i <= chain.length; i++) {
|
|
451
|
+
const step = doc.unwindType(typeName, chain.slice(0, i));
|
|
452
|
+
if (!step) return info;
|
|
453
|
+
const node = step.node;
|
|
454
|
+
const prop = node && (0, _atscript_core.isProp)(node) ? node : void 0;
|
|
455
|
+
if (prop?.has("optional")) info.optional = true;
|
|
456
|
+
if (prop && prop.countAnnotations("db.encrypted") > 0) info.viaEncrypted = true;
|
|
457
|
+
const isJsonProp = prop !== void 0 && prop.countAnnotations("db.json") > 0;
|
|
458
|
+
const isArr = (0, _atscript_core.isArray)(step.def);
|
|
459
|
+
if (isArr) info.viaArray = true;
|
|
460
|
+
if (info.jsonRoot === void 0 && (isJsonProp || isArr)) info.jsonRoot = i;
|
|
461
|
+
if (i === chain.length) info.leafType = require_validation_utils.primitiveBaseType(step.def);
|
|
462
|
+
}
|
|
463
|
+
info.resolved = true;
|
|
464
|
+
return info;
|
|
465
|
+
}
|
|
466
|
+
/** Props of a view interface (its own structure; views don't use `extends`). */
|
|
467
|
+
function viewProps(owner) {
|
|
468
|
+
if ((0, _atscript_core.isInterface)(owner)) return owner.props;
|
|
469
|
+
const def = owner.getDefinition();
|
|
470
|
+
return def && (0, _atscript_core.isStructure)(def) ? def.props : void 0;
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* VW8: a chain ref that passes through a `@db.json` or array node reads a
|
|
474
|
+
* JSON leaf, which views can only extract as a string / number / boolean.
|
|
475
|
+
* Intermediate nodes that `unwindType` can't reach are skipped (sync-time
|
|
476
|
+
* resolution reports them).
|
|
477
|
+
*/
|
|
478
|
+
function validateJsonChain(fieldName, ref, doc, range) {
|
|
479
|
+
const info = jsonChainInfo(ref, doc);
|
|
480
|
+
if (!info.resolved || info.jsonRoot === void 0 || info.jsonRoot >= info.chain.length) return [];
|
|
481
|
+
if (info.leafType !== void 0 && require_derived_rules.JSON_LEAF_TYPES.has(info.leafType)) return [];
|
|
482
|
+
return [{
|
|
483
|
+
message: `Field "${fieldName}" reads "${info.typeName}.${info.chain.join(".")}" inside a JSON-stored field — it must end at a string, number or boolean leaf`,
|
|
484
|
+
severity: 1,
|
|
485
|
+
range
|
|
486
|
+
}];
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Whole-view checks, run once per `@db.view.for` interface:
|
|
490
|
+
*
|
|
491
|
+
* - VW7 — a field reading from a left-joined table must be optional (the
|
|
492
|
+
* join yields NULL for unmatched rows); `@db.agg.count` / `countDistinct`
|
|
493
|
+
* fields are exempt (they count 0, never NULL).
|
|
494
|
+
* - VW8 — a chain ref through a `@db.json` or array node must end at a
|
|
495
|
+
* primitive string / number / boolean leaf.
|
|
496
|
+
* @since 0.1.136
|
|
497
|
+
*/
|
|
498
|
+
function validateViewInterface(owner, doc) {
|
|
499
|
+
const errors = [];
|
|
500
|
+
const props = viewProps(owner);
|
|
501
|
+
if (!props) return errors;
|
|
502
|
+
const leftJoined = /* @__PURE__ */ new Set();
|
|
503
|
+
for (const join of require_validation_utils.viewJoins(owner)) {
|
|
504
|
+
const target = join.args[0]?.text;
|
|
505
|
+
if (target && join.args[2]?.text === "left") leftJoined.add(target);
|
|
506
|
+
}
|
|
507
|
+
for (const [fieldName, prop] of props) {
|
|
508
|
+
const def = prop.getDefinition();
|
|
509
|
+
if (!def || !(0, _atscript_core.isRef)(def)) continue;
|
|
510
|
+
const ref = def;
|
|
511
|
+
const range = (prop.token("identifier") ?? ref.token("identifier"))?.range;
|
|
512
|
+
if (!range) continue;
|
|
513
|
+
if (ref.id && leftJoined.has(ref.id) && !prop.has("optional") && !NULL_SAFE_AGG_ANNOTATIONS.some((name) => prop.countAnnotations(name) > 0)) errors.push({
|
|
514
|
+
message: `Field "${fieldName}" reads from left-joined "${ref.id}" and must be optional (${fieldName}?: …)`,
|
|
515
|
+
severity: 1,
|
|
516
|
+
range
|
|
517
|
+
});
|
|
518
|
+
errors.push(...validateJsonChain(fieldName, ref, doc, range));
|
|
519
|
+
}
|
|
520
|
+
return errors;
|
|
521
|
+
}
|
|
522
|
+
//#endregion
|
|
283
523
|
//#region src/plugin/annotations/column.ts
|
|
284
524
|
const dbColumnAnnotations = {
|
|
285
525
|
patch: { strategy: new _atscript_core.AnnotationSpec({
|
|
@@ -367,6 +607,66 @@ const dbColumnAnnotations = {
|
|
|
367
607
|
return require_validation_utils.validateFieldBaseType(token, doc, "@db.column.precision", ["number", "decimal"]);
|
|
368
608
|
}
|
|
369
609
|
}),
|
|
610
|
+
derived: new _atscript_core.AnnotationSpec({
|
|
611
|
+
description: "Declares a **derived column**: a read-only column computed from one `string`, `number` or `boolean` leaf inside a `@db.json` field of the **same table**, so the leaf can be filtered, sorted, grouped and indexed like a real column. The field's type is a chain reference into that field (`customerId: Order.payload.customer.id`); the path may not cross an array or a `@db.encrypted` field. Declare the field optional when the path can be absent (a missing leaf reads as `null`). A value written to it is dropped; `$inc` / `$dec` / `$mul` on it are rejected.\n\nStorage per adapter, schema sync and the compatible annotations: https://atscript.dev/db/api/storage#derived-columns\n\n**Example:**\n```atscript\n@db.table 'orders'\nexport interface Order {\n @meta.id\n id: number\n\n @db.json\n payload: {\n customer: { id: string, vip: boolean }\n total: number\n }\n\n @db.column.derived\n @db.index.plain\n customerId: Order.payload.customer.id\n\n @db.column.derived\n vip?: Order.payload.customer.vip\n}\n```\n",
|
|
612
|
+
nodeType: ["prop"],
|
|
613
|
+
passedWhenReferred: false,
|
|
614
|
+
multiple: false,
|
|
615
|
+
validate(token, _args, doc) {
|
|
616
|
+
const errors = [];
|
|
617
|
+
const field = token.parentNode;
|
|
618
|
+
const fail = (message, severity = 1) => {
|
|
619
|
+
errors.push({
|
|
620
|
+
message,
|
|
621
|
+
severity,
|
|
622
|
+
range: token.range
|
|
623
|
+
});
|
|
624
|
+
};
|
|
625
|
+
const owner = require_validation_utils.getDbTableOwner(token);
|
|
626
|
+
if (!owner || !(0, _atscript_core.isInterface)(owner) || owner.countAnnotations("db.table") === 0) {
|
|
627
|
+
fail("@db.column.derived is only valid on a top-level field of a @db.table interface");
|
|
628
|
+
return errors;
|
|
629
|
+
}
|
|
630
|
+
for (const [name, why] of require_derived_rules.DERIVED_INCOMPATIBLE) if (field.countAnnotations(name) > 0) fail(`@db.column.derived cannot coexist with @${name} — ${why}`);
|
|
631
|
+
const definition = field.getDefinition();
|
|
632
|
+
if (!definition || !(0, _atscript_core.isRef)(definition) || !definition.hasChain) {
|
|
633
|
+
fail("@db.column.derived requires a chain reference into a @db.json field of the same table (e.g. `customerId: Order.payload.customer.id`)");
|
|
634
|
+
return errors;
|
|
635
|
+
}
|
|
636
|
+
const ref = definition;
|
|
637
|
+
const tableName = require_validation_utils.getParentTypeName(token);
|
|
638
|
+
if (ref.id !== tableName) {
|
|
639
|
+
fail(`@db.column.derived must reference the enclosing table '${tableName ?? ""}', not '${ref.id ?? ""}' — a derived column reads its own row`);
|
|
640
|
+
return errors;
|
|
641
|
+
}
|
|
642
|
+
const info = jsonChainInfo(ref, doc);
|
|
643
|
+
const path = `${info.typeName}.${info.chain.join(".")}`;
|
|
644
|
+
const notInsideJson = `@db.column.derived path '${path}' does not read inside a @db.json field — a flattened or scalar column needs no derived column`;
|
|
645
|
+
if (info.chain.length < 2) {
|
|
646
|
+
fail(notInsideJson);
|
|
647
|
+
return errors;
|
|
648
|
+
}
|
|
649
|
+
if (!info.resolved) return errors;
|
|
650
|
+
if (info.viaArray) {
|
|
651
|
+
fail(`@db.column.derived path '${path}' crosses an array — a derived column reads one scalar leaf`);
|
|
652
|
+
return errors;
|
|
653
|
+
}
|
|
654
|
+
if (info.jsonRoot === void 0 || info.jsonRoot >= info.chain.length) {
|
|
655
|
+
fail(notInsideJson);
|
|
656
|
+
return errors;
|
|
657
|
+
}
|
|
658
|
+
if (info.viaEncrypted) {
|
|
659
|
+
fail(`@db.column.derived path '${path}' reads inside a @db.encrypted field — ciphertext cannot be extracted`);
|
|
660
|
+
return errors;
|
|
661
|
+
}
|
|
662
|
+
if (info.leafType === void 0 || !require_derived_rules.JSON_LEAF_TYPES.has(info.leafType)) {
|
|
663
|
+
fail(`@db.column.derived path '${path}' must end at a string, number or boolean leaf` + (info.leafType ? ` (got '${info.leafType}')` : ""));
|
|
664
|
+
return errors;
|
|
665
|
+
}
|
|
666
|
+
if (info.optional && !field.has("optional")) fail(`@db.column.derived path '${path}' may be absent — declare the field optional (\`${field.id ?? ""}?:\`) so a missing leaf reads as null`, 2);
|
|
667
|
+
return errors;
|
|
668
|
+
}
|
|
669
|
+
}),
|
|
370
670
|
dimension: new _atscript_core.AnnotationSpec({
|
|
371
671
|
description: "Marks a field as a dimension — groupable in aggregate queries ($groupBy). Dimension fields automatically receive a database index during schema sync.",
|
|
372
672
|
nodeType: ["prop"],
|
|
@@ -901,7 +1201,8 @@ const dbRelAnnotations = { rel: {
|
|
|
901
1201
|
argument: {
|
|
902
1202
|
name: "junction",
|
|
903
1203
|
type: "ref",
|
|
904
|
-
description: "The junction table type (must have @db.table and @db.rel.FK fields pointing to both sides)."
|
|
1204
|
+
description: "The junction table type (must have @db.table and @db.rel.FK fields pointing to both sides).",
|
|
1205
|
+
refFilter: isDbTable
|
|
905
1206
|
},
|
|
906
1207
|
validate(token, args, doc) {
|
|
907
1208
|
const errors = [];
|
|
@@ -918,7 +1219,10 @@ const dbRelAnnotations = { rel: {
|
|
|
918
1219
|
});
|
|
919
1220
|
if (!args[0]) return errors;
|
|
920
1221
|
const junctionName = args[0].text;
|
|
921
|
-
errors.push(...require_validation_utils.validateRefArgument(args[0], doc, {
|
|
1222
|
+
errors.push(...require_validation_utils.validateRefArgument(args[0], doc, {
|
|
1223
|
+
accept: isDbTable,
|
|
1224
|
+
expected: "must have @db.table annotation."
|
|
1225
|
+
}));
|
|
922
1226
|
if (errors.length > 0) return errors;
|
|
923
1227
|
const junctionUnwound = doc.unwindType(junctionName);
|
|
924
1228
|
if (!junctionUnwound) return errors;
|
|
@@ -961,7 +1265,8 @@ const dbRelAnnotations = { rel: {
|
|
|
961
1265
|
argument: {
|
|
962
1266
|
name: "condition",
|
|
963
1267
|
type: "query",
|
|
964
|
-
description: "Filter expression restricting which related records are loaded."
|
|
1268
|
+
description: "Filter expression restricting which related records are loaded — references the related type (and the @db.rel.via junction); unqualified fields belong to the related type.",
|
|
1269
|
+
fieldScope: fieldScopes.relFilter
|
|
965
1270
|
},
|
|
966
1271
|
validate(token, args, doc) {
|
|
967
1272
|
const errors = [];
|
|
@@ -978,14 +1283,8 @@ const dbRelAnnotations = { rel: {
|
|
|
978
1283
|
return errors;
|
|
979
1284
|
}
|
|
980
1285
|
if (!args[0]?.queryNode) return errors;
|
|
981
|
-
const
|
|
982
|
-
if (
|
|
983
|
-
const allowedTypes = [targetTypeName];
|
|
984
|
-
if (hasVia) {
|
|
985
|
-
const junctionType = require_validation_utils.getAnnotationAlias(field, "db.rel.via");
|
|
986
|
-
if (junctionType) allowedTypes.push(junctionType);
|
|
987
|
-
}
|
|
988
|
-
errors.push(...require_validation_utils.validateQueryScope(args[0], allowedTypes, targetTypeName, doc));
|
|
1286
|
+
const scope = relFilterScope(field);
|
|
1287
|
+
if (scope) errors.push(...require_validation_utils.validateQueryScope(args[0], scope, doc));
|
|
989
1288
|
return errors;
|
|
990
1289
|
}
|
|
991
1290
|
})
|
|
@@ -1074,6 +1373,7 @@ const dbTableAnnotations = {
|
|
|
1074
1373
|
$self: new _atscript_core.AnnotationSpec({
|
|
1075
1374
|
description: "Marks an interface as a database-persisted entity (table in SQL, collection in MongoDB). If the name argument is omitted, the adapter derives the table name from the interface name.\n\n**Example:**\n```atscript\n@db.table \"users\"\nexport interface User { ... }\n```\n",
|
|
1076
1375
|
nodeType: ["interface"],
|
|
1376
|
+
passedWhenReferred: false,
|
|
1077
1377
|
argument: {
|
|
1078
1378
|
optional: true,
|
|
1079
1379
|
name: "name",
|
|
@@ -1094,6 +1394,7 @@ const dbTableAnnotations = {
|
|
|
1094
1394
|
renamed: new _atscript_core.AnnotationSpec({
|
|
1095
1395
|
description: "Specifies the previous table name for table rename migration. The sync engine generates ALTER TABLE RENAME instead of drop+create.\n\n**Example:**\n```atscript\n@db.table \"app_users\"\n@db.table.renamed \"users\"\nexport interface User { ... }\n```\n",
|
|
1096
1396
|
nodeType: ["interface"],
|
|
1397
|
+
passedWhenReferred: false,
|
|
1097
1398
|
argument: {
|
|
1098
1399
|
name: "oldName",
|
|
1099
1400
|
type: "string",
|
|
@@ -1112,6 +1413,7 @@ const dbTableAnnotations = {
|
|
|
1112
1413
|
preferredId: { uniqueIndex: new _atscript_core.AnnotationSpec({
|
|
1113
1414
|
description: "Selects a unique index as the table's preferred row identifier. When the unique-index name is omitted, the first declared unique-index group wins.\n\n**Example:**\n```atscript\n@db.table\n@db.table.preferredId.uniqueIndex \"by_slug\"\nexport interface Post {\n @db.index.unique \"by_slug\"\n slug: string\n}\n```\n",
|
|
1114
1415
|
nodeType: ["interface"],
|
|
1416
|
+
passedWhenReferred: false,
|
|
1115
1417
|
multiple: false,
|
|
1116
1418
|
argument: {
|
|
1117
1419
|
optional: true,
|
|
@@ -1159,6 +1461,7 @@ const dbTableAnnotations = {
|
|
|
1159
1461
|
schema: new _atscript_core.AnnotationSpec({
|
|
1160
1462
|
description: "Assigns the entity to a database schema/namespace.\n\n**Example:**\n```atscript\n@db.table \"users\"\n@db.schema \"auth\"\nexport interface User { ... }\n```\n",
|
|
1161
1463
|
nodeType: ["interface"],
|
|
1464
|
+
passedWhenReferred: false,
|
|
1162
1465
|
argument: {
|
|
1163
1466
|
name: "name",
|
|
1164
1467
|
type: "string",
|
|
@@ -1168,6 +1471,7 @@ const dbTableAnnotations = {
|
|
|
1168
1471
|
space: new _atscript_core.AnnotationSpec({
|
|
1169
1472
|
description: "Binds the entity to a named database space (a `DbSpace` — one per physical database) for apps running more than one database at once (e.g. MongoDB + PostgreSQL). Absent means the default space.\n\nConsumed by the generated model manifest (grouping in `modelsBySpace`) and by `@TableController(Model)` token binding, which resolves the space registered under this name via `provideDbSpace(space, name)`.\n\n**Example:**\n```atscript\n@db.table \"feed_runs\"\n@db.space \"analytics\"\nexport interface FeedRun { ... }\n```\n",
|
|
1170
1473
|
nodeType: ["interface"],
|
|
1474
|
+
passedWhenReferred: false,
|
|
1171
1475
|
argument: {
|
|
1172
1476
|
name: "name",
|
|
1173
1477
|
type: "string",
|
|
@@ -1177,6 +1481,7 @@ const dbTableAnnotations = {
|
|
|
1177
1481
|
http: { path: new _atscript_core.AnnotationSpec({
|
|
1178
1482
|
description: "HTTP endpoint path where this table is served. Used by the UI for value-help on FK fields. Gets overwritten by the final controller prefix at runtime.\n\n**Example:**\n```atscript\n@db.table \"authors\"\n@db.http.path \"/authors\"\nexport interface Author { ... }\n```\n",
|
|
1179
1483
|
nodeType: ["interface"],
|
|
1484
|
+
passedWhenReferred: false,
|
|
1180
1485
|
argument: {
|
|
1181
1486
|
name: "path",
|
|
1182
1487
|
type: "string",
|
|
@@ -1186,6 +1491,7 @@ const dbTableAnnotations = {
|
|
|
1186
1491
|
sync: { method: new _atscript_core.AnnotationSpec({
|
|
1187
1492
|
description: "Controls how the sync engine handles structural changes that cannot be applied via ALTER TABLE.\n\n- `\"recreate\"` — lossless: create temp table, copy data, drop old, rename.\n- `\"drop\"` — lossy: drop table entirely and create from scratch.\n\nWithout this annotation, structural changes fail with an error requiring manual intervention.\n\n**Example:**\n```atscript\n@db.sync.method \"drop\"\ninterface Logs { ... }\n```\n",
|
|
1188
1493
|
nodeType: ["interface"],
|
|
1494
|
+
passedWhenReferred: false,
|
|
1189
1495
|
argument: {
|
|
1190
1496
|
name: "method",
|
|
1191
1497
|
type: "string",
|
|
@@ -1196,6 +1502,7 @@ const dbTableAnnotations = {
|
|
|
1196
1502
|
depth: { limit: new _atscript_core.AnnotationSpec({
|
|
1197
1503
|
description: "Security guard on nested-write payloads. `N` is a non-negative integer declaring the maximum depth a client may nest `@db.rel.from` children in insert, replace, or patch payloads. Writes deeper than `N` are rejected at the server boundary with HTTP 400 before any DB access.\n\n**Default when absent:** `0` — any nested-write payload is rejected. Authors opt in explicitly to `N >= 1` when they want the server to accept deep writes. This is a security / blast-radius control, not a performance knob.\n\n**Scope:** affects only write acceptance. Has no effect on `/meta` serialization, read/query paths, or wire shape — the meta endpoint always ships FK refs as the shallow `{ id, metadata }` shape regardless of this annotation.\n\n**Example:**\n```atscript\n@db.table \"authors\"\n@db.depth.limit 2\nexport interface Author { ... }\n```\n",
|
|
1198
1504
|
nodeType: ["interface"],
|
|
1505
|
+
passedWhenReferred: false,
|
|
1199
1506
|
multiple: false,
|
|
1200
1507
|
argument: {
|
|
1201
1508
|
name: "depth",
|
|
@@ -1261,6 +1568,7 @@ function tableCapability(capability) {
|
|
|
1261
1568
|
@db.table.${capability} "manual"\nexport interface User {
|
|
1262
1569
|
` + example + "}\n```\n",
|
|
1263
1570
|
nodeType: ["interface"],
|
|
1571
|
+
passedWhenReferred: false,
|
|
1264
1572
|
multiple: false,
|
|
1265
1573
|
argument: {
|
|
1266
1574
|
optional: true,
|
|
@@ -1329,91 +1637,12 @@ const dbUnitAnnotations = { unit: {
|
|
|
1329
1637
|
})
|
|
1330
1638
|
} };
|
|
1331
1639
|
//#endregion
|
|
1332
|
-
//#region src/shared/view-validation.ts
|
|
1333
|
-
/** `@db.agg.*` annotations that are never NULL (the counts: 0 over no value). */
|
|
1334
|
-
const NULL_SAFE_AGG_ANNOTATIONS = require_aggregate_fns.SUPPORTED_AGGREGATE_FNS.filter((fn) => !require_aggregate_fns.NULL_WHEN_EMPTY_AGGREGATE_FNS.has(fn)).map((fn) => `db.agg.${fn}`);
|
|
1335
|
-
/** Primitive leaf types a JSON-stored path may end at. */
|
|
1336
|
-
const JSON_LEAF_TYPES = new Set([
|
|
1337
|
-
"string",
|
|
1338
|
-
"number",
|
|
1339
|
-
"boolean"
|
|
1340
|
-
]);
|
|
1341
|
-
/** Props of a view interface (its own structure; views don't use `extends`). */
|
|
1342
|
-
function viewProps(owner) {
|
|
1343
|
-
if ((0, _atscript_core.isInterface)(owner)) return owner.props;
|
|
1344
|
-
const def = owner.getDefinition();
|
|
1345
|
-
return def && (0, _atscript_core.isStructure)(def) ? def.props : void 0;
|
|
1346
|
-
}
|
|
1347
|
-
/**
|
|
1348
|
-
* VW8: a chain ref that passes through a `@db.json` or array node reads a
|
|
1349
|
-
* JSON leaf, which views can only extract as a string / number / boolean.
|
|
1350
|
-
* Intermediate nodes that `unwindType` can't reach are skipped (sync-time
|
|
1351
|
-
* resolution reports them).
|
|
1352
|
-
*/
|
|
1353
|
-
function validateJsonChain(fieldName, ref, doc, range) {
|
|
1354
|
-
const typeName = ref.id;
|
|
1355
|
-
const chain = ref.chain.map((t) => t.text);
|
|
1356
|
-
if (!typeName || chain.length < 2) return [];
|
|
1357
|
-
let throughJson = false;
|
|
1358
|
-
for (let i = 1; i < chain.length; i++) {
|
|
1359
|
-
const step = doc.unwindType(typeName, chain.slice(0, i));
|
|
1360
|
-
if (!step) return [];
|
|
1361
|
-
const stepNode = step.node;
|
|
1362
|
-
if (stepNode && (0, _atscript_core.isProp)(stepNode) && stepNode.countAnnotations("db.json") > 0 || (0, _atscript_core.isArray)(step.def)) {
|
|
1363
|
-
throughJson = true;
|
|
1364
|
-
break;
|
|
1365
|
-
}
|
|
1366
|
-
}
|
|
1367
|
-
if (!throughJson) return [];
|
|
1368
|
-
const leaf = doc.unwindType(typeName, chain)?.def;
|
|
1369
|
-
const leafType = require_validation_utils.primitiveBaseType(leaf);
|
|
1370
|
-
if (leafType !== void 0 && JSON_LEAF_TYPES.has(leafType)) return [];
|
|
1371
|
-
return [{
|
|
1372
|
-
message: `Field "${fieldName}" reads "${typeName}.${chain.join(".")}" inside a JSON-stored field — it must end at a string, number or boolean leaf`,
|
|
1373
|
-
severity: 1,
|
|
1374
|
-
range
|
|
1375
|
-
}];
|
|
1376
|
-
}
|
|
1377
|
-
/**
|
|
1378
|
-
* Whole-view checks, run once per `@db.view.for` interface:
|
|
1379
|
-
*
|
|
1380
|
-
* - VW7 — a field reading from a left-joined table must be optional (the
|
|
1381
|
-
* join yields NULL for unmatched rows); `@db.agg.count` / `countDistinct`
|
|
1382
|
-
* fields are exempt (they count 0, never NULL).
|
|
1383
|
-
* - VW8 — a chain ref through a `@db.json` or array node must end at a
|
|
1384
|
-
* primitive string / number / boolean leaf.
|
|
1385
|
-
* @since 0.1.136
|
|
1386
|
-
*/
|
|
1387
|
-
function validateViewInterface(owner, doc) {
|
|
1388
|
-
const errors = [];
|
|
1389
|
-
const props = viewProps(owner);
|
|
1390
|
-
if (!props) return errors;
|
|
1391
|
-
const leftJoined = /* @__PURE__ */ new Set();
|
|
1392
|
-
for (const join of require_validation_utils.viewJoins(owner)) {
|
|
1393
|
-
const target = join.args[0]?.text;
|
|
1394
|
-
if (target && join.args[2]?.text === "left") leftJoined.add(target);
|
|
1395
|
-
}
|
|
1396
|
-
for (const [fieldName, prop] of props) {
|
|
1397
|
-
const def = prop.getDefinition();
|
|
1398
|
-
if (!def || !(0, _atscript_core.isRef)(def)) continue;
|
|
1399
|
-
const ref = def;
|
|
1400
|
-
const range = (prop.token("identifier") ?? ref.token("identifier"))?.range;
|
|
1401
|
-
if (!range) continue;
|
|
1402
|
-
if (ref.id && leftJoined.has(ref.id) && !prop.has("optional") && !NULL_SAFE_AGG_ANNOTATIONS.some((name) => prop.countAnnotations(name) > 0)) errors.push({
|
|
1403
|
-
message: `Field "${fieldName}" reads from left-joined "${ref.id}" and must be optional (${fieldName}?: …)`,
|
|
1404
|
-
severity: 1,
|
|
1405
|
-
range
|
|
1406
|
-
});
|
|
1407
|
-
errors.push(...validateJsonChain(fieldName, ref, doc, range));
|
|
1408
|
-
}
|
|
1409
|
-
return errors;
|
|
1410
|
-
}
|
|
1411
|
-
//#endregion
|
|
1412
1640
|
//#region src/plugin/annotations/view.ts
|
|
1413
1641
|
const dbViewAnnotations = { view: {
|
|
1414
1642
|
$self: new _atscript_core.AnnotationSpec({
|
|
1415
1643
|
description: "Marks an interface as a **database view**. Optionally takes a view name argument.\n\n**Example:**\n```atscript\n@db.view \"active_premium_users\"\n@db.view.for User\nexport interface ActivePremiumUser { ... }\n```\n",
|
|
1416
1644
|
nodeType: ["interface"],
|
|
1645
|
+
passedWhenReferred: false,
|
|
1417
1646
|
argument: {
|
|
1418
1647
|
optional: true,
|
|
1419
1648
|
name: "name",
|
|
@@ -1431,12 +1660,14 @@ const dbViewAnnotations = { view: {
|
|
|
1431
1660
|
}
|
|
1432
1661
|
}),
|
|
1433
1662
|
for: new _atscript_core.AnnotationSpec({
|
|
1434
|
-
description: "Specifies the entry/primary
|
|
1663
|
+
description: "Specifies the entry/primary source for a computed view — a `@db.table`, or another `@db.view` (views over views, since 0.1.141). Required for views that map fields via chain refs. A `@db.alias` type cannot be the entry.\n\n**Example:**\n```atscript\n@db.view.for Order\n@db.view.filter `Order.status = 'active'`\nexport interface ActiveOrderDetails { ... }\n```\n",
|
|
1435
1664
|
nodeType: ["interface"],
|
|
1665
|
+
passedWhenReferred: false,
|
|
1436
1666
|
argument: {
|
|
1437
1667
|
name: "entry",
|
|
1438
1668
|
type: "ref",
|
|
1439
|
-
description: "The primary/entry table type (must have @db.table)."
|
|
1669
|
+
description: "The primary/entry table or view type (must have @db.table or @db.view).",
|
|
1670
|
+
refFilter: require_validation_utils.isDbSourceDecl
|
|
1440
1671
|
},
|
|
1441
1672
|
validate(token, args, doc) {
|
|
1442
1673
|
const errors = [];
|
|
@@ -1446,26 +1677,35 @@ const dbViewAnnotations = { view: {
|
|
|
1446
1677
|
severity: 1,
|
|
1447
1678
|
range: token.range
|
|
1448
1679
|
});
|
|
1449
|
-
if (args[0]) errors.push(...require_validation_utils.validateRefArgument(args[0], doc,
|
|
1680
|
+
if (args[0]) errors.push(...require_validation_utils.validateRefArgument(args[0], doc, VIEW_SOURCE_ARGUMENT));
|
|
1681
|
+
const cycle = require_validation_utils.findViewCycle(owner, doc);
|
|
1682
|
+
if (cycle) errors.push({
|
|
1683
|
+
message: `View '${cycle[0]}' depends on itself: ${cycle.join(" → ")}`,
|
|
1684
|
+
severity: 1,
|
|
1685
|
+
range: args[0]?.range ?? token.range
|
|
1686
|
+
});
|
|
1450
1687
|
errors.push(...validateViewInterface(owner, doc));
|
|
1451
1688
|
return errors;
|
|
1452
1689
|
}
|
|
1453
1690
|
}),
|
|
1454
1691
|
joins: new _atscript_core.AnnotationSpec({
|
|
1455
|
-
description: "Declares an explicit join for a view. Joins are INNER by default — pass `'left'` as the third argument to keep entry rows without a match (fields read from a left-joined table must be optional). A join condition may reference the entry table and joins declared before it (chained joins)
|
|
1692
|
+
description: "Declares an explicit join for a view. Joins are INNER by default — pass `'left'` as the third argument to keep entry rows without a match (fields read from a left-joined table must be optional). A join condition may reference the entry table and joins declared before it (chained joins). The target is a `@db.table`, a `@db.view` (since 0.1.141), or a `@db.alias` type — the way to join one table twice or to self-join the entry table: every scope name (entry + joins) must be unique.\n\n**Example:**\n```atscript\n@db.view.for Order\n@db.view.joins Customer, `Customer.id = Order.customerId`\n@db.view.joins Region, `Region.id = Customer.regionId`, 'left'\nexport interface OrderRegion { ... }\n```\n",
|
|
1456
1693
|
nodeType: ["interface"],
|
|
1694
|
+
passedWhenReferred: false,
|
|
1457
1695
|
multiple: true,
|
|
1458
1696
|
mergeStrategy: "append",
|
|
1459
1697
|
argument: [
|
|
1460
1698
|
{
|
|
1461
1699
|
name: "target",
|
|
1462
1700
|
type: "ref",
|
|
1463
|
-
description: "The
|
|
1701
|
+
description: "The type to join: a @db.table, a @db.view, or a @db.alias of one (for a second join of the same table / a self-join).",
|
|
1702
|
+
refFilter: isJoinTarget
|
|
1464
1703
|
},
|
|
1465
1704
|
{
|
|
1466
1705
|
name: "condition",
|
|
1467
1706
|
type: "query",
|
|
1468
|
-
description: "Join condition expression."
|
|
1707
|
+
description: "Join condition expression — may reference the join target, the entry table and the joins declared before this one.",
|
|
1708
|
+
fieldScope: fieldScopes.viewJoin
|
|
1469
1709
|
},
|
|
1470
1710
|
{
|
|
1471
1711
|
name: "kind",
|
|
@@ -1486,7 +1726,10 @@ const dbViewAnnotations = { view: {
|
|
|
1486
1726
|
});
|
|
1487
1727
|
return errors;
|
|
1488
1728
|
}
|
|
1489
|
-
if (args[0]) errors.push(...require_validation_utils.validateRefArgument(args[0], doc, {
|
|
1729
|
+
if (args[0]) errors.push(...require_validation_utils.validateRefArgument(args[0], doc, {
|
|
1730
|
+
accept: isJoinTarget,
|
|
1731
|
+
expected: "must be a @db.table or a @db.view."
|
|
1732
|
+
}));
|
|
1490
1733
|
const entryTypeName = require_validation_utils.getAnnotationAlias(owner, "db.view.for");
|
|
1491
1734
|
if (!entryTypeName) {
|
|
1492
1735
|
errors.push({
|
|
@@ -1496,40 +1739,40 @@ const dbViewAnnotations = { view: {
|
|
|
1496
1739
|
});
|
|
1497
1740
|
return errors;
|
|
1498
1741
|
}
|
|
1499
|
-
const
|
|
1500
|
-
const
|
|
1501
|
-
const earlier = require_validation_utils.
|
|
1742
|
+
const joins = require_validation_utils.viewJoins(owner);
|
|
1743
|
+
const join = joins.find((a) => a.token === token);
|
|
1744
|
+
const earlier = join ? require_validation_utils.earlierJoinTargets(joins, join) : [];
|
|
1502
1745
|
if (args[0]) {
|
|
1503
1746
|
const target = args[0].text;
|
|
1747
|
+
const aliasHint = (name) => {
|
|
1748
|
+
const decl = doc.getDeclarationOwnerNode(target)?.node;
|
|
1749
|
+
return decl && require_validation_utils.isAliasDecl(decl) ? "" : ` — declare a @db.alias type (\`@db.alias ${name}\` + \`export type Other = ${name}\`) to join it under another name`;
|
|
1750
|
+
};
|
|
1504
1751
|
if (target === entryTypeName) errors.push({
|
|
1505
|
-
message: `@db.view.joins cannot join the entry table '${target}'
|
|
1752
|
+
message: `@db.view.joins cannot join the entry table '${target}' directly${aliasHint(target)}`,
|
|
1506
1753
|
severity: 1,
|
|
1507
1754
|
range: args[0].range
|
|
1508
1755
|
});
|
|
1509
1756
|
else if (earlier.includes(target)) errors.push({
|
|
1510
|
-
message: `'${target}' is joined more than once
|
|
1757
|
+
message: `'${target}' is joined more than once${aliasHint(target)}`,
|
|
1511
1758
|
severity: 1,
|
|
1512
1759
|
range: args[0].range
|
|
1513
1760
|
});
|
|
1514
1761
|
}
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
errors.push(...require_validation_utils.validateQueryScope(args[1], [
|
|
1518
|
-
joinTargetName,
|
|
1519
|
-
entryTypeName,
|
|
1520
|
-
...earlier
|
|
1521
|
-
], entryTypeName, doc, "a join may reference the entry table and joins declared before it"));
|
|
1522
|
-
}
|
|
1762
|
+
const scope = join && viewJoinScope(owner, join, earlier);
|
|
1763
|
+
if (args[1]?.queryNode && scope) errors.push(...require_validation_utils.validateQueryScope(args[1], scope, doc, "a join may reference the entry table and joins declared before it"));
|
|
1523
1764
|
return errors;
|
|
1524
1765
|
}
|
|
1525
1766
|
}),
|
|
1526
1767
|
filter: new _atscript_core.AnnotationSpec({
|
|
1527
1768
|
description: "WHERE clause for a view, filtering which rows are included.\n\n**Example:**\n```atscript\n@db.view.for User\n@db.view.filter `User.status = 'active' and User.age >= 18`\nexport interface ActiveUser { ... }\n```\n",
|
|
1528
1769
|
nodeType: ["interface"],
|
|
1770
|
+
passedWhenReferred: false,
|
|
1529
1771
|
argument: {
|
|
1530
1772
|
name: "condition",
|
|
1531
1773
|
type: "query",
|
|
1532
|
-
description: "Filter expression for the view WHERE clause."
|
|
1774
|
+
description: "Filter expression for the view WHERE clause — may reference the entry table and every join; unqualified fields belong to the entry.",
|
|
1775
|
+
fieldScope: fieldScopes.viewFilter
|
|
1533
1776
|
},
|
|
1534
1777
|
validate(token, args, doc) {
|
|
1535
1778
|
const errors = [];
|
|
@@ -1543,8 +1786,8 @@ const dbViewAnnotations = { view: {
|
|
|
1543
1786
|
return errors;
|
|
1544
1787
|
}
|
|
1545
1788
|
if (!args[0]?.queryNode) return errors;
|
|
1546
|
-
const
|
|
1547
|
-
if (!
|
|
1789
|
+
const scope = viewFilterScope(owner);
|
|
1790
|
+
if (!scope) {
|
|
1548
1791
|
errors.push({
|
|
1549
1792
|
message: "@db.view.filter requires @db.view.for to identify the entry table",
|
|
1550
1793
|
severity: 1,
|
|
@@ -1552,13 +1795,14 @@ const dbViewAnnotations = { view: {
|
|
|
1552
1795
|
});
|
|
1553
1796
|
return errors;
|
|
1554
1797
|
}
|
|
1555
|
-
errors.push(...require_validation_utils.validateQueryScope(args[0],
|
|
1798
|
+
errors.push(...require_validation_utils.validateQueryScope(args[0], scope, doc));
|
|
1556
1799
|
return errors;
|
|
1557
1800
|
}
|
|
1558
1801
|
}),
|
|
1559
1802
|
materialized: new _atscript_core.AnnotationSpec({
|
|
1560
1803
|
description: "Marks a view as materialized (precomputed, stored on disk). Supported by PostgreSQL, CockroachDB, Oracle, SQL Server (indexed views), Snowflake. MongoDB supports on-demand materialized views via $merge/$out. Not applicable to MySQL or SQLite.\n\n**Example:**\n```atscript\n@db.view.materialized\n@db.view.for User\n@db.view.filter `User.status = 'active'`\nexport interface ActiveUsers { ... }\n```\n",
|
|
1561
1804
|
nodeType: ["interface"],
|
|
1805
|
+
passedWhenReferred: false,
|
|
1562
1806
|
validate(token, _args, _doc) {
|
|
1563
1807
|
const errors = [];
|
|
1564
1808
|
const owner = token.parentNode;
|
|
@@ -1573,6 +1817,7 @@ const dbViewAnnotations = { view: {
|
|
|
1573
1817
|
renamed: new _atscript_core.AnnotationSpec({
|
|
1574
1818
|
description: "Specifies the previous view name for view rename migration. The sync engine drops the old view and creates the new one.\n\n**Example:**\n```atscript\n@db.view \"active_premium_users\"\n@db.view.renamed \"active_users\"\n@db.view.for User\nexport interface ActivePremiumUser { ... }\n```\n",
|
|
1575
1819
|
nodeType: ["interface"],
|
|
1820
|
+
passedWhenReferred: false,
|
|
1576
1821
|
argument: {
|
|
1577
1822
|
name: "oldName",
|
|
1578
1823
|
type: "string",
|
|
@@ -1590,21 +1835,28 @@ const dbViewAnnotations = { view: {
|
|
|
1590
1835
|
}
|
|
1591
1836
|
}),
|
|
1592
1837
|
having: new _atscript_core.AnnotationSpec({
|
|
1593
|
-
description: "Post-aggregation filter (HAVING clause) for analytical views. References view
|
|
1838
|
+
description: "Post-aggregation filter (HAVING clause) for analytical views. References the view's own fields, unqualified (`totalRevenue > 100`) — aggregate fields by their alias, plain fields as GROUP BY dimensions; source-table refs are not in scope.\n\n**Example:**\n```atscript\n@db.view\n@db.view.for Order\n@db.view.having `totalRevenue > 100`\nexport interface TopCategories { ... }\n```\n",
|
|
1594
1839
|
nodeType: ["interface"],
|
|
1840
|
+
passedWhenReferred: false,
|
|
1595
1841
|
argument: {
|
|
1596
1842
|
name: "condition",
|
|
1597
1843
|
type: "query",
|
|
1598
|
-
description: "HAVING condition
|
|
1844
|
+
description: "HAVING condition — the view's own fields, unqualified.",
|
|
1845
|
+
fieldScope: fieldScopes.viewHaving
|
|
1599
1846
|
},
|
|
1600
|
-
validate(token,
|
|
1847
|
+
validate(token, args, doc) {
|
|
1601
1848
|
const errors = [];
|
|
1602
1849
|
const owner = token.parentNode;
|
|
1603
|
-
if (!require_validation_utils.hasAnyViewAnnotation(owner))
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1850
|
+
if (!require_validation_utils.hasAnyViewAnnotation(owner)) {
|
|
1851
|
+
errors.push({
|
|
1852
|
+
message: "@db.view.having is only valid on @db.view interfaces",
|
|
1853
|
+
severity: 1,
|
|
1854
|
+
range: token.range
|
|
1855
|
+
});
|
|
1856
|
+
return errors;
|
|
1857
|
+
}
|
|
1858
|
+
const scope = viewHavingScope(owner);
|
|
1859
|
+
if (args[0]?.queryNode && scope) errors.push(...require_validation_utils.validateQueryScope(args[0], scope, doc, "@db.view.having references the view's own fields unqualified"));
|
|
1608
1860
|
return errors;
|
|
1609
1861
|
}
|
|
1610
1862
|
})
|
|
@@ -1636,6 +1888,7 @@ const dbPlugin = (options) => ({
|
|
|
1636
1888
|
depth: dbTableAnnotations.depth,
|
|
1637
1889
|
rel: dbRelAnnotations.rel,
|
|
1638
1890
|
view: dbViewAnnotations.view,
|
|
1891
|
+
alias: dbAliasAnnotations.alias,
|
|
1639
1892
|
agg: dbAggAnnotations.agg,
|
|
1640
1893
|
search: dbSearchAnnotations.search,
|
|
1641
1894
|
amount: dbAmountAnnotations.amount,
|