cratefield_tables_api/batch.rs
1//! Several declared reads in one request (issue #153).
2//!
3//! A sidecar or a browser client rendering a page from four tables pays
4//! four round trips otherwise, and a round trip to a Worker is the
5//! expensive part of a read that a database answers in a millisecond.
6//!
7//! # What this does and does not buy
8//!
9//! One round trip. **Not** parallelism: the reads run in sequence against
10//! the request's one database handle, because that is what a handle is.
11//! Saying otherwise would be claiming a property the shape cannot have —
12//! and a caller who believed it would size their batches by the wrong
13//! number.
14//!
15//! # All or nothing
16//!
17//! A batch whose caller may not make one of its reads is refused whole,
18//! naming the read. The alternative — a `200` carrying a refusal per
19//! result — is a success that is not one, and every client would have to
20//! remember to look inside it. A refusal that reads as success is worse
21//! than a refusal.
22//!
23//! # The path cannot be a table
24//!
25//! `__batch` is not a legal declared table name: a name must start with a
26//! lowercase letter and may not contain `__`. So this route cannot shadow
27//! a venture's own table, and `the_batch_path_can_never_be_a_table_name`
28//! says so rather than leaving it to be noticed.
29
30use std::sync::Arc;
31
32use cratefield_core::{Database, Problem, ProblemDef, Scope, Tenancy};
33use http::{HeaderMap, StatusCode};
34use serde::Deserialize;
35use serde_json::{Map, Value, json};
36
37use crate::read::Tables;
38
39/// The path the batch is served at, under the module's own mount.
40pub const PATH: &str = "/__batch";
41
42/// The most reads one batch may ask for.
43///
44/// A cap, because a batch is a fan-out a single request controls: without
45/// one, a caller who may read one table may read it two thousand times in
46/// a request the gateway counts as one.
47pub const MAX_READS: usize = 20;
48
49/// The batch asked for more reads than one request may carry.
50pub const TOO_MANY_READS: ProblemDef = ProblemDef {
51 slug: "too-many-reads",
52 status: StatusCode::BAD_REQUEST,
53 title: "Too many reads in one batch",
54 description: "A batch carries at most 20 reads; send the rest as another batch.",
55};
56
57/// The batch asked for nothing.
58pub const NO_READS: ProblemDef = ProblemDef {
59 slug: "no-reads",
60 status: StatusCode::BAD_REQUEST,
61 title: "A batch with no reads",
62 description: "A batch names at least one read; an empty one is a request with no question.",
63};
64
65/// One read in a batch.
66#[derive(Debug, Deserialize)]
67pub struct Read {
68 /// The declared table.
69 pub table: String,
70 /// Where the page starts — the `next` from a previous read of this
71 /// table, exactly as it was given.
72 #[serde(default)]
73 pub after: Option<Value>,
74 /// `sort=column` or `sort=-column`, the same shape the query string
75 /// takes.
76 #[serde(default)]
77 pub sort: Option<String>,
78 /// Equality filters, keyed by column.
79 ///
80 /// JSON values rather than the query string's text, because a body
81 /// can carry a number as a number. The column's kind still decides
82 /// whether the value is one it can be compared against.
83 #[serde(default, rename = "where")]
84 pub filters: Map<String, Value>,
85}
86
87/// What a batch asks for.
88#[derive(Debug, Deserialize)]
89pub struct Batch {
90 /// The reads, answered in this order.
91 pub reads: Vec<Read>,
92}
93
94/// Runs every read in `batch`, or refuses the batch.
95///
96/// # Errors
97///
98/// [`NO_READS`] or [`TOO_MANY_READS`] for a batch that is not one, and
99/// the first read's own refusal otherwise — access, an undeclared table,
100/// a bad cursor or a bad filter. The refusal names which read it was:
101/// "the third read of a batch failed" is not something a caller can act
102/// on without being told which the third was.
103pub async fn run(
104 tables: &Tables,
105 conn: &dyn Database,
106 tenancy: Tenancy,
107 headers: &HeaderMap,
108 scope: &Scope,
109 batch: &Batch,
110) -> Result<Value, Problem> {
111 if batch.reads.is_empty() {
112 return Err(Problem::new(&NO_READS));
113 }
114 if batch.reads.len() > MAX_READS {
115 return Err(Problem::new(&TOO_MANY_READS).with_detail(format!(
116 "{} reads were asked for and {MAX_READS} is the most",
117 batch.reads.len()
118 )));
119 }
120
121 let mut results = Vec::with_capacity(batch.reads.len());
122 for (at, read) in batch.reads.iter().enumerate() {
123 // Each read is decided on its own: the access level of the table
124 // it names, against this caller. A batch is a way to ask several
125 // questions in one request, never a way to ask one the caller
126 // could not ask alone.
127 let filters = filters_of(read);
128 let body = crate::read::page(
129 tables,
130 conn,
131 tenancy,
132 headers,
133 scope,
134 crate::read::Asked {
135 table: &read.table,
136 after: read.after.as_ref(),
137 filters: &filters,
138 sort: crate::routes::sort_of(read.sort.as_deref()),
139 },
140 )
141 .await
142 .map_err(|problem| {
143 // The caller sent an ordered list; the answer says which of
144 // them it is about.
145 let said = problem.detail.clone();
146 problem.with_detail(match said {
147 Some(detail) => format!("read {at} (`{}`): {detail}", read.table),
148 None => format!("read {at} (`{}`)", read.table),
149 })
150 })?;
151 results.push(json!({ "table": read.table, "rows": body["rows"], "next": body["next"] }));
152 }
153 Ok(json!({ "results": results }))
154}
155
156/// One read's filters, as the query layer takes them.
157///
158/// The columns are not checked here. `page` checks them against the same
159/// declaration, and doing it twice would be two places to keep in step —
160/// the batch's job is to reshape the map, not to re-decide anything.
161fn filters_of(read: &Read) -> Vec<cratefield_tables::Filter> {
162 read.filters
163 .iter()
164 .map(|(column, value)| cratefield_tables::Filter {
165 column: column.clone(),
166 value: value.clone(),
167 })
168 .collect()
169}
170
171/// The state the route needs.
172pub type State = Arc<Tables>;