@atscript/db 0.1.139 → 0.1.141
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.mjs
CHANGED
|
@@ -1,18 +1,11 @@
|
|
|
1
1
|
import { i as SUPPORTED_AGGREGATE_FNS, r as NULL_WHEN_EMPTY_AGGREGATE_FNS } from "./aggregate-fns-CfsveE1w.mjs";
|
|
2
|
+
import { n as DERIVED_INCOMPATIBLE, r as JSON_LEAF_TYPES, t as DB_ENTITY_ANNOTATIONS } from "./derived-rules-0sKn4f5C.mjs";
|
|
2
3
|
import "./consts-C_-5_pFq.mjs";
|
|
3
|
-
import {
|
|
4
|
+
import { S as validateSiblingStringField, _ as getParentTypeName, a as hasAnyViewAnnotation, b as validateExclusiveWith, d as viewJoins, f as viewScopeTypes, g as getParentStruct, h as getNavTargetTypeName, l as validateQueryScope, m as getDbTableOwner, n as findFKFieldsPointingTo, o as isAliasDecl, p as getAnnotationAlias, r as findViewCycle, s as isDbSourceDecl, t as earlierJoinTargets, u as validateRefArgument, v as primitiveBaseType, x as validateFieldBaseType, y as refActionAnnotation } from "./validation-utils-CMR4fe2M.mjs";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
7
|
import { AnnotationSpec, DEFAULT_FORMAT, isArray, isInterface, isPrimitive, isProp, isRef, isStructure } from "@atscript/core";
|
|
7
8
|
//#region src/plugin/manifest.ts
|
|
8
|
-
const DB_ENTITY_ANNOTATIONS = [
|
|
9
|
-
"db.table",
|
|
10
|
-
"db.view",
|
|
11
|
-
"db.view.for"
|
|
12
|
-
];
|
|
13
|
-
function isDbEntity(node) {
|
|
14
|
-
return DB_ENTITY_ANNOTATIONS.some((name) => node.countAnnotations(name) > 0);
|
|
15
|
-
}
|
|
16
9
|
/**
|
|
17
10
|
* Renders the model-manifest module: an inventory of every exported
|
|
18
11
|
* `@db.table` / `@db.view` entity in the project, grouped by `@db.space`.
|
|
@@ -41,7 +34,7 @@ async function generateModelManifest(options, output, format, repo) {
|
|
|
41
34
|
let specifier = path.relative(manifestDir, docPath).split(path.sep).join("/");
|
|
42
35
|
if (!specifier.startsWith(".")) specifier = `./${specifier}`;
|
|
43
36
|
for (const [exportName, node] of [...doc.exports.entries()].toSorted(([a], [b]) => a.localeCompare(b))) {
|
|
44
|
-
if (!
|
|
37
|
+
if (!isDbSourceDecl(node)) continue;
|
|
45
38
|
let alias = exportName;
|
|
46
39
|
for (let n = 1; usedAliases.has(alias); n++) alias = `${exportName}_${n}`;
|
|
47
40
|
usedAliases.add(alias);
|
|
@@ -95,6 +88,113 @@ async function generateModelManifest(options, output, format, repo) {
|
|
|
95
88
|
});
|
|
96
89
|
}
|
|
97
90
|
//#endregion
|
|
91
|
+
//#region src/plugin/lsp-scopes.ts
|
|
92
|
+
/**
|
|
93
|
+
* Editor scopes of the `@db.*` query / field-path arguments and the type
|
|
94
|
+
* filters of the ref arguments — one function per rule. The validators in
|
|
95
|
+
* `annotations/*` derive their in-scope types from these SAME functions, so a
|
|
96
|
+
* diagnostic and what the editor completes, hovers and jumps to never diverge.
|
|
97
|
+
* @since 0.1.141
|
|
98
|
+
*/
|
|
99
|
+
/** The `@db.view.for` entry name of a view node. */
|
|
100
|
+
function viewEntry(view) {
|
|
101
|
+
return getAnnotationAlias(view, "db.view.for");
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* `@db.view.filter` — and a conditional `@db.agg.*` of the same view: the
|
|
105
|
+
* entry table and every `@db.view.joins` target (`@db.alias` names included),
|
|
106
|
+
* in declaration order; an unqualified field belongs to the entry.
|
|
107
|
+
*/
|
|
108
|
+
function viewFilterScope(view) {
|
|
109
|
+
const entry = viewEntry(view);
|
|
110
|
+
return entry ? {
|
|
111
|
+
allowedTypes: viewScopeTypes(view),
|
|
112
|
+
unqualifiedTarget: entry
|
|
113
|
+
} : void 0;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* `@db.view.joins` condition: the join target, the entry table and the joins
|
|
117
|
+
* declared before this one (chained joins — `earlier`, computed from the
|
|
118
|
+
* view's {@link viewJoins} when the caller has not); an unqualified field
|
|
119
|
+
* belongs to the entry.
|
|
120
|
+
*/
|
|
121
|
+
function viewJoinScope(view, join, earlier = earlierJoinTargets(viewJoins(view), join)) {
|
|
122
|
+
const entry = viewEntry(view);
|
|
123
|
+
const target = join.args[0]?.text;
|
|
124
|
+
if (!entry || !target) return;
|
|
125
|
+
return {
|
|
126
|
+
allowedTypes: [
|
|
127
|
+
target,
|
|
128
|
+
entry,
|
|
129
|
+
...earlier
|
|
130
|
+
],
|
|
131
|
+
unqualifiedTarget: entry
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/** `@db.view.having`: the view's own fields, unqualified — no source type is in scope. */
|
|
135
|
+
function viewHavingScope(view) {
|
|
136
|
+
return view.id ? {
|
|
137
|
+
allowedTypes: [],
|
|
138
|
+
unqualifiedTarget: view.id
|
|
139
|
+
} : void 0;
|
|
140
|
+
}
|
|
141
|
+
/** The condition of a `@db.agg.*` on a view field: the scope of the view's `@db.view.filter`. */
|
|
142
|
+
function aggConditionScope(propToken) {
|
|
143
|
+
const view = getDbTableOwner(propToken);
|
|
144
|
+
return view ? viewFilterScope(view) : void 0;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The field of a `@db.agg.*` (`'amount'`, `'settings.level'`): a field path
|
|
148
|
+
* of the prop's chain-ref type, else of the view's `@db.view.for` entry —
|
|
149
|
+
* where the runtime reads the aggregate's source column from.
|
|
150
|
+
*/
|
|
151
|
+
function aggFieldScope(propToken) {
|
|
152
|
+
const def = propToken.parentNode?.getDefinition();
|
|
153
|
+
const refType = def && isRef(def) && def.hasChain ? def.id : void 0;
|
|
154
|
+
const view = refType ? void 0 : getDbTableOwner(propToken);
|
|
155
|
+
const target = refType ?? (view ? viewEntry(view) : void 0);
|
|
156
|
+
return target ? {
|
|
157
|
+
allowedTypes: [],
|
|
158
|
+
unqualifiedTarget: target
|
|
159
|
+
} : void 0;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* `@db.rel.filter` on a navigational field: the related type (`Post` of
|
|
163
|
+
* `posts: Post[]`) and, for `@db.rel.via`, the junction table; an
|
|
164
|
+
* unqualified field belongs to the related type.
|
|
165
|
+
*/
|
|
166
|
+
function relFilterScope(field) {
|
|
167
|
+
const target = getNavTargetTypeName(field);
|
|
168
|
+
if (!target) return;
|
|
169
|
+
const junction = getAnnotationAlias(field, "db.rel.via");
|
|
170
|
+
return {
|
|
171
|
+
allowedTypes: junction ? [target, junction] : [target],
|
|
172
|
+
unqualifiedTarget: target
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/** The `fieldScope` hooks (argument token → scope) the annotation specs declare. */
|
|
176
|
+
const fieldScopes = {
|
|
177
|
+
viewFilter: (arg) => arg.parentNode ? viewFilterScope(arg.parentNode) : void 0,
|
|
178
|
+
viewJoin: (arg) => {
|
|
179
|
+
const view = arg.parentNode;
|
|
180
|
+
const joins = view ? viewJoins(view) : [];
|
|
181
|
+
const join = joins.find((a) => a.args.includes(arg));
|
|
182
|
+
return view && join ? viewJoinScope(view, join, earlierJoinTargets(joins, join)) : void 0;
|
|
183
|
+
},
|
|
184
|
+
viewHaving: (arg) => arg.parentNode ? viewHavingScope(arg.parentNode) : void 0,
|
|
185
|
+
aggCondition: aggConditionScope,
|
|
186
|
+
aggField: aggFieldScope,
|
|
187
|
+
relFilter: (arg) => arg.parentNode ? relFilterScope(arg.parentNode) : void 0
|
|
188
|
+
};
|
|
189
|
+
/** `refFilter` of the `@db.view.joins` target: a view source or a `@db.alias` of one. */
|
|
190
|
+
function isJoinTarget(decl) {
|
|
191
|
+
return isDbSourceDecl(decl) || isAliasDecl(decl);
|
|
192
|
+
}
|
|
193
|
+
/** `refFilter` of `@db.rel.via`: a `@db.table`. */
|
|
194
|
+
function isDbTable(decl) {
|
|
195
|
+
return decl.countAnnotations("db.table") > 0;
|
|
196
|
+
}
|
|
197
|
+
//#endregion
|
|
98
198
|
//#region src/plugin/annotations/agg.ts
|
|
99
199
|
/**
|
|
100
200
|
* A conditional aggregate that is NULL when no row matches (so its field
|
|
@@ -109,7 +209,8 @@ const CONDITION_ARG = {
|
|
|
109
209
|
name: "condition",
|
|
110
210
|
type: "query",
|
|
111
211
|
optional: true,
|
|
112
|
-
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."
|
|
212
|
+
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.",
|
|
213
|
+
fieldScope: fieldScopes.aggCondition
|
|
113
214
|
};
|
|
114
215
|
/**
|
|
115
216
|
* The rules every `@db.agg.*` shares: `'*'` is `count`'s only, and a
|
|
@@ -127,9 +228,8 @@ function validateAggArgs(name, token, args, doc) {
|
|
|
127
228
|
});
|
|
128
229
|
const condition = args[1];
|
|
129
230
|
if (!condition?.queryNode) return errors;
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
if (!owner || !entryTypeName) {
|
|
231
|
+
const scope = aggConditionScope(token);
|
|
232
|
+
if (!scope) {
|
|
133
233
|
errors.push({
|
|
134
234
|
message: `A conditional ${annotation} requires @db.view.for on the view`,
|
|
135
235
|
severity: 1,
|
|
@@ -137,7 +237,7 @@ function validateAggArgs(name, token, args, doc) {
|
|
|
137
237
|
});
|
|
138
238
|
return errors;
|
|
139
239
|
}
|
|
140
|
-
errors.push(...validateQueryScope(condition,
|
|
240
|
+
errors.push(...validateQueryScope(condition, scope, doc));
|
|
141
241
|
const prop = token.parentNode;
|
|
142
242
|
if (conditionalIsNullable(name) && prop && !prop.has("optional")) {
|
|
143
243
|
const field = prop.id ?? "field";
|
|
@@ -169,7 +269,8 @@ function aggSpec(name, description, field, types, extra = "") {
|
|
|
169
269
|
name: "field",
|
|
170
270
|
type: "string",
|
|
171
271
|
optional: field.optional,
|
|
172
|
-
description: field.description
|
|
272
|
+
description: field.description,
|
|
273
|
+
fieldScope: fieldScopes.aggField
|
|
173
274
|
}, CONDITION_ARG],
|
|
174
275
|
validate(token, args, doc) {
|
|
175
276
|
const errors = validateAggArgs(name, token, args, doc);
|
|
@@ -190,6 +291,51 @@ const dbAggAnnotations = { agg: {
|
|
|
190
291
|
max: aggSpec("max", "Declares a view field as MAX of a source column.", { description: "Source column name." })
|
|
191
292
|
} };
|
|
192
293
|
//#endregion
|
|
294
|
+
//#region src/plugin/annotations/alias.ts
|
|
295
|
+
/** The `accept` rule of a view-source ref argument (`@db.view.for`, `@db.alias`). */
|
|
296
|
+
const VIEW_SOURCE_ARGUMENT = {
|
|
297
|
+
accept: isDbSourceDecl,
|
|
298
|
+
expected: "must be a @db.table or a @db.view."
|
|
299
|
+
};
|
|
300
|
+
/**
|
|
301
|
+
* `@db.alias <Target>` — a named join scope over a table or view, so a view
|
|
302
|
+
* can join the same table twice or join its own entry table (self-join).
|
|
303
|
+
* @since 0.1.141
|
|
304
|
+
*/
|
|
305
|
+
const dbAliasAnnotations = { alias: new AnnotationSpec({
|
|
306
|
+
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",
|
|
307
|
+
nodeType: ["type"],
|
|
308
|
+
passedWhenReferred: false,
|
|
309
|
+
argument: {
|
|
310
|
+
name: "target",
|
|
311
|
+
type: "ref",
|
|
312
|
+
description: "The aliased table or view type (must have @db.table or @db.view).",
|
|
313
|
+
refFilter: isDbSourceDecl
|
|
314
|
+
},
|
|
315
|
+
validate(token, args, doc) {
|
|
316
|
+
const errors = [];
|
|
317
|
+
const owner = token.parentNode;
|
|
318
|
+
const target = args[0]?.text;
|
|
319
|
+
for (const name of DB_ENTITY_ANNOTATIONS) if (owner.countAnnotations(name) > 0) {
|
|
320
|
+
errors.push({
|
|
321
|
+
message: `A @db.alias type cannot carry @${name} — it names a join scope over "${target ?? "…"}", not a table or view`,
|
|
322
|
+
severity: 1,
|
|
323
|
+
range: token.range
|
|
324
|
+
});
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
if (!target) return errors;
|
|
328
|
+
const def = owner.getDefinition();
|
|
329
|
+
if ((def && isRef(def) && !def.hasChain ? def.id : void 0) !== target) errors.push({
|
|
330
|
+
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}`,
|
|
331
|
+
severity: 1,
|
|
332
|
+
range: token.range
|
|
333
|
+
});
|
|
334
|
+
errors.push(...validateRefArgument(args[0], doc, VIEW_SOURCE_ARGUMENT));
|
|
335
|
+
return errors;
|
|
336
|
+
}
|
|
337
|
+
}) };
|
|
338
|
+
//#endregion
|
|
193
339
|
//#region src/plugin/annotations/amount.ts
|
|
194
340
|
const CURRENCY_CODE_PATTERN = /^[A-Z0-9]{2,10}$/;
|
|
195
341
|
const dbAmountAnnotations = { amount: { currency: {
|
|
@@ -244,6 +390,100 @@ const dbAmountAnnotations = { amount: { currency: {
|
|
|
244
390
|
})
|
|
245
391
|
} } };
|
|
246
392
|
//#endregion
|
|
393
|
+
//#region src/shared/view-validation.ts
|
|
394
|
+
/** `@db.agg.*` annotations that are never NULL (the counts: 0 over no value). */
|
|
395
|
+
const NULL_SAFE_AGG_ANNOTATIONS = SUPPORTED_AGGREGATE_FNS.filter((fn) => !NULL_WHEN_EMPTY_AGGREGATE_FNS.has(fn)).map((fn) => `db.agg.${fn}`);
|
|
396
|
+
/**
|
|
397
|
+
* Walks a chain ref step by step (`Order.payload`, `Order.payload.customer`, …)
|
|
398
|
+
* and reports the JSON root, arrays, encryption, optionality and the leaf type.
|
|
399
|
+
* A chain shorter than 2 is not a field path (`resolved: false`).
|
|
400
|
+
* @since 0.1.141
|
|
401
|
+
*/
|
|
402
|
+
function jsonChainInfo(ref, doc) {
|
|
403
|
+
const typeName = ref.id ?? "";
|
|
404
|
+
const chain = ref.chain.map((t) => t.text);
|
|
405
|
+
const info = {
|
|
406
|
+
typeName,
|
|
407
|
+
chain,
|
|
408
|
+
resolved: false,
|
|
409
|
+
viaArray: false,
|
|
410
|
+
viaEncrypted: false,
|
|
411
|
+
optional: false
|
|
412
|
+
};
|
|
413
|
+
if (!typeName || chain.length < 2) return info;
|
|
414
|
+
for (let i = 1; i <= chain.length; i++) {
|
|
415
|
+
const step = doc.unwindType(typeName, chain.slice(0, i));
|
|
416
|
+
if (!step) return info;
|
|
417
|
+
const node = step.node;
|
|
418
|
+
const prop = node && isProp(node) ? node : void 0;
|
|
419
|
+
if (prop?.has("optional")) info.optional = true;
|
|
420
|
+
if (prop && prop.countAnnotations("db.encrypted") > 0) info.viaEncrypted = true;
|
|
421
|
+
const isJsonProp = prop !== void 0 && prop.countAnnotations("db.json") > 0;
|
|
422
|
+
const isArr = isArray(step.def);
|
|
423
|
+
if (isArr) info.viaArray = true;
|
|
424
|
+
if (info.jsonRoot === void 0 && (isJsonProp || isArr)) info.jsonRoot = i;
|
|
425
|
+
if (i === chain.length) info.leafType = primitiveBaseType(step.def);
|
|
426
|
+
}
|
|
427
|
+
info.resolved = true;
|
|
428
|
+
return info;
|
|
429
|
+
}
|
|
430
|
+
/** Props of a view interface (its own structure; views don't use `extends`). */
|
|
431
|
+
function viewProps(owner) {
|
|
432
|
+
if (isInterface(owner)) return owner.props;
|
|
433
|
+
const def = owner.getDefinition();
|
|
434
|
+
return def && isStructure(def) ? def.props : void 0;
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* VW8: a chain ref that passes through a `@db.json` or array node reads a
|
|
438
|
+
* JSON leaf, which views can only extract as a string / number / boolean.
|
|
439
|
+
* Intermediate nodes that `unwindType` can't reach are skipped (sync-time
|
|
440
|
+
* resolution reports them).
|
|
441
|
+
*/
|
|
442
|
+
function validateJsonChain(fieldName, ref, doc, range) {
|
|
443
|
+
const info = jsonChainInfo(ref, doc);
|
|
444
|
+
if (!info.resolved || info.jsonRoot === void 0 || info.jsonRoot >= info.chain.length) return [];
|
|
445
|
+
if (info.leafType !== void 0 && JSON_LEAF_TYPES.has(info.leafType)) return [];
|
|
446
|
+
return [{
|
|
447
|
+
message: `Field "${fieldName}" reads "${info.typeName}.${info.chain.join(".")}" inside a JSON-stored field — it must end at a string, number or boolean leaf`,
|
|
448
|
+
severity: 1,
|
|
449
|
+
range
|
|
450
|
+
}];
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Whole-view checks, run once per `@db.view.for` interface:
|
|
454
|
+
*
|
|
455
|
+
* - VW7 — a field reading from a left-joined table must be optional (the
|
|
456
|
+
* join yields NULL for unmatched rows); `@db.agg.count` / `countDistinct`
|
|
457
|
+
* fields are exempt (they count 0, never NULL).
|
|
458
|
+
* - VW8 — a chain ref through a `@db.json` or array node must end at a
|
|
459
|
+
* primitive string / number / boolean leaf.
|
|
460
|
+
* @since 0.1.136
|
|
461
|
+
*/
|
|
462
|
+
function validateViewInterface(owner, doc) {
|
|
463
|
+
const errors = [];
|
|
464
|
+
const props = viewProps(owner);
|
|
465
|
+
if (!props) return errors;
|
|
466
|
+
const leftJoined = /* @__PURE__ */ new Set();
|
|
467
|
+
for (const join of viewJoins(owner)) {
|
|
468
|
+
const target = join.args[0]?.text;
|
|
469
|
+
if (target && join.args[2]?.text === "left") leftJoined.add(target);
|
|
470
|
+
}
|
|
471
|
+
for (const [fieldName, prop] of props) {
|
|
472
|
+
const def = prop.getDefinition();
|
|
473
|
+
if (!def || !isRef(def)) continue;
|
|
474
|
+
const ref = def;
|
|
475
|
+
const range = (prop.token("identifier") ?? ref.token("identifier"))?.range;
|
|
476
|
+
if (!range) continue;
|
|
477
|
+
if (ref.id && leftJoined.has(ref.id) && !prop.has("optional") && !NULL_SAFE_AGG_ANNOTATIONS.some((name) => prop.countAnnotations(name) > 0)) errors.push({
|
|
478
|
+
message: `Field "${fieldName}" reads from left-joined "${ref.id}" and must be optional (${fieldName}?: …)`,
|
|
479
|
+
severity: 1,
|
|
480
|
+
range
|
|
481
|
+
});
|
|
482
|
+
errors.push(...validateJsonChain(fieldName, ref, doc, range));
|
|
483
|
+
}
|
|
484
|
+
return errors;
|
|
485
|
+
}
|
|
486
|
+
//#endregion
|
|
247
487
|
//#region src/plugin/annotations/column.ts
|
|
248
488
|
const dbColumnAnnotations = {
|
|
249
489
|
patch: { strategy: new AnnotationSpec({
|
|
@@ -331,6 +571,66 @@ const dbColumnAnnotations = {
|
|
|
331
571
|
return validateFieldBaseType(token, doc, "@db.column.precision", ["number", "decimal"]);
|
|
332
572
|
}
|
|
333
573
|
}),
|
|
574
|
+
derived: new AnnotationSpec({
|
|
575
|
+
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",
|
|
576
|
+
nodeType: ["prop"],
|
|
577
|
+
passedWhenReferred: false,
|
|
578
|
+
multiple: false,
|
|
579
|
+
validate(token, _args, doc) {
|
|
580
|
+
const errors = [];
|
|
581
|
+
const field = token.parentNode;
|
|
582
|
+
const fail = (message, severity = 1) => {
|
|
583
|
+
errors.push({
|
|
584
|
+
message,
|
|
585
|
+
severity,
|
|
586
|
+
range: token.range
|
|
587
|
+
});
|
|
588
|
+
};
|
|
589
|
+
const owner = getDbTableOwner(token);
|
|
590
|
+
if (!owner || !isInterface(owner) || owner.countAnnotations("db.table") === 0) {
|
|
591
|
+
fail("@db.column.derived is only valid on a top-level field of a @db.table interface");
|
|
592
|
+
return errors;
|
|
593
|
+
}
|
|
594
|
+
for (const [name, why] of DERIVED_INCOMPATIBLE) if (field.countAnnotations(name) > 0) fail(`@db.column.derived cannot coexist with @${name} — ${why}`);
|
|
595
|
+
const definition = field.getDefinition();
|
|
596
|
+
if (!definition || !isRef(definition) || !definition.hasChain) {
|
|
597
|
+
fail("@db.column.derived requires a chain reference into a @db.json field of the same table (e.g. `customerId: Order.payload.customer.id`)");
|
|
598
|
+
return errors;
|
|
599
|
+
}
|
|
600
|
+
const ref = definition;
|
|
601
|
+
const tableName = getParentTypeName(token);
|
|
602
|
+
if (ref.id !== tableName) {
|
|
603
|
+
fail(`@db.column.derived must reference the enclosing table '${tableName ?? ""}', not '${ref.id ?? ""}' — a derived column reads its own row`);
|
|
604
|
+
return errors;
|
|
605
|
+
}
|
|
606
|
+
const info = jsonChainInfo(ref, doc);
|
|
607
|
+
const path = `${info.typeName}.${info.chain.join(".")}`;
|
|
608
|
+
const notInsideJson = `@db.column.derived path '${path}' does not read inside a @db.json field — a flattened or scalar column needs no derived column`;
|
|
609
|
+
if (info.chain.length < 2) {
|
|
610
|
+
fail(notInsideJson);
|
|
611
|
+
return errors;
|
|
612
|
+
}
|
|
613
|
+
if (!info.resolved) return errors;
|
|
614
|
+
if (info.viaArray) {
|
|
615
|
+
fail(`@db.column.derived path '${path}' crosses an array — a derived column reads one scalar leaf`);
|
|
616
|
+
return errors;
|
|
617
|
+
}
|
|
618
|
+
if (info.jsonRoot === void 0 || info.jsonRoot >= info.chain.length) {
|
|
619
|
+
fail(notInsideJson);
|
|
620
|
+
return errors;
|
|
621
|
+
}
|
|
622
|
+
if (info.viaEncrypted) {
|
|
623
|
+
fail(`@db.column.derived path '${path}' reads inside a @db.encrypted field — ciphertext cannot be extracted`);
|
|
624
|
+
return errors;
|
|
625
|
+
}
|
|
626
|
+
if (info.leafType === void 0 || !JSON_LEAF_TYPES.has(info.leafType)) {
|
|
627
|
+
fail(`@db.column.derived path '${path}' must end at a string, number or boolean leaf` + (info.leafType ? ` (got '${info.leafType}')` : ""));
|
|
628
|
+
return errors;
|
|
629
|
+
}
|
|
630
|
+
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);
|
|
631
|
+
return errors;
|
|
632
|
+
}
|
|
633
|
+
}),
|
|
334
634
|
dimension: new AnnotationSpec({
|
|
335
635
|
description: "Marks a field as a dimension — groupable in aggregate queries ($groupBy). Dimension fields automatically receive a database index during schema sync.",
|
|
336
636
|
nodeType: ["prop"],
|
|
@@ -865,7 +1165,8 @@ const dbRelAnnotations = { rel: {
|
|
|
865
1165
|
argument: {
|
|
866
1166
|
name: "junction",
|
|
867
1167
|
type: "ref",
|
|
868
|
-
description: "The junction table type (must have @db.table and @db.rel.FK fields pointing to both sides)."
|
|
1168
|
+
description: "The junction table type (must have @db.table and @db.rel.FK fields pointing to both sides).",
|
|
1169
|
+
refFilter: isDbTable
|
|
869
1170
|
},
|
|
870
1171
|
validate(token, args, doc) {
|
|
871
1172
|
const errors = [];
|
|
@@ -882,7 +1183,10 @@ const dbRelAnnotations = { rel: {
|
|
|
882
1183
|
});
|
|
883
1184
|
if (!args[0]) return errors;
|
|
884
1185
|
const junctionName = args[0].text;
|
|
885
|
-
errors.push(...validateRefArgument(args[0], doc, {
|
|
1186
|
+
errors.push(...validateRefArgument(args[0], doc, {
|
|
1187
|
+
accept: isDbTable,
|
|
1188
|
+
expected: "must have @db.table annotation."
|
|
1189
|
+
}));
|
|
886
1190
|
if (errors.length > 0) return errors;
|
|
887
1191
|
const junctionUnwound = doc.unwindType(junctionName);
|
|
888
1192
|
if (!junctionUnwound) return errors;
|
|
@@ -925,7 +1229,8 @@ const dbRelAnnotations = { rel: {
|
|
|
925
1229
|
argument: {
|
|
926
1230
|
name: "condition",
|
|
927
1231
|
type: "query",
|
|
928
|
-
description: "Filter expression restricting which related records are loaded."
|
|
1232
|
+
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.",
|
|
1233
|
+
fieldScope: fieldScopes.relFilter
|
|
929
1234
|
},
|
|
930
1235
|
validate(token, args, doc) {
|
|
931
1236
|
const errors = [];
|
|
@@ -942,14 +1247,8 @@ const dbRelAnnotations = { rel: {
|
|
|
942
1247
|
return errors;
|
|
943
1248
|
}
|
|
944
1249
|
if (!args[0]?.queryNode) return errors;
|
|
945
|
-
const
|
|
946
|
-
if (
|
|
947
|
-
const allowedTypes = [targetTypeName];
|
|
948
|
-
if (hasVia) {
|
|
949
|
-
const junctionType = getAnnotationAlias(field, "db.rel.via");
|
|
950
|
-
if (junctionType) allowedTypes.push(junctionType);
|
|
951
|
-
}
|
|
952
|
-
errors.push(...validateQueryScope(args[0], allowedTypes, targetTypeName, doc));
|
|
1250
|
+
const scope = relFilterScope(field);
|
|
1251
|
+
if (scope) errors.push(...validateQueryScope(args[0], scope, doc));
|
|
953
1252
|
return errors;
|
|
954
1253
|
}
|
|
955
1254
|
})
|
|
@@ -1038,6 +1337,7 @@ const dbTableAnnotations = {
|
|
|
1038
1337
|
$self: new AnnotationSpec({
|
|
1039
1338
|
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",
|
|
1040
1339
|
nodeType: ["interface"],
|
|
1340
|
+
passedWhenReferred: false,
|
|
1041
1341
|
argument: {
|
|
1042
1342
|
optional: true,
|
|
1043
1343
|
name: "name",
|
|
@@ -1058,6 +1358,7 @@ const dbTableAnnotations = {
|
|
|
1058
1358
|
renamed: new AnnotationSpec({
|
|
1059
1359
|
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",
|
|
1060
1360
|
nodeType: ["interface"],
|
|
1361
|
+
passedWhenReferred: false,
|
|
1061
1362
|
argument: {
|
|
1062
1363
|
name: "oldName",
|
|
1063
1364
|
type: "string",
|
|
@@ -1076,6 +1377,7 @@ const dbTableAnnotations = {
|
|
|
1076
1377
|
preferredId: { uniqueIndex: new AnnotationSpec({
|
|
1077
1378
|
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",
|
|
1078
1379
|
nodeType: ["interface"],
|
|
1380
|
+
passedWhenReferred: false,
|
|
1079
1381
|
multiple: false,
|
|
1080
1382
|
argument: {
|
|
1081
1383
|
optional: true,
|
|
@@ -1123,6 +1425,7 @@ const dbTableAnnotations = {
|
|
|
1123
1425
|
schema: new AnnotationSpec({
|
|
1124
1426
|
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",
|
|
1125
1427
|
nodeType: ["interface"],
|
|
1428
|
+
passedWhenReferred: false,
|
|
1126
1429
|
argument: {
|
|
1127
1430
|
name: "name",
|
|
1128
1431
|
type: "string",
|
|
@@ -1132,6 +1435,7 @@ const dbTableAnnotations = {
|
|
|
1132
1435
|
space: new AnnotationSpec({
|
|
1133
1436
|
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",
|
|
1134
1437
|
nodeType: ["interface"],
|
|
1438
|
+
passedWhenReferred: false,
|
|
1135
1439
|
argument: {
|
|
1136
1440
|
name: "name",
|
|
1137
1441
|
type: "string",
|
|
@@ -1141,6 +1445,7 @@ const dbTableAnnotations = {
|
|
|
1141
1445
|
http: { path: new AnnotationSpec({
|
|
1142
1446
|
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",
|
|
1143
1447
|
nodeType: ["interface"],
|
|
1448
|
+
passedWhenReferred: false,
|
|
1144
1449
|
argument: {
|
|
1145
1450
|
name: "path",
|
|
1146
1451
|
type: "string",
|
|
@@ -1150,6 +1455,7 @@ const dbTableAnnotations = {
|
|
|
1150
1455
|
sync: { method: new AnnotationSpec({
|
|
1151
1456
|
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",
|
|
1152
1457
|
nodeType: ["interface"],
|
|
1458
|
+
passedWhenReferred: false,
|
|
1153
1459
|
argument: {
|
|
1154
1460
|
name: "method",
|
|
1155
1461
|
type: "string",
|
|
@@ -1160,6 +1466,7 @@ const dbTableAnnotations = {
|
|
|
1160
1466
|
depth: { limit: new AnnotationSpec({
|
|
1161
1467
|
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",
|
|
1162
1468
|
nodeType: ["interface"],
|
|
1469
|
+
passedWhenReferred: false,
|
|
1163
1470
|
multiple: false,
|
|
1164
1471
|
argument: {
|
|
1165
1472
|
name: "depth",
|
|
@@ -1225,6 +1532,7 @@ function tableCapability(capability) {
|
|
|
1225
1532
|
@db.table.${capability} "manual"\nexport interface User {
|
|
1226
1533
|
` + example + "}\n```\n",
|
|
1227
1534
|
nodeType: ["interface"],
|
|
1535
|
+
passedWhenReferred: false,
|
|
1228
1536
|
multiple: false,
|
|
1229
1537
|
argument: {
|
|
1230
1538
|
optional: true,
|
|
@@ -1293,91 +1601,12 @@ const dbUnitAnnotations = { unit: {
|
|
|
1293
1601
|
})
|
|
1294
1602
|
} };
|
|
1295
1603
|
//#endregion
|
|
1296
|
-
//#region src/shared/view-validation.ts
|
|
1297
|
-
/** `@db.agg.*` annotations that are never NULL (the counts: 0 over no value). */
|
|
1298
|
-
const NULL_SAFE_AGG_ANNOTATIONS = SUPPORTED_AGGREGATE_FNS.filter((fn) => !NULL_WHEN_EMPTY_AGGREGATE_FNS.has(fn)).map((fn) => `db.agg.${fn}`);
|
|
1299
|
-
/** Primitive leaf types a JSON-stored path may end at. */
|
|
1300
|
-
const JSON_LEAF_TYPES = new Set([
|
|
1301
|
-
"string",
|
|
1302
|
-
"number",
|
|
1303
|
-
"boolean"
|
|
1304
|
-
]);
|
|
1305
|
-
/** Props of a view interface (its own structure; views don't use `extends`). */
|
|
1306
|
-
function viewProps(owner) {
|
|
1307
|
-
if (isInterface(owner)) return owner.props;
|
|
1308
|
-
const def = owner.getDefinition();
|
|
1309
|
-
return def && isStructure(def) ? def.props : void 0;
|
|
1310
|
-
}
|
|
1311
|
-
/**
|
|
1312
|
-
* VW8: a chain ref that passes through a `@db.json` or array node reads a
|
|
1313
|
-
* JSON leaf, which views can only extract as a string / number / boolean.
|
|
1314
|
-
* Intermediate nodes that `unwindType` can't reach are skipped (sync-time
|
|
1315
|
-
* resolution reports them).
|
|
1316
|
-
*/
|
|
1317
|
-
function validateJsonChain(fieldName, ref, doc, range) {
|
|
1318
|
-
const typeName = ref.id;
|
|
1319
|
-
const chain = ref.chain.map((t) => t.text);
|
|
1320
|
-
if (!typeName || chain.length < 2) return [];
|
|
1321
|
-
let throughJson = false;
|
|
1322
|
-
for (let i = 1; i < chain.length; i++) {
|
|
1323
|
-
const step = doc.unwindType(typeName, chain.slice(0, i));
|
|
1324
|
-
if (!step) return [];
|
|
1325
|
-
const stepNode = step.node;
|
|
1326
|
-
if (stepNode && isProp(stepNode) && stepNode.countAnnotations("db.json") > 0 || isArray(step.def)) {
|
|
1327
|
-
throughJson = true;
|
|
1328
|
-
break;
|
|
1329
|
-
}
|
|
1330
|
-
}
|
|
1331
|
-
if (!throughJson) return [];
|
|
1332
|
-
const leaf = doc.unwindType(typeName, chain)?.def;
|
|
1333
|
-
const leafType = primitiveBaseType(leaf);
|
|
1334
|
-
if (leafType !== void 0 && JSON_LEAF_TYPES.has(leafType)) return [];
|
|
1335
|
-
return [{
|
|
1336
|
-
message: `Field "${fieldName}" reads "${typeName}.${chain.join(".")}" inside a JSON-stored field — it must end at a string, number or boolean leaf`,
|
|
1337
|
-
severity: 1,
|
|
1338
|
-
range
|
|
1339
|
-
}];
|
|
1340
|
-
}
|
|
1341
|
-
/**
|
|
1342
|
-
* Whole-view checks, run once per `@db.view.for` interface:
|
|
1343
|
-
*
|
|
1344
|
-
* - VW7 — a field reading from a left-joined table must be optional (the
|
|
1345
|
-
* join yields NULL for unmatched rows); `@db.agg.count` / `countDistinct`
|
|
1346
|
-
* fields are exempt (they count 0, never NULL).
|
|
1347
|
-
* - VW8 — a chain ref through a `@db.json` or array node must end at a
|
|
1348
|
-
* primitive string / number / boolean leaf.
|
|
1349
|
-
* @since 0.1.136
|
|
1350
|
-
*/
|
|
1351
|
-
function validateViewInterface(owner, doc) {
|
|
1352
|
-
const errors = [];
|
|
1353
|
-
const props = viewProps(owner);
|
|
1354
|
-
if (!props) return errors;
|
|
1355
|
-
const leftJoined = /* @__PURE__ */ new Set();
|
|
1356
|
-
for (const join of viewJoins(owner)) {
|
|
1357
|
-
const target = join.args[0]?.text;
|
|
1358
|
-
if (target && join.args[2]?.text === "left") leftJoined.add(target);
|
|
1359
|
-
}
|
|
1360
|
-
for (const [fieldName, prop] of props) {
|
|
1361
|
-
const def = prop.getDefinition();
|
|
1362
|
-
if (!def || !isRef(def)) continue;
|
|
1363
|
-
const ref = def;
|
|
1364
|
-
const range = (prop.token("identifier") ?? ref.token("identifier"))?.range;
|
|
1365
|
-
if (!range) continue;
|
|
1366
|
-
if (ref.id && leftJoined.has(ref.id) && !prop.has("optional") && !NULL_SAFE_AGG_ANNOTATIONS.some((name) => prop.countAnnotations(name) > 0)) errors.push({
|
|
1367
|
-
message: `Field "${fieldName}" reads from left-joined "${ref.id}" and must be optional (${fieldName}?: …)`,
|
|
1368
|
-
severity: 1,
|
|
1369
|
-
range
|
|
1370
|
-
});
|
|
1371
|
-
errors.push(...validateJsonChain(fieldName, ref, doc, range));
|
|
1372
|
-
}
|
|
1373
|
-
return errors;
|
|
1374
|
-
}
|
|
1375
|
-
//#endregion
|
|
1376
1604
|
//#region src/plugin/annotations/view.ts
|
|
1377
1605
|
const dbViewAnnotations = { view: {
|
|
1378
1606
|
$self: new AnnotationSpec({
|
|
1379
1607
|
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",
|
|
1380
1608
|
nodeType: ["interface"],
|
|
1609
|
+
passedWhenReferred: false,
|
|
1381
1610
|
argument: {
|
|
1382
1611
|
optional: true,
|
|
1383
1612
|
name: "name",
|
|
@@ -1395,12 +1624,14 @@ const dbViewAnnotations = { view: {
|
|
|
1395
1624
|
}
|
|
1396
1625
|
}),
|
|
1397
1626
|
for: new AnnotationSpec({
|
|
1398
|
-
description: "Specifies the entry/primary
|
|
1627
|
+
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",
|
|
1399
1628
|
nodeType: ["interface"],
|
|
1629
|
+
passedWhenReferred: false,
|
|
1400
1630
|
argument: {
|
|
1401
1631
|
name: "entry",
|
|
1402
1632
|
type: "ref",
|
|
1403
|
-
description: "The primary/entry table type (must have @db.table)."
|
|
1633
|
+
description: "The primary/entry table or view type (must have @db.table or @db.view).",
|
|
1634
|
+
refFilter: isDbSourceDecl
|
|
1404
1635
|
},
|
|
1405
1636
|
validate(token, args, doc) {
|
|
1406
1637
|
const errors = [];
|
|
@@ -1410,26 +1641,35 @@ const dbViewAnnotations = { view: {
|
|
|
1410
1641
|
severity: 1,
|
|
1411
1642
|
range: token.range
|
|
1412
1643
|
});
|
|
1413
|
-
if (args[0]) errors.push(...validateRefArgument(args[0], doc,
|
|
1644
|
+
if (args[0]) errors.push(...validateRefArgument(args[0], doc, VIEW_SOURCE_ARGUMENT));
|
|
1645
|
+
const cycle = findViewCycle(owner, doc);
|
|
1646
|
+
if (cycle) errors.push({
|
|
1647
|
+
message: `View '${cycle[0]}' depends on itself: ${cycle.join(" → ")}`,
|
|
1648
|
+
severity: 1,
|
|
1649
|
+
range: args[0]?.range ?? token.range
|
|
1650
|
+
});
|
|
1414
1651
|
errors.push(...validateViewInterface(owner, doc));
|
|
1415
1652
|
return errors;
|
|
1416
1653
|
}
|
|
1417
1654
|
}),
|
|
1418
1655
|
joins: new AnnotationSpec({
|
|
1419
|
-
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)
|
|
1656
|
+
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",
|
|
1420
1657
|
nodeType: ["interface"],
|
|
1658
|
+
passedWhenReferred: false,
|
|
1421
1659
|
multiple: true,
|
|
1422
1660
|
mergeStrategy: "append",
|
|
1423
1661
|
argument: [
|
|
1424
1662
|
{
|
|
1425
1663
|
name: "target",
|
|
1426
1664
|
type: "ref",
|
|
1427
|
-
description: "The
|
|
1665
|
+
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).",
|
|
1666
|
+
refFilter: isJoinTarget
|
|
1428
1667
|
},
|
|
1429
1668
|
{
|
|
1430
1669
|
name: "condition",
|
|
1431
1670
|
type: "query",
|
|
1432
|
-
description: "Join condition expression."
|
|
1671
|
+
description: "Join condition expression — may reference the join target, the entry table and the joins declared before this one.",
|
|
1672
|
+
fieldScope: fieldScopes.viewJoin
|
|
1433
1673
|
},
|
|
1434
1674
|
{
|
|
1435
1675
|
name: "kind",
|
|
@@ -1450,7 +1690,10 @@ const dbViewAnnotations = { view: {
|
|
|
1450
1690
|
});
|
|
1451
1691
|
return errors;
|
|
1452
1692
|
}
|
|
1453
|
-
if (args[0]) errors.push(...validateRefArgument(args[0], doc, {
|
|
1693
|
+
if (args[0]) errors.push(...validateRefArgument(args[0], doc, {
|
|
1694
|
+
accept: isJoinTarget,
|
|
1695
|
+
expected: "must be a @db.table or a @db.view."
|
|
1696
|
+
}));
|
|
1454
1697
|
const entryTypeName = getAnnotationAlias(owner, "db.view.for");
|
|
1455
1698
|
if (!entryTypeName) {
|
|
1456
1699
|
errors.push({
|
|
@@ -1460,40 +1703,40 @@ const dbViewAnnotations = { view: {
|
|
|
1460
1703
|
});
|
|
1461
1704
|
return errors;
|
|
1462
1705
|
}
|
|
1463
|
-
const
|
|
1464
|
-
const
|
|
1465
|
-
const earlier =
|
|
1706
|
+
const joins = viewJoins(owner);
|
|
1707
|
+
const join = joins.find((a) => a.token === token);
|
|
1708
|
+
const earlier = join ? earlierJoinTargets(joins, join) : [];
|
|
1466
1709
|
if (args[0]) {
|
|
1467
1710
|
const target = args[0].text;
|
|
1711
|
+
const aliasHint = (name) => {
|
|
1712
|
+
const decl = doc.getDeclarationOwnerNode(target)?.node;
|
|
1713
|
+
return decl && isAliasDecl(decl) ? "" : ` — declare a @db.alias type (\`@db.alias ${name}\` + \`export type Other = ${name}\`) to join it under another name`;
|
|
1714
|
+
};
|
|
1468
1715
|
if (target === entryTypeName) errors.push({
|
|
1469
|
-
message: `@db.view.joins cannot join the entry table '${target}'
|
|
1716
|
+
message: `@db.view.joins cannot join the entry table '${target}' directly${aliasHint(target)}`,
|
|
1470
1717
|
severity: 1,
|
|
1471
1718
|
range: args[0].range
|
|
1472
1719
|
});
|
|
1473
1720
|
else if (earlier.includes(target)) errors.push({
|
|
1474
|
-
message: `'${target}' is joined more than once
|
|
1721
|
+
message: `'${target}' is joined more than once${aliasHint(target)}`,
|
|
1475
1722
|
severity: 1,
|
|
1476
1723
|
range: args[0].range
|
|
1477
1724
|
});
|
|
1478
1725
|
}
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
errors.push(...validateQueryScope(args[1], [
|
|
1482
|
-
joinTargetName,
|
|
1483
|
-
entryTypeName,
|
|
1484
|
-
...earlier
|
|
1485
|
-
], entryTypeName, doc, "a join may reference the entry table and joins declared before it"));
|
|
1486
|
-
}
|
|
1726
|
+
const scope = join && viewJoinScope(owner, join, earlier);
|
|
1727
|
+
if (args[1]?.queryNode && scope) errors.push(...validateQueryScope(args[1], scope, doc, "a join may reference the entry table and joins declared before it"));
|
|
1487
1728
|
return errors;
|
|
1488
1729
|
}
|
|
1489
1730
|
}),
|
|
1490
1731
|
filter: new AnnotationSpec({
|
|
1491
1732
|
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",
|
|
1492
1733
|
nodeType: ["interface"],
|
|
1734
|
+
passedWhenReferred: false,
|
|
1493
1735
|
argument: {
|
|
1494
1736
|
name: "condition",
|
|
1495
1737
|
type: "query",
|
|
1496
|
-
description: "Filter expression for the view WHERE clause."
|
|
1738
|
+
description: "Filter expression for the view WHERE clause — may reference the entry table and every join; unqualified fields belong to the entry.",
|
|
1739
|
+
fieldScope: fieldScopes.viewFilter
|
|
1497
1740
|
},
|
|
1498
1741
|
validate(token, args, doc) {
|
|
1499
1742
|
const errors = [];
|
|
@@ -1507,8 +1750,8 @@ const dbViewAnnotations = { view: {
|
|
|
1507
1750
|
return errors;
|
|
1508
1751
|
}
|
|
1509
1752
|
if (!args[0]?.queryNode) return errors;
|
|
1510
|
-
const
|
|
1511
|
-
if (!
|
|
1753
|
+
const scope = viewFilterScope(owner);
|
|
1754
|
+
if (!scope) {
|
|
1512
1755
|
errors.push({
|
|
1513
1756
|
message: "@db.view.filter requires @db.view.for to identify the entry table",
|
|
1514
1757
|
severity: 1,
|
|
@@ -1516,13 +1759,14 @@ const dbViewAnnotations = { view: {
|
|
|
1516
1759
|
});
|
|
1517
1760
|
return errors;
|
|
1518
1761
|
}
|
|
1519
|
-
errors.push(...validateQueryScope(args[0],
|
|
1762
|
+
errors.push(...validateQueryScope(args[0], scope, doc));
|
|
1520
1763
|
return errors;
|
|
1521
1764
|
}
|
|
1522
1765
|
}),
|
|
1523
1766
|
materialized: new AnnotationSpec({
|
|
1524
1767
|
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",
|
|
1525
1768
|
nodeType: ["interface"],
|
|
1769
|
+
passedWhenReferred: false,
|
|
1526
1770
|
validate(token, _args, _doc) {
|
|
1527
1771
|
const errors = [];
|
|
1528
1772
|
const owner = token.parentNode;
|
|
@@ -1537,6 +1781,7 @@ const dbViewAnnotations = { view: {
|
|
|
1537
1781
|
renamed: new AnnotationSpec({
|
|
1538
1782
|
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",
|
|
1539
1783
|
nodeType: ["interface"],
|
|
1784
|
+
passedWhenReferred: false,
|
|
1540
1785
|
argument: {
|
|
1541
1786
|
name: "oldName",
|
|
1542
1787
|
type: "string",
|
|
@@ -1554,21 +1799,28 @@ const dbViewAnnotations = { view: {
|
|
|
1554
1799
|
}
|
|
1555
1800
|
}),
|
|
1556
1801
|
having: new AnnotationSpec({
|
|
1557
|
-
description: "Post-aggregation filter (HAVING clause) for analytical views. References view
|
|
1802
|
+
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",
|
|
1558
1803
|
nodeType: ["interface"],
|
|
1804
|
+
passedWhenReferred: false,
|
|
1559
1805
|
argument: {
|
|
1560
1806
|
name: "condition",
|
|
1561
1807
|
type: "query",
|
|
1562
|
-
description: "HAVING condition
|
|
1808
|
+
description: "HAVING condition — the view's own fields, unqualified.",
|
|
1809
|
+
fieldScope: fieldScopes.viewHaving
|
|
1563
1810
|
},
|
|
1564
|
-
validate(token,
|
|
1811
|
+
validate(token, args, doc) {
|
|
1565
1812
|
const errors = [];
|
|
1566
1813
|
const owner = token.parentNode;
|
|
1567
|
-
if (!hasAnyViewAnnotation(owner))
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1814
|
+
if (!hasAnyViewAnnotation(owner)) {
|
|
1815
|
+
errors.push({
|
|
1816
|
+
message: "@db.view.having is only valid on @db.view interfaces",
|
|
1817
|
+
severity: 1,
|
|
1818
|
+
range: token.range
|
|
1819
|
+
});
|
|
1820
|
+
return errors;
|
|
1821
|
+
}
|
|
1822
|
+
const scope = viewHavingScope(owner);
|
|
1823
|
+
if (args[0]?.queryNode && scope) errors.push(...validateQueryScope(args[0], scope, doc, "@db.view.having references the view's own fields unqualified"));
|
|
1572
1824
|
return errors;
|
|
1573
1825
|
}
|
|
1574
1826
|
})
|
|
@@ -1600,6 +1852,7 @@ const dbPlugin = (options) => ({
|
|
|
1600
1852
|
depth: dbTableAnnotations.depth,
|
|
1601
1853
|
rel: dbRelAnnotations.rel,
|
|
1602
1854
|
view: dbViewAnnotations.view,
|
|
1855
|
+
alias: dbAliasAnnotations.alias,
|
|
1603
1856
|
agg: dbAggAnnotations.agg,
|
|
1604
1857
|
search: dbSearchAnnotations.search,
|
|
1605
1858
|
amount: dbAmountAnnotations.amount,
|