8000 feat: open options, query cursors, fact log and log writer (v3) by adityamukho · Pull Request #6 · project-minigraf/minigraf-swift · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: CI

on:
push:
branches: [main]
branches: [main, v3]
pull_request:
workflow_call:
inputs:
Expand Down Expand Up @@ -34,3 +34,31 @@ jobs:
targets: aarch64-apple-ios-sim
- name: Build for iOS simulator
run: cargo build --target aarch64-apple-ios-sim --release

# Compiles the generated Swift bindings with Sources/MinigrafKit and runs
# Tests/swift/main.swift against a macOS build of the shim.
swift-test:
name: Swift bindings test (macOS)
runs-on: macos-14
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
- uses: dtolnay/rust-toolchain@stable
- name: Build shim (release)
run: cargo build --release
- name: Generate Swift bindings
run: |
cargo run --release --bin uniffi-bindgen -- generate \
--library target/release/libminigraf_ffi.dylib \
--language swift \
--out-dir build/swift
- name: Compile and run Swift tests
run: |
swiftc -module-name MinigrafKit \
-I build/swift \
-Xcc -fmodule-map-file=build/swift/minigraf_ffiFFI.modulemap \
-L target/release -lminigraf_ffi \
build/swift/minigraf_ffi.swift Sources/MinigrafKit/*.swift Tests/swift/main.swift \
-o build/swift-test
DYLD_LIBRARY_PATH=target/release ./build/swift-test
3 changes: 1 addition & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ path = "src/uniffi_bindgen.rs"

[dependencies]
# Pin to the exact released version. The release workflow updates this line.
minigraf = "=2.0.3"
# Unreleased v3 core (format v8). Switches to minigraf = "=3.0.0" at the v3.0.0 release.
minigraf = { git = "https://github.com/project-minigraf/minigraf", rev = "97cd4a2a85301ea3478250dd654139d45a1f7cd0" }
uniffi = { version = "0.31.1", features = ["cli"] }
thiserror = "2.0.18"
serde_json = "1.0.149"
Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,33 @@ let result = try db.execute(datalog: #"(transact [[:alice :name "Alice"]])"#)
print(result) // {"transacted":1}
```

## Open options, cursors, the fact log and the log writer

```swift
import MinigrafKit

// Read-only: shared lock, nothing written; writes throw MiniGrafError (API-014).
let src = try MiniGrafDb.openWithOptions(path: oldPath, options: OpenOptions(readOnly: true, pageCacheSize: 4096))

// A cursor's answer is fixed when it opens. Each batch is a JSON array of rows,
// encoded like execute()'s "results".
let cursor = try src.query(datalog: "(query [:find ?n :where [?e :name ?n]])")
try cursor.forEachBatch(size: 1000) { batch in /* parse rows */ }

// Copy every fact version, keeping tx and valid-time bounds, into a new file.
let out = try MiniGrafLogWriter.create(path: newPath, options: OpenOptions())
try src.factLog(filter: FactFilter()).forEachRecord { record in
if !record.attribute.hasPrefix(":secret/") { try out.append(record: record) }
}
try out.advanceTxCount(txCount: try src.currentTxCount())
try out.finish() // out.close() instead abandons the build and leaves no file
```

`appendBatch(records:)` appends many records per call. A record's value is a
`MiniGrafValue` (`.text`, `.int64`, `.float64`, `.bool`, `.ref`, `.keyword`, `.null`);
`validTimeForever` is the `validTo` of a fact valid forever. `MiniGrafError.message`
starts with the error's code, such as `[API-015]`.

## Building from source

Requires Rust stable toolchain with iOS targets and Xcode.
Expand Down
37 changes: 37 additions & 0 deletions Sources/MinigrafKit/MinigrafKit+Iteration.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Iteration helpers over the generated bindings. A Swift `Sequence` cannot
// throw, and a batch read can fail, so these take a throwing closure instead.

extension MiniGrafCursor {
/// Calls `body` with each batch of at most `size` rows, as a JSON array of
/// rows encoded like `execute`'s `results`, until the cursor ends.
public func forEachBatch(size: Int32 = 1000, _ body: (String) throws -> Void) throws {
while let batch = try nextBatch(maxRows: size) {
try body(batch)
}
}
}

extension MiniGrafFactLog {
/// Calls `body` with each record, reading `batchSize` records at a time,
/// until the log ends.
public func forEachRecord(batchSize: Int32 = 1000, _ body: (FactRecord) throws -> Void) throws {
while let batch = try nextBatch(maxRecords: batchSize) {
for record in batch {
try body(record)
}
}
}
}

extension MiniGrafError {
/// The error's text, starting with its code, such as `[API-015]`.
public var message: String {
switch self {
case .Storage(let msg), .Query(let msg), .Parse(let msg), .Other(let msg):
return msg
}
}
}

/// `validTo` of a fact that is valid forever.
public let validTimeForever: Int64 = Int64.max
133 changes: 133 additions & 0 deletions Tests/swift/main.swift
7095
Original 10BC8 file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// Run by CI (swift-test job): compiled together with the generated bindings and
// Sources/MinigrafKit into one module, linked against libminigraf_ffi.
import Foundation

var failures = 0
func check(_ condition: Bool, _ what: String, line: Int = #line) {
if !condition {
print("FAIL line \(line): \(what)")
failures += 1
}
}

func errorText(_ body: () throws -> Void) -> String {
do {
try body()
return "no error"
} catch let e as MiniGrafError {
return e.message
} catch {
return "unexpected error"
}
}

let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
func path(_ name: String) -> String { dir.appendingPathComponent(name).path }
let ref = "00000000-0000-4000-8000-000000000001"
let query = "(query [:find ?e ?n :where [?e :n ?n]])"

// Cursor (#462)
do {
let db = try MiniGrafDb.openInMemory()
for i in 0..<25 { _ = try db.execute(datalog: "(transact [[:e\(i) :n \(i)]])") }
let all = try JSONSerialization.jsonObject(with: Data(try db.execute(datalog: query).utf8)) as! [String: Any]
let expected = (all["results"] as! [[Any]]).count
for size: Int32 in [1, 7, 1000] {
let cursor = try db.query(datalog: query)
check(cursor.vars() == ["?e", "?n"], "vars")
var rows = 0
try cursor.forEachBatch(size: size) { batch in
let decoded = try JSONSerialization.jsonObject(with: Data(batch.utf8)) as! [[Any]]
check(!decoded.isEmpty && decoded.count <= Int(size), "batch size")
rows += decoded.count
}
check(rows == expected, "rows for batch size \(size)")
}
let fixed = try db.query(datalog: query)
_ = try db.execute(datalog: "(transact [[:late :n 99]])")
var fixedRows = 0
try fixed.forEachBatch { fixedRows += (try JSONSerialization.jsonObject(with: Data($0.utf8)) as! [[Any]]).count }
check(fixedRows == 25, "cursor fixed at open")
let closed = try db.query(datalog: query)
closed.close()
check(try closed.nextBatch(maxRows: 1) == nil, "closed cursor ends")
check(errorText { _ = try db.query(datalog: "(transact [[:a :n 1]])") }.hasPrefix("[API-012]"), "API-012")
}

// Open options (#465)
do {
let p = path("ro.graph")
do {
let db = try MiniGrafDb.open(path: p)
_ = try db.execute(datalog: "(transact [[:a :n 1] [:b :n 2]])")
try db.checkpoint()
}
let ro = try MiniGrafDb.openWithOptions(path: p, options: OpenOptions(readOnly: true, pageCacheSize: 16))
let ro2 = try MiniGrafDb.openWithOptions(path: p, options: OpenOptions(readOnly: true))
check(try ro.currentTxCount() == 1 && ro2.currentTxCount() == 1, "two read-only handles")
check(errorText { _ = try ro.execute(datalog: "(transact [[:c :n 3]])") }.hasPrefix("[API-014]"), "API-014")
check(errorText { _ = try MiniGrafDb.open(path: p) }.hasPrefix("[STG-02"), "writer refused")
let missing = path("missing.graph")
check(errorText { _ = try MiniGrafDb.openWithOptions(path: missing, options: OpenOptions(readOnly: true)) }.hasPrefix("[STG-042]"), "STG-042")
check(!FileManager.default.fileExists(atPath: missing), "missing file not created")
}

// Fact log and log writer (#467)
do {
let src = try MiniGrafDb.open(path: path("src.graph"))
_ = try src.execute(datalog: #"(transact {:valid-from "2024-01-01" :valid-to "2025-01-01"} [[:alice :name "Alice"] [:alice :tag :t/admin]])"#)
_ = try src.execute(datalog: "(transact [[:bob :friend #uuid \"\(ref)\"] [:bob :score 2.5]])")
_ = try src.execute(datalog: #"(retract [[:alice :name "Alice"]])"#)

func records(_ db: MiniGrafDb, _ filter: FactFilter = FactFilter()) throws -> [FactRecord] {
var out: [FactRecord] = []
try db.factLog(filter: filter).forEachRecord(batchSize: 2) { out.append($0) }
return out
}
let recs = try records(src)
check(recs.count == 5, "five records")
check(recs.map(\.txCount) == recs.map(\.txCount).sorted(), "tx order")
check(recs.first { $0.attribute == ":friend" }?.value == .ref(value: ref), "ref value")
check(recs.first { $0.attribute == ":tag" }?.value == .keyword(value: ":t/admin"), "keyword value")
check(recs.first { $0.attribute == ":score" }?.validTo == validTimeForever, "forever")
check(try records(src, FactFilter(txFrom: 2, txTo: 2)).allSatisfy { $0.txCount == 2 }, "tx filter")
check(errorText { _ = try src.factLog(filter: FactFilter(entities: ["alice"])) }.hasPrefix("[API-017]"), "API-017")

let out = path("out.graph")
let w = try MiniGrafLogWriter.create(path: out, options: OpenOptions())
try w.append(record: recs[0])
try w.appendBatch(records: Array(recs.dropFirst()))
try w.advanceTxCount(txCount: try src.currentTxCount())
check(try w.txCount() == 3, "writer tx_count")
try w.finish()
check(errorText { try w.finish() }.hasPrefix("[API-018]"), "API-018")
let copy = try MiniGrafDb.openWithOptions(path: out, options: OpenOptions(readOnly: true))
check(try copy.currentTxCount() == src.currentTxCount(), "copied tx count")
check(try records(copy) == recs, "copied records")

let hole = path("hole.graph")
let h = try MiniGrafLogWriter.create(path: hole, options: OpenOptions())
try h.appendBatch(records: recs.filter { $0.txCount != 2 })
try h.advanceTxCount(txCount: 3)
try h.finish()
let hdb = try MiniGrafDb.openWithOptions(path: hole, options: OpenOptions(readOnly: true))
check(Set(try records(hdb).map(\.txCount)) == [1, 3], "hole")

let bad = path("bad.graph")
let b = try MiniGrafLogWriter.create(path: bad, options: OpenOptions())
try b.append(record: recs.last!)
let order = errorText { try b.appendBatch(records: [recs.last!, recs.first!]) }
check(order.hasPrefix("[API-015]") && order.hasSuffix("(batch index 1)"), "API-015 batch index")
b.close()
check(!b.isOpen(), "closed writer")
check(!FileManager.default.fileExists(atPath: bad) && !FileManager.default.fileExists(atPath: bad + ".partial"), "abandoned build leaves no file")
check(errorText { _ = try MiniGrafLogWriter.create(path: out, options: OpenOptions()) }.hasPrefix("[STG-043]"), "STG-043")
}

try? FileManager.default.removeItem(at: dir)
if failures > 0 {
print("\(failures) check(s) failed")
exit(1)
}
print("all Swift checks passed")
Loading
Loading
0