feat(containers) Get and display all parent containers in header and search (#4910)

Co-authored-by: Chris Collins <chriscollins@Chriss-MBP.lan>
This commit is contained in:
Chris Collins 2022-05-13 00:17:19 -04:00 committed by GitHub
parent 78c3ca039e
commit 0c5f844e4f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 829 additions and 205 deletions

View File

@ -63,6 +63,7 @@ import com.linkedin.datahub.graphql.resolvers.auth.GetAccessTokenResolver;
import com.linkedin.datahub.graphql.resolvers.browse.BrowsePathsResolver;
import com.linkedin.datahub.graphql.resolvers.browse.BrowseResolver;
import com.linkedin.datahub.graphql.resolvers.config.AppConfigResolver;
import com.linkedin.datahub.graphql.resolvers.container.ParentContainersResolver;
import com.linkedin.datahub.graphql.resolvers.container.ContainerEntitiesResolver;
import com.linkedin.datahub.graphql.resolvers.dataset.DatasetHealthResolver;
import com.linkedin.datahub.graphql.resolvers.deprecation.UpdateDeprecationResolver;
@ -482,6 +483,7 @@ public class GmsGraphQLEngine {
return container.getContainer() != null ? container.getContainer().getUrn() : null;
})
)
.dataFetcher("parentContainers", new ParentContainersResolver(entityClient))
.dataFetcher("dataPlatformInstance",
new LoadableTypeResolver<>(dataPlatformInstanceType,
(env) -> {
@ -724,7 +726,8 @@ public class GmsGraphQLEngine {
this.entityClient,
"dataset",
"subTypes"))
.dataFetcher("runs", new EntityRunsResolver(entityClient)))
.dataFetcher("runs", new EntityRunsResolver(entityClient))
.dataFetcher("parentContainers", new ParentContainersResolver(entityClient)))
.type("Owner", typeWiring -> typeWiring
.dataFetcher("owner", new OwnerTypeResolver<>(ownerTypes,
(env) -> ((Owner) env.getSource()).getOwner()))
@ -854,6 +857,7 @@ public class GmsGraphQLEngine {
return dashboard.getContainer() != null ? dashboard.getContainer().getUrn() : null;
})
)
.dataFetcher("parentContainers", new ParentContainersResolver(entityClient))
);
builder.type("DashboardInfo", typeWiring -> typeWiring
.dataFetcher("charts", new LoadableTypeBatchResolver<>(chartType,
@ -893,6 +897,7 @@ public class GmsGraphQLEngine {
return chart.getContainer() != null ? chart.getContainer().getUrn() : null;
})
)
.dataFetcher("parentContainers", new ParentContainersResolver(entityClient))
);
builder.type("ChartInfo", typeWiring -> typeWiring
.dataFetcher("inputs", new LoadableTypeBatchResolver<>(datasetType,

View File

@ -0,0 +1,76 @@
package com.linkedin.datahub.graphql.resolvers.container;
import com.linkedin.common.urn.Urn;
import com.linkedin.data.DataMap;
import com.linkedin.datahub.graphql.QueryContext;
import com.linkedin.datahub.graphql.exception.DataHubGraphQLException;
import com.linkedin.datahub.graphql.generated.Container;
import com.linkedin.datahub.graphql.generated.Entity;
import com.linkedin.datahub.graphql.generated.ParentContainersResult;
import com.linkedin.datahub.graphql.types.container.mappers.ContainerMapper;
import com.linkedin.entity.EntityResponse;
import com.linkedin.entity.client.EntityClient;
import graphql.schema.DataFetcher;
import graphql.schema.DataFetchingEnvironment;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import static com.linkedin.metadata.Constants.CONTAINER_ASPECT_NAME;
public class ParentContainersResolver implements DataFetcher<CompletableFuture<ParentContainersResult>> {
private final EntityClient _entityClient;
public ParentContainersResolver(final EntityClient entityClient) {
_entityClient = entityClient;
}
private void aggregateParentContainers(List<Container> containers, String urn, QueryContext context) {
try {
Urn entityUrn = new Urn(urn);
EntityResponse entityResponse = _entityClient.getV2(
entityUrn.getEntityType(),
entityUrn,
Collections.singleton(CONTAINER_ASPECT_NAME),
context.getAuthentication()
);
if (entityResponse != null && entityResponse.getAspects().containsKey(CONTAINER_ASPECT_NAME)) {
DataMap dataMap = entityResponse.getAspects().get(CONTAINER_ASPECT_NAME).getValue().data();
com.linkedin.container.Container container = new com.linkedin.container.Container(dataMap);
Urn containerUrn = container.getContainer();
EntityResponse response = _entityClient.getV2(containerUrn.getEntityType(), containerUrn, null, context.getAuthentication());
if (response != null) {
Container mappedContainer = ContainerMapper.map(response);
containers.add(mappedContainer);
aggregateParentContainers(containers, mappedContainer.getUrn(), context);
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
@Override
public CompletableFuture<ParentContainersResult> get(DataFetchingEnvironment environment) {
final QueryContext context = environment.getContext();
final String urn = ((Entity) environment.getSource()).getUrn();
final List<Container> containers = new ArrayList<>();
return CompletableFuture.supplyAsync(() -> {
try {
aggregateParentContainers(containers, urn, context);
final ParentContainersResult result = new ParentContainersResult();
result.setCount(containers.size());
result.setContainers(containers);
return result;
} catch (DataHubGraphQLException e) {
throw new RuntimeException("Failed to load all containers", e);
}
});
}
}

View File

@ -710,6 +710,11 @@ type Dataset implements EntityWithRelationships & Entity {
"""
container: Container
"""
Recursively get the lineage of containers for this entity
"""
parentContainers: ParentContainersResult
"""
Unique guid for dataset
No longer to be used as the Dataset display name. Use properties.name instead
@ -878,6 +883,21 @@ type Dataset implements EntityWithRelationships & Entity {
runs(start: Int, count: Int, direction: RelationshipDirection!): DataProcessInstanceResult
}
"""
All of the parent containers for a given entity
"""
type ParentContainersResult {
"""
The number of containers bubbling up for this entity
"""
count: Int!
"""
A list of parent containers in order from direct parent, to parent's parent etc. If there are no containers, return an emty list
"""
containers: [Container!]!
}
"""
A Dataset entity, which encompasses Relational Tables, Document store collections, streaming topics, and other sets of data having an independent lifecycle
"""
@ -902,6 +922,11 @@ type VersionedDataset implements Entity {
"""
container: Container
"""
Recursively get the lineage of containers for this entity
"""
parentContainers: ParentContainersResult
"""
Unique guid for dataset
No longer to be used as the Dataset display name. Use properties.name instead
@ -1515,6 +1540,11 @@ type Container implements Entity {
"""
container: Container
"""
Recursively get the lineage of containers for this entity
"""
parentContainers: ParentContainersResult
"""
Read-only properties that originate in the source data platform
"""
@ -3720,6 +3750,11 @@ type Dashboard implements EntityWithRelationships & Entity {
"""
container: Container
"""
Recursively get the lineage of containers for this entity
"""
parentContainers: ParentContainersResult
"""
The dashboard tool name
Note that this will soon be deprecated in favor of a standardized notion of Data Platform
@ -3973,6 +4008,11 @@ type Chart implements EntityWithRelationships & Entity {
"""
container: Container
"""
Recursively get the lineage of containers for this entity
"""
parentContainers: ParentContainersResult
"""
The chart tool name
Note that this field will soon be deprecated in favor a unified notion of Data Platform

View File

@ -0,0 +1,118 @@
package com.linkedin.datahub.graphql.resolvers.container;
import com.datahub.authentication.Authentication;
import com.linkedin.common.urn.Urn;
import com.linkedin.container.Container;
import com.linkedin.container.ContainerProperties;
import com.linkedin.datahub.graphql.QueryContext;
import com.linkedin.datahub.graphql.generated.Dataset;
import com.linkedin.datahub.graphql.generated.EntityType;
import com.linkedin.datahub.graphql.generated.ParentContainersResult;
import com.linkedin.entity.Aspect;
import com.linkedin.entity.EntityResponse;
import com.linkedin.entity.EnvelopedAspect;
import com.linkedin.entity.EnvelopedAspectMap;
import com.linkedin.entity.client.EntityClient;
import graphql.schema.DataFetchingEnvironment;
import org.mockito.Mockito;
import org.testng.annotations.Test;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import static com.linkedin.metadata.Constants.CONTAINER_ASPECT_NAME;
import static com.linkedin.metadata.Constants.CONTAINER_ENTITY_NAME;
import static com.linkedin.metadata.Constants.CONTAINER_PROPERTIES_ASPECT_NAME;
import static org.testng.Assert.*;
public class ParentContainersResolverTest {
@Test
public void testGetSuccess() throws Exception {
EntityClient mockClient = Mockito.mock(EntityClient.class);
QueryContext mockContext = Mockito.mock(QueryContext.class);
Mockito.when(mockContext.getAuthentication()).thenReturn(Mockito.mock(Authentication.class));
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
Urn datasetUrn = Urn.createFromString("urn:li:dataset:(test,test,test)");
Dataset datasetEntity = new Dataset();
datasetEntity.setUrn(datasetUrn.toString());
datasetEntity.setType(EntityType.DATASET);
Mockito.when(mockEnv.getSource()).thenReturn(datasetEntity);
final Container parentContainer1 = new Container().setContainer(Urn.createFromString("urn:li:container:test-container"));
final Container parentContainer2 = new Container().setContainer(Urn.createFromString("urn:li:container:test-container2"));
Map<String, EnvelopedAspect> datasetAspects = new HashMap<>();
datasetAspects.put(CONTAINER_ASPECT_NAME, new EnvelopedAspect().setValue(new Aspect(parentContainer1.data())));
Map<String, EnvelopedAspect> parentContainer1Aspects = new HashMap<>();
parentContainer1Aspects.put(CONTAINER_PROPERTIES_ASPECT_NAME, new EnvelopedAspect().setValue(new Aspect(
new ContainerProperties().setName("test_schema").data()
)));
parentContainer1Aspects.put(CONTAINER_ASPECT_NAME, new EnvelopedAspect().setValue(new Aspect(
parentContainer2.data()
)));
Map<String, EnvelopedAspect> parentContainer2Aspects = new HashMap<>();
parentContainer2Aspects.put(CONTAINER_PROPERTIES_ASPECT_NAME, new EnvelopedAspect().setValue(new Aspect(
new ContainerProperties().setName("test_database").data()
)));
Mockito.when(mockClient.getV2(
Mockito.eq(datasetUrn.getEntityType()),
Mockito.eq(datasetUrn),
Mockito.eq(Collections.singleton(CONTAINER_ASPECT_NAME)),
Mockito.any(Authentication.class)
)).thenReturn(new EntityResponse().setAspects(new EnvelopedAspectMap(datasetAspects)));
Mockito.when(mockClient.getV2(
Mockito.eq(parentContainer1.getContainer().getEntityType()),
Mockito.eq(parentContainer1.getContainer()),
Mockito.eq(null),
Mockito.any(Authentication.class)
)).thenReturn(new EntityResponse()
.setEntityName(CONTAINER_ENTITY_NAME)
.setUrn(parentContainer1.getContainer())
.setAspects(new EnvelopedAspectMap(parentContainer1Aspects)));
Mockito.when(mockClient.getV2(
Mockito.eq(parentContainer1.getContainer().getEntityType()),
Mockito.eq(parentContainer1.getContainer()),
Mockito.eq(Collections.singleton(CONTAINER_ASPECT_NAME)),
Mockito.any(Authentication.class)
)).thenReturn(new EntityResponse().setAspects(new EnvelopedAspectMap(parentContainer1Aspects)));
Mockito.when(mockClient.getV2(
Mockito.eq(parentContainer2.getContainer().getEntityType()),
Mockito.eq(parentContainer2.getContainer()),
Mockito.eq(null),
Mockito.any(Authentication.class)
)).thenReturn(new EntityResponse()
.setEntityName(CONTAINER_ENTITY_NAME)
.setUrn(parentContainer2.getContainer())
.setAspects(new EnvelopedAspectMap(parentContainer2Aspects)));
Mockito.when(mockClient.getV2(
Mockito.eq(parentContainer2.getContainer().getEntityType()),
Mockito.eq(parentContainer2.getContainer()),
Mockito.eq(Collections.singleton(CONTAINER_ASPECT_NAME)),
Mockito.any(Authentication.class)
)).thenReturn(new EntityResponse().setAspects(new EnvelopedAspectMap(parentContainer2Aspects)));
ParentContainersResolver resolver = new ParentContainersResolver(mockClient);
ParentContainersResult result = resolver.get(mockEnv).get();
Mockito.verify(mockClient, Mockito.times(5)).getV2(
Mockito.any(),
Mockito.any(),
Mockito.any(),
Mockito.any()
);
assertEquals(result.getCount(), 2);
assertEquals(result.getContainers().get(0).getUrn(), parentContainer1.getContainer().toString());
assertEquals(result.getContainers().get(1).getUrn(), parentContainer2.getContainer().toString());
}
}

View File

@ -24,6 +24,7 @@ import {
ScenarioType,
RecommendationRenderType,
RelationshipDirection,
Container,
} from './types.generated';
import { GetTagDocument } from './graphql/tag.generated';
import { GetMlModelDocument } from './graphql/mlModel.generated';
@ -318,6 +319,10 @@ export const dataset3 = {
customProperties: [{ key: 'propertyAKey', value: 'propertyAValue' }],
externalUrl: 'https://data.hub',
},
parentContainers: {
count: 0,
containers: [],
},
editableProperties: null,
created: {
time: 0,
@ -758,6 +763,29 @@ export const dataset7WithSelfReferentialLineage = {
],
},
};
export const container1 = {
urn: 'urn:li:container:DATABASE',
type: EntityType.Container,
platform: dataPlatform,
properties: {
name: 'database1',
__typename: 'ContainerProperties',
},
__typename: 'Container',
} as Container;
export const container2 = {
urn: 'urn:li:container:SCHEMA',
type: EntityType.Container,
platform: dataPlatform,
properties: {
name: 'schema1',
__typename: 'ContainerProperties',
},
__typename: 'Container',
} as Container;
const glossaryTerm1 = {
urn: 'urn:li:glossaryTerm:1',
type: EntityType.GlossaryTerm,

View File

@ -152,6 +152,7 @@ export class ChartEntity implements Entity<Chart> {
glossaryTerms={data?.glossaryTerms}
logoUrl={data?.platform?.properties?.logoUrl}
domain={data.domain}
parentContainers={data.parentContainers}
/>
);
};

View File

@ -8,6 +8,7 @@ import {
GlossaryTerms,
Owner,
SearchInsight,
ParentContainersResult,
} from '../../../../types.generated';
import DefaultPreviewCard from '../../../preview/DefaultPreviewCard';
import { useEntityRegistry } from '../../../useEntityRegistry';
@ -27,6 +28,7 @@ export const ChartPreview = ({
container,
insights,
logoUrl,
parentContainers,
}: {
urn: string;
platform: string;
@ -41,6 +43,7 @@ export const ChartPreview = ({
container?: Container | null;
insights?: Array<SearchInsight> | null;
logoUrl?: string | null;
parentContainers?: ParentContainersResult | null;
}): JSX.Element => {
const entityRegistry = useEntityRegistry();
const capitalizedPlatform = capitalizeFirstLetter(platform);
@ -61,6 +64,7 @@ export const ChartPreview = ({
domain={domain}
container={container || undefined}
insights={insights}
parentContainers={parentContainers}
/>
);
};

View File

@ -137,6 +137,7 @@ export class ContainerEntity implements Entity<Container> {
container={data.container}
entityCount={data.entities?.total}
domain={data.domain}
parentContainers={data.parentContainers}
/>
);
};

View File

@ -1,5 +1,13 @@
import React from 'react';
import { Container, EntityType, Owner, SearchInsight, SubTypes, Domain } from '../../../../types.generated';
import {
Container,
EntityType,
Owner,
SearchInsight,
SubTypes,
Domain,
ParentContainersResult,
} from '../../../../types.generated';
import DefaultPreviewCard from '../../../preview/DefaultPreviewCard';
import { useEntityRegistry } from '../../../useEntityRegistry';
import { IconStyleType } from '../../Entity';
@ -18,6 +26,7 @@ export const Preview = ({
container,
entityCount,
domain,
parentContainers,
}: {
urn: string;
name: string;
@ -32,6 +41,7 @@ export const Preview = ({
container?: Container | null;
entityCount?: number;
domain?: Domain | null;
parentContainers?: ParentContainersResult | null;
}): JSX.Element => {
const entityRegistry = useEntityRegistry();
const typeName = (subTypes?.typeNames?.length && subTypes?.typeNames[0]) || 'Container';
@ -51,6 +61,7 @@ export const Preview = ({
typeIcon={entityRegistry.getIcon(EntityType.Container, 12, IconStyleType.ACCENT)}
entityCount={entityCount}
domain={domain || undefined}
parentContainers={parentContainers}
/>
);
};

View File

@ -172,6 +172,7 @@ export class DashboardEntity implements Entity<Dashboard> {
logoUrl={data?.platform?.properties?.logoUrl || ''}
domain={data.domain}
container={data.container}
parentContainers={data.parentContainers}
/>
);
};

View File

@ -8,6 +8,7 @@ import {
GlossaryTerms,
Owner,
SearchInsight,
ParentContainersResult,
} from '../../../../types.generated';
import DefaultPreviewCard from '../../../preview/DefaultPreviewCard';
import { useEntityRegistry } from '../../../useEntityRegistry';
@ -27,6 +28,7 @@ export const DashboardPreview = ({
container,
insights,
logoUrl,
parentContainers,
}: {
urn: string;
platform: string;
@ -41,6 +43,7 @@ export const DashboardPreview = ({
container?: Container | null;
insights?: Array<SearchInsight> | null;
logoUrl?: string | null;
parentContainers?: ParentContainersResult | null;
}): JSX.Element => {
const entityRegistry = useEntityRegistry();
const capitalizedPlatform = capitalizeFirstLetter(platform);
@ -61,6 +64,7 @@ export const DashboardPreview = ({
glossaryTerms={glossaryTerms || undefined}
domain={domain}
insights={insights}
parentContainers={parentContainers}
/>
);
};

View File

@ -261,6 +261,7 @@ export class DatasetEntity implements Entity<Dataset> {
glossaryTerms={data.glossaryTerms}
subtype={data.subTypes?.typeNames?.[0]}
container={data.container}
parentContainers={data.parentContainers}
snippet={
// Add match highlights only if all the matched fields are in the FIELDS_TO_HIGHLIGHT
result.matchedFields.length > 0 &&

View File

@ -8,6 +8,7 @@ import {
SearchInsight,
Domain,
Container,
ParentContainersResult,
} from '../../../../types.generated';
import DefaultPreviewCard from '../../../preview/DefaultPreviewCard';
import { useEntityRegistry } from '../../../useEntityRegistry';
@ -30,6 +31,7 @@ export const Preview = ({
glossaryTerms,
subtype,
container,
parentContainers,
}: {
urn: string;
name: string;
@ -46,6 +48,7 @@ export const Preview = ({
glossaryTerms?: GlossaryTerms | null;
subtype?: string | null;
container?: Container | null;
parentContainers?: ParentContainersResult | null;
}): JSX.Element => {
const entityRegistry = useEntityRegistry();
const capitalPlatformName = capitalizeFirstLetterOnly(platformName);
@ -67,6 +70,7 @@ export const Preview = ({
snippet={snippet}
glossaryTerms={glossaryTerms || undefined}
insights={insights}
parentContainers={parentContainers}
/>
);
};

View File

@ -0,0 +1,134 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import PlatformContentView, { getParentContainerNames } from '../header/PlatformContent/PlatformContentView';
import { EntityType } from '../../../../../../types.generated';
import { container1, container2 } from '../../../../../../Mocks';
jest.mock('../../../../../useEntityRegistry', () => ({
useEntityRegistry: () => ({
getEntityUrl: jest.fn(() => 'test'),
getDisplayName: jest.fn(() => 'database1'),
}),
}));
describe('PlatformContent', () => {
const defaultProps = {
platformName: 'mysql',
entityLogoComponent: <></>,
typeIcon: <></>,
entityType: EntityType.Dataset,
parentContainers: [],
parentContainersRef: React.createRef<HTMLDivElement>(),
areContainersTruncated: false,
};
it('should not render any containers if there are none in parentContainers', () => {
const { queryAllByTestId } = render(<PlatformContentView {...defaultProps} />);
expect(queryAllByTestId('container')).toHaveLength(0);
expect(queryAllByTestId('right-arrow')).toHaveLength(0);
});
it('should render a direct parent container correctly when there is only one container', () => {
const { queryAllByTestId, getByText } = render(
<BrowserRouter>
<PlatformContentView {...defaultProps} parentContainers={[container1]} />
</BrowserRouter>,
);
expect(queryAllByTestId('container')).toHaveLength(1);
expect(queryAllByTestId('right-arrow')).toHaveLength(1);
expect(getByText('database1')).toBeInTheDocument();
});
it('should render all parent containers properly', () => {
const { queryAllByTestId } = render(
<BrowserRouter>
<PlatformContentView {...defaultProps} parentContainers={[container1, container2]} />
</BrowserRouter>,
);
expect(queryAllByTestId('container')).toHaveLength(2);
expect(queryAllByTestId('right-arrow')).toHaveLength(2);
});
it('should render the correct number of right arrows with no containers and an instanceId', () => {
render(
<BrowserRouter>
<PlatformContentView {...defaultProps} instanceId="mysql1" />
</BrowserRouter>,
);
expect(screen.queryAllByTestId('right-arrow')).toHaveLength(1);
});
it('should render the correct number of right arrows with multiple containers and an instanceId', () => {
render(
<BrowserRouter>
<PlatformContentView
{...defaultProps}
instanceId="mysql1"
parentContainers={[container1, container2]}
/>
</BrowserRouter>,
);
expect(screen.queryAllByTestId('right-arrow')).toHaveLength(3);
});
it('should render the correct number of right arrows with one containers and an instanceId', () => {
render(
<BrowserRouter>
<PlatformContentView {...defaultProps} instanceId="mysql1" parentContainers={[container2]} />
</BrowserRouter>,
);
expect(screen.queryAllByTestId('right-arrow')).toHaveLength(2);
});
it('should render the correct number of right arrows with multiple containers and no instanceId', () => {
render(
<BrowserRouter>
<PlatformContentView {...defaultProps} parentContainers={[container1, container2]} />
</BrowserRouter>,
);
expect(screen.queryAllByTestId('right-arrow')).toHaveLength(2);
});
it('should render the correct number of right arrows with one container and no instanceId', () => {
render(
<BrowserRouter>
<PlatformContentView {...defaultProps} parentContainers={[container1]} />
</BrowserRouter>,
);
expect(screen.queryAllByTestId('right-arrow')).toHaveLength(1);
});
it('should render the correct number of right arrows with no containers and no instanceID', () => {
render(
<BrowserRouter>
<PlatformContentView {...defaultProps} parentContainers={[]} />
</BrowserRouter>,
);
expect(screen.queryAllByTestId('right-arrow')).toHaveLength(0);
});
});
describe('getParentContainerNames', () => {
it('should return an empty string if there are no parent containers', () => {
const parentContainerNames = getParentContainerNames([]);
expect(parentContainerNames).toBe('');
});
it('should return the name of the parent container if there is only one', () => {
const parentContainerNames = getParentContainerNames([container1]);
expect(parentContainerNames).toBe(container1.properties?.name);
});
it('should return the names of the parents in reverse order, separated by cright arrows if there are multiple without manipulating the original array', () => {
const parentContainers = [container1, container2];
const parentContainersCopy = [...parentContainers];
const parentContainerNames = getParentContainerNames(parentContainers);
expect(parentContainerNames).toBe(`${container2.properties?.name} > ${container1.properties?.name}`);
expect(parentContainers).toMatchObject(parentContainersCopy);
});
});

View File

@ -0,0 +1,30 @@
import React from 'react';
import { Typography } from 'antd';
import styled from 'styled-components';
import { ANTD_GRAY } from '../../../constants';
export const EntityCountText = styled(Typography.Text)`
display: inline-block;
font-size: 12px;
line-height: 20px;
font-weight: 400;
color: ${ANTD_GRAY[7]};
`;
interface Props {
entityCount?: number;
}
function EntityCount(props: Props) {
const { entityCount } = props;
if (!entityCount || entityCount <= 0) return null;
return (
<EntityCountText className="entityCount">
{entityCount.toLocaleString()} {entityCount === 1 ? 'entity' : 'entities'}
</EntityCountText>
);
}
export default EntityCount;

View File

@ -3,21 +3,16 @@ import {
CheckOutlined,
CopyOutlined,
ExclamationCircleOutlined,
FolderOpenOutlined,
InfoCircleOutlined,
LinkOutlined,
MoreOutlined,
RightOutlined,
} from '@ant-design/icons';
import { Typography, Image, Button, Tooltip, Menu, Dropdown, message, Popover } from 'antd';
import { Link } from 'react-router-dom';
import { Typography, Button, Tooltip, Menu, Dropdown, message, Popover } from 'antd';
import styled from 'styled-components';
import moment from 'moment';
import { EntityType } from '../../../../../../types.generated';
import { capitalizeFirstLetterOnly } from '../../../../../shared/textUtil';
import { useEntityRegistry } from '../../../../../useEntityRegistry';
import { IconStyleType } from '../../../../Entity';
import { ANTD_GRAY } from '../../../constants';
import { useEntityData, useRefetch } from '../../../EntityContext';
import analytics, { EventType, EntityActionType } from '../../../../../analytics';
@ -25,17 +20,9 @@ import { EntityHealthStatus } from './EntityHealthStatus';
import { useUpdateDeprecationMutation } from '../../../../../../graphql/mutations.generated';
import { getLocaleTimezone } from '../../../../../shared/time/timeUtils';
import { AddDeprecationDetailsModal } from './AddDeprecationDetailsModal';
const LogoContainer = styled.span`
margin-right: 10px;
`;
const PreviewImage = styled(Image)`
max-height: 17px;
width: auto;
object-fit: contain;
background-color: transparent;
`;
import PlatformContent from './PlatformContent';
import { getPlatformName } from '../../../utils';
import EntityCount from './EntityCount';
const EntityTitle = styled(Typography.Title)`
&&& {
@ -44,36 +31,6 @@ const EntityTitle = styled(Typography.Title)`
}
`;
const PlatformContent = styled.div`
display: flex;
align-items: center;
margin-bottom: 8px;
flex-wrap: wrap;
`;
const PlatformText = styled(Typography.Text)`
font-size: 12px;
line-height: 20px;
font-weight: 700;
color: ${ANTD_GRAY[7]};
`;
const EntityCountText = styled(Typography.Text)`
font-size: 12px;
line-height: 20px;
font-weight: 400;
color: ${ANTD_GRAY[7]};
`;
const PlatformDivider = styled.div`
display: inline-block;
padding-left: 10px;
margin-right: 10px;
border-right: 1px solid ${ANTD_GRAY[4]};
height: 18px;
vertical-align: text-top;
`;
const HeaderContainer = styled.div`
display: flex;
flex-direction: row;
@ -83,23 +40,10 @@ const HeaderContainer = styled.div`
const MainHeaderContent = styled.div`
flex: 1;
`;
width: 85%;
const TypeIcon = styled.span`
margin-right: 8px;
`;
const ContainerText = styled(Typography.Text)`
font-size: 12px;
line-height: 20px;
font-weight: 400;
color: ${ANTD_GRAY[9]};
`;
const ContainerIcon = styled(FolderOpenOutlined)`
&&& {
font-size: 12px;
margin-right: 4px;
.entityCount {
margin: 5px 0 -4px 0;
}
`;
@ -171,17 +115,11 @@ export const EntityHeader = ({ showDeprecateOption }: Props) => {
const [updateDeprecation] = useUpdateDeprecationMutation();
const [showAddDeprecationDetailsModal, setShowAddDeprecationDetailsModal] = useState(false);
const refetch = useRefetch();
const entityRegistry = useEntityRegistry();
const [copiedUrn, setCopiedUrn] = useState(false);
const basePlatformName = entityData?.platform?.properties?.displayName || entityData?.platform?.name;
const basePlatformName = getPlatformName(entityData);
const platformName = capitalizeFirstLetterOnly(basePlatformName);
const platformLogoUrl = entityData?.platform?.properties?.logoUrl;
const platformInstanceId = entityData?.dataPlatformInstance?.instanceId;
const entityLogoComponent = entityRegistry.getIcon(entityType, 12, IconStyleType.ACCENT);
const entityTypeCased =
(entityData?.subTypes?.typeNames?.length && capitalizeFirstLetterOnly(entityData?.subTypes.typeNames[0])) ||
entityRegistry.getEntityName(entityType);
const externalUrl = entityData?.externalUrl || undefined;
const entityCount = entityData?.entityCount;
const hasExternalUrl = !!externalUrl;
const sendAnalytics = () => {
@ -193,10 +131,6 @@ export const EntityHeader = ({ showDeprecateOption }: Props) => {
});
};
const entityCount = entityData?.entityCount;
const typeIcon = entityRegistry.getIcon(entityType, 12, IconStyleType.ACCENT);
const container = entityData?.container;
// Update the Deprecation
const handleUpdateDeprecation = async (deprecatedStatus: boolean) => {
message.loading({ content: 'Updating...' });
@ -247,42 +181,7 @@ export const EntityHeader = ({ showDeprecateOption }: Props) => {
<>
<HeaderContainer>
<MainHeaderContent>
<PlatformContent>
{platformName && (
<LogoContainer>
{(!!platformLogoUrl && (
<PreviewImage preview={false} src={platformLogoUrl} alt={platformName} />
)) ||
entityLogoComponent}
</LogoContainer>
)}
<PlatformText>
{platformName}
{platformInstanceId && ` - ${platformInstanceId}`}
</PlatformText>
{(platformLogoUrl || platformName) && <PlatformDivider />}
{typeIcon && <TypeIcon>{typeIcon}</TypeIcon>}
<PlatformText>{entityData?.entityTypeOverride || entityTypeCased}</PlatformText>
{container && (
<Link to={entityRegistry.getEntityUrl(EntityType.Container, container?.urn)}>
<PlatformDivider />
<ContainerIcon
style={{
color: ANTD_GRAY[9],
}}
/>
<ContainerText>
{entityRegistry.getDisplayName(EntityType.Container, container)}
</ContainerText>
</Link>
)}
{entityCount && entityCount > 0 ? (
<>
<PlatformDivider />
<EntityCountText>{entityCount.toLocaleString()} entities</EntityCountText>
</>
) : null}
</PlatformContent>
<PlatformContent />
<div style={{ display: 'flex', justifyContent: 'left', alignItems: 'center' }}>
<EntityTitle level={3}>{entityData?.name || ' '}</EntityTitle>
{entityData?.deprecation?.deprecated && (
@ -324,6 +223,7 @@ export const EntityHeader = ({ showDeprecateOption }: Props) => {
/>
)}
</div>
<EntityCount entityCount={entityCount} />
</MainHeaderContent>
<SideHeaderContent>
<TopButtonsWrapper>

View File

@ -0,0 +1,51 @@
import React from 'react';
import styled from 'styled-components';
import { Link } from 'react-router-dom';
import { Typography } from 'antd';
import { FolderOpenOutlined } from '@ant-design/icons';
import { Maybe } from 'graphql/jsutils/Maybe';
import { Container, EntityType } from '../../../../../../../types.generated';
import { ANTD_GRAY } from '../../../../constants';
import { useEntityRegistry } from '../../../../../../useEntityRegistry';
const ContainerText = styled(Typography.Text)`
font-size: 12px;
line-height: 20px;
color: ${ANTD_GRAY[7]};
`;
const ContainerIcon = styled(FolderOpenOutlined)`
color: ${ANTD_GRAY[7]};
&&& {
font-size: 12px;
margin-right: 4px;
}
`;
const StyledLink = styled(Link)`
white-space: nowrap;
`;
interface Props {
container: Maybe<Container>;
}
function ContainerLink(props: Props) {
const { container } = props;
const entityRegistry = useEntityRegistry();
if (!container) return null;
const containerUrl = entityRegistry.getEntityUrl(EntityType.Container, container.urn);
const containerName = entityRegistry.getDisplayName(EntityType.Container, container);
return (
<StyledLink to={containerUrl} data-testid="container">
<ContainerIcon />
<ContainerText>{containerName}</ContainerText>
</StyledLink>
);
}
export default ContainerLink;

View File

@ -0,0 +1,59 @@
import React, { useEffect, useRef, useState } from 'react';
import { useEntityRegistry } from '../../../../../../useEntityRegistry';
import { IconStyleType } from '../../../../../Entity';
import { useEntityData } from '../../../../EntityContext';
import { capitalizeFirstLetterOnly } from '../../../../../../shared/textUtil';
import { getPlatformName } from '../../../../utils';
import PlatformContentView from './PlatformContentView';
export function useParentContainersTruncation(dataDependency: any) {
const parentContainersRef = useRef<HTMLDivElement>(null);
const [areContainersTruncated, setAreContainersTruncated] = useState(false);
useEffect(() => {
if (
parentContainersRef &&
parentContainersRef.current &&
parentContainersRef.current.scrollWidth > parentContainersRef.current.clientWidth
) {
setAreContainersTruncated(true);
}
}, [dataDependency]);
return { parentContainersRef, areContainersTruncated };
}
function PlatformContentContainer() {
const { entityType, entityData } = useEntityData();
const entityRegistry = useEntityRegistry();
const basePlatformName = getPlatformName(entityData);
const platformName = capitalizeFirstLetterOnly(basePlatformName);
const platformLogoUrl = entityData?.platform?.properties?.logoUrl;
const entityLogoComponent = entityRegistry.getIcon(entityType, 12, IconStyleType.ACCENT);
const typeIcon = entityRegistry.getIcon(entityType, 12, IconStyleType.ACCENT);
const entityTypeCased =
(entityData?.subTypes?.typeNames?.length && capitalizeFirstLetterOnly(entityData?.subTypes.typeNames[0])) ||
entityRegistry.getEntityName(entityType);
const displayedEntityType = entityData?.entityTypeOverride || entityTypeCased;
const instanceId = entityData?.dataPlatformInstance?.instanceId;
const { parentContainersRef, areContainersTruncated } = useParentContainersTruncation(entityData);
return (
<PlatformContentView
platformName={platformName}
platformLogoUrl={platformLogoUrl}
entityLogoComponent={entityLogoComponent}
instanceId={instanceId}
typeIcon={typeIcon}
entityType={displayedEntityType}
parentContainers={entityData?.parentContainers?.containers}
parentContainersRef={parentContainersRef}
areContainersTruncated={areContainersTruncated}
/>
);
}
export default PlatformContentContainer;

View File

@ -0,0 +1,159 @@
import React from 'react';
import styled from 'styled-components';
import { Typography, Image, Tooltip } from 'antd';
import { RightOutlined } from '@ant-design/icons';
import { Maybe } from 'graphql/jsutils/Maybe';
import { Container } from '../../../../../../../types.generated';
import { ANTD_GRAY } from '../../../../constants';
import ContainerLink from './ContainerLink';
const LogoContainer = styled.span`
margin-right: 8px;
`;
const PreviewImage = styled(Image)`
max-height: 17px;
width: auto;
object-fit: contain;
background-color: transparent;
`;
const PlatformContentWrapper = styled.div`
display: flex;
align-items: center;
margin: 0 8px 8px 0;
flex-wrap: nowrap;
flex: 1;
`;
const PlatformText = styled(Typography.Text)`
font-size: 12px;
line-height: 20px;
font-weight: 700;
color: ${ANTD_GRAY[7]};
white-space: nowrap;
`;
const PlatformDivider = styled.div`
display: inline-block;
padding-left: 8px;
margin-right: 8px;
border-right: 1px solid ${ANTD_GRAY[4]};
height: 18px;
vertical-align: text-top;
`;
const TypeIcon = styled.span`
margin-right: 8px;
`;
const StyledRightOutlined = styled(RightOutlined)`
color: ${ANTD_GRAY[7]};
font-size: 8px;
margin: 0 10px;
`;
const ParentContainersWrapper = styled.div`
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-direction: row-reverse;
display: flex;
`;
const Ellipsis = styled.span`
color: ${ANTD_GRAY[7]};
margin-right: 2px;
`;
const StyledTooltip = styled(Tooltip)`
display: flex;
white-space: nowrap;
overflow: hidden;
`;
export function getParentContainerNames(containers?: Maybe<Container>[] | null) {
let parentNames = '';
if (containers) {
[...containers].reverse().forEach((container, index) => {
if (container?.properties) {
if (index !== 0) {
parentNames += ' > ';
}
parentNames += container.properties.name;
}
});
}
return parentNames;
}
interface Props {
platformName?: string;
platformLogoUrl?: Maybe<string>;
entityLogoComponent?: JSX.Element;
instanceId?: string;
typeIcon?: JSX.Element;
entityType?: string;
parentContainers?: Maybe<Container>[] | null;
parentContainersRef: React.RefObject<HTMLDivElement>;
areContainersTruncated: boolean;
}
function PlatformContentView(props: Props) {
const {
platformName,
platformLogoUrl,
entityLogoComponent,
instanceId,
typeIcon,
entityType,
parentContainers,
parentContainersRef,
areContainersTruncated,
} = props;
const directParentContainer = parentContainers && parentContainers[0];
const remainingParentContainers = parentContainers && parentContainers.slice(1, parentContainers.length);
return (
<PlatformContentWrapper>
{typeIcon && <TypeIcon>{typeIcon}</TypeIcon>}
<PlatformText>{entityType}</PlatformText>
<PlatformDivider />
{platformName && (
<LogoContainer>
{(!!platformLogoUrl && <PreviewImage preview={false} src={platformLogoUrl} alt={platformName} />) ||
entityLogoComponent}
</LogoContainer>
)}
<PlatformText>
{platformName}
{(directParentContainer || instanceId) && <StyledRightOutlined data-testid="right-arrow" />}
</PlatformText>
{instanceId && (
<PlatformText>
{instanceId}
{directParentContainer && <StyledRightOutlined data-testid="right-arrow" />}
</PlatformText>
)}
<StyledTooltip
title={getParentContainerNames(parentContainers)}
overlayStyle={areContainersTruncated ? {} : { display: 'none' }}
>
{areContainersTruncated && <Ellipsis>...</Ellipsis>}
<ParentContainersWrapper ref={parentContainersRef}>
{remainingParentContainers &&
remainingParentContainers.map((container) => (
<span key={container?.urn}>
<ContainerLink container={container} />
<StyledRightOutlined data-testid="right-arrow" />
</span>
))}
</ParentContainersWrapper>
{directParentContainer && <ContainerLink container={directParentContainer} />}
</StyledTooltip>
</PlatformContentWrapper>
);
}
export default PlatformContentView;

View File

@ -0,0 +1,3 @@
import PlatformContentContainer from './PlatformContentContainer';
export default PlatformContentContainer;

View File

@ -26,6 +26,7 @@ import {
Status,
Deprecation,
DataPlatformInstance,
ParentContainersResult,
} from '../../../types.generated';
import { FetchedEntity } from '../../lineage/types';
@ -78,6 +79,7 @@ export type GenericEntityProperties = {
health?: Maybe<Health>;
status?: Maybe<Status>;
deprecation?: Maybe<Deprecation>;
parentContainers?: Maybe<ParentContainersResult>;
};
export type GenericEntityUpdate = {

View File

@ -1,3 +1,5 @@
import { GenericEntityProperties } from './types';
export function urlEncodeUrn(urn: string) {
return (
urn &&
@ -58,4 +60,8 @@ export const singularizeCollectionName = (collectionName: string): string => {
return collectionName;
};
export function getPlatformName(entityData: GenericEntityProperties | null) {
return entityData?.platform?.properties?.displayName || entityData?.platform?.name;
}
export const EDITED_DESCRIPTIONS_CACHE_NAME = 'editedDescriptions';

View File

@ -1,10 +1,17 @@
import { Image, Tooltip, Typography } from 'antd';
import React, { ReactNode } from 'react';
import { FolderOpenOutlined } from '@ant-design/icons';
import { Tooltip, Typography } from 'antd';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import { GlobalTags, Owner, GlossaryTerms, SearchInsight, Container, EntityType, Domain } from '../../types.generated';
import {
GlobalTags,
Owner,
GlossaryTerms,
SearchInsight,
Container,
Domain,
ParentContainersResult,
} from '../../types.generated';
import { useEntityRegistry } from '../useEntityRegistry';
import AvatarsGroup from '../shared/avatar/AvatarsGroup';
@ -13,10 +20,9 @@ import { ANTD_GRAY } from '../entity/shared/constants';
import NoMarkdownViewer from '../entity/shared/components/styled/StripMarkdownText';
import { getNumberWithOrdinal } from '../entity/shared/utils';
import { useEntityData } from '../entity/shared/EntityContext';
const LogoContainer = styled.div`
padding-right: 8px;
`;
import PlatformContentView from '../entity/shared/containers/profile/header/PlatformContent/PlatformContentView';
import { useParentContainersTruncation } from '../entity/shared/containers/profile/header/PlatformContent/PlatformContentContainer';
import EntityCount from '../entity/shared/containers/profile/header/EntityCount';
const PreviewContainer = styled.div`
display: flex;
@ -29,27 +35,18 @@ const PreviewWrapper = styled.div`
width: 100%;
`;
const PlatformInfo = styled.div`
margin-bottom: 8px;
display: flex;
align-items: center;
height: 24px;
`;
const TitleContainer = styled.div`
margin-bottom: 5px;
line-height: 30px;
`;
const PreviewImage = styled(Image)`
max-height: 18px;
width: auto;
object-fit: contain;
margin-right: 8px;
background-color: transparent;
.entityCount {
margin-bottom: 2px;
}
`;
const EntityTitle = styled(Typography.Text)<{ $titleSizePx?: number }>`
display: block;
&&& {
margin-right 8px;
font-size: ${(props) => props.$titleSizePx || 16}px;
@ -65,13 +62,6 @@ const PlatformText = styled(Typography.Text)`
color: ${ANTD_GRAY[7]};
`;
const EntityCountText = styled(Typography.Text)`
font-size: 12px;
line-height: 20px;
font-weight: 400;
color: ${ANTD_GRAY[7]};
`;
const PlatformDivider = styled.div`
display: inline-block;
padding-left: 10px;
@ -117,24 +107,6 @@ const InsightIconContainer = styled.span`
margin-right: 4px;
`;
const TypeIcon = styled.span`
margin-right: 8px;
`;
const ContainerText = styled(Typography.Text)`
font-size: 12px;
line-height: 20px;
font-weight: 400;
color: ${ANTD_GRAY[9]};
`;
const ContainerIcon = styled(FolderOpenOutlined)`
&&& {
font-size: 12px;
margin-right: 4px;
}
`;
interface Props {
name: string;
logoUrl?: string;
@ -160,6 +132,7 @@ interface Props {
// this is provided by the impact analysis view. it is used to display
// how the listed node is connected to the source node
degree?: number;
parentContainers?: ParentContainersResult | null;
}
export default function DefaultPreviewCard({
@ -187,6 +160,7 @@ export default function DefaultPreviewCard({
dataTestID,
onClick,
degree,
parentContainers,
}: Props) {
// sometimes these lists will be rendered inside an entity container (for example, in the case of impact analysis)
// in those cases, we may want to enrich the preview w/ context about the container entity
@ -206,59 +180,38 @@ export default function DefaultPreviewCard({
insightViews.push(snippet);
}
const { parentContainersRef, areContainersTruncated } = useParentContainersTruncation(container);
return (
<PreviewContainer data-testid={dataTestID}>
<PreviewWrapper>
<TitleContainer>
<Link to={url}>
<PlatformInfo>
{(logoUrl && <PreviewImage preview={false} src={logoUrl} alt={platform || ''} />) || (
<LogoContainer>{logoComponent}</LogoContainer>
)}
{platform && (
<PlatformText>
{platform}
{platformInstanceId && ` - ${platformInstanceId}`}
</PlatformText>
)}
{(logoUrl || logoComponent || platform) && <PlatformDivider />}
{typeIcon && <TypeIcon>{typeIcon}</TypeIcon>}
<PlatformText>{type}</PlatformText>
{container && (
<Link to={entityRegistry.getEntityUrl(EntityType.Container, container?.urn)}>
<PlatformDivider />
<ContainerIcon
style={{
color: ANTD_GRAY[9],
}}
/>
<ContainerText>
{entityRegistry.getDisplayName(EntityType.Container, container)}
</ContainerText>
</Link>
)}
{entityCount && entityCount > 0 ? (
<>
<PlatformDivider />
<EntityCountText>{entityCount.toLocaleString()} entities</EntityCountText>
</>
) : null}
{degree !== undefined && degree !== null && (
<span>
<PlatformDivider />
<Tooltip
title={`This entity is a ${getNumberWithOrdinal(degree)} degree connection to ${
entityData?.name || 'the source entity'
}`}
>
<PlatformText>{getNumberWithOrdinal(degree)}</PlatformText>
</Tooltip>
</span>
)}
</PlatformInfo>
<PlatformContentView
platformName={platform}
platformLogoUrl={logoUrl}
entityLogoComponent={logoComponent}
instanceId={platformInstanceId}
typeIcon={typeIcon}
entityType={type}
parentContainers={parentContainers?.containers}
parentContainersRef={parentContainersRef}
areContainersTruncated={areContainersTruncated}
/>
<EntityTitle onClick={onClick} $titleSizePx={titleSizePx}>
{name || ' '}
</EntityTitle>
{degree !== undefined && degree !== null && (
<Tooltip
title={`This entity is a ${getNumberWithOrdinal(degree)} degree connection to ${
entityData?.name || 'the source entity'
}`}
>
<PlatformText>{getNumberWithOrdinal(degree)}</PlatformText>
</Tooltip>
)}
{!!degree && entityCount && <PlatformDivider />}
<EntityCount entityCount={entityCount} />
</Link>
</TitleContainer>
{description && description.length > 0 && (

View File

@ -14,6 +14,7 @@ import { CustomAvatar } from '../shared/avatar';
import { StyledTag } from '../entity/shared/components/styled/StyledTag';
import { useListRecommendationsQuery } from '../../graphql/recommendations.generated';
import { useGetAuthenticatedUserUrn } from '../useGetAuthenticatedUser';
import { getPlatformName } from '../entity/shared/utils';
const SuggestionContainer = styled.div`
display: flex;
@ -109,7 +110,7 @@ const renderEntitySuggestion = (query: string, entity: Entity, registry: EntityR
return renderTagSuggestion(entity as Tag, registry);
}
const genericEntityProps = registry.getGenericEntityProperties(entity.type, entity);
const platformName = genericEntityProps?.platform?.properties?.displayName || genericEntityProps?.platform?.name;
const platformName = getPlatformName(genericEntityProps);
const platformLogoUrl = genericEntityProps?.platform?.properties?.logoUrl;
const displayName =
genericEntityProps?.properties?.qualifiedName ||

View File

@ -59,6 +59,9 @@ query getChart($urn: String!) {
container {
...entityContainer
}
parentContainers {
...parentContainersFields
}
upstream: lineage(input: { direction: UPSTREAM, start: 0, count: 100 }) {
...fullLineageResults
}

View File

@ -36,6 +36,9 @@ query getContainer($urn: String!) {
container {
...entityContainer
}
parentContainers {
...parentContainersFields
}
domain {
...entityDomain
}

View File

@ -64,6 +64,9 @@ query getDataset($urn: String!) {
container {
...entityContainer
}
parentContainers {
...parentContainersFields
}
usageStats(range: MONTH) {
buckets {
bucket

View File

@ -30,6 +30,14 @@ fragment deprecationFields on Deprecation {
decommissionTime
}
fragment parentContainersFields on ParentContainersResult {
count
containers {
...entityContainer
}
}
fragment ownershipFields on Ownership {
owners {
owner {
@ -352,6 +360,9 @@ fragment dashboardFields on Dashboard {
container {
...entityContainer
}
parentContainers {
...parentContainersFields
}
status {
removed
}

View File

@ -65,6 +65,9 @@ fragment searchResultFields on Entity {
container {
...entityContainer
}
parentContainers {
...parentContainersFields
}
deprecation {
...deprecationFields
}
@ -146,6 +149,9 @@ fragment searchResultFields on Entity {
deprecation {
...deprecationFields
}
parentContainers {
...parentContainersFields
}
}
... on Chart {
urn
@ -189,6 +195,9 @@ fragment searchResultFields on Entity {
deprecation {
...deprecationFields
}
parentContainers {
...parentContainersFields
}
}
... on DataFlow {
urn
@ -327,6 +336,9 @@ fragment searchResultFields on Entity {
deprecation {
...deprecationFields
}
parentContainers {
...parentContainersFields
}
}
... on MLFeatureTable {
urn