UNPKG

@quory/core

Version:

Quickly extract relationships from any database

691 lines (674 loc) 24.9 kB
"use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var index_exports = {}; __export(index_exports, { ConditionOperator: () => ConditionOperator, areQueriesEqual: () => areQueriesEqual, booleanConditionOperators: () => booleanConditionOperators, findTableFromSchemas: () => findTableFromSchemas, getCountForQuery: () => getCountForQuery, getEntitiesAndJunctions: () => getEntitiesAndJunctions, getRelationsForTable: () => getRelationsForTable, getSchemas: () => getSchemas, isBooleanCondition: () => isBooleanCondition, isConditionComplete: () => isConditionComplete, isListCondition: () => isListCondition, isValueCondition: () => isValueCondition, listConditionOperators: () => listConditionOperators, parseToCompleteCondition: () => parseToCompleteCondition, runQuery: () => runQuery, splitTableRef: () => splitTableRef, valueConditionOperators: () => valueConditionOperators }); module.exports = __toCommonJS(index_exports); // src/getSchemas.ts async function getSchemas(databaseDriver) { const schemas = []; const retrieveOrCreateTable = (schemaName, tableName) => { let schemaInfo = schemas.find(({ name }) => name === schemaName); if (!schemaInfo) { schemaInfo = { name: schemaName, tables: [] }; schemas.push(schemaInfo); } let tableInfo = schemaInfo.tables.find(({ name }) => name === tableName); if (!tableInfo) { tableInfo = { name: tableName, columns: [] }; schemaInfo.tables.push(tableInfo); } return tableInfo; }; const allColumns = await databaseDriver.getAllColumnsInDatabase(); const allForeignKeys = await databaseDriver.getAllForeignKeysInDatabase(); allColumns.forEach(({ tableName, schemaName, ...column }) => { const tableInfo = retrieveOrCreateTable(schemaName, tableName); tableInfo.columns.push({ ...column, foreignKeys: allForeignKeys.filter( (key) => key.localSchema === schemaName && key.localTable === tableName && key.localColumn === column.name ).map((key) => ({ foreignColumnName: key.foreignColumn, foreignSchemaName: key.foreignSchema, foreignTableName: key.foreignTable, hasForeignKeyConstraint: true, confidence: 1 })), foreignKeyReferences: allForeignKeys.filter( (key) => key.foreignSchema === schemaName && key.foreignTable === tableName && key.foreignColumn === column.name ).map((key) => ({ localColumnName: key.localColumn, localSchemaName: key.localSchema, localTableName: key.localTable, hasForeignKeyConstraint: true, confidence: 1 })) }); }); return schemas; } // util/makeGraphForDatabase.ts var import_graph_data_structure = require("graph-data-structure"); function makeGraphForDatabase(databaseSchemas, ignoreTables = []) { const links = databaseSchemas.flatMap( ({ name: schemaName, tables }) => tables.filter( ({ name: tableName }) => !ignoreTables.some( (table) => table.schemaName === schemaName && table.tableName === tableName ) ).flatMap(({ name: tableName, columns }) => { return [ ...columns.flatMap( (column) => column.foreignKeys.map((key) => ({ source: `${schemaName}.${tableName}`, target: `${key.foreignSchemaName}.${key.foreignTableName}` })) ), ...columns.flatMap( (column) => column.foreignKeyReferences.map((key) => ({ source: `${schemaName}.${tableName}`, target: `${key.localSchemaName}.${key.localTableName}` })) ) ]; }) ); const nodes = databaseSchemas.flatMap( ({ name: schemaName, tables }) => tables.map(({ name: tableName }) => `${schemaName}.${tableName}`) ); const graph = new import_graph_data_structure.Graph(); nodes.forEach((node) => graph.addNode(node)); links.forEach(({ source, target }) => graph.addEdge(source, target)); return graph; } // src/util/findTableFromSchemas.ts function findTableFromSchemas(schemas, schemaName, tableName) { const schema = schemas.find(({ name }) => name === schemaName); if (!schema) { throw new Error(`Could not find schema ${schemaName}`); } const table = schema.tables.find(({ name }) => name === tableName); if (!table) { throw new Error(`Could not find table ${schemaName}.${tableName}`); } return { ...table, schemaName }; } // src/util/getWhereClauseFromConditions.ts function getWhereClauseFromConditions(table, tableAlias, where) { if (!tableAlias) { tableAlias = `${table.schemaName}.${table.name}`; } function makeCondition(condition) { if ("column" in condition && !table.columns.some((column) => column.name === condition.column)) { throw new Error( `Where clause references column ${table.schemaName}.${table.name}.${condition.column} which does not exist` ); } switch (condition.operator) { case "and" /* AND */: case "or" /* OR */: return `(${condition.conditions.map(makeCondition).join(` ${condition.operator.toUpperCase()} `)})`; default: return `${tableAlias}.${condition.column} ${condition.operator.toUpperCase()} ${"value" in condition ? `'${condition.value}'` : `(${condition.values.map((value) => `'${value}'`).join(",")})`}`; } } return makeCondition(where); } // src/util/splitTableRef.ts function splitTableRef(tableRef) { const [schemaName, tableName] = tableRef.split("."); if (!schemaName || !tableName) { throw new Error(`Invalid tableRef: ${tableRef}`); } return [schemaName, tableName]; } // src/util/getSelectForColumn.ts function getSelectForColumn(table, tableAlias, column) { return { name: column.name, ref: `${tableAlias}.${column.name}`, subQueryAlias: `${tableAlias}__${column.name}`, outerAlias: `${table.schemaName}__${table.name}__${column.name}`, includeInOuter: true }; } // src/util/getShortestPath.ts var import_graph_data_structure2 = require("graph-data-structure"); function getShortestPath(graph, sourceRef, targetRef, via) { if (new Set(via).size !== via.length) { throw new Error(`Duplicate table refs in "via" path: ${via.join(", ")}`); } if (via.includes(sourceRef)) { throw new Error(`Via path includes source table ref: ${sourceRef}`); } if (via[via.length - 1] === targetRef) { throw new Error(`Via path ends with target table ref: ${targetRef}`); } const desiredRoute = [sourceRef, ...via, targetRef]; try { return [ sourceRef, ...desiredRoute.flatMap((thisTable, i, arr) => { const nextTable = arr[i + 1]; if (!nextTable) { return []; } const { nodes } = (0, import_graph_data_structure2.shortestPath)(graph, thisTable, nextTable); return nodes.slice(1); }) ]; } catch { throw new Error(`Couldn't find a path from ${sourceRef} to ${targetRef}`); } } // src/prepareQuery.ts var ConditionOperator = /* @__PURE__ */ ((ConditionOperator2) => { ConditionOperator2["AND"] = "and"; ConditionOperator2["OR"] = "or"; ConditionOperator2["LIKE"] = "like"; ConditionOperator2["EQUALS"] = "="; ConditionOperator2["GREATER_THAN"] = ">"; ConditionOperator2["LESS_THAN"] = "<"; ConditionOperator2["GREATER_THAN_OR_EQUAL"] = ">="; ConditionOperator2["LESS_THAN_OR_EQUAL"] = "<="; ConditionOperator2["NOT_EQUALS"] = "<>"; ConditionOperator2["NOT_LIKE"] = "not like"; ConditionOperator2["NOT_IN"] = "not in"; ConditionOperator2["IN"] = "in"; return ConditionOperator2; })(ConditionOperator || {}); var booleanConditionOperators = [ "and" /* AND */, "or" /* OR */ ]; var isBooleanCondition = (condition) => booleanConditionOperators.includes(condition.operator); var valueConditionOperators = [ "=" /* EQUALS */, ">" /* GREATER_THAN */, "<" /* LESS_THAN */, ">=" /* GREATER_THAN_OR_EQUAL */, "<=" /* LESS_THAN_OR_EQUAL */, "<>" /* NOT_EQUALS */, "like" /* LIKE */, "not like" /* NOT_LIKE */ ]; var isValueCondition = (condition) => valueConditionOperators.includes(condition.operator); var listConditionOperators = [ "in" /* IN */, "not in" /* NOT_IN */ ]; var isListCondition = (condition) => listConditionOperators.includes(condition.operator); async function prepareQuery(databaseSchemas, query) { const { base } = query; const [baseSchemaName, baseTableName] = splitTableRef(base.tableRef); const baseTable = findTableFromSchemas( databaseSchemas, baseSchemaName, baseTableName ); if (baseTable.columns.every( (column) => column.foreignKeys.length === 0 && column.foreignKeyReferences.length === 0 )) { throw new Error(`No relationships found for table ${baseTableName}`); } const tableSeenCounts = {}; const getAlias = (tableRef) => { tableSeenCounts[tableRef] = tableSeenCounts[tableRef] ?? 0; tableSeenCounts[tableRef] += 1; const [tableSchema, tableName] = splitTableRef(tableRef); return `${tableSchema}__${tableName}__${tableSeenCounts[tableRef]}`; }; const joinDefsWithAliases = function addAlias(join) { return { ...join, joinAlias: getAlias(join.tableRef), joins: join.joins?.map((childJoin) => addAlias(childJoin)) ?? [] }; }(base); const graph = makeGraphForDatabase(databaseSchemas); const flattenedJoinDefs = function flattenJoinDef(join, parent) { if (join.via && join.via[join.via.length - 1] === join.tableRef) { throw new Error( `Via path (${join.via.join(", ")}) for join ${join.tableRef} ends with table ref ${join.tableRef}` ); } const pathFromBase = parent ? [ ...parent.pathFromBase.slice(0, parent.pathFromBase.length - 1), ...getShortestPath( graph, parent.tableRef, join.tableRef, join.via ?? [] ) ] : [join.tableRef]; const ret = { ...join, pathFromBase }; const { joins: _, ...rest } = ret; if (join.joins) { for (const [index, childJoin] of join.joins.entries()) { const match = join.joins.some( ({ tableRef }, i) => index !== i && tableRef === childJoin.tableRef ); if (match) { throw new Error( `Duplicate table ref ${childJoin.tableRef}. Path: ${pathFromBase.join(" > ")} > ${childJoin.tableRef}` ); } } return [ rest, ...(join.joins ?? []).flatMap( (childJoin) => flattenJoinDef(childJoin, ret) ) ]; } else { return [rest]; } }(joinDefsWithAliases); const joinTree = function buildTree(join) { const joinsInSubTreeOfThisJoin = flattenedJoinDefs.filter(({ joinAlias }) => joinAlias !== join.joinAlias).filter((otherJoin) => { return join.pathFromBase.every( (tableRef, i) => otherJoin.pathFromBase[i] === tableRef ); }); const childJoins = joinsInSubTreeOfThisJoin.filter( ({ pathFromBase }) => pathFromBase.length === join.pathFromBase.length + 1 ); for (const joinInSubTree of joinsInSubTreeOfThisJoin) { const childNodeWithConnection = childJoins.find( ({ pathFromBase }) => joinInSubTree.pathFromBase.join().includes(pathFromBase.join()) ); if (!childNodeWithConnection) { const childNodeTableRef = joinInSubTree.pathFromBase[join.pathFromBase.length]; childJoins.push({ tableRef: childNodeTableRef, select: [], joinAlias: getAlias(childNodeTableRef), pathFromBase: [...join.pathFromBase, childNodeTableRef] }); } } let select2 = join.select; if (select2 === "*") { const [schemaName, tableName] = splitTableRef(join.tableRef); const table = findTableFromSchemas( databaseSchemas, schemaName, tableName ); select2 = table.columns.map(({ name }) => name); } return { ...join, select: select2, joins: childJoins.map(buildTree) }; }(flattenedJoinDefs[0]); const select = function extractSelects(join) { const [schemaName, tableName] = splitTableRef(join.tableRef); const table = findTableFromSchemas(databaseSchemas, schemaName, tableName); return [ ...table.columns.map((column) => getSelectForColumn(table, join.joinAlias, column)).filter((column) => join.select.includes(column.name)), ...join.joins.flatMap(extractSelects) ]; }(joinTree); const conditions = function extractConditions(join) { const [schemaName, tableName] = splitTableRef(join.tableRef); const table = findTableFromSchemas(databaseSchemas, schemaName, tableName); return [ ...join.where ? [getWhereClauseFromConditions(table, join.joinAlias, join.where)] : [], ...join.joins.flatMap(extractConditions) ]; }(joinTree); const orderBy = function extractOrderBy(join, priorities) { return [ ...join.orderBy ? join.orderBy.map(({ column, direction, priority }) => { if (priorities && priorities.length >= 1 && !priority) { throw new Error( "Missing 'priority' field for orderBy. 'priority' must be specified when multiple orderBy conditions exist in the query." ); } if (priority && priorities && priorities.includes(priority)) { throw new Error( `Duplicate 'priority' field for orderBy: ${priority}` ); } return { joinAlias: join.joinAlias, column, order: direction, priority: priority || 0 }; }) : [], ...join.joins.flatMap( (childJoin) => extractOrderBy(childJoin, priorities) ) ]; }(joinTree); let fromClause = `${joinTree.tableRef} AS ${joinTree.joinAlias}`; const primaryKeyRefs = []; function traverseTree(thisJoin) { const [schemaName, tableName] = splitTableRef(thisJoin.tableRef); const thisTable = findTableFromSchemas( databaseSchemas, schemaName, tableName ); primaryKeyRefs.push( ...thisTable.columns.filter((column) => column.includedInPrimaryKey).map((column) => `${thisJoin.joinAlias}.${column.name}`) ); for (const childJoin of thisJoin.joins) { const [childSchemaName, childTableName] = splitTableRef( childJoin.tableRef ); const childTable = findTableFromSchemas( databaseSchemas, childSchemaName, childTableName ); const referenceFromThisTableToChildTable = thisTable.columns.flatMap( (column) => column.foreignKeys.map((ref) => ({ ...ref, columnName: column.name })) ).find( (reference) => reference.foreignSchemaName === childSchemaName && reference.foreignTableName === childTableName ); if (referenceFromThisTableToChildTable) { fromClause += ` INNER JOIN ${childSchemaName}.${childTableName} AS ${childJoin.joinAlias} ON ${childJoin.joinAlias}.${referenceFromThisTableToChildTable.foreignColumnName} = ${thisJoin.joinAlias}.${referenceFromThisTableToChildTable.columnName}`; } else { const referenceFromChildTableToThisTable = childTable.columns.flatMap( (column) => column.foreignKeys.map((ref) => ({ ...ref, columnName: column.name })) ).find( (reference) => reference.foreignSchemaName === schemaName && reference.foreignTableName === tableName ); if (!referenceFromChildTableToThisTable) { throw new Error( `Could not find reference from ${childSchemaName}.${childTableName} to ${schemaName}.${tableName}. This should never happen.` ); } fromClause += ` INNER JOIN ${childSchemaName}.${childTableName} AS ${childJoin.joinAlias} ON ${childJoin.joinAlias}.${referenceFromChildTableToThisTable.columnName} = ${thisJoin.joinAlias}.${referenceFromChildTableToThisTable.foreignColumnName}`; } traverseTree(childJoin); } } traverseTree(joinTree); const sql = `SELECT ${select.map( (column) => `${column.ref} AS ${column.outerAlias}` )} FROM ${fromClause} ${conditions.length >= 1 ? `WHERE ${conditions.join(" AND ")}` : ""} ${primaryKeyRefs.length >= 1 ? `GROUP BY ${primaryKeyRefs.join(", ")}` : ""} ${orderBy.length >= 1 ? `ORDER BY ${orderBy.map( (column) => `${column.joinAlias}.${column.column} ${column.order === "asc" ? "ASC" : "DESC"}` )}` : ""}`; const joinsList = function flattenJoinTree(join, parent = null) { const joinWithParent = { ...join, parent, childJoins: join.joins.map((child) => ({ tableRef: child.tableRef, joinAlias: child.joinAlias })) }; return [ joinWithParent, ...join.joins.flatMap((child) => flattenJoinTree(child, joinWithParent)) ]; }(joinTree); const preparedQuery = { ...query, base: joinTree }; return { preparedQuery, joinsList, sql }; } // src/runQuery.ts async function runQuery(databaseDriver, databaseSchemas, query) { const { sql: preparedSql, preparedQuery, joinsList } = await prepareQuery(databaseSchemas, query); let sql = preparedSql; if (query.limit) { sql += ` LIMIT ${query.limit}`; } const execResult = await databaseDriver.exec(sql).catch((err) => { console.error(`Error while executing SQL: ${sql} . Error below`); throw err; }); return { meta: { preparedQuery, joinsList }, sql, rows: execResult.map((row) => { return joinsList.map((join) => { const [schemaName, tableName] = splitTableRef(join.tableRef); const table = findTableFromSchemas( databaseSchemas, schemaName, tableName ); const columns = table.columns.filter( (column) => join.select === "*" ? true : join.select.includes(column.name) ).map((column) => ({ name: column.name, alias: getSelectForColumn(table, "THIS_CAN_BE_ANYTHING", column).outerAlias })); return { joinAlias: join.joinAlias, tableRef: join.tableRef, data: Object.fromEntries( columns.map((column) => { return [column.name, row[column.alias]]; }) ) }; }); }) }; } // src/getCountForQuery.ts async function getCountForQuery(databaseDriver, databaseSchemas, query) { const { sql, preparedQuery } = await prepareQuery(databaseSchemas, query); const rows = await databaseDriver.exec( `SELECT COUNT(*) AS count FROM (${sql})` ); const count = Number(rows[0].count); return { count, meta: { preparedQuery } }; } // src/getRelationsForTable.ts var import_graph_data_structure3 = require("graph-data-structure"); function getRelationsForTable(databaseSchemas, schemaName, tableName, maxJoins) { const table = findTableFromSchemas(databaseSchemas, schemaName, tableName); if (table.columns.every( (column) => column.foreignKeys.length === 0 && column.foreignKeyReferences.length === 0 )) { throw new Error( `No relationships found for table ${schemaName}.${tableName}` ); } const graph = makeGraphForDatabase(databaseSchemas); return databaseSchemas.flatMap( (schema) => schema.tables.filter(({ name }) => name !== tableName).map((_table) => ({ ..._table, schemaName: schema.name })) ).map((foreignTable) => { try { const { weight: shortestJoinPath } = (0, import_graph_data_structure3.shortestPath)( graph, `${schemaName}.${tableName}`, `${foreignTable.schemaName}.${foreignTable.name}` ); if (maxJoins && shortestJoinPath > maxJoins) { return null; } return { ...foreignTable, schemaName: foreignTable.schemaName, shortestJoinPath }; } catch { return null; } }).filter((_table) => _table !== null); } // src/getEntitiesAndJunctions.ts var import_lodash_es = require("lodash-es"); var import_pluralize = __toESM(require("pluralize"), 1); var getRef = (table) => `${table.schemaName}.${table.name}`; function getEntitiesAndJunctions(schemaRelationships) { const allTables = schemaRelationships.flatMap( (schema) => schema.tables.map((table) => ({ ...table, schemaName: schema.name })) ); const junctions = allTables.filter((table) => { const columnsWithForeignKeys = table.columns.filter((column) => { const foreignKeysPointingAtOtherTables = column.foreignKeys.filter( (foreignKey) => !(foreignKey.foreignSchemaName === table.schemaName && foreignKey.foreignTableName === table.name) ); return foreignKeysPointingAtOtherTables.length > 0; }); if (columnsWithForeignKeys.length < 2) { return false; } const tableNameResemblesJunction = columnsWithForeignKeys.every( (column) => { const [assumedEntityName] = (0, import_lodash_es.snakeCase)(column.name).split("_"); return assumedEntityName && (table.name.includes(assumedEntityName) || table.name.includes((0, import_pluralize.default)(assumedEntityName))); } ); return tableNameResemblesJunction; }).map(getRef); return { junctions, entities: allTables.map(getRef).filter((ref) => !junctions.includes(ref)) }; } // src/util/isConditionComplete.ts function isConditionComplete(condition) { if (isListCondition(condition)) { return Boolean( condition.column && condition.operator && condition.values.length >= 1 ); } if (isValueCondition(condition)) { return Boolean(condition.column && condition.operator && condition.value); } return condition.conditions.every(isConditionComplete); } // src/util/parseToCompleteCondition.ts function parseToCompleteCondition(condition) { if (isBooleanCondition(condition)) { const completeConditions = condition.conditions.filter(isConditionComplete); if (completeConditions.length === 0) { return null; } return { ...condition, conditions: completeConditions }; } return isConditionComplete(condition) ? condition : null; } // src/util/areQueriesEqual.ts function areQueriesEqual(schemas, queryA, queryB) { const convertSelect = (join) => { if (typeof join.select === "string") { const [schemaName, tableName] = splitTableRef(join.tableRef); const table = findTableFromSchemas(schemas, schemaName, tableName); return table.columns.map(({ name }) => name); } return join.select; }; const areJoinsEqual = (joinA, joinB) => { if (!joinB) { return false; } return joinA.tableRef === joinB.tableRef && convertSelect(joinA).join(",") === convertSelect(joinB).join(",") && JSON.stringify(joinA.via || []) === JSON.stringify(joinB.via || []) && JSON.stringify(joinA.where || {}) === JSON.stringify(joinB.where || {}) && JSON.stringify(joinA.orderBy || []) === JSON.stringify(joinB.orderBy || []) && (joinA.joins || []).every( (subJoin, i) => areJoinsEqual(subJoin, (joinB.joins || [])[i]) ); }; return areJoinsEqual(queryA.base, queryB.base); } // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { ConditionOperator, areQueriesEqual, booleanConditionOperators, findTableFromSchemas, getCountForQuery, getEntitiesAndJunctions, getRelationsForTable, getSchemas, isBooleanCondition, isConditionComplete, isListCondition, isValueCondition, listConditionOperators, parseToCompleteCondition, runQuery, splitTableRef, valueConditionOperators });