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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/LICENSE-DuckDB +7 -0
- data/LICENSE-QuackProtocol +21 -0
- data/LICENSE-Rust-dependencies.json +2057 -0
- data/README.md +229 -0
- data/ext/quack/Cargo.lock +1803 -0
- data/ext/quack/Cargo.toml +17 -0
- data/ext/quack/extconf.rb +4 -0
- data/ext/quack/src/lib.rs +647 -0
- data/ext/quack/vendor/quack_protocol/Cargo.lock +1907 -0
- data/ext/quack/vendor/quack_protocol/Cargo.toml +41 -0
- data/ext/quack/vendor/quack_protocol/LICENSE +21 -0
- data/ext/quack/vendor/quack_protocol/PATCHES.md +16 -0
- data/ext/quack/vendor/quack_protocol/README.md +272 -0
- data/ext/quack/vendor/quack_protocol/src/arrow/columns.rs +399 -0
- data/ext/quack/vendor/quack_protocol/src/arrow/mod.rs +134 -0
- data/ext/quack/vendor/quack_protocol/src/arrow/schema.rs +103 -0
- data/ext/quack/vendor/quack_protocol/src/arrow/tests.rs +851 -0
- data/ext/quack/vendor/quack_protocol/src/arrow/values.rs +150 -0
- data/ext/quack/vendor/quack_protocol/src/binary.rs +504 -0
- data/ext/quack/vendor/quack_protocol/src/builders.rs +217 -0
- data/ext/quack/vendor/quack_protocol/src/client.rs +1486 -0
- data/ext/quack/vendor/quack_protocol/src/constants.rs +11 -0
- data/ext/quack/vendor/quack_protocol/src/errors.rs +103 -0
- data/ext/quack/vendor/quack_protocol/src/inspection.rs +250 -0
- data/ext/quack/vendor/quack_protocol/src/json.rs +225 -0
- data/ext/quack/vendor/quack_protocol/src/lib.rs +54 -0
- data/ext/quack/vendor/quack_protocol/src/logical_types.rs +977 -0
- data/ext/quack/vendor/quack_protocol/src/messages.rs +843 -0
- data/ext/quack/vendor/quack_protocol/src/pool.rs +741 -0
- data/ext/quack/vendor/quack_protocol/src/protocol_tests.rs +759 -0
- data/ext/quack/vendor/quack_protocol/src/proxy_error.rs +77 -0
- data/ext/quack/vendor/quack_protocol/src/sql.rs +359 -0
- data/ext/quack/vendor/quack_protocol/src/tls.rs +354 -0
- data/ext/quack/vendor/quack_protocol/src/values.rs +77 -0
- data/ext/quack/vendor/quack_protocol/src/vector.rs +1364 -0
- data/ext/quack/vendor/quack_protocol/testdata/tls/key.der +0 -0
- data/ext/quack/vendor/quack_protocol/testdata/tls/spki.der +0 -0
- data/ext/quack/vendor/quack_protocol/testdata/tls/v1-cert.der +0 -0
- data/ext/quack/vendor/quack_protocol/testdata/tls/v3-cert.der +0 -0
- data/lib/quack/codec.rb +263 -0
- data/lib/quack/connection.rb +131 -0
- data/lib/quack/logical_types.rb +72 -0
- data/lib/quack/result.rb +64 -0
- data/lib/quack/sql.rb +183 -0
- data/lib/quack/types.rb +40 -0
- data/lib/quack/variant.rb +298 -0
- data/lib/quack.rb +34 -0
- metadata +136 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
pub(crate) const QUACK_V1: u64 = 1;
|
|
2
|
+
pub(crate) const QUACK_V3: u64 = 3;
|
|
3
|
+
pub(crate) const MIN_QUACK_VERSION: u64 = QUACK_V1;
|
|
4
|
+
pub(crate) const MAX_QUACK_VERSION: u64 = QUACK_V3;
|
|
5
|
+
pub(crate) const DEFAULT_QUACK_PORT: u16 = 9494;
|
|
6
|
+
pub(crate) const QUACK_ENDPOINT: &str = "/quack";
|
|
7
|
+
pub(crate) const DUCKDB_MIME_TYPE: &str = "application/duckdb";
|
|
8
|
+
pub(crate) const FIELD_END: u16 = 0xffff;
|
|
9
|
+
pub(crate) const OPTIONAL_INDEX_INVALID: u64 = u64::MAX;
|
|
10
|
+
pub(crate) const DEFAULT_HEARTBEAT_TIMEOUT_SECS: u64 = 60;
|
|
11
|
+
pub(crate) const MAX_HEARTBEAT_TIMEOUT_SECS: u64 = 300;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
pub type Result<T> = std::result::Result<T, QuackError>;
|
|
2
|
+
|
|
3
|
+
// The current protocol has no typed error code. Exact comparison keeps this
|
|
4
|
+
// retirement heuristic narrower than arbitrary SQL error text containing one
|
|
5
|
+
// of these server messages. It must never be used to authorize replay.
|
|
6
|
+
const RETIRE_CONNECTION_MESSAGES: [&str; 2] = [
|
|
7
|
+
"Invalid connection id",
|
|
8
|
+
"Connection does not exist / already disconnected",
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
#[derive(Debug, thiserror::Error)]
|
|
12
|
+
pub enum QuackError {
|
|
13
|
+
#[error(transparent)]
|
|
14
|
+
Proxy(#[from] crate::ProxyError),
|
|
15
|
+
#[error("quack protocol error: {0}")]
|
|
16
|
+
Protocol(String),
|
|
17
|
+
#[error("quack server error: {0}")]
|
|
18
|
+
Server(String),
|
|
19
|
+
#[error("unsupported quack type: {0}")]
|
|
20
|
+
UnsupportedType(String),
|
|
21
|
+
#[error("http error: {0}")]
|
|
22
|
+
Http(#[from] reqwest::Error),
|
|
23
|
+
#[error("url parse error: {0}")]
|
|
24
|
+
Url(#[from] url::ParseError),
|
|
25
|
+
#[error("utf-8 error: {0}")]
|
|
26
|
+
Utf8(#[from] std::string::FromUtf8Error),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
impl QuackError {
|
|
30
|
+
pub(crate) fn protocol(message: impl Into<String>) -> Self {
|
|
31
|
+
Self::Protocol(message.into())
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
pub(crate) fn server(message: impl Into<String>) -> Self {
|
|
35
|
+
Self::Server(message.into())
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
pub(crate) fn unsupported(message: impl Into<String>) -> Self {
|
|
39
|
+
Self::UnsupportedType(message.into())
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// The connection that produced this error should not be reused.
|
|
43
|
+
///
|
|
44
|
+
/// Transport and framing failures leave no way to tell what the server did
|
|
45
|
+
/// with the request, so a pool retires the connection rather than hand it
|
|
46
|
+
/// to the next caller. Errors raised without touching the wire - bad
|
|
47
|
+
/// arguments, unsupported types - are not counted.
|
|
48
|
+
///
|
|
49
|
+
/// This says nothing about whether the request ran. Current protocol
|
|
50
|
+
/// versions carry only an error message string, and SQL can produce the
|
|
51
|
+
/// same text as a missing-connection response, so no error is safe to
|
|
52
|
+
/// retry automatically.
|
|
53
|
+
pub fn is_connection_fatal(&self) -> bool {
|
|
54
|
+
match self {
|
|
55
|
+
Self::Http(_) | Self::Protocol(_) => true,
|
|
56
|
+
Self::Proxy(error) => !matches!(
|
|
57
|
+
error,
|
|
58
|
+
crate::ProxyError::QueryFailed | crate::ProxyError::ConnectionBusy
|
|
59
|
+
),
|
|
60
|
+
Self::Server(message) => RETIRE_CONNECTION_MESSAGES
|
|
61
|
+
.iter()
|
|
62
|
+
.any(|known| message == known),
|
|
63
|
+
Self::UnsupportedType(_) | Self::Url(_) | Self::Utf8(_) => false,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#[cfg(test)]
|
|
69
|
+
mod tests {
|
|
70
|
+
use super::*;
|
|
71
|
+
|
|
72
|
+
#[test]
|
|
73
|
+
fn exact_connection_error_text_retires() {
|
|
74
|
+
for message in RETIRE_CONNECTION_MESSAGES {
|
|
75
|
+
let err = QuackError::server(message);
|
|
76
|
+
assert!(err.is_connection_fatal(), "{message}");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
#[test]
|
|
81
|
+
fn sql_error_text_containing_connection_message_is_not_fatal() {
|
|
82
|
+
for message in [
|
|
83
|
+
"Invalid Input Error: Invalid connection id",
|
|
84
|
+
"statement failed: Invalid connection id after committing",
|
|
85
|
+
"Connection does not exist / already disconnected (from SQL)",
|
|
86
|
+
] {
|
|
87
|
+
let err = QuackError::server(message);
|
|
88
|
+
assert!(!err.is_connection_fatal(), "{message}");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
#[test]
|
|
93
|
+
fn sql_errors_leave_the_connection_usable() {
|
|
94
|
+
let err = QuackError::server("Table with name t does not exist!");
|
|
95
|
+
assert!(!err.is_connection_fatal());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#[test]
|
|
99
|
+
fn transport_errors_are_fatal() {
|
|
100
|
+
let err = QuackError::protocol("expected PREPARE_RESPONSE, got FetchResponse");
|
|
101
|
+
assert!(err.is_connection_fatal());
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
//! Bounded metadata inspection for HTTP proxies. Payloads remain opaque.
|
|
2
|
+
//!
|
|
3
|
+
//! Headers share the client's codec. Incomplete input is distinct from invalid
|
|
4
|
+
//! input; callers must stop collecting at [`MAX_MESSAGE_HEADER_BYTES`]. Neither this
|
|
5
|
+
//! API nor its errors expose SQL, authentication fields, or upstream error text.
|
|
6
|
+
|
|
7
|
+
pub use crate::messages::MessageType as Operation;
|
|
8
|
+
use crate::{
|
|
9
|
+
QuackError,
|
|
10
|
+
binary::BinaryReader,
|
|
11
|
+
messages::{self, QuackMessage},
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
pub const MAX_MESSAGE_HEADER_BYTES: usize = 4096;
|
|
15
|
+
pub const MAX_CONTROL_RESPONSE_BYTES: usize = 16 * 1024;
|
|
16
|
+
pub const MAX_CONNECTION_ID_BYTES: usize = 256;
|
|
17
|
+
|
|
18
|
+
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
19
|
+
pub struct MessageHeader {
|
|
20
|
+
pub operation: Operation,
|
|
21
|
+
pub connection_id: Option<String>,
|
|
22
|
+
pub client_query_id: Option<u64>,
|
|
23
|
+
/// Number of bytes occupied by the header; excludes the opaque body.
|
|
24
|
+
pub encoded_len: usize,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
|
|
28
|
+
pub enum InspectionError {
|
|
29
|
+
#[error("incomplete quack message header")]
|
|
30
|
+
Incomplete,
|
|
31
|
+
#[error("invalid quack message header")]
|
|
32
|
+
Invalid,
|
|
33
|
+
#[error("quack metadata exceeds inspection limit")]
|
|
34
|
+
TooLarge,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// Inspects only the header, even when `prefix` also contains a large payload.
|
|
38
|
+
pub fn inspect_message_header(prefix: &[u8]) -> Result<MessageHeader, InspectionError> {
|
|
39
|
+
let bounded = &prefix[..prefix.len().min(MAX_MESSAGE_HEADER_BYTES)];
|
|
40
|
+
let mut reader = BinaryReader::new(bounded);
|
|
41
|
+
let header = match messages::decode_header(&mut reader) {
|
|
42
|
+
Ok(header) => header,
|
|
43
|
+
Err(_) if reader.is_incomplete() => {
|
|
44
|
+
return Err(if bounded.len() == MAX_MESSAGE_HEADER_BYTES {
|
|
45
|
+
InspectionError::TooLarge
|
|
46
|
+
} else {
|
|
47
|
+
InspectionError::Incomplete
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
Err(_) => return Err(InspectionError::Invalid),
|
|
51
|
+
};
|
|
52
|
+
if header
|
|
53
|
+
.connection_id
|
|
54
|
+
.as_ref()
|
|
55
|
+
.is_some_and(|id| id.len() > MAX_CONNECTION_ID_BYTES)
|
|
56
|
+
{
|
|
57
|
+
return Err(InspectionError::TooLarge);
|
|
58
|
+
}
|
|
59
|
+
Ok(MessageHeader {
|
|
60
|
+
operation: header.message_type,
|
|
61
|
+
connection_id: header.connection_id,
|
|
62
|
+
client_query_id: header.client_query_id,
|
|
63
|
+
encoded_len: bounded.len() - reader.remaining(),
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// Only bounded control responses are decoded. Never call on result payloads.
|
|
68
|
+
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
69
|
+
pub enum ControlResponse {
|
|
70
|
+
Connected { connection_id: String },
|
|
71
|
+
Success,
|
|
72
|
+
Error { fatal: bool },
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
pub fn inspect_control_response(bytes: &[u8]) -> Result<ControlResponse, InspectionError> {
|
|
76
|
+
if bytes.len() > MAX_CONTROL_RESPONSE_BYTES {
|
|
77
|
+
return Err(InspectionError::TooLarge);
|
|
78
|
+
}
|
|
79
|
+
let header = inspect_message_header(bytes)?;
|
|
80
|
+
if !matches!(
|
|
81
|
+
header.operation,
|
|
82
|
+
Operation::ConnectionResponse | Operation::SuccessResponse | Operation::ErrorResponse
|
|
83
|
+
) {
|
|
84
|
+
return Err(InspectionError::Invalid);
|
|
85
|
+
}
|
|
86
|
+
if header.operation == Operation::ErrorResponse {
|
|
87
|
+
let mut reader = BinaryReader::new(&bytes[header.encoded_len..]);
|
|
88
|
+
let (message, invalidated) = reader
|
|
89
|
+
.read_object(messages::read_error_fields)
|
|
90
|
+
.map_err(|_| InspectionError::Invalid)?;
|
|
91
|
+
reader.assert_eof().map_err(|_| InspectionError::Invalid)?;
|
|
92
|
+
// Legacy versions carry only text. This conservative fallback can cause
|
|
93
|
+
// recycling, but must never authorize replay or release ownership.
|
|
94
|
+
let fatal = invalidated
|
|
95
|
+
|| message.starts_with("FATAL Error:")
|
|
96
|
+
|| message.starts_with("INTERNAL Error:")
|
|
97
|
+
|| message.starts_with("Fatal Error:")
|
|
98
|
+
|| message.starts_with("Internal Error:")
|
|
99
|
+
|| QuackError::server(message).is_connection_fatal();
|
|
100
|
+
return Ok(ControlResponse::Error { fatal });
|
|
101
|
+
}
|
|
102
|
+
// These bodies are compatible across v1 and v3; v3's added fields are optional.
|
|
103
|
+
let message = messages::decode_message_for_version(bytes, crate::constants::QUACK_V3)
|
|
104
|
+
.map_err(|_| InspectionError::Invalid)?;
|
|
105
|
+
match message {
|
|
106
|
+
QuackMessage::ConnectionResponse {
|
|
107
|
+
header,
|
|
108
|
+
quack_version,
|
|
109
|
+
..
|
|
110
|
+
} => {
|
|
111
|
+
if quack_version.is_some_and(|v| v != 1 && v != 3) {
|
|
112
|
+
return Err(InspectionError::Invalid);
|
|
113
|
+
}
|
|
114
|
+
Ok(ControlResponse::Connected {
|
|
115
|
+
connection_id: header.connection_id.ok_or(InspectionError::Invalid)?,
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
QuackMessage::SuccessResponse { .. } => Ok(ControlResponse::Success),
|
|
119
|
+
_ => Err(InspectionError::Invalid),
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
#[cfg(test)]
|
|
124
|
+
mod tests {
|
|
125
|
+
use super::*;
|
|
126
|
+
use crate::{
|
|
127
|
+
binary::BinaryWriter,
|
|
128
|
+
messages::{MessageHeader as WireMessageHeader, encode_header, encode_message},
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
fn header(operation: Operation, connection: Option<&str>) -> WireMessageHeader {
|
|
132
|
+
WireMessageHeader {
|
|
133
|
+
message_type: operation,
|
|
134
|
+
connection_id: connection.map(str::to_owned),
|
|
135
|
+
client_query_id: Some(42),
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
#[test]
|
|
140
|
+
fn every_request_header_accepts_all_chunk_boundaries_without_payload() {
|
|
141
|
+
for operation in [
|
|
142
|
+
Operation::ConnectionRequest,
|
|
143
|
+
Operation::PrepareRequest,
|
|
144
|
+
Operation::FetchRequest,
|
|
145
|
+
Operation::SendDataRequest,
|
|
146
|
+
Operation::DisconnectMessage,
|
|
147
|
+
Operation::CancelRequest,
|
|
148
|
+
Operation::Acknowledgement,
|
|
149
|
+
Operation::HeartbeatRequest,
|
|
150
|
+
] {
|
|
151
|
+
let mut writer = BinaryWriter::new();
|
|
152
|
+
encode_header(&mut writer, &header(operation, Some("connection-example"))).unwrap();
|
|
153
|
+
let mut bytes = writer.into_bytes();
|
|
154
|
+
for end in 0..bytes.len() {
|
|
155
|
+
assert_eq!(
|
|
156
|
+
inspect_message_header(&bytes[..end]),
|
|
157
|
+
Err(InspectionError::Incomplete)
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
let expected = inspect_message_header(&bytes).unwrap();
|
|
161
|
+
assert_eq!(expected.operation, operation);
|
|
162
|
+
assert_eq!(expected.client_query_id, Some(42));
|
|
163
|
+
assert_eq!(expected.encoded_len, bytes.len());
|
|
164
|
+
bytes.resize(1024 * 1024, 0xfe);
|
|
165
|
+
assert_eq!(inspect_message_header(&bytes).unwrap(), expected);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
#[test]
|
|
170
|
+
fn malformed_and_oversized_headers_do_not_panic_or_echo_input() {
|
|
171
|
+
assert_eq!(
|
|
172
|
+
inspect_message_header(&[2, 0, 1]),
|
|
173
|
+
Err(InspectionError::Invalid)
|
|
174
|
+
);
|
|
175
|
+
let mut writer = BinaryWriter::new();
|
|
176
|
+
encode_header(
|
|
177
|
+
&mut writer,
|
|
178
|
+
&header(Operation::PrepareRequest, Some(&"x".repeat(257))),
|
|
179
|
+
)
|
|
180
|
+
.unwrap();
|
|
181
|
+
assert_eq!(
|
|
182
|
+
inspect_message_header(writer.as_slice()),
|
|
183
|
+
Err(InspectionError::TooLarge)
|
|
184
|
+
);
|
|
185
|
+
for length in 0..MAX_MESSAGE_HEADER_BYTES + 20 {
|
|
186
|
+
let bytes = vec![0xff; length];
|
|
187
|
+
assert!(inspect_message_header(&bytes).is_err());
|
|
188
|
+
}
|
|
189
|
+
// An attacker-controlled usize::MAX string length must not overflow ensure().
|
|
190
|
+
let mut writer = BinaryWriter::new();
|
|
191
|
+
writer.write_field(1, |w| w.write_uleb(3u64)).unwrap();
|
|
192
|
+
writer.write_field(2, |w| w.write_uleb(u64::MAX)).unwrap();
|
|
193
|
+
assert!(inspect_message_header(writer.as_slice()).is_err());
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
#[test]
|
|
197
|
+
fn complete_control_response_is_required_and_errors_are_sanitized() {
|
|
198
|
+
let success = encode_message(&QuackMessage::SuccessResponse {
|
|
199
|
+
header: header(Operation::SuccessResponse, Some("example")),
|
|
200
|
+
})
|
|
201
|
+
.unwrap();
|
|
202
|
+
assert_eq!(
|
|
203
|
+
inspect_control_response(&success).unwrap(),
|
|
204
|
+
ControlResponse::Success
|
|
205
|
+
);
|
|
206
|
+
assert!(inspect_control_response(&success[..success.len() - 1]).is_err());
|
|
207
|
+
let mut trailing = success.clone();
|
|
208
|
+
trailing.push(0);
|
|
209
|
+
assert!(inspect_control_response(&trailing).is_err());
|
|
210
|
+
for (message, fatal) in [
|
|
211
|
+
("Catalog Error: private query text", false),
|
|
212
|
+
("FATAL Error: private details", true),
|
|
213
|
+
("Invalid connection id", true),
|
|
214
|
+
] {
|
|
215
|
+
let bytes = encode_message(&QuackMessage::ErrorResponse {
|
|
216
|
+
header: header(Operation::ErrorResponse, None),
|
|
217
|
+
message: message.into(),
|
|
218
|
+
})
|
|
219
|
+
.unwrap();
|
|
220
|
+
assert_eq!(
|
|
221
|
+
inspect_control_response(&bytes).unwrap(),
|
|
222
|
+
ControlResponse::Error { fatal }
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
#[test]
|
|
228
|
+
fn structured_invalidation_recycles_and_malicious_extra_info_is_bounded() {
|
|
229
|
+
for count in [0, u64::MAX] {
|
|
230
|
+
let mut writer = BinaryWriter::new();
|
|
231
|
+
encode_header(&mut writer, &header(Operation::ErrorResponse, None)).unwrap();
|
|
232
|
+
writer
|
|
233
|
+
.write_object(|w| {
|
|
234
|
+
w.write_field(1, |w| w.write_string("private details"))?;
|
|
235
|
+
w.write_field(2, |w| w.write_string("Catalog"))?;
|
|
236
|
+
w.write_field(3, |w| w.write_uleb(count))?;
|
|
237
|
+
w.write_field(4, |w| w.write_bool(true))
|
|
238
|
+
})
|
|
239
|
+
.unwrap();
|
|
240
|
+
if count == 0 {
|
|
241
|
+
assert_eq!(
|
|
242
|
+
inspect_control_response(writer.as_slice()).unwrap(),
|
|
243
|
+
ControlResponse::Error { fatal: true }
|
|
244
|
+
);
|
|
245
|
+
} else {
|
|
246
|
+
assert!(inspect_control_response(writer.as_slice()).is_err());
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
use base64::Engine;
|
|
2
|
+
use base64::engine::general_purpose::STANDARD;
|
|
3
|
+
use serde_json::{Map, Number, Value as JsonValue, json};
|
|
4
|
+
|
|
5
|
+
use crate::errors::{QuackError, Result};
|
|
6
|
+
use crate::vector::{Row, TimeUnit, TimestampUnit, Value, decimal_to_string};
|
|
7
|
+
|
|
8
|
+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
9
|
+
pub enum BigIntJsonMode {
|
|
10
|
+
String,
|
|
11
|
+
Number,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
15
|
+
pub enum BytesJsonMode {
|
|
16
|
+
Base64,
|
|
17
|
+
Hex,
|
|
18
|
+
Array,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
22
|
+
pub enum TaggedJsonMode {
|
|
23
|
+
Default,
|
|
24
|
+
Tagged,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
28
|
+
pub struct JsonOptions {
|
|
29
|
+
pub bigint: BigIntJsonMode,
|
|
30
|
+
pub bytes: BytesJsonMode,
|
|
31
|
+
pub decimal: TaggedJsonMode,
|
|
32
|
+
pub date: TaggedJsonMode,
|
|
33
|
+
pub time: TaggedJsonMode,
|
|
34
|
+
pub timestamp: TaggedJsonMode,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
impl Default for JsonOptions {
|
|
38
|
+
fn default() -> Self {
|
|
39
|
+
Self {
|
|
40
|
+
bigint: BigIntJsonMode::String,
|
|
41
|
+
bytes: BytesJsonMode::Base64,
|
|
42
|
+
decimal: TaggedJsonMode::Default,
|
|
43
|
+
date: TaggedJsonMode::Default,
|
|
44
|
+
time: TaggedJsonMode::Default,
|
|
45
|
+
timestamp: TaggedJsonMode::Default,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
pub fn to_json_value(value: &Value, options: JsonOptions) -> Result<JsonValue> {
|
|
51
|
+
Ok(match value {
|
|
52
|
+
Value::Null => JsonValue::Null,
|
|
53
|
+
Value::Bool(value) => JsonValue::Bool(*value),
|
|
54
|
+
Value::Int(value) => bigint_to_json(*value as i128, options.bigint)?,
|
|
55
|
+
Value::UInt(value) => unsigned_bigint_to_json(*value as u128, options.bigint)?,
|
|
56
|
+
Value::HugeInt(value) => bigint_to_json(*value, options.bigint)?,
|
|
57
|
+
Value::UHugeInt(value) => unsigned_bigint_to_json(*value, options.bigint)?,
|
|
58
|
+
Value::Float(value) => Number::from_f64(*value as f64)
|
|
59
|
+
.map(JsonValue::Number)
|
|
60
|
+
.unwrap_or(JsonValue::Null),
|
|
61
|
+
Value::Double(value) => Number::from_f64(*value)
|
|
62
|
+
.map(JsonValue::Number)
|
|
63
|
+
.unwrap_or(JsonValue::Null),
|
|
64
|
+
Value::String(value) => JsonValue::String(value.clone()),
|
|
65
|
+
Value::Bytes(value) => bytes_to_json(value, options.bytes),
|
|
66
|
+
Value::Decimal(value) => match options.decimal {
|
|
67
|
+
TaggedJsonMode::Tagged => json!({
|
|
68
|
+
"kind": "decimal",
|
|
69
|
+
"value": value.value.to_string(),
|
|
70
|
+
"width": value.width,
|
|
71
|
+
"scale": value.scale
|
|
72
|
+
}),
|
|
73
|
+
TaggedJsonMode::Default => JsonValue::String(decimal_to_string(value)),
|
|
74
|
+
},
|
|
75
|
+
Value::Date(value) => match options.date {
|
|
76
|
+
TaggedJsonMode::Tagged => json!({ "kind": "date", "days": value.days }),
|
|
77
|
+
TaggedJsonMode::Default => JsonValue::String(date_to_iso(value.days)),
|
|
78
|
+
},
|
|
79
|
+
Value::Time(value) => match options.time {
|
|
80
|
+
TaggedJsonMode::Tagged => json!({
|
|
81
|
+
"kind": "time",
|
|
82
|
+
"unit": match value.unit { TimeUnit::Micros => "micros", TimeUnit::Nanos => "nanos" },
|
|
83
|
+
"value": value.value.to_string()
|
|
84
|
+
}),
|
|
85
|
+
TaggedJsonMode::Default => JsonValue::String(time_to_string(value.value, value.unit)),
|
|
86
|
+
},
|
|
87
|
+
Value::TimeTz(value) => json!({ "kind": "time_tz", "bits": value.bits.to_string() }),
|
|
88
|
+
Value::Timestamp(value) => match options.timestamp {
|
|
89
|
+
TaggedJsonMode::Tagged => {
|
|
90
|
+
let unit = match value.unit {
|
|
91
|
+
TimestampUnit::Seconds => "seconds",
|
|
92
|
+
TimestampUnit::Millis => "millis",
|
|
93
|
+
TimestampUnit::Micros => "micros",
|
|
94
|
+
TimestampUnit::Nanos => "nanos",
|
|
95
|
+
};
|
|
96
|
+
if value.timezone_utc {
|
|
97
|
+
json!({ "kind": "timestamp", "unit": unit, "value": value.value.to_string(), "timezone": "utc" })
|
|
98
|
+
} else {
|
|
99
|
+
json!({ "kind": "timestamp", "unit": unit, "value": value.value.to_string() })
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
TaggedJsonMode::Default => {
|
|
103
|
+
JsonValue::String(timestamp_to_iso(value.value, value.unit)?)
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
Value::Interval(value) => json!({
|
|
107
|
+
"kind": "interval",
|
|
108
|
+
"months": value.months,
|
|
109
|
+
"days": value.days,
|
|
110
|
+
"micros": value.micros.to_string()
|
|
111
|
+
}),
|
|
112
|
+
Value::List(values) => JsonValue::Array(
|
|
113
|
+
values
|
|
114
|
+
.iter()
|
|
115
|
+
.map(|value| to_json_value(value, options))
|
|
116
|
+
.collect::<Result<Vec<_>>>()?,
|
|
117
|
+
),
|
|
118
|
+
Value::Struct(row) => {
|
|
119
|
+
let mut object = Map::new();
|
|
120
|
+
for (key, value) in row {
|
|
121
|
+
object.insert(key.clone(), to_json_value(value, options)?);
|
|
122
|
+
}
|
|
123
|
+
JsonValue::Object(object)
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
pub fn to_json_row(row: &Row, options: JsonOptions) -> Result<JsonValue> {
|
|
129
|
+
let mut object = Map::new();
|
|
130
|
+
for (key, value) in row {
|
|
131
|
+
object.insert(key.clone(), to_json_value(value, options)?);
|
|
132
|
+
}
|
|
133
|
+
Ok(JsonValue::Object(object))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
pub fn to_json_rows(rows: &[Row], options: JsonOptions) -> Result<Vec<JsonValue>> {
|
|
137
|
+
rows.iter().map(|row| to_json_row(row, options)).collect()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
fn bigint_to_json(value: i128, mode: BigIntJsonMode) -> Result<JsonValue> {
|
|
141
|
+
match mode {
|
|
142
|
+
BigIntJsonMode::String => Ok(JsonValue::String(value.to_string())),
|
|
143
|
+
BigIntJsonMode::Number => i64::try_from(value)
|
|
144
|
+
.ok()
|
|
145
|
+
.map(Number::from)
|
|
146
|
+
.map(JsonValue::Number)
|
|
147
|
+
.ok_or_else(|| {
|
|
148
|
+
QuackError::protocol(format!("bigint value {value} exceeds JSON number range"))
|
|
149
|
+
}),
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
fn unsigned_bigint_to_json(value: u128, mode: BigIntJsonMode) -> Result<JsonValue> {
|
|
154
|
+
match mode {
|
|
155
|
+
BigIntJsonMode::String => Ok(JsonValue::String(value.to_string())),
|
|
156
|
+
BigIntJsonMode::Number => u64::try_from(value)
|
|
157
|
+
.ok()
|
|
158
|
+
.map(|value| JsonValue::Number(Number::from(value)))
|
|
159
|
+
.ok_or_else(|| {
|
|
160
|
+
QuackError::protocol(format!("bigint value {value} exceeds JSON number range"))
|
|
161
|
+
}),
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
fn bytes_to_json(value: &[u8], mode: BytesJsonMode) -> JsonValue {
|
|
166
|
+
match mode {
|
|
167
|
+
BytesJsonMode::Base64 => JsonValue::String(STANDARD.encode(value)),
|
|
168
|
+
BytesJsonMode::Hex => {
|
|
169
|
+
JsonValue::String(value.iter().map(|byte| format!("{byte:02x}")).collect())
|
|
170
|
+
}
|
|
171
|
+
BytesJsonMode::Array => JsonValue::Array(value.iter().map(|byte| json!(byte)).collect()),
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
fn date_to_iso(days: i32) -> String {
|
|
176
|
+
let epoch = chrono::NaiveDate::from_ymd_opt(1970, 1, 1).expect("valid epoch");
|
|
177
|
+
(epoch + chrono::Duration::days(days as i64))
|
|
178
|
+
.format("%Y-%m-%d")
|
|
179
|
+
.to_string()
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
fn time_to_string(value: i64, unit: TimeUnit) -> String {
|
|
183
|
+
let nanos = match unit {
|
|
184
|
+
TimeUnit::Micros => value as i128 * 1_000,
|
|
185
|
+
TimeUnit::Nanos => value as i128,
|
|
186
|
+
};
|
|
187
|
+
let total_seconds = nanos.div_euclid(1_000_000_000);
|
|
188
|
+
let fraction = nanos.rem_euclid(1_000_000_000);
|
|
189
|
+
let hours = total_seconds / 3_600;
|
|
190
|
+
let minutes = (total_seconds % 3_600) / 60;
|
|
191
|
+
let seconds = total_seconds % 60;
|
|
192
|
+
let fraction_text = if fraction == 0 {
|
|
193
|
+
String::new()
|
|
194
|
+
} else {
|
|
195
|
+
format!(".{:09}", fraction)
|
|
196
|
+
.trim_end_matches('0')
|
|
197
|
+
.to_string()
|
|
198
|
+
};
|
|
199
|
+
format!("{hours:02}:{minutes:02}:{seconds:02}{fraction_text}")
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
fn timestamp_to_iso(value: i64, unit: TimestampUnit) -> Result<String> {
|
|
203
|
+
let nanos = match unit {
|
|
204
|
+
TimestampUnit::Seconds => value as i128 * 1_000_000_000,
|
|
205
|
+
TimestampUnit::Millis => value as i128 * 1_000_000,
|
|
206
|
+
TimestampUnit::Micros => value as i128 * 1_000,
|
|
207
|
+
TimestampUnit::Nanos => value as i128,
|
|
208
|
+
};
|
|
209
|
+
let seconds = nanos.div_euclid(1_000_000_000);
|
|
210
|
+
let fraction = nanos.rem_euclid(1_000_000_000);
|
|
211
|
+
let datetime = chrono::DateTime::from_timestamp(seconds as i64, fraction as u32)
|
|
212
|
+
.ok_or_else(|| QuackError::protocol(format!("timestamp value {value} is outside range")))?;
|
|
213
|
+
let fraction_text = if fraction == 0 {
|
|
214
|
+
String::new()
|
|
215
|
+
} else {
|
|
216
|
+
format!(".{:09}", fraction)
|
|
217
|
+
.trim_end_matches('0')
|
|
218
|
+
.to_string()
|
|
219
|
+
};
|
|
220
|
+
Ok(format!(
|
|
221
|
+
"{}{}Z",
|
|
222
|
+
datetime.format("%Y-%m-%dT%H:%M:%S"),
|
|
223
|
+
fraction_text
|
|
224
|
+
))
|
|
225
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
//! Rust client-side SDK for DuckDB's experimental Quack remote protocol.
|
|
2
|
+
//!
|
|
3
|
+
//! The crate implements the HTTP client transport plus the DuckDB
|
|
4
|
+
//! `BinarySerializer`-compatible message, logical type, and `DataChunk` codecs
|
|
5
|
+
//! needed by a Quack client.
|
|
6
|
+
//!
|
|
7
|
+
//! [`QuackClient`] is one session on the server and runs one query at a time;
|
|
8
|
+
//! [`QuackPool`] keeps several sessions so queries can run concurrently.
|
|
9
|
+
|
|
10
|
+
#[cfg(feature = "arrow")]
|
|
11
|
+
pub mod arrow;
|
|
12
|
+
mod binary;
|
|
13
|
+
mod builders;
|
|
14
|
+
mod client;
|
|
15
|
+
mod constants;
|
|
16
|
+
mod errors;
|
|
17
|
+
mod proxy_error;
|
|
18
|
+
pub use proxy_error::ProxyError;
|
|
19
|
+
pub mod inspection;
|
|
20
|
+
mod json;
|
|
21
|
+
mod logical_types;
|
|
22
|
+
mod messages;
|
|
23
|
+
mod pool;
|
|
24
|
+
mod sql;
|
|
25
|
+
mod tls;
|
|
26
|
+
mod values;
|
|
27
|
+
mod vector;
|
|
28
|
+
|
|
29
|
+
pub use builders::{ColumnDefinition, ColumnInput, column, data_chunk, data_chunk_from_rows};
|
|
30
|
+
pub use client::{
|
|
31
|
+
QuackClient, QuackClientOptions, QuackConnectionInfo, QuackResultStream, QueryMetadata,
|
|
32
|
+
};
|
|
33
|
+
pub use errors::{QuackError, Result};
|
|
34
|
+
pub use futures_core::Stream;
|
|
35
|
+
pub use json::{
|
|
36
|
+
BigIntJsonMode, BytesJsonMode, JsonOptions, TaggedJsonMode, to_json_row, to_json_rows,
|
|
37
|
+
to_json_value,
|
|
38
|
+
};
|
|
39
|
+
pub use logical_types::{
|
|
40
|
+
ChildType, CoordinateReferenceSystem, ExtraTypeInfo, LogicalType, LogicalTypeId, LogicalTypes,
|
|
41
|
+
};
|
|
42
|
+
pub use pool::{DEFAULT_MAX_CONNECTIONS, PooledClient, QuackPool, QuackPoolOptions};
|
|
43
|
+
pub use sql::{SqlParameter, SqlParameters, format_sql, sql_literal};
|
|
44
|
+
pub use values::{
|
|
45
|
+
date_from_iso_date, date_value, decimal_value, interval_value, time_tz_value, time_value,
|
|
46
|
+
timestamp_value,
|
|
47
|
+
};
|
|
48
|
+
pub use vector::{
|
|
49
|
+
DataChunk, DateValue, DecimalValue, IntervalValue, Row, TimeTzValue, TimeUnit, TimeValue,
|
|
50
|
+
TimestampUnit, TimestampValue, Value, decimal_to_string, rows_from_chunk,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
#[cfg(test)]
|
|
54
|
+
mod protocol_tests;
|