Merge pull request #18070 from backstage/freben/batch-relations

Make `useRelatedEntities` use `getEntitiesByRefs` under the hood
This commit is contained in:
Fredrik Adelöw
2023-06-05 10:32:02 +02:00
committed by GitHub
16 changed files with 155 additions and 114 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-react': patch
---
Make `useRelatedEntities` use `getEntitiesByRefs` under the hood
@@ -32,12 +32,7 @@ describe('<ConsumedApisCard />', () => {
getApiDefinitionWidget: jest.fn(),
} as any;
const catalogApi: jest.Mocked<CatalogApi> = {
getLocationById: jest.fn(),
getEntityByName: jest.fn(),
getEntities: jest.fn(),
addLocation: jest.fn(),
getLocationByRef: jest.fn(),
removeEntityByUid: jest.fn(),
getEntitiesByRefs: jest.fn(),
} as any;
let Wrapper: React.ComponentType<React.PropsWithChildren<{}>>;
@@ -99,7 +94,7 @@ describe('<ConsumedApisCard />', () => {
},
],
};
catalogApi.getEntities.mockResolvedValue({
catalogApi.getEntitiesByRefs.mockResolvedValue({
items: [
{
apiVersion: 'v1',
@@ -32,12 +32,7 @@ describe('<HasApisCard />', () => {
getApiDefinitionWidget: jest.fn(),
} as any;
const catalogApi: jest.Mocked<CatalogApi> = {
getLocationById: jest.fn(),
getEntityByName: jest.fn(),
getEntities: jest.fn(),
addLocation: jest.fn(),
getLocationByRef: jest.fn(),
removeEntityByUid: jest.fn(),
getEntitiesByRefs: jest.fn(),
} as any;
let Wrapper: React.ComponentType<React.PropsWithChildren<{}>>;
@@ -99,7 +94,7 @@ describe('<HasApisCard />', () => {
},
],
};
catalogApi.getEntities.mockResolvedValue({
catalogApi.getEntitiesByRefs.mockResolvedValue({
items: [
{
apiVersion: 'v1',
@@ -32,12 +32,7 @@ describe('<ProvidedApisCard />', () => {
getApiDefinitionWidget: jest.fn(),
} as any;
const catalogApi: jest.Mocked<CatalogApi> = {
getLocationById: jest.fn(),
getEntityByName: jest.fn(),
getEntities: jest.fn(),
addLocation: jest.fn(),
getLocationByRef: jest.fn(),
removeEntityByUid: jest.fn(),
getEntitiesByRefs: jest.fn(),
} as any;
let Wrapper: React.ComponentType<React.PropsWithChildren<{}>>;
@@ -99,7 +94,7 @@ describe('<ProvidedApisCard />', () => {
},
],
};
catalogApi.getEntities.mockResolvedValue({
catalogApi.getEntitiesByRefs.mockResolvedValue({
items: [
{
apiVersion: 'v1',
@@ -28,12 +28,7 @@ import { ConsumingComponentsCard } from './ConsumingComponentsCard';
describe('<ConsumingComponentsCard />', () => {
const catalogApi: jest.Mocked<CatalogApi> = {
getLocationById: jest.fn(),
getEntityByName: jest.fn(),
getEntities: jest.fn(),
addLocation: jest.fn(),
getLocationByRef: jest.fn(),
removeEntityByUid: jest.fn(),
getEntitiesByRefs: jest.fn(),
} as any;
let Wrapper: React.ComponentType<React.PropsWithChildren<{}>>;
@@ -102,7 +97,7 @@ describe('<ConsumingComponentsCard />', () => {
},
],
};
catalogApi.getEntities.mockResolvedValue({
catalogApi.getEntitiesByRefs.mockResolvedValue({
items: [
{
apiVersion: 'v1',
@@ -28,12 +28,7 @@ import { ProvidingComponentsCard } from './ProvidingComponentsCard';
describe('<ProvidingComponentsCard />', () => {
const catalogApi: jest.Mocked<CatalogApi> = {
getLocationById: jest.fn(),
getEntityByName: jest.fn(),
getEntities: jest.fn(),
addLocation: jest.fn(),
getLocationByRef: jest.fn(),
removeEntityByUid: jest.fn(),
getEntitiesByRefs: jest.fn(),
} as any;
let Wrapper: React.ComponentType<React.PropsWithChildren<{}>>;
@@ -102,7 +97,7 @@ describe('<ProvidingComponentsCard />', () => {
},
],
};
catalogApi.getEntities.mockResolvedValue({
catalogApi.getEntitiesByRefs.mockResolvedValue({
items: [
{
apiVersion: 'v1',
+1 -1
View File
@@ -555,7 +555,7 @@ export function useEntityTypeFilter(): {
setSelectedTypes: (types: string[]) => void;
};
// @public (undocumented)
// @public
export function useRelatedEntities(
entity: Entity,
relationFilter: {
@@ -0,0 +1,84 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { TestApiProvider } from '@backstage/test-utils';
import { WrapperComponent, renderHook } from '@testing-library/react-hooks';
import React from 'react';
import { catalogApiRef } from '../api';
import { useRelatedEntities } from './useRelatedEntities';
describe('useRelatedEntities', () => {
afterEach(() => {
jest.resetAllMocks();
});
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'test' },
relations: [
{
type: 'ownedBy',
targetRef: 'group:default/the-owners-1',
},
{
type: 'ownedBy',
targetRef: 'group:default/the-owners-2',
},
{
type: 'partOf',
targetRef: 'component:default/larger-thing',
},
],
};
const catalogApi = {
getEntitiesByRefs: jest.fn(),
};
const wrapper: WrapperComponent<{}> = ({ children }) => {
return (
<TestApiProvider apis={[[catalogApiRef, catalogApi]]}>
{children}
</TestApiProvider>
);
};
it('filters and requests entities', async () => {
catalogApi.getEntitiesByRefs.mockResolvedValueOnce({
items: [entity, null], // one of them doesn't exist
});
const rendered = renderHook(
() => useRelatedEntities(entity, { type: 'ownedby', kind: 'grOUP' }),
{ wrapper },
);
expect(rendered.result.current).toEqual({ loading: true });
await rendered.waitForValueToChange(() => rendered.result.current.loading);
expect(catalogApi.getEntitiesByRefs).toHaveBeenCalledWith({
entityRefs: ['group:default/the-owners-1', 'group:default/the-owners-2'],
});
expect(rendered.result.current).toEqual({
loading: false,
entities: [entity], // filtered out the null
});
});
});
@@ -13,15 +13,18 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity, parseEntityRef } from '@backstage/catalog-model';
import { useApi } from '@backstage/core-plugin-api';
import { chunk, groupBy } from 'lodash';
import useAsync from 'react-use/lib/useAsync';
import { catalogApiRef } from '../api';
const BATCH_SIZE = 20;
/** @public */
/**
* Fetches all entities that appear in the entity's relations, optionally
* filtered by relation type and kind.
*
* @public
*/
export function useRelatedEntities(
entity: Entity,
relationFilter: { type?: string; kind?: string },
@@ -32,70 +35,30 @@ export function useRelatedEntities(
} {
const filterByTypeLower = relationFilter?.type?.toLocaleLowerCase('en-US');
const filterByKindLower = relationFilter?.kind?.toLocaleLowerCase('en-US');
const catalogApi = useApi(catalogApiRef);
const {
loading,
value: entities,
error,
} = useAsync(async () => {
const relations = entity.relations
?.map(r => ({ type: r.type, target: parseEntityRef(r.targetRef) }))
.filter(
r =>
(!filterByTypeLower ||
r.type.toLocaleLowerCase('en-US') === filterByTypeLower) &&
(!filterByKindLower || r.target.kind === filterByKindLower),
);
const relations = entity.relations?.filter(
r =>
(!filterByTypeLower ||
r.type.toLocaleLowerCase('en-US') === filterByTypeLower) &&
(!filterByKindLower ||
parseEntityRef(r.targetRef).kind === filterByKindLower),
);
if (!relations) {
if (!relations?.length) {
return [];
}
// Group the relations by kind and namespace to reduce the size of the request query string.
// Without this grouping, the kind and namespace would need to be specified for each relation, e.g.
// `filter=kind=component,namespace=default,name=example1&filter=kind=component,namespace=default,name=example2`
// with grouping, we can generate a query a string like
// `filter=kind=component,namespace=default,name=example1,example2`
const relationsByKindAndNamespace = Object.values(
groupBy(relations, ({ target }) => {
return `${target.kind}:${target.namespace}`.toLocaleLowerCase('en-US');
}),
);
const { items } = await catalogApi.getEntitiesByRefs({
entityRefs: relations.map(r => r.targetRef),
});
// Split the names within each group into batches to further reduce the query string length.
const batchedRelationsByKindAndNamespace: {
kind: string;
namespace: string;
nameBatches: string[][];
}[] = [];
for (const rs of relationsByKindAndNamespace) {
batchedRelationsByKindAndNamespace.push({
// All relations in a group have the same kind and namespace, so its arbitrary which we pick
kind: rs[0].target.kind,
namespace: rs[0].target.namespace,
nameBatches: chunk(
rs.map(r => r.target.name),
BATCH_SIZE,
),
});
}
const results = await Promise.all(
batchedRelationsByKindAndNamespace.flatMap(rs => {
return rs.nameBatches.map(names => {
return catalogApi.getEntities({
filter: {
kind: rs.kind,
'metadata.namespace': rs.namespace,
'metadata.name': names,
},
});
});
}),
);
return results.flatMap(r => r.items);
return items.filter((x): x is Entity => Boolean(x));
}, [entity, filterByTypeLower, filterByKindLower]);
return {
@@ -27,12 +27,14 @@ import React from 'react';
import { DependencyOfComponentsCard } from './DependencyOfComponentsCard';
describe('<DependencyOfComponentsCard />', () => {
const getEntities: jest.MockedFunction<CatalogApi['getEntities']> = jest.fn();
const getEntitiesByRefs: jest.MockedFunction<
CatalogApi['getEntitiesByRefs']
> = jest.fn();
let Wrapper: React.ComponentType<React.PropsWithChildren<{}>>;
beforeEach(() => {
Wrapper = ({ children }: { children?: React.ReactNode }) => (
<TestApiProvider apis={[[catalogApiRef, { getEntities }]]}>
<TestApiProvider apis={[[catalogApiRef, { getEntitiesByRefs }]]}>
{children}
</TestApiProvider>
);
@@ -85,7 +87,7 @@ describe('<DependencyOfComponentsCard />', () => {
},
],
};
getEntities.mockResolvedValue({
getEntitiesByRefs.mockResolvedValue({
items: [
{
apiVersion: 'v1',
@@ -27,12 +27,14 @@ import React from 'react';
import { DependsOnComponentsCard } from './DependsOnComponentsCard';
describe('<DependsOnComponentsCard />', () => {
const getEntities: jest.MockedFunction<CatalogApi['getEntities']> = jest.fn();
const getEntitiesByRefs: jest.MockedFunction<
CatalogApi['getEntitiesByRefs']
> = jest.fn();
let Wrapper: React.ComponentType<React.PropsWithChildren<{}>>;
beforeEach(() => {
Wrapper = ({ children }: { children?: React.ReactNode }) => (
<TestApiProvider apis={[[catalogApiRef, { getEntities }]]}>
<TestApiProvider apis={[[catalogApiRef, { getEntitiesByRefs }]]}>
{children}
</TestApiProvider>
);
@@ -85,7 +87,7 @@ describe('<DependsOnComponentsCard />', () => {
},
],
};
getEntities.mockResolvedValue({
getEntitiesByRefs.mockResolvedValue({
items: [
{
apiVersion: 'v1',
@@ -27,12 +27,14 @@ import React from 'react';
import { DependsOnResourcesCard } from './DependsOnResourcesCard';
describe('<DependsOnResourcesCard />', () => {
const getEntities: jest.MockedFunction<CatalogApi['getEntities']> = jest.fn();
const getEntitiesByRefs: jest.MockedFunction<
CatalogApi['getEntitiesByRefs']
> = jest.fn();
let Wrapper: React.ComponentType<React.PropsWithChildren<{}>>;
beforeEach(() => {
Wrapper = ({ children }: { children?: React.ReactNode }) => (
<TestApiProvider apis={[[catalogApiRef, { getEntities }]]}>
<TestApiProvider apis={[[catalogApiRef, { getEntitiesByRefs }]]}>
{children}
</TestApiProvider>
);
@@ -85,7 +87,7 @@ describe('<DependsOnResourcesCard />', () => {
},
],
};
getEntities.mockResolvedValue({
getEntitiesByRefs.mockResolvedValue({
items: [
{
apiVersion: 'v1',
@@ -27,12 +27,14 @@ import React from 'react';
import { HasComponentsCard } from './HasComponentsCard';
describe('<HasComponentsCard />', () => {
const getEntities: jest.MockedFunction<CatalogApi['getEntities']> = jest.fn();
const getEntitiesByRefs: jest.MockedFunction<
CatalogApi['getEntitiesByRefs']
> = jest.fn();
let Wrapper: React.ComponentType<React.PropsWithChildren<{}>>;
beforeEach(() => {
Wrapper = ({ children }: { children?: React.ReactNode }) => (
<TestApiProvider apis={[[catalogApiRef, { getEntities }]]}>
<TestApiProvider apis={[[catalogApiRef, { getEntitiesByRefs }]]}>
{children}
</TestApiProvider>
);
@@ -85,7 +87,7 @@ describe('<HasComponentsCard />', () => {
},
],
};
getEntities.mockResolvedValue({
getEntitiesByRefs.mockResolvedValue({
items: [
{
apiVersion: 'v1',
@@ -27,12 +27,14 @@ import React from 'react';
import { HasResourcesCard } from './HasResourcesCard';
describe('<HasResourcesCard />', () => {
const getEntities: jest.MockedFunction<CatalogApi['getEntities']> = jest.fn();
const getEntitiesByRefs: jest.MockedFunction<
CatalogApi['getEntitiesByRefs']
> = jest.fn();
let Wrapper: React.ComponentType<React.PropsWithChildren<{}>>;
beforeEach(() => {
Wrapper = ({ children }: { children?: React.ReactNode }) => (
<TestApiProvider apis={[[catalogApiRef, { getEntities }]]}>
<TestApiProvider apis={[[catalogApiRef, { getEntitiesByRefs }]]}>
{children}
</TestApiProvider>
);
@@ -80,7 +82,7 @@ describe('<HasResourcesCard />', () => {
},
],
};
getEntities.mockResolvedValue({
getEntitiesByRefs.mockResolvedValue({
items: [
{
apiVersion: 'v1',
@@ -27,12 +27,14 @@ import React from 'react';
import { HasSubcomponentsCard } from './HasSubcomponentsCard';
describe('<HasSubcomponentsCard />', () => {
const getEntities: jest.MockedFunction<CatalogApi['getEntities']> = jest.fn();
const getEntitiesByRefs: jest.MockedFunction<
CatalogApi['getEntitiesByRefs']
> = jest.fn();
let Wrapper: React.ComponentType<React.PropsWithChildren<{}>>;
beforeEach(() => {
Wrapper = ({ children }: { children?: React.ReactNode }) => (
<TestApiProvider apis={[[catalogApiRef, { getEntities }]]}>
<TestApiProvider apis={[[catalogApiRef, { getEntitiesByRefs }]]}>
{children}
</TestApiProvider>
);
@@ -85,7 +87,7 @@ describe('<HasSubcomponentsCard />', () => {
},
],
};
getEntities.mockResolvedValue({
getEntitiesByRefs.mockResolvedValue({
items: [
{
apiVersion: 'v1',
@@ -27,12 +27,14 @@ import React from 'react';
import { HasSystemsCard } from './HasSystemsCard';
describe('<HasSystemsCard />', () => {
const getEntities: jest.MockedFunction<CatalogApi['getEntities']> = jest.fn();
const getEntitiesByRefs: jest.MockedFunction<
CatalogApi['getEntitiesByRefs']
> = jest.fn();
let Wrapper: React.ComponentType<React.PropsWithChildren<{}>>;
beforeEach(() => {
Wrapper = ({ children }: { children?: React.ReactNode }) => (
<TestApiProvider apis={[[catalogApiRef, { getEntities }]]}>
<TestApiProvider apis={[[catalogApiRef, { getEntitiesByRefs }]]}>
{children}
</TestApiProvider>
);
@@ -85,7 +87,7 @@ describe('<HasSystemsCard />', () => {
},
],
};
getEntities.mockResolvedValue({
getEntitiesByRefs.mockResolvedValue({
items: [
{
apiVersion: 'v1',