@zero-server/sdk 0.9.0 → 0.9.2
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/LICENSE +21 -21
- package/README.md +460 -437
- package/index.js +414 -412
- package/lib/app.js +1172 -1172
- package/lib/auth/authorize.js +399 -399
- package/lib/auth/enrollment.js +367 -367
- package/lib/auth/index.js +57 -57
- package/lib/auth/jwt.js +731 -731
- package/lib/auth/oauth.js +362 -362
- package/lib/auth/session.js +588 -588
- package/lib/auth/trustedDevice.js +409 -409
- package/lib/auth/twoFactor.js +1150 -1150
- package/lib/auth/webauthn.js +946 -946
- package/lib/body/index.js +14 -14
- package/lib/body/json.js +109 -109
- package/lib/body/multipart.js +440 -440
- package/lib/body/raw.js +71 -71
- package/lib/body/rawBuffer.js +160 -160
- package/lib/body/sendError.js +25 -25
- package/lib/body/text.js +75 -75
- package/lib/body/typeMatch.js +41 -41
- package/lib/body/urlencoded.js +235 -235
- package/lib/cli.js +845 -845
- package/lib/cluster.js +666 -666
- package/lib/debug.js +372 -372
- package/lib/env/index.js +460 -460
- package/lib/errors.js +683 -683
- package/lib/fetch/index.js +256 -256
- package/lib/grpc/balancer.js +378 -378
- package/lib/grpc/call.js +708 -708
- package/lib/grpc/client.js +764 -764
- package/lib/grpc/codec.js +1221 -1221
- package/lib/grpc/credentials.js +398 -398
- package/lib/grpc/frame.js +262 -262
- package/lib/grpc/health.js +287 -287
- package/lib/grpc/index.js +121 -121
- package/lib/grpc/metadata.js +461 -461
- package/lib/grpc/proto.js +821 -821
- package/lib/grpc/reflection.js +590 -590
- package/lib/grpc/server.js +445 -445
- package/lib/grpc/status.js +118 -118
- package/lib/grpc/watch.js +173 -173
- package/lib/http/index.js +10 -10
- package/lib/http/request.js +727 -727
- package/lib/http/response.js +799 -799
- package/lib/lifecycle.js +557 -557
- package/lib/middleware/compress.js +230 -230
- package/lib/middleware/cookieParser.js +237 -237
- package/lib/middleware/cors.js +93 -93
- package/lib/middleware/csrf.js +136 -136
- package/lib/middleware/errorHandler.js +101 -101
- package/lib/middleware/helmet.js +175 -175
- package/lib/middleware/index.js +19 -17
- package/lib/middleware/logger.js +74 -74
- package/lib/middleware/rateLimit.js +88 -88
- package/lib/middleware/requestId.js +53 -53
- package/lib/middleware/static.js +326 -326
- package/lib/middleware/timeout.js +71 -71
- package/lib/middleware/validator.js +254 -254
- package/lib/observe/health.js +326 -326
- package/lib/observe/index.js +50 -50
- package/lib/observe/logger.js +359 -359
- package/lib/observe/metrics.js +805 -805
- package/lib/observe/tracing.js +592 -592
- package/lib/orm/adapters/json.js +290 -290
- package/lib/orm/adapters/memory.js +764 -764
- package/lib/orm/adapters/mongo.js +764 -764
- package/lib/orm/adapters/mysql.js +933 -933
- package/lib/orm/adapters/postgres.js +1144 -1144
- package/lib/orm/adapters/redis.js +1534 -1534
- package/lib/orm/adapters/sql-base.js +212 -212
- package/lib/orm/adapters/sqlite.js +858 -858
- package/lib/orm/audit.js +649 -649
- package/lib/orm/cache.js +394 -394
- package/lib/orm/geo.js +387 -387
- package/lib/orm/index.js +784 -784
- package/lib/orm/migrate.js +432 -432
- package/lib/orm/model.js +1706 -1706
- package/lib/orm/plugin.js +375 -375
- package/lib/orm/procedures.js +836 -836
- package/lib/orm/profiler.js +233 -233
- package/lib/orm/query.js +1772 -1772
- package/lib/orm/replicas.js +241 -241
- package/lib/orm/schema.js +307 -307
- package/lib/orm/search.js +380 -380
- package/lib/orm/seed/data/commerce.js +136 -136
- package/lib/orm/seed/data/internet.js +111 -111
- package/lib/orm/seed/data/locations.js +204 -204
- package/lib/orm/seed/data/names.js +338 -338
- package/lib/orm/seed/data/person.js +128 -128
- package/lib/orm/seed/data/phone.js +211 -211
- package/lib/orm/seed/data/words.js +134 -134
- package/lib/orm/seed/factory.js +178 -178
- package/lib/orm/seed/fake.js +1186 -1186
- package/lib/orm/seed/index.js +18 -18
- package/lib/orm/seed/rng.js +70 -70
- package/lib/orm/seed/seeder.js +124 -124
- package/lib/orm/seed/unique.js +68 -68
- package/lib/orm/snapshot.js +366 -366
- package/lib/orm/tenancy.js +605 -605
- package/lib/orm/views.js +350 -350
- package/lib/router/index.js +436 -436
- package/lib/sse/index.js +8 -8
- package/lib/sse/stream.js +349 -349
- package/lib/ws/connection.js +451 -451
- package/lib/ws/handshake.js +125 -125
- package/lib/ws/index.js +14 -14
- package/lib/ws/room.js +223 -223
- package/package.json +73 -73
- package/types/app.d.ts +223 -223
- package/types/auth.d.ts +520 -520
- package/types/cluster.d.ts +75 -75
- package/types/env.d.ts +80 -80
- package/types/errors.d.ts +316 -316
- package/types/fetch.d.ts +43 -43
- package/types/grpc.d.ts +432 -432
- package/types/index.d.ts +384 -384
- package/types/lifecycle.d.ts +60 -60
- package/types/middleware.d.ts +320 -320
- package/types/observe.d.ts +304 -304
- package/types/orm.d.ts +1887 -1887
- package/types/request.d.ts +109 -109
- package/types/response.d.ts +157 -157
- package/types/router.d.ts +78 -78
- package/types/sse.d.ts +78 -78
- package/types/websocket.d.ts +126 -126
|
@@ -1,764 +1,764 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @module orm/adapters/mongo
|
|
3
|
-
* @description MongoDB adapter using the optional `mongodb` driver.
|
|
4
|
-
* Requires: `npm install mongodb`
|
|
5
|
-
*
|
|
6
|
-
* @example
|
|
7
|
-
* const { Database, Model, TYPES } = require('@zero-server/sdk');
|
|
8
|
-
*
|
|
9
|
-
* const db = Database.connect('mongo', {
|
|
10
|
-
* url: 'mongodb://localhost:27017',
|
|
11
|
-
* database: 'myapp',
|
|
12
|
-
* });
|
|
13
|
-
*
|
|
14
|
-
* class Event extends Model {
|
|
15
|
-
* static table = 'events';
|
|
16
|
-
* static schema = {
|
|
17
|
-
* id: { type: TYPES.STRING, primaryKey: true },
|
|
18
|
-
* name: { type: TYPES.STRING, required: true },
|
|
19
|
-
* payload: { type: TYPES.JSON, default: {} },
|
|
20
|
-
* };
|
|
21
|
-
* static timestamps = true;
|
|
22
|
-
* }
|
|
23
|
-
*
|
|
24
|
-
* db.register(Event);
|
|
25
|
-
* await db.sync();
|
|
26
|
-
*
|
|
27
|
-
* await Event.create({ name: 'signup', payload: { userId: 42 } });
|
|
28
|
-
* const signups = await Event.find({ name: 'signup' });
|
|
29
|
-
*/
|
|
30
|
-
|
|
31
|
-
class MongoAdapter
|
|
32
|
-
{
|
|
33
|
-
/**
|
|
34
|
-
* @constructor
|
|
35
|
-
* @param {object} options - Configuration options.
|
|
36
|
-
* @param {string} options.database - Database name.
|
|
37
|
-
* @param {number} [options.maxPoolSize=10] - Max connection pool size.
|
|
38
|
-
* @param {number} [options.minPoolSize=0] - Min connection pool size.
|
|
39
|
-
* @param {number} [options.connectTimeoutMS=10000] - Connection timeout.
|
|
40
|
-
* @param {number} [options.socketTimeoutMS=0] - Socket timeout (0 = no limit).
|
|
41
|
-
* @param {number} [options.serverSelectionTimeoutMS=30000] - Server selection timeout.
|
|
42
|
-
* @param {boolean} [options.retryWrites=true] - Retry writes on network errors.
|
|
43
|
-
* @param {boolean} [options.retryReads=true] - Retry reads on network errors.
|
|
44
|
-
* @param {string} [options.authSource] - Auth database name.
|
|
45
|
-
* @param {string} [options.replicaSet] - Replica set name.
|
|
46
|
-
* @param {object} [options.clientOptions] - Extra MongoClient options (passed directly).
|
|
47
|
-
*/
|
|
48
|
-
constructor(options = {})
|
|
49
|
-
{
|
|
50
|
-
let mongodb;
|
|
51
|
-
try { mongodb = require('mongodb'); }
|
|
52
|
-
catch (e)
|
|
53
|
-
{
|
|
54
|
-
throw new Error(
|
|
55
|
-
'MongoDB adapter requires "mongodb" package.\n' +
|
|
56
|
-
'Install it with: npm install mongodb'
|
|
57
|
-
);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const url = options.url || 'mongodb://127.0.0.1:27017';
|
|
61
|
-
this._client = new mongodb.MongoClient(url, {
|
|
62
|
-
maxPoolSize: options.maxPoolSize || 10,
|
|
63
|
-
...options.clientOptions,
|
|
64
|
-
});
|
|
65
|
-
this._dbName = options.database;
|
|
66
|
-
this._db = null;
|
|
67
|
-
this._connected = false;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Ensure client is connected and return the database handle.
|
|
72
|
-
* @returns {Promise<import('mongodb').Db>} MongoDB database handle.
|
|
73
|
-
* @private
|
|
74
|
-
*/
|
|
75
|
-
async _getDb()
|
|
76
|
-
{
|
|
77
|
-
if (!this._connected)
|
|
78
|
-
{
|
|
79
|
-
await this._client.connect();
|
|
80
|
-
this._connected = true;
|
|
81
|
-
this._db = this._client.db(this._dbName);
|
|
82
|
-
}
|
|
83
|
-
return this._db;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** @private */
|
|
87
|
-
_col(table) { return this._getDb().then(db => db.collection(table)); }
|
|
88
|
-
|
|
89
|
-
// -- DDL ---------------------------------------------
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Create a table with the given schema.
|
|
93
|
-
* @param {string} table - Table name.
|
|
94
|
-
* @param {object} schema - Column definitions keyed by column name.
|
|
95
|
-
* @returns {Promise<void>}
|
|
96
|
-
*/
|
|
97
|
-
async createTable(table, schema)
|
|
98
|
-
{
|
|
99
|
-
const db = await this._getDb();
|
|
100
|
-
const existing = await db.listCollections({ name: table }).toArray();
|
|
101
|
-
if (existing.length === 0)
|
|
102
|
-
{
|
|
103
|
-
const createOpts = {};
|
|
104
|
-
|
|
105
|
-
// Build JSON Schema validator from schema definition
|
|
106
|
-
if (schema && Object.keys(schema).length > 0)
|
|
107
|
-
{
|
|
108
|
-
const properties = {};
|
|
109
|
-
const required = [];
|
|
110
|
-
for (const [name, def] of Object.entries(schema))
|
|
111
|
-
{
|
|
112
|
-
if (def.primaryKey && def.autoIncrement) continue; // handled by app
|
|
113
|
-
const prop = {};
|
|
114
|
-
const t = (def.type || 'string').toLowerCase();
|
|
115
|
-
if (t === 'integer' || t === 'int' || t === 'bigint') prop.bsonType = 'int';
|
|
116
|
-
else if (t === 'float' || t === 'double' || t === 'decimal' || t === 'number') prop.bsonType = 'double';
|
|
117
|
-
else if (t === 'boolean' || t === 'bool') prop.bsonType = 'bool';
|
|
118
|
-
else if (t === 'date' || t === 'datetime' || t === 'timestamp') prop.bsonType = 'date';
|
|
119
|
-
else if (t === 'json' || t === 'object' || t === 'jsonb') prop.bsonType = 'object';
|
|
120
|
-
else if (t === 'array') prop.bsonType = 'array';
|
|
121
|
-
else prop.bsonType = 'string';
|
|
122
|
-
|
|
123
|
-
if (def.enum) prop.enum = def.enum;
|
|
124
|
-
if (def.min !== undefined) prop.minimum = def.min;
|
|
125
|
-
if (def.max !== undefined) prop.maximum = def.max;
|
|
126
|
-
|
|
127
|
-
properties[name] = prop;
|
|
128
|
-
if (def.required) required.push(name);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
if (Object.keys(properties).length > 0)
|
|
132
|
-
{
|
|
133
|
-
const jsonSchema = { bsonType: 'object', properties };
|
|
134
|
-
if (required.length > 0) jsonSchema.required = required;
|
|
135
|
-
createOpts.validator = { $jsonSchema: jsonSchema };
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
await db.createCollection(table, createOpts);
|
|
140
|
-
|
|
141
|
-
// Create indexes defined in schema
|
|
142
|
-
if (schema)
|
|
143
|
-
{
|
|
144
|
-
const col = db.collection(table);
|
|
145
|
-
for (const [name, def] of Object.entries(schema))
|
|
146
|
-
{
|
|
147
|
-
if (def.unique && !def.primaryKey)
|
|
148
|
-
{
|
|
149
|
-
await col.createIndex({ [name]: 1 }, { unique: true, name: `uq_${table}_${name}` });
|
|
150
|
-
}
|
|
151
|
-
else if (def.index)
|
|
152
|
-
{
|
|
153
|
-
const idxName = typeof def.index === 'string' ? def.index : `idx_${table}_${name}`;
|
|
154
|
-
await col.createIndex({ [name]: 1 }, { name: idxName });
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// Composite unique indexes
|
|
159
|
-
const compositeUniques = {};
|
|
160
|
-
for (const [name, def] of Object.entries(schema))
|
|
161
|
-
{
|
|
162
|
-
if (def.compositeUnique)
|
|
163
|
-
{
|
|
164
|
-
const g = typeof def.compositeUnique === 'string' ? def.compositeUnique : 'default';
|
|
165
|
-
if (!compositeUniques[g]) compositeUniques[g] = {};
|
|
166
|
-
compositeUniques[g][name] = 1;
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
for (const [group, keys] of Object.entries(compositeUniques))
|
|
170
|
-
{
|
|
171
|
-
await col.createIndex(keys, { unique: true, name: `uq_${table}_${group}` });
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// Composite indexes
|
|
175
|
-
const compositeIndexes = {};
|
|
176
|
-
for (const [name, def] of Object.entries(schema))
|
|
177
|
-
{
|
|
178
|
-
if (def.compositeIndex)
|
|
179
|
-
{
|
|
180
|
-
const g = typeof def.compositeIndex === 'string' ? def.compositeIndex : 'default';
|
|
181
|
-
if (!compositeIndexes[g]) compositeIndexes[g] = {};
|
|
182
|
-
compositeIndexes[g][name] = 1;
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
for (const [group, keys] of Object.entries(compositeIndexes))
|
|
186
|
-
{
|
|
187
|
-
await col.createIndex(keys, { name: `idx_${table}_${group}` });
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Drop a table if it exists.
|
|
195
|
-
* @param {string} table - Table name.
|
|
196
|
-
* @returns {Promise<void>}
|
|
197
|
-
*/
|
|
198
|
-
async dropTable(table)
|
|
199
|
-
{
|
|
200
|
-
const db = await this._getDb();
|
|
201
|
-
try { await db.collection(table).drop(); } catch (e) { /* ignore if not exists */ }
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// -- CRUD --------------------------------------------
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Insert a single row.
|
|
208
|
-
* @param {string} table - Table name.
|
|
209
|
-
* @param {object} data - Row data as key-value pairs.
|
|
210
|
-
* @returns {Promise<object>} The inserted row.
|
|
211
|
-
*/
|
|
212
|
-
async insert(table, data)
|
|
213
|
-
{
|
|
214
|
-
const col = await this._col(table);
|
|
215
|
-
// Handle auto-increment for numeric id columns
|
|
216
|
-
if (data.id === undefined || data.id === null)
|
|
217
|
-
{
|
|
218
|
-
const last = await col.find().sort({ id: -1 }).limit(1).toArray();
|
|
219
|
-
data.id = last.length > 0 ? (last[0].id || 0) + 1 : 1;
|
|
220
|
-
}
|
|
221
|
-
const doc = { ...data };
|
|
222
|
-
await col.insertOne(doc);
|
|
223
|
-
// Remove internal _id, return clean object
|
|
224
|
-
delete doc._id;
|
|
225
|
-
return doc;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* Insert multiple rows in a batch.
|
|
230
|
-
* @param {string} table - Table name.
|
|
231
|
-
* @param {Array<object>} dataArray - Array of row objects.
|
|
232
|
-
* @returns {Promise<Array<object>>} The inserted rows.
|
|
233
|
-
*/
|
|
234
|
-
async insertMany(table, dataArray)
|
|
235
|
-
{
|
|
236
|
-
if (!dataArray.length) return [];
|
|
237
|
-
const col = await this._col(table);
|
|
238
|
-
// Auto-increment: find the current max id
|
|
239
|
-
const last = await col.find().sort({ id: -1 }).limit(1).toArray();
|
|
240
|
-
let nextId = last.length > 0 ? (last[0].id || 0) + 1 : 1;
|
|
241
|
-
const docs = dataArray.map(data => {
|
|
242
|
-
const doc = { ...data };
|
|
243
|
-
if (doc.id === undefined || doc.id === null) doc.id = nextId++;
|
|
244
|
-
return doc;
|
|
245
|
-
});
|
|
246
|
-
await col.insertMany(docs);
|
|
247
|
-
return docs.map(d => { const r = { ...d }; delete r._id; return r; });
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/**
|
|
251
|
-
* Execute an aggregate function (count, sum, avg, min, max).
|
|
252
|
-
* @param {object} descriptor - Aggregate descriptor with table, function, field, where.
|
|
253
|
-
* @returns {Promise<number|null>} Aggregate result.
|
|
254
|
-
*/
|
|
255
|
-
async aggregate(descriptor)
|
|
256
|
-
{
|
|
257
|
-
const { table, where, aggregateFn, aggregateField } = descriptor;
|
|
258
|
-
const col = await this._col(table);
|
|
259
|
-
const filter = this._buildFilterFromChain(where);
|
|
260
|
-
const fn = aggregateFn.toLowerCase();
|
|
261
|
-
|
|
262
|
-
if (fn === 'count') return col.countDocuments(filter);
|
|
263
|
-
|
|
264
|
-
const pipeline = [];
|
|
265
|
-
if (Object.keys(filter).length) pipeline.push({ $match: filter });
|
|
266
|
-
|
|
267
|
-
const aggOp = { sum: '$sum', avg: '$avg', min: '$min', max: '$max' }[fn];
|
|
268
|
-
if (!aggOp) return null;
|
|
269
|
-
|
|
270
|
-
pipeline.push({ $group: { _id: null, result: { [aggOp]: `$${aggregateField}` } } });
|
|
271
|
-
const results = await col.aggregate(pipeline).toArray();
|
|
272
|
-
return results.length ? results[0].result : null;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/**
|
|
276
|
-
* Update a single row by primary key.
|
|
277
|
-
* @param {string} table - Table name.
|
|
278
|
-
* @param {string} pk - Primary key column.
|
|
279
|
-
* @param {*} pkVal - Primary key value.
|
|
280
|
-
* @param {object} data - Fields to update.
|
|
281
|
-
* @returns {Promise<void>}
|
|
282
|
-
*/
|
|
283
|
-
async update(table, pk, pkVal, data)
|
|
284
|
-
{
|
|
285
|
-
const col = await this._col(table);
|
|
286
|
-
await col.updateOne({ [pk]: pkVal }, { $set: data });
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/**
|
|
290
|
-
* Update rows matching the given conditions.
|
|
291
|
-
* @param {string} table - Table name.
|
|
292
|
-
* @param {object} conditions - Filter conditions.
|
|
293
|
-
* @param {object} data - Fields to update.
|
|
294
|
-
* @returns {Promise<number>} Number of affected rows.
|
|
295
|
-
*/
|
|
296
|
-
async updateWhere(table, conditions, data)
|
|
297
|
-
{
|
|
298
|
-
const col = await this._col(table);
|
|
299
|
-
const filter = this._buildFilter(conditions);
|
|
300
|
-
const result = await col.updateMany(filter, { $set: data });
|
|
301
|
-
return result.modifiedCount;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
/**
|
|
305
|
-
* Delete a single row by primary key.
|
|
306
|
-
* @param {string} table - Table name.
|
|
307
|
-
* @param {string} pk - Primary key column.
|
|
308
|
-
* @param {*} pkVal - Primary key value.
|
|
309
|
-
* @returns {Promise<void>}
|
|
310
|
-
*/
|
|
311
|
-
async remove(table, pk, pkVal)
|
|
312
|
-
{
|
|
313
|
-
const col = await this._col(table);
|
|
314
|
-
await col.deleteOne({ [pk]: pkVal });
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* Delete rows matching the given conditions.
|
|
319
|
-
* @param {string} table - Table name.
|
|
320
|
-
* @param {object} conditions - Filter conditions.
|
|
321
|
-
* @returns {Promise<number>} Number of deleted rows.
|
|
322
|
-
*/
|
|
323
|
-
async deleteWhere(table, conditions)
|
|
324
|
-
{
|
|
325
|
-
const col = await this._col(table);
|
|
326
|
-
const filter = this._buildFilter(conditions);
|
|
327
|
-
const result = await col.deleteMany(filter);
|
|
328
|
-
return result.deletedCount;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
// -- Query execution ---------------------------------
|
|
332
|
-
|
|
333
|
-
/**
|
|
334
|
-
* Execute a query descriptor built by the Query builder.
|
|
335
|
-
* @param {object} descriptor - Query descriptor with table, fields, where, orderBy, limit, offset.
|
|
336
|
-
* @returns {Promise<Array<object>>} Result rows.
|
|
337
|
-
*/
|
|
338
|
-
async execute(descriptor)
|
|
339
|
-
{
|
|
340
|
-
const { action, table, fields, where, orderBy, limit, offset, distinct } = descriptor;
|
|
341
|
-
const col = await this._col(table);
|
|
342
|
-
|
|
343
|
-
const filter = this._buildFilterFromChain(where);
|
|
344
|
-
|
|
345
|
-
if (action === 'count')
|
|
346
|
-
{
|
|
347
|
-
return col.countDocuments(filter);
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// Projection
|
|
351
|
-
const projection = { _id: 0 };
|
|
352
|
-
if (fields && fields.length > 0)
|
|
353
|
-
{
|
|
354
|
-
for (const f of fields) projection[f] = 1;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
let cursor = col.find(filter, { projection });
|
|
358
|
-
|
|
359
|
-
// Sort
|
|
360
|
-
if (orderBy && orderBy.length > 0)
|
|
361
|
-
{
|
|
362
|
-
const sort = {};
|
|
363
|
-
for (const o of orderBy) sort[o.field] = o.dir === 'desc' ? -1 : 1;
|
|
364
|
-
cursor = cursor.sort(sort);
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
if (offset) cursor = cursor.skip(offset);
|
|
368
|
-
if (limit) cursor = cursor.limit(limit);
|
|
369
|
-
|
|
370
|
-
let results = await cursor.toArray();
|
|
371
|
-
|
|
372
|
-
// Distinct — in-memory since MongoDB distinct() only returns values for a single field
|
|
373
|
-
if (distinct && fields && fields.length > 0)
|
|
374
|
-
{
|
|
375
|
-
const seen = new Set();
|
|
376
|
-
results = results.filter(row =>
|
|
377
|
-
{
|
|
378
|
-
const key = JSON.stringify(fields.map(f => row[f]));
|
|
379
|
-
if (seen.has(key)) return false;
|
|
380
|
-
seen.add(key);
|
|
381
|
-
return true;
|
|
382
|
-
});
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
return results;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
// -- Filter builders ---------------------------------
|
|
389
|
-
|
|
390
|
-
/**
|
|
391
|
-
* Build a MongoDB filter from simple { key: value } conditions.
|
|
392
|
-
* @param {object} conditions - Filter conditions.
|
|
393
|
-
* @returns {object} MongoDB filter document.
|
|
394
|
-
* @private
|
|
395
|
-
*/
|
|
396
|
-
_buildFilter(conditions)
|
|
397
|
-
{
|
|
398
|
-
if (!conditions || Object.keys(conditions).length === 0) return {};
|
|
399
|
-
const filter = {};
|
|
400
|
-
for (const [k, v] of Object.entries(conditions))
|
|
401
|
-
{
|
|
402
|
-
filter[k] = v === null ? null : v;
|
|
403
|
-
}
|
|
404
|
-
return filter;
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
/**
|
|
408
|
-
* Build a MongoDB filter from Query builder where chain.
|
|
409
|
-
* @param {Array} where - Where conditions.
|
|
410
|
-
* @returns {object} MongoDB filter document.
|
|
411
|
-
* @private
|
|
412
|
-
*/
|
|
413
|
-
_buildFilterFromChain(where)
|
|
414
|
-
{
|
|
415
|
-
if (!where || where.length === 0) return {};
|
|
416
|
-
|
|
417
|
-
const andParts = [];
|
|
418
|
-
let currentOr = [];
|
|
419
|
-
|
|
420
|
-
for (let i = 0; i < where.length; i++)
|
|
421
|
-
{
|
|
422
|
-
const w = where[i];
|
|
423
|
-
// Skip raw SQL clauses — not applicable to MongoDB
|
|
424
|
-
if (w.raw) continue;
|
|
425
|
-
const { field, op, value, logic } = w;
|
|
426
|
-
const clause = this._opToMongo(field, op, value);
|
|
427
|
-
|
|
428
|
-
if (i === 0 || logic === 'AND')
|
|
429
|
-
{
|
|
430
|
-
if (currentOr.length > 1)
|
|
431
|
-
{
|
|
432
|
-
andParts.push({ $or: currentOr });
|
|
433
|
-
currentOr = [];
|
|
434
|
-
}
|
|
435
|
-
else if (currentOr.length === 1)
|
|
436
|
-
{
|
|
437
|
-
andParts.push(currentOr[0]);
|
|
438
|
-
currentOr = [];
|
|
439
|
-
}
|
|
440
|
-
currentOr.push(clause);
|
|
441
|
-
}
|
|
442
|
-
else // OR
|
|
443
|
-
{
|
|
444
|
-
currentOr.push(clause);
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
// Flush remaining or group
|
|
449
|
-
if (currentOr.length > 1) andParts.push({ $or: currentOr });
|
|
450
|
-
else if (currentOr.length === 1) andParts.push(currentOr[0]);
|
|
451
|
-
|
|
452
|
-
if (andParts.length === 0) return {};
|
|
453
|
-
if (andParts.length === 1) return andParts[0];
|
|
454
|
-
return { $and: andParts };
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
/**
|
|
458
|
-
* Convert a single operator clause to a MongoDB filter expression.
|
|
459
|
-
* @private
|
|
460
|
-
*/
|
|
461
|
-
_opToMongo(field, op, value)
|
|
462
|
-
{
|
|
463
|
-
switch (op)
|
|
464
|
-
{
|
|
465
|
-
case '=': return { [field]: value };
|
|
466
|
-
case '!=':
|
|
467
|
-
case '<>': return { [field]: { $ne: value } };
|
|
468
|
-
case '>': return { [field]: { $gt: value } };
|
|
469
|
-
case '<': return { [field]: { $lt: value } };
|
|
470
|
-
case '>=': return { [field]: { $gte: value } };
|
|
471
|
-
case '<=': return { [field]: { $lte: value } };
|
|
472
|
-
case 'IN': return { [field]: { $in: value } };
|
|
473
|
-
case 'NOT IN': return { [field]: { $nin: value } };
|
|
474
|
-
case 'BETWEEN': return { [field]: { $gte: value[0], $lte: value[1] } };
|
|
475
|
-
case 'IS NULL': return { [field]: null };
|
|
476
|
-
case 'IS NOT NULL': return { [field]: { $ne: null } };
|
|
477
|
-
case 'LIKE':
|
|
478
|
-
{
|
|
479
|
-
// Convert SQL LIKE to regex: % → .*, _ → .
|
|
480
|
-
const pattern = value
|
|
481
|
-
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
482
|
-
.replace(/%/g, '.*')
|
|
483
|
-
.replace(/_/g, '.');
|
|
484
|
-
return { [field]: { $regex: new RegExp(`^${pattern}$`, 'i') } };
|
|
485
|
-
}
|
|
486
|
-
default: return { [field]: value };
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
// -- Utility -----------------------------------------
|
|
491
|
-
|
|
492
|
-
/**
|
|
493
|
-
* Close the database connection.
|
|
494
|
-
* @returns {Promise<void>}
|
|
495
|
-
*/
|
|
496
|
-
async close() { await this._client.close(); this._connected = false; }
|
|
497
|
-
|
|
498
|
-
/**
|
|
499
|
-
* Run a raw MongoDB command.
|
|
500
|
-
* @param {object} command - MongoDB command document.
|
|
501
|
-
* @returns {Promise<*>} Command result.
|
|
502
|
-
*/
|
|
503
|
-
async raw(command)
|
|
504
|
-
{
|
|
505
|
-
const db = await this._getDb();
|
|
506
|
-
return db.command(command);
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
/**
|
|
510
|
-
* Run multiple operations in a transaction (requires replica set).
|
|
511
|
-
* @param {Function} fn - Receives a session object.
|
|
512
|
-
* @returns {Promise<*>} Return value of fn.
|
|
513
|
-
*/
|
|
514
|
-
async transaction(fn)
|
|
515
|
-
{
|
|
516
|
-
const session = this._client.startSession();
|
|
517
|
-
try
|
|
518
|
-
{
|
|
519
|
-
session.startTransaction();
|
|
520
|
-
const result = await fn(session);
|
|
521
|
-
await session.commitTransaction();
|
|
522
|
-
return result;
|
|
523
|
-
}
|
|
524
|
-
catch (e)
|
|
525
|
-
{
|
|
526
|
-
await session.abortTransaction();
|
|
527
|
-
throw e;
|
|
528
|
-
}
|
|
529
|
-
finally
|
|
530
|
-
{
|
|
531
|
-
await session.endSession();
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
// -- MongoDB Utilities -------------------------------
|
|
536
|
-
|
|
537
|
-
/**
|
|
538
|
-
* List all collections in the database.
|
|
539
|
-
* @returns {Promise<string[]>} Array of collection names.
|
|
540
|
-
*/
|
|
541
|
-
async collections()
|
|
542
|
-
{
|
|
543
|
-
const db = await this._getDb();
|
|
544
|
-
const list = await db.listCollections().toArray();
|
|
545
|
-
return list.map(c => c.name);
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
/**
|
|
549
|
-
* Get database stats (document count, storage size, indexes, etc.).
|
|
550
|
-
* @returns {Promise<{ collections: number, objects: number, dataSize: number, storageSize: number, indexes: number, indexSize: number }>}
|
|
551
|
-
*/
|
|
552
|
-
async stats()
|
|
553
|
-
{
|
|
554
|
-
const db = await this._getDb();
|
|
555
|
-
const s = await db.command({ dbStats: 1 });
|
|
556
|
-
return {
|
|
557
|
-
collections: s.collections || 0,
|
|
558
|
-
objects: s.objects || 0,
|
|
559
|
-
dataSize: s.dataSize || 0,
|
|
560
|
-
storageSize: s.storageSize || 0,
|
|
561
|
-
indexes: s.indexes || 0,
|
|
562
|
-
indexSize: s.indexSize || 0,
|
|
563
|
-
};
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
/**
|
|
567
|
-
* Get collection stats.
|
|
568
|
-
* @param {string} name - Collection name.
|
|
569
|
-
* @returns {Promise<{ count: number, size: number, avgObjSize: number, storageSize: number, nindexes: number }>}
|
|
570
|
-
*/
|
|
571
|
-
async collectionStats(name)
|
|
572
|
-
{
|
|
573
|
-
const db = await this._getDb();
|
|
574
|
-
const s = await db.command({ collStats: name });
|
|
575
|
-
return {
|
|
576
|
-
count: s.count || 0,
|
|
577
|
-
size: s.size || 0,
|
|
578
|
-
avgObjSize: s.avgObjSize || 0,
|
|
579
|
-
storageSize: s.storageSize || 0,
|
|
580
|
-
nindexes: s.nindexes || 0,
|
|
581
|
-
};
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
/**
|
|
585
|
-
* Create an index on a collection.
|
|
586
|
-
* @param {string} collection - Collection name.
|
|
587
|
-
* @param {object|string|string[]} keys - Index specification: object `{ email: 1 }`, string, or string[].
|
|
588
|
-
* @param {object} [options] - Index options (unique, sparse, expireAfterSeconds, etc.).
|
|
589
|
-
* @returns {Promise<string>} Index name.
|
|
590
|
-
*/
|
|
591
|
-
async createIndex(collection, keys, options = {})
|
|
592
|
-
{
|
|
593
|
-
// Normalize array/string columns to MongoDB key spec
|
|
594
|
-
if (Array.isArray(keys))
|
|
595
|
-
keys = Object.fromEntries(keys.map(k => [k, 1]));
|
|
596
|
-
else if (typeof keys === 'string')
|
|
597
|
-
keys = { [keys]: 1 };
|
|
598
|
-
const col = await this._col(collection);
|
|
599
|
-
return col.createIndex(keys, options);
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
/**
|
|
603
|
-
* List indexes on a collection.
|
|
604
|
-
* @param {string} collection - MongoDB collection name.
|
|
605
|
-
* @returns {Promise<Array>} Array of index definitions.
|
|
606
|
-
*/
|
|
607
|
-
async indexes(collection)
|
|
608
|
-
{
|
|
609
|
-
const col = await this._col(collection);
|
|
610
|
-
return col.indexes();
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
/**
|
|
614
|
-
* Drop an index from a collection.
|
|
615
|
-
* @param {string} collection - MongoDB collection name.
|
|
616
|
-
* @param {string} indexName - Index name.
|
|
617
|
-
*/
|
|
618
|
-
async dropIndex(collection, indexName)
|
|
619
|
-
{
|
|
620
|
-
const col = await this._col(collection);
|
|
621
|
-
return col.dropIndex(indexName);
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
/**
|
|
625
|
-
* Ping the MongoDB server.
|
|
626
|
-
* @returns {Promise<boolean>} `true` if the server is reachable.
|
|
627
|
-
*/
|
|
628
|
-
async ping()
|
|
629
|
-
{
|
|
630
|
-
try
|
|
631
|
-
{
|
|
632
|
-
const db = await this._getDb();
|
|
633
|
-
const result = await db.command({ ping: 1 });
|
|
634
|
-
return result.ok === 1;
|
|
635
|
-
}
|
|
636
|
-
catch { return false; }
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
/**
|
|
640
|
-
* Get MongoDB server version and build info.
|
|
641
|
-
* @returns {Promise<string>} Server version string (e.g. `"7.0.4"`).
|
|
642
|
-
*/
|
|
643
|
-
async version()
|
|
644
|
-
{
|
|
645
|
-
const db = await this._getDb();
|
|
646
|
-
const info = await db.command({ buildInfo: 1 });
|
|
647
|
-
return info.version;
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
/**
|
|
651
|
-
* Check if connected.
|
|
652
|
-
* @returns {boolean} `true` if currently connected.
|
|
653
|
-
*/
|
|
654
|
-
get isConnected() { return this._connected; }
|
|
655
|
-
|
|
656
|
-
// -- Migration / DDL Methods -------------------------
|
|
657
|
-
|
|
658
|
-
/**
|
|
659
|
-
* Check if a collection exists.
|
|
660
|
-
* @param {string} table - Table name.
|
|
661
|
-
* @returns {Promise<boolean>} `true` if the collection exists.
|
|
662
|
-
*/
|
|
663
|
-
async hasTable(table)
|
|
664
|
-
{
|
|
665
|
-
const db = await this._getDb();
|
|
666
|
-
const list = await db.listCollections({ name: table }).toArray();
|
|
667
|
-
return list.length > 0;
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
/**
|
|
671
|
-
* Check if a field exists in any document of a collection.
|
|
672
|
-
* @param {string} table - Table name.
|
|
673
|
-
* @param {string} column - Column name.
|
|
674
|
-
* @returns {Promise<boolean>} `true` if any document contains the field.
|
|
675
|
-
*/
|
|
676
|
-
async hasColumn(table, column)
|
|
677
|
-
{
|
|
678
|
-
const col = await this._col(table);
|
|
679
|
-
const doc = await col.findOne({ [column]: { $exists: true } });
|
|
680
|
-
return doc !== null;
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
/**
|
|
684
|
-
* Rename a collection.
|
|
685
|
-
* @param {string} oldName - Current name.
|
|
686
|
-
* @param {string} newName - New name.
|
|
687
|
-
* @returns {Promise<void>}
|
|
688
|
-
*/
|
|
689
|
-
async renameTable(oldName, newName)
|
|
690
|
-
{
|
|
691
|
-
const db = await this._getDb();
|
|
692
|
-
await db.collection(oldName).rename(newName);
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
/**
|
|
696
|
-
* Add a field to all documents (sets default for existing docs).
|
|
697
|
-
* @param {string} table - Table name.
|
|
698
|
-
* @param {string} column - Column name.
|
|
699
|
-
* @param {object} def - Column definition.
|
|
700
|
-
* @returns {Promise<void>}
|
|
701
|
-
*/
|
|
702
|
-
async addColumn(table, column, def)
|
|
703
|
-
{
|
|
704
|
-
const col = await this._col(table);
|
|
705
|
-
const defaultVal = def.default !== undefined ? (typeof def.default === 'function' ? def.default() : def.default) : null;
|
|
706
|
-
await col.updateMany(
|
|
707
|
-
{ [column]: { $exists: false } },
|
|
708
|
-
{ $set: { [column]: defaultVal } }
|
|
709
|
-
);
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
/**
|
|
713
|
-
* Remove a field from all documents.
|
|
714
|
-
* @param {string} table - Table name.
|
|
715
|
-
* @param {string} column - Column name.
|
|
716
|
-
* @returns {Promise<void>}
|
|
717
|
-
*/
|
|
718
|
-
async dropColumn(table, column)
|
|
719
|
-
{
|
|
720
|
-
const col = await this._col(table);
|
|
721
|
-
await col.updateMany({}, { $unset: { [column]: '' } });
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
/**
|
|
725
|
-
* Rename a field in all documents.
|
|
726
|
-
* @param {string} table - Table name.
|
|
727
|
-
* @param {string} oldName - Current name.
|
|
728
|
-
* @param {string} newName - New name.
|
|
729
|
-
* @returns {Promise<void>}
|
|
730
|
-
*/
|
|
731
|
-
async renameColumn(table, oldName, newName)
|
|
732
|
-
{
|
|
733
|
-
const col = await this._col(table);
|
|
734
|
-
await col.updateMany({}, { $rename: { [oldName]: newName } });
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
/**
|
|
738
|
-
* Describe the inferred schema of a collection by sampling documents.
|
|
739
|
-
* @param {string} table - Table name.
|
|
740
|
-
* @param {number} [sampleSize=100] - Number of sample documents.
|
|
741
|
-
* @returns {Promise<Array<{ name: string, types: string[] }>>}
|
|
742
|
-
*/
|
|
743
|
-
async describeTable(table, sampleSize = 100)
|
|
744
|
-
{
|
|
745
|
-
const col = await this._col(table);
|
|
746
|
-
const docs = await col.find().limit(sampleSize).toArray();
|
|
747
|
-
const fieldMap = {};
|
|
748
|
-
for (const doc of docs)
|
|
749
|
-
{
|
|
750
|
-
for (const [key, val] of Object.entries(doc))
|
|
751
|
-
{
|
|
752
|
-
if (key === '_id') continue;
|
|
753
|
-
if (!fieldMap[key]) fieldMap[key] = new Set();
|
|
754
|
-
fieldMap[key].add(typeof val);
|
|
755
|
-
}
|
|
756
|
-
}
|
|
757
|
-
return Object.entries(fieldMap).map(([name, types]) => ({
|
|
758
|
-
name,
|
|
759
|
-
types: [...types],
|
|
760
|
-
}));
|
|
761
|
-
}
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
module.exports = MongoAdapter;
|
|
1
|
+
/**
|
|
2
|
+
* @module orm/adapters/mongo
|
|
3
|
+
* @description MongoDB adapter using the optional `mongodb` driver.
|
|
4
|
+
* Requires: `npm install mongodb`
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* const { Database, Model, TYPES } = require('@zero-server/sdk');
|
|
8
|
+
*
|
|
9
|
+
* const db = Database.connect('mongo', {
|
|
10
|
+
* url: 'mongodb://localhost:27017',
|
|
11
|
+
* database: 'myapp',
|
|
12
|
+
* });
|
|
13
|
+
*
|
|
14
|
+
* class Event extends Model {
|
|
15
|
+
* static table = 'events';
|
|
16
|
+
* static schema = {
|
|
17
|
+
* id: { type: TYPES.STRING, primaryKey: true },
|
|
18
|
+
* name: { type: TYPES.STRING, required: true },
|
|
19
|
+
* payload: { type: TYPES.JSON, default: {} },
|
|
20
|
+
* };
|
|
21
|
+
* static timestamps = true;
|
|
22
|
+
* }
|
|
23
|
+
*
|
|
24
|
+
* db.register(Event);
|
|
25
|
+
* await db.sync();
|
|
26
|
+
*
|
|
27
|
+
* await Event.create({ name: 'signup', payload: { userId: 42 } });
|
|
28
|
+
* const signups = await Event.find({ name: 'signup' });
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
class MongoAdapter
|
|
32
|
+
{
|
|
33
|
+
/**
|
|
34
|
+
* @constructor
|
|
35
|
+
* @param {object} options - Configuration options.
|
|
36
|
+
* @param {string} options.database - Database name.
|
|
37
|
+
* @param {number} [options.maxPoolSize=10] - Max connection pool size.
|
|
38
|
+
* @param {number} [options.minPoolSize=0] - Min connection pool size.
|
|
39
|
+
* @param {number} [options.connectTimeoutMS=10000] - Connection timeout.
|
|
40
|
+
* @param {number} [options.socketTimeoutMS=0] - Socket timeout (0 = no limit).
|
|
41
|
+
* @param {number} [options.serverSelectionTimeoutMS=30000] - Server selection timeout.
|
|
42
|
+
* @param {boolean} [options.retryWrites=true] - Retry writes on network errors.
|
|
43
|
+
* @param {boolean} [options.retryReads=true] - Retry reads on network errors.
|
|
44
|
+
* @param {string} [options.authSource] - Auth database name.
|
|
45
|
+
* @param {string} [options.replicaSet] - Replica set name.
|
|
46
|
+
* @param {object} [options.clientOptions] - Extra MongoClient options (passed directly).
|
|
47
|
+
*/
|
|
48
|
+
constructor(options = {})
|
|
49
|
+
{
|
|
50
|
+
let mongodb;
|
|
51
|
+
try { mongodb = require('mongodb'); }
|
|
52
|
+
catch (e)
|
|
53
|
+
{
|
|
54
|
+
throw new Error(
|
|
55
|
+
'MongoDB adapter requires "mongodb" package.\n' +
|
|
56
|
+
'Install it with: npm install mongodb'
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const url = options.url || 'mongodb://127.0.0.1:27017';
|
|
61
|
+
this._client = new mongodb.MongoClient(url, {
|
|
62
|
+
maxPoolSize: options.maxPoolSize || 10,
|
|
63
|
+
...options.clientOptions,
|
|
64
|
+
});
|
|
65
|
+
this._dbName = options.database;
|
|
66
|
+
this._db = null;
|
|
67
|
+
this._connected = false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Ensure client is connected and return the database handle.
|
|
72
|
+
* @returns {Promise<import('mongodb').Db>} MongoDB database handle.
|
|
73
|
+
* @private
|
|
74
|
+
*/
|
|
75
|
+
async _getDb()
|
|
76
|
+
{
|
|
77
|
+
if (!this._connected)
|
|
78
|
+
{
|
|
79
|
+
await this._client.connect();
|
|
80
|
+
this._connected = true;
|
|
81
|
+
this._db = this._client.db(this._dbName);
|
|
82
|
+
}
|
|
83
|
+
return this._db;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** @private */
|
|
87
|
+
_col(table) { return this._getDb().then(db => db.collection(table)); }
|
|
88
|
+
|
|
89
|
+
// -- DDL ---------------------------------------------
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Create a table with the given schema.
|
|
93
|
+
* @param {string} table - Table name.
|
|
94
|
+
* @param {object} schema - Column definitions keyed by column name.
|
|
95
|
+
* @returns {Promise<void>}
|
|
96
|
+
*/
|
|
97
|
+
async createTable(table, schema)
|
|
98
|
+
{
|
|
99
|
+
const db = await this._getDb();
|
|
100
|
+
const existing = await db.listCollections({ name: table }).toArray();
|
|
101
|
+
if (existing.length === 0)
|
|
102
|
+
{
|
|
103
|
+
const createOpts = {};
|
|
104
|
+
|
|
105
|
+
// Build JSON Schema validator from schema definition
|
|
106
|
+
if (schema && Object.keys(schema).length > 0)
|
|
107
|
+
{
|
|
108
|
+
const properties = {};
|
|
109
|
+
const required = [];
|
|
110
|
+
for (const [name, def] of Object.entries(schema))
|
|
111
|
+
{
|
|
112
|
+
if (def.primaryKey && def.autoIncrement) continue; // handled by app
|
|
113
|
+
const prop = {};
|
|
114
|
+
const t = (def.type || 'string').toLowerCase();
|
|
115
|
+
if (t === 'integer' || t === 'int' || t === 'bigint') prop.bsonType = 'int';
|
|
116
|
+
else if (t === 'float' || t === 'double' || t === 'decimal' || t === 'number') prop.bsonType = 'double';
|
|
117
|
+
else if (t === 'boolean' || t === 'bool') prop.bsonType = 'bool';
|
|
118
|
+
else if (t === 'date' || t === 'datetime' || t === 'timestamp') prop.bsonType = 'date';
|
|
119
|
+
else if (t === 'json' || t === 'object' || t === 'jsonb') prop.bsonType = 'object';
|
|
120
|
+
else if (t === 'array') prop.bsonType = 'array';
|
|
121
|
+
else prop.bsonType = 'string';
|
|
122
|
+
|
|
123
|
+
if (def.enum) prop.enum = def.enum;
|
|
124
|
+
if (def.min !== undefined) prop.minimum = def.min;
|
|
125
|
+
if (def.max !== undefined) prop.maximum = def.max;
|
|
126
|
+
|
|
127
|
+
properties[name] = prop;
|
|
128
|
+
if (def.required) required.push(name);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (Object.keys(properties).length > 0)
|
|
132
|
+
{
|
|
133
|
+
const jsonSchema = { bsonType: 'object', properties };
|
|
134
|
+
if (required.length > 0) jsonSchema.required = required;
|
|
135
|
+
createOpts.validator = { $jsonSchema: jsonSchema };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
await db.createCollection(table, createOpts);
|
|
140
|
+
|
|
141
|
+
// Create indexes defined in schema
|
|
142
|
+
if (schema)
|
|
143
|
+
{
|
|
144
|
+
const col = db.collection(table);
|
|
145
|
+
for (const [name, def] of Object.entries(schema))
|
|
146
|
+
{
|
|
147
|
+
if (def.unique && !def.primaryKey)
|
|
148
|
+
{
|
|
149
|
+
await col.createIndex({ [name]: 1 }, { unique: true, name: `uq_${table}_${name}` });
|
|
150
|
+
}
|
|
151
|
+
else if (def.index)
|
|
152
|
+
{
|
|
153
|
+
const idxName = typeof def.index === 'string' ? def.index : `idx_${table}_${name}`;
|
|
154
|
+
await col.createIndex({ [name]: 1 }, { name: idxName });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Composite unique indexes
|
|
159
|
+
const compositeUniques = {};
|
|
160
|
+
for (const [name, def] of Object.entries(schema))
|
|
161
|
+
{
|
|
162
|
+
if (def.compositeUnique)
|
|
163
|
+
{
|
|
164
|
+
const g = typeof def.compositeUnique === 'string' ? def.compositeUnique : 'default';
|
|
165
|
+
if (!compositeUniques[g]) compositeUniques[g] = {};
|
|
166
|
+
compositeUniques[g][name] = 1;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
for (const [group, keys] of Object.entries(compositeUniques))
|
|
170
|
+
{
|
|
171
|
+
await col.createIndex(keys, { unique: true, name: `uq_${table}_${group}` });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Composite indexes
|
|
175
|
+
const compositeIndexes = {};
|
|
176
|
+
for (const [name, def] of Object.entries(schema))
|
|
177
|
+
{
|
|
178
|
+
if (def.compositeIndex)
|
|
179
|
+
{
|
|
180
|
+
const g = typeof def.compositeIndex === 'string' ? def.compositeIndex : 'default';
|
|
181
|
+
if (!compositeIndexes[g]) compositeIndexes[g] = {};
|
|
182
|
+
compositeIndexes[g][name] = 1;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
for (const [group, keys] of Object.entries(compositeIndexes))
|
|
186
|
+
{
|
|
187
|
+
await col.createIndex(keys, { name: `idx_${table}_${group}` });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Drop a table if it exists.
|
|
195
|
+
* @param {string} table - Table name.
|
|
196
|
+
* @returns {Promise<void>}
|
|
197
|
+
*/
|
|
198
|
+
async dropTable(table)
|
|
199
|
+
{
|
|
200
|
+
const db = await this._getDb();
|
|
201
|
+
try { await db.collection(table).drop(); } catch (e) { /* ignore if not exists */ }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// -- CRUD --------------------------------------------
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Insert a single row.
|
|
208
|
+
* @param {string} table - Table name.
|
|
209
|
+
* @param {object} data - Row data as key-value pairs.
|
|
210
|
+
* @returns {Promise<object>} The inserted row.
|
|
211
|
+
*/
|
|
212
|
+
async insert(table, data)
|
|
213
|
+
{
|
|
214
|
+
const col = await this._col(table);
|
|
215
|
+
// Handle auto-increment for numeric id columns
|
|
216
|
+
if (data.id === undefined || data.id === null)
|
|
217
|
+
{
|
|
218
|
+
const last = await col.find().sort({ id: -1 }).limit(1).toArray();
|
|
219
|
+
data.id = last.length > 0 ? (last[0].id || 0) + 1 : 1;
|
|
220
|
+
}
|
|
221
|
+
const doc = { ...data };
|
|
222
|
+
await col.insertOne(doc);
|
|
223
|
+
// Remove internal _id, return clean object
|
|
224
|
+
delete doc._id;
|
|
225
|
+
return doc;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Insert multiple rows in a batch.
|
|
230
|
+
* @param {string} table - Table name.
|
|
231
|
+
* @param {Array<object>} dataArray - Array of row objects.
|
|
232
|
+
* @returns {Promise<Array<object>>} The inserted rows.
|
|
233
|
+
*/
|
|
234
|
+
async insertMany(table, dataArray)
|
|
235
|
+
{
|
|
236
|
+
if (!dataArray.length) return [];
|
|
237
|
+
const col = await this._col(table);
|
|
238
|
+
// Auto-increment: find the current max id
|
|
239
|
+
const last = await col.find().sort({ id: -1 }).limit(1).toArray();
|
|
240
|
+
let nextId = last.length > 0 ? (last[0].id || 0) + 1 : 1;
|
|
241
|
+
const docs = dataArray.map(data => {
|
|
242
|
+
const doc = { ...data };
|
|
243
|
+
if (doc.id === undefined || doc.id === null) doc.id = nextId++;
|
|
244
|
+
return doc;
|
|
245
|
+
});
|
|
246
|
+
await col.insertMany(docs);
|
|
247
|
+
return docs.map(d => { const r = { ...d }; delete r._id; return r; });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Execute an aggregate function (count, sum, avg, min, max).
|
|
252
|
+
* @param {object} descriptor - Aggregate descriptor with table, function, field, where.
|
|
253
|
+
* @returns {Promise<number|null>} Aggregate result.
|
|
254
|
+
*/
|
|
255
|
+
async aggregate(descriptor)
|
|
256
|
+
{
|
|
257
|
+
const { table, where, aggregateFn, aggregateField } = descriptor;
|
|
258
|
+
const col = await this._col(table);
|
|
259
|
+
const filter = this._buildFilterFromChain(where);
|
|
260
|
+
const fn = aggregateFn.toLowerCase();
|
|
261
|
+
|
|
262
|
+
if (fn === 'count') return col.countDocuments(filter);
|
|
263
|
+
|
|
264
|
+
const pipeline = [];
|
|
265
|
+
if (Object.keys(filter).length) pipeline.push({ $match: filter });
|
|
266
|
+
|
|
267
|
+
const aggOp = { sum: '$sum', avg: '$avg', min: '$min', max: '$max' }[fn];
|
|
268
|
+
if (!aggOp) return null;
|
|
269
|
+
|
|
270
|
+
pipeline.push({ $group: { _id: null, result: { [aggOp]: `$${aggregateField}` } } });
|
|
271
|
+
const results = await col.aggregate(pipeline).toArray();
|
|
272
|
+
return results.length ? results[0].result : null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Update a single row by primary key.
|
|
277
|
+
* @param {string} table - Table name.
|
|
278
|
+
* @param {string} pk - Primary key column.
|
|
279
|
+
* @param {*} pkVal - Primary key value.
|
|
280
|
+
* @param {object} data - Fields to update.
|
|
281
|
+
* @returns {Promise<void>}
|
|
282
|
+
*/
|
|
283
|
+
async update(table, pk, pkVal, data)
|
|
284
|
+
{
|
|
285
|
+
const col = await this._col(table);
|
|
286
|
+
await col.updateOne({ [pk]: pkVal }, { $set: data });
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Update rows matching the given conditions.
|
|
291
|
+
* @param {string} table - Table name.
|
|
292
|
+
* @param {object} conditions - Filter conditions.
|
|
293
|
+
* @param {object} data - Fields to update.
|
|
294
|
+
* @returns {Promise<number>} Number of affected rows.
|
|
295
|
+
*/
|
|
296
|
+
async updateWhere(table, conditions, data)
|
|
297
|
+
{
|
|
298
|
+
const col = await this._col(table);
|
|
299
|
+
const filter = this._buildFilter(conditions);
|
|
300
|
+
const result = await col.updateMany(filter, { $set: data });
|
|
301
|
+
return result.modifiedCount;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Delete a single row by primary key.
|
|
306
|
+
* @param {string} table - Table name.
|
|
307
|
+
* @param {string} pk - Primary key column.
|
|
308
|
+
* @param {*} pkVal - Primary key value.
|
|
309
|
+
* @returns {Promise<void>}
|
|
310
|
+
*/
|
|
311
|
+
async remove(table, pk, pkVal)
|
|
312
|
+
{
|
|
313
|
+
const col = await this._col(table);
|
|
314
|
+
await col.deleteOne({ [pk]: pkVal });
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Delete rows matching the given conditions.
|
|
319
|
+
* @param {string} table - Table name.
|
|
320
|
+
* @param {object} conditions - Filter conditions.
|
|
321
|
+
* @returns {Promise<number>} Number of deleted rows.
|
|
322
|
+
*/
|
|
323
|
+
async deleteWhere(table, conditions)
|
|
324
|
+
{
|
|
325
|
+
const col = await this._col(table);
|
|
326
|
+
const filter = this._buildFilter(conditions);
|
|
327
|
+
const result = await col.deleteMany(filter);
|
|
328
|
+
return result.deletedCount;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// -- Query execution ---------------------------------
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Execute a query descriptor built by the Query builder.
|
|
335
|
+
* @param {object} descriptor - Query descriptor with table, fields, where, orderBy, limit, offset.
|
|
336
|
+
* @returns {Promise<Array<object>>} Result rows.
|
|
337
|
+
*/
|
|
338
|
+
async execute(descriptor)
|
|
339
|
+
{
|
|
340
|
+
const { action, table, fields, where, orderBy, limit, offset, distinct } = descriptor;
|
|
341
|
+
const col = await this._col(table);
|
|
342
|
+
|
|
343
|
+
const filter = this._buildFilterFromChain(where);
|
|
344
|
+
|
|
345
|
+
if (action === 'count')
|
|
346
|
+
{
|
|
347
|
+
return col.countDocuments(filter);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Projection
|
|
351
|
+
const projection = { _id: 0 };
|
|
352
|
+
if (fields && fields.length > 0)
|
|
353
|
+
{
|
|
354
|
+
for (const f of fields) projection[f] = 1;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
let cursor = col.find(filter, { projection });
|
|
358
|
+
|
|
359
|
+
// Sort
|
|
360
|
+
if (orderBy && orderBy.length > 0)
|
|
361
|
+
{
|
|
362
|
+
const sort = {};
|
|
363
|
+
for (const o of orderBy) sort[o.field] = o.dir === 'desc' ? -1 : 1;
|
|
364
|
+
cursor = cursor.sort(sort);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (offset) cursor = cursor.skip(offset);
|
|
368
|
+
if (limit) cursor = cursor.limit(limit);
|
|
369
|
+
|
|
370
|
+
let results = await cursor.toArray();
|
|
371
|
+
|
|
372
|
+
// Distinct — in-memory since MongoDB distinct() only returns values for a single field
|
|
373
|
+
if (distinct && fields && fields.length > 0)
|
|
374
|
+
{
|
|
375
|
+
const seen = new Set();
|
|
376
|
+
results = results.filter(row =>
|
|
377
|
+
{
|
|
378
|
+
const key = JSON.stringify(fields.map(f => row[f]));
|
|
379
|
+
if (seen.has(key)) return false;
|
|
380
|
+
seen.add(key);
|
|
381
|
+
return true;
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return results;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// -- Filter builders ---------------------------------
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Build a MongoDB filter from simple { key: value } conditions.
|
|
392
|
+
* @param {object} conditions - Filter conditions.
|
|
393
|
+
* @returns {object} MongoDB filter document.
|
|
394
|
+
* @private
|
|
395
|
+
*/
|
|
396
|
+
_buildFilter(conditions)
|
|
397
|
+
{
|
|
398
|
+
if (!conditions || Object.keys(conditions).length === 0) return {};
|
|
399
|
+
const filter = {};
|
|
400
|
+
for (const [k, v] of Object.entries(conditions))
|
|
401
|
+
{
|
|
402
|
+
filter[k] = v === null ? null : v;
|
|
403
|
+
}
|
|
404
|
+
return filter;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Build a MongoDB filter from Query builder where chain.
|
|
409
|
+
* @param {Array} where - Where conditions.
|
|
410
|
+
* @returns {object} MongoDB filter document.
|
|
411
|
+
* @private
|
|
412
|
+
*/
|
|
413
|
+
_buildFilterFromChain(where)
|
|
414
|
+
{
|
|
415
|
+
if (!where || where.length === 0) return {};
|
|
416
|
+
|
|
417
|
+
const andParts = [];
|
|
418
|
+
let currentOr = [];
|
|
419
|
+
|
|
420
|
+
for (let i = 0; i < where.length; i++)
|
|
421
|
+
{
|
|
422
|
+
const w = where[i];
|
|
423
|
+
// Skip raw SQL clauses — not applicable to MongoDB
|
|
424
|
+
if (w.raw) continue;
|
|
425
|
+
const { field, op, value, logic } = w;
|
|
426
|
+
const clause = this._opToMongo(field, op, value);
|
|
427
|
+
|
|
428
|
+
if (i === 0 || logic === 'AND')
|
|
429
|
+
{
|
|
430
|
+
if (currentOr.length > 1)
|
|
431
|
+
{
|
|
432
|
+
andParts.push({ $or: currentOr });
|
|
433
|
+
currentOr = [];
|
|
434
|
+
}
|
|
435
|
+
else if (currentOr.length === 1)
|
|
436
|
+
{
|
|
437
|
+
andParts.push(currentOr[0]);
|
|
438
|
+
currentOr = [];
|
|
439
|
+
}
|
|
440
|
+
currentOr.push(clause);
|
|
441
|
+
}
|
|
442
|
+
else // OR
|
|
443
|
+
{
|
|
444
|
+
currentOr.push(clause);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Flush remaining or group
|
|
449
|
+
if (currentOr.length > 1) andParts.push({ $or: currentOr });
|
|
450
|
+
else if (currentOr.length === 1) andParts.push(currentOr[0]);
|
|
451
|
+
|
|
452
|
+
if (andParts.length === 0) return {};
|
|
453
|
+
if (andParts.length === 1) return andParts[0];
|
|
454
|
+
return { $and: andParts };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Convert a single operator clause to a MongoDB filter expression.
|
|
459
|
+
* @private
|
|
460
|
+
*/
|
|
461
|
+
_opToMongo(field, op, value)
|
|
462
|
+
{
|
|
463
|
+
switch (op)
|
|
464
|
+
{
|
|
465
|
+
case '=': return { [field]: value };
|
|
466
|
+
case '!=':
|
|
467
|
+
case '<>': return { [field]: { $ne: value } };
|
|
468
|
+
case '>': return { [field]: { $gt: value } };
|
|
469
|
+
case '<': return { [field]: { $lt: value } };
|
|
470
|
+
case '>=': return { [field]: { $gte: value } };
|
|
471
|
+
case '<=': return { [field]: { $lte: value } };
|
|
472
|
+
case 'IN': return { [field]: { $in: value } };
|
|
473
|
+
case 'NOT IN': return { [field]: { $nin: value } };
|
|
474
|
+
case 'BETWEEN': return { [field]: { $gte: value[0], $lte: value[1] } };
|
|
475
|
+
case 'IS NULL': return { [field]: null };
|
|
476
|
+
case 'IS NOT NULL': return { [field]: { $ne: null } };
|
|
477
|
+
case 'LIKE':
|
|
478
|
+
{
|
|
479
|
+
// Convert SQL LIKE to regex: % → .*, _ → .
|
|
480
|
+
const pattern = value
|
|
481
|
+
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
482
|
+
.replace(/%/g, '.*')
|
|
483
|
+
.replace(/_/g, '.');
|
|
484
|
+
return { [field]: { $regex: new RegExp(`^${pattern}$`, 'i') } };
|
|
485
|
+
}
|
|
486
|
+
default: return { [field]: value };
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// -- Utility -----------------------------------------
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Close the database connection.
|
|
494
|
+
* @returns {Promise<void>}
|
|
495
|
+
*/
|
|
496
|
+
async close() { await this._client.close(); this._connected = false; }
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Run a raw MongoDB command.
|
|
500
|
+
* @param {object} command - MongoDB command document.
|
|
501
|
+
* @returns {Promise<*>} Command result.
|
|
502
|
+
*/
|
|
503
|
+
async raw(command)
|
|
504
|
+
{
|
|
505
|
+
const db = await this._getDb();
|
|
506
|
+
return db.command(command);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Run multiple operations in a transaction (requires replica set).
|
|
511
|
+
* @param {Function} fn - Receives a session object.
|
|
512
|
+
* @returns {Promise<*>} Return value of fn.
|
|
513
|
+
*/
|
|
514
|
+
async transaction(fn)
|
|
515
|
+
{
|
|
516
|
+
const session = this._client.startSession();
|
|
517
|
+
try
|
|
518
|
+
{
|
|
519
|
+
session.startTransaction();
|
|
520
|
+
const result = await fn(session);
|
|
521
|
+
await session.commitTransaction();
|
|
522
|
+
return result;
|
|
523
|
+
}
|
|
524
|
+
catch (e)
|
|
525
|
+
{
|
|
526
|
+
await session.abortTransaction();
|
|
527
|
+
throw e;
|
|
528
|
+
}
|
|
529
|
+
finally
|
|
530
|
+
{
|
|
531
|
+
await session.endSession();
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// -- MongoDB Utilities -------------------------------
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* List all collections in the database.
|
|
539
|
+
* @returns {Promise<string[]>} Array of collection names.
|
|
540
|
+
*/
|
|
541
|
+
async collections()
|
|
542
|
+
{
|
|
543
|
+
const db = await this._getDb();
|
|
544
|
+
const list = await db.listCollections().toArray();
|
|
545
|
+
return list.map(c => c.name);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Get database stats (document count, storage size, indexes, etc.).
|
|
550
|
+
* @returns {Promise<{ collections: number, objects: number, dataSize: number, storageSize: number, indexes: number, indexSize: number }>}
|
|
551
|
+
*/
|
|
552
|
+
async stats()
|
|
553
|
+
{
|
|
554
|
+
const db = await this._getDb();
|
|
555
|
+
const s = await db.command({ dbStats: 1 });
|
|
556
|
+
return {
|
|
557
|
+
collections: s.collections || 0,
|
|
558
|
+
objects: s.objects || 0,
|
|
559
|
+
dataSize: s.dataSize || 0,
|
|
560
|
+
storageSize: s.storageSize || 0,
|
|
561
|
+
indexes: s.indexes || 0,
|
|
562
|
+
indexSize: s.indexSize || 0,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Get collection stats.
|
|
568
|
+
* @param {string} name - Collection name.
|
|
569
|
+
* @returns {Promise<{ count: number, size: number, avgObjSize: number, storageSize: number, nindexes: number }>}
|
|
570
|
+
*/
|
|
571
|
+
async collectionStats(name)
|
|
572
|
+
{
|
|
573
|
+
const db = await this._getDb();
|
|
574
|
+
const s = await db.command({ collStats: name });
|
|
575
|
+
return {
|
|
576
|
+
count: s.count || 0,
|
|
577
|
+
size: s.size || 0,
|
|
578
|
+
avgObjSize: s.avgObjSize || 0,
|
|
579
|
+
storageSize: s.storageSize || 0,
|
|
580
|
+
nindexes: s.nindexes || 0,
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Create an index on a collection.
|
|
586
|
+
* @param {string} collection - Collection name.
|
|
587
|
+
* @param {object|string|string[]} keys - Index specification: object `{ email: 1 }`, string, or string[].
|
|
588
|
+
* @param {object} [options] - Index options (unique, sparse, expireAfterSeconds, etc.).
|
|
589
|
+
* @returns {Promise<string>} Index name.
|
|
590
|
+
*/
|
|
591
|
+
async createIndex(collection, keys, options = {})
|
|
592
|
+
{
|
|
593
|
+
// Normalize array/string columns to MongoDB key spec
|
|
594
|
+
if (Array.isArray(keys))
|
|
595
|
+
keys = Object.fromEntries(keys.map(k => [k, 1]));
|
|
596
|
+
else if (typeof keys === 'string')
|
|
597
|
+
keys = { [keys]: 1 };
|
|
598
|
+
const col = await this._col(collection);
|
|
599
|
+
return col.createIndex(keys, options);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* List indexes on a collection.
|
|
604
|
+
* @param {string} collection - MongoDB collection name.
|
|
605
|
+
* @returns {Promise<Array>} Array of index definitions.
|
|
606
|
+
*/
|
|
607
|
+
async indexes(collection)
|
|
608
|
+
{
|
|
609
|
+
const col = await this._col(collection);
|
|
610
|
+
return col.indexes();
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Drop an index from a collection.
|
|
615
|
+
* @param {string} collection - MongoDB collection name.
|
|
616
|
+
* @param {string} indexName - Index name.
|
|
617
|
+
*/
|
|
618
|
+
async dropIndex(collection, indexName)
|
|
619
|
+
{
|
|
620
|
+
const col = await this._col(collection);
|
|
621
|
+
return col.dropIndex(indexName);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Ping the MongoDB server.
|
|
626
|
+
* @returns {Promise<boolean>} `true` if the server is reachable.
|
|
627
|
+
*/
|
|
628
|
+
async ping()
|
|
629
|
+
{
|
|
630
|
+
try
|
|
631
|
+
{
|
|
632
|
+
const db = await this._getDb();
|
|
633
|
+
const result = await db.command({ ping: 1 });
|
|
634
|
+
return result.ok === 1;
|
|
635
|
+
}
|
|
636
|
+
catch { return false; }
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Get MongoDB server version and build info.
|
|
641
|
+
* @returns {Promise<string>} Server version string (e.g. `"7.0.4"`).
|
|
642
|
+
*/
|
|
643
|
+
async version()
|
|
644
|
+
{
|
|
645
|
+
const db = await this._getDb();
|
|
646
|
+
const info = await db.command({ buildInfo: 1 });
|
|
647
|
+
return info.version;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Check if connected.
|
|
652
|
+
* @returns {boolean} `true` if currently connected.
|
|
653
|
+
*/
|
|
654
|
+
get isConnected() { return this._connected; }
|
|
655
|
+
|
|
656
|
+
// -- Migration / DDL Methods -------------------------
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Check if a collection exists.
|
|
660
|
+
* @param {string} table - Table name.
|
|
661
|
+
* @returns {Promise<boolean>} `true` if the collection exists.
|
|
662
|
+
*/
|
|
663
|
+
async hasTable(table)
|
|
664
|
+
{
|
|
665
|
+
const db = await this._getDb();
|
|
666
|
+
const list = await db.listCollections({ name: table }).toArray();
|
|
667
|
+
return list.length > 0;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Check if a field exists in any document of a collection.
|
|
672
|
+
* @param {string} table - Table name.
|
|
673
|
+
* @param {string} column - Column name.
|
|
674
|
+
* @returns {Promise<boolean>} `true` if any document contains the field.
|
|
675
|
+
*/
|
|
676
|
+
async hasColumn(table, column)
|
|
677
|
+
{
|
|
678
|
+
const col = await this._col(table);
|
|
679
|
+
const doc = await col.findOne({ [column]: { $exists: true } });
|
|
680
|
+
return doc !== null;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Rename a collection.
|
|
685
|
+
* @param {string} oldName - Current name.
|
|
686
|
+
* @param {string} newName - New name.
|
|
687
|
+
* @returns {Promise<void>}
|
|
688
|
+
*/
|
|
689
|
+
async renameTable(oldName, newName)
|
|
690
|
+
{
|
|
691
|
+
const db = await this._getDb();
|
|
692
|
+
await db.collection(oldName).rename(newName);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Add a field to all documents (sets default for existing docs).
|
|
697
|
+
* @param {string} table - Table name.
|
|
698
|
+
* @param {string} column - Column name.
|
|
699
|
+
* @param {object} def - Column definition.
|
|
700
|
+
* @returns {Promise<void>}
|
|
701
|
+
*/
|
|
702
|
+
async addColumn(table, column, def)
|
|
703
|
+
{
|
|
704
|
+
const col = await this._col(table);
|
|
705
|
+
const defaultVal = def.default !== undefined ? (typeof def.default === 'function' ? def.default() : def.default) : null;
|
|
706
|
+
await col.updateMany(
|
|
707
|
+
{ [column]: { $exists: false } },
|
|
708
|
+
{ $set: { [column]: defaultVal } }
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Remove a field from all documents.
|
|
714
|
+
* @param {string} table - Table name.
|
|
715
|
+
* @param {string} column - Column name.
|
|
716
|
+
* @returns {Promise<void>}
|
|
717
|
+
*/
|
|
718
|
+
async dropColumn(table, column)
|
|
719
|
+
{
|
|
720
|
+
const col = await this._col(table);
|
|
721
|
+
await col.updateMany({}, { $unset: { [column]: '' } });
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Rename a field in all documents.
|
|
726
|
+
* @param {string} table - Table name.
|
|
727
|
+
* @param {string} oldName - Current name.
|
|
728
|
+
* @param {string} newName - New name.
|
|
729
|
+
* @returns {Promise<void>}
|
|
730
|
+
*/
|
|
731
|
+
async renameColumn(table, oldName, newName)
|
|
732
|
+
{
|
|
733
|
+
const col = await this._col(table);
|
|
734
|
+
await col.updateMany({}, { $rename: { [oldName]: newName } });
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Describe the inferred schema of a collection by sampling documents.
|
|
739
|
+
* @param {string} table - Table name.
|
|
740
|
+
* @param {number} [sampleSize=100] - Number of sample documents.
|
|
741
|
+
* @returns {Promise<Array<{ name: string, types: string[] }>>}
|
|
742
|
+
*/
|
|
743
|
+
async describeTable(table, sampleSize = 100)
|
|
744
|
+
{
|
|
745
|
+
const col = await this._col(table);
|
|
746
|
+
const docs = await col.find().limit(sampleSize).toArray();
|
|
747
|
+
const fieldMap = {};
|
|
748
|
+
for (const doc of docs)
|
|
749
|
+
{
|
|
750
|
+
for (const [key, val] of Object.entries(doc))
|
|
751
|
+
{
|
|
752
|
+
if (key === '_id') continue;
|
|
753
|
+
if (!fieldMap[key]) fieldMap[key] = new Set();
|
|
754
|
+
fieldMap[key].add(typeof val);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
return Object.entries(fieldMap).map(([name, types]) => ({
|
|
758
|
+
name,
|
|
759
|
+
types: [...types],
|
|
760
|
+
}));
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
module.exports = MongoAdapter;
|