daft-doris 1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
daft_doris/__init__.py ADDED
@@ -0,0 +1,64 @@
1
+ # Licensed under the Apache License, Version 2.0 (the "License");
2
+ # you may not use this file except in compliance with the License.
3
+ # You may obtain a copy of the License at
4
+ #
5
+ # http://www.apache.org/licenses/LICENSE-2.0
6
+
7
+ """Independent Daft read and write support for Apache Doris."""
8
+
9
+ from importlib.metadata import version
10
+
11
+ from daft_doris._common.errors import (
12
+ AuthenticationError,
13
+ CompatibilityError,
14
+ ConfigurationError,
15
+ DaftOlapError,
16
+ DatabaseObjectNotFoundError,
17
+ DatabasePermissionError,
18
+ DependencyError,
19
+ DiscoveryError,
20
+ SchemaError,
21
+ TransportError,
22
+ UnsupportedPredicateError,
23
+ )
24
+ from daft_doris._common.redaction import SecretRef
25
+ from daft_doris.doris.api import read_doris
26
+ from daft_doris.write.api import write_doris
27
+ from daft_doris.write.connection import DorisConnection, DorisTable
28
+ from daft_doris.write.errors import (
29
+ DorisAmbiguousWriteError,
30
+ DorisLabelExistsError,
31
+ DorisMetadataError,
32
+ DorisTableCompatibilityError,
33
+ DorisWriteError,
34
+ )
35
+ from daft_doris.write.options import DorisWriteOptions
36
+ from daft_doris.write.sink import DorisDataSink
37
+
38
+ __version__ = version("daft-doris")
39
+
40
+ __all__ = [
41
+ "AuthenticationError",
42
+ "CompatibilityError",
43
+ "ConfigurationError",
44
+ "DaftOlapError",
45
+ "DatabaseObjectNotFoundError",
46
+ "DatabasePermissionError",
47
+ "DependencyError",
48
+ "DiscoveryError",
49
+ "DorisAmbiguousWriteError",
50
+ "DorisConnection",
51
+ "DorisDataSink",
52
+ "DorisLabelExistsError",
53
+ "DorisMetadataError",
54
+ "DorisTable",
55
+ "DorisTableCompatibilityError",
56
+ "DorisWriteError",
57
+ "DorisWriteOptions",
58
+ "SchemaError",
59
+ "SecretRef",
60
+ "TransportError",
61
+ "UnsupportedPredicateError",
62
+ "read_doris",
63
+ "write_doris",
64
+ ]
@@ -0,0 +1,13 @@
1
+ # Licensed under the Apache License, Version 2.0 (the "License");
2
+ # you may not use this file except in compliance with the License.
3
+ # You may obtain a copy of the License at
4
+ #
5
+ # http://www.apache.org/licenses/LICENSE-2.0
6
+ #
7
+ # Unless required by applicable law or agreed to in writing, software
8
+ # distributed under the License is distributed on an "AS IS" BASIS,
9
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10
+ # See the License for the specific language governing permissions and
11
+ # limitations under the License.
12
+
13
+ """Internal cross-database contracts."""
@@ -0,0 +1,344 @@
1
+ # Licensed under the Apache License, Version 2.0 (the "License");
2
+ # you may not use this file except in compliance with the License.
3
+ # You may obtain a copy of the License at
4
+ #
5
+ # http://www.apache.org/licenses/LICENSE-2.0
6
+ #
7
+ # Unless required by applicable law or agreed to in writing, software
8
+ # distributed under the License is distributed on an "AS IS" BASIS,
9
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10
+ # See the License for the specific language governing permissions and
11
+ # limitations under the License.
12
+
13
+ """Immutable cross-database resource and query contracts."""
14
+
15
+ from __future__ import annotations
16
+
17
+ import pickle
18
+ from collections.abc import Iterable, Iterator, Mapping
19
+ from dataclasses import dataclass, field
20
+ from datetime import datetime, time
21
+ from typing import Any, Literal
22
+
23
+ import pyarrow as pa
24
+
25
+ from daft_doris._common.errors import ConfigurationError
26
+
27
+ SplitMode = Literal["single", "auto"]
28
+ DiscoveryPolicy = Literal["single", "error"]
29
+ _MAX_BATCH_ROWS = 1_000_000
30
+ _MAX_BATCH_BYTES = 1024 * 1024 * 1024
31
+ _MAX_TASKS = 1_024
32
+ _MAX_TIMEOUT_SECONDS = 86_400
33
+
34
+
35
+ def validate_timeout_seconds(name: str, value: object) -> float:
36
+ """Return a validated timeout without exposing caller values in errors."""
37
+ if (
38
+ isinstance(value, bool)
39
+ or not isinstance(value, (int, float))
40
+ or not 0 < value <= _MAX_TIMEOUT_SECONDS
41
+ ):
42
+ raise ConfigurationError(f"{name} must be between 0 and 86,400 seconds")
43
+ return float(value)
44
+
45
+
46
+ def _snapshot_error(value: Any, *, value_path: str) -> ConfigurationError:
47
+ return ConfigurationError(
48
+ f"{value_path} value of type {type(value).__name__} must be safely serializable"
49
+ )
50
+
51
+
52
+ def _validate_snapshot_structure(
53
+ value: Any,
54
+ *,
55
+ value_path: str,
56
+ active_containers: set[int],
57
+ ) -> None:
58
+ if callable(value):
59
+ raise _snapshot_error(value, value_path=value_path)
60
+ if not isinstance(value, (Mapping, list, tuple, set, frozenset)):
61
+ return
62
+
63
+ identity = id(value)
64
+ if identity in active_containers:
65
+ raise ConfigurationError(f"{value_path} contains a cycle")
66
+ active_containers.add(identity)
67
+ try:
68
+ if isinstance(value, Mapping):
69
+ for index, (key, nested) in enumerate(value.items()):
70
+ _validate_snapshot_structure(
71
+ key,
72
+ value_path=f"{value_path}.key[{index}]",
73
+ active_containers=active_containers,
74
+ )
75
+ _validate_snapshot_structure(
76
+ nested,
77
+ value_path=f"{value_path}[{index}]",
78
+ active_containers=active_containers,
79
+ )
80
+ else:
81
+ for index, nested in enumerate(value):
82
+ _validate_snapshot_structure(
83
+ nested,
84
+ value_path=f"{value_path}[{index}]",
85
+ active_containers=active_containers,
86
+ )
87
+ finally:
88
+ active_containers.remove(identity)
89
+
90
+
91
+ def snapshot_serializable(value: Any, *, value_path: str) -> Any:
92
+ """Return an independent pickle-round-tripped value or a redacted configuration error."""
93
+ try:
94
+ _validate_snapshot_structure(
95
+ value,
96
+ value_path=value_path,
97
+ active_containers=set(),
98
+ )
99
+ payload = pickle.dumps(value, protocol=pickle.HIGHEST_PROTOCOL)
100
+ # The payload is created in-process above; external pickle bytes are never accepted.
101
+ snapshot: Any = pickle.loads(payload)
102
+ except ConfigurationError:
103
+ raise
104
+ except Exception:
105
+ raise _snapshot_error(value, value_path=value_path) from None
106
+ return snapshot
107
+
108
+
109
+ def freeze_options(
110
+ options: Mapping[str, Any] | None,
111
+ *,
112
+ reserved: set[str],
113
+ option_name: str,
114
+ ) -> tuple[tuple[str, Any], ...]:
115
+ """Snapshot a mapping into a deterministic tuple and protect managed keys."""
116
+ if options is None:
117
+ return ()
118
+ try:
119
+ items = tuple(options.items())
120
+ except Exception:
121
+ raise ConfigurationError(f"{option_name} must be a readable mapping") from None
122
+ frozen: list[tuple[str, Any]] = []
123
+ for key, value in items:
124
+ if not isinstance(key, str) or not key:
125
+ raise ConfigurationError(f"{option_name} keys must be non-empty strings")
126
+ if key in reserved:
127
+ raise ConfigurationError(f"{option_name} must not override managed option {key!r}")
128
+ frozen.append(
129
+ (
130
+ key,
131
+ snapshot_serializable(value, value_path=f"{option_name}[{key!r}]"),
132
+ )
133
+ )
134
+ return tuple(sorted(frozen, key=lambda item: item[0]))
135
+
136
+
137
+ def freeze_query_parameters(
138
+ parameters: Mapping[str, Any] | None,
139
+ ) -> tuple[tuple[str, Any], ...]:
140
+ """Snapshot named query parameters while preserving caller-specified order."""
141
+ if parameters is None:
142
+ return ()
143
+ try:
144
+ items = tuple(parameters.items())
145
+ except Exception:
146
+ raise ConfigurationError("query_parameters must be a readable mapping") from None
147
+ if any(not isinstance(key, str) or not key for key, _ in items):
148
+ raise ConfigurationError("query parameter names must be non-empty strings")
149
+ frozen = tuple(
150
+ (
151
+ key,
152
+ snapshot_serializable(value, value_path=f"query_parameters[{key!r}]"),
153
+ )
154
+ for key, value in items
155
+ )
156
+ validate_query_parameter_values(value for _, value in frozen)
157
+ return frozen
158
+
159
+
160
+ def thaw_options(
161
+ options: tuple[tuple[str, Any], ...],
162
+ *,
163
+ option_name: str,
164
+ ) -> dict[str, Any]:
165
+ """Return fresh nested option values for one driver invocation."""
166
+ return {
167
+ key: snapshot_serializable(value, value_path=f"{option_name}[{key!r}]")
168
+ for key, value in options
169
+ }
170
+
171
+
172
+ def validate_query_parameter_values(values: Iterable[Any]) -> None:
173
+ """Reject timezone-aware temporal values anywhere in a query parameter container."""
174
+ visited: set[int] = set()
175
+
176
+ def validate(value: Any) -> None:
177
+ if isinstance(value, (datetime, time)) and value.tzinfo is not None:
178
+ raise ConfigurationError(
179
+ "timezone-aware datetime and time query parameters are not supported"
180
+ )
181
+ if isinstance(value, Mapping):
182
+ identity = id(value)
183
+ if identity in visited:
184
+ return
185
+ visited.add(identity)
186
+ for nested in value.values():
187
+ validate(nested)
188
+ return
189
+ if isinstance(value, (list, tuple, set, frozenset)):
190
+ identity = id(value)
191
+ if identity in visited:
192
+ return
193
+ visited.add(identity)
194
+ for nested in value:
195
+ validate(nested)
196
+
197
+ for item in values:
198
+ validate(item)
199
+
200
+
201
+ @dataclass(frozen=True)
202
+ class ResourceLimits:
203
+ """Validated connector bounds applied to every connector invocation."""
204
+
205
+ batch_rows: int = 65_536
206
+ batch_bytes: int = 64 * 1024 * 1024
207
+ target_tasks: int = 8
208
+ max_tasks: int = 256
209
+ connect_timeout_seconds: float = 10.0
210
+ query_timeout_seconds: float = 300.0
211
+
212
+ def __post_init__(self) -> None:
213
+ if (
214
+ isinstance(self.batch_rows, bool)
215
+ or not isinstance(self.batch_rows, int)
216
+ or not 1 <= self.batch_rows <= _MAX_BATCH_ROWS
217
+ ):
218
+ raise ConfigurationError("batch_rows must be between 1 and 1,000,000")
219
+ if (
220
+ isinstance(self.batch_bytes, bool)
221
+ or not isinstance(self.batch_bytes, int)
222
+ or not 1 <= self.batch_bytes <= _MAX_BATCH_BYTES
223
+ ):
224
+ raise ConfigurationError("batch_bytes must be between 1 and 1,073,741,824")
225
+ if (
226
+ isinstance(self.target_tasks, bool)
227
+ or not isinstance(self.target_tasks, int)
228
+ or not 1 <= self.target_tasks <= _MAX_TASKS
229
+ ):
230
+ raise ConfigurationError("target_tasks must be between 1 and 1,024")
231
+ if (
232
+ isinstance(self.max_tasks, bool)
233
+ or not isinstance(self.max_tasks, int)
234
+ or not 1 <= self.max_tasks <= _MAX_TASKS
235
+ ):
236
+ raise ConfigurationError("max_tasks must be between 1 and 1,024")
237
+ if self.target_tasks > self.max_tasks:
238
+ raise ConfigurationError("target_tasks must not exceed max_tasks")
239
+ for name, value in (
240
+ ("connect_timeout_seconds", self.connect_timeout_seconds),
241
+ ("query_timeout_seconds", self.query_timeout_seconds),
242
+ ):
243
+ validate_timeout_seconds(name, value)
244
+
245
+
246
+ def iter_batch_slices(
247
+ batch: pa.RecordBatch,
248
+ limits: ResourceLimits,
249
+ ) -> Iterator[pa.RecordBatch]:
250
+ """Slice one decoded batch by row cap and byte target without dropping oversized rows."""
251
+ offset = 0
252
+ while offset < batch.num_rows:
253
+ upper = min(limits.batch_rows, batch.num_rows - offset)
254
+ if batch.slice(offset, upper).nbytes <= limits.batch_bytes:
255
+ size = upper
256
+ else:
257
+ low = 1
258
+ high = upper
259
+ size = 1
260
+ while low <= high:
261
+ candidate_size = (low + high) // 2
262
+ if batch.slice(offset, candidate_size).nbytes <= limits.batch_bytes:
263
+ size = candidate_size
264
+ low = candidate_size + 1
265
+ else:
266
+ high = candidate_size - 1
267
+ yield batch.slice(offset, size)
268
+ offset += size
269
+
270
+
271
+ @dataclass(frozen=True, repr=False)
272
+ class QuerySpec:
273
+ """A picklable SQL statement, bound values, and canonical Arrow result schema."""
274
+
275
+ sql: str
276
+ positional_parameters: tuple[Any, ...] = ()
277
+ named_parameters: tuple[tuple[str, Any], ...] = ()
278
+ arrow_schema: pa.Schema = field(default_factory=lambda: pa.schema([]))
279
+
280
+ def __post_init__(self) -> None:
281
+ if not self.sql:
282
+ raise ConfigurationError("query SQL must not be empty")
283
+ if self.positional_parameters and self.named_parameters:
284
+ raise ConfigurationError("a query cannot mix positional and named parameters")
285
+ positional_parameters = tuple(
286
+ snapshot_serializable(value, value_path=f"positional_parameters[{index}]")
287
+ for index, value in enumerate(self.positional_parameters)
288
+ )
289
+ named_parameters = tuple(
290
+ (
291
+ name,
292
+ snapshot_serializable(value, value_path=f"named_parameters[{index}]"),
293
+ )
294
+ for index, (name, value) in enumerate(self.named_parameters)
295
+ )
296
+ validate_query_parameter_values(iter(positional_parameters))
297
+ validate_query_parameter_values(iter(value for _, value in named_parameters))
298
+ object.__setattr__(self, "positional_parameters", positional_parameters)
299
+ object.__setattr__(self, "named_parameters", named_parameters)
300
+
301
+ def named_parameter_dict(self) -> dict[str, Any]:
302
+ """Return a fresh parameter mapping for a database driver."""
303
+ return dict(self.named_parameters)
304
+
305
+ def __repr__(self) -> str:
306
+ """Describe query shape without exposing SQL or bound values."""
307
+ parameter_names = tuple(name for name, _ in self.named_parameters)
308
+ return (
309
+ "QuerySpec("
310
+ "sql=<redacted>, "
311
+ f"positional_parameter_count={len(self.positional_parameters)}, "
312
+ f"named_parameter_names={parameter_names!r}, "
313
+ f"arrow_schema={self.arrow_schema!r})"
314
+ )
315
+
316
+
317
+ def group_weighted_items(
318
+ items: tuple[tuple[str, int], ...], *, target_groups: int, max_groups: int
319
+ ) -> tuple[tuple[str, ...], ...]:
320
+ """Greedily balance deterministic weighted items without exceeding a hard group cap."""
321
+ if not items:
322
+ return ()
323
+ group_count = min(len(items), target_groups, max_groups)
324
+ buckets: list[list[str]] = [[] for _ in range(group_count)]
325
+ weights = [0 for _ in range(group_count)]
326
+ for name, weight in sorted(items, key=lambda item: (-item[1], item[0])):
327
+ bucket_index = min(range(group_count), key=lambda index: (weights[index], index))
328
+ buckets[bucket_index].append(name)
329
+ weights[bucket_index] += max(weight, 0)
330
+ return tuple(tuple(sorted(bucket)) for bucket in buckets if bucket)
331
+
332
+
333
+ def group_adjacent_ids(
334
+ values: tuple[int, ...], *, target_groups: int, max_groups: int
335
+ ) -> tuple[tuple[int, ...], ...]:
336
+ """Group sorted integer identifiers deterministically under the task limit."""
337
+ if not values:
338
+ return ()
339
+ group_count = min(len(values), target_groups, max_groups)
340
+ group_size = (len(values) + group_count - 1) // group_count
341
+ ordered = tuple(sorted(values))
342
+ return tuple(
343
+ ordered[index : index + group_size] for index in range(0, len(ordered), group_size)
344
+ )
@@ -0,0 +1,57 @@
1
+ # Licensed under the Apache License, Version 2.0 (the "License");
2
+ # you may not use this file except in compliance with the License.
3
+ # You may obtain a copy of the License at
4
+ #
5
+ # http://www.apache.org/licenses/LICENSE-2.0
6
+ #
7
+ # Unless required by applicable law or agreed to in writing, software
8
+ # distributed under the License is distributed on an "AS IS" BASIS,
9
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10
+ # See the License for the specific language governing permissions and
11
+ # limitations under the License.
12
+
13
+ """Public, credential-safe exception hierarchy."""
14
+
15
+
16
+ class DaftOlapError(RuntimeError):
17
+ """Base error raised by Daft OLAP Connectors."""
18
+
19
+
20
+ class ConfigurationError(DaftOlapError, ValueError):
21
+ """A connector option is invalid."""
22
+
23
+
24
+ class DependencyError(DaftOlapError, ImportError):
25
+ """An optional transport dependency is unavailable."""
26
+
27
+
28
+ class CompatibilityError(DaftOlapError):
29
+ """The installed Daft API cannot safely represent the requested operation."""
30
+
31
+
32
+ class SchemaError(DaftOlapError):
33
+ """A database schema cannot be represented without loss."""
34
+
35
+
36
+ class DiscoveryError(DaftOlapError):
37
+ """Database metadata or split discovery failed."""
38
+
39
+
40
+ class AuthenticationError(DaftOlapError):
41
+ """Database authentication failed."""
42
+
43
+
44
+ class DatabasePermissionError(DaftOlapError):
45
+ """The database account lacks a required permission."""
46
+
47
+
48
+ class DatabaseObjectNotFoundError(DaftOlapError):
49
+ """A requested database, table, or other database object does not exist."""
50
+
51
+
52
+ class TransportError(DaftOlapError):
53
+ """A selected database transport failed."""
54
+
55
+
56
+ class UnsupportedPredicateError(DaftOlapError):
57
+ """A Daft expression is outside the safe predicate subset."""
@@ -0,0 +1,73 @@
1
+ # Licensed under the Apache License, Version 2.0 (the "License");
2
+ # you may not use this file except in compliance with the License.
3
+ # You may obtain a copy of the License at
4
+ #
5
+ # http://www.apache.org/licenses/LICENSE-2.0
6
+ #
7
+ # Unless required by applicable law or agreed to in writing, software
8
+ # distributed under the License is distributed on an "AS IS" BASIS,
9
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10
+ # See the License for the specific language governing permissions and
11
+ # limitations under the License.
12
+
13
+ """Identifier validation and quoting shared by the two backtick dialects."""
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Iterable
18
+ from dataclasses import dataclass
19
+
20
+ from daft_doris._common.errors import ConfigurationError
21
+
22
+
23
+ def validate_identifier(value: str, *, kind: str = "identifier") -> str:
24
+ """Validate one unqualified identifier while preserving database case semantics."""
25
+ if not isinstance(value, str) or not value:
26
+ raise ConfigurationError(f"{kind} must be a non-empty string")
27
+ if "\x00" in value:
28
+ raise ConfigurationError(f"{kind} must not contain a NUL character")
29
+ return value
30
+
31
+
32
+ def quote_identifier(value: str) -> str:
33
+ """Quote one Doris/MySQL identifier, including embedded backticks."""
34
+ validated = validate_identifier(value)
35
+ return f"`{validated.replace('`', '``')}`"
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class QualifiedTable:
40
+ """A validated database and physical table pair."""
41
+
42
+ database: str
43
+ table: str
44
+
45
+ def __post_init__(self) -> None:
46
+ validate_identifier(self.database, kind="database")
47
+ validate_identifier(self.table, kind="table")
48
+
49
+ def sql(self) -> str:
50
+ """Render the two identifiers as a safely quoted qualified name."""
51
+ return f"{quote_identifier(self.database)}.{quote_identifier(self.table)}"
52
+
53
+
54
+ def normalize_columns(columns: Iterable[str] | None) -> tuple[str, ...] | None:
55
+ """Validate, deduplicate, and freeze an optional projection."""
56
+ if columns is None:
57
+ return None
58
+ if isinstance(columns, (str, bytes)):
59
+ raise ConfigurationError("columns must be an iterable of complete column names, not text")
60
+ normalized = tuple(validate_identifier(value, kind="column") for value in columns)
61
+ if not normalized:
62
+ raise ConfigurationError("columns must be None or a non-empty iterable")
63
+ if len(set(normalized)) != len(normalized):
64
+ raise ConfigurationError("columns must not contain duplicates")
65
+ return normalized
66
+
67
+
68
+ def quote_columns(columns: Iterable[str]) -> str:
69
+ """Render a non-empty sequence of quoted column names."""
70
+ normalized = normalize_columns(columns)
71
+ if normalized is None:
72
+ raise AssertionError("normalize_columns returned None for an iterable")
73
+ return ", ".join(quote_identifier(column) for column in normalized)
@@ -0,0 +1,137 @@
1
+ # Licensed under the Apache License, Version 2.0 (the "License");
2
+ # you may not use this file except in compliance with the License.
3
+ # You may obtain a copy of the License at
4
+ #
5
+ # http://www.apache.org/licenses/LICENSE-2.0
6
+ #
7
+ # Unless required by applicable law or agreed to in writing, software
8
+ # distributed under the License is distributed on an "AS IS" BASIS,
9
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10
+ # See the License for the specific language governing permissions and
11
+ # limitations under the License.
12
+
13
+ """Small immutable predicate IR compiled from Daft expressions."""
14
+
15
+ from __future__ import annotations
16
+
17
+ import math
18
+ from dataclasses import dataclass
19
+ from datetime import date, datetime, time
20
+ from decimal import Decimal
21
+ from typing import Literal as TypingLiteral
22
+ from uuid import UUID
23
+
24
+ from daft_doris._common.errors import UnsupportedPredicateError
25
+ from daft_doris._common.identifiers import validate_identifier
26
+
27
+ type Scalar = str | bytes | bool | int | float | Decimal | date | datetime | time | UUID | None
28
+ CompareOperator = TypingLiteral["=", "!=", "<", "<=", ">", ">="]
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Column:
33
+ """A database column reference."""
34
+
35
+ name: str
36
+
37
+ def __post_init__(self) -> None:
38
+ validate_identifier(self.name, kind="predicate column")
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Literal:
43
+ """A value that must be emitted as a driver parameter."""
44
+
45
+ value: Scalar
46
+
47
+ def __post_init__(self) -> None:
48
+ if not isinstance(
49
+ self.value,
50
+ (str, bytes, bool, int, float, Decimal, date, datetime, time, UUID, type(None)),
51
+ ):
52
+ raise UnsupportedPredicateError(
53
+ f"unsupported predicate literal type: {type(self.value).__name__}"
54
+ )
55
+ if isinstance(self.value, float) and not math.isfinite(self.value):
56
+ raise UnsupportedPredicateError(
57
+ "non-finite floating-point literals are not pushed down"
58
+ )
59
+ if isinstance(self.value, Decimal) and not self.value.is_finite():
60
+ raise UnsupportedPredicateError("non-finite decimal literals are not pushed down")
61
+ if isinstance(self.value, (datetime, time)) and self.value.tzinfo is not None:
62
+ raise UnsupportedPredicateError(
63
+ "timezone-aware datetime and time literals are not pushed down"
64
+ )
65
+
66
+
67
+ type Value = Column | Literal
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class Compare:
72
+ """A binary SQL comparison."""
73
+
74
+ operator: CompareOperator
75
+ left: Value
76
+ right: Value
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class And:
81
+ """SQL three-valued conjunction."""
82
+
83
+ left: Predicate
84
+ right: Predicate
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class Or:
89
+ """SQL three-valued disjunction."""
90
+
91
+ left: Predicate
92
+ right: Predicate
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class Not:
97
+ """SQL three-valued negation."""
98
+
99
+ operand: Predicate
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class IsNull:
104
+ """SQL NULL test."""
105
+
106
+ operand: Column
107
+ negated: bool = False
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class InValues:
112
+ """A non-empty SQL IN set without NULL members."""
113
+
114
+ operand: Column
115
+ values: tuple[Literal, ...]
116
+
117
+ def __post_init__(self) -> None:
118
+ if not self.values:
119
+ raise UnsupportedPredicateError("empty IN predicates are not pushed down")
120
+ if any(value.value is None for value in self.values):
121
+ raise UnsupportedPredicateError("IN predicates containing NULL are not pushed down")
122
+
123
+
124
+ type Predicate = Compare | And | Or | Not | IsNull | InValues
125
+
126
+
127
+ def referenced_columns(predicate: Predicate) -> frozenset[str]:
128
+ """Return all column names referenced by an IR tree."""
129
+ if isinstance(predicate, Compare):
130
+ return frozenset(
131
+ value.name for value in (predicate.left, predicate.right) if isinstance(value, Column)
132
+ )
133
+ if isinstance(predicate, (And, Or)):
134
+ return referenced_columns(predicate.left) | referenced_columns(predicate.right)
135
+ if isinstance(predicate, Not):
136
+ return referenced_columns(predicate.operand)
137
+ return frozenset({predicate.operand.name})