stitch relations after deleting

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2022-09-23 14:00:00 +02:00
parent 93fcda09ce
commit 74022e0163
4 changed files with 90 additions and 20 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': patch
---
Make sure to stitch entities correctly after deletion, to ensure that their relations are updated.
@@ -409,7 +409,11 @@ export class CatalogBuilder {
parser,
policy,
});
const unauthorizedEntitiesCatalog = new DefaultEntitiesCatalog(dbClient);
const stitcher = new Stitcher(dbClient, logger);
const unauthorizedEntitiesCatalog = new DefaultEntitiesCatalog(
dbClient,
stitcher,
);
let permissionEvaluator: PermissionEvaluator;
if ('authorizeConditional' in permissions) {
@@ -453,7 +457,6 @@ export class CatalogBuilder {
permissions: catalogPermissions,
rules: this.permissionRules,
});
const stitcher = new Stitcher(dbClient, logger);
const locationStore = new DefaultLocationStore(dbClient);
const configLocationProvider = new ConfigLocationEntityProvider(config);
@@ -25,12 +25,15 @@ import {
DbRefreshStateRow,
DbSearchRow,
} from '../database/tables';
import { Stitcher } from '../stitching/Stitcher';
import { DefaultEntitiesCatalog } from './DefaultEntitiesCatalog';
describe('DefaultEntitiesCatalog', () => {
const databases = TestDatabases.create({
ids: ['POSTGRES_13', 'POSTGRES_9', 'SQLITE_3'],
});
const stitch = jest.fn();
const stitcher: Stitcher = { stitch } as any;
async function createDatabase(databaseId: TestDatabaseId) {
const knex = await databases.init(databaseId);
@@ -122,6 +125,10 @@ describe('DefaultEntitiesCatalog', () => {
);
}
afterEach(() => {
jest.resetAllMocks();
});
describe('entityAncestry', () => {
it.each(databases.eachSupportedId())(
'should return the ancestry with one parent, %p',
@@ -151,7 +158,7 @@ describe('DefaultEntitiesCatalog', () => {
await addEntity(knex, parent, [{ entity: grandparent }]);
await addEntity(knex, root, [{ entity: parent }]);
const catalog = new DefaultEntitiesCatalog(knex);
const catalog = new DefaultEntitiesCatalog(knex, stitcher);
const result = await catalog.entityAncestry('k:default/root');
expect(result.rootEntityRef).toEqual('k:default/root');
@@ -181,7 +188,7 @@ describe('DefaultEntitiesCatalog', () => {
'should throw error if the entity does not exist, %p',
async databaseId => {
const { knex } = await createDatabase(databaseId);
const catalog = new DefaultEntitiesCatalog(knex);
const catalog = new DefaultEntitiesCatalog(knex, stitcher);
await expect(() =>
catalog.entityAncestry('k:default/root'),
).rejects.toThrow('No such entity k:default/root');
@@ -224,7 +231,7 @@ describe('DefaultEntitiesCatalog', () => {
await addEntity(knex, parent2, [{ entity: grandparent }]);
await addEntity(knex, root, [{ entity: parent1 }, { entity: parent2 }]);
const catalog = new DefaultEntitiesCatalog(knex);
const catalog = new DefaultEntitiesCatalog(knex, stitcher);
const result = await catalog.entityAncestry('k:default/root');
expect(result.rootEntityRef).toEqual('k:default/root');
@@ -280,7 +287,7 @@ describe('DefaultEntitiesCatalog', () => {
};
await addEntityToSearch(knex, entity1);
await addEntityToSearch(knex, entity2);
const catalog = new DefaultEntitiesCatalog(knex);
const catalog = new DefaultEntitiesCatalog(knex, stitcher);
const testFilter = {
key: 'spec.test',
@@ -313,7 +320,7 @@ describe('DefaultEntitiesCatalog', () => {
};
await addEntityToSearch(knex, entity1);
await addEntityToSearch(knex, entity2);
const catalog = new DefaultEntitiesCatalog(knex);
const catalog = new DefaultEntitiesCatalog(knex, stitcher);
const testFilter = {
not: {
@@ -360,7 +367,7 @@ describe('DefaultEntitiesCatalog', () => {
await addEntityToSearch(knex, entity2);
await addEntityToSearch(knex, entity3);
await addEntityToSearch(knex, entity4);
const catalog = new DefaultEntitiesCatalog(knex);
const catalog = new DefaultEntitiesCatalog(knex, stitcher);
const testFilter1 = {
key: 'metadata.org',
@@ -415,7 +422,7 @@ describe('DefaultEntitiesCatalog', () => {
};
await addEntityToSearch(knex, entity1);
await addEntityToSearch(knex, entity2);
const catalog = new DefaultEntitiesCatalog(knex);
const catalog = new DefaultEntitiesCatalog(knex, stitcher);
const testFilter1 = {
key: 'metadata.org',
@@ -457,7 +464,7 @@ describe('DefaultEntitiesCatalog', () => {
};
await addEntityToSearch(knex, entity1);
await addEntityToSearch(knex, entity2);
const catalog = new DefaultEntitiesCatalog(knex);
const catalog = new DefaultEntitiesCatalog(knex, stitcher);
const testFilter = {
key: 'kind',
@@ -501,7 +508,7 @@ describe('DefaultEntitiesCatalog', () => {
},
[],
);
const catalog = new DefaultEntitiesCatalog(knex);
const catalog = new DefaultEntitiesCatalog(knex, stitcher);
const { entities } = await catalog.entities();
@@ -557,10 +564,16 @@ describe('DefaultEntitiesCatalog', () => {
metadata: { name: 'root' },
spec: {},
};
const unrelated: Entity = {
const unrelated1: Entity = {
apiVersion: 'a',
kind: 'k',
metadata: { name: 'unrelated' },
metadata: { name: 'unrelated1' },
spec: {},
};
const unrelated2: Entity = {
apiVersion: 'a',
kind: 'k',
metadata: { name: 'unrelated2' },
spec: {},
};
@@ -571,10 +584,23 @@ describe('DefaultEntitiesCatalog', () => {
{ entity: parent1 },
{ entity: parent2 },
]);
await addEntity(knex, unrelated, []);
await addEntity(knex, unrelated1, []);
await addEntity(knex, unrelated2, []);
await knex('refresh_state').update({ result_hash: 'not-changed' });
await knex('relations').insert({
originating_entity_id: uid,
type: 't',
source_entity_ref: 'k:default/root',
target_entity_ref: 'k:default/unrelated1',
});
await knex('relations').insert({
originating_entity_id: uid,
type: 't',
source_entity_ref: 'k:default/unrelated2',
target_entity_ref: 'k:default/root',
});
const catalog = new DefaultEntitiesCatalog(knex);
const catalog = new DefaultEntitiesCatalog(knex, stitcher);
await catalog.removeEntityByUid(uid);
await expect(
@@ -586,8 +612,12 @@ describe('DefaultEntitiesCatalog', () => {
{ entity_ref: 'k:default/grandparent', result_hash: 'not-changed' },
{ entity_ref: 'k:default/parent1', result_hash: 'child-was-deleted' },
{ entity_ref: 'k:default/parent2', result_hash: 'child-was-deleted' },
{ entity_ref: 'k:default/unrelated', result_hash: 'not-changed' },
{ entity_ref: 'k:default/unrelated1', result_hash: 'not-changed' },
{ entity_ref: 'k:default/unrelated2', result_hash: 'not-changed' },
]);
expect(stitch).toHaveBeenCalledWith(
new Set(['k:default/unrelated1', 'k:default/unrelated2']),
);
},
);
});
@@ -616,7 +646,7 @@ describe('DefaultEntitiesCatalog', () => {
metadata: { name: 'two' },
spec: {},
});
const catalog = new DefaultEntitiesCatalog(knex);
const catalog = new DefaultEntitiesCatalog(knex, stitcher);
await expect(catalog.facets({ facets: ['kind'] })).resolves.toEqual({
facets: {
@@ -654,7 +684,7 @@ describe('DefaultEntitiesCatalog', () => {
},
spec: {},
});
const catalog = new DefaultEntitiesCatalog(knex);
const catalog = new DefaultEntitiesCatalog(knex, stitcher);
await expect(
catalog.facets({
@@ -698,7 +728,7 @@ describe('DefaultEntitiesCatalog', () => {
},
spec: {},
});
const catalog = new DefaultEntitiesCatalog(knex);
const catalog = new DefaultEntitiesCatalog(knex, stitcher);
await expect(
catalog.facets({
@@ -38,8 +38,10 @@ import {
DbPageInfo,
DbRefreshStateReferencesRow,
DbRefreshStateRow,
DbRelationsRow,
DbSearchRow,
} from '../database/tables';
import { Stitcher } from '../stitching/Stitcher';
function parsePagination(input?: EntityPagination): {
limit?: number;
@@ -158,7 +160,10 @@ function parseFilter(
}
export class DefaultEntitiesCatalog implements EntitiesCatalog {
constructor(private readonly database: Knex) {}
constructor(
private readonly database: Knex,
private readonly stitcher: Stitcher,
) {}
async entities(request?: EntitiesRequest): Promise<EntitiesResponse> {
const db = this.database;
@@ -244,6 +249,7 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
await this.database<DbRefreshStateRow>('refresh_state')
.update({
result_hash: 'child-was-deleted',
next_update_at: this.database.fn.now(),
})
.whereIn('entity_ref', function parents(builder) {
return builder
@@ -256,9 +262,35 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
.select('refresh_state_references.source_entity_ref');
});
// Stitch the entities that the deleted one had relations to. If we do not
// do this, the entities in the other end of the relations will still look
// like they have a relation to the entity that was deleted, despite not
// having any corresponding rows in the relations table.
const relationPeers = await this.database
.from<DbRelationsRow>('relations')
.innerJoin<DbRefreshStateReferencesRow>('refresh_state', {
'refresh_state.entity_ref': 'relations.target_entity_ref',
})
.where('relations.originating_entity_id', '=', uid)
.andWhere('refresh_state.entity_id', '!=', uid)
.select({ ref: 'relations.target_entity_ref' })
.union(other =>
other
.from<DbRelationsRow>('relations')
.innerJoin<DbRefreshStateReferencesRow>('refresh_state', {
'refresh_state.entity_ref': 'relations.source_entity_ref',
})
.where('relations.originating_entity_id', '=', uid)
.andWhere('refresh_state.entity_id', '!=', uid)
.select({ ref: 'relations.source_entity_ref' }),
);
// Perform the actual deletion
await this.database<DbRefreshStateRow>('refresh_state')
.where('entity_id', uid)
.delete();
await this.stitcher.stitch(new Set(relationPeers.map(p => p.ref)));
}
async entityAncestry(rootRef: string): Promise<EntityAncestryResponse> {