Coverage for python/lsst/daf/butler/direct_query_driver/_driver.py: 87%
457 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-13 08:57 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-13 08:57 +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/>.
28from __future__ import annotations
30__all__ = ("DirectQueryDriver",)
32import dataclasses
33import itertools
34import logging
35import sys
36import uuid
37from collections import defaultdict
38from collections.abc import Iterable, Iterator, Mapping, Set
39from contextlib import ExitStack
40from typing import TYPE_CHECKING, Any, TypeVar, cast, overload
42import sqlalchemy
44from .. import ddl
45from .._collection_type import CollectionType
46from .._dataset_type import DatasetType
47from .._exceptions import InvalidQueryError
48from ..dimensions import DataCoordinate, DataIdValue, DimensionElement, DimensionGroup, DimensionUniverse
49from ..dimensions.record_cache import DimensionRecordCache
50from ..queries import tree as qt
51from ..queries.driver import (
52 DataCoordinateResultPage,
53 DatasetRefResultPage,
54 DimensionRecordResultPage,
55 GeneralResultPage,
56 QueryDriver,
57 ResultPage,
58)
59from ..queries.predicate_constraints_summary import PredicateConstraintsSummary
60from ..queries.result_specs import (
61 DataCoordinateResultSpec,
62 DatasetRefResultSpec,
63 DimensionRecordResultSpec,
64 GeneralResultSpec,
65 ResultSpec,
66)
67from ..registry import CollectionSummary, NoDefaultCollectionError
68from ..registry.interfaces import ChainedCollectionRecord, CollectionRecord
69from ..registry.managers import RegistryManagerInstances
70from ..registry.wildcards import CollectionWildcard
71from ._postprocessing import Postprocessing
72from ._query_analysis import (
73 QueryCollectionAnalysis,
74 QueryFindFirstAnalysis,
75 QueryJoinsAnalysis,
76 QueryTreeAnalysis,
77 ResolvedDatasetSearch,
78)
79from ._query_builder import QueryBuilder, SingleSelectQueryBuilder, UnionQueryBuilder
80from ._result_page_converter import (
81 DataCoordinateResultPageConverter,
82 DatasetRefResultPageConverter,
83 DimensionRecordResultPageConverter,
84 GeneralResultPageConverter,
85 ResultPageConverter,
86 ResultPageConverterContext,
87)
88from ._sql_builders import SqlJoinsBuilder, SqlSelectBuilder, make_table_spec
89from ._sql_column_visitor import SqlColumnVisitor
91if TYPE_CHECKING:
92 from ..registry.interfaces import Database
95_LOG = logging.getLogger(__name__)
97_T = TypeVar("_T", bound=str | qt.AnyDatasetType)
100class DirectQueryDriver(QueryDriver):
101 """The `QueryDriver` implementation for `DirectButler`.
103 Parameters
104 ----------
105 db : `Database`
106 Abstraction for the SQL database.
107 universe : `DimensionUniverse`
108 Definitions of all dimensions.
109 managers : `RegistryManagerInstances`
110 Struct of registry manager objects.
111 dimension_record_cache : `DimensionRecordCache`
112 Cache of dimension records for infrequently-changing, commonly-used
113 dimensions.
114 default_collections : `~collections.abc.Sequence` [ `str` ]
115 Default collection search path.
116 default_data_id : `DataCoordinate`
117 Default governor dimension values.
118 raw_page_size : `int`, optional
119 Number of database rows to fetch for each result page. The actual
120 number of rows in a page may be smaller due to postprocessing.
121 constant_rows_limit : `int`, optional
122 Maximum number of uploaded rows to include in queries via
123 `Database.constant_rows`; above this limit a temporary table is used
124 instead.
125 postprocessing_filter_factor : `int`, optional
126 The number of database rows we expect to have to fetch to yield a
127 single output row for queries that involve postprocessing. This is
128 purely a performance tuning parameter that attempts to balance between
129 fetching too much and requiring multiple fetches; the true value is
130 highly dependent on the actual query.
131 """
133 def __init__(
134 self,
135 db: Database,
136 universe: DimensionUniverse,
137 managers: RegistryManagerInstances,
138 dimension_record_cache: DimensionRecordCache,
139 default_collections: Iterable[str],
140 default_data_id: DataCoordinate,
141 # Increasing raw_page_size increases memory usage for queries on
142 # Butler server, so if you increase this you may need to increase the
143 # memory allocation for the server in Phalanx as well.
144 raw_page_size: int = 2000,
145 constant_rows_limit: int = 1000,
146 postprocessing_filter_factor: int = 10,
147 ):
148 self.db = db
149 self.managers = managers
150 self._dimension_record_cache = dimension_record_cache
151 self._universe = universe
152 self._default_collections = tuple(default_collections)
153 self._default_data_id = default_data_id
154 self._materializations: dict[qt.MaterializationKey, _MaterializationState] = {}
155 self._upload_tables: dict[qt.DataCoordinateUploadKey, sqlalchemy.FromClause] = {}
156 self._exit_stack: ExitStack | None = None
157 self._raw_page_size = raw_page_size
158 self._postprocessing_filter_factor = postprocessing_filter_factor
159 self._constant_rows_limit = min(constant_rows_limit, db.get_constant_rows_max())
160 self._cursors: set[_Cursor] = set()
162 def __enter__(self) -> None:
163 self._exit_stack = ExitStack()
164 # It might be nice to defer opening a transaction here until first use
165 # to reduce the time spent in transactions. But it's worth noting that
166 # this is the default low-level behavior of the Python SQLite driver,
167 # and it makes it incredibly prone to deadlocks. We might be okay
168 # here, because Query doesn't do true write operations - just temp
169 # table writes - but I'm not confident that's enough to make delayed
170 # transaction starts safe against deadlocks, and it'd be more
171 # complicated to implement anyway.
172 #
173 # We start a transaction rather than just opening a connection to make
174 # temp table and cursors work with pg_bouncer transaction affinity.
175 self._exit_stack.enter_context(self.db.transaction(for_temp_tables=True))
177 def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
178 assert self._exit_stack is not None
179 self._materializations.clear()
180 self._upload_tables.clear()
181 # Transfer open cursors' close methods to exit stack, this will help
182 # with the cleanup in case a cursor raises an exceptions on close.
183 for cursor in self._cursors:
184 self._exit_stack.push(cursor.close)
185 self._exit_stack.__exit__(exc_type, exc_value, traceback)
186 self._cursors = set()
187 self._exit_stack = None
189 @property
190 def universe(self) -> DimensionUniverse:
191 return self._universe
193 @overload
194 def execute( 194 ↛ exitline 194 didn't return from function 'execute' because
195 self, result_spec: DataCoordinateResultSpec, tree: qt.QueryTree
196 ) -> Iterator[DataCoordinateResultPage]: ...
198 @overload
199 def execute( 199 ↛ exitline 199 didn't return from function 'execute' because
200 self, result_spec: DimensionRecordResultSpec, tree: qt.QueryTree
201 ) -> Iterator[DimensionRecordResultPage]: ...
203 @overload
204 def execute( 204 ↛ exitline 204 didn't return from function 'execute' because
205 self, result_spec: DatasetRefResultSpec, tree: qt.QueryTree
206 ) -> Iterator[DatasetRefResultPage]: ...
208 @overload
209 def execute(self, result_spec: GeneralResultSpec, tree: qt.QueryTree) -> Iterator[GeneralResultPage]: ... 209 ↛ exitline 209 didn't return from function 'execute' because
211 def execute(self, result_spec: ResultSpec, tree: qt.QueryTree) -> Iterator[ResultPage]:
212 # Docstring inherited.
213 if self._exit_stack is None: 213 ↛ 214line 213 didn't jump to line 214 because the condition on line 213 was never true
214 raise RuntimeError("QueryDriver context must be entered before queries can be executed.")
215 builder = self.build_query(
216 tree,
217 final_columns=result_spec.get_result_columns(),
218 order_by=result_spec.order_by,
219 find_first_dataset=result_spec.find_first_dataset,
220 allow_duplicate_overlaps=result_spec.allow_duplicate_overlaps,
221 )
222 sql_select, sql_columns = builder.finish_select()
223 if result_spec.order_by:
224 visitor = SqlColumnVisitor(sql_columns, self)
225 sql_select = sql_select.order_by(*[visitor.expect_scalar(term) for term in result_spec.order_by])
226 if result_spec.limit is not None:
227 if builder.postprocessing:
228 builder.postprocessing.limit = result_spec.limit
229 else:
230 sql_select = sql_select.limit(result_spec.limit)
231 if builder.postprocessing.limit is not None:
232 # We might want to fetch many fewer rows than the default page
233 # size if we have to implement limit in postprocessing.
234 raw_page_size = min(
235 self._postprocessing_filter_factor * builder.postprocessing.limit,
236 self._raw_page_size,
237 )
238 else:
239 raw_page_size = self._raw_page_size
240 # Execute the query by initializing a _Cursor object that manages the
241 # lifetime of the result.
242 cursor = _Cursor(
243 self.db,
244 sql_select,
245 postprocessing=builder.postprocessing,
246 raw_page_size=raw_page_size,
247 page_converter=self._create_result_page_converter(result_spec, builder.final_columns),
248 )
249 # Since this function isn't a context manager and the caller could stop
250 # iterating before we retrieve all the results, we have to track open
251 # cursors to ensure we can close them as part of higher-level cleanup.
252 self._cursors.add(cursor)
254 # Return the iterator as a separate function, so that all the code
255 # above runs immediately instead of later when we first read from the
256 # iterator. This ensures that any exceptions that are triggered during
257 # set-up for this query occur immediately.
258 return self._read_results(cursor)
260 def _read_results(self, cursor: _Cursor) -> Iterator[ResultPage]:
261 """Read out all of the result pages from the database."""
262 try:
263 while (result_page := cursor.next()) is not None:
264 yield result_page
265 finally:
266 self._cursors.discard(cursor)
267 cursor.close()
269 def _create_result_page_converter(self, spec: ResultSpec, columns: qt.ColumnSet) -> ResultPageConverter:
270 context = ResultPageConverterContext(
271 db=self.db,
272 column_order=columns.get_column_order(),
273 dimension_record_cache=self._dimension_record_cache,
274 )
275 match spec:
276 case DimensionRecordResultSpec():
277 return DimensionRecordResultPageConverter(spec, context)
278 case DataCoordinateResultSpec():
279 return DataCoordinateResultPageConverter(spec, context)
280 case DatasetRefResultSpec():
281 return DatasetRefResultPageConverter(
282 spec, self.get_dataset_type(spec.dataset_type_name), context
283 )
284 case GeneralResultSpec():
285 return GeneralResultPageConverter(spec, context)
286 case _:
287 raise NotImplementedError(f"Result type '{spec.result_type}' not yet implemented")
289 def materialize(
290 self,
291 tree: qt.QueryTree,
292 dimensions: DimensionGroup,
293 datasets: frozenset[str],
294 allow_duplicate_overlaps: bool = False,
295 key: qt.MaterializationKey | None = None,
296 ) -> qt.MaterializationKey:
297 # Docstring inherited.
298 if self._exit_stack is None: 298 ↛ 299line 298 didn't jump to line 299 because the condition on line 298 was never true
299 raise RuntimeError("QueryDriver context must be entered before 'materialize' is called.")
300 plan = self.build_query(
301 tree, qt.ColumnSet(dimensions), allow_duplicate_overlaps=allow_duplicate_overlaps
302 )
303 # Current implementation ignores 'datasets' aside from remembering
304 # them, because figuring out what to put in the temporary table for
305 # them is tricky, especially if calibration collections are involved.
306 # That's okay because:
307 #
308 # - the query whose results we materialize includes the dataset
309 # searches as constraints;
310 #
311 # - we still (in Query.materialize) join the dataset searches back in
312 # anyway, and given materialized data IDs the join to the dataset
313 # search is straightforward and definitely well-indexed, and not much
314 # (if at all) worse than joining back in on a materialized UUID.
315 #
316 sql_select, _ = plan.finish_select(return_columns=False)
317 table = self._exit_stack.enter_context(
318 self.db.temporary_table(
319 make_table_spec(plan.final_columns, self.db, plan.postprocessing, make_indices=True)
320 )
321 )
322 self.db.insert(table, select=sql_select)
323 if key is None: 323 ↛ 325line 323 didn't jump to line 325 because the condition on line 323 was always true
324 key = uuid.uuid4()
325 self._materializations[key] = _MaterializationState(table, datasets, plan.postprocessing)
326 return key
328 def upload_data_coordinates(
329 self,
330 dimensions: DimensionGroup,
331 rows: Iterable[tuple[DataIdValue, ...]],
332 key: qt.DataCoordinateUploadKey | None = None,
333 ) -> qt.DataCoordinateUploadKey:
334 # Docstring inherited.
335 if self._exit_stack is None: 335 ↛ 336line 335 didn't jump to line 336 because the condition on line 335 was never true
336 raise RuntimeError(
337 "QueryDriver context must be entered before 'upload_data_coordinates' is called."
338 )
339 columns = qt.ColumnSet(dimensions).drop_implied_dimension_keys()
340 table_spec = ddl.TableSpec(
341 [columns.get_column_spec(logical_table, field).to_sql_spec() for logical_table, field in columns]
342 )
343 dict_rows: list[dict[str, Any]]
344 if not columns:
345 table_spec.fields.add(
346 ddl.FieldSpec(
347 SqlSelectBuilder.EMPTY_COLUMNS_NAME,
348 dtype=SqlSelectBuilder.EMPTY_COLUMNS_TYPE,
349 nullable=True,
350 )
351 )
352 dict_rows = [{SqlSelectBuilder.EMPTY_COLUMNS_NAME: None}]
353 else:
354 dict_rows = [dict(zip(dimensions.required, values)) for values in rows]
355 from_clause: sqlalchemy.FromClause
356 if self.db.supports_temporary_tables and len(dict_rows) > self._constant_rows_limit:
357 table = self._exit_stack.enter_context(self.db.temporary_table(table_spec))
358 self.db.insert(table, *dict_rows)
359 from_clause = table
360 else:
361 from_clause = self.db.constant_rows(table_spec.fields, *dict_rows)
362 if key is None: 362 ↛ 364line 362 didn't jump to line 364 because the condition on line 362 was always true
363 key = uuid.uuid4()
364 self._upload_tables[key] = from_clause
365 return key
367 def count(
368 self,
369 tree: qt.QueryTree,
370 result_spec: ResultSpec,
371 *,
372 exact: bool,
373 discard: bool,
374 ) -> int:
375 # Docstring inherited.
376 columns = result_spec.get_result_columns()
377 builder = self.build_query(tree, columns, find_first_dataset=result_spec.find_first_dataset)
378 if not all(d.collection_records for d in builder.joins_analysis.datasets.values()):
379 return 0
380 # No need to do similar check on
381 if not exact:
382 builder.postprocessing = Postprocessing()
383 if builder.postprocessing:
384 if not discard:
385 raise InvalidQueryError("Cannot count query rows exactly without discarding them.")
386 sql_select, _ = builder.finish_select(return_columns=False)
387 builder.postprocessing.limit = result_spec.limit
388 n = 0
389 with self.db.query(sql_select.execution_options(yield_per=self._raw_page_size)) as results:
390 for _ in builder.postprocessing.apply(results):
391 n += 1
392 return n
393 # If the query has DISTINCT, GROUP BY, or UNION [ALL], nest it in a
394 # subquery so we count deduplicated rows.
395 select_builder = builder.finish_nested()
396 # Replace the columns of the query with just COUNT(*).
397 select_builder.columns = qt.ColumnSet(self._universe.empty)
398 select_builder.joins.special.clear()
399 count_func: sqlalchemy.ColumnElement[int] = sqlalchemy.func.count()
400 select_builder.joins.special["_ROWCOUNT"] = count_func
401 # Render and run the query.
402 sql_select = select_builder.select(builder.postprocessing)
403 with self.db.query(sql_select) as result:
404 count = cast(int, result.scalar())
405 if result_spec.limit is not None:
406 count = min(count, result_spec.limit)
407 return count
409 def any(self, tree: qt.QueryTree, *, execute: bool, exact: bool) -> bool:
410 # Docstring inherited.
411 builder = self.build_query(tree, qt.ColumnSet(tree.dimensions), allow_duplicate_overlaps=True)
412 if not all(d.collection_records for d in builder.joins_analysis.datasets.values()):
413 return False
414 if not execute:
415 if exact:
416 raise InvalidQueryError("Cannot obtain exact result for 'any' without executing.")
417 return True
418 if builder.postprocessing and exact:
419 sql_select, _ = builder.finish_select(return_columns=False)
420 with self.db.query(
421 sql_select.execution_options(yield_per=self._postprocessing_filter_factor)
422 ) as result:
423 for _ in builder.postprocessing.apply(result):
424 return True
425 return False
426 sql_select, _ = builder.finish_select()
427 with self.db.query(sql_select.limit(1)) as result:
428 return result.first() is not None
430 def explain_no_results(self, tree: qt.QueryTree, execute: bool) -> Iterable[str]:
431 # Docstring inherited.
432 plan = self.build_query(tree, qt.ColumnSet(tree.dimensions), analyze_only=True)
433 if plan.joins_analysis.messages or not execute:
434 return plan.joins_analysis.messages
435 # TODO: guess at ways to split up query that might fail or succeed if
436 # run separately, execute them with LIMIT 1 and report the results.
437 return []
439 def get_dataset_type(self, name: str) -> DatasetType:
440 # Docstring inherited
441 return self.managers.datasets.get_dataset_type(name)
443 def get_default_collections(self) -> tuple[str, ...]:
444 # Docstring inherited.
445 if not self._default_collections:
446 raise NoDefaultCollectionError("No collections provided and no default collections.")
447 return self._default_collections
449 def build_query(
450 self,
451 tree: qt.QueryTree,
452 final_columns: qt.ColumnSet,
453 *,
454 order_by: Iterable[qt.OrderExpression] = (),
455 find_first_dataset: str | qt.AnyDatasetType | None = None,
456 analyze_only: bool = False,
457 allow_duplicate_overlaps: bool = False,
458 ) -> QueryBuilder:
459 """Convert a query description into a nearly-complete builder object
460 for the SQL version of that query.
462 Parameters
463 ----------
464 tree : `.queries.tree.QueryTree`
465 Description of the joins and row filters in the query.
466 final_columns : `.queries.tree.ColumnSet`
467 Final output columns that should be emitted by the SQL query.
468 order_by : `~collections.abc.Iterable` [ \
469 `.queries.tree.OrderExpression` ], optional
470 Column expressions to sort by.
471 find_first_dataset : `str`, ``ANY_DATASET``, or `None`, optional
472 Name of a dataset type for which only one result row for each data
473 ID should be returned, with the collections searched in order.
474 ``ANY_DATASET`` is used to represent the search for all dataset
475 types with a particular set of dimensions in ``tree.any_dataset``.
476 analyze_only : `bool`, optional
477 If `True`, perform the initial analysis needed to construct the
478 builder, but do not call methods that build its SQL form. This can
479 be useful for obtaining diagnostic information about the query that
480 would be generated.
481 allow_duplicate_overlaps : `bool`, optional
482 If set to `True` then query will be allowed to generate
483 non-distinct rows for spatial overlaps.
485 Returns
486 -------
487 builder : `QueryBuilder`
488 An object that contains an analysis of the queries columns and
489 general structure, SQLAlchemy representations of most of the
490 constructed query, and a description of Python-side postprocessing
491 to be performed after executing it. Callers should generally only
492 have to call `finish_select` or `finish_nested`; all ``apply``
493 methods will have already been called.
495 Notes
496 -----
497 The SQL queries produced by this driver are built in three steps:
499 - First we "analyze" the entire query, fetching extra information as
500 needed to identify the tables we'll join in, the columns we'll have
501 at each level of what may be a nested SELECT, and the degree to which
502 we'll have to use GROUP BY / DISTINCT or window functions to obtain
503 just the rows we want. This process initializes a `QueryBuilder`
504 instance, but does not call any of its ``apply*`` methods to actually
505 build the SQLAlchemy version of the query, and it is all that is done
506 when ``analyze_only=True``.
507 - In the next step we call `QueryBuilder` ``apply*`` methods to mutate
508 and/or replace the `QueryBuilder` object's nested `SqlSelectBuilder`
509 instances to reflect that analysis, building the SQL from the inside
510 out (subqueries before their parents). This is also done by the
511 `build_query` method by default
512 - The returned builder can be used by calling either `finish_select`
513 (to get the full executable query) or `finish_nested` (to wrap
514 that query as a subquery - if needed - in order to do related
515 queries, like ``COUNT(*)`` or ``LIMIT 1`` checks).
517 Within both the analysis and application steps, we further split the
518 query *structure* and building process into three stages, mapping
519 roughly to levels of (potential) subquery nesting:
521 - In the "joins" stage, we join all the tables we need columns or
522 constraints from and apply the predicate as a WHERE clause. All
523 non-calculated columns are included in the query at this stage.
524 - In the optional "projection" stage, we apply a GROUP BY or DISTINCT
525 to reduce the set of columns and/or rows, and in some cases add new
526 calculated columns.
527 - In the optional "find first" stage, we use a common table expression
528 with PARTITION ON to search for datasets in a sequence of collections
529 in the order those collections were provided.
531 In addition, there are two different overall structures for butler
532 queries, modeled as two different `QueryBuilder` subclasses.
534 - `SingleSelectQueryBuilder` produces a single possibly-nested SELECT
535 query structured directly according to the stages above. This is
536 used for almost all queries - specifically whenever
537 `QueryTree.any_dataset` is `None`.
538 - `UnionQueryBuilder` implements queries for datasets of multiple types
539 with the same dimensions as a UNION ALL combination of SELECTs, in
540 which each component SELECT has the joins/projection/find-first
541 structure. This cannot be implemented as a sequence of
542 `SingleSelectQueryBuilder` objects, however, since the need for the
543 terms to share a single `Postprocessing` object and column list means
544 that they are not independent.
546 The `build_query` method delegates to methods of the `QueryBuilder`
547 object when "applying" to let them handle these differences. These
548 often call back to other methods on the driver (so code shared by both
549 builders mostly lives in the driver class).
550 """
551 # Analyze the dimensions, dataset searches, and other join operands
552 # that will go into the query. This also initializes a
553 # SqlSelectBuilder and Postprocessing with spatial/temporal constraints
554 # potentially transformed by the dimensions manager (but none of the
555 # rest of the analysis reflected in that SqlSelectBuilder).
556 query_tree_analysis = self._analyze_query_tree(tree, allow_duplicate_overlaps)
557 # The "projection" columns differ from the final columns by not
558 # omitting any dimension keys (this keeps queries for different result
559 # types more similar during construction), including any columns needed
560 # only by order_by terms, and including the collection key if we need
561 # it for GROUP BY or DISTINCT.
562 projection_columns = final_columns.copy()
563 projection_columns.restore_dimension_keys()
564 for term in order_by:
565 term.gather_required_columns(projection_columns)
566 # There are two kinds of query builders: simple SELECTS and UNIONs
567 # over dataset types.
568 builder: QueryBuilder
569 if tree.any_dataset is not None: 569 ↛ 570line 569 didn't jump to line 570 because the condition on line 569 was never true
570 builder = UnionQueryBuilder(
571 query_tree_analysis,
572 union_dataset_dimensions=tree.any_dataset.dimensions,
573 projection_columns=projection_columns,
574 final_columns=final_columns,
575 find_first_dataset=find_first_dataset,
576 )
577 else:
578 assert find_first_dataset != qt.AnyDatasetType.ANY_DATASET
579 builder = SingleSelectQueryBuilder(
580 tree_analysis=query_tree_analysis,
581 projection_columns=projection_columns,
582 final_columns=final_columns,
583 find_first_dataset=find_first_dataset,
584 )
585 # Finish setting up the projection part of the builder.
586 builder.analyze_projection()
587 # The joins-stage query also needs to include all columns needed by the
588 # downstream projection query. Note that this:
589 # - never adds new dimensions to the joins stage (since those are
590 # always a superset of the projection-stage dimensions);
591 # - does not affect our previous determination of
592 # needs_dataset_distinct, because any dataset fields being added to
593 # the joins stage here are already in the projection.
594 builder.joins_analysis.columns.update(builder.projection_columns)
595 # Set up the find-first part of the builder.
596 if find_first_dataset is not None:
597 builder.analyze_find_first()
598 # At this point, analysis is complete, and we can proceed to making
599 # the select_builder(s) reflect that analysis.
600 if not analyze_only:
601 builder.apply_joins(self)
602 builder.apply_projection(self, order_by)
603 builder.apply_find_first(self)
604 return builder
606 def _analyze_query_tree(self, tree: qt.QueryTree, allow_duplicate_overlaps: bool) -> QueryTreeAnalysis:
607 """Analyze a `.queries.tree.QueryTree` as the first step in building
608 a SQL query.
610 Parameters
611 ----------
612 tree : `.queries.tree.QueryTree`
613 Description of the joins and row filters in the query.
614 allow_duplicate_overlaps : `bool`, optional
615 If set to `True` then query will be allowed to generate
616 non-distinct rows for spatial overlaps.
618 Returns
619 -------
620 tree_analysis : `QueryTreeAnalysis`
621 Struct containing additional information need to build the joins
622 stage of a query.
624 Notes
625 -----
626 See `build_query` for the complete picture of how SQL queries are
627 constructed. This method is the very first step for all queries.
629 The fact that this method returns both a QueryPlan and an initial
630 SqlSelectBuilder (rather than just a QueryPlan) is a tradeoff that lets
631 DimensionRecordStorageManager.process_query_overlaps (which is called
632 by the `_analyze_query_tree` call below) pull out overlap expressions
633 from the predicate at the same time it turns them into SQL table joins
634 (in the builder).
635 """
636 # Fetch the records and summaries for any collections we might be
637 # searching for datasets and organize them for the kind of lookups
638 # we'll do later.
639 collection_analysis = self._analyze_collections(tree)
640 # Extract the data ID implied by the predicate; we can use the governor
641 # dimensions in that to constrain the collections we search for
642 # datasets later.
643 predicate_constraints = PredicateConstraintsSummary(tree.predicate)
644 # Use the default data ID to apply additional constraints where needed.
645 predicate_constraints.apply_default_data_id(
646 self._default_data_id,
647 tree.dimensions,
648 validate_governor_constraints=tree.validateGovernorConstraints,
649 )
650 predicate = predicate_constraints.predicate
651 # Delegate to the dimensions manager to rewrite the predicate and start
652 # a SqlSelectBuilder to cover any spatial overlap joins or constraints.
653 # We'll return that SqlSelectBuilder (or copies of it) at the end.
654 (
655 predicate,
656 select_builder,
657 postprocessing,
658 ) = self.managers.dimensions.process_query_overlaps(
659 tree.dimensions,
660 predicate,
661 tree.get_joined_dimension_groups(),
662 collection_analysis.calibration_dataset_types,
663 allow_duplicate_overlaps,
664 predicate_constraints.constraint_data_id,
665 )
666 # Initialize the plan we're return at the end of the method.
667 joins = QueryJoinsAnalysis(predicate=predicate, columns=select_builder.columns)
668 joins.messages.extend(predicate_constraints.messages)
669 # Add columns required by postprocessing.
670 postprocessing.gather_columns_required(joins.columns)
671 # Add materializations, which can also bring in more postprocessing.
672 for m_key, m_dimensions in tree.materializations.items():
673 m_state = self._materializations[m_key]
674 joins.materializations[m_key] = m_dimensions
675 # When a query is materialized, the new tree has an empty
676 # (trivially true) predicate because the original was used to make
677 # the materialized rows. But the original postprocessing isn't
678 # executed when the materialization happens, so we have to include
679 # it here.
680 postprocessing.spatial_join_filtering.extend(m_state.postprocessing.spatial_join_filtering)
681 postprocessing.spatial_where_filtering.extend(m_state.postprocessing.spatial_where_filtering)
682 postprocessing.spatial_expression_filtering.extend(
683 m_state.postprocessing.spatial_expression_filtering
684 )
685 # Add data coordinate uploads.
686 joins.data_coordinate_uploads.update(tree.data_coordinate_uploads)
687 # Add dataset_searches and filter out collections that don't have the
688 # right dataset type or governor dimensions. We re-resolve dataset
689 # searches now that we have a constraint data ID.
690 for dataset_type_name, dataset_search in tree.datasets.items():
691 resolved_dataset_search = self._resolve_dataset_search(
692 dataset_type_name,
693 dataset_search,
694 predicate_constraints.constraint_data_id,
695 collection_analysis.summaries_by_dataset_type[dataset_type_name],
696 )
697 if resolved_dataset_search.dimensions != self.get_dataset_type(dataset_type_name).dimensions: 697 ↛ 701line 697 didn't jump to line 701 because the condition on line 697 was never true
698 # This is really for server-side defensiveness; it's hard to
699 # imagine the query getting different dimensions for a dataset
700 # type in two calls to the same query driver.
701 raise InvalidQueryError(
702 f"Incorrect dimensions {resolved_dataset_search.dimensions} for dataset "
703 f"{dataset_type_name!r} in query "
704 f"(vs. {self.get_dataset_type(dataset_type_name).dimensions})."
705 )
706 joins.datasets[dataset_type_name] = resolved_dataset_search
707 if not resolved_dataset_search.collection_records:
708 joins.messages.append(
709 f"Search for dataset type {resolved_dataset_search.name!r} in "
710 f"{list(dataset_search.collections)} is doomed to fail."
711 )
712 joins.messages.extend(resolved_dataset_search.messages)
713 # Process the special any_dataset search, if there is one. This entails
714 # making a modified copy of the plan for each distinct post-filtering
715 # collection search path.
716 if tree.any_dataset is None: 716 ↛ 720line 716 didn't jump to line 720 because the condition on line 716 was always true
717 return QueryTreeAnalysis(
718 joins, union_datasets=[], initial_select_builder=select_builder, postprocessing=postprocessing
719 )
720 union_datasets = self._resolve_union_datasets(tree.any_dataset.dimensions, collection_analysis)
721 return QueryTreeAnalysis(
722 joins,
723 union_datasets=union_datasets,
724 initial_select_builder=select_builder,
725 postprocessing=postprocessing,
726 )
728 def _resolve_union_datasets(
729 self, dimensions: DimensionGroup, collection_analysis: QueryCollectionAnalysis
730 ) -> list[ResolvedDatasetSearch[list[str]]]:
731 """Resolve searches for union datasets.
733 Parameters
734 ----------
735 dimensions : `DimensionGroup`
736 Dimensions of the union dataset types in this query.
737 collection_analysis : `CollectionAnalysis`
738 Information about the collections appearing in this collection.
740 Returns
741 -------
742 searches : `list` [ `ResolvedDatasetSearch` ]
743 Resolved dataset searches for all union dataset types with these
744 dimensions. Each item in the list groups dataset types with the
745 same collection search path.
746 """
747 # Gather the filtered collection search path for each union dataset
748 # type.
749 collections_by_dataset_type = defaultdict[str, list[str]](list)
750 for collection_record, collection_summary in collection_analysis.summaries_by_dataset_type[
751 qt.ANY_DATASET
752 ]:
753 for dataset_type in collection_summary.dataset_types:
754 if dataset_type.dimensions == dimensions:
755 collections_by_dataset_type[dataset_type.name].append(collection_record.name)
756 # Reverse the lookup order on the mapping we just made to group
757 # dataset types by their collection search path. Each such group
758 # yields a term in a union query builder
759 dataset_searches_by_collections: dict[tuple[str, ...], ResolvedDatasetSearch[list[str]]] = {}
760 for dataset_type_name, collection_path in collections_by_dataset_type.items():
761 key = tuple(collection_path)
762 if (resolved_search := dataset_searches_by_collections.get(key)) is None:
763 resolved_search = ResolvedDatasetSearch[list[str]](
764 [],
765 dimensions=dimensions,
766 collection_records=[
767 collection_analysis.collection_records[collection_name]
768 for collection_name in collection_path
769 ],
770 messages=[],
771 )
772 resolved_search.is_calibration_search = any(
773 r.type is CollectionType.CALIBRATION for r in resolved_search.collection_records
774 )
775 dataset_searches_by_collections[key] = resolved_search
776 resolved_search.name.append(dataset_type_name)
777 return list(dataset_searches_by_collections.values())
779 def apply_initial_query_joins(
780 self,
781 select_builder: SqlSelectBuilder,
782 joins_analysis: QueryJoinsAnalysis,
783 union_dataset_dimensions: DimensionGroup | None,
784 ) -> None:
785 """Apply most of the "joins" stage of query construction to a single
786 SELECT.
788 This method is expected to be invoked by `QueryBuilder.apply_joins`
789 implementations. It handles all tables and subqueries in the FROM
790 clause, except:
792 - the `QueryTree.any_dataset` search (handled by `UnionQueryBuilder`
793 directly);
794 - joins of dimension tables needed only for their keys (handled by
795 `apply_missing_dimension_joins`).
797 Parameters
798 ----------
799 select_builder : `SqlSelectBuilder`
800 Low-level SQL builder for a single SELECT term, modified in place.
801 joins_analysis : `QueryJoinsAnalysis`
802 Information about the joins stage of query construction.
803 union_dataset_dimensions : `DimensionGroup` or `None`
804 Dimensions of the union dataset types, or `None` if this is not
805 a union dataset query.
806 """
807 # Process data coordinate upload joins.
808 for upload_key, upload_dimensions in joins_analysis.data_coordinate_uploads.items():
809 select_builder.joins.join(
810 SqlJoinsBuilder(db=self.db, from_clause=self._upload_tables[upload_key]).extract_dimensions(
811 upload_dimensions.required
812 )
813 )
814 # Process materialization joins. We maintain a set of dataset types
815 # that were included in a materialization; searches for these datasets
816 # can be dropped if they are only present to provide a constraint on
817 # data IDs, since that's already embedded in a materialization.
818 materialized_datasets: set[str] = set()
819 for materialization_key, materialization_dimensions in joins_analysis.materializations.items():
820 materialized_datasets.update(
821 self._join_materialization(
822 select_builder.joins, materialization_key, materialization_dimensions
823 )
824 )
825 # Process dataset joins (not including any union dataset). Datasets
826 # searches included in materialization can be skipped unless we need
827 # something from their tables.
828 materialized_datasets = set()
829 for m_state in self._materializations.values():
830 materialized_datasets.update(m_state.datasets)
831 for dataset_type_name, dataset_search in joins_analysis.datasets.items():
832 if (
833 dataset_type_name not in materialized_datasets
834 or dataset_type_name in select_builder.columns.dataset_fields
835 ):
836 self.join_dataset_search(
837 select_builder.joins,
838 dataset_search,
839 joins_analysis.columns.dataset_fields[dataset_search.name],
840 )
841 # Join in dimension element tables that we know we need relationships
842 # or columns from.
843 for element in joins_analysis.iter_mandatory(union_dataset_dimensions):
844 select_builder.joins.join(
845 self.managers.dimensions.make_joins_builder(
846 element, joins_analysis.columns.dimension_fields[element.name]
847 )
848 )
850 def apply_missing_dimension_joins(
851 self, select_builder: SqlSelectBuilder, joins_analysis: QueryJoinsAnalysis
852 ) -> None:
853 """Apply dimension-table joins to a single SQL SELECT builder to ensure
854 the full set of desired dimension keys is preset.
856 This method is expected to be invoked by `QueryBuilder.apply_joins`
857 implementations.
859 Parameters
860 ----------
861 select_builder : `SqlSelectBuilder`
862 Low-level SQL builder for a single SELECT term, modified in place.
863 joins_analysis : `QueryJoinsAnalysis`
864 Information about the joins stage of query construction.
865 """
866 # See if any dimension keys are still missing, and if so join in
867 # their tables. Note that we know there are no fields needed from
868 # these.
869 while not (select_builder.joins.dimension_keys.keys() >= joins_analysis.columns.dimensions.names):
870 # Look for opportunities to join in multiple dimensions via
871 # single table, to reduce the total number of tables joined in.
872 missing_dimension_names = (
873 joins_analysis.columns.dimensions.names - select_builder.joins.dimension_keys.keys()
874 )
875 best = self._universe[
876 max(
877 missing_dimension_names,
878 key=lambda name: len(self._universe[name].dimensions.names & missing_dimension_names),
879 )
880 ]
881 to_join = self.managers.dimensions.make_joins_builder(best, frozenset())
882 select_builder.joins.join(to_join)
883 # Add the WHERE clause to the builder.
884 select_builder.joins.where(
885 joins_analysis.predicate.visit(SqlColumnVisitor(select_builder.joins, self))
886 )
888 def project_spatial_join_filtering(
889 self,
890 columns: qt.ColumnSet,
891 postprocessing: Postprocessing,
892 select_builders: Iterable[SqlSelectBuilder],
893 ) -> None:
894 """Transform spatial join postprocessing into expressions that can be
895 OR'd together via an aggregate function in a GROUP BY.
897 This only affects spatial join constraints involving region columns
898 whose dimensions are being projected away.
900 Parameters
901 ----------
902 columns : `.queries.tree.ColumnSet`
903 Columns that will be included in the final query.
904 postprocessing : `Postprocessing`
905 Object that describes post-query processing; modified in place.
906 select_builders : `~collections.abc.Iterable` [ `SqlSelectBuilder` ]
907 SQL Builder objects to be modified in place.
908 """
909 kept: list[tuple[DimensionElement, DimensionElement]] = []
910 for a, b in postprocessing.spatial_join_filtering:
911 if a.name not in columns.dimensions.elements or b.name not in columns.dimensions.elements:
912 expr_name = f"_{a}_OVERLAPS_{b}"
913 postprocessing.spatial_expression_filtering.append(expr_name)
914 for select_builder in select_builders:
915 expr = sqlalchemy.cast(
916 sqlalchemy.cast(
917 select_builder.joins.fields[a.name]["region"], type_=sqlalchemy.String
918 )
919 + sqlalchemy.literal("&", type_=sqlalchemy.String)
920 + sqlalchemy.cast(
921 select_builder.joins.fields[b.name]["region"], type_=sqlalchemy.String
922 ),
923 type_=sqlalchemy.LargeBinary,
924 )
925 select_builder.joins.special[expr_name] = expr
926 else:
927 kept.append((a, b))
928 postprocessing.spatial_join_filtering = kept
930 def apply_query_projection(
931 self,
932 select_builder: SqlSelectBuilder,
933 postprocessing: Postprocessing,
934 *,
935 join_datasets: Mapping[str, ResolvedDatasetSearch[str]],
936 union_datasets: ResolvedDatasetSearch[list[str]] | None,
937 projection_columns: qt.ColumnSet,
938 needs_dimension_distinct: bool,
939 needs_dataset_distinct: bool,
940 needs_validity_match_count: bool,
941 find_first_dataset: str | qt.AnyDatasetType | None,
942 order_by: Iterable[qt.OrderExpression],
943 ) -> None:
944 """Apply the "projection" stage of query construction to a single
945 SQL SELECT builder.
947 This method is expected to be invoked by
948 `QueryBuilder.apply_projection` implementations.
950 Parameters
951 ----------
952 select_builder : `SqlSelectBuilder`
953 Low-level SQL builder for a single SELECT term, modified in place.
954 postprocessing : `Postprocessing`
955 Description of query processing that happens in Python after the
956 query is executed by the database.
957 join_datasets : `~collections.abc.Mapping` [ `str`, \
958 `ResolvedDatasetSearch` [ `str` ] ]
959 Information about regular (non-union) dataset searches joined into
960 the query.
961 union_datasets : `ResolvedDatasetSearch [ `list` [ `str` ] ] or `None`
962 Information about a search for dataset of multiple types with the
963 same dimensions and the same post-filtering collection search path.
964 projection_columns : `.queries.tree.ColumnSet`
965 Columns to include in this projection stage of the query.
966 needs_dimension_distinct : `bool`
967 Whether this query needs a GROUP BY or DISTINCT to filter out rows
968 where the only differences are dimension values that are not being
969 returned to the user.
970 needs_dataset_distinct : `bool`
971 Whether this query needs a GROUP BY or DISTINCT to filter out rows
972 that correspond to entries for different collections.
973 needs_validity_match_count : `bool`
974 Whether this query needs a COUNT column to track the number of
975 datasets for each data ID and dataset type. If this is `False` but
976 ``postprocessing.check_validity_match_count`` is `True`, a dummy
977 count column that is just "1" should be added, because the check
978 is needed only for some other SELECT term in a UNION ALL.
979 find_first_dataset : `str` or ``ANY_DATASET`` or `None`
980 Name of the dataset type that will need a find-first stage.
981 ``ANY_DATASET`` is used when the union datasets need a find-first
982 search, while `None` is used to represent both ``find_first=False``
983 and the case when ``find_first=True`` but only only collection has
984 survived filtering.
985 order_by : `~collections.abc.Iterable` [ \
986 `.queries.tree.OrderExpression` ]
987 Order by clause associated with the query.
988 """
989 select_builder.columns = projection_columns
990 if not needs_dimension_distinct and not needs_dataset_distinct and not needs_validity_match_count:
991 if postprocessing.check_validity_match_count: 991 ↛ 992line 991 didn't jump to line 992 because the condition on line 991 was never true
992 select_builder.joins.special[postprocessing.VALIDITY_MATCH_COUNT] = sqlalchemy.literal(1)
993 # Rows are already unique; nothing else to do in this method.
994 return
995 # This method generates either a SELECT DISTINCT [ON] or a SELECT with
996 # GROUP BY. We'll work out which as we go.
997 have_aggregates: bool = False
998 # Dimension key columns form at least most of our GROUP BY or DISTINCT
999 # ON clause.
1000 unique_keys: list[sqlalchemy.ColumnElement[Any]] = [
1001 select_builder.joins.dimension_keys[k][0]
1002 for k in projection_columns.dimensions.data_coordinate_keys
1003 ]
1005 # Many of our fields derive their uniqueness from the unique_key
1006 # fields: if rows are unique over the 'unique_key' fields, then they're
1007 # automatically unique over these 'derived_fields'. We just remember
1008 # these as pairs of (logical_table, field) for now.
1009 derived_fields: list[tuple[str | qt.AnyDatasetType, str]] = []
1011 # There are two reasons we might need an aggregate function:
1012 # - to make sure temporal constraints and joins have resulted in at
1013 # most one validity range match for each data ID and collection,
1014 # when we're doing a find-first query.
1015 # - to compute the unions of regions we need for postprocessing, when
1016 # the data IDs for those regions are not wholly included in the
1017 # results (i.e. we need to postprocess on
1018 # visit_detector_region.region, but the output rows don't have
1019 # detector, just visit - so we pack the overlap expression into a
1020 # blob via an aggregate function and interpret it later).
1021 if postprocessing.check_validity_match_count:
1022 if needs_validity_match_count: 1022 ↛ 1028line 1022 didn't jump to line 1028 because the condition on line 1022 was always true
1023 select_builder.joins.special[postprocessing.VALIDITY_MATCH_COUNT] = (
1024 sqlalchemy.func.count().label(postprocessing.VALIDITY_MATCH_COUNT)
1025 )
1026 have_aggregates = True
1027 else:
1028 select_builder.joins.special[postprocessing.VALIDITY_MATCH_COUNT] = sqlalchemy.literal(1)
1030 for element in postprocessing.iter_missing(projection_columns):
1031 if element.name in projection_columns.dimensions.elements: 1031 ↛ 1035line 1031 didn't jump to line 1035 because the condition on line 1031 was never true
1032 # The region associated with dimension keys returned by the
1033 # query are derived fields, since there is only one region
1034 # associated with each dimension key value.
1035 derived_fields.append((element.name, "region"))
1036 else:
1037 # If there's a projection and we're doing postprocessing, we
1038 # might be collapsing the dimensions of the postprocessing
1039 # regions. When that happens, we want to apply an aggregate
1040 # function to them that computes the union of the regions that
1041 # are grouped together. Note that this should only happen for
1042 # constraints that involve a "given", external-to-the-database
1043 # region (postprocessing.spatial_where_filtering); join
1044 # constraints that need aggregates should have already been
1045 # transformed in advance.
1046 select_builder.joins.fields[element.name]["region"] = ddl.Base64Region.union_aggregate(
1047 select_builder.joins.fields[element.name]["region"]
1048 )
1049 have_aggregates = True
1050 # Postprocessing spatial join constraints where at least one region's
1051 # dimensions are being projected away will have already been turned
1052 # into the kind of expression that sphgeom.Region.decodeOverlapsBase64
1053 # processes. We can just apply an aggregate function to these. Note
1054 # that we don't do this to other constraints in order to minimize
1055 # duplicate fetches of the same region blob.
1056 for expr_name in postprocessing.spatial_expression_filtering:
1057 select_builder.joins.special[expr_name] = sqlalchemy.cast(
1058 sqlalchemy.func.aggregate_strings(select_builder.joins.special[expr_name], "|"),
1059 type_=sqlalchemy.LargeBinary,
1060 )
1061 have_aggregates = True
1063 # All dimension record fields are derived fields.
1064 for element_name, fields_for_element in projection_columns.dimension_fields.items():
1065 for element_field in fields_for_element:
1066 derived_fields.append((element_name, element_field))
1067 # Some dataset fields are derived fields and some are unique keys, and
1068 # it depends on the kinds of collection(s) we're searching and whether
1069 # it's a find-first query.
1070 for dataset_type, fields_for_dataset in projection_columns.dataset_fields.items():
1071 is_find_first = dataset_type == find_first_dataset
1072 dataset_search: ResolvedDatasetSearch[Any]
1073 if dataset_type is qt.ANY_DATASET: 1073 ↛ 1074line 1073 didn't jump to line 1074 because the condition on line 1073 was never true
1074 assert union_datasets is not None
1075 dataset_search = union_datasets
1076 else:
1077 dataset_search = join_datasets[dataset_type]
1078 for dataset_field in fields_for_dataset:
1079 if dataset_field == "collection_key":
1080 # If the collection_key field is present, it's needed for
1081 # uniqueness if we're looking in more than one collection.
1082 # If not, it's a derived field.
1083 if len(dataset_search.collection_records) > 1: 1083 ↛ 1086line 1083 didn't jump to line 1086 because the condition on line 1083 was always true
1084 unique_keys.append(select_builder.joins.fields[dataset_type]["collection_key"])
1085 else:
1086 derived_fields.append((dataset_type, "collection_key"))
1087 elif dataset_field == "timespan" and dataset_search.is_calibration_search: 1087 ↛ 1089line 1087 didn't jump to line 1089 because the condition on line 1087 was never true
1088 # The timespan is also a unique key...
1089 if is_find_first:
1090 # ...unless we're doing a find-first search on this
1091 # dataset, in which case we need to use ANY_VALUE on
1092 # the timespan and check that _VALIDITY_MATCH_COUNT
1093 # (added earlier) is one, indicating that there was
1094 # indeed only one timespan for each data ID in each
1095 # collection that survived the base query's WHERE
1096 # clauses and JOINs.
1097 if not self.db.has_any_aggregate:
1098 raise NotImplementedError(
1099 f"Cannot generate query that returns timespan for {dataset_type!r} after a "
1100 "find-first search, because this database does not support the ANY_VALUE "
1101 "aggregate function (or equivalent)."
1102 )
1103 select_builder.joins.timespans[dataset_type] = select_builder.joins.timespans[
1104 dataset_type
1105 ].apply_any_aggregate(self.db.apply_any_aggregate)
1106 else:
1107 unique_keys.extend(select_builder.joins.timespans[dataset_type].flatten())
1108 elif (
1109 dataset_field == "dataset_id"
1110 and len(dataset_search.collection_records) > 1
1111 and not is_find_first
1112 ):
1113 # If we have more than one collection in the search, we can
1114 # find multiple dataset IDs with the same data ID, in
1115 # different collections.
1116 # In a non-find-first search, we have to make dataset ID a
1117 # unique key to prevent de-duplication for rows with the
1118 # same data ID but different dataset IDs.
1119 # We don't do this for a find-first search because the
1120 # window function will take care of it.
1121 unique_keys.append(select_builder.joins.fields[dataset_type]["dataset_id"])
1122 else:
1123 # Other dataset fields derive their uniqueness from key
1124 # fields.
1125 derived_fields.append((dataset_type, dataset_field))
1126 if not have_aggregates and not derived_fields:
1127 # SELECT DISTINCT is sufficient.
1128 select_builder.distinct = True
1129 # With DISTINCT ON, Postgres requires that the leftmost parts of the
1130 # ORDER BY match the DISTINCT ON expressions. It's somewhat tricky to
1131 # enforce that, so instead we just don't use DISTINCT ON if ORDER BY is
1132 # present. There may be an optimization opportunity by relaxing this
1133 # restriction.
1134 elif not have_aggregates and self.db.has_distinct_on and len(list(order_by)) == 0: 1134 ↛ 1136line 1134 didn't jump to line 1136 because the condition on line 1134 was never true
1135 # SELECT DISTINCT ON is sufficient and supported by this database.
1136 select_builder.distinct = tuple(unique_keys)
1137 else:
1138 # GROUP BY is the only option.
1139 if derived_fields:
1140 if self.db.has_any_aggregate: 1140 ↛ 1151line 1140 didn't jump to line 1151 because the condition on line 1140 was always true
1141 for logical_table, field in derived_fields:
1142 if field == "timespan":
1143 select_builder.joins.timespans[logical_table] = select_builder.joins.timespans[
1144 logical_table
1145 ].apply_any_aggregate(self.db.apply_any_aggregate)
1146 else:
1147 select_builder.joins.fields[logical_table][field] = self.db.apply_any_aggregate(
1148 select_builder.joins.fields[logical_table][field]
1149 )
1150 else:
1151 _LOG.debug(
1152 "Adding %d fields to GROUP BY because this database backend does not support the "
1153 "ANY_VALUE aggregate function (or equivalent). This may result in a poor query "
1154 "plan. Materializing the query first sometimes avoids this problem. This warning "
1155 "can be ignored unless query performance is a problem.",
1156 len(derived_fields),
1157 )
1158 for logical_table, field in derived_fields:
1159 if field == "timespan":
1160 unique_keys.extend(select_builder.joins.timespans[logical_table].flatten())
1161 else:
1162 unique_keys.append(select_builder.joins.fields[logical_table][field])
1163 select_builder.group_by = tuple(unique_keys)
1165 def apply_query_find_first(
1166 self,
1167 select_builder: SqlSelectBuilder,
1168 postprocessing: Postprocessing,
1169 find_first_analysis: QueryFindFirstAnalysis,
1170 ) -> SqlSelectBuilder:
1171 """Apply the "find first" stage of query construction to a single
1172 SQL SELECT builder.
1174 This method is expected to be invoked by
1175 `QueryBuilder.apply_find_first` implementations.
1177 Parameters
1178 ----------
1179 select_builder : `SqlSelectBuilder`
1180 Low-level SQL builder for a single SELECT term, consumed on return.
1181 postprocessing : `Postprocessing`
1182 Description of query processing that happens in Python after the
1183 query is executed by the database.
1184 find_first_analysis : `QueryFindFirstAnalysis`
1185 Information about the find-first stage gathered during the analysis
1186 phase of query construction.
1188 Returns
1189 -------
1190 select_builder : `SqlSelectBuilder`
1191 Low-level SQL builder that includes the find-first logic.
1192 """
1193 # The query we're building looks like this:
1194 #
1195 # WITH {dst}_base AS (
1196 # {target}
1197 # ...
1198 # )
1199 # SELECT
1200 # {dst}_window.*,
1201 # FROM (
1202 # SELECT
1203 # {dst}_base.*,
1204 # ROW_NUMBER() OVER (
1205 # PARTITION BY {dst_base}.{dimensions}
1206 # ORDER BY {rank}
1207 # ) AS rownum
1208 # ) {dst}_window
1209 # WHERE
1210 # {dst}_window.rownum = 1;
1211 #
1212 # The outermost SELECT will be represented by the SqlSelectBuilder we
1213 # return. The SqlSelectBuilder we're given corresponds to the Common
1214 # Table Expression (CTE) at the top.
1215 #
1216 # For SQLite only, we could use a much simpler GROUP BY instead,
1217 # because it extends the standard to do exactly what we want when MIN
1218 # or MAX appears once and a column does not have an aggregate function
1219 # (https://www.sqlite.org/quirks.html). But since that doesn't work
1220 # with PostgreSQL it doesn't help us.
1221 #
1222 select_builder = select_builder.nested(cte=True, force=True, postprocessing=postprocessing)
1223 # We start by filling out the "window" SELECT statement...
1224 partition_by = [
1225 select_builder.joins.dimension_keys[d][0] for d in select_builder.columns.dimensions.required
1226 ]
1227 rank_sql_column = sqlalchemy.case(
1228 {record.key: n for n, record in enumerate(find_first_analysis.search.collection_records)},
1229 value=select_builder.joins.fields[find_first_analysis.dataset_type]["collection_key"],
1230 )
1231 if partition_by:
1232 select_builder.joins.special["_ROWNUM"] = sqlalchemy.sql.func.row_number().over(
1233 partition_by=partition_by, order_by=rank_sql_column
1234 )
1235 else:
1236 select_builder.joins.special["_ROWNUM"] = sqlalchemy.sql.func.row_number().over(
1237 order_by=rank_sql_column
1238 )
1239 # ... and then turn that into a subquery with a constraint on rownum.
1240 select_builder = select_builder.nested(force=True, postprocessing=postprocessing)
1241 # We can now add the WHERE constraint on rownum into the outer query.
1242 select_builder.joins.where(select_builder.joins.special["_ROWNUM"] == 1)
1243 # Don't propagate _ROWNUM into downstream queries.
1244 del select_builder.joins.special["_ROWNUM"]
1245 return select_builder
1247 def _analyze_collections(self, tree: qt.QueryTree) -> QueryCollectionAnalysis:
1248 """Fetch and organize information about all collections appearing in a
1249 query.
1251 Parameters
1252 ----------
1253 tree : `.queries.tree.QueryTree`
1254 Description of the joins and row filters in the query.
1256 Returns
1257 -------
1258 collection_analysis : `QueryCollectionAnalysis`
1259 Struct containing collection records and summaries, organized
1260 for later access by dataset type.
1261 """
1262 # Retrieve collection information for all collections in a tree.
1263 collection_names = set(
1264 itertools.chain.from_iterable(
1265 dataset_search.collections for dataset_search in tree.datasets.values()
1266 )
1267 )
1268 if tree.any_dataset is not None: 1268 ↛ 1269line 1268 didn't jump to line 1269 because the condition on line 1268 was never true
1269 collection_names.update(tree.any_dataset.collections)
1270 collection_records = {
1271 record.name: record
1272 for record in self.managers.collections.resolve_wildcard(
1273 CollectionWildcard.from_names(collection_names), flatten_chains=True, include_chains=True
1274 )
1275 }
1276 non_chain_records = [
1277 record for record in collection_records.values() if record.type is not CollectionType.CHAINED
1278 ]
1279 # Fetch summaries for a subset of dataset types.
1280 if tree.any_dataset is not None: 1280 ↛ 1281line 1280 didn't jump to line 1281 because the condition on line 1280 was never true
1281 summaries = self.managers.datasets.fetch_summaries(non_chain_records, dataset_types=None)
1282 else:
1283 dataset_types = [self.get_dataset_type(dataset_type_name) for dataset_type_name in tree.datasets]
1284 summaries = self.managers.datasets.fetch_summaries(non_chain_records, dataset_types)
1285 result = QueryCollectionAnalysis(collection_records=collection_records)
1286 # Do a preliminary resolution for dataset searches to identify any
1287 # calibration lookups that might participate in temporal joins.
1288 for dataset_type_name, dataset_search in tree.iter_all_dataset_searches():
1289 collection_summaries = self._filter_collections(
1290 dataset_search.collections, collection_records, summaries
1291 )
1292 result.summaries_by_dataset_type[dataset_type_name] = collection_summaries
1293 resolved_dataset_search = self._resolve_dataset_search(
1294 dataset_type_name, dataset_search, {}, collection_summaries
1295 )
1296 if resolved_dataset_search.is_calibration_search:
1297 result.calibration_dataset_types.add(dataset_type_name)
1298 return result
1300 def _filter_collections(
1301 self,
1302 collection_names: Iterable[str],
1303 records: Mapping[str, CollectionRecord],
1304 summaries: Mapping[Any, CollectionSummary],
1305 ) -> list[tuple[CollectionRecord, CollectionSummary]]:
1306 """Return a subset of collection records and summaries ordered
1307 according to the input collection list.
1309 Parameters
1310 ----------
1311 collection_names : `~collections.abc.Iterable` [`str`]
1312 List of collection names.
1313 records : `~collections.abc.Mapping` [`str`, `CollectionRecord`]
1314 Mapping of collection names to collection records, must contain
1315 records for all collections in ``collection_names`` and all their
1316 children collections.
1317 summaries : `~collections.abc.Mapping` [`typing.Any`, \
1318 `CollectionSummary`]
1319 Mapping of collection IDs to collection summaries, must contain
1320 summaries for all non-chained collections in the collection tree.
1322 Returns
1323 -------
1324 result `list` [`tuple` [`CollectionRecord`, `CollectionSummary`]]
1325 Sequence of collection records and their corresponding summaries
1326 ordered according to the order of input collections and their
1327 child collections. Does not include chained collections.
1328 """
1329 done: set[str] = set()
1331 def recurse(names: Iterable[str]) -> Iterator[tuple[CollectionRecord, CollectionSummary]]:
1332 for name in names:
1333 if name not in done:
1334 done.add(name)
1335 record = records[name]
1336 if record.type is CollectionType.CHAINED:
1337 yield from recurse(cast(ChainedCollectionRecord, record).children)
1338 else:
1339 yield record, summaries[record.key]
1341 return list(recurse(collection_names))
1343 def _resolve_dataset_search(
1344 self,
1345 dataset_type_name: _T,
1346 dataset_search: qt.DatasetSearch,
1347 constraint_data_id: Mapping[str, DataIdValue],
1348 collections: list[tuple[CollectionRecord, CollectionSummary]],
1349 ) -> ResolvedDatasetSearch[_T]:
1350 """Resolve the collections that should actually be searched for
1351 datasets of a particular type.
1353 Parameters
1354 ----------
1355 dataset_type_name : `str` or ``ANY_DATASET``
1356 Name of the dataset being searched for.
1357 dataset_search : `.queries.tree.DatasetSearch`
1358 Struct holding the dimensions and original collection search path.
1359 constraint_data_id : `~collections.abc.Mapping`
1360 Data ID mapping derived from the query predicate that may be used
1361 to filter out some collections based on their governor dimensions.
1362 collections : `list` [ `tuple` [ \
1363 `.registry.interfaces.CollectionRecord`, \
1364 `.registry.CollectionSummary` ] ]
1365 Tuples of collection record and summary.
1367 Returns
1368 -------
1369 resolved : `ResolvedDatasetSearch`
1370 Struct that extends `dataset_search`` with the dataset type name
1371 and resolved collection records.
1372 """
1373 result = ResolvedDatasetSearch(dataset_type_name, dataset_search.dimensions)
1374 if not collections:
1375 result.messages.append("No datasets can be found because collection list is empty.")
1376 for collection_record, collection_summary in collections:
1377 rejected: bool = False
1378 if (
1379 result.name is not qt.ANY_DATASET
1380 and result.name not in collection_summary.dataset_types.names
1381 ):
1382 result.messages.append(
1383 f"No datasets of type {result.name!r} in collection {collection_record.name!r}."
1384 )
1385 rejected = True
1386 for governor in (
1387 constraint_data_id.keys()
1388 & collection_summary.governors.keys()
1389 & dataset_search.dimensions.names
1390 ):
1391 if constraint_data_id[governor] not in collection_summary.governors[governor]:
1392 result.messages.append(
1393 f"No datasets with {governor}={constraint_data_id[governor]!r} "
1394 f"in collection {collection_record.name!r}."
1395 )
1396 rejected = True
1397 if not rejected:
1398 if collection_record.type is CollectionType.CALIBRATION:
1399 result.is_calibration_search = True
1400 result.collection_records.append(collection_record)
1401 return result
1403 def _join_materialization(
1404 self,
1405 joins_builder: SqlJoinsBuilder,
1406 key: qt.MaterializationKey,
1407 dimensions: DimensionGroup,
1408 ) -> frozenset[str]:
1409 """Join a materialization into an under-construction query.
1411 Parameters
1412 ----------
1413 joins_builder : `SqlJoinsBuilder`
1414 Component of a `SqlSelectBuilder` that holds the FROM and WHERE
1415 clauses. This will be modified in-place on return.
1416 key : `.queries.tree.MaterializationKey`
1417 Unique identifier created for this materialization when it was
1418 created.
1419 dimensions : `DimensionGroup`
1420 Dimensions of the materialization.
1422 Returns
1423 -------
1424 datasets : `frozenset` [ `str` ]
1425 Dataset types that were included as constraints when this
1426 materialization was created.
1427 """
1428 columns = qt.ColumnSet(dimensions)
1429 m_state = self._materializations[key]
1430 joins_builder.join(
1431 SqlJoinsBuilder(db=self.db, from_clause=m_state.table).extract_columns(
1432 columns, m_state.postprocessing
1433 )
1434 )
1435 return m_state.datasets
1437 @overload
1438 def join_dataset_search( 1438 ↛ exitline 1438 didn't return from function 'join_dataset_search' because
1439 self,
1440 joins_builder: SqlJoinsBuilder,
1441 resolved_search: ResolvedDatasetSearch[list[str]],
1442 fields: Set[qt.AnyDatasetFieldName],
1443 union_dataset_type_name: str,
1444 ) -> None: ...
1446 @overload
1447 def join_dataset_search( 1447 ↛ exitline 1447 didn't return from function 'join_dataset_search' because
1448 self,
1449 joins_builder: SqlJoinsBuilder,
1450 resolved_search: ResolvedDatasetSearch[str],
1451 fields: Set[qt.AnyDatasetFieldName],
1452 ) -> None: ...
1454 def join_dataset_search(
1455 self,
1456 joins_builder: SqlJoinsBuilder,
1457 resolved_search: ResolvedDatasetSearch[Any],
1458 fields: Set[qt.AnyDatasetFieldName],
1459 union_dataset_type_name: str | None = None,
1460 ) -> None:
1461 """Join a dataset search into an under-construction query.
1463 Parameters
1464 ----------
1465 joins_builder : `SqlJoinsBuilder`
1466 Component of a `SqlSelectBuilder` that holds the FROM and WHERE
1467 clauses. This will be modified in-place on return.
1468 resolved_search : `ResolvedDatasetSearch`
1469 Struct that describes the dataset type and collections.
1470 fields : `~collections.abc.Set` [ `str` ]
1471 Dataset fields to include.
1472 union_dataset_type_name : `str`, optional
1473 Dataset type name to use when ``resolved_search`` represents
1474 multiple union datasets.
1475 """
1476 # The asserts below will need to be dropped (and the implications
1477 # dealt with instead) if materializations start having dataset fields.
1478 if union_dataset_type_name is None: 1478 ↛ 1487line 1478 didn't jump to line 1487 because the condition on line 1478 was always true
1479 dataset_type = self.get_dataset_type(cast(str, resolved_search.name))
1480 assert dataset_type.name not in joins_builder.fields, (
1481 "Dataset fields have unexpectedly already been joined in."
1482 )
1483 assert dataset_type.name not in joins_builder.timespans, (
1484 "Dataset timespan has unexpectedly already been joined in."
1485 )
1486 else:
1487 dataset_type = self.get_dataset_type(union_dataset_type_name)
1488 assert qt.ANY_DATASET not in joins_builder.fields, (
1489 "Union dataset fields have unexpectedly already been joined in."
1490 )
1491 assert qt.ANY_DATASET not in joins_builder.timespans, (
1492 "Union dataset timespan has unexpectedly already been joined in."
1493 )
1495 joins_builder.join(
1496 self.managers.datasets.make_joins_builder(
1497 dataset_type,
1498 resolved_search.collection_records,
1499 fields,
1500 is_union=(union_dataset_type_name is not None),
1501 )
1502 )
1505@dataclasses.dataclass
1506class _MaterializationState:
1507 table: sqlalchemy.Table
1508 datasets: frozenset[str]
1509 postprocessing: Postprocessing
1512class _Cursor:
1513 """A helper class for managing paged query results and cursor lifetimes.
1515 This class holds a context manager for the SQLAlchemy cursor object but is
1516 not itself a context manager. It always cleans up (i.e. calls its `close`
1517 method) when it raises an exception or exhausts the cursor, but external
1518 code is responsible for calling `close` when the cursor is abandoned before
1519 it is exhausted, including when that happens due to an external exception.
1521 Parameters
1522 ----------
1523 db : `.registry.interface.Database`
1524 Database to run the query against.
1525 sql : `sqlalchemy.Executable`
1526 SQL query to execute.
1527 postprocessing : `Postprocessing`
1528 Post-query filtering and checks to perform.
1529 raw_page_size : `int`
1530 Maximum number of SQL result rows to return in each page, before
1531 postprocessing.
1532 page_converter : `ResultPageConverter`
1533 Object for converting raw SQL result rows into ResultPage instances.
1534 """
1536 def __init__(
1537 self,
1538 db: Database,
1539 sql: sqlalchemy.Executable,
1540 postprocessing: Postprocessing,
1541 raw_page_size: int,
1542 page_converter: ResultPageConverter,
1543 ):
1544 self._raw_page_size = raw_page_size
1545 self._postprocessing = postprocessing
1546 self._context = db.query(sql, execution_options=dict(yield_per=raw_page_size))
1547 self._page_converter = page_converter
1548 self._closed = False
1549 cursor = self._context.__enter__()
1550 try:
1551 self._iterator = cursor.partitions()
1552 except BaseException:
1553 self.close(*sys.exc_info())
1554 raise
1556 def close(self, exc_type: Any = None, exc_value: Any = None, traceback: Any = None) -> None:
1557 """Close this cursor.
1559 Parameters
1560 ----------
1561 exc_type : `type`
1562 Exception type as obtained from `sys.exc_info`, or `None` if there
1563 was no error.
1564 exc_value : `BaseException` or `None`
1565 Exception instance as obtained from `sys.exc_info`, or `None` if
1566 there was no error.
1567 traceback : `object`
1568 Traceback as obtained from `sys.exc_info`, or `None` if there was
1569 no error.
1570 """
1571 if not self._closed:
1572 self._context.__exit__(exc_type, exc_value, traceback)
1573 self._closed = True
1575 def next(self) -> ResultPage | None:
1576 """Return the next result page from this query.
1578 When there are no more results after this result page, the `next_page`
1579 attribute of the returned object is `None` and the cursor will be
1580 closed. The cursor is also closed if this method raises an exception.
1581 """
1582 if self._closed:
1583 raise RuntimeError("Cannot continue query result iteration: cursor has been closed")
1584 try:
1585 raw_page = next(self._iterator, None)
1586 if raw_page is None:
1587 self.close()
1588 return None
1590 postprocessed_rows = self._postprocessing.apply(raw_page)
1591 return self._page_converter.convert(postprocessed_rows)
1592 except BaseException:
1593 self.close(*sys.exc_info())
1594 raise