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
« 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.
8All classes are plain dataclasses with no dependency on Lark, so the AST can
9be manipulated / serialized independently of the parser.
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.
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):
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)
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"""
38from __future__ import annotations
40from dataclasses import dataclass, field
41from typing import List, Optional, Union
44class Node:
45 """Marker base class for all AST nodes."""
47 pass
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.
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 """
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
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.
80 Example -- "WITH recent AS (SELECT id FROM t WHERE d > 2020) ...":
81 CTE(name='recent', query=SelectExpression(body=Query(...)))
82 """
84 name: str
85 query: SelectExpression
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.
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 """
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
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).
120 Example -- "SELECT id FROM t1 UNION ALL SELECT id FROM t2":
121 SetOperation(op='UNION', distinct=False, left=Query(...), right=Query(...))
122 """
124 op: str # UNION | EXCEPT | INTERSECT
125 distinct: bool
126 left: Query | SetOperation | SelectExpression
127 right: Query | SetOperation | SelectExpression
130@dataclass
131class Star(Node):
132 """`SELECT *` -- no columns to enumerate, all of them are selected."""
134 pass
137@dataclass
138class SelectItem(Node):
139 """One entry of the SELECT list: an expression, plus an optional alias.
141 Example -- "ra AS right_ascension":
142 SelectItem(expr=ColumnRef(['ra']), alias='right_ascension')
143 """
145 expr: Expr
146 alias: str | None = None
149@dataclass
150class SortItem(Node):
151 """One entry of an ORDER BY list: an expression plus a direction.
153 Example -- "ORDER BY mag DESC": SortItem(expr=ColumnRef(['mag']), direction='DESC')
154 """
156 expr: Expr
157 direction: str = "ASC" # ASC | DESC
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)."""
169 name: str # may contain schema.table
170 alias: str | None = None
171 columns: list[str] | None = None # column renaming (rare)
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)."""
180 subquery: SelectExpression
181 alias: str | None = None
182 columns: list[str] | None = None
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).
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 """
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
205TableSource = Union[TableRef, DerivedTable, Join]
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.
218 Example -- "ra > 10": BinaryOp(op='>', left=ColumnRef(['ra']), right=NumberLiteral(10.0))
219 """
221 op: str # AND, OR, +, -, *, /, ||, =, !=, <, <=, >, >=, <>
222 left: Expr
223 right: Expr
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.
234 Example -- "NOT (a = 1)": UnaryOp(op='NOT', operand=BinaryOp('=', ...))
235 """
237 op: str # NOT, -, +
238 operand: Expr
241@dataclass
242class Between(Node):
243 """`expr [NOT] BETWEEN low AND high`.
245 Example -- "x BETWEEN 1 AND 10":
246 Between(expr=ColumnRef(['x']), low=NumberLiteral(1.0), high=NumberLiteral(10.0))
247 """
249 expr: Expr
250 low: Expr
251 high: Expr
252 negated: bool = False
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."""
261 expr: Expr
262 values: list[Expr] | SelectExpression
263 negated: bool = False
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."""
272 expr: Expr
273 pattern: Expr
274 negated: bool = False
275 case_insensitive: bool = False # True for ILIKE (ADQL 2.1)
278@dataclass
279class IsNull(Node):
280 """`expr IS [NOT] NULL`."""
282 expr: Expr
283 negated: bool = False
286@dataclass
287class Exists(Node):
288 """`EXISTS (subquery)` -- true iff the subquery returns at least one row."""
290 subquery: SelectExpression
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."""
302 geom1: Expr
303 geom2: Expr
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)."""
312 geom1: Expr
313 geom2: Expr
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."""
326 parts: list[str] # e.g. ['t', 'ra'] for t.ra
328 def __str__(self):
329 return ".".join(self.parts)
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."""
341 value: float
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)."""
350 value: str
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)`."""
358 pass
361@dataclass
362class BoolLiteral(Node):
363 """`TRUE` or `FALSE` used as a literal value."""
365 value: bool
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."""
377 name: str
378 args: list[Expr] = field(default_factory=list)
379 distinct: bool = False
382@dataclass
383class CountStar(Node):
384 """`COUNT(*)` specifically -- kept distinct from `FunctionCall('COUNT', [...])`
385 since `*` isn't itself a value_expression argument."""
387 pass
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`."""
401 name: str
402 params: list[int] | None = None
405@dataclass
406class Cast(Node):
407 """`CAST(expr AS type)`, ADQL 2.1."""
409 expr: Expr
410 type: CastType
413@dataclass
414class Coalesce(Node):
415 """`COALESCE(a, b, c, ...)` -- ADQL 2.1 only: evaluates to the first
416 non-NULL argument."""
418 args: list[Expr]
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)."""
430 name: str
431 args: list[Expr] = field(default_factory=list)
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."""
440 subquery: SelectExpression
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)."""
449 coordsys: Expr | None # None when omitted (optional as of ADQL 2.1)
450 ra: Expr
451 dec: Expr
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."""
462 coordsys: Expr | None
463 center: Expr # a (ra, dec) Coordinates pair, or a point-valued Expr (ADQL 2.1)
464 radius: Expr
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)."""
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
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)."""
487 ra: Expr
488 dec: Expr
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."""
500 coordsys: Expr | None
501 vertices: list[Expr] # list of Coordinates, or list of point-valued Expr (ADQL 2.1)
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`."""
511 stcs: str # the raw STC-S string literal (narrowed to a literal in ADQL 2.1)
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)."""
521 geom: Expr
524@dataclass
525class Area(Node):
526 """`AREA(geom)` -- the area of a geometry, in square degrees."""
528 geom: Expr
531@dataclass
532class Coord1(Node):
533 """`COORD1(point)` -- the first coordinate (right ascension / longitude)
534 of a point value."""
536 geom: Expr
539@dataclass
540class Coord2(Node):
541 """`COORD2(point)` -- the second coordinate (declination / latitude) of
542 a point value."""
544 geom: Expr
547@dataclass
548class Coordsys(Node):
549 """`COORDSYS(geom)` -- extracts the coordinate system string (e.g.
550 'ICRS') from a geometry value."""
552 geom: Expr
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."""
565 args: list[Expr]
566 numeric_form: bool = False
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]