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 with_connect_timeout(config.connect_timeout, Transport::connect_tcp(host, port))
182 .await?
183 }
184 TransportType::Unix => {
185 let path = info.unix_socket.as_ref().ok_or_else(|| {
186 crate::WireError::Config("Unix transport requires a socket path".into())
187 })?;
188 with_connect_timeout(config.connect_timeout, Transport::connect_unix(path)).await?
189 }
190 };
191
192 // Apply TCP keepalive when configured.
193 if let Some(idle) = config.keepalive_idle {
194 if let Err(e) = transport.apply_keepalive(idle) {
195 tracing::warn!("Failed to apply TCP keepalive (idle={idle:?}): {e}");
196 }
197 }
198
199 let mut conn = Connection::new(transport);
200 conn.startup(&config).await?;
201
202 Ok(Self { conn })
203 }
204
205 /// Connect to Postgres with both custom configuration and TLS encryption
206 ///
207 /// This method combines connection configuration (timeouts, keepalive, etc.)
208 /// with TLS encryption for secure connections with advanced options.
209 ///
210 /// # Errors
211 ///
212 /// Returns [`WireError::Config`] if the connection string is invalid, TLS is requested
213 /// over a Unix socket, or required fields are missing. Returns [`WireError::Io`] if the
214 /// TLS handshake or TCP connection fails.
215 ///
216 /// # Examples
217 ///
218 /// ```no_run
219 /// // Requires: live Postgres server with TLS.
220 /// # async fn example() -> fraiseql_wire::Result<()> {
221 /// use fraiseql_wire::{FraiseClient, connection::{ConnectionConfig, TlsConfig}};
222 /// use std::time::Duration;
223 ///
224 /// // Configure connection with timeouts
225 /// let config = ConnectionConfig::builder("localhost", "mydb")
226 /// .password("secret")
227 /// .statement_timeout(Duration::from_secs(30))
228 /// .build();
229 ///
230 /// // Configure TLS
231 /// let tls = TlsConfig::builder()
232 /// .verify_hostname(true)
233 /// .build()?;
234 ///
235 /// // Connect with both configuration and TLS
236 /// let client = FraiseClient::connect_with_config_and_tls(
237 /// "postgres://secure.db.example.com/mydb",
238 /// config,
239 /// tls
240 /// ).await?;
241 /// # Ok(())
242 /// # }
243 /// ```
244 pub async fn connect_with_config_and_tls(
245 connection_string: &str,
246 config: ConnectionConfig,
247 tls_config: crate::connection::TlsConfig,
248 ) -> Result<Self> {
249 let info = ConnectionInfo::parse(connection_string)?;
250
251 let transport = match info.transport {
252 TransportType::Tcp => {
253 let host = info.host.as_ref().ok_or_else(|| {
254 crate::WireError::Config("TCP transport requires a host".into())
255 })?;
256 let port = info.port.ok_or_else(|| {
257 crate::WireError::Config("TCP transport requires a port".into())
258 })?;
259 with_connect_timeout(
260 config.connect_timeout,
261 Transport::connect_tcp_tls(host, port, &tls_config),
262 )
263 .await?
264 }
265 TransportType::Unix => {
266 return Err(crate::WireError::Config(
267 "TLS is only supported for TCP connections".into(),
268 ));
269 }
270 };
271
272 // Apply TCP keepalive when configured.
273 if let Some(idle) = config.keepalive_idle {
274 if let Err(e) = transport.apply_keepalive(idle) {
275 tracing::warn!("Failed to apply TCP keepalive (idle={idle:?}): {e}");
276 }
277 }
278
279 let mut conn = Connection::new(transport);
280 conn.startup(&config).await?;
281
282 Ok(Self { conn })
283 }
284
285 /// Start building a query for an entity with automatic deserialization
286 ///
287 /// The type parameter T controls consumer-side deserialization only.
288 /// Type T does NOT affect SQL generation, filtering, ordering, or wire protocol.
289 ///
290 /// # Examples
291 ///
292 /// Type-safe query (recommended):
293 /// ```no_run
294 /// // Requires: live Postgres server.
295 /// # async fn example(client: fraiseql_wire::FraiseClient) -> fraiseql_wire::Result<()> {
296 /// use serde::Deserialize;
297 /// use futures::stream::StreamExt;
298 ///
299 /// #[derive(Deserialize)]
300 /// struct User {
301 /// id: String,
302 /// name: String,
303 /// }
304 ///
305 /// let mut stream = client
306 /// .query::<User>("user")
307 /// .where_sql("data->>'type' = 'customer'") // SQL predicate
308 /// .where_rust(|json| {
309 /// // Rust predicate (applied client-side, on JSON)
310 /// json["estimated_value"].as_f64().unwrap_or(0.0) > 1000.0
311 /// })
312 /// .order_by("data->>'name' ASC")
313 /// .execute()
314 /// .await?;
315 ///
316 /// while let Some(result) = stream.next().await {
317 /// let user: User = result?;
318 /// println!("User: {}", user.name);
319 /// }
320 /// # Ok(())
321 /// # }
322 /// ```
323 ///
324 /// Raw JSON query (debugging, forward compatibility):
325 /// ```no_run
326 /// // Requires: live Postgres server.
327 /// # async fn example(client: fraiseql_wire::FraiseClient) -> fraiseql_wire::Result<()> {
328 /// use futures::stream::StreamExt;
329 ///
330 /// let mut stream = client
331 /// .query::<serde_json::Value>("user") // Escape hatch
332 /// .execute()
333 /// .await?;
334 ///
335 /// while let Some(result) = stream.next().await {
336 /// let json = result?;
337 /// println!("JSON: {:?}", json);
338 /// }
339 /// # Ok(())
340 /// # }
341 /// ```
342 pub fn query<T: DeserializeOwned + std::marker::Unpin + 'static>(
343 self,
344 entity: impl Into<String>,
345 ) -> QueryBuilder<T> {
346 QueryBuilder::new(self, entity)
347 }
348
349 /// Execute a raw SQL query (must match fraiseql-wire constraints)
350 ///
351 /// The adaptive-chunking options are threaded through from the query builder
352 /// instead of being hardcoded off, so `adaptive_chunking`/`adaptive_min_size`/
353 /// `adaptive_max_size` actually take effect (audit L-wire-builder).
354 #[allow(clippy::too_many_arguments)] // Reason: mirrors streaming_query's chunking parameters; a struct would add allocation in the hot path
355 pub(crate) async fn execute_query(
356 self,
357 sql: &str,
358 chunk_size: usize,
359 max_memory: Option<usize>,
360 soft_limit_warn_threshold: Option<f32>,
361 soft_limit_fail_threshold: Option<f32>,
362 enable_adaptive_chunking: bool,
363 adaptive_min_chunk_size: Option<usize>,
364 adaptive_max_chunk_size: Option<usize>,
365 ) -> Result<JsonStream> {
366 self.conn
367 .streaming_query(
368 sql,
369 chunk_size,
370 max_memory,
371 soft_limit_warn_threshold,
372 soft_limit_fail_threshold,
373 enable_adaptive_chunking,
374 adaptive_min_chunk_size,
375 adaptive_max_chunk_size,
376 )
377 .await
378 }
379}
380
381/// Apply an optional connect timeout to a transport-connect future.
382///
383/// When `timeout` is `Some`, the future is bounded by [`tokio::time::timeout`] and a
384/// lapse surfaces as [`crate::WireError::Connection`]; when `None` the future runs
385/// to completion unbounded. The `connect_timeout` config field was parsed but never
386/// applied to the connect path (audit L-wire-timeout); the `connect_with_config*`
387/// methods now route their transport setup through this helper.
388async fn with_connect_timeout<F, T>(timeout: Option<std::time::Duration>, fut: F) -> Result<T>
389where
390 F: std::future::Future<Output = Result<T>>,
391{
392 match timeout {
393 Some(d) => match tokio::time::timeout(d, fut).await {
394 Ok(result) => result,
395 Err(_) => Err(crate::WireError::Connection(format!(
396 "connection timed out after {d:?}"
397 ))),
398 },
399 None => fut.await,
400 }
401}
402
403#[cfg(test)]
404mod tests;