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}