Skip to main content

icydb_core/db/query/intent/
access_requirement.rs

1//! Module: query::intent::access_requirement
2//! Responsibility: fail-closed query access assertions evaluated after planning.
3//! Does not own: optimizer ranking or physical access selection.
4//! Boundary: fluent query contracts inspect the selected plan without acting as hints.
5
6use crate::db::query::{
7    explain::{ExplainAccessDecisionKind, ExplainAccessDecisionV1},
8    intent::QueryError,
9    plan::AccessPlannedQuery,
10};
11
12/// Required selected access path for fail-closed fluent query contracts.
13#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14pub enum RequiredAccessPath {
15    /// Require primary-key lookup.
16    ByKey,
17    /// Require multiple primary-key lookup.
18    ByKeys,
19    /// Require primary-key range lookup.
20    KeyRange,
21    /// Require secondary-index equality-prefix access.
22    IndexPrefix,
23    /// Require secondary-index multi-lookup access.
24    IndexMultiLookup,
25    /// Require secondary-index range access.
26    IndexRange,
27    /// Require full scan access.
28    FullScan,
29    /// Require union access.
30    Union,
31    /// Require intersection access.
32    Intersection,
33}
34
35impl RequiredAccessPath {
36    #[cfg(test)]
37    pub(in crate::db) const fn code(self) -> &'static str {
38        match self {
39            Self::ByKey => "ByKey",
40            Self::ByKeys => "ByKeys",
41            Self::KeyRange => "KeyRange",
42            Self::IndexPrefix => "IndexPrefix",
43            Self::IndexMultiLookup => "IndexMultiLookup",
44            Self::IndexRange => "IndexRange",
45            Self::FullScan => "FullScan",
46            Self::Union => "Union",
47            Self::Intersection => "Intersection",
48        }
49    }
50
51    const fn matches(self, actual: ExplainAccessDecisionKind) -> bool {
52        matches!(
53            (self, actual),
54            (Self::ByKey, ExplainAccessDecisionKind::ByKey)
55                | (Self::ByKeys, ExplainAccessDecisionKind::ByKeys)
56                | (Self::KeyRange, ExplainAccessDecisionKind::KeyRange)
57                | (Self::IndexPrefix, ExplainAccessDecisionKind::IndexPrefix)
58                | (
59                    Self::IndexMultiLookup,
60                    ExplainAccessDecisionKind::IndexMultiLookup
61                )
62                | (Self::IndexRange, ExplainAccessDecisionKind::IndexRange)
63                | (Self::FullScan, ExplainAccessDecisionKind::FullScan)
64                | (Self::Union, ExplainAccessDecisionKind::Union)
65                | (Self::Intersection, ExplainAccessDecisionKind::Intersection)
66        )
67    }
68}
69
70#[derive(Clone, Debug, Default, Eq, PartialEq)]
71pub(in crate::db) struct AccessRequirements {
72    index_required: bool,
73    named_index: Option<String>,
74    access_path: Option<RequiredAccessPath>,
75    no_residual_filter: bool,
76}
77
78impl AccessRequirements {
79    pub(in crate::db) const fn new() -> Self {
80        Self {
81            index_required: false,
82            named_index: None,
83            access_path: None,
84            no_residual_filter: false,
85        }
86    }
87
88    pub(in crate::db) const fn require_index(&mut self) {
89        self.index_required = true;
90    }
91
92    pub(in crate::db) fn require_index_named(&mut self, index_name: impl Into<String>) {
93        self.index_required = true;
94        self.named_index = Some(index_name.into());
95    }
96
97    pub(in crate::db) const fn require_access_path(&mut self, path: RequiredAccessPath) {
98        self.access_path = Some(path);
99    }
100
101    pub(in crate::db) const fn require_no_residual_filter(&mut self) {
102        self.no_residual_filter = true;
103    }
104
105    pub(in crate::db) fn validate(&self, plan: &AccessPlannedQuery) -> Result<(), QueryError> {
106        if self.is_empty() {
107            return Ok(());
108        }
109
110        let explain = plan.explain();
111        let decision = explain.access_decision();
112
113        if self.index_required && !selected_access_is_secondary_index(decision.selected.kind) {
114            return Err(QueryError::from(AccessRequirementError::new(
115                AccessRequirementViolation::IndexRequired,
116                decision.clone(),
117            )));
118        }
119
120        if let Some(required_index_name) = &self.named_index
121            && decision.selected.index_name.as_deref() != Some(required_index_name.as_str())
122        {
123            return Err(QueryError::from(AccessRequirementError::new(
124                AccessRequirementViolation::NamedIndexRequired {
125                    expected: required_index_name.clone(),
126                },
127                decision.clone(),
128            )));
129        }
130
131        if let Some(required_path) = self.access_path
132            && !required_path.matches(decision.selected.kind)
133        {
134            return Err(QueryError::from(AccessRequirementError::new(
135                AccessRequirementViolation::AccessPathRequired {
136                    expected: required_path,
137                },
138                decision.clone(),
139            )));
140        }
141
142        if self.no_residual_filter && plan.has_any_residual_filter() {
143            return Err(QueryError::from(AccessRequirementError::new(
144                AccessRequirementViolation::ResidualFilterForbidden,
145                decision.clone(),
146            )));
147        }
148
149        Ok(())
150    }
151
152    pub(in crate::db) const fn is_empty(&self) -> bool {
153        !self.index_required
154            && self.named_index.is_none()
155            && self.access_path.is_none()
156            && !self.no_residual_filter
157    }
158}
159
160/// Query access requirement failure with the selected decision preserved.
161#[derive(Debug)]
162pub struct AccessRequirementError {
163    violation: AccessRequirementViolation,
164    decision: ExplainAccessDecisionV1,
165}
166
167impl AccessRequirementError {
168    pub(in crate::db) const fn new(
169        violation: AccessRequirementViolation,
170        decision: ExplainAccessDecisionV1,
171    ) -> Self {
172        Self {
173            violation,
174            decision,
175        }
176    }
177
178    /// Borrow the violated access requirement.
179    #[must_use]
180    pub const fn violation(&self) -> &AccessRequirementViolation {
181        &self.violation
182    }
183
184    /// Borrow the selected access decision that failed the requirement.
185    #[must_use]
186    pub const fn decision(&self) -> &ExplainAccessDecisionV1 {
187        &self.decision
188    }
189}
190
191/// Specific fail-closed access requirement that was not satisfied.
192#[derive(Clone, Debug, Eq, PartialEq)]
193pub enum AccessRequirementViolation {
194    /// A secondary-index route was required but not selected.
195    IndexRequired,
196    /// One specific semantic index name was required but not selected.
197    NamedIndexRequired {
198        /// Required semantic index name.
199        expected: String,
200    },
201    /// One selected access path kind was required but not selected.
202    AccessPathRequired {
203        /// Required selected access path.
204        expected: RequiredAccessPath,
205    },
206    /// Residual predicate or scalar filter work was forbidden.
207    ResidualFilterForbidden,
208}
209
210const fn selected_access_is_secondary_index(kind: ExplainAccessDecisionKind) -> bool {
211    matches!(
212        kind,
213        ExplainAccessDecisionKind::IndexPrefix
214            | ExplainAccessDecisionKind::IndexMultiLookup
215            | ExplainAccessDecisionKind::IndexRange
216    )
217}