feat(catalog): entirely case insensitive read path of entities
This commit is contained in:
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user