New RedisOM library for Swift

I’m new to Swift development and excited to share my first project with the Swift on Server community. I’ve always wanted to learn Swift, and working on a server-side project felt like the perfect way to get started.

I’d like to introduce RedisOM Swift. I’ve used the Redis OM libraries extensively in other projects written in Python, and thought it would be a unique and fun challenge to work on a port for the Swift community.

What is RedisOM Swift

RedisOM Swift is a high-level Redis object mapper inspired by redis-om-python.
It provides a declarative way to model, persist, and query JSON documents in Redis using Swift key paths, macros, and async/await.

It integrates seamlessly with both Vapor and Swift ServiceLifecycle, making it suitable for API servers, workers, or microservices that need structured access to RedisJSON and RediSearch.

Key Features

  • Declarative Models with @Model macro that automatically generates RediJSON schema and RediSearch indexes.
  • Rich, Fluent Queries with a type-safe builder supporting .where(), .and(), .or() .not(), .limit(), etc.
  • Lifecycle Integration that works with both Vapor’s Lifecycle and Swift ServiceLifecycle for automatic startup and shutdown.
  • Embedded / Nested Model support to query into nested documents using key paths.
  • Flexible Configuration
  • Connection Pooling and Retry Policies

Quick Example

Define your Models

@Model
struct User: JsonModel {
    @Id var id: String?
    @Index(type: .text) var name: String
    @Index var email: String
    @Index var aliases: [String]?
    @Index(type: .numeric) var age: Int?
    @Index var address: [Address]?
    @Index(type: .numeric) var createdAt: Date?

    static let keyPrefix: String = "user"
}

@Model
struct Address: JsonModel {
    @Id var id: String?

    @Index(type: .text) 
    var addressLine1: String

    @Index(type: .text) 
    var addressLine2: String? = nil

    @Index var city: String
    @Index var state: String
    @Index var country: String
    @Index var postalCode: String

    static let keyPrefix: String = "address"
}

Save and Fetch

var user: User = User(
    name: "Alice",
    email: "alice@example.com",
    aliases: ["Alicia", "alice"],
    age: 45,
    address: [
        Address(
            addressLine1: "123 South Main St", city: "Pittsburg", state: "PA",
            country: "US", postalCode: "15120"
        )
    ],
    createdAt: Date()
)
try await user.save()

let fetched = try await User.get(id: user.id!)

Query with RediSearch

let users: [User] = try await User.find().where(\.$name == "Alice").and(
    \.$address[\.$city] == "Pittsburg"
).all()

Integrate with Vapor

public func configure(_ app: Application) throws {
    let redis = try RedisOM(url: "redis://localhost:6379")
    redis.register(User.self)
    app.lifecycle.use(redis)
}

Check it out

The GitHub repository can be found here: GitHub - eric-musliner/redis-om-swift: Object mapping, and more, for Redis and Swift

I’d love feedback, ideas, or contributions from the community as I continue to grow this library. Thanks!

8 Likes

I think your Model macro should be able to attach the JsonModel conformance automatically! And if the property is called id, I think you could auto-attach the @Id if not used elsewhere explicitly.
You index every field in the example, is that common? If so, you could also attach those automatically in the macro, and rather reverse and do a “NotIndex” marker, like in SwiftData (@Transient).
With those changes, your model could look like:
```
@Model
struct User {
var id: String?
@Index(type: .text) var name: String
var email: String
var aliases: [String]?
@Index(type: .numeric) var age: Int?
var address: [Address]?
@Index(type: .numeric) var createdAt: Date?

static let keyPrefix: String = "user"

}
```
If you derive common things like numeric from the Int type, it would get even more compact for common cases.

1 Like

Thanks for the suggestions! I like the idea of automatically attaching the Model macro on the JsonModel. That makes the mechanics a lot dryer. Having the Macro automatically attaching the index wrappers is an interesting idea. Originally I had the macro try to infer the index type but in practice I think giving the user the choice on the type works out better. However, for numeric types being able to have the numeric type inferred would be a nice addition.

This looks interesting

As maintainer on both hummingbird and valkey-swift I’ve a couple of questions

Have you considered breaking out the Vapor integration into a separate package, so this can be used with other frameworks. Currently if I was to use this with a hummingbird server it would unnecessarily add all the Vapor code into my server application.

Also as an alternative have you considered not having the package manage the Redis client. This should be up to the application, which most likely will already have a Redis client. With this you can probably remove any framework integration.

You probably started this before valkey-swift was mentioned, but would you be interested in moving to a more modern Valkey/Redis client. You will automatically gain support for improved integration with swift concurrency including task cancellation, the possibility of sharing connections across tasks and valkey/redis cluster support (although complete cluster scan might not be available yet).

4 Likes

Thanks, Adam!

I think pulling the Vapor integration out to a separate package would be great. It would decrease the footprint a lot, especially the build times if you just want to use the RedisOM client and don’t care about the Vapor integration. Can it be done by making a different target in the Package.swift or is the correct route a separate repository?

I think it’s an interesting idea if the package didn’t manage the connection pooling itself. It makes sense for applications that are already using Redis or Valkey clients. I’d have to think through how the API looks if it was maybe passed a pool on init. The benefit of having framework integration is having the Migrator create the RediSearch indexes automatically on startup so I would want to keep that behavior somehow.

I did start this project before the valkey-swift client was published. I saw when it was released and it did cross my mind to see what it would look like if tried to replace the redis client I was using with the Valkey one. I did run into issues with the RediStack client not fully supporting the modern async/await syntax, and using a modern client would be a great change I think. Especially if it then allowed the package to be compatible with both Redis and Valkey.

Unfortunately at the moment it is still best to use a separate repository. Swift package manager traits were introduced in Swift 6.1 to allow you to enable/disable sections of a package, but at this point in time it will still download the dependencies.

If the application already has a connection pool why would you create another. I can see how some lifecycle support does allow you to run the migrator, beforehand without the user having to call migrate().

Valkey-swift is compatible with Redis. It doesn’t provide support for Redis 8 commands, but then again neither does RediStack. Valkey does have JSON and Search modules so the server should be compatible with your code. Valkey-swift has wrappers for the JSON commands (not heavily tested), but it doesn't for Search. But you can create your own ValkeyCommand to execute the Search commands.

As an aside RediStack should be compatible with Valkey servers. They use the same protocol. The only thing to verify is the JSON/Search commands work as expected. Those modules were implemented after the fork.

2 Likes

If the application already has a connection pool why would you create another. I can see how some lifecycle support does allow you to run the migrator, beforehand without the user having to call migrate().

Yes, precisely. There shouldn’t be a need for two connection pools in an application

Valkey-swift is compatible with Redis. It doesn’t provide support for Redis 8 commands, but then again neither does RediStack. Valkey does have JSON and Search modules so the server should be compatible with your code. Valkey-swift has wrappers for the JSON commands (not heavily tested), but it doesn't for Search. But you can create your own ValkeyCommand to execute the Search commands.

As an aside RediStack should be compatible with Valkey servers. They use the same protocol. The only thing to verify is the JSON/Search commands work as expected. Those modules were implemented after the fork.

Okay, very cool. I’ll see what I can do about using Valkey as the client behind the scenes.

2 Likes