Add lightweight async ChuSQL Rust driver and blog query support
This commit is contained in:
commit
105ad89ace
10 files changed
+892
No files matched your search
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
Generated
+194
@@ -0,0 +1,194 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytes"
|
||||||
|
version = "1.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chusql-driver"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.190"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mio"
|
||||||
|
version = "1.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-project-lite"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.107"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.47"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.154"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "socket2"
|
||||||
|
version = "0.6.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "3.0.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio"
|
||||||
|
version = "1.53.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"libc",
|
||||||
|
"mio",
|
||||||
|
"pin-project-lite",
|
||||||
|
"socket2",
|
||||||
|
"tokio-macros",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-macros"
|
||||||
|
version = "2.7.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasi"
|
||||||
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "chusql-driver"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
description = "Lightweight asynchronous Rust driver for ChuSQL"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = { version = "1", features = ["arbitrary_precision"] }
|
||||||
|
tokio = { version = "1", features = ["net", "io-util", "sync", "time"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# ChuSQL Rust Driver
|
||||||
|
|
||||||
|
轻量异步 ChuSQL 数据库驱动,使用 Tokio,通过 TCP 直接连接 `chusql-server`。可作为 Axum、Actix Web 等 Tokio 后端框架的应用状态使用。
|
||||||
|
|
||||||
|
实现依据是相邻 ChuSQL 项目的 `chusql-interface/src/ChuSQL/Interface/Protocol.hs`、`Link.hs` 和 `chusql-core/model/src/ChuSQL/Core/Protocol.hs`,使用版本 1 的 UTF-8 JSON 行协议。依赖仅有 Tokio、Serde 和 serde_json,没有 ORM 或连接池层。
|
||||||
|
|
||||||
|
## 接入
|
||||||
|
|
||||||
|
在后端项目的 `Cargo.toml` 中添加:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
chusql-driver = { path = "../ChuSQLDriver" }
|
||||||
|
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
use chusql_driver::{Client, Error};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Error> {
|
||||||
|
let db = Client::connect("127.0.0.1:7777", "app", "your-password").await?;
|
||||||
|
db.ping().await?;
|
||||||
|
let result = db.query("SHOW DATABASES").await?;
|
||||||
|
println!("{:?}", result.columns);
|
||||||
|
for row in &result.rows {
|
||||||
|
println!("{row:?}");
|
||||||
|
}
|
||||||
|
if result.truncated {
|
||||||
|
println!("结果已被服务端截断,请缩小查询范围");
|
||||||
|
}
|
||||||
|
db.close().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
需要已初始化并启动 ChuSQL TCP 服务,且登录账号具备相应 SQL 权限。默认地址为 `127.0.0.1:7777`。
|
||||||
|
|
||||||
|
`Client` 可 `clone()`,支持 `Send + Sync`。将它传给 Axum 的 `Router::with_state(db)` 或 Actix Web 的 `web::Data::new(db)`,handler 中直接调用 `db.query(sql).await`。所有克隆共享同一个登录会话,同一时刻只执行一个请求;需要并行执行时建立多个独立连接。不要在多个 handler 间交错执行依赖会话状态的一组 SQL(例如切换数据库或事务),这类操作应使用独立连接。
|
||||||
|
|
||||||
|
## API 与结果
|
||||||
|
|
||||||
|
- `Client::connect(address, user, password)`:连接、协议握手、账号登录。
|
||||||
|
- `Client::connect_with_options(address, user, password, options)`:自定义连接/请求超时和响应字节上限。
|
||||||
|
- `client.query(sql)`:执行单条 SQL,DDL、DML 和 SELECT 使用相同入口。
|
||||||
|
- `client.ping()`:探活。
|
||||||
|
- `client.close()`:关闭所有克隆共享的连接;也可在最后一个客户端释放时自动断开。
|
||||||
|
|
||||||
|
`QueryResult` 保留服务端的 `columns`、`rows`、`row_count`、`truncated` 和 `database`。行内值使用 `serde_json::Value`,可以使用 `as_str()`、`as_i64()`、`as_bool()`、`is_null()` 等方法读取。数值启用任意精度 JSON 表示,运行时自定义类型对象原样保留。`row_count` 是服务端返回的行数,不承诺等同于写操作影响行数;务必检查 `truncated`。
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use chusql_driver::Options;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
let options = Options {
|
||||||
|
timeout: Duration::from_secs(5),
|
||||||
|
max_response_bytes: 8 * 1024 * 1024,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
默认连接及每次请求超时为 30 秒,单条响应上限为 16 MiB(含换行);等待其他请求释放连接的时间不计入超时。可匹配 `Error::Server { code, message }` 处理服务端错误,普通 SQL 错误不会关闭连接。I/O、JSON、超时或取消会使连接失效,后续返回 `Error::Closed`,需要重新建立连接。驱动不自动重试写入:断线或超时发生时,SQL 可能已执行。
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
当前 ChuSQL 协议只接收 SQL 文本,不提供服务端参数绑定。新增的 `Query` 按 ChuSQL 解析规则编码客户端字面量;不要将用户输入直接拼接到 SQL,也不要将其传给 `Query::new` 或 `push`。
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use chusql_driver::Query;
|
||||||
|
|
||||||
|
let title = "It's my blog";
|
||||||
|
let statement = Query::new("INSERT INTO posts (id, title) VALUES (")
|
||||||
|
.bind(1_i64)
|
||||||
|
.push(", ")
|
||||||
|
.bind(title)
|
||||||
|
.push(")");
|
||||||
|
// db.query(statement.as_str()).await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
`bind` 支持 `&str`、`i64`、`bool` 和 `Param::Null`,仅用于 SQL 值。表名和列名必须由代码固定或使用白名单;LIKE 通配符仍有其原本语义。这不是服务端预编译语句,也不模拟问号占位符。
|
||||||
|
|
||||||
|
## 轻量博客
|
||||||
|
|
||||||
|
当前驱动适合低并发个人博客的文章、评论和分类读写。新增 `examples/blog.rs` 展示建表、含单引号/中文/换行的正文写入、更新、列表和删除。对一个空的测试数据库设置 `CHUSQL_DATABASE` 后运行 `cargo run --example blog`;示例会创建并最终删除 `driver_blog_posts`,中途失败时可能保留表。数据库需要事先创建,账号需要建表和读写权限。
|
||||||
|
|
||||||
|
查询可使用 `ORDER BY id DESC LIMIT 10`,翻页使用 `WHERE id < 上一页最后的ID`,通过 `bind` 传入 ID;当前解析器没有 OFFSET。大正文应按需查询,避免列表加载全部正文。ChuSQL 默认请求上限为 1 MiB、结果行数上限为 1000,图片建议独立存储。
|
||||||
|
|
||||||
|
一个共享连接可以作为低流量起点;事务和 `USE` 需要独立连接。超时后重新连接,但不要盲目重试发文;使用唯一 ID 或唯一 slug 避免重复提交。生产使用前仍需对真实 ChuSQL 验证权限、备份恢复和预期并发,驱动本身不保证数据库的成熟度。
|
||||||
|
|
||||||
|
服务器协议通过明文 TCP 传输账号密码及查询数据,跨不可信网络时请使用受保护的网络或加密隧道。本驱动不内置 TLS、ORM 适配、连接池、迁移或自动重连,先保持核心连接功能简单。
|
||||||
|
|
||||||
|
## 验证与示例
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo test --all-targets
|
||||||
|
cargo fmt --check
|
||||||
|
cargo clippy --all-targets -- -D warnings
|
||||||
|
```
|
||||||
|
|
||||||
|
协议测试使用本地 TCP 测试服务覆盖握手、登录、Unicode/换行、结果映射、错误恢复、响应分片、损坏/超长/未终止响应、超时和请求取消。它们不替代真实数据库联调。
|
||||||
|
|
||||||
|
对运行中的真实数据库执行查询:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export CHUSQL_ADDRESS=127.0.0.1:7777
|
||||||
|
export CHUSQL_USER=app
|
||||||
|
# 在运行环境中设置 CHUSQL_PASSWORD
|
||||||
|
cargo run --example query -- 'SHOW DATABASES'
|
||||||
|
```
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
//! Run against a disposable database: creates and deletes a demonstration post.
|
||||||
|
use chusql_driver::{Client, Query};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let address = std::env::var("CHUSQL_ADDRESS").unwrap_or_else(|_| "127.0.0.1:7777".into());
|
||||||
|
let db = Client::connect(
|
||||||
|
address,
|
||||||
|
&std::env::var("CHUSQL_USER")?,
|
||||||
|
&std::env::var("CHUSQL_PASSWORD")?,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
// Use a dedicated session so USE and subsequent operations cannot interleave with other handlers.
|
||||||
|
let database = std::env::var("CHUSQL_DATABASE")?;
|
||||||
|
if database.is_empty()
|
||||||
|
|| !database
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||||
|
|| database.starts_with(|c: char| c.is_ascii_digit())
|
||||||
|
{
|
||||||
|
return Err("CHUSQL_DATABASE must be a plain identifier".into());
|
||||||
|
}
|
||||||
|
db.query(&format!("USE {database}")).await?;
|
||||||
|
// Run once in a disposable database; table must not already exist.
|
||||||
|
db.query("CREATE TABLE driver_blog_posts (id INT PRIMARY KEY, title TEXT, body TEXT, published BOOL)").await?;
|
||||||
|
let insert = Query::new("INSERT INTO driver_blog_posts (id, title, body, published) VALUES (")
|
||||||
|
.bind(1_i64)
|
||||||
|
.push(", ")
|
||||||
|
.bind("ChuSQL 博客")
|
||||||
|
.push(", ")
|
||||||
|
.bind("第一篇文章:It's simple.\n正文支持换行。")
|
||||||
|
.push(", ")
|
||||||
|
.bind(true)
|
||||||
|
.push(")");
|
||||||
|
db.query(insert.as_str()).await?;
|
||||||
|
let update = Query::new("UPDATE driver_blog_posts SET title = ")
|
||||||
|
.bind("更新后的标题")
|
||||||
|
.push(" WHERE id = ")
|
||||||
|
.bind(1_i64);
|
||||||
|
db.query(update.as_str()).await?;
|
||||||
|
let select = Query::new("SELECT id, title, body FROM driver_blog_posts WHERE published = ")
|
||||||
|
.bind(true)
|
||||||
|
.push(" ORDER BY id DESC LIMIT 10");
|
||||||
|
let result = db.query(select.as_str()).await?;
|
||||||
|
assert!(!result.truncated);
|
||||||
|
assert_eq!(result.rows.len(), 1);
|
||||||
|
assert_eq!(result.rows[0][1].as_str(), Some("更新后的标题"));
|
||||||
|
println!("{:?}", result.rows);
|
||||||
|
let delete = Query::new("DELETE FROM driver_blog_posts WHERE id = ").bind(1_i64);
|
||||||
|
db.query(delete.as_str()).await?;
|
||||||
|
db.query("DROP TABLE driver_blog_posts").await?;
|
||||||
|
db.close().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
use chusql_driver::Client;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let address = std::env::var("CHUSQL_ADDRESS").unwrap_or_else(|_| "127.0.0.1:7777".into());
|
||||||
|
let user = std::env::var("CHUSQL_USER")?;
|
||||||
|
let password = std::env::var("CHUSQL_PASSWORD")?;
|
||||||
|
let sql = std::env::args()
|
||||||
|
.nth(1)
|
||||||
|
.unwrap_or_else(|| "SHOW DATABASES".into());
|
||||||
|
let client = Client::connect(address, &user, &password).await?;
|
||||||
|
let result = client.query(&sql).await?;
|
||||||
|
println!("{:?}", result);
|
||||||
|
client.close().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
use std::{fmt, io};
|
||||||
|
|
||||||
|
/// Errors preserve server codes so applications can distinguish authorization and SQL failures.
|
||||||
|
#[derive(Debug)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum Error {
|
||||||
|
Io(io::Error),
|
||||||
|
Json(serde_json::Error),
|
||||||
|
Server { code: String, message: String },
|
||||||
|
Protocol(&'static str),
|
||||||
|
Timeout,
|
||||||
|
Closed,
|
||||||
|
ResponseTooLarge,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Error {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Io(error) => write!(f, "ChuSQL I/O: {error}"),
|
||||||
|
Self::Json(error) => write!(f, "ChuSQL JSON: {error}"),
|
||||||
|
Self::Server { code, message } => write!(f, "ChuSQL {code}: {message}"),
|
||||||
|
Self::Protocol(message) => write!(f, "ChuSQL protocol: {message}"),
|
||||||
|
Self::Timeout => f.write_str("ChuSQL request timed out"),
|
||||||
|
Self::Closed => f.write_str("ChuSQL connection is closed; reconnect before retrying"),
|
||||||
|
Self::ResponseTooLarge => f.write_str("ChuSQL response exceeds configured byte limit"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for Error {
|
||||||
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||||
|
match self {
|
||||||
|
Self::Io(error) => Some(error),
|
||||||
|
Self::Json(error) => Some(error),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<io::Error> for Error {
|
||||||
|
fn from(error: io::Error) -> Self {
|
||||||
|
Self::Io(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<serde_json::Error> for Error {
|
||||||
|
fn from(error: serde_json::Error) -> Self {
|
||||||
|
Self::Json(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
+184
@@ -0,0 +1,184 @@
|
|||||||
|
//! An asynchronous client for ChuSQL's version 1 newline-delimited JSON protocol.
|
||||||
|
//! Clones share one authenticated session and serialize requests.
|
||||||
|
mod error;
|
||||||
|
mod query;
|
||||||
|
|
||||||
|
pub use error::Error;
|
||||||
|
pub use query::{Param, Query};
|
||||||
|
pub use serde_json::Value;
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::{sync::Arc, time::Duration};
|
||||||
|
use tokio::{
|
||||||
|
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
|
||||||
|
net::{TcpStream, ToSocketAddrs},
|
||||||
|
sync::Mutex,
|
||||||
|
time::timeout,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Limits for connecting and exchanging each request (excluding time queued behind other requests).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Options {
|
||||||
|
pub timeout: Duration,
|
||||||
|
pub max_response_bytes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Options {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
timeout: Duration::from_secs(30),
|
||||||
|
max_response_bytes: 16 * 1024 * 1024,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The original wire values are preserved, including NULL and runtime type objects.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct QueryResult {
|
||||||
|
pub columns: Vec<String>,
|
||||||
|
pub rows: Vec<Vec<Value>>,
|
||||||
|
pub row_count: u64,
|
||||||
|
pub truncated: bool,
|
||||||
|
pub database: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(tag = "status")]
|
||||||
|
enum Response {
|
||||||
|
#[serde(rename = "hello")]
|
||||||
|
Hello { protocol: u32 },
|
||||||
|
#[serde(rename = "ok")]
|
||||||
|
Login {},
|
||||||
|
#[serde(rename = "result")]
|
||||||
|
Result(QueryResult),
|
||||||
|
#[serde(rename = "pong")]
|
||||||
|
Pong,
|
||||||
|
#[serde(rename = "error")]
|
||||||
|
Error { code: String, message: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cloneable backend application state. Each clone uses the same connection and login.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Client {
|
||||||
|
connection: Arc<Mutex<Option<BufReader<TcpStream>>>>,
|
||||||
|
options: Options,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
/// Connect, negotiate protocol 1 and authenticate using the default limits.
|
||||||
|
pub async fn connect(
|
||||||
|
address: impl ToSocketAddrs,
|
||||||
|
user: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<Self, Error> {
|
||||||
|
Self::connect_with_options(address, user, password, Options::default()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect with explicit limits. Passwords are sent over plain TCP by the server protocol.
|
||||||
|
pub async fn connect_with_options(
|
||||||
|
address: impl ToSocketAddrs,
|
||||||
|
user: &str,
|
||||||
|
password: &str,
|
||||||
|
options: Options,
|
||||||
|
) -> Result<Self, Error> {
|
||||||
|
let stream = timeout(options.timeout, TcpStream::connect(address))
|
||||||
|
.await
|
||||||
|
.map_err(|_| Error::Timeout)??;
|
||||||
|
stream.set_nodelay(true)?;
|
||||||
|
let client = Self {
|
||||||
|
connection: Arc::new(Mutex::new(Some(BufReader::new(stream)))),
|
||||||
|
options,
|
||||||
|
};
|
||||||
|
match client
|
||||||
|
.request(json!({"method": "hello", "protocol": 1}))
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
Response::Hello { protocol: 1 } => {}
|
||||||
|
_ => return Err(Error::Protocol("expected hello with protocol 1")),
|
||||||
|
}
|
||||||
|
match client
|
||||||
|
.request(json!({"method": "login", "user": user, "password": password}))
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
Response::Login {} => {}
|
||||||
|
_ => return Err(Error::Protocol("expected login acknowledgement")),
|
||||||
|
}
|
||||||
|
Ok(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute one SQL statement. The server does not support bound parameters.
|
||||||
|
/// Never interpolate untrusted input into SQL.
|
||||||
|
pub async fn query(&self, sql: &str) -> Result<QueryResult, Error> {
|
||||||
|
match self.request(json!({"method": "query", "sql": sql})).await? {
|
||||||
|
Response::Result(result) => {
|
||||||
|
if result
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.any(|row| row.len() != result.columns.len())
|
||||||
|
{
|
||||||
|
self.close().await;
|
||||||
|
return Err(Error::Protocol("row width differs from columns"));
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
self.close().await;
|
||||||
|
Err(Error::Protocol("expected query result"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether the server is reachable on this connection.
|
||||||
|
pub async fn ping(&self) -> Result<(), Error> {
|
||||||
|
match self.request(json!({"method": "ping"})).await? {
|
||||||
|
Response::Pong => Ok(()),
|
||||||
|
_ => {
|
||||||
|
self.close().await;
|
||||||
|
Err(Error::Protocol("expected pong"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the shared connection for all clones; repeated calls are harmless.
|
||||||
|
pub async fn close(&self) {
|
||||||
|
self.connection.lock().await.take();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request(&self, request: Value) -> Result<Response, Error> {
|
||||||
|
let mut bytes = serde_json::to_vec(&request)?;
|
||||||
|
bytes.push(b'\n');
|
||||||
|
let mut guard = self.connection.lock().await;
|
||||||
|
// Taking ownership makes cancellation discard the stream instead of reusing a stale response.
|
||||||
|
let mut connection = guard.take().ok_or(Error::Closed)?;
|
||||||
|
let response = timeout(self.options.timeout, async {
|
||||||
|
connection.get_mut().write_all(&bytes).await?;
|
||||||
|
let mut line = Vec::new();
|
||||||
|
loop {
|
||||||
|
let chunk = connection.fill_buf().await?;
|
||||||
|
if chunk.is_empty() {
|
||||||
|
return Err(Error::Closed);
|
||||||
|
}
|
||||||
|
let end = chunk.iter().position(|byte| *byte == b'\n');
|
||||||
|
let used = end.map_or(chunk.len(), |index| index + 1);
|
||||||
|
if used > self.options.max_response_bytes.saturating_sub(line.len()) {
|
||||||
|
return Err(Error::ResponseTooLarge);
|
||||||
|
}
|
||||||
|
line.extend_from_slice(&chunk[..used]);
|
||||||
|
connection.consume(used);
|
||||||
|
if end.is_some() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok::<Response, Error>(serde_json::from_slice(&line)?)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| Error::Timeout)??;
|
||||||
|
*guard = Some(connection);
|
||||||
|
match response {
|
||||||
|
Response::Error { code, message } => Err(Error::Server { code, message }),
|
||||||
|
other => Ok(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/// A scalar SQL value, encoded for ChuSQL's literal syntax.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum Param<'a> {
|
||||||
|
Text(&'a str),
|
||||||
|
Int(i64),
|
||||||
|
Bool(bool),
|
||||||
|
Null,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> From<&'a str> for Param<'a> {
|
||||||
|
fn from(value: &'a str) -> Self {
|
||||||
|
Self::Text(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<i64> for Param<'_> {
|
||||||
|
fn from(value: i64) -> Self {
|
||||||
|
Self::Int(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<bool> for Param<'_> {
|
||||||
|
fn from(value: bool) -> Self {
|
||||||
|
Self::Bool(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds SQL from trusted syntax and separately encoded values.
|
||||||
|
/// This is client-side literal encoding, not a server prepared statement.
|
||||||
|
/// Never pass user input to `new` or `push`; use `bind` for values.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Query(String);
|
||||||
|
|
||||||
|
impl Query {
|
||||||
|
/// Start with trusted SQL syntax.
|
||||||
|
pub fn new(sql: &str) -> Self {
|
||||||
|
Self(sql.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append trusted SQL syntax, including whitespace as needed.
|
||||||
|
pub fn push(mut self, sql: &str) -> Self {
|
||||||
|
self.0.push_str(sql);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append a scalar value. Strings escape quotes using ChuSQL's `''` rule.
|
||||||
|
/// Does not encode table/column names, SQL expressions, or LIKE patterns.
|
||||||
|
pub fn bind<'a>(mut self, value: impl Into<Param<'a>>) -> Self {
|
||||||
|
match value.into() {
|
||||||
|
Param::Text(text) => {
|
||||||
|
self.0.push('\'');
|
||||||
|
for ch in text.chars() {
|
||||||
|
self.0.push(ch);
|
||||||
|
if ch == '\'' {
|
||||||
|
self.0.push('\'');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.0.push('\'');
|
||||||
|
}
|
||||||
|
Param::Int(number) => self.0.push_str(&number.to_string()),
|
||||||
|
Param::Bool(value) => self.0.push_str(if value { "TRUE" } else { "FALSE" }),
|
||||||
|
Param::Null => self.0.push_str("NULL"),
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The encoded SQL; execute it using `client.query(query.as_str()).await`.
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn user_text_stays_inside_one_literal() {
|
||||||
|
let query = Query::new("INSERT INTO posts VALUES (")
|
||||||
|
.bind("楚的博客'); DROP TABLE posts; --\n\\正文\0")
|
||||||
|
.push(", ")
|
||||||
|
.bind(i64::MIN)
|
||||||
|
.push(", ")
|
||||||
|
.bind(true)
|
||||||
|
.push(", ")
|
||||||
|
.bind(Param::Null)
|
||||||
|
.push(")");
|
||||||
|
assert_eq!(
|
||||||
|
query.as_str(),
|
||||||
|
"INSERT INTO posts VALUES ('楚的博客''); DROP TABLE posts; --\n\\正文\0', -9223372036854775808, TRUE, NULL)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_and_consecutive_quotes_are_preserved() {
|
||||||
|
assert_eq!(Query::new("").bind("").as_str(), "''");
|
||||||
|
assert_eq!(Query::new("").bind("''").as_str(), "''''''");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
use chusql_driver::{Client, Error, Options};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::{
|
||||||
|
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
|
||||||
|
net::TcpListener,
|
||||||
|
};
|
||||||
|
|
||||||
|
async fn server(script: Vec<(Value, &'static str)>) -> (String, tokio::task::JoinHandle<()>) {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let address = listener.local_addr().unwrap().to_string();
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
let (stream, _) = listener.accept().await.unwrap();
|
||||||
|
let mut stream = BufReader::new(stream);
|
||||||
|
for (expected, response) in script {
|
||||||
|
let mut line = String::new();
|
||||||
|
stream.read_line(&mut line).await.unwrap();
|
||||||
|
assert_eq!(serde_json::from_str::<Value>(&line).unwrap(), expected);
|
||||||
|
// Split writes also exercise TCP framing across packet boundaries.
|
||||||
|
for chunk in response.as_bytes().chunks(7) {
|
||||||
|
stream.get_mut().write_all(chunk).await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
(address, task)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handshake() -> Vec<(Value, &'static str)> {
|
||||||
|
vec![
|
||||||
|
(
|
||||||
|
json!({"method":"hello","protocol":1}),
|
||||||
|
"{\"status\":\"hello\",\"protocol\":1,\"server\":\"chusql-server\"}\n",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
json!({"method":"login","user":"app","password":"秘密\npass"}),
|
||||||
|
"{\"status\":\"ok\",\"user\":\"app\",\"admin\":false}\n",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn authenticated_session_preserves_results_and_recovers_from_sql_errors() {
|
||||||
|
let mut script = handshake();
|
||||||
|
script.extend([
|
||||||
|
(json!({"method":"query","sql":"SELECT * FROM users"}), "{\"status\":\"result\",\"columns\":[\"id\",\"name\",\"active\",\"extra\"],\"rows\":[[9223372036854775808,\"小楚\",true,null]],\"rowCount\":1,\"truncated\":true,\"database\":\"app\"}\n"),
|
||||||
|
(json!({"method":"query","sql":"bad SQL"}), "{\"status\":\"error\",\"code\":\"syntax_error\",\"message\":\"invalid SQL\"}\n"),
|
||||||
|
(json!({"method":"ping"}), "{\"status\":\"pong\"}\n"),
|
||||||
|
]);
|
||||||
|
let (address, task) = server(script).await;
|
||||||
|
let client = Client::connect(&address, "app", "秘密\npass")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let result = client.query("SELECT * FROM users").await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result.rows[0],
|
||||||
|
vec![
|
||||||
|
json!(9223372036854775808_u64),
|
||||||
|
json!("小楚"),
|
||||||
|
json!(true),
|
||||||
|
Value::Null
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert!(result.truncated);
|
||||||
|
assert_eq!(result.row_count, 1);
|
||||||
|
assert_eq!(result.database.as_deref(), Some("app"));
|
||||||
|
assert!(
|
||||||
|
matches!(client.query("bad SQL").await, Err(Error::Server { code, .. }) if code == "syntax_error")
|
||||||
|
);
|
||||||
|
client.clone().ping().await.unwrap();
|
||||||
|
client.close().await;
|
||||||
|
assert!(matches!(client.ping().await, Err(Error::Closed)));
|
||||||
|
task.await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejects_wrong_protocol_and_failed_login() {
|
||||||
|
let (address, task) = server(vec![(
|
||||||
|
json!({"method":"hello","protocol":1}),
|
||||||
|
"{\"status\":\"hello\",\"protocol\":2}\n",
|
||||||
|
)])
|
||||||
|
.await;
|
||||||
|
assert!(matches!(
|
||||||
|
Client::connect(address, "app", "pw").await,
|
||||||
|
Err(Error::Protocol(_))
|
||||||
|
));
|
||||||
|
task.await.unwrap();
|
||||||
|
let mut script = handshake();
|
||||||
|
script[1].1 =
|
||||||
|
"{\"status\":\"error\",\"code\":\"unauthorized\",\"message\":\"invalid credentials\"}\n";
|
||||||
|
let (address, task) = server(script).await;
|
||||||
|
assert!(
|
||||||
|
matches!(Client::connect(address, "app", "秘密\npass").await, Err(Error::Server { code, .. }) if code == "unauthorized")
|
||||||
|
);
|
||||||
|
task.await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn malformed_oversized_and_unterminated_responses_discard_connection() {
|
||||||
|
for response in [
|
||||||
|
"not JSON\n",
|
||||||
|
"{\"status\":\"pong\"}",
|
||||||
|
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n",
|
||||||
|
] {
|
||||||
|
let mut script = handshake();
|
||||||
|
script.push((json!({"method":"ping"}), response));
|
||||||
|
let (address, task) = server(script).await;
|
||||||
|
let client = Client::connect_with_options(
|
||||||
|
address,
|
||||||
|
"app",
|
||||||
|
"秘密\npass",
|
||||||
|
Options {
|
||||||
|
max_response_bytes: 128,
|
||||||
|
..Options::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(client.ping().await.is_err());
|
||||||
|
assert!(matches!(client.ping().await, Err(Error::Closed)));
|
||||||
|
task.await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn timeout_and_cancellation_do_not_reuse_pending_responses() {
|
||||||
|
for cancel in [false, true] {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let address = listener.local_addr().unwrap();
|
||||||
|
let (sent, received) = tokio::sync::oneshot::channel();
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
let (stream, _) = listener.accept().await.unwrap();
|
||||||
|
let mut stream = BufReader::new(stream);
|
||||||
|
for (_, response) in handshake() {
|
||||||
|
let mut line = String::new();
|
||||||
|
stream.read_line(&mut line).await.unwrap();
|
||||||
|
stream
|
||||||
|
.get_mut()
|
||||||
|
.write_all(response.as_bytes())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let mut line = String::new();
|
||||||
|
stream.read_line(&mut line).await.unwrap();
|
||||||
|
sent.send(()).unwrap();
|
||||||
|
line.clear();
|
||||||
|
assert_eq!(stream.read_line(&mut line).await.unwrap(), 0);
|
||||||
|
});
|
||||||
|
let client = Client::connect_with_options(
|
||||||
|
address,
|
||||||
|
"app",
|
||||||
|
"秘密\npass",
|
||||||
|
Options {
|
||||||
|
timeout: Duration::from_millis(500),
|
||||||
|
..Options::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let pending = tokio::spawn({
|
||||||
|
let client = client.clone();
|
||||||
|
async move { client.ping().await }
|
||||||
|
});
|
||||||
|
received.await.unwrap();
|
||||||
|
if cancel {
|
||||||
|
pending.abort();
|
||||||
|
assert!(pending.await.unwrap_err().is_cancelled());
|
||||||
|
} else {
|
||||||
|
assert!(matches!(pending.await.unwrap(), Err(Error::Timeout)));
|
||||||
|
}
|
||||||
|
assert!(matches!(client.ping().await, Err(Error::Closed)));
|
||||||
|
task.await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user