Coverage for python/lsst/daf/butler/core/dimensions/_schema.py: 22%
111 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-14 09:11 +0000
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-14 09:11 +0000
1# This file is part of daf_butler.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (http://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <http://www.gnu.org/licenses/>.
21from __future__ import annotations
23__all__ = ("addDimensionForeignKey",)
25import copy
26from collections.abc import Mapping
27from typing import TYPE_CHECKING
29from lsst.utils.classes import cached_getter
31from .. import ddl
32from .._column_tags import DimensionKeyColumnTag, DimensionRecordColumnTag
33from ..named import NamedValueSet
34from ..timespan import TimespanDatabaseRepresentation
36if TYPE_CHECKING: # Imports needed only for type annotations; may be circular.
37 from lsst.daf.relation import ColumnTag
39 from ._elements import Dimension, DimensionElement
42def _makeForeignKeySpec(dimension: Dimension) -> ddl.ForeignKeySpec:
43 """Make a `ddl.ForeignKeySpec`.
45 This will reference the table for the given `Dimension` table.
47 Most callers should use the higher-level `addDimensionForeignKey` function
48 instead.
50 Parameters
51 ----------
52 dimension : `Dimension`
53 The dimension to be referenced. Caller guarantees that it is actually
54 associated with a table.
56 Returns
57 -------
58 spec : `ddl.ForeignKeySpec`
59 A database-agnostic foreign key specification.
60 """
61 source = []
62 target = []
63 for other in dimension.required:
64 if other == dimension:
65 target.append(dimension.primaryKey.name)
66 else:
67 target.append(other.name)
68 source.append(other.name)
69 return ddl.ForeignKeySpec(table=dimension.name, source=tuple(source), target=tuple(target))
72def addDimensionForeignKey(
73 tableSpec: ddl.TableSpec,
74 dimension: Dimension,
75 *,
76 primaryKey: bool,
77 nullable: bool = False,
78 constraint: bool = True,
79) -> ddl.FieldSpec:
80 """Add a field and possibly a foreign key to a table specification.
82 The field will reference the table for the given `Dimension`.
84 Parameters
85 ----------
86 tableSpec : `ddl.TableSpec`
87 Specification the field and foreign key are to be added to.
88 dimension : `Dimension`
89 Dimension to be referenced. If this dimension has required
90 dependencies, those must have already been added to the table. A field
91 will be added that correspond to this dimension's primary key, and a
92 foreign key constraint will be added only if the dimension is
93 associated with a table of its own.
94 primaryKey : `bool`
95 If `True`, the new field will be added as part of a compound primary
96 key for the table.
97 nullable : `bool`, optional
98 If `False` (default) the new field will be added with a NOT NULL
99 constraint.
100 constraint : `bool`
101 If `False` (`True` is default), just add the field, not the foreign
102 key constraint.
104 Returns
105 -------
106 fieldSpec : `ddl.FieldSpec`
107 Specification for the field just added.
108 """
109 # Add the dependency's primary key field, but use the dimension name for
110 # the field name to make it unique and more meaningful in this table.
111 fieldSpec = copy.copy(dimension.primaryKey)
112 fieldSpec.name = dimension.name
113 fieldSpec.primaryKey = primaryKey
114 fieldSpec.nullable = nullable
115 tableSpec.fields.add(fieldSpec)
116 # Also add a foreign key constraint on the dependency table, but only if
117 # there actually is one and we weren't told not to.
118 if dimension.hasTable() and dimension.viewOf is None and constraint:
119 tableSpec.foreignKeys.append(_makeForeignKeySpec(dimension))
120 return fieldSpec
123class DimensionElementFields:
124 """Class for constructing table schemas for `DimensionElement`.
126 This creates an object that constructs the table schema for a
127 `DimensionElement` and provides a categorized view of its fields.
129 Parameters
130 ----------
131 element : `DimensionElement`
132 Element for which to make a table specification.
134 Notes
135 -----
136 This combines the foreign key fields from dependencies, unique keys
137 for true `Dimension` instances, metadata fields, and region/timestamp
138 fields for spatial/temporal elements.
140 Callers should use `DimensionUniverse.makeSchemaSpec` if they want to
141 account for elements that have no table or reference another table; this
142 class simply creates a specification for the table an element _would_ have
143 without checking whether it does have one. That can be useful in contexts
144 (e.g. `DimensionRecord`) where we want to simulate the existence of such a
145 table.
146 """
148 def __init__(self, element: DimensionElement):
149 self.element = element
150 self._tableSpec = ddl.TableSpec(fields=())
151 # Add the primary key fields of required dimensions. These continue to
152 # be primary keys in the table for this dimension.
153 self.required = NamedValueSet()
154 self.dimensions = NamedValueSet()
155 self.facts = NamedValueSet()
156 self.standard = NamedValueSet()
157 dependencies = []
158 for dimension in element.required:
159 if dimension != element:
160 fieldSpec = addDimensionForeignKey(self._tableSpec, dimension, primaryKey=True)
161 dependencies.append(fieldSpec.name)
162 else:
163 fieldSpec = element.primaryKey # type: ignore
164 # A Dimension instance is in its own required dependency graph
165 # (always at the end, because of topological ordering). In
166 # this case we don't want to rename the field.
167 self._tableSpec.fields.add(fieldSpec)
168 self.required.add(fieldSpec)
169 self.dimensions.add(fieldSpec)
170 self.standard.add(fieldSpec)
171 # Add fields and foreign keys for implied dimensions. These are
172 # primary keys in their own table, but should not be here. As with
173 # required dependencies, we rename the fields with the dimension name.
174 # We use element.implied instead of element.graph.implied because we
175 # don't want *recursive* implied dependencies.
176 self.implied = NamedValueSet()
177 for dimension in element.implied:
178 fieldSpec = addDimensionForeignKey(self._tableSpec, dimension, primaryKey=False, nullable=False)
179 self.implied.add(fieldSpec)
180 self.dimensions.add(fieldSpec)
181 self.standard.add(fieldSpec)
182 # Add non-primary unique keys and unique constraints for them.
183 for fieldSpec in getattr(element, "alternateKeys", ()):
184 self._tableSpec.fields.add(fieldSpec)
185 self._tableSpec.unique.add(tuple(dependencies) + (fieldSpec.name,))
186 self.standard.add(fieldSpec)
187 self.facts.add(fieldSpec)
188 # Add other metadata fields.
189 for fieldSpec in element.metadata:
190 self._tableSpec.fields.add(fieldSpec)
191 self.standard.add(fieldSpec)
192 self.facts.add(fieldSpec)
193 names = list(self.standard.names)
194 # Add fields for regions and/or timespans.
195 if element.spatial is not None:
196 names.append("region")
197 if element.temporal is not None:
198 names.append(TimespanDatabaseRepresentation.NAME)
199 self.names = tuple(names)
201 def makeTableSpec(
202 self,
203 TimespanReprClass: type[TimespanDatabaseRepresentation],
204 ) -> ddl.TableSpec:
205 """Construct a complete specification for a table.
207 The table could hold the records of this element.
209 Parameters
210 ----------
211 TimespanReprClass : `type` [ `TimespanDatabaseRepresentation` ]
212 Class object that specifies how timespans are represented in the
213 database.
215 Returns
216 -------
217 spec : `ddl.TableSpec`
218 Specification for a table.
219 """
220 if self.element.temporal is not None or self.element.spatial is not None:
221 spec = ddl.TableSpec(
222 fields=NamedValueSet(self._tableSpec.fields),
223 unique=self._tableSpec.unique,
224 indexes=self._tableSpec.indexes,
225 foreignKeys=self._tableSpec.foreignKeys,
226 )
227 if self.element.spatial is not None:
228 spec.fields.add(ddl.FieldSpec.for_region())
229 if self.element.temporal is not None:
230 spec.fields.update(TimespanReprClass.makeFieldSpecs(nullable=True))
231 else:
232 spec = self._tableSpec
233 return spec
235 def __str__(self) -> str:
236 lines = [f"{self.element.name}: "]
237 lines.extend(f" {field.name}: {field.getPythonType().__name__}" for field in self.standard)
238 if self.element.spatial is not None:
239 lines.append(" region: lsst.sphgeom.Region")
240 if self.element.temporal is not None:
241 lines.append(" timespan: lsst.daf.butler.Timespan")
242 return "\n".join(lines)
244 @property
245 @cached_getter
246 def columns(self) -> Mapping[ColumnTag, str]:
247 """A mapping from `ColumnTag` to field name for all fields in this
248 element's records (`~collections.abc.Mapping`).
249 """
250 result: dict[ColumnTag, str] = {}
251 for dimension_name, field_name in zip(self.element.dimensions.names, self.dimensions.names):
252 result[DimensionKeyColumnTag(dimension_name)] = field_name
253 for field_name in self.facts.names:
254 result[DimensionRecordColumnTag(self.element.name, field_name)] = field_name
255 if self.element.spatial:
256 result[DimensionRecordColumnTag(self.element.name, "region")] = "region"
257 if self.element.temporal:
258 result[DimensionRecordColumnTag(self.element.name, "timespan")] = "timespan"
259 return result
261 element: DimensionElement
262 """The dimension element these fields correspond to.
264 (`DimensionElement`)
265 """
267 required: NamedValueSet[ddl.FieldSpec]
268 """The required dimension fields of this table.
270 They correspond to the element's required
271 dimensions, in that order, i.e. `DimensionElement.required`
272 (`NamedValueSet` [ `ddl.FieldSpec` ]).
273 """
275 implied: NamedValueSet[ddl.FieldSpec]
276 """The implied dimension fields of this table.
278 They correspond to the element's implied
279 dimensions, in that order, i.e. `DimensionElement.implied`
280 (`NamedValueSet` [ `ddl.FieldSpec` ]).
281 """
283 dimensions: NamedValueSet[ddl.FieldSpec]
284 """The direct and implied dimension fields of this table.
286 They correspond to the element's direct
287 required and implied dimensions, in that order, i.e.
288 `DimensionElement.dimensions` (`NamedValueSet` [ `ddl.FieldSpec` ]).
289 """
291 facts: NamedValueSet[ddl.FieldSpec]
292 """The standard fields of this table that do not correspond to dimensions.
294 (`NamedValueSet` [ `ddl.FieldSpec` ]).
296 This is equivalent to ``standard - dimensions`` (but possibly in a
297 different order).
298 """
300 standard: NamedValueSet[ddl.FieldSpec]
301 """All standard fields that are expected to have the same form.
303 They are expected to have the same form in all
304 databases; this is all fields other than those that represent a region
305 and/or timespan (`NamedValueSet` [ `ddl.FieldSpec` ]).
306 """
308 names: tuple[str, ...]
309 """The names of all fields in the specification (`tuple` [ `str` ]).
311 This includes "region" and/or "timespan" if `element` is spatial and/or
312 temporal (respectively). The actual database representation of these
313 quantities may involve multiple fields (or even fields only on a different
314 table), but the Python representation of those rows (i.e. `DimensionRecord`
315 instances) will always contain exactly these fields.
316 """