/**
* Copyright (c) 2020-present, Goldman Sachs
*
* Licensed 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, useRef } from 'react';
import { useDrag } from 'react-dnd';
import {
type TreeNodeContainerProps,
type TreeData,
type TreeNodeData,
PURE_DatabaseTableJoinIcon,
TreeView,
ChevronDownIcon,
ChevronRightIcon,
} from '@finos/legend-art';
import {
addUniqueEntry,
assertTrue,
filterByType,
guaranteeType,
isNonNullable,
} from '@finos/legend-shared';
import { renderColumnTypeIcon } from '../../connection-editor/DatabaseEditorHelper.js';
import {
type Type,
type Table,
type Join,
type View,
Column,
stringifyDataType,
} from '@finos/legend-graph';
export const TABLE_ELEMENT_DND_TYPE = 'TABLE_ELEMENT_DND_TYPE';
const JOIN_OPERATOR = '>';
const JOIN_AT_SYMBOL = '@';
const JOIN_PIPE_SYMBOL = '|';
const generateDatabasePointerText = (database: string): string =>
`[${database}]`;
export abstract class TableOrViewTreeNodeData implements TreeNodeData {
id: string;
label: string;
isSelected?: boolean;
isOpen?: boolean;
childrenIds?: string[];
relation: Table | View;
constructor(id: string, label: string, relation: Table | View) {
this.id = id;
this.label = label;
this.relation = relation;
}
}
export class ColumnNodeData extends TableOrViewTreeNodeData {
column: Column;
constructor(
id: string,
label: string,
relation: Table | View,
column: Column,
) {
super(id, label, relation);
this.column = column;
}
}
export class JoinNodeData extends TableOrViewTreeNodeData {
join: Join;
constructor(id: string, label: string, relation: Table | View, join: Join) {
super(id, label, relation);
this.join = join;
}
}
export class TableOrViewTreeNodeDragSource {
data: TableOrViewTreeNodeData;
constructor(data: TableOrViewTreeNodeData) {
this.data = data;
}
}
const generateColumnTreeNodeId = (
column: Column,
relation: Table | View,
parentNode: TableOrViewTreeNodeData | undefined,
): string =>
parentNode
? parentNode instanceof JoinNodeData
? `${parentNode.id} ${JOIN_PIPE_SYMBOL} ${relation.name}.${column.name}`
: `${parentNode.id}.${column.name}`
: `${generateDatabasePointerText(relation.schema._OWNER.path)}${
relation.schema.name
}.${relation.name}.${column.name}`;
const getColumnTreeNodeData = (
column: Column,
relation: Table | View,
parentNode: TableOrViewTreeNodeData | undefined,
): TableOrViewTreeNodeData => {
const columnNode = new ColumnNodeData(
generateColumnTreeNodeId(column, relation, parentNode),
column.name,
relation,
column,
);
return columnNode;
};
// TODO: support more complex join feature (with operation, direction, etc.)
const generateJoinTreeNodeId = (
join: Join,
parentNode: TableOrViewTreeNodeData | undefined,
): string =>
parentNode
? `${parentNode.id} ${JOIN_OPERATOR} ${JOIN_AT_SYMBOL}${join.name}`
: `${generateDatabasePointerText(join.owner.path)}${JOIN_AT_SYMBOL}${
join.name
}`;
const resolveJoinTargetRelation = (
join: Join,
sourceRelation: Table | View,
): Table | View => {
const potentialTargetRelations = new Set
();
join.aliases.forEach((alias) => {
if (alias.first.relation.value !== sourceRelation) {
potentialTargetRelations.add(alias.first.relation.value as Table);
}
if (alias.second.relation.value !== sourceRelation) {
potentialTargetRelations.add(alias.second.relation.value as Table);
}
});
assertTrue(
potentialTargetRelations.size < 2,
`Can't resolve target relation for join`,
);
return potentialTargetRelations.size === 0
? sourceRelation
: (Array.from(potentialTargetRelations.values())[0] as Table | View);
};
const getJoinTreeNodeData = (
join: Join,
relation: Table | View,
parentNode: TableOrViewTreeNodeData | undefined,
): TableOrViewTreeNodeData => {
const joinNode = new JoinNodeData(
generateJoinTreeNodeId(join, parentNode),
join.name,
relation,
join,
);
const childrenIds: string[] = [];
// columns
relation.columns
.slice()
.filter(filterByType(Column))
.sort((a, b) => a.name.toString().localeCompare(b.name.toString()))
.forEach((col) => {
addUniqueEntry(
childrenIds,
generateColumnTreeNodeId(col, relation, joinNode),
);
});
// joins
relation.schema._OWNER.joins
.slice()
.filter(
(_join) =>
_join.aliases.filter(
(alias) =>
alias.first.relation.value === relation ||
alias.second.relation.value === relation,
).length > 0,
)
.sort((a, b) => a.name.toString().localeCompare(b.name.toString()))
.forEach((childJoin) => {
addUniqueEntry(childrenIds, generateJoinTreeNodeId(childJoin, joinNode));
});
joinNode.childrenIds = childrenIds;
return joinNode;
};
const getRelationTreeData = (
relation: Table | View,
): TreeData => {
const rootIds: string[] = [];
const nodes = new Map();
// columns
relation.columns
.slice()
.filter(filterByType(Column))
.sort((a, b) => a.name.toString().localeCompare(b.name.toString()))
.forEach((col) => {
const columnNode = getColumnTreeNodeData(col, relation, undefined);
addUniqueEntry(rootIds, columnNode.id);
nodes.set(columnNode.id, columnNode);
});
// joins
relation.schema._OWNER.joins
.slice()
.filter(
(join) =>
join.aliases.filter(
(alias) =>
alias.first.relation.value === relation ||
alias.second.relation.value === relation,
).length > 0,
)
.sort((a, b) => a.name.toString().localeCompare(b.name.toString()))
.forEach((join) => {
const joinNode = getJoinTreeNodeData(
join,
resolveJoinTargetRelation(join, relation),
undefined,
);
addUniqueEntry(rootIds, joinNode.id);
nodes.set(joinNode.id, joinNode);
});
return { rootIds, nodes };
};
const RelationalOperationElementTreeNodeContainer: React.FC<
TreeNodeContainerProps<
TableOrViewTreeNodeData,
{ selectedType?: Type | undefined }
>
> = (props) => {
const { node, level, stepPaddingInRem, onNodeSelect } = props;
const [, dragConnector] = useDrag(
() => ({
type: TABLE_ELEMENT_DND_TYPE,
item: new TableOrViewTreeNodeDragSource(node),
}),
[node],
);
const ref = useRef(null);
dragConnector(ref);
const isExpandable = Boolean(node.childrenIds?.length);
const nodeTypeIcon =
node instanceof ColumnNodeData ? (
renderColumnTypeIcon(node.column.type)
) : (
);
const selectNode = (): void => onNodeSelect?.(node);
const nodeExpandIcon = isExpandable ? (
node.isOpen ? (
) : (
)
) : (
);
return (
{nodeExpandIcon}
{nodeTypeIcon}
{node instanceof ColumnNodeData && (
)}
);
};
export const TableOrViewSourceTree: React.FC<{
relation: Table | View;
selectedType?: Type | undefined;
}> = (props) => {
const { relation, selectedType } = props;
// NOTE: We only need to compute this once so we use lazy initial state syntax
// See https://reactjs.org/docs/hooks-reference.html#lazy-initial-state
const [treeData, setTreeData] = useState>(
() => getRelationTreeData(relation),
);
const onNodeSelect = (node: TableOrViewTreeNodeData): void => {
if (node.childrenIds?.length) {
node.isOpen = !node.isOpen;
// columns
node.relation.columns.filter(filterByType(Column)).forEach((col) => {
const columnNode = getColumnTreeNodeData(col, node.relation, node);
treeData.nodes.set(columnNode.id, columnNode);
});
// joins
node.relation.schema._OWNER.joins
.filter(
(join) =>
join.aliases.filter(
(alias) =>
alias.first.relation.value === node.relation ||
alias.second.relation.value === node.relation,
).length > 0,
)
.forEach((join) => {
const joinNode = getJoinTreeNodeData(
join,
resolveJoinTargetRelation(join, node.relation),
node,
);
treeData.nodes.set(joinNode.id, joinNode);
});
}
setTreeData({ ...treeData });
};
const getChildNodes = (
node: TableOrViewTreeNodeData,
): TableOrViewTreeNodeData[] => {
if (!node.childrenIds) {
return [];
}
const childrenNodes = node.childrenIds
.map((id) => treeData.nodes.get(id))
.filter(isNonNullable)
// sort so that column nodes come before join nodes
.sort((a, b) => a.label.localeCompare(b.label))
.sort(
(a, b) =>
(b instanceof ColumnNodeData ? 1 : 0) -
(a instanceof ColumnNodeData ? 1 : 0),
);
return childrenNodes;
};
useEffect(() => {
setTreeData(() => getRelationTreeData(relation));
}, [relation]);
return (
);
};