Skip to main content

cratefield_tables_api/
write.rs

1//! Writing a declared table's rows (issue #153).
2//!
3//! Three: create a row, replace one, delete one. Each goes through
4//! [`crate::access::may_write`] first — a separate decision from the read
5//! one, because `public-read` reads for everybody and writes for nobody
6//! and a single function answering both would need a parameter saying
7//! which.
8//!
9//! # A row a caller writes is a row they own
10//!
11//! Under `owner` the subject column is settled by the harness rather than
12//! taken from the body, so a caller cannot write a row into somebody
13//! else's name. See [`crate::access::settle_subject`] for why a body
14//! naming a different subject is refused rather than quietly corrected.
15//!
16//! # Replace, not merge
17//!
18//! `replace` writes every declared non-key field from the body. A merge
19//! would make "unset this field" unexpressible: an absent key and a null
20//! one would both have to mean "leave it".
21//!
22//! # A conflict, and whose it is
23//!
24//! A unique violation on create says *a* row holds the values already;
25//! under `owner` whose row it is decides what may be said about it. The
26//! caller's own row is a conflict they can act on, and 409 says so. A
27//! row belonging to somebody else is answered like any other rejected
28//! write: a 409 would confirm a row the reads refuse to confirm, which
29//! is why they answer 404 for one. See this module's `taken`.
30
31use cratefield_core::{Caller, Database, Problem, ProblemDef, Scope, Tenancy, require_admin};
32use cratefield_tables::{Owned, TableDef, UpdateError};
33use http::{HeaderMap, StatusCode};
34use serde_json::{Map, Value};
35
36use crate::access::{Reach, may_write, settle_subject};
37use crate::read::{NO_SUCH_ROW, Tables, who};
38
39/// The body was not a legal row for this table. Its own slug rather than
40/// core's `validation-failed`: that is a 400 for a request that did not
41/// deserialize; this body parsed and still named no row the table
42/// declares, so it rides a 422.
43pub const NOT_A_ROW: ProblemDef = ProblemDef {
44    slug: "not-a-row",
45    status: StatusCode::UNPROCESSABLE_ENTITY,
46    title: "Not a row of this table",
47    description: "The body does not match the table's declared columns.",
48};
49
50/// A row with that key already exists.
51pub const ALREADY_EXISTS: ProblemDef = ProblemDef {
52    slug: "already-exists",
53    status: StatusCode::CONFLICT,
54    title: "That key is taken",
55    description: "A row of this table already has that primary key.",
56};
57
58/// The decision and the scope for a write, shared by all three.
59async fn permit(
60    tables: &Tables,
61    tenancy: Tenancy,
62    headers: &HeaderMap,
63    name: &str,
64) -> Result<(crate::access::TableApi, Reach), Problem> {
65    let api = tables.declared(name).cloned().ok_or_else(|| {
66        // The same 404 a read gives for a table nobody declared: a
67        // distinguishable answer would say which names exist.
68        Problem::new(&crate::read::NO_SUCH_TABLE)
69    })?;
70    let caller: Caller = who(tables, headers, &api).await?;
71    let reach = may_write(
72        &api,
73        tenancy,
74        &caller,
75        require_admin(tables.ctx.config.as_ref(), headers),
76    )?;
77    Ok((api, reach))
78}
79
80/// The scope as the condition a statement takes.
81fn owned<'a>(reach: &'a Reach, subject: &'a Value) -> Option<Owned<'a>> {
82    match reach {
83        Reach::Everything => None,
84        Reach::OwnedBy { column, .. } => Some(Owned { column, subject }),
85    }
86}
87
88fn subject_value(reach: &Reach) -> Value {
89    match reach {
90        Reach::Everything => Value::Null,
91        Reach::OwnedBy { subject, .. } => Value::String(subject.clone()),
92    }
93}
94
95/// Creates one row.
96///
97/// # Errors
98///
99/// The access decision's refusal, [`NOT_A_ROW`] when the body is not a
100/// legal row, [`ALREADY_EXISTS`] when the key collides with a row the
101/// caller may be told about — under `owner`, their own — the ordinary
102/// rejection when it collides with one they may not, or a database
103/// failure.
104pub async fn create(
105    tables: &Tables,
106    conn: &dyn Database,
107    tenancy: Tenancy,
108    headers: &HeaderMap,
109    scope: &Scope,
110    name: &str,
111    body: Value,
112) -> Result<Value, Problem> {
113    let (api, reach) = permit(tables, tenancy, headers, name).await?;
114    let mut row = body;
115    settle_subject(&reach, &mut row)?;
116
117    let statement = cratefield_tables::insert(&api.table, &row)
118        .map_err(|errors| Problem::new(&NOT_A_ROW).with_detail(errors.detail()))?;
119    match conn.execute(&statement).await {
120        Ok(_) => Ok(row),
121        // A unique violation is *a* row holding the values already;
122        // whose row it is decides the answer, and deciding takes a
123        // re-select (`taken`). Anything else is ours. The adapters do
124        // not give a typed constraint error, so this reads the message —
125        // and answers the generic failure when it cannot tell, rather
126        // than claiming a conflict it has not established.
127        Err(err) if looks_like_a_conflict(&err) => {
128            Err(taken(conn, &api, &reach, &row, scope).await)
129        }
130        Err(err) => {
131            tracing::error!(error = %err, "a declared table's insert failed");
132            Err(Problem::internal().instance(&scope.request_id))
133        }
134    }
135}
136
137/// What a collision on insert means, under the reach the write was
138/// permitted with.
139///
140/// A unique violation establishes that a row holds the values already;
141/// whose row it is decides what may be said about it. Where the reach is
142/// not the caller's own rows, every row is theirs to collide with, and
143/// [`ALREADY_EXISTS`] is the whole answer.
144///
145/// Under [`Reach::OwnedBy`] it is not. A 409 for a collision with a row
146/// belonging to somebody else would confirm a row the caller was never
147/// in a position to learn exists — the fact the reads refuse to confirm
148/// by answering 404 for a row that is not there *or* not theirs
149/// ([`crate::read::one`]). So the conflicting row is selected again with
150/// the owner predicate applied — `select_one`, the statement a read by
151/// key takes, built from the same `owned` scope the writes already
152/// thread through, so the two cannot drift — and 409 is answered only
153/// when that select matches: the caller's own row is the collision, and
154/// the conflict is theirs to fix.
155///
156/// Every other outcome declines to assert a conflict nothing selected.
157/// A key the row does not carry, so the select cannot even be built (a
158/// collision on a column `unique` beyond the key lands here, and the
159/// own-row 409 it gives up is the price of never claiming one), answers
160/// [`NOT_A_ROW`]; so does a select that built and matched nothing — the
161/// ordinary rejection of a write, which says this write did not happen
162/// and nothing about the table. A select that will not build is a
163/// declaration the serving code did not expect, and a database that
164/// would not answer it is down; both are 500s, logged where constructed
165/// as every other failure to reach the database here is.
166///
167/// The re-select runs on both sides of the answer, so an own-row
168/// conflict and somebody else's do the same work and are told apart
169/// only by the answer each earned.
170async fn taken(
171    conn: &dyn Database,
172    api: &crate::access::TableApi,
173    reach: &Reach,
174    row: &Value,
175    scope: &Scope,
176) -> Problem {
177    let subject = subject_value(reach);
178    let Some(owned_rows) = owned(reach, &subject) else {
179        return Problem::new(&ALREADY_EXISTS);
180    };
181    let Some(key) = key_of(&api.table, row) else {
182        return Problem::new(&NOT_A_ROW);
183    };
184    let statement = match cratefield_tables::select_one(&api.table, &key, Some(owned_rows)) {
185        Ok(statement) => statement,
186        // The subject column was checked when the reach was decided (in
187        // `access`), so this is not a refusal about the caller.
188        Err(err) => {
189            tracing::error!(
190                error = %err,
191                "a declared table's conflict re-select could not be built"
192            );
193            return Problem::new(&crate::access::MISDECLARED).instance(&scope.request_id);
194        }
195    };
196    match conn.query(&statement).await {
197        // The row the key selects is the caller's own: the conflict is
198        // real, and the key is theirs to pick again.
199        Ok(rows) if !rows.is_empty() => Problem::new(&ALREADY_EXISTS),
200        // Nothing of theirs holds the key, so the collision is with a
201        // row the caller may not be told about — or, on a second look,
202        // with nothing at all. The ordinary rejection says the same
203        // thing about both.
204        Ok(_) => Problem::new(&NOT_A_ROW),
205        Err(err) => {
206            tracing::error!(error = %err, "a declared table's conflict re-select failed");
207            Problem::internal().instance(&scope.request_id)
208        }
209    }
210}
211
212/// The row's own primary key, as the JSON `select_one` takes it — or
213/// `None` when the row does not carry every key column, and no select
214/// could be built from it. A null counts as not carried: a key column
215/// is `NOT NULL`, so a null key never collided and says nothing about
216/// the row that did.
217fn key_of(table: &TableDef, row: &Value) -> Option<Value> {
218    let mut key = Map::new();
219    for column in &table.primary_key {
220        let value = row.get(column)?;
221        if value.is_null() {
222            return None;
223        }
224        key.insert(column.clone(), value.clone());
225    }
226    Some(Value::Object(key))
227}
228
229/// Whether a database error is a primary-key or unique collision.
230///
231/// Message matching, which is why it is written down rather than inlined:
232/// the `Database` port has no typed constraint error, so the two engines'
233/// own phrases are what there is — SQLite and D1 say `UNIQUE constraint
234/// failed`, Postgres says `duplicate key value violates unique
235/// constraint`.
236///
237/// **The phrases, not the word.** Matching a bare `unique` matched the
238/// table's *name* too: a venture may declare a table called
239/// `unique_codes`, and then `no such table: unique_codes` — the shape of
240/// a half-applied migration — came back as `409 already-exists`. The
241/// caller is told the key is taken, tries another, and is told the same;
242/// the real failure never appears, because the conflict branch does not
243/// log. Both phrases contain a space, which a declared name cannot.
244///
245/// A message that matches neither falls through to the generic failure,
246/// which is the safe direction: a 500 where a 409 was due costs a
247/// retry, and a 409 that did not happen sends a caller looking for a row
248/// that is not there.
249fn looks_like_a_conflict(err: &cratefield_core::DbError) -> bool {
250    let text = format!("{err:?}").to_ascii_lowercase();
251    text.contains("unique constraint failed") || text.contains("violates unique constraint")
252}
253
254/// Replaces one row, by key.
255///
256/// # Errors
257///
258/// The access decision's refusal, [`NOT_A_ROW`], [`NO_SUCH_ROW`] when
259/// nothing was changed — which under `owner` also covers a row belonging
260/// to somebody else — or a database failure.
261// One over the lint's seven: the tenancy arrived and every argument
262// earns its place. Folding the rest into a struct to hide the count
263// would trade a visible signature for a `Asked`-style one nobody asked
264// for — `page` earned that struct; this grew by one parameter, once.
265#[allow(clippy::too_many_arguments)]
266pub async fn replace(
267    tables: &Tables,
268    conn: &dyn Database,
269    tenancy: Tenancy,
270    headers: &HeaderMap,
271    scope: &Scope,
272    name: &str,
273    key: &Value,
274    body: Value,
275) -> Result<Value, Problem> {
276    let (api, reach) = permit(tables, tenancy, headers, name).await?;
277    let mut row = body;
278    settle_subject(&reach, &mut row)?;
279    let subject = subject_value(&reach);
280
281    let statement = cratefield_tables::update(&api.table, key, &row, owned(&reach, &subject))
282        .map_err(|err| match err {
283            UpdateError::Row(errors) => Problem::new(&NOT_A_ROW).with_detail(errors.detail()),
284            UpdateError::Key(err) => {
285                tracing::error!(error = %err, "a declared table's update could not be built");
286                Problem::new(&crate::access::MISDECLARED).instance(&scope.request_id)
287            }
288            // `UpdateError` is `#[non_exhaustive]`. A refusal this build
289            // cannot read is not one it can attribute to the caller, so
290            // it does not: 500, with the reason in the log.
291            other => {
292                tracing::error!(error = %other, "a refusal this build does not understand");
293                Problem::internal().instance(&scope.request_id)
294            }
295        })?;
296    let changed = conn.execute(&statement).await.map_err(|err| {
297        tracing::error!(error = %err, "a declared table's update failed");
298        Problem::internal().instance(&scope.request_id)
299    })?;
300    if changed == 0 {
301        // No row, or not this caller's row. The same answer on purpose:
302        // a distinguishable one confirms a row they were never in a
303        // position to learn exists.
304        return Err(Problem::new(&NO_SUCH_ROW));
305    }
306    Ok(row)
307}
308
309/// Deletes one row, by key.
310///
311/// # Errors
312///
313/// The access decision's refusal, [`NO_SUCH_ROW`] when nothing was
314/// deleted, or a database failure.
315pub async fn remove(
316    tables: &Tables,
317    conn: &dyn Database,
318    tenancy: Tenancy,
319    headers: &HeaderMap,
320    scope: &Scope,
321    name: &str,
322    key: &Value,
323) -> Result<(), Problem> {
324    let (api, reach) = permit(tables, tenancy, headers, name).await?;
325    let subject = subject_value(&reach);
326
327    let statement =
328        cratefield_tables::delete(&api.table, key, owned(&reach, &subject)).map_err(|err| {
329            tracing::error!(error = %err, "a declared table's delete could not be built");
330            Problem::new(&crate::access::MISDECLARED).instance(&scope.request_id)
331        })?;
332    let removed = conn.execute(&statement).await.map_err(|err| {
333        tracing::error!(error = %err, "a declared table's delete failed");
334        Problem::internal().instance(&scope.request_id)
335    })?;
336    if removed == 0 {
337        return Err(Problem::new(&NO_SUCH_ROW));
338    }
339    Ok(())
340}