use crate::errors::FirestoreError;
use crate::{FirestoreCollectionId, FirestoreQueryDirection, FirestoreResult};
use rsb_derive::Builder;
use std::collections::HashSet;
use std::fmt::{self, Display, Formatter};
use std::time::Duration;
pub(crate) const IMPLIED_NAME_FIELD: &str = "__name__";
#[derive(Debug, Eq, PartialEq, Clone, Copy, Hash, Default)]
pub enum FirestoreIndexQueryScope {
#[default]
Collection,
AllDescendants,
}
#[derive(Debug, PartialEq, Eq, Hash, Clone, Builder)]
pub struct FirestoreVectorIndexConfig {
pub dimension: u32,
}
#[derive(Debug, PartialEq, Eq, Hash, Clone)]
pub enum FirestoreIndexFieldMode {
Order(FirestoreQueryDirection),
ArrayContains,
Vector(FirestoreVectorIndexConfig),
}
#[derive(Debug, PartialEq, Clone)]
pub struct FirestoreIndexField {
pub field_path: String,
pub mode: FirestoreIndexFieldMode,
}
impl FirestoreIndexField {
pub(crate) fn new(field_path: String, mode: FirestoreIndexFieldMode) -> Self {
Self { field_path, mode }
}
}
#[derive(Debug, PartialEq, Clone)]
pub struct FirestoreCompositeIndex {
pub fields: Vec<FirestoreIndexField>,
pub query_scope: FirestoreIndexQueryScope,
}
impl FirestoreCompositeIndex {
pub(crate) fn new(fields: Vec<FirestoreIndexField>) -> Self {
Self {
fields,
query_scope: FirestoreIndexQueryScope::Collection,
}
}
#[inline]
pub fn all_descendants(mut self) -> Self {
self.query_scope = FirestoreIndexQueryScope::AllDescendants;
self
}
}
#[derive(Debug, PartialEq, Clone)]
pub struct FirestoreFieldOverrideIndex {
pub query_scope: FirestoreIndexQueryScope,
pub mode: FirestoreIndexFieldMode,
}
impl FirestoreFieldOverrideIndex {
pub(crate) fn new(mode: FirestoreIndexFieldMode) -> Self {
Self {
query_scope: FirestoreIndexQueryScope::Collection,
mode,
}
}
#[inline]
pub fn all_descendants(mut self) -> Self {
self.query_scope = FirestoreIndexQueryScope::AllDescendants;
self
}
}
pub(crate) const ALL_FIELDS_PATH: &str = "*";
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct CanonicalFieldPath(String);
impl CanonicalFieldPath {
pub(crate) fn from_field_resource_name(name: &str) -> FirestoreResult<Self> {
const COLLECTION_GROUPS_MARKER: &str = "/collectionGroups/";
const FIELDS_MARKER: &str = "fields/";
name.find(COLLECTION_GROUPS_MARKER)
.and_then(|groups_at| {
let after_groups = &name[groups_at + COLLECTION_GROUPS_MARKER.len()..];
let group_end = after_groups.find('/')?;
after_groups[group_end + 1..].strip_prefix(FIELDS_MARKER)
})
.map(Self::from)
.ok_or_else(|| {
FirestoreError::invalid_parameters(
"name",
format!("not a field resource name: {name}"),
)
})
}
pub(crate) fn as_str(&self) -> &str {
&self.0
}
fn segments(path: &str) -> Option<Vec<String>> {
let mut segments = Vec::new();
let mut chars = path.chars();
loop {
let mut segment = String::new();
let mut rest = chars.clone();
let ended = if rest.next() == Some('`') {
chars = rest;
loop {
match chars.next()? {
'\\' => segment.push(chars.next()?),
'`' => break,
c => segment.push(c),
}
}
match chars.next() {
None => true,
Some('.') => false,
Some(_) => return None,
}
} else {
loop {
match chars.next() {
None => break true,
Some('.') => break false,
Some('`') => return None,
Some(c) => segment.push(c),
}
}
};
if segment.is_empty() {
return None;
}
segments.push(segment);
if ended {
return Some(segments);
}
}
}
fn is_simple_identifier(segment: &str) -> bool {
let mut chars = segment.chars();
chars
.next()
.is_some_and(|first| first.is_ascii_alphabetic() || first == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
}
impl From<&str> for CanonicalFieldPath {
fn from(path: &str) -> Self {
if path == ALL_FIELDS_PATH {
return Self(path.to_string());
}
let Some(segments) = Self::segments(path) else {
return Self(path.to_string());
};
let mut canonical = String::with_capacity(path.len());
for (position, segment) in segments.iter().enumerate() {
if position > 0 {
canonical.push('.');
}
if Self::is_simple_identifier(segment) {
canonical.push_str(segment);
} else {
canonical.push('`');
for c in segment.chars() {
if c == '`' || c == '\\' {
canonical.push('\\');
}
canonical.push(c);
}
canonical.push('`');
}
}
Self(canonical)
}
}
impl Display for CanonicalFieldPath {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, PartialEq, Eq, Hash, Clone)]
pub enum FirestoreFieldOverrideTarget {
Field(String),
AllFields,
}
impl FirestoreFieldOverrideTarget {
pub(crate) fn as_str(&self) -> &str {
match self {
FirestoreFieldOverrideTarget::Field(path) => path.as_str(),
FirestoreFieldOverrideTarget::AllFields => ALL_FIELDS_PATH,
}
}
}
#[derive(Debug, PartialEq, Clone)]
pub struct FirestoreFieldOverride {
pub target: FirestoreFieldOverrideTarget,
pub indexes: Vec<FirestoreFieldOverrideIndex>,
}
#[derive(Debug, Eq, PartialEq, Clone, Copy, Hash)]
pub enum FirestoreIndexState {
Creating,
Ready,
NeedsRepair,
}
#[derive(Debug, Eq, PartialEq, Clone, Copy, Hash)]
pub enum FirestoreFieldTtlState {
Creating,
Active,
NeedsRepair,
}
#[derive(Debug, PartialEq, Clone)]
pub struct FirestoreListedCompositeIndex {
pub name: String,
pub state: FirestoreIndexState,
pub index: FirestoreCompositeIndex,
}
#[derive(Debug, PartialEq, Clone)]
pub struct FirestoreExplicitFieldOverride {
pub indexes: Vec<FirestoreFieldOverrideIndex>,
pub reverting: bool,
}
#[derive(Debug, PartialEq, Clone)]
pub enum FirestoreFieldOverrideOutcome {
Inherited,
Explicit(FirestoreExplicitFieldOverride),
Unrecognised(String),
}
#[derive(Debug, PartialEq, Clone)]
pub enum FirestoreFieldTtlOutcome {
Configured(FirestoreFieldTtlState),
Unrecognised(String),
}
#[derive(Debug, PartialEq, Clone)]
pub struct FirestoreListedField {
pub name: String,
pub field_path: String,
pub index_override: Option<FirestoreFieldOverrideOutcome>,
pub ttl: Option<FirestoreFieldTtlOutcome>,
}
#[derive(Debug, PartialEq, Clone)]
pub struct FirestoreUnrecognisedIndexItem {
pub name: String,
pub reason: String,
}
#[derive(Debug, PartialEq, Clone, Builder)]
pub struct FirestoreIndexParams {
pub collection_group: FirestoreCollectionId,
#[default = "Vec::new()"]
pub composite_indexes: Vec<FirestoreCompositeIndex>,
#[default = "Vec::new()"]
pub field_overrides: Vec<FirestoreFieldOverride>,
#[default = "Vec::new()"]
pub ttl_fields: Vec<String>,
}
#[derive(Debug, PartialEq, Clone, Builder)]
pub struct FirestoreOperationWaitOptions {
pub timeout: Duration,
#[default = "Duration::from_secs(5)"]
pub poll_interval: Duration,
}
#[derive(Debug, PartialEq, Clone, Builder)]
pub struct FirestoreIndexSyncOptions {
#[default = "false"]
pub prune: bool,
#[default = "None"]
pub wait: Option<FirestoreOperationWaitOptions>,
}
#[derive(Debug, Default, PartialEq, Clone)]
pub struct FirestoreIndexPlan {
pub create_indexes: Vec<FirestoreCompositeIndex>,
pub unchanged: Vec<FirestoreCompositeIndex>,
pub pending: Vec<FirestoreCompositeIndex>,
pub needs_repair: Vec<FirestoreCompositeIndex>,
pub delete_indexes: Vec<FirestoreListedCompositeIndex>,
pub kept_undeclared_indexes: Vec<FirestoreListedCompositeIndex>,
pub update_fields: Vec<FirestoreFieldOverride>,
pub reverting_fields: Vec<FirestoreListedField>,
pub revert_fields: Vec<FirestoreListedField>,
pub kept_undeclared_fields: Vec<FirestoreListedField>,
pub enable_ttl: Vec<String>,
pub unchanged_ttl: Vec<String>,
pub pending_ttl: Vec<String>,
pub needs_repair_ttl: Vec<String>,
pub disable_ttl: Vec<FirestoreListedField>,
pub kept_undeclared_ttl: Vec<FirestoreListedField>,
pub unrecognised: Vec<FirestoreUnrecognisedIndexItem>,
pub skipped: Option<FirestoreIndexSyncSkipReason>,
}
#[derive(Debug, Default, PartialEq, Clone)]
pub struct FirestoreIndexSyncReport {
pub created_indexes: Vec<FirestoreCompositeIndex>,
pub updated_fields: Vec<FirestoreFieldOverride>,
pub enabled_ttl: Vec<String>,
pub unchanged_ttl: Vec<String>,
pub unchanged: Vec<FirestoreCompositeIndex>,
pub pending: Vec<FirestoreCompositeIndex>,
pub needs_repair: Vec<FirestoreCompositeIndex>,
pub pending_ttl: Vec<String>,
pub needs_repair_ttl: Vec<String>,
pub deleted_indexes: Vec<FirestoreListedCompositeIndex>,
pub withheld_deletes: Option<FirestoreIndexDeletesWithheld>,
pub reverted_fields: Vec<FirestoreListedField>,
pub disabled_ttl: Vec<FirestoreListedField>,
pub kept_undeclared_indexes: Vec<FirestoreListedCompositeIndex>,
pub kept_undeclared_fields: Vec<FirestoreListedField>,
pub kept_undeclared_ttl: Vec<FirestoreListedField>,
pub unrecognised: Vec<FirestoreUnrecognisedIndexItem>,
pub skipped: Option<FirestoreIndexSyncSkipReason>,
pub timings: FirestoreIndexSyncTimings,
}
#[derive(Debug, PartialEq, Clone)]
pub struct FirestoreIndexDeletesWithheld {
pub indexes: Vec<FirestoreListedCompositeIndex>,
pub reason: FirestoreIndexDeletesWithheldReason,
}
#[derive(Debug, PartialEq, Clone)]
pub enum FirestoreIndexDeletesWithheldReason {
AlreadyExists(Vec<FirestoreCompositeIndex>),
CreatesUnfinished(Vec<FirestoreCompositeIndex>),
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum FirestoreIndexSyncSkipReason {
Emulator,
}
#[derive(Debug, PartialEq, Eq, Clone, Default)]
pub struct FirestoreIndexSyncTimings {
pub total: Duration,
pub list: Option<Duration>,
pub apply: Option<Duration>,
pub wait: Option<Duration>,
}
const MAX_INDEX_FIELDS_WITH_IMPLIED_NAME: usize = 100;
pub(crate) fn validate_index_params(params: &FirestoreIndexParams) -> FirestoreResult<()> {
validate_collection_group(¶ms.collection_group)?;
validate_composite_indexes(¶ms.composite_indexes)?;
validate_field_overrides(¶ms.field_overrides)?;
validate_ttl_fields(¶ms.ttl_fields)?;
Ok(())
}
pub(crate) fn validate_collection_group(
collection_group: &FirestoreCollectionId,
) -> FirestoreResult<()> {
let id = collection_group.as_str();
let is_reserved = id == "-" || (id.len() >= 4 && id.starts_with("__") && id.ends_with("__"));
if is_reserved {
return Err(FirestoreError::invalid_parameters(
"collection_group",
format!(
"\"{id}\" is a collection group ID Firestore reserves (\"-\", or __*__ such as \
__default__); index management must not target it"
),
));
}
Ok(())
}
fn validate_composite_indexes(indexes: &[FirestoreCompositeIndex]) -> FirestoreResult<()> {
for (position, index) in indexes.iter().enumerate() {
let vector_positions: Vec<usize> = index
.fields
.iter()
.enumerate()
.filter(|(_, field)| matches!(field.mode, FirestoreIndexFieldMode::Vector(_)))
.map(|(field_position, _)| field_position)
.collect();
let is_lone_vector_field = index.fields.len() == 1 && vector_positions == [0];
if index.fields.len() < 2 && !is_lone_vector_field {
return Err(FirestoreError::invalid_parameters(
"composite_indexes",
format!(
"index {position} needs at least two fields, or exactly one vector field, has {}",
index.fields.len()
),
));
}
if index.fields.iter().any(|field| field.field_path.is_empty()) {
return Err(FirestoreError::invalid_parameters(
"composite_indexes",
format!("index {position} has a field with an empty path"),
));
}
let declares_implied_name = index
.fields
.iter()
.any(|field| field.field_path == IMPLIED_NAME_FIELD);
let max_fields = if declares_implied_name {
MAX_INDEX_FIELDS_WITH_IMPLIED_NAME
} else {
MAX_INDEX_FIELDS_WITH_IMPLIED_NAME - 1
};
if index.fields.len() > max_fields {
return Err(FirestoreError::invalid_parameters(
"composite_indexes",
format!(
"index {position} declares {} fields; Firestore allows at most {max_fields} \
(100 including the __name__ field it appends)",
index.fields.len()
),
));
}
for (earlier_position, earlier) in indexes.iter().enumerate().take(position) {
if super::index_diff::composite_index_matches(index, earlier)
|| super::index_diff::composite_index_matches(earlier, index)
{
return Err(FirestoreError::invalid_parameters(
"composite_indexes",
format!(
"index {position} duplicates index {earlier_position} once an implicit \
__name__ field is ignored"
),
));
}
}
match vector_positions.as_slice() {
[] => {}
[only] if *only == index.fields.len() - 1 => {
if let FirestoreIndexFieldMode::Vector(vector) = &index.fields[*only].mode {
validate_vector_dimension(vector.dimension, "composite_indexes")?;
}
}
[only] => {
return Err(FirestoreError::invalid_parameters(
"composite_indexes",
format!(
"index {position}'s vector field must be last, was at position {only} of {}",
index.fields.len()
),
));
}
multiple => {
return Err(FirestoreError::invalid_parameters(
"composite_indexes",
format!(
"index {position} has {} vector fields, at most one is allowed",
multiple.len()
),
));
}
}
}
Ok(())
}
fn validate_vector_dimension(dimension: u32, field: &'static str) -> FirestoreResult<()> {
if dimension == 0 || dimension > 2048 {
return Err(FirestoreError::invalid_parameters(
field,
format!("vector dimension must be between 1 and 2048, was {dimension}"),
));
}
Ok(())
}
fn validate_field_overrides(overrides: &[FirestoreFieldOverride]) -> FirestoreResult<()> {
let mut seen_targets: HashSet<&FirestoreFieldOverrideTarget> = HashSet::new();
for field_override in overrides {
match &field_override.target {
FirestoreFieldOverrideTarget::Field(path) if path.is_empty() => {
return Err(FirestoreError::invalid_parameters(
"field_overrides",
"a field override's field path must not be empty",
));
}
FirestoreFieldOverrideTarget::Field(path) if path == ALL_FIELDS_PATH => {
return Err(FirestoreError::invalid_parameters(
"field_overrides",
"field path \"*\" is reserved; declare it with all_fields() instead of \
field(\"*\")",
));
}
_ => {}
}
if !seen_targets.insert(&field_override.target) {
return Err(FirestoreError::invalid_parameters(
"field_overrides",
format!(
"{} is declared more than once",
field_override.target.as_str()
),
));
}
if super::index_diff::override_index_set(&field_override.indexes).len()
!= field_override.indexes.len()
{
return Err(FirestoreError::invalid_parameters(
"field_overrides",
format!(
"{} declares the same single-field index more than once",
field_override.target.as_str()
),
));
}
for index in &field_override.indexes {
if matches!(index.mode, FirestoreIndexFieldMode::Vector(_)) {
return Err(FirestoreError::invalid_parameters(
"field_overrides",
format!(
"{} declares a vector index, which Firestore does not support as a \
single-field override",
field_override.target.as_str()
),
));
}
}
}
Ok(())
}
fn validate_ttl_fields(ttl_fields: &[String]) -> FirestoreResult<()> {
for path in ttl_fields {
if path.is_empty() {
return Err(FirestoreError::invalid_parameters(
"ttl_fields",
"a TTL field path must not be empty",
));
}
}
if ttl_fields.len() > 1 {
return Err(FirestoreError::invalid_parameters(
"ttl_fields",
format!(
"a collection group may have at most one TTL field, {} were declared",
ttl_fields.len()
),
));
}
Ok(())
}
pub(crate) fn write_section<T: Display>(
f: &mut Formatter<'_>,
label: &str,
items: &[T],
) -> fmt::Result {
if items.is_empty() {
return Ok(());
}
writeln!(f, " {label}: {}", items.len())?;
for item in items {
writeln!(f, " {item}")?;
}
Ok(())
}
fn write_comma_joined<T: Display>(f: &mut Formatter<'_>, items: &[T]) -> fmt::Result {
for (position, item) in items.iter().enumerate() {
if position > 0 {
write!(f, ", ")?;
}
write!(f, "{item}")?;
}
Ok(())
}
impl Display for FirestoreIndexQueryScope {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.write_str(match self {
FirestoreIndexQueryScope::Collection => "COLLECTION",
FirestoreIndexQueryScope::AllDescendants => "COLLECTION_GROUP",
})
}
}
impl Display for FirestoreIndexFieldMode {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
FirestoreIndexFieldMode::Order(FirestoreQueryDirection::Ascending) => {
f.write_str("ASC")
}
FirestoreIndexFieldMode::Order(FirestoreQueryDirection::Descending) => {
f.write_str("DESC")
}
FirestoreIndexFieldMode::ArrayContains => f.write_str("CONTAINS"),
FirestoreIndexFieldMode::Vector(vector) => write!(f, "VECTOR({})", vector.dimension),
}
}
}
impl Display for FirestoreIndexField {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(f, "{} {}", self.field_path, self.mode)
}
}
impl Display for FirestoreCompositeIndex {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(f, "[{}] (", self.query_scope)?;
write_comma_joined(f, &self.fields)?;
write!(f, ")")
}
}
impl Display for FirestoreIndexState {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.write_str(match self {
FirestoreIndexState::Creating => "CREATING",
FirestoreIndexState::Ready => "READY",
FirestoreIndexState::NeedsRepair => "NEEDS_REPAIR",
})
}
}
impl Display for FirestoreListedCompositeIndex {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(f, "{} {} ({})", self.index, self.state, self.name)
}
}
impl Display for FirestoreFieldOverrideIndex {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(f, "[{}] {}", self.query_scope, self.mode)
}
}
impl Display for FirestoreFieldOverrideTarget {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl Display for FirestoreFieldOverride {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.target)?;
if self.indexes.is_empty() {
return f.write_str(" EXEMPT");
}
write!(f, " [")?;
write_comma_joined(f, &self.indexes)?;
write!(f, "]")
}
}
impl Display for FirestoreFieldTtlState {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.write_str(match self {
FirestoreFieldTtlState::Creating => "CREATING",
FirestoreFieldTtlState::Active => "ACTIVE",
FirestoreFieldTtlState::NeedsRepair => "NEEDS_REPAIR",
})
}
}
impl Display for FirestoreListedField {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.field_path)?;
match &self.index_override {
None | Some(FirestoreFieldOverrideOutcome::Inherited) => {}
Some(FirestoreFieldOverrideOutcome::Explicit(explicit)) => {
if explicit.indexes.is_empty() {
write!(f, " EXEMPT")?;
} else {
write!(f, " [")?;
write_comma_joined(f, &explicit.indexes)?;
write!(f, "]")?;
}
if explicit.reverting {
write!(f, " (reverting)")?;
}
}
Some(FirestoreFieldOverrideOutcome::Unrecognised(reason)) => {
write!(f, " override unrecognised: {reason}")?;
}
}
match &self.ttl {
None => {}
Some(FirestoreFieldTtlOutcome::Configured(state)) => write!(f, " ttl={state}")?,
Some(FirestoreFieldTtlOutcome::Unrecognised(reason)) => {
write!(f, " ttl unrecognised: {reason}")?;
}
}
write!(f, " ({})", self.name)
}
}
impl Display for FirestoreUnrecognisedIndexItem {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.name, self.reason)
}
}
impl Display for FirestoreIndexPlan {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
let anything_to_report = !self.create_indexes.is_empty()
|| !self.update_fields.is_empty()
|| !self.reverting_fields.is_empty()
|| !self.enable_ttl.is_empty()
|| !self.delete_indexes.is_empty()
|| !self.revert_fields.is_empty()
|| !self.disable_ttl.is_empty()
|| !self.kept_undeclared_indexes.is_empty()
|| !self.kept_undeclared_fields.is_empty()
|| !self.kept_undeclared_ttl.is_empty()
|| !self.needs_repair.is_empty()
|| !self.needs_repair_ttl.is_empty()
|| !self.unrecognised.is_empty();
if !anything_to_report {
writeln!(
f,
"Firestore index plan: no changes ({} unchanged, {} pending, {} ttl unchanged)",
self.unchanged.len(),
self.pending.len(),
self.unchanged_ttl.len()
)?;
if let Some(reason) = &self.skipped {
writeln!(f, " skipped: {reason}")?;
}
return Ok(());
}
writeln!(f, "Firestore index plan:")?;
write_section(f, "create_indexes", &self.create_indexes)?;
write_section(f, "update_field_overrides", &self.update_fields)?;
write_section(
f,
"reverting_field_overrides (revert in progress, no write planned)",
&self.reverting_fields,
)?;
write_section(f, "disable_ttl", &self.disable_ttl)?;
write_section(f, "enable_ttl", &self.enable_ttl)?;
write_section(f, "delete_indexes", &self.delete_indexes)?;
write_section(f, "revert_field_overrides", &self.revert_fields)?;
write_section(
f,
"kept_undeclared_indexes (prune_undeclared() would delete)",
&self.kept_undeclared_indexes,
)?;
write_section(
f,
"kept_undeclared_fields (prune_undeclared() would revert)",
&self.kept_undeclared_fields,
)?;
write_section(
f,
"kept_undeclared_ttl (prune_undeclared() would disable)",
&self.kept_undeclared_ttl,
)?;
write_section(f, "needs_repair", &self.needs_repair)?;
write_section(f, "needs_repair_ttl", &self.needs_repair_ttl)?;
write_section(f, "unrecognised, never pruned", &self.unrecognised)?;
if !self.unchanged.is_empty() || !self.pending.is_empty() || !self.unchanged_ttl.is_empty()
{
writeln!(
f,
" unchanged: {}, pending: {}, ttl unchanged: {}",
self.unchanged.len(),
self.pending.len(),
self.unchanged_ttl.len()
)?;
}
if let Some(reason) = &self.skipped {
writeln!(f, " skipped: {reason}")?;
}
Ok(())
}
}
impl Display for FirestoreIndexSyncReport {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
writeln!(f, "Firestore index sync report:")?;
write_section(f, "created_indexes", &self.created_indexes)?;
write_section(f, "updated_fields", &self.updated_fields)?;
write_section(f, "enabled_ttl", &self.enabled_ttl)?;
write_section(f, "unchanged_ttl", &self.unchanged_ttl)?;
write_section(f, "unchanged", &self.unchanged)?;
write_section(f, "pending", &self.pending)?;
write_section(f, "needs_repair", &self.needs_repair)?;
write_section(f, "pending_ttl", &self.pending_ttl)?;
write_section(f, "needs_repair_ttl", &self.needs_repair_ttl)?;
write_section(f, "deleted_indexes", &self.deleted_indexes)?;
if let Some(withheld) = &self.withheld_deletes {
writeln!(
f,
" withheld_deletes: {}, because {}",
withheld.indexes.len(),
withheld.reason
)?;
for index in &withheld.indexes {
writeln!(f, " {index}")?;
}
}
write_section(f, "reverted_fields", &self.reverted_fields)?;
write_section(f, "disabled_ttl", &self.disabled_ttl)?;
write_section(f, "kept_undeclared_indexes", &self.kept_undeclared_indexes)?;
write_section(f, "kept_undeclared_fields", &self.kept_undeclared_fields)?;
write_section(f, "kept_undeclared_ttl", &self.kept_undeclared_ttl)?;
write_section(f, "unrecognised", &self.unrecognised)?;
if let Some(reason) = &self.skipped {
writeln!(f, " skipped: {reason}")?;
}
writeln!(f, " timings: {}", self.timings)
}
}
impl Display for FirestoreIndexDeletesWithheldReason {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
FirestoreIndexDeletesWithheldReason::AlreadyExists(indexes) => {
write!(f, "CreateIndex answered ALREADY_EXISTS for ")?;
write_comma_joined(f, indexes)
}
FirestoreIndexDeletesWithheldReason::CreatesUnfinished(indexes) => {
write!(f, "these new indexes had not finished building: ")?;
write_comma_joined(f, indexes)
}
}
}
}
impl Display for FirestoreIndexSyncSkipReason {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
FirestoreIndexSyncSkipReason::Emulator => {
write!(f, "the Firestore emulator does not implement the admin API")
}
}
}
}
impl Display for FirestoreIndexSyncTimings {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(f, "total {} ms", self.total.as_millis())?;
for (phase, duration) in [
("list", self.list),
("apply", self.apply),
("wait", self.wait),
] {
if let Some(duration) = duration {
write!(f, ", {phase} {} ms", duration.as_millis())?;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_field_path_has_one_canonical_spelling() {
for (written, canonical) in [
("x", "x"),
("`x`", "x"),
("a.`b`", "a.b"),
("`a`.b", "a.b"),
("`my-field`", "`my-field`"),
("my-field", "`my-field`"),
("`a/b`.c", "`a/b`.c"),
("`a\\`b`", "`a\\`b`"),
("`back\\\\slash`", "`back\\\\slash`"),
("`_ok9`", "_ok9"),
("`9lives`", "`9lives`"),
("`__name__`", "__name__"),
("*", "*"),
] {
assert_eq!(
CanonicalFieldPath::from(written).as_str(),
canonical,
"{written}"
);
}
}
#[test]
fn a_field_path_outside_the_grammar_is_kept_as_written() {
for written in ["`open", "a..b", "a.", "``", "a`b"] {
assert_eq!(CanonicalFieldPath::from(written).as_str(), written);
}
}
#[test]
fn a_field_resource_name_yields_its_canonical_field_path() {
let name = "projects/p/databases/fields/collectionGroups/users/fields/`a/b`.`c`";
assert_eq!(
CanonicalFieldPath::from_field_resource_name(name)
.unwrap()
.as_str(),
"`a/b`.c"
);
assert!(CanonicalFieldPath::from_field_resource_name("projects/p/other/x").is_err());
}
fn field(path: &str, mode: FirestoreIndexFieldMode) -> FirestoreIndexField {
FirestoreIndexField::new(path.to_string(), mode)
}
fn asc(path: &str) -> FirestoreIndexField {
field(
path,
FirestoreIndexFieldMode::Order(FirestoreQueryDirection::Ascending),
)
}
#[test]
fn composite_index_needs_at_least_two_fields() {
let index = FirestoreCompositeIndex::new(vec![asc("a")]);
let err = validate_composite_indexes(&[index]).unwrap_err();
assert!(err.to_string().contains("at least two fields"));
}
#[test]
fn composite_index_with_two_fields_is_valid() {
let index = FirestoreCompositeIndex::new(vec![asc("a"), asc("b")]);
assert!(validate_composite_indexes(&[index]).is_ok());
}
#[test]
fn vector_field_must_be_last() {
let index = FirestoreCompositeIndex::new(vec![
field(
"v",
FirestoreIndexFieldMode::Vector(FirestoreVectorIndexConfig::new(8)),
),
asc("b"),
]);
let err = validate_composite_indexes(&[index]).unwrap_err();
assert!(err.to_string().contains("must be last"));
}
#[test]
fn vector_field_last_is_valid() {
let index = FirestoreCompositeIndex::new(vec![
asc("a"),
field(
"v",
FirestoreIndexFieldMode::Vector(FirestoreVectorIndexConfig::new(8)),
),
]);
assert!(validate_composite_indexes(&[index]).is_ok());
}
#[test]
fn at_most_one_vector_field() {
let index = FirestoreCompositeIndex::new(vec![
field(
"v1",
FirestoreIndexFieldMode::Vector(FirestoreVectorIndexConfig::new(8)),
),
field(
"v2",
FirestoreIndexFieldMode::Vector(FirestoreVectorIndexConfig::new(8)),
),
]);
let err = validate_composite_indexes(&[index]).unwrap_err();
assert!(err.to_string().contains("at most one is allowed"));
}
#[test]
fn vector_dimension_must_be_in_range() {
for bad_dimension in [0u32, 2049] {
let index = FirestoreCompositeIndex::new(vec![
asc("a"),
field(
"v",
FirestoreIndexFieldMode::Vector(FirestoreVectorIndexConfig::new(bad_dimension)),
),
]);
let err = validate_composite_indexes(&[index]).unwrap_err();
assert!(err.to_string().contains("between 1 and 2048"));
}
let index = FirestoreCompositeIndex::new(vec![
asc("a"),
field(
"v",
FirestoreIndexFieldMode::Vector(FirestoreVectorIndexConfig::new(2048)),
),
]);
assert!(validate_composite_indexes(&[index]).is_ok());
}
#[test]
fn duplicate_field_override_path_is_rejected() {
let overrides = vec![
FirestoreFieldOverride {
target: FirestoreFieldOverrideTarget::Field("tags".to_string()),
indexes: vec![],
},
FirestoreFieldOverride {
target: FirestoreFieldOverrideTarget::Field("tags".to_string()),
indexes: vec![],
},
];
let err = validate_field_overrides(&overrides).unwrap_err();
assert!(err.to_string().contains("declared more than once"));
}
#[test]
fn exempt_and_empty_indexes_are_the_same_and_accepted() {
let overrides = vec![FirestoreFieldOverride {
target: FirestoreFieldOverrideTarget::Field("bio".to_string()),
indexes: vec![],
}];
assert!(validate_field_overrides(&overrides).is_ok());
}
#[test]
fn empty_declaration_is_valid() {
let params = FirestoreIndexParams::new(FirestoreCollectionId::from_static("users"));
assert!(validate_index_params(¶ms).is_ok());
assert!(params.composite_indexes.is_empty());
assert!(params.field_overrides.is_empty());
assert!(params.ttl_fields.is_empty());
}
#[test]
fn default_collection_group_is_rejected() {
let params = FirestoreIndexParams::new(FirestoreCollectionId::from_static("__default__"));
let err = validate_index_params(¶ms).unwrap_err();
assert!(err.to_string().contains("__default__"));
}
#[test]
fn dash_collection_group_is_rejected() {
let params = FirestoreIndexParams::new(FirestoreCollectionId::from_static("-"));
let err = validate_index_params(¶ms).unwrap_err();
assert!(err.to_string().contains("reserves"));
}
#[test]
fn other_reserved_shaped_collection_group_is_rejected() {
let params = FirestoreIndexParams::new(FirestoreCollectionId::from_static("__x__"));
let err = validate_index_params(¶ms).unwrap_err();
assert!(err.to_string().contains("__x__"));
}
#[test]
fn ordinary_collection_group_is_accepted() {
let params = FirestoreIndexParams::new(FirestoreCollectionId::from_static("users"));
assert!(validate_index_params(¶ms).is_ok());
}
#[test]
fn lone_vector_field_composite_index_is_valid() {
let index = FirestoreCompositeIndex::new(vec![field(
"embedding",
FirestoreIndexFieldMode::Vector(FirestoreVectorIndexConfig::new(8)),
)]);
assert!(validate_composite_indexes(&[index]).is_ok());
}
#[test]
fn lone_vector_field_dimension_is_still_validated() {
let index = FirestoreCompositeIndex::new(vec![field(
"embedding",
FirestoreIndexFieldMode::Vector(FirestoreVectorIndexConfig::new(0)),
)]);
let err = validate_composite_indexes(&[index]).unwrap_err();
assert!(err.to_string().contains("between 1 and 2048"));
}
#[test]
fn composite_index_with_empty_field_path_is_rejected() {
let index = FirestoreCompositeIndex::new(vec![asc(""), asc("b")]);
let err = validate_composite_indexes(&[index]).unwrap_err();
assert!(err.to_string().contains("empty path"));
}
#[test]
fn composite_index_exceeding_max_declared_fields_is_rejected() {
let fields: Vec<_> = (0..100).map(|i| asc(&format!("f{i}"))).collect();
let index = FirestoreCompositeIndex::new(fields);
let err = validate_composite_indexes(&[index]).unwrap_err();
assert!(err.to_string().contains("at most 99"));
}
#[test]
fn composite_index_at_max_declared_fields_is_valid() {
let fields: Vec<_> = (0..99).map(|i| asc(&format!("f{i}"))).collect();
let index = FirestoreCompositeIndex::new(fields);
assert!(validate_composite_indexes(&[index]).is_ok());
}
#[test]
fn duplicate_composite_index_after_name_normalisation_is_rejected() {
let index_a = FirestoreCompositeIndex::new(vec![asc("a"), asc("b")]);
let index_b =
FirestoreCompositeIndex::new(vec![asc("a"), asc("b"), asc(IMPLIED_NAME_FIELD)]);
let err = validate_composite_indexes(&[index_a, index_b]).unwrap_err();
assert!(err.to_string().contains("index 1 duplicates index 0"));
}
#[test]
fn composite_index_with_name_field_mid_position_is_not_a_duplicate() {
let index_a = FirestoreCompositeIndex::new(vec![asc("a"), asc("b")]);
let index_b =
FirestoreCompositeIndex::new(vec![asc("a"), asc(IMPLIED_NAME_FIELD), asc("b")]);
assert!(validate_composite_indexes(&[index_a, index_b]).is_ok());
}
#[test]
fn field_override_with_duplicate_index_entries_is_rejected() {
let overrides = vec![FirestoreFieldOverride {
target: FirestoreFieldOverrideTarget::Field("tags".to_string()),
indexes: vec![
FirestoreFieldOverrideIndex::new(FirestoreIndexFieldMode::ArrayContains),
FirestoreFieldOverrideIndex::new(FirestoreIndexFieldMode::ArrayContains),
],
}];
let err = validate_field_overrides(&overrides).unwrap_err();
assert!(err.to_string().contains("more than once"));
}
#[test]
fn field_override_with_vector_mode_is_rejected() {
let overrides = vec![FirestoreFieldOverride {
target: FirestoreFieldOverrideTarget::Field("embedding".to_string()),
indexes: vec![FirestoreFieldOverrideIndex::new(
FirestoreIndexFieldMode::Vector(FirestoreVectorIndexConfig::new(8)),
)],
}];
let err = validate_field_overrides(&overrides).unwrap_err();
assert!(err.to_string().contains("vector index"));
}
#[test]
fn field_override_with_empty_path_is_rejected() {
let overrides = vec![FirestoreFieldOverride {
target: FirestoreFieldOverrideTarget::Field(String::new()),
indexes: vec![],
}];
let err = validate_field_overrides(&overrides).unwrap_err();
assert!(err.to_string().contains("must not be empty"));
}
#[test]
fn field_named_star_is_rejected_pointing_at_all_fields() {
let overrides = vec![FirestoreFieldOverride {
target: FirestoreFieldOverrideTarget::Field(ALL_FIELDS_PATH.to_string()),
indexes: vec![],
}];
let err = validate_field_overrides(&overrides).unwrap_err();
assert!(err.to_string().contains("all_fields()"));
}
#[test]
fn all_fields_target_is_accepted() {
let overrides = vec![FirestoreFieldOverride {
target: FirestoreFieldOverrideTarget::AllFields,
indexes: vec![],
}];
assert!(validate_field_overrides(&overrides).is_ok());
}
#[test]
fn all_fields_declared_twice_is_rejected_as_a_duplicate() {
let overrides = vec![
FirestoreFieldOverride {
target: FirestoreFieldOverrideTarget::AllFields,
indexes: vec![],
},
FirestoreFieldOverride {
target: FirestoreFieldOverrideTarget::AllFields,
indexes: vec![FirestoreFieldOverrideIndex::new(
FirestoreIndexFieldMode::ArrayContains,
)],
},
];
let err = validate_field_overrides(&overrides).unwrap_err();
assert!(err.to_string().contains("declared more than once"));
}
#[test]
fn all_fields_and_a_named_field_are_both_accepted() {
let overrides = vec![
FirestoreFieldOverride {
target: FirestoreFieldOverrideTarget::AllFields,
indexes: vec![],
},
FirestoreFieldOverride {
target: FirestoreFieldOverrideTarget::Field("country".to_string()),
indexes: vec![FirestoreFieldOverrideIndex::new(
FirestoreIndexFieldMode::Order(FirestoreQueryDirection::Ascending),
)],
},
];
assert!(validate_field_overrides(&overrides).is_ok());
}
#[test]
fn single_ttl_field_is_valid() {
assert!(validate_ttl_fields(&["expires_at".to_string()]).is_ok());
}
#[test]
fn more_than_one_ttl_field_is_rejected() {
let err = validate_ttl_fields(&["a".to_string(), "b".to_string()]).unwrap_err();
assert!(err.to_string().contains("at most one TTL field"));
}
#[test]
fn duplicate_ttl_field_is_rejected() {
let err =
validate_ttl_fields(&["expires_at".to_string(), "expires_at".to_string()]).unwrap_err();
assert!(err.to_string().contains("at most one TTL field"));
}
#[test]
fn empty_ttl_field_path_is_rejected() {
let err = validate_ttl_fields(&[String::new()]).unwrap_err();
assert!(err.to_string().contains("must not be empty"));
}
}