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