diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java index be4b73ade8..c0150ea99e 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java @@ -17,6 +17,7 @@ import com.linkedin.datahub.graphql.generated.Dataset; import com.linkedin.datahub.graphql.generated.Entity; import com.linkedin.datahub.graphql.generated.EntityRelationship; import com.linkedin.datahub.graphql.generated.EntityRelationshipLegacy; +import com.linkedin.datahub.graphql.generated.ForeignKeyConstraint; import com.linkedin.datahub.graphql.generated.MLModelProperties; import com.linkedin.datahub.graphql.generated.RelatedDataset; import com.linkedin.datahub.graphql.generated.SearchResult; @@ -511,6 +512,12 @@ public class GmsGraphQLEngine { (env) -> ((RelatedDataset) env.getSource()).getDataset().getUrn())) ) ) + .type("ForeignKeyConstraint", typeWiring -> typeWiring + .dataFetcher("foreignDataset", new AuthenticatedResolver<>( + new LoadableTypeResolver<>(datasetType, + (env) -> ((ForeignKeyConstraint) env.getSource()).getForeignDataset().getUrn())) + ) + ) .type("InstitutionalMemoryMetadata", typeWiring -> typeWiring .dataFetcher("author", new AuthenticatedResolver<>( new LoadableTypeResolver<>(corpUserType, diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataset/mappers/ForeignKeyConstraintMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataset/mappers/ForeignKeyConstraintMapper.java new file mode 100644 index 0000000000..01942cd293 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataset/mappers/ForeignKeyConstraintMapper.java @@ -0,0 +1,41 @@ +package com.linkedin.datahub.graphql.types.dataset.mappers; + +import com.linkedin.common.urn.Urn; +import com.linkedin.datahub.graphql.generated.Dataset; +import com.linkedin.datahub.graphql.generated.ForeignKeyConstraint; +import com.linkedin.datahub.graphql.generated.SchemaFieldEntity; +import com.linkedin.datahub.graphql.types.common.mappers.UrnToEntityMapper; +import java.util.stream.Collectors; + + +public class ForeignKeyConstraintMapper { + private ForeignKeyConstraintMapper() { } + + public static ForeignKeyConstraint map(com.linkedin.schema.ForeignKeyConstraint constraint) { + ForeignKeyConstraint result = new ForeignKeyConstraint(); + result.setName(constraint.getName()); + if (constraint.hasForeignDataset()) { + result.setForeignDataset((Dataset) UrnToEntityMapper.map(constraint.getForeignDataset())); + } + if (constraint.hasSourceFields()) { + result.setSourceFields( + constraint.getSourceFields().stream().map( + schemaFieldUrn -> mapSchemaFieldEntity(schemaFieldUrn) + ).collect(Collectors.toList())); + } + if (constraint.hasForeignFields()) { + result.setForeignFields( + constraint.getForeignFields().stream().map( + schemaFieldUrn -> mapSchemaFieldEntity(schemaFieldUrn) + ).collect(Collectors.toList())); + } + return result; + } + + private static SchemaFieldEntity mapSchemaFieldEntity(Urn schemaFieldUrn) { + SchemaFieldEntity result = new SchemaFieldEntity(); + result.setParent(schemaFieldUrn.getEntityKey().get(0)); + result.setFieldPath(schemaFieldUrn.getEntityKey().get(1)); + return result; + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataset/mappers/SchemaMetadataMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataset/mappers/SchemaMetadataMapper.java index 9b38fbf834..37aa32ed46 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataset/mappers/SchemaMetadataMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataset/mappers/SchemaMetadataMapper.java @@ -33,6 +33,11 @@ public class SchemaMetadataMapper implements ModelMapper ForeignKeyConstraintMapper.map( + foreignKeyConstraint + )).collect(Collectors.toList())); + } return result; } } diff --git a/datahub-graphql-core/src/main/resources/entity.graphql b/datahub-graphql-core/src/main/resources/entity.graphql index 6a6d0d5327..0d26c45505 100644 --- a/datahub-graphql-core/src/main/resources/entity.graphql +++ b/datahub-graphql-core/src/main/resources/entity.graphql @@ -1035,12 +1035,42 @@ type SchemaMetadata implements Aspect { """ primaryKeys: [String!] + """ + Client provided list of foreign key constraints + """ + foreignKeys: [ForeignKeyConstraint] + """ The time at which the schema metadata information was created """ createdAt: Long } +""" +Metadata around a foreign key constraint between two datasets +""" +type ForeignKeyConstraint { + """ + The human-readable name of the constraint + """ + name: String + + """ + List of fields in the foreign dataset + """ + foreignFields: [SchemaFieldEntity] + + """ + List of fields in this dataset + """ + sourceFields: [SchemaFieldEntity] + + """ + The foreign dataset for easy reference + """ + foreignDataset: Dataset +} + """ Deprecated, use SchemaMetadata instead Metadata about a Dataset schema @@ -1122,6 +1152,27 @@ type KeyValueSchema { valueSchema: String! } +""" +Standalone schema field entity. Differs from the SchemaField struct because it is not directly nested inside a +schema field +""" +type SchemaFieldEntity { + """ + Primary key of the schema field + """ + urn: String! + + """ + Field path identifying the field in its dataset + """ + fieldPath: String! + + """ + The primary key of the field's parent. + """ + parent: String! +} + """ Information about an individual field in a Dataset schema """ @@ -4806,4 +4857,3 @@ enum CostType { """ ORG_COST_TYPE } - diff --git a/datahub-web-react/src/Mocks.tsx b/datahub-web-react/src/Mocks.tsx index 1029cd6b07..849dcfc5d1 100644 --- a/datahub-web-react/src/Mocks.tsx +++ b/datahub-web-react/src/Mocks.tsx @@ -377,6 +377,7 @@ export const dataset3 = { }, datasetUrn: 'urn:li:dataset:3', primaryKeys: [], + foreignKeys: [], }, previousSchemaMetadata: null, editableSchemaMetadata: null, diff --git a/datahub-web-react/src/app/entity/dataset/profile/__tests__/Schema.test.tsx b/datahub-web-react/src/app/entity/dataset/profile/__tests__/Schema.test.tsx index 940d3bf25c..d164c135a6 100644 --- a/datahub-web-react/src/app/entity/dataset/profile/__tests__/Schema.test.tsx +++ b/datahub-web-react/src/app/entity/dataset/profile/__tests__/Schema.test.tsx @@ -3,7 +3,7 @@ import { fireEvent, render } from '@testing-library/react'; import { MockedProvider } from '@apollo/client/testing'; import TestPageContainer from '../../../../../utils/test-utils/TestPageContainer'; -import { sampleSchema, sampleSchemaWithTags } from '../stories/sampleSchema'; +import { sampleSchema, sampleSchemaWithPkFk, sampleSchemaWithTags } from '../stories/sampleSchema'; import { mocks } from '../../../../../Mocks'; import { SchemaTab } from '../../../shared/tabs/Dataset/Schema/SchemaTab'; import EntityContext from '../../../shared/EntityContext'; @@ -154,4 +154,62 @@ describe('Schema', () => { ); expect(getByText('shipping_address')).toBeInTheDocument(); }); + + it('renders primary keys', () => { + const { getByText } = render( + + + + + + + , + ); + expect(getByText('Primary Key')).toBeInTheDocument(); + }); + + it('renders foreign keys', () => { + const { getByText, getAllByText } = render( + + + + + + + , + ); + expect(getByText('Foreign Key')).toBeInTheDocument(); + + const fkButton = getByText('Foreign Key'); + fireEvent.click(fkButton); + + expect(getByText('Foreign Key to')).toBeInTheDocument(); + expect(getAllByText('Yet Another Dataset')).toHaveLength(2); + }); }); diff --git a/datahub-web-react/src/app/entity/dataset/profile/schema/utils/schemaTitleRenderer.tsx b/datahub-web-react/src/app/entity/dataset/profile/schema/utils/schemaTitleRenderer.tsx index 2e362872aa..a953fe7cf9 100644 --- a/datahub-web-react/src/app/entity/dataset/profile/schema/utils/schemaTitleRenderer.tsx +++ b/datahub-web-react/src/app/entity/dataset/profile/schema/utils/schemaTitleRenderer.tsx @@ -1,9 +1,12 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Typography } from 'antd'; import styled from 'styled-components'; import translateFieldPath from './translateFieldPath'; import { ExtendedSchemaFields } from './types'; import TypeLabel from '../../../../shared/tabs/Dataset/Schema/components/TypeLabel'; +import { ForeignKeyConstraint, SchemaMetadata } from '../../../../../../types.generated'; +import PrimaryKeyLabel from '../../../../shared/tabs/Dataset/Schema/components/PrimaryKeyLabel'; +import ForeignKeyLabel from '../../../../shared/tabs/Dataset/Schema/components/ForeignKeyLabel'; const MAX_FIELD_PATH_LENGTH = 200; @@ -12,6 +15,7 @@ const MAX_FIELD_PATH_LENGTH = 200; // `; const FieldPathContainer = styled.div` + vertical-align: top; display: inline-block; width: 250px; margin-top: 16px; @@ -25,31 +29,56 @@ const FieldPathText = styled(Typography.Text)` `; // ex: [type=MetadataAuditEvent].[type=union]oldSnapshot.[type=CorpUserSnapshot].[type=array]aspects.[type=union].[type=CorpUserInfo].[type=boolean]active -export default function schemaTitleRenderer(fieldPath: string, record: ExtendedSchemaFields) { - const fieldPathWithoutAnnotations = translateFieldPath(fieldPath); +export default function useSchemaTitleRenderer( + schemaMetadata: SchemaMetadata | undefined | null, + setSelectedFkFieldPath: (params: { fieldPath: string; constraint?: ForeignKeyConstraint | null } | null) => void, +) { + const [highlightedConstraint, setHighlightedConstraint] = useState(null); - const isOverflow = fieldPathWithoutAnnotations.length > MAX_FIELD_PATH_LENGTH; + return (fieldPath: string, record: ExtendedSchemaFields): JSX.Element => { + const fieldPathWithoutAnnotations = translateFieldPath(fieldPath); - let [firstPath, lastPath] = fieldPathWithoutAnnotations.split(/\.(?=[^.]+$)/); + const isOverflow = fieldPathWithoutAnnotations.length > MAX_FIELD_PATH_LENGTH; - if (isOverflow) { - if (lastPath.length >= MAX_FIELD_PATH_LENGTH) { - lastPath = `..${lastPath.substring(lastPath.length - MAX_FIELD_PATH_LENGTH)}`; - firstPath = ''; - } else { - firstPath = firstPath.substring(fieldPath.length - MAX_FIELD_PATH_LENGTH); - if (firstPath.includes('.')) { - firstPath = `..${firstPath.substring(firstPath.indexOf('.'))}`; + let [firstPath, lastPath] = fieldPathWithoutAnnotations.split(/\.(?=[^.]+$)/); + + if (isOverflow) { + if (lastPath.length >= MAX_FIELD_PATH_LENGTH) { + lastPath = `..${lastPath.substring(lastPath.length - MAX_FIELD_PATH_LENGTH)}`; + firstPath = ''; } else { - firstPath = '..'; + firstPath = firstPath.substring(fieldPath.length - MAX_FIELD_PATH_LENGTH); + if (firstPath.includes('.')) { + firstPath = `..${firstPath.substring(firstPath.indexOf('.'))}`; + } else { + firstPath = '..'; + } } } - } - return ( - - {lastPath || firstPath} - - - ); + return ( + <> + + {lastPath || firstPath} + + {schemaMetadata?.primaryKeys?.includes(fieldPath) && } + {schemaMetadata?.foreignKeys + ?.filter( + (constraint) => + (constraint?.sourceFields?.filter((sourceField) => sourceField?.fieldPath === fieldPath) + .length || 0) > 0, + ) + .map((constraint) => ( + + ))} + + + ); + }; } diff --git a/datahub-web-react/src/app/entity/dataset/profile/stories/sampleSchema.ts b/datahub-web-react/src/app/entity/dataset/profile/stories/sampleSchema.ts index 610dcbe2d7..c00ffd49fe 100644 --- a/datahub-web-react/src/app/entity/dataset/profile/stories/sampleSchema.ts +++ b/datahub-web-react/src/app/entity/dataset/profile/stories/sampleSchema.ts @@ -1,3 +1,4 @@ +import { dataset3 } from '../../../../../Mocks'; import { EntityType, Schema, SchemaMetadata, SchemaField, SchemaFieldDataType } from '../../../../../types.generated'; // Extending the schema type with an option for tags @@ -190,3 +191,125 @@ export const sampleSchemaWithTags: Schema = { } as SchemaField, ], }; + +export const sampleSchemaWithPkFk: SchemaMetadata = { + primaryKeys: ['name'], + foreignKeys: [ + { + name: 'constraint', + sourceFields: [ + { + urn: 'datasetUrn', + parent: 'dataset', + fieldPath: 'shipping_address', + }, + ], + foreignFields: [ + { + urn: dataset3.urn, + parent: dataset3.name, + fieldPath: 'address', + }, + ], + foreignDataset: dataset3, + }, + ], + name: 'MockSchema', + platformUrn: 'mock:urn', + version: 1, + hash: '', + fields: [ + { + fieldPath: 'id', + nullable: false, + description: 'order id', + type: SchemaFieldDataType.Number, + nativeDataType: 'number', + recursive: false, + globalTags: { + tags: [ + { + tag: { + urn: 'urn:li:tag:Legacy', + name: 'Legacy', + description: 'this is a legacy dataset', + type: EntityType.Tag, + }, + }, + ], + }, + glossaryTerms: { + terms: [ + { + term: { + type: EntityType.GlossaryTerm, + urn: 'urn:li:glossaryTerm:sample-glossary-term', + name: 'sample-glossary-term', + hierarchicalName: 'example.sample-glossary-term', + glossaryTermInfo: { + definition: 'sample definition', + termSource: 'sample term source', + }, + }, + }, + ], + }, + }, + { + fieldPath: 'name', + nullable: true, + description: 'the name of the order', + type: SchemaFieldDataType.String, + nativeDataType: 'string', + recursive: false, + } as SchemaField, + { + fieldPath: 'shipping_address', + nullable: true, + description: 'the address the order ships to', + type: SchemaFieldDataType.String, + nativeDataType: 'string', + recursive: false, + } as SchemaField, + { + fieldPath: 'count', + nullable: true, + description: 'the number of items in the order', + type: SchemaFieldDataType.Number, + nativeDataType: 'number', + recursive: false, + }, + { + fieldPath: 'cost', + nullable: true, + description: 'the dollar value of the order', + type: SchemaFieldDataType.Number, + nativeDataType: 'number', + recursive: false, + } as SchemaField, + { + fieldPath: 'was_returned', + nullable: true, + description: 'if the order was sent back', + type: SchemaFieldDataType.Boolean, + nativeDataType: 'boolean', + recursive: false, + }, + { + fieldPath: 'payload', + nullable: true, + description: 'payload attached to the order', + type: SchemaFieldDataType.Bytes, + nativeDataType: 'bytes', + recursive: false, + }, + { + fieldPath: 'payment_information', + nullable: true, + description: 'struct representing the payment information', + type: SchemaFieldDataType.Struct, + nativeDataType: 'struct', + recursive: false, + } as SchemaField, + ], +}; diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/SchemaTab.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/SchemaTab.tsx index 7c9ced13a2..0ff0aafe2b 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/SchemaTab.tsx +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/SchemaTab.tsx @@ -61,6 +61,7 @@ export const SchemaTab = () => { ) : rows && rows.length > 0 ? ( <> tr > .ant-table-cell-with-append { @@ -19,29 +29,31 @@ const TableContainer = styled.div` &&& .ant-table-tbody > tr > .ant-table-cell { border-right: none; } + &&& .open-fk-row > td { + padding-bottom: 600px; + vertical-align: top; + } `; -const defaultColumns = [ - { - title: 'Field', - dataIndex: 'fieldPath', - key: 'fieldPath', - width: 250, - render: schemaTitleRenderer, - filtered: true, - }, -]; - export type Props = { rows: Array; + schemaMetadata: SchemaMetadata | undefined | null; editableSchemaMetadata?: EditableSchemaMetadata | null; editMode?: boolean; usageStats?: UsageQueryResult | null; }; -export default function SchemaTable({ rows, editableSchemaMetadata, usageStats, editMode = true }: Props) { +export default function SchemaTable({ + rows, + schemaMetadata, + editableSchemaMetadata, + usageStats, + editMode = true, +}: Props): JSX.Element { const hasUsageStats = useMemo(() => (usageStats?.aggregations?.fields?.length || 0) > 0, [usageStats]); const [tagHoveredIndex, setTagHoveredIndex] = useState(undefined); + const [selectedFkFieldPath, setSelectedFkFieldPath] = + useState(null); const descriptionRender = useDescriptionRenderer(editableSchemaMetadata); const usageStatsRenderer = useUsageStatsRenderer(usageStats); @@ -53,6 +65,7 @@ export default function SchemaTable({ rows, editableSchemaMetadata, usageStats, showTags: false, showTerms: true, }); + const schemaTitleRenderer = useSchemaTitleRenderer(schemaMetadata, setSelectedFkFieldPath); const onTagTermCell = (record: SchemaField, rowIndex: number | undefined) => ({ onMouseEnter: () => { @@ -67,6 +80,15 @@ export default function SchemaTable({ rows, editableSchemaMetadata, usageStats, }, }); + const fieldColumn = { + title: 'Field', + dataIndex: 'fieldPath', + key: 'fieldPath', + width: 250, + render: schemaTitleRenderer, + filtered: true, + }; + const tagColumn = { width: 125, title: 'Tags', @@ -100,26 +122,36 @@ export default function SchemaTable({ rows, editableSchemaMetadata, usageStats, width: 300, }; - let allColumns: ColumnsType = [...defaultColumns, descriptionColumn, tagColumn, termColumn]; + let allColumns: ColumnsType = [fieldColumn, descriptionColumn, tagColumn, termColumn]; if (hasUsageStats) { allColumns = [...allColumns, usageColumn]; } return ( - - - + + + + record.fieldPath === selectedFkFieldPath?.fieldPath ? 'open-fk-row' : '' + } + columns={allColumns} + dataSource={rows} + rowKey="fieldPath" + components={{ + body: { + row: SchemaRow, + }, + }} + expandable={{ + defaultExpandAllRows: false, + expandRowByClick: false, + expandIcon: ExpandIcon, + indentSize: 0, + }} + pagination={false} + /> + + ); } diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/ForeignKeyLabel.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/ForeignKeyLabel.tsx new file mode 100644 index 0000000000..4e4fb10cd6 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/ForeignKeyLabel.tsx @@ -0,0 +1,96 @@ +import React, { useContext, useState } from 'react'; +import { Badge, Table } from 'antd'; +import styled from 'styled-components'; +import { green } from '@ant-design/colors'; +import Modal from 'antd/lib/modal/Modal'; +import { Link } from 'react-router-dom'; + +import { ANTD_GRAY } from '../../../../constants'; +import { EntityType, ForeignKeyConstraint } from '../../../../../../../types.generated'; +import { useBaseEntity } from '../../../../EntityContext'; +import { GetDatasetQuery } from '../../../../../../../graphql/dataset.generated'; +import { useEntityRegistry } from '../../../../../../useEntityRegistry'; +import { FkContext } from '../utils/selectedFkContext'; + +const ForeignKeyBadge = styled(Badge)<{ highlight: boolean }>` + margin-left: 4px; + &&& .ant-badge-count { + background-color: ${(props) => (props.highlight ? green[1] : ANTD_GRAY[1])}; + color: ${green[5]}; + border: 1px solid ${green[2]}; + font-size: 12px; + font-weight: 400; + height: 22px; + cursor: pointer; + } +`; + +type Props = { + highlight: boolean; + fieldPath: string; + constraint?: ForeignKeyConstraint | null; + setHighlightedConstraint: (newActiveConstraint: string | null) => void; + onClick: (params: { fieldPath: string; constraint?: ForeignKeyConstraint | null } | null) => void; +}; + +const zip = (a, b) => + Array.from(Array(Math.max(b.length, a.length)), (_, i) => ({ source: a[i]?.fieldPath, foreign: b[i]?.fieldPath })); + +export default function ForeignKeyLabel({ + fieldPath, + constraint, + highlight, + setHighlightedConstraint, + onClick, +}: Props) { + const selectedFk = useContext(FkContext); + const [showModal, setShowModal] = useState(false); + const baseEntity = useBaseEntity(); + const entityRegistry = useEntityRegistry(); + + const sourceColumn = { + title: ( + + {baseEntity.dataset?.name} + + ), + dataIndex: 'source', + key: 'source', + }; + + const foreignColumn = { + title: ( + + {constraint?.foreignDataset?.name} + + ), + dataIndex: 'foreign', + key: 'foreign', + }; + + const rows = zip(constraint?.sourceFields, constraint?.foreignFields); + + return ( + <> + setShowModal(false)}> + + + (e.key === 'Enter' ? setShowModal(true) : null)} + onClick={() => { + if (selectedFk?.fieldPath === fieldPath && selectedFk?.constraint?.name === constraint?.name) { + onClick(null); + } else { + onClick({ fieldPath, constraint }); + } + }} + onMouseEnter={() => setHighlightedConstraint(constraint?.name || null)} + onMouseLeave={() => setHighlightedConstraint(null)} + > + + + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/PrimaryKeyLabel.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/PrimaryKeyLabel.tsx new file mode 100644 index 0000000000..7535099e7b --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/PrimaryKeyLabel.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { Badge } from 'antd'; +import styled from 'styled-components'; +import { blue } from '@ant-design/colors'; + +import { ANTD_GRAY } from '../../../../constants'; + +const PrimaryKeyBadge = styled(Badge)` + margin-left: 4px; + &&& .ant-badge-count { + background-color: ${ANTD_GRAY[1]}; + color: ${blue[5]}; + border: 1px solid ${blue[2]}; + font-size: 12px; + font-weight: 400; + height: 22px; + } +`; + +export default function PrimaryKeyLabel() { + return ; +} diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaRow.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaRow.tsx new file mode 100644 index 0000000000..a5e1dc15fd --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/components/SchemaRow.tsx @@ -0,0 +1,151 @@ +import { Badge } from 'antd'; +import React, { useContext } from 'react'; +import { Link } from 'react-router-dom'; +import styled from 'styled-components'; +import { GetDatasetQuery } from '../../../../../../../graphql/dataset.generated'; +import { EntityType } from '../../../../../../../types.generated'; +import CompactContext from '../../../../../../shared/CompactContext'; +import { useEntityRegistry } from '../../../../../../useEntityRegistry'; +import { ANTD_GRAY } from '../../../../constants'; +import { useBaseEntity } from '../../../../EntityContext'; +import { FkContext } from '../utils/selectedFkContext'; + +const ForeignKeyContent = styled.div` + position: absolute; + display: flex; + flex-direction: column; + width: 100%; + max-height: 600px; + height: 600px; + z-index: 99999; + margin-top: -590px; + box-shadow: inset 0 7px 16px -7px ${ANTD_GRAY[5]}; +`; + +const EntitySidePanel = styled.div` + overflow-y: scroll; + max-height: 548px; + width: 900px; + height: 548px; + padding: 8px; + border-right: 1px solid ${ANTD_GRAY[4]}; + background-color: white; +`; + +const FieldBadge = styled(Badge)` + margin-left: 4px; + margin-top: 12px; + &&& .ant-badge-count { + background-color: ${ANTD_GRAY[1]}; + color: ${ANTD_GRAY[9]}; + border: 1px solid ${ANTD_GRAY[6]}; + font-size: 12px; + font-weight: 400; + height: 22px; + cursor: pointer; + } +`; + +const ConstraintSection = styled.div` + padding: 20px; + padding-top: 40px; + width: 100%; + min-height: 100%; + display: flex; + justify-content: space-between; + max-height: 548px; + min-height: 548px; + background-color: ${ANTD_GRAY[2]}; +`; + +const TableTitle = styled.span` + font-size: 14px; +`; + +const BodyContent = styled.div` + display: flex; + flex-direction: row; + border-bottom: 1px solid ${ANTD_GRAY[4]}; +`; + +const HeaderContent = styled.div` + margin-top: 12px; + min-height: 40px; + font-size: 16px; + font-weight: 500; + padding-left: 12px; + border-bottom: 1px solid ${ANTD_GRAY[4]}; +`; + +const DatasetLink = styled(Link)` + color: ${ANTD_GRAY[9]}; + font-weight: 800; +`; + +const ArrowContainer = styled.div` + margin-top: 40px; +`; + +export const SchemaRow = ({ + children, + className, + 'data-row-key': fieldPath, +}: { + children: any; + className: string; + 'data-row-key': string; +}) => { + const selectedFk = useContext(FkContext); + const entityRegistry = useEntityRegistry(); + const baseEntity = useBaseEntity(); + + return ( + <> + {children} + {fieldPath === selectedFk?.fieldPath && ( + + + Foreign Key to{' '} + + {selectedFk.constraint?.foreignDataset?.name} + + + + + + {entityRegistry.renderProfile( + EntityType.Dataset, + selectedFk.constraint?.foreignDataset?.urn || '', + )} + + + +
+ {baseEntity.dataset?.name} + {selectedFk.constraint?.sourceFields?.map((field) => ( +
+ +
+ ))} +
+ {'--->'} +
+ {selectedFk.constraint?.foreignDataset?.name} + {selectedFk.constraint?.foreignFields?.map((field) => ( +
+ +
+ ))} +
+
+
+
+ )} + + ); +}; diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/utils/selectedFkContext.ts b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/utils/selectedFkContext.ts new file mode 100644 index 0000000000..cd833d5558 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Schema/utils/selectedFkContext.ts @@ -0,0 +1,5 @@ +import React from 'react'; +import { ForeignKeyConstraint } from '../../../../../../../types.generated'; + +export const FkContext = + React.createContext<{ fieldPath: string; constraint?: ForeignKeyConstraint | null } | null>(null); diff --git a/datahub-web-react/src/graphql/fragments.graphql b/datahub-web-react/src/graphql/fragments.graphql index 698bcf6f7e..4554583ba3 100644 --- a/datahub-web-react/src/graphql/fragments.graphql +++ b/datahub-web-react/src/graphql/fragments.graphql @@ -482,6 +482,40 @@ fragment schemaMetadataFields on SchemaMetadata { } } primaryKeys + foreignKeys { + name + sourceFields { + fieldPath + } + foreignFields { + fieldPath + } + foreignDataset { + urn + name + type + origin + description + uri + platform { + name + info { + displayName + logoUrl + } + } + platformNativeType + ownership { + ...ownershipFields + } + globalTags { + ...globalTagsFields + } + glossaryTerms { + ...glossaryTerms + } + } + } } fragment nonRecursiveMLModel on MLModel { diff --git a/metadata-ingestion/examples/mce_files/bootstrap_mce.json b/metadata-ingestion/examples/mce_files/bootstrap_mce.json index b3ef1d6dea..7ee6da03a5 100644 --- a/metadata-ingestion/examples/mce_files/bootstrap_mce.json +++ b/metadata-ingestion/examples/mce_files/bootstrap_mce.json @@ -876,7 +876,17 @@ } ], "primaryKeys": null, - "foreignKeysSpecs": null + "foreignKeysSpecs": null, + "foreignKeys": [{ + "name": "user id", + "foreignFields": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:hive,fct_users_deleted,PROD),user_id)" + ], + "sourceFields": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:hive,fct_users_created,PROD),user_id)" + ], + "foreignDataset": "urn:li:dataset:(urn:li:dataPlatform:hive,fct_users_deleted,PROD)" + }] } } ] @@ -985,21 +995,6 @@ } }, "fields": [ - { - "fieldPath": "user_id", - "jsonPath": null, - "nullable": false, - "description": { - "string": "Id of the user deleted" - }, - "type": { - "type": { - "com.linkedin.pegasus2avro.schema.BooleanType": {} - } - }, - "nativeDataType": "varchar(100)", - "recursive": false - }, { "fieldPath": "user_name", "jsonPath": null, @@ -1009,15 +1004,100 @@ }, "type": { "type": { - "com.linkedin.pegasus2avro.schema.BooleanType": {} + "com.linkedin.pegasus2avro.schema.StringType": {} } }, - "nativeDataType": "boolean", + "nativeDataType": "varchar(100)", + "recursive": false + }, + { + "fieldPath": "timestamp", + "jsonPath": null, + "nullable": false, + "description": { + "string": "Timestamp user was deleted at" + }, + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.NumberType": {} + } + }, + "nativeDataType": "long", + "recursive": false + }, + { + "fieldPath": "user_id", + "jsonPath": null, + "nullable": false, + "description": { + "string": "Id of the user deleted" + }, + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.StringType": {} + } + }, + "nativeDataType": "varchar(100)", + "recursive": false + }, + { + "fieldPath": "browser_id", + "jsonPath": null, + "nullable": false, + "description": { + "string": "Cookie attached to identify the browser" + }, + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.StringType": {} + } + }, + "nativeDataType": "varchar(100)", + "recursive": false + }, + { + "fieldPath": "session_id", + "jsonPath": null, + "nullable": false, + "description": { + "string": "Cookie attached to identify the session" + }, + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.StringType": {} + } + }, + "nativeDataType": "varchar(100)", + "recursive": false + }, + { + "fieldPath": "deletion_reason", + "jsonPath": null, + "nullable": false, + "description": { + "string": "Why the user chose to deactivate" + }, + "type": { + "type": { + "com.linkedin.pegasus2avro.schema.StringType": {} + } + }, + "nativeDataType": "varchar(100)", "recursive": false } ], - "primaryKeys": null, - "foreignKeysSpecs": null + "primaryKeys": ["user_name"], + "foreignKeysSpecs": null, + "foreignKeys": [{ + "name": "user session", + "foreignFields": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:hive,fct_users_created,PROD),user_id)" + ], + "sourceFields": [ + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:hive,fct_users_deleted,PROD),user_id)" + ], + "foreignDataset": "urn:li:dataset:(urn:li:dataPlatform:hive,fct_users_created,PROD)" + }] } } ]