From 72c9989a68df31c8c1a10f7d7ce5c73f50ef6a61 Mon Sep 17 00:00:00 2001 From: Richard Osborne Date: Thu, 15 Jan 2026 16:04:24 +0100 Subject: [PATCH] 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 --- .../TASK-007A-PROGRESS.md | 150 ++++ .../noodl-runtime/src/api/adapters/index.js | 166 +++++ .../api/adapters/local-sql/LocalSQLAdapter.js | 587 +++++++++++++++ .../api/adapters/local-sql/QueryBuilder.js | 705 ++++++++++++++++++ .../api/adapters/local-sql/SchemaManager.js | 511 +++++++++++++ .../src/api/adapters/local-sql/index.js | 18 + .../noodl-runtime/src/api/adapters/types.js | 164 ++++ .../test/adapters/QueryBuilder.test.js | 451 +++++++++++ 8 files changed, 2752 insertions(+) create mode 100644 dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/TASK-007-integrated-backend/TASK-007A-PROGRESS.md create mode 100644 packages/noodl-runtime/src/api/adapters/index.js create mode 100644 packages/noodl-runtime/src/api/adapters/local-sql/LocalSQLAdapter.js create mode 100644 packages/noodl-runtime/src/api/adapters/local-sql/QueryBuilder.js create mode 100644 packages/noodl-runtime/src/api/adapters/local-sql/SchemaManager.js create mode 100644 packages/noodl-runtime/src/api/adapters/local-sql/index.js create mode 100644 packages/noodl-runtime/src/api/adapters/types.js create mode 100644 packages/noodl-runtime/test/adapters/QueryBuilder.test.js diff --git a/dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/TASK-007-integrated-backend/TASK-007A-PROGRESS.md b/dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/TASK-007-integrated-backend/TASK-007A-PROGRESS.md new file mode 100644 index 0000000..19e182b --- /dev/null +++ b/dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/TASK-007-integrated-backend/TASK-007A-PROGRESS.md @@ -0,0 +1,150 @@ +# TASK-007A: LocalSQL Adapter - Progress + +**Status:** ✅ Core Implementation Complete +**Branch:** `feature/task-007a-localsql-adapter` +**Started:** 2026-01-15 +**Last Updated:** 2026-01-15 + +--- + +## What Was Built + +### Files Created + +``` +packages/noodl-runtime/src/api/adapters/ +├── index.js # Adapter registry & exports +├── types.js # JSDoc type definitions +└── local-sql/ + ├── index.js # Module exports + ├── LocalSQLAdapter.js # Main adapter (implements CloudStore interface) + ├── QueryBuilder.js # Parse-style query → SQL translation + └── SchemaManager.js # Table creation, migrations, export + +packages/noodl-runtime/test/adapters/ +└── QueryBuilder.test.js # Unit tests for query builder +``` + +### Features Implemented + +#### LocalSQLAdapter + +- ✅ Implements same API as existing `CloudStore` class +- ✅ Uses `better-sqlite3` for local SQLite storage +- ✅ WAL mode for better concurrent access +- ✅ Event emission (create, save, delete, fetch) +- ✅ Auto-create tables on first access +- ✅ Auto-add columns for new fields + +#### QueryBuilder + +- ✅ Translates Parse-style queries to SQL +- ✅ All operators: `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte` +- ✅ Collection operators: `$in`, `$nin` +- ✅ Existence: `$exists` +- ✅ Logical: `$and`, `$or` +- ✅ Relation support: `$relatedTo` +- ✅ Parse types: Date, Pointer +- ✅ SQL injection prevention +- ✅ Value serialization/deserialization + +#### SchemaManager + +- ✅ Create tables from Noodl schemas +- ✅ Add columns dynamically +- ✅ Junction tables for relations +- ✅ Export to PostgreSQL SQL +- ✅ Export to Supabase SQL (with RLS policies) +- ✅ JSON schema export/import +- ✅ Schema tracking in `_Schema` table + +### CloudStore Interface Methods + +| Method | Status | Notes | +| ------------------ | ------ | ----------------------- | +| `query()` | ✅ | Full Parse query syntax | +| `fetch()` | ✅ | Single record by ID | +| `create()` | ✅ | With auto ID generation | +| `save()` | ✅ | Update existing | +| `delete()` | ✅ | By objectId | +| `count()` | ✅ | With where filter | +| `aggregate()` | ✅ | sum, avg, max, min | +| `distinct()` | ✅ | Unique values | +| `increment()` | ✅ | Atomic increment | +| `addRelation()` | ✅ | Via junction tables | +| `removeRelation()` | ✅ | Via junction tables | +| `on()` / `off()` | ✅ | Event subscription | + +--- + +## Testing Status + +### Unit Tests + +- ✅ QueryBuilder tests written (56 test cases) +- ⚠️ Runtime package has dependency issues (`terminal-link` missing) +- Tests need `npm install` at repo root to run + +### Manual Testing + +- 🔲 Pending - requires `better-sqlite3` installation + +--- + +## What's Next + +### Remaining for TASK-007A + +1. Install missing test dependencies +2. Run and verify all tests pass +3. Manual integration test with real database + +### Next Steps (TASK-007B: Backend Server) + +- Express server implementation +- REST endpoints matching CloudStore API +- WebSocket for realtime updates +- IPC handlers for Electron main process + +--- + +## Usage Example + +```javascript +const { LocalSQLAdapter } = require('@noodl/runtime/src/api/adapters'); + +// Create adapter +const adapter = new LocalSQLAdapter('/path/to/database.db'); +await adapter.connect(); + +// Create a record +adapter.create({ + collection: 'todos', + data: { title: 'Learn Noodl', completed: false }, + success: (record) => console.log('Created:', record), + error: (err) => console.error(err) +}); + +// Query records +adapter.query({ + collection: 'todos', + where: { completed: { $eq: false } }, + sort: '-createdAt', + limit: 10, + success: (results) => console.log('Todos:', results), + error: (err) => console.error(err) +}); + +// Export schema to PostgreSQL +const pgSQL = adapter.getSchemaManager().generatePostgresSQL(); +console.log(pgSQL); +``` + +--- + +## Notes + +- The adapter matches the callback-based API of CloudStore for compatibility +- `better-sqlite3` is required but not added to package.json yet +- Will be added when integrating with Backend Server (TASK-007B) +- ESLint warnings about `require()` are expected (CommonJS package) diff --git a/packages/noodl-runtime/src/api/adapters/index.js b/packages/noodl-runtime/src/api/adapters/index.js new file mode 100644 index 0000000..ab4af3a --- /dev/null +++ b/packages/noodl-runtime/src/api/adapters/index.js @@ -0,0 +1,166 @@ +/** + * CloudStore Adapters Registry + * + * Provides a unified interface for creating and managing CloudStore adapters. + * Supports local SQLite, Parse Server, and future external adapters. + * + * @module adapters + */ + +const { LocalSQLAdapter, QueryBuilder, SchemaManager } = require('./local-sql'); + +/** + * @typedef {'local' | 'parse' | 'external'} AdapterType + */ + +/** + * @typedef {Object} AdapterConfig + * @property {AdapterType} type - Adapter type + * @property {string} [dbPath] - For local: path to SQLite database + * @property {string} [endpoint] - For parse/external: server endpoint + * @property {string} [appId] - For parse: application ID + * @property {string} [masterKey] - For parse: master key + * @property {Object} [collections] - Collection schemas + */ + +/** + * Adapter Registry - manages adapter instances + */ +class AdapterRegistry { + constructor() { + /** @type {Map} */ + this.adapters = new Map(); + } + + /** + * Create and register an adapter + * + * @param {string} id - Unique adapter ID + * @param {AdapterConfig} config - Adapter configuration + * @returns {Promise} + */ + async createAdapter(id, config) { + if (this.adapters.has(id)) { + return this.adapters.get(id); + } + + let adapter; + + switch (config.type) { + case 'local': + if (!config.dbPath) { + throw new Error('dbPath is required for local adapter'); + } + adapter = new LocalSQLAdapter(config.dbPath, { + collections: config.collections + }); + await adapter.connect(); + break; + + case 'parse': + // For parse adapter, we'll use the existing CloudStore + // This is a placeholder - implement ParseAdapter when needed + throw new Error('Parse adapter not yet refactored. Use existing CloudStore.'); + + case 'external': + // External adapters (Supabase, PocketBase, etc.) - future work + throw new Error(`External adapter type not yet implemented`); + + default: + throw new Error(`Unknown adapter type: ${config.type}`); + } + + this.adapters.set(id, adapter); + return adapter; + } + + /** + * Get an existing adapter by ID + * + * @param {string} id - Adapter ID + * @returns {LocalSQLAdapter|undefined} + */ + getAdapter(id) { + return this.adapters.get(id); + } + + /** + * Check if an adapter exists + * + * @param {string} id - Adapter ID + * @returns {boolean} + */ + hasAdapter(id) { + return this.adapters.has(id); + } + + /** + * Disconnect and remove an adapter + * + * @param {string} id - Adapter ID + * @returns {Promise} + */ + async removeAdapter(id) { + const adapter = this.adapters.get(id); + if (adapter) { + await adapter.disconnect(); + this.adapters.delete(id); + } + } + + /** + * Disconnect and remove all adapters + * + * @returns {Promise} + */ + async disconnectAll() { + for (const adapter of this.adapters.values()) { + await adapter.disconnect(); + } + this.adapters.clear(); + } + + /** + * List all registered adapter IDs + * + * @returns {string[]} + */ + listAdapters() { + return Array.from(this.adapters.keys()); + } +} + +// Singleton instance +let _registryInstance; + +/** + * Get the singleton registry instance + * + * @returns {AdapterRegistry} + */ +function getRegistry() { + if (!_registryInstance) { + _registryInstance = new AdapterRegistry(); + } + return _registryInstance; +} + +module.exports = { + // Classes + AdapterRegistry, + LocalSQLAdapter, + QueryBuilder, + SchemaManager, + + // Singleton access + getRegistry, + + // Convenience function + createLocalAdapter: async (id, dbPath, options = {}) => { + return getRegistry().createAdapter(id, { + type: 'local', + dbPath, + ...options + }); + } +}; diff --git a/packages/noodl-runtime/src/api/adapters/local-sql/LocalSQLAdapter.js b/packages/noodl-runtime/src/api/adapters/local-sql/LocalSQLAdapter.js new file mode 100644 index 0000000..ed66eec --- /dev/null +++ b/packages/noodl-runtime/src/api/adapters/local-sql/LocalSQLAdapter.js @@ -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} + */ + 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} + */ + 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; diff --git a/packages/noodl-runtime/src/api/adapters/local-sql/QueryBuilder.js b/packages/noodl-runtime/src/api/adapters/local-sql/QueryBuilder.js new file mode 100644 index 0000000..541867d --- /dev/null +++ b/packages/noodl-runtime/src/api/adapters/local-sql/QueryBuilder.js @@ -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} 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 +}; diff --git a/packages/noodl-runtime/src/api/adapters/local-sql/SchemaManager.js b/packages/noodl-runtime/src/api/adapters/local-sql/SchemaManager.js new file mode 100644 index 0000000..cd99456 --- /dev/null +++ b/packages/noodl-runtime/src/api/adapters/local-sql/SchemaManager.js @@ -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} + */ +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} + */ +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} 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; diff --git a/packages/noodl-runtime/src/api/adapters/local-sql/index.js b/packages/noodl-runtime/src/api/adapters/local-sql/index.js new file mode 100644 index 0000000..4169fbb --- /dev/null +++ b/packages/noodl-runtime/src/api/adapters/local-sql/index.js @@ -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 +}; diff --git a/packages/noodl-runtime/src/api/adapters/types.js b/packages/noodl-runtime/src/api/adapters/types.js new file mode 100644 index 0000000..9545b96 --- /dev/null +++ b/packages/noodl-runtime/src/api/adapters/types.js @@ -0,0 +1,164 @@ +/** + * CloudStore Adapter Type Definitions + * + * @module adapters/types + */ + +/** + * @typedef {'String' | 'Number' | 'Boolean' | 'Date' | 'Object' | 'Array' | 'Pointer' | 'Relation' | 'GeoPoint' | 'File'} ColumnType + */ + +/** + * @typedef {Object} ColumnDefinition + * @property {string} name - Column name + * @property {ColumnType} type - Data type + * @property {boolean} [required] - Whether field is required + * @property {string} [targetClass] - For Pointer/Relation types, the target collection + * @property {*} [defaultValue] - Default value for the column + */ + +/** + * @typedef {Object} TableSchema + * @property {string} name - Table/Collection name + * @property {ColumnDefinition[]} columns - Column definitions + */ + +/** + * @typedef {Object} QueryOptions + * @property {string} collection - Collection name + * @property {Object} [where] - Query filter (Parse-style) + * @property {number} [limit] - Max records to return + * @property {number} [skip] - Records to skip + * @property {string|string[]} [include] - Relations to include + * @property {string|string[]} [select] - Fields to select + * @property {string|string[]} [sort] - Sort order + * @property {boolean} [count] - Include count in response + * @property {function(Object[], number=): void} success - Success callback + * @property {function(string=): void} error - Error callback + */ + +/** + * @typedef {Object} FetchOptions + * @property {string} collection - Collection name + * @property {string} objectId - Record ID + * @property {string|string[]} [include] - Relations to include + * @property {function(Object): void} success - Success callback + * @property {function(string=): void} error - Error callback + */ + +/** + * @typedef {Object} CreateOptions + * @property {string} collection - Collection name + * @property {Object} data - Record data + * @property {Object} [acl] - Access control list + * @property {function(Object): void} success - Success callback + * @property {function(string=): void} error - Error callback + */ + +/** + * @typedef {Object} SaveOptions + * @property {string} collection - Collection name + * @property {string} objectId - Record ID + * @property {Object} data - Record data + * @property {Object} [acl] - Access control list + * @property {function(Object): void} success - Success callback + * @property {function(string=): void} error - Error callback + */ + +/** + * @typedef {Object} DeleteOptions + * @property {string} collection - Collection name + * @property {string} objectId - Record ID + * @property {function(): void} success - Success callback + * @property {function(string=): void} error - Error callback + */ + +/** + * @typedef {Object} CountOptions + * @property {string} collection - Collection name + * @property {Object} [where] - Query filter + * @property {function(number): void} success - Success callback + * @property {function(string=): void} error - Error callback + */ + +/** + * @typedef {Object} AggregateOptions + * @property {string} collection - Collection name + * @property {Object} [where] - Query filter + * @property {Object} group - Grouping configuration + * @property {number} [limit] - Max results + * @property {number} [skip] - Results to skip + * @property {function(Object): void} success - Success callback + * @property {function(string=): void} error - Error callback + */ + +/** + * @typedef {Object} RelationOptions + * @property {string} collection - Source collection + * @property {string} objectId - Source record ID + * @property {string} key - Relation field name + * @property {string} targetObjectId - Target record ID + * @property {string} targetClass - Target collection name + * @property {function(Object): void} success - Success callback + * @property {function(string=): void} error - Error callback + */ + +/** + * @typedef {Object} IncrementOptions + * @property {string} collection - Collection name + * @property {string} objectId - Record ID + * @property {Object} properties - Properties to increment with amounts + * @property {function(Object): void} success - Success callback + * @property {function(string=): void} error - Error callback + */ + +/** + * @typedef {Object} DistinctOptions + * @property {string} collection - Collection name + * @property {string} property - Property to get distinct values for + * @property {Object} [where] - Query filter + * @property {function(Array): void} success - Success callback + * @property {function(string=): void} error - Error callback + */ + +/** + * @typedef {Object} CloudStoreEvent + * @property {'create' | 'save' | 'delete' | 'fetch'} type - Event type + * @property {string} [objectId] - Record ID + * @property {Object} [object] - Record data + * @property {string} collection - Collection name + */ + +/** + * @typedef {function(CloudStoreEvent): void} EventHandler + */ + +/** + * CloudStore Adapter Interface + * + * All adapters must implement these methods with the same signatures + * as the original CloudStore class. + * + * @interface CloudStoreAdapter + */ + +/** + * @typedef {Object} CloudStoreAdapter + * @property {function(QueryOptions): void} query - Query records + * @property {function(FetchOptions): void} fetch - Fetch single record + * @property {function(CreateOptions): void} create - Create new record + * @property {function(SaveOptions): void} save - Update existing record + * @property {function(DeleteOptions): void} delete - Delete record + * @property {function(CountOptions): void} count - Count records + * @property {function(AggregateOptions): void} aggregate - Aggregate records + * @property {function(DistinctOptions): void} distinct - Get distinct values + * @property {function(IncrementOptions): void} increment - Increment properties + * @property {function(RelationOptions): void} addRelation - Add relation + * @property {function(RelationOptions): void} removeRelation - Remove relation + * @property {function(string, EventHandler, Object=): void} on - Subscribe to events + * @property {function(string, EventHandler=, Object=): void} off - Unsubscribe from events + * @property {function(): Promise} connect - Connect to data store + * @property {function(): Promise} disconnect - Disconnect from data store + */ + +module.exports = {}; diff --git a/packages/noodl-runtime/test/adapters/QueryBuilder.test.js b/packages/noodl-runtime/test/adapters/QueryBuilder.test.js new file mode 100644 index 0000000..54a4a6d --- /dev/null +++ b/packages/noodl-runtime/test/adapters/QueryBuilder.test.js @@ -0,0 +1,451 @@ +const QueryBuilder = require('../../src/api/adapters/local-sql/QueryBuilder'); + +describe('QueryBuilder', () => { + describe('escapeTable', () => { + it('escapes table names', () => { + expect(QueryBuilder.escapeTable('users')).toBe('"users"'); + expect(QueryBuilder.escapeTable('User_Data')).toBe('"User_Data"'); + }); + + it('removes invalid characters from table names', () => { + expect(QueryBuilder.escapeTable('users; DROP TABLE')).toBe('"usersDROPTABLE"'); + expect(QueryBuilder.escapeTable("users'--")).toBe('"users"'); + }); + }); + + describe('escapeColumn', () => { + it('escapes column names', () => { + expect(QueryBuilder.escapeColumn('name')).toBe('"name"'); + expect(QueryBuilder.escapeColumn('user_id')).toBe('"user_id"'); + }); + + it('handles reserved words', () => { + expect(QueryBuilder.escapeColumn('order')).toBe('"order"'); + expect(QueryBuilder.escapeColumn('select')).toBe('"select"'); + }); + }); + + describe('buildWhereClause', () => { + it('returns empty string for empty where', () => { + const params = []; + expect(QueryBuilder.buildWhereClause({}, params)).toBe(''); + expect(QueryBuilder.buildWhereClause(null, params)).toBe(''); + expect(QueryBuilder.buildWhereClause(undefined, params)).toBe(''); + }); + + it('handles $eq operator', () => { + const params = []; + const result = QueryBuilder.buildWhereClause({ name: { $eq: 'John' } }, params); + expect(result).toBe('"name" = ?'); + expect(params).toEqual(['John']); + }); + + it('handles $ne operator', () => { + const params = []; + const result = QueryBuilder.buildWhereClause({ status: { $ne: 'deleted' } }, params); + expect(result).toBe('"status" != ?'); + expect(params).toEqual(['deleted']); + }); + + it('handles $gt, $gte, $lt, $lte operators', () => { + let params = []; + expect(QueryBuilder.buildWhereClause({ age: { $gt: 18 } }, params)).toBe('"age" > ?'); + expect(params).toEqual([18]); + + params = []; + expect(QueryBuilder.buildWhereClause({ age: { $gte: 21 } }, params)).toBe('"age" >= ?'); + expect(params).toEqual([21]); + + params = []; + expect(QueryBuilder.buildWhereClause({ age: { $lt: 65 } }, params)).toBe('"age" < ?'); + expect(params).toEqual([65]); + + params = []; + expect(QueryBuilder.buildWhereClause({ age: { $lte: 100 } }, params)).toBe('"age" <= ?'); + expect(params).toEqual([100]); + }); + + it('handles $in operator', () => { + const params = []; + const result = QueryBuilder.buildWhereClause({ status: { $in: ['active', 'pending'] } }, params); + expect(result).toBe('"status" IN (?, ?)'); + expect(params).toEqual(['active', 'pending']); + }); + + it('handles $nin operator', () => { + const params = []; + const result = QueryBuilder.buildWhereClause({ status: { $nin: ['deleted', 'archived'] } }, params); + expect(result).toBe('"status" NOT IN (?, ?)'); + expect(params).toEqual(['deleted', 'archived']); + }); + + it('handles $exists operator', () => { + let params = []; + expect(QueryBuilder.buildWhereClause({ email: { $exists: true } }, params)).toBe('"email" IS NOT NULL'); + expect(params).toEqual([]); + + params = []; + expect(QueryBuilder.buildWhereClause({ phone: { $exists: false } }, params)).toBe('"phone" IS NULL'); + expect(params).toEqual([]); + }); + + it('handles $and operator', () => { + const params = []; + const result = QueryBuilder.buildWhereClause( + { + $and: [{ name: { $eq: 'John' } }, { age: { $gt: 18 } }] + }, + params + ); + expect(result).toBe('("name" = ? AND "age" > ?)'); + expect(params).toEqual(['John', 18]); + }); + + it('handles $or operator', () => { + const params = []; + const result = QueryBuilder.buildWhereClause( + { + $or: [{ status: { $eq: 'active' } }, { status: { $eq: 'pending' } }] + }, + params + ); + expect(result).toBe('("status" = ? OR "status" = ?)'); + expect(params).toEqual(['active', 'pending']); + }); + + it('handles nested $and and $or', () => { + const params = []; + const result = QueryBuilder.buildWhereClause( + { + $and: [{ $or: [{ type: { $eq: 'A' } }, { type: { $eq: 'B' } }] }, { active: { $eq: true } }] + }, + params + ); + expect(result).toBe('(("type" = ? OR "type" = ?) AND "active" = ?)'); + expect(params).toEqual(['A', 'B', true]); + }); + + it('handles direct equality', () => { + const params = []; + const result = QueryBuilder.buildWhereClause({ name: 'John' }, params); + expect(result).toBe('"name" = ?'); + expect(params).toEqual(['John']); + }); + + it('handles Parse Date type', () => { + const params = []; + const result = QueryBuilder.buildWhereClause( + { + createdAt: { $gt: { __type: 'Date', iso: '2024-01-01T00:00:00.000Z' } } + }, + params + ); + expect(result).toBe('"createdAt" > ?'); + expect(params).toEqual(['2024-01-01T00:00:00.000Z']); + }); + + it('handles Parse Pointer type', () => { + const params = []; + const result = QueryBuilder.buildWhereClause( + { + author: { $eq: { __type: 'Pointer', objectId: 'abc123', className: 'User' } } + }, + params + ); + expect(result).toBe('"author" = ?'); + expect(params).toEqual(['abc123']); + }); + + it('handles null equality', () => { + let params = []; + expect(QueryBuilder.buildWhereClause({ name: { $eq: null } }, params)).toBe('"name" IS NULL'); + + params = []; + expect(QueryBuilder.buildWhereClause({ name: { $ne: null } }, params)).toBe('"name" IS NOT NULL'); + }); + }); + + describe('buildOrderClause', () => { + it('returns empty string for empty sort', () => { + expect(QueryBuilder.buildOrderClause(null)).toBe(''); + expect(QueryBuilder.buildOrderClause(undefined)).toBe(''); + expect(QueryBuilder.buildOrderClause('')).toBe(''); + }); + + it('handles ascending sort', () => { + expect(QueryBuilder.buildOrderClause('name')).toBe('"name" ASC'); + expect(QueryBuilder.buildOrderClause(['name'])).toBe('"name" ASC'); + }); + + it('handles descending sort', () => { + expect(QueryBuilder.buildOrderClause('-createdAt')).toBe('"createdAt" DESC'); + expect(QueryBuilder.buildOrderClause(['-createdAt'])).toBe('"createdAt" DESC'); + }); + + it('handles multiple sort fields', () => { + expect(QueryBuilder.buildOrderClause(['name', '-createdAt'])).toBe('"name" ASC, "createdAt" DESC'); + expect(QueryBuilder.buildOrderClause('name,-createdAt')).toBe('"name" ASC, "createdAt" DESC'); + }); + }); + + describe('buildSelect', () => { + it('builds basic select', () => { + const { sql, params } = QueryBuilder.buildSelect({ collection: 'users' }); + expect(sql).toBe('SELECT * FROM "users"'); + expect(params).toEqual([]); + }); + + it('builds select with where', () => { + const { sql, params } = QueryBuilder.buildSelect({ + collection: 'users', + where: { active: { $eq: true } } + }); + expect(sql).toBe('SELECT * FROM "users" WHERE "active" = ?'); + expect(params).toEqual([true]); + }); + + it('builds select with sort', () => { + const { sql, params } = QueryBuilder.buildSelect({ + collection: 'users', + sort: '-createdAt' + }); + expect(sql).toBe('SELECT * FROM "users" ORDER BY "createdAt" DESC'); + expect(params).toEqual([]); + }); + + it('builds select with limit and skip', () => { + const { sql, params } = QueryBuilder.buildSelect({ + collection: 'users', + limit: 10, + skip: 20 + }); + expect(sql).toBe('SELECT * FROM "users" LIMIT ? OFFSET ?'); + expect(params).toEqual([10, 20]); + }); + + it('builds select with specific fields', () => { + const { sql, params } = QueryBuilder.buildSelect({ + collection: 'users', + select: ['name', 'email'] + }); + expect(sql).toContain('SELECT'); + expect(sql).toContain('"objectId"'); + expect(sql).toContain('"name"'); + expect(sql).toContain('"email"'); + }); + + it('builds complete query', () => { + const { sql, params } = QueryBuilder.buildSelect({ + collection: 'users', + where: { age: { $gte: 18 } }, + sort: ['name', '-createdAt'], + limit: 10, + skip: 5 + }); + expect(sql).toBe('SELECT * FROM "users" WHERE "age" >= ? ORDER BY "name" ASC, "createdAt" DESC LIMIT ? OFFSET ?'); + expect(params).toEqual([18, 10, 5]); + }); + }); + + describe('buildCount', () => { + it('builds basic count', () => { + const { sql, params } = QueryBuilder.buildCount({ collection: 'users' }); + expect(sql).toBe('SELECT COUNT(*) as count FROM "users"'); + expect(params).toEqual([]); + }); + + it('builds count with where', () => { + const { sql, params } = QueryBuilder.buildCount({ + collection: 'users', + where: { active: { $eq: true } } + }); + expect(sql).toBe('SELECT COUNT(*) as count FROM "users" WHERE "active" = ?'); + expect(params).toEqual([true]); + }); + }); + + describe('buildInsert', () => { + it('builds insert with objectId', () => { + const { sql, params } = QueryBuilder.buildInsert( + { collection: 'users', data: { name: 'John', age: 30 } }, + 'abc123' + ); + expect(sql).toContain('INSERT INTO "users"'); + expect(sql).toContain('"objectId"'); + expect(sql).toContain('"name"'); + expect(sql).toContain('"age"'); + expect(params).toContain('abc123'); + expect(params).toContain('John'); + expect(params).toContain(30); + }); + }); + + describe('buildUpdate', () => { + it('builds update', () => { + const { sql, params } = QueryBuilder.buildUpdate({ + collection: 'users', + objectId: 'abc123', + data: { name: 'Jane' } + }); + expect(sql).toContain('UPDATE "users" SET'); + expect(sql).toContain('"name" = ?'); + expect(sql).toContain('WHERE "objectId" = ?'); + expect(params).toContain('Jane'); + expect(params).toContain('abc123'); + }); + }); + + describe('buildDelete', () => { + it('builds delete', () => { + const { sql, params } = QueryBuilder.buildDelete({ + collection: 'users', + objectId: 'abc123' + }); + expect(sql).toBe('DELETE FROM "users" WHERE "objectId" = ?'); + expect(params).toEqual(['abc123']); + }); + }); + + describe('buildIncrement', () => { + it('builds increment', () => { + const { sql, params } = QueryBuilder.buildIncrement({ + collection: 'posts', + objectId: 'abc123', + properties: { views: 1, likes: 5 } + }); + expect(sql).toContain('UPDATE "posts" SET'); + expect(sql).toContain('"views" = COALESCE("views", 0) + ?'); + expect(sql).toContain('"likes" = COALESCE("likes", 0) + ?'); + expect(params).toContain(1); + expect(params).toContain(5); + expect(params).toContain('abc123'); + }); + }); + + describe('buildDistinct', () => { + it('builds distinct', () => { + const { sql, params } = QueryBuilder.buildDistinct({ + collection: 'users', + property: 'country' + }); + expect(sql).toBe('SELECT DISTINCT "country" FROM "users"'); + expect(params).toEqual([]); + }); + + it('builds distinct with where', () => { + const { sql, params } = QueryBuilder.buildDistinct({ + collection: 'users', + property: 'country', + where: { active: { $eq: true } } + }); + expect(sql).toBe('SELECT DISTINCT "country" FROM "users" WHERE "active" = ?'); + expect(params).toEqual([true]); + }); + }); + + describe('buildAggregate', () => { + it('builds aggregate with sum', () => { + const { sql, params } = QueryBuilder.buildAggregate({ + collection: 'orders', + group: { totalAmount: { sum: 'amount' } } + }); + expect(sql).toBe('SELECT SUM("amount") as "totalAmount" FROM "orders"'); + expect(params).toEqual([]); + }); + + it('builds aggregate with avg', () => { + const { sql, params } = QueryBuilder.buildAggregate({ + collection: 'products', + group: { avgPrice: { avg: 'price' } } + }); + expect(sql).toBe('SELECT AVG("price") as "avgPrice" FROM "products"'); + expect(params).toEqual([]); + }); + + it('builds aggregate with max and min', () => { + const { sql, params } = QueryBuilder.buildAggregate({ + collection: 'products', + group: { + maxPrice: { max: 'price' }, + minPrice: { min: 'price' } + } + }); + expect(sql).toContain('MAX("price") as "maxPrice"'); + expect(sql).toContain('MIN("price") as "minPrice"'); + }); + + it('builds aggregate with where', () => { + const { sql, params } = QueryBuilder.buildAggregate({ + collection: 'orders', + where: { status: { $eq: 'completed' } }, + group: { total: { sum: 'amount' } } + }); + expect(sql).toContain('WHERE "status" = ?'); + expect(params).toEqual(['completed']); + }); + }); + + describe('serializeValue', () => { + it('handles null and undefined', () => { + expect(QueryBuilder.serializeValue(null)).toBe(null); + expect(QueryBuilder.serializeValue(undefined)).toBe(null); + }); + + it('handles primitives', () => { + expect(QueryBuilder.serializeValue('hello')).toBe('hello'); + expect(QueryBuilder.serializeValue(42)).toBe(42); + expect(QueryBuilder.serializeValue(true)).toBe(1); + expect(QueryBuilder.serializeValue(false)).toBe(0); + }); + + it('handles Date objects', () => { + const date = new Date('2024-01-01T00:00:00.000Z'); + expect(QueryBuilder.serializeValue(date)).toBe('2024-01-01T00:00:00.000Z'); + }); + + it('handles Parse Date type', () => { + expect(QueryBuilder.serializeValue({ __type: 'Date', iso: '2024-01-01T00:00:00.000Z' })).toBe( + '2024-01-01T00:00:00.000Z' + ); + }); + + it('handles Parse Pointer type', () => { + expect(QueryBuilder.serializeValue({ __type: 'Pointer', objectId: 'abc123', className: 'User' })).toBe('abc123'); + }); + + it('handles arrays and objects as JSON', () => { + expect(QueryBuilder.serializeValue([1, 2, 3])).toBe('[1,2,3]'); + expect(QueryBuilder.serializeValue({ foo: 'bar' })).toBe('{"foo":"bar"}'); + }); + }); + + describe('deserializeValue', () => { + it('handles null and undefined', () => { + expect(QueryBuilder.deserializeValue(null)).toBe(null); + expect(QueryBuilder.deserializeValue(undefined)).toBe(null); + }); + + it('handles Boolean type', () => { + expect(QueryBuilder.deserializeValue(1, 'Boolean')).toBe(true); + expect(QueryBuilder.deserializeValue(0, 'Boolean')).toBe(false); + }); + + it('handles JSON strings for Object type', () => { + expect(QueryBuilder.deserializeValue('{"foo":"bar"}', 'Object')).toEqual({ foo: 'bar' }); + }); + + it('handles JSON strings for Array type', () => { + expect(QueryBuilder.deserializeValue('[1,2,3]', 'Array')).toEqual([1, 2, 3]); + }); + + it('auto-parses JSON strings', () => { + expect(QueryBuilder.deserializeValue('{"foo":"bar"}')).toEqual({ foo: 'bar' }); + expect(QueryBuilder.deserializeValue('[1,2,3]')).toEqual([1, 2, 3]); + }); + + it('returns non-JSON strings as-is', () => { + expect(QueryBuilder.deserializeValue('hello')).toBe('hello'); + expect(QueryBuilder.deserializeValue('not json {')).toBe('not json {'); + }); + }); +});