BSON Decoding and Encoding, Explained
This article walks through the process of implementing BSONDocumentDecodable
and BSONDocumentEncodable
conformances for a model type in detail, and explains the rationale behind library’s design decisions.
Decoding
BSON is a serialization format, so when you receive BSON, you almost always want to decode it into a model type.
Decoding with Codable (Legacy API)
Many Swift users are familiar with the Decodable
protocol, which is a format-agnostic deserialization system native to the Swift standard library. The swift-bson library provides compatibility shims for the Decodable
protocol in the BSONLegacy
module.
Here’s an example definition of a Decodable
model type named BooleanContainer
:
import BSONLegacy
struct BooleanContainer:Decodable, Encodable
{
let b:Bool
init(b:Bool)
{
self.b = b
}
}
Walkthrough.swift:3Here’s how you would decode an instance of BooleanContainer
from a BSON Document:
let fromLegacy:BooleanContainer = try .init(
from: BSON.AnyValue.document(bson))
Walkthrough.swift:29And here’s the output you would see if you printed the decoded instance:
BooleanContainer(b: true)
The Legacy API does not currently support encoding.
Decoding with the BSON API
The format-agnostic Decodable
protocol has well-known performance limitations, so the swift-bson library provides a set of BSON-specific serialization protocols for high-throughput use cases.
Below is a slightly more complex model type, ExampleModel
, which has three stored properties: id
, name
, and rank
. The name
property is optional, and the rank
property is a custom enum type with a RawValue
of type Int32
.
struct ExampleModel
{
let id:Int64
let name:String?
let rank:Rank
enum Rank:Int32
{
case newModel
case risingStar
case aspiringModel
case fashionista
case glamourista
case fashionMaven
case runwayQueen
case trendSetter
case runwayDiva
case topModel
}
}
Walkthrough.swift:37The bare minimum a type needs to decode itself from BSON is a BSONDecodable
conformance. Many standard library types, such as Int32
and Int64
, are already BSONDecodable
.
Decodability protocols
The BSONDecodable
protocol has a derived protocol named BSONDocumentDecodable
. Since we expect ExampleModel
to appear as a BSON document, it is much easier to write a conformance against BSONDocumentDecodable
than BSONDecodable
, because the former provides error handling and field indexing for free.
Defining schema
Unlike the Legacy API, BSONDocumentDecodable
requires an explicit schema definition in the form of a CodingKey
. This type must be RawRepresentable
and backed by a String
. Moreover, because it can appear in error diagnostics, it must also be Sendable
, as Error
itself requires Sendable
.
extension ExampleModel
{
enum CodingKey:String, Sendable
{
case id = "_id"
case name = "D"
case rank = "R"
}
}
Walkthrough.swift:58It’s good practice to use single-letter key names in the CodingKey
ABI for two reasons.
BSON always stores document keys inline, so long keys can increase file size (and memory usage) substantially.
Single-letter keys are more resilient to schema changes, as you can change the property names in the model type without breaking the database ABI.
Decoding fields
The interface for decoding documents is the BSON.DocumentDecoder
type.
Types that conform to BSONDocumentDecodable
must implement the init(bson:)
requirement. This initializer receives a BSON.DocumentDecoder
keyed by the CodingKey
type you provide.
To access a non-optional field, subscript the decoder with the field key and call the decode(to:)
method. This method will throw an error with an attached diagnostic trace if the field is missing or has the wrong type.
To access an optional field, chain the subscript with the optional chaining operator (?
) before calling decode(to:)
.
extension ExampleModel:BSONDocumentDecodable
{
init(bson:BSON.DocumentDecoder<CodingKey>) throws
{
self.id = try bson[.id].decode()
self.name = try bson[.name]?.decode()
self.rank = try bson[.rank]?.decode() ?? .newModel
}
}
Walkthrough.swift:68This won’t compile just yet, because the rank
property has type Rank
, and we haven’t conformed it to BSONDecodable
yet. So the last step is to make Rank
conform to BSONDecodable
by leveraging its existing RawRepresentable
conformance.
extension ExampleModel.Rank:BSONDecodable
{
}
Walkthrough.swift:78Because Int32
is already BSONDecodable
, we don’t need to write any code to satisfy the conformance requirements.
Encoding with the BSON API
Once you have implemented the decoding logic, you are already two-thirds of the way to making a model type round-trippable.
All that’s left in this example is to conform ExampleModel.Rank
to BSONEncodable
, and write the encoding logic for ExampleModel
’s BSONDocumentEncodable.encode(to:)
witness.
extension ExampleModel.Rank:BSONEncodable
{
}
Walkthrough.swift:82Encoding fields
The interface for encoding documents is the BSON.DocumentEncoder
type.
The library passes an instance of this type inout
to your encode(to:)
witness. For maximum performance, it writes key-value pairs immediately to the BSON output stream when you assign to its subscripts. This means the order that the fields appear in the output document is determined by the order in which they were encoded in the encoding function.
extension ExampleModel:BSONDocumentEncodable
{
func encode(to bson:inout BSON.DocumentEncoder<CodingKey>)
{
bson[.id] = self.id
bson[.name] = self.name
bson[.rank] = self.rank == .newModel ? nil : self.rank
}
}
Walkthrough.swift:86The code looks simple, but the encoding syntax is quite powerful. When assigning to BSON.DocumentEncoder.subscript(_:)
’s setter, nil values become no-ops. This means that the name
property will not be encoded if it is nil
, which is almost always what we want.
You can also get a little more creative with the encoding logic. In this example, we also elide the rank
field if the model’s rank is newModel
, to match the behavior of the decoding function, which infers a default rank of newModel
if the field is missing. This could be profitable if newModel
were a very common value for rank
, and we wanted to save space by not encoding it.
Putting It All Together
Here’s an example of how to round-trip an instance of ExampleModel
through the BSON API:
let originalModel:ExampleModel = .init(id: 1,
name: "AAA",
rank: .topModel)
let encodedModel:BSON.Document = .init(encoding: originalModel)
let decodedModel:ExampleModel = try .init(bson: encodedModel)
print(originalModel)
print(decodedModel)
Walkthrough.swift:99When you run this code, you should see the following output:
ExampleModel(id: 1, name: Optional("AAA"), rank: topModel)
ExampleModel(id: 1, name: Optional("AAA"), rank: topModel)