Coverage for python / lsst / daf / butler / dimensions / _database.py: 46%

117 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-28 08:36 +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 software is dual licensed under the GNU General Public License and also 

10# under a 3-clause BSD license. Recipients may choose which of these licenses 

11# to use; please see the files gpl-3.0.txt and/or bsd_license.txt, 

12# respectively. If you choose the GPL option then the following text applies 

13# (but note that there is still no warranty even if you opt for BSD instead): 

14# 

15# This program is free software: you can redistribute it and/or modify 

16# it under the terms of the GNU General Public License as published by 

17# the Free Software Foundation, either version 3 of the License, or 

18# (at your option) any later version. 

19# 

20# This program is distributed in the hope that it will be useful, 

21# but WITHOUT ANY WARRANTY; without even the implied warranty of 

22# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

23# GNU General Public License for more details. 

24# 

25# You should have received a copy of the GNU General Public License 

26# along with this program. If not, see <http://www.gnu.org/licenses/>. 

27 

28from __future__ import annotations 

29 

30__all__ = ( 

31 "DatabaseDimension", 

32 "DatabaseDimensionCombination", 

33 "DatabaseDimensionElement", 

34 "DatabaseTopologicalFamily", 

35) 

36 

37from collections.abc import Iterable, Mapping, Set 

38from types import MappingProxyType 

39from typing import TYPE_CHECKING 

40 

41from lsst.utils.classes import cached_getter 

42 

43from .._named import NamedValueAbstractSet, NamedValueSet 

44from .._topology import TopologicalFamily, TopologicalRelationshipEndpoint, TopologicalSpace 

45from ._elements import Dimension, DimensionCombination, DimensionElement, KeyColumnSpec, MetadataColumnSpec 

46from .construction import DimensionConstructionBuilder, DimensionConstructionVisitor 

47 

48if TYPE_CHECKING: 

49 from ..queries.tree import DimensionFieldReference 

50 from ._governor import GovernorDimension 

51 from ._group import DimensionGroup 

52 

53 

54class DatabaseTopologicalFamily(TopologicalFamily): 

55 """Database topological family implementation. 

56 

57 A `TopologicalFamily` implementation for the `DatabaseDimension` and 

58 `DatabaseDimensionCombination` objects that have direct database 

59 representations. 

60 

61 Parameters 

62 ---------- 

63 name : `str` 

64 Name of the family. 

65 space : `TopologicalSpace` 

66 Space in which this family's regions live. 

67 members : `NamedValueAbstractSet` [ `DimensionElement` ] 

68 The members of this family, ordered according to the priority used 

69 in `choose` (first-choice member first). 

70 """ 

71 

72 def __init__( 

73 self, 

74 name: str, 

75 space: TopologicalSpace, 

76 *, 

77 members: NamedValueAbstractSet[DimensionElement], 

78 ): 

79 super().__init__(name, space) 

80 self.members = members 

81 

82 def choose(self, dimensions: DimensionGroup) -> DimensionElement: 

83 # Docstring inherited from TopologicalFamily. 

84 for member in self.members: 

85 if member.name in dimensions.elements: 

86 return member 

87 raise RuntimeError(f"No recognized endpoints for {self.name} in {dimensions}.") 

88 

89 @property 

90 @cached_getter 

91 def governor(self) -> GovernorDimension: 

92 """Return `GovernorDimension` common to all members of this family. 

93 

94 (`GovernorDimension`). 

95 """ 

96 governors = {m.governor for m in self.members} 

97 if None in governors: 

98 raise RuntimeError( 

99 f"Bad {self.space.name} family definition {self.name}: at least one member " 

100 f"in {self.members} has no GovernorDimension dependency." 

101 ) 

102 try: 

103 (result,) = governors 

104 except ValueError: 

105 raise RuntimeError( 

106 f"Bad {self.space.name} family definition {self.name}: multiple governors {governors} " 

107 f"in {self.members}." 

108 ) from None 

109 return result # type: ignore 

110 

111 def make_column_reference(self, endpoint: TopologicalRelationshipEndpoint) -> DimensionFieldReference: 

112 # Docstring inherited from TopologicalFamily. 

113 from ..queries.tree import DimensionFieldReference 

114 

115 assert isinstance(endpoint, DimensionElement) 

116 return DimensionFieldReference( 

117 element=endpoint, 

118 field=("region" if self.space is TopologicalSpace.SPATIAL else "timespan"), 

119 ) 

120 

121 members: NamedValueAbstractSet[DimensionElement] 

122 """The members of this family, ordered according to the priority used in 

123 `choose` (first-choice member first). 

124 """ 

125 

126 

127class DatabaseTopologicalFamilyConstructionVisitor(DimensionConstructionVisitor): 

128 """A construction visitor for `DatabaseTopologicalFamily`. 

129 

130 This visitor depends on (and is thus visited after) its members. 

131 

132 Parameters 

133 ---------- 

134 space : `TopologicalSpace` 

135 Space in which this family's regions live. 

136 members : `~collections.abc.Iterable` [ `str` ] 

137 The names of the members of this family, ordered according to the 

138 priority used in `choose` (first-choice member first). 

139 """ 

140 

141 def __init__(self, space: TopologicalSpace, members: Iterable[str]): 

142 self._space = space 

143 self._members = tuple(members) 

144 

145 def has_dependencies_in(self, others: Set[str]) -> bool: 

146 # Docstring inherited from DimensionConstructionVisitor. 

147 return not others.isdisjoint(self._members) 

148 

149 def visit(self, name: str, builder: DimensionConstructionBuilder) -> None: 

150 # Docstring inherited from DimensionConstructionVisitor. 

151 members = NamedValueSet(builder.elements[member_name] for member_name in self._members) 

152 family = DatabaseTopologicalFamily(name, self._space, members=members.freeze()) 

153 builder.topology[self._space].add(family) 

154 for member in members: 

155 assert isinstance(member, DatabaseDimension | DatabaseDimensionCombination) 

156 other = member._topology.setdefault(self._space, family) 

157 if other is not family: 

158 raise RuntimeError( 

159 f"{member.name} is declared to be a member of (at least) two " 

160 f"{self._space.name} families: {other.name} and {family.name}." 

161 ) 

162 

163 

164class DatabaseDimensionElement(DimensionElement): 

165 """An intermediate base class for `DimensionElement` database classes. 

166 

167 Instances of these element classes map directly to a database table or 

168 query. 

169 

170 Parameters 

171 ---------- 

172 name : `str` 

173 Name of the dimension. 

174 implied : `NamedValueAbstractSet` [ `Dimension` ] 

175 Other dimensions whose keys are included in this dimension's (logical) 

176 table as foreign keys. 

177 metadata_columns : `NamedValueAbstractSet` [ `MetadataColumnSpec` ] 

178 Field specifications for all non-key fields in this dimension's table. 

179 is_cached : `bool` 

180 Whether this element's records should be persistently cached in the 

181 client. 

182 doc : `str` 

183 Extended description of this element. 

184 """ 

185 

186 def __init__( 

187 self, 

188 name: str, 

189 *, 

190 implied: NamedValueAbstractSet[Dimension], 

191 metadata_columns: NamedValueAbstractSet[MetadataColumnSpec], 

192 is_cached: bool, 

193 doc: str, 

194 ): 

195 self._name = name 

196 self._implied = implied 

197 self._metadata_columns = metadata_columns 

198 self._topology: dict[TopologicalSpace, DatabaseTopologicalFamily] = {} 

199 self._is_cached = is_cached 

200 self._doc = doc 

201 

202 @property 

203 def name(self) -> str: 

204 # Docstring inherited from TopologicalRelationshipEndpoint. 

205 return self._name 

206 

207 @property 

208 def implied(self) -> NamedValueAbstractSet[Dimension]: 

209 # Docstring inherited from DimensionElement. 

210 return self._implied 

211 

212 @property 

213 def metadata_columns(self) -> NamedValueAbstractSet[MetadataColumnSpec]: 

214 # Docstring inherited from DimensionElement. 

215 return self._metadata_columns 

216 

217 @property 

218 def is_cached(self) -> bool: 

219 # Docstring inherited. 

220 return self._is_cached 

221 

222 @property 

223 def documentation(self) -> str: 

224 # Docstring inherited from DimensionElement. 

225 return self._doc 

226 

227 @property 

228 def topology(self) -> Mapping[TopologicalSpace, DatabaseTopologicalFamily]: 

229 # Docstring inherited from TopologicalRelationshipEndpoint 

230 return MappingProxyType(self._topology) 

231 

232 @property 

233 def spatial(self) -> DatabaseTopologicalFamily | None: 

234 # Docstring inherited from TopologicalRelationshipEndpoint 

235 return self.topology.get(TopologicalSpace.SPATIAL) 

236 

237 @property 

238 def temporal(self) -> DatabaseTopologicalFamily | None: 

239 # Docstring inherited from TopologicalRelationshipEndpoint 

240 return self.topology.get(TopologicalSpace.TEMPORAL) 

241 

242 

243class DatabaseDimension(Dimension, DatabaseDimensionElement): 

244 """A `Dimension` class that maps directly to a database table or query. 

245 

246 Parameters 

247 ---------- 

248 name : `str` 

249 Name of the dimension. 

250 required : `NamedValueSet` [ `Dimension` ] 

251 Other dimensions whose keys are part of the compound primary key for 

252 this dimension's (logical) table, as well as references to their own 

253 tables. The ``required`` parameter does not include ``self`` (it 

254 can't, of course), but the corresponding attribute does - it is added 

255 by the constructor. 

256 implied : `NamedValueAbstractSet` [ `Dimension` ] 

257 Other dimensions whose keys are included in this dimension's (logical) 

258 table as foreign keys. 

259 metadata_columns : `NamedValueAbstractSet` [ `MetadataColumnSpec` ] 

260 Field specifications for all non-key fields in this dimension's table. 

261 unique_keys : `NamedValueAbstractSet` [ `KeyColumnSpec` ] 

262 Fields that can each be used to uniquely identify this dimension (given 

263 values for all required dimensions). The first of these is used as 

264 (part of) this dimension's table's primary key, while others are used 

265 to define unique constraints. 

266 implied_union_target : `str` or `None` 

267 If not `None`, the name of an element whose implied values for 

268 this element form the set of allowable values. 

269 is_cached : `bool` 

270 Whether this element's records should be persistently cached in the 

271 client. 

272 doc : `str` 

273 Extended description of this element. 

274 

275 Notes 

276 ----- 

277 `DatabaseDimension` objects may belong to a `TopologicalFamily`, but it is 

278 the responsibility of `DatabaseTopologicalFamilyConstructionVisitor` to 

279 update the `~TopologicalRelationshipEndpoint.topology` attribute of their 

280 members. 

281 """ 

282 

283 def __init__( 

284 self, 

285 name: str, 

286 *, 

287 required: NamedValueSet[Dimension], 

288 implied: NamedValueAbstractSet[Dimension], 

289 metadata_columns: NamedValueAbstractSet[MetadataColumnSpec], 

290 unique_keys: NamedValueAbstractSet[KeyColumnSpec], 

291 implied_union_target: str | None, 

292 is_cached: bool, 

293 doc: str, 

294 ): 

295 super().__init__( 

296 name, implied=implied, metadata_columns=metadata_columns, is_cached=is_cached, doc=doc 

297 ) 

298 required.add(self) 

299 self._required = required.freeze() 

300 self._unique_keys = unique_keys 

301 self._implied_union_target = implied_union_target 

302 

303 @property 

304 def required(self) -> NamedValueAbstractSet[Dimension]: 

305 # Docstring inherited from DimensionElement. 

306 return self._required 

307 

308 @property 

309 def unique_keys(self) -> NamedValueAbstractSet[KeyColumnSpec]: 

310 # Docstring inherited from Dimension. 

311 return self._unique_keys 

312 

313 @property 

314 def implied_union_target(self) -> DimensionElement | None: 

315 # Docstring inherited from DimensionElement. 

316 # This is a bit encapsulation-breaking, but it'll all be cleaned up 

317 # soon when we get rid of the storage objects entirely. 

318 return self.universe[self._implied_union_target] if self._implied_union_target is not None else None 

319 

320 

321class DatabaseDimensionCombination(DimensionCombination, DatabaseDimensionElement): 

322 """A combination class that maps directly to a database table or query. 

323 

324 Parameters 

325 ---------- 

326 name : `str` 

327 Name of the dimension. 

328 required : `NamedValueAbstractSet` [ `Dimension` ] 

329 Dimensions whose keys define the compound primary key for this 

330 combinations's (logical) table, as well as references to their own 

331 tables. 

332 implied : `NamedValueAbstractSet` [ `Dimension` ] 

333 Dimensions whose keys are included in this combinations's (logical) 

334 table as foreign keys. 

335 metadata_columns : `NamedValueAbstractSet` [ `MetadataColumnSpec` ] 

336 Field specifications for all non-key fields in this combination's 

337 table. 

338 is_cached : `bool` 

339 Whether this element's records should be persistently cached in the 

340 client. 

341 always_join : `bool`, optional 

342 If `True`, always include this element in any query or data ID in 

343 which its ``required`` dimensions appear, because it defines a 

344 relationship between those dimensions that must always be satisfied. 

345 populated_by : `Dimension` or `None` 

346 The dimension that this element's records are always inserted, 

347 exported, and imported alongside. 

348 doc : `str` 

349 Extended description of this element. 

350 

351 Notes 

352 ----- 

353 `DatabaseDimensionCombination` objects may belong to a `TopologicalFamily`, 

354 but it is the responsibility of 

355 `DatabaseTopologicalFamilyConstructionVisitor` to update the 

356 `~TopologicalRelationshipEndpoint.topology` attribute of their members. 

357 

358 This class has a lot in common with `DatabaseDimension`, but they are 

359 expected to diverge in future changes, and the only way to make them share 

360 method implementations would be via multiple inheritance. Given the 

361 trivial nature of all of those implementations, this does not seem worth 

362 the drawbacks (particularly the constraints it imposes on constructor 

363 signatures). 

364 """ 

365 

366 def __init__( 

367 self, 

368 name: str, 

369 *, 

370 required: NamedValueAbstractSet[Dimension], 

371 implied: NamedValueAbstractSet[Dimension], 

372 metadata_columns: NamedValueAbstractSet[MetadataColumnSpec], 

373 is_cached: bool, 

374 always_join: bool, 

375 populated_by: Dimension | None, 

376 doc: str, 

377 ): 

378 super().__init__( 

379 name, implied=implied, metadata_columns=metadata_columns, is_cached=is_cached, doc=doc 

380 ) 

381 self._required = required 

382 self._always_join = always_join 

383 self._populated_by = populated_by 

384 

385 @property 

386 def required(self) -> NamedValueAbstractSet[Dimension]: 

387 # Docstring inherited from DimensionElement. 

388 return self._required 

389 

390 @property 

391 def alwaysJoin(self) -> bool: 

392 # Docstring inherited from DimensionElement. 

393 return self._always_join 

394 

395 @property 

396 def defines_relationships(self) -> bool: 

397 # Docstring inherited from DimensionElement. 

398 return self._always_join or bool(self.implied) 

399 

400 @property 

401 def populated_by(self) -> Dimension | None: 

402 # Docstring inherited. 

403 return self._populated_by