fix(postgres): include owner and grants in displayed DDL

This commit is contained in:
zipg 2026-07-27 21:45:35 +08:00 committed by GitHub
parent a5eb6fd1fa
commit 1d671ca698
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 479 additions and 12 deletions

View File

@ -7130,7 +7130,7 @@ async function fetchDdl() {
ddlLoading.value = true;
try {
// Preserve view identity so the backend loads the stored view source instead of synthesizing table DDL.
ddlContent.value = await api.getTableDdl(props.connectionId, props.database || "", props.tableMeta.schema || props.database || "", props.tableMeta.tableName, tableObjectSourceKind(props.tableMeta.tableType), props.tableMeta.catalog);
ddlContent.value = await api.getTableDisplayDdl(props.connectionId, props.database || "", props.tableMeta.schema || props.database || "", props.tableMeta.tableName, tableObjectSourceKind(props.tableMeta.tableType), props.tableMeta.catalog);
} catch (e: any) {
ddlContent.value = `-- Error: ${e}`;
} finally {

View File

@ -61,7 +61,7 @@ watch(
ddlLoading.value = true;
try {
const schema = props.schema || props.database;
const ddl = await api.getTableDdl(props.connectionId, props.database, schema, props.tableName, props.objectType, props.catalog);
const ddl = await api.getTableDisplayDdl(props.connectionId, props.database, schema, props.tableName, props.objectType, props.catalog);
ddlContent.value = await formatSqlForDisplay(ddl, props.formatDialect ?? props.dialect, settingsStore.editorSettings.sqlFormatter);
} catch (e: any) {
ddlError.value = e?.message || String(e);
@ -176,7 +176,7 @@ function retry() {
ddlContent.value = "";
const schema = props.schema || props.database;
api
.getTableDdl(props.connectionId, props.database, schema, props.tableName, props.objectType)
.getTableDisplayDdl(props.connectionId, props.database, schema, props.tableName, props.objectType)
.then(async (ddl) => {
ddlContent.value = await formatSqlForDisplay(ddl, props.formatDialect ?? props.dialect, settingsStore.editorSettings.sqlFormatter);
})

View File

@ -934,7 +934,7 @@ async function fetchTableDdl() {
tableDdlLoading.value = true;
try {
const schema = row.schema || selectedSchema.value || props.database;
const ddl = await api.getTableDdl(props.connection.id, props.database || "", schema, row.name, tableDdlObjectType(row.type), props.catalog);
const ddl = await api.getTableDisplayDdl(props.connection.id, props.database || "", schema, row.name, tableDdlObjectType(row.type), props.catalog);
if (sidePanelGuard.isStale(epoch)) return;
tableDdlContent.value = ddl;
} catch (e: any) {

View File

@ -1248,9 +1248,9 @@ async function generateDdlTemplate() {
const schema = node.schema || node.database;
let ddl: string;
if (node.type === "table") {
ddl = await api.getTableDdl(node.connectionId, node.database, schema, node.label, undefined, node.catalog);
ddl = await api.getTableDisplayDdl(node.connectionId, node.database, schema, node.label, undefined, node.catalog);
} else if (node.type === "materialized_view") {
ddl = await api.getTableDdl(node.connectionId, node.database, schema, node.label, "MATERIALIZED_VIEW", node.catalog);
ddl = await api.getTableDisplayDdl(node.connectionId, node.database, schema, node.label, "MATERIALIZED_VIEW", node.catalog);
} else {
const result = await api.getObjectSource(node.connectionId, node.database, schema, node.label, "VIEW");
ddl = await buildViewDdl({

View File

@ -155,7 +155,7 @@ async function fetchDdl() {
if (!props.connectionId || !props.database || !props.tableName || ddlFetched.value || !tableMetadataCapabilities.value.ddl) return;
ddlLoading.value = true;
try {
const ddl = await api.getTableDdl(props.connectionId, props.database, metadataSchema.value, props.tableName, undefined, props.catalog);
const ddl = await api.getTableDisplayDdl(props.connectionId, props.database, metadataSchema.value, props.tableName, undefined, props.catalog);
ddlContent.value = await formatSqlForDisplay(ddl, sqlFormatDialectForDbType(databaseType.value), settingsStore.editorSettings.sqlFormatter);
ddlFetched.value = true;
} catch (e: any) {
@ -960,7 +960,7 @@ async function hydrateRestoredDraftFromDatabase() {
let nextColumns = await api.getColumns(connectionId, database, schema, tableName, catalog);
if (databaseType.value === "manticoresearch" && tableMetadataCapabilities.value.ddl) {
try {
const ddl = await api.getTableDdl(connectionId, database, schema, tableName, undefined, catalog);
const ddl = await api.getTableDisplayDdl(connectionId, database, schema, tableName, undefined, catalog);
ddlContent.value = await formatSqlForDisplay(ddl, sqlFormatDialectForDbType(databaseType.value), settingsStore.editorSettings.sqlFormatter);
ddlFetched.value = true;
nextColumns = applyManticoreDdlColumnExtras(nextColumns, ddl);
@ -1256,7 +1256,7 @@ async function loadStructure(silent = false, scope: TableStructureRefreshScope =
if (nextColumns) {
if (databaseType.value === "manticoresearch" && tableMetadataCapabilities.value.ddl) {
try {
const ddl = await api.getTableDdl(connectionId, database, schema, tableName, undefined, catalog);
const ddl = await api.getTableDisplayDdl(connectionId, database, schema, tableName, undefined, catalog);
ddlContent.value = await formatSqlForDisplay(ddl, sqlFormatDialectForDbType(databaseType.value), settingsStore.editorSettings.sqlFormatter);
ddlFetched.value = true;
nextColumns = applyManticoreDdlColumnExtras(nextColumns, ddl);

View File

@ -5,6 +5,8 @@ const contentAreaSource = readFileSync(new URL("../../../components/layout/Conte
const connectionTreeSource = readFileSync(new URL("../../../components/sidebar/ConnectionTree.vue", import.meta.url), "utf8");
const ddlViewDialogSource = readFileSync(new URL("../../../components/objects/DdlViewDialog.vue", import.meta.url), "utf8");
const objectBrowserSource = readFileSync(new URL("../../../components/objects/ObjectBrowser.vue", import.meta.url), "utf8");
const fetchTableDdlSource = objectBrowserSource.match(/async function fetchTableDdl\(\)[\s\S]*?(?=\nasync function fetchTableColumns\()/)?.[0] ?? "";
const exportStructureSource = objectBrowserSource.match(/async function exportStructure\([\s\S]*?(?=\nasync function exportDataLegacy\()/)?.[0] ?? "";
function openingTag(source: string, componentName: string): string {
return source.match(new RegExp(`<${componentName}\\b[\\s\\S]*?>`))?.[0] ?? "";
@ -24,7 +26,7 @@ describe("ContentArea external catalog wiring", () => {
});
it("forwards the DDL dialog catalog to the metadata API", () => {
expect(ddlViewDialogSource).toMatch(/api\.getTableDdl\([\s\S]*?props\.objectType, props\.catalog\)/);
expect(ddlViewDialogSource).toMatch(/api\.getTableDisplayDdl\([\s\S]*?props\.objectType, props\.catalog\)/);
});
});
@ -43,3 +45,14 @@ describe("ContentArea object browser refresh wiring", () => {
expect(objectBrowserSource).toMatch(/<Button[^>]*:title="refreshTooltip"[^>]*@click="reload">/);
});
});
describe("ObjectBrowser DDL API boundaries", () => {
it("uses display DDL for the table information panel", () => {
expect(fetchTableDdlSource).toMatch(/api\.getTableDisplayDdl\([\s\S]*?props\.catalog\);/);
});
it("keeps structure exports on the portable base DDL", () => {
expect(exportStructureSource).toMatch(/api\.getTableDdl\([\s\S]*?props\.catalog\);/);
expect(exportStructureSource).not.toContain("api.getTableDisplayDdl(");
});
});

View File

@ -165,6 +165,7 @@ export const listConstraints = forward("listConstraints");
export const listPartitions = forward("listPartitions");
export const listSubpartitions = forward("listSubpartitions");
export const getTableDdl = forward("getTableDdl");
export const getTableDisplayDdl = forward("getTableDisplayDdl");
export const listFunctions = forward("listFunctions");
export const listSequences = forward("listSequences");
export const listRules = forward("listRules");

View File

@ -722,6 +722,10 @@ export async function getTableDdl(connectionId: string, database: string, schema
return get(`/api/schema/ddl?${qs({ connection_id: connectionId, database, schema, table, object_type: objectType, catalog })}`);
}
export async function getTableDisplayDdl(connectionId: string, database: string, schema: string, table: string, objectType?: ObjectSourceKind, catalog?: string): Promise<string> {
return get(`/api/schema/ddl?${qs({ connection_id: connectionId, database, schema, table, object_type: objectType, catalog, include_postgres_access: true })}`);
}
export async function prepareSchemaDiff(options: SchemaDiffPreparationOptions): Promise<SchemaDiffPreparation> {
return post("/api/schema-diff/prepare", options);
}

View File

@ -1202,6 +1202,10 @@ export async function getTableDdl(connectionId: string, database: string, schema
return invoke("get_table_ddl", { connectionId, database, schema, table, objectType, catalog });
}
export async function getTableDisplayDdl(connectionId: string, database: string, schema: string, table: string, objectType?: ObjectSourceKind, catalog?: string): Promise<string> {
return invoke("get_table_ddl", { connectionId, database, schema, table, objectType, catalog, includePostgresAccess: true });
}
export async function prepareSchemaDiff(options: SchemaDiffPreparationOptions): Promise<SchemaDiffPreparation> {
return invoke("prepare_schema_diff", { options });
}

View File

@ -30,6 +30,22 @@ use crate::types::{
OwnerInfo, QueryResult, RuleInfo, SchemaInfo, SequenceInfo, TableInfo, TriggerInfo,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PostgresTablePrivilegeInfo {
pub grantor: String,
pub grantee: String,
pub privilege_type: String,
pub is_grantable: bool,
pub column_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PostgresTableAccessInfo {
pub owner: String,
pub owner_default_privileges: Vec<String>,
pub privileges: Vec<PostgresTablePrivilegeInfo>,
}
fn pg_temporal_to_json_value(row: &Row, idx: usize) -> Option<serde_json::Value> {
if let Ok(v) = row.try_get::<_, DateTime<Local>>(idx) {
return Some(serde_json::Value::String(format_pg_timestamptz(v)));
@ -3338,6 +3354,37 @@ const POSTGRES_OWNERS_SQL: &str =
WHERE n.nspname = $1 \
AND c.relkind IN ('r', 'v', 'm', 'S', 'f', 'p')";
const POSTGRES_TABLE_OWNER_SQL: &str = "SELECT pg_get_userbyid(c.relowner)::text, \
ARRAY(SELECT default_acl.privilege_type::text \
FROM pg_catalog.aclexplode(pg_catalog.acldefault('r', c.relowner)) default_acl \
WHERE default_acl.grantee = c.relowner \
ORDER BY default_acl.privilege_type) \
FROM pg_catalog.pg_class c \
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
WHERE n.nspname = $1 AND c.relname = $2 AND c.relkind IN ('r', 'p') \
ORDER BY c.oid LIMIT 1";
const POSTGRES_TABLE_ACL_PRIVILEGES_SQL: &str =
"SELECT CASE WHEN acl.grantee = 0 THEN 'PUBLIC' ELSE grantee.rolname END::text, \
acl.privilege_type::text, acl.is_grantable, pg_get_userbyid(acl.grantor)::text \
FROM pg_catalog.pg_class c \
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
JOIN LATERAL pg_catalog.aclexplode(COALESCE(c.relacl, pg_catalog.acldefault('r', c.relowner))) acl ON true \
LEFT JOIN pg_catalog.pg_roles grantee ON grantee.oid = acl.grantee \
WHERE n.nspname = $1 AND c.relname = $2 AND c.relkind IN ('r', 'p') \
ORDER BY 4, 1, 2, 3";
const POSTGRES_COLUMN_ACL_PRIVILEGES_SQL: &str =
"SELECT CASE WHEN acl.grantee = 0 THEN 'PUBLIC' ELSE grantee.rolname END::text, \
acl.privilege_type::text, acl.is_grantable, a.attname::text, pg_get_userbyid(acl.grantor)::text \
FROM pg_catalog.pg_class c \
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped \
JOIN LATERAL pg_catalog.aclexplode(a.attacl) acl ON true \
LEFT JOIN pg_catalog.pg_roles grantee ON grantee.oid = acl.grantee \
WHERE n.nspname = $1 AND c.relname = $2 AND c.relkind IN ('r', 'p') \
ORDER BY 5, 1, 2, 3, 4";
fn postgres_owner_object_type(relkind: &str) -> &str {
match relkind {
"r" => "TABLE",
@ -3815,6 +3862,53 @@ pub async fn list_owners(pool: &Pool, schema: &str) -> Result<Vec<OwnerInfo>, St
.collect())
}
pub async fn get_table_access(pool: &Pool, schema: &str, table: &str) -> Result<PostgresTableAccessInfo, String> {
let client = checkout_postgres_client(pool, None, super::connection_timeout()).await?;
let params: [&(dyn tokio_postgres::types::ToSql + Sync); 2] = [&schema, &table];
let owner_rows =
postgres_query_cached(&client, POSTGRES_TABLE_OWNER_SQL, &params).await.map_err(pg_error_to_string)?;
let owner_row = owner_rows.first().ok_or_else(|| "Table owner not found".to_string())?;
let owner = pg_row_try_string(owner_row, 0);
if owner.is_empty() {
return Err("Table owner not found".to_string());
}
let owner_default_privileges = owner_row.try_get::<_, Vec<String>>(1).unwrap_or_default();
if owner_default_privileges.is_empty() {
return Err("Table owner default privileges are unavailable".to_string());
}
let (table_privileges, column_privileges) = tokio::try_join!(
postgres_query_cached(&client, POSTGRES_TABLE_ACL_PRIVILEGES_SQL, &params),
postgres_query_cached(&client, POSTGRES_COLUMN_ACL_PRIVILEGES_SQL, &params),
)
.map_err(pg_error_to_string)?;
let privileges = table_privileges
.iter()
.map(|row| PostgresTablePrivilegeInfo {
grantor: pg_row_try_string(row, 3),
grantee: pg_row_try_string(row, 0),
privilege_type: pg_row_try_string(row, 1),
is_grantable: pg_row_try_bool(row, 2).unwrap_or(false),
column_name: None,
})
.chain(column_privileges.iter().map(|row| PostgresTablePrivilegeInfo {
grantor: pg_row_try_string(row, 4),
grantee: pg_row_try_string(row, 0),
privilege_type: pg_row_try_string(row, 1),
is_grantable: pg_row_try_bool(row, 2).unwrap_or(false),
column_name: Some(pg_row_try_string(row, 3)),
}))
.collect::<Vec<_>>();
if privileges.iter().any(|privilege| {
privilege.grantor.is_empty() || privilege.grantee.is_empty() || privilege.privilege_type.is_empty()
}) {
return Err("Table ACL metadata is incomplete".to_string());
}
Ok(PostgresTableAccessInfo { owner, owner_default_privileges, privileges })
}
/// Execute multiple SQL statements in a single round-trip using batch_execute.
/// Best for DDL scripts where per-statement affected-row counts are not needed.
pub async fn execute_batch(pool: &Pool, statements: &[String]) -> Result<(), String> {
@ -4617,6 +4711,19 @@ mod tests {
assert!(escaped.matches('"').count().is_multiple_of(2), "quote count should be even");
}
#[test]
fn postgres_table_access_reads_complete_catalog_acls() {
assert!(POSTGRES_TABLE_OWNER_SQL.contains("acldefault('r', c.relowner)"));
assert!(
POSTGRES_TABLE_ACL_PRIVILEGES_SQL.contains("COALESCE(c.relacl, pg_catalog.acldefault('r', c.relowner))")
);
assert!(POSTGRES_COLUMN_ACL_PRIVILEGES_SQL.contains("aclexplode(a.attacl)"));
assert!(POSTGRES_TABLE_ACL_PRIVILEGES_SQL.contains("acl.grantee = 0 THEN 'PUBLIC'"));
assert!(POSTGRES_COLUMN_ACL_PRIVILEGES_SQL.contains("acl.grantee = 0 THEN 'PUBLIC'"));
assert!(POSTGRES_TABLE_ACL_PRIVILEGES_SQL.contains("pg_get_userbyid(acl.grantor)"));
assert!(POSTGRES_COLUMN_ACL_PRIVILEGES_SQL.contains("pg_get_userbyid(acl.grantor)"));
}
// --- query_result_row_limit ---
#[test]

View File

@ -3,7 +3,7 @@ use crate::db;
use crate::models::connection::{ConnectionConfig, DatabaseType};
use crate::query::{agent_execute_query_params, should_discard_pool_after_error, QueryExecutionOptions};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::future::Future;
use std::sync::Arc;
use std::time::{Duration, Instant};
@ -5645,6 +5645,29 @@ pub async fn get_table_ddl_core(
schema: &str,
table: &str,
object_type: Option<db::ObjectSourceKind>,
) -> Result<String, String> {
get_table_ddl_core_with_options(state, connection_id, database, schema, table, object_type, false).await
}
pub async fn get_table_display_ddl_core(
state: &AppState,
connection_id: &str,
database: &str,
schema: &str,
table: &str,
object_type: Option<db::ObjectSourceKind>,
) -> Result<String, String> {
get_table_ddl_core_with_options(state, connection_id, database, schema, table, object_type, true).await
}
async fn get_table_ddl_core_with_options(
state: &AppState,
connection_id: &str,
database: &str,
schema: &str,
table: &str,
object_type: Option<db::ObjectSourceKind>,
include_postgres_access: bool,
) -> Result<String, String> {
if crate::sql_dialect::parse_sqlserver_linked_schema_ref(schema).is_some() {
return Err("DDL is not supported for SQL Server linked server tables".to_string());
@ -5801,6 +5824,11 @@ pub async fn get_table_ddl_core(
PoolKind::Postgres(p) if db_config.as_ref().is_some_and(is_cloudberry_config) => {
cloudberry_ddl(p, schema, table).await
}
PoolKind::Postgres(p)
if include_postgres_access && db_config.as_ref().is_some_and(is_native_postgres_config) =>
{
pg_display_ddl(p, schema, table).await
}
PoolKind::Postgres(p) => pg_ddl(p, schema, table).await,
PoolKind::Sqlite(p) => sqlite_ddl(p, schema, table).await,
PoolKind::Rqlite(client) => db::rqlite_driver::table_ddl(client, table).await,
@ -5818,6 +5846,10 @@ fn is_opengauss_family_config(config: &ConnectionConfig) -> bool {
|| matches!(config.driver_profile.as_deref(), Some("opengauss" | "gaussdb"))
}
fn is_native_postgres_config(config: &ConnectionConfig) -> bool {
config.db_type == DatabaseType::Postgres && matches!(config.driver_profile.as_deref(), None | Some("postgres"))
}
fn is_cloudberry_config(config: &ConnectionConfig) -> bool {
matches!(config.driver_profile.as_deref(), Some("cloudberry"))
}
@ -7244,6 +7276,85 @@ mod ddl_tests {
assert!(ddl.contains("COMMENT ON TABLE \"public\".\"users\" IS 'User table';"));
}
#[test]
fn postgres_display_ddl_preserves_owner_revokes_and_grant_chain() {
use db::postgres::{PostgresTableAccessInfo, PostgresTablePrivilegeInfo};
let privilege =
|grantor: &str, grantee: &str, privilege_type: &str, is_grantable: bool, column_name: Option<&str>| {
PostgresTablePrivilegeInfo {
grantor: grantor.to_string(),
grantee: grantee.to_string(),
privilege_type: privilege_type.to_string(),
is_grantable,
column_name: column_name.map(str::to_string),
}
};
let access = PostgresTableAccessInfo {
owner: "table\"owner".to_string(),
owner_default_privileges: vec!["DELETE", "INSERT", "SELECT", "UPDATE"]
.into_iter()
.map(str::to_string)
.collect(),
privileges: vec![
privilege("table\"owner", "table\"owner", "SELECT", false, None),
privilege("table\"owner", "z manager", "SELECT", true, None),
privilege("table\"owner", "z manager", "SELECT", true, Some("customer_name")),
privilege("z manager", "a delegate", "SELECT", true, None),
privilege("a delegate", "reader role", "SELECT", false, Some("customer_name")),
privilege("z manager", "reader role", "SELECT", false, Some("customer_name")),
privilege("table\"owner", "PUBLIC", "INSERT", false, Some("customer_name")),
privilege("table\"owner", "PUBLIC", "INSERT", false, Some("amount")),
],
};
let ddl = append_postgres_access_ddl(
"CREATE TABLE \"app\".\"orders\" (\n \"id\" bigint\n);\n".to_string(),
"app",
"orders",
&access,
);
assert!(ddl.contains("ALTER TABLE \"app\".\"orders\" OWNER TO \"table\"\"owner\";"));
assert!(ddl.contains("REVOKE DELETE, INSERT, UPDATE ON TABLE \"app\".\"orders\" FROM \"table\"\"owner\";"));
assert!(ddl.contains("GRANT INSERT (\"amount\", \"customer_name\") ON TABLE \"app\".\"orders\" TO PUBLIC;"));
assert!(ddl.contains("GRANT SELECT (\"customer_name\") ON TABLE \"app\".\"orders\" TO \"reader role\";"));
let owner_role = ddl.find("SET ROLE \"table\"\"owner\";").unwrap();
let manager_role = ddl.find("SET ROLE \"z manager\";").unwrap();
let delegate_role = ddl.find("SET ROLE \"a delegate\";").unwrap();
assert!(owner_role < manager_role && manager_role < delegate_role, "ddl: {ddl}");
assert!(ddl[owner_role..manager_role]
.contains("GRANT SELECT ON TABLE \"app\".\"orders\" TO \"z manager\" WITH GRANT OPTION;"));
assert!(ddl[owner_role..manager_role].contains(
"GRANT SELECT (\"customer_name\") ON TABLE \"app\".\"orders\" TO \"z manager\" WITH GRANT OPTION;"
));
assert!(ddl[manager_role..delegate_role]
.contains("GRANT SELECT ON TABLE \"app\".\"orders\" TO \"a delegate\" WITH GRANT OPTION;"));
assert!(ddl[delegate_role..]
.contains("GRANT SELECT (\"customer_name\") ON TABLE \"app\".\"orders\" TO \"reader role\";"));
}
#[test]
fn postgres_display_ddl_can_revoke_all_owner_ordinary_privileges() {
let access = db::postgres::PostgresTableAccessInfo {
owner: "locked_owner".to_string(),
owner_default_privileges: vec!["INSERT", "SELECT", "UPDATE"].into_iter().map(str::to_string).collect(),
privileges: vec![],
};
let ddl = append_postgres_access_ddl(
"CREATE TABLE \"app\".\"locked\" (\"id\" bigint);".to_string(),
"app",
"locked",
&access,
);
assert!(ddl.contains("SET ROLE \"locked_owner\";"));
assert!(ddl.contains("REVOKE INSERT, SELECT, UPDATE ON TABLE \"app\".\"locked\" FROM \"locked_owner\";"));
assert!(ddl.ends_with("RESET ROLE;"));
}
#[test]
fn postgres_table_ddl_omits_table_comment_when_empty() {
let columns = vec![column("id", "integer")];
@ -7546,6 +7657,215 @@ pub async fn pg_ddl(pool: &deadpool_postgres::Pool, schema: &str, table: &str) -
))
}
async fn pg_display_ddl(pool: &deadpool_postgres::Pool, schema: &str, table: &str) -> Result<String, String> {
let (ddl, access) = tokio::join!(pg_ddl(pool, schema, table), db::postgres::get_table_access(pool, schema, table));
let ddl = ddl?;
match access {
Ok(access) => Ok(append_postgres_access_ddl(ddl, schema, table, &access)),
Err(error) => {
log::warn!(
"[schema][postgres:table-access-ddl-fallback] schema={} table={} error={}",
schema,
table,
error
);
Ok(ddl)
}
}
}
fn append_postgres_access_ddl(
mut ddl: String,
schema: &str,
table: &str,
access: &db::postgres::PostgresTableAccessInfo,
) -> String {
let table_name = format!("{}.{}", pg_ident(schema), pg_ident(table));
ddl = ddl.trim_end().to_string();
if !ddl.ends_with(';') {
ddl.push(';');
}
ddl.push_str(&format!("\n\nALTER TABLE {table_name} OWNER TO {};", pg_ident(&access.owner)));
let owner_privileges = access
.privileges
.iter()
.filter(|privilege| {
privilege.grantor == access.owner && privilege.grantee == access.owner && privilege.column_name.is_none()
})
.map(|privilege| privilege.privilege_type.clone())
.collect::<BTreeSet<_>>();
let owner_revokes = access
.owner_default_privileges
.iter()
.filter(|privilege| !owner_privileges.contains(*privilege))
.cloned()
.collect::<BTreeSet<_>>();
let grants = normalized_postgres_grants(access);
let grantor_order = postgres_grantor_order(&access.owner, !owner_revokes.is_empty(), &grants);
// PostgreSQL records the active granting role, so each batch must run in that role's context.
for grantor in grantor_order {
ddl.push_str(&format!("\n\nSET ROLE {};", pg_ident(&grantor)));
if grantor == access.owner && !owner_revokes.is_empty() {
ddl.push_str(&format!(
"\nREVOKE {} ON TABLE {table_name} FROM {};",
owner_revokes.iter().cloned().collect::<Vec<_>>().join(", "),
pg_ident(&access.owner)
));
}
append_postgres_grants_for_role(&mut ddl, &table_name, &grantor, &grants);
ddl.push_str("\nRESET ROLE;");
}
ddl
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PostgresGrant {
grantor: String,
grantee: String,
privilege_type: String,
is_grantable: bool,
column_name: Option<String>,
}
fn normalized_postgres_grants(access: &db::postgres::PostgresTableAccessInfo) -> Vec<PostgresGrant> {
let mut grants = BTreeMap::<(String, String, String, Option<String>), bool>::new();
for privilege in &access.privileges {
if privilege.grantor == access.owner && privilege.grantee == access.owner && privilege.column_name.is_none() {
continue;
}
*grants
.entry((
privilege.grantor.clone(),
privilege.grantee.clone(),
privilege.privilege_type.clone(),
privilege.column_name.clone(),
))
.or_default() |= privilege.is_grantable;
}
grants
.into_iter()
.map(|((grantor, grantee, privilege_type, column_name), is_grantable)| PostgresGrant {
grantor,
grantee,
privilege_type,
is_grantable,
column_name,
})
.collect()
}
fn postgres_grant_scope_covers(parent: &PostgresGrant, child: &PostgresGrant) -> bool {
if parent.privilege_type != child.privilege_type {
return false;
}
match (&parent.column_name, &child.column_name) {
(None, _) => true,
(Some(parent), Some(child)) => parent == child,
(Some(_), None) => false,
}
}
fn postgres_grantor_order(owner: &str, include_owner: bool, grants: &[PostgresGrant]) -> Vec<String> {
let mut remaining = grants.iter().map(|grant| grant.grantor.clone()).collect::<BTreeSet<_>>();
if include_owner {
remaining.insert(owner.to_string());
}
let mut dependencies = BTreeMap::<String, BTreeSet<String>>::new();
for child in grants.iter().filter(|grant| grant.grantor != owner) {
for parent in grants.iter().filter(|grant| {
grant.grantee == child.grantor
&& grant.is_grantable
&& grant.grantor != child.grantor
&& postgres_grant_scope_covers(grant, child)
}) {
dependencies.entry(child.grantor.clone()).or_default().insert(parent.grantor.clone());
}
}
let mut order = Vec::with_capacity(remaining.len());
if remaining.remove(owner) {
order.push(owner.to_string());
}
while !remaining.is_empty() {
let ready = remaining
.iter()
.filter(|grantor| {
dependencies.get(*grantor).is_none_or(|required| required.iter().all(|role| !remaining.contains(role)))
})
.cloned()
.collect::<Vec<_>>();
if ready.is_empty() {
order.extend(remaining);
break;
}
for grantor in ready {
remaining.remove(&grantor);
order.push(grantor);
}
}
order
}
fn append_postgres_grants_for_role(ddl: &mut String, table_name: &str, grantor: &str, grants: &[PostgresGrant]) {
let mut table_grants = BTreeMap::<(String, bool), BTreeSet<String>>::new();
let mut column_grants = BTreeMap::<(String, bool), BTreeMap<String, BTreeSet<String>>>::new();
for grant in grants.iter().filter(|grant| grant.grantor == grantor) {
if let Some(column) = &grant.column_name {
column_grants
.entry((grant.grantee.clone(), grant.is_grantable))
.or_default()
.entry(grant.privilege_type.clone())
.or_default()
.insert(column.clone());
} else {
table_grants
.entry((grant.grantee.clone(), grant.is_grantable))
.or_default()
.insert(grant.privilege_type.clone());
}
}
for ((grantee, is_grantable), privileges) in table_grants {
append_postgres_grant_statement(
ddl,
table_name,
&grantee,
is_grantable,
privileges.into_iter().collect::<Vec<_>>().join(", "),
);
}
for ((grantee, is_grantable), privileges) in column_grants {
let privileges = privileges
.into_iter()
.map(|(privilege, columns)| {
format!(
"{} ({})",
privilege,
columns.into_iter().map(|column| pg_ident(&column)).collect::<Vec<_>>().join(", ")
)
})
.collect::<Vec<_>>()
.join(", ");
append_postgres_grant_statement(ddl, table_name, &grantee, is_grantable, privileges);
}
}
fn append_postgres_grant_statement(
ddl: &mut String,
table_name: &str,
grantee: &str,
is_grantable: bool,
privileges: String,
) {
let grantee = if grantee == "PUBLIC" { grantee.to_string() } else { pg_ident(grantee) };
let grant_option = if is_grantable { " WITH GRANT OPTION" } else { "" };
ddl.push_str(&format!("\nGRANT {privileges} ON TABLE {table_name} TO {grantee}{grant_option};"));
}
pub async fn opengauss_table_ddl(pool: &deadpool_postgres::Pool, schema: &str, table: &str) -> Result<String, String> {
let (ddl, trigger_definitions) = tokio::try_join!(
async { first_string_cell(db::postgres::execute_query(pool, &opengauss_table_ddl_sql(schema, table)).await?) },

View File

@ -25,6 +25,7 @@ pub struct SchemaQuery {
pub table_name_filter: Option<String>,
pub apply_visible_filter: Option<bool>,
pub client_session_id: Option<String>,
pub include_postgres_access: Option<bool>,
}
#[derive(Deserialize)]
@ -461,6 +462,17 @@ pub async fn get_ddl(
dbx_core::schema::get_doris_catalog_table_ddl_core(&state.app, &q.connection_id, &catalog, database, table)
.await
.map_err(AppError::from)?
} else if q.include_postgres_access.unwrap_or(false) {
dbx_core::schema::get_table_display_ddl_core(
&state.app,
&q.connection_id,
database,
schema,
table,
q.object_type,
)
.await
.map_err(AppError::from)?
} else {
dbx_core::schema::get_table_ddl_core(&state.app, &q.connection_id, database, schema, table, q.object_type)
.await

View File

@ -431,12 +431,18 @@ pub async fn get_table_ddl(
table: String,
object_type: Option<db::ObjectSourceKind>,
catalog: Option<String>,
include_postgres_access: Option<bool>,
) -> Result<String, String> {
if let Some(catalog) = external_doris_catalog(&state, &connection_id, catalog.as_deref()).await {
return dbx_core::schema::get_doris_catalog_table_ddl_core(&state, &connection_id, &catalog, &database, &table)
.await;
}
dbx_core::schema::get_table_ddl_core(&state, &connection_id, &database, &schema, &table, object_type).await
if include_postgres_access.unwrap_or(false) {
dbx_core::schema::get_table_display_ddl_core(&state, &connection_id, &database, &schema, &table, object_type)
.await
} else {
dbx_core::schema::get_table_ddl_core(&state, &connection_id, &database, &schema, &table, object_type).await
}
}
#[tauri::command]