datus-doris 0.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.
- datus_doris/__init__.py +21 -0
- datus_doris/config.py +27 -0
- datus_doris/connector.py +808 -0
- datus_doris/tpch_data.py +89 -0
- datus_doris-0.1.0.dist-info/METADATA +154 -0
- datus_doris-0.1.0.dist-info/RECORD +8 -0
- datus_doris-0.1.0.dist-info/WHEEL +4 -0
- datus_doris-0.1.0.dist-info/entry_points.txt +2 -0
datus_doris/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Copyright 2025-present DatusAI, Inc.
|
|
2
|
+
# Licensed under the Apache License, Version 2.0.
|
|
3
|
+
# See http://www.apache.org/licenses/LICENSE-2.0 for details.
|
|
4
|
+
|
|
5
|
+
from .config import DorisConfig
|
|
6
|
+
from .connector import DorisConnector
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
__all__ = ["DorisConnector", "DorisConfig", "register"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def register():
|
|
13
|
+
"""Register Doris connector with Datus registry."""
|
|
14
|
+
from datus_db_core import connector_registry
|
|
15
|
+
|
|
16
|
+
connector_registry.register(
|
|
17
|
+
"doris",
|
|
18
|
+
DorisConnector,
|
|
19
|
+
config_class=DorisConfig,
|
|
20
|
+
capabilities={"catalog", "database"},
|
|
21
|
+
)
|
datus_doris/config.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Copyright 2025-present DatusAI, Inc.
|
|
2
|
+
# Licensed under the Apache License, Version 2.0.
|
|
3
|
+
# See http://www.apache.org/licenses/LICENSE-2.0 for details.
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DorisConfig(BaseModel):
|
|
11
|
+
"""Doris-specific configuration."""
|
|
12
|
+
|
|
13
|
+
model_config = ConfigDict(extra="forbid")
|
|
14
|
+
|
|
15
|
+
host: str = Field(default="127.0.0.1", description="Doris server host")
|
|
16
|
+
port: int = Field(default=9030, description="Doris server port")
|
|
17
|
+
username: str = Field(..., description="Doris username")
|
|
18
|
+
password: str = Field(
|
|
19
|
+
default="",
|
|
20
|
+
description="Doris password",
|
|
21
|
+
json_schema_extra={"input_type": "password"},
|
|
22
|
+
)
|
|
23
|
+
catalog: str = Field(default="internal", description="Default catalog name")
|
|
24
|
+
database: Optional[str] = Field(default=None, description="Default database name")
|
|
25
|
+
charset: str = Field(default="utf8mb4", description="Character set to use")
|
|
26
|
+
autocommit: bool = Field(default=True, description="Enable autocommit mode")
|
|
27
|
+
timeout_seconds: int = Field(default=30, description="Connection timeout in seconds")
|
datus_doris/connector.py
ADDED
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
# Copyright 2025-present DatusAI, Inc.
|
|
2
|
+
# Licensed under the Apache License, Version 2.0.
|
|
3
|
+
# See http://www.apache.org/licenses/LICENSE-2.0 for details.
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from typing import Any, Dict, List, Literal, Optional, Set, Union, override
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import text
|
|
10
|
+
|
|
11
|
+
from datus_db_core import (
|
|
12
|
+
TABLE_TYPE,
|
|
13
|
+
CatalogSupportMixin,
|
|
14
|
+
ExecuteSQLResult,
|
|
15
|
+
MaterializedViewSupportMixin,
|
|
16
|
+
MigrationTargetMixin,
|
|
17
|
+
get_logger,
|
|
18
|
+
list_to_in_str,
|
|
19
|
+
parse_context_switch,
|
|
20
|
+
)
|
|
21
|
+
from datus_mysql import MySQLConnector
|
|
22
|
+
|
|
23
|
+
from .config import DorisConfig
|
|
24
|
+
|
|
25
|
+
logger = get_logger(__name__)
|
|
26
|
+
|
|
27
|
+
_INTEGER_TYPES = frozenset({"INT", "INTEGER", "BIGINT", "SMALLINT", "TINYINT", "INT2", "INT4", "INT8", "LARGEINT"})
|
|
28
|
+
_SWITCH_RE = re.compile(r"^\s*SWITCH\s+(.+?)\s*;?\s*$", flags=re.IGNORECASE | re.DOTALL)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _parse_doris_context_switch(sql: str) -> Optional[Dict[str, Any]]:
|
|
32
|
+
"""Parse Doris context commands, including with older datus-db-core releases."""
|
|
33
|
+
switch_match = _SWITCH_RE.match(sql)
|
|
34
|
+
if switch_match:
|
|
35
|
+
target = switch_match.group(1).rstrip(";").strip()
|
|
36
|
+
parsed_target = parse_context_switch(f"USE {target}", dialect="starrocks")
|
|
37
|
+
if not parsed_target:
|
|
38
|
+
return None
|
|
39
|
+
catalog_name = parsed_target.get("database_name") or parsed_target.get("catalog_name")
|
|
40
|
+
return {
|
|
41
|
+
"command": "SWITCH",
|
|
42
|
+
"target": "catalog",
|
|
43
|
+
"catalog_name": catalog_name,
|
|
44
|
+
"database_name": "",
|
|
45
|
+
"schema_name": "",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if re.match(r"^\s*USE\b", sql, flags=re.IGNORECASE):
|
|
49
|
+
# Doris and StarRocks share USE [catalog.]database semantics. Parsing
|
|
50
|
+
# through the older dialect name keeps this adapter compatible with
|
|
51
|
+
# datus-db-core versions released before the Doris dialect was added.
|
|
52
|
+
return parse_context_switch(sql, dialect="starrocks")
|
|
53
|
+
|
|
54
|
+
return parse_context_switch(sql, dialect="doris")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class DorisConnector(MySQLConnector, CatalogSupportMixin, MaterializedViewSupportMixin, MigrationTargetMixin):
|
|
58
|
+
"""
|
|
59
|
+
Doris database connector.
|
|
60
|
+
|
|
61
|
+
Doris uses MySQL protocol but adds multi-catalog support and materialized views.
|
|
62
|
+
Metadata queries use catalog-qualified information_schema (e.g.
|
|
63
|
+
`catalog.information_schema.TABLES`) so they are stateless and thread-safe,
|
|
64
|
+
without requiring session context switching.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, config: Union[DorisConfig, dict]):
|
|
68
|
+
"""
|
|
69
|
+
Initialize Doris connector.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
config: DorisConfig object or dict with configuration
|
|
73
|
+
"""
|
|
74
|
+
# Handle config object or dict
|
|
75
|
+
if isinstance(config, dict):
|
|
76
|
+
config = DorisConfig(**config)
|
|
77
|
+
elif not isinstance(config, DorisConfig):
|
|
78
|
+
raise TypeError(f"config must be DorisConfig or dict, got {type(config)}")
|
|
79
|
+
|
|
80
|
+
self.doris_config = config
|
|
81
|
+
|
|
82
|
+
# Pass MySQL config to parent connector
|
|
83
|
+
from datus_mysql import MySQLConfig
|
|
84
|
+
|
|
85
|
+
# When using a non-default catalog, don't put database in the connection
|
|
86
|
+
# string — it would fail because the database doesn't exist under
|
|
87
|
+
# internal. We switch catalog and USE database in connect().
|
|
88
|
+
needs_catalog_switch = config.catalog and config.catalog not in ("internal", "def")
|
|
89
|
+
mysql_database = "" if needs_catalog_switch else (config.database or "")
|
|
90
|
+
|
|
91
|
+
mysql_config = MySQLConfig(
|
|
92
|
+
host=config.host,
|
|
93
|
+
port=config.port,
|
|
94
|
+
username=config.username,
|
|
95
|
+
password=config.password,
|
|
96
|
+
database=mysql_database,
|
|
97
|
+
charset=config.charset,
|
|
98
|
+
autocommit=config.autocommit,
|
|
99
|
+
timeout_seconds=config.timeout_seconds,
|
|
100
|
+
)
|
|
101
|
+
super().__init__(mysql_config)
|
|
102
|
+
self._default_catalog = self.default_catalog() if config.catalog in ("", None, "def") else config.catalog
|
|
103
|
+
# Keep the configured database in connector context even when it must
|
|
104
|
+
# be omitted from the initial MySQL connection URL. _conn() consumes
|
|
105
|
+
# this value after switching to the configured external catalog.
|
|
106
|
+
self._default_database = config.database or ""
|
|
107
|
+
|
|
108
|
+
# Override dialect to Doris
|
|
109
|
+
self.dialect = "doris"
|
|
110
|
+
|
|
111
|
+
# ==================== Context Manager Support ====================
|
|
112
|
+
|
|
113
|
+
def __enter__(self):
|
|
114
|
+
"""Context manager entry."""
|
|
115
|
+
self.connect()
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
119
|
+
"""Context manager exit with cleanup."""
|
|
120
|
+
self.close()
|
|
121
|
+
return False # Don't suppress exceptions
|
|
122
|
+
|
|
123
|
+
@override
|
|
124
|
+
def execute(
|
|
125
|
+
self,
|
|
126
|
+
input_params: Any,
|
|
127
|
+
result_format: Optional[Literal["csv", "arrow", "pandas", "list"]] = None,
|
|
128
|
+
catalog_name: str = "",
|
|
129
|
+
database_name: str = "",
|
|
130
|
+
schema_name: str = "",
|
|
131
|
+
) -> ExecuteSQLResult:
|
|
132
|
+
"""Route Doris SWITCH commands even with older core SQL classifiers."""
|
|
133
|
+
sql_query = (
|
|
134
|
+
input_params.get("sql_query")
|
|
135
|
+
if isinstance(input_params, dict)
|
|
136
|
+
else getattr(input_params, "sql_query", None)
|
|
137
|
+
)
|
|
138
|
+
if isinstance(sql_query, str) and _SWITCH_RE.match(sql_query):
|
|
139
|
+
self.validate_input(input_params)
|
|
140
|
+
return self.execute_content_set(sql_query.strip())
|
|
141
|
+
return super().execute(
|
|
142
|
+
input_params=input_params,
|
|
143
|
+
result_format=result_format,
|
|
144
|
+
catalog_name=catalog_name,
|
|
145
|
+
database_name=database_name,
|
|
146
|
+
schema_name=schema_name,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# ==================== Catalog Management (CatalogSupportMixin) ====================
|
|
150
|
+
|
|
151
|
+
@override
|
|
152
|
+
def default_catalog(self) -> str:
|
|
153
|
+
"""Doris default catalog."""
|
|
154
|
+
return "internal"
|
|
155
|
+
|
|
156
|
+
@override
|
|
157
|
+
def get_catalogs(self) -> List[str]:
|
|
158
|
+
"""Get list of catalogs."""
|
|
159
|
+
result = self._execute_pandas("SHOW CATALOGS")
|
|
160
|
+
if result.empty:
|
|
161
|
+
return []
|
|
162
|
+
catalog_column = "CatalogName" if "CatalogName" in result.columns else "Catalog"
|
|
163
|
+
return result[catalog_column].tolist()
|
|
164
|
+
|
|
165
|
+
@override
|
|
166
|
+
def switch_catalog(self, catalog_name: str) -> None:
|
|
167
|
+
"""Switch to a different catalog.
|
|
168
|
+
|
|
169
|
+
Clears database_name because the old database may not exist
|
|
170
|
+
in the new catalog.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
catalog_name: Name of the catalog to switch to
|
|
174
|
+
"""
|
|
175
|
+
self.switch_context(catalog_name=catalog_name)
|
|
176
|
+
self.database_name = ""
|
|
177
|
+
|
|
178
|
+
def _resolve_catalog(self, catalog_name: str = "") -> str:
|
|
179
|
+
"""Resolve the effective catalog name, falling back to configured or default."""
|
|
180
|
+
catalog = catalog_name or self.catalog_name
|
|
181
|
+
if not catalog or catalog == "def":
|
|
182
|
+
return self.default_catalog()
|
|
183
|
+
return catalog
|
|
184
|
+
|
|
185
|
+
@override
|
|
186
|
+
def get_current_context(self) -> Dict[str, str]:
|
|
187
|
+
"""Return Doris SQL coordinates with the effective catalog resolved."""
|
|
188
|
+
return {
|
|
189
|
+
"catalog_name": self._resolve_catalog(),
|
|
190
|
+
"database_name": self.database_name or "",
|
|
191
|
+
"schema_name": "",
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
@override
|
|
195
|
+
def do_switch_context(self, conn, catalog_name: str = "", database_name: str = "", schema_name: str = ""):
|
|
196
|
+
"""Apply catalog and/or database context to a connection."""
|
|
197
|
+
if catalog_name:
|
|
198
|
+
conn.execute(text(f"SWITCH {self.quote_identifier(catalog_name)}"))
|
|
199
|
+
conn.commit()
|
|
200
|
+
logger.debug(f"Switched catalog to: {catalog_name}")
|
|
201
|
+
if database_name:
|
|
202
|
+
conn.execute(text(f"USE {self.quote_identifier(database_name)}"))
|
|
203
|
+
conn.commit()
|
|
204
|
+
logger.debug(f"Switched database to: {database_name}")
|
|
205
|
+
|
|
206
|
+
@contextmanager
|
|
207
|
+
@override
|
|
208
|
+
def _conn(self, catalog_name: str = "", database_name: str = "", schema_name: str = ""):
|
|
209
|
+
"""Checkout a connection with catalog-aware context.
|
|
210
|
+
|
|
211
|
+
When a per-call ``catalog_name`` override targets a catalog different
|
|
212
|
+
from the stored thread-local catalog and no ``database_name`` is
|
|
213
|
+
passed explicitly, the stored ``self.database_name`` is NOT carried
|
|
214
|
+
over: it belongs to the old catalog and may not exist in the new
|
|
215
|
+
one, which would fail ``USE <db>`` after ``SWITCH <new>``.
|
|
216
|
+
|
|
217
|
+
The per-call ``catalog_name`` is normalized via ``_resolve_catalog``
|
|
218
|
+
(e.g. the ``"def"`` alias → ``internal``) before both the
|
|
219
|
+
divergence check and the downstream ``SWITCH``, so aliases are
|
|
220
|
+
compared and applied consistently.
|
|
221
|
+
"""
|
|
222
|
+
resolved_catalog = self._resolve_catalog(catalog_name) if catalog_name else ""
|
|
223
|
+
if resolved_catalog and not database_name and resolved_catalog != self.catalog_name:
|
|
224
|
+
effective_database = ""
|
|
225
|
+
effective_schema = schema_name or self.schema_name
|
|
226
|
+
engine = self._ensure_engine()
|
|
227
|
+
conn = engine.connect()
|
|
228
|
+
try:
|
|
229
|
+
self.do_switch_context(
|
|
230
|
+
conn,
|
|
231
|
+
catalog_name=resolved_catalog,
|
|
232
|
+
database_name=effective_database,
|
|
233
|
+
schema_name=effective_schema,
|
|
234
|
+
)
|
|
235
|
+
yield conn
|
|
236
|
+
except Exception:
|
|
237
|
+
try:
|
|
238
|
+
conn.rollback()
|
|
239
|
+
except Exception:
|
|
240
|
+
pass
|
|
241
|
+
raise
|
|
242
|
+
finally:
|
|
243
|
+
conn.close()
|
|
244
|
+
else:
|
|
245
|
+
with super()._conn(
|
|
246
|
+
catalog_name=resolved_catalog or catalog_name,
|
|
247
|
+
database_name=database_name,
|
|
248
|
+
schema_name=schema_name,
|
|
249
|
+
) as conn:
|
|
250
|
+
yield conn
|
|
251
|
+
|
|
252
|
+
@override
|
|
253
|
+
def execute_content_set(self, sql: str) -> ExecuteSQLResult:
|
|
254
|
+
"""Execute USE/SWITCH, clearing stored database on catalog changes.
|
|
255
|
+
|
|
256
|
+
Mirrors ``switch_catalog()``: when the catalog changes, the stored
|
|
257
|
+
``database_name`` no longer refers to a valid database under the
|
|
258
|
+
new catalog, so it is cleared to avoid a stale ``USE`` on the next
|
|
259
|
+
checkout.
|
|
260
|
+
"""
|
|
261
|
+
result = super().execute_content_set(sql)
|
|
262
|
+
if result.success:
|
|
263
|
+
context = _parse_doris_context_switch(sql)
|
|
264
|
+
if context:
|
|
265
|
+
if catalog := context.get("catalog_name"):
|
|
266
|
+
self.catalog_name = catalog
|
|
267
|
+
if database := context.get("database_name"):
|
|
268
|
+
self.database_name = database
|
|
269
|
+
if context.get("target") == "catalog" and context.get("catalog_name"):
|
|
270
|
+
self.database_name = ""
|
|
271
|
+
return result
|
|
272
|
+
|
|
273
|
+
# ==================== Metadata Retrieval (Stateless, Catalog-Qualified) ====================
|
|
274
|
+
|
|
275
|
+
def _get_metadata(
|
|
276
|
+
self,
|
|
277
|
+
table_type: str = "table",
|
|
278
|
+
catalog_name: str = "",
|
|
279
|
+
database_name: str = "",
|
|
280
|
+
) -> List[Dict[str, str]]:
|
|
281
|
+
"""
|
|
282
|
+
Get metadata for tables/views using catalog-qualified information_schema.
|
|
283
|
+
|
|
284
|
+
Uses `catalog.information_schema.TABLES` syntax so no session-level
|
|
285
|
+
catalog switch is needed.
|
|
286
|
+
This makes metadata queries stateless and thread-safe.
|
|
287
|
+
"""
|
|
288
|
+
self.connect()
|
|
289
|
+
current_catalog = self._resolve_catalog(catalog_name)
|
|
290
|
+
database_name = database_name or self.database_name
|
|
291
|
+
|
|
292
|
+
if table_type == "mv":
|
|
293
|
+
return self._get_materialized_view_metadata(current_catalog, database_name)
|
|
294
|
+
|
|
295
|
+
from datus_mysql.connector import _get_metadata_config
|
|
296
|
+
|
|
297
|
+
metadata_config = _get_metadata_config(table_type)
|
|
298
|
+
|
|
299
|
+
# Build WHERE clause
|
|
300
|
+
if database_name:
|
|
301
|
+
safe_db = database_name.replace("\\", "\\\\").replace("'", "''")
|
|
302
|
+
where = f"TABLE_SCHEMA = '{safe_db}'"
|
|
303
|
+
else:
|
|
304
|
+
where = list_to_in_str("TABLE_SCHEMA NOT IN", list(self._sys_databases()))
|
|
305
|
+
|
|
306
|
+
type_filter = list_to_in_str("AND TABLE_TYPE IN", metadata_config.table_types)
|
|
307
|
+
|
|
308
|
+
# Use catalog-qualified information_schema without changing session context.
|
|
309
|
+
query = (
|
|
310
|
+
f"SELECT TABLE_SCHEMA, TABLE_NAME "
|
|
311
|
+
f"FROM {self.quote_identifier(current_catalog)}.information_schema.{metadata_config.info_table} "
|
|
312
|
+
f"WHERE {where} {type_filter}"
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
query_result = self._execute_pandas(query)
|
|
316
|
+
|
|
317
|
+
result = []
|
|
318
|
+
for i in range(len(query_result)):
|
|
319
|
+
db_name = query_result["TABLE_SCHEMA"][i]
|
|
320
|
+
tb_name = query_result["TABLE_NAME"][i]
|
|
321
|
+
result.append(
|
|
322
|
+
{
|
|
323
|
+
"identifier": self.identifier(
|
|
324
|
+
catalog_name=current_catalog, database_name=db_name, table_name=tb_name
|
|
325
|
+
),
|
|
326
|
+
"catalog_name": current_catalog,
|
|
327
|
+
"schema_name": "",
|
|
328
|
+
"database_name": db_name,
|
|
329
|
+
"table_name": tb_name,
|
|
330
|
+
"table_type": table_type,
|
|
331
|
+
}
|
|
332
|
+
)
|
|
333
|
+
if table_type == "table" and current_catalog == self.default_catalog():
|
|
334
|
+
materialized_views = {
|
|
335
|
+
(mv["database_name"], mv["table_name"])
|
|
336
|
+
for mv in self._get_materialized_view_metadata(current_catalog, database_name)
|
|
337
|
+
}
|
|
338
|
+
result = [meta for meta in result if (meta["database_name"], meta["table_name"]) not in materialized_views]
|
|
339
|
+
return result
|
|
340
|
+
|
|
341
|
+
def _get_materialized_view_metadata(self, catalog_name: str, database_name: str = "") -> List[Dict[str, str]]:
|
|
342
|
+
"""Return independently queryable Doris asynchronous materialized views."""
|
|
343
|
+
if catalog_name != self.default_catalog():
|
|
344
|
+
return []
|
|
345
|
+
|
|
346
|
+
databases = [database_name] if database_name else self.get_databases(catalog_name=catalog_name)
|
|
347
|
+
result: List[Dict[str, str]] = []
|
|
348
|
+
for db_name in databases:
|
|
349
|
+
safe_db = db_name.replace("\\", "\\\\").replace('"', '\\"')
|
|
350
|
+
rows = self._execute_pandas(f'SELECT Name, QuerySql FROM mv_infos("database"="{safe_db}")')
|
|
351
|
+
for i in range(len(rows)):
|
|
352
|
+
table_name = str(rows["Name"][i])
|
|
353
|
+
result.append(
|
|
354
|
+
{
|
|
355
|
+
"identifier": self.identifier(
|
|
356
|
+
catalog_name=catalog_name,
|
|
357
|
+
database_name=db_name,
|
|
358
|
+
table_name=table_name,
|
|
359
|
+
),
|
|
360
|
+
"catalog_name": catalog_name,
|
|
361
|
+
"schema_name": "",
|
|
362
|
+
"database_name": db_name,
|
|
363
|
+
"table_name": table_name,
|
|
364
|
+
"table_type": "mv",
|
|
365
|
+
"query_sql": str(rows["QuerySql"][i]),
|
|
366
|
+
}
|
|
367
|
+
)
|
|
368
|
+
return result
|
|
369
|
+
|
|
370
|
+
@override
|
|
371
|
+
@staticmethod
|
|
372
|
+
def _qualify_name(meta, arg_db, arg_schema):
|
|
373
|
+
"""Prefix the table with the db/schema levels the caller left blank.
|
|
374
|
+
|
|
375
|
+
Yields ``[db.][schema.]table`` so an unscoped listing stays addressable; a level is
|
|
376
|
+
prepended only when the caller passed it empty and the row carries that coordinate.
|
|
377
|
+
"""
|
|
378
|
+
parts = []
|
|
379
|
+
if not arg_db and meta.get("database_name"):
|
|
380
|
+
parts.append(meta["database_name"])
|
|
381
|
+
if not arg_schema and meta.get("schema_name"):
|
|
382
|
+
parts.append(meta["schema_name"])
|
|
383
|
+
parts.append(meta["table_name"])
|
|
384
|
+
return ".".join(parts)
|
|
385
|
+
|
|
386
|
+
def get_tables(self, catalog_name: str = "", database_name: str = "", schema_name: str = "") -> List[str]:
|
|
387
|
+
"""Get list of table names."""
|
|
388
|
+
result = self._get_metadata(table_type="table", catalog_name=catalog_name, database_name=database_name)
|
|
389
|
+
return [self._qualify_name(table, database_name, schema_name) for table in result]
|
|
390
|
+
|
|
391
|
+
@override
|
|
392
|
+
def get_views(self, catalog_name: str = "", database_name: str = "", schema_name: str = "") -> List[str]:
|
|
393
|
+
"""Get list of view names."""
|
|
394
|
+
try:
|
|
395
|
+
result = self._get_metadata(table_type="view", catalog_name=catalog_name, database_name=database_name)
|
|
396
|
+
return [self._qualify_name(view, database_name, schema_name) for view in result]
|
|
397
|
+
except Exception as e:
|
|
398
|
+
logger.warning(f"Failed to get views: {e}")
|
|
399
|
+
return []
|
|
400
|
+
|
|
401
|
+
def get_materialized_views(
|
|
402
|
+
self, catalog_name: str = "", database_name: str = "", schema_name: str = ""
|
|
403
|
+
) -> List[str]:
|
|
404
|
+
"""Get list of materialized view names."""
|
|
405
|
+
try:
|
|
406
|
+
result = self._get_metadata(table_type="mv", catalog_name=catalog_name, database_name=database_name)
|
|
407
|
+
return [self._qualify_name(mv, database_name, schema_name) for mv in result]
|
|
408
|
+
except Exception as e:
|
|
409
|
+
logger.warning(f"Failed to get materialized views: {e}")
|
|
410
|
+
return []
|
|
411
|
+
|
|
412
|
+
def get_materialized_views_with_ddl(
|
|
413
|
+
self, catalog_name: str = "", database_name: str = "", schema_name: str = ""
|
|
414
|
+
) -> List[Dict[str, str]]:
|
|
415
|
+
"""Get asynchronous materialized views with their Doris DDL definitions."""
|
|
416
|
+
current_catalog = self._resolve_catalog(catalog_name)
|
|
417
|
+
database_name = database_name or self.database_name
|
|
418
|
+
result = []
|
|
419
|
+
for meta in self._get_materialized_view_metadata(current_catalog, database_name):
|
|
420
|
+
full_name = self.full_name(
|
|
421
|
+
catalog_name=meta["catalog_name"],
|
|
422
|
+
database_name=meta["database_name"],
|
|
423
|
+
table_name=meta["table_name"],
|
|
424
|
+
)
|
|
425
|
+
try:
|
|
426
|
+
definition = self._show_create(full_name, "MATERIALIZED VIEW")
|
|
427
|
+
except Exception as e:
|
|
428
|
+
logger.warning(f"Could not get DDL for {full_name}: {e}")
|
|
429
|
+
definition = meta["query_sql"]
|
|
430
|
+
meta["definition"] = definition
|
|
431
|
+
meta.pop("query_sql", None)
|
|
432
|
+
result.append(meta)
|
|
433
|
+
return result
|
|
434
|
+
|
|
435
|
+
@override
|
|
436
|
+
def _get_objects_with_ddl(
|
|
437
|
+
self,
|
|
438
|
+
table_type: TABLE_TYPE = "table",
|
|
439
|
+
tables: Optional[List[str]] = None,
|
|
440
|
+
catalog_name: str = "",
|
|
441
|
+
database_name: str = "",
|
|
442
|
+
) -> List[Dict[str, str]]:
|
|
443
|
+
"""Get catalog-aware Doris table or view metadata with DDL."""
|
|
444
|
+
from datus_mysql.connector import _get_metadata_config
|
|
445
|
+
|
|
446
|
+
requested = set(tables or [])
|
|
447
|
+
metadata_config = _get_metadata_config(table_type)
|
|
448
|
+
result = []
|
|
449
|
+
for meta in self._get_metadata(table_type, catalog_name, database_name):
|
|
450
|
+
full_name = self.full_name(
|
|
451
|
+
catalog_name=meta["catalog_name"],
|
|
452
|
+
database_name=meta["database_name"],
|
|
453
|
+
table_name=meta["table_name"],
|
|
454
|
+
)
|
|
455
|
+
if requested and meta["table_name"] not in requested and full_name not in requested:
|
|
456
|
+
continue
|
|
457
|
+
try:
|
|
458
|
+
meta["definition"] = self._show_create(full_name, metadata_config.show_create_table)
|
|
459
|
+
except Exception as e:
|
|
460
|
+
logger.warning(f"Could not get DDL for {full_name}: {e}")
|
|
461
|
+
meta["definition"] = f"-- DDL not available for {meta['table_name']}"
|
|
462
|
+
result.append(meta)
|
|
463
|
+
return result
|
|
464
|
+
|
|
465
|
+
@override
|
|
466
|
+
def get_schema(
|
|
467
|
+
self,
|
|
468
|
+
catalog_name: str = "",
|
|
469
|
+
database_name: str = "",
|
|
470
|
+
schema_name: str = "",
|
|
471
|
+
table_name: str = "",
|
|
472
|
+
) -> List[Dict[str, Any]]:
|
|
473
|
+
"""Get Doris column metadata from the requested catalog."""
|
|
474
|
+
if not table_name:
|
|
475
|
+
return []
|
|
476
|
+
current_catalog = self._resolve_catalog(catalog_name)
|
|
477
|
+
database_name = database_name or self.database_name
|
|
478
|
+
safe_db = database_name.replace("\\", "\\\\").replace("'", "''")
|
|
479
|
+
safe_table = table_name.replace("\\", "\\\\").replace("'", "''")
|
|
480
|
+
rows = self._execute_pandas(
|
|
481
|
+
f"""
|
|
482
|
+
SELECT
|
|
483
|
+
COLUMN_NAME AS Field,
|
|
484
|
+
COLUMN_TYPE AS Type,
|
|
485
|
+
IS_NULLABLE AS `Null`,
|
|
486
|
+
COLUMN_KEY AS `Key`,
|
|
487
|
+
COLUMN_DEFAULT AS `Default`,
|
|
488
|
+
COLUMN_COMMENT AS Comment
|
|
489
|
+
FROM {self.quote_identifier(current_catalog)}.information_schema.columns
|
|
490
|
+
WHERE TABLE_SCHEMA = '{safe_db}'
|
|
491
|
+
AND TABLE_NAME = '{safe_table}'
|
|
492
|
+
ORDER BY ORDINAL_POSITION
|
|
493
|
+
"""
|
|
494
|
+
)
|
|
495
|
+
return [
|
|
496
|
+
{
|
|
497
|
+
"cid": i,
|
|
498
|
+
"name": rows["Field"][i],
|
|
499
|
+
"type": rows["Type"][i],
|
|
500
|
+
"nullable": rows["Null"][i] == "YES",
|
|
501
|
+
"default_value": rows["Default"][i],
|
|
502
|
+
"pk": rows["Key"][i] == "PRI",
|
|
503
|
+
"comment": rows["Comment"][i],
|
|
504
|
+
}
|
|
505
|
+
for i in range(len(rows))
|
|
506
|
+
]
|
|
507
|
+
|
|
508
|
+
# ==================== Database Management ====================
|
|
509
|
+
|
|
510
|
+
def _sys_databases(self) -> Set[str]:
|
|
511
|
+
"""System databases to filter out (Doris-specific)."""
|
|
512
|
+
# Include MySQL system databases plus Doris-specific ones
|
|
513
|
+
mysql_sys = super()._sys_databases()
|
|
514
|
+
doris_sys = {"__internal_schema"}
|
|
515
|
+
return mysql_sys | doris_sys
|
|
516
|
+
|
|
517
|
+
@override
|
|
518
|
+
def get_databases(self, catalog_name: str = "", include_sys: bool = False) -> List[str]:
|
|
519
|
+
"""Get list of databases using catalog-qualified SHOW DATABASES."""
|
|
520
|
+
current_catalog = self._resolve_catalog(catalog_name)
|
|
521
|
+
result = self._execute_pandas(f"SHOW DATABASES FROM {self.quote_identifier(current_catalog)}")
|
|
522
|
+
if result.empty:
|
|
523
|
+
return []
|
|
524
|
+
databases = result.iloc[:, 0].tolist()
|
|
525
|
+
if not include_sys:
|
|
526
|
+
sys_dbs = self._sys_databases()
|
|
527
|
+
databases = [db for db in databases if db not in sys_dbs]
|
|
528
|
+
return databases
|
|
529
|
+
|
|
530
|
+
# ==================== Full Name Construction ====================
|
|
531
|
+
|
|
532
|
+
@override
|
|
533
|
+
def full_name(
|
|
534
|
+
self,
|
|
535
|
+
catalog_name: str = "",
|
|
536
|
+
database_name: str = "",
|
|
537
|
+
schema_name: str = "",
|
|
538
|
+
table_name: str = "",
|
|
539
|
+
) -> str:
|
|
540
|
+
"""
|
|
541
|
+
Build fully-qualified table name with catalog support.
|
|
542
|
+
|
|
543
|
+
Doris format: `catalog`.`database`.`table`
|
|
544
|
+
"""
|
|
545
|
+
catalog_name = self._resolve_catalog(catalog_name)
|
|
546
|
+
quoted_table = self.quote_identifier(table_name)
|
|
547
|
+
|
|
548
|
+
if catalog_name:
|
|
549
|
+
if database_name:
|
|
550
|
+
return ".".join(
|
|
551
|
+
(
|
|
552
|
+
self.quote_identifier(catalog_name),
|
|
553
|
+
self.quote_identifier(database_name),
|
|
554
|
+
quoted_table,
|
|
555
|
+
)
|
|
556
|
+
)
|
|
557
|
+
return quoted_table
|
|
558
|
+
if database_name:
|
|
559
|
+
return f"{self.quote_identifier(database_name)}.{quoted_table}"
|
|
560
|
+
return quoted_table
|
|
561
|
+
|
|
562
|
+
@override
|
|
563
|
+
def _sqlalchemy_schema(
|
|
564
|
+
self, catalog_name: str = "", database_name: str = "", schema_name: str = ""
|
|
565
|
+
) -> Optional[str]:
|
|
566
|
+
"""Get schema name for SQLAlchemy Inspector with catalog support."""
|
|
567
|
+
database_name = database_name or self.database_name
|
|
568
|
+
|
|
569
|
+
catalog_name = catalog_name or self.catalog_name or self.default_catalog()
|
|
570
|
+
if database_name:
|
|
571
|
+
return f"{catalog_name}.{database_name}"
|
|
572
|
+
return None
|
|
573
|
+
|
|
574
|
+
@override
|
|
575
|
+
def get_sample_rows(
|
|
576
|
+
self,
|
|
577
|
+
tables: Optional[List[str]] = None,
|
|
578
|
+
top_n: int = 5,
|
|
579
|
+
catalog_name: str = "",
|
|
580
|
+
database_name: str = "",
|
|
581
|
+
schema_name: str = "",
|
|
582
|
+
table_type: TABLE_TYPE = "table",
|
|
583
|
+
) -> List[Dict[str, Any]]:
|
|
584
|
+
"""Get sample rows without losing the requested Doris catalog."""
|
|
585
|
+
if table_type == "full":
|
|
586
|
+
return super().get_sample_rows(
|
|
587
|
+
tables=tables,
|
|
588
|
+
top_n=top_n,
|
|
589
|
+
catalog_name=catalog_name,
|
|
590
|
+
database_name=database_name,
|
|
591
|
+
schema_name=schema_name,
|
|
592
|
+
table_type=table_type,
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
current_catalog = self._resolve_catalog(catalog_name)
|
|
596
|
+
database_name = database_name or self.database_name
|
|
597
|
+
requested = set(tables or [])
|
|
598
|
+
result = []
|
|
599
|
+
for meta in self._get_metadata(table_type, current_catalog, database_name):
|
|
600
|
+
qualified_name = self._qualify_name(meta, database_name, schema_name)
|
|
601
|
+
full_name = self.full_name(
|
|
602
|
+
catalog_name=meta["catalog_name"],
|
|
603
|
+
database_name=meta["database_name"],
|
|
604
|
+
table_name=meta["table_name"],
|
|
605
|
+
)
|
|
606
|
+
if (
|
|
607
|
+
requested
|
|
608
|
+
and meta["table_name"] not in requested
|
|
609
|
+
and qualified_name not in requested
|
|
610
|
+
and full_name not in requested
|
|
611
|
+
):
|
|
612
|
+
continue
|
|
613
|
+
rows = self._execute_pandas(f"SELECT * FROM {full_name} LIMIT {top_n}")
|
|
614
|
+
if rows.empty:
|
|
615
|
+
continue
|
|
616
|
+
result.append({**meta, "sample_rows": rows.to_csv(index=False)})
|
|
617
|
+
return result
|
|
618
|
+
|
|
619
|
+
# ==================== Connection Cleanup ====================
|
|
620
|
+
|
|
621
|
+
@override
|
|
622
|
+
def close(self):
|
|
623
|
+
"""
|
|
624
|
+
Close engine with special handling for PyMySQL cleanup errors.
|
|
625
|
+
|
|
626
|
+
Doris may trigger PyMySQL struct.pack errors during cleanup,
|
|
627
|
+
which we safely ignore.
|
|
628
|
+
"""
|
|
629
|
+
try:
|
|
630
|
+
super().close()
|
|
631
|
+
except Exception as e:
|
|
632
|
+
error_str = str(e)
|
|
633
|
+
|
|
634
|
+
# Check for known PyMySQL cleanup errors
|
|
635
|
+
pymysql_errors = [
|
|
636
|
+
"struct.error",
|
|
637
|
+
"struct.pack",
|
|
638
|
+
"COMMAND.COM_QUIT",
|
|
639
|
+
"required argument is not an integer",
|
|
640
|
+
]
|
|
641
|
+
|
|
642
|
+
if any(err in error_str for err in pymysql_errors):
|
|
643
|
+
logger.debug(f"Ignoring PyMySQL cleanup error: {e}")
|
|
644
|
+
if hasattr(self, "engine"):
|
|
645
|
+
try:
|
|
646
|
+
if self.engine:
|
|
647
|
+
self.engine.dispose()
|
|
648
|
+
except Exception:
|
|
649
|
+
pass
|
|
650
|
+
finally:
|
|
651
|
+
self.engine = None
|
|
652
|
+
self._owns_engine = False
|
|
653
|
+
else:
|
|
654
|
+
logger.error(f"Unexpected close error: {e}")
|
|
655
|
+
raise
|
|
656
|
+
|
|
657
|
+
# ==================== Utility Methods ====================
|
|
658
|
+
|
|
659
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
660
|
+
"""Convert connector to serializable dictionary."""
|
|
661
|
+
return {
|
|
662
|
+
"db_type": "doris",
|
|
663
|
+
"host": self.host,
|
|
664
|
+
"port": self.port,
|
|
665
|
+
"user": self.username,
|
|
666
|
+
"catalog": self.catalog_name,
|
|
667
|
+
"database": self.database_name,
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
def get_type(self) -> str:
|
|
671
|
+
"""Return the database type."""
|
|
672
|
+
return "doris"
|
|
673
|
+
|
|
674
|
+
@override
|
|
675
|
+
def test_connection(self) -> bool:
|
|
676
|
+
"""Test the database connection with proper cleanup."""
|
|
677
|
+
try:
|
|
678
|
+
return super().test_connection()
|
|
679
|
+
finally:
|
|
680
|
+
# Ensure connection is closed after test
|
|
681
|
+
try:
|
|
682
|
+
self.close()
|
|
683
|
+
except Exception as e:
|
|
684
|
+
logger.debug(f"Ignoring cleanup error during test: {e}")
|
|
685
|
+
|
|
686
|
+
# ==================== MigrationTargetMixin ====================
|
|
687
|
+
|
|
688
|
+
def describe_migration_capabilities(self) -> Dict[str, Any]:
|
|
689
|
+
"""Describe Doris-specific DDL and type-mapping requirements."""
|
|
690
|
+
return {
|
|
691
|
+
"supported": True,
|
|
692
|
+
"dialect_family": "mysql-like",
|
|
693
|
+
"requires": [
|
|
694
|
+
"One of DUPLICATE KEY / UNIQUE KEY / AGGREGATE KEY",
|
|
695
|
+
"DISTRIBUTED BY HASH(cols) BUCKETS N",
|
|
696
|
+
],
|
|
697
|
+
"forbids": ["FOREIGN KEY", "FULLTEXT INDEX", "CHECK"],
|
|
698
|
+
"type_hints": {
|
|
699
|
+
"unbounded VARCHAR": "VARCHAR(65533)",
|
|
700
|
+
"TEXT": "STRING",
|
|
701
|
+
"TIMESTAMP": "DATETIME",
|
|
702
|
+
"TIMESTAMPTZ": "DATETIME",
|
|
703
|
+
"TIME": "VARCHAR(20) (Doris has no native TIME)",
|
|
704
|
+
"UUID": "VARCHAR(36)",
|
|
705
|
+
"HUGEINT": "LARGEINT",
|
|
706
|
+
},
|
|
707
|
+
"example_ddl": (
|
|
708
|
+
"CREATE TABLE db.t (\n"
|
|
709
|
+
" id BIGINT NOT NULL,\n"
|
|
710
|
+
" name VARCHAR(255)\n"
|
|
711
|
+
") ENGINE=OLAP\n"
|
|
712
|
+
"DUPLICATE KEY(id)\n"
|
|
713
|
+
"DISTRIBUTED BY HASH(id) BUCKETS 10\n"
|
|
714
|
+
'PROPERTIES ("replication_num" = "1")'
|
|
715
|
+
),
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
def suggest_table_layout(self, columns: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
719
|
+
"""Suggest Doris duplicate-key and distribution columns."""
|
|
720
|
+
if not columns:
|
|
721
|
+
return {"duplicate_key": [], "distributed_by": [], "buckets": 10}
|
|
722
|
+
|
|
723
|
+
keys = self._score_keys(columns, max_keys=3)
|
|
724
|
+
return {"duplicate_key": keys, "distributed_by": keys, "buckets": 10}
|
|
725
|
+
|
|
726
|
+
@staticmethod
|
|
727
|
+
def _score_keys(columns: List[Dict[str, Any]], max_keys: int = 3) -> List[str]:
|
|
728
|
+
"""Select key columns using priority rules.
|
|
729
|
+
|
|
730
|
+
Priority:
|
|
731
|
+
1. Columns with 'id' or '_id' suffix (+100)
|
|
732
|
+
2. INT/BIGINT type columns (+50)
|
|
733
|
+
3. Non-nullable columns preferred (+10)
|
|
734
|
+
4. Fallback to first column
|
|
735
|
+
"""
|
|
736
|
+
import re as _re
|
|
737
|
+
|
|
738
|
+
scored = []
|
|
739
|
+
for col in columns:
|
|
740
|
+
name = col["name"]
|
|
741
|
+
col_type = str(col.get("type", "")).upper()
|
|
742
|
+
base_type = _re.sub(r"\(.*\)", "", col_type).strip()
|
|
743
|
+
nullable = col.get("nullable", True)
|
|
744
|
+
|
|
745
|
+
score = 0
|
|
746
|
+
if name.lower() == "id" or name.lower().endswith("_id"):
|
|
747
|
+
score += 100
|
|
748
|
+
if base_type in _INTEGER_TYPES:
|
|
749
|
+
score += 50
|
|
750
|
+
if not nullable:
|
|
751
|
+
score += 10
|
|
752
|
+
|
|
753
|
+
scored.append((score, name))
|
|
754
|
+
|
|
755
|
+
scored.sort(key=lambda x: (-x[0], x[1]))
|
|
756
|
+
|
|
757
|
+
if scored[0][0] == 0:
|
|
758
|
+
return [columns[0]["name"]]
|
|
759
|
+
|
|
760
|
+
return [name for score, name in scored[:max_keys] if score > 0] or [columns[0]["name"]]
|
|
761
|
+
|
|
762
|
+
def validate_ddl(self, ddl: str) -> List[str]:
|
|
763
|
+
"""Return Doris compatibility errors for a proposed table DDL."""
|
|
764
|
+
errors: List[str] = []
|
|
765
|
+
from datus_db_core.sql_utils import mask_sql_quoted_regions, strip_sql_comments
|
|
766
|
+
|
|
767
|
+
upper = mask_sql_quoted_regions(strip_sql_comments(ddl)).upper()
|
|
768
|
+
has_duplicate_key = bool(re.search(r"\bDUPLICATE\s+KEY\b", upper)) and not re.search(
|
|
769
|
+
r"\bON\s+DUPLICATE\s+KEY\b", upper
|
|
770
|
+
)
|
|
771
|
+
if not (has_duplicate_key or re.search(r"\bUNIQUE\s+KEY\b", upper) or re.search(r"\bAGGREGATE\s+KEY\b", upper)):
|
|
772
|
+
errors.append("Doris DDL must define one of: DUPLICATE KEY / UNIQUE KEY / AGGREGATE KEY")
|
|
773
|
+
|
|
774
|
+
if not re.search(r"\bDISTRIBUTED\s+BY\b", upper):
|
|
775
|
+
errors.append("Doris DDL must include a DISTRIBUTED BY clause")
|
|
776
|
+
|
|
777
|
+
if re.search(r"\bAUTO_INCREMENT\b", upper) and not re.search(r"\bUNIQUE\s+KEY\b", upper):
|
|
778
|
+
errors.append("Doris AUTO_INCREMENT columns require a UNIQUE KEY table")
|
|
779
|
+
|
|
780
|
+
if re.search(r"\bFOREIGN\s+KEY\b", upper):
|
|
781
|
+
errors.append("Doris does not support FOREIGN KEY")
|
|
782
|
+
|
|
783
|
+
if re.search(r"\bFULLTEXT\b", upper):
|
|
784
|
+
errors.append("Doris does not support FULLTEXT indexes")
|
|
785
|
+
|
|
786
|
+
if re.search(r"\bCHECK\s*\(", upper):
|
|
787
|
+
errors.append("Doris does not support CHECK constraints")
|
|
788
|
+
|
|
789
|
+
return errors
|
|
790
|
+
|
|
791
|
+
def map_source_type(self, source_dialect: str, source_type: str) -> str | None:
|
|
792
|
+
"""Map a source type when Doris requires a dialect-specific override."""
|
|
793
|
+
base = source_type.strip().upper()
|
|
794
|
+
# Strip params for matching
|
|
795
|
+
import re as _re
|
|
796
|
+
|
|
797
|
+
base_noparam = _re.sub(r"\(.*\)", "", base).strip()
|
|
798
|
+
# Deterministic overrides for well-known pairings
|
|
799
|
+
overrides = {
|
|
800
|
+
"HUGEINT": "LARGEINT",
|
|
801
|
+
"TIMESTAMP": "DATETIME",
|
|
802
|
+
"TIMESTAMPTZ": "DATETIME",
|
|
803
|
+
"TIMESTAMP WITH TIME ZONE": "DATETIME",
|
|
804
|
+
"TEXT": "STRING",
|
|
805
|
+
"TIME": "VARCHAR(20)",
|
|
806
|
+
"UUID": "VARCHAR(36)",
|
|
807
|
+
}
|
|
808
|
+
return overrides.get(base_noparam)
|
datus_doris/tpch_data.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Copyright 2025-present DatusAI, Inc.
|
|
2
|
+
# Licensed under the Apache License, Version 2.0.
|
|
3
|
+
# See http://www.apache.org/licenses/LICENSE-2.0 for details.
|
|
4
|
+
|
|
5
|
+
"""Doris-dialect TPC-H test data definitions."""
|
|
6
|
+
|
|
7
|
+
from datus_db_core.testing.tpch import ROW_COUNTS, TPCH_TABLES, build_tpch_inserts
|
|
8
|
+
|
|
9
|
+
_TPCH_DDL_ITEMS = [
|
|
10
|
+
(
|
|
11
|
+
"tpch_region",
|
|
12
|
+
"""
|
|
13
|
+
CREATE TABLE IF NOT EXISTS `tpch_region` (
|
|
14
|
+
`regionkey` INT NOT NULL,
|
|
15
|
+
`name` VARCHAR(25) NOT NULL,
|
|
16
|
+
`comment` VARCHAR(152)
|
|
17
|
+
) ENGINE=OLAP
|
|
18
|
+
UNIQUE KEY (`regionkey`)
|
|
19
|
+
DISTRIBUTED BY HASH(`regionkey`) BUCKETS 1
|
|
20
|
+
PROPERTIES ("replication_num" = "1")
|
|
21
|
+
""",
|
|
22
|
+
),
|
|
23
|
+
(
|
|
24
|
+
"tpch_nation",
|
|
25
|
+
"""
|
|
26
|
+
CREATE TABLE IF NOT EXISTS `tpch_nation` (
|
|
27
|
+
`nationkey` INT NOT NULL,
|
|
28
|
+
`name` VARCHAR(25) NOT NULL,
|
|
29
|
+
`regionkey` INT NOT NULL,
|
|
30
|
+
`comment` VARCHAR(152)
|
|
31
|
+
) ENGINE=OLAP
|
|
32
|
+
UNIQUE KEY (`nationkey`)
|
|
33
|
+
DISTRIBUTED BY HASH(`nationkey`) BUCKETS 1
|
|
34
|
+
PROPERTIES ("replication_num" = "1")
|
|
35
|
+
""",
|
|
36
|
+
),
|
|
37
|
+
(
|
|
38
|
+
"tpch_customer",
|
|
39
|
+
"""
|
|
40
|
+
CREATE TABLE IF NOT EXISTS `tpch_customer` (
|
|
41
|
+
`custkey` INT NOT NULL,
|
|
42
|
+
`name` VARCHAR(25) NOT NULL,
|
|
43
|
+
`nationkey` INT NOT NULL,
|
|
44
|
+
`acctbal` DECIMAL(15,2) NOT NULL,
|
|
45
|
+
`mktsegment` VARCHAR(10) NOT NULL
|
|
46
|
+
) ENGINE=OLAP
|
|
47
|
+
UNIQUE KEY (`custkey`)
|
|
48
|
+
DISTRIBUTED BY HASH(`custkey`) BUCKETS 1
|
|
49
|
+
PROPERTIES ("replication_num" = "1")
|
|
50
|
+
""",
|
|
51
|
+
),
|
|
52
|
+
(
|
|
53
|
+
"tpch_orders",
|
|
54
|
+
"""
|
|
55
|
+
CREATE TABLE IF NOT EXISTS `tpch_orders` (
|
|
56
|
+
`orderkey` INT NOT NULL,
|
|
57
|
+
`custkey` INT NOT NULL,
|
|
58
|
+
`orderstatus` VARCHAR(1) NOT NULL,
|
|
59
|
+
`totalprice` DECIMAL(15,2) NOT NULL,
|
|
60
|
+
`orderdate` DATE NOT NULL
|
|
61
|
+
) ENGINE=OLAP
|
|
62
|
+
UNIQUE KEY (`orderkey`)
|
|
63
|
+
DISTRIBUTED BY HASH(`orderkey`) BUCKETS 1
|
|
64
|
+
PROPERTIES ("replication_num" = "1")
|
|
65
|
+
""",
|
|
66
|
+
),
|
|
67
|
+
(
|
|
68
|
+
"tpch_supplier",
|
|
69
|
+
"""
|
|
70
|
+
CREATE TABLE IF NOT EXISTS `tpch_supplier` (
|
|
71
|
+
`suppkey` INT NOT NULL,
|
|
72
|
+
`name` VARCHAR(25) NOT NULL,
|
|
73
|
+
`nationkey` INT NOT NULL,
|
|
74
|
+
`acctbal` DECIMAL(15,2) NOT NULL
|
|
75
|
+
) ENGINE=OLAP
|
|
76
|
+
UNIQUE KEY (`suppkey`)
|
|
77
|
+
DISTRIBUTED BY HASH(`suppkey`) BUCKETS 1
|
|
78
|
+
PROPERTIES ("replication_num" = "1")
|
|
79
|
+
""",
|
|
80
|
+
),
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
if [table for table, _ in _TPCH_DDL_ITEMS] != TPCH_TABLES:
|
|
84
|
+
raise ValueError("Doris TPC-H DDL order must match the shared TPCH_TABLES order")
|
|
85
|
+
|
|
86
|
+
TPCH_DDL = [ddl for _, ddl in _TPCH_DDL_ITEMS]
|
|
87
|
+
TPCH_DATA = build_tpch_inserts(lambda t: f"`{t}`")
|
|
88
|
+
|
|
89
|
+
__all__ = ["TPCH_DDL", "TPCH_DATA", "TPCH_TABLES", "ROW_COUNTS"]
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: datus-doris
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Doris database adapter for Datus
|
|
5
|
+
Project-URL: Homepage, https://github.com/Datus-ai/datus-db-adapters
|
|
6
|
+
Project-URL: Repository, https://github.com/Datus-ai/datus-db-adapters
|
|
7
|
+
Project-URL: Issues, https://github.com/Datus-ai/datus-db-adapters/issues
|
|
8
|
+
Author-email: DatusAI <support@datus.ai>
|
|
9
|
+
License: Apache-2.0
|
|
10
|
+
Keywords: adapter,database,datus,doris
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Requires-Python: >=3.12
|
|
17
|
+
Requires-Dist: datus-db-core>=0.1.5
|
|
18
|
+
Requires-Dist: datus-mysql>=0.1.8
|
|
19
|
+
Requires-Dist: datus-sqlalchemy>=0.1.7
|
|
20
|
+
Requires-Dist: pydantic>=2.0.0
|
|
21
|
+
Requires-Dist: pymysql>=1.1.1
|
|
22
|
+
Provides-Extra: test
|
|
23
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == 'test'
|
|
24
|
+
Requires-Dist: pytest>=7.0.0; extra == 'test'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# datus-doris
|
|
28
|
+
|
|
29
|
+
Apache Doris database adapter for Datus.
|
|
30
|
+
|
|
31
|
+
## Features
|
|
32
|
+
|
|
33
|
+
- MySQL-protocol connection and SQL execution
|
|
34
|
+
- Multi-catalog discovery and context switching
|
|
35
|
+
- Catalog-aware table, view, schema, and sample-row metadata
|
|
36
|
+
- Asynchronous materialized-view discovery and DDL retrieval
|
|
37
|
+
- Three-part identifiers: `catalog.database.table`
|
|
38
|
+
- Doris migration capability, table-layout, and type-mapping guidance
|
|
39
|
+
- List, CSV, Pandas, and Arrow result formats inherited from `datus-mysql`
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install datus-doris
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The package installs `datus-db-core` and `datus-mysql` as dependencies and
|
|
48
|
+
registers the `doris` adapter through the `datus.adapters` entry-point group.
|
|
49
|
+
|
|
50
|
+
## Configuration
|
|
51
|
+
|
|
52
|
+
```yaml
|
|
53
|
+
database:
|
|
54
|
+
type: doris
|
|
55
|
+
host: localhost
|
|
56
|
+
port: 9030
|
|
57
|
+
username: root
|
|
58
|
+
password: ""
|
|
59
|
+
catalog: internal
|
|
60
|
+
database: analytics
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The built-in catalog is `internal`. External catalogs, including Hive Metastore
|
|
64
|
+
catalogs, can be selected through the same connector API.
|
|
65
|
+
|
|
66
|
+
## Python API
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from datus_doris import DorisConfig, DorisConnector
|
|
70
|
+
|
|
71
|
+
config = DorisConfig(
|
|
72
|
+
host="localhost",
|
|
73
|
+
port=9030,
|
|
74
|
+
username="root",
|
|
75
|
+
password="",
|
|
76
|
+
catalog="internal",
|
|
77
|
+
database="analytics",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
with DorisConnector(config) as connector:
|
|
81
|
+
catalogs = connector.get_catalogs()
|
|
82
|
+
tables = connector.get_tables(database_name="analytics")
|
|
83
|
+
views = connector.get_views(database_name="analytics")
|
|
84
|
+
materialized_views = connector.get_materialized_views_with_ddl(
|
|
85
|
+
database_name="analytics"
|
|
86
|
+
)
|
|
87
|
+
result = connector.execute_query("SELECT 1")
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Catalog context can be switched explicitly:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
connector.switch_catalog("hive_catalog")
|
|
94
|
+
databases = connector.get_databases()
|
|
95
|
+
connector.switch_catalog("internal")
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Fully qualified identifiers are rendered as Doris three-part names:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
connector.full_name(
|
|
102
|
+
catalog_name="internal",
|
|
103
|
+
database_name="analytics",
|
|
104
|
+
table_name="events",
|
|
105
|
+
)
|
|
106
|
+
# `internal`.`analytics`.`events`
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Development and testing
|
|
110
|
+
|
|
111
|
+
Unit tests do not require a database:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
ci/run-unit-tests.sh datus-doris
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The repository integration runner starts Apache Doris FE/BE 4.0.7 and Hive
|
|
118
|
+
Metastore 4.0.1, waits for an alive backend and a successful OLAP DDL probe,
|
|
119
|
+
runs the complete integration suite, and removes the test services afterward:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
ci/run-integration-tests.sh doris
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
To run the package-local workflow manually:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
cd datus-doris
|
|
129
|
+
docker compose up -d --wait
|
|
130
|
+
./scripts/test.sh integration
|
|
131
|
+
docker compose down -v
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The integration suite covers catalog operations, Hive external catalogs,
|
|
135
|
+
tables, views, asynchronous materialized views, DDL/DML, sample rows, TPC-H
|
|
136
|
+
queries, and list/CSV/Pandas/Arrow output.
|
|
137
|
+
|
|
138
|
+
### Environment variables
|
|
139
|
+
|
|
140
|
+
| Variable | Default | Description |
|
|
141
|
+
| --- | --- | --- |
|
|
142
|
+
| `DORIS_HOST` | `localhost` | FE query host |
|
|
143
|
+
| `DORIS_PORT` | `9030` | MySQL protocol port |
|
|
144
|
+
| `DORIS_USER` | `root` | Username |
|
|
145
|
+
| `DORIS_PASSWORD` | empty | Password |
|
|
146
|
+
| `DORIS_CATALOG` | `internal` | Initial catalog |
|
|
147
|
+
| `DORIS_DATABASE` | `test` | Test database |
|
|
148
|
+
| `DORIS_FE_JAVA_XMS` | `1024m` | Initial FE JVM heap for the Docker test environment |
|
|
149
|
+
| `DORIS_FE_JAVA_XMX` | `2048m` | Maximum FE JVM heap for the Docker test environment |
|
|
150
|
+
| `HIVE_METASTORE_URI` | `thrift://hive-metastore:9083` | Hive catalog metastore URI |
|
|
151
|
+
|
|
152
|
+
## License
|
|
153
|
+
|
|
154
|
+
Apache License 2.0
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
datus_doris/__init__.py,sha256=JyPSZwOEmOg-k0zW4LpUZoEFKDvoHhBe5tGYxKNjQ8k,585
|
|
2
|
+
datus_doris/config.py,sha256=qJRMCwca33U9p_o8VfMI0WtzvUUdeMAwxJ3BcqdKFG4,1121
|
|
3
|
+
datus_doris/connector.py,sha256=BNFYeJowHEjhNlOmxhVX6StBfMfCziDLt2CLLv3_iOI,31753
|
|
4
|
+
datus_doris/tpch_data.py,sha256=3B2Qb-A--GrqZSVMHIHICLy2fR_qxyI1WiqBixbBS8Y,2568
|
|
5
|
+
datus_doris-0.1.0.dist-info/METADATA,sha256=uNer59NOsI51mQfO2h5ALNCsTBluTnPxMy08GhzLXqY,4382
|
|
6
|
+
datus_doris-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
7
|
+
datus_doris-0.1.0.dist-info/entry_points.txt,sha256=hj1RWF2OBJJNJDlPifLL_-ON3kT8nWrHL0mg5LE4rNc,46
|
|
8
|
+
datus_doris-0.1.0.dist-info/RECORD,,
|