outlet-orm 10.0.0 → 11.1.0
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/README.md +32 -0
- package/bin/reverse.js +0 -1
- package/package.json +1 -1
- package/skills/outlet-orm/ADVANCED.md +29 -0
- package/skills/outlet-orm/API.md +30 -0
- package/skills/outlet-orm/MODELS.md +144 -2
- package/skills/outlet-orm/QUERIES.md +133 -0
- package/skills/outlet-orm/RELATIONS.md +44 -0
- package/skills/outlet-orm/SKILL.md +4 -2
- package/skills/outlet-orm/TYPESCRIPT.md +98 -0
- package/src/AI/AIManager.js +58 -58
- package/src/AI/AIQueryBuilder.js +2 -2
- package/src/AI/AIQueryOptimizer.js +2 -2
- package/src/AI/Contracts/AudioProviderContract.js +2 -2
- package/src/AI/Contracts/ChatProviderContract.js +3 -2
- package/src/AI/Contracts/EmbeddingsProviderContract.js +1 -1
- package/src/AI/Contracts/ImageProviderContract.js +1 -1
- package/src/AI/Contracts/ModelsProviderContract.js +1 -1
- package/src/AI/Contracts/ToolContract.js +1 -1
- package/src/AI/MCPServer.js +3 -3
- package/src/AI/Providers/CustomOpenAIProvider.js +0 -2
- package/src/AI/Providers/GeminiProvider.js +2 -2
- package/src/AI/Providers/OpenAIProvider.js +0 -5
- package/src/AI/Support/DocumentAttachmentMapper.js +37 -37
- package/src/AI/Support/FileSecurity.js +1 -1
- package/src/Backup/BackupManager.js +6 -6
- package/src/Backup/BackupScheduler.js +1 -1
- package/src/Backup/BackupSocketServer.js +2 -2
- package/src/DatabaseConnection.js +51 -0
- package/src/Model.js +245 -5
- package/src/QueryBuilder.js +191 -0
- package/src/Relations/HasOneRelation.js +114 -114
- package/src/Relations/HasOneThroughRelation.js +105 -105
- package/src/Relations/MorphOneRelation.js +4 -2
- package/src/Relations/Relation.js +35 -0
- package/types/index.d.ts +67 -1
package/src/AI/AIManager.js
CHANGED
|
@@ -90,66 +90,66 @@ class AIManager {
|
|
|
90
90
|
/** @private */
|
|
91
91
|
_buildProviderFromOptions(name, options) {
|
|
92
92
|
switch (name) {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
137
|
-
break;
|
|
93
|
+
case 'openai': {
|
|
94
|
+
const api = options.api_key;
|
|
95
|
+
if (api) return new OpenAIProvider(api, options.chat_endpoint || 'https://api.openai.com/v1/chat/completions');
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
case 'ollama': return new OllamaProvider(options.endpoint || 'http://localhost:11434');
|
|
99
|
+
case 'ollama_turbo': {
|
|
100
|
+
const api = options.api_key;
|
|
101
|
+
if (api) return new OllamaTurboProvider(api, options.endpoint || 'https://ollama.com');
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
case 'onn': {
|
|
105
|
+
const api = options.api_key;
|
|
106
|
+
if (api) return new OnnProvider(api, options.endpoint || 'https://api.onn.ai/v1/chat');
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
case 'gemini': {
|
|
110
|
+
const api = options.api_key;
|
|
111
|
+
if (api) return new GeminiProvider(api, options.endpoint);
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
case 'grok': {
|
|
115
|
+
const api = options.api_key;
|
|
116
|
+
if (api) return new GrokProvider(api, options.endpoint);
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
case 'claude': {
|
|
120
|
+
const api = options.api_key;
|
|
121
|
+
if (api) return new ClaudeProvider(api, options.endpoint);
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
case 'mistral': {
|
|
125
|
+
const api = options.api_key;
|
|
126
|
+
if (api) return new MistralProvider(api, options.endpoint || 'https://api.mistral.ai/v1/chat/completions');
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
case 'openai_custom': {
|
|
130
|
+
const api = options.api_key;
|
|
131
|
+
const base = options.base_url;
|
|
132
|
+
if (api && base) {
|
|
133
|
+
return new CustomOpenAIProvider(api, base, options.paths || {},
|
|
134
|
+
options.auth_header || 'Authorization', options.auth_prefix || BEARER_PREFIX,
|
|
135
|
+
options.extra_headers || {});
|
|
138
136
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
case 'openrouter': {
|
|
140
|
+
const api = options.api_key;
|
|
141
|
+
if (api) {
|
|
142
|
+
const base = options.base_url || 'https://openrouter.ai/api/v1';
|
|
143
|
+
const hdrs = {};
|
|
144
|
+
if (options.referer) hdrs['HTTP-Referer'] = options.referer;
|
|
145
|
+
if (options.title) hdrs['X-Title'] = options.title;
|
|
146
|
+
return new CustomOpenAIProvider(api, base,
|
|
147
|
+
{ chat: '/chat/completions', embeddings: '/embeddings', image: '/images/generations', tts: '/audio/speech', stt: '/audio/transcriptions' },
|
|
148
|
+
'Authorization', BEARER_PREFIX, hdrs
|
|
149
|
+
);
|
|
152
150
|
}
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
153
|
}
|
|
154
154
|
return null;
|
|
155
155
|
}
|
package/src/AI/AIQueryBuilder.js
CHANGED
|
@@ -153,12 +153,12 @@ class AIQueryBuilder {
|
|
|
153
153
|
let tables = [];
|
|
154
154
|
if (dialect === 'pg' || dialect === 'postgresql') {
|
|
155
155
|
const res = await this._connection.raw(
|
|
156
|
-
|
|
156
|
+
'SELECT table_name FROM information_schema.tables WHERE table_schema = \'public\' AND table_type = \'BASE TABLE\''
|
|
157
157
|
);
|
|
158
158
|
tables = (res.rows || res).map(r => r.table_name);
|
|
159
159
|
} else if (dialect === 'sqlite' || dialect === 'sqlite3') {
|
|
160
160
|
const res = await this._connection.raw(
|
|
161
|
-
|
|
161
|
+
'SELECT name FROM sqlite_master WHERE type=\'table\' AND name NOT LIKE \'sqlite_%\''
|
|
162
162
|
);
|
|
163
163
|
tables = (Array.isArray(res) ? res : (res.rows || [])).map(r => r.name);
|
|
164
164
|
} else {
|
|
@@ -143,10 +143,10 @@ RULES:
|
|
|
143
143
|
const dialect = this._connection.config?.client || 'mysql';
|
|
144
144
|
let tables = [];
|
|
145
145
|
if (dialect === 'pg' || dialect === 'postgresql') {
|
|
146
|
-
const res = await this._connection.raw(
|
|
146
|
+
const res = await this._connection.raw('SELECT table_name FROM information_schema.tables WHERE table_schema = \'public\'');
|
|
147
147
|
tables = (res.rows || res).map(r => r.table_name);
|
|
148
148
|
} else if (dialect === 'sqlite' || dialect === 'sqlite3') {
|
|
149
|
-
const res = await this._connection.raw(
|
|
149
|
+
const res = await this._connection.raw('SELECT name FROM sqlite_master WHERE type=\'table\' AND name NOT LIKE \'sqlite_%\'');
|
|
150
150
|
tables = (Array.isArray(res) ? res : []).map(r => r.name);
|
|
151
151
|
} else {
|
|
152
152
|
const res = await this._connection.raw('SHOW TABLES');
|
|
@@ -11,7 +11,7 @@ class AudioProviderContract {
|
|
|
11
11
|
* @param {Object} [options={}]
|
|
12
12
|
* @returns {Promise<{audio: string, mime: string}>} audio is base64-encoded
|
|
13
13
|
*/
|
|
14
|
-
async textToSpeech(text,
|
|
14
|
+
async textToSpeech(text, _options = {}) {
|
|
15
15
|
throw new Error('Not implemented: textToSpeech()');
|
|
16
16
|
}
|
|
17
17
|
|
|
@@ -21,7 +21,7 @@ class AudioProviderContract {
|
|
|
21
21
|
* @param {Object} [options={}]
|
|
22
22
|
* @returns {Promise<{text: string, raw?: Object}>}
|
|
23
23
|
*/
|
|
24
|
-
async speechToText(filePath,
|
|
24
|
+
async speechToText(filePath, _options = {}) {
|
|
25
25
|
throw new Error('Not implemented: speechToText()');
|
|
26
26
|
}
|
|
27
27
|
}
|
|
@@ -12,7 +12,7 @@ class ChatProviderContract {
|
|
|
12
12
|
* @param {Object} [options={}]
|
|
13
13
|
* @returns {Promise<Object>}
|
|
14
14
|
*/
|
|
15
|
-
async chat(messages,
|
|
15
|
+
async chat(messages, _options = {}) {
|
|
16
16
|
throw new Error('Not implemented: chat()');
|
|
17
17
|
}
|
|
18
18
|
|
|
@@ -22,7 +22,8 @@ class ChatProviderContract {
|
|
|
22
22
|
* @param {Object} [options={}]
|
|
23
23
|
* @yields {string|Object}
|
|
24
24
|
*/
|
|
25
|
-
|
|
25
|
+
// eslint-disable-next-line require-yield
|
|
26
|
+
async *stream(messages, _options = {}) {
|
|
26
27
|
throw new Error('Not implemented: stream()');
|
|
27
28
|
}
|
|
28
29
|
|
|
@@ -11,7 +11,7 @@ class EmbeddingsProviderContract {
|
|
|
11
11
|
* @param {Object} [options={}]
|
|
12
12
|
* @returns {Promise<{embeddings: number[][], usage?: Object, raw?: Object}>}
|
|
13
13
|
*/
|
|
14
|
-
async embeddings(inputs,
|
|
14
|
+
async embeddings(inputs, _options = {}) {
|
|
15
15
|
throw new Error('Not implemented: embeddings()');
|
|
16
16
|
}
|
|
17
17
|
}
|
|
@@ -11,7 +11,7 @@ class ImageProviderContract {
|
|
|
11
11
|
* @param {Object} [options={}]
|
|
12
12
|
* @returns {Promise<{images: Array, meta?: Object, raw?: Object}>}
|
|
13
13
|
*/
|
|
14
|
-
async generateImage(prompt,
|
|
14
|
+
async generateImage(prompt, _options = {}) {
|
|
15
15
|
throw new Error('Not implemented: generateImage()');
|
|
16
16
|
}
|
|
17
17
|
}
|
|
@@ -19,7 +19,7 @@ class ToolContract {
|
|
|
19
19
|
* @param {Object} args
|
|
20
20
|
* @returns {Promise<string>|string}
|
|
21
21
|
*/
|
|
22
|
-
execute(
|
|
22
|
+
execute(_args) { throw new Error('Not implemented: execute()'); }
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
module.exports = ToolContract;
|
package/src/AI/MCPServer.js
CHANGED
|
@@ -442,7 +442,7 @@ class MCPServer extends EventEmitter {
|
|
|
442
442
|
|
|
443
443
|
// ── migrate_reset ──────────────────────────────────────────────
|
|
444
444
|
|
|
445
|
-
async _toolMigrateReset(
|
|
445
|
+
async _toolMigrateReset(_args) {
|
|
446
446
|
// Consent already validated in _handleToolCall
|
|
447
447
|
const conn = await this._getConnection();
|
|
448
448
|
const { MigrationManager } = require('../../src');
|
|
@@ -517,9 +517,9 @@ class MCPServer extends EventEmitter {
|
|
|
517
517
|
// Get all tables
|
|
518
518
|
let query;
|
|
519
519
|
if (driver === 'sqlite') {
|
|
520
|
-
query =
|
|
520
|
+
query = 'SELECT name FROM sqlite_master WHERE type=\'table\' AND name NOT LIKE \'sqlite_%\' ORDER BY name';
|
|
521
521
|
} else if (driver === 'postgres') {
|
|
522
|
-
query =
|
|
522
|
+
query = 'SELECT tablename AS name FROM pg_tables WHERE schemaname = \'public\' ORDER BY tablename';
|
|
523
523
|
} else {
|
|
524
524
|
query = 'SHOW TABLES';
|
|
525
525
|
}
|
|
@@ -18,7 +18,7 @@ class GeminiProvider {
|
|
|
18
18
|
/** @private */
|
|
19
19
|
_keyQuery() { return `?key=${this.apiKey}`; }
|
|
20
20
|
|
|
21
|
-
async chat(messages,
|
|
21
|
+
async chat(messages, _options = {}) {
|
|
22
22
|
const userTexts = messages
|
|
23
23
|
.filter(m => (m.role || '') !== 'system')
|
|
24
24
|
.map(m => m.content);
|
|
@@ -46,7 +46,7 @@ class GeminiProvider {
|
|
|
46
46
|
|
|
47
47
|
supportsStreaming() { return true; } // simulated
|
|
48
48
|
|
|
49
|
-
async embeddings(inputs,
|
|
49
|
+
async embeddings(inputs, _options = {}) {
|
|
50
50
|
const vectors = [];
|
|
51
51
|
for (const input of inputs) {
|
|
52
52
|
const payload = {
|
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const ChatProviderContract = require('../Contracts/ChatProviderContract');
|
|
4
|
-
const EmbeddingsProviderContract = require('../Contracts/EmbeddingsProviderContract');
|
|
5
|
-
const ImageProviderContract = require('../Contracts/ImageProviderContract');
|
|
6
|
-
const AudioProviderContract = require('../Contracts/AudioProviderContract');
|
|
7
|
-
const ModelsProviderContract = require('../Contracts/ModelsProviderContract');
|
|
8
3
|
const JsonSchemaValidator = require('../Support/JsonSchemaValidator');
|
|
9
4
|
const DocumentAttachmentMapper = require('../Support/DocumentAttachmentMapper');
|
|
10
5
|
const fs = require('fs');
|
|
@@ -26,46 +26,46 @@ class DocumentAttachmentMapper {
|
|
|
26
26
|
for (const att of attachments) {
|
|
27
27
|
if (att instanceof Document) {
|
|
28
28
|
switch (att.kind) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
29
|
+
case 'text':
|
|
30
|
+
if (att.text != null) inlineTexts.push(att.text);
|
|
31
|
+
break;
|
|
32
|
+
case 'local':
|
|
33
|
+
if (att.path && sec.validateFile(att.path)) {
|
|
34
|
+
const mime = att.mime || 'application/octet-stream';
|
|
35
|
+
const b64 = fs.readFileSync(att.path).toString('base64');
|
|
36
|
+
if (mime.startsWith(IMAGE_PREFIX)) {
|
|
37
|
+
images.push(b64);
|
|
38
|
+
} else {
|
|
39
|
+
files.push({ name: path.basename(att.path), type: mime, content: b64 });
|
|
41
40
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
41
|
+
}
|
|
42
|
+
break;
|
|
43
|
+
case 'base64':
|
|
44
|
+
if (att.base64 && att.mime) {
|
|
45
|
+
if (att.mime.startsWith(IMAGE_PREFIX)) {
|
|
46
|
+
images.push(att.base64);
|
|
47
|
+
} else {
|
|
48
|
+
files.push({ name: att.title || 'document', type: att.mime, content: att.base64 });
|
|
50
49
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
50
|
+
}
|
|
51
|
+
break;
|
|
52
|
+
case 'raw':
|
|
53
|
+
if (att.raw && att.mime) {
|
|
54
|
+
const b64 = Buffer.from(att.raw).toString('base64');
|
|
55
|
+
if (att.mime.startsWith(IMAGE_PREFIX)) {
|
|
56
|
+
images.push(b64);
|
|
57
|
+
} else {
|
|
58
|
+
files.push({ name: att.title || 'document', type: att.mime, content: b64 });
|
|
60
59
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
60
|
+
}
|
|
61
|
+
break;
|
|
62
|
+
case 'chunks':
|
|
63
|
+
for (const c of att.chunks) inlineTexts.push(String(c));
|
|
64
|
+
break;
|
|
65
|
+
case 'url':
|
|
66
|
+
case 'file_id':
|
|
67
|
+
default:
|
|
68
|
+
break;
|
|
69
69
|
}
|
|
70
70
|
} else if (typeof att === 'string' && sec.validateFile(att)) {
|
|
71
71
|
const b64 = fs.readFileSync(att).toString('base64');
|
|
@@ -31,7 +31,7 @@ class FileSecurity {
|
|
|
31
31
|
* @param {boolean} [image=false]
|
|
32
32
|
* @returns {boolean}
|
|
33
33
|
*/
|
|
34
|
-
validateFile(filePath,
|
|
34
|
+
validateFile(filePath, _image = false) {
|
|
35
35
|
try {
|
|
36
36
|
if (!fs.existsSync(filePath)) return false;
|
|
37
37
|
const stats = fs.statSync(filePath);
|
|
@@ -40,7 +40,7 @@ function quoteValue(val) {
|
|
|
40
40
|
if (val === null || val === undefined) return 'NULL';
|
|
41
41
|
if (typeof val === 'number' || typeof val === 'boolean') return String(val);
|
|
42
42
|
// Escape single quotes
|
|
43
|
-
return `'${String(val).replace(/'/g,
|
|
43
|
+
return `'${String(val).replace(/'/g, '\'\'')}'`;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/**
|
|
@@ -244,7 +244,7 @@ class BackupManager {
|
|
|
244
244
|
|
|
245
245
|
for (const table of tables) {
|
|
246
246
|
lines.push(`\n-- Table: ${table}`);
|
|
247
|
-
lines.push(
|
|
247
|
+
lines.push('-- -----------------------------------------------------------');
|
|
248
248
|
|
|
249
249
|
// Schema
|
|
250
250
|
const createSql = await this._getCreateTable(table);
|
|
@@ -302,13 +302,13 @@ class BackupManager {
|
|
|
302
302
|
case 'postgres':
|
|
303
303
|
case 'postgresql':
|
|
304
304
|
rows = await this.connection.executeRawQuery(
|
|
305
|
-
|
|
305
|
+
'SELECT tablename AS name FROM pg_tables WHERE schemaname = \'public\' ORDER BY tablename'
|
|
306
306
|
);
|
|
307
307
|
return rows.map((r) => r.name);
|
|
308
308
|
|
|
309
309
|
case 'sqlite':
|
|
310
310
|
rows = await this.connection.executeRawQuery(
|
|
311
|
-
|
|
311
|
+
'SELECT name FROM sqlite_master WHERE type=\'table\' AND name NOT LIKE \'sqlite_%\' ORDER BY name'
|
|
312
312
|
);
|
|
313
313
|
return rows.map((r) => r.name);
|
|
314
314
|
|
|
@@ -382,12 +382,12 @@ class BackupManager {
|
|
|
382
382
|
*/
|
|
383
383
|
_sqlHeader(type) {
|
|
384
384
|
return [
|
|
385
|
-
|
|
385
|
+
'-- outlet-orm backup',
|
|
386
386
|
`-- type : ${type}`,
|
|
387
387
|
`-- driver : ${this.connection.driver}`,
|
|
388
388
|
`-- database : ${this.connection.config?.database || '(unknown)'}`,
|
|
389
389
|
`-- generated : ${new Date().toISOString()}`,
|
|
390
|
-
|
|
390
|
+
'-- ---------------------------------------------------------------',
|
|
391
391
|
].join('\n');
|
|
392
392
|
}
|
|
393
393
|
|
|
@@ -74,7 +74,7 @@ class BackupScheduler {
|
|
|
74
74
|
throw new Error('BackupScheduler: intervalMs must be >= 1000 (1 second)');
|
|
75
75
|
}
|
|
76
76
|
if (type === 'partial' && (!Array.isArray(config.tables) || config.tables.length === 0)) {
|
|
77
|
-
throw new Error(
|
|
77
|
+
throw new Error('BackupScheduler: \'tables\' array is required for partial backups');
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
const name = config.name || `${type}_${Date.now()}`;
|
|
@@ -271,7 +271,7 @@ class BackupSocketServer extends events.EventEmitter {
|
|
|
271
271
|
filePath = await runManager.full(options);
|
|
272
272
|
} else if (type === 'partial') {
|
|
273
273
|
if (!Array.isArray(tables) || tables.length === 0) {
|
|
274
|
-
throw new Error(
|
|
274
|
+
throw new Error('\'tables\' array is required for partial backup');
|
|
275
275
|
}
|
|
276
276
|
filePath = await runManager.partial(tables, options);
|
|
277
277
|
} else if (type === 'journal') {
|
|
@@ -284,7 +284,7 @@ class BackupSocketServer extends events.EventEmitter {
|
|
|
284
284
|
|
|
285
285
|
case 'restore': {
|
|
286
286
|
if (!msg.filePath) {
|
|
287
|
-
throw new Error(
|
|
287
|
+
throw new Error('\'filePath\' is required for restore');
|
|
288
288
|
}
|
|
289
289
|
// Build a restore-capable manager (needs encryptionPassword if file is encrypted)
|
|
290
290
|
const restoreOpts = Object.assign({}, this._managerOptions, {
|
|
@@ -750,6 +750,57 @@ class DatabaseConnection {
|
|
|
750
750
|
return result;
|
|
751
751
|
}
|
|
752
752
|
|
|
753
|
+
/**
|
|
754
|
+
* Execute an aggregate function (SUM, AVG, MIN, MAX) on a column
|
|
755
|
+
* @param {string} table
|
|
756
|
+
* @param {string} fn - Aggregate function name (SUM, AVG, MIN, MAX)
|
|
757
|
+
* @param {string} column
|
|
758
|
+
* @param {Object} query
|
|
759
|
+
* @returns {Promise<number>}
|
|
760
|
+
*/
|
|
761
|
+
async aggregate(table, fn, column, query) {
|
|
762
|
+
await this.connect();
|
|
763
|
+
const allowedFns = ['SUM', 'AVG', 'MIN', 'MAX'];
|
|
764
|
+
const safeFn = fn.toUpperCase();
|
|
765
|
+
if (!allowedFns.includes(safeFn)) {
|
|
766
|
+
throw new Error(`Invalid aggregate function: ${fn}`);
|
|
767
|
+
}
|
|
768
|
+
const safeTable = sanitizeIdentifier(table);
|
|
769
|
+
const safeColumn = sanitizeIdentifier(column);
|
|
770
|
+
|
|
771
|
+
const { whereClause, params } = this.buildWhereClause(query?.wheres || []);
|
|
772
|
+
const sql = `SELECT ${safeFn}(${safeColumn}) as result FROM ${safeTable}${whereClause}`;
|
|
773
|
+
const start = Date.now();
|
|
774
|
+
|
|
775
|
+
let result;
|
|
776
|
+
switch (this.driver) {
|
|
777
|
+
case 'mysql': {
|
|
778
|
+
const conn = this._getConnection();
|
|
779
|
+
const [rows] = await conn.execute(this.convertToDriverPlaceholder(sql), params);
|
|
780
|
+
result = rows[0].result;
|
|
781
|
+
break;
|
|
782
|
+
}
|
|
783
|
+
case 'postgres':
|
|
784
|
+
case 'postgresql': {
|
|
785
|
+
const conn = this._getConnection();
|
|
786
|
+
const pgResult = await conn.query(this.convertToDriverPlaceholder(sql, 'postgres'), params);
|
|
787
|
+
result = parseFloat(pgResult.rows[0].result);
|
|
788
|
+
break;
|
|
789
|
+
}
|
|
790
|
+
case 'sqlite':
|
|
791
|
+
result = await new Promise((resolve, reject) => {
|
|
792
|
+
this.connection.get(sql, params, (err, row) => {
|
|
793
|
+
if (err) reject(new Error(err.message || String(err)));
|
|
794
|
+
else resolve(row.result);
|
|
795
|
+
});
|
|
796
|
+
});
|
|
797
|
+
break;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
logQuery(sql, params, Date.now() - start);
|
|
801
|
+
return result != null ? Number(result) : 0;
|
|
802
|
+
}
|
|
803
|
+
|
|
753
804
|
/**
|
|
754
805
|
* Execute a raw query and return normalized results.
|
|
755
806
|
* ⚠️ SECURITY WARNING: The `sql` parameter is passed to the database driver without
|