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 +64 -0
- daft_doris/_common/__init__.py +13 -0
- daft_doris/_common/contracts.py +344 -0
- daft_doris/_common/errors.py +57 -0
- daft_doris/_common/identifiers.py +73 -0
- daft_doris/_common/predicate_ir.py +137 -0
- daft_doris/_common/redaction.py +71 -0
- daft_doris/_compat.py +276 -0
- daft_doris/doris/__init__.py +18 -0
- daft_doris/doris/api.py +97 -0
- daft_doris/doris/datasource.py +282 -0
- daft_doris/doris/discovery.py +438 -0
- daft_doris/doris/errors.py +127 -0
- daft_doris/doris/schema.py +199 -0
- daft_doris/doris/sql.py +278 -0
- daft_doris/doris/task.py +62 -0
- daft_doris/doris/transports/__init__.py +13 -0
- daft_doris/doris/transports/_pymysql.py +172 -0
- daft_doris/doris/transports/_thread.py +55 -0
- daft_doris/doris/transports/flight.py +181 -0
- daft_doris/doris/transports/mysql.py +190 -0
- daft_doris/write/__init__.py +14 -0
- daft_doris/write/api.py +50 -0
- daft_doris/write/connection.py +146 -0
- daft_doris/write/errors.py +29 -0
- daft_doris/write/metadata.py +383 -0
- daft_doris/write/options.py +145 -0
- daft_doris/write/serialization.py +117 -0
- daft_doris/write/sink.py +164 -0
- daft_doris/write/stream_load.py +423 -0
- daft_doris-1.0.dist-info/METADATA +111 -0
- daft_doris-1.0.dist-info/RECORD +35 -0
- daft_doris-1.0.dist-info/WHEEL +4 -0
- daft_doris-1.0.dist-info/licenses/LICENSE +201 -0
- daft_doris-1.0.dist-info/licenses/NOTICE +2 -0
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})
|