fix(grid): edit keyless postgres tables via ctid row locator

This commit is contained in:
onenewcode
2026-10-16 09:57:12 +08:00
committed by GitHub
parent 668bf6f02c
commit 0264cfbb03
13 changed files with 424 additions and 35 deletions
@@ -134,6 +134,7 @@ import {
isSalesforceNewRowReadonlyColumn,
isTdengineExistingRowReadonlyColumn,
shouldIncludeSyntheticRowId,
showsKeylessEditWarning,
} from "@/lib/table/tableEditing";
import { buildDataGridConditionalUpdateSql, buildDataGridContextFilterCondition, buildDataGridCountSql, buildHiveTablePropertiesSql, type DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql";
import { sumGridCountValues } from "@/lib/dataGrid/dataGridCountValue";
@@ -3817,7 +3818,7 @@ watch(
);
const showQueryEditReadOnlyBadge = computed(() => isResultsContext.value && hasData.value && !props.editable && !!props.queryEditabilityReason);
const queryEditReadOnlyReason = computed(() => (props.queryEditabilityReason ? t(`grid.queryEditUnsupported.${props.queryEditabilityReason}`) : ""));
const showKeylessEditWarning = computed(() => !!props.editable && !!props.tableMeta && canUseKeylessRowPredicate(props.databaseType, props.tableMeta.primaryKeys ?? []));
const showKeylessEditWarning = computed(() => !!props.editable && !!props.tableMeta && showsKeylessEditWarning(props.databaseType, props.tableMeta.primaryKeys ?? [], props.tableMeta.tableType));
type DataGridTableMeta = NonNullable<typeof props.tableMeta>;
const hiveTableTransactional = ref<boolean | undefined>(undefined);
const resultSourceColumns = computed(() => props.result.columns.map((column, index) => props.sourceColumns?.[index] ?? column));
@@ -1841,6 +1841,36 @@ describe("useDataGridEditor saveChanges reload", () => {
expect(editor.saveError.value).toBeFalsy();
});
// A PostgreSQL preview addresses one selected row by its physical locator
// (tableoid/ctid/xmin) so exact duplicate rows stay distinguishable. That
// locator stops matching after another session updates the row, and the save
// must report the conflict instead of silently deleting nothing.
it("refuses a keyless save when the physical row locator no longer matches a row", async () => {
const staleLocatorGuard = {
sql: "SELECT COUNT(*) AS dbx_keyless_row_matches FROM orders_test WHERE (ctid = '(0,1)'::tid)",
maxMatchedRows: 1,
minMatchedRows: 1,
message: keylessGuard.message,
minMessage: "Cannot safely update or delete this PostgreSQL row: it no longer matches the physical row that was loaded.",
};
mocks.prepareDataGridSave.mockResolvedValue({
statements: ["DELETE FROM orders_test WHERE ctid = '(0,1)'::tid"],
rollbackStatements: [],
keylessGuards: [staleLocatorGuard],
});
mocks.executeQuery.mockResolvedValue({ columns: ["dbx_keyless_row_matches"], rows: [[0]] });
const { editor } = createSaveTestEditor({ databaseType: "postgres", primaryKeys: [] });
editor.deletedRows.value.add(0);
await editor.saveChanges();
expect(mocks.executeQuery).toHaveBeenCalledWith("connection-1", "app", staleLocatorGuard.sql, undefined);
expect(mocks.executeBatch).not.toHaveBeenCalled();
expect(editor.saveError.value).toBe(staleLocatorGuard.minMessage);
expect(editor.deletedRows.value.size).toBe(1);
});
it("refuses a keyless save when the guard cannot be counted on the server at all", async () => {
const onExecuteSql = vi.fn().mockResolvedValue(undefined);
mocks.prepareDataGridSave.mockResolvedValue({
@@ -1806,6 +1806,9 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
const count = Number(matched);
if (!Number.isFinite(count)) return KEYLESS_GUARD_UNVERIFIED_ERROR;
if (count > guard.maxMatchedRows) return guard.message;
// A predicate built from a physical row locator must still address a row:
// zero means it moved under us, and the write would silently do nothing.
if (count < (guard.minMatchedRows ?? 0)) return guard.minMessage ?? guard.message;
}
return undefined;
}
@@ -20,6 +20,7 @@ import {
parseSalesforceColumnExtra,
supportsDataGridTransaction,
shouldIncludeSyntheticRowId,
showsKeylessEditWarning,
usesSyntheticRowIdKey,
} from "@/lib/table/tableEditing";
import type { ColumnInfo, IndexInfo } from "@/types/database";
@@ -65,6 +66,30 @@ describe("tableEditing", () => {
expect(editablePrimaryKeys("oceanbase-oracle", [column("ID", true), column("NAME")], "TABLE")).toEqual(["ID"]);
});
it("requests PostgreSQL CTID only for known unkeyed base-table previews", () => {
const columns = [column("ID"), column("VALUE")];
expect(editablePrimaryKeys("postgres", columns, "TABLE")).toEqual([]);
expect(shouldIncludeSyntheticRowId("postgres", [], "TABLE")).toBe(true);
// The sidebar's listTables path reports relkind 'r' as `BASE TABLE`.
expect(shouldIncludeSyntheticRowId("postgres", [], "BASE TABLE")).toBe(true);
expect(shouldIncludeSyntheticRowId("postgres", ["ID"], "TABLE")).toBe(false);
expect(shouldIncludeSyntheticRowId("postgres", [], "PARTITIONED TABLE")).toBe(false);
expect(shouldIncludeSyntheticRowId("postgres", [], "VIEW")).toBe(false);
expect(shouldIncludeSyntheticRowId("postgres", [])).toBe(false);
expect(usesSyntheticRowIdKey("postgres", [DBX_ROWID_COLUMN], "TABLE")).toBe(true);
expect(usesSyntheticRowIdKey("postgres", [DBX_ROWID_COLUMN], "BASE TABLE")).toBe(true);
expect(usesSyntheticRowIdKey("postgres", [DBX_ROWID_COLUMN], "PARTITIONED TABLE")).toBe(false);
expect(isHiddenGridColumn("postgres", DBX_ROWID_COLUMN, [], "TABLE")).toBe(true);
expect(isHiddenGridColumn("postgres", DBX_ROWID_COLUMN, [], "BASE TABLE")).toBe(true);
// The "no stable row identifier" badge must not contradict a preview that
// addresses the selected row through a hidden physical locator.
expect(showsKeylessEditWarning("postgres", [], "TABLE")).toBe(false);
expect(showsKeylessEditWarning("postgres", [], "BASE TABLE")).toBe(false);
expect(showsKeylessEditWarning("postgres", ["ID"], "TABLE")).toBe(false);
expect(showsKeylessEditWarning("postgres", [], "VIEW")).toBe(true);
expect(showsKeylessEditWarning("mysql", [], "TABLE")).toBe(true);
});
it("uses Xugu ROWID for ordinary, partitioned, and temporary tables but not views", () => {
const columns = [column("ID"), column("VALUE")];
expect(editablePrimaryKeys("xugu", columns, "TABLE")).toEqual([DBX_ROWID_COLUMN]);
+3
View File
@@ -2485,7 +2485,10 @@ export async function analyzeEditableQueryEditability(sql: string): Promise<Quer
export interface DataGridSaveGuard {
sql: string;
maxMatchedRows: number;
/** Set for predicates built from a physical row locator that must still match. */
minMatchedRows?: number;
message: string;
minMessage?: string;
}
export interface BlobBoundStatement {
@@ -1,7 +1,7 @@
import type { DatabaseType } from "@/types/database";
import { isSchemaAware, usesTreeSchemaMode } from "@/lib/database/databaseFeatureSupport";
export type SyntheticEditKey = "oracle-rowid" | "xugu-rowid" | "neo4j-element-id";
export type SyntheticEditKey = "oracle-rowid" | "xugu-rowid" | "postgres-ctid" | "neo4j-element-id";
export interface TableDataCapability {
insert: boolean;
@@ -92,6 +92,10 @@ const NAVICAT_STYLE_TABLE_DATA_TYPES = new Set<DatabaseType>([
]);
const DATABASE_CAPABILITY_OVERRIDES: Partial<Record<DatabaseType, Partial<DatabaseCapability>>> = {
postgres: {
// CTID is injected only by base-table data previews; it is not a schema key.
syntheticKey: "postgres-ctid",
},
duckdb: {
tableData: {
insert: true,
+27 -7
View File
@@ -13,6 +13,14 @@ function isKnownOracleBaseTableType(tableType?: string): boolean {
return tableType?.trim().toUpperCase() === "TABLE";
}
function isKnownPostgresBaseTableType(tableType?: string): boolean {
// PostgreSQL reports `relkind = 'r'` through `listTables` as the
// information-schema spelling `BASE TABLE`; `TABLE` still reaches this from
// object-browser paths and tests, so both must be accepted.
const normalized = tableType?.trim().toUpperCase();
return normalized === "TABLE" || normalized === "BASE TABLE";
}
export function isTdengineStableTableType(tableType?: string): boolean {
const normalized = tableType?.trim().toUpperCase();
return normalized === "STABLE" || normalized === "SUPER TABLE" || normalized === "SUPERTABLE";
@@ -74,6 +82,16 @@ export function canUseKeylessRowPredicate(databaseType: DatabaseType | undefined
return primaryKeys.length === 0 && usesKeylessRowPredicate(databaseType);
}
/**
* The toolbar badge warns that updates and deletes fall back to matching every
* column value, so exact duplicate rows can be changed together. A preview that
* carries a hidden physical row locator addresses a single row, so showing that
* warning for it would contradict what the save actually does.
*/
export function showsKeylessEditWarning(databaseType: DatabaseType | undefined, primaryKeys: string[], tableType?: string): boolean {
return canUseKeylessRowPredicate(databaseType, primaryKeys) && !shouldIncludeSyntheticRowId(databaseType, primaryKeys, tableType);
}
export function canEditExistingTableRows(databaseType: DatabaseType | undefined, hiveTableTransactional?: boolean, primaryKeys?: string[]): boolean {
const tableData = getDatabaseCapability(databaseType).tableData;
if (tableData.readonly) return false;
@@ -119,23 +137,25 @@ export function usesSyntheticRowIdKey(databaseType: DatabaseType | undefined, pr
const syntheticKey = getDatabaseCapability(databaseType).syntheticKey;
if (primaryKeys.length !== 1) return false;
if (syntheticKey === "oracle-rowid" || syntheticKey === "xugu-rowid") return primaryKeys[0].toUpperCase() === DBX_ROWID_COLUMN;
if (syntheticKey === "postgres-ctid") {
return primaryKeys[0].toUpperCase() === DBX_ROWID_COLUMN && (!tableType || isKnownPostgresBaseTableType(tableType));
}
return syntheticKey === "neo4j-element-id" && primaryKeys[0] === DBX_NEO4J_ELEMENT_ID_COLUMN;
}
/**
* Table-data tabs may start before metadata has populated declared primary keys.
* Xugu base/partitioned/temp tables can still be addressed safely with ROWID,
* so request the hidden projection during that cold-cache window as well.
*/
/** Requests a hidden physical locator only when the backend knows the object type supports it. */
export function shouldIncludeSyntheticRowId(databaseType: DatabaseType | undefined, primaryKeys: string[], tableType?: string): boolean {
if (isViewTableType(tableType)) return false;
if (getDatabaseCapability(databaseType).syntheticKey === "oracle-rowid" && !isKnownOracleBaseTableType(tableType)) return false;
const syntheticKey = getDatabaseCapability(databaseType).syntheticKey;
if (syntheticKey === "oracle-rowid" && !isKnownOracleBaseTableType(tableType)) return false;
if (syntheticKey === "postgres-ctid" && !isKnownPostgresBaseTableType(tableType)) return false;
if (usesSyntheticRowIdKey(databaseType, primaryKeys, tableType)) return true;
return databaseType === "xugu" && primaryKeys.length === 0;
return (databaseType === "xugu" || syntheticKey === "postgres-ctid") && primaryKeys.length === 0;
}
export function isHiddenGridColumn(databaseType: DatabaseType | undefined, column: string, primaryKeys: string[], tableType?: string): boolean {
if (databaseType === "neo4j" && column === DBX_NEO4J_ELEMENT_ID_COLUMN) return true;
if (databaseType === "postgres" && isKnownPostgresBaseTableType(tableType) && column.toUpperCase() === DBX_ROWID_COLUMN) return true;
// Xugu may inject ROWID before table metadata finishes loading. Keep this
// internal projection hidden even after the real primary keys arrive.
if (databaseType === "xugu" && !isViewTableType(tableType) && column.toUpperCase() === DBX_ROWID_COLUMN) return true;
+249 -21
View File
@@ -30,7 +30,7 @@ use data_grid_salesforce_sql::{
use crate::models::connection::DatabaseType;
use crate::sql_dialect::{
firebird_rows_clause, quote_table_identifier, table_pagination_strategy, uses_oracle_row_id,
firebird_rows_clause, quote_table_identifier, table_pagination_strategy, uses_oracle_row_id, uses_postgres_ctid,
uses_single_row_insert_statements, uses_synthetic_row_id, uses_xugu_row_id, TablePaginationStrategy,
};
use crate::value_literals::{format_ch_array_sql_literal, format_pg_array_sql_literal};
@@ -45,6 +45,8 @@ const DATA_GRID_COLUMN_DISTINCT_VALUES_MAX_LIMIT: usize = 1000;
const KEYLESS_GUARD_COUNT_ALIAS: &str = "dbx_keyless_row_matches";
const KEYLESS_AMBIGUOUS_ROW_ERROR: &str = "Cannot safely update or delete this row: the table has no primary key, so the row is identified by matching every column value, and more than one row in the table matches that condition. Add a primary key or unique index, or make the rows distinguishable, before editing.";
const KEYLESS_UNIDENTIFIABLE_ROW_ERROR: &str = "Cannot safely update or delete this row: the table has no primary key and none of the result columns map to a table column, so there is no condition that can target a single row. Add a primary key, or edit the table directly, before saving.";
const POSTGRES_CTID_UNIDENTIFIABLE_ROW_ERROR: &str = "Cannot safely update or delete this PostgreSQL row because its hidden row identifier is missing or invalid. Refresh the table data before saving.";
const POSTGRES_CTID_STALE_ROW_ERROR: &str = "Cannot safely update or delete this PostgreSQL row: it no longer matches the physical row that was loaded. Another session may have changed or deleted it. Refresh the table data and try again.";
/// Dameng rejects every comparison against its binary LOB types with
/// `Data type mismatch` (SQLSTATE 22000, code -6105), and spatial columns are
/// BLOB-backed and behave the same, so a keyless row predicate — which matches
@@ -385,12 +387,21 @@ pub struct HiveTablePropertiesSqlOptions {
/// `message` unless the returned count is at most `max_matched_rows`, and also
/// refused when the count cannot be obtained at all — an unverified keyless
/// write is exactly the ambiguous write this guard exists to prevent.
///
/// A predicate built from a physical row locator (PostgreSQL `tableoid`/`ctid`)
/// also sets `min_matched_rows`: that locator only stays valid while the row is
/// unchanged, so a count of zero means the row moved and must be reported with
/// `min_message` instead of silently updating nothing.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DataGridSaveGuard {
pub sql: String,
pub max_matched_rows: u32,
#[serde(default)]
pub min_matched_rows: u32,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min_message: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -1365,6 +1376,9 @@ fn validate_data_grid_save(options: &DataGridSaveStatementOptions) -> Option<Str
if let Some(error) = validate_existing_row_primary_keys(options) {
return Some(error);
}
if let Some(error) = validate_postgres_ctid_row_identifier(options) {
return Some(error);
}
if let Some(error) = validate_oracle_keyless_lob_predicate(options) {
return Some(error);
}
@@ -1510,6 +1524,31 @@ fn validate_existing_row_primary_keys(options: &DataGridSaveStatementOptions) ->
None
}
fn validate_postgres_ctid_row_identifier(options: &DataGridSaveStatementOptions) -> Option<String> {
if !uses_postgres_ctid(options.database_type)
|| !options.table_meta.primary_keys.is_empty()
|| (options.dirty_rows.is_empty() && options.deleted_rows.is_empty())
{
return None;
}
let save_columns = effective_columns(options);
// Without the hidden locator in the projection there is nothing to check;
// the save falls back to its previous keyless behaviour.
let row_id_index = find_column_index(options.database_type, &save_columns, DBX_ROWID_COLUMN)?;
for row_index in
options.dirty_rows.iter().map(|(row_index, _)| *row_index).chain(options.deleted_rows.iter().copied())
{
let Some(row) = options.rows.get(row_index) else {
continue;
};
if row.get(row_id_index).and_then(parse_postgres_ctid_row_id).is_none() {
return Some(POSTGRES_CTID_UNIDENTIFIABLE_ROW_ERROR.to_string());
}
}
None
}
fn validate_oracle_keyless_lob_predicate(options: &DataGridSaveStatementOptions) -> Option<String> {
if !uses_oracle_row_id(options.database_type)
|| !options.table_meta.primary_keys.is_empty()
@@ -1680,6 +1719,13 @@ fn validate_inserted_primary_keys(options: &DataGridSaveStatementOptions) -> Opt
None
}
/// One predicate this save sends, plus whether it addresses a physical row
/// locator that must still match a row rather than merely "at most one".
struct GuardedPredicate {
sql: String,
requires_match: bool,
}
/// Builds the statements the save executes, and alongside them the server-side
/// guards for every keyless predicate those statements actually send. The guard
/// is derived from the same `where_clause` value the UPDATE/DELETE carries, so
@@ -1717,13 +1763,18 @@ fn build_data_grid_save_statements(
options.table_meta.primary_keys.iter().map(|primary_key| normalize_column_name(primary_key)).collect();
let guards_keyless_predicates = primary_key_set.is_empty() && uses_keyless_row_predicate(options.database_type);
let mut guarded_predicates: Vec<String> = Vec::new();
let guard_predicate = |predicate: &str, guarded: &mut Vec<String>| {
// With the hidden locator present every generated predicate addresses one
// physical row, so its guard must match exactly one row rather than "at most one".
let postgres_ctid_locator = guards_keyless_predicates
&& uses_postgres_ctid(options.database_type)
&& find_column_index(options.database_type, &save_columns, DBX_ROWID_COLUMN).is_some();
let mut guarded_predicates: Vec<GuardedPredicate> = Vec::new();
let guard_predicate = |predicate: &str, guarded: &mut Vec<GuardedPredicate>| {
if !guards_keyless_predicates || predicate.trim().is_empty() {
return;
}
if !guarded.iter().any(|existing| existing == predicate) {
guarded.push(predicate.to_string());
if !guarded.iter().any(|existing| existing.sql == predicate) {
guarded.push(GuardedPredicate { sql: predicate.to_string(), requires_match: postgres_ctid_locator });
}
};
@@ -1927,9 +1978,11 @@ fn build_data_grid_save_statements(
}
keyless_guards.extend(guarded_predicates.into_iter().map(|predicate| DataGridSaveGuard {
sql: format!("SELECT COUNT(*) AS {KEYLESS_GUARD_COUNT_ALIAS} FROM {table} WHERE ({predicate})"),
sql: format!("SELECT COUNT(*) AS {KEYLESS_GUARD_COUNT_ALIAS} FROM {table} WHERE ({})", predicate.sql),
max_matched_rows: 1,
min_matched_rows: u32::from(predicate.requires_match),
message: KEYLESS_AMBIGUOUS_ROW_ERROR.to_string(),
min_message: predicate.requires_match.then(|| POSTGRES_CTID_STALE_ROW_ERROR.to_string()),
}));
statements
@@ -1961,6 +2014,9 @@ fn build_data_grid_rollback_statements(
);
let table = data_grid_save_table_name(options, schema);
let mut statements = Vec::new();
let postgres_ctid_identity = uses_postgres_ctid(options.database_type)
&& options.table_meta.primary_keys.is_empty()
&& find_column_index(options.database_type, &save_columns, DBX_ROWID_COLUMN).is_some();
for row in &options.new_rows {
// Server-generated keys are unknown; never invent an undo predicate from
@@ -2097,24 +2153,41 @@ fn build_data_grid_rollback_statements(
if sets.is_empty() {
continue;
}
let mut predicates = vec![build_primary_key_where(
options.database_type,
&options.table_meta.primary_keys,
&save_columns,
&after_row,
column_info,
options.identifier_quote.as_deref(),
)];
predicates.extend(writable_changes.iter().map(|((_, value), column)| {
build_save_column_predicate(
let rollback_identity = if postgres_ctid_identity {
let row_predicate = build_save_row_where(
options.database_type,
column,
value,
column_info_for(column_info, column),
true,
&save_columns,
&after_row,
column_info,
options.identifier_quote.as_deref(),
);
if row_predicate.is_empty() {
continue;
}
format!("({row_predicate}) AND (SELECT COUNT(*) FROM {table} WHERE ({row_predicate})) = 1")
} else {
build_primary_key_where(
options.database_type,
&options.table_meta.primary_keys,
&save_columns,
&after_row,
column_info,
options.identifier_quote.as_deref(),
)
}));
};
let mut predicates = vec![rollback_identity];
if !postgres_ctid_identity {
predicates.extend(writable_changes.iter().map(|((_, value), column)| {
build_save_column_predicate(
options.database_type,
column,
value,
column_info_for(column_info, column),
true,
options.identifier_quote.as_deref(),
)
}));
}
statements.push(data_grid_statement(
options.database_type,
format!(
@@ -3513,6 +3586,21 @@ fn build_primary_key_predicate(
identifier_quote: Option<&str>,
) -> MysqlKeyPredicate {
if primary_keys.is_empty() && uses_keyless_row_predicate(database_type) {
if uses_postgres_ctid(database_type) {
if let Some(row_id_index) = find_column_index(database_type, columns, DBX_ROWID_COLUMN) {
// `validate_postgres_ctid_row_identifier` already refused every
// touched row whose locator does not parse, so this fallback only
// keeps a malformed caller from generating an unguarded write.
return MysqlKeyPredicate {
sql: build_postgres_ctid_predicate(row.get(row_id_index).unwrap_or(&Value::Null))
.unwrap_or_else(|| "FALSE".to_string()),
// A physical row locator is a single equality set, not a
// mergeable plain column equality, and PostgreSQL never
// batches grid writes anyway.
plain: None,
};
}
}
// A keyless row predicate matches every column value: never one plain
// equality, and never batched (`supports_mysql_data_grid_batch`).
return MysqlKeyPredicate {
@@ -4145,6 +4233,37 @@ fn is_synthetic_row_id(database_type: Option<DatabaseType>, name: Option<&str>)
uses_synthetic_row_id(database_type) && name.is_some_and(|name| name.eq_ignore_ascii_case(DBX_ROWID_COLUMN))
}
fn parse_postgres_ctid_row_id(value: &Value) -> Option<(u32, String, u32)> {
let value = value.as_str()?;
let (table_oid, rest) = value.split_once(':')?;
if table_oid.is_empty() || !table_oid.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
let table_oid = table_oid.parse::<u32>().ok()?;
let (ctid, xmin) = rest.split_once(':')?;
let ctid = ctid.strip_prefix('(')?.strip_suffix(')')?;
let (block, offset) = ctid.split_once(',')?;
if block.is_empty()
|| offset.is_empty()
|| !block.bytes().all(|byte| byte.is_ascii_digit())
|| !offset.bytes().all(|byte| byte.is_ascii_digit())
{
return None;
}
let block = block.parse::<u32>().ok()?;
let offset = offset.parse::<u16>().ok()?;
if xmin.is_empty() || !xmin.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
let xmin = xmin.parse::<u32>().ok()?;
Some((table_oid, format!("({block},{offset})"), xmin))
}
fn build_postgres_ctid_predicate(value: &Value) -> Option<String> {
let (table_oid, ctid, xmin) = parse_postgres_ctid_row_id(value)?;
Some(format!("tableoid = '{table_oid}'::oid AND ctid = '{ctid}'::tid AND xmin = '{xmin}'::xid"))
}
pub fn is_neo4j_element_id(database_type: Option<DatabaseType>, name: Option<&str>) -> bool {
database_type == Some(DatabaseType::Neo4j) && name == Some(DBX_NEO4J_ELEMENT_ID_COLUMN)
}
@@ -4399,6 +4518,9 @@ fn clickhouse_no_mutable_columns_error() -> String {
fn predicate_ident(database_type: Option<DatabaseType>, name: &str, identifier_quote: Option<&str>) -> String {
if is_synthetic_row_id(database_type, Some(name)) {
if uses_postgres_ctid(database_type) {
return "(tableoid::text || ':' || ctid::text || ':' || xmin::text)".to_string();
}
if uses_xugu_row_id(database_type) {
return "ROWID".to_string();
}
@@ -7009,6 +7131,25 @@ mod tests {
assert_eq!(equals(Some(DatabaseType::VictoriaMetrics), DBX_ROWID_COLUMN, json!("row-id")), None);
}
#[test]
fn builds_postgres_ctid_context_filter_conditions() {
let condition = build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions {
database_type: Some(DatabaseType::Postgres),
identifier_quote: None,
column_name: DBX_ROWID_COLUMN.to_string(),
mode: DataGridContextFilterMode::Equals,
value: json!("16384:(0,1):742"),
values: Vec::new(),
end_value: None,
column_info: None,
});
assert_eq!(
condition.as_deref(),
Some("(tableoid::text || ':' || ctid::text || ':' || xmin::text) = '16384:(0,1):742'")
);
}
#[test]
fn keeps_iotdb_tree_time_epoch_filters_unquoted_without_column_metadata() {
let condition = |mode, value, values, end_value| {
@@ -9885,6 +10026,32 @@ mod tests {
}
}
fn postgres_ctid_options() -> DataGridSaveStatementOptions {
DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Postgres),
identifier_quote: None,
server_version: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("public".to_string()),
table_name: "duplicate_rows".to_string(),
primary_keys: vec![],
columns: Some(vec![column("id", "integer", false, None), column("value", "text", true, None)]),
},
columns: vec![DBX_ROWID_COLUMN.to_string(), "id".to_string(), "value".to_string()],
source_columns: None,
rows: vec![
vec![json!("16384:(0,1):742"), json!(1), json!("same")],
vec![json!("16384:(0,2):743"), json!(1), json!("same")],
],
dirty_rows: vec![(0, vec![(2, json!("updated"))])],
deleted_rows: vec![],
new_rows: vec![],
include_database_name: false,
}
}
/// The predicate a guard counts must be byte-identical to the one its
/// statement carries, otherwise the safety decision is made against a
/// different set of columns or values than the mutation actually uses.
@@ -9936,12 +10103,73 @@ mod tests {
sql: r#"SELECT COUNT(*) AS dbx_keyless_row_matches FROM "daily_stats" WHERE ("stat_date" = '2026-09-07' AND "period" IS NULL)"#
.to_string(),
max_matched_rows: 1,
min_matched_rows: 0,
message: KEYLESS_AMBIGUOUS_ROW_ERROR.to_string(),
min_message: None,
}]
);
assert_guards_cover_statement_predicates(&result);
}
#[test]
fn uses_postgres_tableoid_and_ctid_to_edit_one_duplicate_row() {
let update = prepare_data_grid_save(postgres_ctid_options());
assert_eq!(update.validation_error, None);
assert_eq!(
update.statements,
vec![
r#"UPDATE "public"."duplicate_rows" SET "value" = 'updated' WHERE tableoid = '16384'::oid AND ctid = '(0,1)'::tid AND xmin = '742'::xid;"#
]
);
assert_eq!(update.keyless_guards.len(), 1);
assert_eq!(
update.keyless_guards[0].sql,
r#"SELECT COUNT(*) AS dbx_keyless_row_matches FROM "public"."duplicate_rows" WHERE (tableoid = '16384'::oid AND ctid = '(0,1)'::tid AND xmin = '742'::xid)"#
);
// A physical locator must still address one row: zero matches means the
// row moved, which the frontend reports instead of "deleted successfully".
assert_eq!(update.keyless_guards[0].min_matched_rows, 1);
assert_eq!(update.keyless_guards[0].min_message.as_deref(), Some(POSTGRES_CTID_STALE_ROW_ERROR));
assert_guards_cover_statement_predicates(&update);
// CTID changes after UPDATE, so history rollback uses the new full-row
// values and only proceeds if that predicate still identifies one row.
assert_eq!(
update.rollback_statements,
vec![
r#"UPDATE "public"."duplicate_rows" SET "value" = 'same' WHERE ("id" = 1 AND "value" = 'updated') AND (SELECT COUNT(*) FROM "public"."duplicate_rows" WHERE ("id" = 1 AND "value" = 'updated')) = 1;"#
]
);
let mut delete_options = postgres_ctid_options();
delete_options.dirty_rows.clear();
delete_options.deleted_rows = vec![0];
let delete = prepare_data_grid_save(delete_options);
assert_eq!(
delete.statements,
vec![
r#"DELETE FROM "public"."duplicate_rows" WHERE tableoid = '16384'::oid AND ctid = '(0,1)'::tid AND xmin = '742'::xid;"#
]
);
assert_eq!(delete.keyless_guards.len(), 1);
assert_eq!(delete.keyless_guards[0].min_matched_rows, 1);
assert_eq!(delete.keyless_guards[0].min_message.as_deref(), Some(POSTGRES_CTID_STALE_ROW_ERROR));
assert_guards_cover_statement_predicates(&delete);
}
#[test]
fn rejects_invalid_postgres_ctid_before_building_mutations() {
let mut options = postgres_ctid_options();
options.rows[0][0] = json!("not-a-ctid");
let result = prepare_data_grid_save(options);
assert_eq!(result.validation_error.as_deref(), Some(POSTGRES_CTID_UNIDENTIFIABLE_ROW_ERROR));
assert!(result.statements.is_empty());
assert!(result.keyless_guards.is_empty());
}
#[test]
fn guards_sqlite_keyless_update_even_when_the_loaded_page_looks_unique() {
// The duplicate row may live outside the loaded/filtered page, so a
+2 -2
View File
@@ -19,8 +19,8 @@ mod descriptor_snapshots;
pub use capabilities::{
firebird_rows_clause, is_schema_aware, pagination_strategy, table_pagination_strategy, uses_fetch_first,
uses_oracle_row_id, uses_single_row_insert_statements, uses_synthetic_row_id, uses_xugu_row_id, PaginationContext,
TablePaginationStrategy,
uses_oracle_row_id, uses_postgres_ctid, uses_single_row_insert_statements, uses_synthetic_row_id, uses_xugu_row_id,
PaginationContext, TablePaginationStrategy,
};
pub use ddl_profile::{
profile_for, AutoIncSyntax, DdlDialectProfile, IndexTypePlacement, QuoteStyle, RenameColumnSyntax, TriggerTemplate,
@@ -89,8 +89,14 @@ pub fn uses_xugu_row_id(database_type: Option<DatabaseType>) -> bool {
database_type == Some(DatabaseType::Xugu)
}
/// PostgreSQL exposes `ctid` on ordinary tables. Pair it with `tableoid` to
/// distinguish inherited relations and `xmin` to reject a recycled tuple location.
pub fn uses_postgres_ctid(database_type: Option<DatabaseType>) -> bool {
database_type == Some(DatabaseType::Postgres)
}
pub fn uses_synthetic_row_id(database_type: Option<DatabaseType>) -> bool {
uses_oracle_row_id(database_type) || uses_xugu_row_id(database_type)
uses_oracle_row_id(database_type) || uses_xugu_row_id(database_type) || uses_postgres_ctid(database_type)
}
/// Oracle 系方言不支持 `INSERT ... VALUES (...), (...)` 多行语法,
@@ -1,7 +1,8 @@
use crate::models::connection::DatabaseType;
use super::capabilities::{
firebird_rows_clause, table_pagination_strategy, uses_oracle_row_id, uses_xugu_row_id, TablePaginationStrategy,
firebird_rows_clause, table_pagination_strategy, uses_oracle_row_id, uses_postgres_ctid, uses_xugu_row_id,
TablePaginationStrategy,
};
use super::identifiers::{
normalize_where_input, parse_sqlserver_linked_schema_ref, qualified_table_name, qualified_table_name_with_catalog,
@@ -311,9 +312,19 @@ pub fn build_table_data_select_sql_with_database(
&& is_oracle_base_table_type(options.table_type.as_deref());
let include_xugu_row_id =
options.include_row_id && uses_xugu_row_id(database_type) && !is_view_table_type(options.table_type.as_deref());
let include_postgres_ctid = options.include_row_id
&& uses_postgres_ctid(database_type)
&& is_postgres_base_table_type(options.table_type.as_deref())
&& options.primary_keys.is_empty();
let offset = options.offset.unwrap_or(0);
let select_columns = if include_oracle_row_id {
format!("ROWIDTOCHAR(t.ROWID) AS \"{DBX_ROWID_COLUMN}\", t.*")
} else if include_postgres_ctid {
// tableoid separates inherited relations; xmin keeps a recycled CTID from
// identifying a new tuple. The body stays `*` — the projection this engine
// already used for table data. Bounded large-value previews stay off here
// because they are keyed off a declared primary key, which this table lacks.
format!("tableoid::text || ':' || ctid::text || ':' || xmin::text AS \"{DBX_ROWID_COLUMN}\", *")
} else if let Some(preview_columns) = build_large_value_preview_columns(&options) {
preview_columns
} else if include_xugu_row_id {
@@ -601,6 +612,17 @@ fn is_oracle_base_table_type(table_type: Option<&str>) -> bool {
table_type.is_some_and(|value| value.trim().eq_ignore_ascii_case("TABLE"))
}
fn is_postgres_base_table_type(table_type: Option<&str>) -> bool {
// The PostgreSQL driver reports `relkind = 'r'` as the information-schema
// spelling `BASE TABLE`, while other metadata paths (and the hand-written
// tests) use `TABLE`. Accept both so the locator is actually injected for a
// real sidebar preview.
table_type.is_some_and(|value| {
let value = value.trim();
value.eq_ignore_ascii_case("TABLE") || value.eq_ignore_ascii_case("BASE TABLE")
})
}
pub fn build_table_select_sql(options: TableSelectSqlOptions<'_>) -> String {
let database_type = options.database_type;
if database_type == Some(DatabaseType::VictoriaMetrics) {
@@ -1293,6 +1293,47 @@ fn explicit_table_data_order_is_preserved() {
);
}
#[test]
fn selects_hidden_postgres_tuple_identity_only_for_known_unkeyed_base_tables() {
let base = TableDataSelectSqlOptions {
database_type: Some(DatabaseType::Postgres),
schema: Some("public".to_string()),
table_name: "duplicate_rows".to_string(),
table_type: Some("TABLE".to_string()),
columns: vec!["id".to_string(), "value".to_string()],
limit: Some(20),
include_row_id: true,
..Default::default()
};
assert_eq!(
build_table_data_select_sql(base.clone()),
"SELECT tableoid::text || ':' || ctid::text || ':' || xmin::text AS \"__DBX_ROWID\", * FROM \"public\".\"duplicate_rows\" LIMIT 20;"
);
// The sidebar's `listTables` path spells relkind 'r' as `BASE TABLE`; the
// locator must be injected for that spelling too.
let base_table_spelling = build_table_data_select_sql(TableDataSelectSqlOptions {
table_type: Some("BASE TABLE".to_string()),
..base.clone()
});
assert!(base_table_spelling.contains("ctid"), "BASE TABLE must receive the locator: {base_table_spelling}");
for table_type in ["PARTITIONED TABLE", "FOREIGN TABLE", "VIEW"] {
let sql = build_table_data_select_sql(TableDataSelectSqlOptions {
table_type: Some(table_type.to_string()),
..base.clone()
});
assert!(!sql.contains("ctid"), "{table_type} must not receive a synthetic tuple key: {sql}");
}
let unknown_type = build_table_data_select_sql(TableDataSelectSqlOptions { table_type: None, ..base.clone() });
assert!(!unknown_type.contains("ctid"));
let keyed = build_table_data_select_sql(TableDataSelectSqlOptions { primary_keys: vec!["id".to_string()], ..base });
assert!(!keyed.contains("ctid"));
}
#[test]
fn builds_iris_table_data_sql_with_literal_top_and_ordinary_object() {
let sql = build_table_data_select_sql(TableDataSelectSqlOptions {
+7 -1
View File
@@ -145,7 +145,13 @@ test("detects the synthetic Oracle ROWID key case", () => {
assert.equal(usesSyntheticRowIdKey("oceanbase-oracle", [DBX_ROWID_COLUMN]), true);
assert.equal(usesSyntheticRowIdKey("oracle", [DBX_ROWID_COLUMN], "VIEW"), false);
assert.equal(usesSyntheticRowIdKey("oracle", [DBX_ROWID_COLUMN], "MATERIALIZED_VIEW"), false);
assert.equal(usesSyntheticRowIdKey("postgres", [DBX_ROWID_COLUMN]), false);
// PostgreSQL identifies keyless rows by the hidden `tableoid:ctid:xmin`
// locator, so a bare `__DBX_ROWID` key is the synthetic one. Unknown object
// types still fail closed rather than assuming a base table.
assert.equal(usesSyntheticRowIdKey("postgres", [DBX_ROWID_COLUMN]), true);
assert.equal(usesSyntheticRowIdKey("postgres", [DBX_ROWID_COLUMN], "TABLE"), true);
assert.equal(usesSyntheticRowIdKey("postgres", [DBX_ROWID_COLUMN], "BASE TABLE"), true);
assert.equal(usesSyntheticRowIdKey("postgres", [DBX_ROWID_COLUMN], "PARTITIONED TABLE"), false);
assert.equal(usesSyntheticRowIdKey("oracle", ["ID"]), false);
assert.equal(usesSyntheticRowIdKey("neo4j", [DBX_NEO4J_ELEMENT_ID_COLUMN]), true);
});