* chore: upgrade axum 0.7 to 0.8 and related dependencies Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add route reachability tests for ~80 previously untested endpoints Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: switch feature-gated trigger handlers from axum::async_trait to async_trait crate Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: update new trash routes to axum 0.8 path syntax Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref to latest EE commit Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: upgrade route tests to assert 2xx responses with proper data setup Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: restore npm_proxy and ai_routes tests using local echo servers Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: gate workspace fork test behind enterprise feature flag Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add ~40 more endpoint tests (jobs authed, health, favorites, ACLs, reachability) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address review findings from axum 0.8 upgrade - Use cookie value_trimmed() instead of value() for cookie 0.18 compat - Update comments still referencing old :workspace_id syntax Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref to 61ae055ea31481f1899953e9d5f65566b8c707b1 This commit updates the EE repository reference after PR #486 was merged in windmill-ee-private. Previous ee-repo-ref: 0059d175a6fdddf52998b183bf91059b224704ac New ee-repo-ref: 61ae055ea31481f1899953e9d5f65566b8c707b1 Automated by sync-ee-ref workflow. * test: add test for new get_imports endpoint Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove unused import in raw_apps test Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
1113 lines
34 KiB
Rust
1113 lines
34 KiB
Rust
use std::collections::HashMap;
|
|
|
|
use async_trait::async_trait;
|
|
use axum::{
|
|
extract::Path,
|
|
routing::{delete, get, post},
|
|
Extension, Json, Router,
|
|
};
|
|
use itertools::Itertools;
|
|
use lazy_static::lazy_static;
|
|
use pg_escape::{quote_identifier, quote_literal};
|
|
use quick_cache::sync::Cache;
|
|
use rust_postgres::{types::Type, Client};
|
|
use sqlx::PgConnection;
|
|
use uuid;
|
|
use windmill_common::{
|
|
db::UserDB,
|
|
error::{self, to_anyhow, Error, Result},
|
|
DB,
|
|
};
|
|
use windmill_git_sync::DeployedObject;
|
|
|
|
use windmill_api_auth::ApiAuthed;
|
|
use windmill_trigger::{Trigger, TriggerCrud, TriggerData};
|
|
|
|
use super::{
|
|
check_if_valid_publication_for_postgres_version, create_logical_replication_slot,
|
|
create_pg_publication, drop_publication, generate_random_string, get_default_pg_connection,
|
|
mapper::{Mapper, MappingInfo},
|
|
PostgresConfig, PostgresConfigRequest, PostgresPublicationReplication, PostgresTrigger,
|
|
PublicationData, Relations, Slot, SlotList, TableToTrack, TemplateScript, TestPostgresConfig,
|
|
ERROR_PUBLICATION_NAME_NOT_EXISTS,
|
|
};
|
|
|
|
// Lazy static template cache
|
|
lazy_static! {
|
|
pub static ref TEMPLATE: Cache<String, String> = Cache::new(50);
|
|
}
|
|
|
|
#[async_trait]
|
|
impl TriggerCrud for PostgresTrigger {
|
|
type TriggerConfig = PostgresConfig;
|
|
type Trigger = Trigger<Self::TriggerConfig>;
|
|
type TriggerConfigRequest = PostgresConfigRequest;
|
|
type TestConnectionConfig = TestPostgresConfig;
|
|
|
|
const TABLE_NAME: &'static str = "postgres_trigger";
|
|
const TRIGGER_TYPE: &'static str = "postgres";
|
|
const SUPPORTS_SERVER_STATE: bool = true;
|
|
const SUPPORTS_TEST_CONNECTION: bool = true;
|
|
const ROUTE_PREFIX: &'static str = "/postgres_triggers";
|
|
const DEPLOYMENT_NAME: &'static str = "PostgreSQL trigger";
|
|
const ADDITIONAL_SELECT_FIELDS: &[&'static str] = &[
|
|
"postgres_resource_path",
|
|
"replication_slot_name",
|
|
"publication_name",
|
|
"NULL::text AS basic_mode",
|
|
];
|
|
const IS_ALLOWED_ON_CLOUD: bool = false;
|
|
|
|
fn get_deployed_object(path: String, parent_path: Option<String>) -> DeployedObject {
|
|
DeployedObject::PostgresTrigger { path, parent_path }
|
|
}
|
|
|
|
async fn create_trigger(
|
|
&self,
|
|
db: &DB,
|
|
tx: &mut PgConnection,
|
|
authed: &ApiAuthed,
|
|
w_id: &str,
|
|
trigger: TriggerData<Self::TriggerConfigRequest>,
|
|
) -> Result<()> {
|
|
let resolved_edited_by = trigger.base.resolve_edited_by(authed);
|
|
let resolved_permissioned_as = trigger.base.resolve_permissioned_as(authed);
|
|
let Self::TriggerConfigRequest {
|
|
postgres_resource_path,
|
|
publication_name,
|
|
replication_slot_name,
|
|
publication,
|
|
} = trigger.config;
|
|
|
|
let (pub_name, slot_name) =
|
|
if publication_name.is_empty() && replication_slot_name.is_empty() {
|
|
if publication.is_none() {
|
|
return Err(Error::BadRequest("publication must be set".to_string()));
|
|
}
|
|
|
|
let PostgresPublicationReplication { publication_name, replication_slot_name } =
|
|
create_custom_slot_and_publication_inner(
|
|
authed.clone(),
|
|
UserDB::new(db.clone()),
|
|
&db,
|
|
&postgres_resource_path,
|
|
&w_id,
|
|
&publication.unwrap(),
|
|
)
|
|
.await?;
|
|
|
|
(publication_name, replication_slot_name)
|
|
} else {
|
|
if publication_name.is_empty() {
|
|
return Err(Error::BadRequest(
|
|
"Publication name must not be empty".to_string(),
|
|
));
|
|
} else if replication_slot_name.is_empty() {
|
|
return Err(Error::BadRequest(
|
|
"Replication slot name must not be empty".to_string(),
|
|
));
|
|
}
|
|
(publication_name, replication_slot_name)
|
|
};
|
|
|
|
sqlx::query!(
|
|
r#"
|
|
INSERT INTO postgres_trigger (
|
|
workspace_id,
|
|
path,
|
|
postgres_resource_path,
|
|
replication_slot_name,
|
|
publication_name,
|
|
script_path,
|
|
is_flow,
|
|
mode,
|
|
edited_by,
|
|
permissioned_as,
|
|
edited_at,
|
|
error_handler_path,
|
|
error_handler_args,
|
|
retry
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, now(), $11, $12, $13
|
|
)
|
|
"#,
|
|
w_id,
|
|
trigger.base.path,
|
|
postgres_resource_path,
|
|
slot_name,
|
|
pub_name,
|
|
trigger.base.script_path,
|
|
trigger.base.is_flow,
|
|
trigger.base.mode() as _,
|
|
&resolved_edited_by,
|
|
resolved_permissioned_as,
|
|
trigger.error_handling.error_handler_path,
|
|
trigger.error_handling.error_handler_args as _,
|
|
trigger.error_handling.retry as _
|
|
)
|
|
.execute(tx)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn update_trigger(
|
|
&self,
|
|
db: &DB,
|
|
tx: &mut PgConnection,
|
|
authed: &ApiAuthed,
|
|
w_id: &str,
|
|
path: &str,
|
|
trigger: TriggerData<Self::TriggerConfigRequest>,
|
|
) -> Result<()> {
|
|
let resolved_edited_by = trigger.base.resolve_edited_by(authed);
|
|
let resolved_permissioned_as = trigger.base.resolve_permissioned_as(authed);
|
|
let Self::TriggerConfigRequest {
|
|
replication_slot_name,
|
|
publication_name,
|
|
postgres_resource_path,
|
|
publication,
|
|
} = trigger.config;
|
|
|
|
let mut pg_connection = get_default_pg_connection(
|
|
authed.clone(),
|
|
Some(UserDB::new(db.clone())),
|
|
db,
|
|
&postgres_resource_path,
|
|
&w_id,
|
|
)
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
|
|
let exists =
|
|
check_if_logical_replication_slot_exist(&mut pg_connection, &replication_slot_name)
|
|
.await?;
|
|
|
|
let remote_db_tx = pg_connection.transaction().await.map_err(to_anyhow)?;
|
|
|
|
if !exists {
|
|
tracing::debug!(
|
|
"Logical replication slot named: {} does not exists creating it...",
|
|
&replication_slot_name
|
|
);
|
|
create_logical_replication_slot(remote_db_tx.client(), &replication_slot_name)
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
}
|
|
|
|
if let Some(publication) = publication {
|
|
let publication_data =
|
|
get_publication_scope_and_transaction(remote_db_tx.client(), &publication_name)
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
|
|
update_pg_publication(
|
|
remote_db_tx.client(),
|
|
&publication_name,
|
|
publication,
|
|
publication_data.map(|publication| publication.0),
|
|
)
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
}
|
|
|
|
remote_db_tx.commit().await.map_err(to_anyhow)?;
|
|
|
|
sqlx::query!(
|
|
r#"
|
|
UPDATE postgres_trigger
|
|
SET
|
|
postgres_resource_path = $1,
|
|
replication_slot_name = $2,
|
|
publication_name = $3,
|
|
script_path = $4,
|
|
path = $5,
|
|
is_flow = $6,
|
|
edited_by = $7,
|
|
permissioned_as = $8,
|
|
edited_at = now(),
|
|
server_id = NULL,
|
|
error = NULL,
|
|
error_handler_path = $11,
|
|
error_handler_args = $12,
|
|
retry = $13
|
|
WHERE
|
|
workspace_id = $9 AND path = $10
|
|
"#,
|
|
postgres_resource_path,
|
|
replication_slot_name,
|
|
publication_name,
|
|
trigger.base.script_path,
|
|
trigger.base.path,
|
|
trigger.base.is_flow,
|
|
&resolved_edited_by,
|
|
resolved_permissioned_as,
|
|
w_id,
|
|
path,
|
|
trigger.error_handling.error_handler_path,
|
|
trigger.error_handling.error_handler_args as _,
|
|
trigger.error_handling.retry as _
|
|
)
|
|
.execute(tx)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn test_connection(
|
|
&self,
|
|
db: &DB,
|
|
authed: &ApiAuthed,
|
|
user_db: &UserDB,
|
|
workspace_id: &str,
|
|
config: Self::TestConnectionConfig,
|
|
) -> Result<()> {
|
|
let connect_f = async {
|
|
get_default_pg_connection(
|
|
authed.clone(),
|
|
Some(user_db.clone()),
|
|
db,
|
|
&config.postgres_resource_path,
|
|
workspace_id,
|
|
)
|
|
.await
|
|
.map_err(|err| {
|
|
Error::BadConfig(format!("Error connecting to postgres: {}", err.to_string()))
|
|
})?;
|
|
|
|
Ok::<(), Error>(())
|
|
};
|
|
|
|
connect_f.await?;
|
|
Ok(())
|
|
}
|
|
|
|
fn additional_routes(&self) -> Router {
|
|
Router::new()
|
|
.route("/get_template_script/{id}", get(get_template_script))
|
|
.route("/create_template_script", post(create_template_script))
|
|
.route(
|
|
"/is_valid_postgres_configuration/{*path}",
|
|
get(is_database_in_logical_level),
|
|
)
|
|
.nest("/publication", publication_service())
|
|
.nest("/slot", slot_service())
|
|
.nest("/postgres", postgres_service())
|
|
}
|
|
}
|
|
|
|
fn publication_service() -> Router {
|
|
Router::new()
|
|
.route("/get/{publication_name}/{*path}", get(get_publication_info))
|
|
.route(
|
|
"/create/{publication_name}/{*path}",
|
|
post(create_publication),
|
|
)
|
|
.route(
|
|
"/update/{publication_name}/{*path}",
|
|
post(alter_publication),
|
|
)
|
|
.route(
|
|
"/delete/{publication_name}/{*path}",
|
|
delete(delete_publication),
|
|
)
|
|
.route("/list/{*path}", get(list_database_publication))
|
|
}
|
|
|
|
fn slot_service() -> Router {
|
|
Router::new()
|
|
.route("/list/{*path}", get(list_slot_name))
|
|
.route("/create/{*path}", post(create_slot))
|
|
.route("/delete/{*path}", delete(drop_slot_name))
|
|
}
|
|
|
|
fn postgres_service() -> Router {
|
|
Router::new().route("/version/{*path}", get(get_postgres_version))
|
|
}
|
|
|
|
async fn check_if_logical_replication_slot_exist(
|
|
pg_connection: &mut Client,
|
|
replication_slot_name: &str,
|
|
) -> Result<bool> {
|
|
let row = pg_connection
|
|
.query_opt(
|
|
"SELECT slot_name FROM pg_replication_slots WHERE slot_name = $1",
|
|
&[&replication_slot_name],
|
|
)
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
Ok(row.is_some())
|
|
}
|
|
|
|
async fn create_custom_slot_and_publication_inner(
|
|
authed: ApiAuthed,
|
|
user_db: UserDB,
|
|
db: &DB,
|
|
postgres_resource_path: &str,
|
|
w_id: &str,
|
|
publication: &PublicationData,
|
|
) -> Result<PostgresPublicationReplication> {
|
|
let mut pg_connection = get_default_pg_connection(
|
|
authed.clone(),
|
|
Some(user_db),
|
|
&db,
|
|
&postgres_resource_path,
|
|
&w_id,
|
|
)
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
|
|
let tx = pg_connection.transaction().await.map_err(to_anyhow)?;
|
|
|
|
let publication_name = format!("windmill_trigger_{}", generate_random_string());
|
|
let replication_slot_name = publication_name.clone();
|
|
|
|
create_logical_replication_slot(tx.client(), &replication_slot_name).await?;
|
|
create_pg_publication(
|
|
&tx.client(),
|
|
&publication_name,
|
|
publication.table_to_track.as_deref(),
|
|
&publication.transaction_to_track,
|
|
)
|
|
.await?;
|
|
|
|
tx.commit().await.map_err(to_anyhow)?;
|
|
|
|
Ok(PostgresPublicationReplication::new(
|
|
publication_name,
|
|
replication_slot_name,
|
|
))
|
|
}
|
|
|
|
pub async fn get_postgres_version_internal(pg_connection: &Client) -> Result<String> {
|
|
let row = pg_connection
|
|
.query_one("SHOW server_version;", &[])
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
|
|
let postgres_version: String = row.get(0);
|
|
|
|
Ok(postgres_version)
|
|
}
|
|
|
|
pub async fn get_postgres_version(
|
|
authed: ApiAuthed,
|
|
Extension(db): Extension<DB>,
|
|
Extension(user_db): Extension<UserDB>,
|
|
Path((w_id, postgres_resource_path)): Path<(String, String)>,
|
|
) -> Result<String> {
|
|
let pg_connection = get_default_pg_connection(
|
|
authed.clone(),
|
|
Some(user_db),
|
|
&db,
|
|
&postgres_resource_path,
|
|
&w_id,
|
|
)
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
|
|
let postgres_version = get_postgres_version_internal(&pg_connection).await?;
|
|
|
|
Ok(postgres_version)
|
|
}
|
|
|
|
pub async fn list_slot_name(
|
|
authed: ApiAuthed,
|
|
Extension(user_db): Extension<UserDB>,
|
|
Extension(db): Extension<DB>,
|
|
Path((w_id, postgres_resource_path)): Path<(String, String)>,
|
|
) -> Result<Json<Vec<SlotList>>> {
|
|
let pg_connection: Client = get_default_pg_connection(
|
|
authed.clone(),
|
|
Some(user_db.clone()),
|
|
&db,
|
|
&postgres_resource_path,
|
|
&w_id,
|
|
)
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
|
|
let rows = pg_connection
|
|
.query(
|
|
r#"
|
|
SELECT
|
|
slot_name,
|
|
active
|
|
FROM
|
|
pg_replication_slots
|
|
WHERE
|
|
plugin = 'pgoutput' AND
|
|
slot_type = 'logical';
|
|
"#,
|
|
&[],
|
|
)
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
|
|
let slots = rows
|
|
.into_iter()
|
|
.map(|row| SlotList { slot_name: row.get("slot_name"), active: row.get("active") })
|
|
.collect();
|
|
|
|
Ok(Json(slots))
|
|
}
|
|
|
|
pub async fn create_slot(
|
|
authed: ApiAuthed,
|
|
Extension(user_db): Extension<UserDB>,
|
|
Extension(db): Extension<DB>,
|
|
Path((w_id, postgres_resource_path)): Path<(String, String)>,
|
|
Json(Slot { name }): Json<Slot>,
|
|
) -> Result<String> {
|
|
let pg_connection = get_default_pg_connection(
|
|
authed.clone(),
|
|
Some(user_db.clone()),
|
|
&db,
|
|
&postgres_resource_path,
|
|
&w_id,
|
|
)
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
|
|
create_logical_replication_slot(&pg_connection, &name).await?;
|
|
|
|
Ok(format!("Replication slot {} created!", name))
|
|
}
|
|
|
|
pub async fn drop_logical_replication_slot(pg_connection: &Client, slot_name: &str) -> Result<()> {
|
|
let row = pg_connection
|
|
.query_opt(
|
|
r#"
|
|
SELECT
|
|
active_pid
|
|
FROM
|
|
pg_replication_slots
|
|
WHERE
|
|
slot_name = $1
|
|
"#,
|
|
&[&slot_name],
|
|
)
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
|
|
let active_pid = row.map(|r| r.get::<_, Option<i32>>(0)).flatten();
|
|
|
|
if let Some(pid) = active_pid {
|
|
pg_connection
|
|
.execute("SELECT pg_terminate_backend($1)", &[&pid])
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
}
|
|
|
|
pg_connection
|
|
.execute("SELECT pg_drop_replication_slot($1)", &[&slot_name])
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn drop_slot_name(
|
|
authed: ApiAuthed,
|
|
Extension(user_db): Extension<UserDB>,
|
|
Extension(db): Extension<DB>,
|
|
Path((w_id, postgres_resource_path)): Path<(String, String)>,
|
|
Json(Slot { name }): Json<Slot>,
|
|
) -> Result<String> {
|
|
let pg_connection =
|
|
get_default_pg_connection(authed, Some(user_db), &db, &postgres_resource_path, &w_id)
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
|
|
drop_logical_replication_slot(&pg_connection, &name)
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
|
|
Ok(format!("Replication slot {} deleted!", name))
|
|
}
|
|
|
|
pub async fn list_database_publication(
|
|
authed: ApiAuthed,
|
|
Extension(user_db): Extension<UserDB>,
|
|
Extension(db): Extension<DB>,
|
|
Path((w_id, postgres_resource_path)): Path<(String, String)>,
|
|
) -> Result<Json<Vec<String>>> {
|
|
let pg_connection = get_default_pg_connection(
|
|
authed.clone(),
|
|
Some(user_db.clone()),
|
|
&db,
|
|
&postgres_resource_path,
|
|
&w_id,
|
|
)
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
|
|
let rows = pg_connection
|
|
.query(
|
|
"SELECT pubname AS publication_name FROM pg_publication;",
|
|
&[],
|
|
)
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
|
|
let publications = rows
|
|
.into_iter()
|
|
.map(|row| row.get::<_, String>("publication_name"))
|
|
.collect_vec();
|
|
|
|
Ok(Json(publications))
|
|
}
|
|
|
|
pub async fn get_publication_info(
|
|
authed: ApiAuthed,
|
|
Extension(user_db): Extension<UserDB>,
|
|
Extension(db): Extension<DB>,
|
|
Path((w_id, publication_name, postgres_resource_path)): Path<(String, String, String)>,
|
|
) -> Result<Json<PublicationData>> {
|
|
let mut pg_connection = get_default_pg_connection(
|
|
authed.clone(),
|
|
Some(user_db.clone()),
|
|
&db,
|
|
&postgres_resource_path,
|
|
&w_id,
|
|
)
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
|
|
let publication_data =
|
|
get_publication_scope_and_transaction(&mut pg_connection, &publication_name).await;
|
|
|
|
let (all_table, transaction_to_track) = match publication_data {
|
|
Ok(Some(pub_data)) => pub_data,
|
|
Ok(None) => {
|
|
return Err(Error::NotFound(
|
|
ERROR_PUBLICATION_NAME_NOT_EXISTS.to_string(),
|
|
))
|
|
}
|
|
Err(e) => return Err(e),
|
|
};
|
|
|
|
let table_to_track = if !all_table {
|
|
Some(get_tracked_relations(&mut pg_connection, &publication_name).await?)
|
|
} else {
|
|
None
|
|
};
|
|
Ok(Json(PublicationData::new(
|
|
table_to_track,
|
|
transaction_to_track,
|
|
)))
|
|
}
|
|
|
|
pub async fn create_publication(
|
|
authed: ApiAuthed,
|
|
Extension(user_db): Extension<UserDB>,
|
|
Extension(db): Extension<DB>,
|
|
Path((w_id, publication_name, postgres_resource_path)): Path<(String, String, String)>,
|
|
Json(publication_data): Json<PublicationData>,
|
|
) -> Result<String> {
|
|
let mut pg_connection = get_default_pg_connection(
|
|
authed.clone(),
|
|
Some(user_db.clone()),
|
|
&db,
|
|
&postgres_resource_path,
|
|
&w_id,
|
|
)
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
|
|
let PublicationData { table_to_track, transaction_to_track } = publication_data;
|
|
|
|
let tx = pg_connection.transaction().await.map_err(to_anyhow)?;
|
|
|
|
create_pg_publication(
|
|
tx.client(),
|
|
&publication_name,
|
|
table_to_track.as_deref(),
|
|
&transaction_to_track,
|
|
)
|
|
.await?;
|
|
|
|
tx.commit().await.map_err(to_anyhow)?;
|
|
|
|
Ok(format!(
|
|
"Publication {} successfully created!",
|
|
publication_name
|
|
))
|
|
}
|
|
|
|
pub async fn delete_publication(
|
|
authed: ApiAuthed,
|
|
Extension(user_db): Extension<UserDB>,
|
|
Extension(db): Extension<DB>,
|
|
Path((w_id, publication_name, postgres_resource_path)): Path<(String, String, String)>,
|
|
) -> Result<String> {
|
|
let mut pg_connection = get_default_pg_connection(
|
|
authed.clone(),
|
|
Some(user_db.clone()),
|
|
&db,
|
|
&postgres_resource_path,
|
|
&w_id,
|
|
)
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
|
|
drop_publication(&mut pg_connection, &publication_name).await?;
|
|
|
|
Ok(format!(
|
|
"Publication {} successfully deleted!",
|
|
publication_name
|
|
))
|
|
}
|
|
|
|
pub async fn update_pg_publication(
|
|
pg_connection: &Client,
|
|
publication_name: &str,
|
|
PublicationData { table_to_track, transaction_to_track }: PublicationData,
|
|
all_table: Option<bool>,
|
|
) -> Result<()> {
|
|
let quoted_publication_name = quote_identifier(publication_name);
|
|
let transaction_to_track_as_str = transaction_to_track.iter().join(",");
|
|
match table_to_track {
|
|
Some(ref relations) if !relations.is_empty() => {
|
|
// If all_table is None, the publication does not exist yet
|
|
if all_table.unwrap_or(true) {
|
|
if all_table.is_some_and(|all_table| all_table) {
|
|
drop_publication(pg_connection, publication_name)
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
}
|
|
create_pg_publication(
|
|
pg_connection,
|
|
publication_name,
|
|
table_to_track.as_deref(),
|
|
&transaction_to_track,
|
|
)
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
} else {
|
|
let pg_14 = check_if_valid_publication_for_postgres_version(
|
|
pg_connection,
|
|
table_to_track.as_deref(),
|
|
)
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
|
|
let mut query = format!("ALTER PUBLICATION {} SET ", quoted_publication_name);
|
|
let mut first = true;
|
|
|
|
for (i, schema) in relations.iter().enumerate() {
|
|
if schema.table_to_track.is_empty() {
|
|
query.push_str("TABLES IN SCHEMA ");
|
|
query.push_str("e_identifier(&schema.schema_name));
|
|
} else {
|
|
if pg_14 && first {
|
|
query.push_str("TABLE ONLY ");
|
|
first = false;
|
|
} else if !pg_14 {
|
|
query.push_str("TABLE ONLY ");
|
|
}
|
|
|
|
for (j, table) in schema.table_to_track.iter().enumerate() {
|
|
let table_name = quote_identifier(&table.table_name);
|
|
let schema_name = quote_identifier(&schema.schema_name);
|
|
let full_name = format!("{}.{}", schema_name, table_name);
|
|
query.push_str(&full_name);
|
|
|
|
if let Some(columns) = table.columns_name.as_ref() {
|
|
let cols =
|
|
columns.iter().map(|col| quote_identifier(col)).join(", ");
|
|
query.push_str(&format!(" ({})", cols));
|
|
}
|
|
|
|
if let Some(where_clause) = &table.where_clause {
|
|
query.push_str(&format!(" WHERE ({})", where_clause));
|
|
}
|
|
|
|
if j + 1 != schema.table_to_track.len() {
|
|
query.push_str(", ");
|
|
}
|
|
}
|
|
}
|
|
|
|
if i + 1 != relations.len() {
|
|
query.push_str(", ");
|
|
}
|
|
}
|
|
|
|
pg_connection
|
|
.execute(&query, &[])
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
|
|
let publish_query = format!(
|
|
"ALTER PUBLICATION {} SET (publish = '{}');",
|
|
quoted_publication_name, transaction_to_track_as_str
|
|
);
|
|
pg_connection
|
|
.execute(&publish_query, &[])
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
}
|
|
}
|
|
_ => {
|
|
drop_publication(pg_connection, publication_name)
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
let create_all_query = format!(
|
|
"CREATE PUBLICATION {} FOR ALL TABLES WITH (publish = '{}');",
|
|
quoted_publication_name, transaction_to_track_as_str
|
|
);
|
|
pg_connection
|
|
.execute(&create_all_query, &[])
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn alter_publication(
|
|
authed: ApiAuthed,
|
|
Extension(user_db): Extension<UserDB>,
|
|
Extension(db): Extension<DB>,
|
|
Path((w_id, publication_name, postgres_resource_path)): Path<(String, String, String)>,
|
|
Json(publication_data): Json<PublicationData>,
|
|
) -> Result<String> {
|
|
let mut pg_connection = get_default_pg_connection(
|
|
authed.clone(),
|
|
Some(user_db.clone()),
|
|
&db,
|
|
&postgres_resource_path,
|
|
&w_id,
|
|
)
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
|
|
let tx = pg_connection.transaction().await.map_err(to_anyhow)?;
|
|
|
|
let publication = get_publication_scope_and_transaction(tx.client(), &publication_name)
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
|
|
update_pg_publication(
|
|
tx.client(),
|
|
&publication_name,
|
|
publication_data,
|
|
publication.map(|publication| publication.0),
|
|
)
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
|
|
tx.commit().await.map_err(to_anyhow)?;
|
|
|
|
Ok(format!(
|
|
"Publication {} updated with success",
|
|
publication_name
|
|
))
|
|
}
|
|
|
|
pub async fn get_publication_scope_and_transaction(
|
|
pg_connection: &Client,
|
|
publication_name: &str,
|
|
) -> Result<Option<(bool, Vec<String>)>> {
|
|
let row_opt = pg_connection
|
|
.query_opt(
|
|
r#"
|
|
SELECT
|
|
puballtables AS all_table,
|
|
pubinsert AS insert,
|
|
pubupdate AS update,
|
|
pubdelete AS delete
|
|
FROM
|
|
pg_publication
|
|
WHERE
|
|
pubname = $1
|
|
"#,
|
|
&[&publication_name],
|
|
)
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
|
|
let row = match row_opt {
|
|
Some(r) => r,
|
|
None => return Ok(None),
|
|
};
|
|
|
|
let all_table: bool = row.get("all_table");
|
|
let pub_insert: bool = row.get("insert");
|
|
let pub_update: bool = row.get("update");
|
|
let pub_delete: bool = row.get("delete");
|
|
|
|
let mut transaction_to_track = Vec::with_capacity(3);
|
|
if pub_insert {
|
|
transaction_to_track.push("insert".to_string());
|
|
}
|
|
if pub_update {
|
|
transaction_to_track.push("update".to_string());
|
|
}
|
|
if pub_delete {
|
|
transaction_to_track.push("delete".to_string());
|
|
}
|
|
|
|
Ok(Some((all_table, transaction_to_track)))
|
|
}
|
|
|
|
pub async fn get_tracked_relations(
|
|
pg_connection: &Client,
|
|
publication_name: &str,
|
|
) -> Result<Vec<Relations>> {
|
|
let pg_version = get_postgres_version_internal(pg_connection).await?;
|
|
|
|
let query = if pg_version.starts_with("14") {
|
|
r#"
|
|
SELECT
|
|
schemaname AS schema_name,
|
|
tablename AS table_name,
|
|
NULL::text[] AS columns,
|
|
NULL::text AS where_clause
|
|
FROM
|
|
pg_publication_tables
|
|
WHERE
|
|
pubname = $1;
|
|
"#
|
|
} else {
|
|
r#"
|
|
SELECT
|
|
schemaname AS schema_name,
|
|
tablename AS table_name,
|
|
attnames AS columns,
|
|
rowfilter AS where_clause
|
|
FROM
|
|
pg_publication_tables
|
|
WHERE
|
|
pubname = $1;
|
|
"#
|
|
};
|
|
|
|
let rows = pg_connection
|
|
.query(query, &[&publication_name])
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
|
|
let mut table_to_track: HashMap<String, Relations> = HashMap::new();
|
|
|
|
for row in rows {
|
|
let schema_name: Option<String> = row.get("schema_name");
|
|
let table_name: Option<String> = row.get("table_name");
|
|
let columns: Option<Vec<String>> = row.get("columns");
|
|
let where_clause: Option<String> = row.get("where_clause");
|
|
|
|
let schema_name = schema_name.ok_or_else::<Error, _>(|| {
|
|
anyhow::anyhow!(
|
|
"Unexpected NULL `schema_name` in publication entry (pubname: `{}`). This should never happen unless PostgreSQL internals are corrupted.",
|
|
publication_name,
|
|
)
|
|
.into()
|
|
})?;
|
|
|
|
let table_name = table_name.ok_or_else::<Error, _>(|| {
|
|
anyhow::anyhow!(
|
|
"Unexpected NULL `table_name` for schema `{}` in publication `{}`. This should never happen unless PostgreSQL internals are corrupted.",
|
|
schema_name,
|
|
publication_name,
|
|
)
|
|
.into()
|
|
})?;
|
|
|
|
let entry = table_to_track.entry(schema_name.clone());
|
|
let table_to_track_item = TableToTrack::new(table_name, where_clause, columns);
|
|
|
|
match entry {
|
|
std::collections::hash_map::Entry::Occupied(mut occupied) => {
|
|
occupied.get_mut().add_new_table(table_to_track_item);
|
|
}
|
|
std::collections::hash_map::Entry::Vacant(vacant) => {
|
|
vacant.insert(Relations::new(schema_name, vec![table_to_track_item]));
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(table_to_track.into_values().collect_vec())
|
|
}
|
|
|
|
pub async fn get_template_script(Path((_, id)): Path<(String, String)>) -> Result<String> {
|
|
let template = if let Some((_, template)) = TEMPLATE.remove(&id) {
|
|
template
|
|
} else {
|
|
"".to_string()
|
|
};
|
|
Ok(template)
|
|
}
|
|
|
|
pub async fn create_template_script(
|
|
authed: ApiAuthed,
|
|
Extension(user_db): Extension<UserDB>,
|
|
Extension(db): Extension<DB>,
|
|
Path(w_id): Path<String>,
|
|
Json(template_script): Json<TemplateScript>,
|
|
) -> Result<String> {
|
|
let TemplateScript { postgres_resource_path, relations, language } = template_script;
|
|
|
|
let relations = match relations {
|
|
Some(r) => r,
|
|
None => {
|
|
return Err(
|
|
anyhow::anyhow!("You must at least choose schema to fetch table from").into(),
|
|
)
|
|
}
|
|
};
|
|
|
|
let pg_connection: Client = get_default_pg_connection(
|
|
authed.clone(),
|
|
Some(user_db.clone()),
|
|
&db,
|
|
&postgres_resource_path,
|
|
&w_id,
|
|
)
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
|
|
let mut schema_or_fully_qualified_name = Vec::with_capacity(relations.len());
|
|
let mut columns_list = Vec::with_capacity(relations.len());
|
|
|
|
for relation in relations {
|
|
if !relation.table_to_track.is_empty() {
|
|
for table in relation.table_to_track {
|
|
let fully_qualified_name = format!("{}.{}", relation.schema_name, table.table_name);
|
|
schema_or_fully_qualified_name.push(quote_literal(&fully_qualified_name));
|
|
let columns = table
|
|
.columns_name
|
|
.map(|c| quote_literal(&c.join(",")))
|
|
.unwrap_or_else(|| "''".to_string());
|
|
columns_list.push(columns);
|
|
}
|
|
} else {
|
|
schema_or_fully_qualified_name.push(quote_literal(&relation.schema_name));
|
|
columns_list.push("''".to_string());
|
|
}
|
|
}
|
|
|
|
let tables_name = schema_or_fully_qualified_name.join(",");
|
|
let columns_list = columns_list.join(",");
|
|
|
|
let query = format!(
|
|
r#"
|
|
WITH table_column_mapping AS (
|
|
SELECT
|
|
unnest(ARRAY[{}]) AS table_name,
|
|
unnest(ARRAY[{}]) AS column_list
|
|
),
|
|
parsed_columns AS (
|
|
SELECT
|
|
tcm.table_name,
|
|
CASE
|
|
WHEN tcm.column_list = '' THEN NULL
|
|
ELSE string_to_array(tcm.column_list, ',')
|
|
END AS columns
|
|
FROM table_column_mapping tcm
|
|
)
|
|
SELECT
|
|
ns.nspname AS table_schema,
|
|
cls.relname AS table_name,
|
|
attr.attname AS column_name,
|
|
attr.atttypid AS oid,
|
|
attr.attnotnull AS is_nullable
|
|
FROM pg_attribute attr
|
|
JOIN pg_class cls ON attr.attrelid = cls.oid
|
|
JOIN pg_namespace ns ON cls.relnamespace = ns.oid
|
|
JOIN parsed_columns pc
|
|
ON ns.nspname || '.' || cls.relname = pc.table_name
|
|
OR ns.nspname = pc.table_name
|
|
WHERE
|
|
attr.attnum > 0
|
|
AND NOT attr.attisdropped
|
|
AND cls.relkind = 'r'
|
|
AND (
|
|
pc.columns IS NULL
|
|
OR attr.attname = ANY(pc.columns)
|
|
);
|
|
"#,
|
|
tables_name, columns_list
|
|
);
|
|
|
|
let rows = pg_connection.query(&query, &[]).await.map_err(to_anyhow)?;
|
|
|
|
let mut schema_map: HashMap<String, HashMap<String, Vec<MappingInfo>>> = HashMap::new();
|
|
|
|
#[derive(Debug)]
|
|
struct ColumnInfo {
|
|
table_schema: String,
|
|
table_name: String,
|
|
column_name: String,
|
|
oid: u32,
|
|
is_nullable: bool,
|
|
}
|
|
|
|
for row in rows {
|
|
let info = ColumnInfo {
|
|
table_schema: row.get("table_schema"),
|
|
table_name: row.get("table_name"),
|
|
column_name: row.get("column_name"),
|
|
oid: row.get::<_, u32>("oid"),
|
|
is_nullable: row.get::<_, bool>("is_nullable"),
|
|
};
|
|
|
|
let mapped_info =
|
|
MappingInfo::new(info.column_name, Type::from_oid(info.oid), info.is_nullable);
|
|
|
|
match schema_map.entry(info.table_schema) {
|
|
std::collections::hash_map::Entry::Occupied(mut schema_entry) => {
|
|
match schema_entry.get_mut().entry(info.table_name) {
|
|
std::collections::hash_map::Entry::Occupied(mut table_entry) => {
|
|
table_entry.get_mut().push(mapped_info);
|
|
}
|
|
std::collections::hash_map::Entry::Vacant(v) => {
|
|
v.insert(vec![mapped_info]);
|
|
}
|
|
}
|
|
}
|
|
std::collections::hash_map::Entry::Vacant(schema_vacant) => {
|
|
let mut table_map = HashMap::new();
|
|
table_map.insert(info.table_name, vec![mapped_info]);
|
|
schema_vacant.insert(table_map);
|
|
}
|
|
}
|
|
}
|
|
|
|
let mapper = Mapper::new(schema_map, language);
|
|
let template = mapper.get_template();
|
|
|
|
let id = format!("{}-{}", w_id, uuid::Uuid::new_v4());
|
|
|
|
TEMPLATE.insert(id.clone(), template);
|
|
|
|
Ok(id)
|
|
}
|
|
|
|
pub async fn is_database_in_logical_level(
|
|
authed: ApiAuthed,
|
|
Extension(user_db): Extension<UserDB>,
|
|
Extension(db): Extension<DB>,
|
|
Path((w_id, postgres_resource_path)): Path<(String, String)>,
|
|
) -> error::JsonResult<bool> {
|
|
let pg_connection = get_default_pg_connection(
|
|
authed.clone(),
|
|
Some(user_db.clone()),
|
|
&db,
|
|
&postgres_resource_path,
|
|
&w_id,
|
|
)
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
|
|
let row_opt = pg_connection
|
|
.query_opt("SHOW wal_level;", &[])
|
|
.await
|
|
.map_err(to_anyhow)?;
|
|
|
|
let wal_level: Option<String> = row_opt.map(|row| row.get(0));
|
|
|
|
let is_logical = matches!(wal_level.as_deref(), Some("logical"));
|
|
|
|
Ok(Json(is_logical))
|
|
}
|