use crate::CollectionConfig;
#[cfg(feature = "bson-3")]
use mongodb::bson::deserialize_from_bson;
#[cfg(feature = "compat-3-0-0")]
use mongodb::bson::from_bson as deserialize_from_bson;
use mongodb::Database;
use mongodb::bson::{Bson, Document, doc};
use mongodb::options::{ReadPreference, RunCommandOptions, SelectionCriteria};
use serde::Deserialize;
use std::borrow::Cow;
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortOrder {
Ascending,
Descending,
}
impl From<SortOrder> for Bson {
fn from(v: SortOrder) -> Self {
match v {
SortOrder::Ascending => Self::Int32(1),
SortOrder::Descending => Self::Int32(-1),
}
}
}
#[derive(Clone, Debug)]
enum IndexKey {
SortIndex(SortIndexKey),
TextIndex(TextIndexKey),
}
impl IndexKey {
fn get_key_name(&self) -> String {
match self {
Self::SortIndex(s) => match s.direction {
SortOrder::Ascending => format!("{}_1", s.name),
SortOrder::Descending => format!("{}_-1", s.name),
},
Self::TextIndex(t) => format!("{}_text", t.name),
}
}
fn get_name(&self) -> String {
match self {
Self::SortIndex(s) => s.name.to_string(),
Self::TextIndex(t) => t.name.to_string(),
}
}
fn get_value(&self) -> Bson {
match self {
Self::SortIndex(s) => s.direction.into(),
Self::TextIndex(_) => "text".into(),
}
}
}
#[derive(Debug, Clone)]
struct SortIndexKey {
name: Cow<'static, str>,
direction: SortOrder,
}
#[derive(Debug, Clone)]
struct TextIndexKey {
name: Cow<'static, str>,
}
#[derive(Default, Clone, Debug)]
pub struct Index {
keys: Vec<IndexKey>,
options: Vec<IndexOption>,
}
impl Index {
pub fn new(key: impl Into<Cow<'static, str>>) -> Self {
Self::new_with_direction(key, SortOrder::Ascending)
}
pub fn new_with_direction(key: impl Into<Cow<'static, str>>, direction: SortOrder) -> Self {
let mut index = Self::default();
index.add_key_with_direction(key, direction);
index
}
pub fn new_with_text(key: impl Into<Cow<'static, str>>) -> Self {
let mut index = Self::default();
index.add_key_with_text(key);
index
}
pub fn add_key(&mut self, key: impl Into<Cow<'static, str>>) {
self.add_key_with_direction(key, SortOrder::Ascending);
}
pub fn with_key(mut self, key: impl Into<Cow<'static, str>>) -> Self {
self.add_key(key);
self
}
pub fn add_key_with_direction(
&mut self,
key: impl Into<Cow<'static, str>>,
direction: SortOrder,
) {
self.keys.push(IndexKey::SortIndex(SortIndexKey {
name: key.into(),
direction,
}));
}
pub fn add_key_with_text(&mut self, key: impl Into<Cow<'static, str>>) {
self.keys
.push(IndexKey::TextIndex(TextIndexKey { name: key.into() }));
}
pub fn with_key_with_direction(
mut self,
key: impl Into<Cow<'static, str>>,
direction: SortOrder,
) -> Self {
self.add_key_with_direction(key, direction);
self
}
pub fn add_option(&mut self, option: IndexOption) {
self.options.push(option);
}
pub fn with_option(mut self, option: IndexOption) -> Self {
self.add_option(option);
self
}
pub fn into_document(self) -> Document {
let mut names = Vec::with_capacity(self.keys.len());
let mut keys_doc = Document::new();
for key in self.keys {
names.push(key.get_key_name());
keys_doc.insert(key.get_name(), key.get_value());
}
let mut index_doc = doc! { "key": keys_doc };
for option in self.options {
let (key, value) = option.into_key_value();
index_doc.insert(key, value);
}
if !index_doc.contains_key("name") {
let name = names.join("_");
index_doc.insert("name", name);
}
index_doc
}
}
#[derive(Debug, Clone)]
pub struct Indexes(pub(crate) Vec<Index>);
impl Default for Indexes {
fn default() -> Self {
Self::new()
}
}
impl From<Vec<Index>> for Indexes {
fn from(indexes: Vec<Index>) -> Self {
Self(indexes)
}
}
impl Indexes {
pub fn new() -> Self {
Self(Vec::new())
}
pub fn with(mut self, index: Index) -> Self {
self.0.push(index);
self
}
pub fn create_indexes_command(self, collection_name: &str) -> Document {
let mut indexes = Vec::with_capacity(self.0.len());
for index in self.0 {
indexes.push(index.into_document());
}
doc! {
"createIndexes": collection_name,
"indexes": indexes
}
}
}
#[derive(Debug, Clone)]
pub enum IndexOption {
Background,
Unique,
Name(String),
PartialFilterExpression(Document),
Sparse,
ExpireAfterSeconds(i32),
StorageEngine(Document),
Collation(Document),
Weights(Vec<(String, i32)>),
Custom { name: String, value: Bson },
}
impl IndexOption {
pub fn name(&self) -> &str {
match self {
Self::Background => "background",
Self::Unique => "unique",
Self::Name(..) => "name",
Self::PartialFilterExpression(..) => "partialFilterExpression",
Self::Sparse => "sparse",
Self::ExpireAfterSeconds(..) => "expireAfterSeconds",
Self::StorageEngine(..) => "storageEngine",
Self::Collation(..) => "collation",
Self::Weights(..) => "weights",
Self::Custom { name, .. } => name.as_str(),
}
}
pub fn into_value(self) -> Bson {
match self {
Self::Background | Self::Unique | Self::Sparse => Bson::Boolean(true),
Self::Name(val) => Bson::String(val),
Self::ExpireAfterSeconds(val) => Bson::Int32(val),
Self::PartialFilterExpression(doc)
| Self::StorageEngine(doc)
| Self::Collation(doc) => Bson::Document(doc),
Self::Weights(w) => {
let mut doc = Document::new();
for (k, v) in w {
doc.insert(k, Bson::from(v));
}
Bson::Document(doc)
}
Self::Custom { value, .. } => value,
}
}
pub fn into_key_value(self) -> (String, Bson) {
let name = self.name().to_owned();
let value = self.into_value();
(name, value)
}
}
pub async fn sync_indexes<CollConf: CollectionConfig>(
db: &Database,
) -> Result<(), mongodb::error::Error> {
let mut indexes = CollConf::indexes();
match h_run_command(db, doc! { "listIndexes": CollConf::collection_name() }).await {
Ok(ret) => {
let parsed_ret: ListIndexesRet =
deserialize_from_bson(Bson::Document(ret)).map_err(std::io::Error::other)?;
if parsed_ret.cursor.id != 0 {
return Err(std::io::Error::other(format!(
"couldn't list all indexes from '{}'",
CollConf::collection_name()
))
.into());
}
let mut existing_indexes = HashMap::new();
for index in parsed_ret.cursor.first_batch {
if let Some(key) = index.get("key") {
existing_indexes.insert(key.to_string(), index);
}
}
let mut already_sync = Vec::new();
let mut to_drop = Vec::new();
for (i, index) in indexes.0.clone().into_iter().enumerate() {
let mut text_index_keys = None;
let mut index_doc = if index
.keys
.iter()
.any(|ind| matches!(ind, IndexKey::TextIndex(_)))
{
let mut doc = index.into_document();
text_index_keys = doc.get("key").cloned();
doc.insert("key", doc! { "_fts": "text", "_ftsx": 1 });
doc
} else {
index.into_document()
};
let key = index_doc
.get("key")
.ok_or_else(|| std::io::Error::other("index doc is missing 'key'"))?
.to_string();
if let Some(mut existing_index) = existing_indexes.remove(&key) {
existing_index.remove("ns");
existing_index.remove("v");
if let Some(declared_collation) =
index_doc.get_document("collation").ok().cloned()
{
match expanded_collation(
db,
CollConf::collection_name(),
&declared_collation,
)
.await
{
Ok(CollationExpansion::Expanded(expanded)) => {
apply_expanded_collation(&mut index_doc, expanded)
}
Ok(CollationExpansion::UnrecognizedExplainShape) => (),
Err(e) if is_unauthorized(&e) => (),
Err(e) => return Err(e),
}
}
if let Some(Bson::Document(mut keys_to_set)) = text_index_keys
&& let Some(Bson::Document(existing_weights)) =
existing_index.get("weights")
{
for keys in keys_to_set.iter_mut() {
match keys.1 {
Bson::String(t) if t == "text" => {
*keys.1 = Bson::Int32(1);
}
_ => (),
}
}
if existing_weights.eq(&keys_to_set) {
already_sync.push(i);
} else {
to_drop.push(
index_doc
.get_str("name")
.map_err(std::io::Error::other)?
.to_owned(),
);
}
continue;
}
if index_docs_are_eq(&index_doc, &existing_index) {
already_sync.push(i);
} else {
to_drop.push(
index_doc
.get_str("name")
.map_err(std::io::Error::other)?
.to_owned(),
);
}
}
}
for existing_index in existing_indexes.values() {
let name = existing_index
.get_str("name")
.map_err(std::io::Error::other)?
.to_owned();
if name != "_id_" {
to_drop.push(name);
}
}
if !to_drop.is_empty() {
if h_run_command(
db,
doc! { "dropIndexes": CollConf::collection_name(), "index": &to_drop },
)
.await
.is_err()
{
for index_name in to_drop {
h_run_command(
db,
doc! { "dropIndexes": CollConf::collection_name(), "index": index_name },
)
.await?;
}
}
}
for i in already_sync.into_iter().rev() {
indexes.0.remove(i);
}
}
Err(e) => {
match e.kind.as_ref() {
mongodb::error::ErrorKind::Command(err) if err.code == 26 => {
}
_ => return Err(e),
}
}
}
if !indexes.0.is_empty() {
h_run_command(
db,
indexes.create_indexes_command(CollConf::collection_name()),
)
.await?;
}
Ok(())
}
async fn h_run_command(
db: &Database,
command_doc: Document,
) -> Result<Document, mongodb::error::Error> {
let primary_options = RunCommandOptions::builder()
.selection_criteria(SelectionCriteria::ReadPreference(ReadPreference::Primary))
.build();
let ret = db
.run_command(command_doc)
.with_options(primary_options)
.await?;
deserialize_from_bson::<mongodb::error::CommandError>(Bson::Document(ret.clone())).map_or_else(
|_| Ok(ret),
|err| {
Err(mongodb::error::Error::from(
mongodb::error::ErrorKind::Command(err),
))
},
)
}
#[derive(Deserialize)]
struct ListIndexesRet {
pub cursor: Cursor,
}
#[derive(Deserialize)]
struct Cursor {
pub id: i64,
#[serde(rename = "firstBatch", default)]
pub first_batch: Vec<Document>,
}
fn is_unauthorized(error: &mongodb::error::Error) -> bool {
matches!(error.kind.as_ref(), mongodb::error::ErrorKind::Command(command) if command.code == 13)
}
enum CollationExpansion {
Expanded(Option<Document>),
UnrecognizedExplainShape,
}
#[derive(Debug)]
struct UnrecognizedExplainShape;
fn collation_from_explain(
explain: &Document,
) -> Result<Option<Document>, UnrecognizedExplainShape> {
let Ok(planner) = explain.get_document("queryPlanner") else {
return Err(UnrecognizedExplainShape);
};
if let Ok(collation) = planner.get_document("collation") {
return Ok(Some(collation.clone()));
}
let sharded = planner
.get_document("winningPlan")
.ok()
.and_then(|plan| plan.get_array("shards").ok());
match sharded {
Some(shards) => collation_agreed_across_shards(shards),
None => Ok(None),
}
}
fn shard_collation(shard: &Document) -> Result<Option<Document>, UnrecognizedExplainShape> {
let planner = shard.get_document("queryPlanner").unwrap_or(shard);
if let Ok(collation) = planner.get_document("collation") {
return Ok(Some(collation.clone()));
}
if planner.contains_key("winningPlan") || planner.contains_key("namespace") {
return Ok(None);
}
Err(UnrecognizedExplainShape)
}
fn collation_agreed_across_shards(
shards: &[Bson],
) -> Result<Option<Document>, UnrecognizedExplainShape> {
if shards.is_empty() {
return Err(UnrecognizedExplainShape);
}
let mut agreed: Option<Option<Document>> = None;
for shard in shards {
let Some(shard) = shard.as_document() else {
return Err(UnrecognizedExplainShape);
};
let collation = shard_collation(shard)?;
match &agreed {
None => agreed = Some(collation),
Some(first) if *first != collation => {
return Err(UnrecognizedExplainShape);
}
Some(_) => (),
}
}
Ok(agreed.flatten())
}
fn collation_expansion_from_explain(explain: &Document) -> CollationExpansion {
match collation_from_explain(explain) {
Ok(collation) => CollationExpansion::Expanded(collation),
Err(_) => CollationExpansion::UnrecognizedExplainShape,
}
}
async fn expanded_collation(
db: &Database,
collection: &str,
collation: &Document,
) -> Result<CollationExpansion, mongodb::error::Error> {
let explain = h_run_command(
db,
doc! {
"explain": { "find": collection, "filter": {}, "collation": collation },
"verbosity": "queryPlanner",
},
)
.await?;
Ok(collation_expansion_from_explain(&explain))
}
fn apply_expanded_collation(declared: &mut Document, expanded: Option<Document>) {
match expanded {
Some(collation) => {
declared.insert("collation", collation);
}
None => {
declared.remove("collation");
}
}
}
fn index_docs_are_eq(a: &Document, b: &Document) -> bool {
if a.len() != b.len() {
return false;
}
for (key, a_val) in a {
match b.get(key) {
Some(Bson::Document(b_val)) if key == "collation" => {
let Bson::Document(a_val) = a_val else {
return false;
};
if !collation_docs_are_eq(a_val, b_val) {
return false;
}
}
Some(b_val) if !ordered_bson_are_eq(a_val, b_val) => {
return false;
}
Some(_) => {}
None => {
return false;
}
}
}
true
}
fn collation_docs_are_eq(a: &Document, b: &Document) -> bool {
if a.len() != b.len() {
return false;
}
for (key, a_val) in a {
match b.get(key) {
Some(b_val) if !collation_bson_are_eq(a_val, b_val) => return false,
Some(_) => {}
None => return false,
}
}
true
}
fn collation_bson_are_eq(a: &Bson, b: &Bson) -> bool {
match (a, b) {
(Bson::Document(a), Bson::Document(b)) => collation_docs_are_eq(a, b),
(Bson::Array(a), Bson::Array(b)) => {
a.len() == b.len() && a.iter().zip(b).all(|(a, b)| collation_bson_are_eq(a, b))
}
_ => a == b,
}
}
fn ordered_bson_are_eq(a: &Bson, b: &Bson) -> bool {
match (a, b) {
(Bson::Document(a), Bson::Document(b)) => {
a.len() == b.len()
&& a.iter().zip(b).all(|((a_key, a_value), (b_key, b_value))| {
a_key == b_key && ordered_bson_are_eq(a_value, b_value)
})
}
(Bson::Array(a), Bson::Array(b)) => {
a.len() == b.len() && a.iter().zip(b).all(|(a, b)| ordered_bson_are_eq(a, b))
}
_ => a == b,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_indexes_command() {
let index = Index::new_with_direction("id", SortOrder::Descending)
.with_key("last_seen")
.with_option(IndexOption::Background)
.with_option(IndexOption::Unique);
let index_2 = Index::new("last_seen").with_option(IndexOption::ExpireAfterSeconds(60));
let indexes = Indexes::from(vec![index, index_2]);
assert_eq!(
indexes.create_indexes_command("my_collection"),
doc! {
"createIndexes": "my_collection",
"indexes": [
{
"key": { "id": -1, "last_seen": 1 },
"background": true,
"unique": true,
"name": "id_-1_last_seen_1",
},
{
"key": { "last_seen": 1 },
"expireAfterSeconds": 60,
"name": "last_seen_1",
},
]
}
);
}
fn expansion(locale: &str, strength: i32) -> Document {
doc! {
"locale": locale,
"caseLevel": false,
"caseFirst": "off",
"strength": strength,
"numericOrdering": false,
"alternate": "non-ignorable",
"maxVariable": "punct",
"normalization": false,
"backwards": false,
"version": "57.1",
}
}
fn stored_index(collation: Option<Document>) -> Document {
let mut index = doc! { "key": { "field": 1 }, "name": "collated_field" };
if let Some(collation) = collation {
index.insert("collation", collation);
}
index
}
fn declared_index(collation: Option<Document>) -> Document {
let mut index =
Index::new("field").with_option(IndexOption::Name("collated_field".to_owned()));
if let Some(collation) = collation {
index = index.with_option(IndexOption::Collation(collation));
}
index.into_document()
}
fn explain_reply(query_planner: Document) -> Document {
doc! { "queryPlanner": query_planner, "ok": 1.0 }
}
fn command_error(code: i32, code_name: &str) -> mongodb::error::Error {
let command: mongodb::error::CommandError = deserialize_from_bson(Bson::Document(doc! {
"ok": 0.0,
"code": code,
"codeName": code_name,
"errmsg": "not authorized on db to execute command",
}))
.expect("a command error document should deserialize");
mongodb::error::Error::from(mongodb::error::ErrorKind::Command(command))
}
#[test]
fn only_an_unauthorized_command_error_is_treated_as_a_missing_privilege() {
assert!(is_unauthorized(&command_error(13, "Unauthorized")));
assert!(!is_unauthorized(&command_error(26, "NamespaceNotFound")));
assert!(!is_unauthorized(&command_error(85, "IndexOptionsConflict")));
}
fn sharded_explain_reply(shard_collations: Vec<Option<Document>>) -> Document {
let shards = shard_collations
.into_iter()
.enumerate()
.map(|(i, collation)| {
let mut shard = doc! {
"shardName": format!("shard{i}"),
"namespace": "db.coll",
"winningPlan": { "stage": "COLLSCAN" },
};
if let Some(collation) = collation {
shard.insert("collation", collation);
}
Bson::Document(shard)
})
.collect::<Vec<_>>();
explain_reply(doc! { "winningPlan": { "stage": "SHARD_MERGE", "shards": shards } })
}
fn nested_sharded_explain_reply(collation: Document) -> Document {
explain_reply(doc! {
"winningPlan": {
"shards": [ {
"shardName": "shard0",
"queryPlanner": { "namespace": "db.coll", "collation": collation },
} ],
},
})
}
#[test]
fn a_reported_collation_is_read_from_the_explain_reply() {
let reply = explain_reply(doc! {
"namespace": "db.coll",
"collation": expansion("en", 2),
});
assert_eq!(
collation_from_explain(&reply).unwrap(),
Some(expansion("en", 2))
);
}
#[test]
fn a_reply_without_a_collation_reads_as_none() {
let reply =
explain_reply(doc! { "namespace": "db.coll", "winningPlan": { "stage": "EOF" } });
assert_eq!(collation_from_explain(&reply).unwrap(), None);
}
#[test]
fn a_collation_agreed_by_every_shard_is_read_from_a_sharded_reply() {
let reply = sharded_explain_reply(vec![
Some(expansion("en", 2)),
Some(expansion("en", 2)),
Some(expansion("en", 2)),
]);
assert_eq!(
collation_from_explain(&reply).unwrap(),
Some(expansion("en", 2))
);
}
#[test]
fn a_sharded_reply_without_collations_reads_as_none() {
let reply = sharded_explain_reply(vec![None, None]);
assert_eq!(collation_from_explain(&reply).unwrap(), None);
}
#[test]
fn a_collation_nested_under_a_shard_planner_is_also_read() {
let reply = nested_sharded_explain_reply(expansion("en", 2));
assert_eq!(
collation_from_explain(&reply).unwrap(),
Some(expansion("en", 2))
);
}
#[test]
fn an_unreadable_reply_is_an_error_rather_than_no_collation() {
let unreadable = [
("no queryPlanner", doc! { "ok": 1.0 }),
(
"disagreeing shards",
sharded_explain_reply(vec![Some(expansion("en", 2)), Some(expansion("fr_CA", 2))]),
),
(
"a shard reporting no collation while another does",
sharded_explain_reply(vec![Some(expansion("en", 2)), None]),
),
(
"no shards",
explain_reply(doc! { "winningPlan": { "shards": [] } }),
),
(
"a shard entry that is not recognizable as a planner",
explain_reply(doc! { "winningPlan": { "shards": [ { "shardName": "shard0" } ] } }),
),
(
"a shard that is not a document",
explain_reply(doc! { "winningPlan": { "shards": [ "shard0" ] } }),
),
];
for (what, reply) in unreadable {
assert!(
collation_from_explain(&reply).is_err(),
"`{what}` should have been an error, got {:?}",
collation_from_explain(&reply).unwrap()
);
}
}
#[test]
fn an_unreadable_reply_falls_back_to_the_declared_collation() {
let unreadable = doc! { "ok": 1.0 };
let declared = declared_index(Some(doc! { "locale": "en" }));
let stored = stored_index(Some(expansion("en", 2)));
assert!(matches!(
collation_expansion_from_explain(&unreadable),
CollationExpansion::UnrecognizedExplainShape
));
assert!(
!index_docs_are_eq(&declared, &stored),
"the raw declaration must differ from the stored expansion"
);
}
#[test]
fn an_expanded_declaration_matches_the_stored_index() {
let mut declared = declared_index(Some(doc! { "locale": "en", "strength": 2 }));
apply_expanded_collation(&mut declared, Some(expansion("en", 2)));
assert!(
index_docs_are_eq(&declared, &stored_index(Some(expansion("en", 2)))),
"expanded declaration {declared:?} should match the stored index"
);
}
#[test]
fn no_expansion_drops_the_declared_collation() {
let mut declared = declared_index(Some(doc! { "locale": "simple" }));
apply_expanded_collation(&mut declared, None);
assert!(
!declared.contains_key("collation"),
"the collation should have been dropped, got {declared:?}"
);
assert!(
index_docs_are_eq(&declared, &stored_index(None)),
"a simple-locale declaration should match a collation-less index, got {declared:?}"
);
}
#[test]
fn collations_are_equal_regardless_of_field_order() {
let a = doc! {
"key": { "field": 1 },
"collation": { "locale": "en", "strength": 2 },
};
let b = doc! {
"collation": { "strength": 2, "locale": "en" },
"key": { "field": 1 },
};
assert!(index_docs_are_eq(&a, &b));
}
#[test]
fn ordered_non_collation_documents_are_detected_as_changes() {
let a = doc! {
"key": { "field": 1 },
"partialFilterExpression": { "x": { "a": 1, "b": 2 } },
};
let b = doc! {
"key": { "field": 1 },
"partialFilterExpression": { "x": { "b": 2, "a": 1 } },
};
assert!(!index_docs_are_eq(&a, &b));
}
#[test]
fn a_changed_declaration_is_still_detected() {
let cases = [
(
"strength changed",
Some(expansion("en", 3)),
Some(expansion("en", 2)),
),
(
"locale changed",
Some(expansion("fr_CA", 2)),
Some(expansion("en", 2)),
),
("collation removed", None, Some(expansion("en", 2))),
("collation added", Some(expansion("en", 2)), None),
];
for (what, expanded, stored) in cases {
let mut declared = declared_index(Some(doc! { "locale": "en" }));
apply_expanded_collation(&mut declared, expanded);
assert!(
!index_docs_are_eq(&declared, &stored_index(stored)),
"`{what}` should have been detected as a change, got {declared:?}"
);
}
}
}