1use std::collections::{BTreeMap, BTreeSet};
9
10use radixdb_catalog::{
11 CatalogGeneration, CatalogName, CatalogPayload, ConstraintPayload, ObjectId, ObjectKind,
12};
13use radixdb_core::{DataType, Error, ParamVec, Result, Row, Value};
14use radixdb_sql::{
15 walk_expression_tree, walk_statement_physical_table_sources, walk_statement_tree, Expression,
16 SelectStatement, SimpleTableSource, Statement,
17};
18
19use crate::context::ExecutionContext;
20use crate::navigation::{bind_reference_expand_plan, ReferenceExpandPlan};
21use crate::procedural::transaction_visible_catalog;
22use crate::Executor;
23
24#[cfg(any(test, feature = "test-hooks"))]
25type PublicReadFenceTestHook = ([u8; 16], ObjectId, std::sync::Arc<dyn Fn() + Send + Sync>);
26
27#[cfg(any(test, feature = "test-hooks"))]
28static PUBLIC_READ_FENCE_TEST_HOOK: std::sync::LazyLock<
29 std::sync::Mutex<Option<PublicReadFenceTestHook>>,
30> = std::sync::LazyLock::new(|| std::sync::Mutex::new(None));
31
32#[cfg(any(test, feature = "test-hooks"))]
33static PUBLIC_READ_FENCE_TEST_HOOK_OWNER: std::sync::Mutex<()> = std::sync::Mutex::new(());
34
35#[cfg(any(test, feature = "test-hooks"))]
36#[doc(hidden)]
37pub struct PublicReadFenceTestHookGuard {
38 _owner: std::sync::MutexGuard<'static, ()>,
39}
40
41#[cfg(any(test, feature = "test-hooks"))]
42impl PublicReadFenceTestHookGuard {
43 pub fn install(
44 database_id: [u8; 16],
45 relation_id: ObjectId,
46 hook: std::sync::Arc<dyn Fn() + Send + Sync>,
47 ) -> Self {
48 let owner = PUBLIC_READ_FENCE_TEST_HOOK_OWNER
49 .lock()
50 .unwrap_or_else(|poisoned| poisoned.into_inner());
51 *PUBLIC_READ_FENCE_TEST_HOOK
52 .lock()
53 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
54 Some((database_id, relation_id, hook));
55 Self { _owner: owner }
56 }
57}
58
59#[cfg(any(test, feature = "test-hooks"))]
60impl Drop for PublicReadFenceTestHookGuard {
61 fn drop(&mut self) {
62 *PUBLIC_READ_FENCE_TEST_HOOK
63 .lock()
64 .unwrap_or_else(|poisoned| poisoned.into_inner()) = None;
65 }
66}
67
68#[cfg(any(test, feature = "test-hooks"))]
69fn run_public_read_fence_test_hook(
70 database_id: [u8; 16],
71 accesses: &BTreeMap<ObjectId, BTreeSet<ObjectId>>,
72) {
73 let hook = PUBLIC_READ_FENCE_TEST_HOOK
74 .lock()
75 .unwrap_or_else(|poisoned| poisoned.into_inner())
76 .clone();
77 if let Some((expected_database_id, relation_id, hook)) = hook {
78 if expected_database_id == database_id && accesses.contains_key(&relation_id) {
79 hook();
80 }
81 }
82}
83
84pub const PUBLIC_READ_MAX_PROJECTION: usize = 128;
85pub const PUBLIC_READ_MAX_FILTER_NODES: usize = 512;
86pub const PUBLIC_READ_MAX_NAVIGATION_DEPTH: usize = 8;
87pub const PUBLIC_READ_MAX_JOINS: usize = 8;
88pub const PUBLIC_READ_MAX_PAGE_SIZE: usize = 1_000;
89pub const PUBLIC_READ_MAX_SCANNED_ROWS: usize = 100_000;
90pub const PUBLIC_READ_MAX_RESULT_BYTES: usize = 8 * 1024 * 1024;
91pub const PUBLIC_READ_MAX_PARAMETER_BYTES: usize = 1024 * 1024;
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub struct PublicReadLimits {
95 pub max_projection: usize,
96 pub max_filter_nodes: usize,
97 pub max_navigation_depth: usize,
98 pub max_joins: usize,
99 pub max_page_size: usize,
100 pub max_scanned_rows: usize,
101 pub max_result_bytes: usize,
102 pub max_parameter_bytes: usize,
103}
104
105impl Default for PublicReadLimits {
106 fn default() -> Self {
107 Self {
108 max_projection: 64,
109 max_filter_nodes: 256,
110 max_navigation_depth: 4,
111 max_joins: 4,
112 max_page_size: 256,
113 max_scanned_rows: 25_000,
114 max_result_bytes: 2 * 1024 * 1024,
115 max_parameter_bytes: 256 * 1024,
116 }
117 }
118}
119
120impl PublicReadLimits {
121 pub fn validate(self) -> Result<Self> {
122 validate_limit(
123 "projection",
124 self.max_projection,
125 PUBLIC_READ_MAX_PROJECTION,
126 )?;
127 validate_limit(
128 "filter nodes",
129 self.max_filter_nodes,
130 PUBLIC_READ_MAX_FILTER_NODES,
131 )?;
132 validate_limit(
133 "navigation depth",
134 self.max_navigation_depth,
135 PUBLIC_READ_MAX_NAVIGATION_DEPTH,
136 )?;
137 validate_limit("JOIN count", self.max_joins, PUBLIC_READ_MAX_JOINS)?;
138 validate_limit("page size", self.max_page_size, PUBLIC_READ_MAX_PAGE_SIZE)?;
139 validate_limit(
140 "scanned rows",
141 self.max_scanned_rows,
142 PUBLIC_READ_MAX_SCANNED_ROWS,
143 )?;
144 validate_limit(
145 "result bytes",
146 self.max_result_bytes,
147 PUBLIC_READ_MAX_RESULT_BYTES,
148 )?;
149 validate_limit(
150 "parameter bytes",
151 self.max_parameter_bytes,
152 PUBLIC_READ_MAX_PARAMETER_BYTES,
153 )?;
154 Ok(self)
155 }
156}
157
158fn validate_limit(label: &str, value: usize, ceiling: usize) -> Result<()> {
159 if value == 0 || value > ceiling {
160 return Err(Error::invalid_argument(format!(
161 "public read {label} limit {value} is outside 1..={ceiling}"
162 )));
163 }
164 Ok(())
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct PublicReadRelationSpec {
169 pub name: String,
170 pub columns: Vec<String>,
171}
172
173impl PublicReadRelationSpec {
174 pub fn new(
175 name: impl Into<String>,
176 columns: impl IntoIterator<Item = impl Into<String>>,
177 ) -> Self {
178 Self {
179 name: name.into(),
180 columns: columns.into_iter().map(Into::into).collect(),
181 }
182 }
183}
184
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct PublicReadColumnBinding {
187 pub object_id: ObjectId,
188 pub name: String,
189 pub data_type: DataType,
190 pub nullable: bool,
191}
192
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct PublicReadRelationBinding {
195 pub object_id: ObjectId,
196 pub name: String,
197 pub definition_revision: u64,
198 pub columns: Vec<PublicReadColumnBinding>,
199 pub primary_key: Vec<PublicReadColumnBinding>,
200}
201
202impl PublicReadRelationBinding {
203 fn column(&self, name: &str) -> Option<&PublicReadColumnBinding> {
204 let normalized = CatalogName::new(name).ok()?;
205 self.columns
206 .iter()
207 .find(|column| column.name == normalized.normalized().as_str())
208 }
209
210 fn admits_column_id(&self, id: ObjectId) -> bool {
211 self.columns.iter().any(|column| column.object_id == id)
212 }
213}
214
215#[derive(Debug, Clone, PartialEq, Eq)]
216pub struct BoundPublicReadPolicy {
217 relations: BTreeMap<String, PublicReadRelationBinding>,
218 functions: BTreeSet<String>,
219}
220
221impl BoundPublicReadPolicy {
222 pub fn relations(&self) -> impl ExactSizeIterator<Item = &PublicReadRelationBinding> {
223 self.relations.values()
224 }
225
226 pub fn functions(&self) -> impl ExactSizeIterator<Item = &str> {
227 self.functions.iter().map(String::as_str)
228 }
229
230 pub fn relation(&self, name: &str) -> Option<&PublicReadRelationBinding> {
231 normalized_name(name)
232 .ok()
233 .and_then(|name| self.relations.get(&name))
234 }
235}
236
237#[derive(Debug, Clone, PartialEq)]
238pub struct PublicReadMaterialized {
239 pub columns: Vec<String>,
240 pub rows: Vec<Row>,
241 pub database_id: [u8; 16],
242 pub catalog_id: [u8; 16],
243 pub catalog_generation: u64,
244 pub relations: Vec<PublicReadRelationBinding>,
245}
246
247impl Executor {
248 #[doc(hidden)]
252 pub fn bind_public_read_policy(
253 &self,
254 relations: &[PublicReadRelationSpec],
255 functions: &[String],
256 ) -> Result<BoundPublicReadPolicy> {
257 if self.has_active_transaction() {
258 return Err(Error::invalid_argument(
259 "public read policy cannot be bound inside an explicit transaction",
260 ));
261 }
262 let _fence = self.engine.acquire_ddl_statement_fence(false);
263 let (catalog, _) = transaction_visible_catalog(self)?;
264 let mut bound = BTreeMap::new();
265 for spec in relations {
266 let relation = bind_relation(catalog.as_ref(), spec)?;
267 if bound.insert(relation.name.clone(), relation).is_some() {
268 return Err(Error::invalid_argument(
269 "public read policy contains a duplicate relation",
270 ));
271 }
272 }
273 let mut bound_functions = BTreeSet::new();
274 for function in functions {
275 let name = normalized_name(function)?;
276 if !self.function_registry.exists(&name) {
277 return Err(Error::invalid_argument(format!(
278 "public read function '{name}' is not a registered builtin"
279 )));
280 }
281 bound_functions.insert(name);
282 }
283 Ok(BoundPublicReadPolicy {
284 relations: bound,
285 functions: bound_functions,
286 })
287 }
288
289 #[doc(hidden)]
292 pub fn execute_public_read_sql(
293 &self,
294 sql: &str,
295 params: ParamVec,
296 context: &ExecutionContext,
297 policy: &BoundPublicReadPolicy,
298 limits: PublicReadLimits,
299 ) -> Result<PublicReadMaterialized> {
300 let limits = limits.validate()?;
301 if self.has_active_transaction() {
302 return Err(Error::invalid_argument(
303 "public read cannot run inside an explicit transaction",
304 ));
305 }
306 if value_bytes(¶ms) > limits.max_parameter_bytes {
307 return Err(Error::invalid_argument(
308 "public read parameter-byte budget exceeded",
309 ));
310 }
311
312 let _fence = self.engine.acquire_ddl_statement_fence(false);
313 let (catalog, _) = transaction_visible_catalog(self)?;
314 revalidate_policy(catalog.as_ref(), policy)?;
315
316 let mut program = crate::dispatch::program::parse_program(sql)?;
317 if program.statements.len() != 1 {
318 return Err(public_shape("exactly one SELECT is required"));
319 }
320 let statement = program
321 .statements
322 .pop()
323 .expect("single public statement was checked");
324 let Statement::Select(select) = &statement else {
325 return Err(public_shape("only SELECT is admitted"));
326 };
327 let accesses = validate_public_select(select, policy, limits)?;
328 let navigation = bind_reference_expand_plan(self.engine.as_ref(), select)?;
329 let accesses =
330 bind_navigation_accesses(catalog.as_ref(), policy, accesses, navigation.as_ref())?;
331 crate::authorization::authorize_public_read_accesses(catalog.as_ref(), context, &accesses)?;
332
333 #[cfg(any(test, feature = "test-hooks"))]
334 run_public_read_fence_test_hook(catalog.meta().database_id(), &accesses);
335
336 let mut owned = self.fork_for_stored_function();
337 owned.ddl_fence_already_held = true;
338 let mut bounded = context.with_public_scan_limit(limits.max_scanned_rows);
339 bounded.set_params(params);
340 let mut result = owned.execute_with_context(sql, &bounded)?;
341 let columns = result.columns().to_vec();
342 let mut rows = Vec::new();
343 let mut bytes = columns.iter().map(String::len).sum::<usize>();
344 while result.next() {
345 if rows.len() == limits.max_page_size.saturating_add(1) {
346 let _ = result.close();
347 return Err(public_shape("result row limit exceeded"));
348 }
349 let row = result.take_row();
350 bytes = bytes.saturating_add(row_bytes(&row));
351 if bytes > limits.max_result_bytes {
352 let _ = result.close();
353 return Err(Error::invalid_argument(
354 "public read result-byte budget exceeded",
355 ));
356 }
357 rows.push(row);
358 }
359 if let Some(error) = result.last_error() {
360 let _ = result.close();
361 return Err(error);
362 }
363 result.close()?;
364 let meta = catalog.meta();
365 Ok(PublicReadMaterialized {
366 columns,
367 rows,
368 database_id: meta.database_id(),
369 catalog_id: meta.catalog_id(),
370 catalog_generation: meta.catalog_generation(),
371 relations: accesses
372 .keys()
373 .filter_map(|id| {
374 policy
375 .relations
376 .values()
377 .find(|relation| relation.object_id == *id)
378 .cloned()
379 })
380 .collect(),
381 })
382 }
383}
384
385fn bind_relation(
386 catalog: &CatalogGeneration,
387 spec: &PublicReadRelationSpec,
388) -> Result<PublicReadRelationBinding> {
389 let name = normalized_name(&spec.name)?;
390 let object = catalog
391 .find_relation(ObjectId::BOOTSTRAP_NAMESPACE, &name)
392 .map_err(catalog_error)?
393 .ok_or_else(|| Error::TableNotFound(spec.name.clone()))?;
394 if object.kind() != ObjectKind::Table {
395 return Err(public_shape("generic public reads publish tables only"));
396 }
397 let CatalogPayload::Table(table) = object.payload() else {
398 return Err(Error::internal("table object has non-table payload"));
399 };
400 if spec.columns.is_empty() {
401 return Err(public_shape("published relation must expose columns"));
402 }
403 let mut columns = Vec::with_capacity(spec.columns.len());
404 let mut seen = BTreeSet::new();
405 for requested in &spec.columns {
406 let column = catalog
407 .find_column(object.id(), requested)
408 .map_err(catalog_error)?
409 .ok_or_else(|| Error::ColumnNotFound(requested.clone()))?;
410 if !seen.insert(column.id()) {
411 return Err(public_shape("published column list contains a duplicate"));
412 }
413 columns.push(column_binding(column)?);
414 }
415 let primary_id = table.primary_key_constraint_id().ok_or_else(|| {
416 public_shape("generic public read relation requires an ordered primary key")
417 })?;
418 let primary = catalog
419 .object(primary_id)
420 .ok_or_else(|| Error::internal("primary-key catalog object is missing"))?;
421 let CatalogPayload::Constraint(ConstraintPayload::PrimaryKey { local_column_ids }) =
422 primary.payload()
423 else {
424 return Err(Error::internal(
425 "table primary-key pointer does not reference a primary key",
426 ));
427 };
428 let mut primary_key = Vec::with_capacity(local_column_ids.len());
429 for id in local_column_ids {
430 let column = columns
431 .iter()
432 .find(|column| column.object_id == *id)
433 .ok_or_else(|| public_shape("every primary-key column must be explicitly published"))?;
434 if !column.data_type.is_orderable() {
435 return Err(public_shape("public pagination key is not orderable"));
436 }
437 primary_key.push(column.clone());
438 }
439 Ok(PublicReadRelationBinding {
440 object_id: object.id(),
441 name,
442 definition_revision: object.definition_revision(),
443 columns,
444 primary_key,
445 })
446}
447
448fn column_binding(object: &radixdb_catalog::CatalogObject) -> Result<PublicReadColumnBinding> {
449 let CatalogPayload::Column(column) = object.payload() else {
450 return Err(Error::internal("column object has non-column payload"));
451 };
452 Ok(PublicReadColumnBinding {
453 object_id: object.id(),
454 name: object.name().normalized().as_str().to_owned(),
455 data_type: column.data_type().logical_type(),
456 nullable: column.nullable(),
457 })
458}
459
460fn revalidate_policy(catalog: &CatalogGeneration, policy: &BoundPublicReadPolicy) -> Result<()> {
461 for relation in policy.relations.values() {
462 let current = catalog
463 .object(relation.object_id)
464 .ok_or_else(|| public_shape("published relation identity is stale or was dropped"))?;
465 if current.kind() != ObjectKind::Table
466 || current.name().normalized().as_str() != relation.name
467 || current.definition_revision() != relation.definition_revision
468 {
469 return Err(public_shape("published relation identity is stale"));
470 }
471 for column in relation.columns.iter().chain(&relation.primary_key) {
472 let current = catalog
473 .object(column.object_id)
474 .ok_or_else(|| public_shape("published column identity is stale or was dropped"))?;
475 let CatalogPayload::Column(payload) = current.payload() else {
476 return Err(public_shape("published column identity changed kind"));
477 };
478 if current.parent_id() != Some(relation.object_id)
479 || current.name().normalized().as_str() != column.name
480 || payload.data_type().logical_type() != column.data_type
481 || payload.nullable() != column.nullable
482 {
483 return Err(public_shape("published column identity is stale"));
484 }
485 }
486 }
487 Ok(())
488}
489
490type BoundAccesses = BTreeMap<ObjectId, BTreeSet<ObjectId>>;
491
492fn validate_public_select(
493 select: &SelectStatement,
494 policy: &BoundPublicReadPolicy,
495 limits: PublicReadLimits,
496) -> Result<BoundAccesses> {
497 if select.with.is_some()
498 || select.distinct
499 || !select.distinct_on.is_empty()
500 || !select.group_by.columns.is_empty()
501 || select.having.is_some()
502 || !select.window_defs.is_empty()
503 || !select.set_operations.is_empty()
504 || select.offset.is_some()
505 {
506 return Err(public_shape(
507 "query feature is outside the public read subset",
508 ));
509 }
510 if select.columns.is_empty() || select.columns.len() > limits.max_projection {
511 return Err(public_shape(
512 "projection width is outside the admitted limit",
513 ));
514 }
515 let limit = match select.limit.as_deref() {
516 Some(Expression::IntegerLiteral(value)) if value.value > 0 => value.value as usize,
517 _ => return Err(public_shape("a positive constant LIMIT is required")),
518 };
519 if limit > limits.max_page_size.saturating_add(1) {
520 return Err(public_shape("page size exceeds the admitted limit"));
521 }
522
523 let mut sources = Vec::<SimpleTableSource>::new();
524 walk_statement_physical_table_sources(&Statement::Select(select.clone()), &mut |source| {
525 sources.push(source.clone());
526 });
527 if sources.is_empty() || sources.len() > limits.max_joins.saturating_add(1) {
528 return Err(public_shape(
529 "relation/JOIN count is outside the admitted limit",
530 ));
531 }
532 let mut aliases = BTreeMap::<String, &PublicReadRelationBinding>::new();
533 for source in &sources {
534 if source.as_of.is_some() {
535 return Err(public_shape(
536 "temporal table sources are not publicly admitted",
537 ));
538 }
539 let relation = policy
540 .relation(source.name.value())
541 .ok_or_else(|| public_shape("relation is not explicitly published"))?;
542 let alias = source.alias.as_ref().map_or_else(
543 || source.name.value_lower().to_owned(),
544 |alias| alias.value_lower().to_owned(),
545 );
546 if aliases.insert(alias, relation).is_some() {
547 return Err(public_shape("relation alias is ambiguous"));
548 }
549 }
550
551 let mut filter_nodes = 0usize;
552 if let Some(filter) = &select.where_clause {
553 walk_expression_tree(filter, &mut |_| {
554 filter_nodes = filter_nodes.saturating_add(1)
555 });
556 }
557 if let Some(source) = &select.table_expr {
558 count_public_join_predicate_nodes(source, &mut filter_nodes);
559 }
560 if filter_nodes > limits.max_filter_nodes {
561 return Err(public_shape("filter complexity exceeds the admitted limit"));
562 }
563
564 let mut error = None;
565 let mut joins = 0usize;
566 let mut accesses = BoundAccesses::new();
567 walk_statement_tree(&Statement::Select(select.clone()), &mut |expression| {
568 if error.is_some() {
569 return;
570 }
571 let result = match expression {
572 Expression::TableSource(_) => Ok(()),
573 Expression::JoinSource(join) => {
574 joins = joins.saturating_add(1);
575 let kind = join.join_type.to_uppercase();
576 if !matches!(kind.as_str(), "INNER" | "CROSS")
577 || !join.using_columns.is_empty()
578 || (kind == "INNER" && join.condition.is_none())
579 {
580 Err(public_shape("only INNER/CROSS JOIN ... ON is admitted"))
581 } else {
582 Ok(())
583 }
584 }
585 Expression::Identifier(identifier) => {
586 if aliases.len() != 1 {
587 Err(public_shape(
588 "unqualified columns require one relation source",
589 ))
590 } else {
591 admit_named_column(
592 aliases.values().next().expect("one alias"),
593 identifier.value(),
594 &mut accesses,
595 )
596 }
597 }
598 Expression::QualifiedIdentifier(identifier) => {
599 let depth = identifier.component_count().saturating_sub(1);
600 if depth > limits.max_navigation_depth {
601 Err(public_shape("navigation depth exceeds the admitted limit"))
602 } else if identifier.component_count() == 2 {
603 aliases
604 .get(identifier.qualifier.value_lower())
605 .ok_or_else(|| public_shape("column qualifier is not a relation alias"))
606 .and_then(|relation| {
607 admit_named_column(relation, identifier.name.value(), &mut accesses)
608 })
609 } else if aliases.contains_key(identifier.qualifier.value_lower()) {
610 Ok(())
611 } else {
612 Err(public_shape("navigation root is not a relation alias"))
613 }
614 }
615 Expression::FunctionCall(function) => {
616 let name = function.function.to_lowercase();
617 if policy.functions.contains(name.as_str()) {
618 Ok(())
619 } else {
620 Err(public_shape("function is not explicitly allowlisted"))
621 }
622 }
623 Expression::Star(_)
624 | Expression::QualifiedStar(_)
625 | Expression::SubquerySource(_)
626 | Expression::ValuesSource(_)
627 | Expression::CteReference(_)
628 | Expression::FunctionTableSource(_)
629 | Expression::Exists(_)
630 | Expression::AllAny(_)
631 | Expression::ScalarSubquery(_)
632 | Expression::Window(_) => Err(public_shape(
633 "stars, subqueries, derived sources and windows are not publicly admitted",
634 )),
635 _ => Ok(()),
636 };
637 if let Err(value) = result {
638 error = Some(value);
639 }
640 });
641 if let Some(error) = error {
642 return Err(error);
643 }
644 if joins > limits.max_joins {
645 return Err(public_shape("JOIN count exceeds the admitted limit"));
646 }
647 Ok(accesses)
648}
649
650fn count_public_join_predicate_nodes(expression: &Expression, nodes: &mut usize) {
651 if let Expression::JoinSource(join) = expression {
652 count_public_join_predicate_nodes(&join.left, nodes);
653 count_public_join_predicate_nodes(&join.right, nodes);
654 if let Some(condition) = &join.condition {
655 walk_expression_tree(condition, &mut |_| *nodes = nodes.saturating_add(1));
656 }
657 }
658}
659
660fn admit_named_column(
661 relation: &PublicReadRelationBinding,
662 name: &str,
663 accesses: &mut BoundAccesses,
664) -> Result<()> {
665 let column = relation
666 .column(name)
667 .ok_or_else(|| public_shape("column is not explicitly published"))?;
668 accesses
669 .entry(relation.object_id)
670 .or_default()
671 .insert(column.object_id);
672 Ok(())
673}
674
675fn bind_navigation_accesses(
676 catalog: &CatalogGeneration,
677 policy: &BoundPublicReadPolicy,
678 mut accesses: BoundAccesses,
679 plan: Option<&ReferenceExpandPlan>,
680) -> Result<BoundAccesses> {
681 let Some(plan) = plan else {
682 return Ok(accesses);
683 };
684 for edge in plan.edges() {
685 for column in std::iter::once(edge.source_column())
686 .chain(std::iter::once(edge.target_key_column()))
687 .chain(edge.required_columns())
688 {
689 let table = catalog
690 .find_relation(ObjectId::BOOTSTRAP_NAMESPACE, column.table().table_name())
691 .map_err(catalog_error)?
692 .ok_or_else(|| public_shape("navigation relation is absent from catalog"))?;
693 let published = policy
694 .relations
695 .values()
696 .find(|published| published.object_id == table.id())
697 .ok_or_else(|| public_shape("navigation target relation is not published"))?;
698 let CatalogPayload::Table(table_payload) = table.payload() else {
699 return Err(public_shape("navigation target is not a table"));
700 };
701 let id = *table_payload
702 .column_ids()
703 .get(column.ordinal())
704 .ok_or_else(|| public_shape("navigation column ordinal is stale"))?;
705 if !published.admits_column_id(id) {
706 return Err(public_shape("navigation column is not published"));
707 }
708 accesses.entry(table.id()).or_default().insert(id);
709 }
710 }
711 Ok(accesses)
712}
713
714fn normalized_name(value: &str) -> Result<String> {
715 CatalogName::new(value)
716 .map(|name| name.normalized().as_str().to_owned())
717 .map_err(catalog_error)
718}
719
720fn catalog_error(error: radixdb_catalog::CatalogError) -> Error {
721 Error::invalid_argument(format!("catalog binding failed: {error}"))
722}
723
724fn public_shape(detail: &str) -> Error {
725 Error::invalid_argument(format!("public read rejected: {detail}"))
726}
727
728fn value_bytes(values: &[Value]) -> usize {
729 values.iter().map(single_value_bytes).sum()
730}
731
732fn row_bytes(row: &Row) -> usize {
733 row.iter().map(single_value_bytes).sum::<usize>()
734 + row.len().saturating_mul(std::mem::size_of::<Value>())
735}
736
737fn single_value_bytes(value: &Value) -> usize {
738 match value {
739 Value::Text(value) => value.len(),
740 Value::Extension(value) => value.len(),
741 _ => std::mem::size_of::<Value>(),
742 }
743}