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) 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) 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) const fn require_index(&mut self) {
96        self.index_required = true;
97    }
98
99    pub(in crate::db) fn require_index_named(&mut self, index_name: impl Into<String>) {
100        self.index_required = true;
101        self.named_index = Some(index_name.into());
102    }
103
104    pub(in crate::db) const fn require_access_path(&mut self, path: RequiredAccessPath) {
105        self.access_path = Some(path);
106    }
107
108    pub(in crate::db) const fn require_no_residual_filter(&mut self) {
109        self.no_residual_filter = true;
110    }
111
112    pub(in crate::db) fn validate(&self, plan: &AccessPlannedQuery) -> Result<(), QueryError> {
113        if self.is_empty() {
114            return Ok(());
115        }
116
117        let explain = plan.explain();
118        let decision = explain.access_decision();
119
120        if self.index_required && !selected_access_is_secondary_index(decision.selected.kind) {
121            return Err(QueryError::from(AccessRequirementError::new(
122                AccessRequirementViolation::IndexRequired,
123                decision.clone(),
124            )));
125        }
126
127        if let Some(required_index_name) = &self.named_index
128            && decision.selected.index_name.as_deref() != Some(required_index_name.as_str())
129        {
130            return Err(QueryError::from(AccessRequirementError::new(
131                AccessRequirementViolation::NamedIndexRequired {
132                    expected: required_index_name.clone(),
133                },
134                decision.clone(),
135            )));
136        }
137
138        if let Some(required_path) = self.access_path
139            && !required_path.matches(decision.selected.kind)
140        {
141            return Err(QueryError::from(AccessRequirementError::new(
142                AccessRequirementViolation::AccessPathRequired {
143                    expected: required_path,
144                },
145                decision.clone(),
146            )));
147        }
148
149        if self.no_residual_filter && plan.has_any_residual_filter() {
150            return Err(QueryError::from(AccessRequirementError::new(
151                AccessRequirementViolation::ResidualFilterForbidden,
152                decision.clone(),
153            )));
154        }
155
156        Ok(())
157    }
158
159    pub(in crate::db) const fn is_empty(&self) -> bool {
160        !self.index_required
161            && self.named_index.is_none()
162            && self.access_path.is_none()
163            && !self.no_residual_filter
164    }
165}
166
167/// Query access requirement failure with the selected decision preserved.
168#[derive(Debug)]
169pub struct AccessRequirementError {
170    violation: AccessRequirementViolation,
171    decision: ExplainAccessDecisionV1,
172}
173
174impl AccessRequirementError {
175    pub(in crate::db) const fn new(
176        violation: AccessRequirementViolation,
177        decision: ExplainAccessDecisionV1,
178    ) -> Self {
179        Self {
180            violation,
181            decision,
182        }
183    }
184
185    /// Borrow the violated access requirement.
186    #[must_use]
187    pub const fn violation(&self) -> &AccessRequirementViolation {
188        &self.violation
189    }
190
191    /// Borrow the selected access decision that failed the requirement.
192    #[must_use]
193    pub const fn decision(&self) -> &ExplainAccessDecisionV1 {
194        &self.decision
195    }
196}
197
198/// Specific fail-closed access requirement that was not satisfied.
199#[derive(Clone, Debug, Eq, PartialEq)]
200pub enum AccessRequirementViolation {
201    /// A secondary-index route was required but not selected.
202    IndexRequired,
203    /// One specific semantic index name was required but not selected.
204    NamedIndexRequired {
205        /// Required semantic index name.
206        expected: String,
207    },
208    /// One selected access path kind was required but not selected.
209    AccessPathRequired {
210        /// Required selected access path.
211        expected: RequiredAccessPath,
212    },
213    /// Residual predicate or scalar filter work was forbidden.
214    ResidualFilterForbidden,
215}
216
217const fn selected_access_is_secondary_index(kind: ExplainAccessDecisionKind) -> bool {
218    matches!(
219        kind,
220        ExplainAccessDecisionKind::IndexPrefix
221            | ExplainAccessDecisionKind::IndexMultiLookup
222            | ExplainAccessDecisionKind::IndexBranchSet
223            | ExplainAccessDecisionKind::IndexRange
224    )
225}