LCOV - code coverage report
Current view: top level - dashql-app/src/agent - catalog_name_index.ts Coverage Total Hit
Test: DashQL 915828b Lines: 100.0 % 128 128
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as core from '@ankoh/dashql-core';
       2              : 
       3              : import { isSystemSchema } from '../connections/catalog/system_schemas.js';
       4              : 
       5              : /// Shorter words are never treated as identifiers in natural language
       6            1 : const MIN_IDENTIFIER_LENGTH = 3;
       7              : 
       8              : /// Function words that are never treated as identifiers in natural language, even if a table or column has that name
       9            1 : const STOP_WORDS = new Set([
      10            1 :     'all', 'and', 'any', 'are', 'but', 'can', 'could', 'did', 'does', 'each', 'find', 'for', 'from', 'get', 'give',
      11            1 :     'had', 'has', 'have', 'here', 'how', 'into', 'its', 'list', 'many', 'much', 'not', 'our', 'over', 'per', 'show',
      12            1 :     'should', 'some', 'than', 'that', 'the', 'their', 'then', 'there', 'these', 'this', 'those', 'top', 'was',
      13            1 :     'were', 'what', 'when', 'where', 'which', 'who', 'why', 'will', 'with', 'would', 'your',
      14            1 : ]);
      15              : 
      16              : /// A word, optionally qualified with a dot (orders.amount)
      17            1 : const WORD_REGEX = /[\p{L}_][\p{L}\p{N}_$]*(?:\.[\p{L}_][\p{L}\p{N}_$]*)?/gu;
      18              : 
      19              : /// The kind of a catalog name
      20            1 : export enum CatalogNameKind {
      21            1 :     Table,
      22            1 :     Column,
      23            1 : }
      24              : 
      25              : /// A table or column name in the catalog
      26              : export interface CatalogName {
      27              :     /// The name as stored in the catalog
      28              :     name: string;
      29              :     /// The kind of the named object
      30              :     kind: CatalogNameKind;
      31              :     /// The table of a column, null for tables
      32              :     table: string | null;
      33              : }
      34              : 
      35              : /// A lookup structure for the table and column names of a catalog
      36              : export interface CatalogNameIndex {
      37              :     /// The distinct lowercase names, sorted for prefix searches
      38              :     keys: string[];
      39              :     /// The catalog names per lowercase name
      40              :     byKey: Map<string, CatalogName[]>;
      41              :     /// The lowercase column names per lowercase table name
      42              :     columnsByTable: Map<string, Set<string>>;
      43              : }
      44              : 
      45              : /// A word of a text that names a catalog object
      46              : export interface IdentifierMatch {
      47              :     /// The begin of the word
      48              :     from: number;
      49              :     /// The end of the word
      50              :     to: number;
      51              :     /// The kind of the named object, a qualified column is a column
      52              :     kind: CatalogNameKind;
      53              :     /// The matched catalog names
      54              :     names: CatalogName[];
      55              : }
      56              : 
      57              : /// An index without names
      58            1 : export const EMPTY_CATALOG_NAME_INDEX: CatalogNameIndex = { keys: [], byKey: new Map(), columnsByTable: new Map() };
      59              : 
      60              : /// Build the name index of a catalog, skipping the schemas of the database system.
      61            1 : export function buildCatalogNameIndex(catalog: core.DashQLCatalog): CatalogNameIndex {
      62            1 :     const byKey = new Map<string, CatalogName[]>();
      63            1 :     const columnsByTable = new Map<string, Set<string>>();
      64            1 :     const add = (name: CatalogName) => {
      65            1 :         const key = name.name.toLowerCase();
      66            1 :         const names = byKey.get(key);
      67            1 :         if (names == null) {
      68            1 :             byKey.set(key, [name]);
      69              :         } else {
      70            1 :             names.push(name);
      71              :         }
      72            1 :     };
      73            1 :     const snapshot = catalog.createSnapshot();
      74            1 :     const reader = snapshot.read();
      75            1 :     const flat = reader.catalogReader;
      76            1 :     const entry = new core.buffers.catalog.FlatCatalogEntry();
      77              :     // Every level references its parent by the index into the flat array of the level above
      78            1 :     const systemSchemas: boolean[] = [];
      79            1 :     for (let i = 0; i < flat.schemasLength(); ++i) {
      80            1 :         systemSchemas.push(isSystemSchema(reader.readName(flat.schemas(i, entry)!.nameId())));
      81              :     }
      82            1 :     const tableNames: (string | null)[] = [];
      83            1 :     for (let i = 0; i < flat.tablesLength(); ++i) {
      84            1 :         const table = flat.tables(i, entry)!;
      85            1 :         const name = reader.readName(table.nameId());
      86            1 :         if (systemSchemas[table.flatParentIdx()] || name.length === 0) {
      87            1 :             tableNames.push(null);
      88            1 :             continue;
      89            1 :         }
      90            1 :         tableNames.push(name);
      91            1 :         add({ name, kind: CatalogNameKind.Table, table: null });
      92            1 :     }
      93            1 :     for (let i = 0; i < flat.columnsLength(); ++i) {
      94            1 :         const column = flat.columns(i, entry)!;
      95            1 :         const table = tableNames[column.flatParentIdx()];
      96            1 :         const name = reader.readName(column.nameId());
      97            1 :         if (table == null || name.length === 0) {
      98            1 :             continue;
      99              :         }
     100            1 :         add({ name, kind: CatalogNameKind.Column, table });
     101            1 :         const tableKey = table.toLowerCase();
     102            1 :         let columns = columnsByTable.get(tableKey);
     103            1 :         if (columns == null) {
     104            1 :             columns = new Set();
     105            1 :             columnsByTable.set(tableKey, columns);
     106            1 :         }
     107            1 :         columns.add(name.toLowerCase());
     108            1 :     }
     109            1 :     const keys = [...byKey.keys()].sort();
     110            1 :     return { keys, byKey, columnsByTable };
     111            1 : }
     112              : 
     113              : /// Could a word be an identifier in natural language at all?
     114            1 : export function isIdentifierCandidate(word: string): boolean {
     115            1 :     return word.length >= MIN_IDENTIFIER_LENGTH && !STOP_WORDS.has(word.toLowerCase());
     116            1 : }
     117              : 
     118              : /// Find the catalog names of a word, dotted words must name a column of a table
     119            1 : export function lookupIdentifier(index: CatalogNameIndex, word: string): IdentifierMatch['names'] | null {
     120            1 :     const dot = word.indexOf('.');
     121            1 :     if (dot >= 0) {
     122            1 :         const table = word.substring(0, dot).toLowerCase();
     123            1 :         const column = word.substring(dot + 1).toLowerCase();
     124            1 :         if (!index.columnsByTable.get(table)?.has(column)) {
     125            1 :             return null;
     126              :         }
     127            1 :         return index.byKey.get(column)!.filter(name => name.kind === CatalogNameKind.Column && name.table?.toLowerCase() === table);
     128            1 :     }
     129            1 :     if (!isIdentifierCandidate(word)) {
     130            1 :         return null;
     131              :     }
     132            1 :     return index.byKey.get(word.toLowerCase()) ?? null;
     133            1 : }
     134              : 
     135              : /// Find the distinct lowercase names that start with a prefix, at most limit many
     136            1 : export function lookupPrefix(index: CatalogNameIndex, prefix: string, limit: number): string[] {
     137            1 :     const lower = prefix.toLowerCase();
     138              :     // Binary search the first key that is not smaller than the prefix
     139            1 :     let lo = 0;
     140            1 :     let hi = index.keys.length;
     141            1 :     while (lo < hi) {
     142            1 :         const mid = (lo + hi) >>> 1;
     143            1 :         if (index.keys[mid] < lower) {
     144            1 :             lo = mid + 1;
     145              :         } else {
     146            1 :             hi = mid;
     147              :         }
     148            1 :     }
     149            1 :     const matches: string[] = [];
     150            1 :     for (let i = lo; i < index.keys.length && matches.length < limit && index.keys[i].startsWith(lower); ++i) {
     151            1 :         matches.push(index.keys[i]);
     152              :     }
     153            1 :     return matches;
     154            1 : }
     155              : 
     156              : /// Find the words of a text that name catalog objects
     157            1 : export function findIdentifierMatches(index: CatalogNameIndex, text: string): IdentifierMatch[] {
     158            1 :     const matches: IdentifierMatch[] = [];
     159            1 :     for (const word of text.matchAll(WORD_REGEX)) {
     160            1 :         const names = lookupIdentifier(index, word[0]);
     161            1 :         if (names == null || names.length === 0) {
     162            1 :             continue;
     163              :         }
     164            1 :         matches.push({
     165            1 :             from: word.index,
     166            1 :             to: word.index + word[0].length,
     167            1 :             kind: names.some(name => name.kind === CatalogNameKind.Table) ? CatalogNameKind.Table : CatalogNameKind.Column,
     168            1 :             names,
     169            1 :         });
     170            1 :     }
     171            1 :     return matches;
     172            1 : }
     173              : 
     174              : /// Describe the catalog objects that a text mentions, e.g. "orders (table)" or "amount (column of orders)"
     175            1 : export function collectMentionedIdentifiers(index: CatalogNameIndex, text: string): string[] {
     176            1 :     const mentioned = new Set<string>();
     177            1 :     for (const match of findIdentifierMatches(index, text)) {
     178            1 :         const tables = new Set<string>();
     179            1 :         const columns = new Map<string, Set<string>>();
     180            1 :         for (const name of match.names) {
     181            1 :             if (name.kind === CatalogNameKind.Table) {
     182            1 :                 tables.add(name.name);
     183            1 :             } else {
     184            1 :                 let owners = columns.get(name.name);
     185            1 :                 if (owners == null) {
     186            1 :                     owners = new Set();
     187            1 :                     columns.set(name.name, owners);
     188            1 :                 }
     189            1 :                 if (name.table != null) owners.add(name.table);
     190            1 :             }
     191              :         }
     192            1 :         for (const table of tables) {
     193            1 :             mentioned.add(`${table} (table)`);
     194              :         }
     195            1 :         for (const [column, owners] of columns) {
     196            1 :             mentioned.add(owners.size > 0 ? `${column} (column of ${[...owners].join(', ')})` : `${column} (column)`);
     197              :         }
     198            1 :     }
     199            1 :     return [...mentioned];
     200            1 : }
        

Generated by: LCOV version 2.0-1