@an5/adapters 0.2.11 → 0.2.13

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.
Files changed (91) hide show
  1. package/README.md +133 -2
  2. package/dist/an5Adapter.d.ts +37 -0
  3. package/dist/an5Adapter.js +138 -35
  4. package/dist/base/types.d.ts +14 -0
  5. package/dist/index.d.ts +2 -0
  6. package/dist/index.js +8 -1
  7. package/dist/sqlite/browserEngine.d.ts +13 -0
  8. package/dist/sqlite/browserEngine.js +13 -0
  9. package/dist/sqlite/engine.d.ts +5 -0
  10. package/dist/sqlite/engine.js +10 -0
  11. package/dist/sqlite/index.d.ts +1 -0
  12. package/dist/sqlite/index.js +1 -0
  13. package/dist/sqlite/vector.d.ts +155 -0
  14. package/dist/sqlite/vector.js +436 -0
  15. package/dotnet/An5Adapter.cs +85 -27
  16. package/dotnet/Base/Core.cs +14 -0
  17. package/dotnet/Mssql/MssqlEngine.cs +44 -1
  18. package/dotnet/Postgres/PostgresEngine.cs +2 -0
  19. package/dotnet/Sqlite/SqliteEngine.cs +82 -2
  20. package/dotnet/Sqlite/SqliteVectors.cs +401 -0
  21. package/golang/an5_adapter.go +84 -32
  22. package/golang/base/vectors.go +263 -0
  23. package/golang/sqlite/vector.go +248 -0
  24. package/golang/sqlite_vector_test.go +288 -0
  25. package/golang/table_client.go +67 -3
  26. package/java/pom.xml +177 -0
  27. package/java/src/main/java/an5/adapters/An5Adapter.java +374 -0
  28. package/java/src/main/java/an5/adapters/An5Aggregate.java +116 -0
  29. package/java/src/main/java/an5/adapters/An5GroupBy.java +97 -0
  30. package/java/src/main/java/an5/adapters/An5Query.java +100 -0
  31. package/java/src/main/java/an5/adapters/An5TableClient.java +1031 -0
  32. package/java/src/main/java/an5/adapters/An5ViewClient.java +107 -0
  33. package/java/src/main/java/an5/adapters/base/Dialect.java +103 -0
  34. package/java/src/main/java/an5/adapters/base/FieldMeta.java +42 -0
  35. package/java/src/main/java/an5/adapters/base/JdbcUrls.java +201 -0
  36. package/java/src/main/java/an5/adapters/base/Metadata.java +195 -0
  37. package/java/src/main/java/an5/adapters/base/RelationDef.java +26 -0
  38. package/java/src/main/java/an5/adapters/base/SqlBuilder.java +422 -0
  39. package/java/src/main/java/an5/adapters/base/SqliteVectors.java +375 -0
  40. package/java/src/main/java/an5/adapters/base/Vectors.java +123 -0
  41. package/java/src/test/java/An5AdaptersSmoke.java +733 -0
  42. package/java/src/test/java/Json.java +174 -0
  43. package/java/src/test/java/QuerySemantics.java +111 -0
  44. package/kotlin/build.gradle.kts +145 -0
  45. package/kotlin/settings.gradle.kts +10 -0
  46. package/kotlin/src/main/kotlin/an5/adapters/Aggregate.kt +89 -0
  47. package/kotlin/src/main/kotlin/an5/adapters/An5.kt +91 -0
  48. package/kotlin/src/main/kotlin/an5/adapters/Query.kt +130 -0
  49. package/kotlin/src/main/kotlin/an5/adapters/Rows.kt +166 -0
  50. package/kotlin/src/main/kotlin/an5/adapters/TableClient.kt +169 -0
  51. package/kotlin/src/main/kotlin/an5/adapters/Types.kt +77 -0
  52. package/kotlin/src/test/kotlin/an5/adapters/An5Smoke.kt +329 -0
  53. package/native/sqlite/CMakeLists.txt +12 -0
  54. package/native/sqlite/README.md +117 -0
  55. package/native/sqlite/an5_vector.c +209 -0
  56. package/package.json +33 -7
  57. package/python/an5_adapter.py +37 -6
  58. package/python/base/__init__.py +15 -0
  59. package/python/base/vectors.py +434 -0
  60. package/python/sqlite/__init__.py +2 -2
  61. package/python/sqlite/provider.py +21 -1
  62. package/python/table_client.py +129 -59
  63. package/rust/src/adapter.rs +342 -24
  64. package/rust/src/base/mod.rs +7 -0
  65. package/rust/src/base/vectors.rs +293 -0
  66. package/rust/src/lib.rs +3 -3
  67. package/scripts/sqlite-native-build.js +32 -0
  68. package/swift/Package.swift +22 -0
  69. package/swift/Sources/An5Adapters/An5Adapter.swift +160 -0
  70. package/swift/Sources/An5Adapters/Metadata.swift +183 -0
  71. package/swift/Sources/An5Adapters/Query.swift +105 -0
  72. package/swift/Sources/An5Adapters/Row+Accessors.swift +129 -0
  73. package/swift/Sources/An5Adapters/SQLBuilder.swift +359 -0
  74. package/swift/Sources/An5Adapters/SQLiteDriver.swift +301 -0
  75. package/swift/Sources/An5Adapters/SqliteVectors.swift +243 -0
  76. package/swift/Sources/An5Adapters/TableClient.swift +671 -0
  77. package/swift/Sources/An5Adapters/Types.swift +127 -0
  78. package/swift/Sources/An5Adapters/Vectors.swift +86 -0
  79. package/swift/Sources/An5Adapters/ViewClient.swift +50 -0
  80. package/swift/Sources/CSQLite/module.modulemap +8 -0
  81. package/swift/Sources/CSQLite/shim.h +9 -0
  82. package/swift/Tests/An5AdaptersTests/QuerySemanticsTests.swift +51 -0
  83. package/swift/Tests/An5AdaptersTests/RuntimeSmokeTests.swift +488 -0
  84. package/typescript/src/an5Adapter.ts +146 -29
  85. package/typescript/src/base/types.ts +15 -0
  86. package/typescript/src/index.ts +13 -0
  87. package/typescript/src/sqlite/browserEngine.ts +24 -0
  88. package/typescript/src/sqlite/engine.ts +13 -0
  89. package/typescript/src/sqlite/index.ts +1 -1
  90. package/typescript/src/sqlite/vector.ts +541 -0
  91. package/golang/sqlite/sqlite.go +0 -14
@@ -0,0 +1,733 @@
1
+ import an5.adapters.An5Adapter;
2
+ import an5.adapters.An5Aggregate;
3
+ import an5.adapters.An5GroupBy;
4
+ import an5.adapters.An5Query;
5
+ import an5.adapters.An5TableClient;
6
+ import an5.adapters.An5ViewClient;
7
+ import an5.adapters.base.Dialect;
8
+ import an5.adapters.base.JdbcUrls;
9
+ import an5.adapters.base.Metadata;
10
+ import an5.adapters.base.SqliteVectors;
11
+ import an5.adapters.base.Vectors;
12
+ import java.util.ArrayList;
13
+ import java.util.LinkedHashMap;
14
+ import java.util.List;
15
+ import java.util.Map;
16
+
17
+ /**
18
+ * End-to-end exercise of the Java adapter against a real in-memory SQLite database.
19
+ *
20
+ * <p>Covers the parts a compile check cannot reach: identifier quoting per dialect, the
21
+ * pagination forms, generated primary keys, nested relation writes, transaction rollback,
22
+ * and the in-memory vector fallback. Run by {@code scripts/java-sqlite-smoke.js}.
23
+ */
24
+ public final class An5AdaptersSmoke {
25
+
26
+ private static int failures;
27
+
28
+ private An5AdaptersSmoke() {}
29
+
30
+ public static void main(String[] args) throws Exception {
31
+ Class.forName("org.sqlite.JDBC");
32
+
33
+ checkDialectDetection();
34
+ checkJdbcUrls();
35
+ checkQuoting();
36
+ checkVectors();
37
+ crud();
38
+ relations();
39
+ aggregation();
40
+ transactionRollback();
41
+ viewIsReadOnly();
42
+ vectorFallback();
43
+ sqliteVectorCodecAndPlan();
44
+ sqliteVectorSearch();
45
+ sqliteVectorColumnRoundTrip();
46
+
47
+ if (failures > 0) {
48
+ System.out.println("java smoke: " + failures + " check(s) failed");
49
+ System.exit(1);
50
+ }
51
+ System.out.println("java smoke: all checks passed");
52
+ }
53
+
54
+ // ─── Units ──────────────────────────────────────────────────────────────────────
55
+
56
+ private static void checkDialectDetection() {
57
+ equals("mssql adoc", Dialect.MSSQL, Dialect.detect("Server=db;Database=app"));
58
+ equals("postgres scheme", Dialect.POSTGRES, Dialect.detect("postgresql://localhost/app"));
59
+ equals("sqlite scheme", Dialect.SQLITE, Dialect.detect("sqlite:///tmp/app.db"));
60
+ equals("sqlite suffix", Dialect.SQLITE, Dialect.detect("/tmp/app.sqlite3"));
61
+ equals("sqlite memory", Dialect.SQLITE, Dialect.detect(":memory:"));
62
+ }
63
+
64
+ private static void checkJdbcUrls() {
65
+ equals(
66
+ "sqlite url strips wrapper",
67
+ "jdbc:sqlite::memory:",
68
+ JdbcUrls.url("sqlite:///:memory:"));
69
+ equals(
70
+ "postgres url gains jdbc prefix",
71
+ "jdbc:postgresql://localhost/app",
72
+ JdbcUrls.url("postgres://localhost/app"));
73
+ equals(
74
+ "mssql adoc becomes a jdbc url",
75
+ "jdbc:sqlserver://db.internal:1434;databaseName=app;encrypt=true;trustServerCertificate=true",
76
+ JdbcUrls.url("Server=db.internal,1434;Database=app"));
77
+ equals("memory detected", true, JdbcUrls.isMemory("sqlite::memory:"));
78
+ equals("file is not memory", false, JdbcUrls.isMemory("/tmp/app.db"));
79
+ equals("credentials read", "sa", JdbcUrls.credentials("Server=db;User=sa;Password=pw")[0]);
80
+ }
81
+
82
+ private static void checkQuoting() {
83
+ equals("mssql identifier", "[name]", Dialect.MSSQL.quoteIdentifier("name"));
84
+ equals("mssql escapes bracket", "[we]]ird]", Dialect.MSSQL.quoteIdentifier("we]ird"));
85
+ equals("sqlite identifier", "\"name\"", Dialect.SQLITE.quoteIdentifier("[name]"));
86
+ equals("postgres escapes quote", "\"we\"\"ird\"", Dialect.POSTGRES.quoteIdentifier("we\"ird"));
87
+ equals(
88
+ "sqlite drops the dbo schema",
89
+ "\"users\"",
90
+ an5.adapters.base.SqlBuilder.quoteTable("dbo.users", Dialect.SQLITE));
91
+ equals(
92
+ "mssql keeps the dbo schema",
93
+ "[dbo].[users]",
94
+ an5.adapters.base.SqlBuilder.quoteTable("dbo.users", Dialect.MSSQL));
95
+ equals(
96
+ "sqlite limit",
97
+ " LIMIT 10 OFFSET 5",
98
+ Dialect.SQLITE.pagination(Integer.valueOf(10), 5, "ORDER BY id"));
99
+ equals(
100
+ "mssql fetch",
101
+ " OFFSET 5 ROWS FETCH NEXT 10 ROWS ONLY",
102
+ Dialect.MSSQL.pagination(Integer.valueOf(10), 5, "ORDER BY id"));
103
+ equals(
104
+ "mssql fetch without order",
105
+ " ORDER BY (SELECT NULL) OFFSET 5 ROWS FETCH NEXT 10 ROWS ONLY",
106
+ Dialect.MSSQL.pagination(Integer.valueOf(10), 5, ""));
107
+ }
108
+
109
+ private static void checkVectors() {
110
+ approximately("cosine of identical vectors", 1.0, Vectors.cosineSimilarity(new double[] {1, 2}, new double[] {1, 2}));
111
+ approximately("cosine of opposites", -1.0, Vectors.cosineSimilarity(new double[] {1, 0}, new double[] {-1, 0}));
112
+ equals("cosine of mismatched lengths", 0.0, Vectors.cosineSimilarity(new double[] {1}, new double[] {1, 2}));
113
+ approximately("euclidean", 5.0, Vectors.euclideanDistance(new double[] {0, 0}, new double[] {3, 4}));
114
+ equals("dot", 11.0, Vectors.dotProduct(new double[] {1, 2}, new double[] {3, 4}));
115
+ approximately("dot distance is negated", -11.0, Vectors.distance("dot", new double[] {1, 2}, new double[] {3, 4}));
116
+ equals("vector parses", 3.0, Vectors.parseVector("[1.0, 2.0, 3.0]", 3)[2]);
117
+ equals("vector rejects wrong length", null, Vectors.parseVector("[1.0, 2.0]", 3));
118
+ equals("vector format", "[1.0, 2.5]", Vectors.formatVector(new double[] {1, 2.5}));
119
+ }
120
+
121
+ // ─── CRUD ───────────────────────────────────────────────────────────────────────
122
+
123
+ private static void crud() throws Exception {
124
+ An5Adapter adapter = open();
125
+ try {
126
+ An5TableClient users = adapter.table("User");
127
+ Map<String, Object> created = users.create(map("name", "Ada", "score", 10));
128
+ String id = String.valueOf(created.get("id"));
129
+ equals("generated primary key", true, id.length() > 0);
130
+ equals("create returns the stored row", "Ada", created.get("name"));
131
+ equals("count after create", 1L, users.count(null));
132
+
133
+ users.create(map("name", "Grace", "score", 20));
134
+ users.create(map("name", "Alan", "score", 30));
135
+
136
+ List<Map<String, Object>> ordered =
137
+ users.findMany(new An5Query().orderBy("score", "desc"));
138
+ equals("orderBy desc", "Alan", ordered.get(0).get("name"));
139
+
140
+ List<Map<String, Object>> paged =
141
+ users.findMany(new An5Query().orderBy("score", "asc").skip(1).take(1));
142
+ equals("skip and take", "Grace", paged.get(0).get("name"));
143
+ equals("paged result size", 1, paged.size());
144
+
145
+ List<Map<String, Object>> filtered =
146
+ users.findMany(new An5Query().where(map("score", map("gte", 20))));
147
+ equals("gte filter", 2, filtered.size());
148
+
149
+ List<Map<String, Object>> searched =
150
+ users.findMany(new An5Query().where(map("name", map("contains", "da"))));
151
+ equals("contains filter", 1, searched.size());
152
+ equals(
153
+ "endsWith filter",
154
+ 1,
155
+ users.findMany(new An5Query().where(map("name", map("endsWith", "ce")))).size());
156
+
157
+ List<Map<String, Object>> inList =
158
+ users.findMany(new An5Query().where(map("name", map("in", list("Ada", "Grace")))));
159
+ equals("in filter", 2, inList.size());
160
+ equals(
161
+ "empty in matches nothing",
162
+ 0,
163
+ users.findMany(new An5Query().where(map("name", map("in", list())))).size());
164
+ equals(
165
+ "empty notIn matches everything",
166
+ 3,
167
+ users.findMany(new An5Query().where(map("name", map("notIn", list())))).size());
168
+
169
+ List<Map<String, Object>> selected =
170
+ users.findMany(new An5Query().select("name"));
171
+ equals("select projects columns", 1, selected.get(0).size());
172
+ equals("select keeps the named column", "Ada", selected.get(0).get("name"));
173
+
174
+ Map<String, Object> updated = users.update(map("id", id), map("score", map("increment", 5)));
175
+ equals("increment operator", 15, ((Number) updated.get("score")).intValue());
176
+
177
+ equals(
178
+ "updateMany counts rows",
179
+ 3,
180
+ ((Number) users.updateMany(null, map("score", map("increment", 1))).get("count")).intValue());
181
+
182
+ equals(
183
+ "upsert updates an existing row",
184
+ "Ada L",
185
+ users.upsert(map("id", id), map("name", "x"), map("name", "Ada L")).get("name"));
186
+ equals("upsert left the row count alone", 3L, users.count(null));
187
+ users.upsert(map("name", "Katherine"), map("name", "Katherine", "score", 1), map("score", 2));
188
+ equals("upsert created the missing row", 4L, users.count(null));
189
+
190
+ Map<String, Object> deleted = users.delete(map("id", id));
191
+ equals("delete returns the removed row", "Ada L", deleted.get("name"));
192
+ equals("delete removes exactly one row", 3L, users.count(null));
193
+ equals(
194
+ "deleteMany clears the table",
195
+ 3,
196
+ ((Number) users.deleteMany(null).get("count")).intValue());
197
+ equals("table is empty", 0L, users.count(null));
198
+ } finally {
199
+ adapter.close();
200
+ }
201
+ }
202
+
203
+ // ─── Relations ──────────────────────────────────────────────────────────────────
204
+
205
+ private static void relations() throws Exception {
206
+ An5Adapter adapter = open();
207
+ try {
208
+ Map<String, Object> ada = adapter.table("User").create(map("name", "Ada", "score", 1));
209
+ String adaId = String.valueOf(ada.get("id"));
210
+ adapter.table("Post").create(map("title", "first", "userId", adaId));
211
+ adapter.table("Post").create(map("title", "second", "userId", adaId));
212
+ Map<String, Object> grace = adapter.table("User").create(map("name", "Grace", "score", 2));
213
+ adapter.table("Post").create(map("title", "third", "userId", grace.get("id")));
214
+
215
+ Map<String, Object> include = new LinkedHashMap<String, Object>();
216
+ include.put("posts", Boolean.TRUE);
217
+ include.put("_count", Boolean.TRUE);
218
+ List<Map<String, Object>> rows =
219
+ adapter.table("User").findMany(new An5Query().orderBy("name", "asc").include(include));
220
+
221
+ Map<String, Object> first = rows.get(0);
222
+ equals("rows are ordered as asked", "Ada", first.get("name"));
223
+ equals("many relation eager-loaded", 2, ((List<?>) first.get("posts")).size());
224
+ equals(
225
+ "eager-loaded rows are the related models",
226
+ "first",
227
+ ((Map<?, ?>) ((List<?>) first.get("posts")).get(0)).get("title"));
228
+ equals(
229
+ "relation count eager-loaded",
230
+ 2L,
231
+ ((Number) ((Map<?, ?>) first.get("_count")).get("posts")).longValue());
232
+
233
+ Map<String, Object> nestedCreate = new LinkedHashMap<String, Object>();
234
+ nestedCreate.put("create", map("title", "third"));
235
+ Map<String, Object> withPosts = new LinkedHashMap<String, Object>();
236
+ withPosts.put("posts", nestedCreate);
237
+ Map<String, Object> nested = new LinkedHashMap<String, Object>(map("name", "Barbara"));
238
+ nested.putAll(withPosts);
239
+ adapter.table("User").create(nested);
240
+ equals("nested relation create writes the child", 4L, adapter.table("Post").count(null));
241
+ } finally {
242
+ adapter.close();
243
+ }
244
+ }
245
+
246
+ // ─── Aggregation ────────────────────────────────────────────────────────────────
247
+
248
+ private static void aggregation() throws Exception {
249
+ An5Adapter adapter = open();
250
+ try {
251
+ An5TableClient users = adapter.table("User");
252
+ users.create(map("name", "Ada", "score", 10));
253
+ users.create(map("name", "Grace", "score", 20));
254
+ users.create(map("name", "Alan", "score", 30));
255
+
256
+ Map<String, Object> totals = users.aggregate(new An5Aggregate().count().sum("score").avg("score"));
257
+ equals("aggregate count", 3L, ((Number) totals.get("_count")).longValue());
258
+ equals("aggregate sum", 60.0, ((Number) totals.get("_sum_score")).doubleValue());
259
+ equals("aggregate avg", 20.0, ((Number) totals.get("_avg_score")).doubleValue());
260
+
261
+ List<Map<String, Object>> groups =
262
+ users.groupBy(new An5GroupBy().by("name").sum("score"));
263
+ equals("groupBy row per group", 3, groups.size());
264
+ equals("groupBy counts", 1L, ((Number) groups.get(0).get("_count")).longValue());
265
+
266
+ equals("groupBy needs a by column", "groupBy requires at least one 'by' column",
267
+ failure(new Runnable() {
268
+ @Override
269
+ public void run() {
270
+ try {
271
+ users.groupBy(new An5GroupBy());
272
+ } catch (Exception error) {
273
+ throw new RuntimeException(error.getMessage());
274
+ }
275
+ }
276
+ }));
277
+ } finally {
278
+ adapter.close();
279
+ }
280
+ }
281
+
282
+ // ─── Transactions ───────────────────────────────────────────────────────────────
283
+
284
+ private static void transactionRollback() throws Exception {
285
+ final An5Adapter adapter = open();
286
+ try {
287
+ adapter.table("User").create(map("name", "outside"));
288
+ try {
289
+ adapter.transaction(
290
+ new An5Adapter.TransactionWork<Void>() {
291
+ @Override
292
+ public Void run(An5Adapter scoped) throws java.sql.SQLException {
293
+ scoped.table("User").create(map("name", "inside"));
294
+ throw new IllegalStateException("rollback");
295
+ }
296
+ });
297
+ equals("transaction rethrows", false, true);
298
+ } catch (IllegalStateException expected) {
299
+ equals("transaction rethrows the cause", "rollback", expected.getMessage());
300
+ }
301
+ equals("rollback discarded the inner insert", 1L, adapter.table("User").count(null));
302
+
303
+ adapter.transaction(
304
+ new An5Adapter.TransactionWork<Void>() {
305
+ @Override
306
+ public Void run(An5Adapter scoped) throws java.sql.SQLException {
307
+ scoped.table("User").create(map("name", "committed"));
308
+ return null;
309
+ }
310
+ });
311
+ equals("commit kept the insert", 2L, adapter.table("User").count(null));
312
+ } finally {
313
+ adapter.close();
314
+ }
315
+ }
316
+
317
+ private static void viewIsReadOnly() throws Exception {
318
+ An5Adapter adapter = open();
319
+ try {
320
+ adapter.table("User").create(map("name", "Ada", "score", 1));
321
+ An5ViewClient view = adapter.view("User");
322
+ equals("view reads", 1, view.findMany().size());
323
+ equals("view counts", 1L, view.count());
324
+ equals(
325
+ "view refuses writes",
326
+ true,
327
+ failure(new Runnable() {
328
+ @Override
329
+ public void run() {
330
+ try {
331
+ new An5Adapter(":memory:").view("User").create(map("name", "x"));
332
+ } catch (UnsupportedOperationException expected) {
333
+ throw new RuntimeException("read-only");
334
+ } catch (Exception error) {
335
+ throw new RuntimeException(error.getMessage());
336
+ }
337
+ }
338
+ }).equals("read-only"));
339
+ } finally {
340
+ adapter.close();
341
+ }
342
+ }
343
+
344
+ private static void vectorFallback() throws Exception {
345
+ An5Adapter adapter = open();
346
+ try {
347
+ An5TableClient docs = adapter.table("Document");
348
+ // Orthogonal to the query: cosine distance is scale-invariant, so a longer vector
349
+ // pointing the same way would still rank as the closest.
350
+ docs.create(map("title", "far", "embedding", "[0.0, 1.0]"));
351
+ docs.create(map("title", "near", "embedding", "[1.0, 0.1]"));
352
+ docs.create(map("title", "no vector", "embedding", null));
353
+
354
+ List<Map<String, Object>> hits =
355
+ docs.vectorSearch(new double[] {1, 0}, Integer.valueOf(2), null, "embedding", "cosine");
356
+ equals("vector search skips rows without a vector", 2, hits.size());
357
+ equals("vector search orders by distance", "near", hits.get(0).get("title"));
358
+ equals("vector search reports the distance", true, hits.get(0).containsKey("distance"));
359
+ } finally {
360
+ adapter.close();
361
+ }
362
+ }
363
+
364
+ /**
365
+ * SQLite vector codec and strategy plan.
366
+ *
367
+ * <p>The docstring in {@code SqliteVectors} is the shared specification every runtime
368
+ * implements; test/sqlite-vector.test.js (TypeScript) is the mirror of this coverage.
369
+ */
370
+ private static void sqliteVectorCodecAndPlan() {
371
+ System.out.println("\n[sqlite vector codec]");
372
+ byte[] encoded = SqliteVectors.encodeVector(new double[] {1, -2, 0.5});
373
+ equals("float32 blob length", Integer.valueOf(12), Integer.valueOf(encoded.length));
374
+ equals(
375
+ "little-endian 1.0",
376
+ "0,0,128,63",
377
+ (encoded[0] & 0xff) + "," + (encoded[1] & 0xff) + "," + (encoded[2] & 0xff) + ","
378
+ + (encoded[3] & 0xff));
379
+ equals(
380
+ "blob round trip",
381
+ "[1.0, -2.0, 0.5]",
382
+ java.util.Arrays.toString(SqliteVectors.decodeVector(encoded, 0)));
383
+ equals(
384
+ "legacy JSON text",
385
+ "[1.0, 0.0, 0.0]",
386
+ java.util.Arrays.toString(SqliteVectors.decodeVector("[1, 0, 0]", 0)));
387
+ equals(
388
+ "a float array reads back",
389
+ "[1.0, 2.0]",
390
+ java.util.Arrays.toString(SqliteVectors.decodeVector(new float[] {1, 2}, 0)));
391
+ equals(
392
+ "a mismatched dimension is rejected",
393
+ null,
394
+ SqliteVectors.decodeVector(encoded, Integer.valueOf(5)));
395
+ equals("a partial blob is rejected", null, SqliteVectors.decodeVectorBytes(new byte[3], 0));
396
+ equals("text that is not JSON is rejected", null, SqliteVectors.decodeVector("nope", 0));
397
+ equals("a null vector stays null", null, SqliteVectors.decodeVector(null, 0));
398
+ equals(
399
+ "cosine of a vector with itself",
400
+ 0.0,
401
+ Vectors.distance("cosine", new double[] {1, 0}, new double[] {1, 0}));
402
+ equals(
403
+ "euclidean distance",
404
+ 5.0,
405
+ Vectors.distance("euclidean", new double[] {0, 3}, new double[] {4, 0}));
406
+ equals(
407
+ "dot distance is negated",
408
+ -10.0,
409
+ Vectors.distance("dot", new double[] {1, 2}, new double[] {2, 4}));
410
+ equals("a VECTOR column", true, SqliteVectors.isVectorField("VECTOR(3)"));
411
+ equals("a TEXT column", false, SqliteVectors.isVectorField("TEXT"));
412
+
413
+ System.out.println("\n[sqlite vector strategy plan]");
414
+ SqliteVectors.Capabilities all = new SqliteVectors.Capabilities(true, true, true);
415
+ equals(
416
+ "sqlite-vec and the driver function come first",
417
+ "[sqlite-vec, udf]",
418
+ SqliteVectors.planStrategies(all, "BLOB", null).toString());
419
+ // A BLOB column has no JSON to read, so json_each could only produce NULLs.
420
+ equals(
421
+ "a BLOB column drops the JSON strategy",
422
+ "[]",
423
+ SqliteVectors.planStrategies(
424
+ new SqliteVectors.Capabilities(false, false, true), "BLOB", null).toString());
425
+ equals(
426
+ "a TEXT column keeps the JSON strategy",
427
+ "[sql]",
428
+ SqliteVectors.planStrategies(
429
+ new SqliteVectors.Capabilities(false, false, true), "TEXT", null).toString());
430
+ equals(
431
+ "a pinned strategy wins",
432
+ "[udf]",
433
+ SqliteVectors.planStrategies(all, "BLOB", "udf").toString());
434
+ equals(
435
+ "memory is the caller's own path",
436
+ "[]",
437
+ SqliteVectors.planStrategies(all, "BLOB", "memory").toString());
438
+ equals(
439
+ "an unknown metric falls back to cosine",
440
+ "an5_vec_cosine",
441
+ SqliteVectors.distanceFunction("udf", "nonsense"));
442
+
443
+ System.out.println("\n[sqlite vector ranking sql]");
444
+ List<Object> bind = new ArrayList<Object>();
445
+ String udf =
446
+ SqliteVectors.buildRankingQuery(
447
+ "udf", "cosine", "[documents]", "[embedding]", new double[] {1, 0, 0}, 5,
448
+ " WHERE [id] = ?", "?", bind);
449
+ equals(
450
+ "one WHERE in the ranked subquery",
451
+ Integer.valueOf(1),
452
+ Integer.valueOf(countOf(udf.substring(0, udf.indexOf("an5_ranked")), " WHERE ")));
453
+ equals(
454
+ "the tail stays inside the subquery",
455
+ true,
456
+ udf.indexOf("WHERE [id] = ?") < udf.indexOf("an5_ranked"));
457
+ equals("a NULL distance is filtered", true, udf.contains("WHERE distance IS NOT NULL"));
458
+ equals("the query vector is bound as bytes", true, bind.get(0) instanceof byte[]);
459
+ bind.clear();
460
+ String json =
461
+ SqliteVectors.buildRankingQuery(
462
+ "sql", "cosine", "[documents]", "[embedding]", new double[] {1, 0, 0}, 5, "", "?", bind);
463
+ equals("json_each reads the vector as text", "[1.0, 0.0, 0.0]", bind.get(0));
464
+ equals("the JSON path walks the column", true, json.contains("json_each"));
465
+ }
466
+
467
+ private static int countOf(String haystack, String needle) {
468
+ int count = 0;
469
+ int index = haystack.indexOf(needle);
470
+ while (index >= 0) {
471
+ count++;
472
+ index = haystack.indexOf(needle, index + needle.length());
473
+ }
474
+ return count;
475
+ }
476
+
477
+ /**
478
+ * SQLite ranks a {@code VECTOR(n)} column inside the database.
479
+ *
480
+ * <p>Plain JDBC cannot register a user function or load an extension, so a stock adapter
481
+ * reaches the {@code json_each} strategy for a JSON text column and the in-memory fallback
482
+ * for a float32 BLOB one.
483
+ */
484
+ private static void sqliteVectorSearch() throws Exception {
485
+ System.out.println("\n[sqlite vector search -> json_each on a TEXT column]");
486
+ An5Adapter adapter = open();
487
+ try {
488
+ An5TableClient docs = adapter.table("Document");
489
+ docs.create(map("title", "d1", "embedding", "[1.0, 0.0, 0.0]"));
490
+ docs.create(map("title", "d2", "embedding", "[0.8, 0.2, 0.0]"));
491
+ docs.create(map("title", "d3", "embedding", "[0.0, 1.0, 0.0]"));
492
+ // A row with another dimension must never rank against a 3-dimension query.
493
+ docs.create(map("title", "d4", "embedding", "[1.0, 0.0, 0.0, 1.0]"));
494
+ docs.create(map("title", "d5", "embedding", null));
495
+
496
+ for (String metric : new String[] {"cosine", "euclidean", "dot"}) {
497
+ List<Map<String, Object>> hits =
498
+ docs.vectorSearch(new double[] {1, 0, 0}, Integer.valueOf(9), null, "embedding", metric);
499
+ equals(metric + ": row order", "d1,d2,d3", titles(hits));
500
+ equals(metric + ": the distance is a number", true, hits.get(0).get("distance") instanceof Double);
501
+ }
502
+
503
+ System.out.println("\n[sqlite vector search -> in-memory on a BLOB column]");
504
+ An5Adapter blobAdapter = openWithBlobDocuments();
505
+ try {
506
+ List<Map<String, Object>> hits =
507
+ blobAdapter
508
+ .table("Document")
509
+ .vectorSearch(new double[] {1, 0, 0}, Integer.valueOf(9), null, "embedding", "cosine");
510
+ equals("row order", "d1,d2,d3", titles(hits));
511
+ equals("the vector decodes to numbers", true, hits.get(0).get("embedding") instanceof double[]);
512
+ } finally {
513
+ blobAdapter.close();
514
+ }
515
+
516
+ System.out.println("\n[sqlite vector search -> a pinned strategy]");
517
+ An5Adapter pinned = open();
518
+ try {
519
+ An5TableClient pinnedDocs = pinned.table("Document");
520
+ pinnedDocs.create(map("title", "d1", "embedding", "[1.0, 0.0, 0.0]"));
521
+ pinnedDocs.create(map("title", "d2", "embedding", "[0.8, 0.2, 0.0]"));
522
+ pinnedDocs.create(map("title", "d3", "embedding", "[0.0, 1.0, 0.0]"));
523
+ // `memory` yields no query plan, so the rows are scored in Java instead.
524
+ pinned.setVectorStrategy("memory");
525
+ List<Map<String, Object>> hits =
526
+ pinned
527
+ .table("Document")
528
+ .vectorSearch(new double[] {1, 0, 0}, Integer.valueOf(9), null, "embedding", "cosine");
529
+ equals("the memory fallback ranks correctly", "d1,d2,d3", titles(hits));
530
+ } finally {
531
+ pinned.close();
532
+ }
533
+ } finally {
534
+ adapter.close();
535
+ }
536
+ }
537
+
538
+ private static String titles(List<Map<String, Object>> rows) {
539
+ StringBuilder out = new StringBuilder();
540
+ for (int i = 0; i < rows.size(); i++) {
541
+ if (i > 0) {
542
+ out.append(',');
543
+ }
544
+ out.append(rows.get(i).get("title"));
545
+ }
546
+ return out.toString();
547
+ }
548
+
549
+ /** A database whose vector column holds float32 BLOBs, as {@code VECTOR(n)} now maps to. */
550
+ private static An5Adapter openWithBlobDocuments() throws Exception {
551
+ Metadata.setAdapterMetadata(blobMetadata());
552
+ An5Adapter adapter = new An5Adapter(":memory:");
553
+ adapter.executeRaw("CREATE TABLE documents (id TEXT PRIMARY KEY, title TEXT, embedding BLOB)");
554
+ double[][] vectors = {{1, 0, 0}, {0.8, 0.2, 0}, {0, 1, 0}};
555
+ for (int i = 0; i < vectors.length; i++) {
556
+ adapter.executeRaw(
557
+ "INSERT INTO documents (id, title, embedding) VALUES (?, ?, ?)",
558
+ "d" + (i + 1),
559
+ "d" + (i + 1),
560
+ SqliteVectors.encodeVector(vectors[i]));
561
+ }
562
+ // A row with another dimension must never rank against a 3-dimension query.
563
+ adapter.executeRaw(
564
+ "INSERT INTO documents (id, title, embedding) VALUES ('d4', 'd4', ?)",
565
+ SqliteVectors.encodeVector(new double[] {1, 0, 0, 1}));
566
+ return adapter;
567
+ }
568
+
569
+ private static Map<String, Object> blobMetadata() {
570
+ Map<String, Object> metadata = metadata();
571
+ @SuppressWarnings("unchecked")
572
+ Map<String, Object> fields = (Map<String, Object>) metadata.get("modelFields");
573
+ Map<String, Object> embedding = new LinkedHashMap<String, Object>();
574
+ embedding.put("name", "embedding");
575
+ embedding.put("type", "number[] | string");
576
+ embedding.put("sql", "VECTOR(3)");
577
+ embedding.put("isOptional", Boolean.TRUE);
578
+ embedding.put("hasDefault", Boolean.FALSE);
579
+ embedding.put("isId", Boolean.FALSE);
580
+ fields.put("Document", list(field("id", true), field("title", false), embedding));
581
+ return metadata;
582
+ }
583
+
584
+ /** A {@code VECTOR(n)} column stores float32 bytes and reads back as numbers. */
585
+ private static void sqliteVectorColumnRoundTrip() throws Exception {
586
+ System.out.println("\n[sqlite vector column round trip]");
587
+ An5Adapter adapter = openWithBlobDocuments();
588
+ try {
589
+ An5TableClient docs = adapter.table("Document");
590
+ Map<String, Object> written =
591
+ docs.create(
592
+ map("id", "d9", "title", "written", "embedding", new double[] {0.25, 0.5, 1.0}));
593
+ equals(
594
+ "create returns numbers",
595
+ "[0.25, 0.5, 1.0]",
596
+ java.util.Arrays.toString((double[]) written.get("embedding")));
597
+
598
+ List<Map<String, Object>> stored =
599
+ adapter.exec("SELECT typeof(embedding) AS t FROM documents WHERE id = 'd9'");
600
+ equals("a double[] is written as a BLOB", "blob", stored.get(0).get("t"));
601
+
602
+ List<Map<String, Object>> rows = docs.findMany(new An5Query().where(map("id", "d9")));
603
+ equals(
604
+ "findMany returns numbers",
605
+ "[0.25, 0.5, 1.0]",
606
+ java.util.Arrays.toString((double[]) rows.get(0).get("embedding")));
607
+
608
+ docs.update(map("id", "d1"), map("embedding", new double[] {0.5, 0.5, 0}));
609
+ List<Map<String, Object>> updated = docs.findMany(new An5Query().where(map("id", "d1")));
610
+ equals(
611
+ "update encodes",
612
+ "[0.5, 0.5, 0.0]",
613
+ java.util.Arrays.toString((double[]) updated.get(0).get("embedding")));
614
+
615
+ docs.updateMany(map("id", "d2"), map("embedding", new double[] {0, 0, 1}));
616
+ List<Map<String, Object>> many = docs.findMany(new An5Query().where(map("id", "d2")));
617
+ equals(
618
+ "updateMany encodes",
619
+ "[0.0, 0.0, 1.0]",
620
+ java.util.Arrays.toString((double[]) many.get(0).get("embedding")));
621
+
622
+ equals(
623
+ "a non-vector column is untouched",
624
+ "d1",
625
+ String.valueOf(docs.findMany(new An5Query().where(map("id", "d1"))).get(0).get("title")));
626
+ } finally {
627
+ adapter.close();
628
+ }
629
+ }
630
+
631
+ // ─── Harness ────────────────────────────────────────────────────────────────────
632
+
633
+ /** A fresh in-memory database with the fixture schema and metadata registered. */
634
+ private static An5Adapter open() {
635
+ Metadata.setAdapterMetadata(metadata());
636
+ An5Adapter adapter = new An5Adapter(":memory:");
637
+ try {
638
+ adapter.executeRaw(
639
+ "CREATE TABLE users (id TEXT PRIMARY KEY, name TEXT, score INTEGER, embedding TEXT)");
640
+ adapter.executeRaw("CREATE TABLE documents (id TEXT PRIMARY KEY, title TEXT, embedding TEXT)");
641
+ adapter.executeRaw("CREATE TABLE posts (id TEXT PRIMARY KEY, title TEXT, userId TEXT)");
642
+ } catch (Exception error) {
643
+ throw new RuntimeException(error);
644
+ }
645
+ return adapter;
646
+ }
647
+
648
+ private static Map<String, Object> metadata() {
649
+ Map<String, Object> fields = new LinkedHashMap<String, Object>();
650
+ fields.put("User", list(field("id", true), field("name", false), field("score", false)));
651
+ fields.put("Post", list(field("id", true), field("title", false), field("userId", false)));
652
+ fields.put("Document", list(field("id", true), field("title", false), field("embedding", false)));
653
+
654
+ Map<String, Object> userPosts = new LinkedHashMap<String, Object>();
655
+ userPosts.put("modelName", "Post");
656
+ userPosts.put("relationType", "many");
657
+ userPosts.put("foreignKey", "userId");
658
+ userPosts.put("localKey", "id");
659
+ Map<String, Object> userRelations = new LinkedHashMap<String, Object>();
660
+ userRelations.put("posts", userPosts);
661
+
662
+ Map<String, Object> relations = new LinkedHashMap<String, Object>();
663
+ relations.put("User", userRelations);
664
+
665
+ Map<String, Object> tables = new LinkedHashMap<String, Object>();
666
+ tables.put("User", "users");
667
+ tables.put("Post", "posts");
668
+ tables.put("Document", "documents");
669
+
670
+ Map<String, Object> metadata = new LinkedHashMap<String, Object>();
671
+ metadata.put("modelToTable", tables);
672
+ metadata.put("modelFields", fields);
673
+ metadata.put("relationMap", relations);
674
+ return metadata;
675
+ }
676
+
677
+ private static Map<String, Object> field(String name, boolean id) {
678
+ Map<String, Object> field = new LinkedHashMap<String, Object>();
679
+ field.put("name", name);
680
+ field.put("type", "string");
681
+ field.put("sql", "NVARCHAR(255)");
682
+ field.put("isOptional", Boolean.FALSE);
683
+ field.put("hasDefault", Boolean.FALSE);
684
+ field.put("isId", Boolean.valueOf(id));
685
+ return field;
686
+ }
687
+
688
+ /** A row map from alternating key/value pairs, in declaration order. */
689
+ private static Map<String, Object> map(Object... pairs) {
690
+ Map<String, Object> map = new LinkedHashMap<String, Object>();
691
+ for (int i = 0; i + 1 < pairs.length; i += 2) {
692
+ map.put(String.valueOf(pairs[i]), pairs[i + 1]);
693
+ }
694
+ return map;
695
+ }
696
+
697
+ private static List<Object> list(Object... values) {
698
+ List<Object> list = new ArrayList<Object>();
699
+ for (Object value : values) {
700
+ list.add(value);
701
+ }
702
+ return list;
703
+ }
704
+
705
+ private static String failure(Runnable body) {
706
+ try {
707
+ body.run();
708
+ return "<no failure>";
709
+ } catch (RuntimeException error) {
710
+ return error.getMessage();
711
+ }
712
+ }
713
+
714
+ /** Compares within a tolerance, so a floating-point result is not a rounding failure. */
715
+ private static void approximately(String what, double expected, double actual) {
716
+ if (Math.abs(expected - actual) <= 1e-9) {
717
+ System.out.println("ok " + what);
718
+ return;
719
+ }
720
+ failures++;
721
+ System.out.println("FAIL " + what + ": expected " + expected + ", got " + actual);
722
+ }
723
+
724
+ private static void equals(String what, Object expected, Object actual) {
725
+ boolean same = expected == null ? actual == null : expected.equals(actual);
726
+ if (same) {
727
+ System.out.println("ok " + what);
728
+ return;
729
+ }
730
+ failures++;
731
+ System.out.println("FAIL " + what + ": expected " + expected + ", got " + actual);
732
+ }
733
+ }