@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,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
|
+
}
|