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