Skip to main content

oxirs_arq/lateral_join/
mod.rs

1//! # SPARQL 1.2 LATERAL Join Support
2//!
3//! Implements correlated subqueries (LATERAL joins) for SPARQL 1.2.
4//!
5//! A LATERAL join allows the right-hand side of a join to reference variables
6//! bound by the left-hand side, enabling correlated subqueries that were not
7//! possible in SPARQL 1.1.
8//!
9//! ## SPARQL 1.2 Syntax
10//!
11//! ```sparql
12//! SELECT ?person ?maxScore
13//! WHERE {
14//!   ?person a :Student .
15//!   LATERAL {
16//!     SELECT (MAX(?score) AS ?maxScore)
17//!     WHERE { ?person :hasExam/:score ?score }
18//!   }
19//! }
20//! ```
21//!
22//! ## Semantics
23//!
24//! For each solution mapping `mu` from the left operand, the right operand
25//! is evaluated with `mu` as the initial binding. The result is the
26//! compatible merge of `mu` with each solution from the right operand.
27//!
28//! ## References
29//!
30//! - SPARQL 1.2 Community Group Draft (Section 18.6 – Lateral Joins)
31//! - PostgreSQL LATERAL subqueries (similar concept in SQL)
32
33use serde::{Deserialize, Serialize};
34use std::collections::{HashMap, HashSet};
35use std::fmt;
36use std::time::{Duration, Instant};
37
38// ---------------------------------------------------------------------------
39// Core types
40// ---------------------------------------------------------------------------
41
42/// A single variable binding in a solution mapping.
43#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
44pub enum LateralValue {
45    /// An IRI reference
46    Iri(String),
47    /// A plain or typed literal
48    Literal {
49        /// The lexical value
50        value: String,
51        /// Optional datatype IRI
52        datatype: Option<String>,
53        /// Optional language tag
54        lang: Option<String>,
55    },
56    /// A blank node
57    BlankNode(String),
58}
59
60impl fmt::Display for LateralValue {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        match self {
63            Self::Iri(iri) => write!(f, "<{iri}>"),
64            Self::Literal {
65                value,
66                datatype,
67                lang,
68            } => {
69                write!(f, "\"{value}\"")?;
70                if let Some(dt) = datatype {
71                    write!(f, "^^<{dt}>")?;
72                }
73                if let Some(l) = lang {
74                    write!(f, "@{l}")?;
75                }
76                Ok(())
77            }
78            Self::BlankNode(id) => write!(f, "_:{id}"),
79        }
80    }
81}
82
83/// A solution mapping: a set of (variable -> value) bindings.
84pub type SolutionMapping = HashMap<String, LateralValue>;
85
86/// A LATERAL subquery that may reference variables from the outer scope.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct LateralSubquery {
89    /// Human-readable description (or the original SPARQL fragment).
90    pub description: String,
91    /// Variables from the outer scope that this subquery references.
92    pub correlated_vars: Vec<String>,
93    /// Variables produced by this subquery.
94    pub projected_vars: Vec<String>,
95    /// Whether this subquery contains aggregates.
96    pub has_aggregates: bool,
97    /// Optional LIMIT on the subquery.
98    pub limit: Option<usize>,
99    /// Optional ORDER BY direction for the subquery.
100    pub order_by: Vec<OrderSpec>,
101}
102
103/// Sort specification for subquery ORDER BY.
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct OrderSpec {
106    /// Variable to sort on.
107    pub variable: String,
108    /// Ascending (true) or descending (false).
109    pub ascending: bool,
110}
111
112// ---------------------------------------------------------------------------
113// Lateral join algebra node
114// ---------------------------------------------------------------------------
115
116/// Represents a LATERAL join in the query algebra.
117///
118/// The left operand produces solution mappings; for each such mapping, the
119/// right operand (a [`LateralSubquery`]) is evaluated with the left's
120/// bindings injected.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct LateralJoin {
123    /// Description of the left operand pattern.
124    pub left_description: String,
125    /// The correlated subquery on the right.
126    pub subquery: LateralSubquery,
127    /// Execution strategy chosen by the optimizer.
128    pub strategy: LateralStrategy,
129    /// Optional correlation filter to push down.
130    pub pushed_filters: Vec<String>,
131}
132
133/// Execution strategy for a lateral join.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
135pub enum LateralStrategy {
136    /// Simple nested-loop: for each left row, evaluate the subquery.
137    NestedLoop,
138    /// Batch multiple left rows and evaluate the subquery once per batch,
139    /// using a VALUES clause to inject the batch.
140    BatchedValues,
141    /// Decorrelate the subquery into a regular join + GROUP BY (when possible).
142    Decorrelate,
143    /// Cache subquery results keyed on the correlated variable values.
144    CachedCorrelation,
145}
146
147impl fmt::Display for LateralStrategy {
148    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149        match self {
150            Self::NestedLoop => write!(f, "NestedLoop"),
151            Self::BatchedValues => write!(f, "BatchedValues"),
152            Self::Decorrelate => write!(f, "Decorrelate"),
153            Self::CachedCorrelation => write!(f, "CachedCorrelation"),
154        }
155    }
156}
157
158// ---------------------------------------------------------------------------
159// LateralJoinExecutor — the main engine
160// ---------------------------------------------------------------------------
161
162/// Configuration for the lateral join executor.
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct LateralJoinConfig {
165    /// Maximum rows to batch in `BatchedValues` strategy.
166    pub batch_size: usize,
167    /// Maximum cache entries for `CachedCorrelation` strategy.
168    pub cache_capacity: usize,
169    /// Timeout for each subquery evaluation.
170    pub subquery_timeout: Duration,
171    /// Whether to attempt decorrelation automatically.
172    pub auto_decorrelate: bool,
173    /// Maximum nesting depth for LATERAL inside LATERAL.
174    pub max_nesting_depth: usize,
175}
176
177impl Default for LateralJoinConfig {
178    fn default() -> Self {
179        Self {
180            batch_size: 128,
181            cache_capacity: 4096,
182            subquery_timeout: Duration::from_secs(30),
183            auto_decorrelate: true,
184            max_nesting_depth: 4,
185        }
186    }
187}
188
189/// Statistics collected during lateral join execution.
190#[derive(Debug, Clone, Default, Serialize, Deserialize)]
191pub struct LateralJoinStats {
192    /// Total rows from the left operand.
193    pub left_rows: u64,
194    /// Total result rows produced.
195    pub result_rows: u64,
196    /// Number of subquery evaluations performed.
197    pub subquery_evaluations: u64,
198    /// Number of cache hits (for `CachedCorrelation`).
199    pub cache_hits: u64,
200    /// Number of cache misses.
201    pub cache_misses: u64,
202    /// Number of batches submitted (for `BatchedValues`).
203    pub batches_submitted: u64,
204    /// Total time spent in subquery evaluation.
205    pub subquery_time_ms: u64,
206    /// Whether decorrelation was applied.
207    pub decorrelated: bool,
208    /// Rows eliminated by pushed-down filters.
209    pub rows_filtered: u64,
210}
211
212impl LateralJoinStats {
213    /// Cache hit ratio as a percentage (0.0–100.0).
214    pub fn cache_hit_ratio(&self) -> f64 {
215        let total = self.cache_hits + self.cache_misses;
216        if total == 0 {
217            return 0.0;
218        }
219        (self.cache_hits as f64 / total as f64) * 100.0
220    }
221
222    /// Average subquery evaluation time in milliseconds.
223    pub fn avg_subquery_time_ms(&self) -> f64 {
224        if self.subquery_evaluations == 0 {
225            return 0.0;
226        }
227        self.subquery_time_ms as f64 / self.subquery_evaluations as f64
228    }
229}
230
231/// Executes LATERAL joins using the configured strategy.
232pub struct LateralJoinExecutor {
233    config: LateralJoinConfig,
234    stats: LateralJoinStats,
235    /// Per-correlation-key cache: key = stringified correlated var values.
236    cache: HashMap<String, Vec<SolutionMapping>>,
237}
238
239impl LateralJoinExecutor {
240    /// Create a new executor with the given configuration.
241    pub fn new(config: LateralJoinConfig) -> Self {
242        Self {
243            config,
244            stats: LateralJoinStats::default(),
245            cache: HashMap::new(),
246        }
247    }
248
249    /// Create with default configuration.
250    pub fn with_defaults() -> Self {
251        Self::new(LateralJoinConfig::default())
252    }
253
254    /// Get accumulated statistics.
255    pub fn stats(&self) -> &LateralJoinStats {
256        &self.stats
257    }
258
259    /// Reset statistics and cache.
260    pub fn reset(&mut self) {
261        self.stats = LateralJoinStats::default();
262        self.cache.clear();
263    }
264
265    /// Execute a LATERAL join.
266    ///
267    /// For each row in `left_rows`, the `subquery_evaluator` closure is
268    /// called with the correlated bindings extracted from the left row.
269    /// The closure should return the subquery results for those bindings.
270    pub fn execute<F>(
271        &mut self,
272        lateral: &LateralJoin,
273        left_rows: &[SolutionMapping],
274        subquery_evaluator: F,
275    ) -> Result<Vec<SolutionMapping>, LateralJoinError>
276    where
277        F: Fn(&SolutionMapping) -> Result<Vec<SolutionMapping>, LateralJoinError>,
278    {
279        self.stats.left_rows = left_rows.len() as u64;
280
281        match lateral.strategy {
282            LateralStrategy::NestedLoop => {
283                self.execute_nested_loop(lateral, left_rows, subquery_evaluator)
284            }
285            LateralStrategy::BatchedValues => {
286                self.execute_batched(lateral, left_rows, subquery_evaluator)
287            }
288            LateralStrategy::CachedCorrelation => {
289                self.execute_cached(lateral, left_rows, subquery_evaluator)
290            }
291            LateralStrategy::Decorrelate => {
292                // Decorrelation transforms the query plan; here we fall back to
293                // cached correlation as the runtime strategy after plan rewrite.
294                self.execute_cached(lateral, left_rows, subquery_evaluator)
295            }
296        }
297    }
298
299    // ── Nested-loop execution ─────────────────────────────────────────────
300
301    fn execute_nested_loop<F>(
302        &mut self,
303        lateral: &LateralJoin,
304        left_rows: &[SolutionMapping],
305        evaluator: F,
306    ) -> Result<Vec<SolutionMapping>, LateralJoinError>
307    where
308        F: Fn(&SolutionMapping) -> Result<Vec<SolutionMapping>, LateralJoinError>,
309    {
310        let mut results = Vec::new();
311
312        for left_row in left_rows {
313            // Extract correlated bindings
314            let correlated =
315                Self::extract_correlated_bindings(left_row, &lateral.subquery.correlated_vars);
316
317            // Apply pushed-down filters
318            if !self.passes_pushed_filters(left_row, &lateral.pushed_filters) {
319                self.stats.rows_filtered += 1;
320                continue;
321            }
322
323            let start = Instant::now();
324            let sub_results = evaluator(&correlated)?;
325            self.stats.subquery_time_ms += start.elapsed().as_millis() as u64;
326            self.stats.subquery_evaluations += 1;
327
328            // Merge left row with each subquery result
329            for sub_row in &sub_results {
330                let merged = Self::merge_mappings(left_row, sub_row)?;
331                results.push(merged);
332            }
333        }
334
335        self.stats.result_rows = results.len() as u64;
336        Ok(results)
337    }
338
339    // ── Batched VALUES execution ──────────────────────────────────────────
340
341    fn execute_batched<F>(
342        &mut self,
343        lateral: &LateralJoin,
344        left_rows: &[SolutionMapping],
345        evaluator: F,
346    ) -> Result<Vec<SolutionMapping>, LateralJoinError>
347    where
348        F: Fn(&SolutionMapping) -> Result<Vec<SolutionMapping>, LateralJoinError>,
349    {
350        let mut results = Vec::new();
351        let batch_size = self.config.batch_size.max(1);
352
353        for chunk in left_rows.chunks(batch_size) {
354            self.stats.batches_submitted += 1;
355
356            // Build a combined bindings map for the batch
357            let batch_bindings =
358                Self::build_batch_bindings(chunk, &lateral.subquery.correlated_vars);
359
360            let start = Instant::now();
361            let batch_results = evaluator(&batch_bindings)?;
362            self.stats.subquery_time_ms += start.elapsed().as_millis() as u64;
363            self.stats.subquery_evaluations += 1;
364
365            // For batched evaluation, we need to correlate results back to
366            // their originating left rows. We do this by matching on the
367            // correlated variable values.
368            for left_row in chunk {
369                if !self.passes_pushed_filters(left_row, &lateral.pushed_filters) {
370                    self.stats.rows_filtered += 1;
371                    continue;
372                }
373
374                for sub_row in &batch_results {
375                    if Self::is_compatible(left_row, sub_row, &lateral.subquery.correlated_vars) {
376                        let merged = Self::merge_mappings(left_row, sub_row)?;
377                        results.push(merged);
378                    }
379                }
380            }
381        }
382
383        self.stats.result_rows = results.len() as u64;
384        Ok(results)
385    }
386
387    // ── Cached correlation execution ──────────────────────────────────────
388
389    fn execute_cached<F>(
390        &mut self,
391        lateral: &LateralJoin,
392        left_rows: &[SolutionMapping],
393        evaluator: F,
394    ) -> Result<Vec<SolutionMapping>, LateralJoinError>
395    where
396        F: Fn(&SolutionMapping) -> Result<Vec<SolutionMapping>, LateralJoinError>,
397    {
398        let mut results = Vec::new();
399
400        for left_row in left_rows {
401            if !self.passes_pushed_filters(left_row, &lateral.pushed_filters) {
402                self.stats.rows_filtered += 1;
403                continue;
404            }
405
406            let correlated =
407                Self::extract_correlated_bindings(left_row, &lateral.subquery.correlated_vars);
408            let cache_key = Self::cache_key(&correlated, &lateral.subquery.correlated_vars);
409
410            let sub_results = if let Some(cached) = self.cache.get(&cache_key) {
411                self.stats.cache_hits += 1;
412                cached.clone()
413            } else {
414                self.stats.cache_misses += 1;
415
416                let start = Instant::now();
417                let fresh = evaluator(&correlated)?;
418                self.stats.subquery_time_ms += start.elapsed().as_millis() as u64;
419                self.stats.subquery_evaluations += 1;
420
421                // Evict oldest if at capacity
422                if self.cache.len() >= self.config.cache_capacity {
423                    if let Some(first_key) = self.cache.keys().next().cloned() {
424                        self.cache.remove(&first_key);
425                    }
426                }
427                self.cache.insert(cache_key, fresh.clone());
428                fresh
429            };
430
431            for sub_row in &sub_results {
432                let merged = Self::merge_mappings(left_row, sub_row)?;
433                results.push(merged);
434            }
435        }
436
437        self.stats.result_rows = results.len() as u64;
438        Ok(results)
439    }
440
441    // ── Helper methods ────────────────────────────────────────────────────
442
443    /// Extract only the correlated variable bindings from a solution mapping.
444    fn extract_correlated_bindings(
445        row: &SolutionMapping,
446        correlated_vars: &[String],
447    ) -> SolutionMapping {
448        let mut bindings = SolutionMapping::new();
449        for var in correlated_vars {
450            if let Some(val) = row.get(var) {
451                bindings.insert(var.clone(), val.clone());
452            }
453        }
454        bindings
455    }
456
457    /// Build a combined bindings map representing a batch of left rows.
458    /// This creates a single mapping containing the union of all correlated
459    /// variable values as a comma-separated list for batch evaluation.
460    fn build_batch_bindings(
461        rows: &[SolutionMapping],
462        correlated_vars: &[String],
463    ) -> SolutionMapping {
464        let mut combined = SolutionMapping::new();
465        // For batch evaluation, include all unique values for each correlated var.
466        // The evaluator is expected to use VALUES-style binding injection.
467        for var in correlated_vars {
468            // Collect all unique values for this variable across the batch
469            let mut seen = HashSet::new();
470            for row in rows {
471                if let Some(val) = row.get(var) {
472                    let key = format!("{val}");
473                    if seen.insert(key) {
474                        // Use the first occurrence as the representative
475                        combined.entry(var.clone()).or_insert_with(|| val.clone());
476                    }
477                }
478            }
479        }
480        combined
481    }
482
483    /// Check if a subquery result row is compatible with a left row
484    /// on the correlated variables (i.e., they have the same values).
485    fn is_compatible(
486        left: &SolutionMapping,
487        right: &SolutionMapping,
488        correlated_vars: &[String],
489    ) -> bool {
490        for var in correlated_vars {
491            match (left.get(var), right.get(var)) {
492                (Some(l), Some(r)) => {
493                    if l != r {
494                        return false;
495                    }
496                }
497                (None, Some(_)) | (Some(_), None) => {
498                    // One side has an unbound variable — still compatible
499                    // per SPARQL semantics (unbound is compatible with anything).
500                }
501                (None, None) => {}
502            }
503        }
504        true
505    }
506
507    /// Merge two solution mappings. Variables in `right` overwrite `left`
508    /// only if they are not already present.
509    fn merge_mappings(
510        left: &SolutionMapping,
511        right: &SolutionMapping,
512    ) -> Result<SolutionMapping, LateralJoinError> {
513        let mut merged = left.clone();
514        for (var, val) in right {
515            // LATERAL semantics: the right side can introduce new bindings
516            // and override correlated variables.
517            merged.insert(var.clone(), val.clone());
518        }
519        Ok(merged)
520    }
521
522    /// Produce a cache key from the correlated variable bindings.
523    fn cache_key(correlated: &SolutionMapping, vars: &[String]) -> String {
524        let mut parts = Vec::with_capacity(vars.len());
525        for var in vars {
526            match correlated.get(var) {
527                Some(val) => parts.push(format!("{var}={val}")),
528                None => parts.push(format!("{var}=UNDEF")),
529            }
530        }
531        parts.join("|")
532    }
533
534    /// Evaluate pushed-down filter expressions against a row.
535    /// For now this supports simple "?var = <value>" equality filters.
536    fn passes_pushed_filters(&self, row: &SolutionMapping, filters: &[String]) -> bool {
537        for filter in filters {
538            if let Some((var, expected)) = Self::parse_equality_filter(filter) {
539                if let Some(actual) = row.get(&var) {
540                    let actual_str = format!("{actual}");
541                    if actual_str != expected {
542                        return false;
543                    }
544                }
545            }
546        }
547        true
548    }
549
550    /// Parse a simple equality filter of the form `?var = "value"` or `?var = <iri>`.
551    ///
552    /// The returned expected value is kept in its Display-compatible form so
553    /// that it can be directly compared against `format!("{actual}")` which
554    /// uses the `LateralValue::Display` impl (IRIs are wrapped in `<>`).
555    fn parse_equality_filter(filter: &str) -> Option<(String, String)> {
556        let parts: Vec<&str> = filter.splitn(3, ' ').collect();
557        if parts.len() == 3 && parts[1] == "=" {
558            let var = parts[0].trim_start_matches('?').to_string();
559            // Keep the value as-is so it matches Display output.
560            // For IRIs: "<http://...>" matches LateralValue::Iri Display.
561            // For literals: "\"value\"" matches LateralValue::Literal Display.
562            let val = parts[2].to_string();
563            Some((var, val))
564        } else {
565            None
566        }
567    }
568}
569
570// ---------------------------------------------------------------------------
571// Optimizer — decides the best lateral strategy
572// ---------------------------------------------------------------------------
573
574/// Optimizer that selects the best execution strategy for a LATERAL join.
575#[derive(Default)]
576pub struct LateralOptimizer {
577    /// Configuration thresholds.
578    config: LateralOptimizerConfig,
579}
580
581/// Configuration for the lateral optimizer.
582#[derive(Debug, Clone, Serialize, Deserialize)]
583pub struct LateralOptimizerConfig {
584    /// If the number of distinct correlated key values is below this
585    /// threshold, use caching.
586    pub cache_threshold: usize,
587    /// If the left cardinality exceeds this, use batched evaluation.
588    pub batch_threshold: usize,
589    /// Minimum selectivity improvement to attempt decorrelation.
590    pub decorrelate_min_improvement: f64,
591}
592
593impl Default for LateralOptimizerConfig {
594    fn default() -> Self {
595        Self {
596            cache_threshold: 1000,
597            batch_threshold: 500,
598            decorrelate_min_improvement: 0.3,
599        }
600    }
601}
602
603/// Cost estimate for a particular lateral strategy.
604#[derive(Debug, Clone, Serialize, Deserialize)]
605pub struct LateralCostEstimate {
606    /// The strategy evaluated.
607    pub strategy: LateralStrategy,
608    /// Estimated total cost (abstract units).
609    pub estimated_cost: f64,
610    /// Estimated number of subquery evaluations.
611    pub estimated_evaluations: u64,
612    /// Whether this strategy can use a cache effectively.
613    pub cacheable: bool,
614    /// Whether decorrelation is possible.
615    pub decorrelatable: bool,
616}
617
618impl LateralOptimizer {
619    /// Create with default thresholds.
620    pub fn new() -> Self {
621        Self::default()
622    }
623
624    /// Create with custom configuration.
625    pub fn with_config(config: LateralOptimizerConfig) -> Self {
626        Self { config }
627    }
628
629    /// Choose the best strategy for the given lateral join parameters.
630    pub fn choose_strategy(
631        &self,
632        left_cardinality: u64,
633        distinct_keys: u64,
634        subquery: &LateralSubquery,
635    ) -> LateralCostEstimate {
636        let mut candidates = Vec::new();
637
638        // Nested loop: always possible, cost = left_cardinality * subquery_cost
639        let nl_cost = left_cardinality as f64 * self.estimate_subquery_cost(subquery);
640        candidates.push(LateralCostEstimate {
641            strategy: LateralStrategy::NestedLoop,
642            estimated_cost: nl_cost,
643            estimated_evaluations: left_cardinality,
644            cacheable: false,
645            decorrelatable: false,
646        });
647
648        // Cached: cost = distinct_keys * subquery_cost + (left_cardinality - distinct_keys) * lookup_cost
649        let cache_cost = distinct_keys as f64 * self.estimate_subquery_cost(subquery)
650            + (left_cardinality.saturating_sub(distinct_keys)) as f64 * 0.01;
651        candidates.push(LateralCostEstimate {
652            strategy: LateralStrategy::CachedCorrelation,
653            estimated_cost: cache_cost,
654            estimated_evaluations: distinct_keys,
655            cacheable: distinct_keys < self.config.cache_threshold as u64,
656            decorrelatable: false,
657        });
658
659        // Batched: cost = ceil(left_cardinality / batch_size) * subquery_cost * overhead
660        // Use batch_threshold as a reasonable batch size estimate; each batch
661        // evaluation still has per-row cost for correlating results back.
662        let batch_size = self.config.batch_threshold.max(1) as f64;
663        let batch_evals = (left_cardinality as f64 / batch_size).ceil();
664        // Batch coordination overhead is significant: each row in the batch
665        // must be correlated back, plus the subquery itself is heavier when
666        // processing a batch.  Use a per-row factor plus the batch eval cost.
667        let per_row_correlation_cost = left_cardinality as f64 * 0.5;
668        let batch_cost =
669            batch_evals * self.estimate_subquery_cost(subquery) + per_row_correlation_cost;
670        candidates.push(LateralCostEstimate {
671            strategy: LateralStrategy::BatchedValues,
672            estimated_cost: batch_cost,
673            estimated_evaluations: batch_evals as u64,
674            cacheable: false,
675            decorrelatable: false,
676        });
677
678        // Decorrelate: only if subquery has aggregates and a single correlated var
679        if self.can_decorrelate(subquery) {
680            let decorrelate_cost = left_cardinality as f64 * 0.5; // rough: join is cheaper than correlated eval
681            candidates.push(LateralCostEstimate {
682                strategy: LateralStrategy::Decorrelate,
683                estimated_cost: decorrelate_cost,
684                estimated_evaluations: 1,
685                cacheable: false,
686                decorrelatable: true,
687            });
688        }
689
690        // Pick the cheapest
691        candidates.sort_by(|a, b| {
692            a.estimated_cost
693                .partial_cmp(&b.estimated_cost)
694                .unwrap_or(std::cmp::Ordering::Equal)
695        });
696
697        candidates
698            .into_iter()
699            .next()
700            .expect("at least one candidate strategy")
701    }
702
703    /// Estimate the cost of evaluating the subquery once.
704    fn estimate_subquery_cost(&self, subquery: &LateralSubquery) -> f64 {
705        let mut cost = 1.0;
706        if subquery.has_aggregates {
707            cost *= 2.0;
708        }
709        if let Some(limit) = subquery.limit {
710            cost *= (limit as f64).min(100.0) / 100.0;
711        }
712        if !subquery.order_by.is_empty() {
713            cost *= 1.5;
714        }
715        cost
716    }
717
718    /// Check whether a subquery can be decorrelated into a regular join.
719    ///
720    /// Decorrelation is possible when:
721    /// 1. The subquery has exactly one correlated variable
722    /// 2. The subquery uses aggregation
723    /// 3. The correlated variable is used in a simple equality pattern
724    fn can_decorrelate(&self, subquery: &LateralSubquery) -> bool {
725        subquery.correlated_vars.len() == 1 && subquery.has_aggregates
726    }
727
728    /// Analyze a lateral join and produce a detailed cost comparison.
729    pub fn analyze(
730        &self,
731        left_cardinality: u64,
732        distinct_keys: u64,
733        subquery: &LateralSubquery,
734    ) -> Vec<LateralCostEstimate> {
735        let mut estimates = vec![
736            LateralCostEstimate {
737                strategy: LateralStrategy::NestedLoop,
738                estimated_cost: left_cardinality as f64 * self.estimate_subquery_cost(subquery),
739                estimated_evaluations: left_cardinality,
740                cacheable: false,
741                decorrelatable: false,
742            },
743            LateralCostEstimate {
744                strategy: LateralStrategy::CachedCorrelation,
745                estimated_cost: distinct_keys as f64 * self.estimate_subquery_cost(subquery)
746                    + (left_cardinality.saturating_sub(distinct_keys)) as f64 * 0.01,
747                estimated_evaluations: distinct_keys,
748                cacheable: distinct_keys < self.config.cache_threshold as u64,
749                decorrelatable: false,
750            },
751            {
752                let batch_size = self.config.batch_threshold.max(1) as f64;
753                let batch_evals = (left_cardinality as f64 / batch_size).ceil();
754                let per_row_correlation_cost = left_cardinality as f64 * 0.5;
755                LateralCostEstimate {
756                    strategy: LateralStrategy::BatchedValues,
757                    estimated_cost: batch_evals * self.estimate_subquery_cost(subquery)
758                        + per_row_correlation_cost,
759                    estimated_evaluations: batch_evals as u64,
760                    cacheable: false,
761                    decorrelatable: false,
762                }
763            },
764        ];
765
766        if self.can_decorrelate(subquery) {
767            estimates.push(LateralCostEstimate {
768                strategy: LateralStrategy::Decorrelate,
769                estimated_cost: left_cardinality as f64 * 0.5,
770                estimated_evaluations: 1,
771                cacheable: false,
772                decorrelatable: true,
773            });
774        }
775
776        estimates.sort_by(|a, b| {
777            a.estimated_cost
778                .partial_cmp(&b.estimated_cost)
779                .unwrap_or(std::cmp::Ordering::Equal)
780        });
781        estimates
782    }
783}
784
785// ---------------------------------------------------------------------------
786// Lateral join validation
787// ---------------------------------------------------------------------------
788
789/// Validates LATERAL join constructs for correctness.
790pub struct LateralValidator;
791
792/// Result of validating a LATERAL join.
793#[derive(Debug, Clone, Serialize, Deserialize)]
794pub struct LateralValidationResult {
795    /// Whether the LATERAL join is valid.
796    pub is_valid: bool,
797    /// Validation errors found.
798    pub errors: Vec<LateralValidationError>,
799    /// Warnings (valid but potentially problematic).
800    pub warnings: Vec<String>,
801    /// Detected correlated variables.
802    pub detected_correlated_vars: Vec<String>,
803    /// Variables visible after the LATERAL join.
804    pub output_vars: Vec<String>,
805}
806
807/// A validation error for a LATERAL join.
808#[derive(Debug, Clone, Serialize, Deserialize)]
809pub struct LateralValidationError {
810    /// Error message.
811    pub message: String,
812    /// Error code.
813    pub code: LateralErrorCode,
814}
815
816/// Error codes for lateral validation.
817#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
818pub enum LateralErrorCode {
819    /// No correlated variables found (LATERAL is unnecessary).
820    NoCorrelation,
821    /// A correlated variable is not bound by the left operand.
822    UnboundCorrelatedVar,
823    /// Nesting depth exceeds the configured maximum.
824    ExcessiveNesting,
825    /// The subquery projects a variable that conflicts with the left operand.
826    VariableConflict,
827    /// The subquery uses a disallowed construct (e.g., SERVICE in LATERAL).
828    DisallowedConstruct,
829}
830
831impl LateralValidator {
832    /// Validate a LATERAL join construct.
833    pub fn validate(
834        subquery: &LateralSubquery,
835        left_vars: &[String],
836        nesting_depth: usize,
837        max_depth: usize,
838    ) -> LateralValidationResult {
839        let mut result = LateralValidationResult {
840            is_valid: true,
841            errors: Vec::new(),
842            warnings: Vec::new(),
843            detected_correlated_vars: Vec::new(),
844            output_vars: Vec::new(),
845        };
846
847        // Check nesting depth
848        if nesting_depth > max_depth {
849            result.is_valid = false;
850            result.errors.push(LateralValidationError {
851                message: format!(
852                    "LATERAL nesting depth {nesting_depth} exceeds maximum {max_depth}"
853                ),
854                code: LateralErrorCode::ExcessiveNesting,
855            });
856        }
857
858        let left_set: HashSet<&str> = left_vars.iter().map(|s| s.as_str()).collect();
859
860        // Check that correlated vars are bound by left operand
861        for var in &subquery.correlated_vars {
862            if left_set.contains(var.as_str()) {
863                result.detected_correlated_vars.push(var.clone());
864            } else {
865                result.is_valid = false;
866                result.errors.push(LateralValidationError {
867                    message: format!("Correlated variable ?{var} is not bound by the left operand"),
868                    code: LateralErrorCode::UnboundCorrelatedVar,
869                });
870            }
871        }
872
873        // Warn if no correlation detected
874        if subquery.correlated_vars.is_empty() {
875            result.warnings.push(
876                "LATERAL subquery has no correlated variables; consider using a regular join"
877                    .to_string(),
878            );
879        }
880
881        // Check for variable conflicts
882        for proj_var in &subquery.projected_vars {
883            if left_set.contains(proj_var.as_str()) && !subquery.correlated_vars.contains(proj_var)
884            {
885                result.errors.push(LateralValidationError {
886                    message: format!(
887                        "Projected variable ?{proj_var} conflicts with left operand binding"
888                    ),
889                    code: LateralErrorCode::VariableConflict,
890                });
891                // This is a warning, not an error — LATERAL can override
892                result.warnings.push(format!(
893                    "Variable ?{proj_var} will be overridden by LATERAL subquery"
894                ));
895            }
896        }
897
898        // Compute output variables
899        let mut output = HashSet::new();
900        for var in left_vars {
901            output.insert(var.clone());
902        }
903        for var in &subquery.projected_vars {
904            output.insert(var.clone());
905        }
906        result.output_vars = output.into_iter().collect();
907        result.output_vars.sort();
908
909        result
910    }
911}
912
913// ---------------------------------------------------------------------------
914// Error type
915// ---------------------------------------------------------------------------
916
917/// Errors from lateral join execution.
918#[derive(Debug, Clone, Serialize, Deserialize)]
919pub enum LateralJoinError {
920    /// The subquery evaluation returned an error.
921    SubqueryError(String),
922    /// A timeout occurred during subquery evaluation.
923    Timeout {
924        /// Which subquery timed out.
925        description: String,
926        /// How long was waited.
927        elapsed_ms: u64,
928    },
929    /// Incompatible variable bindings during merge.
930    IncompatibleBindings {
931        /// The variable that had conflicting values.
932        variable: String,
933        /// The left value.
934        left_value: String,
935        /// The right value.
936        right_value: String,
937    },
938    /// Nesting depth exceeded.
939    NestingDepthExceeded {
940        /// Current depth.
941        depth: usize,
942        /// Maximum allowed.
943        max: usize,
944    },
945}
946
947impl fmt::Display for LateralJoinError {
948    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
949        match self {
950            Self::SubqueryError(msg) => write!(f, "Lateral subquery error: {msg}"),
951            Self::Timeout {
952                description,
953                elapsed_ms,
954            } => {
955                write!(
956                    f,
957                    "Lateral subquery timed out after {elapsed_ms}ms: {description}"
958                )
959            }
960            Self::IncompatibleBindings {
961                variable,
962                left_value,
963                right_value,
964            } => {
965                write!(
966                    f,
967                    "Incompatible bindings for ?{variable}: left={left_value}, right={right_value}"
968                )
969            }
970            Self::NestingDepthExceeded { depth, max } => {
971                write!(
972                    f,
973                    "LATERAL nesting depth {depth} exceeds maximum allowed {max}"
974                )
975            }
976        }
977    }
978}
979
980impl std::error::Error for LateralJoinError {}
981
982// ---------------------------------------------------------------------------
983// SPARQL 1.2 LATERAL parser helpers
984// ---------------------------------------------------------------------------
985
986/// Parses and validates LATERAL join patterns from SPARQL text fragments.
987pub struct LateralParser;
988
989/// A parsed LATERAL clause from a SPARQL query.
990#[derive(Debug, Clone, Serialize, Deserialize)]
991pub struct ParsedLateral {
992    /// The outer variables available to the LATERAL clause.
993    pub outer_vars: Vec<String>,
994    /// The detected correlated variables.
995    pub correlated_vars: Vec<String>,
996    /// The projected variables from the subquery.
997    pub projected_vars: Vec<String>,
998    /// Whether the subquery contains aggregates.
999    pub has_aggregates: bool,
1000    /// Whether the subquery contains ORDER BY.
1001    pub has_order_by: bool,
1002    /// Whether the subquery contains LIMIT.
1003    pub has_limit: bool,
1004    /// The raw subquery text (between LATERAL { ... }).
1005    pub subquery_text: String,
1006}
1007
1008impl LateralParser {
1009    /// Detect LATERAL clauses in a SPARQL query string.
1010    ///
1011    /// Returns positions and basic metadata for each LATERAL clause found.
1012    pub fn detect_lateral_clauses(query: &str) -> Vec<LateralClausePosition> {
1013        let mut positions = Vec::new();
1014        let upper = query.to_uppercase();
1015        let mut search_from = 0;
1016
1017        while let Some(idx) = upper[search_from..].find("LATERAL") {
1018            let abs_idx = search_from + idx;
1019            // Verify it's a keyword (not part of another identifier)
1020            let before_ok = abs_idx == 0 || !query.as_bytes()[abs_idx - 1].is_ascii_alphanumeric();
1021            let after_idx = abs_idx + 7;
1022            let after_ok =
1023                after_idx >= query.len() || !query.as_bytes()[after_idx].is_ascii_alphanumeric();
1024
1025            if before_ok && after_ok {
1026                // Find the matching brace
1027                if let Some(brace_start) = query[after_idx..].find('{') {
1028                    let open = after_idx + brace_start;
1029                    if let Some(close) = Self::find_matching_brace(query, open) {
1030                        let body = &query[open + 1..close];
1031                        positions.push(LateralClausePosition {
1032                            start: abs_idx,
1033                            end: close + 1,
1034                            body: body.trim().to_string(),
1035                            has_select: body.to_uppercase().contains("SELECT"),
1036                        });
1037                    }
1038                }
1039            }
1040            search_from = abs_idx + 7;
1041        }
1042
1043        positions
1044    }
1045
1046    /// Find the matching closing brace for an opening brace at `pos`.
1047    fn find_matching_brace(s: &str, pos: usize) -> Option<usize> {
1048        let bytes = s.as_bytes();
1049        if pos >= bytes.len() || bytes[pos] != b'{' {
1050            return None;
1051        }
1052        let mut depth = 0i32;
1053        for (i, &b) in bytes[pos..].iter().enumerate() {
1054            match b {
1055                b'{' => depth += 1,
1056                b'}' => {
1057                    depth -= 1;
1058                    if depth == 0 {
1059                        return Some(pos + i);
1060                    }
1061                }
1062                _ => {}
1063            }
1064        }
1065        None
1066    }
1067
1068    /// Extract variable references (?varName) from a SPARQL fragment.
1069    pub fn extract_variables(fragment: &str) -> Vec<String> {
1070        let mut vars = HashSet::new();
1071        let bytes = fragment.as_bytes();
1072        let mut i = 0;
1073        while i < bytes.len() {
1074            if bytes[i] == b'?' || bytes[i] == b'$' {
1075                let start = i + 1;
1076                i += 1;
1077                while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
1078                    i += 1;
1079                }
1080                if i > start {
1081                    let var = String::from_utf8_lossy(&bytes[start..i]).to_string();
1082                    vars.insert(var);
1083                }
1084            } else {
1085                i += 1;
1086            }
1087        }
1088        let mut result: Vec<_> = vars.into_iter().collect();
1089        result.sort();
1090        result
1091    }
1092
1093    /// Detect aggregate functions in a SPARQL fragment.
1094    pub fn detect_aggregates(fragment: &str) -> bool {
1095        let upper = fragment.to_uppercase();
1096        [
1097            "COUNT(",
1098            "SUM(",
1099            "AVG(",
1100            "MIN(",
1101            "MAX(",
1102            "GROUP_CONCAT(",
1103            "SAMPLE(",
1104        ]
1105        .iter()
1106        .any(|agg| upper.contains(agg))
1107    }
1108
1109    /// Detect ORDER BY in a SPARQL fragment.
1110    pub fn detect_order_by(fragment: &str) -> bool {
1111        fragment.to_uppercase().contains("ORDER BY")
1112    }
1113
1114    /// Detect LIMIT in a SPARQL fragment.
1115    pub fn detect_limit(fragment: &str) -> bool {
1116        fragment.to_uppercase().contains("LIMIT")
1117    }
1118}
1119
1120/// Position and metadata for a detected LATERAL clause.
1121#[derive(Debug, Clone, Serialize, Deserialize)]
1122pub struct LateralClausePosition {
1123    /// Start byte offset in the query string.
1124    pub start: usize,
1125    /// End byte offset (exclusive).
1126    pub end: usize,
1127    /// The body text between the braces.
1128    pub body: String,
1129    /// Whether the body contains a SELECT subquery.
1130    pub has_select: bool,
1131}
1132
1133// ---------------------------------------------------------------------------
1134// Tests
1135// ---------------------------------------------------------------------------
1136
1137#[cfg(test)]
1138mod lateral_join_tests;