mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-03-07 17:43:28 +01:00
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
588 lines
15 KiB
JavaScript
588 lines
15 KiB
JavaScript
/**
|
|
* 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;
|