quack-ruby 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/LICENSE-DuckDB +7 -0
  4. data/LICENSE-QuackProtocol +21 -0
  5. data/LICENSE-Rust-dependencies.json +2057 -0
  6. data/README.md +229 -0
  7. data/ext/quack/Cargo.lock +1803 -0
  8. data/ext/quack/Cargo.toml +17 -0
  9. data/ext/quack/extconf.rb +4 -0
  10. data/ext/quack/src/lib.rs +647 -0
  11. data/ext/quack/vendor/quack_protocol/Cargo.lock +1907 -0
  12. data/ext/quack/vendor/quack_protocol/Cargo.toml +41 -0
  13. data/ext/quack/vendor/quack_protocol/LICENSE +21 -0
  14. data/ext/quack/vendor/quack_protocol/PATCHES.md +16 -0
  15. data/ext/quack/vendor/quack_protocol/README.md +272 -0
  16. data/ext/quack/vendor/quack_protocol/src/arrow/columns.rs +399 -0
  17. data/ext/quack/vendor/quack_protocol/src/arrow/mod.rs +134 -0
  18. data/ext/quack/vendor/quack_protocol/src/arrow/schema.rs +103 -0
  19. data/ext/quack/vendor/quack_protocol/src/arrow/tests.rs +851 -0
  20. data/ext/quack/vendor/quack_protocol/src/arrow/values.rs +150 -0
  21. data/ext/quack/vendor/quack_protocol/src/binary.rs +504 -0
  22. data/ext/quack/vendor/quack_protocol/src/builders.rs +217 -0
  23. data/ext/quack/vendor/quack_protocol/src/client.rs +1486 -0
  24. data/ext/quack/vendor/quack_protocol/src/constants.rs +11 -0
  25. data/ext/quack/vendor/quack_protocol/src/errors.rs +103 -0
  26. data/ext/quack/vendor/quack_protocol/src/inspection.rs +250 -0
  27. data/ext/quack/vendor/quack_protocol/src/json.rs +225 -0
  28. data/ext/quack/vendor/quack_protocol/src/lib.rs +54 -0
  29. data/ext/quack/vendor/quack_protocol/src/logical_types.rs +977 -0
  30. data/ext/quack/vendor/quack_protocol/src/messages.rs +843 -0
  31. data/ext/quack/vendor/quack_protocol/src/pool.rs +741 -0
  32. data/ext/quack/vendor/quack_protocol/src/protocol_tests.rs +759 -0
  33. data/ext/quack/vendor/quack_protocol/src/proxy_error.rs +77 -0
  34. data/ext/quack/vendor/quack_protocol/src/sql.rs +359 -0
  35. data/ext/quack/vendor/quack_protocol/src/tls.rs +354 -0
  36. data/ext/quack/vendor/quack_protocol/src/values.rs +77 -0
  37. data/ext/quack/vendor/quack_protocol/src/vector.rs +1364 -0
  38. data/ext/quack/vendor/quack_protocol/testdata/tls/key.der +0 -0
  39. data/ext/quack/vendor/quack_protocol/testdata/tls/spki.der +0 -0
  40. data/ext/quack/vendor/quack_protocol/testdata/tls/v1-cert.der +0 -0
  41. data/ext/quack/vendor/quack_protocol/testdata/tls/v3-cert.der +0 -0
  42. data/lib/quack/codec.rb +263 -0
  43. data/lib/quack/connection.rb +131 -0
  44. data/lib/quack/logical_types.rb +72 -0
  45. data/lib/quack/result.rb +64 -0
  46. data/lib/quack/sql.rb +183 -0
  47. data/lib/quack/types.rb +40 -0
  48. data/lib/quack/variant.rb +298 -0
  49. data/lib/quack.rb +34 -0
  50. metadata +136 -0
@@ -0,0 +1,77 @@
1
+ //! Stable HTTP rejection categories for admission-aware Quack proxies.
2
+
3
+ /// A typed proxy rejection. It never retains an upstream response body.
4
+ #[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
5
+ pub enum ProxyError {
6
+ #[error("worker at capacity")]
7
+ AtCapacity,
8
+ #[error("worker assignment changed")]
9
+ AssignmentMismatch,
10
+ #[error("worker admission is closed")]
11
+ GateClosed,
12
+ #[error("connection is not admitted")]
13
+ InvalidConnection,
14
+ #[error("connection has an operation in progress")]
15
+ ConnectionBusy,
16
+ #[error("controller authentication required")]
17
+ Unauthorized,
18
+ #[error("worker is unsafe")]
19
+ WorkerUnsafe,
20
+ #[error("worker recycling required")]
21
+ RecycleRequired,
22
+ #[error("query failed")]
23
+ QueryFailed,
24
+ #[error("invalid request")]
25
+ InvalidRequest,
26
+ #[error("request too large")]
27
+ RequestTooLarge,
28
+ }
29
+
30
+ impl ProxyError {
31
+ /// Recognizes a bounded structured failure only when its HTTP status agrees.
32
+ /// Unknown failures remain generic transport/protocol errors, never successes.
33
+ pub fn decode(status: u16, body: &[u8]) -> Option<Self> {
34
+ if body.len() > 16 * 1024 {
35
+ return None;
36
+ }
37
+ let value: serde_json::Value = serde_json::from_slice(body).ok()?;
38
+ let code = value.get("error")?.get("code")?.as_str()?;
39
+ Some(match (status, code) {
40
+ (429, "at_capacity") => Self::AtCapacity,
41
+ (409, "assignment_mismatch") => Self::AssignmentMismatch,
42
+ (503, "gate_closed") => Self::GateClosed,
43
+ (409, "invalid_connection") => Self::InvalidConnection,
44
+ (409, "connection_busy") => Self::ConnectionBusy,
45
+ (401, "unauthorized") => Self::Unauthorized,
46
+ (503, "worker_unsafe") => Self::WorkerUnsafe,
47
+ (503, "recycle_required") => Self::RecycleRequired,
48
+ (400, "query_failed") => Self::QueryFailed,
49
+ (400, "invalid_request") => Self::InvalidRequest,
50
+ (413, "request_too_large") => Self::RequestTooLarge,
51
+ _ => return None,
52
+ })
53
+ }
54
+ }
55
+
56
+ #[cfg(test)]
57
+ mod tests {
58
+ use super::*;
59
+
60
+ #[test]
61
+ fn rejects_unknown_status_code_pairs_and_discards_message_text() {
62
+ for (status, code, expected) in [
63
+ (429, "at_capacity", ProxyError::AtCapacity),
64
+ (409, "assignment_mismatch", ProxyError::AssignmentMismatch),
65
+ (503, "gate_closed", ProxyError::GateClosed),
66
+ (409, "invalid_connection", ProxyError::InvalidConnection),
67
+ (401, "unauthorized", ProxyError::Unauthorized),
68
+ ] {
69
+ let bytes = serde_json::to_vec(&serde_json::json!({"error": {"code": code, "message": "private SQL and credentials"}})).unwrap();
70
+ let actual = ProxyError::decode(status, &bytes).unwrap();
71
+ assert_eq!(actual, expected);
72
+ assert!(!actual.to_string().contains("private"));
73
+ assert_eq!(ProxyError::decode(200, &bytes), None);
74
+ }
75
+ assert_eq!(ProxyError::decode(429, &[b'x'; 16385]), None);
76
+ }
77
+ }
@@ -0,0 +1,359 @@
1
+ use crate::errors::{QuackError, Result};
2
+ use crate::vector::{DateValue, TimeUnit, TimestampUnit, Value, decimal_to_string};
3
+
4
+ #[derive(Clone, Debug, PartialEq)]
5
+ pub enum SqlParameter {
6
+ Value(Value),
7
+ List(Vec<SqlParameter>),
8
+ }
9
+
10
+ impl From<Value> for SqlParameter {
11
+ fn from(value: Value) -> Self {
12
+ Self::Value(value)
13
+ }
14
+ }
15
+
16
+ impl From<&str> for SqlParameter {
17
+ fn from(value: &str) -> Self {
18
+ Self::Value(Value::String(value.to_string()))
19
+ }
20
+ }
21
+
22
+ impl From<String> for SqlParameter {
23
+ fn from(value: String) -> Self {
24
+ Self::Value(Value::String(value))
25
+ }
26
+ }
27
+
28
+ impl From<i32> for SqlParameter {
29
+ fn from(value: i32) -> Self {
30
+ Self::Value(Value::Int(value as i64))
31
+ }
32
+ }
33
+
34
+ impl From<i64> for SqlParameter {
35
+ fn from(value: i64) -> Self {
36
+ Self::Value(Value::Int(value))
37
+ }
38
+ }
39
+
40
+ impl From<u64> for SqlParameter {
41
+ fn from(value: u64) -> Self {
42
+ Self::Value(Value::UInt(value))
43
+ }
44
+ }
45
+
46
+ impl From<bool> for SqlParameter {
47
+ fn from(value: bool) -> Self {
48
+ Self::Value(Value::Bool(value))
49
+ }
50
+ }
51
+
52
+ #[derive(Clone, Debug, PartialEq)]
53
+ pub enum SqlParameters {
54
+ Positional(Vec<SqlParameter>),
55
+ Named(indexmap::IndexMap<String, SqlParameter>),
56
+ }
57
+
58
+ pub fn format_sql(sql: &str, params: Option<&SqlParameters>) -> Result<String> {
59
+ match params {
60
+ None => Ok(sql.to_string()),
61
+ Some(SqlParameters::Positional(params)) => format_positional_sql(sql, params),
62
+ Some(SqlParameters::Named(params)) => format_named_sql(sql, params),
63
+ }
64
+ }
65
+
66
+ pub(crate) struct QuerySql {
67
+ raw: String,
68
+ preview: String,
69
+ }
70
+
71
+ impl QuerySql {
72
+ const HEAD_LEN: usize = 10;
73
+ const TAIL_LEN: usize = 10;
74
+
75
+ pub(crate) fn new(raw: String) -> Self {
76
+ let collapsed = raw.split_whitespace().collect::<Vec<_>>().join(" ");
77
+ let chars: Vec<char> = collapsed.chars().collect();
78
+
79
+ let preview = if chars.len() <= Self::HEAD_LEN + Self::TAIL_LEN {
80
+ collapsed
81
+ } else {
82
+ format!(
83
+ "{}...{}",
84
+ chars[..Self::HEAD_LEN].iter().collect::<String>(),
85
+ chars[chars.len() - Self::TAIL_LEN..]
86
+ .iter()
87
+ .collect::<String>()
88
+ )
89
+ };
90
+
91
+ Self { raw, preview }
92
+ }
93
+
94
+ pub(crate) fn as_str(&self) -> &str {
95
+ &self.raw
96
+ }
97
+ }
98
+
99
+ impl std::fmt::Display for QuerySql {
100
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101
+ f.write_str(&self.preview)
102
+ }
103
+ }
104
+
105
+ pub fn sql_literal(value: &SqlParameter) -> Result<String> {
106
+ match value {
107
+ SqlParameter::List(values) => Ok(format!(
108
+ "[{}]",
109
+ values
110
+ .iter()
111
+ .map(sql_literal)
112
+ .collect::<Result<Vec<_>>>()?
113
+ .join(", ")
114
+ )),
115
+ SqlParameter::Value(value) => value_literal(value),
116
+ }
117
+ }
118
+
119
+ fn value_literal(value: &Value) -> Result<String> {
120
+ Ok(match value {
121
+ Value::Null => "NULL".to_string(),
122
+ Value::Bool(value) => if *value { "TRUE" } else { "FALSE" }.to_string(),
123
+ Value::Int(value) => value.to_string(),
124
+ Value::UInt(value) => value.to_string(),
125
+ Value::HugeInt(value) => value.to_string(),
126
+ Value::UHugeInt(value) => value.to_string(),
127
+ Value::Float(value) if value.is_finite() => value.to_string(),
128
+ Value::Double(value) if value.is_finite() => value.to_string(),
129
+ Value::Float(value) => {
130
+ return Err(QuackError::protocol(format!(
131
+ "cannot encode non-finite SQL number {value}"
132
+ )));
133
+ }
134
+ Value::Double(value) => {
135
+ return Err(QuackError::protocol(format!(
136
+ "cannot encode non-finite SQL number {value}"
137
+ )));
138
+ }
139
+ Value::String(value) => format!("'{}'", value.replace('\'', "''")),
140
+ Value::Bytes(value) => format!("from_hex('{}')", bytes_to_hex(value)),
141
+ Value::Decimal(value) => decimal_to_string(value),
142
+ Value::Date(value) => format!("DATE '{}'", date_from_days(*value)),
143
+ Value::Time(value) => format!("TIME '{}'", format_time(value.value, value.unit)),
144
+ Value::TimeTz(_) => {
145
+ return Err(QuackError::protocol(
146
+ "TIME WITH TIME ZONE parameters are not supported as SQL literals",
147
+ ));
148
+ }
149
+ Value::Timestamp(value) => {
150
+ let keyword = if value.timezone_utc {
151
+ "TIMESTAMPTZ"
152
+ } else {
153
+ "TIMESTAMP"
154
+ };
155
+ format!(
156
+ "{keyword} '{}'",
157
+ format_timestamp(value.value, value.unit, value.timezone_utc)
158
+ )
159
+ }
160
+ Value::Interval(value) => format!(
161
+ "INTERVAL '{} months {} days {} microseconds'",
162
+ value.months, value.days, value.micros
163
+ ),
164
+ Value::List(_) | Value::Struct(_) => {
165
+ return Err(QuackError::protocol(
166
+ "object SQL parameters are not supported; pass scalar values or SqlParameter::List",
167
+ ));
168
+ }
169
+ })
170
+ }
171
+
172
+ fn format_positional_sql(sql: &str, params: &[SqlParameter]) -> Result<String> {
173
+ let mut index = 0usize;
174
+ let formatted = scan_sql(sql, |token| {
175
+ if token != "?" {
176
+ return Ok(token.to_string());
177
+ }
178
+ let value = params.get(index).ok_or_else(|| {
179
+ QuackError::protocol("SQL has more positional placeholders than parameters")
180
+ })?;
181
+ index += 1;
182
+ sql_literal(value)
183
+ })?;
184
+ if index != params.len() {
185
+ return Err(QuackError::protocol(format!(
186
+ "SQL has {index} positional placeholders but {} parameters were provided",
187
+ params.len()
188
+ )));
189
+ }
190
+ Ok(formatted)
191
+ }
192
+
193
+ fn format_named_sql(
194
+ sql: &str,
195
+ params: &indexmap::IndexMap<String, SqlParameter>,
196
+ ) -> Result<String> {
197
+ scan_sql(sql, |token| {
198
+ if !token.starts_with(':') {
199
+ return Ok(token.to_string());
200
+ }
201
+ let name = &token[1..];
202
+ let value = params
203
+ .get(name)
204
+ .ok_or_else(|| QuackError::protocol(format!("missing SQL parameter :{name}")))?;
205
+ sql_literal(value)
206
+ })
207
+ }
208
+
209
+ fn scan_sql(sql: &str, mut replace_token: impl FnMut(&str) -> Result<String>) -> Result<String> {
210
+ let bytes = sql.as_bytes();
211
+ let mut output = String::new();
212
+ let mut index = 0usize;
213
+ while index < bytes.len() {
214
+ let ch = bytes[index] as char;
215
+ let next = bytes.get(index + 1).copied().map(char::from);
216
+ if ch == '\'' {
217
+ let end = read_single_quoted(sql, index);
218
+ output.push_str(&sql[index..end]);
219
+ index = end;
220
+ continue;
221
+ }
222
+ if ch == '"' {
223
+ let end = read_double_quoted(sql, index);
224
+ output.push_str(&sql[index..end]);
225
+ index = end;
226
+ continue;
227
+ }
228
+ if ch == '-' && next == Some('-') {
229
+ let end = sql[index + 2..]
230
+ .find('\n')
231
+ .map(|relative| index + 2 + relative)
232
+ .unwrap_or(sql.len());
233
+ output.push_str(&sql[index..end]);
234
+ index = end;
235
+ continue;
236
+ }
237
+ if ch == '/' && next == Some('*') {
238
+ let end = sql[index + 2..]
239
+ .find("*/")
240
+ .map(|relative| index + 2 + relative + 2)
241
+ .unwrap_or(sql.len());
242
+ output.push_str(&sql[index..end]);
243
+ index = end;
244
+ continue;
245
+ }
246
+ if ch == '?' {
247
+ output.push_str(&replace_token("?")?);
248
+ index += 1;
249
+ continue;
250
+ }
251
+ if ch == ':'
252
+ && index.checked_sub(1).and_then(|i| bytes.get(i)).copied() != Some(b':')
253
+ && next != Some(':')
254
+ && next.is_some_and(is_identifier_start)
255
+ {
256
+ let start = index + 1;
257
+ let mut end = start + 1;
258
+ while end < bytes.len() && is_identifier_part(bytes[end] as char) {
259
+ end += 1;
260
+ }
261
+ output.push_str(&replace_token(&sql[index..end])?);
262
+ index = end;
263
+ continue;
264
+ }
265
+ output.push(ch);
266
+ index += 1;
267
+ }
268
+ Ok(output)
269
+ }
270
+
271
+ fn read_single_quoted(sql: &str, start: usize) -> usize {
272
+ let bytes = sql.as_bytes();
273
+ let mut index = start + 1;
274
+ while index < bytes.len() {
275
+ if bytes[index] == b'\'' && bytes.get(index + 1) == Some(&b'\'') {
276
+ index += 2;
277
+ continue;
278
+ }
279
+ if bytes[index] == b'\'' {
280
+ return index + 1;
281
+ }
282
+ index += 1;
283
+ }
284
+ index
285
+ }
286
+
287
+ fn read_double_quoted(sql: &str, start: usize) -> usize {
288
+ let bytes = sql.as_bytes();
289
+ let mut index = start + 1;
290
+ while index < bytes.len() {
291
+ if bytes[index] == b'"' && bytes.get(index + 1) == Some(&b'"') {
292
+ index += 2;
293
+ continue;
294
+ }
295
+ if bytes[index] == b'"' {
296
+ return index + 1;
297
+ }
298
+ index += 1;
299
+ }
300
+ index
301
+ }
302
+
303
+ fn is_identifier_start(ch: char) -> bool {
304
+ ch.is_ascii_alphabetic() || ch == '_'
305
+ }
306
+
307
+ fn is_identifier_part(ch: char) -> bool {
308
+ ch.is_ascii_alphanumeric() || ch == '_'
309
+ }
310
+
311
+ fn bytes_to_hex(bytes: &[u8]) -> String {
312
+ bytes.iter().map(|byte| format!("{byte:02x}")).collect()
313
+ }
314
+
315
+ fn date_from_days(value: DateValue) -> String {
316
+ let epoch = chrono::NaiveDate::from_ymd_opt(1970, 1, 1).expect("valid epoch");
317
+ (epoch + chrono::Duration::days(value.days as i64))
318
+ .format("%Y-%m-%d")
319
+ .to_string()
320
+ }
321
+
322
+ fn format_timestamp(value: i64, unit: TimestampUnit, timezone_utc: bool) -> String {
323
+ let micros = match unit {
324
+ TimestampUnit::Seconds => value * 1_000_000,
325
+ TimestampUnit::Millis => value * 1_000,
326
+ TimestampUnit::Micros => value,
327
+ TimestampUnit::Nanos => value / 1_000,
328
+ };
329
+ let seconds = micros.div_euclid(1_000_000);
330
+ let micro_fraction = micros.rem_euclid(1_000_000);
331
+ let date = chrono::DateTime::from_timestamp(seconds, (micro_fraction as u32) * 1_000)
332
+ .expect("timestamp in range");
333
+ let suffix = if timezone_utc { "+00" } else { "" };
334
+ format!(
335
+ "{}.{:03}{suffix}",
336
+ date.format("%Y-%m-%d %H:%M:%S"),
337
+ micro_fraction / 1_000
338
+ )
339
+ }
340
+
341
+ fn format_time(value: i64, unit: TimeUnit) -> String {
342
+ let nanos = match unit {
343
+ TimeUnit::Micros => value as i128 * 1_000,
344
+ TimeUnit::Nanos => value as i128,
345
+ };
346
+ let total_seconds = nanos / 1_000_000_000;
347
+ let fraction = nanos % 1_000_000_000;
348
+ let hours = total_seconds / 3_600;
349
+ let minutes = (total_seconds % 3_600) / 60;
350
+ let seconds = total_seconds % 60;
351
+ let fraction_text = if fraction == 0 {
352
+ String::new()
353
+ } else {
354
+ format!(".{:09}", fraction)
355
+ .trim_end_matches('0')
356
+ .to_string()
357
+ };
358
+ format!("{hours:02}:{minutes:02}:{seconds:02}{fraction_text}")
359
+ }