Skip to main content

table_editor/
table.rs

1//! What a repository implements, and what the router holds.
2//!
3//! A repository implements [`TableLogic`] once per table and [`App`] once for
4//! the collection. [`Table`] is the object-safe façade the router dispatches
5//! through; a blanket implementation covers every [`TableLogic`], so nothing
6//! outside this module implements it. Its methods are named apart from
7//! `TableLogic`'s so that a type implementing both can call either without
8//! disambiguation.
9
10use serde::de::DeserializeOwned;
11use serde::{Deserialize, Serialize};
12
13use crate::context::Context;
14use crate::error::{ApiError, ParseError, ValidationError};
15use crate::jsonl;
16use crate::schema::Schema;
17use crate::view::View;
18
19/// Where the editor opens when the address names nothing.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Front {
22    /// The first table the app lists, which is what an app that says nothing
23    /// gets.
24    FirstTable,
25    Table(&'static str),
26    View(&'static str),
27}
28
29/// One repository's editor: a name for the shell, the tables it serves, and
30/// the views it computes.
31pub trait App: Send + Sync + 'static {
32    fn name(&self) -> &str;
33
34    fn subtitle(&self) -> Option<&str> {
35        None
36    }
37
38    /// The tables in the order the shell lists them. The first is what the
39    /// editor opens when nothing else is named.
40    fn tables(&self) -> Vec<&dyn Table>;
41
42    /// The views in the order the shell lists them, before the tables. An app
43    /// of tables alone leaves this alone and serves none.
44    fn views(&self) -> Vec<&dyn View> {
45        Vec::new()
46    }
47
48    /// What a bare address opens.
49    fn front(&self) -> Front {
50        Front::FirstTable
51    }
52
53    fn table(&self, route: &str) -> Option<&dyn Table> {
54        self.tables().into_iter().find(|t| t.route() == route)
55    }
56
57    fn view(&self, route: &str) -> Option<&dyn View> {
58        self.views().into_iter().find(|v| v.route() == route)
59    }
60}
61
62/// One table's per-repository logic.
63///
64/// `parse` and `serialize` default to plain JSONL, so a table whose row type
65/// serializes the way it is stored implements neither. `derive` and `siblings`
66/// default to nothing, so a table with no derived values and no cross-table
67/// data implements neither.
68pub trait TableLogic: Send + Sync + 'static {
69    type Row: Serialize + DeserializeOwned + Send + Sync;
70
71    /// The route segment and `?table=` value, such as `books`. The names
72    /// `app`, `health`, `shutdown`, and `stop` are reserved: the first three
73    /// are control endpoints and the fourth is the `stop` subcommand, which
74    /// clap takes before the positional table name.
75    fn name(&self) -> &'static str;
76
77    /// The file under `Data/`, such as `Books.jsonl`.
78    ///
79    /// It is a bare file name: no directory separators, nothing absolute, and
80    /// not `.` or `..`. Building a [`crate::Server`] over a table that names
81    /// anything else panics. It must exist before the editor can open the
82    /// table; the editor edits a table, it does not create one.
83    fn file(&self) -> &'static str;
84
85    /// The heading the shell shows, such as `Books`.
86    fn title(&self) -> &'static str;
87
88    /// Rebuilt on every read, so sibling-derived options and widths are
89    /// current.
90    fn schema(&self, ctx: &Context) -> Result<Schema, ApiError>;
91
92    fn parse(&self, text: &str) -> Result<Vec<Self::Row>, ParseError> {
93        jsonl::parse(text)
94    }
95
96    fn serialize(&self, rows: &[Self::Row]) -> Result<String, serde_json::Error> {
97        jsonl::serialize(rows)
98    }
99
100    /// Problems with the rows, each reported against the row's one-based
101    /// position in the set. A write is not refused because of them: the editor
102    /// persists what it is given and shows the errors beside the cells.
103    fn validate(&self, rows: &[Self::Row], ctx: &Context)
104    -> Result<Vec<ValidationError>, ApiError>;
105
106    /// Values the editor displays but does not store, parallelling `rows` index
107    /// for index. A `computed` column reads one of the keys of each row's
108    /// object through its `from`.
109    fn derive(
110        &self,
111        _rows: &[Self::Row],
112        _ctx: &Context,
113    ) -> Result<Vec<serde_json::Value>, ApiError> {
114        Ok(Vec::new())
115    }
116
117    /// Cross-table data a bespoke editor needs. The schema-driven editor
118    /// ignores it.
119    fn siblings(&self, _ctx: &Context) -> Result<serde_json::Value, ApiError> {
120        Ok(serde_json::json!({}))
121    }
122}
123
124/// The object-safe façade the router holds. Each method returns the JSON body
125/// of one endpoint.
126pub trait Table: Send + Sync {
127    /// The route segment, from [`TableLogic::name`].
128    fn route(&self) -> &'static str;
129
130    /// The shell's heading, from [`TableLogic::title`].
131    fn heading(&self) -> &'static str;
132
133    /// The file under `Data/`, from [`TableLogic::file`].
134    fn data_file(&self) -> &'static str;
135
136    /// `GET /api/<table>`: the schema, the stored rows, their derivation, their
137    /// validation errors, and any sibling data.
138    fn handle_get(&self, ctx: &Context) -> Result<String, ApiError>;
139
140    /// `PUT /api/<table>`: write the posted rows, then return the derivation of
141    /// what was written.
142    fn handle_put(&self, ctx: &Context, body: &str) -> Result<String, ApiError>;
143
144    /// `POST /api/<table>/derive`: derive and validate the posted rows without
145    /// writing.
146    fn handle_derive(&self, ctx: &Context, body: &str) -> Result<String, ApiError>;
147}
148
149impl<T: TableLogic> Table for T {
150    fn route(&self) -> &'static str {
151        self.name()
152    }
153
154    fn heading(&self) -> &'static str {
155        self.title()
156    }
157
158    fn data_file(&self) -> &'static str {
159        self.file()
160    }
161
162    fn handle_get(&self, ctx: &Context) -> Result<String, ApiError> {
163        let file = self.file();
164        let text = ctx.read(file)?;
165        let rows = self
166            .parse(&text)
167            .map_err(|e| ApiError::from_parse(file, &e))?;
168
169        let mut schema = self.schema(ctx)?;
170        schema.identify(self.name(), self.title());
171
172        to_json(&GetPayload {
173            schema,
174            rows: &rows,
175            derived: self.derive(&rows, ctx)?,
176            errors: self.validate(&rows, ctx)?,
177            siblings: self.siblings(ctx)?,
178        })
179    }
180
181    fn handle_put(&self, ctx: &Context, body: &str) -> Result<String, ApiError> {
182        let file = self.file();
183        let rows: Vec<T::Row> = parse_rows(body)?;
184        let text = self
185            .serialize(&rows)
186            .map_err(|e| ApiError::server(format!("could not serialize {file}: {e}")))?;
187        ctx.write(file, &text)?;
188        self.derive_payload(ctx, &rows)
189    }
190
191    fn handle_derive(&self, ctx: &Context, body: &str) -> Result<String, ApiError> {
192        let rows: Vec<T::Row> = parse_rows(body)?;
193        self.derive_payload(ctx, &rows)
194    }
195}
196
197/// The shared tail of a write and a derivation: validate and derive the rows in
198/// hand.
199trait DerivePayload: TableLogic {
200    fn derive_payload(&self, ctx: &Context, rows: &[Self::Row]) -> Result<String, ApiError> {
201        to_json(&DeriveResponse {
202            derived: self.derive(rows, ctx)?,
203            errors: self.validate(rows, ctx)?,
204        })
205    }
206}
207
208impl<T: TableLogic> DerivePayload for T {}
209
210/// The body of a PUT or derive request: the full set of rows for a table.
211#[derive(Deserialize)]
212struct RowsRequest<T> {
213    rows: Vec<T>,
214}
215
216/// `GET /api/<table>`. `rows` is borrowed to avoid a clone.
217#[derive(Serialize)]
218struct GetPayload<'a, R> {
219    schema: Schema,
220    rows: &'a [R],
221    derived: Vec<serde_json::Value>,
222    errors: Vec<ValidationError>,
223    siblings: serde_json::Value,
224}
225
226/// `PUT /api/<table>` and `POST /api/<table>/derive`.
227#[derive(Serialize)]
228struct DeriveResponse {
229    derived: Vec<serde_json::Value>,
230    errors: Vec<ValidationError>,
231}
232
233/// Read the posted rows, mapping a malformed body to a 400.
234fn parse_rows<T: DeserializeOwned>(body: &str) -> Result<Vec<T>, ApiError> {
235    let request: RowsRequest<T> = serde_json::from_str(body)
236        .map_err(|e| ApiError::bad_request(format!("invalid request body: {e}")))?;
237    Ok(request.rows)
238}
239
240/// Serialize a response value, mapping failure to a 500.
241fn to_json<T: Serialize>(value: &T) -> Result<String, ApiError> {
242    serde_json::to_string(value).map_err(|e| ApiError::server(e.to_string()))
243}
244
245#[cfg(test)]
246mod tests {
247    use serde_json::Value;
248
249    use super::*;
250    use crate::fixture::{self, BOOKS_FILE, Book, Books, GENRES_FILE, Genres};
251
252    #[test]
253    fn get_shapes_schema_rows_derived_errors_and_siblings() {
254        let dir = fixture::temp_dir();
255        dir.write(GENRES_FILE, fixture::NATURAL_HISTORY);
256        dir.write(BOOKS_FILE, fixture::MOSS);
257
258        let v: Value = serde_json::from_str(&Books.handle_get(&dir.context()).unwrap()).unwrap();
259
260        assert_eq!(v["rows"].as_array().unwrap().len(), 1);
261        assert_eq!(v["rows"][0]["title"], "A Field Guide to Moss");
262        assert_eq!(
263            v["derived"][0]["shelf"],
264            "A Field Guide to Moss, Natural History"
265        );
266        assert!(v["errors"].as_array().unwrap().is_empty());
267        assert_eq!(v["siblings"]["genres"][0]["subgenre"], "Natural History");
268    }
269
270    #[test]
271    fn get_names_the_schema_after_the_table() {
272        let dir = fixture::temp_dir();
273        dir.write(BOOKS_FILE, fixture::MOSS);
274
275        let v: Value = serde_json::from_str(&Books.handle_get(&dir.context()).unwrap()).unwrap();
276        assert_eq!(v["schema"]["table"], Books.route());
277        assert_eq!(v["schema"]["title"], Books.heading());
278    }
279
280    #[test]
281    fn get_builds_dependent_options_from_the_sibling_table() {
282        let dir = fixture::temp_dir();
283        dir.write(GENRES_FILE, fixture::NATURAL_HISTORY);
284        dir.write(BOOKS_FILE, fixture::MOSS);
285
286        let v: Value = serde_json::from_str(&Books.handle_get(&dir.context()).unwrap()).unwrap();
287        let subgenre = &v["schema"]["columns"][2];
288
289        assert_eq!(subgenre["field"], "subgenre");
290        assert_eq!(subgenre["options_by"]["field"], "genre");
291        assert_eq!(
292            subgenre["options_by"]["options"]["Reference"][0]["value"],
293            "Natural History"
294        );
295        // max(8, len("Natural History")) + 4
296        assert_eq!(subgenre["width_ch"], 19);
297    }
298
299    #[test]
300    fn get_cross_checks_against_the_sibling_table() {
301        let dir = fixture::temp_dir();
302        dir.write(GENRES_FILE, fixture::NATURAL_HISTORY);
303        dir.write(
304            BOOKS_FILE,
305            r#"{"title":"A Field Guide to Moss","genre":"Reference","subgenre":"Field Guides"}"#,
306        );
307
308        let v: Value = serde_json::from_str(&Books.handle_get(&dir.context()).unwrap()).unwrap();
309        assert!(v["errors"].as_array().unwrap().iter().any(|e| {
310            e["field"] == "subgenre" && e["message"].as_str().unwrap().contains("not found")
311        }));
312    }
313
314    #[test]
315    fn get_skips_cross_checks_when_the_sibling_file_is_missing() {
316        let dir = fixture::temp_dir();
317        dir.write(
318            BOOKS_FILE,
319            r#"{"title":"A Field Guide to Moss","genre":"Reference","subgenre":"Field Guides"}"#,
320        );
321
322        let v: Value = serde_json::from_str(&Books.handle_get(&dir.context()).unwrap()).unwrap();
323        assert!(v["errors"].as_array().unwrap().is_empty());
324        assert!(v["siblings"]["genres"].as_array().unwrap().is_empty());
325    }
326
327    #[test]
328    fn get_of_a_plain_table_has_no_derivation_and_no_siblings() {
329        let dir = fixture::temp_dir();
330        dir.write(GENRES_FILE, fixture::NATURAL_HISTORY);
331
332        let v: Value = serde_json::from_str(&Genres.handle_get(&dir.context()).unwrap()).unwrap();
333
334        assert_eq!(v["rows"][0]["subgenre"], "Natural History");
335        assert!(v["derived"].as_array().unwrap().is_empty());
336        assert!(v["siblings"].as_object().unwrap().is_empty());
337    }
338
339    #[test]
340    fn one_request_sees_one_version_of_a_sibling_table() {
341        let dir = fixture::temp_dir();
342        dir.write(GENRES_FILE, fixture::NATURAL_HISTORY);
343        dir.write(BOOKS_FILE, fixture::MOSS);
344        let ctx = dir.context();
345
346        let first: Value = serde_json::from_str(&Books.handle_get(&ctx).unwrap()).unwrap();
347        assert!(first["errors"].as_array().unwrap().is_empty());
348
349        // The sibling is rewritten under the request. The schema, the
350        // validation, and the sibling payload are built from one read of it,
351        // so they still agree with each other and with what was served.
352        dir.write(
353            GENRES_FILE,
354            r#"{"genre":"Travel","subgenre":"Field Guides"}"#,
355        );
356        let again: Value = serde_json::from_str(&Books.handle_get(&ctx).unwrap()).unwrap();
357        assert_eq!(again["schema"], first["schema"]);
358        assert_eq!(again["siblings"], first["siblings"]);
359        assert!(again["errors"].as_array().unwrap().is_empty());
360
361        // The request after it reads the sibling afresh.
362        let later: Value =
363            serde_json::from_str(&Books.handle_get(&dir.context()).unwrap()).unwrap();
364        assert_eq!(later["siblings"]["genres"][0]["genre"], "Travel");
365        assert!(!later["errors"].as_array().unwrap().is_empty());
366    }
367
368    #[test]
369    fn get_of_a_missing_file_is_a_server_error() {
370        let dir = fixture::temp_dir();
371        assert_eq!(Books.handle_get(&dir.context()).unwrap_err().status, 500);
372    }
373
374    #[test]
375    fn put_writes_the_file_and_returns_the_derivation() {
376        let dir = fixture::temp_dir();
377        dir.write(GENRES_FILE, fixture::NATURAL_HISTORY);
378        let body = format!(r#"{{"rows":[{}]}}"#, fixture::MOSS);
379
380        let json = Books.handle_put(&dir.context(), &body).unwrap();
381        let v: Value = serde_json::from_str(&json).unwrap();
382        assert_eq!(
383            v["derived"][0]["shelf"],
384            "A Field Guide to Moss, Natural History"
385        );
386        assert!(v["errors"].as_array().unwrap().is_empty());
387
388        let written = dir.read(BOOKS_FILE);
389        assert!(written.contains("A Field Guide to Moss"));
390        assert!(written.ends_with('\n'));
391    }
392
393    #[test]
394    fn put_writes_even_with_validation_errors() {
395        let dir = fixture::temp_dir();
396        let body = r#"{"rows":[{"title":"","genre":"Reference","subgenre":"Natural History"}]}"#;
397
398        let json = Books.handle_put(&dir.context(), body).unwrap();
399        let v: Value = serde_json::from_str(&json).unwrap();
400        assert!(!v["errors"].as_array().unwrap().is_empty());
401        assert!(dir.path().join(BOOKS_FILE).exists());
402    }
403
404    #[test]
405    fn derive_does_not_write() {
406        let dir = fixture::temp_dir();
407        let body = format!(r#"{{"rows":[{}]}}"#, fixture::MOSS);
408
409        let json = Books.handle_derive(&dir.context(), &body).unwrap();
410        let v: Value = serde_json::from_str(&json).unwrap();
411        assert_eq!(
412            v["derived"][0]["shelf"],
413            "A Field Guide to Moss, Natural History"
414        );
415        assert!(!dir.path().join(BOOKS_FILE).exists());
416    }
417
418    #[test]
419    fn put_round_trips_through_get() {
420        let dir = fixture::temp_dir();
421        let body = format!(r#"{{"rows":[{}]}}"#, fixture::NATURAL_HISTORY);
422
423        Genres.handle_put(&dir.context(), &body).unwrap();
424        let v: Value = serde_json::from_str(&Genres.handle_get(&dir.context()).unwrap()).unwrap();
425        assert_eq!(v["rows"][0]["genre"], "Reference");
426        assert_eq!(v["rows"][0]["subgenre"], "Natural History");
427    }
428
429    #[test]
430    fn parse_rows_rejects_a_malformed_body() {
431        assert_eq!(parse_rows::<Book>("not json").unwrap_err().status, 400);
432    }
433
434    #[test]
435    fn parse_rows_rejects_a_body_without_rows() {
436        assert_eq!(parse_rows::<Book>("{}").unwrap_err().status, 400);
437    }
438
439    #[test]
440    fn every_computed_column_names_a_key_the_derivation_emits() {
441        let dir = fixture::temp_dir();
442        dir.write(BOOKS_FILE, fixture::MOSS);
443
444        let v: Value = serde_json::from_str(&Books.handle_get(&dir.context()).unwrap()).unwrap();
445        let derived = v["derived"][0].as_object().unwrap();
446
447        let mut checked = 0;
448        for column in v["schema"]["columns"].as_array().unwrap() {
449            if column["type"] == "computed" {
450                let from = column["from"].as_str().unwrap();
451                assert!(derived.contains_key(from), "derivation lacks {from}");
452                checked += 1;
453            }
454        }
455        assert!(checked > 0, "the fixture has no computed column to check");
456    }
457}