Files
dbx/apps/desktop/src/lib/table/objectBrowserRows.ts
T

382 lines
17 KiB
TypeScript

import type { MongoCollectionKind, ObjectBrowserFilter, ObjectInfo, TreeNode, TreeNodeType } from "@/types/database";
export type { ObjectBrowserFilter } from "@/types/database";
import { pinnedTreeNodeIdentityMatches, type PinnedTreeNodeIdentity } from "@/lib/app/pinnedItems";
import { toMongoCollectionKind } from "@/lib/sidebar/mongoCollectionMutation";
import { buildGroupedObjectTreeNodes, buildSimpleObjectTreeNodes, buildTableTreeNodes, compareDatabaseObjectNames, normalizeDatabaseObjectName } from "@/lib/table/tableTree";
import { parseSlashDelimitedRegexQuery } from "@/lib/common/searchPattern";
export type ObjectBrowserRow = {
id: string;
name: string;
displayName: string;
schema?: string;
type: "TABLE" | "VIEW" | "MATERIALIZED_VIEW" | "PROCEDURE" | "FUNCTION" | "TRIGGER" | "EVENT" | "SEQUENCE" | "PACKAGE" | "PACKAGE_BODY" | "TYPE" | "TYPE_BODY";
collectionKind?: MongoCollectionKind;
valid?: boolean | null;
signature?: string | null;
comment?: string | null;
created_at?: string | null;
updated_at?: string | null;
partitionParentId?: string;
partitionCount?: number;
partitionParentSchema?: string;
partitionParentName?: string;
estimatedRows?: number | null;
totalBytes?: number | null;
};
export type ObjectBrowserSortKey = "name" | "type" | "estimatedRows" | "totalBytes" | "created_at" | "updated_at" | "comment";
export type ObjectBrowserSortDirection = "asc" | "desc";
export type ObjectBrowserFilterCounts = Record<ObjectBrowserFilter, number>;
export type ObjectBrowserPinnedTreeNodeContext = {
connectionId: string;
database: string;
schema?: string;
catalog?: string;
sidebarParentId?: string;
};
export function objectBrowserRowTreeNodeType(type: ObjectBrowserRow["type"]): TreeNodeType {
if (type === "TABLE") return "table";
if (type === "VIEW") return "view";
if (type === "MATERIALIZED_VIEW") return "materialized_view";
if (type === "PROCEDURE") return "procedure";
if (type === "FUNCTION") return "function";
if (type === "TRIGGER") return "trigger";
if (type === "EVENT") return "event";
if (type === "SEQUENCE") return "sequence";
if (type === "PACKAGE_BODY") return "package-body";
if (type === "PACKAGE") return "package";
if (type === "TYPE_BODY") return "type-body";
return "type";
}
export function canonicalObjectBrowserPinnedTreeNodeIdentity(identity: PinnedTreeNodeIdentity, database: string): PinnedTreeNodeIdentity {
// Some drivers expose database-level objects with the selected database as
// their schema, while the sidebar represents the same objects without one.
// Canonicalize only this alias; real schemas remain distinct.
return identity.schema === database ? { ...identity, schema: "" } : identity;
}
export function canonicalizeObjectBrowserPinnedTreeNodeIdentity(context: ObjectBrowserPinnedTreeNodeContext): (identity: PinnedTreeNodeIdentity) => PinnedTreeNodeIdentity {
return (identity) => canonicalObjectBrowserPinnedTreeNodeIdentity(identity, context.database);
}
export function objectBrowserRowPinnedTreeNodeIdentity(row: ObjectBrowserRow, context: ObjectBrowserPinnedTreeNodeContext): PinnedTreeNodeIdentity {
return {
connectionId: context.connectionId,
database: context.database,
schema: row.schema ?? context.schema ?? "",
catalog: context.catalog || "",
type: objectBrowserRowTreeNodeType(row.type),
name: row.name,
signature: row.type === "FUNCTION" || row.type === "PROCEDURE" ? row.signature?.trim() || "" : "",
id: row.id,
};
}
export function objectBrowserRowMatchesPinnedTreeNode(row: ObjectBrowserRow, identity: PinnedTreeNodeIdentity, context: ObjectBrowserPinnedTreeNodeContext): boolean {
return pinnedTreeNodeIdentityMatches(identity, objectBrowserRowPinnedTreeNodeIdentity(row, context), canonicalizeObjectBrowserPinnedTreeNodeIdentity(context));
}
function flattenTreeNodeIds(nodes: readonly TreeNode[]): string[] {
return nodes.flatMap((node) => [node.id, ...(node.children ? flattenTreeNodeIds(node.children) : [])]);
}
function objectBrowserRowObjectInfo(row: ObjectBrowserRow, schema?: string): ObjectInfo {
return {
name: row.name,
object_type: row.type,
schema,
signature: row.signature || undefined,
parent_schema: row.partitionParentSchema,
parent_name: row.partitionParentName,
};
}
function legacySchemaCandidates(row: ObjectBrowserRow, context: ObjectBrowserPinnedTreeNodeContext): Array<string | undefined> {
const identity = objectBrowserRowPinnedTreeNodeIdentity(row, context);
const canonicalIdentity = canonicalizeObjectBrowserPinnedTreeNodeIdentity(context)(identity);
return [...new Set([row.schema, context.schema, canonicalIdentity.schema, undefined].map((schema) => schema?.trim() || undefined))];
}
/**
* Builds the historical bare IDs emitted by each sidebar layout. The current
* v2 key is identity-based, but old persisted pins contain only these IDs.
*/
export function objectBrowserRowLegacyPinnedTreeNodeIds(row: ObjectBrowserRow, context: ObjectBrowserPinnedTreeNodeContext): string[] {
const parentId = context.sidebarParentId || `${context.connectionId}:${context.database}`;
const legacyIds = new Set<string>();
for (const schema of legacySchemaCandidates(row, context)) {
const object = objectBrowserRowObjectInfo(row, schema);
const simpleNodes =
row.type === "TABLE" || row.type === "VIEW" || row.type === "MATERIALIZED_VIEW"
? buildTableTreeNodes({
nodeId: parentId,
connectionId: context.connectionId,
database: context.database,
schema,
tables: [
{
name: row.name,
table_type: row.type,
valid: row.valid,
comment: row.comment,
parent_schema: row.partitionParentSchema,
parent_name: row.partitionParentName,
},
],
})
: buildSimpleObjectTreeNodes({ nodeId: parentId, connectionId: context.connectionId, database: context.database, schema, objects: [object] });
for (const id of flattenTreeNodeIds(simpleNodes)) legacyIds.add(id);
const groupedNodes = buildGroupedObjectTreeNodes({ nodeId: parentId, connectionId: context.connectionId, database: context.database, schema, objects: [object] });
for (const group of groupedNodes) {
for (const id of flattenTreeNodeIds(group.children || [])) legacyIds.add(id);
}
}
return [...legacyIds];
}
export function normalizeObjectBrowserType(type: string): ObjectBrowserRow["type"] {
const value = type.toUpperCase();
const normalized = value.replace(/[\s-]+/g, "_");
if (normalized.includes("PACKAGE_BODY")) return "PACKAGE_BODY";
if (normalized.includes("TYPE_BODY")) return "TYPE_BODY";
if (normalized.includes("PACKAGE")) return "PACKAGE";
if (normalized.includes("TRIGGER")) return "TRIGGER";
if (normalized.includes("EVENT")) return "EVENT";
if (normalized.includes("TYPE")) return "TYPE";
if (normalized.includes("MATERIALIZED_VIEW")) return "MATERIALIZED_VIEW";
if (value.includes("VIEW")) return "VIEW";
if (value.includes("SEQ")) return "SEQUENCE";
if (value.includes("PROC")) return "PROCEDURE";
if (value.includes("FUNC")) return "FUNCTION";
return "TABLE";
}
export function buildObjectBrowserRows(options: { objects: ObjectInfo[]; database: string; fallbackSchema: string; rowSchema?: string }): ObjectBrowserRow[] {
const seen = new Map<string, number>();
const rows = options.objects.flatMap((object) => {
const name = normalizeDatabaseObjectName(object.name);
if (!name) return [];
const objectSchema = object.schema ? normalizeDatabaseObjectName(object.schema) : undefined;
const schema = objectSchema || options.rowSchema;
const type = normalizeObjectBrowserType(object.object_type);
const signature = routineSignatureForDisplay(type, object.signature);
const displayName = signature === undefined ? name : `${name}(${signature})`;
const signatureIdPart = signature === undefined ? "" : `:${signature}`;
const baseId = `${schema || options.fallbackSchema || options.database}:${name}:${type}${signatureIdPart}`;
const index = seen.get(baseId) ?? 0;
seen.set(baseId, index + 1);
return [
{
id: `${baseId}:${index}`,
name,
displayName,
schema,
type,
valid: object.valid,
signature,
comment: object.comment,
created_at: object.created_at,
updated_at: object.updated_at,
partitionParentSchema: object.parent_schema ? normalizeDatabaseObjectName(object.parent_schema) : undefined,
partitionParentName: object.parent_name ? normalizeDatabaseObjectName(object.parent_name) : undefined,
},
];
});
markPartitionRows(rows, options.fallbackSchema || options.database);
return rows;
}
export function buildMongoObjectBrowserRows(options: { collections: Array<{ name: string; kind?: string | null }>; database: string }): ObjectBrowserRow[] {
const seen = new Map<string, number>();
return options.collections.flatMap((collection) => {
const name = collection.name;
if (!name) return [];
const collectionKind = toMongoCollectionKind(collection.kind);
const type: ObjectBrowserRow["type"] = collectionKind === "view" ? "VIEW" : "TABLE";
const baseId = `${options.database}:${name}:${type}:${collectionKind}`;
const index = seen.get(baseId) ?? 0;
seen.set(baseId, index + 1);
return [
{
id: `${baseId}:${index}`,
name,
displayName: name,
type,
collectionKind,
},
];
});
}
function routineSignatureForDisplay(type: ObjectBrowserRow["type"], signature: string | null | undefined): string | undefined {
if (type !== "PROCEDURE" && type !== "FUNCTION") return undefined;
if (signature == null) return undefined;
return signature.trim();
}
function markPartitionRows(rows: ObjectBrowserRow[], fallbackSchema: string) {
const tableByKey = new Map<string, ObjectBrowserRow>();
for (const row of rows) {
if (row.type !== "TABLE") continue;
tableByKey.set(objectKey(row, fallbackSchema), row);
}
const partitionCountByParent = new Map<string, number>();
for (const row of rows) {
if (row.type !== "TABLE") continue;
const parentName = row.partitionParentName || partitionParentName(row.name);
if (!parentName) continue;
const parentKey = objectKey({ ...row, schema: row.partitionParentSchema || row.schema, name: parentName }, fallbackSchema);
const parent = tableByKey.get(parentKey);
if (!parent || parent.id === row.id) continue;
row.partitionParentId = parent.id;
partitionCountByParent.set(parent.id, (partitionCountByParent.get(parent.id) ?? 0) + 1);
}
for (const row of rows) {
row.partitionCount = partitionCountByParent.get(row.id);
}
}
function objectKey(row: Pick<ObjectBrowserRow, "schema" | "name" | "type">, fallbackSchema: string) {
return `${row.type}\0${(row.schema || fallbackSchema).toLowerCase()}\0${row.name.toLowerCase()}`;
}
function partitionParentName(name: string): string | null {
const match = /^(.*)_p\d{4,}$/.exec(name);
return match?.[1] || null;
}
export function filterObjectBrowserRows(rows: ObjectBrowserRow[], query: string): ObjectBrowserRow[] {
const q = query.trim().toLowerCase();
if (!q) return rows;
const regex = parseSlashDelimitedRegexQuery(query.trim());
if (regex) {
// Object type labels ("TABLE", "VIEW", "PROCEDURE", ...) are deliberately
// not searchable: a query like "TAB" would otherwise match every TABLE row
// via its type label (issue #6488). The type filter buttons cover type
// scoping, and every other search path (sidebar tree, backend metadata)
// matches names and comments only.
return rows.filter((row) => [row.displayName, row.name, row.comment].filter(Boolean).some((value) => regex.test(String(value))));
}
return rows.filter((row) => [row.displayName, row.name, row.comment].filter(Boolean).some((value) => String(value).toLowerCase().includes(q)));
}
export function countObjectBrowserRowsByFilter(rows: ObjectBrowserRow[]): ObjectBrowserFilterCounts {
const counts: ObjectBrowserFilterCounts = {
all: rows.length,
tables: 0,
views: 0,
materializedViews: 0,
procedures: 0,
functions: 0,
triggers: 0,
events: 0,
sequences: 0,
packages: 0,
types: 0,
};
for (const row of rows) {
if (row.type === "TABLE") counts.tables++;
else if (row.type === "VIEW") counts.views++;
else if (row.type === "MATERIALIZED_VIEW") counts.materializedViews++;
else if (row.type === "PROCEDURE") counts.procedures++;
else if (row.type === "FUNCTION") counts.functions++;
else if (row.type === "TRIGGER") counts.triggers++;
else if (row.type === "EVENT") counts.events++;
else if (row.type === "SEQUENCE") counts.sequences++;
else if (row.type === "PACKAGE" || row.type === "PACKAGE_BODY") counts.packages++;
else if (row.type === "TYPE" || row.type === "TYPE_BODY") counts.types++;
}
return counts;
}
export function summarizeObjectBrowserSearch(rows: ObjectBrowserRow[], query: string): { matchingRows: ObjectBrowserRow[]; counts: ObjectBrowserFilterCounts } {
const matchingRows = filterObjectBrowserRows(rows, query);
return { matchingRows, counts: countObjectBrowserRowsByFilter(matchingRows) };
}
export function sortObjectBrowserRows(rows: ObjectBrowserRow[], key: ObjectBrowserSortKey, direction: ObjectBrowserSortDirection): ObjectBrowserRow[] {
const multiplier = direction === "asc" ? 1 : -1;
// Sort by natural visible name, matching the sidebar tree ordering
// (see fix "keep tables sorted by visible name" and issue #3604).
return [...rows].sort((left, right) => {
const compared = key === "name" ? compareDatabaseObjectNames(left.displayName, right.displayName) : compareObjectBrowserValue(left[key], right[key], key, direction);
if (compared !== 0) return compared * multiplier;
return compareDatabaseObjectNames(left.displayName, right.displayName);
});
}
export function initialObjectBrowserSortDirection(key: ObjectBrowserSortKey): ObjectBrowserSortDirection {
return key === "created_at" || key === "updated_at" || key === "estimatedRows" || key === "totalBytes" ? "desc" : "asc";
}
export function formatObjectBrowserTimestamp(value: string | null | undefined): string {
const text = value?.trim();
if (!text) return "";
return text
.replace("T", " ")
.replace(/\.\d+(?=$|[+-]\d{2}(?::?\d{2})?$)/, "")
.replace(/(?:Z|[+-]\d{2}(?::?\d{2})?)$/, "");
}
export function formatObjectBrowserCount(value: number | null | undefined): string {
if (typeof value !== "number" || !Number.isFinite(value)) return "";
return new Intl.NumberFormat().format(value);
}
export function formatObjectBrowserBytes(value: number | null | undefined): string {
if (typeof value !== "number" || !Number.isFinite(value)) return "";
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
let size = Math.max(0, value);
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
const fractionDigits = unitIndex === 0 || size >= 100 ? 0 : size >= 10 ? 1 : 2;
return `${size.toFixed(fractionDigits)} ${units[unitIndex]}`;
}
function compareObjectBrowserValue(left: string | number | null | undefined, right: string | number | null | undefined, key: ObjectBrowserSortKey, direction: ObjectBrowserSortDirection): number {
if (key === "estimatedRows" || key === "totalBytes") {
return compareObjectBrowserNumber(left, right, direction);
}
const leftText = normalizeSortValue(left);
const rightText = normalizeSortValue(right);
if (!leftText && !rightText) return 0;
if (!leftText) return direction === "asc" ? 1 : -1;
if (!rightText) return direction === "asc" ? -1 : 1;
if (key === "created_at" || key === "updated_at") return leftText.localeCompare(rightText);
return leftText.localeCompare(rightText, undefined, { numeric: true, sensitivity: "base" });
}
function compareObjectBrowserNumber(left: string | number | null | undefined, right: string | number | null | undefined, direction: ObjectBrowserSortDirection): number {
const leftNumber = normalizeSortNumber(left);
const rightNumber = normalizeSortNumber(right);
if (leftNumber === null && rightNumber === null) return 0;
if (leftNumber === null) return direction === "asc" ? 1 : -1;
if (rightNumber === null) return direction === "asc" ? -1 : 1;
return leftNumber - rightNumber;
}
function normalizeSortValue(value: string | number | null | undefined): string {
if (typeof value === "number") return String(value);
return value?.trim() ?? "";
}
function normalizeSortNumber(value: string | number | null | undefined): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value !== "string" || !value.trim()) return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}