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};
11use thiserror::Error as ThisError;
12
13/// Required selected access path for fail-closed fluent query contracts.
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub enum RequiredAccessPath {
16    /// Require primary-key lookup.
17    ByKey,
18    /// Require multiple primary-key lookup.
19    ByKeys,
20    /// Require primary-key range lookup.
21    KeyRange,
22    /// Require secondary-index equality-prefix access.
23    IndexPrefix,
24    /// Require secondary-index multi-lookup access.
25    IndexMultiLookup,
26    /// Require secondary-index range access.
27    IndexRange,
28    /// Require full scan access.
29    FullScan,
30    /// Require union access.
31    Union,
32    /// Require intersection access.
33    Intersection,
34}
35
36impl RequiredAccessPath {
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, ThisError)]
162#[error(
163    "query access requirement failed: {violation}; selected={selected_label}",
164    selected_label = decision.selected.label
165)]
166pub struct AccessRequirementError {
167    violation: AccessRequirementViolation,
168    decision: ExplainAccessDecisionV1,
169}
170
171impl AccessRequirementError {
172    pub(in crate::db) const fn new(
173        violation: AccessRequirementViolation,
174        decision: ExplainAccessDecisionV1,
175    ) -> Self {
176        Self {
177            violation,
178            decision,
179        }
180    }
181
182    /// Borrow the violated access requirement.
183    #[must_use]
184    pub const fn violation(&self) -> &AccessRequirementViolation {
185        &self.violation
186    }
187
188    /// Borrow the selected access decision that failed the requirement.
189    #[must_use]
190    pub const fn decision(&self) -> &ExplainAccessDecisionV1 {
191        &self.decision
192    }
193}
194
195/// Specific fail-closed access requirement that was not satisfied.
196#[derive(Clone, Debug, Eq, PartialEq, ThisError)]
197pub enum AccessRequirementViolation {
198    /// A secondary-index route was required but not selected.
199    #[error("secondary index access required")]
200    IndexRequired,
201    /// One specific semantic index name was required but not selected.
202    #[error("index '{expected}' required")]
203    NamedIndexRequired {
204        /// Required semantic index name.
205        expected: String,
206    },
207    /// One selected access path kind was required but not selected.
208    #[error("access path '{}' required", expected.code())]
209    AccessPathRequired {
210        /// Required selected access path.
211        expected: RequiredAccessPath,
212    },
213    /// Residual predicate or scalar filter work was forbidden.
214    #[error("residual filter forbidden")]
215    ResidualFilterForbidden,
216}
217
218const fn selected_access_is_secondary_index(kind: ExplainAccessDecisionKind) -> bool {
219    matches!(
220        kind,
221        ExplainAccessDecisionKind::IndexPrefix
222            | ExplainAccessDecisionKind::IndexMultiLookup
223            | ExplainAccessDecisionKind::IndexRange
224    )
225}