LCOV - code coverage report
Current view: top level - dashql-app/src/connections/hyper - hyper_catalog_update.ts Coverage Total Hit
Test: DashQL d805a4b Lines: 99.5 % 211 210
Test Date: 2026-11-09 20:22:13

            Line data    Source code
       1              : import * as arrow from 'apache-arrow';
       2              : import * as dashql from '@ankoh/dashql-core';
       3              : 
       4              : import { CATALOG_UPDATE_REGISTER_QUERY } from '../connection_state.js';
       5              : import { DynamicConnectionDispatch } from '../connection_registry.js';
       6              : import { catalogTablesOfSchema, DEFAULT_DATABASE_NAME, packCatalogDescriptor, packCatalogTable, packRelationName, quoteIdentifier, relationFilter, type CatalogTable, type ColumnMetadata, type RelationName } from '../catalog/catalog_sql_generator.js';
       7              : import { CATALOG_QUERY_READ_TIMEOUT_MS, queryPgAttribute, catalogTablesFromPgAttribute } from '../catalog/catalog_query_pg_attribute.js';
       8              : import { QueryExecutionArgs } from '../query_execution_args.js';
       9              : import { QueryExecutor } from '../query_executor.js';
      10              : import { QueryType } from '../../query/query_execution_state.js';
      11              : import type { LoggerLike } from '../../platform/logger/logger.js';
      12              : import type { AttachedDatabase } from './hyperdb_grpc_client.js';
      13              : import { qualifyPrefetchedHyperFunctions } from '../catalog/prefetched_hyper_functions.js';
      14              : import { beginCatalogBatch, publishCatalogBatch, type CatalogBatchOptions } from '../catalog/catalog_batch.js';
      15              : import { generateFunctionScriptHeaderForSource } from '../catalog/catalog_function_sql_generator.js';
      16              : import { generateCatalogSQLFromPgProc, queryPgProc } from '../catalog/catalog_query_pg_proc.js';
      17              : 
      18            1 : const LOG_CTX = 'hyper_catalog';
      19              : 
      20              : export type HyperCloudCatalogTable = arrow.Table<{
      21              :     schema_name: arrow.Utf8;
      22              :     table_name: arrow.Utf8;
      23              :     column_name: arrow.Utf8;
      24              :     ordinal_position: arrow.Int32;
      25              :     data_type: arrow.Utf8;
      26              : }>;
      27              : 
      28              : export interface HyperCatalogUpdateFailure {
      29              :     database: string;
      30              :     error: Error;
      31              : }
      32              : 
      33              : export interface HyperCatalogUpdateResult {
      34              :     updatedDatabases: string[];
      35              :     failures: HyperCatalogUpdateFailure[];
      36              : }
      37              : 
      38              : interface CatalogTarget {
      39              :     key: string;
      40              :     databaseName: string;
      41              :     queryDatabaseName: string | null;
      42              :     path: string;
      43              : }
      44              : 
      45            1 : function toError(value: unknown): Error {
      46            1 :     return value instanceof Error ? value : new Error(String(value));
      47            1 : }
      48              : 
      49            1 : function summarizeFailures(message: string, failures: HyperCatalogUpdateFailure[]): Error {
      50            1 :     return new Error(`${message}: ${failures.map(failure => `${failure.database}: ${failure.error.message}`).join('; ')}`);
      51            1 : }
      52              : 
      53            1 : function buildCatalogTargets(attachedDatabases: AttachedDatabase[]): CatalogTarget[] {
      54            1 :     if (attachedDatabases.length === 0) {
      55            1 :         return [{ key: DEFAULT_DATABASE_NAME, databaseName: DEFAULT_DATABASE_NAME, queryDatabaseName: null, path: '' }];
      56              :     }
      57            1 :     return attachedDatabases.map(database => ({
      58            1 :         key: database.alias ?? '',
      59            1 :         databaseName: database.alias ?? '',
      60            1 :         queryDatabaseName: database.alias || null,
      61            1 :         path: database.path,
      62            1 :     }));
      63            1 : }
      64              : 
      65            1 : export function buildHyperCloudCatalogQuery(databaseAlias: string, relations: RelationName[] | null = null): string {
      66            1 :     const database = quoteIdentifier(databaseAlias);
      67            1 :     const table = (name: string) => `${database}.${quoteIdentifier('_hyper_catalog')}.${quoteIdentifier(name)}`;
      68            1 :     return `
      69              :         SELECT
      70              :             s.schema_name_display AS schema_name,
      71              :             o.object_name_display AS table_name,
      72              :             c.column_name_display AS column_name,
      73              :             c.ordinal_position AS ordinal_position,
      74              :             CAST(c.type_descriptor AS TEXT) AS data_type
      75            1 :         FROM ${table('databases')} d
      76            1 :         JOIN ${table('schemas')} s
      77              :           ON s.account_id = d.account_id
      78              :          AND s.database_id = d.database_id
      79            1 :         JOIN ${table('objects')} o
      80              :           ON o.account_id = s.account_id
      81              :          AND o.schema_id = s.schema_id
      82            1 :         JOIN ${table('columns')} c
      83              :           ON c.account_id = o.account_id
      84              :          AND c.object_id = o.object_id
      85              :         WHERE o.object_type IN ('ICEBERG_TABLE', 'VIEW')
      86            1 :         ${relations == null ? '' : `AND ${relationFilter('s.schema_name_display', 'o.object_name_display', relations)}`}
      87              :         ORDER BY
      88              :             s.schema_name_display,
      89              :             o.object_name_display,
      90              :             c.ordinal_position
      91              :     `;
      92            1 : }
      93              : 
      94            1 : export function catalogTablesFromHyperCloud(result: HyperCloudCatalogTable, connectionName: string, logger?: LoggerLike): CatalogTable[] {
      95            1 :     const schemas = new Map<string, Map<string, ColumnMetadata[]>>();
      96            1 :     const seenColumns = new Set<string>();
      97            1 :     let duplicateColumns = 0;
      98            1 :     let firstDuplicate: Record<string, string> | null = null;
      99            1 :     for (const batch of result.batches) {
     100            1 :         const schemaNames = batch.getChild('schema_name')!;
     101            1 :         const tableNames = batch.getChild('table_name')!;
     102            1 :         const columnNames = batch.getChild('column_name')!;
     103            1 :         const ordinalPositions = batch.getChild('ordinal_position')!;
     104            1 :         const dataTypes = batch.getChild('data_type')!;
     105              : 
     106            1 :         for (let i = 0; i < batch.numRows; ++i) {
     107            1 :             const schemaName = schemaNames.at(i);
     108            1 :             const tableName = tableNames.at(i);
     109            1 :             const columnName = columnNames.at(i);
     110            1 :             if (!schemaName || !tableName || !columnName) continue;
     111              : 
     112            1 :             let tables = schemas.get(schemaName);
     113            1 :             if (!tables) schemas.set(schemaName, tables = new Map());
     114            1 :             let columns = tables.get(tableName);
     115            1 :             if (!columns) tables.set(tableName, columns = []);
     116              :             // The catalog can list a column more than once, a table declares it once
     117            1 :             const columnKey = JSON.stringify([schemaName, tableName, columnName]);
     118            1 :             if (seenColumns.has(columnKey)) {
     119            1 :                 firstDuplicate ??= {
     120            1 :                     schema: schemaName, table: tableName, column: columnName,
     121            1 :                     ordinalPosition: String(ordinalPositions.at(i)), dataType: String(dataTypes.at(i)),
     122            1 :                 };
     123            1 :                 ++duplicateColumns;
     124            1 :                 continue;
     125            1 :             }
     126            1 :             seenColumns.add(columnKey);
     127            1 :             columns.push({
     128            1 :                 name: columnName,
     129            1 :                 ordinalPosition: ordinalPositions.at(i) ?? columns.length,
     130            1 :                 dataType: dataTypes.at(i) ?? null,
     131            1 :             });
     132            1 :         }
     133            1 :     }
     134            1 :     if (firstDuplicate != null) {
     135            1 :         logger?.warn('Skipping duplicate Hyper Cloud catalog columns', {
     136            1 :             database: connectionName, duplicates: duplicateColumns.toString(), ...firstDuplicate,
     137            1 :         }, LOG_CTX);
     138              :     }
     139              : 
     140            1 :     return [...schemas.keys()].sort().flatMap(schemaName => catalogTablesOfSchema(connectionName, schemaName, schemas.get(schemaName)!));
     141            1 : }
     142              : 
     143            1 : async function queryHyperCloudCatalog(
     144            1 :     connectionId: string,
     145            1 :     connectionDispatch: DynamicConnectionDispatch,
     146            1 :     updateId: number,
     147            1 :     databaseAlias: string,
     148            1 :     executor: QueryExecutor,
     149            1 :     abortSignal: AbortSignal,
     150            1 :     relations: RelationName[] | null,
     151            1 : ): Promise<HyperCloudCatalogTable> {
     152              :     const args: QueryExecutionArgs = {
     153            1 :         query: buildHyperCloudCatalogQuery(databaseAlias, relations),
     154            1 :         abortSignal,
     155            1 :         readTimeoutMs: CATALOG_QUERY_READ_TIMEOUT_MS,
     156            1 :         throwOnError: true,
     157            1 :         metadata: {
     158            1 :             queryType: QueryType.CATALOG_QUERY_HYPER_CLOUD,
     159            1 :             title: `Query Hyper Cloud Catalog (${databaseAlias})`,
     160            1 :             description: null,
     161            1 :             issuer: 'Catalog Update',
     162            1 :             userProvided: false,
     163            1 :         },
     164              :     };
     165            1 :     const [queryId, queryExecution] = executor(connectionId, args);
     166            1 :     connectionDispatch(connectionId, {
     167            1 :         type: CATALOG_UPDATE_REGISTER_QUERY,
     168            1 :         value: [updateId, queryId],
     169            1 :     });
     170            1 :     const result = await queryExecution as HyperCloudCatalogTable | null;
     171            1 :     if (result == null || (relations == null && result.numRows === 0)) {
     172            0 :         throw new Error(`Hyper Cloud catalog for ${databaseAlias} returned no relations`);
     173              :     }
     174            1 :     return result;
     175            1 : }
     176              : 
     177            1 : export async function updateHyperCatalog(
     178            1 :     logger: LoggerLike,
     179            1 :     connectionId: string,
     180            1 :     connectionDispatch: DynamicConnectionDispatch,
     181            1 :     updateId: number,
     182            1 :     attachedDatabases: AttachedDatabase[],
     183            1 :     executor: QueryExecutor,
     184            1 :     catalog: dashql.DashQLCatalog,
     185            1 :     dql: dashql.DashQL,
     186            1 :     relationEntryId: number,
     187            1 :     catalogFunctionScript: dashql.DashQLScript,
     188            1 :     resolveLiveFunctionCatalog: boolean,
     189            1 :     abortSignal: AbortSignal,
     190            1 :     options: CatalogBatchOptions = {},
     191              :     /// The relations to ask about, all relations and the functions if absent
     192            1 :     relations: RelationName[] | null = null,
     193            1 : ): Promise<HyperCatalogUpdateResult> {
     194            1 :     options = { ...options, abortSignal };
     195            1 :     const validate = beginCatalogBatch(catalog, options);
     196            1 :     const targets = buildCatalogTargets(attachedDatabases);
     197            1 :     const aliasCounts = new Map<string, number>();
     198            1 :     for (const target of targets) aliasCounts.set(target.key, (aliasCounts.get(target.key) ?? 0) + 1);
     199              : 
     200            1 :     const relationsOf = (target: CatalogTarget) => relations?.filter(relation => relation.databaseName === target.databaseName) ?? null;
     201            1 :     const queriedTargets = relations == null ? targets : targets.filter(target => relationsOf(target)!.length > 0);
     202            1 :     const results = await Promise.all(queriedTargets.map(async target => {
     203            1 :         try {
     204            1 :             abortSignal.throwIfAborted();
     205            1 :             if (attachedDatabases.length > 0 && !target.key.trim()) {
     206            1 :                 throw new Error('Attached databases require a non-empty alias for catalog refresh');
     207              :             }
     208            1 :             if ((aliasCounts.get(target.key) ?? 0) > 1) {
     209            1 :                 throw new Error(`Attached database alias ${JSON.stringify(target.key)} is not unique`);
     210              :             }
     211              : 
     212            1 :             let tables: CatalogTable[];
     213            1 :             if (target.path.startsWith('hyper.cloud')) {
     214            1 :                 const result = await queryHyperCloudCatalog(
     215            1 :                     connectionId,
     216            1 :                     connectionDispatch,
     217            1 :                     updateId,
     218            1 :                     target.databaseName,
     219            1 :                     executor,
     220            1 :                     abortSignal,
     221            1 :                     relationsOf(target),
     222            1 :                 );
     223            1 :                 tables = catalogTablesFromHyperCloud(result, target.databaseName, logger);
     224            1 :             } else {
     225            1 :                 const result = await queryPgAttribute(
     226            1 :                     connectionId,
     227            1 :                     connectionDispatch,
     228            1 :                     updateId,
     229            1 :                     executor,
     230            1 :                     target.queryDatabaseName,
     231            1 :                     abortSignal,
     232            1 :                     relationsOf(target),
     233            1 :                 );
     234            1 :                 if (result == null || (relations == null && result.numRows === 0)) {
     235            1 :                     throw new Error(`pg_attribute for ${target.databaseName} returned no relations`);
     236              :                 }
     237            1 :                 tables = catalogTablesFromPgAttribute(result, target.databaseName);
     238            1 :             }
     239            1 :             if (relations == null && tables.length === 0) throw new Error(`Catalog for ${target.databaseName} returned no usable relations`);
     240            1 :             return { target, tables, error: null };
     241            1 :         } catch (error) {
     242            1 :             return { target, tables: null, error: toError(error) };
     243            1 :         }
     244            1 :     }));
     245              : 
     246            1 :     validate();
     247            1 :     const successful = results.filter(result => result.tables != null);
     248            1 :     const failures = results
     249            1 :         .filter(result => result.error != null)
     250            1 :         .map(result => ({ database: result.target.key || result.target.path, error: result.error! }));
     251            1 :     if (successful.length === 0 && (relations == null || failures.length > 0)) {
     252            1 :         throw summarizeFailures('Failed to refresh every attached database', failures);
     253              :     }
     254            1 :     const updatedDatabases = successful.map(result => result.target.key);
     255            1 :     const tables = successful.flatMap(result => result.tables!);
     256            1 :     const layer = dashql.buffers.catalog.CatalogLayer.CONNECTION;
     257              : 
     258              :     // The answer about relations updates them, the rest of the catalog stays
     259            1 :     if (relations != null) {
     260            1 :         const answered = successful.flatMap(result => relationsOf(result.target)!);
     261            1 :         if (answered.length > 0) {
     262            1 :             catalog.replaceTables(relationEntryId, layer, { relations: answered.map(packRelationName), tables: tables.map(packCatalogTable) });
     263              :         }
     264            1 :         return { updatedDatabases, failures };
     265            1 :     }
     266              : 
     267            1 :     let functionSQL: string;
     268            1 :     if (resolveLiveFunctionCatalog) {
     269            1 :         const functionResult = await queryPgProc(
     270            1 :             connectionId,
     271            1 :             connectionDispatch,
     272            1 :             updateId,
     273            1 :             executor,
     274            1 :             abortSignal,
     275            1 :         );
     276            1 :         validate();
     277            1 :         if (functionResult == null) {
     278            1 :             throw new Error('pg_proc returned no Hyper function metadata result');
     279              :         }
     280            1 :         const functions = generateCatalogSQLFromPgProc(functionResult, DEFAULT_DATABASE_NAME, logger);
     281            1 :         functionSQL = `${generateFunctionScriptHeaderForSource('HyperDB WASM pg_proc')}${functions}`;
     282            1 :     } else {
     283            1 :         functionSQL = qualifyPrefetchedHyperFunctions(DEFAULT_DATABASE_NAME);
     284              :     }
     285              : 
     286            1 :     validate();
     287            1 :     const [functions] = await publishCatalogBatch(dql, catalog, [
     288            1 :         { script: catalogFunctionScript, text: functionSQL, requireFunctions: !resolveLiveFunctionCatalog },
     289            1 :     ], options, validate);
     290            1 :     validate();
     291              :     // A database that failed keeps the relations it had
     292            1 :     if (failures.length === 0) {
     293            1 :         catalog.replaceDescriptor(relationEntryId, layer, packCatalogDescriptor(tables));
     294              :     } else {
     295            1 :         catalog.replaceTables(relationEntryId, layer, { databases: successful.map(result => result.target.databaseName), tables: tables.map(packCatalogTable) });
     296              :     }
     297            1 :     logger.info('Updated Hyper catalog relations', {
     298            1 :         updateId: updateId.toString(),
     299            1 :         databasesUpdated: successful.length.toString(),
     300            1 :         databasesFailed: failures.length.toString(),
     301            1 :         tables: tables.length.toString(),
     302            1 :         functions: functions.functions.toString(),
     303            1 :     }, LOG_CTX);
     304            1 :     return { updatedDatabases, failures };
     305            1 : }
        

Generated by: LCOV version 2.0-1