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:
@@ -49,7 +49,7 @@ pub async fn get_table_data(
|
|||||||
let offset = (page.saturating_sub(1)) * page_size;
|
let offset = (page.saturating_sub(1)) * page_size;
|
||||||
|
|
||||||
let data_sql = format!(
|
let data_sql = format!(
|
||||||
"SELECT * FROM {}{}{} LIMIT {} OFFSET {}",
|
"SELECT *, ctid::text FROM {}{}{} LIMIT {} OFFSET {}",
|
||||||
qualified, where_clause, order_clause, page_size, offset
|
qualified, where_clause, order_clause, page_size, offset
|
||||||
);
|
);
|
||||||
let count_sql = format!("SELECT COUNT(*) FROM {}{}", qualified, where_clause);
|
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 execution_time_ms = start.elapsed().as_millis();
|
||||||
let total_rows: i64 = count_row.get(0);
|
let total_rows: i64 = count_row.get(0);
|
||||||
|
|
||||||
let mut columns = Vec::new();
|
let mut all_columns = Vec::new();
|
||||||
let mut types = Vec::new();
|
let mut all_types = Vec::new();
|
||||||
|
|
||||||
if let Some(first_row) = rows.first() {
|
if let Some(first_row) = rows.first() {
|
||||||
for col in first_row.columns() {
|
for col in first_row.columns() {
|
||||||
columns.push(col.name().to_string());
|
all_columns.push(col.name().to_string());
|
||||||
types.push(col.type_info().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
|
let result_rows: Vec<Vec<Value>> = rows
|
||||||
.iter()
|
.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();
|
.collect();
|
||||||
|
|
||||||
let row_count = result_rows.len();
|
let row_count = result_rows.len();
|
||||||
@@ -91,6 +114,7 @@ pub async fn get_table_data(
|
|||||||
total_rows,
|
total_rows,
|
||||||
page,
|
page,
|
||||||
page_size,
|
page_size,
|
||||||
|
ctids,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +128,7 @@ pub async fn update_row(
|
|||||||
pk_values: Vec<Value>,
|
pk_values: Vec<Value>,
|
||||||
column: String,
|
column: String,
|
||||||
value: Value,
|
value: Value,
|
||||||
|
ctid: Option<String>,
|
||||||
) -> TuskResult<()> {
|
) -> TuskResult<()> {
|
||||||
if state.is_read_only(&connection_id).await {
|
if state.is_read_only(&connection_id).await {
|
||||||
return Err(TuskError::ReadOnly);
|
return Err(TuskError::ReadOnly);
|
||||||
@@ -118,26 +143,40 @@ pub async fn update_row(
|
|||||||
|
|
||||||
let set_clause = format!("{} = $1", escape_ident(&column));
|
let set_clause = format!("{} = $1", escape_ident(&column));
|
||||||
|
|
||||||
let where_parts: Vec<String> = pk_columns
|
if pk_columns.is_empty() {
|
||||||
.iter()
|
// Fallback: use ctid for row identification
|
||||||
.enumerate()
|
let ctid_val = ctid.ok_or_else(|| {
|
||||||
.map(|(i, col)| format!("{} = ${}", escape_ident(col), i + 2))
|
TuskError::Custom("Cannot update: no primary key and no ctid provided".into())
|
||||||
.collect();
|
})?;
|
||||||
let where_clause = where_parts.join(" AND ");
|
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!(
|
let sql = format!(
|
||||||
"UPDATE {} SET {} WHERE {}",
|
"UPDATE {} SET {} WHERE {}",
|
||||||
qualified, set_clause, where_clause
|
qualified, set_clause, where_clause
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut query = sqlx::query(&sql);
|
let mut query = sqlx::query(&sql);
|
||||||
query = bind_json_value(query, &value);
|
query = bind_json_value(query, &value);
|
||||||
for pk_val in &pk_values {
|
for pk_val in &pk_values {
|
||||||
query = bind_json_value(query, pk_val);
|
query = bind_json_value(query, pk_val);
|
||||||
|
}
|
||||||
|
query.execute(pool).await.map_err(TuskError::Database)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
query.execute(pool).await.map_err(TuskError::Database)?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,6 +228,7 @@ pub async fn delete_rows(
|
|||||||
table: String,
|
table: String,
|
||||||
pk_columns: Vec<String>,
|
pk_columns: Vec<String>,
|
||||||
pk_values_list: Vec<Vec<Value>>,
|
pk_values_list: Vec<Vec<Value>>,
|
||||||
|
ctids: Option<Vec<String>>,
|
||||||
) -> TuskResult<u64> {
|
) -> TuskResult<u64> {
|
||||||
if state.is_read_only(&connection_id).await {
|
if state.is_read_only(&connection_id).await {
|
||||||
return Err(TuskError::ReadOnly);
|
return Err(TuskError::ReadOnly);
|
||||||
@@ -202,23 +242,36 @@ pub async fn delete_rows(
|
|||||||
let qualified = format!("{}.{}", escape_ident(&schema), escape_ident(&table));
|
let qualified = format!("{}.{}", escape_ident(&schema), escape_ident(&table));
|
||||||
let mut total_affected: u64 = 0;
|
let mut total_affected: u64 = 0;
|
||||||
|
|
||||||
for pk_values in &pk_values_list {
|
if pk_columns.is_empty() {
|
||||||
let where_parts: Vec<String> = pk_columns
|
// Fallback: use ctids for row identification
|
||||||
.iter()
|
let ctid_list = ctids.ok_or_else(|| {
|
||||||
.enumerate()
|
TuskError::Custom("Cannot delete: no primary key and no ctids provided".into())
|
||||||
.map(|(i, col)| format!("{} = ${}", escape_ident(col), i + 1))
|
})?;
|
||||||
.collect();
|
for ctid_val in &ctid_list {
|
||||||
let where_clause = where_parts.join(" AND ");
|
let sql = format!("DELETE FROM {} WHERE ctid = $1::tid", qualified);
|
||||||
|
let query = sqlx::query(&sql).bind(ctid_val);
|
||||||
let sql = format!("DELETE FROM {} WHERE {}", qualified, where_clause);
|
let result = query.execute(pool).await.map_err(TuskError::Database)?;
|
||||||
|
total_affected += result.rows_affected();
|
||||||
let mut query = sqlx::query(&sql);
|
|
||||||
for val in pk_values {
|
|
||||||
query = bind_json_value(query, val);
|
|
||||||
}
|
}
|
||||||
|
} 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)?;
|
let sql = format!("DELETE FROM {} WHERE {}", qualified, where_clause);
|
||||||
total_affected += result.rows_affected();
|
|
||||||
|
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)
|
Ok(total_affected)
|
||||||
|
|||||||
@@ -20,4 +20,5 @@ pub struct PaginatedQueryResult {
|
|||||||
pub total_rows: i64,
|
pub total_rows: i64,
|
||||||
pub page: u32,
|
pub page: u32,
|
||||||
pub page_size: u32,
|
pub page_size: u32,
|
||||||
|
pub ctids: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,30 +94,46 @@ export function TableDataView({ connectionId, schema, table }: Props) {
|
|||||||
[pendingChanges, isReadOnly]
|
[pendingChanges, isReadOnly]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const usesCtid = pkColumns.length === 0;
|
||||||
|
|
||||||
const handleCommit = async () => {
|
const handleCommit = async () => {
|
||||||
if (!data || pkColumns.length === 0) {
|
if (!data) return;
|
||||||
toast.error("Cannot save: no primary key detected");
|
if (pkColumns.length === 0 && (!data.ctids || data.ctids.length === 0)) {
|
||||||
|
toast.error("Cannot save: no primary key and no ctid available");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
for (const [_key, change] of pendingChanges) {
|
for (const [_key, change] of pendingChanges) {
|
||||||
const row = data.rows[change.rowIndex];
|
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];
|
const colName = data.columns[change.colIndex];
|
||||||
|
|
||||||
await updateRowApi({
|
if (usesCtid) {
|
||||||
connectionId,
|
await updateRowApi({
|
||||||
schema,
|
connectionId,
|
||||||
table,
|
schema,
|
||||||
pkColumns,
|
table,
|
||||||
pkValues: pkValues as unknown[],
|
pkColumns: [],
|
||||||
column: colName,
|
pkValues: [],
|
||||||
value: change.value,
|
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());
|
setPendingChanges(new Map());
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
@@ -182,6 +198,14 @@ export function TableDataView({ connectionId, schema, table }: Props) {
|
|||||||
Read-Only
|
Read-Only
|
||||||
</span>
|
</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" />
|
<Filter className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="WHERE clause (e.g. id > 10)"
|
placeholder="WHERE clause (e.g. id > 10)"
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ export const updateRow = (params: {
|
|||||||
pkValues: unknown[];
|
pkValues: unknown[];
|
||||||
column: string;
|
column: string;
|
||||||
value: unknown;
|
value: unknown;
|
||||||
|
ctid?: string;
|
||||||
}) => invoke<void>("update_row", params);
|
}) => invoke<void>("update_row", params);
|
||||||
|
|
||||||
export const insertRow = (params: {
|
export const insertRow = (params: {
|
||||||
@@ -169,6 +170,7 @@ export const deleteRows = (params: {
|
|||||||
table: string;
|
table: string;
|
||||||
pkColumns: string[];
|
pkColumns: string[];
|
||||||
pkValuesList: unknown[][];
|
pkValuesList: unknown[][];
|
||||||
|
ctids?: string[];
|
||||||
}) => invoke<number>("delete_rows", params);
|
}) => invoke<number>("delete_rows", params);
|
||||||
|
|
||||||
// History
|
// History
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export interface PaginatedQueryResult extends QueryResult {
|
|||||||
total_rows: number;
|
total_rows: number;
|
||||||
page: number;
|
page: number;
|
||||||
page_size: number;
|
page_size: number;
|
||||||
|
ctids: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SchemaObject {
|
export interface SchemaObject {
|
||||||
|
|||||||
Reference in New Issue
Block a user