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))
