Skip to main content

wowsql/
schema.rs

1use reqwest::Client;
2use serde::{Deserialize, Serialize};
3use serde_json::{json, Value};
4use std::time::Duration;
5
6use crate::errors::{SchemaPermissionError, WOWSQLError};
7use crate::models::TableSchema;
8
9// ─── Models ────────────────────────────────────────────────────────
10
11/// Column definition for table creation
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct ColumnDefinition {
14    pub name: String,
15    #[serde(rename = "type")]
16    pub column_type: String,
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub auto_increment: Option<bool>,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub unique: Option<bool>,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub not_null: Option<bool>,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub default: Option<String>,
25}
26
27impl ColumnDefinition {
28    pub fn new(name: impl Into<String>, column_type: impl Into<String>) -> Self {
29        Self {
30            name: name.into(),
31            column_type: column_type.into(),
32            auto_increment: None,
33            unique: None,
34            not_null: None,
35            default: None,
36        }
37    }
38
39    pub fn auto_increment(mut self, value: bool) -> Self {
40        self.auto_increment = Some(value);
41        self
42    }
43
44    pub fn unique(mut self, value: bool) -> Self {
45        self.unique = Some(value);
46        self
47    }
48
49    pub fn not_null(mut self, value: bool) -> Self {
50        self.not_null = Some(value);
51        self
52    }
53
54    pub fn default_value(mut self, value: impl Into<String>) -> Self {
55        self.default = Some(value.into());
56        self
57    }
58}
59
60/// Index definition for table creation
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct IndexDefinition {
63    pub name: Option<String>,
64    pub columns: Vec<String>,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub unique: Option<bool>,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub using: Option<String>,
69}
70
71impl IndexDefinition {
72    pub fn new(columns: Vec<String>) -> Self {
73        Self {
74            name: None,
75            columns,
76            unique: None,
77            using: None,
78        }
79    }
80}
81
82/// Request for creating a table
83#[derive(Debug, Clone, Serialize)]
84pub struct CreateTableRequest {
85    pub table_name: String,
86    pub columns: Vec<ColumnDefinition>,
87    pub primary_key: String,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub indexes: Option<Vec<IndexDefinition>>,
90}
91
92fn assert_uuid_primary_key(pk: &str, columns: &[ColumnDefinition]) -> Result<(), WOWSQLError> {
93    let col = columns.iter().find(|c| c.name == pk).ok_or_else(|| {
94        WOWSQLError::new("Primary key column not found in columns", Some(400), None)
95    })?;
96    let first = col
97        .column_type
98        .split_whitespace()
99        .next()
100        .unwrap_or("")
101        .to_uppercase();
102    if first != "UUID" {
103        return Err(WOWSQLError::new(
104            "Primary key column must use PostgreSQL type UUID",
105            Some(400),
106            None,
107        ));
108    }
109    Ok(())
110}
111
112/// Request for altering a table
113#[derive(Debug, Clone, Serialize)]
114pub struct AlterTableRequest {
115    pub table_name: String,
116    pub operation: String,
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub add_columns: Option<Vec<ColumnDefinition>>,
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub modify_columns: Option<Vec<ColumnDefinition>>,
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub drop_columns: Option<Vec<String>>,
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub rename_columns: Option<Vec<RenameColumn>>,
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub options: Option<Value>,
127}
128
129/// Column rename specification
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct RenameColumn {
132    pub old_name: String,
133    pub new_name: String,
134}
135
136/// Schema operation response
137#[derive(Debug, Clone, Deserialize)]
138pub struct SchemaResponse {
139    pub success: Option<bool>,
140    pub message: Option<String>,
141    pub table: Option<String>,
142    pub operation: Option<String>,
143}
144
145// ─── Builder ───────────────────────────────────────────────────────
146
147/// Builder for configuring a SchemaClient
148pub struct SchemaClientBuilder {
149    project_url: String,
150    service_key: String,
151    base_domain: String,
152    secure: bool,
153    timeout: u64,
154    verify_ssl: bool,
155}
156
157impl SchemaClientBuilder {
158    fn new(project_url: &str, service_key: &str) -> Self {
159        Self {
160            project_url: project_url.to_string(),
161            service_key: service_key.to_string(),
162            base_domain: "wowsql.com".to_string(),
163            secure: true,
164            timeout: 30,
165            verify_ssl: true,
166        }
167    }
168
169    pub fn base_domain(mut self, domain: &str) -> Self {
170        self.base_domain = domain.to_string();
171        self
172    }
173
174    pub fn secure(mut self, secure: bool) -> Self {
175        self.secure = secure;
176        self
177    }
178
179    pub fn timeout(mut self, seconds: u64) -> Self {
180        self.timeout = seconds;
181        self
182    }
183
184    pub fn verify_ssl(mut self, verify: bool) -> Self {
185        self.verify_ssl = verify;
186        self
187    }
188
189    pub fn build(self) -> Result<SchemaClient, WOWSQLError> {
190        let base_url = crate::client::WOWSQLClient::build_base_url(
191            &self.project_url,
192            &self.base_domain,
193            self.secure,
194        );
195
196        let client = Client::builder()
197            .timeout(Duration::from_secs(self.timeout))
198            .danger_accept_invalid_certs(!self.verify_ssl)
199            .build()
200            .map_err(|e| {
201                WOWSQLError::new(&format!("Failed to create HTTP client: {}", e), None, None)
202            })?;
203
204        Ok(SchemaClient {
205            base_url,
206            service_key: self.service_key,
207            client,
208        })
209    }
210}
211
212// ─── SchemaClient ──────────────────────────────────────────────────
213
214/// Schema management client for WOWSQL.
215///
216/// Requires SERVICE ROLE key, not anonymous key.
217pub struct SchemaClient {
218    base_url: String,
219    service_key: String,
220    client: Client,
221}
222
223impl SchemaClient {
224    /// Create a new schema management client.
225    ///
226    /// Requires SERVICE ROLE key, not anonymous key.
227    pub fn new(
228        project_url: impl Into<String>,
229        service_key: impl Into<String>,
230    ) -> Result<Self, WOWSQLError> {
231        let url: String = project_url.into();
232        let key: String = service_key.into();
233        SchemaClientBuilder::new(&url, &key).build()
234    }
235
236    /// Create a builder for advanced configuration
237    pub fn builder(project_url: &str, service_key: &str) -> SchemaClientBuilder {
238        SchemaClientBuilder::new(project_url, service_key)
239    }
240
241    // ─── Table Operations ──────────────────────────────────────────
242
243    /// Create a new table
244    /// Primary key column name is required; that column must use PostgreSQL type `UUID`.
245    pub async fn create_table(
246        &self,
247        name: &str,
248        columns: Vec<ColumnDefinition>,
249        primary_key: &str,
250        indexes: Option<Vec<IndexDefinition>>,
251    ) -> Result<SchemaResponse, WOWSQLError> {
252        assert_uuid_primary_key(primary_key, &columns)?;
253        let url = format!("{}/api/v2/schema/tables", self.base_url);
254        let request = CreateTableRequest {
255            table_name: name.to_string(),
256            columns,
257            primary_key: primary_key.to_string(),
258            indexes,
259        };
260        self.execute_schema_request(&url, "POST", Some(serde_json::to_value(&request).unwrap()))
261            .await
262    }
263
264    /// Alter an existing table
265    pub async fn alter_table(
266        &self,
267        name: &str,
268        operation: &str,
269        options: Option<Value>,
270    ) -> Result<SchemaResponse, WOWSQLError> {
271        let url = format!("{}/api/v2/schema/tables/{}", self.base_url, name);
272        let mut body = json!({
273            "table_name": name,
274            "operation": operation,
275        });
276        if let Some(Value::Object(map)) = options {
277            for (k, v) in map {
278                body[k] = v;
279            }
280        }
281        self.execute_schema_request(&url, "PATCH", Some(body)).await
282    }
283
284    /// Drop a table
285    pub async fn drop_table(
286        &self,
287        name: &str,
288        cascade: Option<bool>,
289    ) -> Result<SchemaResponse, WOWSQLError> {
290        let cascade = cascade.unwrap_or(false);
291        let url = format!(
292            "{}/api/v2/schema/tables/{}?cascade={}",
293            self.base_url, name, cascade
294        );
295        self.execute_schema_request(&url, "DELETE", None).await
296    }
297
298    /// Execute raw SQL for schema operations
299    pub async fn execute_sql(&self, sql: &str) -> Result<SchemaResponse, WOWSQLError> {
300        let url = format!("{}/api/v2/schema/execute", self.base_url);
301        let payload = json!({ "sql": sql });
302        self.execute_schema_request(&url, "POST", Some(payload))
303            .await
304    }
305
306    // ─── Column Convenience Methods ────────────────────────────────
307
308    /// Add a column to an existing table
309    pub async fn add_column(
310        &self,
311        table: &str,
312        column: ColumnDefinition,
313    ) -> Result<SchemaResponse, WOWSQLError> {
314        self.alter_table(
315            table,
316            "add_column",
317            Some(json!({ "add_columns": [column] })),
318        )
319        .await
320    }
321
322    /// Drop a column from a table
323    pub async fn drop_column(
324        &self,
325        table: &str,
326        column_name: &str,
327    ) -> Result<SchemaResponse, WOWSQLError> {
328        self.alter_table(
329            table,
330            "drop_column",
331            Some(json!({ "drop_columns": [column_name] })),
332        )
333        .await
334    }
335
336    /// Rename a column
337    pub async fn rename_column(
338        &self,
339        table: &str,
340        old_name: &str,
341        new_name: &str,
342    ) -> Result<SchemaResponse, WOWSQLError> {
343        self.alter_table(
344            table,
345            "rename_column",
346            Some(json!({
347                "rename_columns": [{
348                    "old_name": old_name,
349                    "new_name": new_name
350                }]
351            })),
352        )
353        .await
354    }
355
356    /// Modify a column's type or constraints
357    pub async fn modify_column(
358        &self,
359        table: &str,
360        column: ColumnDefinition,
361    ) -> Result<SchemaResponse, WOWSQLError> {
362        self.alter_table(
363            table,
364            "modify_column",
365            Some(json!({ "modify_columns": [column] })),
366        )
367        .await
368    }
369
370    // ─── Index Operations ──────────────────────────────────────────
371
372    /// Create an index on a table
373    pub async fn create_index(
374        &self,
375        table: &str,
376        columns: Vec<String>,
377        unique: Option<bool>,
378        name: Option<&str>,
379        using: Option<&str>,
380    ) -> Result<SchemaResponse, WOWSQLError> {
381        let idx_name = name
382            .map(|s| s.to_string())
383            .unwrap_or_else(|| format!("idx_{}_{}", table, columns.join("_")));
384
385        let unique = unique.unwrap_or(false);
386        let unique_str = if unique { "UNIQUE " } else { "" };
387        let using_str = using.map(|u| format!(" USING {}", u)).unwrap_or_default();
388
389        let sql = format!(
390            "CREATE {}INDEX {} ON {}{} ({})",
391            unique_str,
392            idx_name,
393            table,
394            using_str,
395            columns.join(", ")
396        );
397
398        self.execute_sql(&sql).await
399    }
400
401    // ─── Introspection ─────────────────────────────────────────────
402
403    /// List all tables
404    pub async fn list_tables(&self) -> Result<Vec<String>, WOWSQLError> {
405        let url = format!("{}/api/v2/schema/tables", self.base_url);
406        let response: Value = self.execute_json_request(&url, "GET", None).await?;
407
408        if let Some(tables) = response.get("tables").and_then(|v| v.as_array()) {
409            return Ok(tables
410                .iter()
411                .filter_map(|v| v.as_str().map(|s| s.to_string()))
412                .collect());
413        }
414        Ok(vec![])
415    }
416
417    /// Get table schema information
418    pub async fn get_table_schema(&self, table: &str) -> Result<TableSchema, WOWSQLError> {
419        let url = format!("{}/api/v2/schema/tables/{}", self.base_url, table);
420        self.execute_json_request(&url, "GET", None).await
421    }
422
423    // ─── Internal ──────────────────────────────────────────────────
424
425    async fn execute_schema_request(
426        &self,
427        url: &str,
428        method: &str,
429        body: Option<Value>,
430    ) -> Result<SchemaResponse, WOWSQLError> {
431        let mut request = self
432            .client
433            .request(
434                method
435                    .parse()
436                    .map_err(|_| WOWSQLError::new("Invalid method", None, None))?,
437                url,
438            )
439            .header("Content-Type", "application/json")
440            .header("Authorization", format!("Bearer {}", self.service_key));
441
442        if let Some(body) = body {
443            request = request.json(&body);
444        }
445
446        let response = request.send().await?;
447        let status = response.status();
448
449        if status.as_u16() == 403 {
450            return Err(SchemaPermissionError::new(
451                "Schema operations require a SERVICE ROLE key. \
452                 You are using an anonymous key which cannot modify database schema.",
453            )
454            .into());
455        }
456
457        let text = response.text().await?;
458
459        if !status.is_success() {
460            return Err(WOWSQLError::from_response(status.as_u16(), &text));
461        }
462
463        serde_json::from_str(&text)
464            .map_err(|e| WOWSQLError::new(&format!("Failed to parse response: {}", e), None, None))
465    }
466
467    async fn execute_json_request<T: serde::de::DeserializeOwned>(
468        &self,
469        url: &str,
470        method: &str,
471        body: Option<Value>,
472    ) -> Result<T, WOWSQLError> {
473        let mut request = self
474            .client
475            .request(
476                method
477                    .parse()
478                    .map_err(|_| WOWSQLError::new("Invalid method", None, None))?,
479                url,
480            )
481            .header("Content-Type", "application/json")
482            .header("Authorization", format!("Bearer {}", self.service_key));
483
484        if let Some(body) = body {
485            request = request.json(&body);
486        }
487
488        let response = request.send().await?;
489        let status = response.status();
490
491        if status.as_u16() == 403 {
492            return Err(SchemaPermissionError::new(
493                "Schema operations require a SERVICE ROLE key.",
494            )
495            .into());
496        }
497
498        let text = response.text().await?;
499
500        if !status.is_success() {
501            return Err(WOWSQLError::from_response(status.as_u16(), &text));
502        }
503
504        serde_json::from_str(&text)
505            .map_err(|e| WOWSQLError::new(&format!("Failed to parse response: {}", e), None, None))
506    }
507}