Skip to content

Commit ddb6ee0

Browse files
committed
feat(doris): support writable Paimon catalogs, Hive storage_format, and lazy default_catalog lookup
1 parent fd8e955 commit ddb6ee0

5 files changed

Lines changed: 161 additions & 40 deletions

File tree

‎docs/integrations/engines/doris.md‎

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -440,9 +440,34 @@ SQLMesh's Doris dialect natively parses and transpiles Apache Iceberg snapshot t
440440
);
441441
```
442442

443-
### Pattern 4: Reading External Lance Tables & Catalogs (Doris 4.1+)
443+
### Pattern 4: Reading & Writing External Apache Paimon and Apache Hive Catalogs
444444

445-
Apache Doris 4.1+ supports reading multimodal and vector datasets stored in **Lance** format via external Lance catalogs (`'type' = 'lance'`) or table-valued functions (`'format' = 'lance'`).
445+
In addition to Apache Iceberg, `DorisEngineAdapter` supports reading and writing external **Apache Paimon** (`'type' = 'paimon'`, `table_format paimon`) and **Apache Hive** (`'type' = 'hms'`, `table_format hive`) catalogs:
446+
447+
- **Apache Paimon (`table_format paimon`)**: Emits `ENGINE=paimon`, maps `storage_format` to `'file.format'` (`'parquet'` or `'orc'`), and automatically populates Paimon's `'primary-key'` property from `unique_key` (including partition columns when `partitioned_by` is set) so Doris 4.1+ row-level `DELETE` and `MERGE INTO` work out of the box.
448+
- **Apache Hive (`table_format hive`)**: Emits `ENGINE=hive` and maps `storage_format` to `'file_format'` (`'parquet'`, `'orc'`, or `'text'`). Supports `FULL` (`INSERT OVERWRITE TABLE`) and append workloads (`INCREMENTAL_UNMANAGED`), while row-level `DELETE` and `MERGE INTO` are not supported by Hive catalogs in Doris.
449+
450+
```sql
451+
MODEL (
452+
name paimon_catalog.silver.orders_paimon,
453+
kind INCREMENTAL_BY_UNIQUE_KEY (
454+
unique_key order_id
455+
),
456+
table_format paimon,
457+
storage_format parquet,
458+
partitioned_by (ds)
459+
);
460+
461+
SELECT
462+
order_id::BIGINT AS order_id,
463+
amount::DECIMAL(18, 2) AS amount,
464+
ds::DATE AS ds
465+
FROM hive_catalog.raw.orders;
466+
```
467+
468+
### Pattern 5: Reading External Lance, Hudi & Delta Lake Tables (Doris 4.1+)
469+
470+
Apache Doris 4.1+ supports reading multimodal and vector datasets stored in **Lance** format (`'type' = 'lance'` catalog or `s3(...)` / `gcs(...)` / `hdfs(...)` / `local(...)` table-valued functions with `'format' = 'lance'`), as well as external **Apache Hudi** (`'type' = 'hudi'`) and **Delta Lake** (`'type' = 'deltalake'`) catalogs.
446471

447472
```yaml
448473
# config.yaml
@@ -472,8 +497,8 @@ SELECT
472497
FROM lance_catalog.multimodal.clip_embeddings;
473498
```
474499

475-
!!! warning "Lance Tables are Read-Only in Doris 4.1"
476-
Doris 4.1 supports Lance catalogs and `format = 'lance'` table-valued functions for reading only (`CREATE TABLE`, `INSERT`, and `CTAS` are not supported by Doris on Lance catalogs). If a SQLMesh model is configured to materialize into a Lance catalog or sets `table_format = 'lance'`, `DorisEngineAdapter` raises a descriptive `SQLMeshError` directing you to materialize into the `internal` OLAP catalog or an `iceberg` catalog instead.
500+
!!! warning "Lance, Hudi, and Delta Lake Tables are Read-Only in Doris"
501+
Doris supports Lance, Hudi, Delta Lake, and LakeSoul catalogs and `format = 'lance'` table-valued functions for reading only (`CREATE TABLE`, `INSERT`, and `CTAS` are not supported by Doris on those formats). If a SQLMesh model sets `table_format` (or `engine`) to `lance`, `hudi`, `deltalake`, or `lakesoul`, `DorisEngineAdapter` raises a descriptive `SQLMeshError` directing you to materialize into the `internal` OLAP catalog or a writable external catalog (`iceberg`, `paimon`, or `hive`).
477502

478503
---
479504

@@ -746,18 +771,18 @@ MODEL (
746771

747772
## Supported Model Kinds & Catalog Matrix
748773

749-
| SQLMesh Model Kind | Doris Internal OLAP Catalog (`internal` on Local Disk or S3/GCS Storage Vault) | External Apache Iceberg Catalog (`table_format iceberg` on S3/GCS/HDFS) | External Read-Only Lakehouse Catalogs (`lance`, `paimon`, `hudi`, `deltalake`) |
750-
| --- | :---: | :---: | :---: |
751-
| Source / Upstream `SELECT` | Supported | Supported (incl. `FOR TIME/VERSION AS OF`, `@branch`, `@tag`) | Supported (Read-Only via Catalog or S3/GCS TVF) |
752-
| `FULL` | Supported (`CREATE TABLE ... AS SELECT` / `DELETE` + `INSERT`) | Supported (`CREATE TABLE ... ENGINE=iceberg` / `INSERT OVERWRITE TABLE`) | Read-Only in Doris (Write Guarded) |
753-
| `INCREMENTAL_BY_TIME_RANGE` | Supported (`DELETE WHERE time_col >= low AND time_col <= high` + `INSERT`) | Supported (`DELETE` + `INSERT`; requires Doris 4.1.0+ & Iceberg v2) | Read-Only in Doris (Write Guarded) |
754-
| `INCREMENTAL_BY_UNIQUE_KEY` | Supported (Auto-promotes `unique_key` to `UNIQUE KEY`, `DELETE ... USING` + `INSERT` or native `MERGE INTO` when `when_matched` / `merge_filter` is specified) | Supported (Batched key `DELETE` + `INSERT` or native `MERGE INTO`; requires Doris 4.1.0+ & Iceberg v2) | Read-Only in Doris (Write Guarded) |
755-
| `INCREMENTAL_BY_PARTITION` | Supported (Column & time-truncation range `DELETE` + `INSERT`) | Supported (`INSERT OVERWRITE` on Doris 2.1+; partition `DELETE` + `INSERT` requires Doris 4.1.0+ & Iceberg v2) | Read-Only in Doris (Write Guarded) |
756-
| `SCD_TYPE_2_BY_TIME` / `SCD_TYPE_2_BY_COLUMN` | Supported | Supported (Requires Doris 4.1.0+ & Iceberg v2 for row-level `DELETE`) | Read-Only in Doris (Write Guarded) |
757-
| `VIEW` | Supported (`CREATE OR REPLACE VIEW`) | Requires `internal` catalog (`environment_catalog_mapping`) | Requires `internal` catalog (`environment_catalog_mapping`) |
758-
| `VIEW (materialized true)` | Supported (Async MTMV & Sync MV with `materialized_type = 'SYNC'`) | Not supported by Doris inside external catalogs | Not supported by Doris inside external catalogs |
759-
| `SEED` / Python DataFrame Models | Supported (Automatic `VARCHAR(255)` OLAP key coercion) | Supported | Read-Only in Doris (Write Guarded) |
760-
| `CUSTOM ('doris_ddl_only')` | Supported (DDL + `ALTER TABLE` schema evolution only) | Supported | Not supported (Read-Only) |
774+
| SQLMesh Model Kind | Doris Internal OLAP Catalog (`internal` on Local Disk or S3/GCS Storage Vault) | External Iceberg & Paimon Catalogs (`table_format iceberg \| paimon` on S3/GCS/HDFS) | External Hive Catalog (`table_format hive` on S3/GCS/HDFS) | External Read-Only Lakehouse Catalogs (`lance`, `hudi`, `deltalake`, `lakesoul`) |
775+
| --- | :---: | :---: | :---: | :---: |
776+
| Source / Upstream `SELECT` | Supported | Supported (incl. `FOR TIME/VERSION AS OF`, `@branch`, `@tag`) | Supported | Supported (Read-Only via Catalog or S3/GCS TVF) |
777+
| `FULL` | Supported (`CREATE TABLE ... AS SELECT` / `DELETE` + `INSERT`) | Supported (`CREATE TABLE ... ENGINE=iceberg\|paimon` / `INSERT OVERWRITE TABLE`) | Supported (`CREATE TABLE ... ENGINE=hive` / `INSERT OVERWRITE TABLE`) | Read-Only in Doris (Write Guarded) |
778+
| `INCREMENTAL_BY_TIME_RANGE` | Supported (`DELETE WHERE time_col >= low AND time_col <= high` + `INSERT`) | Supported (`DELETE` + `INSERT`; requires Doris 4.1.0+ & Iceberg v2 / Paimon PK) | Not supported by Hive in Doris (No row-level `DELETE`) | Read-Only in Doris (Write Guarded) |
779+
| `INCREMENTAL_BY_UNIQUE_KEY` | Supported (Auto-promotes `unique_key` to `UNIQUE KEY`, `DELETE ... USING` + `INSERT` or native `MERGE INTO` when `when_matched` / `merge_filter` is specified) | Supported (Batched key `DELETE` + `INSERT` or native `MERGE INTO`; requires Doris 4.1.0+ & Iceberg v2 / Paimon PK) | Not supported by Hive in Doris (No `DELETE` / `MERGE INTO`) | Read-Only in Doris (Write Guarded) |
780+
| `INCREMENTAL_BY_PARTITION` | Supported (Column & time-truncation range `DELETE` + `INSERT`) | Supported (Partition `DELETE` + `INSERT`; requires Doris 4.1.0+ & Iceberg v2 / Paimon PK) | Not supported by Hive in Doris (No `DELETE`) | Read-Only in Doris (Write Guarded) |
781+
| `SCD_TYPE_2_BY_TIME` / `SCD_TYPE_2_BY_COLUMN` | Supported | Supported (Requires Doris 4.1.0+ & Iceberg v2 / Paimon PK for row-level `DELETE`) | Not supported by Hive in Doris (No `DELETE`) | Read-Only in Doris (Write Guarded) |
782+
| `VIEW` | Supported (`CREATE OR REPLACE VIEW`) | Requires `internal` catalog (`environment_catalog_mapping`) | Requires `internal` catalog (`environment_catalog_mapping`) | Requires `internal` catalog (`environment_catalog_mapping`) |
783+
| `VIEW (materialized true)` | Supported (Async MTMV & Sync MV with `materialized_type = 'SYNC'`) | Not supported by Doris inside external catalogs | Not supported by Doris inside external catalogs | Not supported by Doris inside external catalogs |
784+
| `SEED` / Python DataFrame Models | Supported (Automatic `VARCHAR(255)` OLAP key coercion) | Supported | Supported | Read-Only in Doris (Write Guarded) |
785+
| `CUSTOM ('doris_ddl_only')` | Supported (DDL + `ALTER TABLE` schema evolution only) | Supported | Supported | Not supported (Read-Only) |
761786

762787
---
763788

‎sqlmesh/core/engine_adapter/base.py‎

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -526,23 +526,37 @@ def replace_query(
526526
target_columns_to_types = self.columns(target_table)
527527
query = source_queries[0].query_factory()
528528

529-
default_catalog = self.default_catalog
529+
quoted_target = quote_identifiers(target_table.copy())
530+
default_catalog: t.Optional[str] = None
531+
default_catalog_loaded = False
532+
qualified_target: t.Optional[exp.Table] = None
530533

531-
def _qualified(table: exp.Table) -> exp.Table:
534+
def _qualified(table: exp.Table, catalog: str) -> exp.Table:
532535
table = table.copy()
533-
if default_catalog and not table.catalog and table.db:
534-
table.set("catalog", exp.parse_identifier(default_catalog))
536+
if not table.catalog and table.db:
537+
table.set("catalog", exp.parse_identifier(catalog))
535538
return quote_identifiers(table)
536539

537-
quoted_target = quote_identifiers(target_table.copy())
538-
qualified_target = _qualified(target_table)
539-
540540
def _is_target_table(node: exp.Expr) -> bool:
541+
nonlocal default_catalog, default_catalog_loaded, qualified_target
541542
if not isinstance(node, exp.Table):
542543
return False
543-
if quote_identifiers(node.copy()) == quoted_target:
544+
quoted_node = quote_identifiers(node.copy())
545+
if quoted_node == quoted_target:
544546
return True
545-
return bool(default_catalog) and _qualified(node) == qualified_target
547+
if quoted_node.this != quoted_target.this or quoted_node.args.get(
548+
"db"
549+
) != quoted_target.args.get("db"):
550+
return False
551+
if not default_catalog_loaded:
552+
default_catalog = self.default_catalog
553+
default_catalog_loaded = True
554+
if default_catalog:
555+
qualified_target = _qualified(target_table, default_catalog)
556+
return (
557+
default_catalog is not None
558+
and _qualified(node, default_catalog) == qualified_target
559+
)
546560

547561
self_referencing = any(_is_target_table(table) for table in query.find_all(exp.Table))
548562
# If a query references itself then it must have a table created regardless of approach used.

‎sqlmesh/core/engine_adapter/doris.py‎

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,6 @@ def _restore_active_catalog_on_reconnect(self, sql: t.Optional[str] = None) -> N
353353

354354
_READ_ONLY_EXTERNAL_FORMATS: t.ClassVar[t.Dict[str, str]] = {
355355
"lance": "Lance",
356-
"paimon": "Paimon",
357356
"hudi": "Hudi",
358357
"deltalake": "Delta Lake",
359358
"delta": "Delta Lake",
@@ -412,11 +411,11 @@ def _is_iceberg_table(
412411
) -> bool:
413412
if self._is_lance_table(table_format=table_format, table_properties=table_properties):
414413
return False
415-
if table_format and str(table_format).strip().lower() in ("iceberg", "hive"):
414+
if table_format and str(table_format).strip().lower() in ("iceberg", "paimon", "hive"):
416415
return True
417416
if table_properties:
418417
engine_val = self._extract_str_value(table_properties.get("engine"))
419-
if engine_val and engine_val.strip().lower() in ("iceberg", "hive"):
418+
if engine_val and engine_val.strip().lower() in ("iceberg", "paimon", "hive"):
420419
return True
421420
effective_catalog = catalog_name or self.default_catalog
422421
if effective_catalog and str(effective_catalog).strip().lower() != "internal":
@@ -5454,23 +5453,28 @@ def _build_table_properties_exp(
54545453
table_properties=table_properties_copy,
54555454
)
54565455

5457-
# Pop Iceberg/Hive engine property if present so it is not duplicated in PROPERTIES (...)
5456+
# Pop Iceberg/Paimon/Hive engine property if present so it is not duplicated in PROPERTIES (...)
54585457
explicit_engine = self._extract_str_value(table_properties_copy.pop("engine", None)) or (
54595458
str(table_format).strip().lower() if table_format else None
54605459
)
5460+
engine_lower = explicit_engine.strip().lower() if explicit_engine else None
54615461
if is_iceberg and is_schema_ddl:
5462-
engine_name = (
5463-
"hive"
5464-
if explicit_engine and explicit_engine.strip().lower() == "hive"
5465-
else "iceberg"
5466-
)
5462+
engine_name = engine_lower if engine_lower in ("hive", "paimon") else "iceberg"
54675463
properties.append(exp.EngineProperty(this=exp.var(engine_name)))
54685464

54695465
if storage_format and not is_materialized_view:
54705466
sf_str = str(storage_format).strip().strip("'").strip('"')
54715467
if sf_str:
54725468
if is_iceberg:
5473-
if (
5469+
if engine_lower == "paimon":
5470+
table_properties_copy.setdefault(
5471+
"file.format", exp.Literal.string(sf_str.lower())
5472+
)
5473+
elif engine_lower == "hive":
5474+
table_properties_copy.setdefault(
5475+
"file_format", exp.Literal.string(sf_str.lower())
5476+
)
5477+
elif (
54745478
"write-format" not in table_properties_copy
54755479
and "write.format.default" not in table_properties_copy
54765480
):
@@ -5613,6 +5617,24 @@ def _build_table_properties_exp(
56135617
chosen_part_by if isinstance(chosen_part_by, list) else [chosen_part_by]
56145618
)
56155619

5620+
if (
5621+
is_iceberg
5622+
and engine_lower == "paimon"
5623+
and unique_key_cols
5624+
and "primary-key" not in table_properties_copy
5625+
):
5626+
paimon_pk_cols = [col_by_lower.get(k.lower(), k) for k in unique_key_cols]
5627+
if partitioned_by:
5628+
parsed_paimon_part, _ = self._parse_partition_expressions(partitioned_by)
5629+
pk_lower_set = {k.lower() for k in paimon_pk_cols}
5630+
for expr in parsed_paimon_part:
5631+
for col_node in expr.find_all(exp.Column):
5632+
part_col_name = col_by_lower.get(col_node.name.lower(), col_node.name)
5633+
if part_col_name.lower() not in pk_lower_set:
5634+
paimon_pk_cols.append(part_col_name)
5635+
pk_lower_set.add(part_col_name.lower())
5636+
table_properties_copy["primary-key"] = exp.Literal.string(",".join(paimon_pk_cols))
5637+
56165638
if not is_iceberg:
56175639
specified_key_types = [
56185640
name

‎tests/core/engine_adapter/integration/test_integration_doris.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,47 @@ def test_doris_iceberg_local_catalog(ctx: TestContext):
105105
read="doris",
106106
)
107107
adapter.execute(compact_expr, quote_identifiers=False)
108+
109+
# Also verify writable Apache Paimon local catalog DDL & schema evolution on Doris 4.1+
110+
paimon_cat = f"local_paimon_{ctx.test_id}"
111+
paimon_db = f"paimon_db_{ctx.test_id}"
112+
paimon_tbl = f"{paimon_cat}.{paimon_db}.orders"
113+
try:
114+
adapter.execute(
115+
f"""
116+
CREATE CATALOG IF NOT EXISTS `{paimon_cat}` PROPERTIES (
117+
'type' = 'paimon',
118+
'warehouse' = 'file:///tmp/doris_paimon_warehouse/{paimon_cat}'
119+
)
120+
""",
121+
quote_identifiers=False,
122+
)
123+
adapter.create_schema(f"{paimon_cat}.{paimon_db}")
124+
adapter.create_table(
125+
paimon_tbl,
126+
{
127+
"id": exp.DataType.build("int"),
128+
"ds": exp.DataType.build("date"),
129+
},
130+
table_format="paimon",
131+
storage_format="parquet",
132+
partitioned_by=[exp.to_column("ds")],
133+
unique_key=[exp.to_column("id")],
134+
)
135+
paimon_cols = adapter.columns(paimon_tbl)
136+
assert "id" in paimon_cols
137+
assert "ds" in paimon_cols
138+
finally:
139+
try:
140+
adapter.drop_schema(
141+
f"{paimon_cat}.{paimon_db}", ignore_if_not_exists=True, cascade=True
142+
)
143+
except Exception:
144+
pass
145+
try:
146+
adapter.execute(f"DROP CATALOG IF EXISTS `{paimon_cat}`", quote_identifiers=False)
147+
except Exception:
148+
pass
108149
finally:
109150
try:
110151
adapter.drop_schema(

‎tests/core/engine_adapter/test_doris.py‎

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4875,14 +4875,32 @@ def test_lance_table_reading_and_write_guard(
48754875
table_properties={"engine": exp.Literal.string("lance")},
48764876
)
48774877

4878-
with pytest.raises(SQLMeshError, match="Doris Paimon tables are read-only"):
4878+
with pytest.raises(SQLMeshError, match="Doris Hudi tables are read-only"):
48794879
adapter.create_table(
4880-
"paimon_cat.ml_db.out_tbl",
4880+
"hudi_cat.ml_db.out_tbl",
48814881
target_columns_to_types={"id": exp.DataType.build("INT", dialect="doris")},
4882-
table_format="paimon",
4882+
table_format="hudi",
48834883
)
48844884

4885-
# 4. External Hive catalog table creation emits ENGINE=hive and skips OLAP key reordering
4885+
# 4. External Paimon catalog table creation emits ENGINE=paimon, 'file.format', and 'primary-key'
4886+
adapter.cursor.execute.reset_mock()
4887+
adapter.create_table(
4888+
"paimon_cat.silver.orders",
4889+
target_columns_to_types={
4890+
"payload": exp.DataType.build("STRING", dialect="doris"),
4891+
"id": exp.DataType.build("INT", dialect="doris"),
4892+
"ds": exp.DataType.build("DATE", dialect="doris"),
4893+
},
4894+
table_format="paimon",
4895+
storage_format="parquet",
4896+
partitioned_by=[exp.to_column("ds")],
4897+
unique_key=[exp.to_column("id")],
4898+
)
4899+
assert to_sql_calls(adapter) == [
4900+
"CREATE TABLE IF NOT EXISTS `paimon_cat`.`silver`.`orders` (`payload` STRING, `id` INT, `ds` DATE) ENGINE=paimon PARTITION BY LIST (`ds`) () PROPERTIES ('file.format'='parquet', 'primary-key'='id,ds')"
4901+
]
4902+
4903+
# 5. External Hive catalog table creation emits ENGINE=hive, 'file_format', and skips OLAP key reordering
48864904
adapter.cursor.execute.reset_mock()
48874905
adapter.create_table(
48884906
"hive_cat.raw_db.events",
@@ -4891,9 +4909,10 @@ def test_lance_table_reading_and_write_guard(
48914909
"id": exp.DataType.build("INT", dialect="doris"),
48924910
},
48934911
table_format="hive",
4912+
storage_format="orc",
48944913
)
48954914
assert to_sql_calls(adapter) == [
4896-
"CREATE TABLE IF NOT EXISTS `hive_cat`.`raw_db`.`events` (`payload` STRING, `id` INT) ENGINE=hive"
4915+
"CREATE TABLE IF NOT EXISTS `hive_cat`.`raw_db`.`events` (`payload` STRING, `id` INT) ENGINE=hive PROPERTIES ('file_format'='orc')"
48974916
]
48984917

48994918

0 commit comments

Comments
 (0)