Coverage for python/lsst/daf/butler/registry/queries/butler_sql_engine.py: 27%
84 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-10-09 03:23 -0700
« prev ^ index » next coverage.py v7.10.6, created at 2025-10-09 03:23 -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/>.
27from __future__ import annotations
29from ... import ddl
31__all__ = ("ButlerSqlEngine",)
33import dataclasses
34from collections.abc import Iterable, Set
35from typing import TYPE_CHECKING, Any, cast
37import astropy.time
38import sqlalchemy
40from lsst.daf.relation import ColumnTag, Relation, Sort, UnaryOperation, UnaryOperationRelation, sql
42from ..._column_tags import is_timespan_column
43from ..._column_type_info import ColumnTypeInfo, LogicalColumn
44from ..._timespan import Timespan
45from ...timespan_database_representation import TimespanDatabaseRepresentation
46from .find_first_dataset import FindFirstDataset
48if TYPE_CHECKING:
49 from ...name_shrinker import NameShrinker
52@dataclasses.dataclass(repr=False, eq=False, kw_only=True)
53class ButlerSqlEngine(sql.Engine[LogicalColumn]):
54 """An extension of the `lsst.daf.relation.sql.Engine` class to add timespan
55 and `FindFirstDataset` operation support.
56 """
58 column_types: ColumnTypeInfo
59 """Struct containing information about column types that depend on registry
60 configuration.
61 """
63 name_shrinker: NameShrinker
64 """Object used to shrink the SQL identifiers that represent a ColumnTag to
65 fit within the database's limit (`NameShrinker`).
66 """
68 def __str__(self) -> str:
69 return self.name
71 def __repr__(self) -> str:
72 return f"ButlerSqlEngine({self.name!r})@{id(self):0x}"
74 def _append_unary_to_select(self, operation: UnaryOperation, target: sql.Select) -> sql.Select:
75 # Docstring inherited.
76 # This override exists to add support for the custom FindFirstDataset
77 # operation.
78 match operation:
79 case FindFirstDataset():
80 if target.has_sort and not target.has_slice:
81 # Existing target is sorted, but not sliced. We want to
82 # move that sort outside (i.e. after) the FindFirstDataset,
83 # since otherwise the FindFirstDataset would put the Sort
84 # into a CTE where it will do nothing.
85 inner = target.reapply_skip(sort=Sort())
86 return sql.Select.apply_skip(operation._finish_apply(inner), sort=target.sort)
87 else:
88 # Apply the FindFirstDataset directly to the existing
89 # target, which we've already asserted starts with a
90 # Select. That existing Select will be used for the CTE
91 # that starts the FindFirstDataset implementation (see
92 # to_payload override).
93 return sql.Select.apply_skip(operation._finish_apply(target))
94 case _:
95 return super()._append_unary_to_select(operation, target)
97 def get_identifier(self, tag: ColumnTag) -> str:
98 # Docstring inherited.
99 return self.name_shrinker.shrink(super().get_identifier(tag))
101 def extract_mapping(
102 self, tags: Iterable[ColumnTag], sql_columns: sqlalchemy.sql.ColumnCollection
103 ) -> dict[ColumnTag, LogicalColumn]:
104 # Docstring inherited.
105 # This override exists to add support for Timespan columns.
106 result: dict[ColumnTag, LogicalColumn] = {}
107 for tag in tags:
108 if is_timespan_column(tag):
109 result[tag] = self.column_types.timespan_cls.from_columns(
110 sql_columns, name=self.get_identifier(tag)
111 )
112 else:
113 result[tag] = sql_columns[self.get_identifier(tag)]
114 return result
116 def select_items(
117 self,
118 items: Iterable[tuple[ColumnTag, LogicalColumn]],
119 sql_from: sqlalchemy.sql.FromClause,
120 *extra: sqlalchemy.sql.ColumnElement,
121 ) -> sqlalchemy.sql.Select:
122 # Docstring inherited.
123 # This override exists to add support for Timespan columns.
124 select_columns: list[sqlalchemy.sql.ColumnElement] = []
125 for tag, logical_column in items:
126 if is_timespan_column(tag):
127 select_columns.extend(
128 cast(TimespanDatabaseRepresentation, logical_column).flatten(
129 name=self.get_identifier(tag)
130 )
131 )
132 else:
133 select_columns.append(
134 cast(sqlalchemy.sql.ColumnElement, logical_column).label(self.get_identifier(tag))
135 )
136 select_columns.extend(extra)
137 self.handle_empty_columns(select_columns)
138 return sqlalchemy.sql.select(*select_columns).select_from(sql_from)
140 def make_zero_select(self, tags: Set[ColumnTag]) -> sqlalchemy.sql.Select:
141 # Docstring inherited.
142 # This override exists to add support for Timespan columns.
143 select_columns: list[sqlalchemy.sql.ColumnElement] = []
144 for tag in tags:
145 if is_timespan_column(tag):
146 select_columns.extend(
147 self.column_types.timespan_cls.fromLiteral(None).flatten(name=self.get_identifier(tag))
148 )
149 else:
150 select_columns.append(sqlalchemy.sql.literal(None).label(self.get_identifier(tag)))
151 self.handle_empty_columns(select_columns)
152 return sqlalchemy.sql.select(*select_columns).where(sqlalchemy.sql.literal(False))
154 def convert_column_literal(self, value: Any) -> LogicalColumn:
155 # Docstring inherited.
156 # This override exists to add support for Timespan columns.
157 if isinstance(value, Timespan):
158 return self.column_types.timespan_cls.fromLiteral(value)
159 elif isinstance(value, astropy.time.Time):
160 return sqlalchemy.sql.literal(value, type_=ddl.AstropyTimeNsecTai)
161 else:
162 return super().convert_column_literal(value)
164 def to_payload(self, relation: Relation) -> sql.Payload[LogicalColumn]:
165 # Docstring inherited.
166 # This override exists to add support for the custom FindFirstDataset
167 # operation.
168 match relation:
169 case UnaryOperationRelation(operation=FindFirstDataset() as operation, target=target):
170 # We build a subquery of the form below to search the
171 # collections in order.
172 #
173 # WITH {dst}_search AS (
174 # {target}
175 # ...
176 # )
177 # SELECT
178 # {dst}_window.*,
179 # FROM (
180 # SELECT
181 # {dst}_search.*,
182 # ROW_NUMBER() OVER (
183 # PARTITION BY {dst_search}.{operation.dimensions}
184 # ORDER BY {operation.rank}
185 # ) AS rownum
186 # ) {dst}_window
187 # WHERE
188 # {dst}_window.rownum = 1;
189 #
190 # We'll start with the Common Table Expression (CTE) at the
191 # top, which we mostly get from the target relation.
192 search = self.to_executable(target).cte(f"{operation.rank.dataset_type}_search")
193 # Now we fill out the SELECT from the CTE, and the subquery it
194 # contains (at the same time, since they have the same columns,
195 # aside from the special 'rownum' window-function column).
196 search_columns = self.extract_mapping(target.columns, search.columns)
197 partition_by = [
198 _assert_column_is_directly_usable_by_sqlalchemy(search_columns[tag])
199 for tag in operation.dimensions
200 ]
201 row_number = sqlalchemy.sql.func.row_number()
202 rank_column = _assert_column_is_directly_usable_by_sqlalchemy(search_columns[operation.rank])
203 if partition_by:
204 rownum_column = row_number.over(partition_by=partition_by, order_by=rank_column)
205 else:
206 rownum_column = row_number.over(order_by=rank_column)
207 window = self.select_items(
208 search_columns.items(), search, rownum_column.label("rownum")
209 ).subquery(f"{operation.rank.dataset_type}_window")
210 return sql.Payload(
211 from_clause=window,
212 columns_available=self.extract_mapping(target.columns, window.columns),
213 where=[window.columns["rownum"] == 1],
214 )
215 case _:
216 return super().to_payload(relation)
219def _assert_column_is_directly_usable_by_sqlalchemy(column: LogicalColumn) -> sqlalchemy.sql.ColumnElement:
220 """Narrow a `LogicalColumn` to a SqlAlchemy ColumnElement, to satisfy the
221 typechecker in cases where no timespans are expected.
222 """
223 if isinstance(column, TimespanDatabaseRepresentation):
224 raise TypeError("Timespans not expected here.")
226 return column