Initial Dever 0.0.1
This commit is contained in:
commit
81233d098c
1479 files changed
+163782
No files matched your search
@@ -0,0 +1,366 @@
|
||||
use dever_runtime::{
|
||||
adapter::Document,
|
||||
database::{Database, Error, Kind, Options, Parameter, Settings},
|
||||
task::{Limits, Runtime},
|
||||
};
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
mod performance;
|
||||
mod scalar;
|
||||
mod schema;
|
||||
|
||||
struct Directory(PathBuf);
|
||||
impl Directory {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"dever-database-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
std::fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
impl Drop for Directory {
|
||||
fn drop(&mut self) {
|
||||
std::fs::remove_dir_all(&self.0).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn options() -> Options {
|
||||
Options {
|
||||
driver: "sqlite".into(),
|
||||
path: "data/case.db".into(),
|
||||
url: None,
|
||||
tls: None,
|
||||
tenant_directory: None,
|
||||
tenant_database_prefix: None,
|
||||
min_connections: 1,
|
||||
max_connections: 1,
|
||||
max_page_size: 20,
|
||||
wait_timeout_ms: 100,
|
||||
io_timeout_ms: 1000,
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime() -> Runtime {
|
||||
Runtime::new(Limits {
|
||||
threads: 2,
|
||||
workers: 2,
|
||||
tasks: 16,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
const CREATE: &str = "CREATE TABLE account (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE);";
|
||||
|
||||
#[test]
|
||||
fn pool_and_checked_out_transaction_drop_outside_runtime_in_any_order() {
|
||||
for order in 0..3 {
|
||||
let directory = Directory::new();
|
||||
let runtime = runtime();
|
||||
let (database, executor) = runtime
|
||||
.run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor.initialize(scope, CREATE).await.unwrap();
|
||||
executor.begin(scope).await.unwrap();
|
||||
executor
|
||||
.execute(
|
||||
scope,
|
||||
"INSERT INTO account(name) VALUES ('uncommitted')",
|
||||
vec![],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
(database, executor)
|
||||
})
|
||||
.unwrap();
|
||||
match order {
|
||||
0 => {
|
||||
drop(executor);
|
||||
database.close();
|
||||
drop(database);
|
||||
drop(runtime);
|
||||
}
|
||||
1 => {
|
||||
drop(database);
|
||||
drop(executor);
|
||||
drop(runtime);
|
||||
}
|
||||
_ => {
|
||||
drop(runtime);
|
||||
drop(database);
|
||||
drop(executor);
|
||||
}
|
||||
}
|
||||
self::runtime()
|
||||
.run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let count = database
|
||||
.executor()
|
||||
.await
|
||||
.unwrap()
|
||||
.query(scope, "SELECT COUNT(*) FROM account", vec![], 1, |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, [0]);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transaction_commit_rollback_and_typed_rows() {
|
||||
let directory = Directory::new();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor.initialize(scope, CREATE).await.unwrap();
|
||||
let owner = executor.begin(scope).await.unwrap();
|
||||
assert!(owner);
|
||||
executor
|
||||
.execute(
|
||||
scope,
|
||||
"INSERT INTO account(name) VALUES (?1)",
|
||||
vec![Parameter::Text("Dever 中文".into())],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let nested = executor.begin(scope).await.unwrap();
|
||||
assert!(!nested);
|
||||
executor.finish(scope, nested, true).await.unwrap();
|
||||
executor.finish(scope, owner, true).await.unwrap();
|
||||
let owner = executor.begin(scope).await.unwrap();
|
||||
executor
|
||||
.execute(
|
||||
scope,
|
||||
"INSERT INTO account(name) VALUES (?1)",
|
||||
vec![Parameter::Text("rollback".into())],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
executor.finish(scope, owner, false).await.unwrap();
|
||||
let rows = executor
|
||||
.query(
|
||||
scope,
|
||||
"SELECT id, name FROM account ORDER BY id",
|
||||
vec![],
|
||||
20,
|
||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows, vec![(1, "Dever 中文".into())]);
|
||||
drop(executor);
|
||||
drop(database.executor().await.unwrap());
|
||||
database.close();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_failure_prevents_outer_commit_and_preserves_pool() {
|
||||
let directory = Directory::new();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor.initialize(scope, CREATE).await.unwrap();
|
||||
let outer = executor.begin(scope).await.unwrap();
|
||||
executor
|
||||
.execute(
|
||||
scope,
|
||||
"INSERT INTO account(name) VALUES ('temporary')",
|
||||
vec![],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let inner = executor.begin(scope).await.unwrap();
|
||||
executor.finish(scope, inner, false).await.unwrap();
|
||||
assert_eq!(
|
||||
executor.finish(scope, outer, true).await.unwrap_err().kind,
|
||||
Kind::Database
|
||||
);
|
||||
drop(executor);
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
let count = executor
|
||||
.query(scope, "SELECT COUNT(*) FROM account", vec![], 1, |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, vec![0]);
|
||||
database.close();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binds_errors_and_result_bounds_are_real_database_checks() {
|
||||
let directory = Directory::new();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor.initialize(scope, CREATE).await.unwrap();
|
||||
let input = "'); DROP TABLE account; --";
|
||||
executor
|
||||
.execute(
|
||||
scope,
|
||||
"INSERT INTO account(name) VALUES (?1)",
|
||||
vec![Parameter::Text(input.into())],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let error = executor
|
||||
.execute(
|
||||
scope,
|
||||
"INSERT INTO account(name) VALUES (?1)",
|
||||
vec![Parameter::Text(input.into())],
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind, Kind::Constraint);
|
||||
assert!(!error.to_string().contains(input));
|
||||
let value = executor
|
||||
.query(scope, "SELECT name FROM account", vec![], 1, |row| {
|
||||
row.get::<_, String>(0)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(value, vec![input]);
|
||||
let invalid = executor
|
||||
.query(scope, "SELECT name FROM account", vec![], 1, |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(invalid.kind, Kind::InvalidData);
|
||||
let overflow = executor
|
||||
.query(scope, "SELECT 1 UNION ALL SELECT 2", vec![], 1, |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(overflow.kind, Kind::InvalidData);
|
||||
let exhausted = database.executor().await.err().unwrap();
|
||||
assert_eq!(exhausted.kind, Kind::PoolExhausted);
|
||||
database.close();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_discards_transaction_before_next_borrow() {
|
||||
let directory = Directory::new();
|
||||
runtime().run(async |scope| {
|
||||
let mut config = options();
|
||||
config.io_timeout_ms = 50;
|
||||
let database = Database::open(&directory.0, config, scope).await.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor.initialize(scope, CREATE).await.unwrap();
|
||||
executor.begin(scope).await.unwrap();
|
||||
executor.execute(scope, "INSERT INTO account(name) VALUES ('must rollback')", vec![]).await.unwrap();
|
||||
let result = executor.query(scope, "WITH RECURSIVE numbers(value) AS (VALUES(1) UNION ALL SELECT value + 1 FROM numbers WHERE value < 1000000000) SELECT SUM(value) FROM numbers", vec![], 1, |row| row.get::<_, i64>(0)).await;
|
||||
assert_eq!(result.unwrap_err().kind, Kind::Timeout);
|
||||
drop(executor);
|
||||
let mut replacement = database.executor().await.unwrap();
|
||||
let count = replacement.query(scope, "SELECT COUNT(*) FROM account", vec![], 1, |row| row.get::<_, i64>(0)).await.unwrap();
|
||||
assert_eq!(count, vec![0]);
|
||||
database.close();
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_and_rollback_cause_do_not_expose_secrets() {
|
||||
for tenant in ["{}", "null"] {
|
||||
let document = Document::parse(format!(r#"{{"tenant":{tenant},"database":{{"default":{{"type":"sqlite","path":"data/app.db"}}}}}}"#)).unwrap();
|
||||
assert!(Settings::load(&document).is_err());
|
||||
}
|
||||
let settings = Settings::load(
|
||||
&Document::parse(
|
||||
r#"{"database":{"default":{"type":"sqlite","path":"data/app.db"}}}"#.into(),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(settings.select("demo", None).unwrap().max_connections, 1);
|
||||
assert!(settings.select("demo", Some("missing")).is_err());
|
||||
for path in ["/tmp/escape", "../escape", "data/../escape", ""] {
|
||||
let mut value = options();
|
||||
value.path = path.into();
|
||||
assert!(value.validate().is_err());
|
||||
}
|
||||
let error = Error::new(Kind::Constraint, "business failure")
|
||||
.with_cause(Error::new(Kind::Connection, "rollback failed"))
|
||||
.with_cause(Error::new(Kind::Pool, "pool close failed"));
|
||||
assert_eq!(error.kind, Kind::Constraint);
|
||||
assert!(error.to_string().contains("rollback failed"));
|
||||
assert!(error.to_string().contains("pool close failed"));
|
||||
let rollback = error.cause.as_ref().unwrap();
|
||||
assert_eq!(rollback.kind, Kind::Connection);
|
||||
assert_eq!(rollback.cause.as_ref().unwrap().kind, Kind::Pool);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_drift_fails_before_touching_data_or_history() {
|
||||
let directory = Directory::new();
|
||||
runtime().run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope).await.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
const SCHEMA: &str = "CREATE TABLE account (id INTEGER PRIMARY KEY, name TEXT NOT NULL); CREATE INDEX account_name ON account(name);";
|
||||
executor.schema(scope, "account", SCHEMA).await.unwrap();
|
||||
executor.schema(scope, "account", SCHEMA).await.unwrap();
|
||||
executor.execute(scope, "INSERT INTO account(name) VALUES ('preserved')", vec![]).await.unwrap();
|
||||
executor.initialize(scope, "DROP INDEX account_name;").await.unwrap();
|
||||
assert_eq!(executor.schema(scope, "account", SCHEMA).await.unwrap_err().kind, Kind::Migration);
|
||||
let values = executor.query(scope, "SELECT name FROM account", vec![], 1, |row| row.get::<_, String>(0)).await.unwrap();
|
||||
assert_eq!(values, vec!["preserved"]);
|
||||
database.close();
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopping_a_running_query_rolls_back_and_releases_its_worker() {
|
||||
let directory = Directory::new();
|
||||
runtime().run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope).await.unwrap();
|
||||
let mut setup = database.executor().await.unwrap();
|
||||
setup.initialize(scope, CREATE).await.unwrap();
|
||||
drop(setup);
|
||||
let copy = database.clone();
|
||||
let (started, ready) = tokio::sync::oneshot::channel();
|
||||
let task = scope.spawn(move |child| async move {
|
||||
let mut executor = copy.executor().await.unwrap();
|
||||
executor.begin(&child).await.unwrap();
|
||||
executor.execute(&child, "INSERT INTO account(name) VALUES ('cancelled')", vec![]).await.unwrap();
|
||||
started.send(()).unwrap();
|
||||
executor.query(&child, "WITH RECURSIVE numbers(value) AS (VALUES(1) UNION ALL SELECT value + 1 FROM numbers WHERE value < 1000000000) SELECT SUM(value) FROM numbers", vec![], 1, |row| row.get::<_, i64>(0)).await
|
||||
}).unwrap();
|
||||
ready.await.unwrap();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
assert!(task.stop().await.unwrap().is_none());
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
let count = executor.query(scope, "SELECT COUNT(*) FROM account", vec![], 1, |row| row.get::<_, i64>(0)).await.unwrap();
|
||||
assert_eq!(count, vec![0]);
|
||||
database.close();
|
||||
}).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//! PostgreSQL 文档规定的 NUMERIC 分组字节;预期值独立于编码实现。
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn numeric_encoding_matches_protocol_words() {
|
||||
for (text, precision, scale, words) in [
|
||||
("0", 5, 2, vec![0, 0, 0, 2]),
|
||||
("-12.34", 5, 2, vec![2, 0, 0x4000, 2, 12, 3400]),
|
||||
("0.00001", 5, 5, vec![1, -2, 0, 5, 1000]),
|
||||
("10000", 5, 0, vec![1, 1, 0, 0, 1]),
|
||||
("12345678.9012", 12, 4, vec![3, 1, 0, 4, 1234, 5678, 9012]),
|
||||
(
|
||||
"9999999999999999999999999999999999",
|
||||
34,
|
||||
0,
|
||||
vec![
|
||||
9, 8, 0, 0, 99, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999,
|
||||
],
|
||||
),
|
||||
] {
|
||||
let value = Decimal::parse(text).unwrap();
|
||||
let expected = words
|
||||
.into_iter()
|
||||
.flat_map(i16::to_be_bytes)
|
||||
.collect::<Vec<_>>();
|
||||
let mut output = BytesMut::new();
|
||||
encode(value, precision, scale, &mut output).unwrap();
|
||||
assert_eq!(output.as_ref(), expected, "{text}");
|
||||
assert_eq!(decode(&output).unwrap(), value);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_numeric_preserves_decimal_extremes_and_zero() {
|
||||
for text in [
|
||||
"0",
|
||||
"-0",
|
||||
"0E-6176",
|
||||
"1E-6176",
|
||||
"1E6111",
|
||||
"-123.456",
|
||||
"1.0000",
|
||||
"9.999999999999999999999999999999999E6144",
|
||||
] {
|
||||
let value = Decimal::parse(text).unwrap();
|
||||
let mut output = BytesMut::new();
|
||||
encode_native(value, &mut output);
|
||||
assert_eq!(decode(&output).unwrap(), value, "{text}");
|
||||
}
|
||||
for (text, precision, scale) in [("1.001", 5, 2), ("1000", 5, 2), ("1", 35, 0), ("1", 2, 3)] {
|
||||
assert!(
|
||||
encode(
|
||||
Decimal::parse(text).unwrap(),
|
||||
precision,
|
||||
scale,
|
||||
&mut BytesMut::new()
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_decode_rejects_malformed_or_inexact_values() {
|
||||
for words in [
|
||||
vec![1, 0, 0, 0],
|
||||
vec![-1, 0, 0, 0],
|
||||
vec![1, 0, 0, 0, 10000],
|
||||
vec![0, 0, 0xc000_u16 as i16, 0],
|
||||
vec![0, 0, 0, -1],
|
||||
vec![1, 1537, 0, 0, 1],
|
||||
vec![1, -1545, 0, 0, 1],
|
||||
vec![
|
||||
9, 8, 0, 0, 999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999,
|
||||
],
|
||||
] {
|
||||
let bytes = words
|
||||
.into_iter()
|
||||
.flat_map(i16::to_be_bytes)
|
||||
.collect::<Vec<_>>();
|
||||
assert!(decode(&bytes).is_err(), "{bytes:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit release performance probe"]
|
||||
fn numeric_decode_cost() {
|
||||
use std::{hint::black_box, time::Instant};
|
||||
println!("\ncase,sample,iterations,elapsed_ns,checksum");
|
||||
for (name, text) in [
|
||||
("zero", "0"),
|
||||
("fraction", "-1234567890123456789012345.123456789"),
|
||||
("exponent", "9.999999999999999999999999999999999E6144"),
|
||||
] {
|
||||
let value = Decimal::parse(text).unwrap();
|
||||
let mut encoded = BytesMut::new();
|
||||
encode_native(value, &mut encoded);
|
||||
let expected = decode(&encoded).unwrap();
|
||||
assert_eq!(expected, value);
|
||||
for _ in 0..100 {
|
||||
black_box(decode(&encoded).unwrap());
|
||||
}
|
||||
for sample in 0..15 {
|
||||
let started = Instant::now();
|
||||
let mut decoded = Decimal::ZERO;
|
||||
for _ in 0..100_000 {
|
||||
decoded = black_box(decode(black_box(&encoded)).unwrap());
|
||||
}
|
||||
let elapsed = started.elapsed().as_nanos();
|
||||
assert_eq!(decoded, expected);
|
||||
let checksum: u64 = decoded
|
||||
.to_be_bytes()
|
||||
.iter()
|
||||
.map(|byte| u64::from(*byte))
|
||||
.sum();
|
||||
println!("numeric_decode_{name},{sample},100000,{elapsed},{checksum}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
use super::*;
|
||||
use std::{hint::black_box, time::Instant};
|
||||
|
||||
#[test]
|
||||
fn json_constraint_keeps_validation_without_constructing_a_value() {
|
||||
let directory = Directory::new();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
let sources = [
|
||||
"null".to_owned(),
|
||||
"1e9999".into(),
|
||||
r#"{"name":"雪\n🙂","values":[1,true]}"#.into(),
|
||||
r#"{"a":1,"a":2}"#.into(),
|
||||
r#""\ud800""#.into(),
|
||||
"[1,]".into(),
|
||||
"null true".into(),
|
||||
"[".repeat(257) + &"]".repeat(257),
|
||||
format!("[{}0]", "0,".repeat(65535)),
|
||||
format!("\"{}\"", "x".repeat(16 * 1024 * 1024)),
|
||||
];
|
||||
for source in sources {
|
||||
let expected = dever_runtime::wire::json::Json::parse(&source).is_ok();
|
||||
let actual = executor
|
||||
.query(
|
||||
scope,
|
||||
"SELECT _dever_json(?1)",
|
||||
vec![Parameter::Text(source)],
|
||||
1,
|
||||
|row| row.get::<_, bool>(0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(actual, [expected]);
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit release SQL validation performance probe"]
|
||||
fn sqlite_json_check_cost() {
|
||||
let directory = Directory::new();
|
||||
let runtime = runtime();
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor
|
||||
.initialize(scope, "CREATE TABLE documents (body TEXT NOT NULL)")
|
||||
.await
|
||||
.unwrap();
|
||||
println!("\ncase,sample,iterations,elapsed_ns,checksum");
|
||||
for (bytes, iterations) in [(64, 100), (4096, 100), (65536, 10)] {
|
||||
executor
|
||||
.execute(scope, "DELETE FROM documents", vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
let source = format!("\"{}\"", "x".repeat(bytes - 2));
|
||||
executor
|
||||
.execute(
|
||||
scope,
|
||||
"WITH RECURSIVE rows(id) AS (SELECT 1 UNION ALL SELECT id+1 FROM rows WHERE id<256) INSERT INTO documents SELECT ?1 FROM rows",
|
||||
vec![Parameter::Text(source)],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// 实际列逐行调用约束;避免常量折叠只验证一次参数。
|
||||
for sample in 0..16 {
|
||||
let start = Instant::now();
|
||||
let mut checksum = 0i64;
|
||||
for _ in 0..iterations {
|
||||
let result = executor
|
||||
.query(
|
||||
scope,
|
||||
"SELECT SUM(_dever_json(body)) FROM documents",
|
||||
vec![],
|
||||
1,
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
checksum += black_box(result[0]);
|
||||
}
|
||||
let elapsed = start.elapsed().as_nanos();
|
||||
assert_eq!(checksum, iterations * 256);
|
||||
if sample != 0 {
|
||||
println!("sqlite_json_check_{bytes},{},{iterations},{elapsed},{checksum}", sample - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
use super::*;
|
||||
use crate::database::{Error, Options, Parameter};
|
||||
use futures_util::FutureExt;
|
||||
use std::panic::AssertUnwindSafe;
|
||||
use tokio_postgres::Client;
|
||||
|
||||
mod cases;
|
||||
mod codec;
|
||||
mod constraints;
|
||||
#[cfg(feature = "net")]
|
||||
mod registry;
|
||||
|
||||
const ID: Column = Column {
|
||||
constraints: &[("account_pkey", "p", "PRIMARY KEY (\"id\")")],
|
||||
..column("id", "bigint")
|
||||
};
|
||||
const NAME: Column = Column {
|
||||
constraints: &[("_dever_unique_account_1", "u", "UNIQUE (\"name\")")],
|
||||
..column("name", "text")
|
||||
};
|
||||
const TITLE: Column = Column {
|
||||
constraints: &[("_dever_unique_account_1", "u", "UNIQUE (\"title\")")],
|
||||
..column("title", "text")
|
||||
};
|
||||
const UID: Column = Column {
|
||||
generated: true,
|
||||
constraints: &[("_dever_unique_account_3", "u", "UNIQUE (\"uid\")")],
|
||||
..column("uid", "uuid")
|
||||
};
|
||||
|
||||
const INITIAL: &str = "CREATE TABLE \"account\" (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name text NOT NULL CONSTRAINT _dever_unique_account_1 UNIQUE, legacy bigint NOT NULL DEFAULT 4, amount numeric(6,2) NOT NULL DEFAULT 1.25);";
|
||||
const FINAL: &str = "CREATE TABLE \"account\" (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, title text NOT NULL CONSTRAINT _dever_unique_account_1 UNIQUE, amount numeric(8,3) NOT NULL DEFAULT 1.25, uid uuid NOT NULL CONSTRAINT _dever_unique_account_3 UNIQUE);";
|
||||
const INDEX: &str = "CREATE INDEX account_title ON account(title);";
|
||||
const HISTORICAL: Migration = Migration {
|
||||
name: "historical",
|
||||
revision: "historical-one",
|
||||
drops: &["ancient"],
|
||||
before: &[statement("UPDATE account SET ancient = 1")],
|
||||
after: &[statement("UPDATE account SET ancient = 2")],
|
||||
};
|
||||
const UPGRADE: Migration = Migration {
|
||||
name: "rename",
|
||||
revision: "rename-one",
|
||||
drops: &["legacy"],
|
||||
before: &[statement("UPDATE account SET name = name || '_before'")],
|
||||
after: &[statement("UPDATE account SET title = title || '_after'")],
|
||||
};
|
||||
const INITIAL_PLAN: Plan = Plan {
|
||||
create: Sql::same(INITIAL),
|
||||
postgres_columns: &[
|
||||
ID,
|
||||
NAME,
|
||||
Column {
|
||||
default: Some("4"),
|
||||
..column("legacy", "bigint")
|
||||
},
|
||||
Column {
|
||||
default: Some("1.25"),
|
||||
..column("amount", "numeric(6,2)")
|
||||
},
|
||||
],
|
||||
migrations: &[HISTORICAL],
|
||||
seeds: &[Statement {
|
||||
sql: Sql::same("INSERT INTO account(name) VALUES ($1) ON CONFLICT(name) DO NOTHING"),
|
||||
parameters: || Ok(vec![Parameter::Text("seed".into())]),
|
||||
}],
|
||||
seed_revision: "initial-seed",
|
||||
..EMPTY
|
||||
};
|
||||
const FINAL_PLAN: Plan = Plan {
|
||||
create: Sql::same(FINAL),
|
||||
indexes: Sql::same(INDEX),
|
||||
postgres_columns: &[
|
||||
ID,
|
||||
TITLE,
|
||||
Column {
|
||||
default: Some("1.25"),
|
||||
..column("amount", "numeric(8,3)")
|
||||
},
|
||||
UID,
|
||||
],
|
||||
migrations: &[HISTORICAL, UPGRADE],
|
||||
renames: &[("title", "name")],
|
||||
seeds: &[Statement {
|
||||
sql: Sql::same(
|
||||
"INSERT INTO account(title,uid) VALUES ($1,$2::uuid) ON CONFLICT(title) DO NOTHING",
|
||||
),
|
||||
parameters: || {
|
||||
Ok(vec![
|
||||
Parameter::Text("new".into()),
|
||||
Parameter::Uuid(*crate::database::scalar::generated_uuid()?.as_bytes()),
|
||||
])
|
||||
},
|
||||
}],
|
||||
seed_revision: "final-seed",
|
||||
};
|
||||
|
||||
const fn statement(sql: &'static str) -> Statement {
|
||||
Statement {
|
||||
sql: Sql::same(sql),
|
||||
parameters: || Ok(vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated config/setting.json database.postgres_test"]
|
||||
fn schema_migration_seed_uuid_and_rollback() {
|
||||
fixture(cases::migrations);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated config/setting.json database.postgres_test"]
|
||||
fn catalog_drift_fails_before_data_or_history_mutation() {
|
||||
fixture(cases::drift);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated config/setting.json database.postgres_test"]
|
||||
fn new_database_records_final_history_without_old_transforms() {
|
||||
fixture(cases::fresh);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated config/setting.json database.postgres_test"]
|
||||
fn native_binary_codecs_validate_precision_and_parameter_types() {
|
||||
fixture(codec::roundtrip);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated config/setting.json database.postgres_test"]
|
||||
fn foreign_keys_and_tightening_validate_existing_data() {
|
||||
fixture(constraints::foreign_keys);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated config/setting.json database.postgres_test"]
|
||||
fn numeric_migrations_reject_precision_loss() {
|
||||
fixture(constraints::numeric);
|
||||
}
|
||||
|
||||
fn configuration() -> serde_json::Value {
|
||||
let path =
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../config/setting.json");
|
||||
let document =
|
||||
crate::adapter::Document::load(&path).expect("cannot read PostgreSQL test settings");
|
||||
let root: serde_json::Value = document.decode().expect("invalid PostgreSQL test settings");
|
||||
root.get("database")
|
||||
.and_then(|database| database.get("postgres_test"))
|
||||
.expect("database.postgres_test must explicitly select an isolated test database")
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn options() -> Options {
|
||||
let options: Options = serde_json::from_value(configuration())
|
||||
.unwrap_or_else(|_| panic!("invalid PostgreSQL test options"));
|
||||
assert_eq!(options.driver, "postgres");
|
||||
options
|
||||
}
|
||||
|
||||
fn fixture(check: impl AsyncFnOnce(&mut Client)) {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
runtime.block_on(isolated(options(), check));
|
||||
}
|
||||
|
||||
#[cfg(feature = "net")]
|
||||
fn registry_fixture(check: impl AsyncFnOnce(&mut Client, &crate::task::Scope<'_>)) {
|
||||
let runtime = crate::task::Runtime::new(crate::task::Limits {
|
||||
threads: 1,
|
||||
workers: 1,
|
||||
tasks: 16,
|
||||
})
|
||||
.unwrap();
|
||||
runtime
|
||||
.run(async |scope| isolated(options(), async |client| check(client, scope).await).await)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn isolated(options: Options, check: impl AsyncFnOnce(&mut Client)) {
|
||||
let database = super::super::Database::open(options).await.unwrap();
|
||||
let mut client = database
|
||||
.pool
|
||||
.get()
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("cannot acquire PostgreSQL test connection"));
|
||||
let name = format!(
|
||||
"dever_test_{}",
|
||||
crate::database::scalar::generated_uuid()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.replace('-', "")
|
||||
);
|
||||
let quoted = schema::quote(&name);
|
||||
client
|
||||
.batch_execute(&format!("CREATE SCHEMA {quoted}"))
|
||||
.await
|
||||
.map_err(Error::from)
|
||||
.unwrap();
|
||||
let result = AssertUnwindSafe(async {
|
||||
client
|
||||
.batch_execute(&format!("SET search_path TO {quoted}"))
|
||||
.await
|
||||
.map_err(Error::from)
|
||||
.unwrap();
|
||||
tokio::time::timeout(std::time::Duration::from_secs(30), check(&mut client))
|
||||
.await
|
||||
.expect("PostgreSQL test deadline exceeded");
|
||||
})
|
||||
.catch_unwind()
|
||||
.await;
|
||||
let cleanup = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
client.batch_execute(&format!(
|
||||
"ROLLBACK; SET search_path TO pg_catalog; DROP SCHEMA {quoted} CASCADE"
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
database.close();
|
||||
cleanup
|
||||
.expect("PostgreSQL test cleanup timed out")
|
||||
.map_err(Error::from)
|
||||
.expect("PostgreSQL test schema cleanup failed");
|
||||
if let Err(panic) = result {
|
||||
std::panic::resume_unwind(panic);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) async fn fresh(client: &mut Client) {
|
||||
let definition = format!("{FINAL}{INDEX}");
|
||||
schema::migrate(client, "account", &definition, Some(&FINAL_PLAN))
|
||||
.await
|
||||
.unwrap();
|
||||
schema::migrate(client, "account", &definition, Some(&FINAL_PLAN))
|
||||
.await
|
||||
.unwrap();
|
||||
let row = client
|
||||
.query_one("SELECT title,uid::text FROM account", &[])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(row.get::<_, String>(0), "new");
|
||||
assert_eq!(row.get::<_, String>(1).as_bytes()[14], b'7');
|
||||
assert_eq!(
|
||||
count(client, "SELECT count(*) FROM _dever_migration").await,
|
||||
2
|
||||
);
|
||||
assert_eq!(count(client, "SELECT count(*) FROM _dever_seed").await, 1);
|
||||
}
|
||||
|
||||
pub(super) async fn migrations(client: &mut Client) {
|
||||
schema::migrate(client, "account", INITIAL, Some(&INITIAL_PLAN))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count(client, "SELECT count(*) FROM account").await, 1);
|
||||
client
|
||||
.execute("UPDATE account SET name='business'", &[])
|
||||
.await
|
||||
.unwrap();
|
||||
schema::migrate(client, "account", INITIAL, Some(&INITIAL_PLAN))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count(client, "SELECT count(*) FROM account").await, 1);
|
||||
client.batch_execute("INSERT INTO account(name) VALUES ('removed-high-id'); DELETE FROM account WHERE name='removed-high-id'").await.unwrap();
|
||||
client
|
||||
.execute(
|
||||
"INSERT INTO account(name) SELECT 'row_' || value FROM generate_series(1,257) AS value",
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let definition = format!("{FINAL}{INDEX}");
|
||||
schema::migrate(client, "account", &definition, Some(&FINAL_PLAN))
|
||||
.await
|
||||
.unwrap();
|
||||
schema::migrate(client, "account", &definition, Some(&FINAL_PLAN))
|
||||
.await
|
||||
.unwrap();
|
||||
let rows = client
|
||||
.query(
|
||||
"SELECT id,title,amount::text,uid::text FROM account ORDER BY id",
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows.len(), 259);
|
||||
assert_eq!(rows[0].get::<_, String>(1), "business_before_after");
|
||||
assert_eq!(rows[0].get::<_, String>(2), "1.250");
|
||||
assert!(rows.last().unwrap().get::<_, i64>(0) > 259);
|
||||
let mut generated = std::collections::BTreeSet::new();
|
||||
for row in &rows {
|
||||
let uuid: String = row.get(3);
|
||||
assert_eq!(uuid.as_bytes()[14], b'7');
|
||||
assert!(generated.insert(uuid));
|
||||
}
|
||||
let first: String = rows[0].get(3);
|
||||
let second: String = rows[1].get(3);
|
||||
assert_eq!(first.as_bytes()[14], b'7');
|
||||
assert_eq!(second.as_bytes()[14], b'7');
|
||||
assert_ne!(first, second);
|
||||
let removed = Plan {
|
||||
migrations: &[HISTORICAL],
|
||||
..FINAL_PLAN
|
||||
};
|
||||
assert_eq!(
|
||||
schema::migrate(client, "account", &definition, Some(&removed))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
Kind::Migration
|
||||
);
|
||||
let changed = const {
|
||||
Plan {
|
||||
migrations: &[HISTORICAL, step("rename", "changed", &["legacy"])],
|
||||
..FINAL_PLAN
|
||||
}
|
||||
};
|
||||
assert_eq!(
|
||||
schema::migrate(client, "account", &definition, Some(&changed))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
Kind::Migration
|
||||
);
|
||||
|
||||
const FAILED: &str = "CREATE TABLE \"account\" (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, title text NOT NULL CONSTRAINT _dever_unique_account_1 UNIQUE, amount numeric(8,3) NOT NULL DEFAULT 1.25, uid uuid NOT NULL CONSTRAINT _dever_unique_account_3 UNIQUE, note text);";
|
||||
let failed = const {
|
||||
Plan {
|
||||
create: Sql::same(FAILED),
|
||||
postgres_columns: &[
|
||||
ID,
|
||||
TITLE,
|
||||
Column {
|
||||
default: Some("1.25"),
|
||||
..column("amount", "numeric(8,3)")
|
||||
},
|
||||
UID,
|
||||
Column {
|
||||
nullable: true,
|
||||
..column("note", "text")
|
||||
},
|
||||
],
|
||||
migrations: &[
|
||||
HISTORICAL,
|
||||
UPGRADE,
|
||||
Migration {
|
||||
name: "fail",
|
||||
revision: "fail-one",
|
||||
drops: &[],
|
||||
before: &[statement("UPDATE account SET title=title || '_rollback'")],
|
||||
after: &[statement("UPDATE account SET title=NULL")],
|
||||
},
|
||||
],
|
||||
..FINAL_PLAN
|
||||
}
|
||||
};
|
||||
assert_eq!(
|
||||
schema::migrate(
|
||||
client,
|
||||
"account",
|
||||
&format!("{FAILED}{INDEX}"),
|
||||
Some(&failed)
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
Kind::Constraint
|
||||
);
|
||||
assert_eq!(count(client, "SELECT count(*) FROM pg_attribute WHERE attrelid='account'::regclass AND attname='note' AND NOT attisdropped").await, 0);
|
||||
assert_eq!(
|
||||
count(client, "SELECT count(*) FROM _dever_migration").await,
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
client
|
||||
.query_one("SELECT title FROM account WHERE id=1", &[])
|
||||
.await
|
||||
.unwrap()
|
||||
.get::<_, String>(0),
|
||||
"business_before_after"
|
||||
);
|
||||
schema::migrate(client, "account", &definition, Some(&FINAL_PLAN))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
pub(super) async fn drift(client: &mut Client) {
|
||||
let definition = format!("{FINAL}{INDEX}");
|
||||
schema::migrate(client, "account", &definition, Some(&FINAL_PLAN))
|
||||
.await
|
||||
.unwrap();
|
||||
let original: String = client
|
||||
.query_one(
|
||||
"SELECT catalog FROM _dever_history WHERE name='account'",
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.get(0);
|
||||
for (change, restore) in [
|
||||
("DROP INDEX account_title", INDEX),
|
||||
(
|
||||
"ALTER TABLE account ALTER COLUMN amount SET DEFAULT 2.25",
|
||||
"ALTER TABLE account ALTER COLUMN amount SET DEFAULT 1.25",
|
||||
),
|
||||
(
|
||||
"ALTER TABLE account ALTER COLUMN title DROP NOT NULL",
|
||||
"ALTER TABLE account ALTER COLUMN title SET NOT NULL",
|
||||
),
|
||||
(
|
||||
"ALTER TABLE account ADD CONSTRAINT _dever_manual CHECK (amount>0)",
|
||||
"ALTER TABLE account DROP CONSTRAINT _dever_manual",
|
||||
),
|
||||
(
|
||||
"ALTER TABLE account ALTER COLUMN id SET INCREMENT BY 2",
|
||||
"ALTER TABLE account ALTER COLUMN id SET INCREMENT BY 1",
|
||||
),
|
||||
] {
|
||||
client.batch_execute(change).await.unwrap();
|
||||
assert_eq!(
|
||||
schema::migrate(client, "account", &definition, Some(&FINAL_PLAN))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
Kind::Migration
|
||||
);
|
||||
assert_eq!(
|
||||
client
|
||||
.query_one(
|
||||
"SELECT catalog FROM _dever_history WHERE name='account'",
|
||||
&[]
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.get::<_, String>(0),
|
||||
original
|
||||
);
|
||||
assert_eq!(count(client, "SELECT count(*) FROM account").await, 1);
|
||||
client.batch_execute(restore).await.unwrap();
|
||||
schema::migrate(client, "account", &definition, Some(&FINAL_PLAN))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
client.batch_execute("DROP TABLE account").await.unwrap();
|
||||
assert_eq!(
|
||||
schema::migrate(client, "account", &definition, Some(&FINAL_PLAN))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
Kind::Migration
|
||||
);
|
||||
}
|
||||
|
||||
async fn count(client: &Client, sql: &str) -> i64 {
|
||||
client.query_one(sql, &[]).await.unwrap().get(0)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
use super::*;
|
||||
use crate::{database::Row, value::decimal::Decimal};
|
||||
|
||||
pub(super) async fn roundtrip(client: &mut Client) {
|
||||
for text in [
|
||||
"0",
|
||||
"-0.0001",
|
||||
"12345.6789",
|
||||
"9.999999999999999999999999999999999E+6144",
|
||||
"1E-6176",
|
||||
] {
|
||||
let expected = Decimal::parse(text).unwrap();
|
||||
let parameter = Parameter::NativeDecimal(expected);
|
||||
let row = client
|
||||
.query_one("SELECT $1::numeric", &[¶meter])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(Row::Postgres(&row).native_decimal(0).unwrap(), expected);
|
||||
}
|
||||
let uuid = crate::database::scalar::generated_uuid().unwrap();
|
||||
let integer = Decimal::parse("9999999999999999999999999999999999").unwrap();
|
||||
let fraction = Decimal::parse("-0.9999999999999999999999999999999999").unwrap();
|
||||
let parameters = [
|
||||
Parameter::Bool(true),
|
||||
Parameter::Integer(i64::MIN),
|
||||
Parameter::Real(1.25),
|
||||
Parameter::Uuid(*uuid.as_bytes()),
|
||||
Parameter::Decimal {
|
||||
value: integer,
|
||||
precision: 34,
|
||||
scale: 0,
|
||||
},
|
||||
Parameter::Decimal {
|
||||
value: fraction,
|
||||
precision: 34,
|
||||
scale: 34,
|
||||
},
|
||||
Parameter::Null,
|
||||
Parameter::Text("雪'\\native".into()),
|
||||
Parameter::Blob(vec![0, 128, 255]),
|
||||
];
|
||||
let references = parameters
|
||||
.iter()
|
||||
.map(|value| value as &(dyn tokio_postgres::types::ToSql + Sync))
|
||||
.collect::<Vec<_>>();
|
||||
let result = client.query_one("SELECT $1::boolean,$2::bigint,$3::float8,$4::uuid,$5::numeric(34,0),$6::numeric(34,34),$7::uuid,$8::text,$9::bytea", &references).await.unwrap();
|
||||
let row = Row::Postgres(&result);
|
||||
assert!(row.get::<_, bool>(0).unwrap());
|
||||
assert_eq!(row.get::<_, i64>(1).unwrap(), i64::MIN);
|
||||
assert_eq!(row.get::<_, f64>(2).unwrap(), 1.25);
|
||||
assert_eq!(row.uuid(3).unwrap(), *uuid.as_bytes());
|
||||
assert_eq!(row.decimal(4, 34, 0).unwrap(), integer);
|
||||
assert_eq!(row.decimal(5, 34, 34).unwrap(), fraction);
|
||||
assert!(row.is_null(6).unwrap());
|
||||
assert_eq!(row.text(7).unwrap(), "雪'\\native");
|
||||
assert_eq!(row.bytes(8).unwrap(), [0, 128, 255]);
|
||||
for value in [
|
||||
"0",
|
||||
"0.0000000000000000000000000000000001",
|
||||
"0.1234000000000000000000000000000000",
|
||||
] {
|
||||
let expected = Decimal::parse(value).unwrap();
|
||||
let parameter = Parameter::Decimal {
|
||||
value: expected,
|
||||
precision: 34,
|
||||
scale: 34,
|
||||
};
|
||||
let result = client
|
||||
.query_one("SELECT $1::numeric(34,34)", &[¶meter])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(Row::Postgres(&result).decimal(0, 34, 34).unwrap(), expected);
|
||||
}
|
||||
for (sql, parameter) in [
|
||||
("SELECT $1::integer", Parameter::Integer(1)),
|
||||
("SELECT $1::text::uuid", Parameter::Uuid(*uuid.as_bytes())),
|
||||
("SELECT $1::float8", Parameter::Real(f64::INFINITY)),
|
||||
("SELECT $1::float8", Parameter::Real(f64::NAN)),
|
||||
(
|
||||
"SELECT $1::numeric(5,2)",
|
||||
Parameter::Decimal {
|
||||
value: Decimal::parse("0.001").unwrap(),
|
||||
precision: 5,
|
||||
scale: 2,
|
||||
},
|
||||
),
|
||||
(
|
||||
"SELECT $1::numeric(5,2)",
|
||||
Parameter::Decimal {
|
||||
value: Decimal::parse("1000").unwrap(),
|
||||
precision: 5,
|
||||
scale: 2,
|
||||
},
|
||||
),
|
||||
] {
|
||||
assert!(client.query_one(sql, &[¶meter]).await.is_err(), "{sql}");
|
||||
}
|
||||
let invalid = client
|
||||
.query_one("SELECT 0.001::numeric,1000::numeric,'NaN'::numeric", &[])
|
||||
.await
|
||||
.unwrap();
|
||||
for position in 0..3 {
|
||||
assert_eq!(
|
||||
Row::Postgres(&invalid)
|
||||
.decimal(position, 5, 2)
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
Kind::InvalidData
|
||||
);
|
||||
}
|
||||
let result = client
|
||||
.query_one(
|
||||
"SELECT $1::bigint,$2::float8",
|
||||
&[&Parameter::Integer(i64::MAX), &Parameter::Real(-f64::MAX)],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.get::<_, i64>(0), i64::MAX);
|
||||
assert_eq!(result.get::<_, f64>(1), -f64::MAX);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) async fn foreign_keys(client: &mut Client) {
|
||||
const PARENT: &str = "CREATE TABLE parent(id bigint PRIMARY KEY);";
|
||||
schema::migrate(client, "parent", PARENT, None)
|
||||
.await
|
||||
.unwrap();
|
||||
schema::migrate(client, "parent", PARENT, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
schema::migrate(
|
||||
client,
|
||||
"parent",
|
||||
"CREATE TABLE parent(id bigint PRIMARY KEY, extra text);",
|
||||
None
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
Kind::Migration
|
||||
);
|
||||
client
|
||||
.execute("INSERT INTO parent VALUES (1)", &[])
|
||||
.await
|
||||
.unwrap();
|
||||
const START: &str = "CREATE TABLE \"child\" (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, parent_id bigint NOT NULL CONSTRAINT _dever_fk_parent REFERENCES parent(id));";
|
||||
const REFERENCE: Column = Column {
|
||||
constraints: &[(
|
||||
"_dever_fk_parent",
|
||||
"f",
|
||||
"FOREIGN KEY (parent_id) REFERENCES parent(id)",
|
||||
)],
|
||||
..column("parent_id", "bigint")
|
||||
};
|
||||
const START_PLAN: Plan = Plan {
|
||||
create: Sql::same(START),
|
||||
postgres_columns: &[ID, REFERENCE],
|
||||
..EMPTY
|
||||
};
|
||||
schema::migrate(client, "child", START, Some(&START_PLAN))
|
||||
.await
|
||||
.unwrap();
|
||||
client
|
||||
.execute("INSERT INTO child(parent_id) VALUES (1)", &[])
|
||||
.await
|
||||
.unwrap();
|
||||
const NEXT: &str = "CREATE TABLE \"child\" (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, parent_id bigint NOT NULL CONSTRAINT _dever_fk_parent REFERENCES parent(id), label text NOT NULL DEFAULT 'ok' CONSTRAINT _dever_check_label CHECK (char_length(label)<=4));";
|
||||
const NEXT_PLAN: Plan = Plan {
|
||||
create: Sql::same(NEXT),
|
||||
postgres_columns: &[
|
||||
ID,
|
||||
REFERENCE,
|
||||
Column {
|
||||
default: Some("'ok'"),
|
||||
constraints: &[("_dever_check_label", "c", "CHECK (char_length(label)<=4)")],
|
||||
..column("label", "text")
|
||||
},
|
||||
],
|
||||
..EMPTY
|
||||
};
|
||||
schema::migrate(client, "child", NEXT, Some(&NEXT_PLAN))
|
||||
.await
|
||||
.unwrap();
|
||||
schema::migrate(client, "child", NEXT, Some(&NEXT_PLAN))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
client
|
||||
.execute("INSERT INTO child(parent_id) VALUES (2)", &[])
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
client
|
||||
.execute("UPDATE child SET label='too long'", &[])
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
client
|
||||
.execute("DELETE FROM parent WHERE id=1", &[])
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
const TIGHT: &str = "CREATE TABLE \"child\" (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, parent_id bigint NOT NULL CONSTRAINT _dever_fk_parent REFERENCES parent(id), label text NOT NULL DEFAULT 'ok' CONSTRAINT _dever_check_label CHECK (char_length(label)<=1));";
|
||||
const TIGHT_PLAN: Plan = Plan {
|
||||
create: Sql::same(TIGHT),
|
||||
postgres_columns: &[
|
||||
ID,
|
||||
REFERENCE,
|
||||
Column {
|
||||
default: Some("'ok'"),
|
||||
constraints: &[("_dever_check_label", "c", "CHECK (char_length(label)<=1)")],
|
||||
..column("label", "text")
|
||||
},
|
||||
],
|
||||
..EMPTY
|
||||
};
|
||||
assert_eq!(
|
||||
schema::migrate(client, "child", TIGHT, Some(&TIGHT_PLAN))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
Kind::Constraint
|
||||
);
|
||||
schema::migrate(client, "child", NEXT, Some(&NEXT_PLAN))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
client
|
||||
.query_one("SELECT label FROM child", &[])
|
||||
.await
|
||||
.unwrap()
|
||||
.get::<_, String>(0),
|
||||
"ok"
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) async fn numeric(client: &mut Client) {
|
||||
const INTEGER: &str = "CREATE TABLE \"numbers\" (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, value bigint NOT NULL);";
|
||||
const FLOAT: &str = "CREATE TABLE \"numbers\" (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, value double precision NOT NULL);";
|
||||
const INTEGER_PLAN: Plan = Plan {
|
||||
create: Sql::same(INTEGER),
|
||||
postgres_columns: &[ID, column("value", "bigint")],
|
||||
..EMPTY
|
||||
};
|
||||
const FLOAT_PLAN: Plan = Plan {
|
||||
create: Sql::same(FLOAT),
|
||||
postgres_columns: &[ID, column("value", "double precision")],
|
||||
..EMPTY
|
||||
};
|
||||
schema::migrate(client, "numbers", INTEGER, Some(&INTEGER_PLAN))
|
||||
.await
|
||||
.unwrap();
|
||||
client
|
||||
.execute(
|
||||
"INSERT INTO numbers(value) VALUES (9007199254740993), (9223372036854775807)",
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
schema::migrate(client, "numbers", FLOAT, Some(&FLOAT_PLAN))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
Kind::Migration
|
||||
);
|
||||
assert_eq!(
|
||||
client
|
||||
.query_one("SELECT value FROM numbers ORDER BY id LIMIT 1", &[])
|
||||
.await
|
||||
.unwrap()
|
||||
.get::<_, i64>(0),
|
||||
9007199254740993
|
||||
);
|
||||
client.batch_execute("DELETE FROM numbers; INSERT INTO numbers(value) VALUES (9007199254740992), (-9223372036854775808), (9223372036854774784)").await.unwrap();
|
||||
assert_eq!(
|
||||
schema::migrate(client, "numbers", FLOAT, Some(&FLOAT_PLAN))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
Kind::Migration
|
||||
);
|
||||
assert_eq!(
|
||||
client
|
||||
.query_one("SELECT value FROM numbers ORDER BY id LIMIT 1", &[])
|
||||
.await
|
||||
.unwrap()
|
||||
.get::<_, i64>(0),
|
||||
9007199254740992
|
||||
);
|
||||
|
||||
const WIDE: &str = "CREATE TABLE \"decimal_numbers\" (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, value numeric(8,3) NOT NULL);";
|
||||
const NARROW: &str = "CREATE TABLE \"decimal_numbers\" (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, value numeric(5,2) NOT NULL);";
|
||||
const WIDE_PLAN: Plan = Plan {
|
||||
create: Sql::same(WIDE),
|
||||
postgres_columns: &[ID, column("value", "numeric(8,3)")],
|
||||
..EMPTY
|
||||
};
|
||||
const NARROW_PLAN: Plan = Plan {
|
||||
create: Sql::same(NARROW),
|
||||
postgres_columns: &[ID, column("value", "numeric(5,2)")],
|
||||
..EMPTY
|
||||
};
|
||||
schema::migrate(client, "decimal_numbers", WIDE, Some(&WIDE_PLAN))
|
||||
.await
|
||||
.unwrap();
|
||||
client
|
||||
.execute("INSERT INTO decimal_numbers(value) VALUES (1.251)", &[])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
schema::migrate(client, "decimal_numbers", NARROW, Some(&NARROW_PLAN))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
Kind::Migration
|
||||
);
|
||||
client
|
||||
.execute("UPDATE decimal_numbers SET value=1000", &[])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
schema::migrate(client, "decimal_numbers", NARROW, Some(&NARROW_PLAN))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
Kind::InvalidData
|
||||
);
|
||||
schema::migrate(client, "decimal_numbers", WIDE, Some(&WIDE_PLAN))
|
||||
.await
|
||||
.unwrap();
|
||||
client
|
||||
.execute("UPDATE decimal_numbers SET value=1.250", &[])
|
||||
.await
|
||||
.unwrap();
|
||||
schema::migrate(client, "decimal_numbers", NARROW, Some(&NARROW_PLAN))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
client
|
||||
.query_one("SELECT value::text FROM decimal_numbers", &[])
|
||||
.await
|
||||
.unwrap()
|
||||
.get::<_, String>(0),
|
||||
"1.25"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
use super::*;
|
||||
use crate::database::{Binding, Context, Registry};
|
||||
use std::{io::Write, os::unix::fs::OpenOptionsExt, path::PathBuf};
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit release pagination performance probe with isolated PostgreSQL"]
|
||||
fn postgres_page_cost() {
|
||||
use crate::database::{Database, PageQuery};
|
||||
use std::{hint::black_box, time::Instant};
|
||||
registry_fixture(async |client, scope| {
|
||||
let settings = options();
|
||||
let timeout = settings.io_timeout();
|
||||
let database = Database::open(std::path::Path::new("."), settings, scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
let count = "SELECT 1::bigint WHERE $1::text IS NOT NULL";
|
||||
let select = "SELECT octet_length($1::text)::bigint LIMIT $2::bigint OFFSET $3::bigint";
|
||||
let count_statement = client.prepare(count).await.unwrap();
|
||||
let select_statement = client.prepare(select).await.unwrap();
|
||||
println!("\ncase,sample,implementation,iterations,elapsed_ns,checksum");
|
||||
for bytes in [64, 65536, 1048576] {
|
||||
let source = "x".repeat(bytes);
|
||||
for sample in 0..16 {
|
||||
for offset in 0..2 {
|
||||
let generated = (sample + offset) % 2 == 0;
|
||||
let started = Instant::now();
|
||||
let mut checksum = 0;
|
||||
for _ in 0..20 {
|
||||
let payload = black_box(source.clone());
|
||||
let value = if generated {
|
||||
let page = executor
|
||||
.page(
|
||||
scope,
|
||||
PageQuery {
|
||||
count: Sql::same(count),
|
||||
select: Sql::same(select),
|
||||
parameters: vec![Parameter::Text(payload)],
|
||||
page: 1,
|
||||
size: 1,
|
||||
},
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!((page.total, page.pages, page.items.len()), (1, 1, 1));
|
||||
page.items[0]
|
||||
} else {
|
||||
// 原生驱动同样执行两个有独立期限的查询;载荷只持有一份。
|
||||
let total = tokio::time::timeout(
|
||||
timeout,
|
||||
client.query_one(&count_statement, &[&payload]),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(total.get::<_, i64>(0), 1);
|
||||
tokio::time::timeout(
|
||||
timeout,
|
||||
client.query_one(&select_statement, &[&payload, &1i64, &0i64]),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.get::<_, i64>(0)
|
||||
};
|
||||
assert_eq!(value, bytes as i64);
|
||||
checksum += black_box(value);
|
||||
}
|
||||
let elapsed = started.elapsed().as_nanos();
|
||||
if sample != 0 {
|
||||
let implementation = if generated { "dever" } else { "rust" };
|
||||
println!(
|
||||
"postgres_page_{bytes},{},{implementation},20,{elapsed},{checksum}",
|
||||
sample - 1
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
database.close();
|
||||
});
|
||||
}
|
||||
|
||||
const fn binding(
|
||||
table: &'static str,
|
||||
sql: &'static str,
|
||||
database: Option<&'static str>,
|
||||
references: &'static [usize],
|
||||
) -> Binding {
|
||||
Binding {
|
||||
storage: crate::database::Storage::Global,
|
||||
component: "demo",
|
||||
database,
|
||||
table,
|
||||
schema: Sql::same(sql),
|
||||
plan: None,
|
||||
references,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated config/setting.json database.postgres_test"]
|
||||
fn first_create_orders_parent_before_alphabetically_earlier_child() {
|
||||
registry_fixture(async |client, scope| {
|
||||
let settings = SettingsFile::new(client).await;
|
||||
let bindings = [
|
||||
binding(
|
||||
"a_child",
|
||||
"CREATE TABLE a_child(id bigint PRIMARY KEY, parent_id bigint NOT NULL REFERENCES z_parent(id));",
|
||||
None,
|
||||
&[2],
|
||||
),
|
||||
binding(
|
||||
"m_independent",
|
||||
"CREATE TABLE m_independent(id bigint PRIMARY KEY);",
|
||||
Some("secondary"),
|
||||
&[],
|
||||
),
|
||||
binding(
|
||||
"z_parent",
|
||||
"CREATE TABLE z_parent(id bigint PRIMARY KEY);",
|
||||
None,
|
||||
&[],
|
||||
),
|
||||
binding(
|
||||
"self_node",
|
||||
"CREATE TABLE self_node(id bigint PRIMARY KEY, parent_id bigint REFERENCES self_node(id));",
|
||||
None,
|
||||
&[3],
|
||||
),
|
||||
];
|
||||
let registry = Registry::load(&settings.path(), &bindings, scope)
|
||||
.await
|
||||
.unwrap();
|
||||
registry.check_transactions(&[&[0, 2, 3]]).unwrap();
|
||||
assert_eq!(
|
||||
registry.check_transactions(&[&[0, 1]]).unwrap_err().kind,
|
||||
Kind::Connection
|
||||
);
|
||||
let mut context = Context::new(registry);
|
||||
context
|
||||
.on(2, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.execute(scope, Sql::same("INSERT INTO z_parent VALUES (42)"), vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
context
|
||||
.on(0, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.execute(
|
||||
scope,
|
||||
Sql::same("INSERT INTO a_child VALUES (1,42)"),
|
||||
vec![],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
context
|
||||
.on(1, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.execute(
|
||||
scope,
|
||||
Sql::same("INSERT INTO m_independent VALUES (1)"),
|
||||
vec![],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
context
|
||||
.on(3, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.execute(
|
||||
scope,
|
||||
Sql::same("INSERT INTO self_node VALUES (1,1)"),
|
||||
vec![],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
drop(context);
|
||||
let repeated = Registry::load(&settings.path(), &bindings, scope)
|
||||
.await
|
||||
.unwrap();
|
||||
repeated.check_transactions(&[&[0, 2]]).unwrap();
|
||||
assert_eq!(
|
||||
client
|
||||
.query_one("SELECT parent_id FROM a_child", &[])
|
||||
.await
|
||||
.unwrap()
|
||||
.get::<_, i64>(0),
|
||||
42
|
||||
);
|
||||
assert_eq!(
|
||||
client
|
||||
.query_one("SELECT count(*) FROM _dever_history", &[])
|
||||
.await
|
||||
.unwrap()
|
||||
.get::<_, i64>(0),
|
||||
4
|
||||
);
|
||||
assert!(client.execute("DELETE FROM z_parent", &[]).await.is_err());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated config/setting.json database.postgres_test"]
|
||||
fn cyclic_postgres_bindings_fail_before_any_schema_or_history_ddl() {
|
||||
registry_fixture(async |client, scope| {
|
||||
let settings = SettingsFile::new(client).await;
|
||||
let bindings = [
|
||||
binding(
|
||||
"independent",
|
||||
"CREATE TABLE independent(id bigint PRIMARY KEY);",
|
||||
None,
|
||||
&[],
|
||||
),
|
||||
binding(
|
||||
"cycle_left",
|
||||
"CREATE TABLE cycle_left(id bigint PRIMARY KEY, parent_id bigint REFERENCES cycle_right(id));",
|
||||
None,
|
||||
&[2],
|
||||
),
|
||||
binding(
|
||||
"cycle_right",
|
||||
"CREATE TABLE cycle_right(id bigint PRIMARY KEY, parent_id bigint REFERENCES cycle_left(id));",
|
||||
None,
|
||||
&[1],
|
||||
),
|
||||
];
|
||||
let error = Registry::load(&settings.path(), &bindings, scope)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_eq!(error.kind, Kind::Migration);
|
||||
assert_eq!(client.query_one("SELECT count(*) FROM pg_class WHERE relnamespace=current_schema()::regnamespace", &[]).await.unwrap().get::<_, i64>(0), 0);
|
||||
});
|
||||
}
|
||||
|
||||
struct SettingsFile(PathBuf);
|
||||
|
||||
impl SettingsFile {
|
||||
async fn new(client: &Client) -> Self {
|
||||
let schema: String = client
|
||||
.query_one("SELECT current_schema()", &[])
|
||||
.await
|
||||
.unwrap()
|
||||
.get(0);
|
||||
let mut connection = configuration();
|
||||
let mut url = connection["url"].as_str().unwrap().to_owned();
|
||||
let parsed: tokio_postgres::Config = url.parse().unwrap();
|
||||
assert!(
|
||||
parsed.get_options().is_none(),
|
||||
"test connection cannot override search_path"
|
||||
);
|
||||
url.push(if url.contains('?') { '&' } else { '?' });
|
||||
url.push_str(&format!("options=-csearch_path%3D{schema}"));
|
||||
connection["url"] = serde_json::Value::String(url);
|
||||
let directory = std::env::temp_dir().join(format!(
|
||||
"dever_registry_{}",
|
||||
crate::database::scalar::generated_uuid().unwrap()
|
||||
));
|
||||
std::fs::create_dir(&directory).unwrap();
|
||||
let settings = Self(directory);
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(settings.path())
|
||||
.unwrap();
|
||||
let document =
|
||||
serde_json::json!({"database":{"default":connection,"secondary":connection}});
|
||||
file.write_all(&serde_json::to_vec(&document).unwrap())
|
||||
.unwrap();
|
||||
settings
|
||||
}
|
||||
|
||||
fn path(&self) -> PathBuf {
|
||||
self.0.join("setting.json")
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SettingsFile {
|
||||
fn drop(&mut self) {
|
||||
std::fs::remove_dir_all(&self.0).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
use super::{alter, history, schema};
|
||||
use crate::database::{Column, Kind, Migration, Plan, Sql, Statement};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
mod live;
|
||||
mod registry;
|
||||
|
||||
const EMPTY: Plan = Plan {
|
||||
create: Sql::same(""),
|
||||
indexes: Sql::same(""),
|
||||
postgres_columns: &[],
|
||||
renames: &[],
|
||||
migrations: &[],
|
||||
seeds: &[],
|
||||
seed_revision: "empty",
|
||||
};
|
||||
|
||||
const fn column(name: &'static str, kind: &'static str) -> Column {
|
||||
Column {
|
||||
name,
|
||||
kind,
|
||||
default: None,
|
||||
nullable: false,
|
||||
generated: false,
|
||||
constraints: &[],
|
||||
}
|
||||
}
|
||||
|
||||
const fn step(
|
||||
name: &'static str,
|
||||
revision: &'static str,
|
||||
drops: &'static [&'static str],
|
||||
) -> Migration {
|
||||
Migration {
|
||||
name,
|
||||
revision,
|
||||
drops,
|
||||
before: &[],
|
||||
after: &[],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_history_is_an_immutable_contiguous_prefix() {
|
||||
let plan = const {
|
||||
Plan {
|
||||
migrations: &[step("first", "one", &[]), step("second", "two", &[])],
|
||||
..EMPTY
|
||||
}
|
||||
};
|
||||
assert_eq!(history::validate(&[], &plan).unwrap(), 0);
|
||||
assert_eq!(
|
||||
history::validate(&[(0, "first".into(), "one".into())], &plan).unwrap(),
|
||||
1
|
||||
);
|
||||
for records in [
|
||||
vec![(1, "first".into(), "one".into())],
|
||||
vec![(0, "second".into(), "two".into())],
|
||||
vec![(0, "first".into(), "modified".into())],
|
||||
vec![
|
||||
(0, "first".into(), "one".into()),
|
||||
(2, "second".into(), "two".into()),
|
||||
],
|
||||
] {
|
||||
assert_eq!(
|
||||
history::validate(&records, &plan).unwrap_err().kind,
|
||||
Kind::Migration
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
history::validate(&[(0, "first".into(), "one".into())], &EMPTY)
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
Kind::Migration
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_mapping_requires_exact_unused_drops_and_unambiguous_renames() {
|
||||
let old = BTreeMap::from([
|
||||
("id".into(), "bigint".into()),
|
||||
("name".into(), "text".into()),
|
||||
("removed".into(), "bigint".into()),
|
||||
]);
|
||||
let plan = const {
|
||||
Plan {
|
||||
postgres_columns: &[
|
||||
column("id", "bigint"),
|
||||
column("title", "text"),
|
||||
column("new", "uuid"),
|
||||
],
|
||||
renames: &[("title", "name")],
|
||||
migrations: &[step("remove", "one", &["removed"])],
|
||||
..EMPTY
|
||||
}
|
||||
};
|
||||
assert_eq!(
|
||||
alter::sources(&old, &plan, 0).unwrap(),
|
||||
[Some("id"), Some("name"), None]
|
||||
);
|
||||
assert_eq!(
|
||||
alter::sources(&old, &plan, 1).unwrap_err().kind,
|
||||
Kind::Migration
|
||||
);
|
||||
let mut ambiguous = old.clone();
|
||||
ambiguous.insert("title".into(), "text".into());
|
||||
assert_eq!(
|
||||
alter::sources(&ambiguous, &plan, 0).unwrap_err().kind,
|
||||
Kind::Migration
|
||||
);
|
||||
let missing = Plan {
|
||||
renames: &[("title", "absent")],
|
||||
..plan
|
||||
};
|
||||
assert_eq!(
|
||||
alter::sources(&old, &missing, 0).unwrap_err().kind,
|
||||
Kind::Migration
|
||||
);
|
||||
let duplicated = Plan {
|
||||
postgres_columns: const {
|
||||
&[
|
||||
column("id", "bigint"),
|
||||
column("title", "text"),
|
||||
column("other", "text"),
|
||||
]
|
||||
},
|
||||
renames: &[("title", "name"), ("other", "name")],
|
||||
..missing
|
||||
};
|
||||
assert_eq!(
|
||||
alter::sources(&old, &duplicated, 0).unwrap_err().kind,
|
||||
Kind::Migration
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_supported_storage_conversions_reach_postgres_ddl() {
|
||||
for (old, next) in [
|
||||
("text", "text"),
|
||||
("numeric(6,2)", "numeric(8,3)"),
|
||||
("numeric(8,3)", "numeric(6,2)"),
|
||||
] {
|
||||
assert!(alter::conversion(old, next));
|
||||
}
|
||||
for (old, next) in [
|
||||
("double precision", "bigint"),
|
||||
("bigint", "double precision"),
|
||||
("text", "uuid"),
|
||||
("bigint", "numeric(8,2)"),
|
||||
("bytea", "text"),
|
||||
("numeric(6,2)", "numeric(3,4)"),
|
||||
] {
|
||||
assert!(!alter::conversion(old, next));
|
||||
}
|
||||
assert_eq!(schema::quote("a\"b"), "\"a\"\"b\"");
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
use crate::database::{Binding, Kind, Sql, context::initialization_order};
|
||||
|
||||
fn binding(references: &'static [usize]) -> Binding {
|
||||
Binding {
|
||||
storage: crate::database::Storage::Global,
|
||||
component: "demo",
|
||||
database: None,
|
||||
table: "unused",
|
||||
schema: Sql::same(""),
|
||||
plan: None,
|
||||
references,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_dependencies_precede_children_without_changing_binding_indices() {
|
||||
let bindings = [
|
||||
binding(&[0, 1, 2, 2]),
|
||||
binding(&[2]),
|
||||
binding(&[]),
|
||||
binding(&[]),
|
||||
];
|
||||
assert_eq!(
|
||||
initialization_order(&bindings, &[true; 4]).unwrap(),
|
||||
[2, 1, 0, 3]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_cycles_fail_before_initialization_but_self_references_are_valid() {
|
||||
assert_eq!(
|
||||
initialization_order(&[binding(&[1]), binding(&[0])], &[true, true])
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
Kind::Migration
|
||||
);
|
||||
assert_eq!(
|
||||
initialization_order(&[binding(&[0])], &[true]).unwrap(),
|
||||
[0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_keeps_source_order_and_accepts_forward_and_cyclic_references() {
|
||||
assert_eq!(
|
||||
initialization_order(&[binding(&[1]), binding(&[0])], &[false, false]).unwrap(),
|
||||
[0, 1]
|
||||
);
|
||||
let bindings = [binding(&[2]), binding(&[3]), binding(&[]), binding(&[1])];
|
||||
assert_eq!(
|
||||
initialization_order(&bindings, &[true, false, true, false]).unwrap(),
|
||||
[1, 2, 0, 3]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
use crate::{
|
||||
adapter::Document,
|
||||
database::{Binding, Context, Parameter, Plan, Registry, Settings, Sql, Statement, Storage},
|
||||
task::{Limits, Runtime},
|
||||
};
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
struct Directory(PathBuf);
|
||||
impl Directory {
|
||||
fn new() -> Self {
|
||||
let directory = Self(std::env::temp_dir().join(format!(
|
||||
"dever-registry-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||
)));
|
||||
std::fs::create_dir(&directory.0).unwrap();
|
||||
directory
|
||||
}
|
||||
}
|
||||
impl Drop for Directory {
|
||||
fn drop(&mut self) {
|
||||
std::fs::remove_dir_all(&self.0).unwrap();
|
||||
}
|
||||
}
|
||||
fn runtime() -> Runtime {
|
||||
Runtime::new(Limits {
|
||||
threads: 2,
|
||||
workers: 2,
|
||||
tasks: 16,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
const PARENT: &str = "CREATE TABLE z_parent(id INTEGER PRIMARY KEY);";
|
||||
const CHILD: &str = "CREATE TABLE a_child(id INTEGER PRIMARY KEY, parent_id INTEGER NOT NULL REFERENCES z_parent(id));";
|
||||
const PARENT_PLAN: Plan = Plan {
|
||||
create: Sql::same(PARENT),
|
||||
indexes: Sql::same(""),
|
||||
postgres_columns: &[],
|
||||
renames: &[],
|
||||
migrations: &[],
|
||||
seed_revision: "parent-v1",
|
||||
seeds: &[Statement {
|
||||
sql: Sql::same("INSERT INTO z_parent VALUES (?1)"),
|
||||
parameters: || Ok(vec![Parameter::Integer(7)]),
|
||||
}],
|
||||
};
|
||||
const CHILD_PLAN: Plan = Plan {
|
||||
create: Sql::same(CHILD),
|
||||
seed_revision: "child-v1",
|
||||
seeds: &[Statement {
|
||||
sql: Sql::same("INSERT INTO a_child VALUES (?1,?2)"),
|
||||
parameters: || Ok(vec![Parameter::Integer(1), Parameter::Integer(7)]),
|
||||
}],
|
||||
..PARENT_PLAN
|
||||
};
|
||||
|
||||
fn binding(
|
||||
table: &'static str,
|
||||
schema: &'static str,
|
||||
plan: Option<&'static Plan>,
|
||||
references: &'static [usize],
|
||||
) -> Binding {
|
||||
Binding {
|
||||
storage: Storage::Global,
|
||||
component: "demo",
|
||||
database: None,
|
||||
table,
|
||||
schema: Sql::same(schema),
|
||||
plan,
|
||||
references,
|
||||
}
|
||||
}
|
||||
|
||||
fn settings() -> Settings {
|
||||
Settings::load(&Document::parse(r#"{"database":{"default":{"type":"sqlite","path":"registry.db","max_connections":1}}}"#.into()).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_internal_bindings_initialize_once_and_reject_conflicting_schema() {
|
||||
let directory = Directory::new();
|
||||
let shared = binding(
|
||||
"queue",
|
||||
"CREATE TABLE queue(id INTEGER PRIMARY KEY);",
|
||||
None,
|
||||
&[],
|
||||
);
|
||||
let mut bindings = [shared.clone(), shared];
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
for expected in [1, 2] {
|
||||
let registry = Registry::prepare(&directory.0, settings(), &bindings, scope)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(registry.same_pool(0, 1));
|
||||
let mut context = Context::new(registry.clone());
|
||||
context
|
||||
.on(0, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.execute(
|
||||
scope,
|
||||
"INSERT INTO queue VALUES (?1)",
|
||||
vec![Parameter::Integer(expected)],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let count = context
|
||||
.on(1, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.query(scope, "SELECT COUNT(*) FROM queue", vec![], 1, |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, [expected]);
|
||||
drop(context);
|
||||
registry.close().await;
|
||||
}
|
||||
bindings[1].schema =
|
||||
Sql::same("CREATE TABLE queue(id INTEGER PRIMARY KEY, extra TEXT);");
|
||||
let error = Registry::prepare(&directory.0, settings(), &bindings, scope)
|
||||
.await
|
||||
.err()
|
||||
.expect("conflicting shared schema accepted");
|
||||
assert_eq!(error.kind, crate::database::Kind::Migration);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_registry_seeds_forward_references_after_all_tables_exist() {
|
||||
let directory = Directory::new();
|
||||
let bindings = [
|
||||
binding("a_child", CHILD, Some(&CHILD_PLAN), &[1]),
|
||||
binding("z_parent", PARENT, Some(&PARENT_PLAN), &[]),
|
||||
];
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
for _ in 0..2 {
|
||||
let registry = Registry::prepare(&directory.0, settings(), &bindings, scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut context = Context::new(registry.clone());
|
||||
let executor = context.on(0, scope).await.unwrap();
|
||||
assert_eq!(
|
||||
executor
|
||||
.query(scope, "SELECT parent_id FROM a_child", vec![], 1, |row| row
|
||||
.get::<_, i64>(0))
|
||||
.await
|
||||
.unwrap(),
|
||||
[7]
|
||||
);
|
||||
assert!(
|
||||
executor
|
||||
.execute(scope, "INSERT INTO a_child VALUES (2,999)", vec![])
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
drop(context);
|
||||
registry.close().await;
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_registry_preserves_cyclic_and_self_references_with_and_without_seeds() {
|
||||
const LEFT: &str = "CREATE TABLE cycle_left(id INTEGER PRIMARY KEY, right_id INTEGER REFERENCES cycle_right(id));";
|
||||
const RIGHT: &str = "CREATE TABLE cycle_right(id INTEGER PRIMARY KEY, left_id INTEGER REFERENCES cycle_left(id));";
|
||||
const SELF: &str = "CREATE TABLE self_node(id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES self_node(id));";
|
||||
const LEFT_PLAN: Plan = Plan {
|
||||
create: Sql::same(LEFT),
|
||||
seeds: &[Statement {
|
||||
sql: Sql::same("INSERT INTO cycle_left VALUES (1,2)"),
|
||||
parameters: || Ok(vec![]),
|
||||
}],
|
||||
..PARENT_PLAN
|
||||
};
|
||||
const RIGHT_PLAN: Plan = Plan {
|
||||
create: Sql::same(RIGHT),
|
||||
seeds: &[Statement {
|
||||
sql: Sql::same("INSERT INTO cycle_right VALUES (2,1)"),
|
||||
parameters: || Ok(vec![]),
|
||||
}],
|
||||
..PARENT_PLAN
|
||||
};
|
||||
const SELF_PLAN: Plan = Plan {
|
||||
create: Sql::same(SELF),
|
||||
seeds: &[Statement {
|
||||
sql: Sql::same("INSERT INTO self_node VALUES (1,1)"),
|
||||
parameters: || Ok(vec![]),
|
||||
}],
|
||||
..PARENT_PLAN
|
||||
};
|
||||
const EMPTY_LEFT: Plan = Plan {
|
||||
seeds: &[],
|
||||
..LEFT_PLAN
|
||||
};
|
||||
const EMPTY_RIGHT: Plan = Plan {
|
||||
seeds: &[],
|
||||
..RIGHT_PLAN
|
||||
};
|
||||
const EMPTY_SELF: Plan = Plan {
|
||||
seeds: &[],
|
||||
..SELF_PLAN
|
||||
};
|
||||
for seeded in [false, true] {
|
||||
let directory = Directory::new();
|
||||
let bindings = [
|
||||
binding(
|
||||
"cycle_left",
|
||||
LEFT,
|
||||
Some(if seeded { &LEFT_PLAN } else { &EMPTY_LEFT }),
|
||||
&[1],
|
||||
),
|
||||
binding(
|
||||
"cycle_right",
|
||||
RIGHT,
|
||||
Some(if seeded { &RIGHT_PLAN } else { &EMPTY_RIGHT }),
|
||||
&[0],
|
||||
),
|
||||
binding(
|
||||
"self_node",
|
||||
SELF,
|
||||
Some(if seeded { &SELF_PLAN } else { &EMPTY_SELF }),
|
||||
&[2],
|
||||
),
|
||||
];
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
for _ in 0..2 {
|
||||
let registry = Registry::prepare(&directory.0, settings(), &bindings, scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut context = Context::new(registry.clone());
|
||||
let executor = context.on(0, scope).await.unwrap();
|
||||
assert_eq!(
|
||||
executor
|
||||
.query(scope, "SELECT count(*) FROM cycle_left", vec![], 1, |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.await
|
||||
.unwrap(),
|
||||
[i64::from(seeded)]
|
||||
);
|
||||
assert!(
|
||||
executor
|
||||
.execute(scope, "INSERT INTO cycle_left VALUES (3,999)", vec![])
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
executor
|
||||
.execute(scope, "INSERT INTO self_node VALUES (3,999)", vec![])
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
drop(context);
|
||||
registry.close().await;
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_registry_invalid_seed_rolls_back_the_physical_group() {
|
||||
const INVALID: Plan = Plan {
|
||||
seeds: &[Statement {
|
||||
sql: Sql::same("INSERT INTO a_child VALUES (1,999)"),
|
||||
parameters: || Ok(vec![]),
|
||||
}],
|
||||
..CHILD_PLAN
|
||||
};
|
||||
let directory = Directory::new();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let bindings = [
|
||||
binding("a_child", CHILD, Some(&INVALID), &[1]),
|
||||
binding("z_parent", PARENT, Some(&PARENT_PLAN), &[]),
|
||||
];
|
||||
let error = Registry::prepare(&directory.0, settings(), &bindings, scope)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_eq!(error.kind, crate::database::Kind::Migration);
|
||||
let options = settings().select("", None).unwrap().clone();
|
||||
let database = crate::database::Database::open(&directory.0, options, scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
assert_eq!(
|
||||
executor
|
||||
.query(
|
||||
scope,
|
||||
"SELECT count(*) FROM sqlite_schema WHERE type='table'",
|
||||
vec![],
|
||||
1,
|
||||
|row| row.get::<_, i64>(0)
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
[0]
|
||||
);
|
||||
drop(executor);
|
||||
database.close();
|
||||
let bindings = [
|
||||
binding("a_child", CHILD, Some(&CHILD_PLAN), &[1]),
|
||||
bindings[1].clone(),
|
||||
];
|
||||
let registry = Registry::prepare(&directory.0, settings(), &bindings, scope)
|
||||
.await
|
||||
.unwrap();
|
||||
registry.close().await;
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_tenant_migration_batches_seed_dependencies_in_each_physical_database() {
|
||||
let directory = Directory::new();
|
||||
let mut config = settings();
|
||||
config.0.get_mut("default").unwrap().tenant_directory = Some("tenants".into());
|
||||
config.1 = Some(crate::database::TenantOptions {
|
||||
database: "default".into(),
|
||||
max_pools: 2,
|
||||
idle_timeout_ms: 100,
|
||||
});
|
||||
let mut bindings = [
|
||||
binding("a_child", CHILD, Some(&CHILD_PLAN), &[1]),
|
||||
binding("z_parent", PARENT, Some(&PARENT_PLAN), &[]),
|
||||
];
|
||||
for binding in &mut bindings {
|
||||
binding.storage = Storage::Tenant;
|
||||
}
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let registry = Registry::prepare(&directory.0, config, &bindings, scope)
|
||||
.await
|
||||
.unwrap();
|
||||
for tenant in [1, 2, 1] {
|
||||
registry.migrate_tenant(scope, tenant).await.unwrap();
|
||||
let mut context = Context::new(registry.clone());
|
||||
context.select_tenant(scope, tenant).await.unwrap();
|
||||
assert_eq!(
|
||||
context
|
||||
.on(0, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.query(scope, "SELECT parent_id FROM a_child", vec![], 1, |row| row
|
||||
.get::<_, i64>(0))
|
||||
.await
|
||||
.unwrap(),
|
||||
[7]
|
||||
);
|
||||
}
|
||||
registry.close().await;
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
use super::*;
|
||||
use dever_runtime::{database::scalar, value::decimal::Decimal};
|
||||
|
||||
#[test]
|
||||
fn decimal_storage_covers_every_precision_and_scale() {
|
||||
for precision in 1..=34 {
|
||||
let limit = 10_i128.pow(precision);
|
||||
for scale in 0..=precision {
|
||||
for coefficient in [-limit + 1, -1, 0, 1, limit - 1] {
|
||||
let value = Decimal::parse(&format!("{coefficient}E-{scale}")).unwrap();
|
||||
let key = scalar::decimal::encode(value, precision, scale).unwrap();
|
||||
assert_eq!(
|
||||
key,
|
||||
format!(
|
||||
"{:0width$}",
|
||||
coefficient + limit,
|
||||
width = precision as usize + 1
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
scalar::decimal::decode(&key, precision, scale).unwrap(),
|
||||
value
|
||||
);
|
||||
}
|
||||
for coefficient in [-limit, limit] {
|
||||
let value = Decimal::parse(&format!("{coefficient}E-{scale}")).unwrap();
|
||||
assert!(scalar::decimal::encode(value, precision, scale).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_storage_rejects_non_digit_keys() {
|
||||
for position in 0..6 {
|
||||
for byte in 0..=127u8 {
|
||||
if byte.is_ascii_digit() {
|
||||
continue;
|
||||
}
|
||||
let mut key = b"100100".to_vec();
|
||||
key[position] = byte;
|
||||
assert!(scalar::decimal::decode(std::str::from_utf8(&key).unwrap(), 5, 2).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_keys_are_exact_unique_and_sort_like_numbers() {
|
||||
let values = [
|
||||
"-999.99", "-100", "-1.01", "-0.01", "0", "0.01", "1.01", "100", "999.99",
|
||||
];
|
||||
let encoded =
|
||||
values.map(|text| scalar::decimal::encode(Decimal::parse(text).unwrap(), 5, 2).unwrap());
|
||||
assert!(encoded.windows(2).all(|pair| pair[0] < pair[1]));
|
||||
for (text, key) in values.iter().zip(&encoded) {
|
||||
assert_eq!(
|
||||
scalar::decimal::decode(key, 5, 2).unwrap(),
|
||||
Decimal::parse(text).unwrap()
|
||||
);
|
||||
}
|
||||
for text in ["1", "1.0", "1.00", "1.000", "1E0"] {
|
||||
assert_eq!(
|
||||
scalar::decimal::encode(Decimal::parse(text).unwrap(), 5, 2).unwrap(),
|
||||
"100100"
|
||||
);
|
||||
}
|
||||
for text in ["0", "-0", "0E-6176"] {
|
||||
assert_eq!(
|
||||
scalar::decimal::encode(Decimal::parse(text).unwrap(), 5, 2).unwrap(),
|
||||
"100000"
|
||||
);
|
||||
}
|
||||
for text in ["1000", "-1000", "0.001", "-0.001", "1E6144", "1E-6176"] {
|
||||
assert_eq!(
|
||||
scalar::decimal::encode(Decimal::parse(text).unwrap(), 5, 2)
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
Kind::InvalidData,
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
for (precision, scale, text) in [
|
||||
(34, 0, "9999999999999999999999999999999999"),
|
||||
(34, 34, "0.9999999999999999999999999999999999"),
|
||||
(34, 4, "-999999999999999999999999999999.9999"),
|
||||
] {
|
||||
let value = Decimal::parse(text).unwrap();
|
||||
let key = scalar::decimal::encode(value, precision, scale).unwrap();
|
||||
assert_eq!(
|
||||
scalar::decimal::decode(&key, precision, scale).unwrap(),
|
||||
value
|
||||
);
|
||||
}
|
||||
for key in [
|
||||
"000000", "200000", "10001", "0100000", "+00001", "10000x", "100\0xx",
|
||||
] {
|
||||
assert_eq!(
|
||||
scalar::decimal::decode(key, 5, 2).unwrap_err().kind,
|
||||
Kind::InvalidData
|
||||
);
|
||||
}
|
||||
let directory = Directory::new();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor
|
||||
.initialize(
|
||||
scope,
|
||||
"CREATE TABLE numbers (amount TEXT UNIQUE, optional TEXT)",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
for key in encoded.iter().rev() {
|
||||
executor
|
||||
.execute(
|
||||
scope,
|
||||
"INSERT INTO numbers(amount) VALUES (?1)",
|
||||
vec![Parameter::Text(key.clone())],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let rows = executor
|
||||
.query(
|
||||
scope,
|
||||
"SELECT amount, optional FROM numbers ORDER BY amount",
|
||||
vec![],
|
||||
20,
|
||||
|row| {
|
||||
assert_eq!(
|
||||
row.get_ref(1)?.data_type(),
|
||||
dever_runtime::database::DataType::Null
|
||||
);
|
||||
scalar::decimal(row, 0, 5, 2)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows, values.map(|text| Decimal::parse(text).unwrap()));
|
||||
assert_eq!(
|
||||
executor
|
||||
.execute(
|
||||
scope,
|
||||
"INSERT INTO numbers(amount) VALUES (?1)",
|
||||
vec![Parameter::Text(encoded[4].clone())]
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
Kind::Constraint
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "calendar", feature = "uuid"))]
|
||||
#[test]
|
||||
fn typed_scalars_preserve_storage_bytes_and_reject_corrupt_values() {
|
||||
use dever_runtime::{
|
||||
time::calendar::{Date, Time},
|
||||
value::uuid::Uuid,
|
||||
wire::json::Json,
|
||||
};
|
||||
let directory = Directory::new();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let database = Database::open(&directory.0, options(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor
|
||||
.initialize(
|
||||
scope,
|
||||
"CREATE TABLE values_test (uid BLOB, day INTEGER, time INTEGER, document TEXT)",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let uid = Uuid::parse("ffffffff-ffff-ffff-ffff-ffffffffffff").unwrap();
|
||||
let document = "{\"value\":123456789012345678901234567890.0010,\"nested\":[true,null]}";
|
||||
for (day, time) in [
|
||||
("0000-01-01", "00:00:00"),
|
||||
("1969-12-31", "23:59:59.999"),
|
||||
("9999-12-31", "12:30:42.001"),
|
||||
] {
|
||||
let day = Date::parse(day).unwrap();
|
||||
let time = Time::parse(time).unwrap();
|
||||
executor
|
||||
.execute(
|
||||
scope,
|
||||
"INSERT INTO values_test VALUES (?1,?2,?3,?4)",
|
||||
vec![
|
||||
Parameter::Blob(uid.as_bytes().to_vec()),
|
||||
Parameter::Integer(day.millis()),
|
||||
Parameter::Integer(time.millis()),
|
||||
Parameter::Text(document.into()),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let actual = executor
|
||||
.query(
|
||||
scope,
|
||||
"SELECT * FROM values_test ORDER BY rowid DESC LIMIT 1",
|
||||
vec![],
|
||||
1,
|
||||
|row| {
|
||||
assert_eq!(
|
||||
row.get_ref(0)?.data_type(),
|
||||
dever_runtime::database::DataType::Blob
|
||||
);
|
||||
assert_eq!(
|
||||
row.get_ref(1)?.data_type(),
|
||||
dever_runtime::database::DataType::Integer
|
||||
);
|
||||
Ok((
|
||||
scalar::uuid(row, 0)?,
|
||||
scalar::date(row, 1)?,
|
||||
scalar::time(row, 2)?,
|
||||
scalar::json(row, 3)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(actual, [(uid, day, time, Json::parse(document).unwrap())]);
|
||||
assert_eq!(actual[0].3.as_str(), document);
|
||||
}
|
||||
executor
|
||||
.execute(scope, "DELETE FROM values_test", vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
for (sql, kind) in [
|
||||
("INSERT INTO values_test(uid) VALUES (zeroblob(15))", "Uuid"),
|
||||
("INSERT INTO values_test(day) VALUES (1)", "Date"),
|
||||
(
|
||||
"INSERT INTO values_test(day) VALUES (-62167219200001)",
|
||||
"Date",
|
||||
),
|
||||
("INSERT INTO values_test(time) VALUES (86400000)", "Time"),
|
||||
("INSERT INTO values_test(time) VALUES (-1)", "Time"),
|
||||
(
|
||||
"INSERT INTO values_test(document) VALUES ('{\"a\":1,\"a\":2}')",
|
||||
"Json",
|
||||
),
|
||||
(
|
||||
"INSERT INTO values_test(document) VALUES ('invalid')",
|
||||
"Json",
|
||||
),
|
||||
] {
|
||||
executor.execute(scope, sql, vec![]).await.unwrap();
|
||||
let failure = executor
|
||||
.query(
|
||||
scope,
|
||||
"SELECT * FROM values_test ORDER BY rowid DESC LIMIT 1",
|
||||
vec![],
|
||||
1,
|
||||
move |row| match kind {
|
||||
"Uuid" => scalar::uuid(row, 0).map(|_| ()),
|
||||
"Date" => scalar::date(row, 1).map(|_| ()),
|
||||
"Time" => scalar::time(row, 2).map(|_| ()),
|
||||
_ => scalar::json(row, 3).map(|_| ()),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(failure.kind, Kind::InvalidData, "{sql}");
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
use super::*;
|
||||
use dever_runtime::database::{Migration, Plan, Sql, Statement};
|
||||
|
||||
const BASE: &str = "CREATE TABLE \"account\" (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE CHECK(_dever_length(name) <= 24), legacy TEXT, amount TEXT(5,2) NOT NULL);";
|
||||
const NEXT: &str = "CREATE TABLE \"account\" (id INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT NOT NULL UNIQUE CHECK(_dever_length(label) <= 8), amount TEXT(7,3) NOT NULL, enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0,1)));";
|
||||
const BEFORE: Statement = Statement {
|
||||
sql: Sql::same("UPDATE account SET name = ?1 WHERE name = ?2"),
|
||||
parameters: || {
|
||||
Ok(vec![
|
||||
Parameter::Text("Editor".into()),
|
||||
Parameter::Text("Legacy".into()),
|
||||
])
|
||||
},
|
||||
};
|
||||
const AFTER: Statement = Statement {
|
||||
sql: Sql::same("UPDATE account SET label = ?1 WHERE label = ?2"),
|
||||
parameters: || {
|
||||
Ok(vec![
|
||||
Parameter::Text("Final".into()),
|
||||
Parameter::Text("Editor".into()),
|
||||
])
|
||||
},
|
||||
};
|
||||
const SEED: Statement = Statement {
|
||||
sql: Sql::same(
|
||||
"INSERT INTO account(label,amount) SELECT ?1,?2 WHERE NOT EXISTS(SELECT 1 FROM account WHERE label = ?1)",
|
||||
),
|
||||
parameters: || {
|
||||
Ok(vec![
|
||||
Parameter::Text("Seed".into()),
|
||||
Parameter::Text("10001000".into()),
|
||||
])
|
||||
},
|
||||
};
|
||||
const STEP: Migration = Migration {
|
||||
name: "normalize",
|
||||
revision: "normalize-v1",
|
||||
drops: &["legacy"],
|
||||
before: &[BEFORE],
|
||||
after: &[AFTER],
|
||||
};
|
||||
const INITIAL: Plan = Plan {
|
||||
create: Sql::same(BASE),
|
||||
indexes: Sql::same(""),
|
||||
postgres_columns: &[],
|
||||
renames: &[],
|
||||
migrations: &[],
|
||||
seeds: &[],
|
||||
seed_revision: "empty",
|
||||
};
|
||||
const UPGRADE: Plan = Plan {
|
||||
create: Sql::same(NEXT),
|
||||
indexes: Sql::same(""),
|
||||
postgres_columns: &[],
|
||||
renames: &[("label", "name")],
|
||||
migrations: &[STEP],
|
||||
seeds: &[SEED],
|
||||
seed_revision: "seed-v1",
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn schema_upgrade_preserves_rows_decimal_foreign_keys_and_id_sequence() {
|
||||
let directory = Directory::new();
|
||||
runtime().run(async |scope| {
|
||||
let database = Database::open(&directory.0,options(),scope).await.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor.migrate(scope,"account",BASE,Some(&INITIAL)).await.unwrap();
|
||||
executor.initialize(scope,"INSERT INTO account(id,name,legacy,amount) VALUES (1,'Legacy','old','101230'),(20,'Deleted','old','100000'); DELETE FROM account WHERE id=20; CREATE TABLE dependent(account_id INTEGER REFERENCES account(id) ON DELETE CASCADE); INSERT INTO dependent VALUES (1);").await.unwrap();
|
||||
executor.migrate(scope,"account",NEXT,Some(&UPGRADE)).await.unwrap();
|
||||
let rows = executor.query(scope,"SELECT id,label,amount,enabled FROM account ORDER BY id",vec![],20,|row|Ok((row.get::<_,i64>(0)?,row.get::<_,String>(1)?,dever_runtime::database::scalar::decimal(row,2,7,3)?.to_string(),row.get::<_,bool>(3)?))).await.unwrap();
|
||||
assert_eq!(rows,[(1,"Final".into(),"12.3".into(),true),(21,"Seed".into(),"1".into(),true)]);
|
||||
let child = executor.query(scope,"SELECT account_id FROM dependent",vec![],20,|row|row.get::<_,i64>(0)).await.unwrap();
|
||||
assert_eq!(child,[1]);
|
||||
assert!(executor.execute(scope,"INSERT INTO dependent VALUES (999)",vec![]).await.is_err());
|
||||
executor.migrate(scope,"account",NEXT,Some(&UPGRADE)).await.unwrap();
|
||||
assert_eq!(executor.query(scope,"SELECT COUNT(*) FROM account",vec![],1,|row|row.get::<_,i64>(0)).await.unwrap(),[2]);
|
||||
let old = executor.migrate(scope,"account",BASE,Some(&INITIAL)).await.unwrap_err();
|
||||
assert_eq!(old.kind,Kind::Migration);
|
||||
const ALTERED: Plan = Plan { migrations:&[Migration { revision:"changed", ..STEP }], ..UPGRADE };
|
||||
assert_eq!(executor.migrate(scope,"account",NEXT,Some(&ALTERED)).await.unwrap_err().kind,Kind::Migration);
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_after_step_or_narrowing_rolls_back_data_ddl_seed_and_history() {
|
||||
let directory = Directory::new();
|
||||
runtime().run(async |scope| {
|
||||
let database = Database::open(&directory.0,options(),scope).await.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor.migrate(scope,"account",BASE,Some(&INITIAL)).await.unwrap();
|
||||
executor.initialize(scope,"INSERT INTO account(name,legacy,amount) VALUES ('Legacy','old','101230');").await.unwrap();
|
||||
const FAIL: Plan = Plan { migrations:&[Migration { after:&[Statement { sql:Sql::same("UPDATE absent_table SET value = ?1"),parameters:||Ok(vec![Parameter::Integer(1)]) }], ..STEP }],..UPGRADE };
|
||||
assert!(executor.migrate(scope,"account",NEXT,Some(&FAIL)).await.is_err());
|
||||
let rows = executor.query(scope,"SELECT name,legacy,amount FROM account",vec![],20,|row|Ok((row.get::<_,String>(0)?,row.get::<_,String>(1)?,row.get::<_,String>(2)?))).await.unwrap();
|
||||
assert_eq!(rows,[("Legacy".into(),"old".into(),"101230".into())]);
|
||||
assert_eq!(executor.query(scope,"SELECT COUNT(*) FROM _dever_migration",vec![],1,|row|row.get::<_,i64>(0)).await.unwrap(),[0]);
|
||||
assert_eq!(executor.query(scope,"SELECT definition FROM _dever_history WHERE name='account'",vec![],1,|row|row.get::<_,String>(0)).await.unwrap(),[BASE]);
|
||||
executor.migrate(scope,"account",NEXT,Some(&UPGRADE)).await.unwrap();
|
||||
const TIGHT: &str = "CREATE TABLE \"account\" (id INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT NOT NULL UNIQUE CHECK(_dever_length(label) <= 4), amount TEXT(7,3) NOT NULL, enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0,1)));";
|
||||
const TIGHT_PLAN: Plan = Plan {create:Sql::same(TIGHT),..UPGRADE};
|
||||
assert_eq!(executor.migrate(scope,"account",TIGHT,Some(&TIGHT_PLAN)).await.unwrap_err().kind,Kind::Constraint);
|
||||
executor.migrate(scope,"account",NEXT,Some(&UPGRADE)).await.unwrap();
|
||||
assert_eq!(executor.query(scope,"SELECT label FROM account ORDER BY id",vec![],20,|row|row.get::<_,String>(0)).await.unwrap(),["Final","Seed"]);
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_conflict_rolls_back_prior_seed_rows_and_revision() {
|
||||
let directory = Directory::new();
|
||||
runtime().run(async |scope| {
|
||||
let database = Database::open(&directory.0,options(),scope).await.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
const SQL: &str = "CREATE TABLE \"account\" (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, email TEXT NOT NULL UNIQUE);";
|
||||
const FIRST: Statement = Statement { sql:Sql::same("INSERT INTO account(name,email) SELECT ?1,?2 WHERE NOT EXISTS(SELECT 1 FROM account WHERE name=?1)"), parameters:||Ok(vec![Parameter::Text("first".into()),Parameter::Text("one".into())]) };
|
||||
const START: Plan = Plan {create:Sql::same(SQL),indexes:Sql::same(""),postgres_columns:&[],renames:&[],migrations:&[],seeds:&[FIRST],seed_revision:"one"};
|
||||
executor.migrate(scope,"account",SQL,Some(&START)).await.unwrap();
|
||||
const MORE: Plan = Plan {seeds:&[FIRST,
|
||||
Statement {sql:FIRST.sql,parameters:||Ok(vec![Parameter::Text("second".into()),Parameter::Text("two".into())])},
|
||||
Statement {sql:FIRST.sql,parameters:||Ok(vec![Parameter::Text("third".into()),Parameter::Text("one".into())])}],seed_revision:"more",..START};
|
||||
assert_eq!(executor.migrate(scope,"account",SQL,Some(&MORE)).await.unwrap_err().kind,Kind::Constraint);
|
||||
assert_eq!(executor.query(scope,"SELECT name FROM account",vec![],20,|row|row.get::<_,String>(0)).await.unwrap(),["first"]);
|
||||
assert_eq!(executor.query(scope,"SELECT revision FROM _dever_seed WHERE model='account'",vec![],1,|row|row.get::<_,String>(0)).await.unwrap(),["one"]);
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_rebuild_keeps_the_last_allocated_id() {
|
||||
let directory = Directory::new();
|
||||
runtime().run(async |scope| {
|
||||
let database = Database::open(&directory.0,options(),scope).await.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
executor.migrate(scope,"account",BASE,Some(&INITIAL)).await.unwrap();
|
||||
executor.initialize(scope,"INSERT INTO account(id,name,amount) VALUES (20,'gone','100000'); DELETE FROM account;").await.unwrap();
|
||||
const EMPTY: Plan = Plan { seeds:&[],seed_revision:"empty",..UPGRADE };
|
||||
executor.migrate(scope,"account",NEXT,Some(&EMPTY)).await.unwrap();
|
||||
executor.execute(scope,"INSERT INTO account(label,amount) VALUES ('new','10000000')",vec![]).await.unwrap();
|
||||
assert_eq!(executor.query(scope,"SELECT id FROM account",vec![],1,|row|row.get::<_,i64>(0)).await.unwrap(),[21]);
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_seed_failure_preserves_earlier_models_and_rolls_back_its_model() {
|
||||
let directory = Directory::new();
|
||||
runtime().run(async |scope| {
|
||||
let database = Database::open(&directory.0,options(),scope).await.unwrap();
|
||||
let mut executor = database.executor().await.unwrap();
|
||||
const CHILD: &str = "CREATE TABLE \"child\" (id INTEGER PRIMARY KEY, parent_id INTEGER NOT NULL REFERENCES parent(id));";
|
||||
const EMPTY: Plan = Plan {create:Sql::same(CHILD),indexes:Sql::same(""),postgres_columns:&[],renames:&[],migrations:&[],seeds:&[],seed_revision:"empty"};
|
||||
// 没有记录时可以先建外键表;真正的 Seed 必须引用已存在的目标记录。
|
||||
executor.migrate(scope,"child",CHILD,Some(&EMPTY)).await.unwrap();
|
||||
const PARENT: &str = "CREATE TABLE \"parent\" (id INTEGER PRIMARY KEY, name TEXT NOT NULL);";
|
||||
executor.schema(scope,"parent",PARENT).await.unwrap();
|
||||
executor.execute(scope,"INSERT INTO parent VALUES (1,'preserved')",vec![]).await.unwrap();
|
||||
const INVALID: Plan = Plan {seeds:&[Statement {sql:Sql::same("INSERT INTO child VALUES (1,999)"),parameters:||Ok(vec![])}],seed_revision:"invalid",..EMPTY};
|
||||
assert!(executor.migrate(scope,"child",CHILD,Some(&INVALID)).await.is_err());
|
||||
assert_eq!(executor.query(scope,"SELECT name FROM parent",vec![],1,|row|row.get::<_,String>(0)).await.unwrap(),["preserved"]);
|
||||
assert_eq!(executor.query(scope,"SELECT COUNT(*) FROM child",vec![],1,|row|row.get::<_,i64>(0)).await.unwrap(),[0]);
|
||||
assert_eq!(executor.query(scope,"SELECT revision FROM _dever_seed WHERE model='child'",vec![],1,|row|row.get::<_,String>(0)).await.unwrap(),["empty"]);
|
||||
assert!(executor.execute(scope,"INSERT INTO child VALUES (1,999)",vec![]).await.is_err());
|
||||
const VALID: Plan = Plan {seeds:&[Statement {sql:Sql::same("INSERT INTO child VALUES (1,1)"),parameters:||Ok(vec![])}],seed_revision:"valid",..EMPTY};
|
||||
executor.migrate(scope,"child",CHILD,Some(&VALID)).await.unwrap();
|
||||
}).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
adapter::Document,
|
||||
database::{Binding, Settings, Storage},
|
||||
task::{Limits, Runtime},
|
||||
};
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::atomic::{AtomicU64, AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
struct Directory(PathBuf);
|
||||
impl Directory {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"dever-row-stream-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
std::fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
impl Drop for Directory {
|
||||
fn drop(&mut self) {
|
||||
std::fs::remove_dir_all(&self.0).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
async fn registry(directory: &Directory, scope: &Scope<'_>) -> Arc<Registry> {
|
||||
let settings = Settings::load(&Document::parse(r#"{"database":{"default":{"type":"sqlite","path":"case.db","max_connections":1,"max_page_size":2,"wait_timeout_ms":1000}}}"#.into()).unwrap()).unwrap();
|
||||
Registry::prepare(
|
||||
&directory.0,
|
||||
settings,
|
||||
&[Binding {
|
||||
component: "demo",
|
||||
database: None,
|
||||
table: "rows",
|
||||
schema: Sql::same("CREATE TABLE rows (id INTEGER PRIMARY KEY)"),
|
||||
plan: None,
|
||||
references: &[],
|
||||
storage: Storage::Global,
|
||||
}],
|
||||
scope,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
const ROWS: Sql = Sql::same(
|
||||
"WITH RECURSIVE items(value) AS (SELECT 1 UNION ALL SELECT value+1 FROM items WHERE value < 10000) SELECT value FROM items",
|
||||
);
|
||||
|
||||
#[test]
|
||||
fn bounded_stream_close_drop_and_decode_error_release_single_connection() {
|
||||
let directory = Directory::new();
|
||||
let runtime = Runtime::new(Limits {
|
||||
threads: 2,
|
||||
workers: 1,
|
||||
tasks: 16,
|
||||
})
|
||||
.unwrap();
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
let registry = registry(&directory, scope).await;
|
||||
let mut context = Context::new(registry.clone());
|
||||
for mode in 0..3 {
|
||||
let produced = Arc::new(AtomicUsize::new(0));
|
||||
let counted = produced.clone();
|
||||
let rows = context
|
||||
.stream(0, scope, ROWS, vec![], move |row| {
|
||||
counted.fetch_add(1, Ordering::Relaxed);
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows.next().await.unwrap(), Some(1));
|
||||
// 消费者暂停时最多只多解码通道一行与待发送一行。
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
assert!(produced.load(Ordering::Relaxed) <= 3);
|
||||
// SQLite 游标在阻塞线程中借用 statement;背压期间仍持有 worker。
|
||||
assert!(
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_millis(10),
|
||||
scope.blocking(|| 42),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
match mode {
|
||||
0 => {
|
||||
rows.close();
|
||||
assert_eq!(rows.next().await.unwrap(), None);
|
||||
}
|
||||
1 => drop(rows),
|
||||
_ => {
|
||||
let alias = rows.clone();
|
||||
alias.close();
|
||||
assert_eq!(rows.next().await.unwrap(), None);
|
||||
}
|
||||
}
|
||||
assert_eq!(scope.blocking(|| 42).await.unwrap(), 42);
|
||||
let found = context
|
||||
.on(0, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.query(scope, "SELECT 42", vec![], 1, |row| row.get::<_, i64>(0))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(found, [42]);
|
||||
}
|
||||
let rows = context
|
||||
.stream(0, scope, Sql::same("SELECT 'bad'"), vec![], |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let StreamError::Database(error) = rows.next().await.unwrap_err() else {
|
||||
panic!("database error must retain its category")
|
||||
};
|
||||
assert_eq!(error.kind, Kind::InvalidData);
|
||||
assert_eq!(rows.next().await.unwrap(), None);
|
||||
assert!(context.on(0, scope).await.is_ok());
|
||||
drop(context);
|
||||
registry.close().await;
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_stream_owner_is_drained_and_pool_remains_usable() {
|
||||
let directory = Directory::new();
|
||||
let runtime = Runtime::new(Limits {
|
||||
threads: 2,
|
||||
workers: 2,
|
||||
tasks: 16,
|
||||
})
|
||||
.unwrap();
|
||||
runtime
|
||||
.run(async |scope| {
|
||||
let registry = registry(&directory, scope).await;
|
||||
let (ready, started) = tokio::sync::oneshot::channel();
|
||||
let owned = registry.clone();
|
||||
let task = scope
|
||||
.spawn(move |child| async move {
|
||||
let mut context = Context::new(owned);
|
||||
let rows = context
|
||||
.stream(0, &child, ROWS, vec![], |row| row.get::<_, i64>(0))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows.next().await.unwrap(), Some(1));
|
||||
ready.send(()).unwrap();
|
||||
std::future::pending::<()>().await;
|
||||
drop(rows);
|
||||
})
|
||||
.unwrap();
|
||||
started.await.unwrap();
|
||||
drop(task);
|
||||
let mut context = Context::new(registry.clone());
|
||||
assert!(context.on(0, scope).await.is_ok());
|
||||
drop(context);
|
||||
registry.close().await;
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
use super::*;
|
||||
use crate::database::tenant::{Manager, Pool};
|
||||
use std::{
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn reaping_deadline_keeps_pending_and_active_pools_visible() {
|
||||
let directory = Directory::new();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let mut settings = settings(true, 2);
|
||||
settings.1.as_mut().unwrap().idle_timeout_ms = 86_400_000;
|
||||
let database = crate::database::Database::open(
|
||||
&directory.0,
|
||||
settings.0.remove("default").unwrap(),
|
||||
scope,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let manager = Manager::new(
|
||||
&directory.0,
|
||||
settings.1.unwrap(),
|
||||
std::slice::from_ref(&database),
|
||||
0,
|
||||
&[],
|
||||
&[],
|
||||
&["default"],
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
let mut cache = manager.cache.lock().await;
|
||||
let first_state = manager.reserve(&mut cache, (0, 1)).unwrap();
|
||||
let active = Arc::new(Pool {
|
||||
database: database.clone(),
|
||||
});
|
||||
{
|
||||
let mut ready = first_state.try_lock().unwrap();
|
||||
ready.pool = Some(active.clone());
|
||||
ready.ready = true;
|
||||
}
|
||||
let first = Arc::downgrade(&first_state);
|
||||
drop(first_state);
|
||||
manager.evict(&mut cache);
|
||||
assert_eq!(first.strong_count(), 1);
|
||||
|
||||
let pending_state = manager.reserve(&mut cache, (0, 2)).unwrap();
|
||||
let pending = Arc::downgrade(&pending_state);
|
||||
drop(pending_state);
|
||||
manager.evict(&mut cache);
|
||||
assert_eq!(pending.strong_count(), 0);
|
||||
assert_eq!(first.strong_count(), 1);
|
||||
|
||||
cache.entries.get_mut(&(0, 1)).unwrap().touched =
|
||||
Instant::now() - Duration::from_secs(172800);
|
||||
cache.check_at = Instant::now();
|
||||
manager.evict(&mut cache);
|
||||
assert_eq!(first.strong_count(), 1);
|
||||
drop(active);
|
||||
manager.evict(&mut cache);
|
||||
assert_eq!(first.strong_count(), 1);
|
||||
assert!(cache.check_at > Instant::now());
|
||||
cache.check_at = Instant::now();
|
||||
manager.evict(&mut cache);
|
||||
assert_eq!(first.strong_count(), 0);
|
||||
drop(cache);
|
||||
manager.close().await;
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tenant_configuration_rejects_unknown_controls_and_escaping_locations() {
|
||||
for tenant in [
|
||||
serde_json::json!({"database":"missing"}),
|
||||
serde_json::json!({"database":"default","mode":"table"}),
|
||||
serde_json::json!({"database":"default","max_pools":0}),
|
||||
serde_json::json!({"database":"default","idle_timeout_ms":0}),
|
||||
serde_json::Value::Null,
|
||||
] {
|
||||
let document = serde_json::json!({"database":{"default":{"type":"sqlite","path":"app.db","tenant_directory":"tenants"}}, "tenant":tenant});
|
||||
assert!(Settings::load(&Document::parse(document.to_string()).unwrap()).is_err());
|
||||
}
|
||||
for path in ["", "../tenants", "/tmp/tenants", "data/../tenants"] {
|
||||
let mut config = settings(true, 2);
|
||||
config.0.get_mut("default").unwrap().tenant_directory = Some(path.to_owned());
|
||||
assert!(config.0["default"].validate().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_tenant_namespaces_and_platform_files_are_rejected() {
|
||||
let directory = Directory::new();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let mut config = settings(true, 2);
|
||||
let mut secondary = config.0["default"].clone();
|
||||
secondary.path = "secondary.db".to_owned();
|
||||
secondary.tenant_directory = Some("tenants//".to_owned());
|
||||
config.0.insert("secondary".to_owned(), secondary);
|
||||
let mut models = bindings();
|
||||
models[0].database = Some("secondary");
|
||||
assert_eq!(
|
||||
Registry::prepare(&directory.0, config, &models, scope)
|
||||
.await
|
||||
.err()
|
||||
.unwrap()
|
||||
.kind,
|
||||
Kind::Connection
|
||||
);
|
||||
let mut config = settings(true, 2);
|
||||
config.0.get_mut("default").unwrap().path = "tenants/1.db".to_owned();
|
||||
assert_eq!(
|
||||
Registry::prepare(&directory.0, config, &bindings(), scope)
|
||||
.await
|
||||
.err()
|
||||
.unwrap()
|
||||
.kind,
|
||||
Kind::Connection
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_database_is_selected_explicitly_and_multiple_physical_sources_migrate() {
|
||||
let directory = Directory::new();
|
||||
runtime().run(async |scope| {
|
||||
let mut config = settings(true, 1);
|
||||
let mut security = config.0["default"].clone();
|
||||
security.path = "security.db".to_owned();
|
||||
security.tenant_directory = Some("security-tenants".to_owned());
|
||||
config.0.insert("security".to_owned(), security);
|
||||
config.1.as_mut().unwrap().database = "security".to_owned();
|
||||
let registry = Registry::prepare(&directory.0, config, &bindings(), scope).await.unwrap();
|
||||
registry.migrate_tenant(scope, 1).await.unwrap();
|
||||
let mut context = Context::new(registry.clone());
|
||||
context.select_tenant(scope, 1).await.unwrap();
|
||||
let marker = context.control(scope).await.unwrap().query(scope, "SELECT COUNT(*) FROM _dever_tenant_storage WHERE ready=1 AND length(fingerprint)=64", vec![], 1, |row| row.get::<_, i64>(0)).await.unwrap();
|
||||
assert_eq!(marker, [2]);
|
||||
assert!(registry.check_transactions(&[&[0,2]]).is_err());
|
||||
assert!(context.select_tenant(scope, 2).await.is_err());
|
||||
assert_eq!(context.tenant_id(), None);
|
||||
assert!(context.on(0, scope).await.is_err());
|
||||
registry.close().await;
|
||||
assert!(directory.0.join("security-tenants/1.db").exists());
|
||||
assert!(directory.0.join("tenants/1.db").exists());
|
||||
}).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn explicit_migration_routes_models_roles_and_control_to_distinct_physical_storage() {
|
||||
let directory = Directory::new();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let registry = Registry::prepare(&directory.0, settings(true, 4), &bindings(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(registry.tenant_enabled());
|
||||
assert!(registry.is_tenant(0));
|
||||
let mut missing = Context::new(registry.clone());
|
||||
assert_eq!(
|
||||
missing.on(0, scope).await.err().unwrap().kind,
|
||||
Kind::InvalidData
|
||||
);
|
||||
assert_eq!(
|
||||
missing.select_tenant(scope, 1).await.unwrap_err().kind,
|
||||
Kind::Migration
|
||||
);
|
||||
assert!(!directory.0.join("tenants").exists());
|
||||
let rows = missing
|
||||
.control(scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.query(
|
||||
scope,
|
||||
"SELECT COUNT(*) FROM sqlite_schema WHERE name='items'",
|
||||
vec![],
|
||||
1,
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows, [0]);
|
||||
registry.migrate_tenant(scope, 1).await.unwrap();
|
||||
registry.migrate_tenant(scope, 2).await.unwrap();
|
||||
registry.migrate_tenant(scope, 1).await.unwrap();
|
||||
assert_eq!(
|
||||
registry.queue_tenants(0, scope).await.unwrap(),
|
||||
[Some(1), Some(2)]
|
||||
);
|
||||
assert_eq!(registry.queue_tenants(1, scope).await.unwrap(), [None]);
|
||||
for id in [1, 2] {
|
||||
let mut context = Context::new(registry.clone());
|
||||
context.select_tenant(scope, id).await.unwrap();
|
||||
assert_eq!(context.tenant_id(), Some(id));
|
||||
assert_eq!(context.detached().tenant_id(), Some(id));
|
||||
let owner = context.enter();
|
||||
context
|
||||
.on(0, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.execute(
|
||||
scope,
|
||||
"INSERT INTO items(id,value) VALUES(1,$1)",
|
||||
vec![crate::database::Parameter::Text(format!("tenant {id}"))],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
context
|
||||
.on(2, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.execute(scope, "INSERT INTO roles(id) VALUES(1)", vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
context.finish(scope, owner, true).await.unwrap();
|
||||
let rows = context
|
||||
.on(0, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.query(scope, "SELECT value FROM items", vec![], 1, |row| {
|
||||
row.get::<_, String>(0)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows, [format!("tenant {id}")]);
|
||||
let count = context
|
||||
.control(scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.query(scope, "SELECT COUNT(*) FROM roles", vec![], 1, |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, [0]);
|
||||
}
|
||||
registry.close().await;
|
||||
assert!(directory.0.join("tenants/1.db").is_file());
|
||||
assert!(directory.0.join("tenants/2.db").is_file());
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_switches_are_immediate_validated_and_do_not_hide_ready_queues() {
|
||||
let directory = Directory::new();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let registry = Registry::prepare(&directory.0, settings(true, 2), &bindings(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
registry.migrate_tenant(scope, 1).await.unwrap();
|
||||
let mut context = Context::new(registry.clone());
|
||||
context.select_tenant(scope, 1).await.unwrap();
|
||||
assert!(context.components_enabled(scope, &["shop"]).await.unwrap());
|
||||
registry
|
||||
.set_component(scope, 1, "shop", false)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!context.components_enabled(scope, &["shop"]).await.unwrap());
|
||||
assert!(context.require_components(scope, &["shop"]).await.is_err());
|
||||
assert!(
|
||||
context
|
||||
.components_enabled(scope, &["platform"])
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(registry.queue_tenants(0, scope).await.unwrap(), [Some(1)]);
|
||||
for invalid in ["platform", "unknown", "dever"] {
|
||||
assert_eq!(
|
||||
registry
|
||||
.set_component(scope, 1, invalid, false)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
Kind::InvalidData
|
||||
);
|
||||
}
|
||||
registry
|
||||
.set_component(scope, 1, "shop", true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(context.components_enabled(scope, &["shop"]).await.unwrap());
|
||||
registry.close().await;
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_component_listing_paginates_and_observes_current_tenant_state() {
|
||||
let directory = Directory::new();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let mut models = bindings().to_vec();
|
||||
for component in ["billing", "catalog"] {
|
||||
models.push(crate::database::Binding {
|
||||
component,
|
||||
table: component,
|
||||
schema: if component == "billing" {
|
||||
Sql::same("CREATE TABLE billing(id BIGINT PRIMARY KEY)")
|
||||
} else {
|
||||
Sql::same("CREATE TABLE catalog(id BIGINT PRIMARY KEY)")
|
||||
},
|
||||
..bindings()[0].clone()
|
||||
});
|
||||
}
|
||||
let registry = Registry::prepare(&directory.0, settings(true, 2), &models, scope)
|
||||
.await
|
||||
.unwrap();
|
||||
registry.migrate_tenant(scope, 1).await.unwrap();
|
||||
registry.migrate_tenant(scope, 2).await.unwrap();
|
||||
let components = ["billing", "catalog", "shop", "platform", "unknown"]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let mut context = Context::new(registry.clone());
|
||||
assert!(
|
||||
context
|
||||
.disabled_components(scope, &components)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
context.select_tenant(scope, 1).await.unwrap();
|
||||
for component in ["billing", "catalog", "shop"] {
|
||||
registry
|
||||
.set_component(scope, 1, component, false)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
context
|
||||
.disabled_components(scope, &components)
|
||||
.await
|
||||
.unwrap(),
|
||||
["billing", "catalog", "shop"]
|
||||
.map(String::from)
|
||||
.into_iter()
|
||||
.collect()
|
||||
);
|
||||
registry
|
||||
.set_component(scope, 1, "catalog", true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
context
|
||||
.disabled_components(scope, &components)
|
||||
.await
|
||||
.unwrap(),
|
||||
["billing", "shop"].map(String::from).into_iter().collect()
|
||||
);
|
||||
context.select_tenant(scope, 2).await.unwrap();
|
||||
assert!(
|
||||
context
|
||||
.disabled_components(scope, &components)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
registry.close().await;
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transactions_and_foreign_keys_cannot_cross_tenant_boundaries() {
|
||||
let directory = Directory::new();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let registry = Registry::prepare(&directory.0, settings(true, 2), &bindings(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(registry.check_transactions(&[&[0, 1]]).is_err());
|
||||
registry.check_transactions(&[&[0, 2]]).unwrap();
|
||||
registry.check_transactions(&[&[1, 2]]).unwrap();
|
||||
assert!(registry.check_transactions(&[&[2, 0, 1]]).is_err());
|
||||
registry.migrate_tenant(scope, 1).await.unwrap();
|
||||
let mut context = Context::new(registry.clone());
|
||||
context.select_tenant(scope, 1).await.unwrap();
|
||||
context
|
||||
.on(1, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.query(scope, "SELECT COUNT(*) FROM accounts", vec![], 1, |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let owner = context.enter();
|
||||
context
|
||||
.on(0, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.execute(
|
||||
scope,
|
||||
"INSERT INTO items(id,value) VALUES(1,'rollback')",
|
||||
vec![],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(context.on(1, scope).await.is_err());
|
||||
assert!(context.select_tenant(scope, 2).await.is_err());
|
||||
context.finish(scope, owner, false).await.unwrap();
|
||||
assert_eq!(
|
||||
context
|
||||
.on(0, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.query(scope, "SELECT COUNT(*) FROM items", vec![], 1, |row| row
|
||||
.get::<_, i64>(0))
|
||||
.await
|
||||
.unwrap(),
|
||||
[0]
|
||||
);
|
||||
let owner = context.enter();
|
||||
context
|
||||
.on(1, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.execute(scope, "INSERT INTO accounts(id) VALUES(1)", vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(context.on(0, scope).await.is_err());
|
||||
context.finish(scope, owner, true).await.unwrap();
|
||||
assert_eq!(
|
||||
context
|
||||
.on(1, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.query(scope, "SELECT COUNT(*) FROM accounts", vec![], 1, |row| row
|
||||
.get::<_, i64>(0))
|
||||
.await
|
||||
.unwrap(),
|
||||
[1]
|
||||
);
|
||||
drop(context);
|
||||
registry.close().await;
|
||||
let mut invalid = bindings();
|
||||
invalid[0].references = &[1];
|
||||
assert_eq!(
|
||||
Registry::prepare(&directory.0, settings(true, 2), &invalid, scope)
|
||||
.await
|
||||
.err()
|
||||
.unwrap()
|
||||
.kind,
|
||||
Kind::Connection
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_tenant_configuration_preserves_ordinary_database_routing() {
|
||||
let directory = Directory::new();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let registry = Registry::prepare(&directory.0, settings(false, 2), &bindings(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!registry.tenant_enabled());
|
||||
registry.check_transactions(&[&[0, 1, 2]]).unwrap();
|
||||
let mut context = Context::new(registry.clone());
|
||||
assert!(context.select_tenant(scope, 1).await.is_err());
|
||||
context
|
||||
.on(0, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.execute(
|
||||
scope,
|
||||
"INSERT INTO items(id,value) VALUES(1,'platform')",
|
||||
vec![],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(context.require_components(scope, &["shop"]).await.is_ok());
|
||||
drop(context);
|
||||
registry.close().await;
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bounded_cache_never_evicts_an_active_tenant_pool() {
|
||||
let directory = Directory::new();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let registry = Registry::prepare(&directory.0, settings(true, 1), &bindings(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
registry.migrate_tenant(scope, 1).await.unwrap();
|
||||
registry.migrate_tenant(scope, 2).await.unwrap();
|
||||
let mut first = Context::new(registry.clone());
|
||||
first.select_tenant(scope, 1).await.unwrap();
|
||||
first.on(0, scope).await.unwrap();
|
||||
let mut second = Context::new(registry.clone());
|
||||
assert_eq!(
|
||||
second.select_tenant(scope, 2).await.unwrap_err().kind,
|
||||
Kind::PoolExhausted
|
||||
);
|
||||
assert_eq!(
|
||||
registry.migrate_tenant(scope, 1).await.unwrap_err().kind,
|
||||
Kind::Pool
|
||||
);
|
||||
drop(first);
|
||||
second.select_tenant(scope, 2).await.unwrap();
|
||||
second.on(0, scope).await.unwrap();
|
||||
drop(second);
|
||||
registry.close().await;
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_or_missing_markers_and_files_never_fall_back_or_recreate_storage() {
|
||||
let directory = Directory::new();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let registry = Registry::prepare(&directory.0, settings(true, 1), &bindings(), scope)
|
||||
.await
|
||||
.unwrap();
|
||||
registry.migrate_tenant(scope, 1).await.unwrap();
|
||||
let mut context = Context::new(registry.clone());
|
||||
context.select_tenant(scope, 1).await.unwrap();
|
||||
context
|
||||
.on(0, scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.execute(
|
||||
scope,
|
||||
"UPDATE _dever_tenant_ready SET fingerprint='wrong'",
|
||||
vec![],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
drop(context);
|
||||
let mut next = Context::new(registry.clone());
|
||||
assert_eq!(
|
||||
next.select_tenant(scope, 1).await.unwrap_err().kind,
|
||||
Kind::Migration
|
||||
);
|
||||
registry.migrate_tenant(scope, 1).await.unwrap();
|
||||
next.control(scope)
|
||||
.await
|
||||
.unwrap()
|
||||
.execute(scope, "UPDATE _dever_tenant_storage SET ready=0", vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(next.select_tenant(scope, 1).await.is_err());
|
||||
assert!(registry.queue_tenants(0, scope).await.unwrap().is_empty());
|
||||
registry.migrate_tenant(scope, 1).await.unwrap();
|
||||
std::fs::remove_file(directory.0.join("tenants/1.db")).unwrap();
|
||||
assert!(next.select_tenant(scope, 1).await.is_err());
|
||||
assert!(!directory.0.join("tenants/1.db").exists());
|
||||
for id in [0, -1] {
|
||||
assert_eq!(
|
||||
registry.migrate_tenant(scope, id).await.unwrap_err().kind,
|
||||
Kind::InvalidData
|
||||
);
|
||||
}
|
||||
registry.close().await;
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_schema_migration_stays_unready_and_never_becomes_a_cached_success() {
|
||||
let directory = Directory::new();
|
||||
runtime().run(async |scope| {
|
||||
let registry = Registry::prepare(&directory.0, settings(true, 2), &bindings(), scope).await.unwrap();
|
||||
registry.migrate_tenant(scope, 1).await.unwrap();
|
||||
registry.close().await;
|
||||
drop(registry);
|
||||
let mut changed = bindings();
|
||||
changed[0].schema = Sql::same("CREATE TABLE items(id BIGINT PRIMARY KEY, value TEXT NOT NULL, changed BIGINT NOT NULL)");
|
||||
let registry = Registry::prepare(&directory.0, settings(true, 2), &changed, scope).await.unwrap();
|
||||
let mut context = Context::new(registry.clone());
|
||||
assert!(context.select_tenant(scope, 1).await.is_err());
|
||||
assert_eq!(registry.migrate_tenant(scope, 1).await.unwrap_err().kind, Kind::Migration);
|
||||
assert!(context.select_tenant(scope, 1).await.is_err());
|
||||
assert!(registry.queue_tenants(0, scope).await.unwrap().is_empty());
|
||||
registry.close().await;
|
||||
}).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use crate::{
|
||||
adapter::Document,
|
||||
database::{Binding, Context, Kind, Registry, Settings, Sql, Storage},
|
||||
task::{Limits, Runtime},
|
||||
};
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
mod cache;
|
||||
mod configuration;
|
||||
mod isolation;
|
||||
mod lifecycle;
|
||||
mod performance;
|
||||
#[cfg(feature = "postgres")]
|
||||
mod postgres;
|
||||
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
struct Directory(PathBuf);
|
||||
impl Directory {
|
||||
fn new() -> Self {
|
||||
let directory = Self(std::env::temp_dir().join(format!(
|
||||
"dever-tenant-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||
)));
|
||||
std::fs::create_dir(&directory.0).unwrap();
|
||||
directory
|
||||
}
|
||||
}
|
||||
impl Drop for Directory {
|
||||
fn drop(&mut self) {
|
||||
std::fs::remove_dir_all(&self.0).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime() -> Runtime {
|
||||
Runtime::new(Limits {
|
||||
threads: 2,
|
||||
workers: 2,
|
||||
tasks: 32,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn settings(tenant: bool, pools: usize) -> Settings {
|
||||
let mut document = serde_json::json!({"database":{"default":{"type":"sqlite","path":"platform.db","tenant_directory":"tenants","max_connections":2,"max_page_size":1,"wait_timeout_ms":100}}});
|
||||
if tenant {
|
||||
document["tenant"] =
|
||||
serde_json::json!({"database":"default","max_pools":pools,"idle_timeout_ms":10});
|
||||
}
|
||||
Settings::load(&Document::parse(document.to_string()).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn bindings() -> [Binding; 4] {
|
||||
[
|
||||
Binding {
|
||||
storage: Storage::Tenant,
|
||||
component: "shop",
|
||||
database: None,
|
||||
table: "items",
|
||||
schema: Sql::same("CREATE TABLE items(id BIGINT PRIMARY KEY, value TEXT NOT NULL)"),
|
||||
plan: None,
|
||||
references: &[],
|
||||
},
|
||||
Binding {
|
||||
storage: Storage::Global,
|
||||
component: "platform",
|
||||
database: None,
|
||||
table: "accounts",
|
||||
schema: Sql::same("CREATE TABLE accounts(id BIGINT PRIMARY KEY)"),
|
||||
plan: None,
|
||||
references: &[],
|
||||
},
|
||||
Binding {
|
||||
storage: Storage::Authorization,
|
||||
component: "dever",
|
||||
database: None,
|
||||
table: "roles",
|
||||
schema: Sql::same("CREATE TABLE roles(id BIGINT PRIMARY KEY)"),
|
||||
plan: None,
|
||||
references: &[],
|
||||
},
|
||||
Binding {
|
||||
storage: Storage::Control,
|
||||
component: "dever",
|
||||
database: None,
|
||||
table: "permissions",
|
||||
schema: Sql::same("CREATE TABLE permissions(id BIGINT PRIMARY KEY)"),
|
||||
plan: None,
|
||||
references: &[],
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
use super::*;
|
||||
use crate::database::tenant::{Manager, Pool};
|
||||
use std::{hint::black_box, sync::Arc, time::Instant};
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit release performance probe"]
|
||||
fn cached_pool_reaping_cost() {
|
||||
println!("\ncase,sample,iterations,elapsed_ns,checksum");
|
||||
for count in [1, 32, 256] {
|
||||
for busy in [false, true] {
|
||||
let directory = Directory::new();
|
||||
runtime()
|
||||
.run(async |scope| {
|
||||
let mut settings = settings(true, count);
|
||||
settings.1.as_mut().unwrap().idle_timeout_ms = 86_400_000;
|
||||
let database = crate::database::Database::open(
|
||||
&directory.0,
|
||||
settings.0.remove("default").unwrap(),
|
||||
scope,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let manager = Manager::new(
|
||||
&directory.0,
|
||||
settings.1.unwrap(),
|
||||
std::slice::from_ref(&database),
|
||||
0,
|
||||
&[],
|
||||
&[],
|
||||
&["default"],
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
let mut cache = manager.cache.lock().await;
|
||||
let mut states = Vec::new();
|
||||
for tenant in 0..count {
|
||||
let state = manager.reserve(&mut cache, (0, tenant as i64 + 1)).unwrap();
|
||||
let mut ready = state.try_lock().unwrap();
|
||||
ready.pool = Some(Arc::new(Pool {
|
||||
database: database.clone(),
|
||||
}));
|
||||
ready.ready = true;
|
||||
states.push(Arc::downgrade(&state));
|
||||
}
|
||||
// 只测回收检查,不包含建库、SQL 或池获取;Weak 不改变使用中状态。
|
||||
let active = busy.then(|| states[0].upgrade().unwrap());
|
||||
for _ in 0..100 {
|
||||
manager.evict(&mut cache);
|
||||
}
|
||||
for sample in 0..15 {
|
||||
let started = Instant::now();
|
||||
for _ in 0..10_000 {
|
||||
black_box(&manager).evict(black_box(&mut cache));
|
||||
}
|
||||
let elapsed = started.elapsed().as_nanos();
|
||||
assert_eq!(states[0].strong_count(), if busy { 2 } else { 1 });
|
||||
println!("tenant_cache_{count}_{busy},{sample},10000,{elapsed},{count}");
|
||||
}
|
||||
drop(active);
|
||||
drop(cache);
|
||||
manager.close().await;
|
||||
assert!(states.iter().all(|state| state.strong_count() == 0));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
use super::*;
|
||||
use crate::database::{Database, Lease, Options, Parameter};
|
||||
use futures_util::FutureExt;
|
||||
use std::panic::AssertUnwindSafe;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicit isolated postgres_tenant_test template and CREATEDB"]
|
||||
fn physical_databases_are_explicit_isolated_idempotent_and_fail_closed() {
|
||||
let directory = Directory::new();
|
||||
runtime().run(async |scope| {
|
||||
let document = Document::load(&PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../config/setting.json")).unwrap();
|
||||
let settings: serde_json::Value = document.decode().unwrap();
|
||||
let admin: Options = serde_json::from_value(settings["database"]["postgres_test"].clone()).unwrap();
|
||||
let mut options: Options = serde_json::from_value(settings["database"]["postgres_tenant_test"].clone()).unwrap();
|
||||
let template = options.url.as_ref().unwrap().parse::<tokio_postgres::Config>().unwrap();
|
||||
assert!(template.get_dbname().unwrap().contains("{case}"));
|
||||
assert_eq!(options.url.as_ref().unwrap().matches("{case}").count(), 1);
|
||||
let token = crate::database::scalar::generated_uuid().unwrap().to_string().replace('-', "");
|
||||
options.url = Some(options.url.unwrap().replace("{case}", &token));
|
||||
let control_name = options.url.as_ref().unwrap().parse::<tokio_postgres::Config>().unwrap().get_dbname().unwrap().to_owned();
|
||||
assert!((1..=63).contains(&control_name.len()));
|
||||
assert!(control_name.bytes().all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_'));
|
||||
let prefix = format!("dv_{token}");
|
||||
options.tenant_database_prefix = Some(prefix.clone());
|
||||
options.max_connections = 2;
|
||||
options.max_page_size = 1;
|
||||
let tenant_options = options.clone();
|
||||
let admin = Database::open(&directory.0, admin, scope).await.unwrap();
|
||||
let mut admin_executor = admin.executor().await.unwrap();
|
||||
let Lease::Postgres(lease) = &mut admin_executor.lease else { unreachable!() };
|
||||
lease.call(async |client| client.batch_execute(&format!("CREATE DATABASE \"{control_name}\"")).await.map_err(Into::into)).await.unwrap();
|
||||
let mut database = std::collections::BTreeMap::new();
|
||||
database.insert("default".to_owned(), options);
|
||||
let config = Settings(database, Some(crate::database::TenantOptions { database: "default".to_owned(), max_pools: 2, idle_timeout_ms: 100 }));
|
||||
let mut registry = None;
|
||||
let result = AssertUnwindSafe(async {
|
||||
registry = Some(Registry::prepare(&directory.0, config, &bindings(), scope).await.unwrap());
|
||||
let registry = registry.as_ref().unwrap();
|
||||
let mut absent = Context::new(registry.clone());
|
||||
assert_eq!(absent.select_tenant(scope, 1).await.unwrap_err().kind, Kind::Migration);
|
||||
assert_eq!(admin_executor.query(scope, Sql::same("SELECT COUNT(*) FROM pg_database WHERE datname=$1"), vec![Parameter::Text(format!("{prefix}_1"))], 1, |row| row.get::<_, i64>(0)).await.unwrap(), [0]);
|
||||
for id in [1, 2, 1] { registry.migrate_tenant(scope, id).await.unwrap(); }
|
||||
assert_eq!(registry.queue_tenants(0, scope).await.unwrap(), [Some(1), Some(2)]);
|
||||
for id in [1, 2] {
|
||||
let mut context = Context::new(registry.clone());
|
||||
context.select_tenant(scope, id).await.unwrap();
|
||||
let owner = context.enter();
|
||||
context.on(0, scope).await.unwrap().execute(scope, Sql::same("INSERT INTO items(id,value) VALUES(1,$1)"), vec![Parameter::Text(format!("tenant {id}"))]).await.unwrap();
|
||||
context.on(2, scope).await.unwrap().execute(scope, "INSERT INTO roles(id) VALUES(1)", vec![]).await.unwrap();
|
||||
assert!(context.on(1, scope).await.is_err());
|
||||
context.finish(scope, owner, true).await.unwrap();
|
||||
assert_eq!(context.on(0, scope).await.unwrap().query(scope, "SELECT value FROM items", vec![], 1, |row| row.get::<_, String>(0)).await.unwrap(), [format!("tenant {id}")]);
|
||||
assert_eq!(context.control(scope).await.unwrap().query(scope, "SELECT COUNT(*) FROM roles", vec![], 1, |row| row.get::<_, i64>(0)).await.unwrap(), [0]);
|
||||
}
|
||||
// 冷租户的标记被真实表锁阻塞时,另一租户仍能进入;取消后可重新准备。
|
||||
tokio::time::sleep(std::time::Duration::from_millis(110)).await;
|
||||
Context::new(registry.clone()).select_tenant(scope, 1).await.unwrap();
|
||||
let held = Database::open_existing(&directory.0, tenant_options.clone(), Some(&format!("{prefix}_2")), scope).await.unwrap();
|
||||
let mut held_executor = held.executor().await.unwrap();
|
||||
held_executor.begin(scope).await.unwrap();
|
||||
held_executor.initialize(scope, "LOCK TABLE _dever_tenant_ready IN ACCESS EXCLUSIVE MODE").await.unwrap();
|
||||
let copy = registry.clone();
|
||||
let blocked = scope.spawn(move |child| async move { Context::new(copy).select_tenant(&child, 2).await }).unwrap();
|
||||
let started = tokio::time::Instant::now();
|
||||
loop {
|
||||
let waiting = admin_executor.query(scope, Sql::same("SELECT COUNT(*) FROM pg_stat_activity WHERE datname=$1 AND wait_event_type='Lock'"), vec![Parameter::Text(format!("{prefix}_2"))], 1, |row| row.get::<_, i64>(0)).await.unwrap();
|
||||
if waiting[0] > 0 { break; }
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(2));
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
}
|
||||
tokio::time::timeout(std::time::Duration::from_millis(500), Context::new(registry.clone()).select_tenant(scope, 1)).await.unwrap().unwrap();
|
||||
assert!(blocked.stop().await.unwrap().is_none());
|
||||
held_executor.finish(scope, true, false).await.unwrap();
|
||||
drop(held_executor);
|
||||
held.close();
|
||||
Context::new(registry.clone()).select_tenant(scope, 2).await.unwrap();
|
||||
registry.set_component(scope, 1, "shop", false).await.unwrap();
|
||||
let mut context = Context::new(registry.clone());
|
||||
context.select_tenant(scope, 1).await.unwrap();
|
||||
assert!(!context.components_enabled(scope, &["shop"]).await.unwrap());
|
||||
assert_eq!(registry.queue_tenants(0, scope).await.unwrap(), [Some(1), Some(2)]);
|
||||
context.on(0, scope).await.unwrap().execute(scope, "UPDATE _dever_tenant_ready SET ready=0", vec![]).await.unwrap();
|
||||
drop(context);
|
||||
assert_eq!(Context::new(registry.clone()).select_tenant(scope, 1).await.unwrap_err().kind, Kind::Migration);
|
||||
}).catch_unwind().await;
|
||||
if let Some(registry) = registry.take() {
|
||||
registry.close().await;
|
||||
drop(registry);
|
||||
}
|
||||
// Pool::close 释放驱动句柄后,让同一 runtime 的连接任务完成退出。
|
||||
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
|
||||
let Lease::Postgres(lease) = &mut admin_executor.lease else { unreachable!() };
|
||||
for name in [format!("{prefix}_1"), format!("{prefix}_2"), control_name] {
|
||||
lease.call(async |client| client.batch_execute(&format!("DROP DATABASE IF EXISTS \"{name}\"")).await.map_err(Into::into)).await.unwrap();
|
||||
}
|
||||
drop(admin_executor);
|
||||
admin.close();
|
||||
if let Err(panic) = result { std::panic::resume_unwind(panic); }
|
||||
}).unwrap();
|
||||
}
|
||||
Reference in new issue
Block a user