Skip to main content

uqa_storage/
analyzer_binding.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Durable, independently resolved analyzer sides and their field owner.
8
9use std::sync::Arc;
10
11use serde::{Deserialize, Serialize};
12use serde_json::value::RawValue;
13use uqa_analysis::{AnalyzerResources, CompiledAnalyzer};
14
15use crate::{AnalyzerPhase, InvertedIndex, StorageBackendError, StorageBackendResult};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum AnalyzerBindingOwner {
20    /// The physical field's default or an explicit field assignment.
21    Field,
22    /// A GIN definition selected the analyzer for both sides.
23    Gin,
24}
25
26#[derive(Debug, Clone)]
27pub struct BoundAnalyzerRevision {
28    pub name: Option<String>,
29    pub compiled: Arc<CompiledAnalyzer>,
30}
31
32#[derive(Debug, Clone)]
33pub struct FieldAnalyzerBinding {
34    pub index: BoundAnalyzerRevision,
35    pub search: BoundAnalyzerRevision,
36    pub owner: AnalyzerBindingOwner,
37    pub last_phase: AnalyzerPhase,
38}
39
40#[derive(Serialize, Deserialize)]
41#[serde(deny_unknown_fields)]
42struct StoredSide {
43    name: Option<String>,
44    descriptor: Box<RawValue>,
45}
46
47#[derive(Serialize, Deserialize)]
48#[serde(deny_unknown_fields)]
49struct StoredBinding {
50    format: String,
51    version: u32,
52    owner: AnalyzerBindingOwner,
53    last_phase: AnalyzerPhase,
54    index: StoredSide,
55    search: StoredSide,
56}
57
58impl FieldAnalyzerBinding {
59    pub fn unassigned(index: Arc<CompiledAnalyzer>, search: Arc<CompiledAnalyzer>) -> Self {
60        Self {
61            index: BoundAnalyzerRevision {
62                name: None,
63                compiled: index,
64            },
65            search: BoundAnalyzerRevision {
66                name: None,
67                compiled: search,
68            },
69            owner: AnalyzerBindingOwner::Field,
70            last_phase: AnalyzerPhase::Both,
71        }
72    }
73
74    pub fn assigned(
75        &self,
76        name: &str,
77        compiled: Arc<CompiledAnalyzer>,
78        phase: AnalyzerPhase,
79        owner: AnalyzerBindingOwner,
80    ) -> Self {
81        let mut result = self.clone();
82        let revision = BoundAnalyzerRevision {
83            name: Some(name.to_owned()),
84            compiled,
85        };
86        match phase {
87            AnalyzerPhase::Index => result.index = revision,
88            AnalyzerPhase::Search => result.search = revision,
89            AnalyzerPhase::Both => {
90                result.index = revision.clone();
91                result.search = revision;
92            }
93        }
94        result.owner = owner;
95        result.last_phase = phase;
96        result
97    }
98
99    pub fn uses_name(&self, name: &str) -> bool {
100        self.index.name.as_deref() == Some(name) || self.search.name.as_deref() == Some(name)
101    }
102
103    /// Compatibility label for the most recent explicit assignment. Exact execution always uses both retained revisions.
104    pub fn last_assignment(&self) -> Option<(String, String)> {
105        let side = match self.last_phase {
106            AnalyzerPhase::Index | AnalyzerPhase::Both => &self.index,
107            AnalyzerPhase::Search => &self.search,
108        };
109        side.name
110            .clone()
111            .map(|name| (name, self.phase_name().to_owned()))
112    }
113
114    pub fn phase_name(&self) -> &'static str {
115        match self.last_phase {
116            AnalyzerPhase::Index => "index",
117            AnalyzerPhase::Search => "search",
118            AnalyzerPhase::Both => "both",
119        }
120    }
121
122    /// Install both immutable sides without name or file resolution. Callers provide an empty/restoring provider, or the exact index revision already associated with its postings.
123    pub fn install(&self, field: &str, index: &mut dyn InvertedIndex) -> StorageBackendResult<()> {
124        self.validate()?;
125        index
126            .set_field_analyzer_revisions(
127                field,
128                self.index.compiled.clone(),
129                self.search.compiled.clone(),
130            )
131            .map_err(StorageBackendError::Other)
132    }
133
134    pub fn to_json(&self) -> StorageBackendResult<String> {
135        self.validate()?;
136        let side = |revision: &BoundAnalyzerRevision| -> StorageBackendResult<StoredSide> {
137            Ok(StoredSide {
138                name: revision.name.clone(),
139                descriptor: RawValue::from_string(
140                    revision.compiled.descriptor().canonical_json().to_owned(),
141                )?,
142            })
143        };
144        Ok(serde_json::to_string(&StoredBinding {
145            format: "uqa-field-analyzer-binding".into(),
146            version: 1,
147            owner: self.owner,
148            last_phase: self.last_phase,
149            index: side(&self.index)?,
150            search: side(&self.search)?,
151        })?)
152    }
153
154    pub fn from_json(json: &str, resources: &AnalyzerResources) -> StorageBackendResult<Self> {
155        // Bound the envelope before allocating either descriptor. RawValue retains duplicate properties for the descriptor's strict verifier.
156        let limit = resources.limits().max_descriptor_bytes.saturating_mul(3);
157        if json.len() > limit {
158            return Err(StorageBackendError::Other(
159                "analyzer binding exceeds descriptor limits".into(),
160            ));
161        }
162        let saved: StoredBinding = serde_json::from_str(json)?;
163        if saved.format != "uqa-field-analyzer-binding" || saved.version != 1 {
164            return Err(StorageBackendError::Other(
165                "unsupported analyzer binding format".into(),
166            ));
167        }
168        let side = |saved: StoredSide| -> StorageBackendResult<BoundAnalyzerRevision> {
169            Ok(BoundAnalyzerRevision {
170                name: saved.name,
171                compiled: resources.restore_json(saved.descriptor.get())?,
172            })
173        };
174        let restored = Self {
175            owner: saved.owner,
176            last_phase: saved.last_phase,
177            index: side(saved.index)?,
178            search: side(saved.search)?,
179        };
180        restored.validate()?;
181        Ok(restored)
182    }
183
184    fn validate(&self) -> StorageBackendResult<()> {
185        for side in [&self.index, &self.search] {
186            if side
187                .name
188                .as_deref()
189                .is_some_and(|name| name.is_empty() || name.trim() != name)
190            {
191                return Err(StorageBackendError::Other(
192                    "analyzer binding has an invalid name".into(),
193                ));
194            }
195        }
196        let invalid_last_assignment = match self.last_phase {
197            AnalyzerPhase::Index => self.index.name.is_none(),
198            AnalyzerPhase::Search => self.search.name.is_none(),
199            AnalyzerPhase::Both => {
200                self.index.name != self.search.name
201                    || (self.index.name.is_some()
202                        && self.index.compiled.descriptor().fingerprint()
203                            != self.search.compiled.descriptor().fingerprint())
204            }
205        };
206        if invalid_last_assignment {
207            return Err(StorageBackendError::Other(
208                "analyzer binding disagrees with its last assignment".into(),
209            ));
210        }
211        if self.owner == AnalyzerBindingOwner::Gin
212            && (self.index.name.is_none()
213                || self.index.name != self.search.name
214                || self.index.compiled.descriptor().fingerprint()
215                    != self.search.compiled.descriptor().fingerprint()
216                || self.last_phase != AnalyzerPhase::Both)
217        {
218            return Err(StorageBackendError::Other(
219                "GIN analyzer ownership requires one named revision on both sides".into(),
220            ));
221        }
222        Ok(())
223    }
224}