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 <[email protected]>
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))


Reply via email to