Skip to main content

qail_pg/driver/
ops.rs

1//! PgDriver operations: transaction control, batch execution, statement timeout,
2//! RLS context, pipeline, COPY bulk/export, and cursor streaming.
3
4use super::core::PgDriver;
5use super::pipeline::AstPipelineMode;
6use super::prepared::PreparedStatement;
7use super::rls;
8use super::types::*;
9use super::{AutoCountPath, AutoCountPlan};
10use crate::protocol::AstEncoder;
11use qail_core::ast::Qail;
12
13impl PgDriver {
14    // ==================== TRANSACTION CONTROL ====================
15
16    /// Begin a transaction (AST-native).
17    pub async fn begin(&mut self) -> PgResult<()> {
18        self.connection.begin_transaction().await
19    }
20
21    /// Commit the current transaction (AST-native).
22    pub async fn commit(&mut self) -> PgResult<()> {
23        self.connection.commit().await
24    }
25
26    /// Rollback the current transaction (AST-native).
27    pub async fn rollback(&mut self) -> PgResult<()> {
28        self.connection.rollback().await
29    }
30
31    /// Create a named savepoint within the current transaction.
32    /// Savepoints allow partial rollback within a transaction.
33    /// Use `rollback_to()` to return to this savepoint.
34    /// # Example
35    /// ```ignore
36    /// driver.begin().await?;
37    /// driver.execute(&insert1).await?;
38    /// driver.savepoint("sp1").await?;
39    /// driver.execute(&insert2).await?;
40    /// driver.rollback_to("sp1").await?; // Undo insert2, keep insert1
41    /// driver.commit().await?;
42    /// ```
43    pub async fn savepoint(&mut self, name: &str) -> PgResult<()> {
44        self.connection.savepoint(name).await
45    }
46
47    /// Rollback to a previously created savepoint.
48    /// Discards all changes since the named savepoint was created,
49    /// but keeps the transaction open.
50    pub async fn rollback_to(&mut self, name: &str) -> PgResult<()> {
51        self.connection.rollback_to(name).await
52    }
53
54    /// Release a savepoint (free resources, if no longer needed).
55    /// After release, the savepoint cannot be rolled back to.
56    pub async fn release_savepoint(&mut self, name: &str) -> PgResult<()> {
57        self.connection.release_savepoint(name).await
58    }
59
60    // ==================== BATCH TRANSACTIONS ====================
61
62    /// Execute multiple commands in a single atomic transaction.
63    /// All commands succeed or all are rolled back.
64    /// # Example
65    /// ```ignore
66    /// let cmds = vec![
67    ///     Qail::add("users").columns(["name"]).values(["Alice"]),
68    ///     Qail::add("users").columns(["name"]).values(["Bob"]),
69    /// ];
70    /// let results = driver.execute_batch(&cmds).await?;
71    /// // results = [1, 1] (rows affected)
72    /// ```
73    pub async fn execute_batch(&mut self, cmds: &[Qail]) -> PgResult<Vec<u64>> {
74        self.begin().await?;
75        let mut results = Vec::with_capacity(cmds.len());
76        for cmd in cmds {
77            match self.execute(cmd).await {
78                Ok(n) => results.push(n),
79                Err(e) => {
80                    if self.rollback().await.is_err() {
81                        self.connection.mark_io_desynced();
82                    }
83                    return Err(e);
84                }
85            }
86        }
87        self.commit().await?;
88        Ok(results)
89    }
90
91    // ==================== STATEMENT TIMEOUT ====================
92
93    /// Set statement timeout for this connection (in milliseconds).
94    /// # Example
95    /// ```ignore
96    /// driver.set_statement_timeout(30_000).await?; // 30 seconds
97    /// ```
98    pub async fn set_statement_timeout(&mut self, ms: u32) -> PgResult<()> {
99        let cmd = Qail::session_set("statement_timeout", ms.to_string());
100        self.execute(&cmd).await.map(|_| ())
101    }
102
103    /// Reset statement timeout to default (no limit).
104    pub async fn reset_statement_timeout(&mut self) -> PgResult<()> {
105        let cmd = Qail::session_reset("statement_timeout");
106        self.execute(&cmd).await.map(|_| ())
107    }
108
109    /// Execute trusted administrative SQL using PostgreSQL's simple-query protocol.
110    ///
111    /// This is intended for internal/bootstrap DDL that cannot yet be expressed
112    /// by the QAIL AST, such as idempotent catalog maintenance statements.
113    pub async fn execute_simple(&mut self, sql: &str) -> PgResult<()> {
114        self.connection.execute_simple(sql).await
115    }
116
117    /// Execute trusted administrative SQL and return rows.
118    ///
119    /// This is the row-returning counterpart to `execute_simple`; prefer AST
120    /// APIs for application data access.
121    pub async fn simple_query(&mut self, sql: &str) -> PgResult<Vec<PgRow>> {
122        self.connection.simple_query(sql).await
123    }
124
125    // ==================== RLS (MULTI-TENANT) ====================
126
127    /// Set the RLS context for multi-tenant data isolation.
128    ///
129    /// Configures PostgreSQL session variables (`app.current_tenant_id`, etc.)
130    /// so that RLS policies automatically filter data by tenant.
131    ///
132    /// Since `PgDriver` takes `&mut self`, the borrow checker guarantees
133    /// that `set_config` and all subsequent queries execute on the **same
134    /// connection** — no pool race conditions possible.
135    ///
136    /// # Example
137    /// ```ignore
138    /// driver.set_rls_context(RlsContext::tenant("tenant-123")).await?;
139    /// let orders = driver.fetch_all(&Qail::get("orders")).await?;
140    /// // orders only contains rows for tenant-123
141    /// ```
142    pub async fn set_rls_context(&mut self, ctx: rls::RlsContext) -> PgResult<()> {
143        let sql = rls::context_to_sql(&ctx);
144        if sql.as_bytes().contains(&0) {
145            return Err(crate::PgError::Protocol(
146                "SQL contains NULL byte (0x00) which is invalid in PostgreSQL".to_string(),
147            ));
148        }
149        self.connection.execute_simple(&sql).await?;
150        self.rls_context = Some(ctx);
151        Ok(())
152    }
153
154    /// Clear the RLS context, resetting session variables to safe defaults.
155    ///
156    /// After clearing, all RLS-protected queries will return zero rows
157    /// (empty tenant scope matches nothing).
158    pub async fn clear_rls_context(&mut self) -> PgResult<()> {
159        let sql = rls::reset_sql();
160        if sql.as_bytes().contains(&0) {
161            return Err(crate::PgError::Protocol(
162                "SQL contains NULL byte (0x00) which is invalid in PostgreSQL".to_string(),
163            ));
164        }
165        self.connection.execute_simple(sql).await?;
166        self.rls_context = None;
167        Ok(())
168    }
169
170    /// Get the current RLS context, if any.
171    pub fn rls_context(&self) -> Option<&rls::RlsContext> {
172        self.rls_context.as_ref()
173    }
174
175    // ==================== PIPELINE (BATCH) ====================
176
177    /// Execute multiple Qail ASTs in a single network round-trip (PIPELINING).
178    /// # Example
179    /// ```ignore
180    /// let cmds: Vec<Qail> = (1..=1000)
181    ///     .map(|i| Qail::get("harbors").columns(["id", "name"]).limit(i))
182    ///     .collect();
183    /// let count = driver.pipeline_execute_count(&cmds).await?;
184    /// assert_eq!(count, 1000);
185    /// ```
186    pub async fn pipeline_execute_count(&mut self, cmds: &[Qail]) -> PgResult<usize> {
187        self.pipeline_execute_count_with_mode(cmds, AstPipelineMode::Auto)
188            .await
189    }
190
191    /// Execute commands with runtime auto strategy and return both count and plan.
192    ///
193    /// Strategy:
194    /// - `len <= 1`: single cached query path
195    /// - `2..8`: one-shot pipeline
196    /// - `>= 8`: cached pipeline
197    pub async fn execute_count_auto_with_plan(
198        &mut self,
199        cmds: &[Qail],
200    ) -> PgResult<(usize, AutoCountPlan)> {
201        let plan = AutoCountPlan::for_driver(cmds.len());
202
203        let completed = match plan.path {
204            AutoCountPath::SingleCached => {
205                if cmds.is_empty() {
206                    0
207                } else {
208                    let _ = self.fetch_all_cached(&cmds[0]).await?;
209                    1
210                }
211            }
212            AutoCountPath::PipelineOneShot => {
213                self.connection
214                    .pipeline_execute_count_ast_with_mode(cmds, AstPipelineMode::OneShot)
215                    .await?
216            }
217            AutoCountPath::PipelineCached => {
218                self.connection
219                    .pipeline_execute_count_ast_with_mode(cmds, AstPipelineMode::Cached)
220                    .await?
221            }
222            AutoCountPath::PoolParallel => {
223                return Err(PgError::Protocol(
224                    "driver auto planner returned pool-parallel path".to_string(),
225                ));
226            }
227        };
228
229        Ok((completed, plan))
230    }
231
232    /// Execute commands with runtime auto strategy.
233    #[inline]
234    pub async fn execute_count_auto(&mut self, cmds: &[Qail]) -> PgResult<usize> {
235        let (completed, _plan) = self.execute_count_auto_with_plan(cmds).await?;
236        Ok(completed)
237    }
238
239    /// Execute multiple Qail ASTs with an explicit pipeline strategy.
240    ///
241    /// Use [`AstPipelineMode::Cached`] for repeated templates in large batches,
242    /// or [`AstPipelineMode::OneShot`] for tiny one-off batches.
243    pub async fn pipeline_execute_count_with_mode(
244        &mut self,
245        cmds: &[Qail],
246        mode: AstPipelineMode,
247    ) -> PgResult<usize> {
248        self.connection
249            .pipeline_execute_count_ast_with_mode(cmds, mode)
250            .await
251    }
252
253    /// Execute multiple Qail ASTs and return full row data.
254    pub async fn pipeline_execute_rows(&mut self, cmds: &[Qail]) -> PgResult<Vec<Vec<PgRow>>> {
255        let raw_results = self.connection.pipeline_execute_rows_ast(cmds).await?;
256
257        let results: Vec<Vec<PgRow>> = raw_results
258            .into_iter()
259            .map(|rows| {
260                rows.into_iter()
261                    .map(|columns| PgRow {
262                        columns,
263                        column_info: None,
264                    })
265                    .collect()
266            })
267            .collect();
268
269        Ok(results)
270    }
271
272    /// Run `EXPLAIN (FORMAT JSON)` on a Qail AST command and return parsed estimates.
273    ///
274    /// Returns `Ok(None)` when PostgreSQL returns an unexpected JSON shape.
275    pub async fn explain_estimate(
276        &mut self,
277        cmd: &Qail,
278    ) -> PgResult<Option<crate::driver::explain::ExplainEstimate>> {
279        let (sql, params) =
280            AstEncoder::encode_cmd_sql(cmd).map_err(|e| PgError::Encode(e.to_string()))?;
281        let explain_sql = format!("EXPLAIN (FORMAT JSON) {}", sql);
282        let rows = self.connection.query(&explain_sql, &params).await?;
283
284        let mut json_output = String::new();
285        for row in &rows {
286            if let Some(Some(val)) = row.first()
287                && let Ok(text) = std::str::from_utf8(val)
288            {
289                json_output.push_str(text);
290            }
291        }
292
293        Ok(crate::driver::explain::parse_explain_json(&json_output))
294    }
295
296    /// Prepare a SQL statement for repeated execution.
297    pub async fn prepare(&mut self, sql: &str) -> PgResult<PreparedStatement> {
298        self.connection.prepare(sql).await
299    }
300
301    /// Execute a prepared statement pipeline in FAST mode (count only).
302    pub async fn pipeline_execute_prepared_count(
303        &mut self,
304        stmt: &PreparedStatement,
305        params_batch: &[Vec<Option<Vec<u8>>>],
306    ) -> PgResult<usize> {
307        self.connection
308            .pipeline_execute_prepared_count(stmt, params_batch)
309            .await
310    }
311
312    /// Bulk insert data using PostgreSQL COPY protocol (AST-native).
313    /// Uses a Qail::Add to get validated table and column names from the AST,
314    /// not user-provided strings. This is the sound, AST-native approach.
315    /// # Example
316    /// ```ignore
317    /// // Create a Qail::Add to define table and columns
318    /// let cmd = Qail::add("users")
319    ///     .columns(["id", "name", "email"]);
320    /// // Bulk insert rows
321    /// let rows: Vec<Vec<Value>> = vec![
322    ///     vec![Value::Int(1), Value::String("Alice"), Value::String("alice@ex.com")],
323    ///     vec![Value::Int(2), Value::String("Bob"), Value::String("bob@ex.com")],
324    /// ];
325    /// driver.copy_bulk(&cmd, &rows).await?;
326    /// ```
327    pub async fn copy_bulk(
328        &mut self,
329        cmd: &Qail,
330        rows: &[Vec<qail_core::ast::Value>],
331    ) -> PgResult<u64> {
332        use qail_core::ast::Action;
333
334        if cmd.action != Action::Add {
335            return Err(PgError::Query(
336                "copy_bulk requires Qail::Add action".to_string(),
337            ));
338        }
339
340        let table = &cmd.table;
341
342        let columns: Vec<String> = cmd
343            .columns
344            .iter()
345            .filter_map(|expr| {
346                use qail_core::ast::Expr;
347                match expr {
348                    Expr::Named(name) => Some(name.clone()),
349                    Expr::Aliased { name, .. } => Some(name.clone()),
350                    Expr::Star => None, // Can't COPY with *
351                    _ => None,
352                }
353            })
354            .collect();
355
356        if columns.is_empty() {
357            return Err(PgError::Query(
358                "copy_bulk requires columns in Qail".to_string(),
359            ));
360        }
361
362        // Use optimized COPY path: direct Value → bytes encoding, single syscall
363        self.connection.copy_in_fast(table, &columns, rows).await
364    }
365
366    /// **Fastest** bulk insert using pre-encoded COPY data.
367    /// Accepts raw COPY text format bytes. Use when caller has already
368    /// encoded rows to avoid any encoding overhead.
369    /// # Format
370    /// Data should be tab-separated rows with newlines (COPY text format):
371    /// `1\thello\t3.14\n2\tworld\t2.71\n`
372    /// # Example
373    /// ```ignore
374    /// let cmd = Qail::add("users").columns(["id", "name"]);
375    /// let data = b"1\tAlice\n2\tBob\n";
376    /// driver.copy_bulk_bytes(&cmd, data).await?;
377    /// ```
378    pub async fn copy_bulk_bytes(&mut self, cmd: &Qail, data: &[u8]) -> PgResult<u64> {
379        use qail_core::ast::Action;
380
381        if cmd.action != Action::Add {
382            return Err(PgError::Query(
383                "copy_bulk_bytes requires Qail::Add action".to_string(),
384            ));
385        }
386
387        let table = &cmd.table;
388        let columns: Vec<String> = cmd
389            .columns
390            .iter()
391            .filter_map(|expr| {
392                use qail_core::ast::Expr;
393                match expr {
394                    Expr::Named(name) => Some(name.clone()),
395                    Expr::Aliased { name, .. } => Some(name.clone()),
396                    _ => None,
397                }
398            })
399            .collect();
400
401        if columns.is_empty() {
402            return Err(PgError::Query(
403                "copy_bulk_bytes requires columns in Qail".to_string(),
404            ));
405        }
406
407        // Direct to raw COPY - zero encoding!
408        self.connection.copy_in_raw(table, &columns, data).await
409    }
410
411    /// Export table data using PostgreSQL COPY TO STDOUT (zero-copy streaming).
412    /// Returns rows as tab-separated bytes for direct re-import via copy_bulk_bytes.
413    /// # Example
414    /// ```ignore
415    /// let data = driver.copy_export_table("users", &["id", "name"]).await?;
416    /// shadow_driver.copy_bulk_bytes(&cmd, &data).await?;
417    /// ```
418    pub async fn copy_export_table(
419        &mut self,
420        table: &str,
421        columns: &[String],
422    ) -> PgResult<Vec<u8>> {
423        let cols: Vec<String> = columns
424            .iter()
425            .map(|c| super::copy::quote_copy_column_ident(c))
426            .collect::<PgResult<_>>()?;
427        let sql = format!(
428            "COPY {} ({}) TO STDOUT",
429            super::copy::quote_copy_table_ref(table)?,
430            cols.join(", ")
431        );
432
433        self.connection.copy_out_raw(&sql).await
434    }
435
436    /// Stream table export using COPY TO STDOUT with bounded memory usage.
437    ///
438    /// Chunks are forwarded directly from PostgreSQL to `on_chunk`.
439    pub async fn copy_export_table_stream<F, Fut>(
440        &mut self,
441        table: &str,
442        columns: &[String],
443        on_chunk: F,
444    ) -> PgResult<()>
445    where
446        F: FnMut(Vec<u8>) -> Fut,
447        Fut: std::future::Future<Output = PgResult<()>>,
448    {
449        let cols: Vec<String> = columns
450            .iter()
451            .map(|c| super::copy::quote_copy_column_ident(c))
452            .collect::<PgResult<_>>()?;
453        let sql = format!(
454            "COPY {} ({}) TO STDOUT",
455            super::copy::quote_copy_table_ref(table)?,
456            cols.join(", ")
457        );
458        self.connection.copy_out_raw_stream(&sql, on_chunk).await
459    }
460
461    /// Stream an AST-native `Qail::Export` command as raw COPY chunks.
462    pub async fn copy_export_cmd_stream<F, Fut>(&mut self, cmd: &Qail, on_chunk: F) -> PgResult<()>
463    where
464        F: FnMut(Vec<u8>) -> Fut,
465        Fut: std::future::Future<Output = PgResult<()>>,
466    {
467        self.connection.copy_export_stream_raw(cmd, on_chunk).await
468    }
469
470    /// Stream an AST-native `Qail::Export` command as parsed text rows.
471    pub async fn copy_export_cmd_stream_rows<F>(&mut self, cmd: &Qail, on_row: F) -> PgResult<()>
472    where
473        F: FnMut(Vec<String>) -> PgResult<()>,
474    {
475        self.connection.copy_export_stream_rows(cmd, on_row).await
476    }
477
478    /// Stream large result sets using PostgreSQL cursors.
479    /// This method uses DECLARE CURSOR internally to stream rows in batches,
480    /// avoiding loading the entire result set into memory.
481    /// # Example
482    /// ```ignore
483    /// let cmd = Qail::get("large_table");
484    /// let batches = driver.stream_cmd(&cmd, 100).await?;
485    /// for batch in batches {
486    ///     for row in batch {
487    ///         // process row
488    ///     }
489    /// }
490    /// ```
491    pub async fn stream_cmd(&mut self, cmd: &Qail, batch_size: usize) -> PgResult<Vec<Vec<PgRow>>> {
492        validate_stream_batch_size(batch_size)?;
493
494        use std::sync::atomic::{AtomicU64, Ordering};
495        static CURSOR_ID: AtomicU64 = AtomicU64::new(0);
496
497        let cursor_name = format!("qail_cursor_{}", CURSOR_ID.fetch_add(1, Ordering::SeqCst));
498
499        // AST-NATIVE: Generate SQL directly from AST (no to_sql_parameterized!)
500        let mut sql_buf = bytes::BytesMut::with_capacity(256);
501        let mut params: Vec<Option<Vec<u8>>> = Vec::new();
502        AstEncoder::encode_select_sql(cmd, &mut sql_buf, &mut params)
503            .map_err(|e| PgError::Encode(e.to_string()))?;
504        let sql = std::str::from_utf8(&sql_buf)
505            .map_err(|e| PgError::Encode(format!("encoded SQL is not UTF-8: {}", e)))?
506            .to_string();
507
508        // Must be in a transaction for cursors
509        self.connection.begin_transaction().await?;
510
511        let stream_result = async {
512            // Declare cursor with bind params — Extended Query Protocol handles $1, $2 etc.
513            self.connection
514                .declare_cursor(&cursor_name, &sql, &params)
515                .await?;
516
517            // Fetch all batches
518            let mut all_batches = Vec::new();
519            while let Some(rows) = self
520                .connection
521                .fetch_cursor(&cursor_name, batch_size)
522                .await?
523            {
524                let pg_rows: Vec<PgRow> = rows
525                    .into_iter()
526                    .map(|cols| PgRow {
527                        columns: cols,
528                        column_info: None,
529                    })
530                    .collect();
531                all_batches.push(pg_rows);
532            }
533
534            self.connection.close_cursor(&cursor_name).await?;
535            Ok(all_batches)
536        }
537        .await;
538
539        match stream_result {
540            Ok(all_batches) => {
541                self.connection.commit().await?;
542                Ok(all_batches)
543            }
544            Err(err) => {
545                if self.connection.rollback().await.is_err() {
546                    self.connection.mark_io_desynced();
547                }
548                Err(err)
549            }
550        }
551    }
552}
553
554fn validate_stream_batch_size(batch_size: usize) -> PgResult<()> {
555    if batch_size == 0 {
556        return Err(PgError::Query(
557            "stream_cmd batch_size must be greater than 0".to_string(),
558        ));
559    }
560    Ok(())
561}
562
563#[cfg(test)]
564mod tests {
565    use super::validate_stream_batch_size;
566
567    #[test]
568    fn stream_batch_size_zero_is_rejected() {
569        let err = validate_stream_batch_size(0).expect_err("zero batch size must fail");
570        assert!(err.to_string().contains("batch_size"));
571    }
572
573    #[test]
574    fn stream_batch_size_positive_is_accepted() {
575        validate_stream_batch_size(1).expect("positive batch size should pass");
576    }
577}