4个版本 (2个重大更新)
0.3.1 | 2024年7月22日 |
---|---|
0.3.0 | 2024年7月14日 |
0.2.0 | 2024年6月28日 |
0.1.0 | 2024年6月24日 |
#1057 in 过程宏
每月284次下载
用于 nanosql
70KB
1.5K SLoC
NanoSQL:SQLite的轻量级强类型数据映射器
NanoSQL是一个小型的数据映射库,它帮助您使用强类型参数和结果集执行SQL语句。它不尝试对您的SQL代码进行类型检查。相反,它只确保参数和结果可以正确地序列化和反序列化。
概述
该库围绕预编译语句构建。首先,您创建查询的接口和实现描述。这通过包含输入(参数)和输出(结果)类型作为关联类型以及构建SQL文本函数的Query
trait来实现。您可以手动实现此trait,或者使用define_query
宏作为便捷的快捷方式。
接下来,您使用Connection::compile()
将Query
编译成CompiledStatement
。这包装了一个SQLite预编译语句,但限制了其参数和返回类型。
最后,您在编译后的语句上调用CompiledStatement::invoke()
函数来实际查询数据库
- 查询的输入可以是实现了
Param
特性的任何类型。这包括基本类型、基本类型的可选类型、基本类型或可选类型的元组以及具有基本类型或可选类型字段的 struct。Nanosql 可以与位置参数和命名参数一起工作,并支持 SQLite 接受的所有参数前缀(?
、:
、@
和$
)。如果激活了 crate 的derive
功能,则可以推导出此特性。 - 查询的输出是实现了
ResultSet
特性的类型。这通常是一种类集合(例如,标准Vec
类型),或者任何其他将 SQL 查询返回的行聚合到有意义的数据结构中的类型。值得注意的是,Option<T>
和Single
<T>
可以用来期望最多一个或正好一个记录。这些类型在它们的包装值类型实现了ResultRecord
时实现ResultSet
。 ResultRecord
是一个可以由类似元组和类似 struct 的类型实现以反序列化单个行的特性。此特性也可以#[derive]
。
通过 Connection
对象的特殊辅助/扩展方法,可以执行诸如创建表模式和插入记录等极其常见(基本上不可避免)的任务,这些方法通过 ConnectionExt
特性实现。这些方法进一步使用 Table
特性来准备和调用相应的 SQL 语句,并可以方便地调用。
示例
最基本的内容 - 创建一个表,将一些记录插入其中,然后检索它们
use std::fmt::{self, Formatter};
use nanosql::{
Result, Connection, ConnectionExt, Query,
ToSql, FromSql, AsSqlTy, Param, ResultRecord, Table
};
/// This type is going to represent our table.
///
/// We derive the `Param` trait for it so that it can be used for
/// binding parameters to the statement when inserting new records,
/// and the `ResultRecord` trait so that we can use it to retrieve
/// results, too.
///
/// We also derive the `Table` trait so that basic operations such as
/// creating the table in the schema and bulk insertion can be performed
/// using the appropriate convenience methods in [`ConnectionExt`].
///
/// The parameter prefix is '$' by default (if not specified via the
/// param_prefix attribute); it may also be one of ':', '@', or '?',
/// the last one being allowed only for tuples and scalar parameters.
///
/// `#[nanosql(rename)]` on a struct renames the table itself, while
/// `#[nanosql(rename_all)]` applies a casing convention to all columns.
#[derive(Clone, PartialEq, Eq, Hash, Debug, Param, ResultRecord, Table)]
#[nanosql(param_prefix = '$')] // optional
#[nanosql(rename = "MyLittlePet", rename_all = "lowerCamelCase")]
struct Pet {
/// If you don't like the default `AsSqlTy` impl for your column's
/// type, you can specify a different one. Here we add a non-zero
/// constraint, but the `id` remains a plain `i64` for convenience.
///
/// You can also add additional `CHECK` constraints, if necessary.
#[nanosql(sql_ty = core::num::NonZeroI64, check = "id <= 999999")]
id: i64,
/// You can apply a `UNIQUE` constraint to any field.
#[nanosql(unique)]
nick_name: String,
/// You can also rename fields/columns one by one
#[nanosql(rename = "type")]
kind: PetKind,
}
/// Collective and field-level casing/renaming also works with `enum`s
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, ToSql, FromSql, AsSqlTy)]
#[nanosql(rename_all = "UPPER_SNAKE_CASE")]
enum PetKind {
Dog,
#[nanosql(rename = "KITTEN")]
Cat,
Fish,
}
/// Our first custom query retrieves a pet by its unique ID.
///
/// If you don't want to spell out the impl by hand, you can
/// use the `define_query!{}` macro for a shorter incantation.
struct PetById;
impl Query for PetById {
/// The type of the parameter(s). This can be a single scalar, a tuple,
/// a tuple struct of scalars, or a struct with named fields of scalar types.
type Input<'p> = i64;
/// The return type of a query can be either a scalar, a single record (struct or
/// tuple), or an optional of a scalar/record (when it returns either 0 or 1 rows),
/// or a collection of arbitrarily many scalars/records. Here we choose an `Option`,
/// because a given ID corresponds to at most one `Pet`.
type Output = Option<Pet>;
/// Finally, we create the actual SQL query.
fn format_sql(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
formatter.write_str("SELECT id, nickName, type FROM MyLittlePet WHERE id = ?")
}
}
fn main() -> Result<()> {
// First, we open a database connection.
let mut conn = Connection::connect_in_memory()?;
// Then, we ensure that the table exists in the schema.
conn.create_table::<Pet>()?;
// Next, we insert a couple of records so we have test data to work on.
conn.insert_batch([
Pet {
id: 1,
nick_name: "Fluffy".into(),
kind: PetKind::Dog,
},
Pet {
id: 2,
nick_name: "Hello Kitty".into(),
kind: PetKind::Cat,
},
Pet {
id: 3,
nick_name: "Nemo".into(),
kind: PetKind::Fish,
},
])?;
// We then compile the query into a prepared statement.
let mut stmt = conn.compile(PetById)?;
// Finally, we execute the compiled statement, passing parameters, and retrieve the results.
let result = stmt.invoke(3)?;
assert_eq!(result, Some(Pet {
id: 3,
nick_name: "Nemo".into(),
kind: PetKind::Fish,
}));
// We can re-use the statement and execute it multiple times
let result = stmt.invoke(99)?;
assert_eq!(result, None);
drop(stmt);
// Inserting a pet with id = 0 should fail due to the `#[nanosql(sql_ty = ...)]` attribute.
let insert_id_0_result = conn.insert_batch([
Pet {
id: 0,
nick_name: "Error".into(),
kind: PetKind::Cat,
}
]);
assert!(insert_id_0_result.is_err(), "id = 0 violates NonZeroI64's CHECK constraint");
// Inserting a pet with a high ID is expected to fail due to the CHECK constraint.
let insert_id_high_result = conn.insert_batch([
Pet {
id: 1000000,
nick_name: "this is unique".into(),
kind: PetKind::Dog,
}
]);
assert!(insert_id_high_result.is_err(), "id = 1000000 violates extra CHECK constraint");
// Inserting a pet with a duplicate name is not allowed due to `#[nanosql(unique)]`.
let insert_dup_name_result = conn.insert_batch([
Pet {
id: 137731,
nick_name: "Hello Kitty".into(),
kind: PetKind::Dog,
}
]);
assert!(insert_dup_name_result.is_err(), "duplicate name violates uniqueness constraint");
Ok(())
}
有关更高级和有趣的示例,请参阅 nanosql/examples
目录(特别是 realistic.rs
)。
关于批量插入和事务的注意事项
方法 [ConnectionExt::insert_batch()
] 将插入语句封装在事务中以提高性能。事务的排他性通过在类型级别上使用 rusqlite
的 Connection
对象可变(唯一)借用来实现,该对象在事务期间被可变借用。这意味着 insert_batch()
也需要可变借用。然而,准备和调用查询需要不可变借用,并且预准备语句将 Connection
借用为其生命周期。因此,当混合批量插入与其他查询时,可能会出现“无法以可变方式借用 connection
,因为它也被以不可变方式借用”的错误。解决这个问题有两个基本方法
- 在调用 [
ConnectionExt::insert_batch()
] 之前删除未完成的预准备语句; - 或者如果您不能这样做,那么可以使用 [
Connection::unchecked_transaction()
] 获取一个不需要在编译时检查排他性的事务对象,然后在事务对象上调用不可变借用 [TransactionExt::insert_batch()
] 方法。
Cargo 功能
derive
:激活过程宏 - 主要为常用特性创建自定义的#[derive]
。默认启用。expr-check
:使用sqlparser
包在编译时检查 derive 宏属性中原始 SQL 表达式的语法错误。这确保了生成的任何 SQL 都是有效的,并且用户提供的 SQL 代码中的语法错误将被清楚地指出,而不是在运行时导致神秘的语句准备错误。默认启用。not-nan
:为ordered_float::NotNan
实现Param
和ResultRecord
。这允许在查询中获得更安全的类型接口:由于 SQLite 将NaN
视为 SQL 的NULL
值,因此当绑定或检索一个f32::NAN
或f64::NAN
以及相应的参数需要被NOT NULL
时,或者源列 可以 为NULL
,您可能会遇到意外的错误。pretty-eqp
:使用ptree
包来美化打印EXPLAIN QUERY PLAN
的结果。这将实现Display
对于 [ConnectionExt::explain_query_plan()
] 的返回类型QueryPlan
,以使用 ASCII 艺术渲染树,呈现为便于阅读的格式。
关于测试套件的说明
-
测试尝试全面测试库的所有功能。因此,它们依赖于在
Cargo.toml
中定义的大多数或所有Cargo功能。因此,为了成功编译和运行所有测试,您必须在Cargo中传递cargo test --all-features
。 -
compiletest_rs
包用于确保派生宏能够检测某些类型的错误,例如多个主键或引用不存在列的表级约束。由于
compiletest_rs
包的结构方式,测试可能会有些不可靠。如果您遇到E0464
错误(例如,“找到rlib
依赖nanosql
的多个候选者”),则在运行cargo test compile_fail --all-features
之前,先运行cargo clean
。
TL;DR: 运行测试的最佳“懒惰”方式是./runtests.sh
脚本,它只是做
cargo clean
cargo test --workspace --all-features
依赖项
~2.5–3.5MB
~71K SLoC