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.
@@ -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")
@@ -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)
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [datus.adapters]
2
+ doris = datus_doris:register