feat(catalog): entirely case insensitive read path of entities

This commit is contained in:
Fredrik Adelöw
2020-09-23 14:01:09 +02:00
parent 1ef015d68c
commit ab4de80231
24 changed files with 351 additions and 153 deletions
+1 -6
View File
@@ -87,7 +87,7 @@ export type EntityMeta = JsonObject & {
/**
* The name of the entity.
*
* Must be uniqe within the catalog at any given point in time, for any
* Must be unique within the catalog at any given point in time, for any
* given namespace + kind pair.
*/
name: string;
@@ -120,8 +120,3 @@ export type EntityMeta = JsonObject & {
*/
tags?: string[];
};
/**
* The keys of EntityMeta that are auto-generated.
*/
export const entityMetaGeneratedFields = ['uid', 'etag', 'generation'] as const;
@@ -0,0 +1,29 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
/**
* The namespace that entities without an explicit namespace fall into.
*/
export const ENTITY_DEFAULT_NAMESPACE = 'default';
/**
* The keys of EntityMeta that are auto-generated.
*/
export const ENTITY_META_GENERATED_FIELDS = [
'uid',
'etag',
'generation',
] as const;
+10 -2
View File
@@ -14,10 +14,18 @@
* limitations under the License.
*/
export { entityMetaGeneratedFields } from './Entity';
export {
ENTITY_DEFAULT_NAMESPACE,
ENTITY_META_GENERATED_FIELDS,
} from './constants';
export type { Entity, EntityMeta } from './Entity';
export * from './policies';
export { parseEntityName, serializeEntityRef } from './ref';
export {
getEntityName,
parseEntityName,
parseEntityRef,
serializeEntityRef,
} from './ref';
export {
entityHasChanges,
generateEntityEtag,
@@ -15,6 +15,7 @@
*/
import yaml from 'yaml';
import { ENTITY_DEFAULT_NAMESPACE } from '../constants';
import { DefaultNamespaceEntityPolicy } from './DefaultNamespaceEntityPolicy';
describe('DefaultNamespaceEntityPolicy', () => {
@@ -54,7 +55,10 @@ describe('DefaultNamespaceEntityPolicy', () => {
await expect(result).resolves.not.toBe(withoutNamespace);
await expect(result).resolves.toEqual(
expect.objectContaining({
metadata: { name: 'my-component-yay', namespace: 'default' },
metadata: {
name: 'my-component-yay',
namespace: ENTITY_DEFAULT_NAMESPACE,
},
}),
);
});
@@ -16,6 +16,7 @@
import lodash from 'lodash';
import { EntityPolicy } from '../../types';
import { ENTITY_DEFAULT_NAMESPACE } from '../constants';
import { Entity } from '../Entity';
/**
@@ -24,7 +25,7 @@ import { Entity } from '../Entity';
export class DefaultNamespaceEntityPolicy implements EntityPolicy {
private readonly namespace: string;
constructor(namespace: string = 'default') {
constructor(namespace: string = ENTITY_DEFAULT_NAMESPACE) {
this.namespace = namespace;
}
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { ENTITY_DEFAULT_NAMESPACE } from './constants';
import { parseEntityName, parseEntityRef, serializeEntityRef } from './ref';
describe('ref', () => {
@@ -27,7 +28,7 @@ describe('ref', () => {
expect(() => parseEntityName('b/c')).toThrow(/kind/);
expect(parseEntityName('a:c')).toEqual({
kind: 'a',
namespace: 'default',
namespace: ENTITY_DEFAULT_NAMESPACE,
name: 'c',
});
expect(() => parseEntityName('c')).toThrow(/kind/);
@@ -116,7 +117,7 @@ describe('ref', () => {
).toEqual({ kind: 'a', namespace: 'y', name: 'c' });
expect(parseEntityName('a:c', { defaultKind: 'x' })).toEqual({
kind: 'a',
namespace: 'default',
namespace: ENTITY_DEFAULT_NAMESPACE,
name: 'c',
});
expect(
@@ -124,7 +125,7 @@ describe('ref', () => {
).toEqual({ kind: 'x', namespace: 'y', name: 'c' });
expect(parseEntityName('c', { defaultKind: 'x' })).toEqual({
kind: 'x',
namespace: 'default',
namespace: ENTITY_DEFAULT_NAMESPACE,
name: 'c',
});
});
@@ -150,7 +151,7 @@ describe('ref', () => {
).toEqual({ kind: 'a', namespace: 'y', name: 'c' });
expect(
parseEntityName({ kind: 'a', name: 'c' }, { defaultKind: 'x' }),
).toEqual({ kind: 'a', namespace: 'default', name: 'c' });
).toEqual({ kind: 'a', namespace: ENTITY_DEFAULT_NAMESPACE, name: 'c' });
expect(
parseEntityName(
{ name: 'c' },
@@ -159,7 +160,7 @@ describe('ref', () => {
).toEqual({ kind: 'x', namespace: 'y', name: 'c' });
expect(parseEntityName({ name: 'c' }, { defaultKind: 'x' })).toEqual({
kind: 'x',
namespace: 'default',
namespace: ENTITY_DEFAULT_NAMESPACE,
name: 'c',
});
// empty strings are errors, not defaults
+18 -1
View File
@@ -15,6 +15,23 @@
*/
import { EntityName, EntityRef } from '../types';
import { ENTITY_DEFAULT_NAMESPACE } from './constants';
import { Entity } from './Entity';
/**
* Extracts the kind, namespace and name that form the name triplet of the
* given entity.
*
* @param entity An entity
* @returns The complete entity name
*/
export function getEntityName(entity: Entity): EntityName {
return {
kind: entity.kind,
namespace: entity.metadata.namespace || ENTITY_DEFAULT_NAMESPACE,
name: entity.metadata.name,
};
}
/**
* The context of defaults that entity reference parsing happens within.
@@ -43,7 +60,7 @@ export function parseEntityName(
context: EntityRefContext = {},
): EntityName {
const { kind, namespace, name } = parseEntityRef(ref, {
defaultNamespace: 'default',
defaultNamespace: ENTITY_DEFAULT_NAMESPACE,
...context,
});
@@ -13,4 +13,5 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const LOCATION_ANNOTATION = 'backstage.io/managed-by-location';
@@ -0,0 +1,32 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
// @ts-check
/**
* @param {import('knex')} knex
*/
exports.up = async function up(knex) {
await knex('entities')
.where({ namespace: null })
.update({ namespace: 'default' });
await knex('entities_search').update({
key: knex.raw('LOWER(key)'),
value: knex.raw('LOWER(value)'),
});
};
exports.down = async function down() {};
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model';
import { Logger } from 'winston';
import { CoalescedEntitiesCatalog } from './CoalescedEntitiesCatalog';
import { EntitiesCatalog } from './types';
@@ -115,9 +115,13 @@ describe('CoalescedEntitiesCatalog', () => {
c1.entityByName.mockResolvedValueOnce(undefined);
c2.entityByName.mockResolvedValueOnce(e2);
const catalog = new CoalescedEntitiesCatalog([c1, c2], logger);
await expect(catalog.entityByName('k', undefined, 'n2')).resolves.toBe(
e2,
);
await expect(
catalog.entityByName({
kind: 'k',
namespace: ENTITY_DEFAULT_NAMESPACE,
name: 'n2',
}),
).resolves.toBe(e2);
expect(c1.entityByName).toBeCalledTimes(1);
expect(c2.entityByName).toBeCalledTimes(1);
});
@@ -127,7 +131,11 @@ describe('CoalescedEntitiesCatalog', () => {
c2.entityByName.mockResolvedValueOnce(undefined);
const catalog = new CoalescedEntitiesCatalog([c1, c2], logger);
await expect(
catalog.entityByName('k', undefined, 'n2'),
catalog.entityByName({
kind: 'k',
namespace: ENTITY_DEFAULT_NAMESPACE,
name: 'n2',
}),
).resolves.toBeUndefined();
expect(c1.entityByName).toBeCalledTimes(1);
expect(c2.entityByName).toBeCalledTimes(1);
@@ -137,9 +145,13 @@ describe('CoalescedEntitiesCatalog', () => {
c1.entityByName.mockResolvedValueOnce(e1);
c2.entityByName.mockRejectedValueOnce(new Error('boo'));
const catalog = new CoalescedEntitiesCatalog([c1, c2], logger);
await expect(catalog.entityByName('k', undefined, 'n2')).resolves.toBe(
e1,
);
await expect(
catalog.entityByName({
kind: 'k',
namespace: ENTITY_DEFAULT_NAMESPACE,
name: 'n2',
}),
).resolves.toBe(e1);
expect(c1.entityByName).toBeCalledTimes(1);
expect(c2.entityByName).toBeCalledTimes(1);
expect(mockLogger.warn).toBeCalledWith(expect.stringMatching(/boo/));
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { Entity, EntityName } from '@backstage/catalog-model';
import { Logger } from 'winston';
import { EntityFilters } from '../database';
import { EntitiesCatalog } from './types';
@@ -72,14 +72,10 @@ export class CoalescedEntitiesCatalog implements EntitiesCatalog {
return results.find(Boolean);
}
async entityByName(
kind: string,
namespace: string | undefined,
name: string,
): Promise<Entity | undefined> {
async entityByName(name: EntityName): Promise<Entity | undefined> {
const ops = this.inner.map(async catalog => {
try {
return await catalog.entityByName(kind, namespace, name);
return await catalog.entityByName(name);
} catch (e) {
this.logger.warn(`Inner entityByName call failed, ${e}`);
return undefined;
@@ -27,7 +27,7 @@ describe('DatabaseEntitiesCatalog', () => {
addEntity: jest.fn(),
updateEntity: jest.fn(),
entities: jest.fn(),
entity: jest.fn(),
entityByName: jest.fn(),
entityByUid: jest.fn(),
removeEntity: jest.fn(),
addLocation: jest.fn(),
@@ -61,12 +61,12 @@ describe('DatabaseEntitiesCatalog', () => {
const catalog = new DatabaseEntitiesCatalog(db);
const result = await catalog.addOrUpdateEntity(entity);
expect(db.entities).toHaveBeenCalledTimes(1);
expect(db.entities).toHaveBeenCalledWith(expect.anything(), [
{ key: 'kind', values: ['b'] },
{ key: 'name', values: ['c'] },
{ key: 'namespace', values: ['d'] },
]);
expect(db.entityByName).toHaveBeenCalledTimes(1);
expect(db.entityByName).toHaveBeenCalledWith(expect.anything(), {
kind: 'b',
namespace: 'd',
name: 'c',
});
expect(db.addEntity).toHaveBeenCalledTimes(1);
expect(result).toBe(entity);
});
@@ -146,18 +146,18 @@ describe('DatabaseEntitiesCatalog', () => {
},
};
db.entities.mockResolvedValue([{ entity: existing }]);
db.entityByName.mockResolvedValue({ entity: existing });
db.updateEntity.mockResolvedValue({ entity: existing });
const catalog = new DatabaseEntitiesCatalog(db);
const result = await catalog.addOrUpdateEntity(added);
expect(db.entities).toHaveBeenCalledTimes(1);
expect(db.entities).toHaveBeenCalledWith(expect.anything(), [
{ key: 'kind', values: ['b'] },
{ key: 'name', values: ['c'] },
{ key: 'namespace', values: ['d'] },
]);
expect(db.entityByName).toHaveBeenCalledTimes(1);
expect(db.entityByName).toHaveBeenCalledWith(expect.anything(), {
kind: 'b',
namespace: 'd',
name: 'c',
});
expect(db.updateEntity).toHaveBeenCalledTimes(1);
expect(db.updateEntity).toHaveBeenCalledWith(
expect.anything(),
@@ -16,7 +16,9 @@
import { NotFoundError } from '@backstage/backend-common';
import {
EntityName,
generateUpdatedEntity,
getEntityName,
LOCATION_ANNOTATION,
} from '@backstage/catalog-model';
import type { Entity } from '@backstage/catalog-model';
@@ -34,20 +36,15 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
}
async entityByUid(uid: string): Promise<Entity | undefined> {
const matches = await this.database.transaction(tx =>
this.database.entities(tx, [{ key: 'uid', values: [uid] }]),
const response = await this.database.transaction(tx =>
this.database.entityByUid(tx, uid),
);
return matches.length ? matches[0].entity : undefined;
return response?.entity;
}
async entityByName(
kind: string,
namespace: string | undefined,
name: string,
): Promise<Entity | undefined> {
async entityByName(name: EntityName): Promise<Entity | undefined> {
const response = await this.database.transaction(tx =>
this.entityByNameInternal(tx, kind, name, namespace),
this.database.entityByName(tx, name),
);
return response?.entity;
}
@@ -61,12 +58,7 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
// entity) existing entity, to know whether to update or add
const existing = entity.metadata.uid
? await this.database.entityByUid(tx, entity.metadata.uid)
: await this.entityByNameInternal(
tx,
entity.kind,
entity.metadata.name,
entity.metadata.namespace,
);
: await this.database.entityByName(tx, getEntityName(entity));
// If it's an update, run the algorithm for annotation merging, updating
// etag/generation, etc.
@@ -113,25 +105,4 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
return undefined;
});
}
private async entityByNameInternal(
tx: unknown,
kind: string,
name: string,
namespace: string | undefined,
): Promise<DbEntityResponse | undefined> {
const matches = await this.database.entities(tx, [
{ key: 'kind', values: [kind] },
{ key: 'name', values: [name] },
{
key: 'namespace',
values:
!namespace || namespace === 'default'
? [null, 'default']
: [namespace],
},
]);
return matches.length ? matches[0] : undefined;
}
}
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import type { Entity } from '@backstage/catalog-model';
import { Entity, EntityName, getEntityName } from '@backstage/catalog-model';
import lodash from 'lodash';
import type { EntitiesCatalog } from './types';
@@ -34,17 +34,15 @@ export class StaticEntitiesCatalog implements EntitiesCatalog {
return item ? lodash.cloneDeep(item) : undefined;
}
async entityByName(
kind: string,
name: string,
namespace: string | undefined,
): Promise<Entity | undefined> {
const item = this._entities.find(
e =>
kind === e.kind &&
name === e.metadata.name &&
namespace === e.metadata.namespace,
);
async entityByName(name: EntityName): Promise<Entity | undefined> {
const item = this._entities.find(e => {
const candidate = getEntityName(e);
return (
name.kind.toLowerCase() === candidate.kind.toLowerCase() &&
name.namespace.toLowerCase() === candidate.namespace.toLowerCase() &&
name.name.toLowerCase() === candidate.name.toLowerCase()
);
});
return item ? lodash.cloneDeep(item) : undefined;
}
+2 -6
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { Entity, Location } from '@backstage/catalog-model';
import { Entity, EntityName, Location } from '@backstage/catalog-model';
import type { EntityFilters } from '../database';
//
@@ -24,11 +24,7 @@ import type { EntityFilters } from '../database';
export type EntitiesCatalog = {
entities(filters?: EntityFilters): Promise<Entity[]>;
entityByUid(uid: string): Promise<Entity | undefined>;
entityByName(
kind: string,
namespace: string | undefined,
name: string,
): Promise<Entity | undefined>;
entityByName(name: EntityName): Promise<Entity | undefined>;
addOrUpdateEntity(entity: Entity, locationId?: string): Promise<Entity>;
removeEntityByUid(uid: string): Promise<void>;
};
@@ -395,5 +395,90 @@ describe('CommonDatabase', () => {
]),
);
});
it('can get all specific entities for matching filters case insensitively)', async () => {
const entities: Entity[] = [
{ apiVersion: 'A', kind: 'K1', metadata: { name: 'N' } },
{
apiVersion: 'a',
kind: 'k2',
metadata: { name: 'n' },
spec: { c: 'Some' },
},
{
apiVersion: 'a',
kind: 'k3',
metadata: { name: 'n' },
spec: { c: null },
},
];
await db.transaction(async tx => {
for (const entity of entities) {
await db.addEntity(tx, { entity });
}
});
const rows = await db.transaction(async tx =>
db.entities(tx, [
{ key: 'ApiVersioN', values: ['A'] },
{ key: 'spEc.C', values: [null, 'some'] },
]),
);
expect(rows.length).toEqual(3);
expect(rows).toEqual(
expect.arrayContaining([
{
locationId: undefined,
entity: expect.objectContaining({ kind: 'K1' }),
},
{
locationId: undefined,
entity: expect.objectContaining({ kind: 'k2' }),
},
{
locationId: undefined,
entity: expect.objectContaining({ kind: 'k3' }),
},
]),
);
});
});
describe('entityByName', () => {
it('can get entities case insensitively', async () => {
const entities: Entity[] = [
{
apiVersion: 'a',
kind: 'k1',
metadata: { name: 'n' },
},
{
apiVersion: 'B',
kind: 'K2',
metadata: { name: 'N', namespace: 'NS' },
},
];
await db.transaction(async tx => {
for (const entity of entities) {
await db.addEntity(tx, { entity });
}
});
const e1 = await db.transaction(async tx =>
db.entityByName(tx, { kind: 'k1', namespace: 'default', name: 'n' }),
);
expect(e1!.entity.metadata.name).toEqual('n');
const e2 = await db.transaction(async tx =>
db.entityByName(tx, { kind: 'k2', namespace: 'nS', name: 'n' }),
);
expect(e2!.entity.metadata.name).toEqual('N');
const e3 = await db.transaction(async tx =>
db.entityByName(tx, { kind: 'unknown', namespace: 'nS', name: 'n' }),
);
expect(e3).toBeUndefined();
});
});
});
@@ -22,9 +22,12 @@ import {
import {
Entity,
EntityMeta,
entityMetaGeneratedFields,
EntityName,
ENTITY_DEFAULT_NAMESPACE,
ENTITY_META_GENERATED_FIELDS,
generateEntityEtag,
generateEntityUid,
getEntityName,
Location,
} from '@backstage/catalog-model';
import Knex from 'knex';
@@ -172,7 +175,7 @@ export class CommonDatabase implements Database {
let builder = tx<DbEntitiesRow>('entities');
for (const [indexU, filter] of (filters ?? []).entries()) {
const index = Number(indexU);
const key = filter.key.replace('*', '%');
const key = filter.key.toLowerCase().replace('*', '%');
const keyOp = filter.key.includes('*') ? 'like' : '=';
let matchNulls = false;
@@ -183,9 +186,9 @@ export class CommonDatabase implements Database {
if (!value) {
matchNulls = true;
} else if (value.includes('*')) {
matchLike.push(value.replace('*', '%'));
matchLike.push(value.toLowerCase().replace('*', '%'));
} else {
matchIn.push(value);
matchIn.push(value.toLowerCase());
}
}
@@ -219,16 +222,19 @@ export class CommonDatabase implements Database {
return rows.map(row => this.toEntityResponse(row));
}
async entity(
async entityByName(
txOpaque: unknown,
kind: string,
name: string,
namespace?: string,
name: EntityName,
): Promise<DbEntityResponse | undefined> {
const tx = txOpaque as Knex.Transaction<any, any>;
const rows = await tx<DbEntitiesRow>('entities')
.where({ kind, name, namespace: namespace || null })
.whereRaw(
tx.raw(
'LOWER(kind) = LOWER(?) AND LOWER(namespace) = LOWER(?) AND LOWER(name) = LOWER(?)',
[name.kind, name.namespace, name.name],
),
)
.select();
if (rows.length !== 1) {
@@ -240,11 +246,13 @@ export class CommonDatabase implements Database {
async entityByUid(
txOpaque: unknown,
id: string,
uid: string,
): Promise<DbEntityResponse | undefined> {
const tx = txOpaque as Knex.Transaction<any, any>;
const rows = await tx<DbEntitiesRow>('entities').where({ id }).select();
const rows = await tx<DbEntitiesRow>('entities')
.where({ id: uid })
.select();
if (rows.length !== 1) {
return undefined;
@@ -381,36 +389,40 @@ export class CommonDatabase implements Database {
tx: Knex.Transaction<any, any>,
data: Entity,
): Promise<void> {
const newKind = data.kind;
const newName = data.metadata.name;
const newNamespace = data.metadata.namespace;
const {
kind: newKind,
namespace: newNamespace,
name: newName,
} = getEntityName(data);
const newKindNorm = this.normalize(newKind);
const newNamespaceNorm = this.normalize(newNamespace);
const newNameNorm = this.normalize(newName);
const newNamespaceNorm = this.normalize(newNamespace || '');
for (const item of await this.entities(tx)) {
if (data.metadata.uid === item.entity.metadata.uid) {
continue;
}
const oldKind = item.entity.kind;
const oldName = item.entity.metadata.name;
const oldNamespace = item.entity.metadata.namespace;
const {
kind: oldKind,
namespace: oldNamespace,
name: oldName,
} = getEntityName(item.entity);
const oldKindNorm = this.normalize(oldKind);
const oldNamespaceNorm = this.normalize(oldNamespace);
const oldNameNorm = this.normalize(oldName);
const oldNamespaceNorm = this.normalize(oldNamespace || '');
if (
oldKindNorm === newKindNorm &&
oldNameNorm === newNameNorm &&
oldNamespaceNorm === newNamespaceNorm
oldNamespaceNorm === newNamespaceNorm &&
oldNameNorm === newNameNorm
) {
// Only throw if things were actually different - for completely equal
// things, we let the database handle the conflict
if (
oldKind !== newKind ||
oldName !== newName ||
oldNamespace !== newNamespace
oldNamespace !== newNamespace ||
oldName !== newName
) {
const message = `Kind, namespace, name are too similar to an existing entity`;
throw new ConflictError(message);
@@ -431,9 +443,9 @@ export class CommonDatabase implements Database {
api_version: entity.apiVersion,
kind: entity.kind,
name: entity.metadata.name,
namespace: entity.metadata.namespace || null,
namespace: entity.metadata.namespace || ENTITY_DEFAULT_NAMESPACE,
metadata: JSON.stringify(
lodash.omit(entity.metadata, ...entityMetaGeneratedFields),
lodash.omit(entity.metadata, ...ENTITY_META_GENERATED_FIELDS),
),
spec: entity.spec ? JSON.stringify(entity.spec) : null,
};
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import type { Entity } from '@backstage/catalog-model';
import { ENTITY_DEFAULT_NAMESPACE, Entity } from '@backstage/catalog-model';
import { buildEntitySearch, visitEntityPart } from './search';
import type { DbEntitiesSearchRow } from './types';
@@ -95,6 +95,16 @@ describe('search', () => {
{ entity_id: 'eid', key: 'root.list.a', value: '2' },
]);
});
it('emits lowercase version of keys and values', () => {
const input = { theRoot: { listItems: [{ a: 'One' }, { a: 2 }] } };
const output: DbEntitiesSearchRow[] = [];
visitEntityPart('eid', '', input, output);
expect(output).toEqual([
{ entity_id: 'eid', key: 'theroot.listitems.a', value: 'one' },
{ entity_id: 'eid', key: 'theroot.listitems.a', value: '2' },
]);
});
});
describe('buildEntitySearch', () => {
@@ -108,11 +118,17 @@ describe('search', () => {
{ entity_id: 'eid', key: 'metadata.name', value: 'n' },
{ entity_id: 'eid', key: 'metadata.namespace', value: null },
{ entity_id: 'eid', key: 'metadata.uid', value: null },
{ entity_id: 'eid', key: 'apiVersion', value: 'a' },
{
entity_id: 'eid',
key: 'metadata.namespace',
value: ENTITY_DEFAULT_NAMESPACE,
},
{ entity_id: 'eid', key: 'apiversion', value: 'a' },
{ entity_id: 'eid', key: 'kind', value: 'b' },
{ entity_id: 'eid', key: 'name', value: 'n' },
{ entity_id: 'eid', key: 'namespace', value: null },
{ entity_id: 'eid', key: 'uid', value: null },
{ entity_id: 'eid', key: 'namespace', value: ENTITY_DEFAULT_NAMESPACE },
]);
});
+19 -4
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import type { Entity } from '@backstage/catalog-model';
import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model';
import type { DbEntitiesSearchRow } from './types';
// Search entries that start with these prefixes, also get a shorthand without
@@ -26,7 +26,7 @@ const SHORTHAND_KEY_PREFIXES = [
'spec.',
];
// These are exluded in the generic loop, either because they do not make sense
// These are excluded in the generic loop, either because they do not make sense
// to index, or because they are special-case always inserted whether they are
// null or not
const SPECIAL_KEYS = [
@@ -42,7 +42,7 @@ function toValue(current: any): string | null {
return null;
}
return String(current);
return String(current).toLowerCase();
}
// Helper for iterating through a nested structure and outputting a list of
@@ -106,7 +106,12 @@ export function visitEntityPart(
// object
for (const [key, value] of Object.entries(current)) {
visitEntityPart(entityId, path ? `${path}.${key}` : key, value, output);
visitEntityPart(
entityId,
(path ? `${path}.${key}` : key).toLowerCase(),
value,
output,
);
}
}
@@ -141,6 +146,16 @@ export function buildEntitySearch(
},
];
// Namespace not specified has the default value "default", so we want to
// match on that as well
if (!entity.metadata.namespace) {
result.push({
entity_id: entityId,
key: 'metadata.namespace',
value: toValue(ENTITY_DEFAULT_NAMESPACE),
});
}
// Visit the entire structure recursively
visitEntityPart(entityId, '', entity, result);
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import type { Entity, Location } from '@backstage/catalog-model';
import type { Entity, EntityName, Location } from '@backstage/catalog-model';
export type DbEntitiesRow = {
id: string;
@@ -129,11 +129,9 @@ export type Database = {
entities(tx: unknown, filters?: EntityFilters): Promise<DbEntityResponse[]>;
entity(
entityByName(
tx: unknown,
kind: string,
name: string,
namespace?: string,
name: EntityName,
): Promise<DbEntityResponse | undefined>;
entityByUid(tx: unknown, uid: string): Promise<DbEntityResponse | undefined>;
@@ -15,7 +15,12 @@
*/
import { getVoidLogger } from '@backstage/backend-common';
import { Entity, Location, LocationSpec } from '@backstage/catalog-model';
import {
Entity,
ENTITY_DEFAULT_NAMESPACE,
Location,
LocationSpec,
} from '@backstage/catalog-model';
import { EntitiesCatalog, LocationsCatalog } from '../catalog';
import { LocationUpdateStatus } from '../catalog/types';
import { DatabaseLocationUpdateLogStatus } from '../database/types';
@@ -200,12 +205,11 @@ describe('HigherOrderOperations', () => {
target: 'thing',
});
expect(entitiesCatalog.entityByName).toHaveBeenCalledTimes(1);
expect(entitiesCatalog.entityByName).toHaveBeenNthCalledWith(
1,
'Component',
undefined,
'c1',
);
expect(entitiesCatalog.entityByName).toHaveBeenNthCalledWith(1, {
kind: 'Component',
namespace: ENTITY_DEFAULT_NAMESPACE,
name: 'c1',
});
expect(entitiesCatalog.addOrUpdateEntity).toHaveBeenCalledTimes(1);
expect(entitiesCatalog.addOrUpdateEntity).toHaveBeenNthCalledWith(
1,
@@ -18,6 +18,7 @@ import { InputError } from '@backstage/backend-common';
import {
Entity,
entityHasChanges,
getEntityName,
Location,
LocationSpec,
} from '@backstage/catalog-model';
@@ -162,16 +163,14 @@ export class HigherOrderOperations implements HigherOrderOperation {
const { entity } = item;
this.logger.debug(
`Read entity kind="${entity.kind}" name="${
entity.metadata.name
}" namespace="${entity.metadata.namespace || ''}"`,
`Read entity kind="${entity.kind}" namespace="${
entity.metadata.namespace || ''
}" name="${entity.metadata.name}"`,
);
try {
const previous = await this.entitiesCatalog.entityByName(
entity.kind,
entity.metadata.namespace,
entity.metadata.name,
getEntityName(entity),
);
if (!previous) {
@@ -136,7 +136,11 @@ describe('createRouter', () => {
const response = await request(app).get('/entities/by-name/k/ns/n');
expect(entitiesCatalog.entityByName).toHaveBeenCalledTimes(1);
expect(entitiesCatalog.entityByName).toHaveBeenCalledWith('k', 'ns', 'n');
expect(entitiesCatalog.entityByName).toHaveBeenCalledWith({
kind: 'k',
namespace: 'ns',
name: 'n',
});
expect(response.status).toEqual(200);
expect(response.body).toEqual(expect.objectContaining(entity));
});
@@ -147,7 +151,11 @@ describe('createRouter', () => {
const response = await request(app).get('/entities/by-name/b/d/c');
expect(entitiesCatalog.entityByName).toHaveBeenCalledTimes(1);
expect(entitiesCatalog.entityByName).toHaveBeenCalledWith('b', 'd', 'c');
expect(entitiesCatalog.entityByName).toHaveBeenCalledWith({
kind: 'b',
namespace: 'd',
name: 'c',
});
expect(response.status).toEqual(404);
expect(response.text).toMatch(/name/);
});
@@ -67,11 +67,11 @@ export async function createRouter(
})
.get('/entities/by-name/:kind/:namespace/:name', async (req, res) => {
const { kind, namespace, name } = req.params;
const entity = await entitiesCatalog.entityByName(
const entity = await entitiesCatalog.entityByName({
kind,
namespace,
name,
);
});
if (!entity) {
res
.status(404)