Coverage for python/lsst/daf/butler/registry/queries/_query_backend.py: 37%

83 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-06 01:32 -0800

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/>. 

27from __future__ import annotations 

28 

29__all__ = ("QueryBackend",) 

30 

31from abc import abstractmethod 

32from collections.abc import Iterable, Mapping, Sequence, Set 

33from contextlib import AbstractContextManager 

34from typing import TYPE_CHECKING, Any, Generic, TypeVar 

35 

36from lsst.daf.relation import ( 

37 BinaryOperationRelation, 

38 ColumnTag, 

39 LeafRelation, 

40 MarkerRelation, 

41 Relation, 

42 UnaryOperationRelation, 

43) 

44 

45from ..._collection_type import CollectionType 

46from ..._column_tags import DatasetColumnTag, DimensionKeyColumnTag 

47from ..._dataset_type import DatasetType 

48from ..._exceptions import MissingDatasetTypeError 

49from ..._exceptions_legacy import DatasetTypeError 

50from ...dimensions import DimensionGroup, DimensionRecordSet, DimensionUniverse 

51from ..wildcards import CollectionWildcard 

52from ._query_context import QueryContext 

53 

54if TYPE_CHECKING: 

55 from ..interfaces import CollectionRecord 

56 

57 

58_C = TypeVar("_C", bound=QueryContext) 

59 

60 

61class QueryBackend(Generic[_C]): 

62 """An interface for constructing and evaluating the 

63 `~lsst.daf.relation.Relation` objects that comprise registry queries. 

64 

65 This ABC is expected to have a concrete subclass for each concrete registry 

66 type, and most subclasses will be paired with a `QueryContext` subclass. 

67 See `QueryContext` for the division of responsibilities between these two 

68 interfaces. 

69 """ 

70 

71 @property 

72 @abstractmethod 

73 def universe(self) -> DimensionUniverse: 

74 """Definition of all dimensions and dimension elements for this 

75 registry (`DimensionUniverse`). 

76 """ 

77 raise NotImplementedError() 

78 

79 @abstractmethod 

80 def caching_context(self) -> AbstractContextManager[None]: 

81 """Enable caching of collection records and summaries for the duration 

82 of the returned context manager. 

83 """ 

84 raise NotImplementedError() 

85 

86 def context(self) -> _C: 

87 """Return a context manager that can be used to execute queries with 

88 this backend. 

89 

90 Returns 

91 ------- 

92 context : `QueryContext` 

93 Context manager that manages state and connections needed to 

94 execute queries. 

95 """ 

96 raise NotImplementedError() 

97 

98 @abstractmethod 

99 def get_collection_name(self, key: Any) -> str: 

100 """Return the collection name associated with a collection primary key 

101 value. 

102 

103 Parameters 

104 ---------- 

105 key : `~typing.Any` 

106 Collection primary key value. 

107 

108 Returns 

109 ------- 

110 name : `str` 

111 Collection name. 

112 """ 

113 raise NotImplementedError() 

114 

115 @abstractmethod 

116 def resolve_collection_wildcard( 

117 self, 

118 expression: Any, 

119 *, 

120 collection_types: Set[CollectionType] = CollectionType.all(), 

121 flatten_chains: bool = True, 

122 include_chains: bool | None = None, 

123 ) -> list[CollectionRecord]: 

124 """Return the collection records that match a wildcard expression. 

125 

126 Parameters 

127 ---------- 

128 expression : `~typing.Any` 

129 Names and/or patterns for collections; will be passed to 

130 `CollectionWildcard.from_expression`. 

131 collection_types : `collections.abc.Set` [ `CollectionType` ], optional 

132 If provided, only yield collections of these types. 

133 flatten_chains : `bool`, optional 

134 If `True` (default) recursively yield the child collections of 

135 `~CollectionType.CHAINED` collections. 

136 include_chains : `bool`, optional 

137 If `False`, return records for `~CollectionType.CHAINED` 

138 collections themselves. The default is the opposite of 

139 ``flattenChains``: either return records for CHAINED collections or 

140 their children, but not both. 

141 

142 Returns 

143 ------- 

144 records : `list` [ `CollectionRecord` ] 

145 Matching collection records. 

146 """ 

147 raise NotImplementedError() 

148 

149 @abstractmethod 

150 def resolve_dataset_type_wildcard( 

151 self, 

152 expression: Any, 

153 missing: list[str] | None = None, 

154 explicit_only: bool = False, 

155 ) -> list[DatasetType]: 

156 """Return the dataset types that match a wildcard expression. 

157 

158 Parameters 

159 ---------- 

160 expression : `~typing.Any` 

161 Names and/or patterns for dataset types; will be passed to 

162 `DatasetTypeWildcard.from_expression`. 

163 missing : `list` of `str`, optional 

164 String dataset type names that were explicitly given (i.e. not 

165 regular expression patterns) but not found will be appended to this 

166 list, if it is provided. 

167 explicit_only : `bool`, optional 

168 If `True`, require explicit `DatasetType` instances or `str` names, 

169 with `re.Pattern` instances deprecated and ``...`` prohibited. 

170 

171 Returns 

172 ------- 

173 dataset_types : `list` [ `DatasetType` ] 

174 A list of resolved dataset types. 

175 """ 

176 raise NotImplementedError() 

177 

178 def resolve_single_dataset_type_wildcard( 

179 self, 

180 expression: Any, 

181 explicit_only: bool = False, 

182 ) -> DatasetType: 

183 """Return a single dataset type that matches a wildcard expression. 

184 

185 Parameters 

186 ---------- 

187 expression : `~typing.Any` 

188 Names and/or patterns for the dataset type; will be passed to 

189 `DatasetTypeWildcard.from_expression`. 

190 explicit_only : `bool`, optional 

191 If `True`, require explicit `DatasetType` instances or `str` names, 

192 with `re.Pattern` instances deprecated and ``...`` prohibited. 

193 

194 Returns 

195 ------- 

196 single : `DatasetType` 

197 The matched dataset type. 

198 """ 

199 missing: list[str] = [] 

200 matching = self.resolve_dataset_type_wildcard( 

201 expression, missing=missing, explicit_only=explicit_only 

202 ) 

203 if not matching: 

204 if missing: 

205 raise MissingDatasetTypeError( 

206 "\n".join( 

207 f"Dataset type {t!r} is not registered, so no instances of it can exist." 

208 for t in missing 

209 ) 

210 ) 

211 else: 

212 raise MissingDatasetTypeError( 

213 f"No registered dataset types matched expression {expression!r}, " 

214 "so no datasets will be found." 

215 ) 

216 if len(matching) > 1: 

217 raise DatasetTypeError( 

218 f"Expression {expression!r} matched multiple parent dataset types: " 

219 f"{[t.name for t in matching]}, but only one is allowed." 

220 ) 

221 (single_parent,) = matching 

222 if missing: 

223 raise DatasetTypeError( 

224 f"Expression {expression!r} appears to involve multiple dataset types, even though only " 

225 f"one ({single_parent.name}) is registered, and only one is allowed here." 

226 ) 

227 return single_parent 

228 

229 @abstractmethod 

230 def filter_dataset_collections( 

231 self, 

232 dataset_types: Iterable[DatasetType], 

233 collections: Sequence[CollectionRecord], 

234 *, 

235 governor_constraints: Mapping[str, Set[str]], 

236 rejections: list[str] | None = None, 

237 ) -> dict[DatasetType, list[CollectionRecord]]: 

238 """Filter a sequence of collections to those for which a dataset query 

239 might succeed. 

240 

241 Parameters 

242 ---------- 

243 dataset_types : `~collections.abc.Iterable` [ `DatasetType` ] 

244 Dataset types that are being queried. Must include only parent 

245 or standalone dataset types, not components. 

246 collections : `~collections.abc.Sequence` [ `CollectionRecord` ] 

247 Sequence of collections that will be searched. 

248 governor_constraints : `~collections.abc.Mapping` [ `str`, \ 

249 `~collections.abc.Set` [ `str` ] ], optional 

250 Constraints imposed by other aspects of the query on governor 

251 dimensions; collections inconsistent with these constraints will be 

252 skipped. 

253 rejections : `list` [ `str` ], optional 

254 If not `None`, a `list` that diagnostic messages will be appended 

255 to, for any collection that matches ``collections`` that is not 

256 returned. At least one message is guaranteed whenever the result 

257 is empty. 

258 

259 Returns 

260 ------- 

261 dataset_collections : `dict` [ `DatasetType`, \ 

262 `list` [ `CollectionRecord` ] ] 

263 The collections to search for each dataset. The dictionary's keys 

264 are always exactly ``dataset_types`` (in the same order), and each 

265 nested `list` of collections is ordered consistently with the 

266 given ``collections``. 

267 

268 Notes 

269 ----- 

270 This method accepts multiple dataset types and multiple collections at 

271 once to enable implementations to batch up the fetching of summary 

272 information needed to relate them. 

273 """ 

274 raise NotImplementedError() 

275 

276 def resolve_dataset_collections( 

277 self, 

278 dataset_type: DatasetType, 

279 collections: CollectionWildcard, 

280 *, 

281 governor_constraints: Mapping[str, Set[str]], 

282 rejections: list[str] | None = None, 

283 collection_types: Set[CollectionType] = CollectionType.all(), 

284 allow_calibration_collections: bool = False, 

285 ) -> list[CollectionRecord]: 

286 """Resolve the sequence of collections to query for a dataset type. 

287 

288 Parameters 

289 ---------- 

290 dataset_type : `DatasetType` 

291 Dataset type to be queried in the returned collections. 

292 collections : `CollectionWildcard` 

293 Expression for the collections to be queried. 

294 governor_constraints : `~collections.abc.Mapping` [ `str`, \ 

295 `~collections.abc.Set` ], optional 

296 Constraints imposed by other aspects of the query on governor 

297 dimensions; collections inconsistent with these constraints will be 

298 skipped. 

299 rejections : `list` [ `str` ], optional 

300 If not `None`, a `list` that diagnostic messages will be appended 

301 to, for any collection that matches ``collections`` that is not 

302 returned. At least one message is guaranteed whenever the result 

303 is empty. 

304 collection_types : `~collections.abc.Set` [ `CollectionType` ], \ 

305 optional 

306 Collection types to consider when resolving the collection 

307 expression. 

308 allow_calibration_collections : `bool`, optional 

309 If `False`, skip (with a ``rejections`` message) any calibration 

310 collections that match ``collections`` are not given explicitly by 

311 name, and raise `NotImplementedError` for any calibration 

312 collection that is given explicitly. This is a temporary option 

313 that will be removed when the query system can handle temporal 

314 joins involving calibration collections. 

315 

316 Returns 

317 ------- 

318 records : `list` [ `CollectionRecord` ] 

319 A new list of `CollectionRecord` instances, for collections that 

320 both match ``collections`` and may have datasets of the given type. 

321 

322 Notes 

323 ----- 

324 This is a higher-level driver for `resolve_collection_wildcard` and 

325 `filter_dataset_collections` that is mostly concerned with handling 

326 queries against `~Collection.Type.CALIBRATION` collections that aren't 

327 fully supported yet. Once that support improves, this method may be 

328 removed. 

329 """ 

330 if collections == CollectionWildcard() and collection_types == CollectionType.all(): 

331 collection_types = {CollectionType.RUN} 

332 explicit_collections = frozenset(collections.strings) 

333 matching_collection_records = self.resolve_collection_wildcard( 

334 collections, collection_types=collection_types 

335 ) 

336 ((_, filtered_collection_records),) = self.filter_dataset_collections( 

337 [dataset_type], 

338 matching_collection_records, 

339 governor_constraints=governor_constraints, 

340 rejections=rejections, 

341 ).items() 

342 if not allow_calibration_collections: 

343 supported_collection_records: list[CollectionRecord] = [] 

344 for record in filtered_collection_records: 

345 if record.type is CollectionType.CALIBRATION: 

346 # If collection name was provided explicitly then raise, 

347 # since this is a kind of query we don't support yet; 

348 # otherwise collection is a part of a chained one or regex 

349 # match, and we skip it to not break queries of other 

350 # included collections. 

351 if record.name in explicit_collections: 

352 raise NotImplementedError( 

353 f"Query for dataset type {dataset_type.name!r} in CALIBRATION-type " 

354 f"collection {record.name!r} is not yet supported." 

355 ) 

356 else: 

357 if rejections is not None: 

358 rejections.append( 

359 f"Not searching for dataset {dataset_type.name!r} in CALIBRATION " 

360 f"collection {record.name!r} because calibration queries aren't fully " 

361 "implemented; this is not an error only because the query structure " 

362 "implies that searching this collection may be incidental." 

363 ) 

364 supported_collection_records.append(record) 

365 else: 

366 supported_collection_records.append(record) 

367 else: 

368 supported_collection_records = filtered_collection_records 

369 if not supported_collection_records and rejections is not None and not rejections: 

370 rejections.append(f"No collections to search matching expression {collections!r}.") 

371 return supported_collection_records 

372 

373 def make_doomed_dataset_relation( 

374 self, 

375 dataset_type: DatasetType, 

376 columns: Set[str], 

377 messages: Iterable[str], 

378 context: _C, 

379 ) -> Relation: 

380 """Construct a relation that represents a doomed query for datasets. 

381 

382 Parameters 

383 ---------- 

384 dataset_type : `DatasetType` 

385 Dataset type being queried. 

386 columns : `~collections.abc.Set` [ `str` ] 

387 Dataset columns to include (dimension key columns are always 

388 included). See `make_dataset_query_relation` for allowed values. 

389 messages : `~collections.abc.Iterable` [ `str` ] 

390 Diagnostic messages that explain why the query is doomed to yield 

391 no rows. 

392 context : `QueryContext` 

393 Context that manages per-query state. 

394 

395 Returns 

396 ------- 

397 relation : `lsst.daf.relation.Relation` 

398 Relation with the requested columns and no rows. 

399 """ 

400 column_tags: set[ColumnTag] = set(DimensionKeyColumnTag.generate(dataset_type.dimensions.required)) 

401 column_tags.update(DatasetColumnTag.generate(dataset_type.name, columns)) 

402 return context.preferred_engine.make_doomed_relation(columns=column_tags, messages=list(messages)) 

403 

404 @abstractmethod 

405 def resolve_governor_constraints( 

406 self, dimensions: DimensionGroup, constraints: Mapping[str, Set[str]] 

407 ) -> Mapping[str, Set[str]]: 

408 """Resolve governor dimension constraints provided by user input to 

409 a query against the content in the `Registry`. 

410 

411 Parameters 

412 ---------- 

413 dimensions : `DimensionGroup` 

414 Dimensions that bound the governor dimensions to consider (via 

415 ``dimensions.governors``, more specifically). 

416 constraints : `~collections.abc.Mapping` [ `str`, \ 

417 `~collections.abc.Set` [ `str` ] ] 

418 Constraints from user input to the query (e.g. from data IDs and 

419 string expression predicates). 

420 

421 Returns 

422 ------- 

423 resolved : `~collections.abc.Mapping` [ `str`, \ 

424 `~collections.abc.Set` [ `str` ] ] 

425 A shallow copy of ``constraints`` with keys equal to 

426 ``dimensions.governors.names`` and value sets constrained by the 

427 Registry content if they were not already in ``constraints``. 

428 

429 Raises 

430 ------ 

431 DataIdValueError 

432 Raised if ``constraints`` includes governor dimension values that 

433 are not present in the `Registry`. 

434 """ 

435 raise NotImplementedError() 

436 

437 @abstractmethod 

438 def get_dimension_record_cache(self, element_name: str) -> DimensionRecordSet | None: 

439 """Return a local cache of all `DimensionRecord` objects for a 

440 dimension element, fetching it if necessary. 

441 

442 Parameters 

443 ---------- 

444 element_name : `str` 

445 Name of the dimension element. 

446 

447 Returns 

448 ------- 

449 cache : `~collections.abc.Mapping` [ `DataCoordinate`, \ 

450 `DimensionRecord` ] or `None` 

451 Mapping from data ID to dimension record, or `None` if this 

452 element's records are never cached. 

453 """ 

454 raise NotImplementedError() 

455 

456 def extract_dimension_relationships(self, relation: Relation) -> set[frozenset[str]]: 

457 """Extract the dimension key relationships encoded in a relation tree. 

458 

459 Parameters 

460 ---------- 

461 relation : `Relation` 

462 Relation tree to process. 

463 

464 Returns 

465 ------- 

466 relationships : `set` [ `frozenset` [ `str` ] ] 

467 Set of sets of dimension names, where each inner set represents a 

468 relationship between dimensions. 

469 

470 Notes 

471 ----- 

472 Dimension relationships include both many-to-one implied dependencies 

473 and many-to-many joins backed by "always-join" dimension elements, and 

474 it's important to join in the dimension table that defines a 

475 relationship in any query involving dimensions that are a superset of 

476 that relationship. For example, let's consider a relation tree that 

477 joins dataset existence-check relations for two dataset types, with 

478 dimensions ``{instrument, exposure, detector}`` and ``{instrument, 

479 physical_filter}``. The joined relation appears to have all dimension 

480 keys in its expanded graph present except ``band``, and the system 

481 could easily correct this by joining that dimension in directly. But 

482 it's also missing the ``{instrument, exposure, physical_filter}`` 

483 relationship we'd get from the ``exposure`` dimension's own relation 

484 (``exposure`` implies ``physical_filter``) and the similar 

485 ``{instrument, physical_filter, band}`` relationship from the 

486 ``physical_filter`` dimension relation; we need the relationship logic 

487 to recognize that those dimensions need to be joined in as well in 

488 order for the full relation to have rows that represent valid data IDs. 

489 

490 The implementation of this method relies on the assumption that 

491 `LeafRelation` objects always have rows that are consistent with all 

492 defined relationships (i.e. are valid data IDs). This is true for not 

493 just dimension relations themselves, but anything created from queries 

494 based on them, including datasets and query results. It is possible to 

495 construct `LeafRelation` objects that don't satisfy this criteria (e.g. 

496 when accepting in user-provided data IDs), and in this case 

497 higher-level guards or warnings must be provided.`` 

498 """ 

499 return { 

500 frozenset( 

501 tag.dimension 

502 for tag in DimensionKeyColumnTag.filter_from(leaf_relation.columns & relation.columns) 

503 ) 

504 for leaf_relation in self._extract_leaf_relations(relation).values() 

505 } 

506 

507 def _extract_leaf_relations(self, relation: Relation) -> dict[str, LeafRelation]: 

508 """Recursively extract leaf relations from a relation tree. 

509 

510 Parameters 

511 ---------- 

512 relation : `Relation` 

513 Tree to process. 

514 

515 Returns 

516 ------- 

517 leaves : `dict` [ `str`, `LeafRelation` ] 

518 Leaf relations, keyed and deduplicated by name. 

519 """ 

520 match relation: 

521 case LeafRelation() as leaf: 

522 return {leaf.name: leaf} 

523 case UnaryOperationRelation(target=target): 

524 return self._extract_leaf_relations(target) 

525 case BinaryOperationRelation(lhs=lhs, rhs=rhs): 

526 return self._extract_leaf_relations(lhs) | self._extract_leaf_relations(rhs) 

527 case MarkerRelation(target=target): 

528 return self._extract_leaf_relations(target) 

529 raise AssertionError("Match should be exhaustive and all branches should return.")