google-sheet-as-sql
Version:
Use Google Sheets like an SQL database with full CRUD, filters, sorting, and more.
988 lines (866 loc) • 34.4 kB
JavaScript
const { google } = require('googleapis');
class GoogleSheetDB {
constructor(credentials, sheetId, sheetName) {
if (!credentials) throw new Error('Missing Google credentials JSON');
this.sheetId = sheetId;
this.sheetName = sheetName;
this.auth = new google.auth.JWT(
credentials.client_email,
null,
credentials.private_key,
['https://www.googleapis.com/auth/spreadsheets']
);
this.sheets = google.sheets({ version: 'v4', auth: this.auth });
}
async _getHeaders() {
const res = await this.sheets.spreadsheets.values.get({
spreadsheetId: this.sheetId,
range: `${this.sheetName}!1:1`,
});
return res.data.values[0];
}
async _getSheetData() {
const res = await this.sheets.spreadsheets.values.get({
spreadsheetId: this.sheetId,
range: this.sheetName,
});
const rows = res.data.values || [];
if (rows.length === 0) return [];
const headers = rows[0];
return rows.slice(1).map((row, i) => {
const obj = {};
headers.forEach((key, j) => {
obj[key] = row[j] || '';
});
obj._row = i + 2;
return obj;
});
}
_parseValue(value, isDate) {
if (isDate) return new Date(value).getTime();
if (!isNaN(value)) return parseFloat(value);
return value.toString().toLowerCase();
}
_applyFilter(data, where) {
return data.filter(row => {
return Object.entries(where).every(([key, condition]) => {
const rowVal = row[key] || '';
const isDate = key.toLowerCase().includes('date');
if (typeof condition === 'object' && condition !== null) {
const { op, value } = condition;
const a = this._parseValue(rowVal, isDate);
const b = this._parseValue(value, isDate);
switch (op) {
case '>': return a > b;
case '<': return a < b;
case '>=': return a >= b;
case '<=': return a <= b;
case '!=': return a != b;
case '=': return a == b;
case 'contains': return rowVal.toLowerCase().includes(String(value).toLowerCase());
default: return false;
}
} else {
return rowVal == condition;
}
});
});
}
_applySorting(data, orderBy = []) {
return data.sort((a, b) => {
for (let { column, direction } of orderBy) {
const isDate = column.toLowerCase().includes('date');
const valA = this._parseValue(a[column], isDate);
const valB = this._parseValue(b[column], isDate);
if (valA < valB) return direction === 'desc' ? 1 : -1;
if (valA > valB) return direction === 'desc' ? -1 : 1;
}
return 0;
});
}
_applySelectFields(data, fields) {
return data.map(row => {
const newObj = {};
fields.forEach(f => newObj[f] = row[f]);
return newObj;
});
}
async createTable(columns = []) {
if (!columns || !Array.isArray(columns) || columns.length === 0) {
throw new Error('You must pass an array of column names');
}
try {
// Check if sheet already exists
const metadata = await this.sheets.spreadsheets.get({
spreadsheetId: this.sheetId,
});
const existingSheet = metadata.data.sheets.find(
sheet => sheet.properties.title === this.sheetName
);
// If sheet does not exist, create it
if (!existingSheet) {
await this.sheets.spreadsheets.batchUpdate({
spreadsheetId: this.sheetId,
requestBody: {
requests: [
{
addSheet: {
properties: {
title: this.sheetName,
},
},
},
],
},
});
console.log(`Sheet "${this.sheetName}" created.`);
} else {
console.log(`Sheet "${this.sheetName}" already exists.`);
}
// Write headers to row 1
await this.sheets.spreadsheets.values.update({
spreadsheetId: this.sheetId,
range: `${this.sheetName}!A1`,
valueInputOption: 'USER_ENTERED',
resource: {
values: [columns],
},
});
return {
success: true,
message: 'Table created with headers.',
columns,
};
} catch (err) {
console.error('Error in createTable():', err.message);
return { success: false, error: err.message };
}
}
async select(where = {}, options = {}) {
let data = await this._getSheetData();
data = this._applyFilter(data, where);
if (options.orderBy) data = this._applySorting(data, options.orderBy);
if (options.offset) data = data.slice(options.offset);
if (options.limit) data = data.slice(0, options.limit);
if (options.selectFields) data = this._applySelectFields(data, options.selectFields);
return data;
}
async insertOne(rowObj) {
const headers = await this._getHeaders();
const row = headers.map(h => rowObj[h] || '');
// Restrict the range to only the columns in your header (e.g., A to last header column)
const lastColLetter = String.fromCharCode(65 + headers.length - 1); // e.g., 'C' for 3 headers
const range = `${this.sheetName}!A:${lastColLetter}`;
const res = await this.sheets.spreadsheets.values.append({
spreadsheetId: this.sheetId,
range,
valueInputOption: 'USER_ENTERED',
resource: { values: [row] },
});
return {
success: true,
updatedRange: res.data.updates.updatedRange,
insertedData: rowObj,
};
}
async insertMany(data) {
const headers = await this._getHeaders();
const rows = [];
const dataArray = Array.isArray(data) ? data : [data];
for (const rowObj of dataArray) {
const row = headers.map(h => rowObj[h] || '');
rows.push(row);
}
const lastColLetter = String.fromCharCode(65 + headers.length - 1);
const range = `${this.sheetName}!A:${lastColLetter}`;
const res = await this.sheets.spreadsheets.values.append({
spreadsheetId: this.sheetId,
range,
valueInputOption: 'USER_ENTERED',
resource: { values: rows },
});
return {
success: true,
insertedCount: rows.length,
updatedRange: res.data.updates.updatedRange,
insertedData: dataArray,
};
}
async update(where, newData) {
const data = await this._getSheetData();
const headers = Object.keys(data[0] || {}).filter(h => h !== '_row');
const matching = this._applyFilter(data, where);
const updated = [];
for (const row of matching) {
const updatedRow = headers.map(h => newData[h] ?? row[h]);
const range = `${this.sheetName}!A${row._row}:${String.fromCharCode(65 + headers.length - 1)}${row._row}`;
await this.sheets.spreadsheets.values.update({
spreadsheetId: this.sheetId,
range,
valueInputOption: 'USER_ENTERED',
resource: { values: [updatedRow] },
});
updated.push({ row: row._row, newData: updatedRow });
}
return {
success: true,
updatedCount: updated.length,
updatedRows: updated,
};
}
async updateOrInsert(where, newData) {
// Check if any row matches the where condition
const existingRows = await this.select(where);
if (existingRows.length > 0) {
// If matching rows exist, update them with newData
return await this.update(where, newData);
} else {
// If no matching row, merge where and newData to form a new row and insert it
const newRow = { ...where, ...newData };
return await this.insertOne(newRow);
}
}
async insertBeforeRow(where, newData) {
// Find the first row that matches the where condition
const matchingRows = await this.select(where);
if (matchingRows.length === 0) {
// If no matching row is found, simply insert newData at the end.
return await this.insertOne(newData);
}
// Get the target row number where the matching row is found.
const targetRow = matchingRows[0];
const targetRowNumber = targetRow._row; // e.g. if 'Alice' is in row 5, targetRowNumber = 5
// Retrieve sheet information to perform row insertion
const metadata = await this.sheets.spreadsheets.get({
spreadsheetId: this.sheetId,
});
const sheetInfo = metadata.data.sheets.find(
sheet => sheet.properties.title === this.sheetName
);
if (!sheetInfo) throw new Error(`Sheet ${this.sheetName} not found`);
const sheetTabId = sheetInfo.properties.sheetId;
// Calculate the zero-indexed insertion index (targetRowNumber - 1)
const insertionIndex = targetRowNumber - 1;
// Insert a new row at the target index (this pushes the target row down)
await this.sheets.spreadsheets.batchUpdate({
spreadsheetId: this.sheetId,
requestBody: {
requests: [
{
insertDimension: {
range: {
sheetId: sheetTabId,
dimension: 'ROWS',
startIndex: insertionIndex,
endIndex: insertionIndex + 1,
},
inheritFromBefore: true,
},
},
],
},
});
// Get headers and build the new row array
const headers = await this._getHeaders();
const newRow = headers.map(h => newData[h] || '');
// Update the newly inserted row (which now takes the targetRowNumber)
const range = `${this.sheetName}!A${targetRowNumber}:${String.fromCharCode(65 + headers.length - 1)}${targetRowNumber}`;
await this.sheets.spreadsheets.values.update({
spreadsheetId: this.sheetId,
range,
valueInputOption: 'USER_ENTERED',
resource: { values: [newRow] },
});
return { success: true, action: 'inserted', row: targetRowNumber, newData };
}
async replaceBeforeRow(where, newData) {
// Find the first row that matches the where condition (e.g., { name: 'Alice' })
const matchingRows = await this.select(where);
if (matchingRows.length === 0) {
// If no matching row is found, just insert the newData at the end.
return await this.insertOne(newData);
}
const targetRow = matchingRows[0];
let insertRowNumber = targetRow._row; // This is the row where "Alice" is found
// Get the headers for constructing row arrays
const headers = await this._getHeaders();
// If the target row is the first data row (row 2), we cannot update row 1 (the header)
if (insertRowNumber === 2) {
// Insert a new row at position 2 (which pushes the target row down)
const metadata = await this.sheets.spreadsheets.get({ spreadsheetId: this.sheetId });
const sheetInfo = metadata.data.sheets.find(
sheet => sheet.properties.title === this.sheetName
);
if (!sheetInfo) throw new Error(`Sheet ${this.sheetName} not found`);
const sheetTabId = sheetInfo.properties.sheetId;
// Insert one row at index 1 (0-indexed for row 2)
await this.sheets.spreadsheets.batchUpdate({
spreadsheetId: this.sheetId,
requestBody: {
requests: [
{
insertDimension: {
range: {
sheetId: sheetTabId,
dimension: 'ROWS',
startIndex: 1,
endIndex: 2,
},
inheritFromBefore: true,
},
},
],
},
});
// Update the newly inserted row (row 2) with newData
const newRow = headers.map(h => newData[h] || '');
const range = `${this.sheetName}!A2:${String.fromCharCode(65 + headers.length - 1)}2`;
await this.sheets.spreadsheets.values.update({
spreadsheetId: this.sheetId,
range,
valueInputOption: 'USER_ENTERED',
resource: { values: [newRow] },
});
return { success: true, action: 'inserted', row: 2, newData };
} else {
// Determine the row immediately before the target row
const rowBeforeNumber = insertRowNumber - 1;
const sheetData = await this._getSheetData();
const rowBefore = sheetData.find(row => row._row === rowBeforeNumber);
if (rowBefore) {
// If a row exists before the target row, update that row with newData.
const updatedRow = headers.map(h => (newData[h] !== undefined ? newData[h] : rowBefore[h]));
const range = `${this.sheetName}!A${rowBeforeNumber}:${String.fromCharCode(65 + headers.length - 1)}${rowBeforeNumber}`;
await this.sheets.spreadsheets.values.update({
spreadsheetId: this.sheetId,
range,
valueInputOption: 'USER_ENTERED',
resource: { values: [updatedRow] },
});
return { success: true, action: 'updated', row: rowBeforeNumber, newData };
} else {
// No row exists immediately before the target row – insert a new row there.
const metadata = await this.sheets.spreadsheets.get({ spreadsheetId: this.sheetId });
const sheetInfo = metadata.data.sheets.find(
sheet => sheet.properties.title === this.sheetName
);
if (!sheetInfo) throw new Error(`Sheet ${this.sheetName} not found`);
const sheetTabId = sheetInfo.properties.sheetId;
// Insert a new row at the correct position.
// Note: The API uses zero-indexed indices. To insert before row `rowBeforeNumber`,
// set startIndex to rowBeforeNumber - 1.
await this.sheets.spreadsheets.batchUpdate({
spreadsheetId: this.sheetId,
requestBody: {
requests: [
{
insertDimension: {
range: {
sheetId: sheetTabId,
dimension: 'ROWS',
startIndex: rowBeforeNumber - 1,
endIndex: rowBeforeNumber,
},
inheritFromBefore: true,
},
},
],
},
});
// Now update the inserted row with newData.
const newRow = headers.map(h => newData[h] || '');
const range = `${this.sheetName}!A${rowBeforeNumber}:${String.fromCharCode(65 + headers.length - 1)}${rowBeforeNumber}`;
await this.sheets.spreadsheets.values.update({
spreadsheetId: this.sheetId,
range,
valueInputOption: 'USER_ENTERED',
resource: { values: [newRow] },
});
return { success: true, action: 'inserted', row: rowBeforeNumber, newData };
}
}
}
async insertAfterRow(where, newData) {
// Find the first row that matches the where condition
const matchingRows = await this.select(where);
if (matchingRows.length === 0) {
// If no matching row is found, simply insert newData at the end.
return await this.insertOne(newData);
}
// Get the target row number where the matching row is found.
const targetRow = matchingRows[0];
const targetRowNumber = targetRow._row; // e.g. if 'Alice' is in row 5, targetRowNumber = 5
// Retrieve sheet information to perform row insertion
const metadata = await this.sheets.spreadsheets.get({
spreadsheetId: this.sheetId,
});
const sheetInfo = metadata.data.sheets.find(
sheet => sheet.properties.title === this.sheetName
);
if (!sheetInfo) throw new Error(`Sheet ${this.sheetName} not found`);
const sheetTabId = sheetInfo.properties.sheetId;
// Calculate the zero-indexed insertion index.
// For after insertion, if target row is at one-indexed row X, then we insert at index X
// so the new row becomes one-indexed row X+1.
const insertionIndex = targetRowNumber;
// Insert a new row at the insertion index (this pushes the target row's subsequent rows down)
await this.sheets.spreadsheets.batchUpdate({
spreadsheetId: this.sheetId,
requestBody: {
requests: [
{
insertDimension: {
range: {
sheetId: sheetTabId,
dimension: 'ROWS',
startIndex: insertionIndex,
endIndex: insertionIndex + 1,
},
inheritFromBefore: true,
},
},
],
},
});
// New inserted row number is targetRowNumber + 1
const newRowNumber = targetRowNumber + 1;
// Get headers and build the new row array
const headers = await this._getHeaders();
const newRow = headers.map(h => newData[h] || '');
// Update the newly inserted row with newData
const range = `${this.sheetName}!A${newRowNumber}:${String.fromCharCode(65 + headers.length - 1)}${newRowNumber}`;
await this.sheets.spreadsheets.values.update({
spreadsheetId: this.sheetId,
range,
valueInputOption: 'USER_ENTERED',
resource: { values: [newRow] },
});
return { success: true, action: 'inserted', row: newRowNumber, newData };
}
async updateOrInsertBeforeRow(where, uniqueKey, newData, ignoreEmptyRows = false) {
// Find the first row that matches the "where" condition.
const matchingRows = await this.select(where);
if (matchingRows.length === 0) {
// If no matching row is found, simply insert newData at the end.
return await this.insertOne(newData);
}
const targetRow = matchingRows[0];
const targetRowNumber = targetRow._row; // e.g., if the target row is 10
// Get headers for constructing the row.
const headers = await this._getHeaders();
// Ensure newData has the uniqueKey property.
if (!newData.hasOwnProperty(uniqueKey)) {
throw new Error(`newData must contain the unique key '${uniqueKey}'`);
}
// Retrieve all sheet data.
const sheetData = await this._getSheetData();
// Filter for rows above the target row.
const rowsBefore = sheetData.filter(row => row._row < targetRowNumber);
// First, check if any row above has the same unique key value.
const existingRow = rowsBefore.find(row => row[uniqueKey] === newData[uniqueKey]);
if (existingRow) {
const rowNumber = existingRow._row;
const updatedRow = headers.map(h => newData[h] !== undefined ? newData[h] : existingRow[h]);
const range = `${this.sheetName}!A${rowNumber}:${String.fromCharCode(65 + headers.length - 1)}${rowNumber}`;
await this.sheets.spreadsheets.values.update({
spreadsheetId: this.sheetId,
range,
valueInputOption: 'USER_ENTERED',
resource: { values: [updatedRow] },
});
return { success: true, action: 'updated', row: rowNumber, newData };
}
// If ignoreEmptyRows is true, check for contiguous empty rows immediately above the target row.
if (ignoreEmptyRows) {
// Build a map for quick access by row number.
const rowMap = {};
sheetData.forEach(row => { rowMap[row._row] = row; });
let emptyRowCandidate = null;
// Iterate downward from targetRowNumber - 1 to row 2 (first data row).
// We want the topmost row in the contiguous empty block.
for (let r = targetRowNumber - 1; r >= 2; r--) {
// If the row isn't returned, assume it's empty.
const rowObj = rowMap[r];
const isEmpty = rowObj
? headers.every(h => !rowObj[h] || rowObj[h] === '')
: true;
if (isEmpty) {
emptyRowCandidate = r; // update candidate with the current (lower) row number
} else {
// As soon as we hit a non-empty row, break out.
break;
}
}
if (emptyRowCandidate !== null) {
// Update the empty row found (i.e. the row immediately after the last non-empty row).
const rowNumber = emptyRowCandidate;
const updatedRow = headers.map(h => newData[h] !== undefined ? newData[h] : '');
const range = `${this.sheetName}!A${rowNumber}:${String.fromCharCode(65 + headers.length - 1)}${rowNumber}`;
await this.sheets.spreadsheets.values.update({
spreadsheetId: this.sheetId,
range,
valueInputOption: 'USER_ENTERED',
resource: { values: [updatedRow] },
});
return { success: true, action: 'updated (empty row)', row: rowNumber, newData };
}
}
// If no existing row or empty row is found, insert a new row before the target row.
const metadata = await this.sheets.spreadsheets.get({ spreadsheetId: this.sheetId });
const sheetInfo = metadata.data.sheets.find(sheet => sheet.properties.title === this.sheetName);
if (!sheetInfo) throw new Error(`Sheet ${this.sheetName} not found`);
const sheetTabId = sheetInfo.properties.sheetId;
// Calculate the zero-indexed insertion index (targetRowNumber - 1).
const insertionIndex = targetRowNumber - 1;
await this.sheets.spreadsheets.batchUpdate({
spreadsheetId: this.sheetId,
requestBody: {
requests: [{
insertDimension: {
range: {
sheetId: sheetTabId,
dimension: 'ROWS',
startIndex: insertionIndex,
endIndex: insertionIndex + 1,
},
inheritFromBefore: true,
},
}],
},
});
// After insertion, the new row occupies the targetRowNumber.
const newRowNumber = targetRowNumber;
const newRow = headers.map(h => newData[h] || '');
const range = `${this.sheetName}!A${newRowNumber}:${String.fromCharCode(65 + headers.length - 1)}${newRowNumber}`;
await this.sheets.spreadsheets.values.update({
spreadsheetId: this.sheetId,
range,
valueInputOption: 'USER_ENTERED',
resource: { values: [newRow] },
});
return { success: true, action: 'inserted', row: newRowNumber, newData };
}
async updateOrInsertAfterRow(where, uniqueKey, newData) {
// Find the first row that matches the "where" condition.
const matchingRows = await this.select(where);
if (matchingRows.length === 0) {
// If no matching row is found, insert newData at the end.
return await this.insertOne(newData);
}
const targetRow = matchingRows[0];
const targetRowNumber = targetRow._row; // e.g. if the target row is 5
// Get headers for constructing the row.
const headers = await this._getHeaders();
// Ensure newData has the uniqueKey property.
if (!newData.hasOwnProperty(uniqueKey)) {
throw new Error(`newData must contain the unique key '${uniqueKey}'`);
}
// Retrieve all sheet data and filter for rows after the target row.
const sheetData = await this._getSheetData();
const rowsAfter = sheetData.filter(row => row._row > targetRowNumber);
// Look for a row among those after that has the same unique key value.
const existingRow = rowsAfter.find(row => row[uniqueKey] === newData[uniqueKey]);
if (existingRow) {
// Update the existing row with newData.
const rowNumber = existingRow._row;
const updatedRow = headers.map(h => newData[h] !== undefined ? newData[h] : existingRow[h]);
const range = `${this.sheetName}!A${rowNumber}:${String.fromCharCode(65 + headers.length - 1)}${rowNumber}`;
await this.sheets.spreadsheets.values.update({
spreadsheetId: this.sheetId,
range,
valueInputOption: 'USER_ENTERED',
resource: { values: [updatedRow] },
});
return { success: true, action: 'updated', row: rowNumber, newData };
} else {
// Otherwise, insert a new row after the target row.
const metadata = await this.sheets.spreadsheets.get({ spreadsheetId: this.sheetId });
const sheetInfo = metadata.data.sheets.find(sheet => sheet.properties.title === this.sheetName);
if (!sheetInfo) throw new Error(`Sheet ${this.sheetName} not found`);
const sheetTabId = sheetInfo.properties.sheetId;
// For after insertion, the zero-indexed insertion index is the targetRowNumber.
const insertionIndex = targetRowNumber;
await this.sheets.spreadsheets.batchUpdate({
spreadsheetId: this.sheetId,
requestBody: {
requests: [{
insertDimension: {
range: {
sheetId: sheetTabId,
dimension: 'ROWS',
startIndex: insertionIndex,
endIndex: insertionIndex + 1,
},
inheritFromBefore: true,
},
}],
},
});
// After insertion, the new row occupies targetRowNumber + 1.
const newRowNumber = targetRowNumber + 1;
const newRow = headers.map(h => newData[h] || '');
const range = `${this.sheetName}!A${newRowNumber}:${String.fromCharCode(65 + headers.length - 1)}${newRowNumber}`;
await this.sheets.spreadsheets.values.update({
spreadsheetId: this.sheetId,
range,
valueInputOption: 'USER_ENTERED',
resource: { values: [newRow] },
});
return { success: true, action: 'inserted', row: newRowNumber, newData };
}
}
async delete(where) {
const data = await this._getSheetData();
const matching = this._applyFilter(data, where);
const deleted = [];
for (const row of matching) {
const range = `${this.sheetName}!A${row._row}:${String.fromCharCode(65 + Object.keys(row).length - 2)}${row._row}`;
await this.sheets.spreadsheets.values.clear({
spreadsheetId: this.sheetId,
range,
});
deleted.push(row._row);
}
return {
success: true,
deletedCount: deleted.length,
deletedRows: deleted,
};
}
// Drop the entire sheet tab
async dropTable() {
try {
const metadata = await this.sheets.spreadsheets.get({
spreadsheetId: this.sheetId,
});
const sheet = metadata.data.sheets.find(
sheet => sheet.properties.title === this.sheetName
);
if (!sheet) {
return { success: false, message: 'Sheet not found' };
}
const sheetId = sheet.properties.sheetId;
await this.sheets.spreadsheets.batchUpdate({
spreadsheetId: this.sheetId,
requestBody: {
requests: [
{
deleteSheet: {
sheetId,
},
},
],
},
});
return { success: true, message: `Sheet "${this.sheetName}" dropped.` };
} catch (err) {
return { success: false, error: err.message };
}
}
// Truncate: Clear all rows except the header
async truncateTable() {
try {
// Clear everything except header row (row 1)
const headers = await this._getHeaders();
const range = `${this.sheetName}!A2:Z1000`; // adjust range if needed
await this.sheets.spreadsheets.values.clear({
spreadsheetId: this.sheetId,
range,
});
return {
success: true,
message: `Table "${this.sheetName}" truncated (data cleared, headers kept).`,
headers,
};
} catch (err) {
return { success: false, error: err.message };
}
}
// List all sheet/tab names
async getTables() {
try {
const metadata = await this.sheets.spreadsheets.get({
spreadsheetId: this.sheetId,
});
const sheetNames = metadata.data.sheets.map(
sheet => sheet.properties.title
);
return {
success: true,
tables: sheetNames,
};
} catch (err) {
return { success: false, error: err.message };
}
}
// Show details of the current table (tab)
async showTableDetail() {
try {
const headers = await this._getHeaders();
return {
success: true,
sheetName: this.sheetName,
columns: headers,
};
} catch (err) {
return { success: false, error: err.message };
}
}
_sqlToNosqlConverter(sqlQuery) {
const query = sqlQuery.trim();
const upperQuery = query.toUpperCase();
// CREATE TABLE: e.g., "CREATE TABLE myTable (col1, col2, col3)"
if (upperQuery.startsWith('CREATE TABLE')) {
const match = query.match(/CREATE\s+TABLE\s+\w+\s*\(([^)]+)\)/i);
if (!match) throw new Error('Invalid CREATE TABLE syntax');
const columns = match[1].split(',').map(col => col.trim());
return { operation: 'createTable', args: { columns } };
}
// DROP TABLE: e.g., "DROP TABLE myTable"
if (upperQuery.startsWith('DROP TABLE')) {
return { operation: 'dropTable', args: {} };
}
// TRUNCATE TABLE: e.g., "TRUNCATE TABLE myTable"
if (upperQuery.startsWith('TRUNCATE TABLE')) {
return { operation: 'truncateTable', args: {} };
}
// INSERT INTO: e.g., "INSERT INTO myTable (col1, col2) VALUES ('val1', 'val2')"
if (upperQuery.startsWith('INSERT INTO')) {
const match = query.match(/INSERT\s+INTO\s+\w+\s*\(([^)]+)\)\s+VALUES\s*\(([^)]+)\)/i);
if (!match) throw new Error('Invalid INSERT INTO syntax');
const columns = match[1].split(',').map(col => col.trim());
const values = match[2]
.split(',')
.map(val => val.trim().replace(/^['"]|['"]$/g, ''));
const obj = {};
columns.forEach((col, index) => {
obj[col] = values[index];
});
return { operation: 'insertOne', args: { obj } };
}
// SELECT: e.g., "SELECT * FROM myTable WHERE col1 = 'val1' AND col2 = 'val2' ORDER BY col1 DESC LIMIT 10 OFFSET 5"
if (upperQuery.startsWith('SELECT')) {
const selectMatch = query.match(/SELECT\s+(.*?)\s+FROM\s+\w+/i);
if (!selectMatch) throw new Error('Invalid SELECT syntax');
const fieldsPart = selectMatch[1].trim();
let selectFields = undefined;
if (fieldsPart !== '*' && fieldsPart !== '') {
selectFields = fieldsPart.split(',').map(f => f.trim());
}
let where = {};
const whereMatch = query.match(/WHERE\s+(.+?)(ORDER BY|LIMIT|OFFSET|$)/i);
if (whereMatch) {
const conditionsStr = whereMatch[1].trim();
const conditions = conditionsStr.split(/\s+AND\s+/i);
conditions.forEach(condition => {
const parts = condition.split('=');
if (parts.length === 2) {
const key = parts[0].trim();
const value = parts[1].trim().replace(/^['"]|['"]$/g, '');
where[key] = value;
}
});
}
let options = {};
const orderMatch = query.match(/ORDER BY\s+(.+?)(LIMIT|OFFSET|$)/i);
if (orderMatch) {
const orderStr = orderMatch[1].trim();
const orders = orderStr.split(',').map(order => {
const parts = order.trim().split(/\s+/);
return { column: parts[0], direction: parts[1] ? parts[1].toLowerCase() : 'asc' };
});
options.orderBy = orders;
}
const limitMatch = query.match(/LIMIT\s+(\d+)/i);
if (limitMatch) {
options.limit = parseInt(limitMatch[1], 10);
}
const offsetMatch = query.match(/OFFSET\s+(\d+)/i);
if (offsetMatch) {
options.offset = parseInt(offsetMatch[1], 10);
}
if (selectFields) {
options.selectFields = selectFields;
}
return { operation: 'select', args: { where, options } };
}
// UPDATE: e.g., "UPDATE myTable SET col1 = 'newVal', col2 = 'anotherVal' WHERE col3 = 'val3'"
if (upperQuery.startsWith('UPDATE')) {
const updateMatch = query.match(/UPDATE\s+\w+\s+SET\s+(.+?)\s+WHERE\s+(.+)/i);
if (!updateMatch) throw new Error('Invalid UPDATE syntax');
const setPart = updateMatch[1].trim();
const wherePart = updateMatch[2].trim();
let newData = {};
setPart.split(',').forEach(pair => {
const parts = pair.split('=');
if (parts.length === 2) {
const key = parts[0].trim();
const value = parts[1].trim().replace(/^['"]|['"]$/g, '');
newData[key] = value;
}
});
let where = {};
wherePart.split(/\s+AND\s+/i).forEach(condition => {
const parts = condition.split('=');
if (parts.length === 2) {
const key = parts[0].trim();
const value = parts[1].trim().replace(/^['"]|['"]$/g, '');
where[key] = value;
}
});
return { operation: 'update', args: { where, newData } };
}
// DELETE: e.g., "DELETE FROM myTable WHERE col1 = 'val1'"
if (upperQuery.startsWith('DELETE')) {
const deleteMatch = query.match(/DELETE\s+FROM\s+\w+\s+WHERE\s+(.+)/i);
if (!deleteMatch) throw new Error('Invalid DELETE syntax');
const wherePart = deleteMatch[1].trim();
let where = {};
wherePart.split(/\s+AND\s+/i).forEach(condition => {
const parts = condition.split('=');
if (parts.length === 2) {
const key = parts[0].trim();
const value = parts[1].trim().replace(/^['"]|['"]$/g, '');
where[key] = value;
}
});
return { operation: 'delete', args: { where } };
}
// GET TABLES (non-standard SQL)
if (upperQuery === 'GET TABLES') {
return { operation: 'getTables', args: {} };
}
// SHOW TABLE DETAIL (non-standard SQL)
if (upperQuery === 'SHOW TABLE DETAIL') {
return { operation: 'showTableDetail', args: {} };
}
throw new Error('Unsupported SQL operation');
}
// The exec method that uses the SQL converter and calls the appropriate method
async query(sqlQuery) {
const command = this._sqlToNosqlConverter(sqlQuery);
switch (command.operation) {
case 'createTable':
return await this.createTable(command.args.columns);
case 'dropTable':
return await this.dropTable();
case 'truncateTable':
return await this.truncateTable();
case 'insertOne':
return await this.insertOne(command.args.obj);
case 'select':
return await this.select(command.args.where, command.args.options);
case 'update':
return await this.update(command.args.where, command.args.newData);
case 'delete':
return await this.delete(command.args.where);
case 'getTables':
return await this.getTables();
case 'showTableDetail':
return await this.showTableDetail();
default:
throw new Error('Unsupported operation');
}
}
}
module.exports = GoogleSheetDB;