You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ms...@apache.org on 2025/12/18 11:06:39 UTC
(superset) 01/01: feat: join editor
This is an automated email from the ASF dual-hosted git repository.
msyavuz pushed a commit to branch msyavuz/feat/join-editor
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 89aea1c67b2b13aa0db119d424bb4686eb631bed
Author: Mehmet Salih Yavuz <sa...@proton.me>
AuthorDate: Thu Dec 18 14:06:17 2025 +0300
feat: join editor
---
.../DatabaseSchemaEditor/JoinEditorModal.tsx | 356 ++++++++++++++
.../components/DatabaseSchemaEditor/JoinsList.tsx | 341 +++++++++++++
.../src/components/DatabaseSchemaEditor/index.ts | 22 +
superset/databases/analyzer_api.py | 543 +++++++++++++++++++++
4 files changed, 1262 insertions(+)
diff --git a/superset-frontend/src/components/DatabaseSchemaEditor/JoinEditorModal.tsx b/superset-frontend/src/components/DatabaseSchemaEditor/JoinEditorModal.tsx
new file mode 100644
index 0000000000..8899a9953e
--- /dev/null
+++ b/superset-frontend/src/components/DatabaseSchemaEditor/JoinEditorModal.tsx
@@ -0,0 +1,356 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { useState, useEffect, useMemo } from 'react';
+import { t } from '@superset-ui/core';
+import { styled } from '@apache-superset/core/ui';
+import {
+ Modal,
+ Select,
+ Input,
+ Form,
+ Space,
+ Typography,
+ Alert,
+} from '@superset-ui/core/components';
+
+export enum JoinType {
+ INNER = 'inner',
+ LEFT = 'left',
+ RIGHT = 'right',
+ FULL = 'full',
+ CROSS = 'cross',
+}
+
+export enum Cardinality {
+ ONE_TO_ONE = '1:1',
+ ONE_TO_MANY = '1:N',
+ MANY_TO_ONE = 'N:1',
+ MANY_TO_MANY = 'N:M',
+}
+
+export interface Table {
+ id: number;
+ name: string;
+ columns?: Column[];
+}
+
+export interface Column {
+ id: number;
+ name: string;
+ type: string;
+}
+
+export interface Join {
+ id?: number;
+ source_table: string;
+ source_table_id?: number;
+ source_columns: string[];
+ target_table: string;
+ target_table_id?: number;
+ target_columns: string[];
+ join_type: JoinType;
+ cardinality: Cardinality;
+ semantic_context?: string;
+}
+
+interface JoinEditorModalProps {
+ visible: boolean;
+ join?: Join | null;
+ tables: Table[];
+ onSave: (join: Join) => void;
+ onCancel: () => void;
+}
+
+const StyledForm = styled(Form)`
+ .ant-form-item {
+ margin-bottom: ${({ theme }) => theme.sizeUnit * 4}px;
+ }
+`;
+
+const ColumnSelectionGroup = styled.div`
+ display: flex;
+ gap: ${({ theme }) => theme.sizeUnit * 2}px;
+ align-items: center;
+`;
+
+const JoinEditorModal = ({
+ visible,
+ join,
+ tables,
+ onSave,
+ onCancel,
+}: JoinEditorModalProps) => {
+ const [form] = Form.useForm();
+ const [sourceTable, setSourceTable] = useState<string | undefined>(
+ join?.source_table,
+ );
+ const [targetTable, setTargetTable] = useState<string | undefined>(
+ join?.target_table,
+ );
+
+ useEffect(() => {
+ if (visible) {
+ if (join) {
+ form.setFieldsValue({
+ source_table: join.source_table,
+ source_columns: join.source_columns,
+ target_table: join.target_table,
+ target_columns: join.target_columns,
+ join_type: join.join_type,
+ cardinality: join.cardinality,
+ semantic_context: join.semantic_context,
+ });
+ setSourceTable(join.source_table);
+ setTargetTable(join.target_table);
+ } else {
+ form.resetFields();
+ setSourceTable(undefined);
+ setTargetTable(undefined);
+ }
+ }
+ }, [visible, join, form]);
+
+ const sourceTableColumns = useMemo(
+ () =>
+ tables.find(table => table.name === sourceTable)?.columns || [],
+ [tables, sourceTable],
+ );
+
+ const targetTableColumns = useMemo(
+ () =>
+ tables.find(table => table.name === targetTable)?.columns || [],
+ [tables, targetTable],
+ );
+
+ const joinTypeOptions = [
+ { value: JoinType.INNER, label: t('Inner Join') },
+ { value: JoinType.LEFT, label: t('Left Join') },
+ { value: JoinType.RIGHT, label: t('Right Join') },
+ { value: JoinType.FULL, label: t('Full Outer Join') },
+ { value: JoinType.CROSS, label: t('Cross Join') },
+ ];
+
+ const cardinalityOptions = [
+ { value: Cardinality.ONE_TO_ONE, label: t('One to One (1:1)') },
+ { value: Cardinality.ONE_TO_MANY, label: t('One to Many (1:N)') },
+ { value: Cardinality.MANY_TO_ONE, label: t('Many to One (N:1)') },
+ { value: Cardinality.MANY_TO_MANY, label: t('Many to Many (N:M)') },
+ ];
+
+ const handleSourceTableChange = (value: string) => {
+ setSourceTable(value);
+ form.setFieldValue('source_columns', []);
+ };
+
+ const handleTargetTableChange = (value: string) => {
+ setTargetTable(value);
+ form.setFieldValue('target_columns', []);
+ };
+
+ const handleSubmit = async () => {
+ try {
+ const values = await form.validateFields();
+ const sourceTableId = tables.find(
+ t => t.name === values.source_table,
+ )?.id;
+ const targetTableId = tables.find(
+ t => t.name === values.target_table,
+ )?.id;
+
+ onSave({
+ ...join,
+ ...values,
+ source_table_id: sourceTableId,
+ target_table_id: targetTableId,
+ });
+ form.resetFields();
+ } catch (error) {
+ // Form validation failed
+ }
+ };
+
+ return (
+ <Modal
+ title={join ? t('Edit Join Relationship') : t('Add Join Relationship')}
+ visible={visible}
+ onOk={handleSubmit}
+ onCancel={onCancel}
+ width={800}
+ okText={join ? t('Update') : t('Add')}
+ cancelText={t('Cancel')}
+ >
+ <StyledForm form={form} layout="vertical">
+ <Alert
+ message={t('Join Configuration')}
+ description={t(
+ 'Define the relationship between tables. This join will be used when generating dashboards and visualizations.',
+ )}
+ type="info"
+ showIcon
+ style={{ marginBottom: 24 }}
+ />
+
+ <Typography.Title level={5}>{t('Tables')}</Typography.Title>
+
+ <ColumnSelectionGroup>
+ <Form.Item
+ name="source_table"
+ label={t('Source Table')}
+ rules={[{ required: true, message: t('Please select a source table') }]}
+ style={{ flex: 1, marginBottom: 0 }}
+ >
+ <Select
+ placeholder={t('Select source table')}
+ onChange={handleSourceTableChange}
+ showSearch
+ optionFilterProp="label"
+ options={tables.map(table => ({
+ value: table.name,
+ label: table.name,
+ }))}
+ />
+ </Form.Item>
+
+ <Form.Item
+ name="join_type"
+ label={t('Join Type')}
+ rules={[{ required: true, message: t('Please select a join type') }]}
+ style={{ minWidth: 150, marginBottom: 0 }}
+ >
+ <Select
+ placeholder={t('Select join type')}
+ options={joinTypeOptions}
+ />
+ </Form.Item>
+
+ <Form.Item
+ name="target_table"
+ label={t('Target Table')}
+ rules={[{ required: true, message: t('Please select a target table') }]}
+ style={{ flex: 1, marginBottom: 0 }}
+ >
+ <Select
+ placeholder={t('Select target table')}
+ onChange={handleTargetTableChange}
+ showSearch
+ optionFilterProp="label"
+ options={tables.map(table => ({
+ value: table.name,
+ label: table.name,
+ }))}
+ />
+ </Form.Item>
+ </ColumnSelectionGroup>
+
+ <Typography.Title level={5} style={{ marginTop: 24 }}>
+ {t('Join Columns')}
+ </Typography.Title>
+
+ <ColumnSelectionGroup>
+ <Form.Item
+ name="source_columns"
+ label={t('Source Columns')}
+ rules={[
+ { required: true, message: t('Please select source columns') },
+ ]}
+ style={{ flex: 1 }}
+ >
+ <Select
+ mode="multiple"
+ placeholder={t('Select columns from source table')}
+ disabled={!sourceTable}
+ options={sourceTableColumns.map(col => ({
+ value: col.name,
+ label: `${col.name} (${col.type})`,
+ }))}
+ />
+ </Form.Item>
+
+ <Typography.Text>=</Typography.Text>
+
+ <Form.Item
+ name="target_columns"
+ label={t('Target Columns')}
+ rules={[
+ { required: true, message: t('Please select target columns') },
+ ({ getFieldValue }) => ({
+ validator(_, value) {
+ const sourceColumns = getFieldValue('source_columns') || [];
+ if (value && value.length !== sourceColumns.length) {
+ return Promise.reject(
+ new Error(
+ t(
+ 'Number of target columns must match source columns',
+ ),
+ ),
+ );
+ }
+ return Promise.resolve();
+ },
+ }),
+ ]}
+ style={{ flex: 1 }}
+ >
+ <Select
+ mode="multiple"
+ placeholder={t('Select columns from target table')}
+ disabled={!targetTable}
+ options={targetTableColumns.map(col => ({
+ value: col.name,
+ label: `${col.name} (${col.type})`,
+ }))}
+ />
+ </Form.Item>
+ </ColumnSelectionGroup>
+
+ <Typography.Title level={5} style={{ marginTop: 24 }}>
+ {t('Relationship Details')}
+ </Typography.Title>
+
+ <Form.Item
+ name="cardinality"
+ label={t('Cardinality')}
+ rules={[{ required: true, message: t('Please select cardinality') }]}
+ >
+ <Select
+ placeholder={t('Select relationship cardinality')}
+ options={cardinalityOptions}
+ />
+ </Form.Item>
+
+ <Form.Item
+ name="semantic_context"
+ label={t('Description (AI Generated)')}
+ help={t(
+ 'This description was generated by AI and can be edited for clarity',
+ )}
+ >
+ <Input.TextArea
+ rows={3}
+ placeholder={t(
+ 'e.g., "Orders are linked to customers through the customer_id field"',
+ )}
+ />
+ </Form.Item>
+ </StyledForm>
+ </Modal>
+ );
+};
+
+export default JoinEditorModal;
\ No newline at end of file
diff --git a/superset-frontend/src/components/DatabaseSchemaEditor/JoinsList.tsx b/superset-frontend/src/components/DatabaseSchemaEditor/JoinsList.tsx
new file mode 100644
index 0000000000..2a1775474a
--- /dev/null
+++ b/superset-frontend/src/components/DatabaseSchemaEditor/JoinsList.tsx
@@ -0,0 +1,341 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { useState } from 'react';
+import { t, SupersetClient } from '@superset-ui/core';
+import { styled } from '@apache-superset/core/ui';
+import {
+ Table,
+ Button,
+ Space,
+ Typography,
+ Popconfirm,
+ Tag,
+ message,
+} from '@superset-ui/core/components';
+import { EditOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons';
+import JoinEditorModal, {
+ Join,
+ JoinType,
+ Cardinality,
+ Table as TableType,
+} from './JoinEditorModal';
+
+interface JoinsListProps {
+ databaseReportId: number;
+ joins: Join[];
+ tables: TableType[];
+ onJoinsUpdate?: (joins: Join[]) => void;
+ editable?: boolean;
+}
+
+const StyledTableContainer = styled.div`
+ ${({ theme }) => `
+ padding: ${theme.sizeUnit * 4}px;
+ background-color: ${theme.colorBgContainer};
+ border-radius: ${theme.borderRadiusSM}px;
+ `}
+`;
+
+const HeaderContainer = styled.div`
+ ${({ theme }) => `
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: ${theme.sizeUnit * 4}px;
+ `}
+`;
+
+const JoinTypeTag = styled(Tag)<{ joinType: JoinType }>`
+ ${({ theme, joinType }) => {
+ const colorMap = {
+ [JoinType.INNER]: theme.colorSuccess,
+ [JoinType.LEFT]: theme.colorInfo,
+ [JoinType.RIGHT]: theme.colorWarning,
+ [JoinType.FULL]: theme.colorError,
+ [JoinType.CROSS]: theme.colorTextSecondary,
+ };
+ return `
+ background-color: ${colorMap[joinType]}20;
+ color: ${colorMap[joinType]};
+ border-color: ${colorMap[joinType]};
+ `;
+ }}
+`;
+
+const CardinalityTag = styled(Tag)`
+ ${({ theme }) => `
+ background-color: ${theme.colorPrimaryBg};
+ color: ${theme.colorPrimary};
+ border-color: ${theme.colorPrimary};
+ `}
+`;
+
+const JoinsList = ({
+ databaseReportId,
+ joins: initialJoins,
+ tables,
+ onJoinsUpdate,
+ editable = true,
+}: JoinsListProps) => {
+ const [joins, setJoins] = useState<Join[]>(initialJoins);
+ const [modalVisible, setModalVisible] = useState(false);
+ const [editingJoin, setEditingJoin] = useState<Join | null>(null);
+ const [loading, setLoading] = useState(false);
+
+ const handleAddJoin = () => {
+ setEditingJoin(null);
+ setModalVisible(true);
+ };
+
+ const handleEditJoin = (join: Join) => {
+ setEditingJoin(join);
+ setModalVisible(true);
+ };
+
+ const handleDeleteJoin = async (joinId: number) => {
+ setLoading(true);
+ try {
+ const response = await SupersetClient.delete({
+ endpoint: `/api/v1/datasource_analyzer/report/${databaseReportId}/join/${joinId}`,
+ });
+
+ if (response.ok) {
+ const updatedJoins = joins.filter(j => j.id !== joinId);
+ setJoins(updatedJoins);
+ onJoinsUpdate?.(updatedJoins);
+ message.success(t('Join deleted successfully'));
+ } else {
+ throw new Error('Failed to delete join');
+ }
+ } catch (error) {
+ console.error('Delete join error:', error);
+ message.error(
+ t('Failed to delete join: %s', error?.message || String(error)),
+ );
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleSaveJoin = async (join: Join) => {
+ setLoading(true);
+ try {
+ const endpoint = join.id
+ ? `/api/v1/datasource_analyzer/report/${databaseReportId}/join/${join.id}`
+ : `/api/v1/datasource_analyzer/report/${databaseReportId}/join`;
+
+ const method = join.id ? 'PUT' : 'POST';
+
+ const response = await SupersetClient.request({
+ endpoint,
+ method,
+ jsonPayload: join,
+ });
+
+ if (response.ok) {
+ const savedJoin = response.json;
+ let updatedJoins: Join[];
+
+ if (join.id) {
+ updatedJoins = joins.map(j => (j.id === join.id ? savedJoin : j));
+ } else {
+ updatedJoins = [...joins, savedJoin];
+ }
+
+ setJoins(updatedJoins);
+ onJoinsUpdate?.(updatedJoins);
+ setModalVisible(false);
+ message.success(
+ join.id
+ ? t('Join updated successfully')
+ : t('Join created successfully'),
+ );
+ } else {
+ throw new Error('Failed to save join');
+ }
+ } catch (error) {
+ console.error('Save join error:', error);
+ message.error(
+ t('Failed to save join: %s', error?.message || String(error)),
+ );
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const getJoinTypeLabel = (type: JoinType) => {
+ const labels = {
+ [JoinType.INNER]: t('INNER'),
+ [JoinType.LEFT]: t('LEFT'),
+ [JoinType.RIGHT]: t('RIGHT'),
+ [JoinType.FULL]: t('FULL'),
+ [JoinType.CROSS]: t('CROSS'),
+ };
+ return labels[type] || type;
+ };
+
+ const columns = [
+ {
+ title: t('Source Table'),
+ dataIndex: 'source_table',
+ key: 'source_table',
+ render: (text: string) => (
+ <Typography.Text strong>{text}</Typography.Text>
+ ),
+ },
+ {
+ title: t('Source Columns'),
+ dataIndex: 'source_columns',
+ key: 'source_columns',
+ render: (columns: string[]) => (
+ <Typography.Text code>{columns.join(', ')}</Typography.Text>
+ ),
+ },
+ {
+ title: t('Join Type'),
+ dataIndex: 'join_type',
+ key: 'join_type',
+ render: (type: JoinType) => (
+ <JoinTypeTag joinType={type}>{getJoinTypeLabel(type)}</JoinTypeTag>
+ ),
+ },
+ {
+ title: t('Target Table'),
+ dataIndex: 'target_table',
+ key: 'target_table',
+ render: (text: string) => (
+ <Typography.Text strong>{text}</Typography.Text>
+ ),
+ },
+ {
+ title: t('Target Columns'),
+ dataIndex: 'target_columns',
+ key: 'target_columns',
+ render: (columns: string[]) => (
+ <Typography.Text code>{columns.join(', ')}</Typography.Text>
+ ),
+ },
+ {
+ title: t('Cardinality'),
+ dataIndex: 'cardinality',
+ key: 'cardinality',
+ render: (cardinality: Cardinality) => (
+ <CardinalityTag>{cardinality}</CardinalityTag>
+ ),
+ },
+ {
+ title: t('Description'),
+ dataIndex: 'semantic_context',
+ key: 'semantic_context',
+ ellipsis: true,
+ render: (text: string) => (
+ <Typography.Text
+ ellipsis={{ tooltip: text }}
+ style={{ maxWidth: 200 }}
+ >
+ {text || '-'}
+ </Typography.Text>
+ ),
+ },
+ ];
+
+ if (editable) {
+ columns.push({
+ title: t('Actions'),
+ key: 'actions',
+ fixed: 'right',
+ width: 100,
+ render: (_: unknown, record: Join) => (
+ <Space size="small">
+ <Button
+ type="link"
+ icon={<EditOutlined />}
+ onClick={() => handleEditJoin(record)}
+ size="small"
+ />
+ <Popconfirm
+ title={t('Delete Join')}
+ description={t(
+ 'Are you sure you want to delete this join relationship?',
+ )}
+ onConfirm={() => record.id && handleDeleteJoin(record.id)}
+ okText={t('Yes')}
+ cancelText={t('No')}
+ >
+ <Button
+ type="link"
+ danger
+ icon={<DeleteOutlined />}
+ size="small"
+ />
+ </Popconfirm>
+ </Space>
+ ),
+ });
+ }
+
+ return (
+ <StyledTableContainer>
+ <HeaderContainer>
+ <div>
+ <Typography.Title level={4}>{t('Table Joins')}</Typography.Title>
+ <Typography.Text type="secondary">
+ {t('AI-detected and user-defined relationships between tables')}
+ </Typography.Text>
+ </div>
+ {editable && (
+ <Button
+ type="primary"
+ icon={<PlusOutlined />}
+ onClick={handleAddJoin}
+ >
+ {t('Add Join')}
+ </Button>
+ )}
+ </HeaderContainer>
+
+ <Table
+ columns={columns}
+ dataSource={joins}
+ rowKey="id"
+ loading={loading}
+ pagination={{
+ pageSize: 10,
+ showSizeChanger: true,
+ showTotal: (total, range) =>
+ t('%s-%s of %s joins', range[0], range[1], total),
+ }}
+ scroll={{ x: 'max-content' }}
+ locale={{
+ emptyText: t('No joins defined yet'),
+ }}
+ />
+
+ <JoinEditorModal
+ visible={modalVisible}
+ join={editingJoin}
+ tables={tables}
+ onSave={handleSaveJoin}
+ onCancel={() => setModalVisible(false)}
+ />
+ </StyledTableContainer>
+ );
+};
+
+export default JoinsList;
\ No newline at end of file
diff --git a/superset-frontend/src/components/DatabaseSchemaEditor/index.ts b/superset-frontend/src/components/DatabaseSchemaEditor/index.ts
new file mode 100644
index 0000000000..ebb9d291bd
--- /dev/null
+++ b/superset-frontend/src/components/DatabaseSchemaEditor/index.ts
@@ -0,0 +1,22 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+export { default as JoinEditorModal } from './JoinEditorModal';
+export { default as JoinsList } from './JoinsList';
+export type { Join, Table, Column } from './JoinEditorModal';
+export { JoinType, Cardinality } from './JoinEditorModal';
\ No newline at end of file
diff --git a/superset/databases/analyzer_api.py b/superset/databases/analyzer_api.py
index 75e04d2fd2..49997b4c77 100644
--- a/superset/databases/analyzer_api.py
+++ b/superset/databases/analyzer_api.py
@@ -304,3 +304,546 @@ class DatasourceAnalyzerRestApi(BaseSupersetApi):
except Exception as e:
logger.exception("Error retrieving report")
return self.response_500(message=str(e))
+
+ @expose("/report/<int:report_id>/table/<int:table_id>", methods=("PUT",))
+ @protect()
+ @safe
+ @statsd_metrics
+ @requires_json
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.update_table",
+ log_to_statsd=True,
+ )
+ def update_table(self, report_id: int, table_id: int) -> Response:
+ """Update table description.
+ ---
+ put:
+ summary: Update table description
+ description: Update the AI-generated or user-edited description for a table
+ parameters:
+ - in: path
+ name: report_id
+ required: true
+ schema:
+ type: integer
+ - in: path
+ name: table_id
+ required: true
+ schema:
+ type: integer
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ description:
+ type: string
+ description: New description for the table
+ responses:
+ 200:
+ description: Table updated successfully
+ 404:
+ description: Table not found
+ 500:
+ description: Internal server error
+ """
+ try:
+ from superset.models.database_analyzer import AnalyzedTable
+
+ data = request.json or {}
+
+ table = (
+ db.session.query(AnalyzedTable)
+ .filter_by(id=table_id, report_id=report_id)
+ .first()
+ )
+
+ if not table:
+ return self.response_404(message="Table not found")
+
+ table.ai_description = data.get("description")
+ db.session.commit()
+
+ return self.response(
+ 200,
+ id=table.id,
+ name=table.table_name,
+ description=table.ai_description,
+ )
+
+ except Exception as e:
+ logger.exception("Error updating table")
+ db.session.rollback()
+ return self.response_500(message=str(e))
+
+ @expose("/report/<int:report_id>/column/<int:column_id>", methods=("PUT",))
+ @protect()
+ @safe
+ @statsd_metrics
+ @requires_json
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.update_column",
+ log_to_statsd=True,
+ )
+ def update_column(self, report_id: int, column_id: int) -> Response:
+ """Update column description.
+ ---
+ put:
+ summary: Update column description
+ description: Update the AI-generated or user-edited description for a column
+ parameters:
+ - in: path
+ name: report_id
+ required: true
+ schema:
+ type: integer
+ - in: path
+ name: column_id
+ required: true
+ schema:
+ type: integer
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ description:
+ type: string
+ description: New description for the column
+ responses:
+ 200:
+ description: Column updated successfully
+ 404:
+ description: Column not found
+ 500:
+ description: Internal server error
+ """
+ try:
+ from superset.models.database_analyzer import AnalyzedColumn, AnalyzedTable
+
+ data = request.json or {}
+
+ # Verify column belongs to a table in this report
+ column = (
+ db.session.query(AnalyzedColumn)
+ .join(AnalyzedTable)
+ .filter(
+ AnalyzedColumn.id == column_id,
+ AnalyzedTable.report_id == report_id,
+ )
+ .first()
+ )
+
+ if not column:
+ return self.response_404(message="Column not found")
+
+ column.ai_description = data.get("description")
+ db.session.commit()
+
+ return self.response(
+ 200,
+ id=column.id,
+ name=column.column_name,
+ description=column.ai_description,
+ )
+
+ except Exception as e:
+ logger.exception("Error updating column")
+ db.session.rollback()
+ return self.response_500(message=str(e))
+
+ @expose("/report/<int:report_id>/join", methods=("POST",))
+ @protect()
+ @safe
+ @statsd_metrics
+ @requires_json
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.create_join",
+ log_to_statsd=True,
+ )
+ def create_join(self, report_id: int) -> Response:
+ """Create a new join relationship.
+ ---
+ post:
+ summary: Create join relationship
+ description: Create a new join relationship between tables
+ parameters:
+ - in: path
+ name: report_id
+ required: true
+ schema:
+ type: integer
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [source_table_id, target_table_id, source_columns, target_columns, join_type, cardinality]
+ properties:
+ source_table_id:
+ type: integer
+ target_table_id:
+ type: integer
+ source_columns:
+ type: array
+ items:
+ type: string
+ target_columns:
+ type: array
+ items:
+ type: string
+ join_type:
+ type: string
+ enum: [inner, left, right, full, cross]
+ cardinality:
+ type: string
+ enum: ["1:1", "1:N", "N:1", "N:M"]
+ semantic_context:
+ type: string
+ responses:
+ 201:
+ description: Join created successfully
+ 400:
+ description: Bad request
+ 404:
+ description: Report or table not found
+ 500:
+ description: Internal server error
+ """
+ try:
+ import json
+ from superset.models.database_analyzer import (
+ AnalyzedTable,
+ InferredJoin,
+ JoinType,
+ Cardinality,
+ )
+
+ data = request.json or {}
+
+ # Verify report exists
+ report = db.session.query(DatabaseSchemaReport).get(report_id)
+ if not report:
+ return self.response_404(message="Report not found")
+
+ # Verify tables belong to this report
+ source_table = (
+ db.session.query(AnalyzedTable)
+ .filter_by(id=data["source_table_id"], report_id=report_id)
+ .first()
+ )
+ target_table = (
+ db.session.query(AnalyzedTable)
+ .filter_by(id=data["target_table_id"], report_id=report_id)
+ .first()
+ )
+
+ if not source_table or not target_table:
+ return self.response_404(message="Table not found")
+
+ # Create join
+ join = InferredJoin(
+ report_id=report_id,
+ source_table_id=data["source_table_id"],
+ target_table_id=data["target_table_id"],
+ source_columns=json.dumps(data["source_columns"]),
+ target_columns=json.dumps(data["target_columns"]),
+ join_type=JoinType(data["join_type"]),
+ cardinality=Cardinality(data["cardinality"]),
+ semantic_context=data.get("semantic_context"),
+ )
+
+ db.session.add(join)
+ db.session.commit()
+
+ return self.response(
+ 201,
+ id=join.id,
+ source_table=source_table.table_name,
+ source_columns=data["source_columns"],
+ target_table=target_table.table_name,
+ target_columns=data["target_columns"],
+ join_type=join.join_type.value,
+ cardinality=join.cardinality.value,
+ semantic_context=join.semantic_context,
+ )
+
+ except Exception as e:
+ logger.exception("Error creating join")
+ db.session.rollback()
+ return self.response_500(message=str(e))
+
+ @expose("/report/<int:report_id>/join/<int:join_id>", methods=("PUT",))
+ @protect()
+ @safe
+ @statsd_metrics
+ @requires_json
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.update_join",
+ log_to_statsd=True,
+ )
+ def update_join(self, report_id: int, join_id: int) -> Response:
+ """Update a join relationship.
+ ---
+ put:
+ summary: Update join relationship
+ description: Update an existing join relationship
+ parameters:
+ - in: path
+ name: report_id
+ required: true
+ schema:
+ type: integer
+ - in: path
+ name: join_id
+ required: true
+ schema:
+ type: integer
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ source_table_id:
+ type: integer
+ target_table_id:
+ type: integer
+ source_columns:
+ type: array
+ items:
+ type: string
+ target_columns:
+ type: array
+ items:
+ type: string
+ join_type:
+ type: string
+ enum: [inner, left, right, full, cross]
+ cardinality:
+ type: string
+ enum: ["1:1", "1:N", "N:1", "N:M"]
+ semantic_context:
+ type: string
+ responses:
+ 200:
+ description: Join updated successfully
+ 404:
+ description: Join not found
+ 500:
+ description: Internal server error
+ """
+ try:
+ import json
+ from superset.models.database_analyzer import (
+ AnalyzedTable,
+ InferredJoin,
+ JoinType,
+ Cardinality,
+ )
+
+ data = request.json or {}
+
+ join = (
+ db.session.query(InferredJoin)
+ .filter_by(id=join_id, report_id=report_id)
+ .first()
+ )
+
+ if not join:
+ return self.response_404(message="Join not found")
+
+ # Update fields if provided
+ if "source_table_id" in data:
+ source_table = (
+ db.session.query(AnalyzedTable)
+ .filter_by(id=data["source_table_id"], report_id=report_id)
+ .first()
+ )
+ if not source_table:
+ return self.response_404(message="Source table not found")
+ join.source_table_id = data["source_table_id"]
+
+ if "target_table_id" in data:
+ target_table = (
+ db.session.query(AnalyzedTable)
+ .filter_by(id=data["target_table_id"], report_id=report_id)
+ .first()
+ )
+ if not target_table:
+ return self.response_404(message="Target table not found")
+ join.target_table_id = data["target_table_id"]
+
+ if "source_columns" in data:
+ join.source_columns = json.dumps(data["source_columns"])
+ if "target_columns" in data:
+ join.target_columns = json.dumps(data["target_columns"])
+ if "join_type" in data:
+ join.join_type = JoinType(data["join_type"])
+ if "cardinality" in data:
+ join.cardinality = Cardinality(data["cardinality"])
+ if "semantic_context" in data:
+ join.semantic_context = data["semantic_context"]
+
+ db.session.commit()
+
+ return self.response(
+ 200,
+ id=join.id,
+ source_table=join.source_table.table_name,
+ source_columns=json.loads(join.source_columns),
+ target_table=join.target_table.table_name,
+ target_columns=json.loads(join.target_columns),
+ join_type=join.join_type.value,
+ cardinality=join.cardinality.value,
+ semantic_context=join.semantic_context,
+ )
+
+ except Exception as e:
+ logger.exception("Error updating join")
+ db.session.rollback()
+ return self.response_500(message=str(e))
+
+ @expose("/report/<int:report_id>/join/<int:join_id>", methods=("DELETE",))
+ @protect()
+ @safe
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.delete_join",
+ log_to_statsd=True,
+ )
+ def delete_join(self, report_id: int, join_id: int) -> Response:
+ """Delete a join relationship.
+ ---
+ delete:
+ summary: Delete join relationship
+ description: Delete an existing join relationship
+ parameters:
+ - in: path
+ name: report_id
+ required: true
+ schema:
+ type: integer
+ - in: path
+ name: join_id
+ required: true
+ schema:
+ type: integer
+ responses:
+ 204:
+ description: Join deleted successfully
+ 404:
+ description: Join not found
+ 500:
+ description: Internal server error
+ """
+ try:
+ from superset.models.database_analyzer import InferredJoin
+
+ join = (
+ db.session.query(InferredJoin)
+ .filter_by(id=join_id, report_id=report_id)
+ .first()
+ )
+
+ if not join:
+ return self.response_404(message="Join not found")
+
+ db.session.delete(join)
+ db.session.commit()
+
+ return self.response(204)
+
+ except Exception as e:
+ logger.exception("Error deleting join")
+ db.session.rollback()
+ return self.response_500(message=str(e))
+
+ @expose("/report/<int:report_id>/generate_dashboard", methods=("POST",))
+ @protect()
+ @safe
+ @statsd_metrics
+ @requires_json
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.generate_dashboard",
+ log_to_statsd=True,
+ )
+ def generate_dashboard(self, report_id: int) -> Response:
+ """Generate dashboard from database report.
+ ---
+ post:
+ summary: Generate dashboard
+ description: >-
+ Launch dashboard generation job based on the analyzed database schema.
+ Returns a run_id for tracking the generation progress.
+ parameters:
+ - in: path
+ name: report_id
+ required: true
+ schema:
+ type: integer
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [dashboard_id]
+ properties:
+ dashboard_id:
+ type: integer
+ description: ID of the dashboard template to use
+ responses:
+ 200:
+ description: Dashboard generation started
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ run_id:
+ type: string
+ description: Unique identifier for tracking the generation job
+ 404:
+ description: Report not found
+ 500:
+ description: Internal server error
+ """
+ try:
+ data = request.json or {}
+
+ # Verify report exists
+ report = db.session.query(DatabaseSchemaReport).get(report_id)
+ if not report:
+ return self.response_404(message="Report not found")
+
+ # TODO: Launch dashboard generation Celery job
+ # This is a placeholder for the actual dashboard generation logic
+ # which should be implemented in the Dashboard Generator Celery job ticket
+
+ # For now, return a mock run_id
+ import uuid
+ run_id = str(uuid.uuid4())
+
+ # In the actual implementation, this would:
+ # 1. Launch the Dashboard Generator Celery job
+ # 2. Pass the database_report_id and dashboard_id
+ # 3. Return the actual run_id from the Celery job
+
+ logger.info(
+ "Dashboard generation requested for report_id=%s with template_id=%s",
+ report_id,
+ data.get("dashboard_id"),
+ )
+
+ return self.response(200, run_id=run_id)
+
+ except Exception as e:
+ logger.exception("Error starting dashboard generation")
+ return self.response_500(message=str(e))