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.
261 lines
11 KiB
TypeScript
261 lines
11 KiB
TypeScript
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();
|
|
}
|
|
}
|
|
}
|