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(); 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 = { 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 { 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 { 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 { 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(); const excluded = new Set(); 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 { 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 { 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 { 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 { if ((await this.repository.listRelationships(databaseId)).some((relationship) => relationship.id === relationshipId)) { throw new LogicalRelationshipReadOnlyError(); } } }