mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 18:36:36 +00:00
fix(grid): edit keyless postgres tables via ctid row locator
This commit is contained in:
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user