mirror of
https://github.com/datahub-project/datahub.git
synced 2025-12-24 08:28:12 +00:00
feat(react): show primary keys & foreign keys in the schema (#3298)
This commit is contained in:
parent
febbced383
commit
08e18868dc
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -33,6 +33,11 @@ public class SchemaMetadataMapper implements ModelMapper<VersionedAspect, com.li
|
||||
result.setFields(input.getFields().stream().map(SchemaFieldMapper::map).collect(Collectors.toList()));
|
||||
result.setPlatformSchema(PlatformSchemaMapper.map(input.getPlatformSchema()));
|
||||
result.setAspectVersion(inputWithMetadata.getVersion());
|
||||
if (input.hasForeignKeys()) {
|
||||
result.setForeignKeys(input.getForeignKeys().stream().map(foreignKeyConstraint -> ForeignKeyConstraintMapper.map(
|
||||
foreignKeyConstraint
|
||||
)).collect(Collectors.toList()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -377,6 +377,7 @@ export const dataset3 = {
|
||||
},
|
||||
datasetUrn: 'urn:li:dataset:3',
|
||||
primaryKeys: [],
|
||||
foreignKeys: [],
|
||||
},
|
||||
previousSchemaMetadata: null,
|
||||
editableSchemaMetadata: null,
|
||||
|
||||
@ -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(
|
||||
<MockedProvider mocks={mocks} addTypename={false}>
|
||||
<TestPageContainer>
|
||||
<EntityContext.Provider
|
||||
value={{
|
||||
urn: 'urn:li:dataset:123',
|
||||
entityType: EntityType.Dataset,
|
||||
entityData: {
|
||||
description: 'This is a description',
|
||||
schemaMetadata: sampleSchemaWithPkFk as SchemaMetadata,
|
||||
},
|
||||
baseEntity: {},
|
||||
updateEntity: jest.fn(),
|
||||
routeToTab: jest.fn(),
|
||||
refetch: jest.fn(),
|
||||
}}
|
||||
>
|
||||
<SchemaTab />
|
||||
</EntityContext.Provider>
|
||||
</TestPageContainer>
|
||||
</MockedProvider>,
|
||||
);
|
||||
expect(getByText('Primary Key')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders foreign keys', () => {
|
||||
const { getByText, getAllByText } = render(
|
||||
<MockedProvider mocks={mocks} addTypename={false}>
|
||||
<TestPageContainer>
|
||||
<EntityContext.Provider
|
||||
value={{
|
||||
urn: 'urn:li:dataset:123',
|
||||
entityType: EntityType.Dataset,
|
||||
entityData: {
|
||||
description: 'This is a description',
|
||||
schemaMetadata: sampleSchemaWithPkFk as SchemaMetadata,
|
||||
},
|
||||
baseEntity: {},
|
||||
updateEntity: jest.fn(),
|
||||
routeToTab: jest.fn(),
|
||||
refetch: jest.fn(),
|
||||
}}
|
||||
>
|
||||
<SchemaTab />
|
||||
</EntityContext.Provider>
|
||||
</TestPageContainer>
|
||||
</MockedProvider>,
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<string | null>(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 (
|
||||
<FieldPathContainer>
|
||||
<FieldPathText>{lastPath || firstPath}</FieldPathText>
|
||||
<TypeLabel type={record.type} nativeDataType={record.nativeDataType} />
|
||||
</FieldPathContainer>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<FieldPathContainer>
|
||||
<FieldPathText>{lastPath || firstPath}</FieldPathText>
|
||||
<TypeLabel type={record.type} nativeDataType={record.nativeDataType} />
|
||||
{schemaMetadata?.primaryKeys?.includes(fieldPath) && <PrimaryKeyLabel />}
|
||||
{schemaMetadata?.foreignKeys
|
||||
?.filter(
|
||||
(constraint) =>
|
||||
(constraint?.sourceFields?.filter((sourceField) => sourceField?.fieldPath === fieldPath)
|
||||
.length || 0) > 0,
|
||||
)
|
||||
.map((constraint) => (
|
||||
<ForeignKeyLabel
|
||||
fieldPath={fieldPath}
|
||||
constraint={constraint}
|
||||
highlight={constraint?.name === highlightedConstraint}
|
||||
setHighlightedConstraint={setHighlightedConstraint}
|
||||
onClick={setSelectedFkFieldPath}
|
||||
/>
|
||||
))}
|
||||
</FieldPathContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
@ -61,6 +61,7 @@ export const SchemaTab = () => {
|
||||
) : rows && rows.length > 0 ? (
|
||||
<>
|
||||
<SchemaTable
|
||||
schemaMetadata={schemaMetadata}
|
||||
rows={rows}
|
||||
editMode
|
||||
editableSchemaMetadata={editableSchemaMetadata}
|
||||
|
||||
@ -1,14 +1,24 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
import styled from 'styled-components';
|
||||
import { EditableSchemaMetadata, SchemaField, UsageQueryResult } from '../../../../../../types.generated';
|
||||
import schemaTitleRenderer from '../../../../dataset/profile/schema/utils/schemaTitleRenderer';
|
||||
import {} from 'antd';
|
||||
|
||||
import {
|
||||
EditableSchemaMetadata,
|
||||
ForeignKeyConstraint,
|
||||
SchemaField,
|
||||
SchemaMetadata,
|
||||
UsageQueryResult,
|
||||
} from '../../../../../../types.generated';
|
||||
import useSchemaTitleRenderer from '../../../../dataset/profile/schema/utils/schemaTitleRenderer';
|
||||
import { ExtendedSchemaFields } from '../../../../dataset/profile/schema/utils/types';
|
||||
import useDescriptionRenderer from './utils/useDescriptionRenderer';
|
||||
import useUsageStatsRenderer from './utils/useUsageStatsRenderer';
|
||||
import useTagsAndTermsRenderer from './utils/useTagsAndTermsRenderer';
|
||||
import ExpandIcon from './components/ExpandIcon';
|
||||
import { StyledTable } from '../../../components/styled/StyledTable';
|
||||
import { SchemaRow } from './components/SchemaRow';
|
||||
import { FkContext } from './utils/selectedFkContext';
|
||||
|
||||
const TableContainer = styled.div`
|
||||
&&& .ant-table-tbody > 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<ExtendedSchemaFields>;
|
||||
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<string | undefined>(undefined);
|
||||
const [selectedFkFieldPath, setSelectedFkFieldPath] =
|
||||
useState<null | { fieldPath: string; constraint?: ForeignKeyConstraint | null }>(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<ExtendedSchemaFields> = [...defaultColumns, descriptionColumn, tagColumn, termColumn];
|
||||
let allColumns: ColumnsType<ExtendedSchemaFields> = [fieldColumn, descriptionColumn, tagColumn, termColumn];
|
||||
|
||||
if (hasUsageStats) {
|
||||
allColumns = [...allColumns, usageColumn];
|
||||
}
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<StyledTable
|
||||
columns={allColumns}
|
||||
dataSource={rows}
|
||||
rowKey="fieldPath"
|
||||
expandable={{
|
||||
defaultExpandAllRows: false,
|
||||
expandRowByClick: false,
|
||||
expandIcon: ExpandIcon,
|
||||
indentSize: 0,
|
||||
}}
|
||||
pagination={false}
|
||||
/>
|
||||
</TableContainer>
|
||||
<FkContext.Provider value={selectedFkFieldPath}>
|
||||
<TableContainer>
|
||||
<StyledTable
|
||||
rowClassName={(record) =>
|
||||
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}
|
||||
/>
|
||||
</TableContainer>
|
||||
</FkContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<GetDatasetQuery>();
|
||||
const entityRegistry = useEntityRegistry();
|
||||
|
||||
const sourceColumn = {
|
||||
title: (
|
||||
<Link to={entityRegistry.getEntityUrl(EntityType.Dataset, baseEntity?.dataset?.urn || '')}>
|
||||
{baseEntity.dataset?.name}
|
||||
</Link>
|
||||
),
|
||||
dataIndex: 'source',
|
||||
key: 'source',
|
||||
};
|
||||
|
||||
const foreignColumn = {
|
||||
title: (
|
||||
<Link to={entityRegistry.getEntityUrl(EntityType.Dataset, constraint?.foreignDataset?.urn || '')}>
|
||||
{constraint?.foreignDataset?.name}
|
||||
</Link>
|
||||
),
|
||||
dataIndex: 'foreign',
|
||||
key: 'foreign',
|
||||
};
|
||||
|
||||
const rows = zip(constraint?.sourceFields, constraint?.foreignFields);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal title={constraint?.name || 'Foreign Key'} visible={showModal} onCancel={() => setShowModal(false)}>
|
||||
<Table columns={[sourceColumn, foreignColumn]} dataSource={rows} pagination={false} />
|
||||
</Modal>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyPress={(e) => (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)}
|
||||
>
|
||||
<ForeignKeyBadge highlight={highlight || selectedFk?.fieldPath === fieldPath} count="Foreign Key" />
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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 <PrimaryKeyBadge count="Primary Key" />;
|
||||
}
|
||||
@ -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<GetDatasetQuery>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr className={className}>{children}</tr>
|
||||
{fieldPath === selectedFk?.fieldPath && (
|
||||
<ForeignKeyContent>
|
||||
<HeaderContent>
|
||||
Foreign Key to{' '}
|
||||
<DatasetLink
|
||||
to={entityRegistry.getEntityUrl(
|
||||
EntityType.Dataset,
|
||||
selectedFk.constraint?.foreignDataset?.urn || '',
|
||||
)}
|
||||
>
|
||||
{selectedFk.constraint?.foreignDataset?.name}
|
||||
</DatasetLink>
|
||||
</HeaderContent>
|
||||
<BodyContent>
|
||||
<EntitySidePanel>
|
||||
<CompactContext.Provider value>
|
||||
{entityRegistry.renderProfile(
|
||||
EntityType.Dataset,
|
||||
selectedFk.constraint?.foreignDataset?.urn || '',
|
||||
)}
|
||||
</CompactContext.Provider>
|
||||
</EntitySidePanel>
|
||||
<ConstraintSection>
|
||||
<div>
|
||||
<TableTitle>{baseEntity.dataset?.name}</TableTitle>
|
||||
{selectedFk.constraint?.sourceFields?.map((field) => (
|
||||
<div>
|
||||
<FieldBadge count={field?.fieldPath} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ArrowContainer>{'--->'}</ArrowContainer>
|
||||
<div>
|
||||
<TableTitle>{selectedFk.constraint?.foreignDataset?.name}</TableTitle>
|
||||
{selectedFk.constraint?.foreignFields?.map((field) => (
|
||||
<div>
|
||||
<FieldBadge count={field?.fieldPath} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ConstraintSection>
|
||||
</BodyContent>
|
||||
</ForeignKeyContent>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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);
|
||||
@ -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 {
|
||||
|
||||
@ -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)"
|
||||
}]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user