@isrd-isi-edu/ermrestjs 2.5.0 → 2.6.1

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.
@@ -8,6 +8,27 @@ import $log from '@isrd-isi-edu/ermrestjs/src/services/logger';
8
8
  // legacy
9
9
  import { Column, ForeignKeyRef, Table } from '@isrd-isi-edu/ermrestjs/js/core';
10
10
 
11
+ /**
12
+ * Defines a condition that controls visibility of a column or related entity.
13
+ * Used in source-definitions annotation under the `conditions` key, or inline
14
+ * on a column-directive via `condition` / `condition_key`.
15
+ */
16
+ // export type ConditionDefinition = {
17
+ // /** reference to an existing sourcekey whose data determines the condition */
18
+ // sourcekey?: string;
19
+ // /** inline source path (alternative to sourcekey) */
20
+ // source?: unknown;
21
+ // /** behavior when condition source returns empty: "hide" (default) or "show" */
22
+ // on_empty: 'show' | 'hide';
23
+ // /** optional template evaluated with $self, no markdown rendering */
24
+ // condition_pattern?: string;
25
+ // /** template engine for condition_pattern: "mustache" (default) or "handlebars" */
26
+ // template_engine?: string;
27
+ // /** optional list of sourcekeys whose data should be available when evaluating condition_pattern */
28
+ // wait_for?: string[];
29
+ // };
30
+ export type ConditionDefinition = Record<string, unknown>;
31
+
11
32
  /**
12
33
  * Result of Table.sourceDefinitions
13
34
  */
@@ -39,6 +60,10 @@ class TableSourceDefinitions {
39
60
  * this has been added because of path prefix where a sourcekey might rely on other sourcekeys
40
61
  */
41
62
  sourceDependencies: Record<string, string[]> = {};
63
+ /**
64
+ * reusable condition definitions defined in the source-definitions annotation
65
+ */
66
+ private conditions: Record<string, ConditionDefinition> = {};
42
67
 
43
68
  constructor(
44
69
  table: Table,
@@ -47,6 +72,7 @@ class TableSourceDefinitions {
47
72
  sources: Record<string, SourceObjectWrapper>,
48
73
  sourceMapping: Record<string, string[]>,
49
74
  sourceDependencies: Record<string, string[]>,
75
+ conditions: Record<string, ConditionDefinition> = {},
50
76
  ) {
51
77
  this.table = table;
52
78
  this.columns = columns;
@@ -54,6 +80,17 @@ class TableSourceDefinitions {
54
80
  this.sources = sources;
55
81
  this.sourceMapping = sourceMapping;
56
82
  this.sourceDependencies = sourceDependencies;
83
+ this.conditions = conditions;
84
+ }
85
+
86
+ /**
87
+ * Get a condition definition by its key.
88
+ * NOTE: the returned definition is not processed and might be invalid.
89
+ * @param key The key of the condition definition.
90
+ * @returns The condition definition or undefined if not found.
91
+ */
92
+ getCondition(key: string): ConditionDefinition | undefined {
93
+ return this.conditions[key];
57
94
  }
58
95
 
59
96
  /**
@@ -0,0 +1,432 @@
1
+ // models
2
+ import ActiveListCondition from '@isrd-isi-edu/ermrestjs/src/models/active-list-condition';
3
+ import type { Reference, VisibleColumn } from '@isrd-isi-edu/ermrestjs/src/models/reference/reference';
4
+ import type { Tuple } from '@isrd-isi-edu/ermrestjs/src/models/reference';
5
+ import type { PseudoColumn, ForeignKeyPseudoColumn, KeyPseudoColumn } from '@isrd-isi-edu/ermrestjs/src/models/reference-column';
6
+
7
+ // services
8
+
9
+ // utils
10
+ import { isAllOutboundColumn, isRelatedColumn } from '@isrd-isi-edu/ermrestjs/src/utils/column-utils';
11
+
12
+ // legacy
13
+ import { _isEntryContext } from '@isrd-isi-edu/ermrestjs/js/utils/helpers';
14
+
15
+ // ============================================================================
16
+ // TYPES
17
+ // ============================================================================
18
+
19
+ export type ActiveListRequestObject = {
20
+ /** The index of the object in the column's values array */
21
+ index?: number;
22
+ /** whether this is for a visible column */
23
+ column?: boolean;
24
+ /** whether this is for a related entity */
25
+ related?: boolean;
26
+ /** whether this is for an inline related entity */
27
+ inline?: boolean;
28
+ /** whether this is for the citation */
29
+ citation?: boolean;
30
+ /** whether this is for a condition evaluation (index refers to the conditionalGroups index) */
31
+ condition?: boolean;
32
+
33
+ isWaitFor: boolean;
34
+ };
35
+
36
+ export type ActiveListRequest = {
37
+ column: VisibleColumn;
38
+
39
+ /**
40
+ * whether the request is "first outbound" type.
41
+ * These booleans are used for figuring out how the data should be fetched.
42
+ */
43
+ firstOutbound?: boolean;
44
+ /**
45
+ * whether the request is for an aggregate column.
46
+ */
47
+ aggregate?: boolean;
48
+ entity?: boolean;
49
+ /**
50
+ * whether the request is an entityset.
51
+ */
52
+ entityset?: boolean;
53
+
54
+ /** where this request is needed. */
55
+ objects: Array<ActiveListRequestObject>;
56
+ };
57
+
58
+ export type ActiveListRelatedEntityRequest = {
59
+ /**
60
+ * whether the request is for an inline related entity.
61
+ * the .index indicated which related entity it is
62
+ */
63
+ inline?: boolean;
64
+ /**
65
+ * whether the request is for a related entity.
66
+ * the .index indicated which related entity it is
67
+ */
68
+ related?: boolean;
69
+ /** the index of the related entity in the reference.related array */
70
+ index: number;
71
+ };
72
+
73
+ /**
74
+ * Identifies a single conditioned item: the column/inline-related column/related-entity
75
+ * whose visibility is gated by the group's condition. Multiple items share a group when
76
+ * they reference the same `condition_key`.
77
+ */
78
+ export type ActiveListConditionedItem = {
79
+ column?: boolean;
80
+ inline?: boolean;
81
+ related?: boolean;
82
+ index: number;
83
+ };
84
+
85
+ export type ActiveListConditionalGroup = {
86
+ /** The condition object (class with evaluateCondition method) */
87
+ condition: ActiveListCondition;
88
+ /**
89
+ * The column / inline / related entries gated by this condition. Used by chaise to
90
+ * mark the user's columns/related-models as conditioned (and to flip their hide flag
91
+ * when the condition resolves).
92
+ * Distinct from `dependentRequests`: `conditionedItems` covers EVERY conditioned
93
+ * item — including sync scalar columns whose `dependentRequests` entry is a no-op —
94
+ * while `dependentRequests` is just the secondary fetches gated behind the condition.
95
+ */
96
+ conditionedItems: ActiveListConditionedItem[];
97
+ /** Requests gated behind this condition (main content + its wait-fors) */
98
+ dependentRequests: Array<ActiveListRequest | ActiveListRelatedEntityRequest>;
99
+ };
100
+
101
+ export type ActiveList = {
102
+ /**
103
+ * The list of requests that need to be made.
104
+ * Already ordered based on the priority needed for the page load (top to bottom).
105
+ */
106
+ requests: Array<ActiveListRequest | ActiveListRelatedEntityRequest>;
107
+ /** The list of conditional groups that need to be evaluated. */
108
+ conditionalGroups: ActiveListConditionalGroup[];
109
+ allOutBounds: Array<PseudoColumn | ForeignKeyPseudoColumn>;
110
+ selfLinks: Array<KeyPseudoColumn>;
111
+ };
112
+
113
+ export enum ActiveListRequestTypes {
114
+ COLUMN = 'column',
115
+ RELATED = 'related',
116
+ INLINE = 'inline',
117
+ CITATION = 'citation',
118
+ CONDITION = 'condition',
119
+ }
120
+
121
+ // ============================================================================
122
+ // BUILDER
123
+ // ============================================================================
124
+
125
+ /**
126
+ * Builds an ActiveList for a Reference. Encapsulates the dedup state and
127
+ * the various add* helpers that used to live as nested closures inside
128
+ * Reference.generateActiveList.
129
+ *
130
+ * Behavior is intended to be 1:1 with the previous nested implementation.
131
+ */
132
+ export class ActiveListBuilder {
133
+ // result buckets (public so the driver in reference.ts can push directly
134
+ // for the related-entity case, matching the original code).
135
+ requests: Array<ActiveListRequest | ActiveListRelatedEntityRequest> = [];
136
+ allOutBounds: Array<PseudoColumn | ForeignKeyPseudoColumn> = [];
137
+ selfLinks: Array<KeyPseudoColumn> = [];
138
+ conditionalGroups: ActiveListConditionalGroup[] = [];
139
+
140
+ // dedup maps
141
+ private consideredUniqueFiltered: { [key: string]: number } = {};
142
+ private consideredSets: { [key: string]: number } = {};
143
+ private consideredOutbounds: { [key: string]: boolean } = {};
144
+ private consideredAggregates: { [key: string]: number } = {};
145
+ private consideredSelfLinks: { [key: string]: boolean } = {};
146
+ private consideredEntryWaitFors: { [key: string]: number } = {};
147
+
148
+ private isEntry: boolean;
149
+
150
+ constructor(
151
+ private reference: Reference,
152
+ /**
153
+ * The main request tuple.
154
+ */
155
+ private tuple: Tuple | undefined,
156
+ /**
157
+ * Whether the active list is being built for detailed context
158
+ */
159
+ private isDetailed: boolean,
160
+ ) {
161
+ this.isEntry = _isEntryContext(reference.context);
162
+ }
163
+
164
+ /**
165
+ * Whether the given column is aggregate (either hasWaitForAggregate or is a path column with aggregate)
166
+ */
167
+ hasAggregate(col: VisibleColumn): boolean {
168
+ return col.hasWaitForAggregate || ((col as PseudoColumn).isPathColumn && (col as PseudoColumn).hasAggregate);
169
+ }
170
+
171
+ /**
172
+ * col: the column that we need its data
173
+ * isWaitFor: whether it was part of waitFor or just visible
174
+ * type: where in the page it belongs to
175
+ * index: the container index (column index, or related index) (optional)
176
+ */
177
+ addCol(col: VisibleColumn, isWaitFor: boolean, type: ActiveListRequestTypes, index?: number): void {
178
+ const obj: ActiveListRequestObject = { isWaitFor: isWaitFor };
179
+
180
+ // add the type
181
+ obj[type] = true;
182
+
183
+ // add index if available (not available in citation)
184
+ if (Number.isInteger(index)) {
185
+ obj.index = index;
186
+ }
187
+
188
+ // entry wait_fors (asset wait_for)
189
+ if (isWaitFor && this.isEntry) {
190
+ if (col.name in this.consideredEntryWaitFors) {
191
+ (this.requests[this.consideredEntryWaitFors[col.name]] as ActiveListRequest).objects.push(obj);
192
+ return;
193
+ }
194
+ this.consideredEntryWaitFors[col.name] = this.requests.length;
195
+ this.requests.push({ firstOutbound: true, column: col, objects: [obj] });
196
+ return;
197
+ }
198
+
199
+ // unique filtered
200
+ // TODO FILTER_IN_SOURCE chaise should use this type of column as well?
201
+ // TODO FILTER_IN_SOURCE should be added to documentation as well
202
+ if ((col as PseudoColumn).sourceObjectWrapper?.isUniqueFiltered) {
203
+ // duplicate
204
+ if (col.name in this.consideredUniqueFiltered) {
205
+ (this.requests[this.consideredUniqueFiltered[col.name]] as ActiveListRequest).objects.push(obj);
206
+ return;
207
+ }
208
+
209
+ // new
210
+ this.consideredUniqueFiltered[col.name] = this.requests.length;
211
+ this.requests.push({ entity: true, column: col, objects: [obj] });
212
+ return;
213
+ }
214
+
215
+ // aggregates
216
+ if ((col as PseudoColumn).isPathColumn && (col as PseudoColumn).hasAggregate) {
217
+ // duplicate
218
+ if (col.name in this.consideredAggregates) {
219
+ (this.requests[this.consideredAggregates[col.name]] as ActiveListRequest).objects.push(obj);
220
+ return;
221
+ }
222
+
223
+ // new
224
+ this.consideredAggregates[col.name] = this.requests.length;
225
+ this.requests.push({ aggregate: true, column: col, objects: [obj] });
226
+ return;
227
+ }
228
+
229
+ // entitysets
230
+ if (isRelatedColumn(col)) {
231
+ if (!this.isDetailed) {
232
+ return; // only acceptable in detailed
233
+ }
234
+
235
+ if (col.name in this.consideredSets) {
236
+ (this.requests[this.consideredSets[col.name]] as ActiveListRequest).objects.push(obj);
237
+ return;
238
+ }
239
+
240
+ this.consideredSets[col.name] = this.requests.length;
241
+ this.requests.push({ entityset: true, column: col, objects: [obj] });
242
+ }
243
+
244
+ // all outbounds
245
+ if (isAllOutboundColumn(col)) {
246
+ if (col.name in this.consideredOutbounds) return;
247
+ this.consideredOutbounds[col.name] = true;
248
+ this.allOutBounds.push(col as PseudoColumn | ForeignKeyPseudoColumn);
249
+ }
250
+
251
+ // self-links
252
+ if ((col as KeyPseudoColumn).isKey) {
253
+ if (col.name in this.consideredSelfLinks) return;
254
+ this.consideredSelfLinks[col.name] = true;
255
+ this.selfLinks.push(col as KeyPseudoColumn);
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Same as addCol, but adds to a dependent requests array
261
+ * instead of the main requests array. Used for conditional groups.
262
+ */
263
+ addColToDependent(
264
+ dependentRequests: Array<ActiveListRequest | ActiveListRelatedEntityRequest>,
265
+ col: VisibleColumn,
266
+ isWaitFor: boolean,
267
+ type: ActiveListRequestTypes,
268
+ index?: number,
269
+ ): void {
270
+ const obj: ActiveListRequestObject = { isWaitFor: isWaitFor };
271
+ obj[type] = true;
272
+ if (Number.isInteger(index)) {
273
+ obj.index = index;
274
+ }
275
+
276
+ // unique filtered (mirrors addCol branch; dedup is scoped to dependentRequests)
277
+ if ((col as PseudoColumn).sourceObjectWrapper?.isUniqueFiltered) {
278
+ const existing = dependentRequests.find((r) => (r as ActiveListRequest).column?.name === col.name);
279
+ if (existing) {
280
+ (existing as ActiveListRequest).objects.push(obj);
281
+ return;
282
+ }
283
+ dependentRequests.push({ entity: true, column: col, objects: [obj] });
284
+ return;
285
+ }
286
+
287
+ // aggregates
288
+ if ((col as PseudoColumn).isPathColumn && (col as PseudoColumn).hasAggregate) {
289
+ // check if already in dependentRequests
290
+ const existing = dependentRequests.find((r) => (r as ActiveListRequest).column?.name === col.name && (r as ActiveListRequest).aggregate) as
291
+ | ActiveListRequest
292
+ | undefined;
293
+ if (existing) {
294
+ existing.objects.push(obj);
295
+ return;
296
+ }
297
+ dependentRequests.push({ aggregate: true, column: col, objects: [obj] });
298
+ return;
299
+ }
300
+
301
+ // entitysets
302
+ if (isRelatedColumn(col)) {
303
+ const existing = dependentRequests.find((r) => (r as ActiveListRequest).column?.name === col.name && (r as ActiveListRequest).entityset) as
304
+ | ActiveListRequest
305
+ | undefined;
306
+ if (existing) {
307
+ existing.objects.push(obj);
308
+ return;
309
+ }
310
+ dependentRequests.push({ entityset: true, column: col, objects: [obj] });
311
+ return;
312
+ }
313
+
314
+ // all outbounds — still add to main allOutBounds
315
+ if (isAllOutboundColumn(col)) {
316
+ if (!(col.name in this.consideredOutbounds)) {
317
+ this.consideredOutbounds[col.name] = true;
318
+ this.allOutBounds.push(col as PseudoColumn | ForeignKeyPseudoColumn);
319
+ }
320
+ }
321
+
322
+ // self-links — still add to main selfLinks
323
+ if ((col as KeyPseudoColumn).isKey) {
324
+ if (!(col.name in this.consideredSelfLinks)) {
325
+ this.consideredSelfLinks[col.name] = true;
326
+ this.selfLinks.push(col as KeyPseudoColumn);
327
+ }
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Add a visible column and its waitFors to the active list as inline requests (with deduplication).
333
+ */
334
+ addInline(col: VisibleColumn, i: number): void {
335
+ if (isRelatedColumn(col)) {
336
+ this.requests.push({ inline: true, index: i });
337
+ } else {
338
+ this.addCol(col, false, ActiveListRequestTypes.COLUMN, i);
339
+ }
340
+
341
+ col.waitFor.forEach((wf) => {
342
+ this.addCol(wf, true, isRelatedColumn(col) ? ActiveListRequestTypes.INLINE : ActiveListRequestTypes.COLUMN, i);
343
+ });
344
+ }
345
+
346
+ /** Same as addInline but adds to dependent requests in a conditional group */
347
+ addInlineToDependent(dependentRequests: Array<ActiveListRequest | ActiveListRelatedEntityRequest>, col: VisibleColumn, i: number): void {
348
+ if (isRelatedColumn(col)) {
349
+ dependentRequests.push({ inline: true, index: i });
350
+ } else {
351
+ this.addColToDependent(dependentRequests, col, false, ActiveListRequestTypes.COLUMN, i);
352
+ }
353
+
354
+ col.waitFor.forEach((wf) => {
355
+ this.addColToDependent(dependentRequests, wf, true, isRelatedColumn(col) ? ActiveListRequestTypes.INLINE : ActiveListRequestTypes.COLUMN, i);
356
+ });
357
+ }
358
+
359
+ /**
360
+ * Add a column / related entity that has a condition into a conditional group.
361
+ * Always handles the item via the group flow (regardless of whether the
362
+ * condition source is sync or async). The caller does NOT add the user's
363
+ * column directly — its identity goes into the group's `conditionedItems` so
364
+ * chaise knows to gate its visibility, and any secondary fetches go into
365
+ * `dependentRequests`.
366
+ *
367
+ * For sync sources, `addCol` is a no-op on `requests` (sync sources are
368
+ * not entityset/aggregate by construction), so the source isn't fetched —
369
+ * but its all-outbound joins / self-link keys still get registered into
370
+ * `allOutBounds` / `selfLinks`. Chaise evaluates the condition immediately
371
+ * after the main entity is read.
372
+ *
373
+ * For async sources, `addCol` pushes the source (and any async wait-fors)
374
+ * into `requests`, tagged with `condition: true` so chaise can dispatch
375
+ * `evaluateCondition` once the data lands.
376
+ */
377
+ processConditionedItem(
378
+ condition: ActiveListCondition,
379
+ conditionedItem: ActiveListConditionedItem,
380
+ addToDependent: (dependentRequests: Array<ActiveListRequest | ActiveListRelatedEntityRequest>) => void,
381
+ ): void {
382
+ // dedup: if this condition was already grouped (via condition_key), reuse
383
+ // that group's dependent list and add this item to its conditionedItems list.
384
+ if (condition.conditionKey) {
385
+ const existingGroup = this.conditionalGroups.find((g) => g.condition.conditionKey === condition.conditionKey);
386
+ if (existingGroup) {
387
+ addToDependent(existingGroup.dependentRequests);
388
+ existingGroup.conditionedItems.push(conditionedItem);
389
+ return;
390
+ }
391
+ }
392
+
393
+ const conditionIndex = this.conditionalGroups.length;
394
+
395
+ // register the condition's own column and its wait-fors. For async sources
396
+ // this populates `requests` (with `condition: true, index: conditionIndex`
397
+ // so chaise routes the completion to evaluateCondition); for sync sources
398
+ // it's a no-op on `requests` but still populates `allOutBounds` / `selfLinks`.
399
+ this.addCol(condition.column, false, ActiveListRequestTypes.CONDITION, conditionIndex);
400
+ condition.waitFor.forEach((wf) => {
401
+ this.addCol(wf, true, ActiveListRequestTypes.CONDITION, conditionIndex);
402
+ });
403
+
404
+ // build the dependent list and stash the new group.
405
+ const dependentRequests: Array<ActiveListRequest | ActiveListRelatedEntityRequest> = [];
406
+ addToDependent(dependentRequests);
407
+ this.conditionalGroups.push({
408
+ condition,
409
+ conditionedItems: [conditionedItem],
410
+ dependentRequests,
411
+ });
412
+ }
413
+
414
+ /** Add a fkey from sourceDefinitions to allOutBounds (with dedup). */
415
+ addAllOutBound(fkCol: ForeignKeyPseudoColumn): void {
416
+ if (fkCol.name in this.consideredOutbounds) return;
417
+ this.consideredOutbounds[fkCol.name] = true;
418
+ this.allOutBounds.push(fkCol);
419
+ }
420
+
421
+ /**
422
+ * Return the built ActiveList object. After calling this method, the builder should not be used anymore.
423
+ */
424
+ build(): ActiveList {
425
+ return {
426
+ requests: this.requests,
427
+ conditionalGroups: this.conditionalGroups,
428
+ allOutBounds: this.allOutBounds,
429
+ selfLinks: this.selfLinks,
430
+ };
431
+ }
432
+ }
@@ -405,6 +405,9 @@ export const _warningMessages = Object.freeze({
405
405
  FILTER_NOT_ALLOWED: 'filter in source is only supported in `filter` context of visible-columns',
406
406
  FILTER_NO_PATH_NOT_ALLOWED: 'filter in source is not supported with local columns or all-outbound paths.',
407
407
  USED_IN_IFRAME_INPUT: 'the column already used in another column mapping.',
408
+ CONDITION: {
409
+ INVALID_SOURCE: 'condition definition is invalid. `source` or `sourcekey` is required and must be valid.',
410
+ },
408
411
  });
409
412
 
410
413
  export const _permissionMessages = Object.freeze({
@@ -507,7 +507,12 @@ export function computeReadPath(
507
507
  /**
508
508
  * Generate the list of columns for this reference based on context and annotations
509
509
  */
510
- export function generateColumnsList(reference: Reference | RelatedReference, tuple?: Tuple, columnsList?: any[], skipLog?: boolean): VisibleColumn[] {
510
+ export function generateColumnsList(
511
+ reference: Reference | RelatedReference,
512
+ tuple?: Tuple,
513
+ columnsList?: Array<unknown>,
514
+ skipLog?: boolean,
515
+ ): VisibleColumn[] {
511
516
  const resultColumns: VisibleColumn[] = [];
512
517
  const consideredColumns: { [key: string]: boolean } = {}; // to avoid duplicate pseudo columns
513
518
  const tableColumns: { [key: string]: boolean } = {}; // to make sure the hashes we generate are not clashing with table column names
@@ -0,0 +1,117 @@
1
+ // models
2
+ import type { Tuple } from '@isrd-isi-edu/ermrestjs/src/models/reference';
3
+ import type { ReferenceColumn } from '@isrd-isi-edu/ermrestjs/src/models/reference-column';
4
+ import type { PseudoColumn } from '@isrd-isi-edu/ermrestjs/src/models/reference-column/pseudo-column';
5
+ import type { ForeignKeyPseudoColumn } from '@isrd-isi-edu/ermrestjs/src/models/reference-column/foreign-key-pseudo-column';
6
+ import type { KeyPseudoColumn } from '@isrd-isi-edu/ermrestjs/src/models/reference-column/key-pseudo-column';
7
+
8
+ // legacy
9
+ import { _getRowTemplateVariables } from '@isrd-isi-edu/ermrestjs/js/utils/helpers';
10
+
11
+ /**
12
+ * Build the `$self` (and where applicable `$_self`) template variables for a
13
+ * given column and tuple. Consolidates the derivation logic that used to be
14
+ * duplicated across `sourceFormatPresentation` in `ReferenceColumn`,
15
+ * `PseudoColumn`, `ForeignKeyPseudoColumn`, `KeyPseudoColumn`, and
16
+ * `ActiveListCondition`.
17
+ *
18
+ * The returned shape varies with the column:
19
+ * - scalar sources (local column, all-outbound scalar) ⇒ `{ $self, $_self }`
20
+ * where `$_self` is the raw value and `$self` is `formatvalue(...)`'d.
21
+ * - entity-mode sources (key, foreign-key, all-outbound entity, entityset) ⇒
22
+ * `{ $self }` only, where `$self` is the row template (`{ values, rowName, uri, ... }`).
23
+ * - aggregates ⇒ `columnValue.templateVariables` directly (already contains
24
+ * `$self`/`$_self`).
25
+ * - missing data (entity row didn't load, etc.) ⇒ `{}`.
26
+ *
27
+ * @param column - the column whose `$self` we are building.
28
+ * @param mainTuple - the main record tuple. Used to source `$self`/`$_self`
29
+ * for any path that doesn't require an async fetch (local columns,
30
+ * all-outbound, key, foreign-key).
31
+ * @param columnValue - the fetched value, only meaningful for async sources:
32
+ * - **entityset** (Reference.read result): a {@link Page} — `templateVariables`
33
+ * is read from it.
34
+ * - **aggregate** (column.getAggregatedValue result, unwrapped first row):
35
+ * `{ value, templateVariables }` — `templateVariables` is read from it.
36
+ * - **null/undefined** for sync sources, or before the fetch has completed
37
+ * (sync paths ignore it; async paths return `{}`).
38
+ * @returns the `$self`/`$_self` slice ready to be merged into the
39
+ * accumulated template variables.
40
+ */
41
+ export function buildSelfTemplateVariables(column: ReferenceColumn, mainTuple: Tuple, columnValue?: any): Record<string, any> {
42
+ const context = (column as any)._context as string;
43
+
44
+ // KeyPseudoColumn: $self is row-level template variables from mainTuple.data
45
+ if ((column as any).isKey) {
46
+ const keyCol = column as KeyPseudoColumn;
47
+ return {
48
+ $self: _getRowTemplateVariables(keyCol.table, context, mainTuple.data),
49
+ };
50
+ }
51
+
52
+ // ForeignKeyPseudoColumn: $self is row-level template variables from linkedData
53
+ if ((column as any).isForeignKey) {
54
+ const fkCol = column as ForeignKeyPseudoColumn;
55
+ if (!mainTuple.linkedData[fkCol.name]) {
56
+ return {};
57
+ }
58
+ return {
59
+ $self: _getRowTemplateVariables(fkCol.table, context, mainTuple.linkedData[fkCol.name]),
60
+ };
61
+ }
62
+
63
+ // PseudoColumn (path-based source column)
64
+ if ((column as any).isPathColumn) {
65
+ const pseudoCol = column as PseudoColumn;
66
+
67
+ // aggregate: use precomputed templateVariables from the fetched value
68
+ if (pseudoCol.hasAggregate && columnValue) {
69
+ return columnValue.templateVariables || {};
70
+ }
71
+
72
+ // all-outbound (isUnique): use mainTuple.linkedData
73
+ if (pseudoCol.hasPath && pseudoCol.isUnique) {
74
+ if (!mainTuple.linkedData[pseudoCol.name]) {
75
+ return {};
76
+ }
77
+ // scalar
78
+ if (!pseudoCol.isEntityMode) {
79
+ const baseCol = pseudoCol.baseColumn;
80
+ return {
81
+ $self: baseCol.formatvalue(mainTuple.linkedData[pseudoCol.name][baseCol.name], context),
82
+ $_self: mainTuple.linkedData[pseudoCol.name][baseCol.name],
83
+ };
84
+ }
85
+ // entity
86
+ return {
87
+ $self: _getRowTemplateVariables(pseudoCol.table, context, mainTuple.linkedData[pseudoCol.name]),
88
+ };
89
+ }
90
+
91
+ // entityset: use templateVariables from the page result
92
+ if (columnValue && columnValue.templateVariables) {
93
+ return columnValue.templateVariables;
94
+ }
95
+
96
+ // other paths with a base column (scalar)
97
+ if (pseudoCol.baseColumn) {
98
+ return {
99
+ $self: pseudoCol.baseColumn.formatvalue(mainTuple.data[pseudoCol.baseColumn.name], context),
100
+ $_self: mainTuple.data[pseudoCol.baseColumn.name],
101
+ };
102
+ }
103
+
104
+ return {};
105
+ }
106
+
107
+ // Base ReferenceColumn (non-pseudo, simple column)
108
+ const baseCol = column.baseColumns[0];
109
+ if (baseCol) {
110
+ return {
111
+ $self: baseCol.formatvalue(mainTuple.data[baseCol.name], context),
112
+ $_self: mainTuple.data[baseCol.name],
113
+ };
114
+ }
115
+
116
+ return {};
117
+ }