ssh_commander_core/postgres/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::postgres::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 sql = format!(
73 "UPDATE {qualified} SET {col} = $1::{ty} WHERE ctid = $2::tid",
74 qualified = qualified,
75 col = col,
76 ty = quote_ident(column_type),
77 );
78 client
79 .execute(&sql, &[&value, &ctid])
80 .await
81 .map_err(PgError::Driver)?
82 }
83 None => {
84 let sql = format!(
85 "UPDATE {qualified} SET {col} = NULL WHERE ctid = $1::tid",
86 qualified = qualified,
87 col = col,
88 );
89 client
90 .execute(&sql, &[&ctid])
91 .await
92 .map_err(PgError::Driver)?
93 }
94 };
95
96 Ok(UpdateOutcome { rows_affected })
97}
98
99/// One column's worth of input for an INSERT. Caller emits a list
100/// of these for the columns it wants to set explicitly; columns not
101/// in the list are left to the server's `DEFAULT` (which honors
102/// `pg_attrdef`-defined defaults, sequences, generated values, etc).
103#[derive(Debug, Clone)]
104pub struct InsertColumnInput {
105 pub name: String,
106 /// `pg_type.typname` for the server-side text→typed cast.
107 pub type_name: String,
108 /// `None` writes SQL NULL; `Some(text)` is bound and cast.
109 pub value: Option<String>,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct InsertedRow {
114 /// Cells in the order specified by the caller's `return_columns`.
115 /// Mirrors the FFI execution-result row shape so the UI can
116 /// append directly to its in-memory rows array.
117 pub cells: Vec<Option<String>>,
118}
119
120/// Insert one row, returning the requested columns for the new row.
121///
122/// `return_columns` controls the `RETURNING` clause and the order
123/// of cells in the result. The UI typically passes the same column
124/// names + `__pg_rowid__` (which becomes `ctid` server-side) the
125/// existing result already shows, so the new row slots straight
126/// into the in-memory grid.
127pub async fn insert_row(
128 client: &Client,
129 schema: &str,
130 table: &str,
131 inputs: &[InsertColumnInput],
132 return_columns: &[String],
133) -> Result<InsertedRow, PgError> {
134 let qualified = format!("{}.{}", quote_ident(schema), quote_ident(table));
135
136 // Empty inputs → INSERT … DEFAULT VALUES. Works iff every
137 // column either has a default or accepts NULL; otherwise the
138 // server complains and we surface that error.
139 let column_clause: String;
140 let values_clause: String;
141 if inputs.is_empty() {
142 column_clause = String::new();
143 values_clause = "DEFAULT VALUES".to_string();
144 } else {
145 let cols = inputs
146 .iter()
147 .map(|i| quote_ident(&i.name))
148 .collect::<Vec<_>>()
149 .join(", ");
150 column_clause = format!(" ({cols})");
151 let placeholders = inputs
152 .iter()
153 .enumerate()
154 .map(|(idx, i)| format!("${}::{}", idx + 1, quote_ident(&i.type_name)))
155 .collect::<Vec<_>>()
156 .join(", ");
157 values_clause = format!("VALUES ({placeholders})");
158 }
159
160 // Cast every RETURNING column to text so we can read the row as
161 // `Option<String>` regardless of underlying type. Postgres's
162 // text output format covers every type the server can render,
163 // mirroring the read path the rest of the explorer uses.
164 let returning = if return_columns.is_empty() {
165 // Caller didn't ask for anything back — return ctid alone
166 // so the UI at least gets a row identity to work with.
167 "ctid::text AS \"__pg_rowid__\"".to_string()
168 } else {
169 return_columns
170 .iter()
171 .map(|name| {
172 let alias = quote_ident(name);
173 if name == "__pg_rowid__" {
174 format!("ctid::text AS {alias}")
175 } else {
176 format!("{}::text AS {alias}", quote_ident(name))
177 }
178 })
179 .collect::<Vec<_>>()
180 .join(", ")
181 };
182
183 let sql = format!(
184 "INSERT INTO {qualified}{column_clause} {values_clause} RETURNING {returning}",
185 qualified = qualified,
186 column_clause = column_clause,
187 values_clause = values_clause,
188 returning = returning,
189 );
190
191 // Parameter list: each input contributes one `Option<&str>`.
192 // tokio_postgres' `Option<&str>` impl serializes to NULL when
193 // `None`, so we don't branch SQL for nulls.
194 let params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = inputs
195 .iter()
196 .map(|i| &i.value as &(dyn tokio_postgres::types::ToSql + Sync))
197 .collect();
198
199 // `simple_query` doesn't take params, so we use the extended
200 // protocol via `query`. The RETURNING shape is fixed so we can
201 // pull each value as a `&str` (text-coerced via `pg_get_typeof`
202 // wouldn't be needed — `query` returns binary by default but
203 // we get text for unknown types).
204 //
205 // Actually using `simple_query` would force a text protocol which
206 // gives us uniform `Option<&str>` extraction. The downside is
207 // we lose typed parameter binding. The compromise: use `query`
208 // with the bound params, then for each column read a
209 // `Option<String>` via the postgres-types Text feature. Since
210 // we only RETURNING-cast one row, the cost is negligible.
211 let rows = client.query(&sql, ¶ms).await.map_err(PgError::Driver)?;
212 let row = rows.into_iter().next().ok_or_else(|| {
213 // RETURNING on a successful single-row INSERT must produce
214 // exactly one row. Anything else is a server-side surprise
215 // we surface rather than panic on.
216 PgError::Connect("INSERT returned no row".to_string())
217 })?;
218
219 // Every RETURNING column was cast to text in the SQL above, so
220 // each column reads cleanly as `Option<String>`.
221 let mut cells: Vec<Option<String>> = Vec::with_capacity(row.len());
222 for idx in 0..row.len() {
223 let v: Option<String> = row.try_get(idx).map_err(PgError::Driver)?;
224 cells.push(v);
225 }
226
227 Ok(InsertedRow { cells })
228}
229
230/// Delete one or more rows identified by their ctids. Returns the
231/// number of rows actually deleted (callers compare against the
232/// requested count to surface "some rows were already gone" — the
233/// same optimistic-locking semantic as cell UPDATEs).
234///
235/// One round trip via `DELETE … WHERE ctid = ANY($1)`. ctids are
236/// passed as a `text[]` and cast to `tid[]` server-side, since
237/// `tokio_postgres` doesn't have a native `tid[]` ToSql impl.
238pub async fn delete_rows(
239 client: &Client,
240 schema: &str,
241 table: &str,
242 ctids: &[String],
243) -> Result<UpdateOutcome, PgError> {
244 if ctids.is_empty() {
245 return Ok(UpdateOutcome { rows_affected: 0 });
246 }
247 let qualified = format!("{}.{}", quote_ident(schema), quote_ident(table));
248 let sql = format!(
249 "DELETE FROM {qualified} WHERE ctid = ANY($1::text[]::tid[])",
250 qualified = qualified,
251 );
252 let rows_affected = client
253 .execute(&sql, &[&ctids])
254 .await
255 .map_err(PgError::Driver)?;
256 Ok(UpdateOutcome { rows_affected })
257}
258
259/// Postgres double-quote escaping. Embedded `"` becomes `""`; the
260/// whole identifier is wrapped in double quotes. Defensive against
261/// names with spaces, mixed case, or reserved words.
262fn quote_ident(s: &str) -> String {
263 format!("\"{}\"", s.replace('"', "\"\""))
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269
270 #[test]
271 fn quote_ident_handles_simple_and_quoted_names() {
272 assert_eq!(quote_ident("users"), "\"users\"");
273 assert_eq!(quote_ident("MyTable"), "\"MyTable\"");
274 assert_eq!(quote_ident("with\"quote"), "\"with\"\"quote\"");
275 // Reserved words quote without special handling — the wrap
276 // makes them safe.
277 assert_eq!(quote_ident("order"), "\"order\"");
278 // Empty and whitespace get quoted as-is; the server will
279 // reject empty identifiers but that's fine — we don't
280 // pre-validate.
281 assert_eq!(quote_ident(""), "\"\"");
282 }
283
284 #[test]
285 fn update_outcome_round_trips() {
286 let o = UpdateOutcome { rows_affected: 1 };
287 let json = serde_json::to_string(&o).expect("serialize");
288 let back: UpdateOutcome = serde_json::from_str(&json).expect("deserialize");
289 assert_eq!(back.rows_affected, 1);
290 }
291}