Skip to main content

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