Skip to content

Commit 9a407ea

Browse files
committed
feat(datasource): Hive data source supports adapting to SparkThriftServer metadata parsing #1391
1 parent d8d600c commit 9a407ea

9 files changed

Lines changed: 223 additions & 20 deletions

File tree

‎backend/apps/datasource/models/datasource.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ class DatasourceConf(BaseModel):
123123
lowVersion: bool = False
124124
ssl: bool = False
125125
poolSize: int = 5
126+
engine: str = 'hive'
126127

127128
def to_dict(self):
128129
return {
@@ -140,7 +141,8 @@ def to_dict(self):
140141
"timeout": self.timeout,
141142
"lowVersion": self.lowVersion,
142143
"ssl": self.ssl,
143-
"poolSize": self.poolSize
144+
"poolSize": self.poolSize,
145+
"engine": self.engine
144146
}
145147

146148

‎backend/apps/db/db.py‎

Lines changed: 84 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,88 @@ def get_origin_connect(type: str, conf: DatasourceConf):
126126
)
127127

128128

129+
def is_spark_thrift(conf: DatasourceConf) -> bool:
130+
"""Whether the hive datasource points to a Spark Thrift Server."""
131+
engine = getattr(conf, 'engine', None) or 'hive'
132+
return equals_ignore_case(engine, 'spark')
133+
134+
135+
def parse_hive_table_rows(res, is_spark: bool):
136+
"""Parse SHOW TABLES result rows.
137+
138+
- HiveServer2 returns a single `tab_name` column.
139+
- Spark Thrift Server (2.4+ through 4.x) returns
140+
`(database, tableName, isTemporary)`; the plain table name always sits in
141+
the second column. Over the legacy Hive-JDBC path a header row named
142+
`tableName` may be returned in-band, which we skip defensively.
143+
144+
Returns a list of (table_name, comment) tuples.
145+
"""
146+
tables = []
147+
seen = set()
148+
for item in res:
149+
if not item:
150+
continue
151+
row = [c.decode('utf-8') if isinstance(c, bytes) else c for c in item]
152+
row = [str(c) if c is not None else '' for c in row]
153+
154+
if is_spark:
155+
# Spark Thrift Server returns (database, tableName, isTemporary).
156+
if len(row) >= 2:
157+
# Skip a possible in-band header row over the legacy Hive-JDBC path.
158+
if row[1].lower() == 'tablename':
159+
continue
160+
name = row[1].strip()
161+
else:
162+
name = row[0].strip()
163+
else:
164+
# HiveServer2 returns a single tab_name column.
165+
name = row[0].strip()
166+
167+
if not name or name in seen:
168+
continue
169+
seen.add(name)
170+
tables.append((name, ''))
171+
return tables
172+
173+
174+
def parse_hive_field_rows(res, is_spark: bool):
175+
"""Parse DESCRIBE table result rows for HiveServer2 and Spark Thrift Server
176+
(all Spark 2.4+ versions use the same col_name/data_type/comment shape with
177+
a `# Partition Information` / `# col_name` section).
178+
179+
On the legacy Hive-JDBC path the (col_name, data_type, comment) header may
180+
also be returned in-band; we skip it defensively.
181+
182+
Returns a list of (field_name, field_type, field_comment) tuples.
183+
"""
184+
fields = []
185+
seen_fields = set()
186+
for item in res:
187+
field_name = item[0] if len(item) > 0 else None
188+
field_type = item[1] if len(item) > 1 else None
189+
field_comment = item[2] if len(item) > 2 else None
190+
191+
if not field_name or not str(field_name).strip():
192+
continue
193+
stripped_name = str(field_name).strip()
194+
195+
type_str = (field_type or '').strip() if isinstance(field_type, str) else (str(field_type).strip() if field_type else '')
196+
197+
# Legacy Hive-JDBC path may return the column header row as data.
198+
if is_spark and stripped_name.lower() == 'col_name':
199+
continue
200+
201+
# DESCRIBE includes section headings and repeats partition columns.
202+
if stripped_name.lstrip().startswith('#') and type_str in ('', 'data_type'):
203+
continue
204+
if stripped_name in seen_fields:
205+
continue
206+
seen_fields.add(stripped_name)
207+
fields.append((field_name, type_str, field_comment))
208+
return fields
209+
210+
129211
# use sqlalchemy
130212
def get_engine(ds: CoreDatasource, timeout: int = 0, use_pool: bool = False) -> Engine:
131213
conf = DatasourceConf(**json.loads(aes_decrypt(ds.configuration))) if not equals_ignore_case(ds.type,
@@ -534,7 +616,7 @@ def get_tables(ds: CoreDatasource):
534616
with get_driver_connection(ds) as conn, conn.cursor() as cursor:
535617
cursor.execute(sql)
536618
res = cursor.fetchall()
537-
res_list = [TableSchema(*item) for item in res]
619+
res_list = [TableSchema(*item) for item in parse_hive_table_rows(res, is_spark_thrift(conf))]
538620
return res_list
539621

540622

@@ -585,19 +667,7 @@ def get_fields(ds: CoreDatasource, table_name: str = None):
585667
with get_driver_pool(ds).connection() as conn, conn.cursor() as cursor:
586668
cursor.execute(sql)
587669
res = cursor.fetchall()
588-
res_list = []
589-
seen_fields = set()
590-
for item in res:
591-
field_name = item[0]
592-
if not field_name or not field_name.strip():
593-
continue
594-
# DESCRIBE includes section headings and repeats partition columns.
595-
if field_name.lstrip().startswith('#') and (item[1] or '').strip() in ('', 'data_type'):
596-
continue
597-
if field_name in seen_fields:
598-
continue
599-
seen_fields.add(field_name)
600-
res_list.append(ColumnSchema(*item[:3]))
670+
res_list = [ColumnSchema(*item) for item in parse_hive_field_rows(res, is_spark_thrift(conf))]
601671
return res_list
602672

603673

‎backend/tests/test_hive_fields.py‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ def get_hive_fields():
4141
}
4242
_load_symbol("common/utils/utils.py", "equals_ignore_case", namespace)
4343
_load_symbol("apps/datasource/models/datasource.py", "ColumnSchema", namespace)
44+
_load_symbol("apps/db/db.py", "is_spark_thrift", namespace)
45+
_load_symbol("apps/db/db.py", "parse_hive_field_rows", namespace)
4446
get_fields = _load_symbol("apps/db/db.py", "get_fields", namespace)
4547

4648
def discover(rows):
@@ -122,3 +124,106 @@ def test_extra_driver_columns_remain_supported(get_hive_fields):
122124
@pytest.mark.parametrize("rows", [[], [("", "", ""), ("# col_name", "data_type", "comment")]])
123125
def test_no_real_columns_returns_empty_list(get_hive_fields, rows):
124126
assert get_hive_fields(rows) == []
127+
128+
129+
# --- Spark Thrift Server support ---
130+
131+
132+
@pytest.fixture
133+
def get_hive_spark_fields():
134+
cursor = Mock()
135+
connection = SimpleNamespace(cursor=lambda: nullcontext(cursor))
136+
pool = SimpleNamespace(connection=lambda: nullcontext(connection))
137+
engine_probe = Mock(return_value=True)
138+
namespace = {
139+
"json": json,
140+
"CoreDatasource": SimpleNamespace,
141+
"DatasourceConf": SimpleNamespace,
142+
"aes_decrypt": lambda value: value,
143+
"DB": SimpleNamespace(
144+
get_db=lambda _: SimpleNamespace(connect_type="driver")
145+
),
146+
"ConnectType": SimpleNamespace(sqlalchemy="sqlalchemy"),
147+
"get_driver_pool": lambda _: pool,
148+
"get_field_sql": lambda *args: ("DESCRIBE sample", None, None),
149+
"is_spark_thrift": engine_probe,
150+
}
151+
_load_symbol("common/utils/utils.py", "equals_ignore_case", namespace)
152+
_load_symbol("apps/datasource/models/datasource.py", "ColumnSchema", namespace)
153+
_load_symbol("apps/db/db.py", "parse_hive_field_rows", namespace)
154+
get_fields = _load_symbol("apps/db/db.py", "get_fields", namespace)
155+
156+
def discover(rows):
157+
cursor.fetchall.return_value = rows
158+
fields = get_fields(SimpleNamespace(type="hive", configuration="{}"), "sample")
159+
return [(field.fieldName, field.fieldType, field.fieldComment) for field in fields]
160+
161+
return discover
162+
163+
164+
def test_spark_describe_skips_col_name_header_row(get_hive_spark_fields):
165+
# Spark Thrift Server returns the (col_name, data_type, comment) header as data.
166+
rows = [("col_name", "data_type", "comment"),
167+
("id", "bigint", None),
168+
("dt", "string", "partition date")]
169+
assert get_hive_spark_fields(rows) == [
170+
("id", "bigint", None),
171+
("dt", "string", "partition date"),
172+
]
173+
174+
175+
def test_spark_describe_with_partition_section(get_hive_spark_fields):
176+
rows = [
177+
("col_name", "data_type", "comment"),
178+
("id", "bigint", None),
179+
("# Partition Information", "", ""),
180+
("# col_name", "data_type", "comment"),
181+
("dt", "string", "partition date"),
182+
]
183+
assert get_hive_spark_fields(rows) == [
184+
("id", "bigint", None),
185+
("dt", "string", "partition date"),
186+
]
187+
188+
189+
# --- SHOW TABLES (parse_hive_table_rows) ---
190+
191+
192+
@pytest.fixture
193+
def get_hive_tables():
194+
namespace = {"DatasourceConf": SimpleNamespace}
195+
_load_symbol("apps/db/db.py", "parse_hive_table_rows", namespace)
196+
_load_symbol("common/utils/utils.py", "equals_ignore_case", namespace)
197+
_load_symbol("apps/db/db.py", "is_spark_thrift", namespace)
198+
return namespace["parse_hive_table_rows"]
199+
200+
201+
def test_hs2_show_tables_single_column(get_hive_tables):
202+
rows = [("orders",), ("users",), ("orders",)]
203+
assert get_hive_tables(rows, False) == [("orders", ""), ("users", "")]
204+
205+
206+
def test_spark_show_tables_three_columns_with_header(get_hive_tables):
207+
rows = [
208+
("default", "tableName", "isTemporary"), # header row returned as data
209+
("default", "orders", "false"),
210+
("default", "users", "false"),
211+
("default", "orders", "false"),
212+
]
213+
assert get_hive_tables(rows, True) == [("orders", ""), ("users", "")]
214+
215+
216+
def test_spark_show_tables_without_header(get_hive_tables):
217+
rows = [("default", "sales", "false"), ("default", "inventory", "false")]
218+
assert get_hive_tables(rows, True) == [("sales", ""), ("inventory", "")]
219+
220+
221+
def test_spark_show_tables_ignores_database_column_and_handles_temp_views(get_hive_tables):
222+
# Spark 2.4+/3.x/4.x always returns (database, tableName, isTemporary);
223+
# take the second column and tolerate an empty database (temp views).
224+
rows = [
225+
("salesdb", "orders", "false"),
226+
("", "temp_view", "true"),
227+
("salesdb", "orders", "false"),
228+
]
229+
assert get_hive_tables(rows, True) == [("orders", ""), ("temp_view", "")]

‎frontend/src/i18n/en.json‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,10 @@
422422
"low_version": "Compatible with lower versions",
423423
"ssl": "Enable SSL",
424424
"file_path": "File Path",
425-
"pool_size": "Connection Pool Size"
425+
"pool_size": "Connection Pool Size",
426+
"engine": "Engine",
427+
"engine_hive": "HiveServer2",
428+
"engine_spark": "Spark Thrift Server"
426429
},
427430
"sync_fields": "Sync Fields",
428431
"sync_fields_success": "Sync fields successfully",

‎frontend/src/i18n/ko-KR.json‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,10 @@
422422
"low_version": "낮은 버전 호환",
423423
"ssl": "SSL 활성화",
424424
"file_path": "파일 경로",
425-
"pool_size": "연결 풀 크기"
425+
"pool_size": "연결 풀 크기",
426+
"engine": "엔진",
427+
"engine_hive": "HiveServer2",
428+
"engine_spark": "Spark Thrift Server"
426429
},
427430
"sync_fields": "동기화된 테이블 구조",
428431
"sync_fields_success": "테이블 구조 동기화 성공",

‎frontend/src/i18n/th-TH.json‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,10 @@
422422
"low_version": "เข้ากันได้กับเวอร์ชันต่ำกว่า",
423423
"ssl": "เปิดใช้งาน SSL",
424424
"file_path": "เส้นทางไฟล์",
425-
"pool_size": "ขนาดพูลการเชื่อมต่อ"
425+
"pool_size": "ขนาดพูลการเชื่อมต่อ",
426+
"engine": "เอนจิน",
427+
"engine_hive": "HiveServer2",
428+
"engine_spark": "Spark Thrift Server"
426429
},
427430
"sync_fields": "ซิงค์โครงสร้างตาราง",
428431
"sync_fields_success": "ซิงค์โครงสร้างตารางสำเร็จ",

‎frontend/src/i18n/zh-CN.json‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,10 @@
422422
"low_version": "兼容低版本",
423423
"ssl": "启用 SSL",
424424
"file_path": "文件路径",
425-
"pool_size": "连接池大小"
425+
"pool_size": "连接池大小",
426+
"engine": "引擎",
427+
"engine_hive": "HiveServer2",
428+
"engine_spark": "Spark Thrift Server"
426429
},
427430
"sync_fields": "同步表结构",
428431
"sync_fields_success": "同步表结构成功",

‎frontend/src/i18n/zh-TW.json‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,10 @@
422422
"low_version": "相容低版本",
423423
"ssl": "啟用 SSL",
424424
"file_path": "文件路徑",
425-
"pool_size": "連線池大小"
425+
"pool_size": "連線池大小",
426+
"engine": "引擎",
427+
"engine_hive": "HiveServer2",
428+
"engine_spark": "Spark Thrift Server"
426429
},
427430
"sync_fields": "同步表結構",
428431
"sync_fields_success": "同步表結構成功",

‎frontend/src/views/ds/DatasourceForm.vue‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ const form = ref<any>({
129129
lowVersion: false,
130130
ssl: false,
131131
poolSize: 5,
132+
engine: 'hive',
132133
})
133134
134135
const close = () => {
@@ -182,6 +183,7 @@ const initForm = (item: any, editTable: boolean = false) => {
182183
configuration.poolSize !== null && configuration.poolSize !== undefined
183184
? configuration.poolSize
184185
: 5
186+
form.value.engine = configuration.engine ? configuration.engine : 'hive'
185187
}
186188
187189
if (editTable) {
@@ -256,6 +258,7 @@ const initForm = (item: any, editTable: boolean = false) => {
256258
lowVersion: false,
257259
ssl: false,
258260
poolSize: 5,
261+
engine: 'hive',
259262
}
260263
}
261264
dialogVisible.value = true
@@ -346,6 +349,7 @@ const buildConf = () => {
346349
lowVersion: form.value.lowVersion,
347350
ssl: form.value.ssl,
348351
poolSize: form.value.poolSize,
352+
engine: form.value.engine,
349353
})
350354
)
351355
const obj = JSON.parse(JSON.stringify(form.value))
@@ -364,6 +368,7 @@ const buildConf = () => {
364368
delete obj.lowVersion
365369
delete obj.ssl
366370
delete obj.poolSize
371+
delete obj.engine
367372
return obj
368373
}
369374
@@ -738,6 +743,12 @@ defineExpose({
738743
<el-radio value="sid">{{ t('ds.form.mode.sid') }}</el-radio>
739744
</el-radio-group>
740745
</el-form-item>
746+
<el-form-item v-if="form.type === 'hive'" :label="t('ds.form.engine')" prop="engine">
747+
<el-radio-group v-model="form.engine">
748+
<el-radio value="hive">{{ t('ds.form.engine_hive') }}</el-radio>
749+
<el-radio value="spark">{{ t('ds.form.engine_spark') }}</el-radio>
750+
</el-radio-group>
751+
</el-form-item>
741752
<el-form-item
742753
v-if="form.type === 'sqlServer'"
743754
:label="t('ds.form.low_version')"

0 commit comments

Comments
 (0)