Coverage for src/pyadql/ast_nodes.py: 99%

191 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-23 22:58 +0000

1# PyADQL - pyadql turns an ADQL query into an AST 

2# Copyright (C) 2026 - Centre National d'Etudes Spatiales (Jean-Christophe Malapert for PDSSP) 

3# This file is part of PyADQL <https://gitlab.cnes.fr/pdssp/common/pyadql> 

4# SPDX-License-Identifier: Apache-2.0 

5""" 

6AST nodes for ADQL 2.1. 

7 

8All classes are plain dataclasses with no dependency on Lark, so the AST can 

9be manipulated / serialized independently of the parser. 

10 

11See docs/AST.md for a guided tour (diagram, node reference table, worked 

12example, and a tree-walking recipe) -- this module's docstrings cover each 

13node individually; docs/AST.md covers how they fit together. 

14 

15Top-level shape 

16---------------- 

17`parse_adql()` returns a `SelectExpression`, matching Annex A's own 

18structure (`query_specification ::= [with_clause] select_expression`, with 

19`with_clause` folded directly onto `SelectExpression` here for ergonomics -- 

20there's no other content at that level, so nesting a separate wrapper node 

21just for an optional WITH clause would only add friction): 

22 

23 SelectExpression 

24 with_clause: Optional[List[CTE]] -- WITH ... AS (...), 2.1 only 

25 body: Query | SetOperation -- the actual result rows 

26 order_by: Optional[List[SortItem]] -- applies to the WHOLE combined result 

27 offset: Optional[int] -- ditto (2.1 only) 

28 

29This mirrors a real, deliberate ADQL 2.1 change from 2.0: ORDER BY / OFFSET 

30now apply once, to the combined result of any UNION/EXCEPT/INTERSECT, never 

31to an individual unparenthesized SELECT -- so they live on `SelectExpression`, 

32not on `Query`. For the common case of a single plain SELECT with no set 

33operations, `tree.body` is a `Query` holding the familiar 

34distinct/top/select_list/from_clause/where/group_by/having fields; `tree` 

35itself holds order_by/offset/with_clause. 

36""" 

37 

38from __future__ import annotations 

39 

40from dataclasses import dataclass, field 

41from typing import List, Optional, Union 

42 

43 

44class Node: 

45 """Marker base class for all AST nodes.""" 

46 

47 pass 

48 

49 

50# --------------------------------------------------------------------------- 

51# Top-level query 

52# --------------------------------------------------------------------------- 

53@dataclass 

54class SelectExpression(Node): 

55 """Root node returned by parse_adql(). See module docstring for the 

56 full shape. Also the node produced for *any* parenthesized subquery 

57 (a CTE's body, a derived table, a scalar subquery, EXISTS's/IN's 

58 subquery form) -- the same wrapper is reused everywhere a full ADQL 

59 "SELECT ... [ORDER BY ...] [OFFSET ...]" can appear. 

60 

61 Example -- "SELECT id FROM t1 UNION SELECT id FROM t2 ORDER BY id": 

62 SelectExpression( 

63 body=SetOperation(op='UNION', left=Query(...), right=Query(...)), 

64 order_by=[SortItem(expr=ColumnRef(['id']), direction='ASC')], 

65 ) 

66 """ 

67 

68 body: Query | SetOperation 

69 order_by: list[SortItem] | None = None 

70 offset: int | None = None 

71 with_clause: list[CTE] | None = None # WITH ... AS (...), ADQL 2.1 only 

72 

73 

74@dataclass 

75class CTE(Node): 

76 """One entry of a WITH clause: `name AS (query)` (ADQL 2.1 only). 

77 `query` is itself a full SelectExpression, so a CTE can use ORDER BY / 

78 OFFSET internally just like any subquery. 

79 

80 Example -- "WITH recent AS (SELECT id FROM t WHERE d > 2020) ...": 

81 CTE(name='recent', query=SelectExpression(body=Query(...))) 

82 """ 

83 

84 name: str 

85 query: SelectExpression 

86 

87 

88@dataclass 

89class Query(Node): 

90 """A single `SELECT ... FROM ... [WHERE] [GROUP BY] [HAVING]` (Annex A's 

91 <select_query>). Does NOT carry ORDER BY / OFFSET -- see SelectExpression 

92 above for why. This is what you get at `tree.body` for any plain query 

93 without UNION/EXCEPT/INTERSECT. 

94 

95 Example -- "SELECT DISTINCT TOP 10 ra FROM t WHERE ra > 5": 

96 Query(distinct=True, top=10, 

97 select_list=[SelectItem(expr=ColumnRef(['ra']))], 

98 from_clause=[TableRef(name='t')], 

99 where=BinaryOp('>', ColumnRef(['ra']), NumberLiteral(5.0))) 

100 """ 

101 

102 distinct: bool 

103 top: int | None 

104 select_list: Star | list[SelectItem] 

105 from_clause: list[TableSource] 

106 where: Expr | None = None 

107 group_by: list[Expr] | None = None 

108 having: Expr | None = None 

109 

110 

111@dataclass 

112class SetOperation(Node): 

113 """`UNION` / `EXCEPT` / `INTERSECT` combining two queries. `distinct` is 

114 True unless the query used the `ALL` keyword (`UNION ALL` keeps 

115 duplicate rows; plain `UNION` removes them). `left`/`right` are usually 

116 `Query`, but can themselves be a `SetOperation` (chained set ops, e.g. 

117 "A UNION B INTERSECT C") or a `SelectExpression` when that operand was 

118 explicitly parenthesized (which lets it carry its own ORDER BY/OFFSET). 

119 

120 Example -- "SELECT id FROM t1 UNION ALL SELECT id FROM t2": 

121 SetOperation(op='UNION', distinct=False, left=Query(...), right=Query(...)) 

122 """ 

123 

124 op: str # UNION | EXCEPT | INTERSECT 

125 distinct: bool 

126 left: Query | SetOperation | SelectExpression 

127 right: Query | SetOperation | SelectExpression 

128 

129 

130@dataclass 

131class Star(Node): 

132 """`SELECT *` -- no columns to enumerate, all of them are selected.""" 

133 

134 pass 

135 

136 

137@dataclass 

138class SelectItem(Node): 

139 """One entry of the SELECT list: an expression, plus an optional alias. 

140 

141 Example -- "ra AS right_ascension": 

142 SelectItem(expr=ColumnRef(['ra']), alias='right_ascension') 

143 """ 

144 

145 expr: Expr 

146 alias: str | None = None 

147 

148 

149@dataclass 

150class SortItem(Node): 

151 """One entry of an ORDER BY list: an expression plus a direction. 

152 

153 Example -- "ORDER BY mag DESC": SortItem(expr=ColumnRef(['mag']), direction='DESC') 

154 """ 

155 

156 expr: Expr 

157 direction: str = "ASC" # ASC | DESC 

158 

159 

160# --------------------------------------------------------------------------- 

161# Table sources / joins 

162# --------------------------------------------------------------------------- 

163@dataclass 

164class TableRef(Node): 

165 """A plain table reference in FROM, e.g. "gaiadr3.gaia_source AS g". 

166 `name` keeps the schema-qualified name as one dotted string (it is not 

167 split into separate schema/table fields).""" 

168 

169 name: str # may contain schema.table 

170 alias: str | None = None 

171 columns: list[str] | None = None # column renaming (rare) 

172 

173 

174@dataclass 

175class DerivedTable(Node): 

176 """A subquery used as a table in FROM: `(SELECT ...) AS alias`. 

177 `subquery` is a full SelectExpression (it can have its own ORDER BY, 

178 for instance).""" 

179 

180 subquery: SelectExpression 

181 alias: str | None = None 

182 columns: list[str] | None = None 

183 

184 

185@dataclass 

186class Join(Node): 

187 """One JOIN in the FROM clause. `left`/`right` are the two table 

188 sources being joined -- either can itself be a `Join`, which is how a 

189 chain like "a JOIN b JOIN c" becomes a left-leaning tree: 

190 Join(left=Join(left=a, right=b), right=c). 

191 

192 Example -- "t1 AS a LEFT JOIN t2 AS b ON a.id = b.id": 

193 Join(left=TableRef('t1', 'a'), right=TableRef('t2', 'b'), 

194 join_type='LEFT', on=BinaryOp('=', ColumnRef(['a','id']), ColumnRef(['b','id']))) 

195 """ 

196 

197 left: TableSource 

198 right: TableSource 

199 join_type: str = "INNER" # INNER | LEFT | RIGHT | FULL 

200 natural: bool = False 

201 on: Expr | None = None 

202 using: list[str] | None = None 

203 

204 

205TableSource = Union[TableRef, DerivedTable, Join] 

206 

207 

208# --------------------------------------------------------------------------- 

209# Expressions - logic / predicates 

210# --------------------------------------------------------------------------- 

211@dataclass 

212class BinaryOp(Node): 

213 """Any operator taking two operands: boolean (`AND`/`OR`), arithmetic 

214 (`+ - * /`), string (`||`), or comparison (`= != < <= > >= <>`) -- one 

215 class covers all of them since they share the exact same shape; check 

216 `op` to know which one you're looking at. 

217 

218 Example -- "ra > 10": BinaryOp(op='>', left=ColumnRef(['ra']), right=NumberLiteral(10.0)) 

219 """ 

220 

221 op: str # AND, OR, +, -, *, /, ||, =, !=, <, <=, >, >=, <> 

222 left: Expr 

223 right: Expr 

224 

225 

226@dataclass 

227class UnaryOp(Node): 

228 """A prefix operator with a single operand: `NOT <condition>`, or unary 

229 `-x` / `+x`. Note `NOT x BETWEEN a AND b` (this class, wrapping a 

230 `Between`) is a different AST shape from `x NOT BETWEEN a AND b` 

231 (a `Between` with `negated=True`) -- both are valid ADQL, meaning the 

232 same thing, but the negation sits in a different place in the tree. 

233 

234 Example -- "NOT (a = 1)": UnaryOp(op='NOT', operand=BinaryOp('=', ...)) 

235 """ 

236 

237 op: str # NOT, -, + 

238 operand: Expr 

239 

240 

241@dataclass 

242class Between(Node): 

243 """`expr [NOT] BETWEEN low AND high`. 

244 

245 Example -- "x BETWEEN 1 AND 10": 

246 Between(expr=ColumnRef(['x']), low=NumberLiteral(1.0), high=NumberLiteral(10.0)) 

247 """ 

248 

249 expr: Expr 

250 low: Expr 

251 high: Expr 

252 negated: bool = False 

253 

254 

255@dataclass 

256class InPredicate(Node): 

257 """`expr [NOT] IN (...)`. `values` is either a plain list of expressions 

258 (`IN (1, 2, 3)`) or a `SelectExpression` (`IN (SELECT id FROM other)`) -- 

259 check `isinstance(values, list)` to tell which form you have.""" 

260 

261 expr: Expr 

262 values: list[Expr] | SelectExpression 

263 negated: bool = False 

264 

265 

266@dataclass 

267class Like(Node): 

268 """`expr [NOT] LIKE pattern` or `expr [NOT] ILIKE pattern`. 

269 `case_insensitive=True` marks ILIKE (case-insensitive matching, ADQL 2.1) 

270 as opposed to plain LIKE.""" 

271 

272 expr: Expr 

273 pattern: Expr 

274 negated: bool = False 

275 case_insensitive: bool = False # True for ILIKE (ADQL 2.1) 

276 

277 

278@dataclass 

279class IsNull(Node): 

280 """`expr IS [NOT] NULL`.""" 

281 

282 expr: Expr 

283 negated: bool = False 

284 

285 

286@dataclass 

287class Exists(Node): 

288 """`EXISTS (subquery)` -- true iff the subquery returns at least one row.""" 

289 

290 subquery: SelectExpression 

291 

292 

293@dataclass 

294class Contains(Node): 

295 """`CONTAINS(geom1, geom2)` -- a numeric geometry predicate: 1 if geom1 

296 is (fully) contained within geom2, 0 otherwise. Since it returns a 

297 number rather than a boolean, it's normally compared explicitly, e.g. 

298 "CONTAINS(POINT(...), CIRCLE(...)) = 1" -- that comparison shows up as 

299 an ordinary `BinaryOp('=', Contains(...), NumberLiteral(1.0))` wrapping 

300 this node, not as a special "contains predicate" of its own.""" 

301 

302 geom1: Expr 

303 geom2: Expr 

304 

305 

306@dataclass 

307class Intersects(Node): 

308 """`INTERSECTS(geom1, geom2)` -- 1 if the two geometries overlap at all, 

309 0 otherwise. Same usage pattern as `Contains` (normally wrapped in an 

310 explicit `= 1` comparison).""" 

311 

312 geom1: Expr 

313 geom2: Expr 

314 

315 

316# --------------------------------------------------------------------------- 

317# Expressions - values 

318# --------------------------------------------------------------------------- 

319@dataclass 

320class ColumnRef(Node): 

321 """A column reference, optionally qualified by a table alias: 

322 `parts` holds each dot-separated segment in order, e.g. `['t', 'ra']` 

323 for `t.ra`, or just `['ra']` for a bare column name. `str(ref)` 

324 reconstructs the dotted form.""" 

325 

326 parts: list[str] # e.g. ['t', 'ra'] for t.ra 

327 

328 def __str__(self): 

329 return ".".join(self.parts) 

330 

331 

332@dataclass 

333class NumberLiteral(Node): 

334 """A numeric literal, e.g. `42` or `3.14e10`. Always stored as `float`, 

335 even for integer-looking literals -- note `TOP 10`'s `10` and 

336 `OFFSET 5`'s `5` are stored as a plain Python `int` directly on 

337 `Query.top` / `SelectExpression.offset`, not wrapped in a 

338 `NumberLiteral`; this class is only for numbers appearing inside 

339 expressions.""" 

340 

341 value: float 

342 

343 

344@dataclass 

345class StringLiteral(Node): 

346 """A single-quoted string literal, e.g. `'ICRS'`. The value is already 

347 unescaped (a doubled quote `''` in the source becomes a single `'` 

348 here).""" 

349 

350 value: str 

351 

352 

353@dataclass 

354class NullLiteral(Node): 

355 """The `NULL` literal used as a value_expression (as opposed to the 

356 `IS NULL` predicate, which is `IsNull` above) -- e.g. `COALESCE(x, NULL)`.""" 

357 

358 pass 

359 

360 

361@dataclass 

362class BoolLiteral(Node): 

363 """`TRUE` or `FALSE` used as a literal value.""" 

364 

365 value: bool 

366 

367 

368@dataclass 

369class FunctionCall(Node): 

370 """A call to a *recognized* ADQL function: an aggregate (`COUNT`, `SUM`, 

371 `AVG`, `MIN`, `MAX`), a numeric/trig function (`ABS`, `SIN`, `MOD`, 

372 `IN_UNIT`, ...), or a string function (`LOWER`, `UPPER`). `distinct` is 

373 only meaningful for aggregates (`COUNT(DISTINCT x)`). Contrast with 

374 `UserFunctionCall`, used for any name pyadql doesn't recognize as a 

375 built-in ADQL function.""" 

376 

377 name: str 

378 args: list[Expr] = field(default_factory=list) 

379 distinct: bool = False 

380 

381 

382@dataclass 

383class CountStar(Node): 

384 """`COUNT(*)` specifically -- kept distinct from `FunctionCall('COUNT', [...])` 

385 since `*` isn't itself a value_expression argument.""" 

386 

387 pass 

388 

389 

390@dataclass 

391class CastType(Node): 

392 """The target type of a CAST, e.g. `CastType('VARCHAR', [20])` for 

393 `CAST(x AS VARCHAR(20))`, or `CastType('DOUBLE PRECISION')` for 

394 `CAST(x AS DOUBLE PRECISION)`. ADQL 2.1 enumerates legal CAST targets 

395 explicitly (character_string_type | numeric_type | datetime_type | 

396 geometry_type) rather than accepting an arbitrary type name -- so 

397 `name` is always one of `CHAR`, `VARCHAR`, `SMALLINT`, `INTEGER`, 

398 `BIGINT`, `REAL`, `DOUBLE PRECISION`, `TIMESTAMP`, `POINT`, `CIRCLE`, 

399 `POLYGON`.""" 

400 

401 name: str 

402 params: list[int] | None = None 

403 

404 

405@dataclass 

406class Cast(Node): 

407 """`CAST(expr AS type)`, ADQL 2.1.""" 

408 

409 expr: Expr 

410 type: CastType 

411 

412 

413@dataclass 

414class Coalesce(Node): 

415 """`COALESCE(a, b, c, ...)` -- ADQL 2.1 only: evaluates to the first 

416 non-NULL argument.""" 

417 

418 args: list[Expr] 

419 

420 

421@dataclass 

422class UserFunctionCall(Node): 

423 """A call to a function name pyadql does *not* recognize as a built-in 

424 ADQL function -- typically a TAP-service-specific user-defined function 

425 (e.g. `gavo_transform(...)`, `ivo_hashlist_has(...)`), but also the 

426 fallback for a recognized keyword used with an unexpected argument 

427 shape (see the "Known limitations" note in the README about 

428 `POINT(a, b, c)` with a non-string first argument).""" 

429 

430 name: str 

431 args: list[Expr] = field(default_factory=list) 

432 

433 

434@dataclass 

435class ScalarSubquery(Node): 

436 """A subquery used as a single value inside an expression, e.g. 

437 `SELECT (SELECT MAX(x) FROM other) FROM t`. `subquery` is a full 

438 SelectExpression.""" 

439 

440 subquery: SelectExpression 

441 

442 

443# -- ADQL geometry ------------------------------------------------------------ 

444@dataclass 

445class Point(Node): 

446 """`POINT([coordsys,] ra, dec)`. `coordsys` is `None` when omitted -- 

447 made optional in ADQL 2.1 (it was mandatory in 2.0).""" 

448 

449 coordsys: Expr | None # None when omitted (optional as of ADQL 2.1) 

450 ra: Expr 

451 dec: Expr 

452 

453 

454@dataclass 

455class Circle(Node): 

456 """`CIRCLE([coordsys,] center, radius)`. `center` is either a 

457 `Coordinates` pair (`CIRCLE('ICRS', 10, 20, 1)`) or, as of ADQL 2.1, any 

458 point-valued expression -- a column, a `POINT(...)`/`CENTROID(...)` 

459 call, or a UDF (`CIRCLE(center_col, 1)`). Check 

460 `isinstance(center, Coordinates)` to tell the two forms apart.""" 

461 

462 coordsys: Expr | None 

463 center: Expr # a (ra, dec) Coordinates pair, or a point-valued Expr (ADQL 2.1) 

464 radius: Expr 

465 

466 

467@dataclass 

468class Box(Node): 

469 """`BOX([coordsys,] center, width, height)`. `center` follows the same 

470 two-forms rule as `Circle.center` above. Note: BOX itself is deprecated 

471 as of ADQL 2.1 (still parsed here, but flagged for eventual removal by 

472 the standard).""" 

473 

474 coordsys: Expr | None 

475 center: Expr # a (ra, dec) Coordinates pair, or a point-valued Expr (ADQL 2.1) 

476 width: Expr 

477 height: Expr 

478 

479 

480@dataclass 

481class Coordinates(Node): 

482 """A raw `(ra, dec)` numeric coordinate pair -- used for `POINT`'s 

483 argument, and as one alternative for `Circle`/`Box` centers and 

484 `Polygon` vertices (the other alternative being a point-valued 

485 expression, see those classes).""" 

486 

487 ra: Expr 

488 dec: Expr 

489 

490 

491@dataclass 

492class Polygon(Node): 

493 """`POLYGON([coordsys,] v1, v2, v3, ...)` -- at least 3 vertices. 

494 `vertices` is either a list of `Coordinates` pairs (`POLYGON('ICRS', 1,2, 

495 3,4,5,6)`) or, as of ADQL 2.1, a list of point-valued expressions 

496 (`POLYGON('ICRS', p1, p2, p3)`) -- the two forms are not mixed within 

497 one call. Check `isinstance(vertices[0], Coordinates)` to tell which 

498 form you have.""" 

499 

500 coordsys: Expr | None 

501 vertices: list[Expr] # list of Coordinates, or list of point-valued Expr (ADQL 2.1) 

502 

503 

504@dataclass 

505class Region(Node): 

506 """`REGION('stc-s string')` -- a region described by an STC-S string 

507 (a separate mini-language, not parsed further here). ADQL 2.1 narrows 

508 the argument to a plain string literal (2.0 allowed any string 

509 expression), so `stcs` is a plain `str`, not an `Expr`.""" 

510 

511 stcs: str # the raw STC-S string literal (narrowed to a literal in ADQL 2.1) 

512 

513 

514@dataclass 

515class Centroid(Node): 

516 """`CENTROID(geom)` -- the center point of a geometry (e.g. of a 

517 POLYGON or CIRCLE). Returns a point value, so it can itself be used 

518 wherever a point-valued expression is expected (a `Circle`/`Box` 

519 center, a `Polygon` vertex).""" 

520 

521 geom: Expr 

522 

523 

524@dataclass 

525class Area(Node): 

526 """`AREA(geom)` -- the area of a geometry, in square degrees.""" 

527 

528 geom: Expr 

529 

530 

531@dataclass 

532class Coord1(Node): 

533 """`COORD1(point)` -- the first coordinate (right ascension / longitude) 

534 of a point value.""" 

535 

536 geom: Expr 

537 

538 

539@dataclass 

540class Coord2(Node): 

541 """`COORD2(point)` -- the second coordinate (declination / latitude) of 

542 a point value.""" 

543 

544 geom: Expr 

545 

546 

547@dataclass 

548class Coordsys(Node): 

549 """`COORDSYS(geom)` -- extracts the coordinate system string (e.g. 

550 'ICRS') from a geometry value.""" 

551 

552 geom: Expr 

553 

554 

555@dataclass 

556class Distance(Node): 

557 """`DISTANCE(...)` -- angular distance between two points, in degrees. 

558 ADQL 2.1 adds a second overload alongside the original two-point form: 

559 four raw numeric arguments (ra1, dec1, ra2, dec2), the form recommended 

560 by the standard for an efficient sky crossmatch. `args` is either 

561 `[point_expr, point_expr]` (`numeric_form=False`) or `[num, num, num, 

562 num]` (`numeric_form=True`) -- check `numeric_form` rather than 

563 `len(args)` to tell the two apart unambiguously.""" 

564 

565 args: list[Expr] 

566 numeric_form: bool = False 

567 

568 

569Expr = Union[ 

570 BinaryOp, 

571 UnaryOp, 

572 Between, 

573 InPredicate, 

574 Like, 

575 IsNull, 

576 Exists, 

577 Contains, 

578 Intersects, 

579 ColumnRef, 

580 NumberLiteral, 

581 StringLiteral, 

582 NullLiteral, 

583 BoolLiteral, 

584 FunctionCall, 

585 CountStar, 

586 Cast, 

587 Coalesce, 

588 UserFunctionCall, 

589 ScalarSubquery, 

590 Point, 

591 Circle, 

592 Box, 

593 Polygon, 

594 Coordinates, 

595 Region, 

596 Centroid, 

597 Area, 

598 Coord1, 

599 Coord2, 

600 Coordsys, 

601 Distance, 

602]