1use std::collections::BTreeSet;
9
10use serde::{Deserialize, Serialize};
11
12use crate::planner::{
13 AggregateFunction, LogicalPlan, Projection, TableReference, TableReferenceAccess, TypedExpr,
14 TypedExprKind,
15};
16
17pub const REMOTE_READ_CATALOG_VERSION: &str = "v0.8";
19
20pub const REMOTE_DETERMINISTIC_SCALAR_FUNCTIONS: &[&str] = &[
24 "abs",
25 "sign",
26 "round",
27 "floor",
28 "ceil",
29 "ceiling",
30 "trunc",
31 "mod",
32 "power",
33 "pow",
34 "sqrt",
35 "exp",
36 "ln",
37 "log",
38 "log10",
39 "sin",
40 "cos",
41 "tan",
42 "asin",
43 "acos",
44 "atan",
45 "atan2",
46 "degrees",
47 "radians",
48 "pi",
49 "sha256",
50 "md5",
51 "simhash",
52 "hamming_distance",
53 "hex",
54 "unhex",
55 "encode",
56 "decode",
57 "length",
58 "char_length",
59 "octet_length",
60 "upper",
61 "lower",
62 "initcap",
63 "substr",
64 "left",
65 "right",
66 "trim",
67 "ltrim",
68 "rtrim",
69 "replace",
70 "instr",
71 "strpos",
72 "concat",
73 "concat_ws",
74 "repeat",
75 "reverse",
76 "lpad",
77 "rpad",
78 "split_part",
79 "regexp_replace",
80 "regexp_match",
81 "regexp_matches",
82 "coalesce",
83 "nullif",
84 "ifnull",
85 "iif",
86 "greatest",
87 "least",
88 "typeof",
89 "pg_typeof",
90 "quote",
91];
92
93pub const REMOTE_LOCAL_ONLY_SCALAR_FUNCTIONS: &[&str] = &[
95 "vector_similarity",
96 "vector_distance",
97 "vector_dims",
98 "vector_norm",
99 "random",
100 "now",
101 "gen_random_uuid",
102 "uuidv7",
103 "memory_stats",
104 "io_stats",
105 "clear_cache",
106];
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub enum RemoteReadClassification {
111 Supported(RemoteReadDescriptor),
113 LocalOnly(RemoteReadRejection),
116 UnsupportedRemote(RemoteReadRejection),
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126pub struct RemoteReadDescriptor {
127 pub catalog_version: String,
128 pub table: String,
129 pub shape: RemoteReadShape,
130 pub operators: RemoteReadOperators,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135pub enum RemoteReadShape {
136 Rows,
137 Aggregate { aggregates: Vec<RemoteAggregate> },
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(rename_all = "snake_case")]
143pub enum RemoteAggregate {
144 Count,
145 Sum,
146 Total,
147 Avg,
148 Min,
149 Max,
150 GroupConcat,
151 StringAgg,
152}
153
154#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
156pub struct RemoteReadOperators {
157 pub filter: bool,
158 pub projection: bool,
159 pub order_by: bool,
160 pub limit: bool,
161 pub offset: bool,
162 pub group_by: bool,
163 pub having: bool,
164 pub deterministic_scalar: bool,
165 pub aggregate_distinct: bool,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170pub struct RemoteReadRejection {
171 pub code: String,
172 pub reason: String,
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
177#[serde(rename_all = "snake_case")]
178pub enum RemoteReadCoverageStatus {
179 RemoteSupported,
180 LocalOnly,
181 PreExecutionRejection,
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
186pub struct RemoteReadCoverageEntry {
187 pub id: &'static str,
188 pub public_surface: &'static str,
189 #[serde(skip_serializing)]
190 pub identities: &'static [&'static str],
191 pub remote_status: RemoteReadCoverageStatus,
192 pub prerequisite: &'static str,
193 pub normal_outcome: &'static str,
194 pub failure_outcome: &'static str,
195}
196
197impl RemoteReadRejection {
198 fn local_only(code: &str, reason: &str) -> RemoteReadClassification {
199 RemoteReadClassification::LocalOnly(Self {
200 code: code.to_string(),
201 reason: reason.to_string(),
202 })
203 }
204
205 fn unsupported(code: &str, reason: &str) -> RemoteReadClassification {
206 RemoteReadClassification::UnsupportedRemote(Self {
207 code: code.to_string(),
208 reason: reason.to_string(),
209 })
210 }
211}
212
213#[derive(Debug, Default, Clone, Copy)]
215pub struct RemoteReadCatalogV0_8;
216
217impl RemoteReadCatalogV0_8 {
218 pub fn classify(
220 &self,
221 plan: &LogicalPlan,
222 table_references: &[TableReference],
223 ) -> RemoteReadClassification {
224 classify(plan, table_references)
225 }
226
227 pub fn coverage_entries(&self) -> Vec<RemoteReadCoverageEntry> {
230 coverage_entries()
231 }
232}
233
234pub fn coverage_entries() -> Vec<RemoteReadCoverageEntry> {
236 use RemoteReadCoverageStatus::{LocalOnly, PreExecutionRejection, RemoteSupported};
237
238 vec![
239 RemoteReadCoverageEntry {
240 id: "select.one_table.read_only",
241 public_surface: "one logical table SELECT with projection, WHERE, ORDER BY, LIMIT, OFFSET",
242 identities: &[
243 "select",
244 "projection",
245 "where",
246 "order_by",
247 "limit",
248 "offset",
249 ],
250 remote_status: RemoteSupported,
251 prerequisite: "closed catalog, fenced retained read point, authorized range targets",
252 normal_outcome: "prepared globally equivalent result",
253 failure_outcome: "classified pre-execution or routed-read failure; no local fallback",
254 },
255 RemoteReadCoverageEntry {
256 id: "select.aggregate.basic",
257 public_surface: "one-table COUNT, SUM, TOTAL, AVG, MIN, MAX with GROUP BY/HAVING/DISTINCT",
258 identities: &[
259 "count", "sum", "total", "avg", "min", "max", "group_by", "having", "distinct",
260 ],
261 remote_status: RemoteSupported,
262 prerequisite: "closed aggregate descriptor and global finalization budget",
263 normal_outcome: "prepared globally equivalent aggregate result",
264 failure_outcome: "classified pre-execution or global preparation failure",
265 },
266 RemoteReadCoverageEntry {
267 id: "select.aggregate.ordered_string",
268 public_surface: "one-table GROUP_CONCAT and STRING_AGG with global ordered replay",
269 identities: &["group_concat", "string_agg"],
270 remote_status: RemoteSupported,
271 prerequisite: "closed aggregate descriptor and ordered raw-value finalization budget",
272 normal_outcome: "prepared globally ordered aggregate result",
273 failure_outcome: "classified pre-execution or global preparation failure",
274 },
275 RemoteReadCoverageEntry {
276 id: "scalar.deterministic",
277 public_surface: "explicit deterministic scalar function list in RemoteReadCatalogV0_8",
278 identities: REMOTE_DETERMINISTIC_SCALAR_FUNCTIONS,
279 remote_status: RemoteSupported,
280 prerequisite: "one-table SELECT and each function identity listed by the v0.8 catalog",
281 normal_outcome: "evaluated as part of a prepared remotely supported read",
282 failure_outcome: "unlisted function is rejected before transport",
283 },
284 RemoteReadCoverageEntry {
285 id: "scalar.local_only",
286 public_surface: "vector, random/UUID, statistics, and cache-control scalar functions",
287 identities: REMOTE_LOCAL_ONLY_SCALAR_FUNCTIONS,
288 remote_status: LocalOnly,
289 prerequisite: "local execution profile",
290 normal_outcome: "v0.7.4 local SQL behavior",
291 failure_outcome: "remote profile receives an explicit local-only classification",
292 },
293 RemoteReadCoverageEntry {
294 id: "statement.ddl",
295 public_surface: "CREATE/DROP TABLE and CREATE/DROP INDEX",
296 identities: &["create_table", "drop_table", "create_index", "drop_index"],
297 remote_status: PreExecutionRejection,
298 prerequisite: "local schema-management workflow",
299 normal_outcome: "v0.7.4 local SQL behavior",
300 failure_outcome: "ddl_not_supported_remote before transport",
301 },
302 RemoteReadCoverageEntry {
303 id: "statement.dml",
304 public_surface: "INSERT, UPDATE, DELETE",
305 identities: &["insert", "update", "delete"],
306 remote_status: PreExecutionRejection,
307 prerequisite: "local transaction workflow",
308 normal_outcome: "v0.7.4 local SQL behavior",
309 failure_outcome: "dml_not_supported_remote before transport",
310 },
311 RemoteReadCoverageEntry {
312 id: "statement.pragma",
313 public_surface: "PRAGMA",
314 identities: &["pragma"],
315 remote_status: LocalOnly,
316 prerequisite: "local execution profile",
317 normal_outcome: "v0.7.4 local SQL behavior",
318 failure_outcome: "pragma_local_only before transport",
319 },
320 RemoteReadCoverageEntry {
321 id: "relation.join",
322 public_surface: "JOIN",
323 identities: &[
324 "inner_join",
325 "left_join",
326 "right_join",
327 "full_join",
328 "cross_join",
329 ],
330 remote_status: PreExecutionRejection,
331 prerequisite: "local execution profile",
332 normal_outcome: "v0.7.4 local SQL behavior",
333 failure_outcome: "join_not_supported_remote before transport",
334 },
335 RemoteReadCoverageEntry {
336 id: "relation.subquery",
337 public_surface: "scalar, IN, EXISTS, and quantified subqueries",
338 identities: &["scalar_subquery", "in_subquery", "exists", "quantified"],
339 remote_status: PreExecutionRejection,
340 prerequisite: "local execution profile",
341 normal_outcome: "v0.7.4 local SQL behavior",
342 failure_outcome: "subquery_not_supported_remote before transport",
343 },
344 RemoteReadCoverageEntry {
345 id: "relation.compound_window",
346 public_surface: "compound and window query forms",
347 identities: &["compound_query", "window_expression"],
348 remote_status: PreExecutionRejection,
349 prerequisite: "a future remote catalog version",
350 normal_outcome: "not a v0.8 remote-read form",
351 failure_outcome: "function_not_in_remote_catalog before transport",
352 },
353 RemoteReadCoverageEntry {
354 id: "scalar.standard_predicate",
355 public_surface: "truth, distinctness, and row-value predicates",
356 identities: &["truth_predicate", "is_distinct_from", "row_comparison"],
357 remote_status: LocalOnly,
358 prerequisite: "local execution profile",
359 normal_outcome: "three-valued evaluation by the local executor",
360 failure_outcome: "standard_predicate_local_only before transport",
361 },
362 RemoteReadCoverageEntry {
363 id: "scalar.try_cast",
364 public_surface: "TRY_CAST safe conversion",
365 identities: &["try_cast"],
366 remote_status: LocalOnly,
367 prerequisite: "local execution profile",
368 normal_outcome: "NULL-on-conversion-failure evaluation by the local executor",
369 failure_outcome: "try_cast_local_only before transport",
370 },
371 RemoteReadCoverageEntry {
372 id: "pagination.fetch_with_ties",
373 public_surface: "FETCH ... WITH TIES peer-preserving row limits",
374 identities: &["fetch_with_ties"],
375 remote_status: LocalOnly,
376 prerequisite: "local execution profile",
377 normal_outcome: "peer-preserving limit evaluation by the local executor",
378 failure_outcome: "fetch_with_ties_local_only before transport",
379 },
380 RemoteReadCoverageEntry {
381 id: "relation.distinct_on",
382 public_surface: "SELECT DISTINCT ON deterministic first-row deduplication",
383 identities: &["distinct_on"],
384 remote_status: LocalOnly,
385 prerequisite: "local execution profile",
386 normal_outcome: "deterministic per-key first-row evaluation by the local executor",
387 failure_outcome: "distinct_on_local_only before transport",
388 },
389 RemoteReadCoverageEntry {
390 id: "scalar.aggregate_filter",
391 public_surface: "aggregate FILTER (WHERE ...) per-aggregate row filtering",
392 identities: &["aggregate_filter"],
393 remote_status: LocalOnly,
394 prerequisite: "local execution profile",
395 normal_outcome: "per-aggregate predicate filtering by the local executor",
396 failure_outcome: "aggregate_filter_local_only before transport",
397 },
398 RemoteReadCoverageEntry {
399 id: "scalar.ordered_aggregate",
400 public_surface: "aggregate-local ORDER BY and WITHIN GROUP ordered-set aggregates",
401 identities: &["aggregate_order_by", "within_group", "percentile_disc"],
402 remote_status: LocalOnly,
403 prerequisite: "local execution profile",
404 normal_outcome: "ordered aggregate evaluation by the local executor",
405 failure_outcome: "ordered_aggregate_local_only before transport",
406 },
407 RemoteReadCoverageEntry {
408 id: "aggregate.grouping_sets",
409 public_surface: "GROUPING SETS / ROLLUP / CUBE multi-set aggregation",
410 identities: &["rollup", "cube", "grouping_sets", "grouping", "grouping_id"],
411 remote_status: LocalOnly,
412 prerequisite: "local execution profile",
413 normal_outcome: "single-pass multi-set aggregation by the local executor",
414 failure_outcome: "grouping_sets_local_only before transport",
415 },
416 RemoteReadCoverageEntry {
417 id: "relation.lateral_join",
418 public_surface: "LATERAL joins over a correlated relation",
419 identities: &["lateral", "cross_join_lateral", "left_join_lateral"],
420 remote_status: PreExecutionRejection,
421 prerequisite: "local execution profile",
422 normal_outcome: "per-left-row correlated evaluation by the local executor",
423 failure_outcome: "lateral_join_not_supported_remote before transport",
424 },
425 RemoteReadCoverageEntry {
426 id: "relation.table_function",
427 public_surface: "FROM-clause table functions",
428 identities: &["unnest", "generate_series"],
429 remote_status: LocalOnly,
430 prerequisite: "local execution profile",
431 normal_outcome: "row generation by the local executor",
432 failure_outcome: "table_function_not_supported_remote before transport",
433 },
434 RemoteReadCoverageEntry {
435 id: "relation.recursive_cte",
436 public_surface: "recursive common table expressions",
437 identities: &["with_recursive"],
438 remote_status: PreExecutionRejection,
439 prerequisite: "local execution profile",
440 normal_outcome: "bounded fixed-point evaluation by the local executor",
441 failure_outcome: "recursive_cte_not_supported_remote before transport",
442 },
443 RemoteReadCoverageEntry {
444 id: "transaction.multi_statement",
445 public_surface: "existing multi-statement Transaction API workflow",
446 identities: &["transaction_api"],
447 remote_status: LocalOnly,
448 prerequisite: "local transaction workflow",
449 normal_outcome: "v0.7.4 local transaction behavior",
450 failure_outcome: "remote profile receives an explicit pre-execution classification",
451 },
452 ]
453}
454
455pub fn classify(
457 plan: &LogicalPlan,
458 table_references: &[TableReference],
459) -> RemoteReadClassification {
460 if let Some(classification) = table_boundary(table_references) {
461 return classification;
462 }
463 if plan.contains_join() {
464 return RemoteReadRejection::unsupported(
465 "join_not_supported_remote",
466 "JOIN is outside the v0.8 remote-read catalog",
467 );
468 }
469
470 let mut analysis = Analysis::default();
471 if let Err(rejection) = validate_plan(plan, &mut analysis) {
472 return rejection;
473 }
474 let table = match single_table(table_references) {
475 Some(table) => table,
476 None => {
477 return RemoteReadRejection::unsupported(
478 "single_logical_table_required",
479 "remote reads require exactly one physical logical table",
480 );
481 }
482 };
483 if analysis.scan_count != 1 {
484 return RemoteReadRejection::unsupported(
485 "single_logical_table_required",
486 "remote reads require exactly one table scan",
487 );
488 }
489 if analysis
490 .scan_tables
491 .first()
492 .is_none_or(|scan_table| scan_table != &table)
493 {
494 return RemoteReadRejection::unsupported(
495 "table_reference_mismatch",
496 "planned scan table does not match the routing table reference",
497 );
498 }
499
500 RemoteReadClassification::Supported(RemoteReadDescriptor {
501 catalog_version: REMOTE_READ_CATALOG_VERSION.to_string(),
502 table,
503 shape: if analysis.aggregates.is_empty() {
504 RemoteReadShape::Rows
505 } else {
506 RemoteReadShape::Aggregate {
507 aggregates: analysis.aggregates,
508 }
509 },
510 operators: analysis.operators,
511 })
512}
513
514fn table_boundary(table_references: &[TableReference]) -> Option<RemoteReadClassification> {
515 if table_references
516 .iter()
517 .any(|reference| reference.access != TableReferenceAccess::Read)
518 {
519 return Some(RemoteReadRejection::unsupported(
520 "read_only_select_required",
521 "remote reads require a read-only SELECT",
522 ));
523 }
524 let tables: BTreeSet<_> = table_references
525 .iter()
526 .map(|reference| reference.table_name.as_str())
527 .collect();
528 if tables.len() > 1 {
529 return Some(RemoteReadRejection::unsupported(
530 "single_logical_table_required",
531 "remote reads cannot span multiple logical tables",
532 ));
533 }
534 None
535}
536
537fn single_table(table_references: &[TableReference]) -> Option<String> {
538 table_references
539 .first()
540 .map(|reference| reference.table_name.clone())
541}
542
543#[derive(Debug, Default)]
544struct Analysis {
545 scan_count: usize,
546 scan_tables: Vec<String>,
547 aggregates: Vec<RemoteAggregate>,
548 operators: RemoteReadOperators,
549}
550
551fn validate_plan(
552 plan: &LogicalPlan,
553 analysis: &mut Analysis,
554) -> Result<(), RemoteReadClassification> {
555 match plan {
556 LogicalPlan::Pragma { .. } => Err(RemoteReadRejection::local_only(
557 "pragma_local_only",
558 "PRAGMA remains available only to the local executor",
559 )),
560 LogicalPlan::Insert { .. }
561 | LogicalPlan::InsertSelect { .. }
562 | LogicalPlan::Update { .. }
563 | LogicalPlan::Delete { .. } => Err(RemoteReadRejection::unsupported(
564 "dml_not_supported_remote",
565 "DML is outside the read-only remote-read catalog",
566 )),
567 LogicalPlan::CreateTable { .. }
568 | LogicalPlan::DropTable { .. }
569 | LogicalPlan::CreateIndex { .. }
570 | LogicalPlan::DropIndex { .. } => Err(RemoteReadRejection::unsupported(
571 "ddl_not_supported_remote",
572 "DDL is outside the read-only remote-read catalog",
573 )),
574 LogicalPlan::Join { .. } => Err(RemoteReadRejection::unsupported(
575 "join_not_supported_remote",
576 "JOIN is outside the v0.8 remote-read catalog",
577 )),
578 LogicalPlan::LateralJoin { .. } => Err(RemoteReadRejection::unsupported(
579 "lateral_join_not_supported_remote",
580 "LATERAL joins are outside the v0.8 remote-read catalog",
581 )),
582 LogicalPlan::TableFunction { .. } => Err(RemoteReadRejection::local_only(
583 "table_function_not_supported_remote",
584 "FROM-clause table functions are evaluated by the local executor",
585 )),
586 LogicalPlan::Window { .. } => Err(RemoteReadRejection::unsupported(
587 "window_not_supported_remote",
588 "window functions are outside the v0.8 remote-read catalog",
589 )),
590 LogicalPlan::SetOperation { .. } => Err(RemoteReadRejection::unsupported(
591 "set_operation_not_supported_remote",
592 "set operations are outside the v0.8 remote-read catalog",
593 )),
594 LogicalPlan::Values { .. } => Err(RemoteReadRejection::local_only(
595 "values_local_only",
596 "VALUES relations are evaluated by the local executor",
597 )),
598 LogicalPlan::RecursiveCte { .. } | LogicalPlan::RecursiveReference { .. } => {
599 Err(RemoteReadRejection::unsupported(
600 "recursive_cte_not_supported_remote",
601 "recursive common table expressions are outside the v0.8 remote-read catalog",
602 ))
603 }
604 LogicalPlan::Scan { table, projection } => {
605 analysis.scan_count += 1;
606 analysis.scan_tables.push(table.clone());
607 validate_projection(projection, false, analysis)
608 }
609 LogicalPlan::Filter { input, predicate } => {
610 analysis.operators.filter = true;
611 validate_expr(predicate, false, analysis)?;
612 validate_plan(input, analysis)
613 }
614 LogicalPlan::Project { input, projection } => {
615 validate_plan(input, analysis)?;
616 analysis.operators.projection = true;
617 validate_projection(projection, !analysis.aggregates.is_empty(), analysis)
618 }
619 LogicalPlan::Aggregate {
620 input,
621 group_keys,
622 aggregates,
623 having,
624 projection,
625 grouping_sets,
626 } => {
627 if grouping_sets.is_some() {
628 return Err(RemoteReadRejection::local_only(
629 "grouping_sets_local_only",
630 "GROUPING SETS/ROLLUP/CUBE aggregation is not in the v0.8 remote-read catalog",
631 ));
632 }
633 analysis.operators.group_by = !group_keys.is_empty();
634 analysis.operators.having = having.is_some();
635 for group_key in group_keys {
636 validate_expr(group_key, false, analysis)?;
637 }
638 for aggregate in aggregates {
639 if aggregate.filter.is_some() {
640 return Err(RemoteReadRejection::local_only(
641 "aggregate_filter_local_only",
642 "aggregate FILTER (WHERE ...) is not in the v0.8 remote-read catalog",
643 ));
644 }
645 let Some(aggregate_name) = remote_aggregate(&aggregate.function) else {
646 return Err(RemoteReadRejection::local_only(
647 "ordered_aggregate_local_only",
648 "ordered-set aggregates are not in the v0.8 remote-read catalog",
649 ));
650 };
651 if !aggregate.order_by.is_empty() {
652 return Err(RemoteReadRejection::local_only(
653 "ordered_aggregate_local_only",
654 "aggregate-local ORDER BY is not in the v0.8 remote-read catalog",
655 ));
656 }
657 analysis.operators.aggregate_distinct |= aggregate.distinct;
658 if let Some(argument) = &aggregate.arg {
659 validate_expr(argument, false, analysis)?;
660 }
661 analysis.aggregates.push(aggregate_name);
662 }
663 if let Some(having) = having {
664 validate_expr(having, true, analysis)?;
665 }
666 validate_projection(projection, true, analysis)?;
667 validate_plan(input, analysis)
668 }
669 LogicalPlan::Sort { input, order_by } => {
670 validate_plan(input, analysis)?;
671 analysis.operators.order_by = true;
672 for sort in order_by {
673 validate_expr(sort.expr(), !analysis.aggregates.is_empty(), analysis)?;
674 }
675 Ok(())
676 }
677 LogicalPlan::Limit {
678 input,
679 limit,
680 offset,
681 ties,
682 } => {
683 if ties.is_some() {
684 return Err(RemoteReadRejection::local_only(
685 "fetch_with_ties_local_only",
686 "FETCH ... WITH TIES is not in the v0.8 remote-read catalog",
687 ));
688 }
689 analysis.operators.limit |= limit.is_some();
690 analysis.operators.offset |= offset.is_some();
691 validate_plan(input, analysis)
692 }
693 LogicalPlan::DistinctOn { .. } => Err(RemoteReadRejection::local_only(
694 "distinct_on_local_only",
695 "SELECT DISTINCT ON is not in the v0.8 remote-read catalog",
696 )),
697 }
698}
699
700fn validate_projection(
701 projection: &Projection,
702 allow_aggregate: bool,
703 analysis: &mut Analysis,
704) -> Result<(), RemoteReadClassification> {
705 if let Projection::Columns(columns) = projection {
706 analysis.operators.projection = true;
707 for column in columns {
708 validate_expr(&column.expr, allow_aggregate, analysis)?;
709 }
710 }
711 Ok(())
712}
713
714fn validate_expr(
715 expr: &TypedExpr,
716 allow_aggregate: bool,
717 analysis: &mut Analysis,
718) -> Result<(), RemoteReadClassification> {
719 match &expr.kind {
720 TypedExprKind::Literal(_) | TypedExprKind::ColumnRef { .. } => Ok(()),
721 TypedExprKind::VectorLiteral(_) => Err(RemoteReadRejection::local_only(
722 "vector_sql_local_only",
723 "vector SQL is not in the v0.8 remote-read catalog",
724 )),
725 TypedExprKind::TryCast { .. } => Err(RemoteReadRejection::local_only(
726 "try_cast_local_only",
727 "TRY_CAST is not in the v0.8 remote-read catalog",
728 )),
729 TypedExprKind::BinaryOp { left, right, .. } => {
730 validate_expr(left, allow_aggregate, analysis)?;
731 validate_expr(right, allow_aggregate, analysis)
732 }
733 TypedExprKind::UnaryOp { operand, .. }
734 | TypedExprKind::Cast { expr: operand, .. }
735 | TypedExprKind::IsNull { expr: operand, .. } => {
736 validate_expr(operand, allow_aggregate, analysis)
737 }
738 TypedExprKind::Case {
739 operand,
740 branches,
741 else_expr,
742 } => {
743 if let Some(operand) = operand {
744 validate_expr(operand, allow_aggregate, analysis)?;
745 }
746 for branch in branches {
747 validate_expr(&branch.when, allow_aggregate, analysis)?;
748 validate_expr(&branch.then, allow_aggregate, analysis)?;
749 }
750 if let Some(else_expr) = else_expr {
751 validate_expr(else_expr, allow_aggregate, analysis)?;
752 }
753 Ok(())
754 }
755 TypedExprKind::Between {
756 expr, low, high, ..
757 } => {
758 validate_expr(expr, allow_aggregate, analysis)?;
759 validate_expr(low, allow_aggregate, analysis)?;
760 validate_expr(high, allow_aggregate, analysis)
761 }
762 TypedExprKind::Like {
763 expr,
764 pattern,
765 escape,
766 ..
767 } => {
768 validate_expr(expr, allow_aggregate, analysis)?;
769 validate_expr(pattern, allow_aggregate, analysis)?;
770 if let Some(escape) = escape {
771 validate_expr(escape, allow_aggregate, analysis)?;
772 }
773 Ok(())
774 }
775 TypedExprKind::InList { expr, list, .. } => {
776 validate_expr(expr, allow_aggregate, analysis)?;
777 for item in list {
778 validate_expr(item, allow_aggregate, analysis)?;
779 }
780 Ok(())
781 }
782 TypedExprKind::FunctionCall { name, args, .. } => {
783 if allow_aggregate && aggregate_function_name(name) {
784 for argument in args {
785 validate_expr(argument, false, analysis)?;
786 }
787 return Ok(());
788 }
789 let normalized = name.to_ascii_lowercase();
790 if normalized.starts_with("__alopex_truth_") || normalized.starts_with("__alopex_row_")
791 {
792 return Err(RemoteReadRejection::local_only(
793 "standard_predicate_local_only",
794 "standard predicates are not in the v0.8 remote-read catalog",
795 ));
796 }
797 if REMOTE_LOCAL_ONLY_SCALAR_FUNCTIONS.contains(&normalized.as_str()) {
798 return Err(RemoteReadRejection::local_only(
799 "stateful_function_local_only",
800 "the requested scalar function remains local-only",
801 ));
802 }
803 if !REMOTE_DETERMINISTIC_SCALAR_FUNCTIONS.contains(&normalized.as_str()) {
804 return Err(RemoteReadRejection::unsupported(
805 "function_not_in_remote_catalog",
806 "function is not explicitly listed in the remote-read catalog",
807 ));
808 }
809 analysis.operators.deterministic_scalar = true;
810 for argument in args {
811 validate_expr(argument, false, analysis)?;
812 }
813 Ok(())
814 }
815 TypedExprKind::ScalarSubquery(_)
816 | TypedExprKind::InSubquery { .. }
817 | TypedExprKind::Exists { .. }
818 | TypedExprKind::Quantified { .. } => Err(RemoteReadRejection::unsupported(
819 "subquery_not_supported_remote",
820 "subqueries are outside the v0.8 remote-read catalog",
821 )),
822 }
823}
824
825fn remote_aggregate(function: &AggregateFunction) -> Option<RemoteAggregate> {
829 match function {
830 AggregateFunction::Count => Some(RemoteAggregate::Count),
831 AggregateFunction::Sum => Some(RemoteAggregate::Sum),
832 AggregateFunction::Total => Some(RemoteAggregate::Total),
833 AggregateFunction::Avg => Some(RemoteAggregate::Avg),
834 AggregateFunction::Min => Some(RemoteAggregate::Min),
835 AggregateFunction::Max => Some(RemoteAggregate::Max),
836 AggregateFunction::GroupConcat { .. } => Some(RemoteAggregate::GroupConcat),
837 AggregateFunction::StringAgg { .. } => Some(RemoteAggregate::StringAgg),
838 AggregateFunction::PercentileDisc { .. } => None,
839 }
840}
841
842fn aggregate_function_name(name: &str) -> bool {
843 matches!(
844 name.to_ascii_lowercase().as_str(),
845 "count" | "sum" | "total" | "avg" | "min" | "max" | "group_concat" | "string_agg"
846 )
847}
848
849trait SortExprExt {
850 fn expr(&self) -> &TypedExpr;
851}
852
853impl SortExprExt for crate::planner::SortExpr {
854 fn expr(&self) -> &TypedExpr {
855 &self.expr
856 }
857}
858
859#[cfg(test)]
860mod tests {
861 use super::*;
862 use crate::Span;
863 use crate::ast::expr::Literal;
864 use crate::catalog::ColumnMetadata;
865 use crate::planner::{Projection, RecursiveCteLimits, ResolvedType, SortExpr, TypedExpr};
866
867 fn references() -> Vec<TableReference> {
868 vec![TableReference::new(
869 "users",
870 TableReferenceAccess::Read,
871 crate::planner::TableReferenceSource::LogicalPlanScan,
872 )]
873 }
874
875 fn scan() -> LogicalPlan {
876 LogicalPlan::scan("users".to_string(), Projection::All(vec!["id".to_string()]))
877 }
878
879 fn column() -> TypedExpr {
880 TypedExpr::column_ref(
881 "users".to_string(),
882 "id".to_string(),
883 0,
884 ResolvedType::Integer,
885 Span::default(),
886 )
887 }
888
889 #[test]
890 fn classifies_closed_single_table_read_shape() {
891 let plan = LogicalPlan::limit(
892 LogicalPlan::sort(
893 LogicalPlan::filter(scan(), column()),
894 vec![SortExpr::asc(column())],
895 ),
896 Some(10),
897 Some(3),
898 );
899
900 let RemoteReadClassification::Supported(descriptor) = classify(&plan, &references()) else {
901 panic!("single-table deterministic read must be accepted");
902 };
903 assert_eq!(descriptor.catalog_version, REMOTE_READ_CATALOG_VERSION);
904 assert_eq!(descriptor.table, "users");
905 assert_eq!(descriptor.shape, RemoteReadShape::Rows);
906 assert!(descriptor.operators.filter);
907 assert!(descriptor.operators.order_by);
908 assert!(descriptor.operators.limit);
909 assert!(descriptor.operators.offset);
910 }
911
912 #[test]
913 fn rejects_join_and_dml_before_transport() {
914 let join = LogicalPlan::join(scan(), scan(), crate::planner::JoinType::Inner, None, None);
915 assert!(matches!(
916 classify(&join, &references()),
917 RemoteReadClassification::UnsupportedRemote(RemoteReadRejection { code, .. })
918 if code == "join_not_supported_remote"
919 ));
920
921 let insert = LogicalPlan::insert("users".to_string(), vec!["id".to_string()], vec![]);
922 assert!(matches!(
923 classify(&insert, &references()),
924 RemoteReadClassification::UnsupportedRemote(RemoteReadRejection { code, .. })
925 if code == "dml_not_supported_remote"
926 ));
927 }
928
929 #[test]
930 fn rejects_ddl_and_pragma_before_a_remote_session_exists() {
931 let ddl = LogicalPlan::drop_table("users".to_string(), false);
932 assert!(matches!(
933 classify(&ddl, &[]),
934 RemoteReadClassification::UnsupportedRemote(RemoteReadRejection { code, .. })
935 if code == "ddl_not_supported_remote"
936 ));
937
938 let pragma = LogicalPlan::Pragma {
939 name: "cache_size".to_string(),
940 value: None,
941 };
942 assert!(matches!(
943 classify(&pragma, &[]),
944 RemoteReadClassification::LocalOnly(RemoteReadRejection { code, .. })
945 if code == "pragma_local_only"
946 ));
947 }
948
949 #[test]
950 fn values_relations_remain_local_only() {
951 let plan = LogicalPlan::Values {
952 rows: vec![vec![TypedExpr::literal(
953 Literal::Number("1".to_string()),
954 ResolvedType::Integer,
955 Span::default(),
956 )]],
957 schema: vec![ColumnMetadata::new("column1", ResolvedType::Integer)],
958 };
959 assert!(matches!(
960 classify(&plan, &[]),
961 RemoteReadClassification::LocalOnly(RemoteReadRejection { code, .. })
962 if code == "values_local_only"
963 ));
964 }
965
966 #[test]
967 fn recursive_cte_is_rejected_before_remote_transport() {
968 let schema = vec![ColumnMetadata::new("id", ResolvedType::Integer)];
969 let reference = LogicalPlan::RecursiveReference {
970 name: "counter".to_string(),
971 schema: schema.clone(),
972 };
973 let recursive = LogicalPlan::RecursiveCte {
974 name: "counter".to_string(),
975 anchor: Box::new(scan()),
976 recursive_term: Box::new(reference.clone()),
977 union_all: true,
978 schema,
979 limits: RecursiveCteLimits::default(),
980 };
981
982 for plan in [&recursive, &reference] {
983 assert!(matches!(
984 classify(plan, &references()),
985 RemoteReadClassification::UnsupportedRemote(RemoteReadRejection { code, reason })
986 if code == "recursive_cte_not_supported_remote"
987 && reason.contains("outside the v0.8 remote-read catalog")
988 ));
989 }
990 }
991
992 #[test]
993 fn stateful_and_vector_expressions_remain_local_only() {
994 let random = TypedExpr::function_call(
995 "random".to_string(),
996 vec![],
997 false,
998 false,
999 ResolvedType::Double,
1000 Span::default(),
1001 );
1002 let random_plan = LogicalPlan::filter(scan(), random);
1003 assert!(matches!(
1004 classify(&random_plan, &references()),
1005 RemoteReadClassification::LocalOnly(RemoteReadRejection { code, .. })
1006 if code == "stateful_function_local_only"
1007 ));
1008
1009 let vector_plan = LogicalPlan::filter(
1010 scan(),
1011 TypedExpr::vector_literal(vec![1.0, 2.0], 2, Span::default()),
1012 );
1013 assert!(matches!(
1014 classify(&vector_plan, &references()),
1015 RemoteReadClassification::LocalOnly(RemoteReadRejection { code, .. })
1016 if code == "vector_sql_local_only"
1017 ));
1018 }
1019
1020 #[test]
1021 fn standard_predicates_remain_local_only() {
1022 let predicate = TypedExpr::function_call(
1023 "__alopex_truth_true:0".to_string(),
1024 vec![TypedExpr::literal(
1025 Literal::Boolean(true),
1026 ResolvedType::Boolean,
1027 Span::default(),
1028 )],
1029 false,
1030 false,
1031 ResolvedType::Boolean,
1032 Span::default(),
1033 );
1034 assert!(matches!(
1035 classify(&LogicalPlan::filter(scan(), predicate), &references()),
1036 RemoteReadClassification::LocalOnly(RemoteReadRejection { code, .. })
1037 if code == "standard_predicate_local_only"
1038 ));
1039 }
1040
1041 #[test]
1042 fn try_cast_remains_local_only() {
1043 let expression = TypedExpr::try_cast(
1044 TypedExpr::literal(
1045 Literal::String("42".to_string()),
1046 ResolvedType::Text,
1047 Span::default(),
1048 ),
1049 ResolvedType::Integer,
1050 Span::default(),
1051 );
1052 assert!(matches!(
1053 classify(&LogicalPlan::filter(scan(), expression), &references()),
1054 RemoteReadClassification::LocalOnly(RemoteReadRejection { code, .. })
1055 if code == "try_cast_local_only"
1056 ));
1057 }
1058
1059 #[test]
1060 fn grouping_sets_remain_local_only() {
1061 let plan = LogicalPlan::Aggregate {
1062 input: Box::new(scan()),
1063 group_keys: vec![column()],
1064 aggregates: Vec::new(),
1065 having: None,
1066 projection: Projection::All(vec!["value".to_string()]),
1067 grouping_sets: Some(vec![0b0, 0b1]),
1068 };
1069 assert!(matches!(
1070 classify(&plan, &references()),
1071 RemoteReadClassification::LocalOnly(RemoteReadRejection { code, .. })
1072 if code == "grouping_sets_local_only"
1073 ));
1074 }
1075
1076 #[test]
1077 fn fetch_with_ties_remains_local_only() {
1078 let sorted = LogicalPlan::sort(scan(), vec![SortExpr::asc(column())]);
1079 let ties = if let LogicalPlan::Sort { order_by, .. } = &sorted {
1080 Some(order_by.clone())
1081 } else {
1082 unreachable!("sort constructed above")
1083 };
1084 let plan = LogicalPlan::Limit {
1085 input: Box::new(sorted),
1086 limit: Some(2),
1087 offset: None,
1088 ties,
1089 };
1090 assert!(matches!(
1091 classify(&plan, &references()),
1092 RemoteReadClassification::LocalOnly(RemoteReadRejection { code, .. })
1093 if code == "fetch_with_ties_local_only"
1094 ));
1095
1096 let desugared = LogicalPlan::limit(
1098 LogicalPlan::sort(scan(), vec![SortExpr::asc(column())]),
1099 Some(2),
1100 Some(1),
1101 );
1102 assert!(matches!(
1103 classify(&desugared, &references()),
1104 RemoteReadClassification::Supported(_)
1105 ));
1106
1107 assert!(
1108 coverage_entries()
1109 .iter()
1110 .any(|entry| entry.id == "pagination.fetch_with_ties"
1111 && matches!(entry.remote_status, RemoteReadCoverageStatus::LocalOnly))
1112 );
1113 }
1114
1115 #[test]
1116 fn subqueries_are_rejected_and_descriptor_never_contains_plan() {
1117 let subquery = TypedExpr::new(
1118 TypedExprKind::ScalarSubquery(Box::new(scan())),
1119 ResolvedType::Integer,
1120 Span::default(),
1121 );
1122 assert!(matches!(
1123 classify(&LogicalPlan::filter(scan(), subquery), &references()),
1124 RemoteReadClassification::UnsupportedRemote(RemoteReadRejection { code, .. })
1125 if code == "subquery_not_supported_remote"
1126 ));
1127
1128 let encoded = serde_json::to_string(&classify(&scan(), &references())).unwrap();
1129 assert!(!encoded.contains("LogicalPlan"));
1130 assert!(!encoded.contains("column_index"));
1131 }
1132
1133 #[test]
1134 fn aggregate_catalog_includes_string_aggregates() {
1135 let aggregate = crate::planner::AggregateExpr {
1136 function: AggregateFunction::StringAgg {
1137 separator: Some(",".to_string()),
1138 },
1139 arg: Some(column()),
1140 distinct: true,
1141 result_type: ResolvedType::Text,
1142 filter: None,
1143 order_by: Vec::new(),
1144 };
1145 let plan = LogicalPlan::aggregate(
1146 scan(),
1147 vec![column()],
1148 vec![aggregate],
1149 Some(TypedExpr::literal(
1150 Literal::Boolean(true),
1151 ResolvedType::Boolean,
1152 Span::default(),
1153 )),
1154 Projection::All(vec![]),
1155 );
1156 let RemoteReadClassification::Supported(descriptor) = classify(&plan, &references()) else {
1157 panic!("listed aggregate must be accepted");
1158 };
1159 assert_eq!(
1160 descriptor.shape,
1161 RemoteReadShape::Aggregate {
1162 aggregates: vec![RemoteAggregate::StringAgg]
1163 }
1164 );
1165 assert!(descriptor.operators.group_by);
1166 assert!(descriptor.operators.having);
1167 assert!(descriptor.operators.aggregate_distinct);
1168 }
1169}