Skip to main content

icydb_core/db/session/sql/dispatch/
mod.rs

1//! Module: db::session::sql::dispatch
2//! Responsibility: module-local ownership and contracts for db::session::sql::dispatch.
3//! Does not own: cross-module orchestration outside this module.
4//! Boundary: exposes this module API while keeping implementation details internal.
5
6mod computed;
7mod lowered;
8
9use crate::{
10    db::{
11        DbSession, MissingRowPolicy, PersistedRow, Query, QueryError,
12        executor::{
13            EntityAuthority, execute_sql_projection_rows_for_canister,
14            execute_sql_projection_text_rows_for_canister,
15        },
16        identifiers_tail_match,
17        query::intent::StructuralQuery,
18        session::sql::{
19            SqlDispatchResult, SqlParsedStatement, SqlStatementRoute,
20            aggregate::{
21                SqlAggregateSurface, parsed_requires_dedicated_sql_aggregate_lane,
22                unsupported_sql_aggregate_lane_message,
23            },
24            computed_projection,
25            projection::{
26                SqlProjectionPayload, projection_labels_from_entity_model,
27                projection_labels_from_projection_spec, sql_projection_rows_from_kernel_rows,
28            },
29        },
30        sql::lowering::{
31            LoweredSqlCommand, LoweredSqlQuery, bind_lowered_sql_query,
32            lower_sql_command_from_prepared_statement,
33        },
34    },
35    traits::{CanisterKind, EntityKind, EntityValue},
36};
37
38///
39/// GeneratedSqlDispatchAttempt
40///
41/// Hidden generated-query dispatch envelope used by the facade helper to keep
42/// generated route ownership in core while preserving the public EXPLAIN error
43/// rewrite contract at the outer boundary.
44///
45
46#[doc(hidden)]
47pub struct GeneratedSqlDispatchAttempt {
48    entity_name: &'static str,
49    explain_order_field: Option<&'static str>,
50    result: Result<SqlDispatchResult, QueryError>,
51}
52
53impl GeneratedSqlDispatchAttempt {
54    // Build one generated-query dispatch attempt with optional explain-hint context.
55    const fn new(
56        entity_name: &'static str,
57        explain_order_field: Option<&'static str>,
58        result: Result<SqlDispatchResult, QueryError>,
59    ) -> Self {
60        Self {
61            entity_name,
62            explain_order_field,
63            result,
64        }
65    }
66
67    /// Borrow the resolved entity name for this generated-query attempt.
68    #[must_use]
69    pub const fn entity_name(&self) -> &'static str {
70        self.entity_name
71    }
72
73    /// Borrow the suggested deterministic order field for EXPLAIN rewrites.
74    #[must_use]
75    pub const fn explain_order_field(&self) -> Option<&'static str> {
76        self.explain_order_field
77    }
78
79    /// Consume and return the generated-query dispatch result.
80    pub fn into_result(self) -> Result<SqlDispatchResult, QueryError> {
81        self.result
82    }
83}
84
85#[derive(Clone, Copy, Debug, Eq, PartialEq)]
86pub(in crate::db::session::sql) enum SqlGroupingSurface {
87    Scalar,
88    Dispatch,
89    GeneratedQuerySurface,
90    Grouped,
91}
92
93const fn unsupported_sql_grouping_message(surface: SqlGroupingSurface) -> &'static str {
94    match surface {
95        SqlGroupingSurface::Scalar => {
96            "execute_sql rejects grouped SELECT; use execute_sql_grouped(...)"
97        }
98        SqlGroupingSurface::Dispatch => {
99            "execute_sql_dispatch rejects grouped SELECT execution; use execute_sql_grouped(...)"
100        }
101        SqlGroupingSurface::GeneratedQuerySurface => {
102            "generated SQL query surface rejects grouped SELECT execution; use execute_sql_grouped(...)"
103        }
104        SqlGroupingSurface::Grouped => "execute_sql_grouped requires grouped SQL query intent",
105    }
106}
107
108// Enforce the generated canister query contract that empty SQL is unsupported
109// before any parser/lowering work occurs.
110fn trim_generated_query_sql_input(sql: &str) -> Result<&str, QueryError> {
111    let sql_trimmed = sql.trim();
112    if sql_trimmed.is_empty() {
113        return Err(QueryError::unsupported_query(
114            "query endpoint requires a non-empty SQL string",
115        ));
116    }
117
118    Ok(sql_trimmed)
119}
120
121// Render the generated-surface entity list from the descriptor table instead
122// of assuming every session-visible entity belongs on the public query export.
123fn generated_sql_entities(authorities: &[EntityAuthority]) -> Vec<String> {
124    let mut entities = Vec::with_capacity(authorities.len());
125
126    for authority in authorities {
127        entities.push(authority.model().name().to_string());
128    }
129
130    entities
131}
132
133// Resolve one generated query route onto the descriptor-owned authority table.
134fn authority_for_generated_sql_route(
135    route: &SqlStatementRoute,
136    authorities: &[EntityAuthority],
137) -> Result<EntityAuthority, QueryError> {
138    let sql_entity = route.entity();
139
140    for authority in authorities {
141        if identifiers_tail_match(sql_entity, authority.model().name()) {
142            return Ok(*authority);
143        }
144    }
145
146    Err(unsupported_generated_sql_entity_error(
147        sql_entity,
148        authorities,
149    ))
150}
151
152// Keep the generated query-surface unsupported-entity contract stable while
153// moving authority lookup out of the build-generated shim.
154fn unsupported_generated_sql_entity_error(
155    entity_name: &str,
156    authorities: &[EntityAuthority],
157) -> QueryError {
158    let mut supported = String::new();
159
160    for (index, authority) in authorities.iter().enumerate() {
161        if index != 0 {
162            supported.push_str(", ");
163        }
164
165        supported.push_str(authority.model().name());
166    }
167
168    QueryError::unsupported_query(format!(
169        "query endpoint does not support entity '{entity_name}'; supported: {supported}"
170    ))
171}
172
173impl<C: CanisterKind> DbSession<C> {
174    // Execute one structural SQL load query and return only row-oriented SQL
175    // projection values, keeping typed projection rows out of the shared SQL
176    // query-lane path.
177    fn execute_structural_sql_projection(
178        &self,
179        query: StructuralQuery,
180        authority: EntityAuthority,
181    ) -> Result<SqlProjectionPayload, QueryError> {
182        // Phase 1: build the structural access plan once and reuse its
183        // projection contract for both labels and row materialization.
184        let visible_indexes =
185            self.visible_indexes_for_store_model(authority.store_path(), authority.model())?;
186        let plan = query.build_plan_with_visible_indexes(&visible_indexes)?;
187        let projection = plan.projection_spec(authority.model());
188        let columns = projection_labels_from_projection_spec(&projection);
189
190        // Phase 2: execute the shared structural load path with the already
191        // derived projection semantics.
192        let projected = execute_sql_projection_rows_for_canister(
193            &self.db,
194            self.debug,
195            authority.model(),
196            projection,
197            authority,
198            plan,
199        )
200        .map_err(QueryError::execute)?;
201        let (rows, row_count) = projected.into_parts();
202
203        Ok(SqlProjectionPayload::new(columns, rows, row_count))
204    }
205
206    // Execute one structural SQL load query and return render-ready text rows
207    // for the dispatch lane when the terminal short path can prove them
208    // directly.
209    fn execute_structural_sql_projection_text(
210        &self,
211        query: StructuralQuery,
212        authority: EntityAuthority,
213    ) -> Result<SqlDispatchResult, QueryError> {
214        // Phase 1: build the structural access plan once and reuse its
215        // projection contract for both labels and text-row materialization.
216        let visible_indexes =
217            self.visible_indexes_for_store_model(authority.store_path(), authority.model())?;
218        let plan = query.build_plan_with_visible_indexes(&visible_indexes)?;
219        let projection = plan.projection_spec(authority.model());
220        let columns = projection_labels_from_projection_spec(&projection);
221
222        // Phase 2: execute the shared structural load path with the already
223        // derived projection semantics while preferring rendered SQL rows.
224        let projected = execute_sql_projection_text_rows_for_canister(
225            &self.db,
226            self.debug,
227            authority.model(),
228            projection,
229            authority,
230            plan,
231        )
232        .map_err(QueryError::execute)?;
233        let (rows, row_count) = projected.into_parts();
234
235        Ok(SqlDispatchResult::ProjectionText {
236            columns,
237            rows,
238            row_count,
239        })
240    }
241
242    // Execute one typed SQL delete query while keeping the row payload on the
243    // typed delete executor boundary that still owns non-runtime-hook delete
244    // commit-window application.
245    fn execute_typed_sql_delete<E>(&self, query: &Query<E>) -> Result<SqlDispatchResult, QueryError>
246    where
247        E: PersistedRow<Canister = C> + EntityValue,
248    {
249        let plan = self
250            .compile_query_with_visible_indexes(query)?
251            .into_executable();
252        let deleted = self
253            .with_metrics(|| self.delete_executor::<E>().execute_sql_projection(plan))
254            .map_err(QueryError::execute)?;
255        let (rows, row_count) = deleted.into_parts();
256        let rows = sql_projection_rows_from_kernel_rows(rows);
257
258        Ok(SqlProjectionPayload::new(
259            projection_labels_from_entity_model(E::MODEL),
260            rows,
261            row_count,
262        )
263        .into_dispatch_result())
264    }
265
266    // Validate that one SQL-derived query intent matches the grouped/scalar
267    // execution surface that is about to consume it.
268    pub(in crate::db::session::sql) fn ensure_sql_query_grouping<E>(
269        query: &Query<E>,
270        surface: SqlGroupingSurface,
271    ) -> Result<(), QueryError>
272    where
273        E: EntityKind,
274    {
275        match (surface, query.has_grouping()) {
276            (
277                SqlGroupingSurface::Scalar
278                | SqlGroupingSurface::Dispatch
279                | SqlGroupingSurface::GeneratedQuerySurface,
280                false,
281            )
282            | (SqlGroupingSurface::Grouped, true) => Ok(()),
283            (
284                SqlGroupingSurface::Scalar
285                | SqlGroupingSurface::Dispatch
286                | SqlGroupingSurface::GeneratedQuerySurface,
287                true,
288            )
289            | (SqlGroupingSurface::Grouped, false) => Err(QueryError::unsupported_query(
290                unsupported_sql_grouping_message(surface),
291            )),
292        }
293    }
294
295    // Validate one lowered shared SQL query shape against the grouped/scalar
296    // contract for surfaces that do not materialize a typed `Query<E>`.
297    pub(in crate::db::session::sql) fn ensure_lowered_sql_query_grouping(
298        lowered: &LoweredSqlCommand,
299        surface: SqlGroupingSurface,
300    ) -> Result<(), QueryError> {
301        let Some(query) = lowered.query() else {
302            return Ok(());
303        };
304
305        match (surface, query.has_grouping()) {
306            (
307                SqlGroupingSurface::Scalar
308                | SqlGroupingSurface::Dispatch
309                | SqlGroupingSurface::GeneratedQuerySurface,
310                false,
311            )
312            | (SqlGroupingSurface::Grouped, true) => Ok(()),
313            (
314                SqlGroupingSurface::Scalar
315                | SqlGroupingSurface::Dispatch
316                | SqlGroupingSurface::GeneratedQuerySurface,
317                true,
318            )
319            | (SqlGroupingSurface::Grouped, false) => Err(QueryError::unsupported_query(
320                unsupported_sql_grouping_message(surface),
321            )),
322        }
323    }
324
325    /// Execute one reduced SQL statement into one unified SQL dispatch payload.
326    pub fn execute_sql_dispatch<E>(&self, sql: &str) -> Result<SqlDispatchResult, QueryError>
327    where
328        E: PersistedRow<Canister = C> + EntityValue,
329    {
330        let parsed = self.parse_sql_statement(sql)?;
331
332        self.execute_sql_dispatch_parsed::<E>(&parsed)
333    }
334
335    /// Execute one parsed reduced SQL statement into one unified SQL payload.
336    pub fn execute_sql_dispatch_parsed<E>(
337        &self,
338        parsed: &SqlParsedStatement,
339    ) -> Result<SqlDispatchResult, QueryError>
340    where
341        E: PersistedRow<Canister = C> + EntityValue,
342    {
343        match parsed.route() {
344            SqlStatementRoute::Query { .. } => {
345                if parsed_requires_dedicated_sql_aggregate_lane(parsed) {
346                    return Err(QueryError::unsupported_query(
347                        unsupported_sql_aggregate_lane_message(
348                            SqlAggregateSurface::ExecuteSqlDispatch,
349                        ),
350                    ));
351                }
352
353                if let Some(plan) =
354                    computed_projection::computed_sql_projection_plan(&parsed.statement)?
355                {
356                    return self.execute_computed_sql_projection_dispatch::<E>(plan);
357                }
358
359                // Phase 1: keep typed dispatch on the shared lowered query lane
360                // for plain `SELECT`, and only pay typed query binding on the
361                // `DELETE` branch that still owns typed commit semantics.
362                let lowered = parsed
363                    .lower_query_lane_for_entity(E::MODEL.name(), E::MODEL.primary_key.name)?;
364
365                Self::ensure_lowered_sql_query_grouping(&lowered, SqlGroupingSurface::Dispatch)?;
366
367                // Phase 2: dispatch `SELECT` directly from the lowered shape so
368                // typed SQL projection does not rebuild and discard a typed
369                // `Query<E>` before returning to the structural executor path.
370                match lowered.query() {
371                    Some(LoweredSqlQuery::Select(select)) => self
372                        .execute_lowered_sql_dispatch_select_core(
373                            select,
374                            EntityAuthority::for_type::<E>(),
375                        ),
376                    Some(LoweredSqlQuery::Delete(delete)) => {
377                        let typed_query = bind_lowered_sql_query::<E>(
378                            LoweredSqlQuery::Delete(delete.clone()),
379                            MissingRowPolicy::Ignore,
380                        )
381                        .map_err(QueryError::from_sql_lowering_error)?;
382
383                        self.execute_typed_sql_delete(&typed_query)
384                    }
385                    None => Err(QueryError::unsupported_query(
386                        "execute_sql_dispatch accepts SELECT or DELETE only",
387                    )),
388                }
389            }
390            SqlStatementRoute::Explain { .. } => {
391                if let Some((mode, plan)) =
392                    computed_projection::computed_sql_projection_explain_plan(&parsed.statement)?
393                {
394                    return self
395                        .explain_computed_sql_projection_dispatch::<E>(mode, plan)
396                        .map(SqlDispatchResult::Explain);
397                }
398
399                let lowered = lower_sql_command_from_prepared_statement(
400                    parsed.prepare(E::MODEL.name())?,
401                    E::MODEL.primary_key.name,
402                )
403                .map_err(QueryError::from_sql_lowering_error)?;
404                if let Some(explain) = self.explain_lowered_sql_execution_for_authority(
405                    &lowered,
406                    EntityAuthority::for_type::<E>(),
407                )? {
408                    return Ok(SqlDispatchResult::Explain(explain));
409                }
410
411                self.explain_lowered_sql_for_authority(&lowered, EntityAuthority::for_type::<E>())
412                    .map(SqlDispatchResult::Explain)
413            }
414            SqlStatementRoute::Describe { .. } => {
415                Ok(SqlDispatchResult::Describe(self.describe_entity::<E>()))
416            }
417            SqlStatementRoute::ShowIndexes { .. } => {
418                Ok(SqlDispatchResult::ShowIndexes(self.show_indexes::<E>()))
419            }
420            SqlStatementRoute::ShowColumns { .. } => {
421                Ok(SqlDispatchResult::ShowColumns(self.show_columns::<E>()))
422            }
423            SqlStatementRoute::ShowEntities => {
424                Ok(SqlDispatchResult::ShowEntities(self.show_entities()))
425            }
426        }
427    }
428
429    /// Execute one parsed reduced SQL statement through the generated canister
430    /// query/explain surface for one already-resolved dynamic authority.
431    ///
432    /// This keeps the canister SQL facade on the same reduced SQL ownership
433    /// boundary as typed dispatch without forcing the outer facade to reopen
434    /// typed-generic routing just to preserve parity for computed projections.
435    #[doc(hidden)]
436    pub fn execute_generated_query_surface_dispatch_for_authority(
437        &self,
438        parsed: &SqlParsedStatement,
439        authority: EntityAuthority,
440    ) -> Result<SqlDispatchResult, QueryError> {
441        match parsed.route() {
442            SqlStatementRoute::Query { .. } => {
443                if parsed_requires_dedicated_sql_aggregate_lane(parsed) {
444                    return Err(QueryError::unsupported_query(
445                        unsupported_sql_aggregate_lane_message(
446                            SqlAggregateSurface::GeneratedQuerySurface,
447                        ),
448                    ));
449                }
450
451                if let Some(plan) =
452                    computed_projection::computed_sql_projection_plan(&parsed.statement)?
453                {
454                    return self
455                        .execute_computed_sql_projection_dispatch_for_authority(plan, authority);
456                }
457
458                let lowered = parsed.lower_query_lane_for_entity(
459                    authority.model().name(),
460                    authority.model().primary_key.name,
461                )?;
462
463                Self::ensure_lowered_sql_query_grouping(
464                    &lowered,
465                    SqlGroupingSurface::GeneratedQuerySurface,
466                )?;
467
468                self.execute_lowered_sql_dispatch_query_for_authority(&lowered, authority)
469            }
470            SqlStatementRoute::Explain { .. } => {
471                if let Some((mode, plan)) =
472                    computed_projection::computed_sql_projection_explain_plan(&parsed.statement)?
473                {
474                    return self
475                        .explain_computed_sql_projection_dispatch_for_authority(
476                            mode, plan, authority,
477                        )
478                        .map(SqlDispatchResult::Explain);
479                }
480
481                let lowered = parsed.lower_query_lane_for_entity(
482                    authority.model().name(),
483                    authority.model().primary_key.name,
484                )?;
485                if let Some(explain) =
486                    self.explain_lowered_sql_execution_for_authority(&lowered, authority)?
487                {
488                    return Ok(SqlDispatchResult::Explain(explain));
489                }
490
491                self.explain_lowered_sql_for_authority(&lowered, authority)
492                    .map(SqlDispatchResult::Explain)
493            }
494            SqlStatementRoute::Describe { .. }
495            | SqlStatementRoute::ShowIndexes { .. }
496            | SqlStatementRoute::ShowColumns { .. }
497            | SqlStatementRoute::ShowEntities => Err(QueryError::unsupported_query(
498                "generated SQL query surface requires query or EXPLAIN statement lanes",
499            )),
500        }
501    }
502
503    /// Execute one raw SQL string through the generated canister query surface.
504    ///
505    /// This hidden helper keeps parse, route, authority, and metadata/query
506    /// dispatch ownership in core so the build-generated `sql_dispatch` shim
507    /// stays close to a pure descriptor table plus public ABI wrapper.
508    #[doc(hidden)]
509    #[must_use]
510    pub fn execute_generated_query_surface_sql(
511        &self,
512        sql: &str,
513        authorities: &[EntityAuthority],
514    ) -> GeneratedSqlDispatchAttempt {
515        // Phase 1: normalize and parse once so every generated route family
516        // shares the same SQL ownership boundary.
517        let sql_trimmed = match trim_generated_query_sql_input(sql) {
518            Ok(sql_trimmed) => sql_trimmed,
519            Err(err) => return GeneratedSqlDispatchAttempt::new("", None, Err(err)),
520        };
521        let parsed = match self.parse_sql_statement(sql_trimmed) {
522            Ok(parsed) => parsed,
523            Err(err) => return GeneratedSqlDispatchAttempt::new("", None, Err(err)),
524        };
525
526        // Phase 2: keep SHOW ENTITIES descriptor-owned and resolve all other
527        // generated routes against the emitted authority table exactly once.
528        if matches!(parsed.route(), SqlStatementRoute::ShowEntities) {
529            return GeneratedSqlDispatchAttempt::new(
530                "",
531                None,
532                Ok(SqlDispatchResult::ShowEntities(generated_sql_entities(
533                    authorities,
534                ))),
535            );
536        }
537        let authority = match authority_for_generated_sql_route(parsed.route(), authorities) {
538            Ok(authority) => authority,
539            Err(err) => return GeneratedSqlDispatchAttempt::new("", None, Err(err)),
540        };
541
542        // Phase 3: dispatch the resolved route through the existing query,
543        // explain, and metadata helpers without rebuilding route ownership in
544        // the generated build output.
545        let entity_name = authority.model().name();
546        let explain_order_field = parsed
547            .route()
548            .is_explain()
549            .then_some(authority.model().primary_key.name);
550        let result = match parsed.route() {
551            SqlStatementRoute::Query { .. } | SqlStatementRoute::Explain { .. } => {
552                self.execute_generated_query_surface_dispatch_for_authority(&parsed, authority)
553            }
554            SqlStatementRoute::Describe { .. } => Ok(SqlDispatchResult::Describe(
555                self.describe_entity_model(authority.model()),
556            )),
557            SqlStatementRoute::ShowIndexes { .. } => Ok(SqlDispatchResult::ShowIndexes(
558                self.show_indexes_for_store_model(authority.store_path(), authority.model()),
559            )),
560            SqlStatementRoute::ShowColumns { .. } => Ok(SqlDispatchResult::ShowColumns(
561                self.show_columns_for_model(authority.model()),
562            )),
563            SqlStatementRoute::ShowEntities => unreachable!(
564                "SHOW ENTITIES is handled before authority resolution for generated query dispatch"
565            ),
566        };
567
568        GeneratedSqlDispatchAttempt::new(entity_name, explain_order_field, result)
569    }
570}