Embed relate in a Swift app
A first Swift program on the relate Swift package, which opens a store, passes parameters, runs transactions, reads result metadata, and catches a typed error.
swift
By the end of this page, you'll have a Swift program that keeps a small graph of people and projects in one file. It reads the graph with parameters and reports errors by their case and code. Along the way we write inside a transaction A unit of work that commits as a whole or not at all; each statement is its own transaction unless an explicit begin … commit groups several. Read more in the glossary, roll one back, and read what a result says about itself. It takes about fifteen minutes. relate runs inside your app's process, so there is no server to start, and every step writes to a store it creates.
Prerequisites
- Swift 6.0 or later on macOS 13 or later. The package also builds for iOS 16 or later; this page runs on the Mac.
- A terminal and a text editor. The relate CLI is not needed: the package carries the engine.
Install the package
The Swift client A language package that runs the relate engine inside a program's process: the Python, Node.js, Rust, Java, Go and Swift clients. Read more in the glossary is the SwiftPM package Relate, imported as import Relate. Create an executable package and add it:
mkdir Team && cd Team
swift package init --type executableIn Package.swift, add the package to dependencies and the product to your executable target's dependencies:
.package(url: "https://github.com/relatedb/relate", from: "0.1.2")
.product(name: "Relate", package: "relate")The package declares iOS 16 and macOS 13 as its minimum platforms, so set platforms: [.macOS(.v13)] on your package too. It links the engine as a prebuilt static XCFramework, so the engine is not compiled from source; only the package's Swift wrapper is. Each program on this page is top-level code in Sources/Team/main.swift, run with swift run. Top-level code needs that file to be the target's only entry point, so remove any file with @main that the template created in Sources/Team. The package has no version constant of its own, so this program asks the engine which release it is:
import Relate
let db = try Database(path: ":memory:")
let conn = try db.connect()
let result = try conn.query(
"CALL dbms.components() YIELD name, versions "
+ "WHERE name = 'Relate' RETURN versions[0] AS version")
print(result.rows[0][0].stringValue ?? "unknown")0.1.2
Notice the version: the programs on this page were checked against that release, and their recorded output comes from it.
Open a store
A store A relate database: a directory, a single .rl file, or :memory:, opened with --database. Read more in the glossary is where relate keeps a graph. :memory: is a store that lives in the process and forgets everything when it closes. A path ending in .rl is a single-file store A store kept in one .rl file that holds the snapshot and the WAL together; the layout for a --database path ending in .rl. Read more in the glossary, which Database(path:) creates when nothing is there yet.
import Foundation
import Relate
let memory = try Database(path: ":memory:")
let conn = try memory.connect()
let result = try conn.query("RETURN 'hello' AS greeting")
print(result.rows[0][0].stringValue ?? "")
_ = try Database(path: "./team.rl")
print(FileManager.default.fileExists(atPath: "team.rl"))hello true
Database(path:) opens a store, connect() returns a connection on it, and query(_:_:) runs one statement and returns its whole result. Every call is synchronous and throws on failure, so each one takes try. A Database closes when its last reference is released, and each connection holds its database. Notice that team.rl now exists.
Create the graph
Our graph is three people and two projects: who works on what, since when, and who maintains relate. One CREATE statement writes all of it. The rest of this page uses the same statement, as team, on an in-memory store, so each program runs on its own.
import Relate
let team = """
CREATE (ada:Person {name: 'Ada', role: 'engineer'}),
(grace:Person {name: 'Grace', role: 'engineer'}),
(noor:Person {name: 'Noor', role: 'maintainer'}),
(relate:Project {name: 'relate', language: 'Rust'}),
(tck:Project {name: 'opencypher-tck', language: 'Gherkin'}),
(ada)-[:WORKS_ON {since: 2024}]->(relate),
(grace)-[:WORKS_ON {since: 2023}]->(relate),
(grace)-[:WORKS_ON {since: 2022}]->(tck),
(noor)-[:MAINTAINS]->(relate),
(ada)-[:KNOWS]->(grace)
"""
let db = try Database(path: ":memory:")
let conn = try db.connect()
let result = try conn.query(team)
print("nodes created:", result.stats.nodesCreated)
print("relationships created:", result.stats.relationshipsCreated)nodes created: 5 relationships created: 5
A write returns no rows, so we read its counters from result.stats. Five nodes and five relationships: the whole team.
Read it back with parameters
A statement names each value it needs as $name, and query(_:_:) takes the values as a [String: CypherValue] dictionary. The statement text stays the same while the values change, and a value never becomes part of the Cypher.
import Relate
let team = """
CREATE (ada:Person {name: 'Ada', role: 'engineer'}),
(grace:Person {name: 'Grace', role: 'engineer'}),
(noor:Person {name: 'Noor', role: 'maintainer'}),
(relate:Project {name: 'relate', language: 'Rust'}),
(tck:Project {name: 'opencypher-tck', language: 'Gherkin'}),
(ada)-[:WORKS_ON {since: 2024}]->(relate),
(grace)-[:WORKS_ON {since: 2023}]->(relate),
(grace)-[:WORKS_ON {since: 2022}]->(tck),
(noor)-[:MAINTAINS]->(relate),
(ada)-[:KNOWS]->(grace)
"""
let teamOf = """
MATCH (p:Person)-[w:WORKS_ON]->(:Project {name: $project})
RETURN p.name AS name, w.since AS since
ORDER BY since
"""
let db = try Database(path: ":memory:")
let conn = try db.connect()
try conn.query(team)
for project in ["relate", "opencypher-tck"] {
print(project)
for row in try conn.query(teamOf, ["project": CypherValue(project)]).records {
let name = row["name"]?.stringValue ?? ""
let since = row["since"]?.intValue ?? 0
print(" \(name) since \(since)")
}
}relate Grace since 2023 Ada since 2024 opencypher-tck Grace since 2022
records gives each row as a dictionary keyed by the names in RETURN. Each value is a CypherValue, and stringValue and intValue read it as a Swift type, or nil when it is another kind. A literal needs no wrapping (["project": "relate"]), and a variable goes through CypherValue(_:). Notice that Noor isn't listed: she maintains relate through a MAINTAINS relationship, and the pattern asks for WORKS_ON.
Change it in a transaction
A connection commits each statement on its own until you call begin(). From then on, every statement is part of one transaction, commit() keeps it, and rollback() throws it away. This program adds Lin to the team and commits, then deletes every project and rolls back.
import Relate
let team = """
CREATE (ada:Person {name: 'Ada', role: 'engineer'}),
(grace:Person {name: 'Grace', role: 'engineer'}),
(noor:Person {name: 'Noor', role: 'maintainer'}),
(relate:Project {name: 'relate', language: 'Rust'}),
(tck:Project {name: 'opencypher-tck', language: 'Gherkin'}),
(ada)-[:WORKS_ON {since: 2024}]->(relate),
(grace)-[:WORKS_ON {since: 2023}]->(relate),
(grace)-[:WORKS_ON {since: 2022}]->(tck),
(noor)-[:MAINTAINS]->(relate),
(ada)-[:KNOWS]->(grace)
"""
func count(_ conn: Connection, _ label: String) throws -> Int64 {
try conn.query("MATCH (n:\(label)) RETURN count(n) AS n").scalar().intValue ?? 0
}
let db = try Database(path: ":memory:")
let conn = try db.connect()
try conn.query(team)
try conn.begin()
try conn.query(
"MATCH (tck:Project {name: 'opencypher-tck'}) "
+ "CREATE (:Person {name: 'Lin', role: 'engineer'})-[:WORKS_ON {since: 2026}]->(tck)")
let bookmark = try conn.commit()
print("committed:", bookmark != nil)
print("people after commit:", try count(conn, "Person"))
try conn.begin()
try conn.query("MATCH (p:Project) DETACH DELETE p")
print("projects inside the transaction:", try count(conn, "Project"))
try conn.rollback()
print("projects after rollback:", try count(conn, "Project"))committed: true people after commit: 4 projects inside the transaction: 0 projects after rollback: 2
Inside the transaction, the delete is visible: zero projects. After rollback(), both are back, and Lin, who was committed, stays. commit() returns a bookmark The marker a commit returns, relate:<n>, naming its place in the store's commit sequence; returned by the clients and over Bolt. Read more in the glossary, the commit's place in the store's history as relate:<n>, or nil when the transaction wrote nothing. scalar() reads a result of exactly one row and one column. Each transaction starts with its own begin().
Read result metadata
A QueryResult describes itself as well as holding rows: the column names, what kind of statement it was, and a stats value that counts what the statement changed.
import Relate
let team = """
CREATE (ada:Person {name: 'Ada', role: 'engineer'}),
(grace:Person {name: 'Grace', role: 'engineer'}),
(noor:Person {name: 'Noor', role: 'maintainer'}),
(relate:Project {name: 'relate', language: 'Rust'}),
(tck:Project {name: 'opencypher-tck', language: 'Gherkin'}),
(ada)-[:WORKS_ON {since: 2024}]->(relate),
(grace)-[:WORKS_ON {since: 2023}]->(relate),
(grace)-[:WORKS_ON {since: 2022}]->(tck),
(noor)-[:MAINTAINS]->(relate),
(ada)-[:KNOWS]->(grace)
"""
let db = try Database(path: ":memory:")
let conn = try db.connect()
try conn.query(team)
let result = try conn.query(
"MATCH (p:Person)-[w:WORKS_ON]->(:Project {name: $project}) "
+ "RETURN p.name AS name, w.since AS since ORDER BY since",
["project": "relate"])
print(result.columns)
for (column, value) in zip(result.columns, result.rows[0]) {
print(column, value.kind)
}
print("query type:", result.queryType.rawValue)
let write = try conn.query("MATCH (n:Person {name: 'Noor'}) SET n.role = 'lead'")
print("query type:", write.queryType.rawValue)
print("properties set:", write.stats.propertiesSet)["name", "since"] name string since int query type: r query type: w properties set: 1
A result has no list of column types: each value carries its own kind, which kind reads back, and a node arrives as .node with its labels and properties. queryType is a QueryType whose raw value is r for a read and w for a write that returns no columns. stats holds all 11 change counters, zeros included.
Catch an error
Every failure throws a RelateError, and its case says where it came from. A statement the engine rejects is .engine, carrying an EngineError. A statement that uses $name without a value is rejected before it runs:
import Relate
let team = """
CREATE (ada:Person {name: 'Ada', role: 'engineer'}),
(grace:Person {name: 'Grace', role: 'engineer'}),
(noor:Person {name: 'Noor', role: 'maintainer'}),
(relate:Project {name: 'relate', language: 'Rust'}),
(tck:Project {name: 'opencypher-tck', language: 'Gherkin'}),
(ada)-[:WORKS_ON {since: 2024}]->(relate),
(grace)-[:WORKS_ON {since: 2023}]->(relate),
(grace)-[:WORKS_ON {since: 2022}]->(tck),
(noor)-[:MAINTAINS]->(relate),
(ada)-[:KNOWS]->(grace)
"""
let roleOf = "MATCH (p:Person {name: $name}) RETURN p.role AS role"
let db = try Database(path: ":memory:")
let conn = try db.connect()
try conn.query(team)
do {
try conn.query(roleOf)
} catch RelateError.engine(let e) {
print(e.message)
print("gqlStatus:", e.gqlStatus)
print("code:", e.code)
print("stage:", e.stage)
print("retryable:", e.retryable)
}
print(try conn.query(roleOf, ["name": "Noor"]).scalar().stringValue ?? "")semantic error: ParameterMissing: Expected parameter(s): name gqlStatus: 42N02 code: Neo.ClientError.Statement.ParameterMissing stage: compile retryable: false maintainer
The message says what's missing; the properties are what code branches on:
| Property | This error | Meaning |
|---|---|---|
gqlStatus | 42N02 | The GQLSTATUS code; the Python and Node.js clients report the same code for the same error |
code | Neo.ClientError.Statement.ParameterMissing | The Neo4j status code for the same error |
stage | compile | Where it was found: .compile before any work, .runtime while producing rows, .internal for a failure outside the statement, such as a locked store |
retryable | false | Whether running it again can succeed; true for a write-write conflict, never for a mistake in the statement |
The other two cases come from the client, not the engine: .invalidParameter for a value it refuses to bind, such as a node, and .decoding for a reply that breaks the engine's contract. The errors reference lists this message with its cause and remedy. With the value bound, the same statement finds Noor's role.
Put it together
main.swift puts the steps together in one program. It opens team.rl, creates the graph only when the store has no people yet, and prints who works on each project. A relate error anywhere ends it with the error's code and a nonzero exit status.
import Foundation
import Relate
let team = """
CREATE (ada:Person {name: 'Ada', role: 'engineer'}),
(grace:Person {name: 'Grace', role: 'engineer'}),
(noor:Person {name: 'Noor', role: 'maintainer'}),
(relate:Project {name: 'relate', language: 'Rust'}),
(tck:Project {name: 'opencypher-tck', language: 'Gherkin'}),
(ada)-[:WORKS_ON {since: 2024}]->(relate),
(grace)-[:WORKS_ON {since: 2023}]->(relate),
(grace)-[:WORKS_ON {since: 2022}]->(tck),
(noor)-[:MAINTAINS]->(relate),
(ada)-[:KNOWS]->(grace)
"""
let teamOf = """
MATCH (p:Person)-[w:WORKS_ON]->(:Project {name: $project})
RETURN p.name AS name, w.since AS since
ORDER BY since
"""
func ensureTeam(_ conn: Connection) throws {
let people = try conn.query("MATCH (p:Person) RETURN count(p) AS n").scalar().intValue ?? 0
if people == 0 {
try conn.query(team)
print("created the team graph")
} else {
print("found \(people) people")
}
}
func report(_ conn: Connection, _ project: String) throws {
print(project)
for row in try conn.query(teamOf, ["project": CypherValue(project)]).records {
print(" \(row["name"]?.stringValue ?? "") since \(row["since"]?.intValue ?? 0)")
}
}
func run() throws {
let db = try Database(path: "./team.rl")
let conn = try db.connect()
try ensureTeam(conn)
try report(conn, "relate")
try report(conn, "opencypher-tck")
}
do {
try run()
} catch let error as RelateError {
let code = error.engineError?.gqlStatus ?? "client"
FileHandle.standardError.write(Data("relate error \(code): \(error.message)\n".utf8))
exit(1)
}created the team graph relate Grace since 2023 Ada since 2024 opencypher-tck Grace since 2022
Run it with swift run. Each statement is durable in team.rl when query(_:_:) returns, and the store closes when run() returns and releases the last reference to it. Run it a second time and it prints found 3 people instead of creating the graph again, then the same report. If another process has team.rl open for writing, Database(path:) throws .engine with stage .internal, and the program prints relate error 42000: internal error and exits with status 1.
To read the file with no chance of changing it, open it read-only: Database(readOnlyPath: "./team.rl"). A write then throws .engine with GQLSTATUS 42N18, and a path with no store behind it throws instead of creating a new store.
What you built
You have a Swift program that keeps a graph of people and projects in team.rl, one file you can copy or archive. It reads with parameters, writes inside transactions, knows a read from a write by its query type, and reports errors by case and GQLSTATUS code. Ada, Grace, and Noor are waiting in that file for the next run.
Next
- Embed relate in a Python app: the same steps, the same graph, in Python.
- Use the Swift package: open modes, parameters, transactions, metadata and errors in depth.
- Swift API: every type and method.
- Errors: every error message, its cause, its remedy, and its codes.
- Transactions and concurrency: what a transaction isolates, and why a store has one writer.