fix(postgres): include owner and grants in displayed DDL
This commit is contained in:
parent
a5eb6fd1fa
commit
1d671ca698
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, ¶ms).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, ¶ms),
|
||||
postgres_query_cached(&client, POSTGRES_COLUMN_ACL_PRIVILEGES_SQL, ¶ms),
|
||||
)
|
||||
.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]
|
||||
|
|
|
|||
|
|
@ -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?) },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in New Issue