ssh_commander_pg/edit.rs
1//! Cell-level row editing for the explorer.
2//!
3//! Sprint 14 model: editing is enabled only on tabs opened from the
4//! schema browser ("double-click a relation"), where the explorer
5//! knows the (schema, table) and the auto-generated SELECT carries
6//! `ctid AS __pg_rowid__`. The UI extracts that hidden column to use
7//! as the row identifier for UPDATEs.
8//!
9//! ## Why ctid
10//!
11//! `ctid` is Postgres's internal per-row identifier on heap tables.
12//! It is:
13//!
14//! - Always unique within a relation (so the WHERE matches at most
15//! one row).
16//! - Always present (no PK assumption needed).
17//! - Mutated on every UPDATE — which gives us *free* optimistic
18//! locking. If two clients race to edit the same row, the second
19//! UPDATE matches zero rows and we surface "row no longer there"
20//! instead of clobbering.
21//!
22//! Limitation: views, materialized views, and foreign tables don't
23//! have a meaningful ctid. We don't try to edit them; the UI gates
24//! editing on the relation kind already.
25//!
26//! ## Type binding
27//!
28//! Values cross the FFI as `String`. We bind `$1` as text and cast
29//! it to the column's declared type server-side: `SET "col" = $1::int4`.
30//! Postgres's text-input parser handles the heavy lifting (timestamps,
31//! arrays, ranges, JSON). Setting NULL skips the parameter entirely
32//! since `''::int4` would fail.
33//!
34//! Identifiers are quoted defensively (PG allows mixed-case and
35//! reserved-word identifiers; an unquoted `Order` would target a
36//! lowercase `order`). Type names from `pg_type.typname` are also
37//! quoted to be safe — most don't need it but the cost is zero.
38
39use serde::{Deserialize, Serialize};
40use tokio_postgres::Client;
41
42use crate::PgError;
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct UpdateOutcome {
46 /// Number of rows the UPDATE matched. `1` for the happy path,
47 /// `0` if the ctid is gone (row was deleted or modified by
48 /// another session) — UI surfaces that as a refresh prompt.
49 pub rows_affected: u64,
50}
51
52/// Issue a single-cell UPDATE. `new_value: None` means SET NULL.
53/// `column_type` is the column's `pg_type.typname` (e.g. `int4`,
54/// `timestamptz`); used for the server-side text→typed cast.
55pub async fn update_cell(
56 client: &Client,
57 schema: &str,
58 table: &str,
59 column: &str,
60 column_type: &str,
61 new_value: Option<&str>,
62 ctid: &str,
63) -> Result<UpdateOutcome, PgError> {
64 let qualified = format!("{}.{}", quote_ident(schema), quote_ident(table));
65 let col = quote_ident(column);
66
67 // ctid is bound as text and cast server-side via `::text::tid`. The
68 // intermediate `::text` is required: with a bare `$N::tid`, Postgres
69 // infers the parameter's own type as `tid`, and tokio-postgres
70 // (postgres-types >= 0.2.14) then rejects the `&str` bind with
71 // `WrongType { postgres: Tid, rust: "&str" }`. Forcing the parameter
72 // to `text` first lets the `&str` bind, then casts to `tid`
73 // server-side. The standard text form `(0,1)` is what Postgres
74 // returns from `SELECT ctid`, so round-tripping as a String just
75 // works. (The delete path already double-casts via `::text[]::tid[]`.)
76 let rows_affected = match new_value {
77 Some(value) => {
78 let ty = validate_type_expression(column_type)?;
79 let sql = format!(
80 "UPDATE {qualified} SET {col} = $1::text::{ty} WHERE ctid = $2::text::tid",
81 qualified = qualified,
82 col = col,
83 ty = ty,
84 );
85 client
86 .execute(&sql, &[&value, &ctid])
87 .await
88 .map_err(PgError::Driver)?
89 }
90 None => {
91 let sql = format!(
92 "UPDATE {qualified} SET {col} = NULL WHERE ctid = $1::text::tid",
93 qualified = qualified,
94 col = col,
95 );
96 client
97 .execute(&sql, &[&ctid])
98 .await
99 .map_err(PgError::Driver)?
100 }
101 };
102
103 Ok(UpdateOutcome { rows_affected })
104}
105
106/// One column's worth of input for an INSERT. Caller emits a list
107/// of these for the columns it wants to set explicitly; columns not
108/// in the list are left to the server's `DEFAULT` (which honors
109/// `pg_attrdef`-defined defaults, sequences, generated values, etc).
110#[derive(Debug, Clone)]
111pub struct InsertColumnInput {
112 pub name: String,
113 /// `pg_type.typname` for the server-side text→typed cast.
114 pub type_name: String,
115 /// `None` writes SQL NULL; `Some(text)` is bound and cast.
116 pub value: Option<String>,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct InsertedRow {
121 /// Cells in the order specified by the caller's `return_columns`.
122 /// Mirrors the FFI execution-result row shape so the UI can
123 /// append directly to its in-memory rows array.
124 pub cells: Vec<Option<String>>,
125}
126
127/// Insert one row, returning the requested columns for the new row.
128///
129/// `return_columns` controls the `RETURNING` clause and the order
130/// of cells in the result. The UI typically passes the same column
131/// names + `__pg_rowid__` (which becomes `ctid` server-side) the
132/// existing result already shows, so the new row slots straight
133/// into the in-memory grid.
134pub async fn insert_row(
135 client: &Client,
136 schema: &str,
137 table: &str,
138 inputs: &[InsertColumnInput],
139 return_columns: &[String],
140) -> Result<InsertedRow, PgError> {
141 let qualified = format!("{}.{}", quote_ident(schema), quote_ident(table));
142
143 // Empty inputs → INSERT … DEFAULT VALUES. Works iff every
144 // column either has a default or accepts NULL; otherwise the
145 // server complains and we surface that error.
146 let column_clause: String;
147 let values_clause: String;
148 if inputs.is_empty() {
149 column_clause = String::new();
150 values_clause = "DEFAULT VALUES".to_string();
151 } else {
152 let cols = inputs
153 .iter()
154 .map(|i| quote_ident(&i.name))
155 .collect::<Vec<_>>()
156 .join(", ");
157 column_clause = format!(" ({cols})");
158 let placeholders = inputs
159 .iter()
160 .enumerate()
161 .map(|(idx, i)| {
162 validate_type_expression(&i.type_name).map(|ty| format!("${}::text::{ty}", idx + 1))
163 })
164 .collect::<Result<Vec<_>, PgError>>()?
165 .join(", ");
166 values_clause = format!("VALUES ({placeholders})");
167 }
168
169 // Cast every RETURNING column to text so we can read the row as
170 // `Option<String>` regardless of underlying type. Postgres's
171 // text output format covers every type the server can render,
172 // mirroring the read path the rest of the explorer uses.
173 let returning = if return_columns.is_empty() {
174 // Caller didn't ask for anything back — return ctid alone
175 // so the UI at least gets a row identity to work with.
176 "ctid::text AS \"__pg_rowid__\"".to_string()
177 } else {
178 return_columns
179 .iter()
180 .map(|name| {
181 let alias = quote_ident(name);
182 if name == "__pg_rowid__" {
183 format!("ctid::text AS {alias}")
184 } else {
185 format!("{}::text AS {alias}", quote_ident(name))
186 }
187 })
188 .collect::<Vec<_>>()
189 .join(", ")
190 };
191
192 let sql = format!(
193 "INSERT INTO {qualified}{column_clause} {values_clause} RETURNING {returning}",
194 qualified = qualified,
195 column_clause = column_clause,
196 values_clause = values_clause,
197 returning = returning,
198 );
199
200 // Parameter list: each input contributes one `Option<&str>`.
201 // tokio_postgres' `Option<&str>` impl serializes to NULL when
202 // `None`, so we don't branch SQL for nulls.
203 let params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = inputs
204 .iter()
205 .map(|i| &i.value as &(dyn tokio_postgres::types::ToSql + Sync))
206 .collect();
207
208 // `simple_query` doesn't take params, so we use the extended
209 // protocol via `query`. The RETURNING shape is fixed so we can
210 // pull each value as a `&str` (text-coerced via `pg_get_typeof`
211 // wouldn't be needed — `query` returns binary by default but
212 // we get text for unknown types).
213 //
214 // Actually using `simple_query` would force a text protocol which
215 // gives us uniform `Option<&str>` extraction. The downside is
216 // we lose typed parameter binding. The compromise: use `query`
217 // with the bound params, then for each column read a
218 // `Option<String>` via the postgres-types Text feature. Since
219 // we only RETURNING-cast one row, the cost is negligible.
220 let rows = client.query(&sql, ¶ms).await.map_err(PgError::Driver)?;
221 let row = rows.into_iter().next().ok_or_else(|| {
222 // RETURNING on a successful single-row INSERT must produce
223 // exactly one row. Anything else is a server-side surprise
224 // we surface rather than panic on.
225 PgError::Connect("INSERT returned no row".to_string())
226 })?;
227
228 // Every RETURNING column was cast to text in the SQL above, so
229 // each column reads cleanly as `Option<String>`.
230 let mut cells: Vec<Option<String>> = Vec::with_capacity(row.len());
231 for idx in 0..row.len() {
232 let v: Option<String> = row.try_get(idx).map_err(PgError::Driver)?;
233 cells.push(v);
234 }
235
236 Ok(InsertedRow { cells })
237}
238
239/// Delete one or more rows identified by their ctids. Returns the
240/// number of rows actually deleted (callers compare against the
241/// requested count to surface "some rows were already gone" — the
242/// same optimistic-locking semantic as cell UPDATEs).
243///
244/// One round trip via `DELETE … WHERE ctid = ANY($1)`. ctids are
245/// passed as a `text[]` and cast to `tid[]` server-side, since
246/// `tokio_postgres` doesn't have a native `tid[]` ToSql impl.
247pub async fn delete_rows(
248 client: &Client,
249 schema: &str,
250 table: &str,
251 ctids: &[String],
252) -> Result<UpdateOutcome, PgError> {
253 if ctids.is_empty() {
254 return Ok(UpdateOutcome { rows_affected: 0 });
255 }
256 let qualified = format!("{}.{}", quote_ident(schema), quote_ident(table));
257 let sql = format!(
258 "DELETE FROM {qualified} WHERE ctid = ANY($1::text[]::tid[])",
259 qualified = qualified,
260 );
261 let rows_affected = client
262 .execute(&sql, &[&ctids])
263 .await
264 .map_err(PgError::Driver)?;
265 Ok(UpdateOutcome { rows_affected })
266}
267
268/// Postgres double-quote escaping. Embedded `"` becomes `""`; the
269/// whole identifier is wrapped in double quotes. Defensive against
270/// names with spaces, mixed case, or reserved words.
271fn quote_ident(s: &str) -> String {
272 format!("\"{}\"", s.replace('"', "\"\""))
273}
274
275/// Validate a type expression produced by `pg_catalog.format_type`.
276///
277/// PostgreSQL does not let a cast target be bound as a parameter, so
278/// edit/insert SQL has to embed the type expression. We accept the
279/// conservative subset emitted for built-in/common formatted types
280/// (`integer`, `character varying(255)`, `timestamp with time zone`,
281/// `numeric(10,2)`, `"Schema"."Type"[]`) and reject shell/SQL
282/// metacharacters that could terminate the cast expression.
283fn validate_type_expression(s: &str) -> Result<&str, PgError> {
284 let trimmed = s.trim();
285 if trimmed.is_empty() {
286 return Err(PgError::InvalidInput(
287 "type expression is empty".to_string(),
288 ));
289 }
290
291 if !trimmed.chars().all(|c| {
292 c.is_ascii_alphanumeric()
293 || matches!(c, '_' | ' ' | '"' | '.' | ',' | '(' | ')' | '[' | ']')
294 }) {
295 return Err(PgError::InvalidInput(format!(
296 "unsupported type expression: {trimmed}"
297 )));
298 }
299
300 let mut quoted = false;
301 let mut chars = trimmed.chars().peekable();
302 while let Some(ch) = chars.next() {
303 if ch == '"' {
304 if quoted && chars.peek() == Some(&'"') {
305 let _ = chars.next();
306 } else {
307 quoted = !quoted;
308 }
309 }
310 }
311 if quoted {
312 return Err(PgError::InvalidInput(format!(
313 "unterminated quoted type expression: {trimmed}"
314 )));
315 }
316
317 Ok(trimmed)
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 #[test]
325 fn quote_ident_handles_simple_and_quoted_names() {
326 assert_eq!(quote_ident("users"), "\"users\"");
327 assert_eq!(quote_ident("MyTable"), "\"MyTable\"");
328 assert_eq!(quote_ident("with\"quote"), "\"with\"\"quote\"");
329 // Reserved words quote without special handling — the wrap
330 // makes them safe.
331 assert_eq!(quote_ident("order"), "\"order\"");
332 // Empty and whitespace get quoted as-is; the server will
333 // reject empty identifiers but that's fine — we don't
334 // pre-validate.
335 assert_eq!(quote_ident(""), "\"\"");
336 }
337
338 #[test]
339 fn update_outcome_round_trips() {
340 let o = UpdateOutcome { rows_affected: 1 };
341 let json = serde_json::to_string(&o).expect("serialize");
342 let back: UpdateOutcome = serde_json::from_str(&json).expect("deserialize");
343 assert_eq!(back.rows_affected, 1);
344 }
345
346 #[test]
347 fn type_expression_accepts_common_format_type_outputs() {
348 for ty in [
349 "integer",
350 "character varying(255)",
351 "timestamp with time zone",
352 "numeric(10,2)",
353 "text[]",
354 "\"MySchema\".\"MyType\"[]",
355 ] {
356 assert_eq!(validate_type_expression(ty).unwrap(), ty);
357 }
358 }
359
360 #[test]
361 fn type_expression_rejects_injection_shapes() {
362 for ty in [
363 "",
364 "text; DROP TABLE x",
365 "text -- comment",
366 "text/*comment*/",
367 "text 'literal'",
368 "$tag$text$tag$",
369 "\"unterminated",
370 ] {
371 assert!(validate_type_expression(ty).is_err(), "{ty}");
372 }
373 }
374}