@an5/adapters 0.2.9 → 0.2.11
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 +30 -0
- package/dist/an5Adapter.js +54 -31
- package/dist/base/metadata.d.ts +2 -0
- package/dist/base/metadata.js +10 -0
- package/dist/base/sql.d.ts +2 -0
- package/dist/base/sql.js +40 -40
- package/dist/googlesheets/adapter.js +16 -8
- package/dist/googlesheets/config.d.ts +13 -0
- package/dist/googlesheets/config.js +5 -1
- package/dist/googlesheets/helpers.js +13 -8
- package/dist/googlesheets/oauth.d.ts +3 -0
- package/dist/googlesheets/oauth.js +43 -0
- package/dist/googlesheets/parseConnectionString.js +12 -0
- package/dist/googlesheets/tableClient.js +5 -23
- package/dist/sqlite/engine.js +2 -1
- package/dotnet/Base/Core.cs +14 -24
- package/golang/base/where.go +32 -49
- package/golang/base/where_test.go +57 -0
- package/package.json +19 -6
- package/python/base/sql.py +6 -7
- package/rust/src/adapter.rs +2 -0
- package/rust/src/base/dialect.rs +38 -9
- package/rust/src/base/metadata.rs +4 -0
- package/rust/src/base/where_builder.rs +9 -28
- package/typescript/src/an5Adapter.ts +56 -30
- package/typescript/src/base/metadata.ts +8 -0
- package/typescript/src/base/sql.ts +38 -30
- package/typescript/src/googlesheets/adapter.ts +17 -8
- package/typescript/src/googlesheets/config.ts +10 -1
- package/typescript/src/googlesheets/helpers.ts +11 -8
- package/typescript/src/googlesheets/oauth.ts +31 -0
- package/typescript/src/googlesheets/parseConnectionString.ts +8 -0
- package/typescript/src/googlesheets/tableClient.ts +5 -19
- package/typescript/src/sqlite/engine.ts +2 -1
|
@@ -26,10 +26,11 @@ import {
|
|
|
26
26
|
type NBaseAdapterConfig,
|
|
27
27
|
type NBaseVectorClient,
|
|
28
28
|
} from './nbase';
|
|
29
|
-
import { buildOrderBy, parseWhere, quote } from './base/sql';
|
|
29
|
+
import { bindParam, buildOrderBy, parseWhere, quote } from './base/sql';
|
|
30
30
|
import {
|
|
31
31
|
createAdapterProxy,
|
|
32
32
|
getFieldsForModel,
|
|
33
|
+
resolveIdField,
|
|
33
34
|
getModelToTable,
|
|
34
35
|
getRelationMap,
|
|
35
36
|
getRelationsForModel,
|
|
@@ -51,39 +52,56 @@ function sanitizeParamName(name: string): string {
|
|
|
51
52
|
return /^[A-Za-z]/.test(cleaned) ? cleaned : `p_${cleaned}`;
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
/** Match only unique/primary-key violations, never general constraint errors. */
|
|
56
|
+
function isDuplicateKeyError(error: unknown, dialect: Dialect): boolean {
|
|
57
|
+
if (!error || typeof error !== 'object') return false;
|
|
58
|
+
const e = error as any;
|
|
59
|
+
if (dialect === 'postgres') return e.code === '23505';
|
|
60
|
+
if (dialect === 'mysql') return e.code === 'ER_DUP_ENTRY' || e.errno === 1062;
|
|
61
|
+
if (dialect === 'sqlite') {
|
|
62
|
+
return e.code === 'SQLITE_CONSTRAINT_UNIQUE' || e.code === 'SQLITE_CONSTRAINT_PRIMARYKEY'
|
|
63
|
+
|| e.errcode === 2067 || e.errcode === 1555;
|
|
64
|
+
}
|
|
65
|
+
if (dialect === 'mssql') {
|
|
66
|
+
const number = e.number ?? e.originalError?.info?.number;
|
|
67
|
+
return number === 2601 || number === 2627;
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
54
72
|
function appendUpdateSet(sets: string[], params: Record<string, any>, col: string, val: any, dialect: Dialect): void {
|
|
55
73
|
const quoted = quote(col, dialect);
|
|
56
74
|
const safeCol = sanitizeParamName(col);
|
|
57
75
|
if (val && typeof val === 'object' && !(val instanceof Date)) {
|
|
58
76
|
if (val.increment !== undefined) {
|
|
59
|
-
|
|
60
|
-
|
|
77
|
+
const p = bindParam(params, `s_${safeCol}_inc`, val.increment);
|
|
78
|
+
sets.push(`${quoted} = ${quoted} + @${p}`);
|
|
61
79
|
return;
|
|
62
80
|
}
|
|
63
81
|
if (val.decrement !== undefined) {
|
|
64
|
-
|
|
65
|
-
|
|
82
|
+
const p = bindParam(params, `s_${safeCol}_dec`, val.decrement);
|
|
83
|
+
sets.push(`${quoted} = ${quoted} - @${p}`);
|
|
66
84
|
return;
|
|
67
85
|
}
|
|
68
86
|
if (val.multiply !== undefined) {
|
|
69
|
-
|
|
70
|
-
|
|
87
|
+
const p = bindParam(params, `s_${safeCol}_mul`, val.multiply);
|
|
88
|
+
sets.push(`${quoted} = ${quoted} * @${p}`);
|
|
71
89
|
return;
|
|
72
90
|
}
|
|
73
91
|
if (val.divide !== undefined) {
|
|
74
|
-
|
|
75
|
-
|
|
92
|
+
const p = bindParam(params, `s_${safeCol}_div`, val.divide);
|
|
93
|
+
sets.push(`${quoted} = ${quoted} / @${p}`);
|
|
76
94
|
return;
|
|
77
95
|
}
|
|
78
96
|
if (val.set !== undefined) {
|
|
79
|
-
|
|
80
|
-
|
|
97
|
+
const p = bindParam(params, `s_${safeCol}_set`, val.set);
|
|
98
|
+
sets.push(`${quoted} = @${p}`);
|
|
81
99
|
return;
|
|
82
100
|
}
|
|
83
101
|
}
|
|
84
102
|
|
|
85
|
-
|
|
86
|
-
|
|
103
|
+
const p = bindParam(params, `s_${safeCol}`, val);
|
|
104
|
+
sets.push(`${quoted} = @${p}`);
|
|
87
105
|
}
|
|
88
106
|
|
|
89
107
|
function selectedAggregateFields(fields: any): string[] {
|
|
@@ -317,7 +335,8 @@ export class An5Adapter {
|
|
|
317
335
|
} else if (forDialect.startsWith('mysql://') || forDialect.startsWith('mariadb://')) {
|
|
318
336
|
this._engineType = 'mysql';
|
|
319
337
|
} else if (
|
|
320
|
-
forDialect
|
|
338
|
+
forDialect === ':memory:'
|
|
339
|
+
|| forDialect.startsWith('sqlite:')
|
|
321
340
|
|| SQLITE_FILE_SUFFIXES.some((suffix) => forDialect.endsWith(suffix))
|
|
322
341
|
) {
|
|
323
342
|
this._engineType = 'sqlite';
|
|
@@ -788,7 +807,8 @@ export class AdapterTableClient<T = any> {
|
|
|
788
807
|
query = `SELECT ${cols} FROM ${this.tableName}`;
|
|
789
808
|
if (whereSql) query += ` WHERE ${whereSql}`;
|
|
790
809
|
if (orderSql) query += ` ${orderSql}`;
|
|
791
|
-
|
|
810
|
+
const limit = take ?? (this.dialect === 'sqlite' ? '-1' : this.dialect === 'mysql' ? '18446744073709551615' : 'ALL');
|
|
811
|
+
query += ` LIMIT ${limit} OFFSET ${skip}`;
|
|
792
812
|
} else {
|
|
793
813
|
// mssql requires ORDER BY before OFFSET/FETCH
|
|
794
814
|
query = `SELECT ${cols} FROM ${this.tableName}${this.nolock}`;
|
|
@@ -853,9 +873,7 @@ export class AdapterTableClient<T = any> {
|
|
|
853
873
|
}
|
|
854
874
|
|
|
855
875
|
const fields = getFieldsForModel(this.modelName);
|
|
856
|
-
const idFieldName =
|
|
857
|
-
? 'id'
|
|
858
|
-
: Object.keys(fields).find((name) => name.endsWith('_id') || name.endsWith('Id') || name.toLowerCase() === 'id');
|
|
876
|
+
const idFieldName = resolveIdField(fields);
|
|
859
877
|
|
|
860
878
|
const data: any = { ...rawData };
|
|
861
879
|
if (idFieldName) {
|
|
@@ -917,8 +935,20 @@ export class AdapterTableClient<T = any> {
|
|
|
917
935
|
if (firstRow === undefined) return { count: 0 };
|
|
918
936
|
const firstCols = Object.keys(firstRow).filter(k => (firstRow as any)[k] !== undefined);
|
|
919
937
|
|
|
920
|
-
|
|
921
|
-
|
|
938
|
+
const sameColumns = args.data.every(row => {
|
|
939
|
+
const cols = Object.keys(row).filter(k => (row as any)[k] !== undefined);
|
|
940
|
+
return cols.length === firstCols.length && firstCols.every(col => cols.includes(col));
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
const fields = getFieldsForModel(this.modelName);
|
|
944
|
+
const idFieldName = resolveIdField(fields);
|
|
945
|
+
const idDef = idFieldName ? fields[idFieldName] : undefined;
|
|
946
|
+
const idType = typeof idDef === 'string' ? idDef : (idDef?.ts || idDef?.sql || idDef?.type || '');
|
|
947
|
+
const generatesId = ['string', 'uuid', 'uniqueidentifier', 'nvarchar', 'varchar', 'text'].includes(String(idType).toLowerCase());
|
|
948
|
+
const needsGeneratedId = idFieldName && generatesId && args.data.some(row => !(row as any)[idFieldName]);
|
|
949
|
+
|
|
950
|
+
// Rows needing generated IDs follow create's default handling.
|
|
951
|
+
if (firstCols.length > 0 && sameColumns && !needsGeneratedId && !args.skipDuplicates) {
|
|
922
952
|
const params: Record<string, any> = {};
|
|
923
953
|
const rowPlaceholders: string[] = [];
|
|
924
954
|
|
|
@@ -933,19 +963,15 @@ export class AdapterTableClient<T = any> {
|
|
|
933
963
|
}
|
|
934
964
|
|
|
935
965
|
const query = `INSERT INTO ${this.tableName} (${firstCols.map(c => quote(c, this.dialect)).join(', ')}) VALUES ${rowPlaceholders.join(', ')}`;
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
return { count: args.data.length };
|
|
939
|
-
} catch {
|
|
940
|
-
// fallback below if bulk fails (e.g. mixed column sets)
|
|
941
|
-
}
|
|
966
|
+
await this.doExecuteRaw(query, params);
|
|
967
|
+
return { count: args.data.length };
|
|
942
968
|
}
|
|
943
969
|
|
|
944
970
|
// Row-by-row fallback
|
|
945
971
|
let count = 0;
|
|
946
972
|
for (const row of args.data) {
|
|
947
973
|
try { await this.create({ data: row }); count++; }
|
|
948
|
-
catch (e) { if (!args.skipDuplicates) throw e; }
|
|
974
|
+
catch (e) { if (!args.skipDuplicates || !isDuplicateKeyError(e, this.dialect)) throw e; }
|
|
949
975
|
}
|
|
950
976
|
return { count };
|
|
951
977
|
}
|
|
@@ -1131,7 +1157,7 @@ export class AdapterTableClient<T = any> {
|
|
|
1131
1157
|
|
|
1132
1158
|
async groupBy(args: any): Promise<any[]> {
|
|
1133
1159
|
const params: Record<string, any> = {};
|
|
1134
|
-
const whereSql = parseWhere(this.modelName, args?.where, params, this.dialect);
|
|
1160
|
+
const whereSql = parseWhere(this.modelName, args?.where, params, this.dialect, '', this.whereContext());
|
|
1135
1161
|
const byFields = normalizeByFields(args?.by);
|
|
1136
1162
|
if (byFields.length === 0) throw new Error("groupBy requires 'by' fields");
|
|
1137
1163
|
const byCols = byFields.map((b: string) => quote(b, this.dialect)).join(', ');
|
|
@@ -1150,8 +1176,8 @@ export class AdapterTableClient<T = any> {
|
|
|
1150
1176
|
const skip = toNonNegativeInt(args?.skip);
|
|
1151
1177
|
const take = toNonNegativeInt(args?.take, 1);
|
|
1152
1178
|
if (this.dialect === 'postgres' || this.dialect === 'mysql' || this.dialect === 'sqlite') {
|
|
1153
|
-
|
|
1154
|
-
query += ` OFFSET ${skip}`;
|
|
1179
|
+
const limit = hasTake ? take : (this.dialect === 'sqlite' ? '-1' : this.dialect === 'mysql' ? '18446744073709551615' : 'ALL');
|
|
1180
|
+
query += ` LIMIT ${limit} OFFSET ${skip}`;
|
|
1155
1181
|
} else {
|
|
1156
1182
|
query += ` OFFSET ${skip} ROWS`;
|
|
1157
1183
|
if (hasTake) query += ` FETCH NEXT ${take} ROWS ONLY`;
|
|
@@ -13,6 +13,14 @@ export interface ModelFieldMeta {
|
|
|
13
13
|
[key: string]: any;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/** Prefer schema primary-key metadata; retain name inference for legacy clients. */
|
|
17
|
+
export function resolveIdField(fields: Record<string, any>): string | undefined {
|
|
18
|
+
const primaryKey = Object.keys(fields).find(name => fields[name]?.isId === true);
|
|
19
|
+
if (primaryKey) return primaryKey;
|
|
20
|
+
if (Object.prototype.hasOwnProperty.call(fields, 'id')) return 'id';
|
|
21
|
+
return Object.keys(fields).find(name => name.endsWith('_id') || name.endsWith('Id') || name.toLowerCase() === 'id');
|
|
22
|
+
}
|
|
23
|
+
|
|
16
24
|
export interface AdapterMetadata {
|
|
17
25
|
modelToTable?: Record<string, string>;
|
|
18
26
|
modelFields?: Record<string, any>;
|
|
@@ -39,6 +39,15 @@ function sanitizeParamName(name: string): string {
|
|
|
39
39
|
return /^[A-Za-z]/.test(cleaned) ? cleaned : `p_${cleaned}`;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/** Allocate parameters without overwriting values from sibling clauses. */
|
|
43
|
+
export function bindParam(params: Record<string, any>, name: string, value: any): string {
|
|
44
|
+
let candidate = name;
|
|
45
|
+
let suffix = 1;
|
|
46
|
+
while (Object.prototype.hasOwnProperty.call(params, candidate)) candidate = `${name}_${suffix++}`;
|
|
47
|
+
params[candidate] = value;
|
|
48
|
+
return candidate;
|
|
49
|
+
}
|
|
50
|
+
|
|
42
51
|
function normalizeSortDirection(dir: unknown): 'ASC' | 'DESC' {
|
|
43
52
|
return typeof dir === 'string' && dir.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
|
44
53
|
}
|
|
@@ -88,10 +97,11 @@ export function parseWhere(
|
|
|
88
97
|
if (!where) return '';
|
|
89
98
|
const conditions: string[] = [];
|
|
90
99
|
|
|
100
|
+
const relations = ctx?.relationMap?.[modelName] || {};
|
|
91
101
|
const cleanWhere: Record<string, any> = {};
|
|
92
102
|
for (const [key, value] of Object.entries(where)) {
|
|
93
103
|
if (
|
|
94
|
-
key.includes('_') &&
|
|
104
|
+
key.includes('_') && !relations[key] &&
|
|
95
105
|
value && typeof value === 'object' &&
|
|
96
106
|
!(value instanceof Date) &&
|
|
97
107
|
!isOperatorValue(value)
|
|
@@ -102,23 +112,23 @@ export function parseWhere(
|
|
|
102
112
|
}
|
|
103
113
|
}
|
|
104
114
|
|
|
105
|
-
const relations = ctx?.relationMap?.[modelName] || {};
|
|
106
115
|
const colPrefix = ctx?.colPrefix || '';
|
|
107
116
|
|
|
108
117
|
for (const [key, value] of Object.entries(cleanWhere)) {
|
|
118
|
+
if (value === undefined) continue;
|
|
109
119
|
if (key === 'OR' && Array.isArray(value)) {
|
|
110
|
-
const sub = value.map((v, i) => parseWhere(modelName, v, params, dialect, `${prefix}or_${i}_`, ctx))
|
|
111
|
-
|
|
120
|
+
const sub = value.map((v, i) => parseWhere(modelName, v, params, dialect, `${prefix}or_${i}_`, ctx) || '1=1');
|
|
121
|
+
conditions.push(sub.length > 0 ? `(${sub.join(' OR ')})` : '1=0');
|
|
112
122
|
} else if (key === 'AND') {
|
|
113
123
|
const items = Array.isArray(value) ? value : [value];
|
|
114
124
|
const sub = items.map((v, i) => parseWhere(modelName, v, params, dialect, `${prefix}and_${i}_`, ctx)).filter(Boolean);
|
|
115
125
|
if (sub.length > 0) conditions.push(`(${sub.join(' AND ')})`);
|
|
116
126
|
} else if (key === 'NOT') {
|
|
117
127
|
const items = Array.isArray(value) ? value : [value];
|
|
118
|
-
const sub = items.map((v, i) => parseWhere(modelName, v, params, dialect, `${prefix}not_${i}_`, ctx))
|
|
119
|
-
if (sub.length > 0) conditions.push(`NOT (${sub.join('
|
|
128
|
+
const sub = items.map((v, i) => parseWhere(modelName, v, params, dialect, `${prefix}not_${i}_`, ctx) || '1=1');
|
|
129
|
+
if (sub.length > 0) conditions.push(`NOT (${sub.join(' OR ')})`);
|
|
120
130
|
} else if (relations[key] && value && typeof value === 'object') {
|
|
121
|
-
const clause = buildRelationClause(relations[key], value, modelName, params, dialect, prefix
|
|
131
|
+
const clause = buildRelationClause(relations[key], value, modelName, params, dialect, `${prefix}${key}_`, ctx);
|
|
122
132
|
if (clause) conditions.push(clause);
|
|
123
133
|
} else {
|
|
124
134
|
const pname = sanitizeParamName(`${prefix}${key}`);
|
|
@@ -131,26 +141,24 @@ export function parseWhere(
|
|
|
131
141
|
if (v.not !== undefined) {
|
|
132
142
|
if (v.not === null) { conditions.push(`${col} IS NOT NULL`); }
|
|
133
143
|
else if (v.not && typeof v.not === 'object' && !(v.not instanceof Date) && !Array.isArray(v.not)) {
|
|
134
|
-
const
|
|
135
|
-
const nestedSql = parseWhere(modelName, { [key]: v.not }, nestedParams, dialect, `${prefix}${key}_not_`, ctx);
|
|
136
|
-
Object.assign(params, nestedParams);
|
|
144
|
+
const nestedSql = parseWhere(modelName, { [key]: v.not }, params, dialect, `${prefix}${key}_not_`, ctx);
|
|
137
145
|
if (nestedSql) conditions.push(`NOT (${nestedSql})`);
|
|
138
|
-
} else { const p = `${pname}_not
|
|
146
|
+
} else { const p = bindParam(params, `${pname}_not`, v.not); conditions.push(`${col} <> @${p}`); }
|
|
139
147
|
}
|
|
140
148
|
if (v.equals !== undefined) {
|
|
141
149
|
if (v.equals === null) { conditions.push(`${col} IS NULL`); }
|
|
142
|
-
else { const p = `${pname}_eq
|
|
150
|
+
else { const p = bindParam(params, `${pname}_eq`, v.equals); conditions.push(`${col} = @${p}`); }
|
|
143
151
|
}
|
|
144
|
-
if (v.contains !== undefined) { const p = `${pname}_co
|
|
145
|
-
if (v.startsWith !== undefined) { const p = `${pname}_sw
|
|
146
|
-
if (v.endsWith !== undefined) { const p = `${pname}_ew
|
|
147
|
-
if (v.gte !== undefined) { const p = `${pname}_gte
|
|
148
|
-
if (v.lte !== undefined) { const p = `${pname}_lte
|
|
149
|
-
if (v.gt !== undefined) { const p = `${pname}_gt
|
|
150
|
-
if (v.lt !== undefined) { const p = `${pname}_lt
|
|
152
|
+
if (v.contains !== undefined) { const p = bindParam(params, `${pname}_co`, `%${v.contains}%`); conditions.push(`${col} LIKE @${p}`); }
|
|
153
|
+
if (v.startsWith !== undefined) { const p = bindParam(params, `${pname}_sw`, `${v.startsWith}%`); conditions.push(`${col} LIKE @${p}`); }
|
|
154
|
+
if (v.endsWith !== undefined) { const p = bindParam(params, `${pname}_ew`, `%${v.endsWith}`); conditions.push(`${col} LIKE @${p}`); }
|
|
155
|
+
if (v.gte !== undefined) { const p = bindParam(params, `${pname}_gte`, v.gte); conditions.push(`${col} >= @${p}`); }
|
|
156
|
+
if (v.lte !== undefined) { const p = bindParam(params, `${pname}_lte`, v.lte); conditions.push(`${col} <= @${p}`); }
|
|
157
|
+
if (v.gt !== undefined) { const p = bindParam(params, `${pname}_gt`, v.gt); conditions.push(`${col} > @${p}`); }
|
|
158
|
+
if (v.lt !== undefined) { const p = bindParam(params, `${pname}_lt`, v.lt); conditions.push(`${col} < @${p}`); }
|
|
151
159
|
if (v.in !== undefined) {
|
|
152
160
|
if (Array.isArray(v.in) && v.in.length > 0) {
|
|
153
|
-
const ps = v.in.map((x: any, i: number) => { const p = `${pname}_in${i}
|
|
161
|
+
const ps = v.in.map((x: any, i: number) => { const p = bindParam(params, `${pname}_in${i}`, x); return `@${p}`; });
|
|
154
162
|
conditions.push(`${col} IN (${ps.join(', ')})`);
|
|
155
163
|
} else {
|
|
156
164
|
conditions.push('1=0');
|
|
@@ -158,15 +166,15 @@ export function parseWhere(
|
|
|
158
166
|
}
|
|
159
167
|
if (v.notIn !== undefined) {
|
|
160
168
|
if (Array.isArray(v.notIn) && v.notIn.length > 0) {
|
|
161
|
-
const ps = v.notIn.map((x: any, i: number) => { const p = `${pname}_notin${i}
|
|
169
|
+
const ps = v.notIn.map((x: any, i: number) => { const p = bindParam(params, `${pname}_notin${i}`, x); return `@${p}`; });
|
|
162
170
|
conditions.push(`${col} NOT IN (${ps.join(', ')})`);
|
|
163
171
|
} else {
|
|
164
172
|
conditions.push('1=1');
|
|
165
173
|
}
|
|
166
174
|
}
|
|
167
175
|
} else {
|
|
168
|
-
params
|
|
169
|
-
conditions.push(`${col} = @${
|
|
176
|
+
const p = bindParam(params, pname, value);
|
|
177
|
+
conditions.push(`${col} = @${p}`);
|
|
170
178
|
}
|
|
171
179
|
}
|
|
172
180
|
}
|
|
@@ -203,28 +211,28 @@ function buildRelationClause(
|
|
|
203
211
|
|
|
204
212
|
if (isMany) {
|
|
205
213
|
if (value.some !== undefined) {
|
|
206
|
-
const inner = buildInnerWhere(relation.modelName, value.some === null ? {} : value.some, params, dialect, prefix
|
|
214
|
+
const inner = buildInnerWhere(relation.modelName, value.some === null ? {} : value.some, params, dialect, `${prefix}some_`, ctx, childAlias);
|
|
207
215
|
parts.push(buildExists(inner));
|
|
208
216
|
}
|
|
209
217
|
if (value.none !== undefined) {
|
|
210
|
-
const inner = buildInnerWhere(relation.modelName, value.none === null ? {} : value.none, params, dialect, prefix
|
|
218
|
+
const inner = buildInnerWhere(relation.modelName, value.none === null ? {} : value.none, params, dialect, `${prefix}none_`, ctx, childAlias);
|
|
211
219
|
parts.push(`NOT ${buildExists(inner)}`);
|
|
212
220
|
}
|
|
213
221
|
if (value.every !== undefined) {
|
|
214
222
|
// "every" = no related row violates the condition.
|
|
215
|
-
const inner = buildInnerWhere(relation.modelName, value.every === null ? {} : value.every, params, dialect, prefix
|
|
223
|
+
const inner = buildInnerWhere(relation.modelName, value.every === null ? {} : value.every, params, dialect, `${prefix}every_`, ctx, childAlias);
|
|
216
224
|
const negated = inner ? `NOT (${inner})` : '';
|
|
217
225
|
const violated = `EXISTS (SELECT 1 FROM ${childTable} AS ${childAlias} WHERE ${correlation}${negated ? ` AND ${negated}` : ' AND 1=0'})`;
|
|
218
226
|
parts.push(`NOT ${violated}`);
|
|
219
227
|
}
|
|
220
228
|
} else {
|
|
221
229
|
if (value.is !== undefined) {
|
|
222
|
-
const inner = buildInnerWhere(relation.modelName, value.is === null ? {} : value.is, params, dialect, prefix
|
|
223
|
-
parts.push(buildExists(inner));
|
|
230
|
+
const inner = buildInnerWhere(relation.modelName, value.is === null ? {} : value.is, params, dialect, `${prefix}is_`, ctx, childAlias);
|
|
231
|
+
parts.push(value.is === null ? `NOT ${buildExists(inner)}` : buildExists(inner));
|
|
224
232
|
}
|
|
225
233
|
if (value.isNot !== undefined) {
|
|
226
|
-
const inner = buildInnerWhere(relation.modelName, value.isNot === null ? {} : value.isNot, params, dialect, prefix
|
|
227
|
-
parts.push(`NOT ${buildExists(inner)}`);
|
|
234
|
+
const inner = buildInnerWhere(relation.modelName, value.isNot === null ? {} : value.isNot, params, dialect, `${prefix}isNot_`, ctx, childAlias);
|
|
235
|
+
parts.push(value.isNot === null ? buildExists(inner) : `NOT ${buildExists(inner)}`);
|
|
228
236
|
}
|
|
229
237
|
}
|
|
230
238
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { An5SheetsAdapterConfig, resolveConfig } from './config';
|
|
2
|
+
import { sheetsTokenProvider } from './oauth';
|
|
2
3
|
import type { sheets_v4 } from 'googleapis';
|
|
3
4
|
import { execQuery } from './sqlExecutor';
|
|
4
5
|
import { SheetsTableClient } from './tableClient';
|
|
@@ -8,12 +9,10 @@ import { createAdapterProxy } from '../base/metadata';
|
|
|
8
9
|
|
|
9
10
|
// ─── Fetch-based API proxy for OAuth Access Token / API Key (browser-compatible) ──
|
|
10
11
|
|
|
11
|
-
function createFetchApi(spreadsheetId: string, accessToken?: string, apiKey?: string) {
|
|
12
|
+
function createFetchApi(spreadsheetId: string, accessToken?: string, apiKey?: string, oauth: An5SheetsAdapterConfig = { spreadsheetId }) {
|
|
12
13
|
const base = `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}`;
|
|
13
14
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
14
|
-
|
|
15
|
-
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
16
|
-
}
|
|
15
|
+
const tokenProvider = sheetsTokenProvider({ ...oauth, ...(accessToken ? { accessToken } : {}) });
|
|
17
16
|
|
|
18
17
|
async function request<T>(method: string, path: string, body?: any): Promise<T> {
|
|
19
18
|
let url = path.startsWith('http') ? path : `${base}${path}`;
|
|
@@ -21,9 +20,14 @@ function createFetchApi(spreadsheetId: string, accessToken?: string, apiKey?: st
|
|
|
21
20
|
const sep = url.includes('?') ? '&' : '?';
|
|
22
21
|
url += `${sep}key=${encodeURIComponent(apiKey)}`;
|
|
23
22
|
}
|
|
24
|
-
const
|
|
23
|
+
const token = await tokenProvider();
|
|
24
|
+
const opts: RequestInit = { method, headers: { ...headers, ...(token ? { Authorization: `Bearer ${token}` } : {}) } };
|
|
25
25
|
if (body) opts.body = JSON.stringify(body);
|
|
26
|
-
|
|
26
|
+
let res = await fetch(url, opts);
|
|
27
|
+
if (res.status === 401 && oauth.refreshToken) {
|
|
28
|
+
const refreshed = await tokenProvider(true);
|
|
29
|
+
res = await fetch(url, { ...opts, headers: { ...headers, Authorization: `Bearer ${refreshed}` } });
|
|
30
|
+
}
|
|
27
31
|
if (!res.ok) {
|
|
28
32
|
const text = await res.text().catch(() => '');
|
|
29
33
|
const err = new Error(`Google Sheets API error (${res.status}): ${text}`);
|
|
@@ -85,13 +89,17 @@ export class An5SheetsAdapter {
|
|
|
85
89
|
private sheetsCache: Promise<SheetMeta[]> | null = null;
|
|
86
90
|
|
|
87
91
|
constructor(config: An5SheetsAdapterConfig) {
|
|
92
|
+
const browser = typeof window !== 'undefined' || (typeof self !== 'undefined' && typeof process === 'undefined');
|
|
93
|
+
if (browser && (config.refreshToken || config.oauthClientSecret || config.privateKey || config.credentials)) {
|
|
94
|
+
throw new Error('Browser Google Sheets connections require a user access token. Keep desktop OAuth refresh tokens and service account keys outside the browser.');
|
|
95
|
+
}
|
|
88
96
|
this.config = resolveConfig(config);
|
|
89
97
|
// Dynamic model access (`db.user` / `db.User`), same as An5Adapter.
|
|
90
98
|
return createAdapterProxy(this, (name) => this.table(name));
|
|
91
99
|
}
|
|
92
100
|
|
|
93
101
|
private get isFetchMode(): boolean {
|
|
94
|
-
return !!(this.config as any).accessToken || !!(this.config as any).apiKey;
|
|
102
|
+
return !!(this.config as any).accessToken || !!(this.config as any).refreshToken || !!(this.config as any).apiKey;
|
|
95
103
|
}
|
|
96
104
|
|
|
97
105
|
async getSheets(): Promise<any> {
|
|
@@ -100,7 +108,8 @@ export class An5SheetsAdapter {
|
|
|
100
108
|
this.fetchApi = createFetchApi(
|
|
101
109
|
this.config.spreadsheetId,
|
|
102
110
|
(this.config as any).accessToken,
|
|
103
|
-
(this.config as any).apiKey
|
|
111
|
+
(this.config as any).apiKey,
|
|
112
|
+
this.config as An5SheetsAdapterConfig
|
|
104
113
|
);
|
|
105
114
|
}
|
|
106
115
|
return this.fetchApi;
|
|
@@ -10,6 +10,11 @@ export interface An5SheetsAdapterConfig {
|
|
|
10
10
|
* When provided, the adapter uses raw fetch() instead of googleapis JWT, making it compatible
|
|
11
11
|
* with browser environments. Mutually exclusive with clientEmail/privateKey/credentials. */
|
|
12
12
|
accessToken?: string;
|
|
13
|
+
/** Offline desktop OAuth credentials. Keep these in secure storage, never source control. */
|
|
14
|
+
refreshToken?: string;
|
|
15
|
+
oauthClientId?: string;
|
|
16
|
+
oauthClientSecret?: string;
|
|
17
|
+
tokenExpiresAt?: number;
|
|
13
18
|
/** Google API key for browser-based API access. */
|
|
14
19
|
apiKey?: string;
|
|
15
20
|
}
|
|
@@ -22,10 +27,14 @@ export function normalizeKey(key: string): string {
|
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
export function resolveConfig(config: An5SheetsAdapterConfig) {
|
|
25
|
-
if (config.accessToken || config.apiKey) {
|
|
30
|
+
if (config.accessToken || config.refreshToken || config.apiKey) {
|
|
26
31
|
return {
|
|
27
32
|
spreadsheetId: config.spreadsheetId,
|
|
28
33
|
accessToken: config.accessToken,
|
|
34
|
+
refreshToken: config.refreshToken,
|
|
35
|
+
oauthClientId: config.oauthClientId,
|
|
36
|
+
oauthClientSecret: config.oauthClientSecret,
|
|
37
|
+
tokenExpiresAt: config.tokenExpiresAt,
|
|
29
38
|
apiKey: config.apiKey,
|
|
30
39
|
sheetMapping: config.sheetMapping,
|
|
31
40
|
};
|
|
@@ -21,9 +21,7 @@ export function matchWhere(row: Record<string, any>, where: any): boolean {
|
|
|
21
21
|
key.includes('_') &&
|
|
22
22
|
value && typeof value === 'object' &&
|
|
23
23
|
!(value instanceof Date) &&
|
|
24
|
-
!
|
|
25
|
-
!(value as any).not && !(value as any).gte &&
|
|
26
|
-
!(value as any).lte && !(value as any).gt && !(value as any).lt
|
|
24
|
+
!['equals', 'in', 'notIn', 'contains', 'startsWith', 'endsWith', 'not', 'gte', 'lte', 'gt', 'lt'].some(op => op in value)
|
|
27
25
|
) {
|
|
28
26
|
Object.assign(cleanWhere, value);
|
|
29
27
|
} else {
|
|
@@ -32,12 +30,14 @@ export function matchWhere(row: Record<string, any>, where: any): boolean {
|
|
|
32
30
|
}
|
|
33
31
|
|
|
34
32
|
for (const [key, value] of Object.entries(cleanWhere)) {
|
|
33
|
+
if (value === undefined) continue;
|
|
35
34
|
if (key === 'OR' && Array.isArray(value)) {
|
|
36
35
|
if (!value.some((v: any) => matchWhere(row, v))) return false;
|
|
37
36
|
continue;
|
|
38
37
|
}
|
|
39
|
-
if (key === 'AND'
|
|
40
|
-
|
|
38
|
+
if (key === 'AND') {
|
|
39
|
+
const clauses = Array.isArray(value) ? value : [value];
|
|
40
|
+
if (!clauses.every((v: any) => matchWhere(row, v))) return false;
|
|
41
41
|
continue;
|
|
42
42
|
}
|
|
43
43
|
if (key === 'NOT') {
|
|
@@ -57,17 +57,20 @@ export function matchWhere(row: Record<string, any>, where: any): boolean {
|
|
|
57
57
|
|
|
58
58
|
if (v.not !== undefined) {
|
|
59
59
|
if (v.not === null) { if (cellVal === null || cellVal === undefined) return false; }
|
|
60
|
+
else if (v.not && typeof v.not === 'object' && !(v.not instanceof Date) && !Array.isArray(v.not)) {
|
|
61
|
+
if (matchWhere(row, { [key]: v.not })) return false;
|
|
62
|
+
}
|
|
60
63
|
else if (normalize(cellVal) == normalize(v.not)) return false;
|
|
61
64
|
}
|
|
62
65
|
if (v.equals !== undefined && normalize(cellVal) != normalize(v.equals)) return false;
|
|
63
66
|
if (v.contains !== undefined) {
|
|
64
|
-
if (
|
|
67
|
+
if (cellVal === null || cellVal === undefined || !normalize(String(cellVal)).includes(normalize(v.contains))) return false;
|
|
65
68
|
}
|
|
66
69
|
if (v.startsWith !== undefined) {
|
|
67
|
-
if (
|
|
70
|
+
if (cellVal === null || cellVal === undefined || !normalize(String(cellVal)).startsWith(normalize(v.startsWith))) return false;
|
|
68
71
|
}
|
|
69
72
|
if (v.endsWith !== undefined) {
|
|
70
|
-
if (
|
|
73
|
+
if (cellVal === null || cellVal === undefined || !normalize(String(cellVal)).endsWith(normalize(v.endsWith))) return false;
|
|
71
74
|
}
|
|
72
75
|
if (v.gte !== undefined && !(Number(cellVal) >= Number(v.gte))) return false;
|
|
73
76
|
if (v.lte !== undefined && !(Number(cellVal) <= Number(v.lte))) return false;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { An5SheetsAdapterConfig } from './config';
|
|
2
|
+
|
|
3
|
+
/** Deduplicate refresh requests within an adapter; do not expose token responses. */
|
|
4
|
+
export function sheetsTokenProvider(config: An5SheetsAdapterConfig) {
|
|
5
|
+
let token = config.accessToken;
|
|
6
|
+
let expiresAt = config.tokenExpiresAt;
|
|
7
|
+
let pending: Promise<string> | undefined;
|
|
8
|
+
return async (force = false): Promise<string | undefined> => {
|
|
9
|
+
if (token && !force && (expiresAt === undefined || expiresAt > Date.now() + 60000)) return token;
|
|
10
|
+
if (!config.refreshToken || !config.oauthClientId) {
|
|
11
|
+
if (force || expiresAt !== undefined) throw new Error('Google access expired. Reconnect your Google account.');
|
|
12
|
+
return token;
|
|
13
|
+
}
|
|
14
|
+
if (!pending) pending = (async () => {
|
|
15
|
+
try {
|
|
16
|
+
const response = await fetch('https://oauth2.googleapis.com/token', {
|
|
17
|
+
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
18
|
+
body: new URLSearchParams({ grant_type: 'refresh_token', client_id: config.oauthClientId!, refresh_token: config.refreshToken!, ...(config.oauthClientSecret ? { client_secret: config.oauthClientSecret } : {}) }),
|
|
19
|
+
signal: AbortSignal.timeout(20000),
|
|
20
|
+
});
|
|
21
|
+
if (!response.ok) throw new Error();
|
|
22
|
+
const result = await response.json() as { access_token?: string; expires_in?: number };
|
|
23
|
+
if (!result.access_token || typeof result.access_token !== 'string' || typeof result.expires_in !== 'number' || !Number.isFinite(result.expires_in) || result.expires_in <= 0) throw new Error();
|
|
24
|
+
token = result.access_token; expiresAt = Date.now() + result.expires_in * 1000;
|
|
25
|
+
return token;
|
|
26
|
+
} catch { throw new Error('Google access expired or was revoked. Reconnect your Google account.'); }
|
|
27
|
+
finally { pending = undefined; }
|
|
28
|
+
})();
|
|
29
|
+
return pending;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -33,6 +33,14 @@ export function parseSheetsConnectionString(url: string): An5SheetsAdapterConfig
|
|
|
33
33
|
if (config.clientemail) result.clientEmail = config.clientemail;
|
|
34
34
|
if (config.privatekey) result.privateKey = config.privatekey;
|
|
35
35
|
if (config.accesstoken) result.accessToken = config.accesstoken;
|
|
36
|
+
if (config.refreshtoken) result.refreshToken = config.refreshtoken;
|
|
37
|
+
if (config.oauthclientid) result.oauthClientId = config.oauthclientid;
|
|
38
|
+
if (config.oauthclientsecret) result.oauthClientSecret = config.oauthclientsecret;
|
|
39
|
+
if (config.tokenexpiresat) {
|
|
40
|
+
const expiry = Number(config.tokenexpiresat);
|
|
41
|
+
if (!Number.isFinite(expiry) || expiry < 0) throw new Error('Invalid OAuth token expiry.');
|
|
42
|
+
result.tokenExpiresAt = expiry;
|
|
43
|
+
}
|
|
36
44
|
if (config.apikey) result.apiKey = config.apikey;
|
|
37
45
|
|
|
38
46
|
if (config.sheetmapping) {
|
|
@@ -1,18 +1,11 @@
|
|
|
1
1
|
import { generateUUID } from '../base/uuid';
|
|
2
|
-
import { getFieldsForModel } from '../base/metadata';
|
|
2
|
+
import { getFieldsForModel, resolveIdField } from '../base/metadata';
|
|
3
3
|
import type { An5SheetsAdapter } from './adapter';
|
|
4
4
|
import { buildOrderBy, coerceCell, esc, matchWhere, resolveSheetName, sortRows } from './helpers';
|
|
5
5
|
import { withRetry } from './retry';
|
|
6
6
|
|
|
7
7
|
// ─── Utility ──────────────────────────────────────────────────────────────────
|
|
8
8
|
|
|
9
|
-
function resolveIdField(fields: Record<string, any>): string | undefined {
|
|
10
|
-
if (Object.prototype.hasOwnProperty.call(fields, 'id')) return 'id';
|
|
11
|
-
return Object.keys(fields).find(
|
|
12
|
-
name => name.endsWith('_id') || name.endsWith('Id') || name.toLowerCase() === 'id'
|
|
13
|
-
);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
9
|
// ─── Table Client ─────────────────────────────────────────────────────────────
|
|
17
10
|
|
|
18
11
|
export class SheetsTableClient<T = any> {
|
|
@@ -227,17 +220,10 @@ export class SheetsTableClient<T = any> {
|
|
|
227
220
|
return this.rowToValues(data, headers);
|
|
228
221
|
});
|
|
229
222
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
if (!args.skipDuplicates) throw e;
|
|
235
|
-
let count = 0;
|
|
236
|
-
for (const row of args.data) {
|
|
237
|
-
try { await this.create({ data: row }); count++; } catch { /* skip */ }
|
|
238
|
-
}
|
|
239
|
-
return { count };
|
|
240
|
-
}
|
|
223
|
+
// Sheets has no unique constraints. An append failure must propagate;
|
|
224
|
+
// retrying individual rows could duplicate a batch the service accepted.
|
|
225
|
+
await this.adapter.appendRange(`${this.escSheetName}!A:A`, batchValues);
|
|
226
|
+
return { count: batchValues.length };
|
|
241
227
|
}
|
|
242
228
|
|
|
243
229
|
async update(args: { where: any; data: Partial<T> }): Promise<T> {
|
|
@@ -14,7 +14,8 @@ export class SqliteEngine implements QueryEngine {
|
|
|
14
14
|
if (!BetterSqlite3) throw new Error('better-sqlite3 package is required for SQLite support. Run: npm install better-sqlite3');
|
|
15
15
|
this.filePath = (adapterConfig.connectionString ?? '')
|
|
16
16
|
.replace(/^sqlite:\/\/\//i, '/')
|
|
17
|
-
.replace(/^sqlite:\/\//i, '')
|
|
17
|
+
.replace(/^sqlite:\/\//i, '')
|
|
18
|
+
.replace(/^sqlite:/i, '');
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
private getDb(): any {
|