Coverage for python/lsst/daf/butler/registry/queries/expressions/check.py: 27%

195 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-10-11 01:27 -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 

29__all__ = ( 

30 "CheckVisitor", 

31 "InspectionSummary", 

32 "InspectionVisitor", 

33) 

34 

35import dataclasses 

36from collections.abc import Mapping, Sequence, Set 

37from typing import TYPE_CHECKING, Any 

38from uuid import UUID 

39 

40from ...._column_tags import DatasetColumnTag, DimensionKeyColumnTag, DimensionRecordColumnTag 

41from ....dimensions import DataCoordinate, DataIdValue, Dimension, DimensionGroup, DimensionUniverse 

42from ..._exceptions import UserExpressionError 

43from .categorize import ExpressionConstant, categorizeConstant, categorizeElementId 

44from .normalForm import NormalForm, NormalFormVisitor 

45from .parser import Node, TreeVisitor 

46 

47if TYPE_CHECKING: 

48 import astropy.time 

49 

50 from lsst.daf.relation import ColumnTag 

51 

52 

53@dataclasses.dataclass 

54class InspectionSummary: 

55 """Base class for objects used by `CheckVisitor` and `InspectionVisitor` 

56 to gather information about a parsed expression. 

57 """ 

58 

59 def update(self, other: InspectionSummary) -> None: 

60 """Update ``self`` with all dimensions and columns from ``other``. 

61 

62 Parameters 

63 ---------- 

64 other : `InspectionSummary` 

65 The other summary object. 

66 """ 

67 self.dimensions.update(other.dimensions) 

68 for element, columns in other.columns.items(): 

69 self.columns.setdefault(element, set()).update(columns) 

70 self.hasIngestDate = self.hasIngestDate or other.hasIngestDate 

71 

72 dimensions: set[str] = dataclasses.field(default_factory=set) 

73 """Names of dimensions whose primary keys or dependencies were referenced 

74 anywhere in this branch (`set` [ `str` ]). 

75 """ 

76 

77 columns: dict[str, set[str]] = dataclasses.field(default_factory=dict) 

78 """Names of dimension element tables whose columns were referenced anywhere 

79 in this branch (`dict` [ `str`, `set` [ `str` ] ]). 

80 """ 

81 

82 hasIngestDate: bool = False 

83 """Whether this expression includes the special dataset ingest date 

84 identifier (`bool`). 

85 """ 

86 

87 def make_column_tag_set(self, dataset_type_name: str | None) -> set[ColumnTag]: 

88 """Transform the columns captured here into a set of `ColumnTag` 

89 objects. 

90 

91 Parameters 

92 ---------- 

93 dataset_type_name : `str` or `None` 

94 Name of the dataset type to assume for unqualified dataset columns, 

95 or `None` to reject any such identifiers. 

96 

97 Returns 

98 ------- 

99 tag_set : `set` [ `ColumnTag` ] 

100 Set of categorized column tags. 

101 """ 

102 result: set[ColumnTag] = set() 

103 if self.hasIngestDate: 

104 if dataset_type_name is None: 

105 raise UserExpressionError( 

106 "Expression requires an ingest date, which requires exactly one dataset type." 

107 ) 

108 result.add(DatasetColumnTag(dataset_type_name, "ingest_date")) 

109 result.update(DimensionKeyColumnTag.generate(self.dimensions)) 

110 for dimension_element, columns in self.columns.items(): 

111 result.update(DimensionRecordColumnTag.generate(dimension_element, columns)) 

112 return result 

113 

114 

115@dataclasses.dataclass 

116class TreeSummary(InspectionSummary): 

117 """Result object used by `InspectionVisitor` to gather information about 

118 a parsed expression. 

119 

120 Notes 

121 ----- 

122 TreeSummary adds attributes that allow dimension equivalence expressions 

123 (e.g. "tract=4") to be recognized when they appear in simple contexts 

124 (surrounded only by ANDs and ORs). When `InspectionVisitor` is used on its 

125 own (i.e. when ``check=False`` in the query code), these don't do anything, 

126 but they don't cost much, either. They are used by `CheckVisitor` when it 

127 delegates to `InspectionVisitor` to see what governor dimension values are 

128 set in a branch of the normal-form expression. 

129 """ 

130 

131 def merge(self, other: TreeSummary, isEq: bool = False) -> TreeSummary: 

132 """Merge ``other`` into ``self``, making ``self`` a summary of both 

133 expression tree branches. 

134 

135 Parameters 

136 ---------- 

137 other : `TreeSummary` 

138 The other summary object. 

139 isEq : `bool`, optional 

140 If `True` (`False` is default), these summaries are being combined 

141 via the equality operator. 

142 

143 Returns 

144 ------- 

145 self : `TreeSummary` 

146 The merged summary (updated in-place). 

147 """ 

148 self.update(other) 

149 if isEq and self.isDataIdKeyOnly() and other.isDataIdValueOnly(): 

150 self.dataIdValue = other.dataIdValue 

151 elif isEq and self.isDataIdValueOnly() and other.isDataIdKeyOnly(): 

152 self.dataIdKey = other.dataIdKey 

153 else: 

154 self.dataIdKey = None 

155 self.dataIdValue = None 

156 return self 

157 

158 def isDataIdKeyOnly(self) -> bool: 

159 """Test whether this branch is _just_ a data ID key identifier.""" 

160 return self.dataIdKey is not None and self.dataIdValue is None 

161 

162 def isDataIdValueOnly(self) -> bool: 

163 """Test whether this branch is _just_ a literal value that may be 

164 used as the value in a data ID key-value pair. 

165 """ 

166 return self.dataIdKey is None and self.dataIdValue is not None 

167 

168 dataIdKey: Dimension | None = None 

169 """A `Dimension` that is (if `dataIdValue` is not `None`) or may be 

170 (if `dataIdValue` is `None`) fully identified by a literal value in this 

171 branch. 

172 """ 

173 

174 dataIdValue: str | None = None 

175 """A literal value that constrains (if `dataIdKey` is not `None`) or may 

176 constrain (if `dataIdKey` is `None`) a dimension in this branch. 

177 

178 This is always a `str` or `None`, but it may need to be coerced to `int` 

179 to reflect the actual user intent. 

180 """ 

181 

182 

183class InspectionVisitor(TreeVisitor[TreeSummary]): 

184 """Implements TreeVisitor to identify dimension elements that need 

185 to be included in a query, prior to actually constructing a SQLAlchemy 

186 WHERE clause from it. 

187 

188 Parameters 

189 ---------- 

190 universe : `DimensionUniverse` 

191 All known dimensions. 

192 bind : `~collections.abc.Mapping` [ `str`, `object` ] 

193 Mapping containing literal values that should be injected into the 

194 query expression, keyed by the identifiers they replace. 

195 """ 

196 

197 def __init__(self, universe: DimensionUniverse, bind: Mapping[str, Any]): 

198 self.universe = universe 

199 self.bind = bind 

200 

201 def visitNumericLiteral(self, value: str, node: Node) -> TreeSummary: 

202 # Docstring inherited from TreeVisitor.visitNumericLiteral 

203 return TreeSummary(dataIdValue=value) 

204 

205 def visitStringLiteral(self, value: str, node: Node) -> TreeSummary: 

206 # Docstring inherited from TreeVisitor.visitStringLiteral 

207 return TreeSummary(dataIdValue=value) 

208 

209 def visitTimeLiteral(self, value: astropy.time.Time, node: Node) -> TreeSummary: 

210 # Docstring inherited from TreeVisitor.visitTimeLiteral 

211 return TreeSummary() 

212 

213 def visitUuidLiteral(self, value: UUID, node: Node) -> TreeSummary: 

214 # Docstring inherited from TreeVisitor.visitUuidLiteral 

215 return TreeSummary() 

216 

217 def visitIdentifier(self, name: str, node: Node) -> TreeSummary: 

218 # Docstring inherited from TreeVisitor.visitIdentifier 

219 if name in self.bind: 

220 return self.visitBind(name, node) 

221 

222 constant = categorizeConstant(name) 

223 if constant is ExpressionConstant.INGEST_DATE: 

224 return TreeSummary(hasIngestDate=True) 

225 elif constant is ExpressionConstant.NULL: 

226 return TreeSummary() 

227 assert constant is None, "Enum variant conditionals should be exhaustive." 

228 element, column = categorizeElementId(self.universe, name) 

229 if column is None: 

230 assert isinstance(element, Dimension) 

231 return TreeSummary( 

232 dimensions=set(element.minimal_group.names), 

233 dataIdKey=element, 

234 ) 

235 else: 

236 return TreeSummary(dimensions=set(element.minimal_group.names), columns={element.name: {column}}) 

237 

238 def visitBind(self, name: str, node: Node) -> TreeSummary: 

239 if name not in self.bind: 

240 raise UserExpressionError(f"Name {name!r} is not in the bind map.") 

241 value = self.bind[name] 

242 match value: 

243 case list() | tuple() | Set(): 

244 # This can happen on rhs of IN operator, if there is only one 

245 # element in the list then take it. 

246 match len(value): 

247 case 1: 

248 (value,) = value 

249 return TreeSummary(dataIdValue=value) 

250 case _: 

251 return TreeSummary() 

252 case _: 

253 return TreeSummary(dataIdValue=value) 

254 

255 def visitUnaryOp(self, operator: str, operand: TreeSummary, node: Node) -> TreeSummary: 

256 # Docstring inherited from TreeVisitor.visitUnaryOp 

257 return operand 

258 

259 def visitBinaryOp(self, operator: str, lhs: TreeSummary, rhs: TreeSummary, node: Node) -> TreeSummary: 

260 # Docstring inherited from TreeVisitor.visitBinaryOp 

261 return lhs.merge(rhs, isEq=(operator == "=")) 

262 

263 def visitIsIn(self, lhs: TreeSummary, values: list[TreeSummary], not_in: bool, node: Node) -> TreeSummary: 

264 # Docstring inherited from TreeVisitor.visitIsIn 

265 for v in values: 

266 lhs.merge(v) 

267 return lhs 

268 

269 def visitParens(self, expression: TreeSummary, node: Node) -> TreeSummary: 

270 # Docstring inherited from TreeVisitor.visitParens 

271 return expression 

272 

273 def visitTupleNode(self, items: tuple[TreeSummary, ...], node: Node) -> TreeSummary: 

274 # Docstring inherited from base class 

275 result = TreeSummary() 

276 for i in items: 

277 result.merge(i) 

278 return result 

279 

280 def visitRangeLiteral(self, start: int, stop: int, stride: int | None, node: Node) -> TreeSummary: 

281 # Docstring inherited from TreeVisitor.visitRangeLiteral 

282 return TreeSummary() 

283 

284 def visitPointNode(self, ra: TreeSummary, dec: TreeSummary, node: Node) -> TreeSummary: 

285 # Docstring inherited from base class 

286 return TreeSummary() 

287 

288 def visitCircleNode( 

289 self, ra: TreeSummary, dec: TreeSummary, radius: TreeSummary, node: Node 

290 ) -> TreeSummary: 

291 # Docstring inherited from base class 

292 return TreeSummary() 

293 

294 def visitPolygonNode(self, vertices: list[tuple[TreeSummary, TreeSummary]], node: Node) -> TreeSummary: 

295 # Docstring inherited from base class 

296 return TreeSummary() 

297 

298 def visitRegionNode(self, pos: TreeSummary, node: Node) -> TreeSummary: 

299 # Docstring inherited from base class 

300 return TreeSummary() 

301 

302 def visitBoxNode( 

303 self, ra: TreeSummary, dec: TreeSummary, width: TreeSummary, height: TreeSummary, node: Node 

304 ) -> TreeSummary: 

305 # Docstring inherited from base class 

306 return TreeSummary() 

307 

308 def visitGlobNode(self, expression: TreeSummary, pattern: TreeSummary, node: Node) -> TreeSummary: 

309 # Docstring inherited from base class 

310 # pattern is a literal, but expression should refer to a dimension. 

311 return expression 

312 

313 

314@dataclasses.dataclass 

315class InnerSummary(InspectionSummary): 

316 """Result object used by `CheckVisitor` to gather referenced dimensions 

317 and tables from an inner group of AND'd together expression branches, and 

318 check them for consistency and completeness. 

319 """ 

320 

321 dimension_values: dict[str, DataIdValue] = dataclasses.field(default_factory=dict) 

322 """Mapping containing the values of all dimensions that are equated with 

323 literal values in this expression branch. 

324 """ 

325 

326 defaultsNeeded: set[str] = dataclasses.field(default_factory=set) 

327 """Governor dimensions whose values are needed by the query, not provided 

328 in the query itself, and present in the default data ID. 

329 

330 These should be added to the query's data ID when finalizing the WHERE 

331 clause. 

332 """ 

333 

334 

335@dataclasses.dataclass 

336class OuterSummary(InspectionSummary): 

337 """Result object used by `CheckVisitor` to gather referenced dimensions, 

338 tables, and governor dimension values from the entire expression. 

339 """ 

340 

341 dimension_constraints: dict[str, set[DataIdValue]] = dataclasses.field(default_factory=dict) 

342 """Mapping containing all values that appear in this expression for 

343 dimensions relevant to the query. 

344 

345 Dimensions that are absent from this dict are not constrained by this 

346 expression. 

347 """ 

348 

349 defaultsNeeded: set[str] = dataclasses.field(default_factory=set) 

350 """Governor dimensions whose values are needed by the query, not provided 

351 in the query itself, and present in the default data ID. 

352 

353 These should be added to the query's data ID when finalizing the WHERE 

354 clause. 

355 """ 

356 

357 

358class CheckVisitor(NormalFormVisitor[TreeSummary, InnerSummary, OuterSummary]): 

359 """An implementation of `NormalFormVisitor` that identifies the dimensions 

360 and tables that need to be included in a query while performing some checks 

361 for completeness and consistency. 

362 

363 Parameters 

364 ---------- 

365 dataId : `DataCoordinate` 

366 Dimension values that are fully known in advance. 

367 dimensions : `DimensionGroup` 

368 The dimensions the query would include in the absence of this 

369 expression. 

370 bind : `~collections.abc.Mapping` [ `str`, `object` ] 

371 Mapping containing literal values that should be injected into the 

372 query expression, keyed by the identifiers they replace. 

373 defaults : `DataCoordinate` 

374 A data ID containing default for governor dimensions. 

375 allow_orphans : `bool`, optional 

376 If `True`, permit expressions to refer to dimensions without providing 

377 a value for their governor dimensions (e.g. referring to a visit 

378 without an instrument). Should be left to default to `False` in 

379 essentially all new code. 

380 """ 

381 

382 def __init__( 

383 self, 

384 dataId: DataCoordinate, 

385 dimensions: DimensionGroup, 

386 bind: Mapping[str, Any], 

387 defaults: DataCoordinate, 

388 allow_orphans: bool = False, 

389 ): 

390 self.dataId = dataId 

391 self.dimensions = dimensions 

392 self.defaults = defaults 

393 self._branchVisitor = InspectionVisitor(dataId.universe, bind) 

394 self._allow_orphans = allow_orphans 

395 

396 @property 

397 def universe(self) -> DimensionUniverse: 

398 """Object that defines all dimensions.""" 

399 return self.dimensions.universe 

400 

401 def visitBranch(self, node: Node) -> TreeSummary: 

402 # Docstring inherited from NormalFormVisitor. 

403 return node.visit(self._branchVisitor) 

404 

405 def visitInner(self, branches: Sequence[TreeSummary], form: NormalForm) -> InnerSummary: 

406 # Docstring inherited from NormalFormVisitor. 

407 # Disjunctive normal form means inner branches are AND'd together... 

408 assert form is NormalForm.DISJUNCTIVE 

409 # ...and that means each branch we iterate over together below 

410 # constrains the others, and they all need to be consistent. Moreover, 

411 # because outer branches are OR'd together, we also know that if 

412 # something is missing from one of these branches (like a governor 

413 # dimension value like the instrument or skymap needed to interpret a 

414 # visit or tract number), it really is missing, because there's no way 

415 # some other inner branch can constraint it. 

416 # 

417 # That is, except the data ID the visitor was passed at construction; 

418 # that's AND'd to the entire expression later, and thus it affects all 

419 # branches. To take care of that, we add any governor values it 

420 # contains to the summary in advance. 

421 summary = InnerSummary() 

422 summary.dimension_values.update(self.dataId.mapping) 

423 # Finally, we loop over those branches. 

424 for branch in branches: 

425 # Update the sets of dimensions and columns we've seen anywhere in 

426 # the expression in any context. 

427 summary.update(branch) 

428 # Test whether this branch has a form like '<dimension>=<value>' 

429 # (or equivalent; categorizeIdentifier is smart enough to see that 

430 # e.g. 'detector.id=4' is equivalent to 'detector=4'). If so, 

431 # remember that we've constrained it on this branch to later make 

432 # sure it's consistent with any other constraints on any other 

433 # branches its AND'd with. 

434 if branch.dataIdKey is not None and branch.dataIdValue is not None: 

435 new_value = branch.dataIdKey.primaryKey.getPythonType()(branch.dataIdValue) 

436 value = summary.dimension_values.setdefault(branch.dataIdKey.name, new_value) 

437 if value != new_value: 

438 # Expression says something like "instrument='HSC' AND 

439 # instrument='DECam'", or data ID has one and expression 

440 # has the other. 

441 if branch.dataIdKey.name in self.dataId: 

442 raise UserExpressionError( 

443 f"Conflict between expression containing {branch.dataIdKey.name}={new_value!r} " 

444 f"and data ID with {branch.dataIdKey.name}={value!r}." 

445 ) 

446 else: 

447 raise UserExpressionError( 

448 f"Conflicting literal values for {branch.dataIdKey.name} in expression: " 

449 f"{value!r} != {branch.dataIdValue!r}." 

450 ) 

451 # Now that we know which governor values we've constrained, see if any 

452 # are missing, i.e. if the expression contains something like "visit=X" 

453 # without saying what instrument that visit corresponds to. This rules 

454 # out a lot of accidents, but it also rules out possibly-legitimate 

455 # multi-instrument queries like "visit.seeing < 0.7". But it's not 

456 # unreasonable to ask the user to be explicit about the instruments 

457 # they want to consider to work around this restriction, and that's 

458 # what we do. Note that if someone does write an expression like 

459 # 

460 # (instrument='HSC' OR instrument='DECam') AND visit.seeing < 0.7 

461 # 

462 # then in disjunctive normal form that will become 

463 # 

464 # (instrument='HSC' AND visit.seeing < 0.7) 

465 # OR (instrument='DECam' AND visit.seeing < 0.7) 

466 # 

467 # i.e. each instrument will get its own outer branch and the logic here 

468 # still works (that sort of thing is why we convert to normal form, 

469 # after all). 

470 governorsNeededInBranch: set[str] = set() 

471 for dimension in summary.dimensions: 

472 governorsNeededInBranch.update(self.universe.dimensions[dimension].minimal_group.governors) 

473 if not governorsNeededInBranch.issubset(summary.dimension_values.keys()): 

474 missing = governorsNeededInBranch - summary.dimension_values.keys() 

475 if missing <= self.defaults.dimensions.required: 

476 summary.defaultsNeeded.update(missing) 

477 elif not self._allow_orphans: 

478 still_missing = missing - self.defaults.dimensions.names 

479 raise UserExpressionError( 

480 f"No value(s) for governor dimensions {still_missing} in expression " 

481 "that references dependent dimensions. 'Governor' dimensions must always be specified " 

482 "completely in either the query expression (via simple 'name=<value>' terms, not 'IN' " 

483 "terms) or in a data ID passed to the query method." 

484 ) 

485 return summary 

486 

487 def visitOuter(self, branches: Sequence[InnerSummary], form: NormalForm) -> OuterSummary: 

488 # Docstring inherited from NormalFormVisitor. 

489 # Disjunctive normal form means outer branches are OR'd together. 

490 assert form is NormalForm.DISJUNCTIVE 

491 summary = OuterSummary() 

492 if branches: 

493 # Iterate over branches in first pass to gather all dimensions and 

494 # columns referenced. This aggregation is for the full query, so 

495 # we don't care whether things are joined by AND or OR (or + or -, 

496 # etc). Also gather the set of dimensions directly constrained or 

497 # pulled from defaults in _all_ branches. This is the set we will 

498 # be able to bound overall; any dimensions not referenced by even 

499 # one branch could be unbounded. 

500 dimensions_in_all_branches = set(self.universe.dimensions.names) 

501 for branch in branches: 

502 summary.update(branch) 

503 summary.defaultsNeeded.update(branch.defaultsNeeded) 

504 dimensions_in_all_branches.intersection_update(branch.dimension_values) 

505 # Go back through and set up the dimension bounds. 

506 summary.dimension_constraints.update( 

507 {dimension: set() for dimension in dimensions_in_all_branches} 

508 ) 

509 for dim in dimensions_in_all_branches: 

510 for branch in branches: 

511 summary.dimension_constraints[dim].add(branch.dimension_values[dim]) 

512 # See if we've referenced any dimensions that weren't in the original 

513 # query graph; if so, we update that to include them. This is what 

514 # lets a user say "tract=X" on the command line (well, "skymap=Y AND 

515 # tract=X" - logic in visitInner checks for that) when running a task 

516 # like ISR that has nothing to do with skymaps. 

517 if not summary.dimensions.issubset(self.dimensions.names): 

518 self.dimensions = self.universe.conform(summary.dimensions | self.dimensions.names) 

519 for dimension, values in summary.dimension_constraints.items(): 

520 if dimension in summary.defaultsNeeded: 

521 # One branch contained an explicit value for this dimension 

522 # while another needed to refer to the default data ID. 

523 # Even if these refer to the same value, that inconsistency 

524 # probably indicates user error. 

525 raise UserExpressionError( 

526 f"Governor dimension {dimension} is explicitly " 

527 f"constrained to {values} in one or more branches of " 

528 "this query where expression, but is left to default " 

529 f"to {self.defaults[dimension]!r} in another branch. " 

530 "Defaults and explicit constraints cannot be mixed." 

531 ) 

532 # If any default data ID values were needed, update self.dataId with 

533 # them, and then update the governor restriction with them. 

534 if summary.defaultsNeeded: 

535 defaultsNeededGraph = self.universe.conform(summary.defaultsNeeded) 

536 self.dataId = self.dataId.union(self.defaults.subset(defaultsNeededGraph)) 

537 for dimension in summary.defaultsNeeded: 

538 summary.dimension_constraints[dimension] = {self.defaults[dimension]} 

539 

540 return summary