Progressive type-safety with the KtMongo driver for MongoDB¶
The KtMongo DSL is crafted to allow the Kotlin compiler to verify as much as possible of the validity of the queries. Since MongoDB itself isn't typed, this trades off liberty to improve safety.
Most of the time, this is the best choice: developers can quickly write queries without having to worry about syntax details.
Still, any subsequently complex project will at some point encounter a situation that is safe in MongoDB but cannot be verified by the Kotlin compiler. KtMongo is built to allow progressive type safety: KtMongo is type-safe by default but provides operators that bypass some safety in some situations.
This page describes some of these operators.
Type-safe queries¶
By default, KtMongo is strongly type-safe. This means that the Kotlin compiler is able to check almost the entirety of a query.
class User(
val _id: ObjectId,
val profile: Profile,
)
class Profile(
val name: String,
val age: Int,
)
val users = database.collection<User>("users")
users.find {
User::profile / Profile::age gte 18
}
In this query, the compiler verifies that:
- The property
Profile.agemust be of typeInt, since we're using the operatorgtewith18, which is anInt. - The property
User.profilemust be of typeProfile, since we're referring to its child fieldProfile.age. - The collection must be declared of type
User, since the query refers to its fieldUser.profile.
As a comparison, the KMongo library, one of the main inspirations of KtMongo, is only able to verify the first two. The Java and Kotlin official drivers make none of these verifications.
Additionally, this type-safety allows the compiler to decide on the correct operator depending on the situation.
For example, KtMongo will automatically use dot-notation or $getField depending on the situation, will automatically use the correct syntax of $eq, etc.
In a codebase, almost all queries should remain within the type-safe API, as this provides the best IDE and compiler support.
Progressive type safety¶
Still, sometimes, type-safety can slow us down if we know how MongoDB will behave in these situations.
Nullability¶
If a value is declared nullable, but we know null cannot happen in practice:
Available in: aggregations.
Incorrect subtype¶
If we use polymorphic serialization and want to access a field of one of the possible options:
class Invoice(
val _id: ObjectId,
val data: Invoice,
)
sealed interface InvoiceData
class Accepted(
val signatureDate: Instant,
val author: ObjectId,
) : InvoiceData
class Draft(
val expirationDate: Instant,
) : InvoiceData
invoices.find {
Invoice::data unsafe Accepted::signatureDate lte (clock.now() - 7.days)
}
Here, Invoice::data / Accepted::signatureDate is not allowed because data is not of type Accepted.
The unsafe keyword allows bypassing that type verification.
Available in: queries.
Incorrect type¶
Sometimes, especially when dealing with old data that may not have been migrated properly, we may have to deal with fields that do not have the type declared in the DTO.
class User(
val _id: ObjectId,
val name: String,
val age: Int,
)
users.filter { User::age hasType BsonType.Double }
.updateManyWithPipeline {
set {
User::age set User::age.unsafeCast<Double>()
.toInt()
}
}
Available in: queries, aggregations, on pipelines and on collections.
Temporary field¶
In aggregations, it is common to create a temporary field that only exists in intermediate stages of the pipeline and is never serialized neither in the input nor the output.
val lookupOutput = Field.unsafe<List<Departments>>("departments")
users.aggregate()
.lookup {
into(lookupOutput)
from(departments.aggregate())
on(User::departmentId, Department::_id)
}
.project {
include(User::name)
User::department set lookupOutput[0]
}
Since the field departments is never serialized (it is created by the $lookup and deleted by the $project), it would be inconvenient to add it to one of the DTOs.
Available: everywhere.
Arbitrary data¶
MongoDB documents are not typed, they can contain arbitrary data. When we want to take advantage of this power, we should use the types BsonDocument, BsonArray or BsonValue:
class User(
val _id: ObjectId,
val name: String,
val externalData: BsonDocument,
)
users.find {
User::externalData.get<Profile?>("profile") ne null
}
Available in: queries, aggregations.
Arbitrary operators¶
If you really need to go outside the features provided by KtMongo, you can implement custom operators. In this case, there are no limits at all.
To learn how to write custom operators, visit AbstractBsonNode.
Every KtMongo DSL block provides the accept method which you can use to inject arbitrary BSON in any request.
If you happen to need to write custom operators, this is probably a sign that something is missing in the KtMongo library. Please create a feature request or contribute your custom operator.
Available: everywhere.