UNPKG

@freckleface/golembase-tables

Version:

SQL-like table implementation on top of Golem Base

336 lines (335 loc) 13.2 kB
import { transformPOJOToAnnotations } from '@freckleface/golembase-js-transformations'; import pkg from 'node-sql-parser'; const { Parser } = pkg; const encoder = new TextEncoder(); const decoder = new TextDecoder(); export const CreateTableObjToGBCreate = (app, parseResult) => { if (parseResult.sqlType != 'create table' || !parseResult.data) { throw new Error("Invalid input: Expected a 'create table' parse result with data."); } let createTableObj = { app: app, ...parseResult.data }; // Create empty index entries, which will get filled in as we add data for this table for (let index of createTableObj.indexes?.split(',') || []) { createTableObj[`index_${index}`] = ''; } //console.log(createTableObj); const create = { data: encoder.encode(`${createTableObj.type} ${createTableObj.tablename}`), btl: 100, ...transformPOJOToAnnotations(createTableObj) }; return create; }; export const SQLCreateTableToGBCreate = (app, createSql) => { let createTableObj = { app: app, ...parseSql(createSql) }; // Create empty index entries, which will get filled in as we add data for this table for (let index of createTableObj.indexes?.split(',') || []) { createTableObj[`index_${index}`] = ''; } //console.log(createTableObj); const create = { data: encoder.encode(`${createTableObj.type} ${createTableObj.tablename}`), btl: 100, ...transformPOJOToAnnotations(createTableObj) }; return create; }; export const InsertObjToGBCreate = (app, parseResult) => { if (parseResult.sqlType != 'insert' || !parseResult.data) { throw new Error("Invalid input: Expected an 'insert' parse result with data."); } let insertObj = { app: app, ...parseResult.data }; insertObj.app = app; const create = { data: encoder.encode(`${insertObj.type} ${insertObj.tablename}`), btl: 100, ...transformPOJOToAnnotations(insertObj) }; return create; }; export const SQLInsertToGBCreate = (app, insertSQL) => { let insertObj = { app: app, ...parseSql(insertSQL) }; //console.log(insertObj); const create = { data: encoder.encode(`${insertObj.type} ${insertObj.tablename}`), btl: 100, ...transformPOJOToAnnotations(insertObj) }; return create; }; /** * Filters an object to include only specified keys, plus a set of mandatory keys. * * @param select A comma-delimited string of keys to include in the result. * @param obj The source object to filter. * @returns A new object containing only the selected and mandatory keys. */ export const filterObjectBySelect = (select, obj) => { // Get the list of keys to keep from the 'select' string. const keysToKeep = select.split(','); // Initialize the new object with the three always-included fields. const filteredObj = { app: obj.app, type: obj.type, tablename: obj.tablename, }; // Iterate over the keys we want to keep. for (const key of keysToKeep) { // Check if the source object actually has this property before adding it. if (obj.hasOwnProperty(key)) { filteredObj[key] = obj[key]; } } return filteredObj; }; ; export const parseForeignKeyString = (input) => { // Regex to match the pattern and capture the three text parts. const fkRegex = /^.*\|FK:(.*):(.*):(.*)$/; // Use the .match() method to execute the regex against the input string. const match = input.match(fkRegex); // If match is null, the string didn't fit the pattern. if (!match) { return null; } // The captured groups are in the resulting array at indices 1, 2, and 3. // match[0] would be the entire matched string. return { tablename: match[1], // The first captured group: 'departments' localKey: match[2], // The second captured group: 'dept_id' viewKey: match[3], // The third captured group: 'department_name' }; }; /** * Finds all foreign keys in a POJO and builds a query object for each one. * * @param pojo The data object to scan for foreign keys. * @param fks A map of foreign key metadata. * @returns An array of objects, one for each foreign key found. */ export const buildFkQueries = (pojo, fks) => { // Initialize an empty array to store all the results const results = []; for (const [key, value] of Object.entries(pojo)) { // Check if the current key from the POJO exists in our FKs map if (fks[key]) { const fkInfo = fks[key]; const quote = (val) => typeof val === 'string' ? `"${val}"` : val; const appClause = `app=${quote(pojo.app)}`; const typeClause = `type="tabledata"`; const tableClause = `tablename="${fkInfo.tablename}"`; const keyClause = `${fkInfo.localKey}=${quote(value)}`; const assembledQueryString = `${appClause} && ${typeClause} && ${tableClause} && ${keyClause}`; // ✨ Push the new object into the results array instead of returning results.push({ queryString: assembledQueryString, ...fkInfo }); } } // Return the array of all found foreign key queries return results; }; /** * Groups a list of parsed SQL statements into batches. * 'create table' and 'insert' statements are grouped together. * 'select' statements are always in their own batch. * * @param statements An array of ParseResult objects. * @returns The statements grouped into batches. */ export const groupSqlIntoBatches = (statements) => { const batches = []; let currentBatch = []; for (const statement of statements) { if (statement.sqlType === 'select') { // If the current batch has items, push it to the main list first. if (currentBatch.length > 0) { batches.push(currentBatch); } // Reset the current batch. currentBatch = []; // Push the select statement as its own new batch. if (statement.data) { batches.push([statement]); } } else { // For 'create table' or 'insert', just add to the current batch. if (statement.data) { currentBatch.push(statement); } } } // After the loop, if there's anything left in the current batch, add it. if (currentBatch.length > 0) { batches.push(currentBatch); } return batches; }; // The main function that dispatches to the correct parser // ✨ UPDATED: The return type is now 'ParseResult | null' export const parseSql = (sqlString) => { try { const parser = new Parser(); let ast = parser.astify(sqlString); if (Array.isArray(ast)) { ast = ast[0]; } if (!ast) return null; // ✨ UPDATED: The switch now returns the standardized object switch (ast.type) { case 'create': return { sqlType: 'create table', data: parseCreateTable(ast) }; case 'select': return { sqlType: 'select', data: parseSelect(ast) }; case 'insert': return { sqlType: 'insert', data: parseInsert(ast) }; default: throw new Error(`Unsupported SQL statement type: ${ast.type}`); } } catch (e) { if (e instanceof Error) { throw (e.message); } else { throw ('Unknown error'); } } }; // ----------------------------------------------------------------------------- // CREATE TABLE PARSER // ----------------------------------------------------------------------------- function parseCreateTable(ast) { if (ast.keyword !== 'table') return null; const typeMap = { 'INTEGER': 'number', 'TEXT': 'string', 'REAL': 'number', }; const result = { type: 'table', tablename: ast.table[0].table, }; const indexColumns = []; for (const def of ast.create_definitions) { if (def.resource === 'column') { const fieldName = def.column.column; if (fieldName.toLowerCase() === 'tablename' || fieldName.toLowerCase() === 'indexes') { throw new Error(`Column name '${fieldName}' is a reserved word and cannot be used.`); } result[fieldName] = typeMap[def.definition.dataType] || 'unknown'; } else if (def.constraint_type === 'FOREIGN KEY') { const localColumn = def.definition[0].column; if (result[localColumn]) { const refTable = def.reference_definition.table[0].table; const refColumn = def.reference_definition.definition[0].column; let fkString = `|FK:${refTable}:${refColumn}`; const constraintName = def.constraint; if (constraintName && constraintName.includes('__view_as__')) { const viewColumn = constraintName.split('__view_as__')[1]; if (viewColumn) fkString += `:${viewColumn}`; } result[localColumn] += fkString; } } else if (def.resource === 'index') { const indexedColumn = def.definition[0].column; indexColumns.push(indexedColumn); } } if (indexColumns.length > 0) result.indexes = indexColumns.join(','); return result; } // ----------------------------------------------------------------------------- // SELECT PARSER // ----------------------------------------------------------------------------- function parseSelect(ast) { const buildWhereString = (node) => { if (!node) return ''; if (node.type === 'double_quote_string' || node.type === 'single_quote_string') return `"${node.value}"`; if (node.type === 'number') return node.value.toString(); if (node.type === 'column_ref') return node.column; if (node.type === 'binary_expr') { const operatorMap = { 'AND': '&&', 'OR': '||', '=': '=' }; const left = buildWhereString(node.left); const right = buildWhereString(node.right); const operator = operatorMap[node.operator] || node.operator; const expr = `${left} ${operator} ${right}`; return node.parentheses ? `(${expr})` : expr; } return ''; }; const selectedColumns = ast.columns.map((col) => col.expr.column).join(','); const tablename = ast.from[0].table; const typeClause = `type = "tabledata" && tablename = "${tablename}"`; const mainWhereClause = buildWhereString(ast.where); return { select: selectedColumns, tablename: tablename, where: `${typeClause}${mainWhereClause === '' ? '' : ' && '}${mainWhereClause}` }; } // ----------------------------------------------------------------------------- // INSERT PARSER // ----------------------------------------------------------------------------- function parseInsert(ast) { const insertAst = ast; const result = {}; result.type = 'tabledata'; result.tablename = insertAst.table[0].table; const columns = insertAst.columns; const values = insertAst.values[0].value; if (columns.length !== values.length) { throw new Error('Insert statement has a mismatch between columns and values.'); } columns.forEach((colName, index) => { const valueNode = values[index]; result[colName] = valueNode.value; }); return result; } // --- DEMO --- // console.log("--- Parsing INSERT statement ---"); // const insertSql = 'insert into users (user_id, username, dept_id, building, phone_number) values (10, "fred", "ACCT", "central", "800-867-5309")'; // console.log(JSON.stringify(parseSql(insertSql), null, 2)); // console.log("--- Parsing CREATE statements ---"); // const createSql = ` // CREATE TABLE users ( // user_id INTEGER, // username TEXT, // dept_id INTEGER, // building TEXT, // phone_number TEXT, // CONSTRAINT fk__view_as__department_name // FOREIGN KEY (dept_id) REFERENCES departments(dept_id), // INDEX idx_username (username), // INDEX idx_dept_id (dept_id) // ) // `; // console.log(JSON.stringify(parseSql(createSql), null, 2)); // const createSql2 = ` // CREATE TABLE departments ( // dept_id TEXT, // department_name TEXT, // city TEXT, // INDEX idx_dept_id (dept_id) // ) // `; // console.log(JSON.stringify(parseSql(createSql2), null, 2)); // console.log("\n--- Parsing SELECT statement ---"); // const selectSql = 'select username, phone_number from users where username = "fred" and (department = "accounting" or building = "central")'; // console.log(JSON.stringify(parseSql(selectSql), null, 2));