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 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 16:14:26 +03:00
parent e76a96deb8
commit baa794b66a
5 changed files with 133 additions and 52 deletions

View File

@@ -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<String> = 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<Vec<Value>> = 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<Value>,
column: String,
value: Value,
ctid: Option<String>,
) -> 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<String> = 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<String> = 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<String>,
pk_values_list: Vec<Vec<Value>>,
ctids: Option<Vec<String>>,
) -> TuskResult<u64> {
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<String> = 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<String> = 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)

View File

@@ -20,4 +20,5 @@ pub struct PaginatedQueryResult {
pub total_rows: i64,
pub page: u32,
pub page_size: u32,
pub ctids: Vec<String>,
}

View File

@@ -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
</span>
)}
{!isReadOnly && usesCtid && (
<span
className="rounded bg-orange-500/10 px-1.5 py-0.5 text-[10px] font-medium text-orange-600 dark:text-orange-400"
title="This table has no primary key. Edits use physical row ID (ctid), which may change after VACUUM or concurrent writes."
>
No PK using ctid
</span>
)}
<Filter className="h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="WHERE clause (e.g. id > 10)"

View File

@@ -153,6 +153,7 @@ export const updateRow = (params: {
pkValues: unknown[];
column: string;
value: unknown;
ctid?: string;
}) => invoke<void>("update_row", params);
export const insertRow = (params: {
@@ -169,6 +170,7 @@ export const deleteRows = (params: {
table: string;
pkColumns: string[];
pkValuesList: unknown[][];
ctids?: string[];
}) => invoke<number>("delete_rows", params);
// History

View File

@@ -30,6 +30,7 @@ export interface PaginatedQueryResult extends QueryResult {
total_rows: number;
page: number;
page_size: number;
ctids: string[];
}
export interface SchemaObject {