mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-03-08 10:03:31 +01:00
feat(runtime): add LocalSQL adapter for CloudStore interface
TASK-007A: LocalSQL Adapter (Phase 5/Phase 11 shared foundation) - Add LocalSQLAdapter implementing CloudStore interface with SQLite backend - Add QueryBuilder for Parse-style query to SQL translation - Add SchemaManager for table creation, migrations, and exports - Support all CloudStore methods: query, fetch, create, save, delete - Support aggregate, distinct, increment, count operations - Support relations via junction tables - Add schema export to PostgreSQL and Supabase formats - Add comprehensive unit tests for QueryBuilder This adapter enables: - Local offline database for development - Foundation for Phase 11 execution history storage - Schema migration path to production databases
This commit is contained in:
@@ -0,0 +1,587 @@
|
||||
/**
|
||||
* LocalSQLAdapter - SQLite-based CloudStore adapter
|
||||
*
|
||||
* Implements the CloudStore interface using SQLite for local storage.
|
||||
* Provides the same API as the Parse-based CloudStore but stores
|
||||
* data locally using better-sqlite3.
|
||||
*
|
||||
* @module adapters/local-sql/LocalSQLAdapter
|
||||
*/
|
||||
|
||||
const EventEmitter = require('../../../events');
|
||||
const QueryBuilder = require('./QueryBuilder');
|
||||
const SchemaManager = require('./SchemaManager');
|
||||
|
||||
/**
|
||||
* Generate a unique object ID (similar to Parse objectId)
|
||||
*
|
||||
* @returns {string} 10-character alphanumeric ID
|
||||
*/
|
||||
function generateObjectId() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let id = '';
|
||||
for (let i = 0; i < 10; i++) {
|
||||
id += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* LocalSQLAdapter class
|
||||
*
|
||||
* Implements the same interface as CloudStore but uses SQLite
|
||||
*/
|
||||
class LocalSQLAdapter {
|
||||
/**
|
||||
* @param {string} dbPath - Path to SQLite database file
|
||||
* @param {Object} [options] - Configuration options
|
||||
* @param {boolean} [options.autoCreateTables=true] - Auto-create tables on first access
|
||||
* @param {Object} [options.collections] - Collection schemas (same as dbCollections metadata)
|
||||
*/
|
||||
constructor(dbPath, options = {}) {
|
||||
this.dbPath = dbPath;
|
||||
this.options = {
|
||||
autoCreateTables: true,
|
||||
...options
|
||||
};
|
||||
|
||||
this.db = null;
|
||||
this.schemaManager = null;
|
||||
this.events = new EventEmitter();
|
||||
this.events.setMaxListeners(10000);
|
||||
|
||||
// Collection schemas (like CloudStore._collections)
|
||||
this._collections = options.collections || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the database
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async connect() {
|
||||
if (this.db) {
|
||||
return; // Already connected
|
||||
}
|
||||
|
||||
// Dynamic import of better-sqlite3 (Node.js only)
|
||||
// This allows the module to be loaded in browser environments
|
||||
// where better-sqlite3 is not available
|
||||
try {
|
||||
const Database = require('better-sqlite3');
|
||||
this.db = new Database(this.dbPath);
|
||||
|
||||
// Enable WAL mode for better concurrent access
|
||||
this.db.pragma('journal_mode = WAL');
|
||||
|
||||
// Initialize schema manager
|
||||
this.schemaManager = new SchemaManager(this.db);
|
||||
this.schemaManager.ensureSchemaTable();
|
||||
|
||||
// Create tables from collections if provided
|
||||
if (this.options.autoCreateTables && this._collections) {
|
||||
for (const [name, collection] of Object.entries(this._collections)) {
|
||||
if (collection.schema) {
|
||||
this.schemaManager.createTable({
|
||||
name,
|
||||
columns: Object.entries(collection.schema.properties || {}).map(([colName, colDef]) => ({
|
||||
name: colName,
|
||||
type: colDef.type,
|
||||
required: colDef.required,
|
||||
targetClass: colDef.targetClass
|
||||
}))
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to connect to SQLite database: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the database
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async disconnect() {
|
||||
if (this.db) {
|
||||
this.db.close();
|
||||
this.db = null;
|
||||
this.schemaManager = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure table exists (auto-create if needed)
|
||||
*
|
||||
* @private
|
||||
* @param {string} collection - Collection name
|
||||
*/
|
||||
_ensureTable(collection) {
|
||||
if (!this.schemaManager) {
|
||||
throw new Error('Database not connected');
|
||||
}
|
||||
|
||||
// Check if table exists
|
||||
const exists = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = ?").get(collection);
|
||||
|
||||
if (!exists && this.options.autoCreateTables) {
|
||||
// Auto-create table with basic schema
|
||||
this.schemaManager.createTable({
|
||||
name: collection,
|
||||
columns: []
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get schema for a collection
|
||||
*
|
||||
* @private
|
||||
* @param {string} collection - Collection name
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
_getSchema(collection) {
|
||||
if (this._collections[collection]) {
|
||||
return this._collections[collection].schema;
|
||||
}
|
||||
return this.schemaManager?.getTableSchema(collection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a database row to a record object
|
||||
*
|
||||
* @private
|
||||
* @param {Object} row - Database row
|
||||
* @param {string} collection - Collection name
|
||||
* @returns {Object}
|
||||
*/
|
||||
_rowToRecord(row, collection) {
|
||||
if (!row) return null;
|
||||
|
||||
const schema = this._getSchema(collection);
|
||||
const record = {};
|
||||
|
||||
for (const [key, value] of Object.entries(row)) {
|
||||
const colType = schema?.properties?.[key]?.type;
|
||||
record[key] = QueryBuilder.deserializeValue(value, colType);
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// CloudStore Interface Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Subscribe to events
|
||||
*
|
||||
* @param {string} event - Event name
|
||||
* @param {Function} handler - Event handler
|
||||
* @param {Object} [context] - Context for handler
|
||||
*/
|
||||
on(event, handler, context) {
|
||||
this.events.on(event, handler, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from events
|
||||
*
|
||||
* @param {string} [event] - Event name (optional - removes all if not provided)
|
||||
* @param {Function} [handler] - Event handler
|
||||
* @param {Object} [context] - Context
|
||||
*/
|
||||
off(event, handler, context) {
|
||||
if (event) {
|
||||
this.events.off(event, handler, context);
|
||||
} else {
|
||||
this.events.removeAllListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query records
|
||||
*
|
||||
* @param {Object} options - Query options
|
||||
*/
|
||||
query(options) {
|
||||
try {
|
||||
this._ensureTable(options.collection);
|
||||
|
||||
const schema = this._getSchema(options.collection);
|
||||
const { sql, params } = QueryBuilder.buildSelect(options, schema);
|
||||
|
||||
const rows = this.db.prepare(sql).all(...params);
|
||||
const results = rows.map((row) => this._rowToRecord(row, options.collection));
|
||||
|
||||
// Handle count if requested
|
||||
let count;
|
||||
if (options.count) {
|
||||
const { sql: countSQL, params: countParams } = QueryBuilder.buildCount(options, schema);
|
||||
const countRow = this.db.prepare(countSQL).get(...countParams);
|
||||
count = countRow?.count || 0;
|
||||
}
|
||||
|
||||
options.success(results, count);
|
||||
} catch (e) {
|
||||
console.error('LocalSQLAdapter.query error:', e);
|
||||
options.error(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single record
|
||||
*
|
||||
* @param {Object} options - Fetch options
|
||||
*/
|
||||
fetch(options) {
|
||||
try {
|
||||
this._ensureTable(options.collection);
|
||||
|
||||
const sql = `SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "objectId" = ?`;
|
||||
const row = this.db.prepare(sql).get(options.objectId);
|
||||
|
||||
if (!row) {
|
||||
options.error('Object not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const record = this._rowToRecord(row, options.collection);
|
||||
|
||||
options.success(record);
|
||||
|
||||
this.events.emit('fetch', {
|
||||
type: 'fetch',
|
||||
objectId: options.objectId,
|
||||
object: record,
|
||||
collection: options.collection
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('LocalSQLAdapter.fetch error:', e);
|
||||
options.error(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new record
|
||||
*
|
||||
* @param {Object} options - Create options
|
||||
*/
|
||||
create(options) {
|
||||
try {
|
||||
this._ensureTable(options.collection);
|
||||
|
||||
// Auto-add columns for new fields
|
||||
if (this.options.autoCreateTables && this.schemaManager) {
|
||||
for (const [key, value] of Object.entries(options.data)) {
|
||||
if (key !== 'objectId' && key !== 'createdAt' && key !== 'updatedAt') {
|
||||
const type = this._inferType(value);
|
||||
this.schemaManager.addColumn(options.collection, { name: key, type });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const objectId = generateObjectId();
|
||||
const { sql, params } = QueryBuilder.buildInsert(options, objectId);
|
||||
|
||||
this.db.prepare(sql).run(...params);
|
||||
|
||||
// Fetch the created record to get all fields
|
||||
const createdRow = this.db
|
||||
.prepare(`SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "objectId" = ?`)
|
||||
.get(objectId);
|
||||
|
||||
const record = this._rowToRecord(createdRow, options.collection);
|
||||
|
||||
options.success(record);
|
||||
|
||||
this.events.emit('create', {
|
||||
type: 'create',
|
||||
objectId,
|
||||
object: record,
|
||||
collection: options.collection
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('LocalSQLAdapter.create error:', e);
|
||||
options.error(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save (update) an existing record
|
||||
*
|
||||
* @param {Object} options - Save options
|
||||
*/
|
||||
save(options) {
|
||||
try {
|
||||
this._ensureTable(options.collection);
|
||||
|
||||
// Auto-add columns for new fields
|
||||
if (this.options.autoCreateTables && this.schemaManager) {
|
||||
for (const [key, value] of Object.entries(options.data)) {
|
||||
if (key !== 'objectId' && key !== 'createdAt' && key !== 'updatedAt') {
|
||||
const type = this._inferType(value);
|
||||
this.schemaManager.addColumn(options.collection, { name: key, type });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { sql, params } = QueryBuilder.buildUpdate(options);
|
||||
this.db.prepare(sql).run(...params);
|
||||
|
||||
// Fetch the updated record
|
||||
const updatedRow = this.db
|
||||
.prepare(`SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "objectId" = ?`)
|
||||
.get(options.objectId);
|
||||
|
||||
const record = this._rowToRecord(updatedRow, options.collection);
|
||||
|
||||
options.success(record);
|
||||
|
||||
this.events.emit('save', {
|
||||
type: 'save',
|
||||
objectId: options.objectId,
|
||||
object: record,
|
||||
collection: options.collection
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('LocalSQLAdapter.save error:', e);
|
||||
options.error(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a record
|
||||
*
|
||||
* @param {Object} options - Delete options
|
||||
*/
|
||||
delete(options) {
|
||||
try {
|
||||
this._ensureTable(options.collection);
|
||||
|
||||
const { sql, params } = QueryBuilder.buildDelete(options);
|
||||
this.db.prepare(sql).run(...params);
|
||||
|
||||
options.success();
|
||||
|
||||
this.events.emit('delete', {
|
||||
type: 'delete',
|
||||
objectId: options.objectId,
|
||||
collection: options.collection
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('LocalSQLAdapter.delete error:', e);
|
||||
options.error(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count records
|
||||
*
|
||||
* @param {Object} options - Count options
|
||||
*/
|
||||
count(options) {
|
||||
try {
|
||||
this._ensureTable(options.collection);
|
||||
|
||||
const schema = this._getSchema(options.collection);
|
||||
const { sql, params } = QueryBuilder.buildCount(options, schema);
|
||||
|
||||
const row = this.db.prepare(sql).get(...params);
|
||||
options.success(row?.count || 0);
|
||||
} catch (e) {
|
||||
console.error('LocalSQLAdapter.count error:', e);
|
||||
options.error(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate records
|
||||
*
|
||||
* @param {Object} options - Aggregate options
|
||||
*/
|
||||
aggregate(options) {
|
||||
try {
|
||||
this._ensureTable(options.collection);
|
||||
|
||||
const { sql, params } = QueryBuilder.buildAggregate(options);
|
||||
const row = this.db.prepare(sql).get(...params);
|
||||
|
||||
// Format result like Parse Server
|
||||
const result = {};
|
||||
if (row) {
|
||||
for (const key of Object.keys(options.group)) {
|
||||
result[key] = row[key];
|
||||
}
|
||||
}
|
||||
|
||||
options.success(result);
|
||||
} catch (e) {
|
||||
console.error('LocalSQLAdapter.aggregate error:', e);
|
||||
options.error(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get distinct values
|
||||
*
|
||||
* @param {Object} options - Distinct options
|
||||
*/
|
||||
distinct(options) {
|
||||
try {
|
||||
this._ensureTable(options.collection);
|
||||
|
||||
const { sql, params } = QueryBuilder.buildDistinct(options);
|
||||
const rows = this.db.prepare(sql).all(...params);
|
||||
|
||||
const results = rows.map((r) => r[options.property]);
|
||||
options.success(results);
|
||||
} catch (e) {
|
||||
console.error('LocalSQLAdapter.distinct error:', e);
|
||||
options.error(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment properties
|
||||
*
|
||||
* @param {Object} options - Increment options
|
||||
*/
|
||||
increment(options) {
|
||||
try {
|
||||
this._ensureTable(options.collection);
|
||||
|
||||
const { sql, params } = QueryBuilder.buildIncrement(options);
|
||||
this.db.prepare(sql).run(...params);
|
||||
|
||||
// Fetch the updated record
|
||||
const updatedRow = this.db
|
||||
.prepare(`SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "objectId" = ?`)
|
||||
.get(options.objectId);
|
||||
|
||||
const record = this._rowToRecord(updatedRow, options.collection);
|
||||
options.success(record);
|
||||
} catch (e) {
|
||||
console.error('LocalSQLAdapter.increment error:', e);
|
||||
options.error(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a relation
|
||||
*
|
||||
* @param {Object} options - Relation options
|
||||
*/
|
||||
addRelation(options) {
|
||||
try {
|
||||
this.schemaManager.addRelation(options.collection, options.objectId, options.key, options.targetObjectId);
|
||||
|
||||
options.success({});
|
||||
} catch (e) {
|
||||
console.error('LocalSQLAdapter.addRelation error:', e);
|
||||
options.error(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a relation
|
||||
*
|
||||
* @param {Object} options - Relation options
|
||||
*/
|
||||
removeRelation(options) {
|
||||
try {
|
||||
this.schemaManager.removeRelation(options.collection, options.objectId, options.key, options.targetObjectId);
|
||||
|
||||
options.success({});
|
||||
} catch (e) {
|
||||
console.error('LocalSQLAdapter.removeRelation error:', e);
|
||||
options.error(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Additional LocalSQL-specific methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Get the schema manager instance
|
||||
*
|
||||
* @returns {SchemaManager}
|
||||
*/
|
||||
getSchemaManager() {
|
||||
return this.schemaManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw database instance
|
||||
*
|
||||
* @returns {import('better-sqlite3').Database}
|
||||
*/
|
||||
getDatabase() {
|
||||
return this.db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute raw SQL (use with caution!)
|
||||
*
|
||||
* @param {string} sql - SQL statement
|
||||
* @param {Array} [params] - Parameters
|
||||
* @returns {any}
|
||||
*/
|
||||
exec(sql, params = []) {
|
||||
if (params.length > 0) {
|
||||
return this.db.prepare(sql).all(...params);
|
||||
}
|
||||
return this.db.exec(sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a transaction
|
||||
*
|
||||
* @param {Function} fn - Function to run in transaction
|
||||
* @returns {any}
|
||||
*/
|
||||
transaction(fn) {
|
||||
return this.db.transaction(fn)();
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer type from a JavaScript value
|
||||
*
|
||||
* @private
|
||||
* @param {*} value
|
||||
* @returns {string}
|
||||
*/
|
||||
_inferType(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return 'String';
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return 'String';
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return 'Number';
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return 'Boolean';
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return 'Date';
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return 'Array';
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
if (value.__type === 'Date') return 'Date';
|
||||
if (value.__type === 'Pointer') return 'Pointer';
|
||||
if (value.__type === 'File') return 'File';
|
||||
if (value.__type === 'GeoPoint') return 'GeoPoint';
|
||||
return 'Object';
|
||||
}
|
||||
return 'String';
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = LocalSQLAdapter;
|
||||
@@ -0,0 +1,705 @@
|
||||
/**
|
||||
* QueryBuilder - Translates Parse-style queries to SQLite SQL
|
||||
*
|
||||
* Parse uses operators like $eq, $ne, $gt, $lt, $in, etc.
|
||||
* This translates them to SQL WHERE clauses.
|
||||
*
|
||||
* @module adapters/local-sql/QueryBuilder
|
||||
*/
|
||||
|
||||
/**
|
||||
* Reserved SQLite keywords that need to be escaped
|
||||
*/
|
||||
const RESERVED_WORDS = new Set([
|
||||
'order',
|
||||
'group',
|
||||
'select',
|
||||
'from',
|
||||
'where',
|
||||
'index',
|
||||
'table',
|
||||
'create',
|
||||
'drop',
|
||||
'alter',
|
||||
'delete',
|
||||
'insert',
|
||||
'update',
|
||||
'key',
|
||||
'primary',
|
||||
'foreign',
|
||||
'references',
|
||||
'null',
|
||||
'not',
|
||||
'and',
|
||||
'or',
|
||||
'in',
|
||||
'like',
|
||||
'between',
|
||||
'is',
|
||||
'exists',
|
||||
'case',
|
||||
'when',
|
||||
'then',
|
||||
'else',
|
||||
'end',
|
||||
'join',
|
||||
'inner',
|
||||
'outer',
|
||||
'left',
|
||||
'right',
|
||||
'on',
|
||||
'as',
|
||||
'asc',
|
||||
'desc',
|
||||
'limit',
|
||||
'offset',
|
||||
'union',
|
||||
'distinct',
|
||||
'all',
|
||||
'any',
|
||||
'some',
|
||||
'true',
|
||||
'false',
|
||||
'default',
|
||||
'values',
|
||||
'set',
|
||||
'into',
|
||||
'by',
|
||||
'having',
|
||||
'count',
|
||||
'sum',
|
||||
'avg',
|
||||
'min',
|
||||
'max'
|
||||
]);
|
||||
|
||||
/**
|
||||
* Escape a table name for SQL
|
||||
* @param {string} name
|
||||
* @returns {string}
|
||||
*/
|
||||
function escapeTable(name) {
|
||||
// Sanitize: only allow alphanumeric and underscore
|
||||
const sanitized = name.replace(/[^a-zA-Z0-9_]/g, '');
|
||||
return `"${sanitized}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a column name for SQL
|
||||
* @param {string} name
|
||||
* @returns {string}
|
||||
*/
|
||||
function escapeColumn(name) {
|
||||
// Sanitize: only allow alphanumeric and underscore
|
||||
const sanitized = name.replace(/[^a-zA-Z0-9_]/g, '');
|
||||
// Always quote to handle reserved words
|
||||
return `"${sanitized}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Parse Date object to ISO string for SQLite
|
||||
* @param {Object|Date|string} value
|
||||
* @returns {string}
|
||||
*/
|
||||
function convertDateValue(value) {
|
||||
if (value && value.__type === 'Date' && value.iso) {
|
||||
return value.iso;
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Parse Pointer to its objectId
|
||||
* @param {Object|string} value
|
||||
* @returns {string}
|
||||
*/
|
||||
function convertPointerValue(value) {
|
||||
if (value && value.__type === 'Pointer' && value.objectId) {
|
||||
return value.objectId;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a WHERE clause from a Parse-style query
|
||||
*
|
||||
* @param {Object} where - Parse-style query object
|
||||
* @param {Array} params - Array to push parameter values to
|
||||
* @param {Object} [schema] - Optional schema for type-aware conversion
|
||||
* @returns {string} SQL WHERE clause (without "WHERE" keyword)
|
||||
*/
|
||||
function buildWhereClause(where, params, schema) {
|
||||
if (!where || Object.keys(where).length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const conditions = [];
|
||||
|
||||
for (const [key, condition] of Object.entries(where)) {
|
||||
// Handle logical operators
|
||||
if (key === '$and' && Array.isArray(condition)) {
|
||||
const subConditions = condition.map((sub) => buildWhereClause(sub, params, schema)).filter((c) => c);
|
||||
if (subConditions.length > 0) {
|
||||
conditions.push(`(${subConditions.join(' AND ')})`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === '$or' && Array.isArray(condition)) {
|
||||
const subConditions = condition.map((sub) => buildWhereClause(sub, params, schema)).filter((c) => c);
|
||||
if (subConditions.length > 0) {
|
||||
conditions.push(`(${subConditions.join(' OR ')})`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle $relatedTo - this is tricky with SQLite
|
||||
if (key === '$relatedTo') {
|
||||
// For relations, we need a subquery on the junction table
|
||||
const { object, key: relationKey } = condition;
|
||||
if (object && object.objectId && object.className && relationKey) {
|
||||
const junctionTable = `_Join_${relationKey}_${object.className}`;
|
||||
conditions.push(`"objectId" IN (SELECT "relatedId" FROM ${escapeTable(junctionTable)} WHERE "owningId" = ?)`);
|
||||
params.push(object.objectId);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle field conditions
|
||||
const col = escapeColumn(key);
|
||||
|
||||
if (typeof condition !== 'object' || condition === null) {
|
||||
// Direct equality
|
||||
conditions.push(`${col} = ?`);
|
||||
params.push(convertDateValue(convertPointerValue(condition)));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle Parse operators
|
||||
for (const [op, value] of Object.entries(condition)) {
|
||||
const sqlCondition = translateOperator(col, op, value, params, schema);
|
||||
if (sqlCondition) {
|
||||
conditions.push(sqlCondition);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return conditions.join(' AND ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a single Parse operator to SQL
|
||||
*
|
||||
* @param {string} col - Escaped column name
|
||||
* @param {string} op - Parse operator ($eq, $ne, etc.)
|
||||
* @param {*} value - Comparison value
|
||||
* @param {Array} params - Parameters array to push values to
|
||||
* @param {Object} [schema] - Optional schema
|
||||
* @returns {string|null} SQL condition or null
|
||||
*/
|
||||
function translateOperator(col, op, value, params, schema) {
|
||||
// Convert special types
|
||||
const convertedValue = convertDateValue(convertPointerValue(value));
|
||||
|
||||
switch (op) {
|
||||
case '$eq':
|
||||
if (convertedValue === null) {
|
||||
return `${col} IS NULL`;
|
||||
}
|
||||
params.push(convertedValue);
|
||||
return `${col} = ?`;
|
||||
|
||||
case '$ne':
|
||||
if (convertedValue === null) {
|
||||
return `${col} IS NOT NULL`;
|
||||
}
|
||||
params.push(convertedValue);
|
||||
return `${col} != ?`;
|
||||
|
||||
case '$gt':
|
||||
params.push(convertedValue);
|
||||
return `${col} > ?`;
|
||||
|
||||
case '$gte':
|
||||
params.push(convertedValue);
|
||||
return `${col} >= ?`;
|
||||
|
||||
case '$lt':
|
||||
params.push(convertedValue);
|
||||
return `${col} < ?`;
|
||||
|
||||
case '$lte':
|
||||
params.push(convertedValue);
|
||||
return `${col} <= ?`;
|
||||
|
||||
case '$in':
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
return '0'; // Always false
|
||||
}
|
||||
const inValues = value.map((v) => convertDateValue(convertPointerValue(v)));
|
||||
const placeholders = inValues.map(() => '?').join(', ');
|
||||
params.push(...inValues);
|
||||
return `${col} IN (${placeholders})`;
|
||||
|
||||
case '$nin':
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
return '1'; // Always true (not in empty set)
|
||||
}
|
||||
const ninValues = value.map((v) => convertDateValue(convertPointerValue(v)));
|
||||
const ninPlaceholders = ninValues.map(() => '?').join(', ');
|
||||
params.push(...ninValues);
|
||||
return `${col} NOT IN (${ninPlaceholders})`;
|
||||
|
||||
case '$exists':
|
||||
return value ? `${col} IS NOT NULL` : `${col} IS NULL`;
|
||||
|
||||
case '$regex':
|
||||
// SQLite doesn't have native regex, use LIKE with % wildcards
|
||||
// This is a simplification - only handles basic patterns
|
||||
params.push(`%${value}%`);
|
||||
return `${col} LIKE ?`;
|
||||
|
||||
case '$options':
|
||||
// This is used with $regex, ignore here
|
||||
return null;
|
||||
|
||||
case '$text':
|
||||
// Full text search - convert to LIKE
|
||||
if (value && value.$search) {
|
||||
const term = typeof value.$search === 'string' ? value.$search : value.$search.$term || '';
|
||||
params.push(`%${term}%`);
|
||||
return `${col} LIKE ?`;
|
||||
}
|
||||
return null;
|
||||
|
||||
// Geo queries - not fully supported in SQLite without extensions
|
||||
case '$nearSphere':
|
||||
case '$within':
|
||||
case '$geoWithin':
|
||||
console.warn(`Geo query operator ${op} not supported in SQLite adapter`);
|
||||
return null;
|
||||
|
||||
default:
|
||||
console.warn(`Unknown query operator: ${op}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build ORDER BY clause from Parse-style sort
|
||||
*
|
||||
* @param {string|string[]} sort - Sort specification (e.g., 'name' or '-createdAt' for desc)
|
||||
* @returns {string} SQL ORDER BY clause (without "ORDER BY" keyword)
|
||||
*/
|
||||
function buildOrderClause(sort) {
|
||||
if (!sort) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const sortArray = Array.isArray(sort) ? sort : sort.split(',');
|
||||
|
||||
const orders = sortArray.map((s) => {
|
||||
const trimmed = s.trim();
|
||||
if (trimmed.startsWith('-')) {
|
||||
return `${escapeColumn(trimmed.substring(1))} DESC`;
|
||||
}
|
||||
return `${escapeColumn(trimmed)} ASC`;
|
||||
});
|
||||
|
||||
return orders.join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a SELECT query
|
||||
*
|
||||
* @param {Object} options - Query options
|
||||
* @param {string} options.collection - Collection name
|
||||
* @param {Object} [options.where] - Parse-style query filter
|
||||
* @param {string|string[]} [options.select] - Fields to select
|
||||
* @param {string|string[]} [options.sort] - Sort order
|
||||
* @param {number} [options.limit] - Max records
|
||||
* @param {number} [options.skip] - Records to skip
|
||||
* @param {Object} [schema] - Optional schema
|
||||
* @returns {{ sql: string, params: Array }}
|
||||
*/
|
||||
function buildSelect(options, schema) {
|
||||
const params = [];
|
||||
const table = escapeTable(options.collection);
|
||||
|
||||
// Build SELECT clause
|
||||
let selectClause = '*';
|
||||
if (options.select) {
|
||||
const selectArray = Array.isArray(options.select) ? options.select : options.select.split(',');
|
||||
// Always include objectId
|
||||
const fields = new Set(['objectId', ...selectArray.map((s) => s.trim())]);
|
||||
selectClause = Array.from(fields)
|
||||
.map((f) => escapeColumn(f))
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
let sql = `SELECT ${selectClause} FROM ${table}`;
|
||||
|
||||
// Build WHERE clause
|
||||
if (options.where) {
|
||||
const whereClause = buildWhereClause(options.where, params, schema);
|
||||
if (whereClause) {
|
||||
sql += ` WHERE ${whereClause}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Build ORDER BY clause
|
||||
if (options.sort) {
|
||||
const orderClause = buildOrderClause(options.sort);
|
||||
if (orderClause) {
|
||||
sql += ` ORDER BY ${orderClause}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Build LIMIT/OFFSET
|
||||
if (options.limit !== undefined) {
|
||||
sql += ' LIMIT ?';
|
||||
params.push(options.limit);
|
||||
}
|
||||
|
||||
if (options.skip !== undefined && options.skip > 0) {
|
||||
sql += ' OFFSET ?';
|
||||
params.push(options.skip);
|
||||
}
|
||||
|
||||
return { sql, params };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a COUNT query
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {string} options.collection
|
||||
* @param {Object} [options.where]
|
||||
* @param {Object} [schema]
|
||||
* @returns {{ sql: string, params: Array }}
|
||||
*/
|
||||
function buildCount(options, schema) {
|
||||
const params = [];
|
||||
const table = escapeTable(options.collection);
|
||||
|
||||
let sql = `SELECT COUNT(*) as count FROM ${table}`;
|
||||
|
||||
if (options.where) {
|
||||
const whereClause = buildWhereClause(options.where, params, schema);
|
||||
if (whereClause) {
|
||||
sql += ` WHERE ${whereClause}`;
|
||||
}
|
||||
}
|
||||
|
||||
return { sql, params };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an INSERT query
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {string} options.collection
|
||||
* @param {Object} options.data
|
||||
* @param {string} objectId
|
||||
* @returns {{ sql: string, params: Array }}
|
||||
*/
|
||||
function buildInsert(options, objectId) {
|
||||
const params = [];
|
||||
const table = escapeTable(options.collection);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const data = {
|
||||
objectId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...options.data
|
||||
};
|
||||
|
||||
// Remove protected fields
|
||||
delete data._createdAt;
|
||||
delete data._updatedAt;
|
||||
|
||||
const columns = [];
|
||||
const placeholders = [];
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
columns.push(escapeColumn(key));
|
||||
placeholders.push('?');
|
||||
params.push(serializeValue(value));
|
||||
}
|
||||
|
||||
const sql = `INSERT INTO ${table} (${columns.join(', ')}) VALUES (${placeholders.join(', ')})`;
|
||||
|
||||
return { sql, params };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an UPDATE query
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {string} options.collection
|
||||
* @param {string} options.objectId
|
||||
* @param {Object} options.data
|
||||
* @returns {{ sql: string, params: Array }}
|
||||
*/
|
||||
function buildUpdate(options) {
|
||||
const params = [];
|
||||
const table = escapeTable(options.collection);
|
||||
|
||||
const data = { ...options.data };
|
||||
|
||||
// Add updatedAt
|
||||
data.updatedAt = new Date().toISOString();
|
||||
|
||||
// Remove protected fields
|
||||
delete data.objectId;
|
||||
delete data.createdAt;
|
||||
delete data._createdAt;
|
||||
delete data._updatedAt;
|
||||
|
||||
const setClause = [];
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
setClause.push(`${escapeColumn(key)} = ?`);
|
||||
params.push(serializeValue(value));
|
||||
}
|
||||
|
||||
params.push(options.objectId);
|
||||
|
||||
const sql = `UPDATE ${table} SET ${setClause.join(', ')} WHERE "objectId" = ?`;
|
||||
|
||||
return { sql, params };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a DELETE query
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {string} options.collection
|
||||
* @param {string} options.objectId
|
||||
* @returns {{ sql: string, params: Array }}
|
||||
*/
|
||||
function buildDelete(options) {
|
||||
const table = escapeTable(options.collection);
|
||||
const sql = `DELETE FROM ${table} WHERE "objectId" = ?`;
|
||||
return { sql, params: [options.objectId] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an INCREMENT query
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {string} options.collection
|
||||
* @param {string} options.objectId
|
||||
* @param {Object<string, number>} options.properties
|
||||
* @returns {{ sql: string, params: Array }}
|
||||
*/
|
||||
function buildIncrement(options) {
|
||||
const params = [];
|
||||
const table = escapeTable(options.collection);
|
||||
|
||||
const setClause = [];
|
||||
|
||||
for (const [key, amount] of Object.entries(options.properties)) {
|
||||
const col = escapeColumn(key);
|
||||
setClause.push(`${col} = COALESCE(${col}, 0) + ?`);
|
||||
params.push(amount);
|
||||
}
|
||||
|
||||
// Add updatedAt
|
||||
setClause.push('"updatedAt" = ?');
|
||||
params.push(new Date().toISOString());
|
||||
|
||||
params.push(options.objectId);
|
||||
|
||||
const sql = `UPDATE ${table} SET ${setClause.join(', ')} WHERE "objectId" = ?`;
|
||||
|
||||
return { sql, params };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a DISTINCT query
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {string} options.collection
|
||||
* @param {string} options.property
|
||||
* @param {Object} [options.where]
|
||||
* @returns {{ sql: string, params: Array }}
|
||||
*/
|
||||
function buildDistinct(options) {
|
||||
const params = [];
|
||||
const table = escapeTable(options.collection);
|
||||
const col = escapeColumn(options.property);
|
||||
|
||||
let sql = `SELECT DISTINCT ${col} FROM ${table}`;
|
||||
|
||||
if (options.where) {
|
||||
const whereClause = buildWhereClause(options.where, params);
|
||||
if (whereClause) {
|
||||
sql += ` WHERE ${whereClause}`;
|
||||
}
|
||||
}
|
||||
|
||||
return { sql, params };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an AGGREGATE query
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {string} options.collection
|
||||
* @param {Object} [options.where]
|
||||
* @param {Object} options.group - Grouping config with avg/sum/max/min/distinct
|
||||
* @param {number} [options.limit]
|
||||
* @param {number} [options.skip]
|
||||
* @returns {{ sql: string, params: Array }}
|
||||
*/
|
||||
function buildAggregate(options) {
|
||||
const params = [];
|
||||
const table = escapeTable(options.collection);
|
||||
|
||||
const selectParts = [];
|
||||
|
||||
for (const [alias, groupConfig] of Object.entries(options.group)) {
|
||||
if (groupConfig.avg !== undefined) {
|
||||
selectParts.push(`AVG(${escapeColumn(groupConfig.avg)}) as ${escapeColumn(alias)}`);
|
||||
} else if (groupConfig.sum !== undefined) {
|
||||
selectParts.push(`SUM(${escapeColumn(groupConfig.sum)}) as ${escapeColumn(alias)}`);
|
||||
} else if (groupConfig.max !== undefined) {
|
||||
selectParts.push(`MAX(${escapeColumn(groupConfig.max)}) as ${escapeColumn(alias)}`);
|
||||
} else if (groupConfig.min !== undefined) {
|
||||
selectParts.push(`MIN(${escapeColumn(groupConfig.min)}) as ${escapeColumn(alias)}`);
|
||||
} else if (groupConfig.distinct !== undefined) {
|
||||
// COUNT DISTINCT as alternative to $addToSet
|
||||
selectParts.push(`COUNT(DISTINCT ${escapeColumn(groupConfig.distinct)}) as ${escapeColumn(alias)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (selectParts.length === 0) {
|
||||
return { sql: `SELECT COUNT(*) as count FROM ${table}`, params: [] };
|
||||
}
|
||||
|
||||
let sql = `SELECT ${selectParts.join(', ')} FROM ${table}`;
|
||||
|
||||
if (options.where) {
|
||||
const whereClause = buildWhereClause(options.where, params);
|
||||
if (whereClause) {
|
||||
sql += ` WHERE ${whereClause}`;
|
||||
}
|
||||
}
|
||||
|
||||
return { sql, params };
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a JavaScript value for SQLite storage
|
||||
*
|
||||
* @param {*} value
|
||||
* @returns {*}
|
||||
*/
|
||||
function serializeValue(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle Parse types
|
||||
if (value && typeof value === 'object') {
|
||||
// Date type
|
||||
if (value.__type === 'Date' && value.iso) {
|
||||
return value.iso;
|
||||
}
|
||||
// Pointer type - store just the objectId
|
||||
if (value.__type === 'Pointer' && value.objectId) {
|
||||
return value.objectId;
|
||||
}
|
||||
// File type - store as JSON
|
||||
if (value.__type === 'File') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
// GeoPoint type - store as JSON
|
||||
if (value.__type === 'GeoPoint') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
// Arrays and objects - store as JSON
|
||||
if (Array.isArray(value) || Object.keys(value).length > 0) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Date objects
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
// Handle booleans - SQLite uses 0/1
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 1 : 0;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize a SQLite value back to JavaScript
|
||||
*
|
||||
* @param {*} value
|
||||
* @param {string} [type] - Expected type from schema
|
||||
* @returns {*}
|
||||
*/
|
||||
function deserializeValue(value, type) {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle type-based deserialization
|
||||
if (type === 'Boolean') {
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
if (type === 'Date') {
|
||||
return value; // Keep as ISO string, let CloudStore handle Date objects
|
||||
}
|
||||
|
||||
if (type === 'Object' || type === 'Array' || type === 'GeoPoint' || type === 'File') {
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (e) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to parse JSON strings that look like objects/arrays
|
||||
if (typeof value === 'string') {
|
||||
if ((value.startsWith('{') && value.endsWith('}')) || (value.startsWith('[') && value.endsWith(']'))) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (e) {
|
||||
// Not valid JSON, return as-is
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
escapeTable,
|
||||
escapeColumn,
|
||||
buildWhereClause,
|
||||
buildOrderClause,
|
||||
buildSelect,
|
||||
buildCount,
|
||||
buildInsert,
|
||||
buildUpdate,
|
||||
buildDelete,
|
||||
buildIncrement,
|
||||
buildDistinct,
|
||||
buildAggregate,
|
||||
serializeValue,
|
||||
deserializeValue
|
||||
};
|
||||
@@ -0,0 +1,511 @@
|
||||
/**
|
||||
* SchemaManager - Handles SQLite schema creation, migration, and export
|
||||
*
|
||||
* Manages table creation based on Noodl collection schemas,
|
||||
* handles migrations when schema changes, and can export
|
||||
* schemas to other database formats (Postgres, Supabase, etc.)
|
||||
*
|
||||
* @module adapters/local-sql/SchemaManager
|
||||
*/
|
||||
|
||||
const { escapeTable, escapeColumn } = require('./QueryBuilder');
|
||||
|
||||
/**
|
||||
* Map Noodl/Parse types to SQLite types
|
||||
*
|
||||
* @type {Object<string, string>}
|
||||
*/
|
||||
const TYPE_MAP = {
|
||||
String: 'TEXT',
|
||||
Number: 'REAL',
|
||||
Boolean: 'INTEGER', // SQLite uses 0/1
|
||||
Date: 'TEXT', // ISO8601 string
|
||||
Object: 'TEXT', // JSON string
|
||||
Array: 'TEXT', // JSON string
|
||||
Pointer: 'TEXT', // objectId reference
|
||||
Relation: null, // Handled via junction tables
|
||||
GeoPoint: 'TEXT', // JSON string
|
||||
File: 'TEXT' // JSON string with url/name
|
||||
};
|
||||
|
||||
/**
|
||||
* Map Noodl types to PostgreSQL types (for export)
|
||||
*
|
||||
* @type {Object<string, string>}
|
||||
*/
|
||||
const POSTGRES_TYPE_MAP = {
|
||||
String: 'TEXT',
|
||||
Number: 'NUMERIC',
|
||||
Boolean: 'BOOLEAN',
|
||||
Date: 'TIMESTAMPTZ',
|
||||
Object: 'JSONB',
|
||||
Array: 'JSONB',
|
||||
Pointer: 'TEXT', // or UUID with FK
|
||||
Relation: null,
|
||||
GeoPoint: 'POINT', // or use PostGIS
|
||||
File: 'JSONB'
|
||||
};
|
||||
|
||||
/**
|
||||
* SchemaManager class
|
||||
*/
|
||||
class SchemaManager {
|
||||
/**
|
||||
* @param {import('better-sqlite3').Database} db - SQLite database instance
|
||||
*/
|
||||
constructor(db) {
|
||||
this.db = db;
|
||||
this._schemaCache = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the internal schema tracking table exists
|
||||
*/
|
||||
ensureSchemaTable() {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS "_Schema" (
|
||||
"name" TEXT PRIMARY KEY,
|
||||
"schema" TEXT NOT NULL,
|
||||
"createdAt" TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a table from a schema definition
|
||||
*
|
||||
* @param {Object} schema - Table schema
|
||||
* @param {string} schema.name - Table name
|
||||
* @param {Array<Object>} schema.columns - Column definitions
|
||||
* @returns {boolean} Whether table was created (false if already existed)
|
||||
*/
|
||||
createTable(schema) {
|
||||
const tableName = schema.name;
|
||||
|
||||
// Check if table exists
|
||||
const exists = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = ?").get(tableName);
|
||||
|
||||
if (exists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build column definitions
|
||||
const columnDefs = [
|
||||
'"objectId" TEXT PRIMARY KEY',
|
||||
'"createdAt" TEXT DEFAULT CURRENT_TIMESTAMP',
|
||||
'"updatedAt" TEXT DEFAULT CURRENT_TIMESTAMP',
|
||||
'"ACL" TEXT' // Access control list as JSON
|
||||
];
|
||||
|
||||
// Add user-defined columns
|
||||
for (const col of schema.columns || []) {
|
||||
const colDef = this._columnToSQL(col);
|
||||
if (colDef) {
|
||||
columnDefs.push(colDef);
|
||||
}
|
||||
}
|
||||
|
||||
// Create main table
|
||||
const createSQL = `CREATE TABLE ${escapeTable(tableName)} (${columnDefs.join(', ')})`;
|
||||
this.db.exec(createSQL);
|
||||
|
||||
// Create standard indexes
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS "idx_${tableName}_createdAt" ON ${escapeTable(tableName)}("createdAt")`);
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS "idx_${tableName}_updatedAt" ON ${escapeTable(tableName)}("updatedAt")`);
|
||||
|
||||
// Create junction tables for relations
|
||||
for (const col of schema.columns || []) {
|
||||
if (col.type === 'Relation' && col.targetClass) {
|
||||
this._createJunctionTable(tableName, col.name, col.targetClass);
|
||||
}
|
||||
}
|
||||
|
||||
// Store schema in tracking table
|
||||
this.ensureSchemaTable();
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR REPLACE INTO "_Schema" ("name", "schema", "updatedAt")
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)`
|
||||
)
|
||||
.run(tableName, JSON.stringify(schema));
|
||||
|
||||
this._schemaCache.set(tableName, schema);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a column to an existing table
|
||||
*
|
||||
* @param {string} tableName - Table name
|
||||
* @param {Object} column - Column definition
|
||||
*/
|
||||
addColumn(tableName, column) {
|
||||
const colDef = this._columnToSQL(column);
|
||||
if (!colDef) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.db.exec(`ALTER TABLE ${escapeTable(tableName)} ADD COLUMN ${colDef}`);
|
||||
|
||||
// Update schema tracking
|
||||
const schema = this.getTableSchema(tableName);
|
||||
if (schema) {
|
||||
schema.columns = schema.columns || [];
|
||||
schema.columns.push(column);
|
||||
this.db
|
||||
.prepare(`UPDATE "_Schema" SET "schema" = ?, "updatedAt" = CURRENT_TIMESTAMP WHERE "name" = ?`)
|
||||
.run(JSON.stringify(schema), tableName);
|
||||
this._schemaCache.set(tableName, schema);
|
||||
}
|
||||
} catch (e) {
|
||||
// Column may already exist
|
||||
if (!e.message.includes('duplicate column name')) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get schema for a table
|
||||
*
|
||||
* @param {string} tableName - Table name
|
||||
* @returns {Object|null} Schema definition or null
|
||||
*/
|
||||
getTableSchema(tableName) {
|
||||
if (this._schemaCache.has(tableName)) {
|
||||
return this._schemaCache.get(tableName);
|
||||
}
|
||||
|
||||
this.ensureSchemaTable();
|
||||
|
||||
const row = this.db.prepare('SELECT "schema" FROM "_Schema" WHERE "name" = ?').get(tableName);
|
||||
|
||||
if (row) {
|
||||
const schema = JSON.parse(row.schema);
|
||||
this._schemaCache.set(tableName, schema);
|
||||
return schema;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all tables
|
||||
*
|
||||
* @returns {string[]} Table names
|
||||
*/
|
||||
listTables() {
|
||||
const rows = this.db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_%'")
|
||||
.all();
|
||||
|
||||
return rows.map((r) => r.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export all schemas
|
||||
*
|
||||
* @returns {Object[]} Array of schema definitions
|
||||
*/
|
||||
exportSchemas() {
|
||||
this.ensureSchemaTable();
|
||||
|
||||
const rows = this.db.prepare('SELECT "name", "schema" FROM "_Schema" WHERE "name" NOT LIKE \'_%\'').all();
|
||||
|
||||
return rows.map((r) => JSON.parse(r.schema));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PostgreSQL-compatible SQL for migration
|
||||
*
|
||||
* @returns {string} SQL statements
|
||||
*/
|
||||
generatePostgresSQL() {
|
||||
const schemas = this.exportSchemas();
|
||||
const statements = [];
|
||||
|
||||
statements.push('-- Generated by Noodl LocalSQL Export');
|
||||
statements.push('-- PostgreSQL Schema');
|
||||
statements.push('');
|
||||
|
||||
for (const schema of schemas) {
|
||||
statements.push(`-- Table: ${schema.name}`);
|
||||
|
||||
const columnDefs = [
|
||||
'"objectId" TEXT PRIMARY KEY',
|
||||
'"createdAt" TIMESTAMPTZ DEFAULT NOW()',
|
||||
'"updatedAt" TIMESTAMPTZ DEFAULT NOW()',
|
||||
'"ACL" JSONB'
|
||||
];
|
||||
|
||||
for (const col of schema.columns || []) {
|
||||
const pgType = POSTGRES_TYPE_MAP[col.type];
|
||||
if (pgType) {
|
||||
let def = `"${col.name}" ${pgType}`;
|
||||
if (col.required) def += ' NOT NULL';
|
||||
columnDefs.push(def);
|
||||
}
|
||||
}
|
||||
|
||||
statements.push(`CREATE TABLE IF NOT EXISTS "${schema.name}" (`);
|
||||
statements.push(` ${columnDefs.join(',\n ')}`);
|
||||
statements.push(');');
|
||||
statements.push('');
|
||||
|
||||
// Indexes
|
||||
statements.push(`CREATE INDEX IF NOT EXISTS "idx_${schema.name}_createdAt" ON "${schema.name}"("createdAt");`);
|
||||
statements.push(`CREATE INDEX IF NOT EXISTS "idx_${schema.name}_updatedAt" ON "${schema.name}"("updatedAt");`);
|
||||
statements.push('');
|
||||
|
||||
// Add updatedAt trigger
|
||||
statements.push(`-- Trigger for auto-updating updatedAt`);
|
||||
statements.push(`CREATE OR REPLACE FUNCTION update_updated_at_column()`);
|
||||
statements.push(`RETURNS TRIGGER AS $$`);
|
||||
statements.push(`BEGIN`);
|
||||
statements.push(` NEW."updatedAt" = NOW();`);
|
||||
statements.push(` RETURN NEW;`);
|
||||
statements.push(`END;`);
|
||||
statements.push(`$$ language 'plpgsql';`);
|
||||
statements.push('');
|
||||
statements.push(`DROP TRIGGER IF EXISTS "update_${schema.name}_updated_at" ON "${schema.name}";`);
|
||||
statements.push(`CREATE TRIGGER "update_${schema.name}_updated_at" BEFORE UPDATE ON "${schema.name}"`);
|
||||
statements.push(` FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();`);
|
||||
statements.push('');
|
||||
}
|
||||
|
||||
return statements.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Supabase-compatible SQL (includes RLS policies)
|
||||
*
|
||||
* @returns {string} SQL statements
|
||||
*/
|
||||
generateSupabaseSQL() {
|
||||
const baseSQL = this.generatePostgresSQL();
|
||||
const schemas = this.exportSchemas();
|
||||
const rlsStatements = [];
|
||||
|
||||
rlsStatements.push('');
|
||||
rlsStatements.push('-- Row Level Security Policies');
|
||||
rlsStatements.push('');
|
||||
|
||||
for (const schema of schemas) {
|
||||
const tableName = schema.name;
|
||||
|
||||
rlsStatements.push(`-- RLS for ${tableName}`);
|
||||
rlsStatements.push(`ALTER TABLE "${tableName}" ENABLE ROW LEVEL SECURITY;`);
|
||||
rlsStatements.push('');
|
||||
|
||||
// Default policy: allow authenticated users
|
||||
rlsStatements.push(`-- Allow authenticated users to read all records`);
|
||||
rlsStatements.push(
|
||||
`CREATE POLICY "Allow authenticated read" ON "${tableName}" FOR SELECT TO authenticated USING (true);`
|
||||
);
|
||||
rlsStatements.push('');
|
||||
|
||||
rlsStatements.push(`-- Allow users to insert their own records`);
|
||||
rlsStatements.push(
|
||||
`CREATE POLICY "Allow insert" ON "${tableName}" FOR INSERT TO authenticated WITH CHECK (true);`
|
||||
);
|
||||
rlsStatements.push('');
|
||||
|
||||
rlsStatements.push(`-- Allow users to update their own records (customize based on ACL)`);
|
||||
rlsStatements.push(
|
||||
`CREATE POLICY "Allow update" ON "${tableName}" FOR UPDATE TO authenticated USING (true) WITH CHECK (true);`
|
||||
);
|
||||
rlsStatements.push('');
|
||||
|
||||
rlsStatements.push(`-- Allow users to delete their own records (customize based on ACL)`);
|
||||
rlsStatements.push(`CREATE POLICY "Allow delete" ON "${tableName}" FOR DELETE TO authenticated USING (true);`);
|
||||
rlsStatements.push('');
|
||||
}
|
||||
|
||||
return baseSQL + rlsStatements.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate JSON schema export
|
||||
*
|
||||
* @returns {Object} JSON schema definition
|
||||
*/
|
||||
exportAsJSON() {
|
||||
return {
|
||||
version: '1.0',
|
||||
exportedAt: new Date().toISOString(),
|
||||
tables: this.exportSchemas()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Import schema from JSON
|
||||
*
|
||||
* @param {Object} jsonSchema - Schema definition from exportAsJSON
|
||||
*/
|
||||
importFromJSON(jsonSchema) {
|
||||
const tables = jsonSchema.tables || [];
|
||||
|
||||
for (const tableSchema of tables) {
|
||||
this.createTable(tableSchema);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a table needs migration (schema changed)
|
||||
*
|
||||
* @param {string} tableName - Table name
|
||||
* @param {Object} newSchema - New schema definition
|
||||
* @returns {Object} Migration info with added/removed columns
|
||||
*/
|
||||
checkMigration(tableName, newSchema) {
|
||||
const currentSchema = this.getTableSchema(tableName);
|
||||
|
||||
if (!currentSchema) {
|
||||
return { needsMigration: false, tableExists: false };
|
||||
}
|
||||
|
||||
const currentCols = new Set((currentSchema.columns || []).map((c) => c.name));
|
||||
const newCols = new Set((newSchema.columns || []).map((c) => c.name));
|
||||
|
||||
const added = [...newCols].filter((c) => !currentCols.has(c));
|
||||
const removed = [...currentCols].filter((c) => !newCols.has(c));
|
||||
|
||||
return {
|
||||
needsMigration: added.length > 0 || removed.length > 0,
|
||||
tableExists: true,
|
||||
added,
|
||||
removed
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert column definition to SQL
|
||||
*
|
||||
* @private
|
||||
* @param {Object} col - Column definition
|
||||
* @returns {string|null} SQL column definition
|
||||
*/
|
||||
_columnToSQL(col) {
|
||||
const sqlType = TYPE_MAP[col.type];
|
||||
if (!sqlType) {
|
||||
return null; // Relations handled separately
|
||||
}
|
||||
|
||||
let def = `${escapeColumn(col.name)} ${sqlType}`;
|
||||
if (col.required) {
|
||||
def += ' NOT NULL';
|
||||
}
|
||||
if (col.defaultValue !== undefined) {
|
||||
if (typeof col.defaultValue === 'string') {
|
||||
def += ` DEFAULT '${col.defaultValue}'`;
|
||||
} else if (typeof col.defaultValue === 'boolean') {
|
||||
def += ` DEFAULT ${col.defaultValue ? 1 : 0}`;
|
||||
} else {
|
||||
def += ` DEFAULT ${col.defaultValue}`;
|
||||
}
|
||||
}
|
||||
|
||||
return def;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a junction table for many-to-many relations
|
||||
*
|
||||
* @private
|
||||
* @param {string} owningClass - Source class name
|
||||
* @param {string} relationName - Relation field name
|
||||
* @param {string} targetClass - Target class name
|
||||
*/
|
||||
_createJunctionTable(owningClass, relationName, targetClass) {
|
||||
const junctionTable = `_Join_${relationName}_${owningClass}`;
|
||||
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ${escapeTable(junctionTable)} (
|
||||
"owningId" TEXT NOT NULL,
|
||||
"relatedId" TEXT NOT NULL,
|
||||
PRIMARY KEY ("owningId", "relatedId")
|
||||
)
|
||||
`);
|
||||
|
||||
// Create indexes for efficient lookups
|
||||
this.db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS "idx_${junctionTable}_owning" ON ${escapeTable(junctionTable)}("owningId")`
|
||||
);
|
||||
this.db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS "idx_${junctionTable}_related" ON ${escapeTable(junctionTable)}("relatedId")`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add to a relation (junction table)
|
||||
*
|
||||
* @param {string} owningClass - Source class name
|
||||
* @param {string} owningId - Source record ID
|
||||
* @param {string} relationName - Relation field name
|
||||
* @param {string} targetId - Target record ID
|
||||
*/
|
||||
addRelation(owningClass, owningId, relationName, targetId) {
|
||||
const junctionTable = `_Join_${relationName}_${owningClass}`;
|
||||
|
||||
try {
|
||||
this.db
|
||||
.prepare(`INSERT OR IGNORE INTO ${escapeTable(junctionTable)} ("owningId", "relatedId") VALUES (?, ?)`)
|
||||
.run(owningId, targetId);
|
||||
} catch (e) {
|
||||
// Table might not exist
|
||||
if (e.message.includes('no such table')) {
|
||||
this._createJunctionTable(owningClass, relationName, 'Unknown');
|
||||
this.db
|
||||
.prepare(`INSERT OR IGNORE INTO ${escapeTable(junctionTable)} ("owningId", "relatedId") VALUES (?, ?)`)
|
||||
.run(owningId, targetId);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove from a relation (junction table)
|
||||
*
|
||||
* @param {string} owningClass - Source class name
|
||||
* @param {string} owningId - Source record ID
|
||||
* @param {string} relationName - Relation field name
|
||||
* @param {string} targetId - Target record ID
|
||||
*/
|
||||
removeRelation(owningClass, owningId, relationName, targetId) {
|
||||
const junctionTable = `_Join_${relationName}_${owningClass}`;
|
||||
|
||||
this.db
|
||||
.prepare(`DELETE FROM ${escapeTable(junctionTable)} WHERE "owningId" = ? AND "relatedId" = ?`)
|
||||
.run(owningId, targetId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get related IDs from a relation
|
||||
*
|
||||
* @param {string} owningClass - Source class name
|
||||
* @param {string} owningId - Source record ID
|
||||
* @param {string} relationName - Relation field name
|
||||
* @returns {string[]} Related record IDs
|
||||
*/
|
||||
getRelatedIds(owningClass, owningId, relationName) {
|
||||
const junctionTable = `_Join_${relationName}_${owningClass}`;
|
||||
|
||||
try {
|
||||
const rows = this.db
|
||||
.prepare(`SELECT "relatedId" FROM ${escapeTable(junctionTable)} WHERE "owningId" = ?`)
|
||||
.all(owningId);
|
||||
return rows.map((r) => r.relatedId);
|
||||
} catch (e) {
|
||||
if (e.message.includes('no such table')) {
|
||||
return [];
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SchemaManager;
|
||||
18
packages/noodl-runtime/src/api/adapters/local-sql/index.js
Normal file
18
packages/noodl-runtime/src/api/adapters/local-sql/index.js
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* LocalSQL Adapter Module
|
||||
*
|
||||
* SQLite-based implementation of the CloudStore adapter interface.
|
||||
* Provides local storage with the same API as Parse-based CloudStore.
|
||||
*
|
||||
* @module adapters/local-sql
|
||||
*/
|
||||
|
||||
const LocalSQLAdapter = require('./LocalSQLAdapter');
|
||||
const QueryBuilder = require('./QueryBuilder');
|
||||
const SchemaManager = require('./SchemaManager');
|
||||
|
||||
module.exports = {
|
||||
LocalSQLAdapter,
|
||||
QueryBuilder,
|
||||
SchemaManager
|
||||
};
|
||||
Reference in New Issue
Block a user