@an5/adapters 0.2.3 → 0.2.5
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 +305 -283
- package/{typescript → dist}/browser.d.ts +1 -1
- package/{typescript → dist}/browser.js +2 -1
- package/{typescript → dist}/index.d.ts +2 -0
- package/{typescript → dist}/index.js +6 -1
- package/dotnet/An5Adapter.cs +275 -275
- package/dotnet/Base/Core.cs +82 -82
- package/dotnet/Mssql/MssqlEngine.cs +144 -144
- package/dotnet/Postgres/PostgresEngine.cs +121 -121
- package/package.json +21 -11
- package/python/an5_adapter.py +97 -97
- package/python/base/__init__.py +3 -3
- package/python/base/dialects.py +8 -8
- package/python/base/metadata.py +10 -10
- package/python/base/sql.py +121 -121
- package/python/mssql/__init__.py +1 -1
- package/python/mssql/provider.py +62 -62
- package/python/postgres/__init__.py +1 -1
- package/python/postgres/provider.py +15 -15
- package/python/table_client.py +216 -216
- package/typescript/src/an5Adapter.ts +505 -505
- package/typescript/src/base/index.ts +3 -3
- package/typescript/src/base/metadata.ts +28 -28
- package/typescript/src/base/sql.ts +98 -98
- package/typescript/src/base/types.ts +24 -24
- package/typescript/src/browser.ts +6 -6
- package/typescript/src/config.ts +95 -95
- package/typescript/src/googlesheets/adapter.ts +259 -259
- package/typescript/src/googlesheets/an5SheetsAdapter.ts +3 -3
- package/typescript/src/googlesheets/config.ts +47 -47
- package/typescript/src/googlesheets/helpers.ts +143 -143
- package/typescript/src/googlesheets/index.ts +3 -3
- package/typescript/src/googlesheets/parseConnectionString.ts +46 -46
- package/typescript/src/googlesheets/retry.ts +126 -126
- package/typescript/src/googlesheets/sqlExecutor.ts +301 -301
- package/typescript/src/googlesheets/tableClient.ts +455 -455
- package/typescript/src/googlesheets/types.ts +6 -6
- package/typescript/src/index.ts +28 -19
- package/typescript/src/mssql/engine.ts +132 -132
- package/typescript/src/mssql/index.ts +1 -1
- package/typescript/src/mysql/engine.ts +80 -80
- package/typescript/src/mysql/index.ts +1 -1
- package/typescript/src/postgres/engine.ts +87 -87
- package/typescript/src/postgres/index.ts +1 -1
- package/typescript/src/sqlite/engine.ts +55 -55
- package/typescript/src/sqlite/index.ts +1 -1
- /package/{typescript → dist}/an5Adapter.d.ts +0 -0
- /package/{typescript → dist}/an5Adapter.js +0 -0
- /package/{typescript → dist}/base/index.d.ts +0 -0
- /package/{typescript → dist}/base/index.js +0 -0
- /package/{typescript → dist}/base/metadata.d.ts +0 -0
- /package/{typescript → dist}/base/metadata.js +0 -0
- /package/{typescript → dist}/base/sql.d.ts +0 -0
- /package/{typescript → dist}/base/sql.js +0 -0
- /package/{typescript → dist}/base/types.d.ts +0 -0
- /package/{typescript → dist}/base/types.js +0 -0
- /package/{typescript → dist}/base/uuid.d.ts +0 -0
- /package/{typescript → dist}/base/uuid.js +0 -0
- /package/{typescript → dist}/config.d.ts +0 -0
- /package/{typescript → dist}/config.js +0 -0
- /package/{typescript → dist}/googlesheets/adapter.d.ts +0 -0
- /package/{typescript → dist}/googlesheets/adapter.js +0 -0
- /package/{typescript → dist}/googlesheets/an5SheetsAdapter.d.ts +0 -0
- /package/{typescript → dist}/googlesheets/an5SheetsAdapter.js +0 -0
- /package/{typescript → dist}/googlesheets/config.d.ts +0 -0
- /package/{typescript → dist}/googlesheets/config.js +0 -0
- /package/{typescript → dist}/googlesheets/helpers.d.ts +0 -0
- /package/{typescript → dist}/googlesheets/helpers.js +0 -0
- /package/{typescript → dist}/googlesheets/index.d.ts +0 -0
- /package/{typescript → dist}/googlesheets/index.js +0 -0
- /package/{typescript → dist}/googlesheets/parseConnectionString.d.ts +0 -0
- /package/{typescript → dist}/googlesheets/parseConnectionString.js +0 -0
- /package/{typescript → dist}/googlesheets/retry.d.ts +0 -0
- /package/{typescript → dist}/googlesheets/retry.js +0 -0
- /package/{typescript → dist}/googlesheets/sqlExecutor.d.ts +0 -0
- /package/{typescript → dist}/googlesheets/sqlExecutor.js +0 -0
- /package/{typescript → dist}/googlesheets/tableClient.d.ts +0 -0
- /package/{typescript → dist}/googlesheets/tableClient.js +0 -0
- /package/{typescript → dist}/googlesheets/types.d.ts +0 -0
- /package/{typescript → dist}/googlesheets/types.js +0 -0
- /package/{typescript → dist}/mssql/engine.d.ts +0 -0
- /package/{typescript → dist}/mssql/engine.js +0 -0
- /package/{typescript → dist}/mssql/index.d.ts +0 -0
- /package/{typescript → dist}/mssql/index.js +0 -0
- /package/{typescript → dist}/mysql/engine.d.ts +0 -0
- /package/{typescript → dist}/mysql/engine.js +0 -0
- /package/{typescript → dist}/mysql/index.d.ts +0 -0
- /package/{typescript → dist}/mysql/index.js +0 -0
- /package/{typescript → dist}/postgres/engine.d.ts +0 -0
- /package/{typescript → dist}/postgres/engine.js +0 -0
- /package/{typescript → dist}/postgres/index.d.ts +0 -0
- /package/{typescript → dist}/postgres/index.js +0 -0
- /package/{typescript → dist}/sqlite/engine.d.ts +0 -0
- /package/{typescript → dist}/sqlite/engine.js +0 -0
- /package/{typescript → dist}/sqlite/index.d.ts +0 -0
- /package/{typescript → dist}/sqlite/index.js +0 -0
|
@@ -1,301 +1,301 @@
|
|
|
1
|
-
import type { An5SheetsAdapter } from './adapter';
|
|
2
|
-
import { esc } from './helpers';
|
|
3
|
-
import { withRetry } from './retry';
|
|
4
|
-
|
|
5
|
-
// ─── SQL query executor ───────────────────────────────────────────────────────
|
|
6
|
-
|
|
7
|
-
export type ExecResult = any[] | number;
|
|
8
|
-
|
|
9
|
-
export async function execQuery(adapter: An5SheetsAdapter, query: string, params: Record<string, any> | undefined, _modelMap?: Record<string, string>, _sheetId?: string): Promise<ExecResult> {
|
|
10
|
-
const q = query.trim();
|
|
11
|
-
const upper = q.toUpperCase();
|
|
12
|
-
|
|
13
|
-
if (upper.startsWith('SELECT')) return execSelect(adapter, q, params);
|
|
14
|
-
if (upper.startsWith('INSERT')) return execInsert(adapter, q, params);
|
|
15
|
-
if (upper.startsWith('UPDATE')) return execUpdate(adapter, q, params);
|
|
16
|
-
if (upper.startsWith('DELETE')) return execDelete(adapter, q, params);
|
|
17
|
-
|
|
18
|
-
// Fallback: treat as raw range or sheet name
|
|
19
|
-
const range = q.includes('!') ? q : esc(q) + '!A:ZZ';
|
|
20
|
-
const raw = await adapter.readRange(range);
|
|
21
|
-
return raw;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function substParams(sql: string, params?: Record<string, any>): { text: string; values: any[] } {
|
|
25
|
-
if (!params) return { text: sql, values: [] };
|
|
26
|
-
const values: any[] = [];
|
|
27
|
-
const text = sql.replace(/@(\w+)/g, (_, name) => {
|
|
28
|
-
if (params[name] !== undefined) {
|
|
29
|
-
const idx = values.length;
|
|
30
|
-
values.push(params[name]);
|
|
31
|
-
return `?${idx}`;
|
|
32
|
-
}
|
|
33
|
-
return `@${name}`;
|
|
34
|
-
});
|
|
35
|
-
return { text, values };
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function matchRowSQL(row: Record<string, any>, whereText: string, values: any[]): boolean {
|
|
39
|
-
if (!whereText) return true;
|
|
40
|
-
const trimmed = whereText.trim();
|
|
41
|
-
|
|
42
|
-
// Handle simple AND conditions: field = ?0 AND field2 = ?1
|
|
43
|
-
const parts = trimmed.split(/\s+AND\s+/i);
|
|
44
|
-
for (const part of parts) {
|
|
45
|
-
const m = part.match(/^(.+?)\s*(=|!=|<>|>|<|>=|<=|LIKE|CONTAINS)\s*(.+)$/i);
|
|
46
|
-
if (!m) continue;
|
|
47
|
-
const field = m[1].trim().replace(/[[\]]/g, '');
|
|
48
|
-
const op = m[2].toUpperCase();
|
|
49
|
-
const valRef = m[3].trim();
|
|
50
|
-
|
|
51
|
-
const val = valRef.startsWith('?') ? values[parseInt(valRef.slice(1))] : valRef.replace(/^['"]|['"]$/g, '');
|
|
52
|
-
const cell = row[field];
|
|
53
|
-
|
|
54
|
-
switch (op) {
|
|
55
|
-
case '=': if (cell != val) return false; break;
|
|
56
|
-
case '!=': case '<>': if (cell == val) return false; break;
|
|
57
|
-
case '>': if (!(Number(cell) > Number(val))) return false; break;
|
|
58
|
-
case '<': if (!(Number(cell) < Number(val))) return false; break;
|
|
59
|
-
case '>=': if (!(Number(cell) >= Number(val))) return false; break;
|
|
60
|
-
case '<=': if (!(Number(cell) <= Number(val))) return false; break;
|
|
61
|
-
case 'LIKE': if (!cell || !String(cell).toLowerCase().includes(String(val).toLowerCase().replace(/%/g, ''))) return false; break;
|
|
62
|
-
case 'CONTAINS': if (!cell || !String(cell).includes(String(val))) return false; break;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
return true;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export function extractColsAndVals(sql: string, params?: Record<string, any>): { cols: string[]; vals: any[] } {
|
|
69
|
-
const cols: string[] = [];
|
|
70
|
-
const vals: any[] = [];
|
|
71
|
-
|
|
72
|
-
const colMatch = sql.match(/\(([^)]+)\)\s*VALUES/i);
|
|
73
|
-
if (colMatch) {
|
|
74
|
-
const rawCols = colMatch[1].split(',').map(c => c.trim().replace(/[[\]]/g, ''));
|
|
75
|
-
// Find actual values
|
|
76
|
-
const valMatch = sql.substring(colMatch.index! + colMatch[0].length).match(/\(([^)]+)\)/);
|
|
77
|
-
if (valMatch) {
|
|
78
|
-
const rawVals = valMatch[1].split(',').map(v => v.trim());
|
|
79
|
-
for (let i = 0; i < rawCols.length; i++) {
|
|
80
|
-
cols.push(rawCols[i]);
|
|
81
|
-
const rv = rawVals[i];
|
|
82
|
-
if (rv.startsWith('@') && params && params[rv.slice(1)] !== undefined) {
|
|
83
|
-
vals.push(params[rv.slice(1)]);
|
|
84
|
-
} else {
|
|
85
|
-
vals.push(rv.replace(/^['"]|['"]$/g, ''));
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
return { cols, vals };
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
async function execSelect(adapter: An5SheetsAdapter, query: string, params?: Record<string, any>): Promise<any[]> {
|
|
94
|
-
const m = query.match(/SELECT\s+(.+?)\s+FROM\s+(?:'([^']+)'|"([^"]+)"|(\S+))\s*(?:WHERE\s+(.+))?$/i);
|
|
95
|
-
if (!m) {
|
|
96
|
-
// Try reading as raw range
|
|
97
|
-
const raw = await adapter.readRange(esc(query.replace(/^SELECT\s+\*\s+FROM\s+/i, '').trim()) + '!A:ZZ');
|
|
98
|
-
const headers = raw[0] || [];
|
|
99
|
-
return raw.slice(1).map(r => {
|
|
100
|
-
const obj: any = {};
|
|
101
|
-
headers.forEach((h: any, i: number) => { obj[h] = r[i] ?? null; });
|
|
102
|
-
return obj;
|
|
103
|
-
});
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const sheetName = m[2] || m[3] || m[4];
|
|
107
|
-
const whereClause = m[5];
|
|
108
|
-
|
|
109
|
-
const raw = await adapter.readRange(esc(sheetName) + '!A:ZZ');
|
|
110
|
-
if (raw.length < 1) return [];
|
|
111
|
-
|
|
112
|
-
const headers = raw[0] as string[];
|
|
113
|
-
const fieldList = m[1].trim();
|
|
114
|
-
const selectAll = fieldList === '*';
|
|
115
|
-
|
|
116
|
-
const rows = raw.slice(1).map(row => {
|
|
117
|
-
const obj: any = {};
|
|
118
|
-
headers.forEach((h, i) => { obj[h] = row[i] ?? null; });
|
|
119
|
-
return obj;
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
if (!whereClause) return selectAll ? rows : rows.map(r => pickFields(r, fieldList, headers));
|
|
123
|
-
const { text, values } = substParams(query, params);
|
|
124
|
-
// Extract just the WHERE part
|
|
125
|
-
const whereParts = query.match(/WHERE\s+(.+)$/i);
|
|
126
|
-
const whereText = whereParts ? whereParts[1] : '';
|
|
127
|
-
const { text: whereSubst, values: whereVals } = substParams(whereText, params);
|
|
128
|
-
|
|
129
|
-
const result = rows.filter(r => matchRowSQL(r, whereSubst, whereVals));
|
|
130
|
-
return selectAll ? result : result.map(r => pickFields(r, fieldList, headers));
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function pickFields(row: any, fieldList: string, headers: string[]): any {
|
|
134
|
-
const fields = fieldList.split(',').map(f => f.trim().replace(/[[\]]/g, ''));
|
|
135
|
-
const obj: any = {};
|
|
136
|
-
fields.forEach(f => { obj[f] = row[f]; });
|
|
137
|
-
return obj;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
async function execInsert(adapter: An5SheetsAdapter, query: string, params?: Record<string, any>): Promise<number> {
|
|
141
|
-
const m = query.match(/INSERT\s+INTO\s+(?:'([^']+)'|"([^"]+)"|(\S+))\s*(.*)/i);
|
|
142
|
-
if (!m) throw new Error('Invalid INSERT syntax');
|
|
143
|
-
|
|
144
|
-
const sheetName = m[1] || m[2] || m[3];
|
|
145
|
-
const { cols, vals } = extractColsAndVals(query, params);
|
|
146
|
-
if (cols.length === 0) throw new Error('Cannot extract columns from INSERT');
|
|
147
|
-
|
|
148
|
-
// Ensure headers exist
|
|
149
|
-
const raw = await adapter.readRange(esc(sheetName) + '!A1:ZZ1');
|
|
150
|
-
const existingHeaders = (raw[0] || []) as string[];
|
|
151
|
-
let headers = existingHeaders.length > 0 ? existingHeaders : cols;
|
|
152
|
-
|
|
153
|
-
if (existingHeaders.length === 0) {
|
|
154
|
-
// Write headers first
|
|
155
|
-
const api = await adapter.getSheets();
|
|
156
|
-
await withRetry(() =>
|
|
157
|
-
api.spreadsheets.values.update({
|
|
158
|
-
spreadsheetId: adapter.config.spreadsheetId,
|
|
159
|
-
range: esc(sheetName) + '!A1',
|
|
160
|
-
valueInputOption: 'RAW',
|
|
161
|
-
requestBody: { values: [cols] },
|
|
162
|
-
})
|
|
163
|
-
);
|
|
164
|
-
headers = cols;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const rowData: any = {};
|
|
168
|
-
cols.forEach((c, i) => { rowData[c] = vals[i]; });
|
|
169
|
-
for (const h of headers) { if (rowData[h] === undefined) rowData[h] = null; }
|
|
170
|
-
|
|
171
|
-
const rowVals = headers.map(h => {
|
|
172
|
-
const v = rowData[h];
|
|
173
|
-
if (v === null || v === undefined) return '';
|
|
174
|
-
if (v instanceof Date) return v.toISOString();
|
|
175
|
-
return String(v);
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
await adapter.appendRange(esc(sheetName) + '!A:A', [rowVals]);
|
|
179
|
-
return 1;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
async function execUpdate(adapter: An5SheetsAdapter, query: string, params?: Record<string, any>): Promise<number> {
|
|
183
|
-
const m = query.match(/UPDATE\s+(?:'([^']+)'|"([^"]+)"|(\S+))\s+SET\s+(.+?)(?:\s+WHERE\s+(.+))?$/i);
|
|
184
|
-
if (!m) throw new Error('Invalid UPDATE syntax');
|
|
185
|
-
|
|
186
|
-
const sheetName = m[1] || m[2] || m[3];
|
|
187
|
-
const setClause = m[4];
|
|
188
|
-
const whereClause = m[5];
|
|
189
|
-
|
|
190
|
-
// Parse SET clause
|
|
191
|
-
const setPairs = setClause.split(',').map(p => p.trim());
|
|
192
|
-
const setFields: string[] = [];
|
|
193
|
-
const setVals: any[] = [];
|
|
194
|
-
for (const pair of setPairs) {
|
|
195
|
-
const sm = pair.match(/^(\w+)\s*=\s*(.+)$/i);
|
|
196
|
-
if (!sm) continue;
|
|
197
|
-
setFields.push(sm[1].replace(/[[\]]/g, ''));
|
|
198
|
-
const rv = sm[2].trim();
|
|
199
|
-
if (rv.startsWith('@') && params && params[rv.slice(1)] !== undefined) {
|
|
200
|
-
setVals.push(params[rv.slice(1)]);
|
|
201
|
-
} else {
|
|
202
|
-
setVals.push(rv.replace(/^['"]|['"]$/g, ''));
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Read data
|
|
207
|
-
const range = esc(sheetName) + '!A:ZZ';
|
|
208
|
-
const raw = await adapter.readRange(range);
|
|
209
|
-
if (raw.length < 1) return 0;
|
|
210
|
-
|
|
211
|
-
const headers = raw[0] as string[];
|
|
212
|
-
const rows = raw.slice(1);
|
|
213
|
-
|
|
214
|
-
// Determine which rows to update
|
|
215
|
-
const { text: whereSubst, values: whereVals } = substParams(whereClause || '', params);
|
|
216
|
-
let count = 0;
|
|
217
|
-
for (let i = 0; i < rows.length; i++) {
|
|
218
|
-
const row: any = {};
|
|
219
|
-
headers.forEach((h, j) => { row[h] = rows[i][j] ?? null; });
|
|
220
|
-
row.__row = i + 2;
|
|
221
|
-
|
|
222
|
-
if (whereClause && !matchRowSQL(row, whereSubst, whereVals)) continue;
|
|
223
|
-
|
|
224
|
-
// Apply SET values
|
|
225
|
-
setFields.forEach((f, idx) => { row[f] = setVals[idx]; });
|
|
226
|
-
const updated = headers.map(h => {
|
|
227
|
-
const v = row[h];
|
|
228
|
-
if (v === null || v === undefined) return '';
|
|
229
|
-
if (v instanceof Date) return v.toISOString();
|
|
230
|
-
return String(v);
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
await adapter.writeRange(esc(sheetName) + `!A${row.__row}`, [updated]);
|
|
234
|
-
count++;
|
|
235
|
-
}
|
|
236
|
-
return count;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
async function execDelete(adapter: An5SheetsAdapter, query: string, params?: Record<string, any>): Promise<number> {
|
|
240
|
-
const m = query.match(/DELETE\s+FROM\s+(?:'([^']+)'|"([^"]+)"|(\S+))\s*(?:WHERE\s+(.+))?$/i);
|
|
241
|
-
if (!m) throw new Error('Invalid DELETE syntax');
|
|
242
|
-
|
|
243
|
-
const sheetName = m[1] || m[2] || m[3];
|
|
244
|
-
const whereClause = m[4];
|
|
245
|
-
|
|
246
|
-
const meta = await adapter.getSheetMeta(sheetName);
|
|
247
|
-
if (!meta) return 0;
|
|
248
|
-
|
|
249
|
-
if (!whereClause) {
|
|
250
|
-
// Delete all rows (keep headers)
|
|
251
|
-
const api = await adapter.getSheets();
|
|
252
|
-
await withRetry(() =>
|
|
253
|
-
api.spreadsheets.batchUpdate({
|
|
254
|
-
spreadsheetId: adapter.config.spreadsheetId,
|
|
255
|
-
requestBody: {
|
|
256
|
-
requests: [{
|
|
257
|
-
deleteDimension: {
|
|
258
|
-
range: { sheetId: meta.sheetId, dimension: 'ROWS', startIndex: 1, endIndex: 999999 },
|
|
259
|
-
},
|
|
260
|
-
}],
|
|
261
|
-
},
|
|
262
|
-
})
|
|
263
|
-
);
|
|
264
|
-
return -1; // unknown count
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// Read and find matching rows
|
|
268
|
-
const raw = await adapter.readRange(esc(sheetName) + '!A:ZZ');
|
|
269
|
-
if (raw.length < 1) return 0;
|
|
270
|
-
|
|
271
|
-
const headers = raw[0] as string[];
|
|
272
|
-
const { text: whereSubst, values: whereVals } = substParams(whereClause, params);
|
|
273
|
-
|
|
274
|
-
const toDelete: number[] = [];
|
|
275
|
-
for (let i = 1; i < raw.length; i++) {
|
|
276
|
-
const row: any = {};
|
|
277
|
-
headers.forEach((h, j) => { row[h] = raw[i][j] ?? null; });
|
|
278
|
-
if (matchRowSQL(row, whereSubst, whereVals)) {
|
|
279
|
-
toDelete.push(i + 1);
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
// Delete from bottom to top
|
|
284
|
-
toDelete.sort((a, b) => b - a);
|
|
285
|
-
for (const rowIdx of toDelete) {
|
|
286
|
-
const api = await adapter.getSheets();
|
|
287
|
-
await withRetry(() =>
|
|
288
|
-
api.spreadsheets.batchUpdate({
|
|
289
|
-
spreadsheetId: adapter.config.spreadsheetId,
|
|
290
|
-
requestBody: {
|
|
291
|
-
requests: [{
|
|
292
|
-
deleteDimension: {
|
|
293
|
-
range: { sheetId: meta.sheetId, dimension: 'ROWS', startIndex: rowIdx - 1, endIndex: rowIdx },
|
|
294
|
-
},
|
|
295
|
-
}],
|
|
296
|
-
},
|
|
297
|
-
})
|
|
298
|
-
);
|
|
299
|
-
}
|
|
300
|
-
return toDelete.length;
|
|
301
|
-
}
|
|
1
|
+
import type { An5SheetsAdapter } from './adapter';
|
|
2
|
+
import { esc } from './helpers';
|
|
3
|
+
import { withRetry } from './retry';
|
|
4
|
+
|
|
5
|
+
// ─── SQL query executor ───────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export type ExecResult = any[] | number;
|
|
8
|
+
|
|
9
|
+
export async function execQuery(adapter: An5SheetsAdapter, query: string, params: Record<string, any> | undefined, _modelMap?: Record<string, string>, _sheetId?: string): Promise<ExecResult> {
|
|
10
|
+
const q = query.trim();
|
|
11
|
+
const upper = q.toUpperCase();
|
|
12
|
+
|
|
13
|
+
if (upper.startsWith('SELECT')) return execSelect(adapter, q, params);
|
|
14
|
+
if (upper.startsWith('INSERT')) return execInsert(adapter, q, params);
|
|
15
|
+
if (upper.startsWith('UPDATE')) return execUpdate(adapter, q, params);
|
|
16
|
+
if (upper.startsWith('DELETE')) return execDelete(adapter, q, params);
|
|
17
|
+
|
|
18
|
+
// Fallback: treat as raw range or sheet name
|
|
19
|
+
const range = q.includes('!') ? q : esc(q) + '!A:ZZ';
|
|
20
|
+
const raw = await adapter.readRange(range);
|
|
21
|
+
return raw;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function substParams(sql: string, params?: Record<string, any>): { text: string; values: any[] } {
|
|
25
|
+
if (!params) return { text: sql, values: [] };
|
|
26
|
+
const values: any[] = [];
|
|
27
|
+
const text = sql.replace(/@(\w+)/g, (_, name) => {
|
|
28
|
+
if (params[name] !== undefined) {
|
|
29
|
+
const idx = values.length;
|
|
30
|
+
values.push(params[name]);
|
|
31
|
+
return `?${idx}`;
|
|
32
|
+
}
|
|
33
|
+
return `@${name}`;
|
|
34
|
+
});
|
|
35
|
+
return { text, values };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function matchRowSQL(row: Record<string, any>, whereText: string, values: any[]): boolean {
|
|
39
|
+
if (!whereText) return true;
|
|
40
|
+
const trimmed = whereText.trim();
|
|
41
|
+
|
|
42
|
+
// Handle simple AND conditions: field = ?0 AND field2 = ?1
|
|
43
|
+
const parts = trimmed.split(/\s+AND\s+/i);
|
|
44
|
+
for (const part of parts) {
|
|
45
|
+
const m = part.match(/^(.+?)\s*(=|!=|<>|>|<|>=|<=|LIKE|CONTAINS)\s*(.+)$/i);
|
|
46
|
+
if (!m) continue;
|
|
47
|
+
const field = m[1].trim().replace(/[[\]]/g, '');
|
|
48
|
+
const op = m[2].toUpperCase();
|
|
49
|
+
const valRef = m[3].trim();
|
|
50
|
+
|
|
51
|
+
const val = valRef.startsWith('?') ? values[parseInt(valRef.slice(1))] : valRef.replace(/^['"]|['"]$/g, '');
|
|
52
|
+
const cell = row[field];
|
|
53
|
+
|
|
54
|
+
switch (op) {
|
|
55
|
+
case '=': if (cell != val) return false; break;
|
|
56
|
+
case '!=': case '<>': if (cell == val) return false; break;
|
|
57
|
+
case '>': if (!(Number(cell) > Number(val))) return false; break;
|
|
58
|
+
case '<': if (!(Number(cell) < Number(val))) return false; break;
|
|
59
|
+
case '>=': if (!(Number(cell) >= Number(val))) return false; break;
|
|
60
|
+
case '<=': if (!(Number(cell) <= Number(val))) return false; break;
|
|
61
|
+
case 'LIKE': if (!cell || !String(cell).toLowerCase().includes(String(val).toLowerCase().replace(/%/g, ''))) return false; break;
|
|
62
|
+
case 'CONTAINS': if (!cell || !String(cell).includes(String(val))) return false; break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function extractColsAndVals(sql: string, params?: Record<string, any>): { cols: string[]; vals: any[] } {
|
|
69
|
+
const cols: string[] = [];
|
|
70
|
+
const vals: any[] = [];
|
|
71
|
+
|
|
72
|
+
const colMatch = sql.match(/\(([^)]+)\)\s*VALUES/i);
|
|
73
|
+
if (colMatch) {
|
|
74
|
+
const rawCols = colMatch[1].split(',').map(c => c.trim().replace(/[[\]]/g, ''));
|
|
75
|
+
// Find actual values
|
|
76
|
+
const valMatch = sql.substring(colMatch.index! + colMatch[0].length).match(/\(([^)]+)\)/);
|
|
77
|
+
if (valMatch) {
|
|
78
|
+
const rawVals = valMatch[1].split(',').map(v => v.trim());
|
|
79
|
+
for (let i = 0; i < rawCols.length; i++) {
|
|
80
|
+
cols.push(rawCols[i]);
|
|
81
|
+
const rv = rawVals[i];
|
|
82
|
+
if (rv.startsWith('@') && params && params[rv.slice(1)] !== undefined) {
|
|
83
|
+
vals.push(params[rv.slice(1)]);
|
|
84
|
+
} else {
|
|
85
|
+
vals.push(rv.replace(/^['"]|['"]$/g, ''));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { cols, vals };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function execSelect(adapter: An5SheetsAdapter, query: string, params?: Record<string, any>): Promise<any[]> {
|
|
94
|
+
const m = query.match(/SELECT\s+(.+?)\s+FROM\s+(?:'([^']+)'|"([^"]+)"|(\S+))\s*(?:WHERE\s+(.+))?$/i);
|
|
95
|
+
if (!m) {
|
|
96
|
+
// Try reading as raw range
|
|
97
|
+
const raw = await adapter.readRange(esc(query.replace(/^SELECT\s+\*\s+FROM\s+/i, '').trim()) + '!A:ZZ');
|
|
98
|
+
const headers = raw[0] || [];
|
|
99
|
+
return raw.slice(1).map(r => {
|
|
100
|
+
const obj: any = {};
|
|
101
|
+
headers.forEach((h: any, i: number) => { obj[h] = r[i] ?? null; });
|
|
102
|
+
return obj;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const sheetName = m[2] || m[3] || m[4];
|
|
107
|
+
const whereClause = m[5];
|
|
108
|
+
|
|
109
|
+
const raw = await adapter.readRange(esc(sheetName) + '!A:ZZ');
|
|
110
|
+
if (raw.length < 1) return [];
|
|
111
|
+
|
|
112
|
+
const headers = raw[0] as string[];
|
|
113
|
+
const fieldList = m[1].trim();
|
|
114
|
+
const selectAll = fieldList === '*';
|
|
115
|
+
|
|
116
|
+
const rows = raw.slice(1).map(row => {
|
|
117
|
+
const obj: any = {};
|
|
118
|
+
headers.forEach((h, i) => { obj[h] = row[i] ?? null; });
|
|
119
|
+
return obj;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
if (!whereClause) return selectAll ? rows : rows.map(r => pickFields(r, fieldList, headers));
|
|
123
|
+
const { text, values } = substParams(query, params);
|
|
124
|
+
// Extract just the WHERE part
|
|
125
|
+
const whereParts = query.match(/WHERE\s+(.+)$/i);
|
|
126
|
+
const whereText = whereParts ? whereParts[1] : '';
|
|
127
|
+
const { text: whereSubst, values: whereVals } = substParams(whereText, params);
|
|
128
|
+
|
|
129
|
+
const result = rows.filter(r => matchRowSQL(r, whereSubst, whereVals));
|
|
130
|
+
return selectAll ? result : result.map(r => pickFields(r, fieldList, headers));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function pickFields(row: any, fieldList: string, headers: string[]): any {
|
|
134
|
+
const fields = fieldList.split(',').map(f => f.trim().replace(/[[\]]/g, ''));
|
|
135
|
+
const obj: any = {};
|
|
136
|
+
fields.forEach(f => { obj[f] = row[f]; });
|
|
137
|
+
return obj;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function execInsert(adapter: An5SheetsAdapter, query: string, params?: Record<string, any>): Promise<number> {
|
|
141
|
+
const m = query.match(/INSERT\s+INTO\s+(?:'([^']+)'|"([^"]+)"|(\S+))\s*(.*)/i);
|
|
142
|
+
if (!m) throw new Error('Invalid INSERT syntax');
|
|
143
|
+
|
|
144
|
+
const sheetName = m[1] || m[2] || m[3];
|
|
145
|
+
const { cols, vals } = extractColsAndVals(query, params);
|
|
146
|
+
if (cols.length === 0) throw new Error('Cannot extract columns from INSERT');
|
|
147
|
+
|
|
148
|
+
// Ensure headers exist
|
|
149
|
+
const raw = await adapter.readRange(esc(sheetName) + '!A1:ZZ1');
|
|
150
|
+
const existingHeaders = (raw[0] || []) as string[];
|
|
151
|
+
let headers = existingHeaders.length > 0 ? existingHeaders : cols;
|
|
152
|
+
|
|
153
|
+
if (existingHeaders.length === 0) {
|
|
154
|
+
// Write headers first
|
|
155
|
+
const api = await adapter.getSheets();
|
|
156
|
+
await withRetry(() =>
|
|
157
|
+
api.spreadsheets.values.update({
|
|
158
|
+
spreadsheetId: adapter.config.spreadsheetId,
|
|
159
|
+
range: esc(sheetName) + '!A1',
|
|
160
|
+
valueInputOption: 'RAW',
|
|
161
|
+
requestBody: { values: [cols] },
|
|
162
|
+
})
|
|
163
|
+
);
|
|
164
|
+
headers = cols;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const rowData: any = {};
|
|
168
|
+
cols.forEach((c, i) => { rowData[c] = vals[i]; });
|
|
169
|
+
for (const h of headers) { if (rowData[h] === undefined) rowData[h] = null; }
|
|
170
|
+
|
|
171
|
+
const rowVals = headers.map(h => {
|
|
172
|
+
const v = rowData[h];
|
|
173
|
+
if (v === null || v === undefined) return '';
|
|
174
|
+
if (v instanceof Date) return v.toISOString();
|
|
175
|
+
return String(v);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
await adapter.appendRange(esc(sheetName) + '!A:A', [rowVals]);
|
|
179
|
+
return 1;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function execUpdate(adapter: An5SheetsAdapter, query: string, params?: Record<string, any>): Promise<number> {
|
|
183
|
+
const m = query.match(/UPDATE\s+(?:'([^']+)'|"([^"]+)"|(\S+))\s+SET\s+(.+?)(?:\s+WHERE\s+(.+))?$/i);
|
|
184
|
+
if (!m) throw new Error('Invalid UPDATE syntax');
|
|
185
|
+
|
|
186
|
+
const sheetName = m[1] || m[2] || m[3];
|
|
187
|
+
const setClause = m[4];
|
|
188
|
+
const whereClause = m[5];
|
|
189
|
+
|
|
190
|
+
// Parse SET clause
|
|
191
|
+
const setPairs = setClause.split(',').map(p => p.trim());
|
|
192
|
+
const setFields: string[] = [];
|
|
193
|
+
const setVals: any[] = [];
|
|
194
|
+
for (const pair of setPairs) {
|
|
195
|
+
const sm = pair.match(/^(\w+)\s*=\s*(.+)$/i);
|
|
196
|
+
if (!sm) continue;
|
|
197
|
+
setFields.push(sm[1].replace(/[[\]]/g, ''));
|
|
198
|
+
const rv = sm[2].trim();
|
|
199
|
+
if (rv.startsWith('@') && params && params[rv.slice(1)] !== undefined) {
|
|
200
|
+
setVals.push(params[rv.slice(1)]);
|
|
201
|
+
} else {
|
|
202
|
+
setVals.push(rv.replace(/^['"]|['"]$/g, ''));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Read data
|
|
207
|
+
const range = esc(sheetName) + '!A:ZZ';
|
|
208
|
+
const raw = await adapter.readRange(range);
|
|
209
|
+
if (raw.length < 1) return 0;
|
|
210
|
+
|
|
211
|
+
const headers = raw[0] as string[];
|
|
212
|
+
const rows = raw.slice(1);
|
|
213
|
+
|
|
214
|
+
// Determine which rows to update
|
|
215
|
+
const { text: whereSubst, values: whereVals } = substParams(whereClause || '', params);
|
|
216
|
+
let count = 0;
|
|
217
|
+
for (let i = 0; i < rows.length; i++) {
|
|
218
|
+
const row: any = {};
|
|
219
|
+
headers.forEach((h, j) => { row[h] = rows[i][j] ?? null; });
|
|
220
|
+
row.__row = i + 2;
|
|
221
|
+
|
|
222
|
+
if (whereClause && !matchRowSQL(row, whereSubst, whereVals)) continue;
|
|
223
|
+
|
|
224
|
+
// Apply SET values
|
|
225
|
+
setFields.forEach((f, idx) => { row[f] = setVals[idx]; });
|
|
226
|
+
const updated = headers.map(h => {
|
|
227
|
+
const v = row[h];
|
|
228
|
+
if (v === null || v === undefined) return '';
|
|
229
|
+
if (v instanceof Date) return v.toISOString();
|
|
230
|
+
return String(v);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
await adapter.writeRange(esc(sheetName) + `!A${row.__row}`, [updated]);
|
|
234
|
+
count++;
|
|
235
|
+
}
|
|
236
|
+
return count;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function execDelete(adapter: An5SheetsAdapter, query: string, params?: Record<string, any>): Promise<number> {
|
|
240
|
+
const m = query.match(/DELETE\s+FROM\s+(?:'([^']+)'|"([^"]+)"|(\S+))\s*(?:WHERE\s+(.+))?$/i);
|
|
241
|
+
if (!m) throw new Error('Invalid DELETE syntax');
|
|
242
|
+
|
|
243
|
+
const sheetName = m[1] || m[2] || m[3];
|
|
244
|
+
const whereClause = m[4];
|
|
245
|
+
|
|
246
|
+
const meta = await adapter.getSheetMeta(sheetName);
|
|
247
|
+
if (!meta) return 0;
|
|
248
|
+
|
|
249
|
+
if (!whereClause) {
|
|
250
|
+
// Delete all rows (keep headers)
|
|
251
|
+
const api = await adapter.getSheets();
|
|
252
|
+
await withRetry(() =>
|
|
253
|
+
api.spreadsheets.batchUpdate({
|
|
254
|
+
spreadsheetId: adapter.config.spreadsheetId,
|
|
255
|
+
requestBody: {
|
|
256
|
+
requests: [{
|
|
257
|
+
deleteDimension: {
|
|
258
|
+
range: { sheetId: meta.sheetId, dimension: 'ROWS', startIndex: 1, endIndex: 999999 },
|
|
259
|
+
},
|
|
260
|
+
}],
|
|
261
|
+
},
|
|
262
|
+
})
|
|
263
|
+
);
|
|
264
|
+
return -1; // unknown count
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Read and find matching rows
|
|
268
|
+
const raw = await adapter.readRange(esc(sheetName) + '!A:ZZ');
|
|
269
|
+
if (raw.length < 1) return 0;
|
|
270
|
+
|
|
271
|
+
const headers = raw[0] as string[];
|
|
272
|
+
const { text: whereSubst, values: whereVals } = substParams(whereClause, params);
|
|
273
|
+
|
|
274
|
+
const toDelete: number[] = [];
|
|
275
|
+
for (let i = 1; i < raw.length; i++) {
|
|
276
|
+
const row: any = {};
|
|
277
|
+
headers.forEach((h, j) => { row[h] = raw[i][j] ?? null; });
|
|
278
|
+
if (matchRowSQL(row, whereSubst, whereVals)) {
|
|
279
|
+
toDelete.push(i + 1);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Delete from bottom to top
|
|
284
|
+
toDelete.sort((a, b) => b - a);
|
|
285
|
+
for (const rowIdx of toDelete) {
|
|
286
|
+
const api = await adapter.getSheets();
|
|
287
|
+
await withRetry(() =>
|
|
288
|
+
api.spreadsheets.batchUpdate({
|
|
289
|
+
spreadsheetId: adapter.config.spreadsheetId,
|
|
290
|
+
requestBody: {
|
|
291
|
+
requests: [{
|
|
292
|
+
deleteDimension: {
|
|
293
|
+
range: { sheetId: meta.sheetId, dimension: 'ROWS', startIndex: rowIdx - 1, endIndex: rowIdx },
|
|
294
|
+
},
|
|
295
|
+
}],
|
|
296
|
+
},
|
|
297
|
+
})
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
return toDelete.length;
|
|
301
|
+
}
|