Coverage for python/lsst/daf/butler/queries/_query.py: 15%

208 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-10-10 01:29 -0700

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__ = ("Query",) 

31 

32from collections.abc import Iterable, Mapping, Set 

33from types import EllipsisType 

34from typing import Any, final 

35 

36import astropy.table 

37 

38from lsst.utils.iteration import ensure_iterable 

39 

40from .._dataset_type import DatasetType 

41from .._exceptions import DimensionNameError, InvalidQueryError 

42from .._storage_class import StorageClassFactory 

43from ..dimensions import DataCoordinate, DataId, DataIdValue, DimensionGroup 

44from ..registry import DatasetTypeError 

45from ._base import QueryBase 

46from ._data_coordinate_query_results import DataCoordinateQueryResults 

47from ._dataset_query_results import DatasetRefQueryResults 

48from ._dimension_record_query_results import DimensionRecordQueryResults 

49from ._general_query_results import GeneralQueryResults 

50from ._identifiers import IdentifierContext, interpret_identifier 

51from .convert_args import convert_where_args 

52from .driver import QueryDriver 

53from .expression_factory import ExpressionFactory 

54from .predicate_constraints_summary import PredicateConstraintsSummary 

55from .result_specs import ( 

56 DataCoordinateResultSpec, 

57 DatasetRefResultSpec, 

58 DimensionRecordResultSpec, 

59 GeneralResultSpec, 

60) 

61from .tree import ( 

62 ANY_DATASET, 

63 DatasetFieldName, 

64 DatasetFieldReference, 

65 DatasetSearch, 

66 DimensionFieldReference, 

67 DimensionKeyReference, 

68 Predicate, 

69 QueryTree, 

70 make_identity_query_tree, 

71) 

72 

73 

74@final 

75class Query(QueryBase): 

76 """A method-chaining builder for butler queries. 

77 

78 Parameters 

79 ---------- 

80 driver : `~lsst.daf.butler.queries.driver.QueryDriver` 

81 Implementation object that knows how to actually execute queries. 

82 tree : `~lsst.daf.butler.queries.tree.QueryTree`, optional 

83 Description of the query as a tree of joins and column expressions. 

84 Defaults to the result of a call to 

85 `~lsst.daf.butler.queries.tree.make_identity_query_tree`. 

86 

87 Notes 

88 ----- 

89 `Query` objects should never be constructed directly by users; use 

90 `Butler.query <lsst.daf.butler.Butler.query>` instead. 

91 

92 A `Query` object represents the first stage of query construction, in which 

93 constraints and joins are defined (roughly corresponding to the WHERE and 

94 FROM clauses in SQL). The various "results" objects represent the second 

95 (and final) stage, where the columns returned are specified and any sorting 

96 or integer slicing can be applied. Result objects are obtained from the 

97 `data_ids`, `datasets`, and `dimension_records` methods. 

98 

99 `Query` and query-result objects are always immutable (except for caching 

100 information fetched from the database or server), so modifier methods 

101 always return a new object without modifying the current one. 

102 """ 

103 

104 def __init__(self, driver: QueryDriver, tree: QueryTree | None = None): 

105 # __init__ defined here because there are multiple base classes and 

106 # not all define __init__ (and hence inherit object.__init__, which 

107 # just ignores its args). Even if we just delegate to super(), it 

108 # seems less fragile to make it explicit here. 

109 if tree is None: 

110 tree = make_identity_query_tree(driver.universe) 

111 super().__init__(driver, tree) 

112 

113 # If ``_allow_duplicate_overlaps`` is set to `True` then query will be 

114 # allowed to generate non-distinct rows for spatial overlaps. This is 

115 # not a part of public API for now, to be used by graph builder as 

116 # optimization. 

117 self._allow_duplicate_overlaps: bool = False 

118 

119 @property 

120 def constraint_dataset_types(self) -> Set[str]: 

121 """The names of all dataset types joined into the query. 

122 

123 The existence of datasets of these types constrains the data IDs of any 

124 type of result. Fields for these dataset types are also usable in 

125 'where' expressions. 

126 """ 

127 # Note that this includes only dataset type names, not `DatasetType` 

128 # instances; the `DatasetQueryResults` adapter returned by the 

129 # `datasets` method does include `DatasetType` instances, since it is 

130 # in a better position to track and respect any storage class override 

131 # specified. 

132 return self._tree.datasets.keys() 

133 

134 @property 

135 def constraint_dimensions(self) -> DimensionGroup: 

136 """Dimensions currently present in the query, either directly or 

137 indirectly. 

138 

139 This includes dimensions that are present in any joined subquery (such 

140 as a dataset search, materialization, or data ID upload) or `where` 

141 argument, as well as any required or implied dependency of those 

142 dimensions. 

143 """ 

144 return self._tree.dimensions 

145 

146 @property 

147 def expression_factory(self) -> ExpressionFactory: 

148 """A factory for column expressions using overloaded operators. 

149 (`~lsst.daf.butler.queries.expression_factory.ExpressionFactory`). 

150 

151 Notes 

152 ----- 

153 Typically this attribute will be assigned to a single-character local 

154 variable, and then its (dynamic) attributes can be used to obtain 

155 references to columns that can be included in a query:: 

156 

157 with butler.query() as query: 

158 x = query.expression_factory 

159 query = query.where( 

160 x.instrument == "LSSTCam", 

161 x.visit.day_obs > 20240701, 

162 x.any(x.band == "u", x.band == "y"), 

163 ) 

164 

165 As shown above, the returned object also has an 

166 `~lsst.daf.butler.queries.expression_factory.ExpressionFactory.any` 

167 method to create combine expressions with logical OR (as well as 

168 `~lsst.daf.butler.queries.expression_factory.ExpressionFactory.not_` 

169 and 

170 `~lsst.daf.butler.queries.expression_factory.ExpressionFactory.all`, 

171 though the latter is rarely necessary since `where` already combines 

172 its arguments with AND). 

173 

174 Proxies for fields associated with individual datasets but not 

175 dimension records (``dataset_id``, ``ingest_date``, ``run``, 

176 ``collection``, as well as ``timespan`` for 

177 `~lsst.daf.butler.CollectionType.CALIBRATION` collection searches) can 

178 be obtained with dict-like access instead:: 

179 

180 with butler.query() as query: 

181 query = query.order_by(x["raw"].ingest_date) 

182 

183 Expression proxy objects that correspond to scalar columns overload the 

184 standard comparison operators (``==``, ``!=``, ``<``, ``>``, ``<=``, 

185 ``>=``) and provide 

186 `~lsst.daf.butler.queries.expression_factory.ScalarExpressionProxy.in_range`, 

187 `~lsst.daf.butler.queries.expression_factory.ScalarExpressionProxy.in_iterable`, and 

188 `~lsst.daf.butler.queries.expression_factory.ScalarExpressionProxy.in_query` 

189 methods for membership tests. For ``order_by`` contexts, they also have a 

190 `~lsst.daf.butler.queries.expression_factory.ScalarExpressionProxy.desc` 

191 property to indicate that the sort order for that expression should be 

192 reversed. 

193 

194 Proxy objects for 

195 `region <lsst.daf.butler.queries.expression_factory.RegionProxy>` and 

196 `timespan <lsst.daf.butler.queries.expression_factory.TimespanProxy>` 

197 fields have an ``overlaps`` method, and timespans also have 

198 `~lsst.daf.butler.queries.expression_factory.TimespanProxy.begin` and 

199 `~lsst.daf.butler.queries.expression_factory.TimespanProxy.end` 

200 properties to access scalar expression proxies for the bounds. 

201 

202 All proxy objects also have an 

203 `~lsst.daf.butler.queries.expression_factory.ExpressionProxy.is_null` 

204 property. 

205 

206 Literal values can be created by calling 

207 `ExpressionFactory.literal <lsst.daf.butler.queries.expression_factory.ExpressionFactory.literal>`, 

208 but can almost always be created implicitly via overloaded operators 

209 instead. 

210 """ # noqa: W505, long docstrings 

211 return ExpressionFactory(self._driver.universe) 

212 

213 def data_ids( 

214 self, dimensions: DimensionGroup | Iterable[str] | str | None = None 

215 ) -> DataCoordinateQueryResults: 

216 """Return a result object that is a `~lsst.daf.butler.DataCoordinate` 

217 iterable. 

218 

219 Parameters 

220 ---------- 

221 dimensions : `~lsst.daf.butler.DimensionGroup`, `str`, or \ 

222 `~collections.abc.Iterable` [`str`], optional 

223 The dimensions of the data IDs to yield, as either 

224 `~lsst.daf.butler.DimensionGroup` instances or `str` names. Will 

225 be automatically expanded to a complete 

226 `~lsst.daf.butler.DimensionGroup`. These dimensions do not need to 

227 match the query's current dimensions. Default is 

228 `constraint_dimensions`. 

229 

230 Returns 

231 ------- 

232 data_ids : `~lsst.daf.butler.queries.DataCoordinateQueryResults` 

233 Data IDs matching the given query parameters. These are guaranteed 

234 to identify all dimensions (``DataCoordinate.hasFull`` returns 

235 `True`), but will not contain `~lsst.daf.butler.DimensionRecord` 

236 objects (``DataCoordinate.hasRecords`` returns `False`). Call 

237 `~DataCoordinateQueryResults.with_dimension_records` on the 

238 returned object to include dimension records as well. 

239 """ 

240 tree = self._tree 

241 if dimensions is None: 

242 dimensions = self._tree.dimensions 

243 else: 

244 dimensions = self._driver.universe.conform(dimensions) 

245 if not dimensions <= self._tree.dimensions: 

246 tree = tree.join_dimensions(dimensions) 

247 result_spec = DataCoordinateResultSpec( 

248 dimensions=dimensions, 

249 include_dimension_records=False, 

250 allow_duplicate_overlaps=self._allow_duplicate_overlaps, 

251 ) 

252 return DataCoordinateQueryResults(self._driver, tree, result_spec) 

253 

254 def datasets( 

255 self, 

256 dataset_type: str | DatasetType, 

257 collections: str | Iterable[str] | None = None, 

258 *, 

259 find_first: bool = True, 

260 ) -> DatasetRefQueryResults: 

261 """Return a result object that is a `~lsst.daf.butler.DatasetRef` 

262 iterable. 

263 

264 Parameters 

265 ---------- 

266 dataset_type : `str` or `~lsst.daf.butler.DatasetType` 

267 The dataset type to search for. 

268 collections : `str` or `~collections.abc.Iterable` [ `str` ], optional 

269 The collection or collections to search, in order. If not provided 

270 or `None`, and the dataset has not already been joined into the 

271 query, the default collection search path for this butler is used. 

272 find_first : `bool`, optional 

273 If `True` (default), for each result data ID, only yield one 

274 `~lsst.daf.butler.DatasetRef` of each 

275 `~lsst.daf.butler.DatasetType`, from the first collection in 

276 which a dataset of that dataset type appears (according to the 

277 order of ``collections`` passed in). If `True`, ``collections`` 

278 must not be ``...``. 

279 

280 Returns 

281 ------- 

282 refs : `lsst.daf.butler.queries.DatasetRefQueryResults` 

283 Dataset references matching the given query criteria. Nested data 

284 IDs are guaranteed to include values for all implied dimensions 

285 (i.e. ``DataCoordinate.hasFull`` will return `True`), but will not 

286 include dimension records (``DataCoordinate.hasRecords`` will be 

287 `False`) unless 

288 `~.queries.DatasetRefQueryResults.with_dimension_records` is 

289 called on the result object (which returns a new one). 

290 

291 Raises 

292 ------ 

293 lsst.daf.butler.registry.DatasetTypeExpressionError 

294 Raised when the ``dataset_type`` expression is invalid. 

295 lsst.daf.butler.registry.NoDefaultCollectionError 

296 Raised when ``collections`` is `None` and default butler 

297 collections are not defined. 

298 TypeError 

299 Raised when the arguments are incompatible, such as when a 

300 collection wildcard is passed when ``find_first`` is `True` 

301 """ 

302 dataset_type_name, storage_class_name, query = self._join_dataset_search_impl( 

303 dataset_type, collections 

304 ) 

305 dataset_search = query._tree.datasets[dataset_type_name] 

306 spec = DatasetRefResultSpec.model_construct( 

307 dataset_type_name=dataset_type_name, 

308 dimensions=dataset_search.dimensions, 

309 storage_class_name=storage_class_name, 

310 include_dimension_records=False, 

311 find_first=find_first, 

312 allow_duplicate_overlaps=self._allow_duplicate_overlaps, 

313 ) 

314 return DatasetRefQueryResults(self._driver, tree=query._tree, spec=spec) 

315 

316 def dimension_records(self, element: str) -> DimensionRecordQueryResults: 

317 """Return a result object that is a `~lsst.daf.butler.DimensionRecord` 

318 iterable. 

319 

320 Parameters 

321 ---------- 

322 element : `str` 

323 The name of a dimension element to obtain records for. 

324 

325 Returns 

326 ------- 

327 records : `lsst.daf.butler.queries.DimensionRecordQueryResults` 

328 Data IDs matching the given query parameters. 

329 """ 

330 if element not in self._driver.universe: 

331 # Prefer an explicit exception over a KeyError below. 

332 raise DimensionNameError( 

333 f"No such dimension '{element}', available dimensions: " + str(self._driver.universe.elements) 

334 ) 

335 tree = self._tree 

336 if element not in tree.dimensions.elements: 

337 tree = tree.join_dimensions(self._driver.universe[element].minimal_group) 

338 result_spec = DimensionRecordResultSpec( 

339 element=self._driver.universe[element], allow_duplicate_overlaps=self._allow_duplicate_overlaps 

340 ) 

341 return DimensionRecordQueryResults(self._driver, tree, result_spec) 

342 

343 def general( 

344 self, 

345 dimensions: DimensionGroup | Iterable[str], 

346 *names: str, 

347 dimension_fields: Mapping[str, Set[str]] | None = None, 

348 dataset_fields: Mapping[str, Set[DatasetFieldName] | EllipsisType] | None = None, 

349 find_first: bool | None = None, 

350 ) -> GeneralQueryResults: 

351 """Execute query returning general result. 

352 

353 **This is an experimental interface and may change at any time.** 

354 

355 Parameters 

356 ---------- 

357 dimensions : `~lsst.daf.butler.DimensionGroup` or \ 

358 `~collections.abc.Iterable` [ `str` ] 

359 The dimensions that span all fields returned by this query. 

360 *names : `str` 

361 Names of dimensions fields (in "dimension.field" format), dataset 

362 fields (in "dataset_type.field" format) to include in this query. 

363 dimension_fields : `~collections.abc.Mapping` [`str`, \ 

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

365 Dimension record fields included in this query, keyed by dimension 

366 element name. 

367 dataset_fields : `~collections.abc.Mapping` [`str`, \ 

368 `~collections.abc.Set` | ``...`` ], optional 

369 Dataset fields included in this query, the key in the mapping is 

370 dataset type name. Ellipsis (``...``) can be used for value 

371 to include all dataset fields needed to extract 

372 `~lsst.daf.butler.DatasetRef` instances later. 

373 find_first : `bool`, optional 

374 Whether this query requires find-first resolution for a dataset. 

375 This is ignored and can be omitted if the query has no dataset 

376 fields. It must be explicitly set to `False` if there are multiple 

377 dataset types with fields, or if any dataset's ``collections`` 

378 or ``timespan`` fields are included in the results. 

379 

380 Returns 

381 ------- 

382 result : `~lsst.daf.butler.queries.GeneralQueryResults` 

383 Query result that can be iterated over. 

384 

385 Notes 

386 ----- 

387 The dimensions of the returned query are automatically expanded to 

388 include those associated with all dimension and dataset fields; the 

389 ``dimensions`` argument is just the minimal dimensions to return. 

390 """ 

391 if dimension_fields is None: 

392 dimension_fields = {} 

393 if dataset_fields is None: 

394 dataset_fields = {} 

395 # Processing fields from mapping args, processing the special `...` 

396 # wildcard and dropping keys with empty values. 

397 dataset_fields_dict: dict[str, set[DatasetFieldName]] = {} 

398 for dataset_type_name, fields_for_dataset_type in dataset_fields.items(): 

399 if fields_for_dataset_type is ...: 

400 new_fields_for_dataset_type: set[DatasetFieldName] = { 

401 "run", 

402 "dataset_id", 

403 } # all we need for DatasetRefs. 

404 else: 

405 new_fields_for_dataset_type = set(fields_for_dataset_type) 

406 if new_fields_for_dataset_type: 

407 dataset_fields_dict[dataset_type_name] = new_fields_for_dataset_type 

408 dimension_fields_dict = { 

409 element_name: new_fields_for_element 

410 for element_name, fields_for_element in dimension_fields.items() 

411 if (new_fields_for_element := set(fields_for_element)) 

412 } 

413 # Parse all names passed as positional arguments, and start to 

414 # accumulate additional dimension names we'll need in the results. 

415 dimensions = self._driver.universe.conform(dimensions) 

416 context = IdentifierContext(dimensions, set(self._tree.datasets)) 

417 extra_dimension_names: set[str] = set() 

418 for name in names: 

419 identifier = interpret_identifier(context, name) 

420 match identifier: 

421 case DimensionKeyReference(dimension=dimension): 

422 # Could be because someone asked for the key field. 

423 extra_dimension_names.add(dimension.name) 

424 case DimensionFieldReference(element=element, field=field): 

425 dimension_fields_dict.setdefault(element.name, set()).add(field) 

426 case DatasetFieldReference(dataset_type=dataset_type, field=dataset_field): 

427 if dataset_type is ANY_DATASET: 

428 raise InvalidQueryError("Dataset wildcard fields are not supported by Query.general.") 

429 dataset_fields_dict.setdefault(dataset_type, set()).add(dataset_field) 

430 case _: 

431 raise TypeError(f"Unexpected type of identifier ({name}): {identifier}") 

432 # Add more dimension names from the field mappings (including those 

433 # we just populated from args). Also check that the dataset fields 

434 # are consistent with find_first. 

435 for element_name, fields in dimension_fields_dict.items(): 

436 extra_dimension_names.update(self._driver.universe[element_name].minimal_group.names) 

437 for dataset_type_name, fields_for_dataset_type in dataset_fields_dict.items(): 

438 if "collections" in fields_for_dataset_type and find_first is not False: 

439 raise InvalidQueryError( 

440 f"find_first=False must be passed explicitly if {dataset_type_name}.collections " 

441 "is included in query results." 

442 ) 

443 if "timespan" in fields_for_dataset_type and find_first is not False: 

444 raise InvalidQueryError( 

445 f"find_first=False must be passed explicitly if {dataset_type_name}.timespan " 

446 "is included in query results." 

447 ) 

448 try: 

449 dataset_search = self._tree.datasets[dataset_type_name] 

450 except KeyError: 

451 raise InvalidQueryError( 

452 f"A search for dataset type {dataset_type_name!r} must be explicitly joined into the " 

453 "query before including its fields in query results." 

454 ) from None 

455 extra_dimension_names.update(dataset_search.dimensions.names) 

456 if find_first is None: 

457 if dataset_fields_dict: 

458 raise InvalidQueryError( 

459 "find_first must be passed if dataset fields are included in query results." 

460 ) 

461 else: 

462 find_first = False 

463 if find_first and len(dataset_fields_dict) != 1: 

464 raise InvalidQueryError( 

465 "find_first=True is not valid unless exactly one dataset type's fields are requested." 

466 ) 

467 # Combine extra dimensions with the original ones. 

468 dimensions = self._driver.universe.conform(dimensions.names | extra_dimension_names) 

469 # Merge missing dimensions into the tree. 

470 tree = self._tree 

471 if not dimensions <= tree.dimensions: 

472 tree = tree.join_dimensions(dimensions) 

473 result_spec = GeneralResultSpec( 

474 dimensions=dimensions, 

475 dimension_fields=dimension_fields_dict, 

476 dataset_fields=dataset_fields_dict, 

477 find_first=find_first, 

478 allow_duplicate_overlaps=self._allow_duplicate_overlaps, 

479 ) 

480 return GeneralQueryResults(self._driver, tree=tree, spec=result_spec) 

481 

482 def materialize( 

483 self, 

484 *, 

485 dimensions: Iterable[str] | DimensionGroup | None = None, 

486 datasets: Iterable[str] | None = None, 

487 ) -> Query: 

488 """Execute the query, save its results to a temporary location, and 

489 return a new query that represents fetching or joining against those 

490 saved results. 

491 

492 Parameters 

493 ---------- 

494 dimensions : `~collections.abc.Iterable` [ `str` ] or \ 

495 `~lsst.daf.butler.DimensionGroup`, optional 

496 Dimensions to include in the temporary results. Default is to 

497 include all dimensions in the query. 

498 datasets : `~collections.abc.Iterable` [ `str` ], optional 

499 Names of dataset types that should be included in the new query; 

500 default is to include `constraint_dataset_types`. 

501 

502 Returns 

503 ------- 

504 query : `Query` 

505 A new query object whose that represents the materialized rows. 

506 

507 Notes 

508 ----- 

509 Only dimension key columns and (at the discretion of the 

510 implementation) certain dataset columns are actually materialized, 

511 since at this stage we do not know which dataset or dimension record 

512 fields are actually needed in result rows, and these can be joined back 

513 in on the materialized dimension keys. But all constraints on those 

514 dimension keys (including dataset existence) are applied to the 

515 materialized rows. 

516 """ 

517 if datasets is None: 

518 datasets = frozenset(self.constraint_dataset_types) 

519 else: 

520 datasets = frozenset(datasets) 

521 if not (datasets <= self.constraint_dataset_types): 

522 raise InvalidQueryError( 

523 f"Dataset(s) {datasets - self.constraint_dataset_types} are present in the query." 

524 ) 

525 if dimensions is None: 

526 dimensions = self._tree.dimensions 

527 else: 

528 dimensions = self._driver.universe.conform(dimensions) 

529 key = self._driver.materialize( 

530 self._tree, dimensions, datasets, allow_duplicate_overlaps=self._allow_duplicate_overlaps 

531 ) 

532 tree = make_identity_query_tree(self._driver.universe).join_materialization( 

533 key, dimensions=dimensions 

534 ) 

535 for dataset_type_name in datasets: 

536 dataset_search = self._tree.datasets[dataset_type_name] 

537 if not (dataset_search.dimensions <= tree.dimensions): 

538 raise InvalidQueryError( 

539 f"Materialization-backed query has dimensions {tree.dimensions}, which do not " 

540 f"cover the dimensions {dataset_search.dimensions} of dataset {dataset_type_name!r}. " 

541 "Expand the dimensions or drop this dataset type in the arguments to materialize to " 

542 "avoid this error." 

543 ) 

544 tree = tree.join_dataset(dataset_type_name, dataset_search) 

545 return Query(self._driver, tree) 

546 

547 def join_dataset_search( 

548 self, 

549 dataset_type: str | DatasetType, 

550 collections: Iterable[str] | None = None, 

551 ) -> Query: 

552 """Return a new query with a search for a dataset joined in. 

553 

554 Parameters 

555 ---------- 

556 dataset_type : `str` or `~lsst.daf.butler.DatasetType` 

557 Dataset type or name. May not refer to a dataset component. 

558 collections : `~collections.abc.Iterable` [ `str` ], optional 

559 Iterable of collections to search. Order is preserved, but will 

560 not matter if the dataset search is only used as a constraint on 

561 dimensions or if ``find_first=False`` when requesting results. If 

562 not present or `None`, the default collection search path will be 

563 used. 

564 

565 Returns 

566 ------- 

567 query : `Query` 

568 A new query object with dataset columns available and rows 

569 restricted to those consistent with the found data IDs. 

570 

571 Raises 

572 ------ 

573 DatasetTypeError 

574 Raised if given dataset type is inconsistent with the registered 

575 dataset type. 

576 MissingDatasetTypeError 

577 Raised if the dataset type has not been registered and only a 

578 `str` dataset type name was given. 

579 

580 Notes 

581 ----- 

582 This method may require communication with the server unless the 

583 dataset type and collections have already been referenced by the same 

584 query context. 

585 """ 

586 _, _, query = self._join_dataset_search_impl( 

587 dataset_type, collections, allow_storage_class_overrides=False 

588 ) 

589 return query 

590 

591 def join_data_coordinates(self, iterable: Iterable[DataCoordinate]) -> Query: 

592 """Return a new query that joins in an explicit iterable of data IDs. 

593 

594 Parameters 

595 ---------- 

596 iterable : `~collections.abc.Iterable` \ 

597 [`~lsst.daf.butler.DataCoordinate`] 

598 Iterable of `~lsst.daf.butler.DataCoordinate`. All items must have 

599 the same dimensions. Must have at least one item. 

600 

601 Returns 

602 ------- 

603 query : `Query` 

604 A new query object with the data IDs joined in. 

605 """ 

606 rows: set[tuple[DataIdValue, ...]] = set() 

607 dimensions: DimensionGroup | None = None 

608 for data_coordinate in iterable: 

609 if dimensions is None: 

610 dimensions = data_coordinate.dimensions 

611 elif dimensions != data_coordinate.dimensions: 

612 raise InvalidQueryError( 

613 f"Inconsistent dimensions: {dimensions} != {data_coordinate.dimensions}." 

614 ) 

615 rows.add(data_coordinate.required_values) 

616 if dimensions is None: 

617 raise InvalidQueryError("Cannot upload an empty data coordinate set.") 

618 key = self._driver.upload_data_coordinates(dimensions, rows) 

619 return Query( 

620 tree=self._tree.join_data_coordinate_upload(dimensions=dimensions, key=key), 

621 driver=self._driver, 

622 ) 

623 

624 def join_data_coordinate_table(self, table: astropy.table.Table) -> Query: 

625 """Return a new query that joins in an explicit table of data IDs. 

626 

627 Parameters 

628 ---------- 

629 table : `astropy.table.Table` 

630 A table of data IDs to join. Columns must be dimension names, and 

631 columns for dimensions whose values that are implied by others are 

632 ignored. If there is no column for a required dimension is missing 

633 but is fully constrained to a literal by a previous `where` call, a 

634 constant-valued column will be added. 

635 

636 Returns 

637 ------- 

638 query : `Query` 

639 A new query object with the data IDs joined in. 

640 """ 

641 if not len(table): 

642 raise InvalidQueryError("Cannot upload an empty data coordinate set.") 

643 column_names = set(table.colnames) 

644 dimensions = self._driver.universe.conform(column_names) 

645 # To avoid numpy scalar types that will upset SQLAlchemy, we turn the 

646 # columns we care about into lists of regular Python scalars. We do 

647 # this in dimensions.required order so we can zip the values of this 

648 # dict later to make data ID 'required_values' tuples. 

649 column_lists = {d: table[d].data.tolist() if d in column_names else None for d in dimensions.required} 

650 if not column_names.issuperset(dimensions.required): 

651 # If columns are missing, see if they're fixed by a previous 

652 # `where` call or equivalent. 

653 predicate_summary = PredicateConstraintsSummary(self._tree.predicate) 

654 missing = dimensions.required - column_names 

655 provided_by_predicate = predicate_summary.constraint_data_id.keys() & missing 

656 missing -= provided_by_predicate 

657 if missing: 

658 raise InvalidQueryError(f"Data coordinate table is missing required dimension(s) {missing}.") 

659 if provided_by_predicate: 

660 for k in provided_by_predicate: 

661 column_lists[k] = [predicate_summary.constraint_data_id[k]] * len(table) 

662 key = self._driver.upload_data_coordinates(dimensions, zip(*column_lists.values(), strict=True)) 

663 return Query( 

664 tree=self._tree.join_data_coordinate_upload(dimensions=dimensions, key=key), 

665 driver=self._driver, 

666 ) 

667 

668 def join_dimensions(self, dimensions: Iterable[str] | DimensionGroup) -> Query: 

669 """Return a new query that joins the logical tables for additional 

670 dimensions. 

671 

672 Parameters 

673 ---------- 

674 dimensions : `~collections.abc.Iterable` [ `str` ] or \ 

675 `~lsst.daf.butler.DimensionGroup` 

676 Names of dimensions to join in. 

677 

678 Returns 

679 ------- 

680 query : `Query` 

681 A new query object with the dimensions joined in. 

682 

683 Notes 

684 ----- 

685 Dimensions are automatically joined in whenever needed, so this method 

686 should rarely need to be called directly. 

687 """ 

688 dimensions = self._driver.universe.conform(dimensions) 

689 return Query(tree=self._tree.join_dimensions(dimensions), driver=self._driver) 

690 

691 def where( 

692 self, 

693 *args: str | Predicate | DataId, 

694 bind: Mapping[str, Any] | None = None, 

695 **kwargs: Any, 

696 ) -> Query: 

697 """Return a query with a boolean-expression filter on its rows. 

698 

699 Parameters 

700 ---------- 

701 *args 

702 Constraints to apply, combined with logical AND. Arguments may be 

703 `str` expressions to parse, 

704 `~lsst.daf.butler.queries.tree.Predicate` objects (these are 

705 typically constructed via `expression_factory`) or data IDs. 

706 bind : `~collections.abc.Mapping` 

707 Mapping from string identifier appearing in a string expression to 

708 a literal value that should be substituted for it. This is 

709 recommended instead of embedding literals directly into the 

710 expression, especially for strings, timespans, or other types where 

711 quoting or formatting is nontrivial. 

712 **kwargs 

713 Data ID key value pairs that extend and override any present in 

714 ``*args``. 

715 

716 Returns 

717 ------- 

718 query : `Query` 

719 A new query object with the given row filters (as well as any 

720 already present in ``self``). All row filters are combined with 

721 logical AND. 

722 

723 Notes 

724 ----- 

725 Expressions referring to dimensions or dimension elements are resolved 

726 automatically. References to dataset fields (see `expression_factory` 

727 for the distinction) cannot be resolved by default; they must either be 

728 preceded by a call to `join_dataset_search` or must be passed to 

729 `DatasetRefQueryResults.where <lsst.daf.butler.queries.DatasetRefQueryResults.where>` 

730 instead. 

731 

732 Data ID values are not checked for consistency; they are extracted from 

733 ``args`` and then ``kwargs`` and combined, with later values overriding 

734 earlier ones. 

735 """ # noqa: W505, long docstrings 

736 return Query( 

737 tree=self._tree.where( 

738 convert_where_args( 

739 self.constraint_dimensions, 

740 self.constraint_dataset_types, 

741 *args, 

742 bind=bind, 

743 **kwargs, 

744 ) 

745 ), 

746 driver=self._driver, 

747 ) 

748 

749 def _skip_governor_validation(self) -> Query: 

750 tree = self._tree.model_copy(update={"validateGovernorConstraints": False}) 

751 return Query(tree=tree, driver=self._driver) 

752 

753 def _join_dataset_search_impl( 

754 self, 

755 dataset_type: str | DatasetType, 

756 collections: Iterable[str] | None = None, 

757 allow_storage_class_overrides: bool = True, 

758 ) -> tuple[str, str, Query]: 

759 """Implement `join_dataset_search`, and also return the dataset type 

760 name and storage class, in addition to the modified Query. 

761 """ 

762 # In this method we need the dimensions of the dataset type, but we 

763 # might not need the storage class, since the dataset may only be used 

764 # as an existence constraint. It depends on whether 

765 # `join_dataset_search` or `datasets` is calling this method. 

766 dimensions: DimensionGroup | None = None 

767 storage_class_name: str | None = None 

768 # Handle DatasetType vs. str arg. 

769 if isinstance(dataset_type, DatasetType): 

770 dataset_type_name = dataset_type.name 

771 dimensions = dataset_type.dimensions 

772 storage_class_name = dataset_type.storageClass_name 

773 elif isinstance(dataset_type, str): 

774 dataset_type_name = dataset_type 

775 else: 

776 raise TypeError(f"Invalid dataset type argument {dataset_type!r}.") 

777 # See if this dataset has already been joined into the query. 

778 if existing_search := self._tree.datasets.get(dataset_type_name): 

779 if collections is None: 

780 collections = existing_search.collections 

781 else: 

782 collections = tuple(ensure_iterable(collections)) 

783 if collections != existing_search.collections: 

784 raise InvalidQueryError( 

785 f"Dataset type {dataset_type_name!r} was already joined into this " 

786 "query with a different collection search path (previously " 

787 f"[{', '.join(existing_search.collections)}], now [{', '.join(collections)}])." 

788 ) 

789 if dimensions is None: 

790 dimensions = existing_search.dimensions 

791 else: 

792 if collections is None: 

793 collections = self._driver.get_default_collections() 

794 collections = tuple(ensure_iterable(collections)) 

795 # Look up the data repository definition of the dataset type to check 

796 # for consistency, or get dimensions and storage class if we don't have 

797 # them. 

798 resolved_dataset_type = self._driver.get_dataset_type(dataset_type_name) 

799 resolved_dimensions = resolved_dataset_type.dimensions 

800 if dimensions is not None and dimensions != resolved_dimensions: 

801 raise DatasetTypeError( 

802 f"Given dimensions {dimensions} for dataset type {dataset_type_name!r} do not match the " 

803 f"registered dimensions {resolved_dimensions}." 

804 ) 

805 if storage_class_name is not None: 

806 if storage_class_name != resolved_dataset_type.storageClass_name: 

807 if not allow_storage_class_overrides: 

808 raise InvalidQueryError( 

809 f"Storage class {storage_class_name!r} for dataset type {dataset_type!r} differs " 

810 f"from repository definition {resolved_dataset_type.storageClass_name!r}, but " 

811 "join_dataset_search does not are about storage classes and cannot record this " 

812 "override. Pass the override to `Query.datasets` instead." 

813 ) 

814 if not ( 

815 StorageClassFactory() 

816 .getStorageClass(storage_class_name) 

817 .can_convert(resolved_dataset_type.storageClass) 

818 ): 

819 raise DatasetTypeError( 

820 f"Given storage class {storage_class_name!r} for {dataset_type_name!r} is not " 

821 f"compatible with repository storage class {resolved_dataset_type.storageClass_name}." 

822 ) 

823 else: 

824 storage_class_name = resolved_dataset_type.storageClass_name 

825 dataset_search = DatasetSearch.model_construct( 

826 collections=collections, 

827 dimensions=resolved_dimensions, 

828 ) 

829 return ( 

830 dataset_type_name, 

831 storage_class_name, 

832 Query(self._driver, self._tree.join_dataset(dataset_type_name, dataset_search)), 

833 )