Skip to main content

fraiseql_db/
fraiseql_wire_adapter.rs

1//! FraiseQL-Wire adapter implementation.
2//!
3//! This adapter integrates fraiseql-wire as an alternative database backend,
4//! providing streaming JSON queries with low memory overhead.
5
6use std::collections::HashMap;
7
8use async_trait::async_trait;
9use fraiseql_error::{FraiseQLError, Result};
10use futures::stream::StreamExt;
11
12use super::{
13    order_by::{Tiebreak, render_order_by_columns},
14    traits::DatabaseAdapter,
15    types::{DatabaseType, JsonbValue, PoolMetrics, sql_hints::OrderByClause},
16    where_clause::WhereClause,
17    where_sql_generator::WhereSqlGenerator,
18    wire_pool::WireClientFactory,
19};
20
21/// The bare `ORDER BY` column list for the wire builder, or a named refusal.
22///
23/// This adapter assembles raw SQL — `WhereSqlGenerator` escapes values into the
24/// string because the wire protocol path has no bind parameters, and that
25/// generator's own documentation says not to use it in new code for exactly
26/// that reason. So an ordering that binds a value has nowhere to put it here.
27///
28/// Today that is full-text relevance (#1284), and the refusal is consistent
29/// with the WHERE side: this backend already refuses every full-text *operator*
30/// (`where_sql_generator.rs`), so a `?search=` request cannot reach this
31/// function in the first place. It is refused rather than escaped because the
32/// alternative — interpolating a client's search string into an `ORDER BY` —
33/// is the one thing the parameter channel exists to prevent.
34///
35/// # Errors
36///
37/// Returns `FraiseQLError::Validation` when a clause binds a parameter, or when
38/// field-name validation fails.
39fn order_by_columns(order_by: Option<&[OrderByClause]>) -> Result<Option<String>> {
40    if order_by.is_some_and(|cs| cs.iter().any(OrderByClause::binds_parameter)) {
41        return Err(FraiseQLError::validation(
42            "full-text relevance ordering binds the search text as a parameter, which the              fraiseql-wire adapter cannot supply: it assembles raw SQL. This backend already              refuses full-text search predicates for the same reason.",
43        ));
44    }
45    // `Tiebreak::Identity`: this adapter slices LIMIT/OFFSET in memory, but each
46    // call re-runs the query, so consecutive pages are two orderings of tied rows
47    // exactly as on the SQL path (#1287).
48    Ok(
49        render_order_by_columns(order_by, DatabaseType::PostgreSQL, 1, Tiebreak::Identity)?
50            .map(|r| r.columns),
51    )
52}
53
54/// FraiseQL-Wire database adapter.
55///
56/// Uses fraiseql-wire for streaming JSON queries with bounded memory usage.
57/// This adapter is optimized for read-heavy workloads with large result sets.
58///
59/// # Streaming reads
60///
61/// This adapter inherits the trait's *buffering* default for
62/// [`stream_with_projection`](DatabaseAdapter::stream_with_projection), so its rows
63/// are correct but materialised — the same shape the collecting methods below already
64/// have. That is a deliberate non-implementation, not an oversight: the underlying
65/// `QueryStream` really does stream, and threading it through would be a small change,
66/// but no CI leg executes this adapter against a real database, so the result could
67/// not be proven — only compiled. See #1115.
68///
69/// # Architecture
70///
71/// - Connection Factory: Creates fresh clients on demand
72/// - Streaming: Results are streamed incrementally (O(chunk_size) memory)
73/// - WHERE Translation: AST → SQL via `WhereSqlGenerator`
74///
75/// # Example
76///
77/// ```ignore
78/// use fraiseql_core::db::{FraiseWireAdapter, WhereClause, WhereOperator, DatabaseAdapter};
79/// use serde_json::json;
80///
81/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
82/// // Create adapter
83/// let adapter = FraiseWireAdapter::new("postgres://localhost/mydb");
84///
85/// // Build WHERE clause
86/// let where_clause = WhereClause::Field {
87///     path: vec!["status".to_string()],
88///     operator: WhereOperator::Eq,
89///     value: json!("active"),
90/// };
91///
92/// // Execute query
93/// let results = adapter
94///     .execute_where_query("v_user", Some(&where_clause), Some(10), None, None)
95///     .await?;
96///
97/// println!("Found {} users", results.len());
98/// # Ok(())
99/// # }
100/// ```
101#[derive(Debug, Clone)]
102pub struct FraiseWireAdapter {
103    factory:    WireClientFactory,
104    chunk_size: usize,
105}
106
107impl FraiseWireAdapter {
108    /// Create a new FraiseWire adapter.
109    ///
110    /// # Arguments
111    ///
112    /// * `connection_string` - PostgreSQL connection string
113    ///
114    /// # Example
115    ///
116    /// ```ignore
117    /// use fraiseql_core::db::FraiseWireAdapter;
118    ///
119    /// let adapter = FraiseWireAdapter::new("postgres://localhost/fraiseql");
120    /// ```
121    #[must_use]
122    pub fn new(connection_string: impl Into<String>) -> Self {
123        Self {
124            factory:    WireClientFactory::new(connection_string),
125            chunk_size: 1024, // Default chunk size
126        }
127    }
128
129    /// Set chunk size for streaming queries.
130    ///
131    /// Larger chunk sizes increase throughput but use more memory.
132    /// Smaller chunk sizes reduce memory usage but may decrease throughput.
133    ///
134    /// # Arguments
135    ///
136    /// * `chunk_size` - Number of rows per chunk (default: 1024)
137    ///
138    /// # Example
139    ///
140    /// ```ignore
141    /// use fraiseql_core::db::FraiseWireAdapter;
142    ///
143    /// let adapter = FraiseWireAdapter::new("postgres://localhost/fraiseql")
144    ///     .with_chunk_size(512);
145    /// ```
146    #[must_use = "builder method returns modified builder"]
147    pub const fn with_chunk_size(mut self, chunk_size: usize) -> Self {
148        self.chunk_size = chunk_size;
149        self
150    }
151
152    /// Execute manual query with in-memory LIMIT/OFFSET slicing.
153    ///
154    /// fraiseql-wire's `QueryBuilder` does not expose LIMIT/OFFSET, so this collects the
155    /// (optionally ordered + filtered) stream and applies the window in memory. `order_by`
156    /// is pushed down to the wire builder so the slice is taken over correctly-sorted rows.
157    ///
158    /// # Errors
159    ///
160    /// Returns [`FraiseQLError::Validation`] if SQL generation fails, or
161    /// [`FraiseQLError::Database`] if the wire connection or query execution fails.
162    async fn execute_manual_query(
163        &self,
164        view: &str,
165        where_clause: Option<&WhereClause>,
166        limit: Option<u32>,
167        offset: Option<u32>,
168        order_by: Option<&[OrderByClause]>,
169    ) -> Result<Vec<JsonbValue>> {
170        // Create fresh client
171        let client = self.factory.create_client().await?;
172
173        // fraiseql-wire doesn't expose LIMIT/OFFSET on its QueryBuilder, so we collect the
174        // full (filtered + ordered) stream and slice the window in memory below. ORDER BY is
175        // pushed down so the slice is taken over sorted rows, not DB-native order.
176
177        // Pass view name directly - fraiseql-wire now uses entity names as-is (fixed in commit
178        // 6c78e30)
179        let mut builder = client.query::<serde_json::Value>(view).chunk_size(self.chunk_size);
180
181        if let Some(clause) = where_clause {
182            let where_sql = WhereSqlGenerator::to_sql(clause)?;
183            builder = builder.where_sql(where_sql);
184        }
185
186        // FraiseWire is PostgreSQL-only (see `database_type`); render the bare ORDER BY
187        // column list (the wire builder supplies the `ORDER BY` keyword itself).
188        if let Some(columns) = order_by_columns(order_by)? {
189            builder = builder.order_by(columns);
190        }
191
192        let mut stream = builder.execute().await.map_err(|e| FraiseQLError::Database {
193            message:   format!("fraiseql-wire query failed: {e}"),
194            sql_state: None,
195        })?;
196
197        // Collect all results
198        let mut results = Vec::new();
199        let offset_usize = offset.unwrap_or(0) as usize;
200        let limit_usize = limit.map(|l| l as usize);
201
202        let mut count = 0;
203        while let Some(item) = stream.next().await {
204            let json = item.map_err(|e| FraiseQLError::Database {
205                message:   format!("Stream error: {e}"),
206                sql_state: None,
207            })?;
208
209            // Apply offset and limit manually
210            if count >= offset_usize {
211                results.push(JsonbValue::new(json));
212
213                if let Some(lim) = limit_usize {
214                    if results.len() >= lim {
215                        break;
216                    }
217                }
218            }
219            count += 1;
220        }
221
222        Ok(results)
223    }
224}
225
226// Reason: DatabaseAdapter is defined with #[async_trait]; all implementations must match
227// its transformed method signatures to satisfy the trait contract
228// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
229#[async_trait]
230impl DatabaseAdapter for FraiseWireAdapter {
231    /// # Errors
232    ///
233    /// Returns [`FraiseQLError::Database`] if the wire connection or query execution fails.
234    async fn execute_with_projection(
235        &self,
236        view: &str,
237        _projection: Option<&crate::types::SqlProjectionHint>,
238        where_clause: Option<&WhereClause>,
239        limit: Option<u32>,
240        offset: Option<u32>,
241        order_by: Option<&[OrderByClause]>,
242    ) -> Result<Vec<JsonbValue>> {
243        // FraiseWire uses streaming, so projection is handled by field selection
244        // Fall back to standard query for now
245        self.execute_where_query(view, where_clause, limit, offset, order_by).await
246    }
247
248    /// # Errors
249    ///
250    /// Returns [`FraiseQLError::Validation`] if SQL generation fails, or
251    /// [`FraiseQLError::Database`] if the wire connection or query execution fails.
252    async fn execute_where_query(
253        &self,
254        view: &str,
255        where_clause: Option<&WhereClause>,
256        limit: Option<u32>,
257        offset: Option<u32>,
258        order_by: Option<&[OrderByClause]>,
259    ) -> Result<Vec<JsonbValue>> {
260        // fraiseql-wire generates SQL as: SELECT data FROM {entity}
261        // where entity is used exactly as provided (no prefix modifications)
262        let entity = view;
263
264        // Add LIMIT and OFFSET if provided
265        // Note: fraiseql-wire QueryBuilder doesn't expose limit/offset, so these cases
266        // collect-and-slice in memory (order_by is pushed down there too).
267        if limit.is_some() || offset.is_some() {
268            return self.execute_manual_query(view, where_clause, limit, offset, order_by).await;
269        }
270
271        // Create fresh client
272        let client = self.factory.create_client().await?;
273
274        // Start building query
275        let mut builder = client.query::<serde_json::Value>(entity).chunk_size(self.chunk_size);
276
277        // Add WHERE clause if provided
278        if let Some(clause) = where_clause {
279            let where_sql = WhereSqlGenerator::to_sql(clause)?;
280            builder = builder.where_sql(where_sql);
281        }
282
283        // Honor ORDER BY (audit #442: previously dropped — relay/keyset pagination over the
284        // wire backend silently returned DB-native order). FraiseWire is PostgreSQL-only.
285        if let Some(columns) = order_by_columns(order_by)? {
286            builder = builder.order_by(columns);
287        }
288
289        // Execute streaming query
290        let mut stream = builder.execute().await.map_err(|e| FraiseQLError::Database {
291            message:   format!("fraiseql-wire query failed: {e}"),
292            sql_state: None,
293        })?;
294
295        // Collect results
296        let mut results = Vec::new();
297        while let Some(item) = stream.next().await {
298            let json = item.map_err(|e| FraiseQLError::Database {
299                message:   format!("Stream error: {e}"),
300                sql_state: None,
301            })?;
302            results.push(JsonbValue::new(json));
303        }
304
305        Ok(results)
306    }
307
308    fn database_type(&self) -> DatabaseType {
309        DatabaseType::PostgreSQL
310    }
311
312    /// Stated rather than inherited, with the reason: a composed read carries every
313    /// embedded level's RLS predicate in its `WHERE`, and this adapter binds no parameters —
314    /// it escapes values into literal SQL. Composing here would run authorization
315    /// predicates through string escaping; binding parameters comes first.
316    fn supports_composed_reads(&self) -> bool {
317        false
318    }
319
320    async fn health_check(&self) -> Result<()> {
321        // fraiseql-wire's FraiseClient contains non-Send types (raw pointers in TLS),
322        // which makes it incompatible with the async_trait Send requirement.
323        // As a workaround, we just verify the connection string is non-empty.
324        // Actual connectivity is verified when queries are executed.
325        if self.factory.connection_string().is_empty() {
326            return Err(FraiseQLError::Database {
327                message:   "Connection string is empty".to_string(),
328                sql_state: None,
329            });
330        }
331        Ok(())
332    }
333
334    fn pool_metrics(&self) -> PoolMetrics {
335        // fraiseql-wire doesn't pool connections, so metrics are not applicable
336        PoolMetrics {
337            total_connections:  0,
338            idle_connections:   0,
339            active_connections: 0,
340            waiting_requests:   0,
341        }
342    }
343
344    /// # Errors
345    ///
346    /// Always returns [`FraiseQLError::Database`] — arbitrary SQL is not supported
347    /// by fraiseql-wire; use [`Self::execute_where_query`] instead.
348    async fn execute_raw_query(
349        &self,
350        _sql: &str,
351    ) -> Result<Vec<HashMap<String, serde_json::Value>>> {
352        // fraiseql-wire doesn't support arbitrary SQL queries
353        // It only supports SELECT data FROM v_{entity} WHERE ... queries
354        // This limitation is intentional as per fraiseql-wire's design
355        Err(FraiseQLError::Database {
356            message: "fraiseql-wire does not support arbitrary SQL queries. Use execute_where_query instead.".to_string(),
357            sql_state: None,
358        })
359    }
360
361    /// # Errors
362    ///
363    /// Always returns [`FraiseQLError::Database`] — aggregate SQL is not supported
364    /// by fraiseql-wire.
365    async fn execute_parameterized_aggregate(
366        &self,
367        _sql: &str,
368        _params: &[serde_json::Value],
369    ) -> Result<Vec<HashMap<String, serde_json::Value>>> {
370        // fraiseql-wire does not support aggregate queries with arbitrary SQL.
371        Err(FraiseQLError::Database {
372            message:   "fraiseql-wire does not support aggregate SQL queries.".to_string(),
373            sql_state: None,
374        })
375    }
376
377    async fn query_stats(&self, _limit: u32) -> Result<Vec<crate::types::QueryStatEntry>> {
378        // fraiseql-wire does not have access to database-level query statistics.
379        Ok(vec![])
380    }
381
382    async fn query_stats_by_id(&self, _id: &str) -> Result<Option<crate::types::QueryStatEntry>> {
383        Ok(None)
384    }
385}
386
387#[cfg(test)]
388mod tests;