Skip to main content

fraiseql_wire/client/
fraise_client.rs

1//! `FraiseClient` implementation
2
3use super::connection_string::{ConnectionInfo, TransportType};
4use super::query_builder::QueryBuilder;
5use crate::connection::{Connection, ConnectionConfig, Transport};
6#[allow(unused_imports)] // Reason: used only in doc links for `# Errors` sections
7use crate::error::WireError;
8use crate::stream::JsonStream;
9use crate::Result;
10use serde::de::DeserializeOwned;
11
12/// FraiseQL wire protocol client
13pub struct FraiseClient {
14    conn: Connection,
15}
16
17impl FraiseClient {
18    /// Connect to Postgres using connection string
19    ///
20    /// # Errors
21    ///
22    /// Returns [`WireError::Config`] if the connection string is invalid or missing required
23    /// fields. Returns [`WireError`] if the TCP or Unix socket connection fails, or if
24    /// startup/authentication is rejected by the server.
25    ///
26    /// # Examples
27    ///
28    /// ```no_run
29    /// // Requires: live Postgres server.
30    /// # async fn example() -> fraiseql_wire::Result<()> {
31    /// use fraiseql_wire::FraiseClient;
32    ///
33    /// // TCP connection
34    /// let client = FraiseClient::connect("postgres://localhost/mydb").await?;
35    ///
36    /// // Unix socket
37    /// let client = FraiseClient::connect("postgres:///mydb").await?;
38    /// # Ok(())
39    /// # }
40    /// ```
41    ///
42    /// # Errors
43    ///
44    /// Returns [`WireError::Config`] if the connection string is malformed or missing
45    /// required fields (host/port for TCP, path for Unix sockets).
46    /// Returns [`WireError::Io`] if the underlying TCP or Unix socket connection fails.
47    pub async fn connect(connection_string: &str) -> Result<Self> {
48        let info = ConnectionInfo::parse(connection_string)?;
49
50        let transport = match info.transport {
51            TransportType::Tcp => {
52                let host = info.host.as_ref().ok_or_else(|| {
53                    crate::WireError::Config("TCP transport requires a host".into())
54                })?;
55                let port = info.port.ok_or_else(|| {
56                    crate::WireError::Config("TCP transport requires a port".into())
57                })?;
58                Transport::connect_tcp(host, port).await?
59            }
60            TransportType::Unix => {
61                let path = info.unix_socket.as_ref().ok_or_else(|| {
62                    crate::WireError::Config("Unix transport requires a socket path".into())
63                })?;
64                Transport::connect_unix(path).await?
65            }
66        };
67
68        let mut conn = Connection::new(transport);
69        let config = info.to_config();
70        conn.startup(&config).await?;
71
72        Ok(Self { conn })
73    }
74
75    /// Connect to Postgres with TLS encryption
76    ///
77    /// TLS is configured independently from the connection string. The connection string
78    /// should contain the hostname and credentials (user/password), while TLS configuration
79    /// is provided separately via `TlsConfig`.
80    ///
81    /// # Errors
82    ///
83    /// Returns [`WireError::Config`] if the connection string is invalid, TLS is requested
84    /// over a Unix socket, or required fields are missing. Returns [`WireError::Io`] if the
85    /// TLS handshake or TCP connection fails.
86    ///
87    /// # Examples
88    ///
89    /// ```no_run
90    /// // Requires: live Postgres server with TLS.
91    /// # async fn example() -> fraiseql_wire::Result<()> {
92    /// use fraiseql_wire::{FraiseClient, connection::TlsConfig};
93    ///
94    /// // Configure TLS with system root certificates
95    /// let tls = TlsConfig::builder()
96    ///     .verify_hostname(true)
97    ///     .build()?;
98    ///
99    /// // Connect with TLS
100    /// let client = FraiseClient::connect_tls("postgres://secure.db.example.com/mydb", tls).await?;
101    /// # Ok(())
102    /// # }
103    /// ```
104    pub async fn connect_tls(
105        connection_string: &str,
106        tls_config: crate::connection::TlsConfig,
107    ) -> Result<Self> {
108        let info = ConnectionInfo::parse(connection_string)?;
109
110        let transport = match info.transport {
111            TransportType::Tcp => {
112                let host = info.host.as_ref().ok_or_else(|| {
113                    crate::WireError::Config("TCP transport requires a host".into())
114                })?;
115                let port = info.port.ok_or_else(|| {
116                    crate::WireError::Config("TCP transport requires a port".into())
117                })?;
118                Transport::connect_tcp_tls(host, port, &tls_config).await?
119            }
120            TransportType::Unix => {
121                return Err(crate::WireError::Config(
122                    "TLS is only supported for TCP connections".into(),
123                ));
124            }
125        };
126
127        let mut conn = Connection::new(transport);
128        let config = info.to_config();
129        conn.startup(&config).await?;
130
131        Ok(Self { conn })
132    }
133
134    /// Connect to Postgres with custom connection configuration
135    ///
136    /// This method allows you to configure timeouts, keepalive intervals, and other
137    /// connection options. The connection configuration is merged with parameters from
138    /// the connection string.
139    ///
140    /// # Errors
141    ///
142    /// Returns [`WireError::Config`] if the connection string is invalid or missing required
143    /// fields. Returns [`WireError::Io`] if the TCP or Unix socket connection fails, or if
144    /// startup/authentication is rejected by the server.
145    ///
146    /// # Examples
147    ///
148    /// ```no_run
149    /// // Requires: live Postgres server.
150    /// # async fn example() -> fraiseql_wire::Result<()> {
151    /// use fraiseql_wire::{FraiseClient, connection::ConnectionConfig};
152    /// use std::time::Duration;
153    ///
154    /// // Build connection configuration with timeouts
155    /// let config = ConnectionConfig::builder("localhost", "mydb")
156    ///     .password("secret")
157    ///     .statement_timeout(Duration::from_secs(30))
158    ///     .keepalive_idle(Duration::from_secs(300))
159    ///     .application_name("my_app")
160    ///     .build();
161    ///
162    /// // Connect with configuration
163    /// let client = FraiseClient::connect_with_config("postgres://localhost:5432/mydb", config).await?;
164    /// # Ok(())
165    /// # }
166    /// ```
167    pub async fn connect_with_config(
168        connection_string: &str,
169        config: ConnectionConfig,
170    ) -> Result<Self> {
171        let info = ConnectionInfo::parse(connection_string)?;
172
173        let transport = match info.transport {
174            TransportType::Tcp => {
175                let host = info.host.as_ref().ok_or_else(|| {
176                    crate::WireError::Config("TCP transport requires a host".into())
177                })?;
178                let port = info.port.ok_or_else(|| {
179                    crate::WireError::Config("TCP transport requires a port".into())
180                })?;
181                Transport::connect_tcp(host, port).await?
182            }
183            TransportType::Unix => {
184                let path = info.unix_socket.as_ref().ok_or_else(|| {
185                    crate::WireError::Config("Unix transport requires a socket path".into())
186                })?;
187                Transport::connect_unix(path).await?
188            }
189        };
190
191        // Apply TCP keepalive when configured.
192        if let Some(idle) = config.keepalive_idle {
193            if let Err(e) = transport.apply_keepalive(idle) {
194                tracing::warn!("Failed to apply TCP keepalive (idle={idle:?}): {e}");
195            }
196        }
197
198        let mut conn = Connection::new(transport);
199        conn.startup(&config).await?;
200
201        Ok(Self { conn })
202    }
203
204    /// Connect to Postgres with both custom configuration and TLS encryption
205    ///
206    /// This method combines connection configuration (timeouts, keepalive, etc.)
207    /// with TLS encryption for secure connections with advanced options.
208    ///
209    /// # Errors
210    ///
211    /// Returns [`WireError::Config`] if the connection string is invalid, TLS is requested
212    /// over a Unix socket, or required fields are missing. Returns [`WireError::Io`] if the
213    /// TLS handshake or TCP connection fails.
214    ///
215    /// # Examples
216    ///
217    /// ```no_run
218    /// // Requires: live Postgres server with TLS.
219    /// # async fn example() -> fraiseql_wire::Result<()> {
220    /// use fraiseql_wire::{FraiseClient, connection::{ConnectionConfig, TlsConfig}};
221    /// use std::time::Duration;
222    ///
223    /// // Configure connection with timeouts
224    /// let config = ConnectionConfig::builder("localhost", "mydb")
225    ///     .password("secret")
226    ///     .statement_timeout(Duration::from_secs(30))
227    ///     .build();
228    ///
229    /// // Configure TLS
230    /// let tls = TlsConfig::builder()
231    ///     .verify_hostname(true)
232    ///     .build()?;
233    ///
234    /// // Connect with both configuration and TLS
235    /// let client = FraiseClient::connect_with_config_and_tls(
236    ///     "postgres://secure.db.example.com/mydb",
237    ///     config,
238    ///     tls
239    /// ).await?;
240    /// # Ok(())
241    /// # }
242    /// ```
243    pub async fn connect_with_config_and_tls(
244        connection_string: &str,
245        config: ConnectionConfig,
246        tls_config: crate::connection::TlsConfig,
247    ) -> Result<Self> {
248        let info = ConnectionInfo::parse(connection_string)?;
249
250        let transport = match info.transport {
251            TransportType::Tcp => {
252                let host = info.host.as_ref().ok_or_else(|| {
253                    crate::WireError::Config("TCP transport requires a host".into())
254                })?;
255                let port = info.port.ok_or_else(|| {
256                    crate::WireError::Config("TCP transport requires a port".into())
257                })?;
258                Transport::connect_tcp_tls(host, port, &tls_config).await?
259            }
260            TransportType::Unix => {
261                return Err(crate::WireError::Config(
262                    "TLS is only supported for TCP connections".into(),
263                ));
264            }
265        };
266
267        // Apply TCP keepalive when configured.
268        if let Some(idle) = config.keepalive_idle {
269            if let Err(e) = transport.apply_keepalive(idle) {
270                tracing::warn!("Failed to apply TCP keepalive (idle={idle:?}): {e}");
271            }
272        }
273
274        let mut conn = Connection::new(transport);
275        conn.startup(&config).await?;
276
277        Ok(Self { conn })
278    }
279
280    /// Start building a query for an entity with automatic deserialization
281    ///
282    /// The type parameter T controls consumer-side deserialization only.
283    /// Type T does NOT affect SQL generation, filtering, ordering, or wire protocol.
284    ///
285    /// # Examples
286    ///
287    /// Type-safe query (recommended):
288    /// ```no_run
289    /// // Requires: live Postgres server.
290    /// # async fn example(client: fraiseql_wire::FraiseClient) -> fraiseql_wire::Result<()> {
291    /// use serde::Deserialize;
292    /// use futures::stream::StreamExt;
293    ///
294    /// #[derive(Deserialize)]
295    /// struct User {
296    ///     id: String,
297    ///     name: String,
298    /// }
299    ///
300    /// let mut stream = client
301    ///     .query::<User>("user")
302    ///     .where_sql("data->>'type' = 'customer'")  // SQL predicate
303    ///     .where_rust(|json| {
304    ///         // Rust predicate (applied client-side, on JSON)
305    ///         json["estimated_value"].as_f64().unwrap_or(0.0) > 1000.0
306    ///     })
307    ///     .order_by("data->>'name' ASC")
308    ///     .execute()
309    ///     .await?;
310    ///
311    /// while let Some(result) = stream.next().await {
312    ///     let user: User = result?;
313    ///     println!("User: {}", user.name);
314    /// }
315    /// # Ok(())
316    /// # }
317    /// ```
318    ///
319    /// Raw JSON query (debugging, forward compatibility):
320    /// ```no_run
321    /// // Requires: live Postgres server.
322    /// # async fn example(client: fraiseql_wire::FraiseClient) -> fraiseql_wire::Result<()> {
323    /// use futures::stream::StreamExt;
324    ///
325    /// let mut stream = client
326    ///     .query::<serde_json::Value>("user")  // Escape hatch
327    ///     .execute()
328    ///     .await?;
329    ///
330    /// while let Some(result) = stream.next().await {
331    ///     let json = result?;
332    ///     println!("JSON: {:?}", json);
333    /// }
334    /// # Ok(())
335    /// # }
336    /// ```
337    pub fn query<T: DeserializeOwned + std::marker::Unpin + 'static>(
338        self,
339        entity: impl Into<String>,
340    ) -> QueryBuilder<T> {
341        QueryBuilder::new(self, entity)
342    }
343
344    /// Execute a raw SQL query (must match fraiseql-wire constraints)
345    pub(crate) async fn execute_query(
346        self,
347        sql: &str,
348        chunk_size: usize,
349        max_memory: Option<usize>,
350        soft_limit_warn_threshold: Option<f32>,
351        soft_limit_fail_threshold: Option<f32>,
352    ) -> Result<JsonStream> {
353        self.conn
354            .streaming_query(
355                sql,
356                chunk_size,
357                max_memory,
358                soft_limit_warn_threshold,
359                soft_limit_fail_threshold,
360                false, // enable_adaptive_chunking: disabled by default for backward compatibility
361                None,  // adaptive_min_chunk_size
362                None,  // adaptive_max_chunk_size
363            )
364            .await
365    }
366}