@freckleface/golembase-tables
Version:
SQL-like table implementation on top of Golem Base
336 lines (335 loc) • 13.2 kB
JavaScript
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));