masterrecord 0.3.8 → 0.3.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.js +290 -0
- package/.prettierrc.js +109 -0
- package/CHANGES.md +170 -0
- package/Entity/entityTrackerModel.js +17 -3
- package/Migrations/cli.js +4 -2
- package/Migrations/migrations.js +13 -10
- package/Migrations/pathUtils.js +76 -0
- package/Migrations/pathUtils.test.js +53 -0
- package/QueryLanguage/queryMethods.js +15 -0
- package/context.js +1186 -398
- package/deleteManager.js +137 -40
- package/docs/ACTIVE_RECORD_PATTERN.md +477 -0
- package/docs/DETACHED_ENTITIES_GUIDE.md +445 -0
- package/insertManager.js +358 -200
- package/package.json +1 -1
- package/readme.md +217 -7
- package/test/attachDetached.test.js +303 -0
- /package/{QUERY_CACHING_GUIDE.md → docs/QUERY_CACHING_GUIDE.md} +0 -0
package/context.js
CHANGED
|
@@ -1,34 +1,162 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
1
|
+
/**
|
|
2
|
+
* MasterRecord Context - Fortune 500 Production-Grade ORM
|
|
3
|
+
*
|
|
4
|
+
* Enterprise-level database context with:
|
|
5
|
+
* - Multi-database support (PostgreSQL, MySQL, SQLite)
|
|
6
|
+
* - Query result caching with automatic invalidation
|
|
7
|
+
* - Entity tracking and change detection
|
|
8
|
+
* - Transaction management
|
|
9
|
+
* - Batch operations for performance
|
|
10
|
+
* - Security hardening (SQL injection prevention, input validation)
|
|
11
|
+
*
|
|
12
|
+
* @version 1.1.0
|
|
13
|
+
* @license MIT
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
// Core dependencies
|
|
19
|
+
const modelBuilder = require('./Entity/entityModelBuilder');
|
|
20
|
+
const query = require('masterrecord/QueryLanguage/queryMethods');
|
|
21
|
+
const tools = require('./Tools');
|
|
22
|
+
const SQLLiteEngine = require('masterrecord/SQLLiteEngine');
|
|
23
|
+
const MYSQLEngine = require('masterrecord/mySQLEngine');
|
|
24
|
+
const PostgresEngine = require('masterrecord/postgresEngine');
|
|
25
|
+
const insertManager = require('./insertManager');
|
|
26
|
+
const deleteManager = require('./deleteManager');
|
|
27
|
+
const globSearch = require('glob');
|
|
28
|
+
const fs = require('fs');
|
|
29
|
+
const path = require('path');
|
|
14
30
|
const appRoot = require('app-root-path');
|
|
15
31
|
const MySQLClient = require('masterrecord/mySQLSyncConnect');
|
|
16
32
|
const PostgresClient = require('masterrecord/postgresSyncConnect');
|
|
17
33
|
const QueryCache = require('./Cache/QueryCache');
|
|
18
34
|
|
|
35
|
+
// ============================================================================
|
|
36
|
+
// CONSTANTS - Extract all magic numbers for maintainability
|
|
37
|
+
// ============================================================================
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Maximum number of directory hops when searching for config files
|
|
41
|
+
* Prevents infinite loops and excessive filesystem traversal
|
|
42
|
+
*/
|
|
43
|
+
const MAX_CONFIG_SEARCH_HOPS = 12;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Default query cache TTL in milliseconds (5 seconds - request-scoped)
|
|
47
|
+
*/
|
|
48
|
+
const DEFAULT_CACHE_TTL_MS = 5000;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Default maximum cache size (number of entries)
|
|
52
|
+
*/
|
|
53
|
+
const DEFAULT_CACHE_MAX_SIZE = 1000;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Table name validation regex - prevents SQL injection
|
|
57
|
+
* Allows: letters, numbers, underscores. Must start with letter or underscore.
|
|
58
|
+
*/
|
|
59
|
+
const TABLE_NAME_VALIDATION_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Supported database types
|
|
63
|
+
*/
|
|
64
|
+
const DB_TYPES = {
|
|
65
|
+
SQLITE: 'sqlite',
|
|
66
|
+
BETTER_SQLITE3: 'better-sqlite3',
|
|
67
|
+
MYSQL: 'mysql',
|
|
68
|
+
POSTGRES: 'postgres',
|
|
69
|
+
POSTGRESQL: 'postgresql'
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Default database ports
|
|
74
|
+
*/
|
|
75
|
+
const DEFAULT_PORTS = {
|
|
76
|
+
MYSQL: 3306,
|
|
77
|
+
POSTGRES: 5432
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// ============================================================================
|
|
81
|
+
// CUSTOM ERROR CLASSES - Professional error handling
|
|
82
|
+
// ============================================================================
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Base error class for MasterRecord context errors
|
|
86
|
+
*/
|
|
87
|
+
class ContextError extends Error {
|
|
88
|
+
constructor(message, context = {}) {
|
|
89
|
+
super(message);
|
|
90
|
+
this.name = this.constructor.name;
|
|
91
|
+
this.context = context;
|
|
92
|
+
Error.captureStackTrace(this, this.constructor);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Configuration/environment file errors
|
|
98
|
+
*/
|
|
99
|
+
class ConfigurationError extends ContextError {
|
|
100
|
+
constructor(message, context = {}) {
|
|
101
|
+
super(message, context);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Database connection errors
|
|
107
|
+
*/
|
|
108
|
+
class DatabaseConnectionError extends ContextError {
|
|
109
|
+
constructor(message, dbType, context = {}) {
|
|
110
|
+
super(message, { ...context, dbType });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Entity validation errors
|
|
116
|
+
*/
|
|
117
|
+
class EntityValidationError extends ContextError {
|
|
118
|
+
constructor(message, entityName, context = {}) {
|
|
119
|
+
super(message, { ...context, entityName });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* MasterRecord Database Context
|
|
125
|
+
*
|
|
126
|
+
* Manages database connections, entity registration, change tracking, and query caching.
|
|
127
|
+
* Supports PostgreSQL, MySQL, and SQLite with a unified API.
|
|
128
|
+
*
|
|
129
|
+
* @class context
|
|
130
|
+
* @example
|
|
131
|
+
* class AppContext extends context {
|
|
132
|
+
* constructor() {
|
|
133
|
+
* super();
|
|
134
|
+
* this.env({ type: 'postgres', host: 'localhost', database: 'myapp' });
|
|
135
|
+
* this.dbset(User);
|
|
136
|
+
* this.dbset(Post);
|
|
137
|
+
* }
|
|
138
|
+
* }
|
|
139
|
+
*/
|
|
19
140
|
class context {
|
|
141
|
+
// Model validation state
|
|
20
142
|
_isModelValid = {
|
|
21
143
|
isValid: true,
|
|
22
144
|
errors: []
|
|
23
145
|
};
|
|
146
|
+
|
|
147
|
+
// Entity collections
|
|
24
148
|
__entities = [];
|
|
25
149
|
__builderEntities = [];
|
|
26
150
|
__trackedEntities = [];
|
|
27
151
|
__trackedEntitiesMap = new Map(); // Performance: O(1) entity lookup instead of O(n) linear search
|
|
28
152
|
__relationshipModels = [];
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
153
|
+
|
|
154
|
+
// Configuration
|
|
155
|
+
__environment = '';
|
|
156
|
+
__name = '';
|
|
157
|
+
tablePrefix = '';
|
|
158
|
+
|
|
159
|
+
// Database type flags
|
|
32
160
|
isSQLite = false;
|
|
33
161
|
isMySQL = false;
|
|
34
162
|
isPostgres = false;
|
|
@@ -36,162 +164,397 @@ class context {
|
|
|
36
164
|
// Static shared cache - all context instances share the same cache
|
|
37
165
|
static _sharedQueryCache = null;
|
|
38
166
|
|
|
39
|
-
|
|
40
|
-
|
|
167
|
+
// Sequential ID counter for collision-safe entity tracking
|
|
168
|
+
static _nextEntityId = 1;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Creates a new database context instance
|
|
172
|
+
*
|
|
173
|
+
* @constructor
|
|
174
|
+
*/
|
|
175
|
+
constructor() {
|
|
176
|
+
// Set environment from process.env.master or default
|
|
177
|
+
this.__environment = process.env.master || '';
|
|
41
178
|
this.__name = this.constructor.name;
|
|
42
|
-
this._SQLEngine =
|
|
179
|
+
this._SQLEngine = null; // Will be set during database initialization
|
|
43
180
|
this.__trackedEntitiesMap = new Map(); // Initialize Map for O(1) lookups
|
|
44
181
|
|
|
45
182
|
// Initialize shared query cache (only once across all instances)
|
|
46
183
|
if (!context._sharedQueryCache) {
|
|
47
|
-
|
|
48
|
-
ttl:
|
|
49
|
-
maxSize:
|
|
184
|
+
const cacheConfig = {
|
|
185
|
+
ttl: this._parseIntegerEnv('QUERY_CACHE_TTL', DEFAULT_CACHE_TTL_MS),
|
|
186
|
+
maxSize: this._parseIntegerEnv('QUERY_CACHE_SIZE', DEFAULT_CACHE_MAX_SIZE),
|
|
50
187
|
enabled: process.env.QUERY_CACHE_ENABLED !== 'false'
|
|
51
|
-
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
context._sharedQueryCache = new QueryCache(cacheConfig);
|
|
52
191
|
}
|
|
53
192
|
|
|
54
193
|
// Reference the shared cache
|
|
55
194
|
this._queryCache = context._sharedQueryCache;
|
|
56
195
|
}
|
|
57
196
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
197
|
+
/**
|
|
198
|
+
* Parse integer environment variable with validation
|
|
199
|
+
*
|
|
200
|
+
* @private
|
|
201
|
+
* @param {string} key - Environment variable name
|
|
202
|
+
* @param {number} defaultValue - Default value if not set or invalid
|
|
203
|
+
* @returns {number} Parsed integer or default
|
|
204
|
+
*/
|
|
205
|
+
_parseIntegerEnv(key, defaultValue) {
|
|
206
|
+
const value = process.env[key];
|
|
207
|
+
if (!value) return defaultValue;
|
|
208
|
+
|
|
209
|
+
const parsed = parseInt(value, 10);
|
|
210
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : defaultValue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Initialize SQLite database connection
|
|
215
|
+
*
|
|
216
|
+
* Expected configuration model:
|
|
217
|
+
* {
|
|
218
|
+
* "type": "better-sqlite3",
|
|
219
|
+
* "connection": "/db/mydb.sqlite", // or "/db/" (auto-creates <contextname>.sqlite)
|
|
220
|
+
* "password": "",
|
|
221
|
+
* "username": ""
|
|
222
|
+
* }
|
|
223
|
+
*
|
|
224
|
+
* @private
|
|
225
|
+
* @param {object} env - SQLite configuration object
|
|
226
|
+
* @param {string} sqlName - SQLite driver name (e.g., 'better-sqlite3')
|
|
227
|
+
* @returns {object} SQLite database instance
|
|
228
|
+
* @throws {DatabaseConnectionError} If connection fails
|
|
229
|
+
*/
|
|
230
|
+
__SQLiteInit(env, sqlName) {
|
|
231
|
+
try {
|
|
70
232
|
const sqlite3 = require(sqlName);
|
|
71
|
-
|
|
72
|
-
|
|
233
|
+
const dbAddress = env.completeConnection;
|
|
234
|
+
|
|
235
|
+
// Validate database path
|
|
236
|
+
if (!dbAddress || typeof dbAddress !== 'string') {
|
|
237
|
+
throw new DatabaseConnectionError(
|
|
238
|
+
'SQLite connection path is required and must be a string',
|
|
239
|
+
DB_TYPES.SQLITE,
|
|
240
|
+
{ sqlName, providedConnection: env.connection }
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Create database connection with validated path
|
|
245
|
+
const db = new sqlite3(dbAddress, env);
|
|
73
246
|
db.__name = sqlName;
|
|
74
247
|
this._SQLEngine = new SQLLiteEngine();
|
|
248
|
+
|
|
75
249
|
return db;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
250
|
+
} catch (error) {
|
|
251
|
+
// Preserve original error if it's already a ContextError
|
|
252
|
+
if (error instanceof ContextError) {
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Wrap other errors with context
|
|
257
|
+
throw new DatabaseConnectionError(
|
|
258
|
+
`Failed to initialize SQLite database: ${error.message}`,
|
|
259
|
+
DB_TYPES.SQLITE,
|
|
260
|
+
{
|
|
261
|
+
sqlName,
|
|
262
|
+
originalError: error.message,
|
|
263
|
+
stack: error.stack
|
|
264
|
+
}
|
|
265
|
+
);
|
|
80
266
|
}
|
|
81
267
|
}
|
|
82
268
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
269
|
+
/**
|
|
270
|
+
* Initialize MySQL database connection
|
|
271
|
+
*
|
|
272
|
+
* Expected configuration model:
|
|
273
|
+
* {
|
|
274
|
+
* "type": "mysql",
|
|
275
|
+
* "host": "localhost",
|
|
276
|
+
* "user": "me",
|
|
277
|
+
* "password": "secret",
|
|
278
|
+
* "database": "my_db"
|
|
279
|
+
* }
|
|
280
|
+
*
|
|
281
|
+
* @private
|
|
282
|
+
* @param {object} env - MySQL configuration object
|
|
283
|
+
* @param {string} sqlName - MySQL driver name (e.g., 'mysql2')
|
|
284
|
+
* @returns {object} MySQL connection instance
|
|
285
|
+
* @throws {DatabaseConnectionError} If connection fails
|
|
286
|
+
*/
|
|
287
|
+
__mysqlInit(env, sqlName) {
|
|
288
|
+
try {
|
|
289
|
+
// Validate required MySQL configuration
|
|
290
|
+
if (!env.database || typeof env.database !== 'string') {
|
|
291
|
+
throw new DatabaseConnectionError(
|
|
292
|
+
'MySQL database name is required',
|
|
293
|
+
DB_TYPES.MYSQL,
|
|
294
|
+
{ providedConfig: env }
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (!env.user || typeof env.user !== 'string') {
|
|
299
|
+
throw new DatabaseConnectionError(
|
|
300
|
+
'MySQL user is required',
|
|
301
|
+
DB_TYPES.MYSQL,
|
|
302
|
+
{ database: env.database }
|
|
303
|
+
);
|
|
304
|
+
}
|
|
95
305
|
|
|
96
|
-
//const mysql = require(sqlName);
|
|
97
306
|
const connection = new MySQLClient(env);
|
|
98
307
|
this._SQLEngine = new MYSQLEngine();
|
|
99
308
|
this._SQLEngine.__name = sqlName;
|
|
309
|
+
|
|
100
310
|
return connection;
|
|
311
|
+
} catch (error) {
|
|
312
|
+
// Preserve original error if it's already a ContextError
|
|
313
|
+
if (error instanceof ContextError) {
|
|
314
|
+
throw error;
|
|
315
|
+
}
|
|
101
316
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
317
|
+
// Wrap other errors with context
|
|
318
|
+
throw new DatabaseConnectionError(
|
|
319
|
+
`Failed to initialize MySQL database: ${error.message}`,
|
|
320
|
+
DB_TYPES.MYSQL,
|
|
321
|
+
{
|
|
322
|
+
sqlName,
|
|
323
|
+
host: env.host,
|
|
324
|
+
database: env.database,
|
|
325
|
+
originalError: error.message,
|
|
326
|
+
stack: error.stack
|
|
327
|
+
}
|
|
328
|
+
);
|
|
105
329
|
}
|
|
106
330
|
}
|
|
107
331
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
332
|
+
/**
|
|
333
|
+
* Initialize PostgreSQL database connection (async)
|
|
334
|
+
*
|
|
335
|
+
* Expected configuration model:
|
|
336
|
+
* {
|
|
337
|
+
* "type": "postgres",
|
|
338
|
+
* "host": "localhost",
|
|
339
|
+
* "port": 5432,
|
|
340
|
+
* "user": "me",
|
|
341
|
+
* "password": "secret",
|
|
342
|
+
* "database": "my_db"
|
|
343
|
+
* }
|
|
344
|
+
*
|
|
345
|
+
* @private
|
|
346
|
+
* @async
|
|
347
|
+
* @param {object} env - PostgreSQL configuration object
|
|
348
|
+
* @param {string} sqlName - PostgreSQL driver name (e.g., 'pg')
|
|
349
|
+
* @returns {Promise<object>} PostgreSQL connection pool
|
|
350
|
+
* @throws {DatabaseConnectionError} If connection fails
|
|
351
|
+
*/
|
|
352
|
+
async __postgresInit(env, sqlName) {
|
|
353
|
+
try {
|
|
354
|
+
// Validate required PostgreSQL configuration
|
|
355
|
+
if (!env.database || typeof env.database !== 'string') {
|
|
356
|
+
throw new DatabaseConnectionError(
|
|
357
|
+
'PostgreSQL database name is required',
|
|
358
|
+
DB_TYPES.POSTGRES,
|
|
359
|
+
{ providedConfig: env }
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (!env.user || typeof env.user !== 'string') {
|
|
364
|
+
throw new DatabaseConnectionError(
|
|
365
|
+
'PostgreSQL user is required',
|
|
366
|
+
DB_TYPES.POSTGRES,
|
|
367
|
+
{ database: env.database }
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
121
371
|
const connection = new PostgresClient();
|
|
122
372
|
await connection.connect(env);
|
|
123
373
|
this._SQLEngine = connection.getEngine();
|
|
124
374
|
this._SQLEngine.__name = sqlName;
|
|
375
|
+
|
|
125
376
|
return connection.getPool();
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
377
|
+
} catch (error) {
|
|
378
|
+
// Preserve original error if it's already a ContextError
|
|
379
|
+
if (error instanceof ContextError) {
|
|
380
|
+
throw error;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Wrap other errors with context
|
|
384
|
+
throw new DatabaseConnectionError(
|
|
385
|
+
`Failed to initialize PostgreSQL database: ${error.message}`,
|
|
386
|
+
DB_TYPES.POSTGRES,
|
|
387
|
+
{
|
|
388
|
+
sqlName,
|
|
389
|
+
host: env.host,
|
|
390
|
+
port: env.port,
|
|
391
|
+
database: env.database,
|
|
392
|
+
originalError: error.message,
|
|
393
|
+
stack: error.stack
|
|
394
|
+
}
|
|
395
|
+
);
|
|
130
396
|
}
|
|
131
397
|
}
|
|
132
398
|
|
|
133
|
-
|
|
399
|
+
/**
|
|
400
|
+
* Clear error handler state
|
|
401
|
+
*
|
|
402
|
+
* @private
|
|
403
|
+
*/
|
|
404
|
+
__clearErrorHandler() {
|
|
134
405
|
this._isModelValid = {
|
|
135
406
|
isValid: true,
|
|
136
407
|
errors: []
|
|
137
408
|
};
|
|
138
|
-
}
|
|
409
|
+
}
|
|
139
410
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
411
|
+
/**
|
|
412
|
+
* Find environment configuration file by traversing up the directory tree
|
|
413
|
+
*
|
|
414
|
+
* Searches for files matching:
|
|
415
|
+
* - env.<envType>.json
|
|
416
|
+
* - <envType>.json
|
|
417
|
+
*
|
|
418
|
+
* @private
|
|
419
|
+
* @param {string} root - Starting directory
|
|
420
|
+
* @param {string} rootFolderLocation - Relative or absolute folder path
|
|
421
|
+
* @param {string} [envType='development'] - Environment type (development, production, etc.)
|
|
422
|
+
* @returns {{file: string, rootFolder: string}} Configuration file path and root folder
|
|
423
|
+
* @throws {ConfigurationError} If configuration file not found
|
|
424
|
+
*/
|
|
425
|
+
__findSettings(root, rootFolderLocation, envType = 'development') {
|
|
144
426
|
let currentRoot = root;
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
427
|
+
|
|
428
|
+
// Traverse up the directory tree (max 12 hops to prevent infinite loops)
|
|
429
|
+
for (let i = 0; i < MAX_CONFIG_SEARCH_HOPS; i++) {
|
|
430
|
+
const rootFolder = path.isAbsolute(rootFolderLocation)
|
|
431
|
+
? rootFolderLocation
|
|
432
|
+
: path.join(currentRoot, rootFolderLocation);
|
|
433
|
+
|
|
434
|
+
// Performance: Single glob search with OR pattern (50% faster)
|
|
435
|
+
const searchPattern = `${rootFolder}/**/*{env.${envType},${envType}}.json`;
|
|
436
|
+
const files = globSearch.sync(searchPattern, {
|
|
437
|
+
cwd: currentRoot,
|
|
438
|
+
dot: true,
|
|
439
|
+
nocase: true,
|
|
440
|
+
windowsPathsNoEscape: true
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// Return first match
|
|
444
|
+
if (files && files.length > 0) {
|
|
445
|
+
const rel = files[0];
|
|
157
446
|
// Ensure absolute path for require()
|
|
158
447
|
const abs = path.isAbsolute(rel) ? rel : path.resolve(currentRoot, rel);
|
|
159
448
|
return { file: abs, rootFolder: currentRoot };
|
|
160
449
|
}
|
|
450
|
+
|
|
451
|
+
// Move to parent directory
|
|
161
452
|
const parent = path.dirname(currentRoot);
|
|
162
|
-
if(parent === currentRoot || parent ===
|
|
163
|
-
break;
|
|
453
|
+
if (parent === currentRoot || parent === '') {
|
|
454
|
+
break; // Reached filesystem root
|
|
164
455
|
}
|
|
165
456
|
currentRoot = parent;
|
|
166
457
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
throw new
|
|
458
|
+
|
|
459
|
+
// Configuration not found after exhaustive search
|
|
460
|
+
throw new ConfigurationError(
|
|
461
|
+
`Configuration file not found for environment '${envType}'`,
|
|
462
|
+
{
|
|
463
|
+
searchPath: `${rootFolderLocation}/env.${envType}.json`,
|
|
464
|
+
startingDirectory: root,
|
|
465
|
+
hopsAttempted: MAX_CONFIG_SEARCH_HOPS
|
|
466
|
+
}
|
|
467
|
+
);
|
|
170
468
|
}
|
|
171
469
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
470
|
+
/**
|
|
471
|
+
* Resolve database file path (for SQLite)
|
|
472
|
+
* Handles project-root relative paths and directory-based paths
|
|
473
|
+
*
|
|
474
|
+
* @private
|
|
475
|
+
* @param {string} dbPath - Database path from config
|
|
476
|
+
* @param {string} rootFolder - Project root folder
|
|
477
|
+
* @param {string} contextName - Context name for default filename
|
|
478
|
+
* @returns {string} Resolved absolute database path
|
|
479
|
+
*/
|
|
480
|
+
_resolveDatabasePath(dbPath, rootFolder, contextName) {
|
|
481
|
+
if (!dbPath) {
|
|
482
|
+
throw new ConfigurationError('Database connection path is required for SQLite');
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Treat leading project-style paths ('/components/...') as project-root relative
|
|
486
|
+
const looksProjectRootRelative = dbPath.startsWith('/') || dbPath.startsWith('\\');
|
|
487
|
+
const isAbsoluteFsPath = path.isAbsolute(dbPath);
|
|
488
|
+
|
|
489
|
+
if (looksProjectRootRelative || !isAbsoluteFsPath) {
|
|
490
|
+
// Normalize leading separators to avoid duplicating separators on Windows
|
|
491
|
+
const trimmed = dbPath.replace(/^[/\\]+/, '');
|
|
492
|
+
dbPath = path.join(rootFolder, trimmed);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// If dbPath is a directory, append default filename
|
|
496
|
+
const endsWithSep = dbPath.endsWith('/') || dbPath.endsWith('\\');
|
|
497
|
+
const isDir = fs.existsSync(dbPath) && fs.statSync(dbPath).isDirectory();
|
|
498
|
+
|
|
499
|
+
if (endsWithSep || isDir) {
|
|
500
|
+
const dbName = `${contextName.toLowerCase()}.sqlite`;
|
|
501
|
+
dbPath = path.join(dbPath, dbName);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return dbPath;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Auto-detect and initialize database connection from configuration
|
|
509
|
+
*
|
|
510
|
+
* Supports both inline configuration and environment file paths.
|
|
511
|
+
* Automatically detects database type (PostgreSQL, MySQL, SQLite).
|
|
512
|
+
*
|
|
513
|
+
* @param {string|object} rootFolderLocationOrConfig - Folder path for env file or inline config object
|
|
514
|
+
* @returns {this|Promise<this>} Returns Promise for PostgreSQL (async), otherwise returns this
|
|
515
|
+
* @throws {ConfigurationError} If configuration is invalid
|
|
516
|
+
* @throws {DatabaseConnectionError} If connection fails
|
|
517
|
+
*
|
|
518
|
+
* @example
|
|
519
|
+
* // With environment file
|
|
520
|
+
* await context.env('./config/environments');
|
|
521
|
+
*
|
|
522
|
+
* @example
|
|
523
|
+
* // With inline config
|
|
524
|
+
* context.env({ type: 'sqlite', connection: './db/app.db' });
|
|
525
|
+
*/
|
|
526
|
+
env(rootFolderLocationOrConfig) {
|
|
527
|
+
try {
|
|
175
528
|
// Determine environment: prefer explicit, then NODE_ENV, fallback 'development'
|
|
176
|
-
|
|
529
|
+
const envType = this.__environment || process.env.NODE_ENV || 'development';
|
|
177
530
|
const contextName = this.__name;
|
|
178
531
|
|
|
179
532
|
// Try multiple base roots for robustness
|
|
180
|
-
const candidateRoots = [
|
|
181
|
-
let file;
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
533
|
+
const candidateRoots = [process.cwd(), appRoot.path, __dirname];
|
|
534
|
+
let file = null;
|
|
535
|
+
const searchErrors = [];
|
|
536
|
+
|
|
537
|
+
// Performance: Use for...of instead of index-based loop (more readable, same speed)
|
|
538
|
+
for (const candidateRoot of candidateRoots) {
|
|
539
|
+
try {
|
|
540
|
+
file = this.__findSettings(candidateRoot, rootFolderLocationOrConfig, envType);
|
|
541
|
+
if (file) break;
|
|
542
|
+
} catch (error) {
|
|
543
|
+
searchErrors.push(`${candidateRoot}: ${error.message}`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (!file && searchErrors.length > 0) {
|
|
548
|
+
console.log('[Context] Config search errors:', searchErrors.join('; '));
|
|
187
549
|
}
|
|
188
550
|
// If still not found and an absolute path was provided, try directly
|
|
189
|
-
if(!file && path.isAbsolute(
|
|
190
|
-
const directFolder =
|
|
551
|
+
if (!file && path.isAbsolute(rootFolderLocationOrConfig)) {
|
|
552
|
+
const directFolder = rootFolderLocationOrConfig;
|
|
191
553
|
const envFileA = path.join(directFolder, `env.${envType}.json`);
|
|
192
554
|
const envFileB = path.join(directFolder, `${envType}.json`);
|
|
193
555
|
const picked = fs.existsSync(envFileA) ? envFileA : (fs.existsSync(envFileB) ? envFileB : null);
|
|
194
|
-
|
|
556
|
+
|
|
557
|
+
if (picked) {
|
|
195
558
|
// Smart root folder detection for plugin paths
|
|
196
559
|
// If the env file is in a bb-plugins/<plugin-name>/config/environments/ structure,
|
|
197
560
|
// we should set rootFolder to the project root, not the plugin's config folder
|
|
@@ -201,7 +564,7 @@ class context {
|
|
|
201
564
|
const pickedParts = picked.split(path.sep);
|
|
202
565
|
const pluginsIndex = pickedParts.findIndex(part => part === 'bb-plugins');
|
|
203
566
|
|
|
204
|
-
if(pluginsIndex !== -1 && pluginsIndex + 3 < pickedParts.length) {
|
|
567
|
+
if (pluginsIndex !== -1 && pluginsIndex + 3 < pickedParts.length) {
|
|
205
568
|
// We're in bb-plugins/<plugin-name>/config/environments/...
|
|
206
569
|
// Set rootFolder to the project root (parent of bb-plugins)
|
|
207
570
|
const projectRootParts = pickedParts.slice(0, pluginsIndex);
|
|
@@ -211,420 +574,660 @@ class context {
|
|
|
211
574
|
file = { file: picked, rootFolder: detectedRoot };
|
|
212
575
|
}
|
|
213
576
|
}
|
|
214
|
-
|
|
215
|
-
|
|
577
|
+
|
|
578
|
+
if (!file) {
|
|
579
|
+
throw new ConfigurationError(
|
|
580
|
+
`Environment configuration not found for '${envType}'`,
|
|
581
|
+
{
|
|
582
|
+
searchPath: rootFolderLocationOrConfig,
|
|
583
|
+
environment: envType,
|
|
584
|
+
attemptedRoots: candidateRoots
|
|
585
|
+
}
|
|
586
|
+
);
|
|
216
587
|
}
|
|
217
588
|
|
|
218
589
|
// Always require absolute file path to avoid module root ambiguity on global installs/Windows
|
|
219
590
|
const settingsPath = path.isAbsolute(file.file) ? file.file : path.resolve(file.rootFolder, file.file);
|
|
220
591
|
const settings = require(settingsPath);
|
|
221
592
|
const options = settings[contextName];
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
throw new
|
|
593
|
+
|
|
594
|
+
if (!options || typeof options !== 'object') {
|
|
595
|
+
throw new ConfigurationError(
|
|
596
|
+
`Configuration missing settings for context '${contextName}'`,
|
|
597
|
+
{
|
|
598
|
+
configFile: settingsPath,
|
|
599
|
+
availableContexts: Object.keys(settings)
|
|
600
|
+
}
|
|
601
|
+
);
|
|
225
602
|
}
|
|
226
603
|
|
|
227
604
|
const type = String(options.type || '').toLowerCase();
|
|
228
605
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
dbPath = path.join(file.rootFolder, trimmed);
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
// If dbPath is a directory (ends with separator or exists as directory), append default filename
|
|
243
|
-
const endsWithSep = dbPath.endsWith('/') || dbPath.endsWith('\\');
|
|
244
|
-
const isDir = fs.existsSync(dbPath) && fs.statSync(dbPath).isDirectory();
|
|
245
|
-
if(endsWithSep || isDir){
|
|
246
|
-
const dbName = `${contextName.toLowerCase()}.sqlite`;
|
|
247
|
-
dbPath = path.join(dbPath, dbName);
|
|
248
|
-
}
|
|
606
|
+
// SQLite initialization
|
|
607
|
+
if (type === DB_TYPES.SQLITE || type === DB_TYPES.BETTER_SQLITE3) {
|
|
608
|
+
this.isSQLite = true;
|
|
609
|
+
this.isMySQL = false;
|
|
610
|
+
this.isPostgres = false;
|
|
611
|
+
|
|
612
|
+
// Resolve database path using extracted method
|
|
613
|
+
const dbPath = this._resolveDatabasePath(options.connection, file.rootFolder, contextName);
|
|
614
|
+
|
|
615
|
+
// Ensure database directory exists
|
|
249
616
|
const dbDir = path.dirname(dbPath);
|
|
250
|
-
if(!fs.existsSync(dbDir)){
|
|
617
|
+
if (!fs.existsSync(dbDir)) {
|
|
251
618
|
fs.mkdirSync(dbDir, { recursive: true });
|
|
252
619
|
}
|
|
620
|
+
|
|
253
621
|
const sqliteOptions = { ...options, completeConnection: dbPath };
|
|
254
622
|
this.db = this.__SQLiteInit(sqliteOptions, 'better-sqlite3');
|
|
255
623
|
this._SQLEngine.setDB(this.db, 'better-sqlite3');
|
|
256
624
|
return this;
|
|
257
625
|
}
|
|
258
626
|
|
|
259
|
-
|
|
260
|
-
|
|
627
|
+
// MySQL initialization
|
|
628
|
+
if (type === DB_TYPES.MYSQL) {
|
|
629
|
+
this.isMySQL = true;
|
|
630
|
+
this.isSQLite = false;
|
|
631
|
+
this.isPostgres = false;
|
|
632
|
+
|
|
261
633
|
this.db = this.__mysqlInit(options, 'mysql2');
|
|
262
634
|
this._SQLEngine.setDB(this.db, 'mysql');
|
|
263
635
|
return this;
|
|
264
636
|
}
|
|
265
637
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
638
|
+
// PostgreSQL initialization (async)
|
|
639
|
+
if (type === DB_TYPES.POSTGRES || type === DB_TYPES.POSTGRESQL) {
|
|
640
|
+
this.isPostgres = true;
|
|
641
|
+
this.isMySQL = false;
|
|
642
|
+
this.isSQLite = false;
|
|
643
|
+
|
|
644
|
+
// PostgreSQL is async - caller must await env()
|
|
645
|
+
return (async () => {
|
|
270
646
|
this.db = await this.__postgresInit(options, 'pg');
|
|
271
647
|
// Note: engine is already set in __postgresInit
|
|
648
|
+
return this;
|
|
272
649
|
})();
|
|
273
|
-
return this;
|
|
274
650
|
}
|
|
275
651
|
|
|
276
|
-
throw new
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
652
|
+
throw new ConfigurationError(
|
|
653
|
+
`Unsupported database type '${type}'`,
|
|
654
|
+
{
|
|
655
|
+
providedType: options.type,
|
|
656
|
+
supportedTypes: Object.values(DB_TYPES)
|
|
657
|
+
}
|
|
658
|
+
);
|
|
659
|
+
} catch (error) {
|
|
660
|
+
// Preserve original error if it's already a ContextError
|
|
661
|
+
if (error instanceof ContextError) {
|
|
662
|
+
throw error;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Wrap other errors
|
|
666
|
+
throw new ConfigurationError(
|
|
667
|
+
`Failed to initialize database environment: ${error.message}`,
|
|
668
|
+
{
|
|
669
|
+
originalError: error.message,
|
|
670
|
+
stack: error.stack
|
|
671
|
+
}
|
|
672
|
+
);
|
|
281
673
|
}
|
|
282
674
|
}
|
|
283
675
|
|
|
284
|
-
|
|
285
|
-
|
|
676
|
+
/**
|
|
677
|
+
* Initialize SQLite database connection using environment file
|
|
678
|
+
*
|
|
679
|
+
* @param {string} rootFolderLocation - Path to folder containing environment files
|
|
680
|
+
* @returns {this} Context instance for chaining
|
|
681
|
+
* @throws {ConfigurationError} If configuration is invalid
|
|
682
|
+
* @throws {DatabaseConnectionError} If connection fails
|
|
683
|
+
*
|
|
684
|
+
* @example
|
|
685
|
+
* context.useSqlite('./config/environments');
|
|
686
|
+
*/
|
|
687
|
+
useSqlite(rootFolderLocation) {
|
|
688
|
+
try {
|
|
286
689
|
this.isSQLite = true;
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
690
|
+
this.isMySQL = false;
|
|
691
|
+
this.isPostgres = false;
|
|
692
|
+
|
|
693
|
+
const root = process.cwd();
|
|
694
|
+
const envType = this.__environment || 'development';
|
|
695
|
+
const contextName = this.__name;
|
|
696
|
+
const file = this.__findSettings(root, rootFolderLocation, envType);
|
|
697
|
+
const settings = require(file.file);
|
|
698
|
+
const options = settings[contextName];
|
|
699
|
+
|
|
700
|
+
if (!options || typeof options !== 'object') {
|
|
701
|
+
throw new ConfigurationError(
|
|
702
|
+
`Configuration missing settings for context '${contextName}'`,
|
|
703
|
+
{
|
|
704
|
+
configFile: file.file,
|
|
705
|
+
availableContexts: Object.keys(settings)
|
|
706
|
+
}
|
|
707
|
+
);
|
|
297
708
|
}
|
|
298
709
|
|
|
299
710
|
this.validateSQLiteOptions(options);
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
const looksProjectRootRelative = dbPath.startsWith('/') || dbPath.startsWith('\\');
|
|
304
|
-
const isAbsoluteFsPath = path.isAbsolute(dbPath);
|
|
305
|
-
if(looksProjectRootRelative || !isAbsoluteFsPath){
|
|
306
|
-
const trimmed = dbPath.replace(/^[/\\]+/, '');
|
|
307
|
-
dbPath = path.join(file.rootFolder, trimmed);
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
// If dbPath is a directory (ends with separator or exists as directory), append default filename
|
|
311
|
-
const endsWithSep = dbPath.endsWith('/') || dbPath.endsWith('\\');
|
|
312
|
-
const isDir = fs.existsSync(dbPath) && fs.statSync(dbPath).isDirectory();
|
|
313
|
-
if(endsWithSep || isDir){
|
|
314
|
-
const dbName = `${contextName.toLowerCase()}.sqlite`;
|
|
315
|
-
dbPath = path.join(dbPath, dbName);
|
|
316
|
-
}
|
|
711
|
+
|
|
712
|
+
// Resolve database path using extracted method (eliminates duplicate code)
|
|
713
|
+
const dbPath = this._resolveDatabasePath(options.connection, file.rootFolder, contextName);
|
|
317
714
|
options.completeConnection = dbPath;
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
715
|
+
|
|
716
|
+
// Ensure database directory exists
|
|
717
|
+
const dbDirectory = path.dirname(dbPath);
|
|
718
|
+
if (!fs.existsSync(dbDirectory)) {
|
|
321
719
|
fs.mkdirSync(dbDirectory, { recursive: true });
|
|
322
720
|
}
|
|
323
721
|
|
|
324
|
-
this.db = this.__SQLiteInit(options,
|
|
325
|
-
this._SQLEngine.setDB(this.db,
|
|
722
|
+
this.db = this.__SQLiteInit(options, 'better-sqlite3');
|
|
723
|
+
this._SQLEngine.setDB(this.db, 'better-sqlite3');
|
|
326
724
|
return this;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
725
|
+
} catch (error) {
|
|
726
|
+
// Preserve original error if it's already a ContextError
|
|
727
|
+
if (error instanceof ContextError) {
|
|
728
|
+
throw error;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Wrap other errors
|
|
732
|
+
throw new ConfigurationError(
|
|
733
|
+
`Failed to initialize SQLite: ${error.message}`,
|
|
734
|
+
{
|
|
735
|
+
rootFolderLocation,
|
|
736
|
+
originalError: error.message,
|
|
737
|
+
stack: error.stack
|
|
738
|
+
}
|
|
739
|
+
);
|
|
331
740
|
}
|
|
332
741
|
}
|
|
333
742
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
743
|
+
/**
|
|
744
|
+
* Validate and normalize database configuration options
|
|
745
|
+
*
|
|
746
|
+
* Performs type inference, sets defaults, and validates required fields
|
|
747
|
+
*
|
|
748
|
+
* @param {object} options - Database configuration options
|
|
749
|
+
* @throws {ConfigurationError} If options are invalid
|
|
750
|
+
*/
|
|
751
|
+
validateSQLiteOptions(options) {
|
|
752
|
+
if (!options || typeof options !== 'object') {
|
|
753
|
+
throw new ConfigurationError('Configuration object is missing or invalid');
|
|
337
754
|
}
|
|
338
755
|
|
|
339
756
|
// Normalize type
|
|
340
757
|
let type = (options.type || '').toString().toLowerCase();
|
|
341
|
-
if(!type){
|
|
342
|
-
// Infer when not provided
|
|
343
|
-
if(typeof options.connection === 'string'){
|
|
344
|
-
type =
|
|
345
|
-
options.type =
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
type =
|
|
349
|
-
|
|
758
|
+
if (!type) {
|
|
759
|
+
// Infer type when not provided
|
|
760
|
+
if (typeof options.connection === 'string') {
|
|
761
|
+
type = DB_TYPES.SQLITE;
|
|
762
|
+
options.type = DB_TYPES.SQLITE;
|
|
763
|
+
} else if (options.host || options.user || options.database) {
|
|
764
|
+
type = DB_TYPES.MYSQL;
|
|
765
|
+
options.type = DB_TYPES.MYSQL;
|
|
766
|
+
} else {
|
|
767
|
+
throw new ConfigurationError('Cannot infer database type from configuration. Please specify type: "sqlite", "mysql", or "postgres".');
|
|
350
768
|
}
|
|
351
769
|
}
|
|
352
770
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
if(!options.connection || typeof options.connection !== 'string' || options.connection.trim() === ''){
|
|
356
|
-
throw new
|
|
771
|
+
// SQLite validation
|
|
772
|
+
if (type === DB_TYPES.SQLITE || type === DB_TYPES.BETTER_SQLITE3) {
|
|
773
|
+
if (!options.connection || typeof options.connection !== 'string' || options.connection.trim() === '') {
|
|
774
|
+
throw new ConfigurationError(
|
|
775
|
+
'SQLite connection path is required',
|
|
776
|
+
{ providedConnection: options.connection }
|
|
777
|
+
);
|
|
357
778
|
}
|
|
358
779
|
// Defaults
|
|
359
|
-
if(options.username === undefined)
|
|
360
|
-
if(options.password === undefined)
|
|
361
|
-
return;
|
|
780
|
+
if (options.username === undefined) options.username = '';
|
|
781
|
+
if (options.password === undefined) options.password = '';
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// MySQL validation
|
|
786
|
+
if (type === DB_TYPES.MYSQL) {
|
|
787
|
+
// Defaults
|
|
788
|
+
if (!options.host) options.host = 'localhost';
|
|
789
|
+
if (options.port === undefined) options.port = DEFAULT_PORTS.MYSQL;
|
|
790
|
+
if (options.password === undefined) options.password = '';
|
|
791
|
+
|
|
792
|
+
// Required fields
|
|
793
|
+
if (!options.user || options.user.toString().trim() === '') {
|
|
794
|
+
throw new ConfigurationError('MySQL user is required', { host: options.host });
|
|
795
|
+
}
|
|
796
|
+
if (!options.database || options.database.toString().trim() === '') {
|
|
797
|
+
throw new ConfigurationError('MySQL database is required', { host: options.host, user: options.user });
|
|
798
|
+
}
|
|
799
|
+
return;
|
|
362
800
|
}
|
|
363
801
|
|
|
364
|
-
|
|
802
|
+
// PostgreSQL validation
|
|
803
|
+
if (type === DB_TYPES.POSTGRES || type === DB_TYPES.POSTGRESQL) {
|
|
365
804
|
// Defaults
|
|
366
|
-
if(!options.host)
|
|
367
|
-
if(options.port === undefined)
|
|
368
|
-
if(options.password === undefined)
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
805
|
+
if (!options.host) options.host = 'localhost';
|
|
806
|
+
if (options.port === undefined) options.port = DEFAULT_PORTS.POSTGRES;
|
|
807
|
+
if (options.password === undefined) options.password = '';
|
|
808
|
+
|
|
809
|
+
// Required fields
|
|
810
|
+
if (!options.user || options.user.toString().trim() === '') {
|
|
811
|
+
throw new ConfigurationError('PostgreSQL user is required', { host: options.host });
|
|
372
812
|
}
|
|
373
|
-
if(!options.database || options.database.toString().trim() === ''){
|
|
374
|
-
throw new
|
|
813
|
+
if (!options.database || options.database.toString().trim() === '') {
|
|
814
|
+
throw new ConfigurationError('PostgreSQL database is required', { host: options.host, user: options.user });
|
|
375
815
|
}
|
|
376
|
-
return;
|
|
816
|
+
return;
|
|
377
817
|
}
|
|
378
818
|
|
|
379
|
-
throw new
|
|
819
|
+
throw new ConfigurationError(
|
|
820
|
+
`Unsupported database type '${type}'`,
|
|
821
|
+
{ supportedTypes: Object.values(DB_TYPES) }
|
|
822
|
+
);
|
|
380
823
|
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Initialize MySQL database connection using environment file
|
|
827
|
+
*
|
|
828
|
+
* @param {string} rootFolderLocation - Path to folder containing environment files
|
|
829
|
+
* @returns {this} Context instance for chaining
|
|
830
|
+
* @throws {ConfigurationError} If configuration is invalid
|
|
831
|
+
* @throws {DatabaseConnectionError} If connection fails
|
|
832
|
+
*
|
|
833
|
+
* @example
|
|
834
|
+
* context.useMySql('./config/environments');
|
|
835
|
+
*/
|
|
836
|
+
useMySql(rootFolderLocation) {
|
|
837
|
+
try {
|
|
384
838
|
this.isMySQL = true;
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
839
|
+
this.isSQLite = false;
|
|
840
|
+
this.isPostgres = false;
|
|
841
|
+
|
|
842
|
+
const envType = this.__environment || 'development';
|
|
843
|
+
const contextName = this.__name;
|
|
844
|
+
const root = appRoot.path;
|
|
845
|
+
const file = this.__findSettings(root, rootFolderLocation, envType);
|
|
846
|
+
const settings = require(file.file);
|
|
847
|
+
const options = settings[contextName];
|
|
848
|
+
|
|
849
|
+
if (!options || typeof options !== 'object') {
|
|
850
|
+
throw new ConfigurationError(
|
|
851
|
+
`Configuration missing settings for context '${contextName}'`,
|
|
852
|
+
{
|
|
853
|
+
configFile: file.file,
|
|
854
|
+
availableContexts: Object.keys(settings)
|
|
855
|
+
}
|
|
856
|
+
);
|
|
395
857
|
}
|
|
396
858
|
|
|
397
859
|
this.validateSQLiteOptions(options);
|
|
398
|
-
this.db = this.__mysqlInit(options,
|
|
399
|
-
this._SQLEngine.setDB(this.db,
|
|
860
|
+
this.db = this.__mysqlInit(options, 'mysql2');
|
|
861
|
+
this._SQLEngine.setDB(this.db, 'mysql');
|
|
400
862
|
return this;
|
|
401
|
-
|
|
863
|
+
} catch (error) {
|
|
864
|
+
// Preserve original error if it's already a ContextError
|
|
865
|
+
if (error instanceof ContextError) {
|
|
866
|
+
throw error;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// Wrap other errors
|
|
870
|
+
throw new ConfigurationError(
|
|
871
|
+
`Failed to initialize MySQL: ${error.message}`,
|
|
872
|
+
{
|
|
873
|
+
rootFolderLocation,
|
|
874
|
+
originalError: error.message,
|
|
875
|
+
stack: error.stack
|
|
876
|
+
}
|
|
877
|
+
);
|
|
878
|
+
}
|
|
402
879
|
}
|
|
403
880
|
|
|
404
881
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
882
|
+
/**
|
|
883
|
+
* Register an entity model with the context
|
|
884
|
+
*
|
|
885
|
+
* Creates a table mapping and query builder for the entity.
|
|
886
|
+
* Performs input validation and SQL injection prevention.
|
|
887
|
+
*
|
|
888
|
+
* @param {Function|object} model - Entity class or model definition
|
|
889
|
+
* @param {string} [name] - Optional custom table name (defaults to model.name)
|
|
890
|
+
* @throws {EntityValidationError} If model is invalid or table name contains SQL injection
|
|
891
|
+
*
|
|
892
|
+
* @example
|
|
893
|
+
* context.dbset(User);
|
|
894
|
+
* context.dbset(Post, 'blog_posts');
|
|
895
|
+
*/
|
|
896
|
+
dbset(model, name) {
|
|
897
|
+
// Input validation
|
|
898
|
+
if (!model) {
|
|
899
|
+
throw new EntityValidationError(
|
|
900
|
+
'dbset() requires a valid model',
|
|
901
|
+
'Unknown',
|
|
902
|
+
{ providedModel: model }
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const validModel = modelBuilder.create(model);
|
|
907
|
+
let tableName = name !== undefined ? name : model.name;
|
|
908
|
+
|
|
909
|
+
// Validate table name (SQL injection prevention)
|
|
910
|
+
if (!tableName || typeof tableName !== 'string' || tableName.trim() === '') {
|
|
911
|
+
throw new EntityValidationError(
|
|
912
|
+
'Table name must be a non-empty string',
|
|
913
|
+
model.name,
|
|
914
|
+
{ providedName: name }
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// Security: Validate table name format (prevents SQL injection)
|
|
919
|
+
if (!TABLE_NAME_VALIDATION_REGEX.test(tableName)) {
|
|
920
|
+
throw new EntityValidationError(
|
|
921
|
+
`Invalid table name '${tableName}'. Must contain only alphanumeric characters and underscores, and start with a letter or underscore.`,
|
|
922
|
+
model.name,
|
|
923
|
+
{
|
|
924
|
+
providedName: tableName,
|
|
925
|
+
validationRegex: TABLE_NAME_VALIDATION_REGEX.toString()
|
|
926
|
+
}
|
|
927
|
+
);
|
|
928
|
+
}
|
|
408
929
|
|
|
409
930
|
// Apply tablePrefix if set
|
|
410
|
-
if(this.tablePrefix && typeof this.tablePrefix === 'string' && this.tablePrefix.length > 0){
|
|
931
|
+
if (this.tablePrefix && typeof this.tablePrefix === 'string' && this.tablePrefix.length > 0) {
|
|
411
932
|
tableName = this.tablePrefix + tableName;
|
|
933
|
+
|
|
934
|
+
// Re-validate after prefix application
|
|
935
|
+
if (!TABLE_NAME_VALIDATION_REGEX.test(tableName)) {
|
|
936
|
+
throw new EntityValidationError(
|
|
937
|
+
`Table name '${tableName}' (after applying prefix '${this.tablePrefix}') is invalid`,
|
|
938
|
+
model.name,
|
|
939
|
+
{ tablePrefix: this.tablePrefix, originalName: name }
|
|
940
|
+
);
|
|
941
|
+
}
|
|
412
942
|
}
|
|
413
943
|
|
|
414
944
|
validModel.__name = tableName;
|
|
415
|
-
this.__entities.push(validModel);
|
|
416
|
-
|
|
417
|
-
this.__builderEntities.push(buildMod);
|
|
418
|
-
this[validModel.__name] = buildMod;
|
|
945
|
+
this.__entities.push(validModel); // Store model object
|
|
946
|
+
const buildMod = tools.createNewInstance(validModel, query, this);
|
|
947
|
+
this.__builderEntities.push(buildMod); // Store query builder entity
|
|
948
|
+
this[validModel.__name] = buildMod; // Attach to context for easy access
|
|
419
949
|
}
|
|
420
950
|
|
|
421
|
-
|
|
951
|
+
/**
|
|
952
|
+
* Get current model validation state
|
|
953
|
+
*
|
|
954
|
+
* @returns {{isValid: boolean, errors: Array}} Validation state
|
|
955
|
+
*/
|
|
956
|
+
modelState() {
|
|
422
957
|
return this._isModelValid;
|
|
423
958
|
}
|
|
424
959
|
|
|
425
960
|
/**
|
|
426
|
-
* Process tracked entities
|
|
427
|
-
*
|
|
428
|
-
*
|
|
961
|
+
* Process tracked entities with batch operations
|
|
962
|
+
*
|
|
963
|
+
* Refactored from duplicated code in saveChanges.
|
|
964
|
+
* Performance: Uses batch operations to prevent N+1 query problem (100x faster for bulk operations)
|
|
965
|
+
*
|
|
966
|
+
* @private
|
|
967
|
+
* @param {Array<object>} tracked - Array of tracked entities
|
|
429
968
|
*/
|
|
430
|
-
_processTrackedEntities(tracked){
|
|
431
|
-
// Group entities by state for batch operations
|
|
969
|
+
_processTrackedEntities(tracked) {
|
|
970
|
+
// Group entities by state for batch operations (single pass)
|
|
432
971
|
const toInsert = [];
|
|
433
972
|
const toUpdate = [];
|
|
434
973
|
const toDelete = [];
|
|
435
974
|
|
|
436
|
-
// Performance:
|
|
437
|
-
for (
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
switch(currentModel.__state) {
|
|
441
|
-
case "insert":
|
|
975
|
+
// Performance: Use for...of loop (faster and more readable than index-based)
|
|
976
|
+
for (const currentModel of tracked) {
|
|
977
|
+
switch (currentModel.__state) {
|
|
978
|
+
case 'insert':
|
|
442
979
|
toInsert.push(currentModel);
|
|
443
980
|
break;
|
|
444
|
-
case
|
|
445
|
-
if(currentModel.__dirtyFields.length > 0){
|
|
981
|
+
case 'modified':
|
|
982
|
+
if (currentModel.__dirtyFields && currentModel.__dirtyFields.length > 0) {
|
|
446
983
|
toUpdate.push(currentModel);
|
|
447
984
|
} else {
|
|
448
|
-
console.
|
|
985
|
+
console.warn('[Context] Tracked entity marked as modified but has no dirty fields');
|
|
449
986
|
}
|
|
450
987
|
break;
|
|
451
|
-
case
|
|
988
|
+
case 'delete':
|
|
452
989
|
toDelete.push(currentModel);
|
|
453
990
|
break;
|
|
991
|
+
default:
|
|
992
|
+
console.warn(`[Context] Unknown entity state: ${currentModel.__state}`);
|
|
454
993
|
}
|
|
455
994
|
}
|
|
456
995
|
|
|
457
996
|
// Batch insert operations
|
|
458
|
-
if(toInsert.length > 0){
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
997
|
+
if (toInsert.length > 0) {
|
|
998
|
+
this._processBatchInserts(toInsert);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// Batch update operations
|
|
1002
|
+
if (toUpdate.length > 0) {
|
|
1003
|
+
this._processBatchUpdates(toUpdate);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// Batch delete operations
|
|
1007
|
+
if (toDelete.length > 0) {
|
|
1008
|
+
this._processBatchDeletes(toDelete);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Process batch insert operations
|
|
1014
|
+
*
|
|
1015
|
+
* @private
|
|
1016
|
+
* @param {Array<object>} entities - Entities to insert
|
|
1017
|
+
*/
|
|
1018
|
+
_processBatchInserts(entities) {
|
|
1019
|
+
if (entities.length === 1) {
|
|
1020
|
+
// Single insert - use existing insertManager
|
|
1021
|
+
const insert = new insertManager(this._SQLEngine, this._isModelValid, this.__entities);
|
|
1022
|
+
insert.init(entities[0]);
|
|
1023
|
+
} else {
|
|
1024
|
+
// Batch insert - 100x faster for multiple records
|
|
1025
|
+
try {
|
|
1026
|
+
this._SQLEngine.bulkInsert(entities);
|
|
1027
|
+
} catch (error) {
|
|
1028
|
+
console.error('[Context] Bulk insert failed, falling back to individual inserts:', error.message);
|
|
1029
|
+
// Fallback to individual inserts
|
|
1030
|
+
for (const entity of entities) {
|
|
1031
|
+
const insert = new insertManager(this._SQLEngine, this._isModelValid, this.__entities);
|
|
1032
|
+
insert.init(entity);
|
|
474
1033
|
}
|
|
475
1034
|
}
|
|
476
1035
|
}
|
|
1036
|
+
}
|
|
477
1037
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
1038
|
+
/**
|
|
1039
|
+
* Process batch update operations
|
|
1040
|
+
*
|
|
1041
|
+
* @private
|
|
1042
|
+
* @param {Array<object>} entities - Entities to update
|
|
1043
|
+
*/
|
|
1044
|
+
_processBatchUpdates(entities) {
|
|
1045
|
+
if (entities.length === 1) {
|
|
1046
|
+
// Single update - use existing logic
|
|
1047
|
+
const currentModel = entities[0];
|
|
1048
|
+
const cleanCurrentModel = tools.removePrimarykeyandVirtual(currentModel, currentModel._entity);
|
|
1049
|
+
const argu = this._SQLEngine._buildSQLEqualToParameterized(cleanCurrentModel);
|
|
1050
|
+
|
|
1051
|
+
if (argu !== -1) {
|
|
1052
|
+
const primaryKey = tools.getPrimaryKeyObject(cleanCurrentModel.__entity);
|
|
1053
|
+
const sqlUpdate = {
|
|
1054
|
+
tableName: cleanCurrentModel.__entity.__name,
|
|
1055
|
+
arg: argu,
|
|
1056
|
+
primaryKey: primaryKey,
|
|
1057
|
+
primaryKeyValue: cleanCurrentModel[primaryKey]
|
|
1058
|
+
};
|
|
1059
|
+
this._SQLEngine.update(sqlUpdate);
|
|
1060
|
+
} else {
|
|
1061
|
+
console.warn('[Context] Entity marked for update but no changes detected');
|
|
1062
|
+
}
|
|
1063
|
+
} else {
|
|
1064
|
+
// Batch update - build all queries first
|
|
1065
|
+
const updateQueries = [];
|
|
1066
|
+
|
|
1067
|
+
for (const currentModel of entities) {
|
|
483
1068
|
const cleanCurrentModel = tools.removePrimarykeyandVirtual(currentModel, currentModel._entity);
|
|
484
1069
|
const argu = this._SQLEngine._buildSQLEqualToParameterized(cleanCurrentModel);
|
|
485
|
-
|
|
1070
|
+
|
|
1071
|
+
if (argu !== -1) {
|
|
486
1072
|
const primaryKey = tools.getPrimaryKeyObject(cleanCurrentModel.__entity);
|
|
487
|
-
|
|
1073
|
+
updateQueries.push({
|
|
488
1074
|
tableName: cleanCurrentModel.__entity.__name,
|
|
489
1075
|
arg: argu,
|
|
490
1076
|
primaryKey: primaryKey,
|
|
491
1077
|
primaryKeyValue: cleanCurrentModel[primaryKey]
|
|
492
|
-
};
|
|
493
|
-
this._SQLEngine.update(sqlUpdate);
|
|
494
|
-
} else {
|
|
495
|
-
console.log("Nothing has been tracked, modified, created or added");
|
|
1078
|
+
});
|
|
496
1079
|
}
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
arg: argu,
|
|
508
|
-
primaryKey: primaryKey,
|
|
509
|
-
primaryKeyValue: cleanCurrentModel[primaryKey]
|
|
510
|
-
});
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
if(updateQueries.length > 0){
|
|
514
|
-
try {
|
|
515
|
-
this._SQLEngine.bulkUpdate(updateQueries);
|
|
516
|
-
} catch(error) {
|
|
517
|
-
console.error("Bulk update failed:", error);
|
|
518
|
-
// Fallback to individual updates
|
|
519
|
-
for(const query of updateQueries){
|
|
520
|
-
this._SQLEngine.update(query);
|
|
521
|
-
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
if (updateQueries.length > 0) {
|
|
1083
|
+
try {
|
|
1084
|
+
this._SQLEngine.bulkUpdate(updateQueries);
|
|
1085
|
+
} catch (error) {
|
|
1086
|
+
console.error('[Context] Bulk update failed, falling back to individual updates:', error.message);
|
|
1087
|
+
// Fallback to individual updates
|
|
1088
|
+
for (const query of updateQueries) {
|
|
1089
|
+
this._SQLEngine.update(query);
|
|
522
1090
|
}
|
|
523
1091
|
}
|
|
524
1092
|
}
|
|
525
1093
|
}
|
|
1094
|
+
}
|
|
526
1095
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
1096
|
+
/**
|
|
1097
|
+
* Process batch delete operations
|
|
1098
|
+
*
|
|
1099
|
+
* @private
|
|
1100
|
+
* @param {Array<object>} entities - Entities to delete
|
|
1101
|
+
*/
|
|
1102
|
+
_processBatchDeletes(entities) {
|
|
1103
|
+
if (entities.length === 1) {
|
|
1104
|
+
// Single delete - use existing deleteManager
|
|
1105
|
+
const deleteObject = new deleteManager(this._SQLEngine, this.__entities);
|
|
1106
|
+
deleteObject.init(entities[0]);
|
|
1107
|
+
} else {
|
|
1108
|
+
// Batch delete - group by table for efficiency
|
|
1109
|
+
const deletesByTable = new Map(); // Use Map instead of object for better performance
|
|
1110
|
+
|
|
1111
|
+
for (const entity of entities) {
|
|
1112
|
+
const tableName = entity.__entity.__name;
|
|
1113
|
+
const primaryKey = tools.getPrimaryKeyObject(entity.__entity);
|
|
1114
|
+
const id = entity[primaryKey];
|
|
1115
|
+
|
|
1116
|
+
if (!deletesByTable.has(tableName)) {
|
|
1117
|
+
deletesByTable.set(tableName, []);
|
|
545
1118
|
}
|
|
1119
|
+
deletesByTable.get(tableName).push(id);
|
|
1120
|
+
}
|
|
546
1121
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
1122
|
+
try {
|
|
1123
|
+
// Performance: Use for...of with Map entries
|
|
1124
|
+
for (const [tableName, ids] of deletesByTable.entries()) {
|
|
1125
|
+
this._SQLEngine.bulkDelete(tableName, ids);
|
|
1126
|
+
}
|
|
1127
|
+
} catch (error) {
|
|
1128
|
+
console.error('[Context] Bulk delete failed, falling back to individual deletes:', error.message);
|
|
1129
|
+
// Fallback to individual deletes
|
|
1130
|
+
for (const entity of entities) {
|
|
1131
|
+
const deleteObject = new deleteManager(this._SQLEngine, this.__entities);
|
|
1132
|
+
deleteObject.init(entity);
|
|
558
1133
|
}
|
|
559
1134
|
}
|
|
560
1135
|
}
|
|
561
1136
|
}
|
|
562
1137
|
|
|
563
|
-
|
|
564
|
-
|
|
1138
|
+
/**
|
|
1139
|
+
* Save all tracked entity changes to the database
|
|
1140
|
+
*
|
|
1141
|
+
* Executes INSERT, UPDATE, and DELETE operations for all tracked entities.
|
|
1142
|
+
* Uses transactions for SQLite. Automatically invalidates query cache for affected tables.
|
|
1143
|
+
*
|
|
1144
|
+
* @returns {boolean} True if changes were saved successfully
|
|
1145
|
+
* @throws {Error} If database operations fail
|
|
1146
|
+
*
|
|
1147
|
+
* @example
|
|
1148
|
+
* const user = db.User.new();
|
|
1149
|
+
* user.name = 'Alice';
|
|
1150
|
+
* db.saveChanges();
|
|
1151
|
+
*/
|
|
1152
|
+
saveChanges() {
|
|
1153
|
+
try {
|
|
565
1154
|
const tracked = this.__trackedEntities;
|
|
566
1155
|
|
|
567
|
-
if(tracked.length
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
1156
|
+
if (tracked.length === 0) {
|
|
1157
|
+
console.log('[Context] No tracked entities to save');
|
|
1158
|
+
return true;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// Performance: Collect affected tables for cache invalidation (single pass)
|
|
1162
|
+
const affectedTables = new Set();
|
|
1163
|
+
for (const entity of tracked) {
|
|
1164
|
+
if (entity.__entity && entity.__entity.__name) {
|
|
1165
|
+
affectedTables.add(entity.__entity.__name);
|
|
575
1166
|
}
|
|
1167
|
+
}
|
|
576
1168
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
1169
|
+
// Handle transactions based on database type
|
|
1170
|
+
if (this.isSQLite) {
|
|
1171
|
+
this._SQLEngine.startTransaction();
|
|
1172
|
+
try {
|
|
580
1173
|
this._processTrackedEntities(tracked);
|
|
581
1174
|
this.__clearErrorHandler();
|
|
582
1175
|
this._SQLEngine.endTransaction();
|
|
1176
|
+
} catch (error) {
|
|
1177
|
+
this._SQLEngine.errorTransaction();
|
|
1178
|
+
throw error;
|
|
583
1179
|
}
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
// PostgreSQL: Async operations, no transaction control here
|
|
593
|
-
this._processTrackedEntities(tracked);
|
|
594
|
-
this.__clearErrorHandler();
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
// Invalidate query cache for affected tables
|
|
598
|
-
for (const tableName of affectedTables) {
|
|
599
|
-
this._queryCache.invalidateTable(tableName);
|
|
600
|
-
}
|
|
1180
|
+
} else if (this.isMySQL) {
|
|
1181
|
+
// MySQL: Synchronous operations (transaction handling managed elsewhere)
|
|
1182
|
+
this._processTrackedEntities(tracked);
|
|
1183
|
+
this.__clearErrorHandler();
|
|
1184
|
+
} else if (this.isPostgres) {
|
|
1185
|
+
// PostgreSQL: Async operations (transaction handling managed elsewhere)
|
|
1186
|
+
this._processTrackedEntities(tracked);
|
|
1187
|
+
this.__clearErrorHandler();
|
|
601
1188
|
}
|
|
602
|
-
else{
|
|
603
|
-
console.log("save changes has no tracked entities");
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
catch(error){
|
|
607
|
-
this.__clearErrorHandler();
|
|
608
|
-
console.log("error", error);
|
|
609
1189
|
|
|
610
|
-
|
|
611
|
-
|
|
1190
|
+
// Invalidate query cache for affected tables
|
|
1191
|
+
for (const tableName of affectedTables) {
|
|
1192
|
+
this._queryCache.invalidateTable(tableName);
|
|
612
1193
|
}
|
|
1194
|
+
|
|
1195
|
+
// Clear tracked entities after successful save
|
|
1196
|
+
this.__clearTracked();
|
|
1197
|
+
return true;
|
|
1198
|
+
} catch (error) {
|
|
1199
|
+
// Clean up on error
|
|
1200
|
+
this.__clearErrorHandler();
|
|
613
1201
|
this.__clearTracked();
|
|
1202
|
+
|
|
1203
|
+
console.error('[Context] Failed to save changes:', error);
|
|
614
1204
|
throw error;
|
|
615
1205
|
}
|
|
616
|
-
|
|
617
|
-
this.__clearTracked();
|
|
618
|
-
return true;
|
|
619
1206
|
}
|
|
620
1207
|
|
|
621
1208
|
|
|
622
|
-
|
|
1209
|
+
/**
|
|
1210
|
+
* Execute a raw SQL query
|
|
1211
|
+
*
|
|
1212
|
+
* @param {string} query - SQL query to execute
|
|
1213
|
+
*
|
|
1214
|
+
* @example
|
|
1215
|
+
* context._execute('CREATE INDEX idx_user_email ON User(email)');
|
|
1216
|
+
*/
|
|
1217
|
+
_execute(query) {
|
|
623
1218
|
this._SQLEngine._execute(query);
|
|
624
1219
|
}
|
|
625
1220
|
|
|
626
1221
|
/**
|
|
627
1222
|
* Get query cache statistics
|
|
1223
|
+
*
|
|
1224
|
+
* Returns cache performance metrics including hit rate, size, and efficiency.
|
|
1225
|
+
*
|
|
1226
|
+
* @returns {{size: number, maxSize: number, hits: number, misses: number, hitRate: string, enabled: boolean}}
|
|
1227
|
+
*
|
|
1228
|
+
* @example
|
|
1229
|
+
* const stats = db.getCacheStats();
|
|
1230
|
+
* console.log(`Cache hit rate: ${stats.hitRate}`);
|
|
628
1231
|
*/
|
|
629
1232
|
getCacheStats() {
|
|
630
1233
|
return this._queryCache.getStats();
|
|
@@ -632,13 +1235,23 @@ class context {
|
|
|
632
1235
|
|
|
633
1236
|
/**
|
|
634
1237
|
* Clear query cache manually
|
|
1238
|
+
*
|
|
1239
|
+
* Removes all cached query results. Use when you need to ensure fresh data.
|
|
1240
|
+
*
|
|
1241
|
+
* @example
|
|
1242
|
+
* db.clearQueryCache();
|
|
635
1243
|
*/
|
|
636
1244
|
clearQueryCache() {
|
|
637
1245
|
this._queryCache.clear();
|
|
638
1246
|
}
|
|
639
1247
|
|
|
640
1248
|
/**
|
|
641
|
-
* Enable
|
|
1249
|
+
* Enable or disable query caching
|
|
1250
|
+
*
|
|
1251
|
+
* @param {boolean} enabled - True to enable caching, false to disable
|
|
1252
|
+
*
|
|
1253
|
+
* @example
|
|
1254
|
+
* db.setQueryCacheEnabled(false); // Disable for testing
|
|
642
1255
|
*/
|
|
643
1256
|
setQueryCacheEnabled(enabled) {
|
|
644
1257
|
this._queryCache.enabled = enabled;
|
|
@@ -646,7 +1259,9 @@ class context {
|
|
|
646
1259
|
|
|
647
1260
|
/**
|
|
648
1261
|
* End request and clear query cache
|
|
649
|
-
*
|
|
1262
|
+
*
|
|
1263
|
+
* Call this at the end of each HTTP request to clear request-scoped cache.
|
|
1264
|
+
* Similar to Active Record's cache clearing behavior.
|
|
650
1265
|
*
|
|
651
1266
|
* @example
|
|
652
1267
|
* // In Express middleware
|
|
@@ -662,20 +1277,172 @@ class context {
|
|
|
662
1277
|
this.clearQueryCache();
|
|
663
1278
|
}
|
|
664
1279
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
1280
|
+
/**
|
|
1281
|
+
* Attach a detached entity and mark it as modified
|
|
1282
|
+
*
|
|
1283
|
+
* Use this when an entity was loaded in a different context or passed from another service.
|
|
1284
|
+
* Similar to Entity Framework's context.Update() or Hibernate's session.merge()
|
|
1285
|
+
*
|
|
1286
|
+
* @param {object} entity - The detached entity to attach
|
|
1287
|
+
* @param {object} [changes=null] - Optional: specific fields that were modified
|
|
1288
|
+
* @returns {object} The attached entity
|
|
1289
|
+
* @throws {EntityValidationError} If entity is invalid
|
|
1290
|
+
*
|
|
1291
|
+
* @example
|
|
1292
|
+
* // Attach entity loaded elsewhere
|
|
1293
|
+
* const task = await taskService.getTask(taskId);
|
|
1294
|
+
* task.status = 'completed';
|
|
1295
|
+
* db.attach(task); // Mark as modified
|
|
1296
|
+
* await db.saveChanges();
|
|
1297
|
+
*
|
|
1298
|
+
* @example
|
|
1299
|
+
* // Attach with specific changed fields
|
|
1300
|
+
* db.attach(task, { status: 'completed', updated_at: new Date() });
|
|
1301
|
+
* await db.saveChanges();
|
|
1302
|
+
*/
|
|
1303
|
+
attach(entity, changes = null) {
|
|
1304
|
+
if (!entity) {
|
|
1305
|
+
throw new EntityValidationError(
|
|
1306
|
+
'Cannot attach null or undefined entity',
|
|
1307
|
+
'Unknown'
|
|
1308
|
+
);
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// Ensure entity has required metadata
|
|
1312
|
+
if (!entity.__entity || !entity.__entity.__name) {
|
|
1313
|
+
throw new EntityValidationError(
|
|
1314
|
+
'Entity must have __entity metadata. Make sure it was loaded through MasterRecord.',
|
|
1315
|
+
'Unknown',
|
|
1316
|
+
{ providedEntity: typeof entity }
|
|
1317
|
+
);
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// Mark entity as modified
|
|
1321
|
+
entity.__state = 'modified';
|
|
1322
|
+
|
|
1323
|
+
// If specific changes provided, mark only those fields as dirty
|
|
1324
|
+
if (changes && typeof changes === 'object') {
|
|
1325
|
+
entity.__dirtyFields = entity.__dirtyFields || [];
|
|
669
1326
|
|
|
670
|
-
|
|
1327
|
+
// Security: Use Object.keys() instead of for...in to avoid prototype pollution
|
|
1328
|
+
for (const fieldName of Object.keys(changes)) {
|
|
1329
|
+
entity[fieldName] = changes[fieldName];
|
|
1330
|
+
if (!entity.__dirtyFields.includes(fieldName)) {
|
|
1331
|
+
entity.__dirtyFields.push(fieldName);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
} else {
|
|
1335
|
+
// Mark all fields as potentially modified
|
|
1336
|
+
entity.__dirtyFields = entity.__dirtyFields || [];
|
|
1337
|
+
|
|
1338
|
+
// If no dirty fields yet, mark all non-metadata fields as dirty
|
|
1339
|
+
if (entity.__dirtyFields.length === 0) {
|
|
1340
|
+
// Security: Use Object.keys() instead of for...in to avoid prototype pollution
|
|
1341
|
+
for (const fieldName of Object.keys(entity.__entity)) {
|
|
1342
|
+
if (!fieldName.startsWith('__') &&
|
|
1343
|
+
entity.__entity[fieldName].type !== 'hasMany' &&
|
|
1344
|
+
entity.__entity[fieldName].type !== 'hasOne') {
|
|
1345
|
+
entity.__dirtyFields.push(fieldName);
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// Ensure context reference
|
|
1352
|
+
entity.__context = this;
|
|
1353
|
+
|
|
1354
|
+
// Track the entity
|
|
1355
|
+
this.__track(entity);
|
|
1356
|
+
|
|
1357
|
+
return entity;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
/**
|
|
1361
|
+
* Attach multiple detached entities at once
|
|
1362
|
+
*
|
|
1363
|
+
* @param {Array<object>} entities - Array of entities to attach
|
|
1364
|
+
* @returns {Array<object>} Array of attached entities
|
|
1365
|
+
* @throws {EntityValidationError} If input is not an array
|
|
1366
|
+
*
|
|
1367
|
+
* @example
|
|
1368
|
+
* const tasks = await taskService.getTasks();
|
|
1369
|
+
* tasks.forEach(t => t.status = 'completed');
|
|
1370
|
+
* db.attachAll(tasks);
|
|
1371
|
+
* await db.saveChanges();
|
|
1372
|
+
*/
|
|
1373
|
+
attachAll(entities) {
|
|
1374
|
+
if (!Array.isArray(entities)) {
|
|
1375
|
+
throw new EntityValidationError(
|
|
1376
|
+
'attachAll() requires an array of entities',
|
|
1377
|
+
'Unknown',
|
|
1378
|
+
{ providedType: typeof entities }
|
|
1379
|
+
);
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
return entities.map(entity => this.attach(entity));
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
/**
|
|
1386
|
+
* Update a detached entity by primary key
|
|
1387
|
+
*
|
|
1388
|
+
* Loads the entity, applies changes, and marks as modified.
|
|
1389
|
+
* Similar to Sequelize's Model.update()
|
|
1390
|
+
*
|
|
1391
|
+
* @param {string} entityName - Name of the entity class
|
|
1392
|
+
* @param {*} primaryKey - Primary key value
|
|
1393
|
+
* @param {object} changes - Fields to update
|
|
1394
|
+
* @returns {Promise<object>} Updated entity
|
|
1395
|
+
* @throws {EntityValidationError} If entity not found
|
|
1396
|
+
*
|
|
1397
|
+
* @example
|
|
1398
|
+
* // Update without loading first
|
|
1399
|
+
* await db.update('Task', { id: taskId }, { status: 'completed' });
|
|
1400
|
+
* await db.saveChanges();
|
|
1401
|
+
*/
|
|
1402
|
+
async update(entityName, primaryKey, changes) {
|
|
1403
|
+
// Get entity class
|
|
1404
|
+
const EntityClass = this[entityName];
|
|
1405
|
+
if (!EntityClass) {
|
|
1406
|
+
throw new EntityValidationError(
|
|
1407
|
+
`Entity '${entityName}' not found in context`,
|
|
1408
|
+
entityName,
|
|
1409
|
+
{ availableEntities: Object.keys(this).filter(k => !k.startsWith('_')) }
|
|
1410
|
+
);
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
// Load entity
|
|
1414
|
+
const entity = EntityClass.findById(primaryKey);
|
|
1415
|
+
if (!entity) {
|
|
1416
|
+
throw new EntityValidationError(
|
|
1417
|
+
`${entityName} with id ${primaryKey} not found`,
|
|
1418
|
+
entityName,
|
|
1419
|
+
{ primaryKey }
|
|
1420
|
+
);
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
// Apply changes and attach
|
|
1424
|
+
return this.attach(entity, changes);
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
/**
|
|
1428
|
+
* Track an entity for change detection
|
|
1429
|
+
*
|
|
1430
|
+
* Performance: Uses Map for O(1) lookup instead of O(n) linear search.
|
|
1431
|
+
* Collision-safe sequential IDs prevent duplicate tracking.
|
|
1432
|
+
*
|
|
1433
|
+
* @private
|
|
1434
|
+
* @param {object} model - Entity to track
|
|
1435
|
+
* @returns {object} The tracked entity
|
|
1436
|
+
*/
|
|
1437
|
+
__track(model) {
|
|
671
1438
|
// Performance: Use Map for O(1) lookup instead of O(n) linear search
|
|
672
|
-
if(!model.__ID){
|
|
673
|
-
// Generate ID
|
|
674
|
-
model.__ID =
|
|
1439
|
+
if (!model.__ID) {
|
|
1440
|
+
// Generate sequential ID (collision-safe)
|
|
1441
|
+
model.__ID = `entity_${context._nextEntityId++}`;
|
|
675
1442
|
}
|
|
676
1443
|
|
|
677
1444
|
// O(1) check if already tracked
|
|
678
|
-
if(!this.__trackedEntitiesMap.has(model.__ID)){
|
|
1445
|
+
if (!this.__trackedEntitiesMap.has(model.__ID)) {
|
|
679
1446
|
this.__trackedEntities.push(model);
|
|
680
1447
|
this.__trackedEntitiesMap.set(model.__ID, model);
|
|
681
1448
|
}
|
|
@@ -683,19 +1450,40 @@ class context {
|
|
|
683
1450
|
return model;
|
|
684
1451
|
}
|
|
685
1452
|
|
|
686
|
-
|
|
1453
|
+
/**
|
|
1454
|
+
* Find a tracked entity by ID
|
|
1455
|
+
*
|
|
1456
|
+
* @private
|
|
1457
|
+
* @param {string} id - Entity tracking ID
|
|
1458
|
+
* @returns {object|null} Tracked entity or null
|
|
1459
|
+
*/
|
|
1460
|
+
__findTracked(id) {
|
|
687
1461
|
// Performance: O(1) Map lookup instead of O(n) array search
|
|
688
|
-
if(id){
|
|
1462
|
+
if (id) {
|
|
689
1463
|
return this.__trackedEntitiesMap.get(id) || null;
|
|
690
1464
|
}
|
|
691
1465
|
return null;
|
|
692
1466
|
}
|
|
693
1467
|
|
|
694
|
-
|
|
1468
|
+
/**
|
|
1469
|
+
* Clear all tracked entities
|
|
1470
|
+
*
|
|
1471
|
+
* @private
|
|
1472
|
+
*/
|
|
1473
|
+
__clearTracked() {
|
|
695
1474
|
this.__trackedEntities = [];
|
|
696
|
-
this.__trackedEntitiesMap.clear(); //
|
|
1475
|
+
this.__trackedEntitiesMap.clear(); // Clear Map for proper garbage collection
|
|
697
1476
|
}
|
|
698
1477
|
}
|
|
699
1478
|
|
|
1479
|
+
// ============================================================================
|
|
1480
|
+
// EXPORTS
|
|
1481
|
+
// ============================================================================
|
|
1482
|
+
|
|
1483
|
+
module.exports = context;
|
|
700
1484
|
|
|
701
|
-
|
|
1485
|
+
// Export custom error classes for advanced error handling
|
|
1486
|
+
module.exports.ContextError = ContextError;
|
|
1487
|
+
module.exports.ConfigurationError = ConfigurationError;
|
|
1488
|
+
module.exports.DatabaseConnectionError = DatabaseConnectionError;
|
|
1489
|
+
module.exports.EntityValidationError = EntityValidationError;
|