Coverage for python/lsst/daf/butler/direct_query_driver/_sql_builders.py: 95%

187 statements  

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

27 

28from __future__ import annotations 

29 

30__all__ = ("SqlColumns", "SqlJoinsBuilder", "SqlSelectBuilder", "make_table_spec") 

31 

32import dataclasses 

33import itertools 

34from collections.abc import Iterable, Sequence 

35from typing import TYPE_CHECKING, Any, ClassVar, Self 

36 

37import sqlalchemy 

38import sqlalchemy.dialects.postgresql 

39 

40from .. import ddl 

41from ..dimensions import DimensionGroup 

42from ..dimensions._group import SortedSequenceSet 

43from ..nonempty_mapping import NonemptyMapping 

44from ..queries import tree as qt 

45from ._postprocessing import Postprocessing 

46 

47if TYPE_CHECKING: 

48 from ..registry.interfaces import Database 

49 from ..timespan_database_representation import TimespanDatabaseRepresentation 

50 

51 

52@dataclasses.dataclass 

53class SqlSelectBuilder: 

54 """A struct used to represent an under-construction SQL SELECT query. 

55 

56 This object's methods frequently "consume" ``self``, by either returning 

57 it after modification or returning related copy that may share state with 

58 the original. Users should be careful never to use consumed instances, and 

59 are recommended to reuse the same variable name to make that hard to do 

60 accidentally. 

61 """ 

62 

63 joins: SqlJoinsBuilder 

64 """Struct representing the SQL FROM and WHERE clauses, as well as the 

65 columns *available* to the query (but not necessarily in the SELECT 

66 clause). 

67 """ 

68 

69 columns: qt.ColumnSet 

70 """Columns to include the SELECT clause. 

71 

72 This does not include columns required only by `Postprocessing` and columns 

73 in `SqlJoinsBuilder.special`, which are also always included in the SELECT 

74 clause. 

75 """ 

76 

77 distinct: bool | tuple[sqlalchemy.ColumnElement[Any], ...] = () 

78 """A representation of a DISTINCT or DISTINCT ON clause. 

79 

80 If `True`, this represents a SELECT DISTINCT. If a non-empty sequence, 

81 this represents a SELECT DISTINCT ON. If `False` or an empty sequence, 

82 there is no DISTINCT clause. 

83 """ 

84 

85 group_by: tuple[sqlalchemy.ColumnElement[Any], ...] = () 

86 """A representation of a GROUP BY clause. 

87 

88 If not-empty, a GROUP BY clause with these columns is added. This 

89 generally requires that every `sqlalchemy.ColumnElement` held in the nested 

90 `joins` builder that is part of `columns` must either be part of `group_by` 

91 or hold an aggregate function. 

92 """ 

93 

94 EMPTY_COLUMNS_NAME: ClassVar[str] = "IGNORED" 

95 """Name of the column added to a SQL SELECT clause in order to construct 

96 queries that have no real columns. 

97 """ 

98 

99 EMPTY_COLUMNS_TYPE: ClassVar[type] = sqlalchemy.Boolean 

100 """Type of the column added to a SQL SELECT clause in order to construct 

101 queries that have no real columns. 

102 """ 

103 

104 def copy(self) -> SqlSelectBuilder: 

105 """Return a copy that can be safely mutated without affecting the 

106 original. 

107 """ 

108 return dataclasses.replace(self, joins=self.joins.copy(), columns=self.columns.copy()) 

109 

110 @classmethod 

111 def handle_empty_columns( 

112 cls, columns: list[sqlalchemy.sql.ColumnElement] 

113 ) -> list[sqlalchemy.ColumnElement]: 

114 """Handle the edge case where a SELECT statement has no columns, by 

115 adding a literal column that should be ignored. 

116 

117 Parameters 

118 ---------- 

119 columns : `list` [ `sqlalchemy.ColumnElement` ] 

120 List of SQLAlchemy column objects. This may have no elements when 

121 this method is called, and will always have at least one element 

122 when it returns. 

123 

124 Returns 

125 ------- 

126 columns : `list` [ `sqlalchemy.ColumnElement` ] 

127 The same list that was passed in, after any modification. 

128 """ 

129 if not columns: 

130 columns.append(sqlalchemy.sql.literal(True).label(cls.EMPTY_COLUMNS_NAME)) 

131 return columns 

132 

133 def select(self, postprocessing: Postprocessing | None) -> sqlalchemy.Select: 

134 """Transform this builder into a SQLAlchemy representation of a SELECT 

135 query. 

136 

137 Parameters 

138 ---------- 

139 postprocessing : `Postprocessing` 

140 Struct representing post-query processing in Python, which may 

141 require additional columns in the query results. 

142 

143 Returns 

144 ------- 

145 select : `sqlalchemy.Select` 

146 SQLAlchemy SELECT statement. 

147 """ 

148 assert not (self.distinct and self.group_by), "At most one of distinct and group_by can be set." 

149 sql_columns: list[sqlalchemy.ColumnElement[Any]] = [] 

150 for logical_table, field in self.columns: 

151 name = self.columns.get_qualified_name(logical_table, field) 

152 if field is None: 

153 assert logical_table is not qt.ANY_DATASET 

154 sql_columns.append(self.joins.dimension_keys[logical_table][0].label(name)) 

155 else: 

156 name = self.joins.db.name_shrinker.shrink(name) 

157 if self.columns.is_timespan(logical_table, field): 

158 sql_columns.extend(self.joins.timespans[logical_table].flatten(name)) 

159 else: 

160 sql_columns.append(self.joins.fields[logical_table][field].label(name)) 

161 if postprocessing is not None: 

162 for element in postprocessing.iter_missing(self.columns): 

163 sql_columns.append( 

164 self.joins.fields[element.name]["region"].label( 

165 self.joins.db.name_shrinker.shrink( 

166 self.columns.get_qualified_name(element.name, "region") 

167 ) 

168 ) 

169 ) 

170 for label, sql_column in self.joins.special.items(): 

171 sql_columns.append(sql_column.label(label)) 

172 self.handle_empty_columns(sql_columns) 

173 result = sqlalchemy.select(*sql_columns) 

174 if self.joins.from_clause is not None: 

175 result = result.select_from(self.joins.from_clause) 

176 if self.distinct is True: 

177 result = result.distinct() 

178 elif self.distinct: 178 ↛ 182line 178 didn't jump to line 182 because the condition on line 178 was never true

179 # SQLAlchemy 2.1 deprecates Select.distinct(*columns) in favor of 

180 # Select.ext(dialects.postgresql.distinct_on(*columns)), but that 

181 # does not exist in 2.0. 

182 if (distinct_on := getattr(sqlalchemy.dialects.postgresql, "distinct_on", None)) and hasattr( 

183 result, "ext" 

184 ): 

185 result = result.ext(distinct_on(*self.distinct)) 

186 else: 

187 result = result.distinct(*self.distinct) 

188 if self.group_by: 

189 result = result.group_by(*self.group_by) 

190 if self.joins.where_terms: 

191 result = result.where(*self.joins.where_terms) 

192 return result 

193 

194 def join(self, other: SqlJoinsBuilder) -> SqlSelectBuilder: 

195 """Join tables, subqueries, and WHERE clauses from another query into 

196 this one, in place. 

197 

198 Parameters 

199 ---------- 

200 other : `SqlJoinsBuilder` 

201 Object holding the FROM and WHERE clauses to add to this one. 

202 JOIN ON clauses are generated via the dimension keys in common. 

203 

204 Returns 

205 ------- 

206 self : `SqlSelectBuilder` 

207 This `SqlSelectBuilder` instance (never a copy); returned to enable 

208 method-chaining. 

209 """ 

210 self.joins.join(other) 

211 return self 

212 

213 def into_joins_builder( 

214 self, cte: bool = False, force: bool = False, *, postprocessing: Postprocessing | None 

215 ) -> SqlJoinsBuilder: 

216 """Convert this builder into a `SqlJoinsBuilder`, nesting it in a 

217 subquery or common table expression only if needed to apply DISTINCT or 

218 GROUP BY clauses. 

219 

220 This method consumes ``self``. 

221 

222 Parameters 

223 ---------- 

224 cte : `bool`, optional 

225 If `True`, nest via a common table expression instead of a 

226 subquery. 

227 force : `bool`, optional 

228 If `True`, nest via a subquery or common table expression even if 

229 there is no DISTINCT or GROUP BY. 

230 postprocessing : `Postprocessing` 

231 Struct representing post-query processing in Python, which may 

232 require additional columns in the query results. 

233 

234 Returns 

235 ------- 

236 joins_builder : `SqlJoinsBuilder` 

237 SqlJoinsBuilder` with at least all columns in `columns` available. 

238 This may or may not be the `joins` attribute of this object. 

239 """ 

240 if force or self.distinct or self.group_by: 

241 sql_from_clause = ( 

242 self.select(postprocessing).cte() if cte else self.select(postprocessing).subquery() 

243 ) 

244 return SqlJoinsBuilder(db=self.joins.db, from_clause=sql_from_clause).extract_columns( 

245 self.columns, postprocessing, special=self.joins.special.keys() 

246 ) 

247 return self.joins 

248 

249 def nested( 

250 self, cte: bool = False, force: bool = False, *, postprocessing: Postprocessing | None 

251 ) -> SqlSelectBuilder: 

252 """Convert this builder into a `SqlSelectBuilder` that is guaranteed to 

253 have no DISTINCT or GROUP BY, nesting it in a subquery or common table 

254 expression only if needed to apply any current DISTINCT or GROUP BY 

255 clauses. 

256 

257 This method consumes ``self``. 

258 

259 Parameters 

260 ---------- 

261 cte : `bool`, optional 

262 If `True`, nest via a common table expression instead of a 

263 subquery. 

264 force : `bool`, optional 

265 If `True`, nest via a subquery or common table expression even if 

266 there is no DISTINCT or GROUP BY. 

267 postprocessing : `Postprocessing` 

268 Struct representing post-query processing in Python, which may 

269 require additional columns in the query results. 

270 

271 Returns 

272 ------- 

273 builder : `SqlSelectBuilder` 

274 `SqlSelectBuilder` with at least all columns in `columns` 

275 available. This may or may not be the ``builder`` attribute of 

276 this object. 

277 """ 

278 return SqlSelectBuilder( 

279 self.into_joins_builder(cte=cte, force=force, postprocessing=postprocessing), columns=self.columns 

280 ) 

281 

282 def union_subquery( 

283 self, others: Iterable[SqlSelectBuilder], postprocessing: Postprocessing | None = None 

284 ) -> SqlJoinsBuilder: 

285 """Combine this builder with others to make a SELECT UNION subquery. 

286 

287 Parameters 

288 ---------- 

289 others : `~collections.abc.Iterable` [ `SqlSelectBuilder` ] 

290 Other query builders to union with. Their `columns` attributes 

291 must be the same as those of ``self``. 

292 postprocessing : `Postprocessing` 

293 Struct representing post-query processing in Python, which may 

294 require additional columns in the query results. 

295 

296 Returns 

297 ------- 

298 joins_builder : `SqlJoinsBuilder` 

299 `SqlJoinsBuilder` with at least all columns in `columns` available. 

300 This may or may not be the `joins` attribute of this object. 

301 """ 

302 select0 = self.select(postprocessing) 

303 other_selects = [other.select(postprocessing) for other in others] 

304 return SqlJoinsBuilder( 

305 db=self.joins.db, 

306 from_clause=select0.union(*other_selects).subquery(), 

307 ).extract_columns(self.columns, postprocessing) 

308 

309 

310@dataclasses.dataclass(kw_only=True) 

311class SqlColumns: 

312 """A struct that holds SQLAlchemy columns objects for a query, categorized 

313 by type. 

314 

315 This class mostly serves as a base class for `SqlJoinsBuilder`, but unlike 

316 `SqlJoinsBuilder` it is capable of representing columns in a compound 

317 SELECT (i.e. UNION or UNION ALL) clause, not just a FROM clause. 

318 """ 

319 

320 db: Database 

321 """Object that abstracts over the database engine.""" 

322 

323 dimension_keys: NonemptyMapping[str, list[sqlalchemy.ColumnElement]] = dataclasses.field( 

324 default_factory=lambda: NonemptyMapping(list) 

325 ) 

326 """Mapping of dimension keys included in the FROM clause. 

327 

328 Nested lists correspond to different tables that have the same dimension 

329 key (which should all have equal values for all result rows). 

330 """ 

331 

332 fields: NonemptyMapping[str | qt.AnyDatasetType, dict[str, sqlalchemy.ColumnElement[Any]]] = ( 

333 dataclasses.field(default_factory=lambda: NonemptyMapping(dict)) 

334 ) 

335 """Mapping of columns that are neither dimension keys nor timespans. 

336 

337 Inner and outer keys correspond to the "logical table" and "field" pairs 

338 that result from iterating over `~.queries.tree.ColumnSet`, with the former 

339 either a dimension element name or dataset type name. 

340 """ 

341 

342 timespans: dict[str | qt.AnyDatasetType, TimespanDatabaseRepresentation] = dataclasses.field( 

343 default_factory=dict 

344 ) 

345 """Mapping of timespan columns. 

346 

347 Keys are "logical tables" - dimension element names or dataset type names. 

348 """ 

349 

350 special: dict[str, sqlalchemy.ColumnElement[Any]] = dataclasses.field(default_factory=dict) 

351 """Special columns that are available from the FROM clause and 

352 automatically included in the SELECT clause when this join builder is 

353 nested within a `SqlSelectBuilder`. 

354 

355 These columns are not part of the dimension universe and are not associated 

356 with a dataset. They are never returned to users, even if they may be 

357 included in raw SQL results. 

358 """ 

359 

360 def extract_dimensions( 

361 self, dimensions: Iterable[str], *, column_collection: sqlalchemy.ColumnCollection, **kwargs: str 

362 ) -> Self: 

363 """Add dimension key columns from `from_clause` into `dimension_keys`. 

364 

365 Parameters 

366 ---------- 

367 dimensions : `~collections.abc.Iterable` [ `str` ] 

368 Names of dimensions to include, assuming that their names in 

369 ``sql_columns`` are just the dimension names. 

370 column_collection : `sqlalchemy.ColumnCollection` 

371 SQLAlchemy column collection to extract from. 

372 

373 **kwargs : `str` 

374 Additional dimensions to include, with the names in ``sql_columns`` 

375 as keys and the actual dimension names as values. 

376 

377 Returns 

378 ------- 

379 self : `QueryColumns` 

380 This `QueryColumns` instance (never a copy). Provided to enable 

381 method chaining. 

382 """ 

383 for dimension_name in dimensions: 

384 self.dimension_keys[dimension_name].append(column_collection[dimension_name]) 

385 for k, v in kwargs.items(): 

386 self.dimension_keys[v].append(column_collection[k]) 

387 return self 

388 

389 def extract_columns( 

390 self, 

391 columns: qt.ColumnSet, 

392 postprocessing: Postprocessing | None = None, 

393 special: Iterable[str] = (), 

394 *, 

395 column_collection: sqlalchemy.ColumnCollection, 

396 ) -> Self: 

397 """Add columns from `from_clause` into `dimension_keys`. 

398 

399 Parameters 

400 ---------- 

401 columns : `.queries.tree.ColumnSet` 

402 Columns to include, assuming that 

403 `.queries.tree.ColumnSet.get_qualified_name` corresponds to the 

404 name used in ``sql_columns`` (after name shrinking). 

405 postprocessing : `Postprocessing`, optional 

406 Postprocessing object whose needed columns should also be included. 

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

408 Additional special columns to extract. 

409 column_collection : `sqlalchemy.ColumnCollection` 

410 SQLAlchemy column collection to extract from. 

411 

412 Returns 

413 ------- 

414 self : `QueryColumns` 

415 This `QueryColumns` instance (never a copy). Provided to enable 

416 method chaining. 

417 """ 

418 for logical_table, field in columns: 

419 name = columns.get_qualified_name(logical_table, field) 

420 if field is None: 

421 assert logical_table is not qt.ANY_DATASET 

422 self.dimension_keys[logical_table].append(column_collection[name]) 

423 else: 

424 name = self.db.name_shrinker.shrink(name) 

425 if columns.is_timespan(logical_table, field): 

426 self.timespans[logical_table] = self.db.getTimespanRepresentation().from_columns( 

427 column_collection, name 

428 ) 

429 else: 

430 self.fields[logical_table][field] = column_collection[name] 

431 if postprocessing is not None: 

432 for element in postprocessing.iter_missing(columns): 

433 self.fields[element.name]["region"] = column_collection[ 

434 self.db.name_shrinker.shrink(columns.get_qualified_name(element.name, "region")) 

435 ] 

436 for name in postprocessing.spatial_expression_filtering: 

437 self.special[name] = column_collection[name] 

438 if postprocessing.check_validity_match_count: 

439 self.special[postprocessing.VALIDITY_MATCH_COUNT] = column_collection[ 

440 postprocessing.VALIDITY_MATCH_COUNT 

441 ] 

442 for name in special: 

443 self.special[name] = column_collection[name] 

444 return self 

445 

446 

447@dataclasses.dataclass(kw_only=True) 

448class SqlJoinsBuilder(SqlColumns): 

449 """A struct used to represent the FROM and WHERE clauses of an 

450 under-construction SQL SELECT query. 

451 

452 This object's methods frequently "consume" ``self``, by either returning 

453 it after modification or returning related copy that may share state with 

454 the original. Users should be careful never to use consumed instances, and 

455 are recommended to reuse the same variable name to make that hard to do 

456 accidentally. 

457 """ 

458 

459 from_clause: sqlalchemy.FromClause | None = None 

460 """SQLAlchemy representation of the FROM clause. 

461 

462 This is initialized to `None` but in almost all cases is immediately 

463 replaced. 

464 """ 

465 

466 where_terms: list[sqlalchemy.ColumnElement[bool]] = dataclasses.field(default_factory=list) 

467 """Sequence of WHERE clause terms to be combined with AND.""" 

468 

469 def copy(self) -> SqlJoinsBuilder: 

470 """Return a copy that can be safely mutated without affecting the 

471 original. 

472 """ 

473 return dataclasses.replace( 

474 self, 

475 where_terms=self.where_terms.copy(), 

476 dimension_keys=self.dimension_keys.copy(), 

477 fields=self.fields.copy(), 

478 timespans=self.timespans.copy(), 

479 special=self.special.copy(), 

480 ) 

481 

482 def extract_dimensions( 

483 self, 

484 dimensions: Iterable[str], 

485 *, 

486 column_collection: sqlalchemy.ColumnCollection | None = None, 

487 **kwargs: str, 

488 ) -> Self: 

489 """Add dimension key columns from `from_clause` into `dimension_keys`. 

490 

491 Parameters 

492 ---------- 

493 dimensions : `~collections.abc.Iterable` [ `str` ] 

494 Names of dimensions to include, assuming that their names in 

495 ``sql_columns`` are just the dimension names. 

496 column_collection : `sqlalchemy.ColumnCollection`, optional 

497 SQLAlchemy column collection to extract from. Defaults to 

498 ``self.from_clause.columns``. 

499 **kwargs : `str` 

500 Additional dimensions to include, with the names in ``sql_columns`` 

501 as keys and the actual dimension names as values. 

502 

503 Returns 

504 ------- 

505 self : `SqlJoinsBuilder` 

506 This `SqlJoinsBuilder` instance (never a copy). Provided to enable 

507 method chaining. 

508 """ 

509 if column_collection is None: 509 ↛ 512line 509 didn't jump to line 512 because the condition on line 509 was always true

510 assert self.from_clause is not None, "Cannot extract columns with no FROM clause." 

511 column_collection = self.from_clause.columns 

512 return super().extract_dimensions(dimensions, column_collection=column_collection, **kwargs) 

513 

514 def extract_columns( 

515 self, 

516 columns: qt.ColumnSet, 

517 postprocessing: Postprocessing | None = None, 

518 special: Iterable[str] = (), 

519 *, 

520 column_collection: sqlalchemy.ColumnCollection | None = None, 

521 ) -> Self: 

522 """Add columns from `from_clause` into `dimension_keys`. 

523 

524 Parameters 

525 ---------- 

526 columns : `.queries.tree.ColumnSet` 

527 Columns to include, assuming that 

528 `.queries.tree.ColumnSet.get_qualified_name` corresponds to the 

529 name used in ``sql_columns`` (after name shrinking). 

530 postprocessing : `Postprocessing`, optional 

531 Postprocessing object whose needed columns should also be included. 

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

533 Additional special columns to extract. 

534 column_collection : `sqlalchemy.ColumnCollection`, optional 

535 SQLAlchemy column collection to extract from. Defaults to 

536 ``self.from_clause.columns``. 

537 

538 Returns 

539 ------- 

540 self : `SqlJoinsBuilder` 

541 This `SqlJoinsBuilder` instance (never a copy). Provided to enable 

542 method chaining. 

543 """ 

544 if column_collection is None: 544 ↛ 547line 544 didn't jump to line 547 because the condition on line 544 was always true

545 assert self.from_clause is not None, "Cannot extract columns with no FROM clause." 

546 column_collection = self.from_clause.columns 

547 return super().extract_columns(columns, postprocessing, special, column_collection=column_collection) 

548 

549 def join(self, other: SqlJoinsBuilder) -> SqlJoinsBuilder: 

550 """Combine this `SqlJoinsBuilder` with another via an INNER JOIN on 

551 dimension keys. 

552 

553 This method consumes ``self``. 

554 

555 Parameters 

556 ---------- 

557 other : `SqlJoinsBuilder` 

558 Other join builder to combine with this one. 

559 

560 Returns 

561 ------- 

562 joined : `SqlJoinsBuilder` 

563 A `SqlJoinsBuilder` with all columns present in either operand, 

564 with its `from_clause` representing a SQL INNER JOIN where the 

565 dimension key columns common to both operands are constrained to be 

566 equal. If either operand does not have `from_clause`, the other's 

567 is used. The `where_terms` of the two operands are concatenated, 

568 representing a logical AND (with no attempt at deduplication). 

569 """ 

570 join_on: list[sqlalchemy.ColumnElement] = [] 

571 for dimension_name in other.dimension_keys.keys(): 

572 if dimension_name in self.dimension_keys: 

573 for column1, column2 in itertools.product( 

574 self.dimension_keys[dimension_name], other.dimension_keys[dimension_name] 

575 ): 

576 join_on.append(column1 == column2) 

577 self.dimension_keys[dimension_name].extend(other.dimension_keys[dimension_name]) 

578 if self.from_clause is None: 

579 self.from_clause = other.from_clause 

580 elif other.from_clause is not None: 580 ↛ 590line 580 didn't jump to line 590 because the condition on line 580 was always true

581 join_on_sql: sqlalchemy.ColumnElement[bool] 

582 match len(join_on): 

583 case 0: 

584 join_on_sql = sqlalchemy.true() 

585 case 1: 

586 (join_on_sql,) = join_on 

587 case _: 

588 join_on_sql = sqlalchemy.and_(*join_on) 

589 self.from_clause = self.from_clause.join(other.from_clause, onclause=join_on_sql) 

590 for logical_table, fields in other.fields.items(): 

591 self.fields[logical_table].update(fields) 

592 self.timespans.update(other.timespans) 

593 self.special.update(other.special) 

594 self.where_terms += other.where_terms 

595 return self 

596 

597 def where(self, *args: sqlalchemy.ColumnElement[bool]) -> SqlJoinsBuilder: 

598 """Add a WHERE clause term. 

599 

600 Parameters 

601 ---------- 

602 *args : `sqlalchemy.ColumnElement` 

603 SQL boolean column expressions to be combined with AND. 

604 

605 Returns 

606 ------- 

607 self : `SqlJoinsBuilder` 

608 This `SqlJoinsBuilder` instance (never a copy). Provided to enable 

609 method chaining. 

610 """ 

611 self.where_terms.extend(args) 

612 return self 

613 

614 def to_select_builder( 

615 self, 

616 columns: qt.ColumnSet, 

617 distinct: bool | Sequence[sqlalchemy.ColumnElement[Any]] = (), 

618 group_by: Sequence[sqlalchemy.ColumnElement[Any]] = (), 

619 ) -> SqlSelectBuilder: 

620 """Convert this join builder into a `SqlSelectBuilder` by providing 

621 SELECT clause columns and optional DISTINCT or GROUP BY clauses. 

622 

623 This method consumes ``self``. 

624 

625 Parameters 

626 ---------- 

627 columns : `~.queries.tree.ColumnSet` 

628 Regular columns to include in the SELECT clause. 

629 distinct : `bool` or `~collections.abc.Sequence` [ \ 

630 `sqlalchemy.ColumnElement` ], optional 

631 Specification of the DISTINCT clause (see 

632 `SqlSelectBuilder.distinct`). 

633 group_by : `~collections.abc.Sequence` [ \ 

634 `sqlalchemy.ColumnElement` ], optional 

635 Specification of the GROUP BY clause (see 

636 `SqlSelectBuilder.group_by`). 

637 

638 Returns 

639 ------- 

640 builder : `SqlSelectBuilder` 

641 New query builder. 

642 """ 

643 return SqlSelectBuilder( 

644 self, 

645 columns, 

646 distinct=distinct if type(distinct) is bool else tuple(distinct), 

647 group_by=tuple(group_by), 

648 ) 

649 

650 

651def make_table_spec( 

652 columns: qt.ColumnSet, db: Database, postprocessing: Postprocessing | None, *, make_indices: bool = False 

653) -> ddl.TableSpec: 

654 """Make a specification that can be used to create a table to store 

655 this query's outputs. 

656 

657 Parameters 

658 ---------- 

659 columns : `lsst.daf.butler.queries.tree.ColumnSet` 

660 Columns to include in the table. 

661 db : `Database` 

662 Database engine and connection abstraction. 

663 postprocessing : `Postprocessing` 

664 Struct representing post-query processing in Python, which may 

665 require additional columns in the query results. 

666 make_indices : `bool`, optional 

667 If `True` add indices for groups of columns. 

668 

669 Returns 

670 ------- 

671 spec : `.ddl.TableSpec` 

672 Table specification for this query's result columns (including 

673 those from `postprocessing` and `SqlJoinsBuilder.special`). 

674 """ 

675 indices = _make_table_indices(columns.dimensions) if make_indices else [] 

676 results = ddl.TableSpec( 

677 [ 

678 columns.get_column_spec(logical_table, field).to_sql_spec(name_shrinker=db.name_shrinker) 

679 for logical_table, field in columns 

680 ], 

681 indexes=indices, 

682 ) 

683 if postprocessing: 

684 for element in postprocessing.iter_missing(columns): 

685 results.fields.add( 

686 ddl.FieldSpec.for_region( 

687 db.name_shrinker.shrink(columns.get_qualified_name(element.name, "region")) 

688 ) 

689 ) 

690 for name in postprocessing.spatial_expression_filtering: 

691 results.fields.add(ddl.FieldSpec(name, dtype=sqlalchemy.types.LargeBinary, nullable=True)) 

692 if not results.fields: 692 ↛ 693line 692 didn't jump to line 693 because the condition on line 692 was never true

693 results.fields.add( 

694 ddl.FieldSpec(name=SqlSelectBuilder.EMPTY_COLUMNS_NAME, dtype=SqlSelectBuilder.EMPTY_COLUMNS_TYPE) 

695 ) 

696 return results 

697 

698 

699def _make_table_indices(dimensions: DimensionGroup) -> list[ddl.IndexSpec]: 

700 index_columns: list[SortedSequenceSet] = [] 

701 for dimension in dimensions.required: 

702 minimal_group = dimensions.universe[dimension].minimal_group.required 

703 

704 for idx in range(len(index_columns)): 

705 if index_columns[idx] <= minimal_group: 

706 index_columns[idx] = minimal_group 

707 break 

708 else: 

709 index_columns.append(minimal_group) 

710 

711 return [ddl.IndexSpec(*columns) for columns in index_columns]