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}