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;