feat: consolidate database management work
Add catalog-owned logical relationships and runtime snapshots, extend the database-management UI and validation coverage, and document the updated operational workflow. Keep active sensitive-generation status in a tooltip and indicator, and update the layout E2E to follow the history action in its new database-scoped location.
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
import type {
|
||||
CatalogLogicalRelationship,
|
||||
CatalogLogicalRelationshipCandidate,
|
||||
CatalogLogicalRelationshipContext,
|
||||
CatalogLogicalRelationshipEndpoint,
|
||||
CatalogRelationship,
|
||||
CatalogRepository,
|
||||
} from "./types.js";
|
||||
|
||||
export class LogicalRelationshipDatabaseNotFoundError extends Error {}
|
||||
export class LogicalRelationshipDuplicateError extends Error {}
|
||||
export class LogicalRelationshipNotFoundError extends Error {}
|
||||
export class LogicalRelationshipReadOnlyError extends Error {}
|
||||
export class LogicalRelationshipSchemaStaleError extends Error {}
|
||||
export class LogicalRelationshipTargetNotUniqueError extends Error {}
|
||||
export class LogicalRelationshipTypeIncompatibleError extends Error {}
|
||||
export class LogicalRelationshipColumnNotFoundError extends Error {
|
||||
constructor(readonly field: "sourceColumnId" | "targetColumnId") {
|
||||
super(`Catalog column '${field}' was not found`);
|
||||
}
|
||||
}
|
||||
|
||||
export interface RebuildGeneratedRelationshipsResult {
|
||||
added: number;
|
||||
alreadyPresent: number;
|
||||
excluded: number;
|
||||
ambiguous: number;
|
||||
}
|
||||
|
||||
function identifierTokens(value: string): string[] {
|
||||
return value
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function singularWord(value: string): string {
|
||||
if (value.length > 4 && value.endsWith("ies")) return `${value.slice(0, -3)}y`;
|
||||
if (value.length > 4 && /(ches|shes|xes|zes|ses)$/.test(value)) return value.slice(0, -2);
|
||||
if (value.length > 3 && value.endsWith("s") && !/(ss|us)$/.test(value)) return value.slice(0, -1);
|
||||
return value;
|
||||
}
|
||||
|
||||
function tableAliases(tableName: string): string[] {
|
||||
const tokens = identifierTokens(tableName);
|
||||
if (tokens.length === 0) return [];
|
||||
const normalized = tokens.join("_");
|
||||
const singular = [...tokens];
|
||||
singular[singular.length - 1] = singularWord(singular[singular.length - 1]);
|
||||
return [...new Set([normalized, singular.join("_")])];
|
||||
}
|
||||
|
||||
const GENERIC_PRIMARY_KEY_NAMES = new Set(["id", "key", "code", "pk"]);
|
||||
|
||||
function nameMatches(
|
||||
source: CatalogLogicalRelationshipEndpoint,
|
||||
target: CatalogLogicalRelationshipEndpoint,
|
||||
): boolean {
|
||||
const sourceName = identifierTokens(source.columnName).join("_");
|
||||
const targetName = identifierTokens(target.columnName).join("_");
|
||||
if (!sourceName || !targetName) return false;
|
||||
const expected = new Set<string>();
|
||||
if (!GENERIC_PRIMARY_KEY_NAMES.has(targetName)) expected.add(targetName);
|
||||
for (const alias of tableAliases(target.tableName)) {
|
||||
expected.add(`${alias}_${targetName}`);
|
||||
expected.add(`${alias.replaceAll("_", "")}${targetName.replaceAll("_", "")}`);
|
||||
if (targetName === "id" || targetName === "pk") expected.add(alias);
|
||||
}
|
||||
return expected.has(sourceName);
|
||||
}
|
||||
|
||||
function canonicalDataType(value: string): string {
|
||||
const normalized = value.trim().toLowerCase().replace(/\s+/g, " ");
|
||||
const arraySuffix = normalized.endsWith("[]") ? "[]" : "";
|
||||
const base = arraySuffix ? normalized.slice(0, -2) : normalized;
|
||||
const withoutModifier = base.replace(/\([^)]*\)/g, "").trim();
|
||||
const aliases: Record<string, string> = {
|
||||
int2: "smallint",
|
||||
smallserial: "smallint",
|
||||
int4: "integer",
|
||||
int: "integer",
|
||||
serial: "integer",
|
||||
int8: "bigint",
|
||||
bigserial: "bigint",
|
||||
decimal: "numeric",
|
||||
varchar: "text",
|
||||
"character varying": "text",
|
||||
bool: "boolean",
|
||||
"timestamp without time zone": "timestamp",
|
||||
"timestamp with time zone": "timestamptz",
|
||||
"time without time zone": "time",
|
||||
"time with time zone": "timetz",
|
||||
};
|
||||
return `${aliases[withoutModifier] ?? withoutModifier}${arraySuffix}`;
|
||||
}
|
||||
|
||||
function typesCompatible(left: string, right: string): boolean {
|
||||
return canonicalDataType(left) === canonicalDataType(right);
|
||||
}
|
||||
|
||||
function pairKey(sourceColumnId: string, targetColumnId: string): string {
|
||||
return `${sourceColumnId}\u0000${targetColumnId}`;
|
||||
}
|
||||
|
||||
function relationshipPair(relationship: CatalogLogicalRelationship): CatalogLogicalRelationshipCandidate {
|
||||
return {
|
||||
sourceColumnId: relationship.columns[0].sourceColumnId,
|
||||
targetColumnId: relationship.columns[0].targetColumnId,
|
||||
};
|
||||
}
|
||||
|
||||
function relationshipSortKey(relationship: CatalogRelationship): string {
|
||||
const sourceColumns = relationship.columns.map((column) => column.sourceColumnName).join(",");
|
||||
const targetColumns = relationship.columns.map((column) => column.targetColumnName).join(",");
|
||||
return [
|
||||
relationship.sourceTableName,
|
||||
sourceColumns,
|
||||
relationship.targetTableName,
|
||||
targetColumns,
|
||||
relationship.origin,
|
||||
].join("\u0000");
|
||||
}
|
||||
|
||||
export class CatalogLogicalRelationshipService {
|
||||
constructor(private readonly repository: CatalogRepository) {}
|
||||
|
||||
async list(databaseId: string): Promise<CatalogRelationship[]> {
|
||||
if (!(await this.repository.get(databaseId))) throw new LogicalRelationshipDatabaseNotFoundError();
|
||||
const relationships: CatalogRelationship[] = [
|
||||
...await this.repository.listRelationships(databaseId),
|
||||
...await this.repository.listLogicalRelationships(databaseId),
|
||||
];
|
||||
return relationships.sort((left, right) => relationshipSortKey(left).localeCompare(relationshipSortKey(right)));
|
||||
}
|
||||
|
||||
async addManual(
|
||||
databaseId: string,
|
||||
sourceColumnId: string,
|
||||
targetColumnId: string,
|
||||
): Promise<CatalogLogicalRelationship> {
|
||||
const context = await this.requiredContext(databaseId);
|
||||
const source = context.endpoints.find((endpoint) => endpoint.columnId === sourceColumnId);
|
||||
if (!source) throw new LogicalRelationshipColumnNotFoundError("sourceColumnId");
|
||||
const target = context.endpoints.find((endpoint) => endpoint.columnId === targetColumnId);
|
||||
if (!target) throw new LogicalRelationshipColumnNotFoundError("targetColumnId");
|
||||
if (sourceColumnId === targetColumnId
|
||||
|| target.primaryKeyPosition === null
|
||||
|| target.tablePrimaryKeyColumnCount !== 1) {
|
||||
throw new LogicalRelationshipTargetNotUniqueError();
|
||||
}
|
||||
if (!typesCompatible(source.dataType, target.dataType)) {
|
||||
throw new LogicalRelationshipTypeIncompatibleError();
|
||||
}
|
||||
const key = pairKey(sourceColumnId, targetColumnId);
|
||||
if (context.physicalPairs.some((pair) => pairKey(pair.sourceColumnId, pair.targetColumnId) === key)
|
||||
|| context.logicalRelationships.some((relationship) => {
|
||||
const pair = relationshipPair(relationship);
|
||||
return pairKey(pair.sourceColumnId, pair.targetColumnId) === key;
|
||||
})) throw new LogicalRelationshipDuplicateError();
|
||||
const created = await this.repository.insertLogicalRelationship(
|
||||
databaseId,
|
||||
sourceColumnId,
|
||||
targetColumnId,
|
||||
false,
|
||||
);
|
||||
if (!created) throw new LogicalRelationshipDuplicateError();
|
||||
return created;
|
||||
}
|
||||
|
||||
async rebuildGenerated(databaseId: string): Promise<RebuildGeneratedRelationshipsResult> {
|
||||
const context = await this.requiredContext(databaseId);
|
||||
const targets = context.endpoints.filter((endpoint) => (
|
||||
endpoint.primaryKeyPosition !== null && endpoint.tablePrimaryKeyColumnCount === 1
|
||||
));
|
||||
const physical = new Set(context.physicalPairs.map((pair) => pairKey(pair.sourceColumnId, pair.targetColumnId)));
|
||||
const active = new Set<string>();
|
||||
const excluded = new Set<string>();
|
||||
for (const relationship of context.logicalRelationships) {
|
||||
const pair = relationshipPair(relationship);
|
||||
(relationship.status === "excluded" ? excluded : active)
|
||||
.add(pairKey(pair.sourceColumnId, pair.targetColumnId));
|
||||
}
|
||||
|
||||
const pending: CatalogLogicalRelationshipCandidate[] = [];
|
||||
let alreadyPresent = 0;
|
||||
let excludedCount = 0;
|
||||
let ambiguous = 0;
|
||||
const dimTimeTargets = targets.filter((target) => (
|
||||
identifierTokens(target.tableName).join("_") === "dim_time"
|
||||
));
|
||||
const sources = context.endpoints.filter((endpoint) => (
|
||||
endpoint.primaryKeyPosition === null || endpoint.tablePrimaryKeyColumnCount > 1
|
||||
));
|
||||
for (const source of sources) {
|
||||
const sourceName = identifierTokens(source.columnName).join("_");
|
||||
const isTimeKey = sourceName.endsWith("time_key")
|
||||
&& identifierTokens(source.tableName).join("_") !== "dim_time";
|
||||
const candidates = isTimeKey ? dimTimeTargets : targets;
|
||||
const matches = candidates.filter((target) => (
|
||||
target.columnId !== source.columnId
|
||||
&& typesCompatible(source.dataType, target.dataType)
|
||||
&& (isTimeKey || nameMatches(source, target))
|
||||
));
|
||||
if (matches.length > 1) {
|
||||
ambiguous += 1;
|
||||
continue;
|
||||
}
|
||||
if (matches.length === 0) continue;
|
||||
const candidate = { sourceColumnId: source.columnId, targetColumnId: matches[0].columnId };
|
||||
const key = pairKey(candidate.sourceColumnId, candidate.targetColumnId);
|
||||
if (physical.has(key) || active.has(key)) {
|
||||
alreadyPresent += 1;
|
||||
} else if (excluded.has(key)) {
|
||||
excludedCount += 1;
|
||||
} else {
|
||||
pending.push(candidate);
|
||||
}
|
||||
}
|
||||
const added = await this.repository.insertGeneratedLogicalRelationships(databaseId, pending);
|
||||
alreadyPresent += pending.length - added;
|
||||
return { added, alreadyPresent, excluded: excludedCount, ambiguous };
|
||||
}
|
||||
|
||||
async setStatus(
|
||||
databaseId: string,
|
||||
relationshipId: string,
|
||||
status: CatalogLogicalRelationship["status"],
|
||||
): Promise<CatalogLogicalRelationship> {
|
||||
if (!(await this.repository.get(databaseId))) throw new LogicalRelationshipDatabaseNotFoundError();
|
||||
const updated = await this.repository.setLogicalRelationshipStatus(databaseId, relationshipId, status);
|
||||
if (updated) return updated;
|
||||
await this.assertNotPhysical(databaseId, relationshipId);
|
||||
throw new LogicalRelationshipNotFoundError();
|
||||
}
|
||||
|
||||
async deletePermanently(databaseId: string, relationshipId: string): Promise<void> {
|
||||
if (!(await this.repository.get(databaseId))) throw new LogicalRelationshipDatabaseNotFoundError();
|
||||
if (await this.repository.deleteLogicalRelationship(databaseId, relationshipId)) return;
|
||||
await this.assertNotPhysical(databaseId, relationshipId);
|
||||
throw new LogicalRelationshipNotFoundError();
|
||||
}
|
||||
|
||||
private async requiredContext(databaseId: string): Promise<CatalogLogicalRelationshipContext> {
|
||||
const database = await this.repository.get(databaseId);
|
||||
if (!database) throw new LogicalRelationshipDatabaseNotFoundError();
|
||||
if (database.schemaSyncedVersion !== database.version) {
|
||||
throw new LogicalRelationshipSchemaStaleError();
|
||||
}
|
||||
const context = await this.repository.getLogicalRelationshipContext(databaseId);
|
||||
if (!context) throw new LogicalRelationshipDatabaseNotFoundError();
|
||||
return context;
|
||||
}
|
||||
|
||||
private async assertNotPhysical(databaseId: string, relationshipId: string): Promise<void> {
|
||||
if ((await this.repository.listRelationships(databaseId)).some((relationship) => relationship.id === relationshipId)) {
|
||||
throw new LogicalRelationshipReadOnlyError();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user