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

1# This file is part of daf_butler. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (http://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# This software is dual licensed under the GNU General Public License and also 

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

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

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

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

14# 

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

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

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

18# (at your option) any later version. 

19# 

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

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

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

23# GNU General Public License for more details. 

24# 

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

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

27from __future__ import annotations 

28 

29from ... import ddl 

30 

31__all__ = ("ButlerSqlEngine",) 

32 

33import dataclasses 

34from collections.abc import Iterable, Set 

35from typing import TYPE_CHECKING, Any, cast 

36 

37import astropy.time 

38import sqlalchemy 

39 

40from lsst.daf.relation import ColumnTag, Relation, Sort, UnaryOperation, UnaryOperationRelation, sql 

41 

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 

47 

48if TYPE_CHECKING: 

49 from ...name_shrinker import NameShrinker 

50 

51 

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 """ 

57 

58 column_types: ColumnTypeInfo 

59 """Struct containing information about column types that depend on registry 

60 configuration. 

61 """ 

62 

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 """ 

67 

68 def __str__(self) -> str: 

69 return self.name 

70 

71 def __repr__(self) -> str: 

72 return f"ButlerSqlEngine({self.name!r})@{id(self):0x}" 

73 

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) 

96 

97 def get_identifier(self, tag: ColumnTag) -> str: 

98 # Docstring inherited. 

99 return self.name_shrinker.shrink(super().get_identifier(tag)) 

100 

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 

115 

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) 

139 

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)) 

153 

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) 

163 

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) 

217 

218 

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.") 

225 

226 return column