@an5/adapters 0.2.10 → 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 (109) hide show
  1. package/README.md +163 -2
  2. package/dist/an5Adapter.d.ts +37 -0
  3. package/dist/an5Adapter.js +190 -65
  4. package/dist/base/metadata.d.ts +2 -0
  5. package/dist/base/metadata.js +10 -0
  6. package/dist/base/sql.d.ts +2 -0
  7. package/dist/base/sql.js +24 -25
  8. package/dist/base/types.d.ts +14 -0
  9. package/dist/googlesheets/adapter.js +16 -8
  10. package/dist/googlesheets/config.d.ts +13 -0
  11. package/dist/googlesheets/config.js +5 -1
  12. package/dist/googlesheets/oauth.d.ts +3 -0
  13. package/dist/googlesheets/oauth.js +43 -0
  14. package/dist/googlesheets/parseConnectionString.js +12 -0
  15. package/dist/googlesheets/tableClient.js +5 -23
  16. package/dist/index.d.ts +2 -0
  17. package/dist/index.js +8 -1
  18. package/dist/sqlite/browserEngine.d.ts +13 -0
  19. package/dist/sqlite/browserEngine.js +13 -0
  20. package/dist/sqlite/engine.d.ts +5 -0
  21. package/dist/sqlite/engine.js +10 -0
  22. package/dist/sqlite/index.d.ts +1 -0
  23. package/dist/sqlite/index.js +1 -0
  24. package/dist/sqlite/vector.d.ts +155 -0
  25. package/dist/sqlite/vector.js +436 -0
  26. package/dotnet/An5Adapter.cs +85 -27
  27. package/dotnet/Base/Core.cs +14 -0
  28. package/dotnet/Mssql/MssqlEngine.cs +44 -1
  29. package/dotnet/Postgres/PostgresEngine.cs +2 -0
  30. package/dotnet/Sqlite/SqliteEngine.cs +82 -2
  31. package/dotnet/Sqlite/SqliteVectors.cs +401 -0
  32. package/golang/an5_adapter.go +84 -32
  33. package/golang/base/vectors.go +263 -0
  34. package/golang/sqlite/vector.go +248 -0
  35. package/golang/sqlite_vector_test.go +288 -0
  36. package/golang/table_client.go +67 -3
  37. package/java/pom.xml +177 -0
  38. package/java/src/main/java/an5/adapters/An5Adapter.java +374 -0
  39. package/java/src/main/java/an5/adapters/An5Aggregate.java +116 -0
  40. package/java/src/main/java/an5/adapters/An5GroupBy.java +97 -0
  41. package/java/src/main/java/an5/adapters/An5Query.java +100 -0
  42. package/java/src/main/java/an5/adapters/An5TableClient.java +1031 -0
  43. package/java/src/main/java/an5/adapters/An5ViewClient.java +107 -0
  44. package/java/src/main/java/an5/adapters/base/Dialect.java +103 -0
  45. package/java/src/main/java/an5/adapters/base/FieldMeta.java +42 -0
  46. package/java/src/main/java/an5/adapters/base/JdbcUrls.java +201 -0
  47. package/java/src/main/java/an5/adapters/base/Metadata.java +195 -0
  48. package/java/src/main/java/an5/adapters/base/RelationDef.java +26 -0
  49. package/java/src/main/java/an5/adapters/base/SqlBuilder.java +422 -0
  50. package/java/src/main/java/an5/adapters/base/SqliteVectors.java +375 -0
  51. package/java/src/main/java/an5/adapters/base/Vectors.java +123 -0
  52. package/java/src/test/java/An5AdaptersSmoke.java +733 -0
  53. package/java/src/test/java/Json.java +174 -0
  54. package/java/src/test/java/QuerySemantics.java +111 -0
  55. package/kotlin/build.gradle.kts +145 -0
  56. package/kotlin/settings.gradle.kts +10 -0
  57. package/kotlin/src/main/kotlin/an5/adapters/Aggregate.kt +89 -0
  58. package/kotlin/src/main/kotlin/an5/adapters/An5.kt +91 -0
  59. package/kotlin/src/main/kotlin/an5/adapters/Query.kt +130 -0
  60. package/kotlin/src/main/kotlin/an5/adapters/Rows.kt +166 -0
  61. package/kotlin/src/main/kotlin/an5/adapters/TableClient.kt +169 -0
  62. package/kotlin/src/main/kotlin/an5/adapters/Types.kt +77 -0
  63. package/kotlin/src/test/kotlin/an5/adapters/An5Smoke.kt +329 -0
  64. package/native/sqlite/CMakeLists.txt +12 -0
  65. package/native/sqlite/README.md +117 -0
  66. package/native/sqlite/an5_vector.c +209 -0
  67. package/package.json +40 -9
  68. package/python/an5_adapter.py +37 -6
  69. package/python/base/__init__.py +15 -0
  70. package/python/base/vectors.py +434 -0
  71. package/python/sqlite/__init__.py +2 -2
  72. package/python/sqlite/provider.py +21 -1
  73. package/python/table_client.py +129 -59
  74. package/rust/src/adapter.rs +342 -24
  75. package/rust/src/base/mod.rs +7 -0
  76. package/rust/src/base/vectors.rs +293 -0
  77. package/rust/src/lib.rs +3 -3
  78. package/scripts/sqlite-native-build.js +32 -0
  79. package/swift/Package.swift +22 -0
  80. package/swift/Sources/An5Adapters/An5Adapter.swift +160 -0
  81. package/swift/Sources/An5Adapters/Metadata.swift +183 -0
  82. package/swift/Sources/An5Adapters/Query.swift +105 -0
  83. package/swift/Sources/An5Adapters/Row+Accessors.swift +129 -0
  84. package/swift/Sources/An5Adapters/SQLBuilder.swift +359 -0
  85. package/swift/Sources/An5Adapters/SQLiteDriver.swift +301 -0
  86. package/swift/Sources/An5Adapters/SqliteVectors.swift +243 -0
  87. package/swift/Sources/An5Adapters/TableClient.swift +671 -0
  88. package/swift/Sources/An5Adapters/Types.swift +127 -0
  89. package/swift/Sources/An5Adapters/Vectors.swift +86 -0
  90. package/swift/Sources/An5Adapters/ViewClient.swift +50 -0
  91. package/swift/Sources/CSQLite/module.modulemap +8 -0
  92. package/swift/Sources/CSQLite/shim.h +9 -0
  93. package/swift/Tests/An5AdaptersTests/QuerySemanticsTests.swift +51 -0
  94. package/swift/Tests/An5AdaptersTests/RuntimeSmokeTests.swift +488 -0
  95. package/typescript/src/an5Adapter.ts +200 -58
  96. package/typescript/src/base/metadata.ts +8 -0
  97. package/typescript/src/base/sql.ts +23 -16
  98. package/typescript/src/base/types.ts +15 -0
  99. package/typescript/src/googlesheets/adapter.ts +17 -8
  100. package/typescript/src/googlesheets/config.ts +10 -1
  101. package/typescript/src/googlesheets/oauth.ts +31 -0
  102. package/typescript/src/googlesheets/parseConnectionString.ts +8 -0
  103. package/typescript/src/googlesheets/tableClient.ts +5 -19
  104. package/typescript/src/index.ts +13 -0
  105. package/typescript/src/sqlite/browserEngine.ts +24 -0
  106. package/typescript/src/sqlite/engine.ts +13 -0
  107. package/typescript/src/sqlite/index.ts +1 -1
  108. package/typescript/src/sqlite/vector.ts +541 -0
  109. package/golang/sqlite/sqlite.go +0 -14
@@ -0,0 +1,488 @@
1
+ import Foundation
2
+ import XCTest
3
+ @testable import An5Adapters
4
+
5
+ /// End-to-end exercise of the Swift runtime against a real in-memory SQLite database.
6
+ ///
7
+ /// Covers what a compile check cannot: that the generated SQL is valid for the driver, that
8
+ /// generated primary keys come back, that nested relation writes land, that a rollback
9
+ /// really discards, and that the in-memory vector fallback ranks correctly.
10
+ final class RuntimeSmokeTests: XCTestCase {
11
+
12
+ private func makeAdapter() throws -> An5Adapter {
13
+ let adapter = try An5Adapter(path: ":memory:", metadata: Self.metadata)
14
+ try adapter.execute("CREATE TABLE users (id TEXT PRIMARY KEY, name TEXT, score INTEGER, nickname TEXT)")
15
+ try adapter.execute("CREATE TABLE posts (id TEXT PRIMARY KEY, title TEXT, userId TEXT)")
16
+ try adapter.execute("CREATE TABLE documents (id TEXT PRIMARY KEY, title TEXT, embedding TEXT)")
17
+ return adapter
18
+ }
19
+
20
+ // MARK: - Dialects, quoting and URLs
21
+
22
+ func testDialectDetection() {
23
+ XCTAssertEqual(Dialect.detect("Server=db;Database=app"), .mssql)
24
+ XCTAssertEqual(Dialect.detect("postgresql://localhost/app"), .postgres)
25
+ XCTAssertEqual(Dialect.detect("sqlite:///tmp/app.db"), .sqlite)
26
+ XCTAssertEqual(Dialect.detect("/tmp/app.sqlite3"), .sqlite)
27
+ XCTAssertEqual(Dialect.detect(":memory:"), .sqlite)
28
+ }
29
+
30
+ func testQuoting() {
31
+ XCTAssertEqual(Dialect.mssql.quote("name"), "[name]")
32
+ XCTAssertEqual(Dialect.mssql.quote("we]ird"), "[we]]ird]")
33
+ XCTAssertEqual(Dialect.sqlite.quote("[name]"), "\"name\"")
34
+ XCTAssertEqual(Dialect.postgres.quote("we\"ird"), "\"we\"\"ird\"")
35
+ XCTAssertEqual(SQLBuilder.quoteTable("dbo.users", dialect: .sqlite), "\"users\"")
36
+ XCTAssertEqual(SQLBuilder.quoteTable("dbo.users", dialect: .mssql), "[dbo].[users]")
37
+ }
38
+
39
+ func testPagination() {
40
+ XCTAssertEqual(Dialect.sqlite.pagination(take: 10, skip: 5, orderBy: "ORDER BY id"), " LIMIT 10 OFFSET 5")
41
+ XCTAssertEqual(
42
+ Dialect.mssql.pagination(take: 10, skip: 5, orderBy: "ORDER BY id"),
43
+ " OFFSET 5 ROWS FETCH NEXT 10 ROWS ONLY"
44
+ )
45
+ XCTAssertEqual(
46
+ Dialect.mssql.pagination(take: 10, skip: 5, orderBy: ""),
47
+ " ORDER BY (SELECT NULL) OFFSET 5 ROWS FETCH NEXT 10 ROWS ONLY"
48
+ )
49
+ XCTAssertEqual(Dialect.sqlite.pagination(take: nil, skip: 5, orderBy: ""), "")
50
+ }
51
+
52
+ // MARK: - Vectors
53
+
54
+ func testVectors() {
55
+ XCTAssertEqual(Vectors.cosineSimilarity([1, 2], [1, 2]), 1.0, accuracy: 1e-9)
56
+ XCTAssertEqual(Vectors.cosineSimilarity([1, 0], [-1, 0]), -1.0, accuracy: 1e-9)
57
+ XCTAssertEqual(Vectors.cosineSimilarity([1], [1, 2]), 0)
58
+ XCTAssertEqual(Vectors.euclideanDistance([0, 0], [3, 4]), 5.0, accuracy: 1e-9)
59
+ XCTAssertEqual(Vectors.dotProduct([1, 2], [3, 4]), 11.0, accuracy: 1e-9)
60
+ XCTAssertEqual(DistanceMetric.distance(.dot, [1, 2], [3, 4]), -11.0, accuracy: 1e-9)
61
+ XCTAssertEqual(Vectors.parse("[1.0, 2.0, 3.0]", expectedLength: 3)?.count, 3)
62
+ XCTAssertNil(Vectors.parse("[1.0, 2.0]", expectedLength: 3))
63
+ XCTAssertEqual(Vectors.format([1.0, 2.5]), "[1.0, 2.5]")
64
+ }
65
+
66
+ // MARK: - CRUD
67
+
68
+ func testCrud() throws {
69
+ let adapter = try makeAdapter()
70
+ let users = adapter.table("User")
71
+
72
+ let created = try users.create(["name": "Ada", "score": 10])
73
+ let id = try XCTUnwrap(created["id"] as? String)
74
+ XCTAssertFalse(id.isEmpty, "a model with an @id column gets a generated key")
75
+ XCTAssertEqual(created["name"] as? String, "Ada")
76
+
77
+ XCTAssertEqual(try users.count(), 1)
78
+ try users.createMany([
79
+ ["name": "Grace", "score": 20],
80
+ ["name": "Alan", "score": 30],
81
+ ])
82
+
83
+ var ordered = Query()
84
+ ordered.orderBy = [["score": "desc"]]
85
+ XCTAssertEqual(try users.findMany(ordered).first?["name"] as? String, "Alan")
86
+
87
+ var paged = Query()
88
+ paged.orderBy = [["score": "asc"]]
89
+ paged.skip = 1
90
+ paged.take = 1
91
+ let page = try users.findMany(paged)
92
+ XCTAssertEqual(page.count, 1)
93
+ XCTAssertEqual(page.first?["name"] as? String, "Grace")
94
+
95
+ XCTAssertEqual(try users.findMany(filter: Query.gte("score", 20)).count, 2)
96
+ XCTAssertEqual(try users.findMany(filter: Query.lt("score", 20)).count, 1)
97
+ XCTAssertEqual(try users.findMany(filter: Query.equals("name", "Ada")).count, 1)
98
+ XCTAssertEqual(try users.findMany(filter: Query.contains("name", "da")).count, 1)
99
+ XCTAssertEqual(try users.findMany(filter: Query.endsWith("name", "ce")).count, 1)
100
+ XCTAssertEqual(try users.findMany(filter: Query.in("name", ["Ada", "Grace"])).count, 2)
101
+ XCTAssertEqual(try users.findMany(filter: Query.`in`("name", [])).count, 0)
102
+ XCTAssertEqual(try users.findMany(filter: Query.notIn("name", [])).count, 3)
103
+ XCTAssertEqual(
104
+ try users.findMany(filter: Query.or(Query.equals("name", "Ada"), Query.equals("name", "Alan"))).count,
105
+ 2
106
+ )
107
+ XCTAssertEqual(
108
+ try users.findMany(filter: Query.and(Query.gte("score", 10), Query.lte("score", 20))).count,
109
+ 2
110
+ )
111
+ XCTAssertEqual(
112
+ try users.findMany(filter: Query.not(Query.equals("name", "Ada"), Query.equals("name", "Grace"))).count,
113
+ 1
114
+ )
115
+ XCTAssertEqual(try users.findMany(filter: Query.or()).count, 0)
116
+ XCTAssertEqual(try users.findMany(filter: Query.isNull("nickname")).count, 3)
117
+
118
+ var projection = Query()
119
+ projection.select = ["name"]
120
+ let selected = try users.findMany(projection)
121
+ XCTAssertEqual(selected.first?.keys.sorted(), ["name"])
122
+
123
+ XCTAssertEqual(
124
+ try users.update(filter: ["id": id], data: ["score": ["increment": 5]])?["score"] as? Int64,
125
+ 15
126
+ )
127
+ XCTAssertEqual(try users.updateMany(filter: nil, data: ["score": ["increment": 1]]), 3)
128
+ XCTAssertEqual(
129
+ try users.upsert(filter: ["id": id], create: ["name": "x"], update: ["name": "Ada L"])["name"] as? String,
130
+ "Ada L"
131
+ )
132
+ XCTAssertEqual(try users.count(), 3)
133
+ try users.upsert(filter: ["name": "Katherine"], create: ["name": "Katherine"], update: ["name": "K"])
134
+ XCTAssertEqual(try users.count(), 4)
135
+
136
+ XCTAssertEqual(try users.delete(filter: ["id": id])?["name"] as? String, "Ada L")
137
+ XCTAssertEqual(try users.count(), 3)
138
+ XCTAssertEqual(try users.deleteMany(), 3)
139
+ XCTAssertEqual(try users.count(), 0)
140
+ }
141
+
142
+ // MARK: - Aggregation
143
+
144
+ func testAggregation() throws {
145
+ let adapter = try makeAdapter()
146
+ let users = adapter.table("User")
147
+ try users.createMany([
148
+ ["name": "Ada", "score": 10],
149
+ ["name": "Grace", "score": 20],
150
+ ["name": "Alan", "score": 30],
151
+ ])
152
+
153
+ var totals = Aggregate()
154
+ totals.count = true
155
+ totals.sum = ["score"]
156
+ totals.average = ["score"]
157
+ totals.maximum = ["score"]
158
+ let aggregate = try users.aggregate(totals)
159
+ XCTAssertEqual((aggregate["_count"] as? NSNumber)?.intValue, 3)
160
+ XCTAssertEqual((aggregate["_sum_score"] as? NSNumber)?.doubleValue, 60.0)
161
+ XCTAssertEqual((aggregate["_avg_score"] as? NSNumber)?.doubleValue, 20.0)
162
+ XCTAssertEqual((aggregate["_max_score"] as? NSNumber)?.doubleValue, 30.0)
163
+
164
+ var filtered = Aggregate()
165
+ filtered.count = true
166
+ filtered.filter = Query.gte("score", 20)
167
+ XCTAssertEqual(((try users.aggregate(filtered)["_count"]) as? NSNumber)?.intValue, 2)
168
+
169
+ var groups = Aggregate()
170
+ groups.by = ["name"]
171
+ groups.sum = ["score"]
172
+ let grouped = try users.groupBy(groups)
173
+ XCTAssertEqual(grouped.count, 3)
174
+ XCTAssertTrue(grouped.allSatisfy { ($0["_count"] as? NSNumber)?.intValue == 1 })
175
+
176
+ let empty = Aggregate()
177
+ XCTAssertThrowsError(try users.groupBy(empty))
178
+ XCTAssertThrowsError(try users.aggregate(Aggregate()))
179
+ }
180
+
181
+ // MARK: - Relations
182
+
183
+ func testRelations() throws {
184
+ let adapter = try makeAdapter()
185
+ let ada = try adapter.table("User").create(["name": "Ada", "score": 1])
186
+ let adaId = try XCTUnwrap(ada["id"] as? String)
187
+ try adapter.table("Post").create(["title": "first", "userId": adaId])
188
+ try adapter.table("Post").create(["title": "second", "userId": adaId])
189
+
190
+ var query = Query()
191
+ query.orderBy = [["name": "asc"]]
192
+ query.include = ["posts": true, "_count": true]
193
+ let rows = try adapter.table("User").findMany(query)
194
+ let first = try XCTUnwrap(rows.first)
195
+
196
+ let posts = try XCTUnwrap(first["posts"] as? [Row])
197
+ XCTAssertEqual(posts.count, 2)
198
+ XCTAssertEqual(posts.first?["title"] as? String, "first")
199
+ XCTAssertEqual((first["_count"] as? [String: Int])?["posts"], 2)
200
+
201
+ try adapter.table("User").create([
202
+ "name": "Barbara",
203
+ "posts": ["create": ["title": "third"]],
204
+ ])
205
+ XCTAssertEqual(try adapter.table("Post").count(), 3)
206
+ }
207
+
208
+ // MARK: - Transactions
209
+
210
+ func testTransactionRollback() throws {
211
+ let adapter = try makeAdapter()
212
+ let users = adapter.table("User")
213
+ try users.create(["name": "outside"])
214
+
215
+ struct Boom: Error {}
216
+ XCTAssertThrowsError(
217
+ _ = try adapter.transaction { scoped in
218
+ try scoped.table("User").create(["name": "inside"])
219
+ throw Boom()
220
+ }
221
+ )
222
+ XCTAssertEqual(try users.count(), 1, "a rollback discards the inner insert")
223
+
224
+ _ = try adapter.transaction { scoped in
225
+ try scoped.table("User").create(["name": "committed"])
226
+ }
227
+ XCTAssertEqual(try users.count(), 2)
228
+
229
+ XCTAssertThrowsError(
230
+ _ = try adapter.transaction { scoped in
231
+ try scoped.transaction { _ in }
232
+ },
233
+ "a nested transaction is refused rather than silently flattened"
234
+ )
235
+ }
236
+
237
+ // MARK: - Views and vector search
238
+
239
+ func testViewIsReadOnly() throws {
240
+ let adapter = try makeAdapter()
241
+ try adapter.table("User").create(["name": "Ada", "score": 1])
242
+ let view = adapter.view("User")
243
+ XCTAssertEqual(try view.count(), 1)
244
+ XCTAssertEqual(try view.findMany().count, 1)
245
+ XCTAssertThrowsError(try view.create(["name": "x"])) { error in
246
+ guard case .readOnly = error as? An5Error else {
247
+ return XCTFail("expected a read-only error, got \(error)")
248
+ }
249
+ }
250
+ }
251
+
252
+ func testVectorFallback() throws {
253
+ let adapter = try makeAdapter()
254
+ let documents = adapter.table("Document")
255
+ // Orthogonal to the query: cosine distance is scale-invariant, so a longer vector
256
+ // pointing the same way would still rank as the closest.
257
+ try documents.create(["title": "far", "embedding": "[0.0, 1.0]"])
258
+ try documents.create(["title": "near", "embedding": "[1.0, 0.1]"])
259
+ try documents.create(["title": "no vector", "embedding": NSNull()])
260
+
261
+ let hits = try documents.vectorSearch([1.0, 0.0], take: 2)
262
+ XCTAssertEqual(hits.count, 2, "rows without a vector are skipped")
263
+ XCTAssertEqual(hits.first?["title"] as? String, "near")
264
+ XCTAssertNotNil(hits.first?["distance"] as Any?)
265
+ }
266
+
267
+ // MARK: - SQLite vector search
268
+
269
+ /// The bundled driver registers the `an5_vec_*` functions and can load sqlite-vec, so a
270
+ /// `VECTOR(n)` column is ranked inside the database.
271
+ ///
272
+ /// The docstring in `SqliteVectors` is the shared specification every runtime implements;
273
+ /// test/sqlite-vector.test.js (TypeScript) is the mirror of this coverage.
274
+ func testSqliteVectorCodecAndPlan() {
275
+ let encoded = SqliteVectors.encode([1, -2, 0.5])
276
+ XCTAssertEqual(encoded.count, 12, "float32 per value")
277
+ XCTAssertEqual(Array(encoded.prefix(4)), [0x00, 0x00, 0x80, 0x3F], "little-endian 1.0")
278
+ XCTAssertEqual(SqliteVectors.decodeBlob(encoded) ?? [], [1, -2, 0.5])
279
+ XCTAssertEqual(SqliteVectors.decode("[1, 0, 0]") ?? [], [1, 0, 0], "legacy JSON text")
280
+ XCTAssertEqual(SqliteVectors.decode(encoded, expectedLength: 3) ?? [], [1, -2, 0.5])
281
+ XCTAssertNil(SqliteVectors.decode(encoded, expectedLength: 5), "another dimension")
282
+ XCTAssertNil(SqliteVectors.decodeBlob(Data([1, 2, 3])), "a partial blob")
283
+ XCTAssertNil(SqliteVectors.decode("nope"))
284
+ XCTAssertNil(SqliteVectors.decode(nil))
285
+
286
+ let all = SqliteVectors.Capabilities(vec: true, udf: true, json1: true)
287
+ XCTAssertEqual(
288
+ SqliteVectors.plan(capabilities: all, declaredType: "BLOB", preference: nil),
289
+ ["sqlite-vec", "udf"]
290
+ )
291
+ // A BLOB column has no JSON to read, so json_each could only produce NULLs.
292
+ XCTAssertEqual(
293
+ SqliteVectors.plan(
294
+ capabilities: SqliteVectors.Capabilities(json1: true), declaredType: "BLOB",
295
+ preference: nil
296
+ ),
297
+ []
298
+ )
299
+ XCTAssertEqual(
300
+ SqliteVectors.plan(
301
+ capabilities: SqliteVectors.Capabilities(json1: true), declaredType: "TEXT",
302
+ preference: nil
303
+ ),
304
+ ["sql"]
305
+ )
306
+ XCTAssertEqual(
307
+ SqliteVectors.plan(capabilities: all, declaredType: "BLOB", preference: "udf"), ["udf"]
308
+ )
309
+ XCTAssertEqual(
310
+ SqliteVectors.plan(capabilities: all, declaredType: "BLOB", preference: "memory"), [],
311
+ "memory is the caller's own path, not a query"
312
+ )
313
+
314
+ var bind: [SQLValue] = []
315
+ let udf = SqliteVectors.rankingQuery(
316
+ strategy: SqliteVectors.strategyUdf, metric: .cosine, table: "[documents]",
317
+ column: "[embedding]", vector: [1, 0, 0], take: 5, tail: " WHERE [id] = ?",
318
+ placeholder: "?", bind: &bind
319
+ )
320
+ XCTAssertNotNil(udf)
321
+ XCTAssertEqual(udf?.contains("WHERE distance IS NOT NULL"), true)
322
+ switch bind.first {
323
+ case .some(.blob): break
324
+ default: XCTFail("the query vector is bound as bytes")
325
+ }
326
+ bind = []
327
+ let json = SqliteVectors.rankingQuery(
328
+ strategy: SqliteVectors.strategySQL, metric: .cosine, table: "[documents]",
329
+ column: "[embedding]", vector: [1, 0, 0], take: 5, tail: "", placeholder: "?", bind: &bind
330
+ )
331
+ XCTAssertEqual(json?.contains("json_each"), true)
332
+ switch bind.first {
333
+ case .some(.text): break
334
+ default: XCTFail("json_each reads the vector as text")
335
+ }
336
+ }
337
+
338
+ func testSqliteVectorSearchAndRoundTrip() throws {
339
+ let adapter = try makeBlobAdapter()
340
+ let documents = adapter.table("Document")
341
+ let vectors: [(String, [Double])] = [
342
+ ("d1", [1, 0, 0]), ("d2", [0.8, 0.2, 0]), ("d3", [0, 1, 0]),
343
+ ]
344
+ for (id, vector) in vectors {
345
+ try adapter.execute(
346
+ "INSERT INTO documents (id, title, embedding) VALUES (?, ?, ?)",
347
+ [id, id, SqliteVectors.encode(vector)]
348
+ )
349
+ }
350
+ // A row with another dimension must never rank against a 3-dimension query, and one
351
+ // with no vector must not win by default.
352
+ try adapter.execute(
353
+ "INSERT INTO documents (id, title, embedding) VALUES ('d4', 'd4', ?)",
354
+ [SqliteVectors.encode([1, 0, 0, 1])]
355
+ )
356
+ try adapter.execute("INSERT INTO documents (id, title, embedding) VALUES ('d5', 'd5', NULL)")
357
+
358
+ for metric in [DistanceMetric.cosine, .euclidean, .dot] {
359
+ let hits = try documents.vectorSearch([1, 0, 0], take: 9, metric: metric)
360
+ XCTAssertEqual(hits.compactMap { $0["title"] as? String }, ["d1", "d2", "d3"],
361
+ "\(metric.rawValue) row order")
362
+ XCTAssertNotNil(hits.first?["distance"] as Any?, "\(metric.rawValue) reports a distance")
363
+ XCTAssertTrue(hits.first?["embedding"] is [Double], "\(metric.rawValue) decodes the column")
364
+ }
365
+
366
+ // `memory` pins the client-side fallback, which ranks the same rows.
367
+ adapter.vectorStrategy = SqliteVectors.strategyMemory
368
+ let pinned = try documents.vectorSearch([1, 0, 0], take: 9)
369
+ XCTAssertEqual(pinned.compactMap { $0["title"] as? String }, ["d1", "d2", "d3"])
370
+ adapter.vectorStrategy = nil
371
+
372
+ let written = try documents.create(
373
+ ["id": "d9", "title": "written", "embedding": [0.25, 0.5, 1.0]]
374
+ )
375
+ let stored = try adapter.query("SELECT typeof(embedding) AS t FROM documents WHERE id = 'd9'")
376
+ XCTAssertEqual(stored.first?["t"] as? String, "blob", "an array is written as a float32 BLOB")
377
+ XCTAssertEqual(written["embedding"] as? [Double] ?? [], [0.25, 0.5, 1.0])
378
+
379
+ let found = try documents.findMany(filter: ["id": "d9"])
380
+ XCTAssertEqual(found.first?["embedding"] as? [Double] ?? [], [0.25, 0.5, 1.0])
381
+ try documents.update(filter: ["id": "d1"], data: ["embedding": [0.5, 0.5, 0]])
382
+ let updated = try documents.findMany(filter: ["id": "d1"])
383
+ XCTAssertEqual(updated.first?["embedding"] as? [Double] ?? [], [0.5, 0.5, 0])
384
+ XCTAssertEqual(updated.first?["title"] as? String, "d1", "a non-vector column is untouched")
385
+ try documents.updateMany(filter: ["id": "d2"], data: ["embedding": [0, 0, 1]])
386
+ let many = try documents.findMany(filter: ["id": "d2"])
387
+ XCTAssertEqual(many.first?["embedding"] as? [Double] ?? [], [0, 0, 1])
388
+ }
389
+
390
+ /// A loaded AN5 C extension must not be shadowed by the Swift callbacks.
391
+ ///
392
+ /// SQLite lets a later `sqlite3_create_function` replace an earlier one, so registering
393
+ /// callbacks over a loaded extension would silently swap native code for the slower path.
394
+ ///
395
+ /// Skipped unless `AN5_NATIVE_VECTOR_PATH` points at a built extension; the Swift gate
396
+ /// builds one with `build:sqlite:native`, like the Python and .NET gates do.
397
+ func testNativeExtensionIsPreserved() throws {
398
+ guard let path = ProcessInfo.processInfo.environment["AN5_NATIVE_VECTOR_PATH"],
399
+ FileManager.default.fileExists(atPath: path) else {
400
+ throw XCTSkip("AN5_NATIVE_VECTOR_PATH is not set; run build:sqlite:native first")
401
+ }
402
+ SQLiteDriver.sqliteVecPath = path
403
+ defer { SQLiteDriver.sqliteVecPath = nil }
404
+ let driver = try SQLiteDriver(path: ":memory:")
405
+
406
+ let loaded = try driver.query("SELECT an5_vector_version() AS v", [])
407
+ XCTAssertEqual(loaded.first?["v"] as? String, "an5-vector/1")
408
+
409
+ // `0.1` is not representable in float32, so this result proves the C function is
410
+ // in use: a Swift callback would compute it in Double.
411
+ let rows = try driver.query("SELECT an5_vec_ip('[0.1]', '[0.1]') AS d", [])
412
+ let expected = -(Double(Float(0.1)) * Double(Float(0.1)))
413
+ XCTAssertEqual(try XCTUnwrap(rows.first?["d"] as? Double), expected, accuracy: 1e-12)
414
+ }
415
+
416
+ /// A database whose vector column holds float32 BLOBs, as `VECTOR(n)` now maps to.
417
+ private func makeBlobAdapter() throws -> An5Adapter {
418
+ let metadata = Metadata(
419
+ modelToTable: ["Document": "documents"],
420
+ modelFields: [
421
+ "Document": [
422
+ FieldMeta(name: "id", type: "string", sqlType: "TEXT", isId: true),
423
+ FieldMeta(name: "title", type: "string", sqlType: "TEXT"),
424
+ FieldMeta(
425
+ name: "embedding", type: "number[] | string", sqlType: "VECTOR(3)",
426
+ isOptional: true
427
+ ),
428
+ ]
429
+ ],
430
+ relationMap: [:]
431
+ )
432
+ let adapter = try An5Adapter(path: ":memory:", metadata: metadata)
433
+ try adapter.execute("CREATE TABLE documents (id TEXT PRIMARY KEY, title TEXT, embedding BLOB)")
434
+ return adapter
435
+ }
436
+
437
+ // MARK: - Metadata
438
+
439
+ func testMetadataAcceptsEitherSpelling() throws {
440
+ let snake = try XCTUnwrap(Metadata([
441
+ "model_to_table": ["User": "users"],
442
+ "model_fields": ["User": [["name": "id", "isId": true]]],
443
+ "relation_map": [:],
444
+ ]))
445
+ XCTAssertEqual(snake.table("User"), "users")
446
+ XCTAssertEqual(snake.idField("User")?.name, "id")
447
+
448
+ let camel = try XCTUnwrap(Metadata([
449
+ "modelToTable": ["user": "users"],
450
+ "modelFields": ["user": [["name": "id", "isId": true]]],
451
+ "relationMap": [:],
452
+ ]))
453
+ // The generated client names tables in PascalCase while the metadata keys them in
454
+ // camelCase; a plain lookup would find no columns and skip the primary key.
455
+ XCTAssertEqual(camel.table("User"), "users")
456
+ XCTAssertEqual(camel.idField("User")?.name, "id")
457
+ }
458
+
459
+ func testUnsupportedValueIsRejectedNotSilentlyDropped() throws {
460
+ struct Opaque {}
461
+ let adapter = try makeAdapter()
462
+ // A value with no encoding becomes NULL rather than being interpolated into the SQL.
463
+ try adapter.table("User").create(["name": Opaque(), "score": 1])
464
+ XCTAssertEqual(try adapter.table("User").count(), 1)
465
+ }
466
+
467
+ // MARK: - Fixtures
468
+
469
+ static let metadata = Metadata(
470
+ modelToTable: ["User": "users", "Post": "posts", "Document": "documents"],
471
+ modelFields: [
472
+ "User": [FieldMeta(name: "id", type: "string", sqlType: "NVARCHAR(255)", isId: true),
473
+ FieldMeta(name: "name", type: "string", sqlType: "NVARCHAR(255)"),
474
+ FieldMeta(name: "score", type: "number", sqlType: "INT")],
475
+ "Post": [FieldMeta(name: "id", type: "string", sqlType: "NVARCHAR(255)", isId: true),
476
+ FieldMeta(name: "title", type: "string", sqlType: "NVARCHAR(255)"),
477
+ FieldMeta(name: "userId", type: "string", sqlType: "NVARCHAR(255)")],
478
+ "Document": [FieldMeta(name: "id", type: "string", sqlType: "NVARCHAR(255)", isId: true),
479
+ FieldMeta(name: "title", type: "string", sqlType: "NVARCHAR(255)"),
480
+ FieldMeta(name: "embedding", type: "vector", sqlType: "VECTOR")],
481
+ ],
482
+ relationMap: [
483
+ "User": [
484
+ "posts": RelationDef(modelName: "Post", relationType: "many", foreignKey: "userId", localKey: "id")
485
+ ]
486
+ ]
487
+ )
488
+ }