Skip to main content

icydb_core/db/session/
mod.rs

1//! Module: session
2//! Responsibility: user-facing query/write execution facade over db executors.
3//! Does not own: planning semantics, cursor validation rules, or storage mutation protocol.
4//! Boundary: converts fluent/query intent calls into executor operations and response DTOs.
5
6mod query;
7#[cfg(feature = "sql")]
8mod sql;
9///
10/// TESTS
11///
12#[cfg(all(test, feature = "sql"))]
13mod tests;
14mod write;
15
16use crate::{
17    db::{
18        Db, EntityFieldDescription, EntitySchemaDescription, FluentDeleteQuery, FluentLoadQuery,
19        IndexState, IntegrityReport, MigrationPlan, MigrationRunOutcome, MissingRowPolicy,
20        PersistedRow, Query, QueryError, StorageReport, StoreRegistry, WriteBatchResponse,
21        commit::EntityRuntimeHooks,
22        data::DataKey,
23        executor::{DeleteExecutor, LoadExecutor, SaveExecutor},
24        query::plan::VisibleIndexes,
25        schema::{
26            describe_entity_model, show_indexes_for_model,
27            show_indexes_for_model_with_runtime_state,
28        },
29    },
30    error::InternalError,
31    metrics::sink::{MetricsSink, with_metrics_sink},
32    model::entity::EntityModel,
33    traits::{CanisterKind, EntityKind, EntityValue, Path},
34    value::Value,
35};
36use std::{cell::OnceCell, thread::LocalKey};
37
38#[cfg(feature = "perf-attribution")]
39pub use query::QueryExecutionAttribution;
40#[cfg(all(feature = "sql", feature = "perf-attribution"))]
41pub use sql::SqlQueryExecutionAttribution;
42#[cfg(feature = "sql")]
43pub use sql::SqlStatementResult;
44#[cfg(all(feature = "sql", feature = "structural-read-metrics"))]
45pub use sql::{SqlProjectionMaterializationMetrics, with_sql_projection_materialization_metrics};
46
47///
48/// DbSession
49///
50/// Session-scoped database handle with policy (debug, metrics) and execution routing.
51///
52
53pub struct DbSession<C: CanisterKind> {
54    db: Db<C>,
55    debug: bool,
56    metrics: Option<&'static dyn MetricsSink>,
57    query_plan_cache: OnceCell<std::cell::RefCell<query::QueryPlanCache>>,
58    #[cfg(feature = "sql")]
59    sql_compiled_command_cache: OnceCell<std::cell::RefCell<sql::SqlCompiledCommandCache>>,
60    #[cfg(feature = "sql")]
61    sql_select_plan_cache: OnceCell<std::cell::RefCell<sql::SqlSelectPlanCache>>,
62}
63
64impl<C: CanisterKind> DbSession<C> {
65    /// Construct one session facade for a database handle.
66    #[must_use]
67    pub(crate) const fn new(db: Db<C>) -> Self {
68        Self {
69            db,
70            debug: false,
71            metrics: None,
72            query_plan_cache: OnceCell::new(),
73            #[cfg(feature = "sql")]
74            sql_compiled_command_cache: OnceCell::new(),
75            #[cfg(feature = "sql")]
76            sql_select_plan_cache: OnceCell::new(),
77        }
78    }
79
80    /// Construct one session facade from store registry and runtime hooks.
81    #[must_use]
82    pub const fn new_with_hooks(
83        store: &'static LocalKey<StoreRegistry>,
84        entity_runtime_hooks: &'static [EntityRuntimeHooks<C>],
85    ) -> Self {
86        Self::new(Db::new_with_hooks(store, entity_runtime_hooks))
87    }
88
89    /// Enable debug execution behavior where supported by executors.
90    #[must_use]
91    pub const fn debug(mut self) -> Self {
92        self.debug = true;
93        self
94    }
95
96    /// Attach one metrics sink for all session-executed operations.
97    #[must_use]
98    pub const fn metrics_sink(mut self, sink: &'static dyn MetricsSink) -> Self {
99        self.metrics = Some(sink);
100        self
101    }
102
103    fn with_metrics<T>(&self, f: impl FnOnce() -> T) -> T {
104        if let Some(sink) = self.metrics {
105            with_metrics_sink(sink, f)
106        } else {
107            f()
108        }
109    }
110
111    // Shared save-facade wrapper keeps metrics wiring and response shaping uniform.
112    fn execute_save_with<E, T, R>(
113        &self,
114        op: impl FnOnce(SaveExecutor<E>) -> Result<T, InternalError>,
115        map: impl FnOnce(T) -> R,
116    ) -> Result<R, InternalError>
117    where
118        E: PersistedRow<Canister = C> + EntityValue,
119    {
120        let value = self.with_metrics(|| op(self.save_executor::<E>()))?;
121
122        Ok(map(value))
123    }
124
125    // Shared save-facade wrappers keep response shape explicit at call sites.
126    fn execute_save_entity<E>(
127        &self,
128        op: impl FnOnce(SaveExecutor<E>) -> Result<E, InternalError>,
129    ) -> Result<E, InternalError>
130    where
131        E: PersistedRow<Canister = C> + EntityValue,
132    {
133        self.execute_save_with(op, std::convert::identity)
134    }
135
136    fn execute_save_batch<E>(
137        &self,
138        op: impl FnOnce(SaveExecutor<E>) -> Result<Vec<E>, InternalError>,
139    ) -> Result<WriteBatchResponse<E>, InternalError>
140    where
141        E: PersistedRow<Canister = C> + EntityValue,
142    {
143        self.execute_save_with(op, WriteBatchResponse::new)
144    }
145
146    // ---------------------------------------------------------------------
147    // Query entry points (public, fluent)
148    // ---------------------------------------------------------------------
149
150    /// Start a fluent load query with default missing-row policy (`Ignore`).
151    #[must_use]
152    pub const fn load<E>(&self) -> FluentLoadQuery<'_, E>
153    where
154        E: EntityKind<Canister = C>,
155    {
156        FluentLoadQuery::new(self, Query::new(MissingRowPolicy::Ignore))
157    }
158
159    /// Start a fluent load query with explicit missing-row policy.
160    #[must_use]
161    pub const fn load_with_consistency<E>(
162        &self,
163        consistency: MissingRowPolicy,
164    ) -> FluentLoadQuery<'_, E>
165    where
166        E: EntityKind<Canister = C>,
167    {
168        FluentLoadQuery::new(self, Query::new(consistency))
169    }
170
171    /// Start a fluent delete query with default missing-row policy (`Ignore`).
172    #[must_use]
173    pub fn delete<E>(&self) -> FluentDeleteQuery<'_, E>
174    where
175        E: PersistedRow<Canister = C>,
176    {
177        FluentDeleteQuery::new(self, Query::new(MissingRowPolicy::Ignore).delete())
178    }
179
180    /// Start a fluent delete query with explicit missing-row policy.
181    #[must_use]
182    pub fn delete_with_consistency<E>(
183        &self,
184        consistency: MissingRowPolicy,
185    ) -> FluentDeleteQuery<'_, E>
186    where
187        E: PersistedRow<Canister = C>,
188    {
189        FluentDeleteQuery::new(self, Query::new(consistency).delete())
190    }
191
192    /// Return one constant scalar row equivalent to SQL `SELECT 1`.
193    ///
194    /// This terminal bypasses query planning and access routing entirely.
195    #[must_use]
196    pub const fn select_one(&self) -> Value {
197        Value::Int(1)
198    }
199
200    /// Return one stable, human-readable index listing for the entity schema.
201    ///
202    /// Output format mirrors SQL-style introspection:
203    /// - `PRIMARY KEY (field)`
204    /// - `INDEX name (field_a, field_b)`
205    /// - `UNIQUE INDEX name (field_a, field_b)`
206    #[must_use]
207    pub fn show_indexes<E>(&self) -> Vec<String>
208    where
209        E: EntityKind<Canister = C>,
210    {
211        self.show_indexes_for_store_model(E::Store::PATH, E::MODEL)
212    }
213
214    /// Return one stable, human-readable index listing for one schema model.
215    ///
216    /// This model-only helper is schema-owned and intentionally does not
217    /// attach runtime lifecycle state because it does not carry store
218    /// placement context.
219    #[must_use]
220    pub fn show_indexes_for_model(&self, model: &'static EntityModel) -> Vec<String> {
221        show_indexes_for_model(model)
222    }
223
224    // Return one stable, human-readable index listing for one resolved
225    // store/model pair, attaching the current runtime lifecycle state when the
226    // registry can resolve the backing store handle.
227    pub(in crate::db) fn show_indexes_for_store_model(
228        &self,
229        store_path: &str,
230        model: &'static EntityModel,
231    ) -> Vec<String> {
232        let runtime_state = self.try_index_state_for_store_path(store_path);
233
234        show_indexes_for_model_with_runtime_state(model, runtime_state)
235    }
236
237    /// Return one stable list of field descriptors for the entity schema.
238    #[must_use]
239    pub fn show_columns<E>(&self) -> Vec<EntityFieldDescription>
240    where
241        E: EntityKind<Canister = C>,
242    {
243        self.show_columns_for_model(E::MODEL)
244    }
245
246    /// Return one stable list of field descriptors for one schema model.
247    #[must_use]
248    pub fn show_columns_for_model(
249        &self,
250        model: &'static EntityModel,
251    ) -> Vec<EntityFieldDescription> {
252        describe_entity_model(model).fields().to_vec()
253    }
254
255    /// Return one stable list of runtime-registered entity names.
256    #[must_use]
257    pub fn show_entities(&self) -> Vec<String> {
258        self.db.runtime_entity_names()
259    }
260
261    /// Return one stable list of runtime-registered entity names.
262    ///
263    /// `SHOW TABLES` is only an alias for `SHOW ENTITIES`, so the typed
264    /// metadata surface keeps the same alias relationship.
265    #[must_use]
266    pub fn show_tables(&self) -> Vec<String> {
267        self.show_entities()
268    }
269
270    // Best-effort runtime state lookup for metadata surfaces.
271    // SHOW INDEXES should stay readable even if one store handle is missing
272    // from the registry, so this helper falls back to the pure schema-owned
273    // listing instead of turning metadata inspection into an execution error.
274    fn try_index_state_for_store_path(&self, store_path: &str) -> Option<IndexState> {
275        self.db
276            .with_store_registry(|registry| registry.try_get_store(store_path).ok())
277            .map(|store| store.index_state())
278    }
279
280    // Resolve the exact secondary-index set that is visible to planner-owned
281    // query planning for one recovered store/model pair.
282    fn visible_indexes_for_store_model(
283        &self,
284        store_path: &str,
285        model: &'static EntityModel,
286    ) -> Result<VisibleIndexes<'static>, QueryError> {
287        // Phase 1: resolve the recovered store state once at the session
288        // boundary so query/executor planning does not reopen lifecycle checks.
289        let store = self
290            .db
291            .recovered_store(store_path)
292            .map_err(QueryError::execute)?;
293        let state = store.index_state();
294        if state != IndexState::Ready {
295            return Ok(VisibleIndexes::none());
296        }
297        debug_assert_eq!(state, IndexState::Ready);
298
299        // Phase 2: planner-visible indexes are exactly the model-owned index
300        // declarations once the recovered store is query-visible.
301        Ok(VisibleIndexes::planner_visible(model.indexes()))
302    }
303
304    /// Return one structured schema description for the entity.
305    ///
306    /// This is a typed `DESCRIBE`-style introspection surface consumed by
307    /// developer tooling and pre-EXPLAIN debugging.
308    #[must_use]
309    pub fn describe_entity<E>(&self) -> EntitySchemaDescription
310    where
311        E: EntityKind<Canister = C>,
312    {
313        self.describe_entity_model(E::MODEL)
314    }
315
316    /// Return one structured schema description for one schema model.
317    #[must_use]
318    pub fn describe_entity_model(&self, model: &'static EntityModel) -> EntitySchemaDescription {
319        describe_entity_model(model)
320    }
321
322    /// Build one point-in-time storage report for observability endpoints.
323    pub fn storage_report(
324        &self,
325        name_to_path: &[(&'static str, &'static str)],
326    ) -> Result<StorageReport, InternalError> {
327        self.db.storage_report(name_to_path)
328    }
329
330    /// Build one point-in-time storage report using default entity-path labels.
331    pub fn storage_report_default(&self) -> Result<StorageReport, InternalError> {
332        self.db.storage_report_default()
333    }
334
335    /// Build one point-in-time integrity scan report for observability endpoints.
336    pub fn integrity_report(&self) -> Result<IntegrityReport, InternalError> {
337        self.db.integrity_report()
338    }
339
340    /// Execute one bounded migration run with durable internal cursor state.
341    ///
342    /// Migration progress is persisted internally so upgrades/restarts can
343    /// resume from the last successful step without external cursor ownership.
344    pub fn execute_migration_plan(
345        &self,
346        plan: &MigrationPlan,
347        max_steps: usize,
348    ) -> Result<MigrationRunOutcome, InternalError> {
349        self.with_metrics(|| self.db.execute_migration_plan(plan, max_steps))
350    }
351
352    // ---------------------------------------------------------------------
353    // Low-level executors (crate-internal; execution primitives)
354    // ---------------------------------------------------------------------
355
356    #[must_use]
357    pub(in crate::db) const fn load_executor<E>(&self) -> LoadExecutor<E>
358    where
359        E: EntityKind<Canister = C> + EntityValue,
360    {
361        LoadExecutor::new(self.db, self.debug)
362    }
363
364    #[must_use]
365    pub(in crate::db) const fn delete_executor<E>(&self) -> DeleteExecutor<E>
366    where
367        E: PersistedRow<Canister = C> + EntityValue,
368    {
369        DeleteExecutor::new(self.db)
370    }
371
372    #[must_use]
373    pub(in crate::db) const fn save_executor<E>(&self) -> SaveExecutor<E>
374    where
375        E: PersistedRow<Canister = C> + EntityValue,
376    {
377        SaveExecutor::new(self.db, self.debug)
378    }
379}
380
381/// Remove one entity row from the authoritative data store only.
382///
383/// This hidden helper exists for stale-index test fixtures that need to keep
384/// secondary/index state intact while deleting the base row bytes.
385#[doc(hidden)]
386pub fn debug_remove_entity_row_data_only<C, E>(
387    session: &DbSession<C>,
388    key: &E::Key,
389) -> Result<bool, InternalError>
390where
391    C: CanisterKind,
392    E: PersistedRow<Canister = C> + EntityValue,
393{
394    // Phase 1: resolve the store through the recovered session boundary so
395    // the helper cannot mutate pre-recovery state.
396    let store = session.db.recovered_store(E::Store::PATH)?;
397
398    // Phase 2: remove only the raw row-store entry and compute the canonical
399    // storage key that any surviving secondary memberships still point at.
400    let data_key = DataKey::try_from_field_value(E::ENTITY_TAG, key)?;
401    let raw_key = data_key.to_raw()?;
402    let storage_key = data_key.storage_key();
403
404    // Phase 3: preserve the secondary entries but mark any surviving raw
405    // memberships as explicitly missing so stale-index fixtures can exercise
406    // impossible-state behavior without lying about row existence.
407    let removed = store.with_data_mut(|data| data.remove(&raw_key).is_some());
408    if !removed {
409        return Ok(false);
410    }
411
412    store.with_index_mut(|index| index.mark_memberships_missing_for_storage_key(storage_key))?;
413
414    Ok(true)
415}
416
417/// Mark one recovered store index with one explicit lifecycle state.
418///
419/// This hidden helper exists for test fixtures that need to force one index
420/// out of the `Ready` state while keeping all other lifecycle plumbing
421/// unchanged.
422#[doc(hidden)]
423pub fn debug_mark_store_index_state<C>(
424    session: &DbSession<C>,
425    store_path: &str,
426    state: IndexState,
427) -> Result<(), InternalError>
428where
429    C: CanisterKind,
430{
431    // Phase 1: resolve the recovered store so lifecycle mutation cannot
432    // target pre-recovery state.
433    let store = session.db.recovered_store(store_path)?;
434
435    // Phase 2: apply the explicit lifecycle state directly to the index half
436    // of the store pair so tests can observe the `Ready` gate in isolation.
437    match state {
438        IndexState::Building => store.mark_index_building(),
439        IndexState::Ready => store.mark_index_ready(),
440        IndexState::Dropping => store.mark_index_dropping(),
441    }
442
443    Ok(())
444}