metricflow-sql 0.1.1__py3-none-any.whl

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.
Files changed (56) hide show
  1. metricflow_sql/__init__.py +8 -0
  2. metricflow_sql/compiler/__init__.py +0 -0
  3. metricflow_sql/compiler/catalog/__init__.py +0 -0
  4. metricflow_sql/compiler/catalog/cluster.py +152 -0
  5. metricflow_sql/compiler/catalog/information_schema.py +47 -0
  6. metricflow_sql/compiler/catalog/metadata.py +65 -0
  7. metricflow_sql/compiler/catalog/passthrough.py +184 -0
  8. metricflow_sql/compiler/catalog/protocols.py +67 -0
  9. metricflow_sql/compiler/catalog/semantic.py +59 -0
  10. metricflow_sql/compiler/compiler.py +297 -0
  11. metricflow_sql/compiler/rules/__init__.py +0 -0
  12. metricflow_sql/compiler/rules/expand_constellations.py +790 -0
  13. metricflow_sql/compiler/rules/expand_datasets.py +1208 -0
  14. metricflow_sql/compiler/rules/expand_metadata.py +88 -0
  15. metricflow_sql/compiler/rules/isolate_fragments.py +244 -0
  16. metricflow_sql/compiler/rules/passthrough_tables.py +36 -0
  17. metricflow_sql/compiler/table_arguments.py +35 -0
  18. metricflow_sql/converters/__init__.py +0 -0
  19. metricflow_sql/converters/lookml.py +1041 -0
  20. metricflow_sql/objects/__init__.py +0 -0
  21. metricflow_sql/objects/base.py +308 -0
  22. metricflow_sql/objects/constellation.py +210 -0
  23. metricflow_sql/objects/dataset.py +275 -0
  24. metricflow_sql/objects/entity.py +131 -0
  25. metricflow_sql/objects/reference.py +35 -0
  26. metricflow_sql/objects/wiki.py +101 -0
  27. metricflow_sql/py.typed +0 -0
  28. metricflow_sql/registry/__init__.py +0 -0
  29. metricflow_sql/registry/memory.py +409 -0
  30. metricflow_sql/registry/operation.py +25 -0
  31. metricflow_sql/registry/reference_resolution.py +68 -0
  32. metricflow_sql/registry/registry.py +225 -0
  33. metricflow_sql/registry/type_resolver.py +460 -0
  34. metricflow_sql/schemas/__init__.py +0 -0
  35. metricflow_sql/schemas/bundle.py +327 -0
  36. metricflow_sql/schemas/fragment.schema.json +1165 -0
  37. metricflow_sql/schemas/generate.py +70 -0
  38. metricflow_sql/schemas/load.py +333 -0
  39. metricflow_sql/schemas/v1/__init__.py +51 -0
  40. metricflow_sql/schemas/v1/fragment.py +544 -0
  41. metricflow_sql/server/__init__.py +4 -0
  42. metricflow_sql/server/app.py +33 -0
  43. metricflow_sql/server/compile.py +69 -0
  44. metricflow_sql/server/errors.py +10 -0
  45. metricflow_sql/server/serve.py +161 -0
  46. metricflow_sql/utils/__init__.py +0 -0
  47. metricflow_sql/utils/composed_set.py +32 -0
  48. metricflow_sql/utils/normalize.py +74 -0
  49. metricflow_sql/utils/ordered_set.py +24 -0
  50. metricflow_sql/utils/sql.py +226 -0
  51. metricflow_sql/utils/template.py +57 -0
  52. metricflow_sql-0.1.1.dist-info/METADATA +30 -0
  53. metricflow_sql-0.1.1.dist-info/RECORD +56 -0
  54. metricflow_sql-0.1.1.dist-info/WHEEL +4 -0
  55. metricflow_sql-0.1.1.dist-info/entry_points.txt +2 -0
  56. metricflow_sql-0.1.1.dist-info/licenses/LICENSE +29 -0
File without changes
@@ -0,0 +1,308 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass, field
5
+ from enum import StrEnum
6
+ from typing import Any
7
+ from urllib.parse import quote, unquote
8
+
9
+ from sqlglot import exp
10
+ from sqlglot.dialects.dialect import NormalizationStrategy
11
+
12
+ from metricflow_sql.utils.normalize import read_identifier, read_table
13
+ from metricflow_sql.utils.ordered_set import OrderedSet
14
+ from metricflow_sql.utils.sql import column_paths
15
+
16
+ DEFAULT_NAMESPACE = "public"
17
+
18
+
19
+ class Prefix(StrEnum):
20
+ # Datasets, entities and constellations share one, as they share names.
21
+ TABLE = "t"
22
+ FIELD = "f"
23
+ SOURCE = "s"
24
+ ASSIGNMENT = "a"
25
+ PAGE = "p"
26
+
27
+ @property
28
+ def qualified(self) -> bool:
29
+ """Whether names are qualified by their parent, e.g. `f:<dataset>:<field>`."""
30
+ return self in _QUALIFIED_PREFIXES
31
+
32
+ @classmethod
33
+ def parse(cls, text: str) -> Prefix:
34
+ """The prefix `text` names, case-insensitively: a prefix (`f`) or its alias (`metric`)."""
35
+ text = text.lower()
36
+ prefix = _PREFIX_ALIASES.get(text)
37
+ return prefix if prefix is not None else cls(text)
38
+
39
+
40
+ # How many parts an id of each prefix has, tables and fields.
41
+ _PART_COUNTS = {
42
+ Prefix.TABLE: 1,
43
+ Prefix.FIELD: 2,
44
+ Prefix.SOURCE: 2,
45
+ Prefix.ASSIGNMENT: 3,
46
+ Prefix.PAGE: 1,
47
+ }
48
+
49
+ _QUALIFIED_PREFIXES = frozenset({Prefix.FIELD, Prefix.SOURCE, Prefix.ASSIGNMENT})
50
+
51
+ # A table after the first is in the first's namespace, as the objects resolve it.
52
+ _TABLE_PARTS = {
53
+ Prefix.TABLE: (0,),
54
+ Prefix.FIELD: (0,),
55
+ Prefix.SOURCE: (0, 1),
56
+ Prefix.ASSIGNMENT: (0, 2),
57
+ Prefix.PAGE: (),
58
+ }
59
+
60
+ # Human-friendly names for prefixes, e.g. `metric:sales:revenue` for `f:sales:revenue`.
61
+ # Kinds sharing a prefix (e.g. `metric` and `role`) are interchangeable; they document intent.
62
+ _PREFIX_ALIASES: dict[str, Prefix] = {
63
+ "dataset": Prefix.TABLE,
64
+ "entity": Prefix.TABLE,
65
+ "constellation": Prefix.TABLE,
66
+ "field": Prefix.FIELD,
67
+ "column": Prefix.FIELD,
68
+ "calculation": Prefix.FIELD,
69
+ "relationship": Prefix.FIELD,
70
+ "attribute": Prefix.FIELD,
71
+ "metric": Prefix.FIELD,
72
+ "role": Prefix.FIELD,
73
+ "source": Prefix.SOURCE,
74
+ "assignment": Prefix.ASSIGNMENT,
75
+ "page": Prefix.PAGE,
76
+ }
77
+
78
+
79
+ @dataclass(frozen=True, slots=True)
80
+ class Id:
81
+ """
82
+ Object identifier.
83
+
84
+ Should be unique within an environment.
85
+ """
86
+
87
+ prefix: Prefix
88
+ parts: tuple[exp.Table | exp.Identifier, ...]
89
+
90
+ def __post_init__(self) -> None:
91
+ for i in _TABLE_PARTS[self.prefix]:
92
+ match self.parts[i] if i < len(self.parts) else None:
93
+ case exp.Table(db="") | exp.Identifier():
94
+ raise ValueError(f"{self!r} names a table without its namespace")
95
+
96
+ def names(self) -> tuple[str, ...]:
97
+ names: list[str] = []
98
+ for part in self.parts:
99
+ match part:
100
+ case exp.Table():
101
+ names.extend((part.db, part.name))
102
+ case exp.Identifier():
103
+ names.append(part.name)
104
+ return tuple(names)
105
+
106
+ def text(self) -> str:
107
+ """
108
+ `<prefix>:<name>:...`, where a table is its namespace and name, so a field is
109
+ `f:<namespace>:<table>:<field>`. Names are the registry's, normalized, so they're written
110
+ percent-encoded as a URL component, e.g. `f:SALES:Net%20Orders:AMOUNT` on Snowflake.
111
+ """
112
+ return ":".join((self.prefix.value, *map(_escape, self.names())))
113
+
114
+ @classmethod
115
+ def parse(cls, text: str) -> Id:
116
+ """
117
+ Parse an id as `text` writes it, e.g. `f:SALES:Net%20Orders:AMOUNT`, where every table
118
+ names its namespace. Names are read exactly: they're already the registry's. Escapes are
119
+ decoded leniently, so only `:` and `%` need them.
120
+ """
121
+ prefix, texts = _split(text)
122
+ if len(texts) != _PART_COUNTS[prefix] + len(_TABLE_PARTS[prefix]):
123
+ raise ValueError(f"Invalid id '{text}': wrong number of names for '{prefix.value}'")
124
+ return cls(prefix, _read_exact(prefix, texts))
125
+
126
+ @classmethod
127
+ def resolve(cls, text: str, dialect: str, strategy: NormalizationStrategy) -> Id:
128
+ """
129
+ Resolve a reference written by hand, e.g. `metric:sales:revenue`, whose prefix may be an
130
+ alias. Names are read as the registry reads a query's, and a table is in the namespace it
131
+ gives, e.g. `finance.sales`, else the first table's, else the default one, so the example
132
+ is `f:public:sales:revenue`. An id as
133
+ `text` writes it, which names every namespace, is read as `parse` does.
134
+ """
135
+ prefix, texts = _split(text)
136
+ tables = _TABLE_PARTS[prefix]
137
+ if len(texts) == _PART_COUNTS[prefix] + len(tables):
138
+ return cls(prefix, _read_exact(prefix, texts))
139
+ if len(texts) != _PART_COUNTS[prefix]:
140
+ raise ValueError(f"Invalid id '{text}': wrong number of names for '{prefix.value}'")
141
+ namespace = read_identifier(DEFAULT_NAMESPACE, dialect, strategy)
142
+ parts: list[exp.Table | exp.Identifier] = []
143
+ for i, part in enumerate(texts):
144
+ if i in tables:
145
+ table = in_namespace(read_table(part, dialect, strategy), namespace)
146
+ if not parts:
147
+ namespace = table.args["db"]
148
+ parts.append(table)
149
+ else:
150
+ parts.append(read_identifier(part, dialect, strategy))
151
+ return cls(prefix, tuple(parts))
152
+
153
+
154
+ def _split(text: str) -> tuple[Prefix, list[str]]:
155
+ """The prefix of `text`, which may be an alias, and its names, unescaped."""
156
+ prefix_text, separator, name = text.partition(":")
157
+ try:
158
+ prefix = Prefix.parse(prefix_text)
159
+ except ValueError:
160
+ raise ValueError(f"Invalid id '{text}': unknown prefix '{prefix_text}'") from None
161
+ if prefix is Prefix.PAGE:
162
+ if not name:
163
+ raise ValueError(f"Invalid id '{text}': expected '{prefix_text}:<name>'")
164
+ return prefix, [_unescape(name)]
165
+ texts = [_unescape(part) for part in name.split(":")]
166
+ if prefix.qualified:
167
+ if len(texts) < 2 or not all(texts):
168
+ raise ValueError(f"Invalid id '{text}': expected '{prefix_text}:<parent>:<name>'")
169
+ elif not (separator and name):
170
+ raise ValueError(f"Invalid id '{text}': expected '{prefix_text}:<name>'")
171
+ return prefix, texts
172
+
173
+
174
+ def _escape(name: str) -> str:
175
+ return quote(name, safe="")
176
+
177
+
178
+ def _unescape(text: str) -> str:
179
+ return unquote(text, errors="strict")
180
+
181
+
182
+ def _read_exact(prefix: Prefix, texts: list[str]) -> tuple[exp.Table | exp.Identifier, ...]:
183
+ """The parts of an id as `text` writes it: a namespace and name for each table."""
184
+ parts: list[exp.Table | exp.Identifier] = []
185
+ remaining = iter(texts)
186
+ for i in range(_PART_COUNTS[prefix]):
187
+ if i in _TABLE_PARTS[prefix]:
188
+ namespace, table = next(remaining), next(remaining)
189
+ parts.append(
190
+ exp.table_(
191
+ exp.to_identifier(table, quoted=True),
192
+ db=exp.to_identifier(namespace, quoted=True),
193
+ )
194
+ )
195
+ else:
196
+ parts.append(exp.to_identifier(next(remaining), quoted=True))
197
+ return tuple(parts)
198
+
199
+
200
+ def in_namespace(name: exp.Table, namespace: exp.Identifier) -> exp.Table:
201
+ """A reference without a namespace is in the referring object's, as SQL resolves a table."""
202
+ return name if name.db else exp.table_(name.this, db=namespace)
203
+
204
+
205
+ @dataclass(frozen=True, slots=True)
206
+ class Path:
207
+ base: Id
208
+ path: tuple[exp.Identifier, ...]
209
+
210
+
211
+ @dataclass(frozen=True, slots=True)
212
+ class Link:
213
+ """An outgoing reference from one object to `target`, e.g. a markdown link in a wiki page."""
214
+
215
+ target: Id
216
+ label: str
217
+
218
+
219
+ DEPENDENCY_LINK = "dependency"
220
+ PARENT_LINK = "parent"
221
+ TYPE_LINK = "type"
222
+
223
+
224
+ @dataclass(frozen=True, slots=True)
225
+ class Backlink:
226
+ """An incoming reference to an object, from `source` - the reverse of a `Link`."""
227
+
228
+ source: Id
229
+ label: str
230
+
231
+
232
+ @dataclass(frozen=True, slots=True)
233
+ class Chunk:
234
+ """An independently indexable piece of an object's text, for search."""
235
+
236
+ text: str
237
+ # Heading trail locating the chunk within its object, e.g. ("Setup", "Install").
238
+ section: tuple[str, ...] = ()
239
+
240
+
241
+ @dataclass(frozen=True, kw_only=True)
242
+ class BaseObject:
243
+ """
244
+ Abstract base class for all objects.
245
+ """
246
+
247
+ meta: dict[str, Any] = field(default_factory=dict)
248
+ label: str | None = None
249
+ description: str | None = None
250
+
251
+ @property
252
+ def id(self) -> Id:
253
+ raise NotImplementedError
254
+
255
+ def dependencies(self) -> OrderedSet[Id | Path]:
256
+ """
257
+ References to other objects that this object depends on for compilation
258
+ """
259
+ return OrderedSet()
260
+
261
+ def links(self) -> OrderedSet[Link]:
262
+ links: OrderedSet[Link] = OrderedSet()
263
+ for dependency in self.dependencies():
264
+ match dependency:
265
+ case Id():
266
+ links.add(Link(target=dependency, label=DEPENDENCY_LINK))
267
+ case Path():
268
+ # TODO: link paths too, e.g. to the relationship a path starts from.
269
+ pass
270
+ return links
271
+
272
+ def chunks(self) -> tuple[Chunk, ...]:
273
+ """Indexable text for search"""
274
+ parts = (" ".join(self.id.names()), self.label, self.description)
275
+ return (Chunk(text="\n".join(part for part in parts if part)),)
276
+
277
+
278
+ def dataset_paths(
279
+ parts: tuple[exp.Identifier, ...], namespace: exp.Identifier
280
+ ) -> tuple[tuple[exp.Table, tuple[exp.Identifier, ...]], ...]:
281
+ """Which reading of `a.b.c` applies depends on what's in scope, as in SQL: a dataset `a` in
282
+ `namespace` wins over `a.b`. Dependencies can't tell, so they depend on both."""
283
+ own = (exp.table_(parts[0], db=namespace), parts[1:])
284
+ if len(parts) < 3:
285
+ return (own,)
286
+ return own, (exp.table_(parts[1], db=parts[0]), parts[2:])
287
+
288
+
289
+ def resolve_dataset_path(
290
+ parts: tuple[exp.Identifier, ...],
291
+ namespace: exp.Identifier,
292
+ exists: Callable[[exp.Table], bool],
293
+ ) -> tuple[exp.Table, tuple[exp.Identifier, ...]]:
294
+ readings = dataset_paths(parts, namespace)
295
+ return next((reading for reading in readings if exists(reading[0])), readings[0])
296
+
297
+
298
+ def deps_from_dataset_expr(dataset: exp.Table, expr: exp.Expr) -> OrderedSet[Id | Path]:
299
+ """
300
+ Get dependencies from a dataset SQL expression, using `dataset` as the base.
301
+ """
302
+ deps: OrderedSet[Id | Path] = OrderedSet()
303
+ for col in expr.find_all(exp.Column):
304
+ if not col.table:
305
+ deps.add(Id(Prefix.FIELD, (dataset, col.this.copy())))
306
+ for parts in column_paths(expr):
307
+ deps.add(Path(base=Id(Prefix.TABLE, (dataset,)), path=parts))
308
+ return deps
@@ -0,0 +1,210 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass
6
+ from dataclasses import field as dcfield
7
+
8
+ from sqlglot import expressions as exp
9
+
10
+ from metricflow_sql.objects.base import (
11
+ PARENT_LINK,
12
+ TYPE_LINK,
13
+ BaseObject,
14
+ Id,
15
+ Link,
16
+ Path,
17
+ Prefix,
18
+ dataset_paths,
19
+ deps_from_dataset_expr,
20
+ in_namespace,
21
+ )
22
+ from metricflow_sql.utils.ordered_set import OrderedSet
23
+ from metricflow_sql.utils.sql import column_paths, ref_target
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class IncludeAll:
30
+ """Include all fields in the import"""
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Include:
35
+ """Include `field` in the import"""
36
+
37
+ field: exp.Identifier
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Exclude:
42
+ """Exclude `field` from the import"""
43
+
44
+ field: exp.Identifier
45
+
46
+
47
+ ImportRule = IncludeAll | Include | Exclude
48
+
49
+
50
+ def import_rule_from_str(rule: str, name: Callable[[str], exp.Identifier]) -> ImportRule:
51
+ if rule == "*":
52
+ return IncludeAll()
53
+ if rule.startswith("!"):
54
+ return Exclude(field=name(rule[1:]))
55
+ return Include(field=name(rule))
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class Import:
60
+ """
61
+ Specification for how one constellation imports another
62
+ """
63
+
64
+ constellation: exp.Table
65
+ fields: tuple[ImportRule, ...]
66
+
67
+ def includes(self, field: exp.Identifier) -> bool:
68
+ """Returns whether `field` is imported. Only exclusions, e.g. `!refunds`, imply `*`."""
69
+ if Exclude(field=field) in self.fields:
70
+ return False
71
+ return (
72
+ IncludeAll() in self.fields
73
+ or Include(field=field) in self.fields
74
+ or (bool(self.fields) and all(isinstance(rule, Exclude) for rule in self.fields))
75
+ )
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class Constellation(BaseObject):
80
+ """
81
+ A Constellation is a group of fact tables connected by conformed dimensions.
82
+ Constellations capture higher-order business domains and enable multi-fact queries.
83
+
84
+ Constellations can include configuration from other constellations.
85
+ """
86
+
87
+ name: exp.Table
88
+ imports: list[Import] = dcfield(default_factory=list)
89
+
90
+ @property
91
+ def id(self) -> Id:
92
+ return Id(Prefix.TABLE, (self.name,))
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class _ConstellationField(BaseObject):
97
+ constellation: exp.Table
98
+ name: exp.Identifier
99
+
100
+ @property
101
+ def id(self) -> Id:
102
+ return Id(Prefix.FIELD, (self.constellation, self.name))
103
+
104
+ @property
105
+ def namespace(self) -> exp.Identifier:
106
+ namespace: exp.Identifier = self.constellation.args["db"]
107
+ return namespace
108
+
109
+ def links(self) -> OrderedSet[Link]:
110
+ links = super().links()
111
+ links.add(Link(target=Id(Prefix.TABLE, (self.constellation,)), label=PARENT_LINK))
112
+ return links
113
+
114
+
115
+ @dataclass(frozen=True)
116
+ class Role(_ConstellationField):
117
+ """
118
+ A Role is a qualitative property that describes `Metrics` - metrics can be sliced
119
+ and diced by roles.
120
+
121
+ Roles are scoped to `Constellation`s.
122
+
123
+ Each table contributing to a constellation can assign columns to a Role.
124
+
125
+ `datatype` can be a physical data type or a REF to an entity or dataset, in which case
126
+ datasets must implement the role with a relationship.
127
+ """
128
+
129
+ datatype: exp.DataType | None = None
130
+
131
+ def dependencies(self) -> OrderedSet[Id | Path]:
132
+ deps: OrderedSet[Id | Path] = OrderedSet()
133
+ deps.add(Id(Prefix.TABLE, (self.constellation,)))
134
+ return deps
135
+
136
+ def links(self) -> OrderedSet[Link]:
137
+ links = super().links()
138
+ reference = ref_target(self.datatype)
139
+ if reference is not None:
140
+ target = Id(Prefix.TABLE, (in_namespace(reference, self.namespace),))
141
+ links.add(Link(target=target, label=TYPE_LINK))
142
+ return links
143
+
144
+
145
+ @dataclass(frozen=True)
146
+ class Metric(_ConstellationField):
147
+ """
148
+ A Metric is an aggregatable measurement.
149
+
150
+ Metrics are typically quantitative, but they can be any aggregatable value.
151
+ Metrics can span multiple datasets.
152
+
153
+ Metrics are scoped to `Constellation`s.
154
+
155
+ `expr` is a SQL expression, where qualified columns reference fields from datasets
156
+ and unqualified columns reference other metrics or roles.
157
+
158
+ `datatype` should be resolved from dependencies.
159
+ """
160
+
161
+ expr: exp.Expr
162
+ datatype: exp.DataType | None = None
163
+
164
+ def dependencies(self) -> OrderedSet[Id | Path]:
165
+ deps: OrderedSet[Id | Path] = OrderedSet()
166
+ deps.add(Id(Prefix.TABLE, (self.constellation,)))
167
+ for col in self.expr.find_all(exp.Column):
168
+ # Unqualified references are other metrics or roles.
169
+ if not col.table:
170
+ deps.add(Id(Prefix.FIELD, (self.constellation, col.this.copy())))
171
+ for parts in column_paths(self.expr):
172
+ for dataset, path in dataset_paths(parts, self.namespace):
173
+ deps.add(Path(base=Id(Prefix.TABLE, (dataset,)), path=path))
174
+ return deps
175
+
176
+
177
+ @dataclass(frozen=True)
178
+ class Assignment(BaseObject):
179
+ """
180
+ An Assignment defines how a `Dataset` provides a `Role`.
181
+
182
+ This is akin to "dimension conforming" in traditional dimensional modeling, allowing
183
+ cross-table analysis - queries reference roles, and the physical location
184
+ of roles are resolved per dataset.
185
+
186
+ Assignments are unique per `constellation`/`dataset`/`role`.
187
+
188
+ `expr` is a sql expression, where columns are resolved relative to `dataset`.
189
+ """
190
+
191
+ constellation: exp.Table
192
+ dataset: exp.Table
193
+ role: exp.Identifier
194
+ expr: exp.Expr
195
+
196
+ @property
197
+ def id(self) -> Id:
198
+ return Id(Prefix.ASSIGNMENT, (self.constellation, self.role, self.dataset))
199
+
200
+ def dependencies(self) -> OrderedSet[Id | Path]:
201
+ deps = deps_from_dataset_expr(self.dataset, self.expr)
202
+ # A constant like `'store'` reads no column, but the branch still reads the dataset.
203
+ deps.add(Id(Prefix.TABLE, (self.dataset,)))
204
+ return deps
205
+
206
+ def links(self) -> OrderedSet[Link]:
207
+ links = super().links()
208
+ links.add(Link(target=Id(Prefix.TABLE, (self.constellation,)), label=PARENT_LINK))
209
+ links.add(Link(target=Id(Prefix.FIELD, (self.constellation, self.role)), label=PARENT_LINK))
210
+ return links