From baa794b66a7e4c65573673f76f59c6dc64927614 Mon Sep 17 00:00:00 2001 From: "A.Shakhmatov" Date: Wed, 18 Feb 2026 16:14:26 +0300 Subject: [PATCH] feat: fallback to ctid for editing tables without primary key When a table has no PRIMARY KEY, use PostgreSQL's ctid (physical row ID) to identify rows for UPDATE/DELETE operations instead of blocking edits. Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/commands/data.rs | 127 +++++++++++++----- src-tauri/src/models/query_result.rs | 1 + src/components/table-viewer/TableDataView.tsx | 54 +++++--- src/lib/tauri.ts | 2 + src/types/index.ts | 1 + 5 files changed, 133 insertions(+), 52 deletions(-) diff --git a/src-tauri/src/commands/data.rs b/src-tauri/src/commands/data.rs index 1395302..0043fd9 100644 --- a/src-tauri/src/commands/data.rs +++ b/src-tauri/src/commands/data.rs @@ -49,7 +49,7 @@ pub async fn get_table_data( let offset = (page.saturating_sub(1)) * page_size; let data_sql = format!( - "SELECT * FROM {}{}{} LIMIT {} OFFSET {}", + "SELECT *, ctid::text FROM {}{}{} LIMIT {} OFFSET {}", qualified, where_clause, order_clause, page_size, offset ); let count_sql = format!("SELECT COUNT(*) FROM {}{}", qualified, where_clause); @@ -65,19 +65,42 @@ pub async fn get_table_data( let execution_time_ms = start.elapsed().as_millis(); let total_rows: i64 = count_row.get(0); - let mut columns = Vec::new(); - let mut types = Vec::new(); + let mut all_columns = Vec::new(); + let mut all_types = Vec::new(); if let Some(first_row) = rows.first() { for col in first_row.columns() { - columns.push(col.name().to_string()); - types.push(col.type_info().name().to_string()); + all_columns.push(col.name().to_string()); + all_types.push(col.type_info().name().to_string()); } } + // Find and strip the trailing ctid column + let ctid_idx = all_columns.iter().rposition(|c| c == "ctid"); + let mut ctids: Vec = Vec::new(); + + let (columns, types) = if let Some(idx) = ctid_idx { + let mut cols = all_columns.clone(); + let mut tps = all_types.clone(); + cols.remove(idx); + tps.remove(idx); + (cols, tps) + } else { + (all_columns.clone(), all_types.clone()) + }; + let result_rows: Vec> = rows .iter() - .map(|row| (0..columns.len()).map(|i| pg_value_to_json(row, i)).collect()) + .map(|row| { + if let Some(idx) = ctid_idx { + let ctid_val: String = row.get(idx); + ctids.push(ctid_val); + } + (0..all_columns.len()) + .filter(|i| Some(*i) != ctid_idx) + .map(|i| pg_value_to_json(row, i)) + .collect() + }) .collect(); let row_count = result_rows.len(); @@ -91,6 +114,7 @@ pub async fn get_table_data( total_rows, page, page_size, + ctids, }) } @@ -104,6 +128,7 @@ pub async fn update_row( pk_values: Vec, column: String, value: Value, + ctid: Option, ) -> TuskResult<()> { if state.is_read_only(&connection_id).await { return Err(TuskError::ReadOnly); @@ -118,26 +143,40 @@ pub async fn update_row( let set_clause = format!("{} = $1", escape_ident(&column)); - let where_parts: Vec = pk_columns - .iter() - .enumerate() - .map(|(i, col)| format!("{} = ${}", escape_ident(col), i + 2)) - .collect(); - let where_clause = where_parts.join(" AND "); + if pk_columns.is_empty() { + // Fallback: use ctid for row identification + let ctid_val = ctid.ok_or_else(|| { + TuskError::Custom("Cannot update: no primary key and no ctid provided".into()) + })?; + let sql = format!( + "UPDATE {} SET {} WHERE ctid = $2::tid", + qualified, set_clause + ); + let mut query = sqlx::query(&sql); + query = bind_json_value(query, &value); + query = query.bind(ctid_val); + query.execute(pool).await.map_err(TuskError::Database)?; + } else { + let where_parts: Vec = pk_columns + .iter() + .enumerate() + .map(|(i, col)| format!("{} = ${}", escape_ident(col), i + 2)) + .collect(); + let where_clause = where_parts.join(" AND "); - let sql = format!( - "UPDATE {} SET {} WHERE {}", - qualified, set_clause, where_clause - ); + let sql = format!( + "UPDATE {} SET {} WHERE {}", + qualified, set_clause, where_clause + ); - let mut query = sqlx::query(&sql); - query = bind_json_value(query, &value); - for pk_val in &pk_values { - query = bind_json_value(query, pk_val); + let mut query = sqlx::query(&sql); + query = bind_json_value(query, &value); + for pk_val in &pk_values { + query = bind_json_value(query, pk_val); + } + query.execute(pool).await.map_err(TuskError::Database)?; } - query.execute(pool).await.map_err(TuskError::Database)?; - Ok(()) } @@ -189,6 +228,7 @@ pub async fn delete_rows( table: String, pk_columns: Vec, pk_values_list: Vec>, + ctids: Option>, ) -> TuskResult { if state.is_read_only(&connection_id).await { return Err(TuskError::ReadOnly); @@ -202,23 +242,36 @@ pub async fn delete_rows( let qualified = format!("{}.{}", escape_ident(&schema), escape_ident(&table)); let mut total_affected: u64 = 0; - for pk_values in &pk_values_list { - let where_parts: Vec = pk_columns - .iter() - .enumerate() - .map(|(i, col)| format!("{} = ${}", escape_ident(col), i + 1)) - .collect(); - let where_clause = where_parts.join(" AND "); - - let sql = format!("DELETE FROM {} WHERE {}", qualified, where_clause); - - let mut query = sqlx::query(&sql); - for val in pk_values { - query = bind_json_value(query, val); + if pk_columns.is_empty() { + // Fallback: use ctids for row identification + let ctid_list = ctids.ok_or_else(|| { + TuskError::Custom("Cannot delete: no primary key and no ctids provided".into()) + })?; + for ctid_val in &ctid_list { + let sql = format!("DELETE FROM {} WHERE ctid = $1::tid", qualified); + let query = sqlx::query(&sql).bind(ctid_val); + let result = query.execute(pool).await.map_err(TuskError::Database)?; + total_affected += result.rows_affected(); } + } else { + for pk_values in &pk_values_list { + let where_parts: Vec = pk_columns + .iter() + .enumerate() + .map(|(i, col)| format!("{} = ${}", escape_ident(col), i + 1)) + .collect(); + let where_clause = where_parts.join(" AND "); - let result = query.execute(pool).await.map_err(TuskError::Database)?; - total_affected += result.rows_affected(); + let sql = format!("DELETE FROM {} WHERE {}", qualified, where_clause); + + let mut query = sqlx::query(&sql); + for val in pk_values { + query = bind_json_value(query, val); + } + + let result = query.execute(pool).await.map_err(TuskError::Database)?; + total_affected += result.rows_affected(); + } } Ok(total_affected) diff --git a/src-tauri/src/models/query_result.rs b/src-tauri/src/models/query_result.rs index a3cbeeb..d5cf409 100644 --- a/src-tauri/src/models/query_result.rs +++ b/src-tauri/src/models/query_result.rs @@ -20,4 +20,5 @@ pub struct PaginatedQueryResult { pub total_rows: i64, pub page: u32, pub page_size: u32, + pub ctids: Vec, } diff --git a/src/components/table-viewer/TableDataView.tsx b/src/components/table-viewer/TableDataView.tsx index 51714ed..76730b7 100644 --- a/src/components/table-viewer/TableDataView.tsx +++ b/src/components/table-viewer/TableDataView.tsx @@ -94,30 +94,46 @@ export function TableDataView({ connectionId, schema, table }: Props) { [pendingChanges, isReadOnly] ); + const usesCtid = pkColumns.length === 0; + const handleCommit = async () => { - if (!data || pkColumns.length === 0) { - toast.error("Cannot save: no primary key detected"); + if (!data) return; + if (pkColumns.length === 0 && (!data.ctids || data.ctids.length === 0)) { + toast.error("Cannot save: no primary key and no ctid available"); return; } setIsSaving(true); try { for (const [_key, change] of pendingChanges) { const row = data.rows[change.rowIndex]; - const pkValues = pkColumns.map((pkCol) => { - const idx = data.columns.indexOf(pkCol); - return row[idx]; - }); const colName = data.columns[change.colIndex]; - await updateRowApi({ - connectionId, - schema, - table, - pkColumns, - pkValues: pkValues as unknown[], - column: colName, - value: change.value, - }); + if (usesCtid) { + await updateRowApi({ + connectionId, + schema, + table, + pkColumns: [], + pkValues: [], + column: colName, + value: change.value, + ctid: data.ctids[change.rowIndex], + }); + } else { + const pkValues = pkColumns.map((pkCol) => { + const idx = data.columns.indexOf(pkCol); + return row[idx]; + }); + await updateRowApi({ + connectionId, + schema, + table, + pkColumns, + pkValues: pkValues as unknown[], + column: colName, + value: change.value, + }); + } } setPendingChanges(new Map()); queryClient.invalidateQueries({ @@ -182,6 +198,14 @@ export function TableDataView({ connectionId, schema, table }: Props) { Read-Only )} + {!isReadOnly && usesCtid && ( + + No PK — using ctid + + )} invoke("update_row", params); export const insertRow = (params: { @@ -169,6 +170,7 @@ export const deleteRows = (params: { table: string; pkColumns: string[]; pkValuesList: unknown[][]; + ctids?: string[]; }) => invoke("delete_rows", params); // History diff --git a/src/types/index.ts b/src/types/index.ts index 8226a63..7855ac2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -30,6 +30,7 @@ export interface PaginatedQueryResult extends QueryResult { total_rows: number; page: number; page_size: number; + ctids: string[]; } export interface SchemaObject {