@an5/adapters 0.2.11 → 0.2.14
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 +137 -5
- package/dist/an5Adapter.d.ts +42 -1
- package/dist/an5Adapter.js +146 -38
- package/dist/base/types.d.ts +14 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +8 -1
- package/dist/sqlite/browserEngine.d.ts +13 -0
- package/dist/sqlite/browserEngine.js +13 -0
- package/dist/sqlite/engine.d.ts +5 -0
- package/dist/sqlite/engine.js +10 -0
- package/dist/sqlite/index.d.ts +1 -0
- package/dist/sqlite/index.js +1 -0
- package/dist/sqlite/vector.d.ts +155 -0
- package/dist/sqlite/vector.js +432 -0
- package/dotnet/An5Adapter.cs +99 -28
- package/dotnet/Base/Core.cs +14 -0
- package/dotnet/Mssql/MssqlEngine.cs +44 -1
- package/dotnet/Postgres/PostgresEngine.cs +2 -0
- package/dotnet/Sqlite/SqliteEngine.cs +82 -2
- package/dotnet/Sqlite/SqliteVectors.cs +401 -0
- package/golang/an5_adapter.go +84 -32
- package/golang/base/vectors.go +263 -0
- package/golang/sqlite/vector.go +248 -0
- package/golang/sqlite_vector_test.go +288 -0
- package/golang/table_client.go +67 -3
- package/java/pom.xml +177 -0
- package/java/src/main/java/an5/adapters/An5Adapter.java +374 -0
- package/java/src/main/java/an5/adapters/An5Aggregate.java +116 -0
- package/java/src/main/java/an5/adapters/An5GroupBy.java +97 -0
- package/java/src/main/java/an5/adapters/An5Query.java +100 -0
- package/java/src/main/java/an5/adapters/An5TableClient.java +1030 -0
- package/java/src/main/java/an5/adapters/An5ViewClient.java +107 -0
- package/java/src/main/java/an5/adapters/base/Dialect.java +103 -0
- package/java/src/main/java/an5/adapters/base/FieldMeta.java +42 -0
- package/java/src/main/java/an5/adapters/base/JdbcUrls.java +201 -0
- package/java/src/main/java/an5/adapters/base/Metadata.java +195 -0
- package/java/src/main/java/an5/adapters/base/RelationDef.java +26 -0
- package/java/src/main/java/an5/adapters/base/SqlBuilder.java +422 -0
- package/java/src/main/java/an5/adapters/base/SqliteVectors.java +375 -0
- package/java/src/main/java/an5/adapters/base/Vectors.java +123 -0
- package/java/src/test/java/An5AdaptersSmoke.java +733 -0
- package/java/src/test/java/Json.java +174 -0
- package/java/src/test/java/QuerySemantics.java +111 -0
- package/kotlin/build.gradle.kts +145 -0
- package/kotlin/settings.gradle.kts +10 -0
- package/kotlin/src/main/kotlin/an5/adapters/Aggregate.kt +89 -0
- package/kotlin/src/main/kotlin/an5/adapters/An5.kt +91 -0
- package/kotlin/src/main/kotlin/an5/adapters/Query.kt +130 -0
- package/kotlin/src/main/kotlin/an5/adapters/Rows.kt +166 -0
- package/kotlin/src/main/kotlin/an5/adapters/TableClient.kt +169 -0
- package/kotlin/src/main/kotlin/an5/adapters/Types.kt +77 -0
- package/kotlin/src/test/kotlin/an5/adapters/An5Smoke.kt +329 -0
- package/native/sqlite/CMakeLists.txt +12 -0
- package/native/sqlite/README.md +117 -0
- package/native/sqlite/an5_vector.c +209 -0
- package/package.json +33 -7
- package/python/an5_adapter.py +37 -6
- package/python/base/__init__.py +15 -0
- package/python/base/vectors.py +431 -0
- package/python/sqlite/__init__.py +2 -2
- package/python/sqlite/provider.py +21 -1
- package/python/table_client.py +129 -59
- package/rust/src/adapter.rs +342 -24
- package/rust/src/base/mod.rs +7 -0
- package/rust/src/base/vectors.rs +293 -0
- package/rust/src/lib.rs +3 -3
- package/scripts/sqlite-native-build.js +32 -0
- package/swift/Package.swift +22 -0
- package/swift/Sources/An5Adapters/An5Adapter.swift +160 -0
- package/swift/Sources/An5Adapters/Metadata.swift +183 -0
- package/swift/Sources/An5Adapters/Query.swift +105 -0
- package/swift/Sources/An5Adapters/Row+Accessors.swift +129 -0
- package/swift/Sources/An5Adapters/SQLBuilder.swift +359 -0
- package/swift/Sources/An5Adapters/SQLiteDriver.swift +301 -0
- package/swift/Sources/An5Adapters/SqliteVectors.swift +243 -0
- package/swift/Sources/An5Adapters/TableClient.swift +671 -0
- package/swift/Sources/An5Adapters/Types.swift +127 -0
- package/swift/Sources/An5Adapters/Vectors.swift +86 -0
- package/swift/Sources/An5Adapters/ViewClient.swift +50 -0
- package/swift/Sources/CSQLite/module.modulemap +8 -0
- package/swift/Sources/CSQLite/shim.h +9 -0
- package/swift/Tests/An5AdaptersTests/QuerySemanticsTests.swift +51 -0
- package/swift/Tests/An5AdaptersTests/RuntimeSmokeTests.swift +488 -0
- package/typescript/src/an5Adapter.ts +155 -31
- package/typescript/src/base/types.ts +15 -0
- package/typescript/src/index.ts +13 -0
- package/typescript/src/sqlite/browserEngine.ts +24 -0
- package/typescript/src/sqlite/engine.ts +13 -0
- package/typescript/src/sqlite/index.ts +1 -1
- package/typescript/src/sqlite/vector.ts +537 -0
- package/golang/sqlite/sqlite.go +0 -14
|
@@ -0,0 +1,1030 @@
|
|
|
1
|
+
package an5.adapters;
|
|
2
|
+
|
|
3
|
+
import an5.adapters.base.Dialect;
|
|
4
|
+
import an5.adapters.base.FieldMeta;
|
|
5
|
+
import an5.adapters.base.Metadata;
|
|
6
|
+
import an5.adapters.base.RelationDef;
|
|
7
|
+
import an5.adapters.base.SqlBuilder;
|
|
8
|
+
import an5.adapters.base.SqliteVectors;
|
|
9
|
+
import an5.adapters.base.Vectors;
|
|
10
|
+
import java.sql.SQLException;
|
|
11
|
+
import java.util.ArrayList;
|
|
12
|
+
import java.util.Collections;
|
|
13
|
+
import java.util.Comparator;
|
|
14
|
+
import java.util.LinkedHashMap;
|
|
15
|
+
import java.util.LinkedHashSet;
|
|
16
|
+
import java.util.List;
|
|
17
|
+
import java.util.Map;
|
|
18
|
+
import java.util.Set;
|
|
19
|
+
import java.util.UUID;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* CRUD and queries for one table, driven entirely by the registered metadata.
|
|
23
|
+
*
|
|
24
|
+
* <p>Rows come back as {@code Map<String, Object>} keyed by column label. The generated
|
|
25
|
+
* client wraps them in its own model types, so this class stays independent of the schema
|
|
26
|
+
* and of the generator — the same reason the other adapters' table clients work on
|
|
27
|
+
* untyped rows.
|
|
28
|
+
*/
|
|
29
|
+
public class An5TableClient {
|
|
30
|
+
|
|
31
|
+
private final An5Adapter adapter;
|
|
32
|
+
private final String modelName;
|
|
33
|
+
private final Dialect dialect;
|
|
34
|
+
|
|
35
|
+
public An5TableClient(An5Adapter adapter, String modelName) {
|
|
36
|
+
this.adapter = adapter;
|
|
37
|
+
this.modelName = modelName;
|
|
38
|
+
this.dialect = adapter.dialect();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
public String modelName() {
|
|
42
|
+
return modelName;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private String tableSql() {
|
|
46
|
+
return SqlBuilder.quoteTable(Metadata.resolveTable(modelName), dialect);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** {@code WITH (NOLOCK)} is an MSSQL hint; leaving it in makes every other dialect fail. */
|
|
50
|
+
private String nolock() {
|
|
51
|
+
return dialect.supportsNolock() ? " WITH (NOLOCK)" : "";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private List<FieldMeta> fields() {
|
|
55
|
+
return Metadata.fieldsFor(modelName);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The {@code VECTOR(n)} columns of this model, from the generated metadata. */
|
|
59
|
+
private List<String> vectorFields() {
|
|
60
|
+
List<String> names = new ArrayList<String>();
|
|
61
|
+
for (FieldMeta field : fields()) {
|
|
62
|
+
if (SqliteVectors.isVectorField(field.sqlType) || SqliteVectors.isVectorField(field.type)) {
|
|
63
|
+
names.add(field.name);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return names;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Encodes every {@code VECTOR(n)} value on its way into SQLite.
|
|
71
|
+
*
|
|
72
|
+
* <p>The column holds a float32 BLOB, which is a third of the size of the JSON text it
|
|
73
|
+
* replaces. Values that already arrived as text or bytes are left alone, so rows written by
|
|
74
|
+
* an older version stay readable.
|
|
75
|
+
*/
|
|
76
|
+
private Map<String, Object> encodeVectorFields(Map<String, Object> data) {
|
|
77
|
+
if (dialect != Dialect.SQLITE) {
|
|
78
|
+
return data;
|
|
79
|
+
}
|
|
80
|
+
List<String> names = vectorFields();
|
|
81
|
+
if (names.isEmpty()) {
|
|
82
|
+
return data;
|
|
83
|
+
}
|
|
84
|
+
Map<String, Object> out = new LinkedHashMap<String, Object>(data);
|
|
85
|
+
for (String name : names) {
|
|
86
|
+
Object value = out.get(name);
|
|
87
|
+
if (!SqliteVectors.needsVectorEncoding(value)) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
double[] decoded = SqliteVectors.decodeVector(value, 0);
|
|
91
|
+
if (decoded != null) {
|
|
92
|
+
out.put(name, SqliteVectors.encodeVector(decoded));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Decodes {@code VECTOR(n)} columns back into arrays of numbers after a read. */
|
|
99
|
+
private List<Map<String, Object>> decodeVectorRows(List<Map<String, Object>> rows) {
|
|
100
|
+
if (dialect != Dialect.SQLITE || rows.isEmpty()) {
|
|
101
|
+
return rows;
|
|
102
|
+
}
|
|
103
|
+
List<String> names = vectorFields();
|
|
104
|
+
if (names.isEmpty()) {
|
|
105
|
+
return rows;
|
|
106
|
+
}
|
|
107
|
+
for (Map<String, Object> row : rows) {
|
|
108
|
+
for (String name : names) {
|
|
109
|
+
if (!row.containsKey(name)) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
double[] decoded = SqliteVectors.decodeVector(row.get(name), 0);
|
|
113
|
+
if (decoded != null) {
|
|
114
|
+
row.put(name, decoded);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return rows;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ─── Reads ──────────────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
public List<Map<String, Object>> findMany() throws SQLException {
|
|
124
|
+
return findMany(new An5Query());
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
public List<Map<String, Object>> findMany(An5Query query) throws SQLException {
|
|
128
|
+
Map<String, Object> params = new LinkedHashMap<String, Object>();
|
|
129
|
+
SqlBuilder.Where where = SqlBuilder.parseWhere(query.whereMap(), dialect, params, "");
|
|
130
|
+
String orderSql = SqlBuilder.buildOrderBy(query.orderByValue(), dialect);
|
|
131
|
+
|
|
132
|
+
Map<String, RelationDef> relations = Metadata.relationsFor(modelName);
|
|
133
|
+
List<String> selected = query.selectValue();
|
|
134
|
+
String columns = "SELECT *";
|
|
135
|
+
if (selected != null && !selected.isEmpty() && !hasRelationSelect(selected, relations)) {
|
|
136
|
+
columns = "SELECT " + quoteAll(selected);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
StringBuilder sql = new StringBuilder(columns);
|
|
140
|
+
sql.append(" FROM ").append(tableSql()).append(nolock());
|
|
141
|
+
if (!where.isEmpty()) {
|
|
142
|
+
sql.append(" WHERE ").append(where.sql);
|
|
143
|
+
}
|
|
144
|
+
if (!orderSql.isEmpty()) {
|
|
145
|
+
sql.append(' ').append(orderSql);
|
|
146
|
+
}
|
|
147
|
+
sql.append(dialect.pagination(query.takeValue(), query.skipValue(), orderSql));
|
|
148
|
+
|
|
149
|
+
List<Map<String, Object>> rows = adapter.exec(sql.toString(), where.params);
|
|
150
|
+
if (query.includeValue() != null && !query.includeValue().isEmpty()) {
|
|
151
|
+
resolveIncludes(relations, rows, query.includeValue());
|
|
152
|
+
}
|
|
153
|
+
if (selected != null) {
|
|
154
|
+
if (hasRelationSelect(selected, relations)) {
|
|
155
|
+
resolveIncludes(relations, rows, relationSelect(selected, relations));
|
|
156
|
+
}
|
|
157
|
+
rows = projectFields(rows, selected);
|
|
158
|
+
}
|
|
159
|
+
return decodeVectorRows(rows);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The first matching row, or {@code null}. Always limited to one row server-side. */
|
|
163
|
+
public Map<String, Object> findFirst(An5Query query) throws SQLException {
|
|
164
|
+
List<Map<String, Object>> rows = findMany(limitedTo(query, 1));
|
|
165
|
+
return rows.isEmpty() ? null : rows.get(0);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
public Map<String, Object> findFirst(Map<String, Object> where) throws SQLException {
|
|
169
|
+
return findFirst(new An5Query().where(where));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
public Map<String, Object> findUnique(An5Query query) throws SQLException {
|
|
173
|
+
return findFirst(query);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
public Map<String, Object> findUnique(Map<String, Object> where) throws SQLException {
|
|
177
|
+
return findFirst(where);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** How many rows match. */
|
|
181
|
+
public long count() throws SQLException {
|
|
182
|
+
return count(null);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
public long count(Map<String, Object> where) throws SQLException {
|
|
186
|
+
Map<String, Object> params = new LinkedHashMap<String, Object>();
|
|
187
|
+
SqlBuilder.Where clause = SqlBuilder.parseWhere(where, dialect, params, "");
|
|
188
|
+
StringBuilder sql =
|
|
189
|
+
new StringBuilder("SELECT COUNT(*) AS cnt FROM ").append(tableSql()).append(nolock());
|
|
190
|
+
if (!clause.isEmpty()) {
|
|
191
|
+
sql.append(" WHERE ").append(clause.sql);
|
|
192
|
+
}
|
|
193
|
+
List<Map<String, Object>> rows = adapter.exec(sql.toString(), clause.params);
|
|
194
|
+
if (rows.isEmpty()) {
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
Object value = rows.get(0).values().iterator().next();
|
|
198
|
+
return value instanceof Number ? ((Number) value).longValue() : 0;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ─── Writes ─────────────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Inserts a row and returns it, re-reading it so server-side defaults and generated
|
|
205
|
+
* columns come back with the result.
|
|
206
|
+
*
|
|
207
|
+
* <p>A model with an {@code @id} column and no value for it gets a fresh UUID, which is
|
|
208
|
+
* what makes a client-created row addressable without a database-side default.
|
|
209
|
+
*/
|
|
210
|
+
public Map<String, Object> create(Map<String, Object> data) throws SQLException {
|
|
211
|
+
return create(data, new An5Query());
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
public Map<String, Object> create(Map<String, Object> data, An5Query query) throws SQLException {
|
|
215
|
+
Map<String, Object> relationWrites = splitRelationWrites(data);
|
|
216
|
+
Map<String, Object> scalars = withoutRelations(data);
|
|
217
|
+
|
|
218
|
+
FieldMeta idField = FieldMeta.idField(fields());
|
|
219
|
+
if (idField != null && !scalars.containsKey(idField.name)) {
|
|
220
|
+
scalars.put(idField.name, UUID.randomUUID().toString());
|
|
221
|
+
}
|
|
222
|
+
scalars = encodeVectorFields(scalars);
|
|
223
|
+
|
|
224
|
+
List<String> columns = new ArrayList<String>();
|
|
225
|
+
List<Object> values = new ArrayList<Object>();
|
|
226
|
+
for (Map.Entry<String, Object> entry : scalars.entrySet()) {
|
|
227
|
+
// A null column is left out rather than bound as NULL: an unset column takes the
|
|
228
|
+
// schema's DEFAULT, which is what the caller meant by leaving it out.
|
|
229
|
+
if (entry.getValue() != null) {
|
|
230
|
+
columns.add(entry.getKey());
|
|
231
|
+
values.add(entry.getValue());
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (!columns.isEmpty()) {
|
|
236
|
+
List<String> placeholders = new ArrayList<String>();
|
|
237
|
+
for (int i = 0; i < columns.size(); i++) {
|
|
238
|
+
placeholders.add(dialect.placeholder());
|
|
239
|
+
}
|
|
240
|
+
adapter.execute(
|
|
241
|
+
"INSERT INTO "
|
|
242
|
+
+ tableSql()
|
|
243
|
+
+ " ("
|
|
244
|
+
+ quoteAll(columns)
|
|
245
|
+
+ ") VALUES ("
|
|
246
|
+
+ join(placeholders, ", ")
|
|
247
|
+
+ ")",
|
|
248
|
+
values);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
Map<String, Object> created = scalars;
|
|
252
|
+
if (idField != null) {
|
|
253
|
+
Map<String, Object> found =
|
|
254
|
+
findFirst(new An5Query().where(single(idField.name, scalars.get(idField.name))));
|
|
255
|
+
if (found != null) {
|
|
256
|
+
created = found;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
applyRelationCreates(created, relationWrites);
|
|
261
|
+
return projectAfterWrite(created, query);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Inserts many rows, one statement each.
|
|
266
|
+
*
|
|
267
|
+
* <p>Row-at-a-time on purpose: a batch would share one parameter list, so a single bad row
|
|
268
|
+
* would lose every good row with it, and {@code skipDuplicates} has no meaning for a batch.
|
|
269
|
+
*/
|
|
270
|
+
public Map<String, Object> createMany(List<Map<String, Object>> rows, boolean skipDuplicates)
|
|
271
|
+
throws SQLException {
|
|
272
|
+
int count = 0;
|
|
273
|
+
for (Map<String, Object> row : rows) {
|
|
274
|
+
try {
|
|
275
|
+
create(row);
|
|
276
|
+
count++;
|
|
277
|
+
} catch (SQLException error) {
|
|
278
|
+
if (!skipDuplicates) {
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
Map<String, Object> result = new LinkedHashMap<String, Object>();
|
|
284
|
+
result.put("count", count);
|
|
285
|
+
return result;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Updates the rows matching {@code query.where} and returns the first of them re-read. */
|
|
289
|
+
public Map<String, Object> update(An5Query query, Map<String, Object> data) throws SQLException {
|
|
290
|
+
return update(query, data, new An5Query());
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Updates the rows matching {@code query.where}, then applies {@code options} to the result.
|
|
295
|
+
*
|
|
296
|
+
* <p>The filter and the returned projection are separate arguments on purpose: a caller
|
|
297
|
+
* that wants the updated row's relations must still say which rows to update, and folding
|
|
298
|
+
* the two into one object makes it possible to project without filtering.
|
|
299
|
+
*/
|
|
300
|
+
public Map<String, Object> update(An5Query query, Map<String, Object> data, An5Query options)
|
|
301
|
+
throws SQLException {
|
|
302
|
+
Map<String, Object> params = new LinkedHashMap<String, Object>();
|
|
303
|
+
SqlBuilder.Where where = SqlBuilder.parseWhere(query.whereMap(), dialect, params, "w_");
|
|
304
|
+
|
|
305
|
+
Map<String, Object> relationWrites = splitRelationWrites(data);
|
|
306
|
+
Map<String, Object> scalars = withoutRelations(data);
|
|
307
|
+
|
|
308
|
+
scalars = encodeVectorFields(scalars);
|
|
309
|
+
List<String> setParts = new ArrayList<String>();
|
|
310
|
+
List<Object> setValues = new ArrayList<Object>();
|
|
311
|
+
for (Map.Entry<String, Object> entry : scalars.entrySet()) {
|
|
312
|
+
if (entry.getValue() != null) {
|
|
313
|
+
SqlBuilder.appendUpdateSet(setParts, setValues, entry.getKey(), entry.getValue(), dialect);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (!setParts.isEmpty()) {
|
|
318
|
+
List<Object> allValues = new ArrayList<Object>(setValues);
|
|
319
|
+
allValues.addAll(where.params);
|
|
320
|
+
StringBuilder sql =
|
|
321
|
+
new StringBuilder("UPDATE ").append(tableSql()).append(" SET ").append(join(setParts, ", "));
|
|
322
|
+
if (!where.isEmpty()) {
|
|
323
|
+
sql.append(" WHERE ").append(where.sql);
|
|
324
|
+
}
|
|
325
|
+
adapter.execute(sql.toString(), allValues);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
Map<String, Object> updated = findFirst(query);
|
|
329
|
+
applyRelationUpdates(updated, query.whereMap(), relationWrites);
|
|
330
|
+
return projectAfterWrite(updated, options);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
public Map<String, Object> update(Map<String, Object> where, Map<String, Object> data)
|
|
334
|
+
throws SQLException {
|
|
335
|
+
return update(new An5Query().where(where), data);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Updates every matching row and gives back how many changed. */
|
|
339
|
+
public Map<String, Object> updateMany(Map<String, Object> where, Map<String, Object> data)
|
|
340
|
+
throws SQLException {
|
|
341
|
+
Map<String, Object> params = new LinkedHashMap<String, Object>();
|
|
342
|
+
SqlBuilder.Where clause = SqlBuilder.parseWhere(where, dialect, params, "w_");
|
|
343
|
+
|
|
344
|
+
Map<String, Object> encoded = encodeVectorFields(data);
|
|
345
|
+
List<String> setParts = new ArrayList<String>();
|
|
346
|
+
List<Object> setValues = new ArrayList<Object>();
|
|
347
|
+
for (Map.Entry<String, Object> entry : encoded.entrySet()) {
|
|
348
|
+
if (entry.getValue() != null) {
|
|
349
|
+
SqlBuilder.appendUpdateSet(setParts, setValues, entry.getKey(), entry.getValue(), dialect);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
Map<String, Object> result = new LinkedHashMap<String, Object>();
|
|
354
|
+
if (setParts.isEmpty()) {
|
|
355
|
+
result.put("count", 0);
|
|
356
|
+
return result;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
List<Object> allValues = new ArrayList<Object>(setValues);
|
|
360
|
+
allValues.addAll(clause.params);
|
|
361
|
+
StringBuilder sql =
|
|
362
|
+
new StringBuilder("UPDATE ").append(tableSql()).append(" SET ").append(join(setParts, ", "));
|
|
363
|
+
if (!clause.isEmpty()) {
|
|
364
|
+
sql.append(" WHERE ").append(clause.sql);
|
|
365
|
+
}
|
|
366
|
+
result.put("count", adapter.execute(sql.toString(), allValues));
|
|
367
|
+
return result;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Deletes the matching rows and returns the one that was there first. */
|
|
371
|
+
public Map<String, Object> delete(Map<String, Object> where) throws SQLException {
|
|
372
|
+
Map<String, Object> existing = findFirst(where);
|
|
373
|
+
deleteMany(where);
|
|
374
|
+
return existing;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Deletes every matching row, or the whole table when {@code where} is {@code null}. */
|
|
378
|
+
public Map<String, Object> deleteMany(Map<String, Object> where) throws SQLException {
|
|
379
|
+
Map<String, Object> params = new LinkedHashMap<String, Object>();
|
|
380
|
+
SqlBuilder.Where clause = SqlBuilder.parseWhere(where, dialect, params, "");
|
|
381
|
+
StringBuilder sql = new StringBuilder("DELETE FROM ").append(tableSql());
|
|
382
|
+
if (!clause.isEmpty()) {
|
|
383
|
+
sql.append(" WHERE ").append(clause.sql);
|
|
384
|
+
}
|
|
385
|
+
Map<String, Object> result = new LinkedHashMap<String, Object>();
|
|
386
|
+
result.put("count", adapter.execute(sql.toString(), clause.params));
|
|
387
|
+
return result;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Updates the matching row when it exists, creates it otherwise. */
|
|
391
|
+
public Map<String, Object> upsert(
|
|
392
|
+
Map<String, Object> where, Map<String, Object> createData, Map<String, Object> updateData)
|
|
393
|
+
throws SQLException {
|
|
394
|
+
Map<String, Object> existing = findFirst(where);
|
|
395
|
+
if (existing != null) {
|
|
396
|
+
Map<String, Object> updated = update(where, updateData);
|
|
397
|
+
return updated == null ? existing : updated;
|
|
398
|
+
}
|
|
399
|
+
return create(createData);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ─── Aggregation ────────────────────────────────────────────────────────────────
|
|
403
|
+
|
|
404
|
+
/** One row of aggregate values, keyed {@code _count}, {@code _sum_<field>} and so on. */
|
|
405
|
+
public Map<String, Object> aggregate(An5Aggregate aggregate) throws SQLException {
|
|
406
|
+
List<String> expressions = aggregate.selectExpressions(dialect);
|
|
407
|
+
if (expressions.isEmpty()) {
|
|
408
|
+
throw new IllegalArgumentException("aggregate requires at least one aggregator column");
|
|
409
|
+
}
|
|
410
|
+
Map<String, Object> params = new LinkedHashMap<String, Object>();
|
|
411
|
+
SqlBuilder.Where where = SqlBuilder.parseWhere(aggregate.whereMap(), dialect, params, "");
|
|
412
|
+
StringBuilder sql =
|
|
413
|
+
new StringBuilder("SELECT ")
|
|
414
|
+
.append(join(expressions, ", "))
|
|
415
|
+
.append(" FROM ")
|
|
416
|
+
.append(tableSql());
|
|
417
|
+
if (!where.isEmpty()) {
|
|
418
|
+
sql.append(" WHERE ").append(where.sql);
|
|
419
|
+
}
|
|
420
|
+
List<Map<String, Object>> rows = adapter.exec(sql.toString(), where.params);
|
|
421
|
+
return rows.isEmpty() ? new LinkedHashMap<String, Object>() : rows.get(0);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** One row per group, each carrying the group's {@code _count} and aggregates. */
|
|
425
|
+
public List<Map<String, Object>> groupBy(An5GroupBy group) throws SQLException {
|
|
426
|
+
List<String> byFields = group.byFields();
|
|
427
|
+
if (byFields.isEmpty()) {
|
|
428
|
+
throw new IllegalArgumentException("groupBy requires at least one 'by' column");
|
|
429
|
+
}
|
|
430
|
+
Map<String, Object> params = new LinkedHashMap<String, Object>();
|
|
431
|
+
SqlBuilder.Where where = SqlBuilder.parseWhere(group.whereMap(), dialect, params, "");
|
|
432
|
+
|
|
433
|
+
String byColumns = quoteAll(byFields);
|
|
434
|
+
// Counted unconditionally: a group is only useful next to its size, and a group whose
|
|
435
|
+
// rows were all filtered out still has to come back as a zero rather than disappear.
|
|
436
|
+
List<String> expressions = new ArrayList<String>();
|
|
437
|
+
expressions.add("COUNT(*) AS _count");
|
|
438
|
+
for (String expression : group.selectExpressions(dialect)) {
|
|
439
|
+
if (!"COUNT(*) AS _count".equals(expression)) {
|
|
440
|
+
expressions.add(expression);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
StringBuilder sql =
|
|
445
|
+
new StringBuilder("SELECT ")
|
|
446
|
+
.append(byColumns)
|
|
447
|
+
.append(", ")
|
|
448
|
+
.append(join(expressions, ", "))
|
|
449
|
+
.append(" FROM ")
|
|
450
|
+
.append(tableSql());
|
|
451
|
+
if (!where.isEmpty()) {
|
|
452
|
+
sql.append(" WHERE ").append(where.sql);
|
|
453
|
+
}
|
|
454
|
+
sql.append(" GROUP BY ").append(byColumns);
|
|
455
|
+
|
|
456
|
+
Integer take = group.takeValue();
|
|
457
|
+
int skip = group.skipValue();
|
|
458
|
+
boolean paging = take != null || skip > 0;
|
|
459
|
+
String orderSql = SqlBuilder.buildOrderBy(group.orderByValue(), dialect);
|
|
460
|
+
if (orderSql.isEmpty() && paging) {
|
|
461
|
+
// Paging an unordered result gives an arbitrary slice of the groups; ordering by the
|
|
462
|
+
// group columns at least makes the same query return the same slice.
|
|
463
|
+
orderSql = "ORDER BY " + byColumns;
|
|
464
|
+
}
|
|
465
|
+
if (!orderSql.isEmpty()) {
|
|
466
|
+
sql.append(' ').append(orderSql);
|
|
467
|
+
}
|
|
468
|
+
if (paging) {
|
|
469
|
+
sql.append(dialect.pagination(take == null ? 1 : take, skip, orderSql));
|
|
470
|
+
}
|
|
471
|
+
return adapter.exec(sql.toString(), where.params);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ─── Vector search ──────────────────────────────────────────────────────────────
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Nearest rows to {@code vector}, by the database's own vector functions where it has
|
|
478
|
+
* them and in memory where it does not.
|
|
479
|
+
*
|
|
480
|
+
* <p>SQLite has no vector operators at all, so it goes straight to the fallback rather
|
|
481
|
+
* than building SQL that will only throw.
|
|
482
|
+
*/
|
|
483
|
+
public List<Map<String, Object>> vectorSearch(
|
|
484
|
+
double[] vector, Integer take, Map<String, Object> where, String vectorField, String metric)
|
|
485
|
+
throws SQLException {
|
|
486
|
+
int limit = take == null ? 10 : take;
|
|
487
|
+
String field = vectorField == null || vectorField.isEmpty() ? "embedding" : vectorField;
|
|
488
|
+
String distanceMetric = metric == null || metric.isEmpty() ? "cosine" : metric;
|
|
489
|
+
|
|
490
|
+
Map<String, Object> params = new LinkedHashMap<String, Object>();
|
|
491
|
+
SqlBuilder.Where clause = SqlBuilder.parseWhere(where, dialect, params, "");
|
|
492
|
+
|
|
493
|
+
if (dialect == Dialect.SQLITE) {
|
|
494
|
+
// SQLite ranks through sqlite-vec, a registered distance function or json_each,
|
|
495
|
+
// whichever this connection supports, so the table never has to leave the database.
|
|
496
|
+
List<Map<String, Object>> ranked =
|
|
497
|
+
sqliteVectorSearch(vector, limit, field, distanceMetric, clause);
|
|
498
|
+
if (ranked != null) {
|
|
499
|
+
return ranked;
|
|
500
|
+
}
|
|
501
|
+
// No in-database strategy: score the column in Java instead.
|
|
502
|
+
return scoreVectorsInMemory(vector, limit, field, distanceMetric, where);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
try {
|
|
506
|
+
return nativeVectorSearch(vector, limit, field, distanceMetric, clause);
|
|
507
|
+
} catch (SQLException ignored) {
|
|
508
|
+
// The engine may have no vector extension installed at all. Falling through to the
|
|
509
|
+
// in-memory path is the documented behaviour, not a silent success.
|
|
510
|
+
}
|
|
511
|
+
return scoreVectorsInMemory(vector, limit, field, distanceMetric, where);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* SQLite ranks a {@code VECTOR(n)} column inside the database.
|
|
516
|
+
*
|
|
517
|
+
* <p>Returns {@code null} when the connection offers no strategy, so the caller falls back to
|
|
518
|
+
* scoring in Java.
|
|
519
|
+
*/
|
|
520
|
+
private List<Map<String, Object>> sqliteVectorSearch(
|
|
521
|
+
double[] vector, int limit, String field, String metric, SqlBuilder.Where where) {
|
|
522
|
+
String column = dialect.quoteIdentifier(field);
|
|
523
|
+
String tail = where.isEmpty() ? "" : " WHERE " + where.sql;
|
|
524
|
+
SqliteVectors.Capabilities capabilities = adapter.sqliteVectorCapabilities();
|
|
525
|
+
String declared = adapter.sqliteColumnType(Metadata.resolveTable(modelName), field);
|
|
526
|
+
|
|
527
|
+
for (String strategy :
|
|
528
|
+
SqliteVectors.planStrategies(capabilities, declared, adapter.vectorStrategy())) {
|
|
529
|
+
List<Object> bind = new ArrayList<Object>();
|
|
530
|
+
if (SqliteVectors.STRATEGY_SQL.equals(strategy)) {
|
|
531
|
+
try {
|
|
532
|
+
List<Map<String, Object>> binary = adapter.exec("SELECT 1 FROM " + tableSql()
|
|
533
|
+
+ (tail.isEmpty() ? " WHERE " : tail + " AND ")
|
|
534
|
+
+ "typeof(" + column + ") = 'blob' LIMIT 1", where.params);
|
|
535
|
+
if (!binary.isEmpty()) continue;
|
|
536
|
+
} catch (SQLException ignored) { continue; }
|
|
537
|
+
}
|
|
538
|
+
String sql =
|
|
539
|
+
SqliteVectors.buildRankingQuery(
|
|
540
|
+
strategy, metric, tableSql(), column, vector, limit, tail,
|
|
541
|
+
dialect.placeholder(), bind);
|
|
542
|
+
if (sql == null) {
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
// The strategy's own placeholders sit inside the ranked subquery, so their values lead
|
|
546
|
+
// the list; the caller's WHERE values follow, because its placeholders come after them.
|
|
547
|
+
List<Object> values = new ArrayList<Object>(bind);
|
|
548
|
+
values.addAll(where.params);
|
|
549
|
+
try {
|
|
550
|
+
return decodeVectorRows(adapter.exec(sql, values));
|
|
551
|
+
} catch (SQLException ignored) {
|
|
552
|
+
// The connection advertised the capability but the query failed, e.g. an extension
|
|
553
|
+
// that did not really load. The next strategy is cheaper than reporting the failure.
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return null;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Ranks the vector column in Java.
|
|
561
|
+
*
|
|
562
|
+
* <p>The last resort for every provider, and the only option on a SQLite connection that
|
|
563
|
+
* offers no in-database strategy. The whole table is loaded, so it is only reasonable for a
|
|
564
|
+
* table small enough to fit in memory.
|
|
565
|
+
*/
|
|
566
|
+
private List<Map<String, Object>> scoreVectorsInMemory(
|
|
567
|
+
double[] vector, int limit, String field, String metric, Map<String, Object> where)
|
|
568
|
+
throws SQLException {
|
|
569
|
+
int dimension = vector.length;
|
|
570
|
+
An5Query query = where == null ? new An5Query() : new An5Query().where(where);
|
|
571
|
+
List<Map<String, Object>> rows = findMany(query);
|
|
572
|
+
List<Integer> scored = new ArrayList<Integer>();
|
|
573
|
+
List<Double> distances = new ArrayList<Double>();
|
|
574
|
+
for (int i = 0; i < rows.size(); i++) {
|
|
575
|
+
double[] stored = SqliteVectors.decodeVector(rows.get(i).get(field), dimension);
|
|
576
|
+
if (stored == null) {
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
scored.add(i);
|
|
580
|
+
distances.add(Vectors.distance(metric, vector, stored));
|
|
581
|
+
}
|
|
582
|
+
Comparator<Integer> byDistance =
|
|
583
|
+
new Comparator<Integer>() {
|
|
584
|
+
@Override
|
|
585
|
+
public int compare(Integer left, Integer right) {
|
|
586
|
+
return Double.compare(distances.get(left), distances.get(right));
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
Collections.sort(scored, byDistance);
|
|
590
|
+
|
|
591
|
+
List<Map<String, Object>> out = new ArrayList<Map<String, Object>>();
|
|
592
|
+
for (int i = 0; i < scored.size() && i < limit; i++) {
|
|
593
|
+
Map<String, Object> row = new LinkedHashMap<String, Object>(rows.get(scored.get(i)));
|
|
594
|
+
row.put("distance", distances.get(scored.get(i)));
|
|
595
|
+
out.add(row);
|
|
596
|
+
}
|
|
597
|
+
return out;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
private List<Map<String, Object>> nativeVectorSearch(
|
|
601
|
+
double[] vector, int limit, String field, String metric, SqlBuilder.Where where)
|
|
602
|
+
throws SQLException {
|
|
603
|
+
int dimension = vector.length;
|
|
604
|
+
String quotedField = dialect.quoteIdentifier(field);
|
|
605
|
+
String placeholder = dialect.placeholder();
|
|
606
|
+
StringBuilder sql = new StringBuilder();
|
|
607
|
+
|
|
608
|
+
if (dialect == Dialect.POSTGRES) {
|
|
609
|
+
String operator;
|
|
610
|
+
if ("euclidean".equalsIgnoreCase(metric)) {
|
|
611
|
+
operator = "<->";
|
|
612
|
+
} else if ("dot".equalsIgnoreCase(metric)) {
|
|
613
|
+
operator = "<#>";
|
|
614
|
+
} else {
|
|
615
|
+
operator = "<=>";
|
|
616
|
+
}
|
|
617
|
+
sql.append("SELECT *, (")
|
|
618
|
+
.append(quotedField)
|
|
619
|
+
.append(' ')
|
|
620
|
+
.append(operator)
|
|
621
|
+
.append(' ')
|
|
622
|
+
.append(placeholder)
|
|
623
|
+
.append("::vector) AS distance FROM ")
|
|
624
|
+
.append(tableSql());
|
|
625
|
+
sql.append(" WHERE ").append(quotedField).append(" IS NOT NULL");
|
|
626
|
+
if (!where.isEmpty()) {
|
|
627
|
+
sql.append(" AND (").append(where.sql).append(')');
|
|
628
|
+
}
|
|
629
|
+
sql.append(" ORDER BY distance ASC LIMIT ").append(limit);
|
|
630
|
+
} else {
|
|
631
|
+
sql.append("SELECT TOP (")
|
|
632
|
+
.append(limit)
|
|
633
|
+
.append(") *, VECTOR_DISTANCE('")
|
|
634
|
+
.append(metric)
|
|
635
|
+
.append("', CAST(")
|
|
636
|
+
.append(quotedField)
|
|
637
|
+
.append(" AS VECTOR(")
|
|
638
|
+
.append(dimension)
|
|
639
|
+
.append(", float32)), CAST(")
|
|
640
|
+
.append(placeholder)
|
|
641
|
+
.append(" AS VECTOR(")
|
|
642
|
+
.append(dimension)
|
|
643
|
+
.append(", float32))) AS distance FROM ")
|
|
644
|
+
.append(tableSql())
|
|
645
|
+
.append(nolock());
|
|
646
|
+
sql.append(" WHERE ").append(quotedField).append(" IS NOT NULL");
|
|
647
|
+
if (!where.isEmpty()) {
|
|
648
|
+
sql.append(" AND (").append(where.sql).append(')');
|
|
649
|
+
}
|
|
650
|
+
sql.append(" ORDER BY distance ASC");
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// The vector leads the value list because its placeholder leads the SQL.
|
|
654
|
+
List<Object> values = new ArrayList<Object>();
|
|
655
|
+
values.add(Vectors.formatVector(vector));
|
|
656
|
+
values.addAll(where.params);
|
|
657
|
+
return adapter.exec(sql.toString(), values);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// ─── Relations ──────────────────────────────────────────────────────────────────
|
|
661
|
+
|
|
662
|
+
/** Eager-loads relations onto rows already fetched, in place. */
|
|
663
|
+
@SuppressWarnings("unchecked")
|
|
664
|
+
private void resolveIncludes(
|
|
665
|
+
Map<String, RelationDef> relations,
|
|
666
|
+
List<Map<String, Object>> rows,
|
|
667
|
+
Map<String, Object> include)
|
|
668
|
+
throws SQLException {
|
|
669
|
+
if (rows == null || rows.isEmpty() || include == null || include.isEmpty()) {
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
for (Map.Entry<String, Object> entry : include.entrySet()) {
|
|
674
|
+
String key = entry.getKey();
|
|
675
|
+
Object value = entry.getValue();
|
|
676
|
+
if (value == null || Boolean.FALSE.equals(value)) {
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if ("_count".equals(key)) {
|
|
681
|
+
applyRelationCounts(relations, rows);
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
RelationDef relation = relations.get(key);
|
|
686
|
+
if (relation == null) {
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
boolean isMany = relation.isMany();
|
|
690
|
+
String joinKey = isMany ? relation.localKey : relation.foreignKey;
|
|
691
|
+
String matchKey = isMany ? relation.foreignKey : relation.localKey;
|
|
692
|
+
|
|
693
|
+
List<Object> uniqueKeys = uniqueKeys(rows, joinKey);
|
|
694
|
+
if (uniqueKeys.isEmpty()) {
|
|
695
|
+
for (Map<String, Object> row : rows) {
|
|
696
|
+
row.put(key, isMany ? new ArrayList<Object>() : null);
|
|
697
|
+
}
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
Map<String, Object> nestedWhere = single(matchKey, single("in", uniqueKeys));
|
|
702
|
+
An5Query nested = new An5Query().where(nestedWhere);
|
|
703
|
+
if (value instanceof Map) {
|
|
704
|
+
Map<String, Object> options = (Map<String, Object>) value;
|
|
705
|
+
Object extra = options.get("where");
|
|
706
|
+
if (extra instanceof Map) {
|
|
707
|
+
for (Map.Entry<String, Object> condition : ((Map<String, Object>) extra).entrySet()) {
|
|
708
|
+
nestedWhere.put(condition.getKey(), condition.getValue());
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
if (options.get("orderBy") != null) {
|
|
712
|
+
nested.orderBy(options.get("orderBy"));
|
|
713
|
+
}
|
|
714
|
+
if (options.get("skip") instanceof Number) {
|
|
715
|
+
nested.skip(((Number) options.get("skip")).intValue());
|
|
716
|
+
}
|
|
717
|
+
if (options.get("take") instanceof Number) {
|
|
718
|
+
nested.take(((Number) options.get("take")).intValue());
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
List<Map<String, Object>> related = adapter.table(relation.modelName).findMany(nested);
|
|
723
|
+
List<Map<String, Object>> projected = related;
|
|
724
|
+
if (value instanceof Map) {
|
|
725
|
+
Map<String, Object> options = (Map<String, Object>) value;
|
|
726
|
+
if (options.get("include") instanceof Map) {
|
|
727
|
+
resolveIncludes(Metadata.relationsFor(relation.modelName), related, (Map<String, Object>) options.get("include"));
|
|
728
|
+
}
|
|
729
|
+
if (options.get("select") instanceof List) {
|
|
730
|
+
List<String> fields = new ArrayList<String>();
|
|
731
|
+
for (Object field : (List<?>) options.get("select")) {
|
|
732
|
+
if (field != null && !String.valueOf(field).isEmpty()) {
|
|
733
|
+
fields.add(String.valueOf(field));
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
projected = projectFields(related, fields);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
Map<Object, List<Map<String, Object>>> groups = new LinkedHashMap<Object, List<Map<String, Object>>>();
|
|
741
|
+
String groupColumn = isMany ? relation.foreignKey : relation.localKey;
|
|
742
|
+
for (int i = 0; i < related.size(); i++) {
|
|
743
|
+
Object groupKey = related.get(i).get(groupColumn);
|
|
744
|
+
if (!groups.containsKey(groupKey)) {
|
|
745
|
+
groups.put(groupKey, new ArrayList<Map<String, Object>>());
|
|
746
|
+
}
|
|
747
|
+
groups.get(groupKey).add(projected.get(i));
|
|
748
|
+
}
|
|
749
|
+
String rowColumn = isMany ? relation.localKey : relation.foreignKey;
|
|
750
|
+
for (Map<String, Object> row : rows) {
|
|
751
|
+
List<Map<String, Object>> matches = groups.get(row.get(rowColumn));
|
|
752
|
+
if (isMany) {
|
|
753
|
+
row.put(key, matches == null ? new ArrayList<Object>() : matches);
|
|
754
|
+
} else {
|
|
755
|
+
row.put(key, matches == null || matches.isEmpty() ? null : matches.get(0));
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
@SuppressWarnings("unchecked")
|
|
762
|
+
private void applyRelationCounts(
|
|
763
|
+
Map<String, RelationDef> relations, List<Map<String, Object>> rows) throws SQLException {
|
|
764
|
+
for (Map<String, Object> row : rows) {
|
|
765
|
+
row.put("_count", new LinkedHashMap<String, Object>());
|
|
766
|
+
}
|
|
767
|
+
for (Map.Entry<String, RelationDef> entry : relations.entrySet()) {
|
|
768
|
+
RelationDef relation = entry.getValue();
|
|
769
|
+
if (!relation.isMany()) {
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
List<Object> uniqueKeys = uniqueKeys(rows, relation.localKey);
|
|
773
|
+
if (uniqueKeys.isEmpty()) {
|
|
774
|
+
for (Map<String, Object> row : rows) {
|
|
775
|
+
((Map<String, Object>) row.get("_count")).put(entry.getKey(), 0);
|
|
776
|
+
}
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
Map<String, Integer> counts = new LinkedHashMap<String, Integer>();
|
|
780
|
+
List<Map<String, Object>> related =
|
|
781
|
+
adapter
|
|
782
|
+
.table(relation.modelName)
|
|
783
|
+
.findMany(new An5Query().where(single(relation.foreignKey, single("in", uniqueKeys))));
|
|
784
|
+
for (Map<String, Object> row : related) {
|
|
785
|
+
Object key = row.get(relation.foreignKey);
|
|
786
|
+
if (key == null) {
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
String asString = String.valueOf(key);
|
|
790
|
+
counts.put(asString, counts.containsKey(asString) ? counts.get(asString) + 1 : 1);
|
|
791
|
+
}
|
|
792
|
+
for (Map<String, Object> row : rows) {
|
|
793
|
+
Object key = row.get(relation.localKey);
|
|
794
|
+
Integer count = key == null ? null : counts.get(String.valueOf(key));
|
|
795
|
+
((Map<String, Object>) row.get("_count")).put(entry.getKey(), count == null ? 0 : count);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** The relation objects in a write payload, keyed by relation name. */
|
|
801
|
+
private Map<String, Object> splitRelationWrites(Map<String, Object> data) {
|
|
802
|
+
Map<String, RelationDef> relations = Metadata.relationsFor(modelName);
|
|
803
|
+
Map<String, Object> relationWrites = new LinkedHashMap<String, Object>();
|
|
804
|
+
for (Map.Entry<String, Object> entry : data.entrySet()) {
|
|
805
|
+
if (relations.containsKey(entry.getKey()) && entry.getValue() instanceof Map) {
|
|
806
|
+
relationWrites.put(entry.getKey(), entry.getValue());
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
return relationWrites;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* The write payload with the relation objects removed.
|
|
814
|
+
*
|
|
815
|
+
* <p>A relation has no column, so leaving its key in the payload would put it into the
|
|
816
|
+
* {@code INSERT} list and the database would reject the column name.
|
|
817
|
+
*/
|
|
818
|
+
private Map<String, Object> withoutRelations(Map<String, Object> data) {
|
|
819
|
+
Map<String, Object> scalars = new LinkedHashMap<String, Object>(data);
|
|
820
|
+
for (String key : splitRelationWrites(data).keySet()) {
|
|
821
|
+
scalars.remove(key);
|
|
822
|
+
}
|
|
823
|
+
return scalars;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
@SuppressWarnings("unchecked")
|
|
827
|
+
private void applyRelationCreates(Map<String, Object> parent, Map<String, Object> relationWrites)
|
|
828
|
+
throws SQLException {
|
|
829
|
+
Map<String, RelationDef> relations = Metadata.relationsFor(modelName);
|
|
830
|
+
for (Map.Entry<String, Object> entry : relationWrites.entrySet()) {
|
|
831
|
+
RelationDef relation = relations.get(entry.getKey());
|
|
832
|
+
if (relation == null || !(entry.getValue() instanceof Map)) {
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
835
|
+
Object create = ((Map<String, Object>) entry.getValue()).get("create");
|
|
836
|
+
if (create == null) {
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
for (Object item : toList(create)) {
|
|
840
|
+
if (!(item instanceof Map)) {
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
Map<String, Object> merged = new LinkedHashMap<String, Object>((Map<String, Object>) item);
|
|
844
|
+
merged.put(relation.foreignKey, parent == null ? null : parent.get(relation.localKey));
|
|
845
|
+
adapter.table(relation.modelName).create(merged);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
@SuppressWarnings("unchecked")
|
|
851
|
+
private void applyRelationUpdates(
|
|
852
|
+
Map<String, Object> parent, Map<String, Object> where, Map<String, Object> relationWrites)
|
|
853
|
+
throws SQLException {
|
|
854
|
+
Map<String, RelationDef> relations = Metadata.relationsFor(modelName);
|
|
855
|
+
for (Map.Entry<String, Object> entry : relationWrites.entrySet()) {
|
|
856
|
+
RelationDef relation = relations.get(entry.getKey());
|
|
857
|
+
if (relation == null || !(entry.getValue() instanceof Map)) {
|
|
858
|
+
continue;
|
|
859
|
+
}
|
|
860
|
+
Map<String, Object> write = (Map<String, Object>) entry.getValue();
|
|
861
|
+
An5TableClient child = adapter.table(relation.modelName);
|
|
862
|
+
|
|
863
|
+
Object create = write.get("create");
|
|
864
|
+
if (create != null) {
|
|
865
|
+
for (Object item : toList(create)) {
|
|
866
|
+
if (!(item instanceof Map)) {
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
Map<String, Object> merged = new LinkedHashMap<String, Object>((Map<String, Object>) item);
|
|
870
|
+
merged.put(relation.foreignKey, parent == null ? null : parent.get(relation.localKey));
|
|
871
|
+
child.create(merged);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
Object update = write.get("update");
|
|
876
|
+
if (update instanceof Map) {
|
|
877
|
+
Map<String, Object> spec = (Map<String, Object>) update;
|
|
878
|
+
Object nestedWhere = spec.get("where");
|
|
879
|
+
Object nestedData = spec.get("data");
|
|
880
|
+
// Falls back to the parent's filter, never to "no filter": a relation update with no
|
|
881
|
+
// filter of its own would otherwise rewrite every row of the related table.
|
|
882
|
+
child.update(
|
|
883
|
+
nestedWhere instanceof Map ? (Map<String, Object>) nestedWhere : where,
|
|
884
|
+
nestedData instanceof Map
|
|
885
|
+
? (Map<String, Object>) nestedData
|
|
886
|
+
: new LinkedHashMap<String, Object>());
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
Object disconnect = write.get("disconnect");
|
|
890
|
+
if (disconnect instanceof Map) {
|
|
891
|
+
child.updateMany((Map<String, Object>) disconnect, single(relation.foreignKey, null));
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
private Map<String, Object> projectAfterWrite(Map<String, Object> row, An5Query query)
|
|
897
|
+
throws SQLException {
|
|
898
|
+
if (row == null) {
|
|
899
|
+
return null;
|
|
900
|
+
}
|
|
901
|
+
List<Map<String, Object>> rows = new ArrayList<Map<String, Object>>();
|
|
902
|
+
rows.add(row);
|
|
903
|
+
Map<String, RelationDef> relations = Metadata.relationsFor(modelName);
|
|
904
|
+
if (query.includeValue() != null && !query.includeValue().isEmpty()) {
|
|
905
|
+
resolveIncludes(relations, rows, query.includeValue());
|
|
906
|
+
}
|
|
907
|
+
if (query.selectValue() != null) {
|
|
908
|
+
resolveIncludes(relations, rows, relationSelect(query.selectValue(), relations));
|
|
909
|
+
return projectFields(rows, query.selectValue()).get(0);
|
|
910
|
+
}
|
|
911
|
+
return row;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// ─── Row helpers ────────────────────────────────────────────────────────────────
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* The same query with a row cap, leaving the caller's query untouched.
|
|
918
|
+
*
|
|
919
|
+
* <p>Mutating it instead would silently cap every later use of the same object, so a
|
|
920
|
+
* {@code findFirst} would quietly limit a caller's subsequent {@code findMany}.
|
|
921
|
+
*/
|
|
922
|
+
private static An5Query limitedTo(An5Query query, int take) {
|
|
923
|
+
return new An5Query()
|
|
924
|
+
.where(query.whereMap())
|
|
925
|
+
.orderBy(query.orderByValue())
|
|
926
|
+
.skip(query.skipValue())
|
|
927
|
+
.take(take)
|
|
928
|
+
.select(query.selectValue() == null ? new String[0] : query.selectValue().toArray(new String[0]))
|
|
929
|
+
.include(query.includeValue());
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
private static List<Object> uniqueKeys(List<Map<String, Object>> rows, String column) {
|
|
933
|
+
Set<Object> seen = new LinkedHashSet<Object>();
|
|
934
|
+
for (Map<String, Object> row : rows) {
|
|
935
|
+
Object value = row.get(column);
|
|
936
|
+
// Numbers are normalised so that a key read as an int in one row and a long in the
|
|
937
|
+
// next does not turn into two `IN` values for the same row.
|
|
938
|
+
if (value != null) {
|
|
939
|
+
seen.add(value instanceof Number ? ((Number) value).doubleValue() : value);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
return new ArrayList<Object>(seen);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/** The columns, quoted for this dialect and joined into a comma-separated list. */
|
|
946
|
+
private String quoteAll(List<String> columns) {
|
|
947
|
+
List<String> quoted = new ArrayList<String>();
|
|
948
|
+
for (String column : columns) {
|
|
949
|
+
quoted.add(dialect.quoteIdentifier(column));
|
|
950
|
+
}
|
|
951
|
+
return join(quoted, ", ");
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
private static boolean hasRelationSelect(
|
|
955
|
+
List<String> selected, Map<String, RelationDef> relations) {
|
|
956
|
+
for (String field : selected) {
|
|
957
|
+
if ("_count".equals(field) || relations.containsKey(field)) {
|
|
958
|
+
return true;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
return false;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/** The relation entries of a {@code select} list, as the shape {@code resolveIncludes} takes. */
|
|
965
|
+
private static Map<String, Object> relationSelect(
|
|
966
|
+
List<String> selected, Map<String, RelationDef> relations) {
|
|
967
|
+
Map<String, Object> include = new LinkedHashMap<String, Object>();
|
|
968
|
+
for (String field : selected) {
|
|
969
|
+
if ("_count".equals(field) || relations.containsKey(field)) {
|
|
970
|
+
include.put(field, Boolean.TRUE);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
return include;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
private static List<Map<String, Object>> projectFields(
|
|
977
|
+
List<Map<String, Object>> rows, List<String> selected) {
|
|
978
|
+
if (selected == null || selected.isEmpty()) {
|
|
979
|
+
return rows;
|
|
980
|
+
}
|
|
981
|
+
List<Map<String, Object>> projected = new ArrayList<Map<String, Object>>();
|
|
982
|
+
for (Map<String, Object> row : rows) {
|
|
983
|
+
Map<String, Object> kept = new LinkedHashMap<String, Object>();
|
|
984
|
+
for (String field : selected) {
|
|
985
|
+
if (row.containsKey(field)) {
|
|
986
|
+
kept.put(field, row.get(field));
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
if (row.containsKey("_count")) {
|
|
990
|
+
kept.put("_count", row.get("_count"));
|
|
991
|
+
}
|
|
992
|
+
projected.add(kept);
|
|
993
|
+
}
|
|
994
|
+
return projected;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
private static List<Object> toList(Object value) {
|
|
998
|
+
if (value == null) {
|
|
999
|
+
return new ArrayList<Object>();
|
|
1000
|
+
}
|
|
1001
|
+
if (value instanceof List) {
|
|
1002
|
+
return new ArrayList<Object>((List<?>) value);
|
|
1003
|
+
}
|
|
1004
|
+
List<Object> list = new ArrayList<Object>();
|
|
1005
|
+
list.add(value);
|
|
1006
|
+
return list;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
private static Map<String, Object> single(String key, Object value) {
|
|
1010
|
+
Map<String, Object> map = new LinkedHashMap<String, Object>();
|
|
1011
|
+
map.put(key, value);
|
|
1012
|
+
return map;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/** An empty, mutable row map, for callers building one field at a time. */
|
|
1016
|
+
public static Map<String, Object> row() {
|
|
1017
|
+
return new LinkedHashMap<String, Object>();
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
private String join(List<String> parts, String separator) {
|
|
1021
|
+
StringBuilder out = new StringBuilder();
|
|
1022
|
+
for (int i = 0; i < parts.size(); i++) {
|
|
1023
|
+
if (i > 0) {
|
|
1024
|
+
out.append(separator);
|
|
1025
|
+
}
|
|
1026
|
+
out.append(parts.get(i));
|
|
1027
|
+
}
|
|
1028
|
+
return out.toString();
|
|
1029
|
+
}
|
|
1030
|
+
}
|