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