wowsql 1.1.0

Official Rust SDK for WOWSQL - MySQL Backend-as-a-Service with S3 Storage
Documentation
use reqwest::{Client, StatusCode};
use serde::{Deserialize, Serialize};
use serde_json::json;

use crate::errors::WOWSQLError;

/// Column definition for table creation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ColumnDefinition {
    pub name: String,
    #[serde(rename = "type")]
    pub column_type: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub auto_increment: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub unique: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub not_null: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub default: Option<String>,
}

impl ColumnDefinition {
    pub fn new(name: impl Into<String>, column_type: impl Into<String>) -> Self {
        Self {
            name: name.into(),
            column_type: column_type.into(),
            auto_increment: None,
            unique: None,
            not_null: None,
            default: None,
        }
    }

    pub fn auto_increment(mut self, value: bool) -> Self {
        self.auto_increment = Some(value);
        self
    }

    pub fn unique(mut self, value: bool) -> Self {
        self.unique = Some(value);
        self
    }

    pub fn not_null(mut self, value: bool) -> Self {
        self.not_null = Some(value);
        self
    }

    pub fn default_value(mut self, value: impl Into<String>) -> Self {
        self.default = Some(value.into());
        self
    }
}

/// Index definition for table creation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexDefinition {
    pub name: String,
    pub columns: Vec<String>,
}

/// Request for creating a table
#[derive(Debug, Clone, Serialize)]
pub struct CreateTableRequest {
    pub table_name: String,
    pub columns: Vec<ColumnDefinition>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub primary_key: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub indexes: Option<Vec<IndexDefinition>>,
}

/// Request for altering a table
#[derive(Debug, Clone, Serialize)]
pub struct AlterTableRequest {
    pub table_name: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub add_columns: Option<Vec<ColumnDefinition>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub modify_columns: Option<Vec<ColumnDefinition>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub drop_columns: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub rename_columns: Option<Vec<RenameColumn>>,
}

/// Column rename specification
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RenameColumn {
    pub old_name: String,
    pub new_name: String,
}

/// Schema operation response
#[derive(Debug, Clone, Deserialize)]
pub struct SchemaResponse {
    pub success: Option<bool>,
    pub message: Option<String>,
    pub table: Option<String>,
    pub operation: Option<String>,
}

/// Schema management client for WOWSQL.
///
/// ⚠️ IMPORTANT: Requires SERVICE ROLE key, not anonymous key!
///
/// # Example
///
/// ```no_run
/// use wowsql::{WOWSQLSchema, ColumnDefinition, CreateTableRequest};
///
/// #[tokio::main]
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
///     let schema = WOWSQLSchema::new(
///         "https://myproject.wowsql.com",
///         "service_xyz..."  // NOT anon key!
///     )?;
///
///     // Create table
///     schema.create_table(CreateTableRequest {
///         table_name: "users".to_string(),
///         columns: vec![
///             ColumnDefinition::new("id", "INT").auto_increment(true),
///             ColumnDefinition::new("email", "VARCHAR(255)").unique(true).not_null(true),
///         ],
///         primary_key: Some("id".to_string()),
///         indexes: None,
///     }).await?;
///
///     Ok(())
/// }
/// ```
pub struct WOWSQLSchema {
    base_url: String,
    service_key: String,
    client: Client,
}

impl WOWSQLSchema {
    /// Create a new schema management client.
    ///
    /// ⚠️ IMPORTANT: Requires SERVICE ROLE key, not anonymous key!
    pub fn new(
        project_url: impl Into<String>,
        service_key: impl Into<String>,
    ) -> Result<Self, WOWSQLError> {
        Ok(Self {
            base_url: project_url.into().trim_end_matches('/').to_string(),
            service_key: service_key.into(),
            client: Client::new(),
        })
    }

    /// Create a new table.
    ///
    /// # Errors
    ///
    /// Returns `WOWSQLError::PermissionDenied` if using anonymous key instead of service key.
    /// Returns `WOWSQLError` if table creation fails.
    pub async fn create_table(
        &self,
        request: CreateTableRequest,
    ) -> Result<SchemaResponse, WOWSQLError> {
        let url = format!("{}/api/v2/schema/tables", self.base_url);

        let response = self
            .client
            .post(&url)
            .header("Authorization", format!("Bearer {}", self.service_key))
            .json(&request)
            .send()
            .await?;

        if response.status() == StatusCode::FORBIDDEN {
            return Err(WOWSQLError::PermissionDenied(
                "Schema operations require a SERVICE ROLE key. \
                You are using an anonymous key which cannot modify database schema. \
                Please use your service role key instead."
                    .to_string(),
            ));
        }

        if !response.status().is_success() {
            let error_text = response.text().await?;
            return Err(WOWSQLError::RequestFailed(format!(
                "Failed to create table: {}",
                error_text
            )));
        }

        let result = response.json::<SchemaResponse>().await?;
        Ok(result)
    }

    /// Alter an existing table.
    ///
    /// # Errors
    ///
    /// Returns `WOWSQLError::PermissionDenied` if using anonymous key.
    /// Returns `WOWSQLError` if alteration fails.
    pub async fn alter_table(
        &self,
        request: AlterTableRequest,
    ) -> Result<SchemaResponse, WOWSQLError> {
        let url = format!(
            "{}/api/v2/schema/tables/{}",
            self.base_url, request.table_name
        );

        let response = self
            .client
            .patch(&url)
            .header("Authorization", format!("Bearer {}", self.service_key))
            .json(&request)
            .send()
            .await?;

        if response.status() == StatusCode::FORBIDDEN {
            return Err(WOWSQLError::PermissionDenied(
                "Schema operations require a SERVICE ROLE key.".to_string(),
            ));
        }

        if !response.status().is_success() {
            let error_text = response.text().await?;
            return Err(WOWSQLError::RequestFailed(format!(
                "Failed to alter table: {}",
                error_text
            )));
        }

        let result = response.json::<SchemaResponse>().await?;
        Ok(result)
    }

    /// Drop a table.
    ///
    /// ⚠️ WARNING: This operation cannot be undone!
    ///
    /// # Errors
    ///
    /// Returns `WOWSQLError::PermissionDenied` if using anonymous key.
    /// Returns `WOWSQLError` if drop fails.
    pub async fn drop_table(
        &self,
        table_name: &str,
        cascade: bool,
    ) -> Result<SchemaResponse, WOWSQLError> {
        let url = format!(
            "{}/api/v2/schema/tables/{}?cascade={}",
            self.base_url, table_name, cascade
        );

        let response = self
            .client
            .delete(&url)
            .header("Authorization", format!("Bearer {}", self.service_key))
            .send()
            .await?;

        if response.status() == StatusCode::FORBIDDEN {
            return Err(WOWSQLError::PermissionDenied(
                "Schema operations require a SERVICE ROLE key.".to_string(),
            ));
        }

        if !response.status().is_success() {
            let error_text = response.text().await?;
            return Err(WOWSQLError::RequestFailed(format!(
                "Failed to drop table: {}",
                error_text
            )));
        }

        let result = response.json::<SchemaResponse>().await?;
        Ok(result)
    }

    /// Execute raw SQL for schema operations.
    ///
    /// ⚠️ Only schema operations allowed (CREATE TABLE, ALTER TABLE, etc.)
    ///
    /// # Errors
    ///
    /// Returns `WOWSQLError::PermissionDenied` if using anonymous key.
    /// Returns `WOWSQLError` if execution fails.
    pub async fn execute_sql(&self, sql: &str) -> Result<SchemaResponse, WOWSQLError> {
        let url = format!("{}/api/v2/schema/execute", self.base_url);

        let payload = json!({ "sql": sql });

        let response = self
            .client
            .post(&url)
            .header("Authorization", format!("Bearer {}", self.service_key))
            .json(&payload)
            .send()
            .await?;

        if response.status() == StatusCode::FORBIDDEN {
            return Err(WOWSQLError::PermissionDenied(
                "Schema operations require a SERVICE ROLE key.".to_string(),
            ));
        }

        if !response.status().is_success() {
            let error_text = response.text().await?;
            return Err(WOWSQLError::RequestFailed(format!(
                "Failed to execute SQL: {}",
                error_text
            )));
        }

        let result = response.json::<SchemaResponse>().await?;
        Ok(result)
    }
}