Squashed from work; the fine-grained history is under tag archive/work-2026-08-26: - store: the ceremony dies — batch publish, one transaction, and the engine stops re-reading its own log - sqlite: derived tables get their real names, their predicates, and all their arms
839 lines
32 KiB
Rust
839 lines
32 KiB
Rust
//! P1 acceptance: the SQL layer answers the asset store's real queries with
|
|
//! exactly the rows the system `sqlite3` CLI returns, and proves it uses index
|
|
//! seeks where it must.
|
|
|
|
mod common;
|
|
|
|
use common::*;
|
|
use makepad_sqlite::{Database, Value};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
fn catalog_copy() -> Option<PathBuf> {
|
|
let p = Path::new(
|
|
"/private/tmp/claude-501/-Users-admin-makepad-makepad/9ffb7a56-6354-42a8-8256-89ffed8580ec/scratchpad/store-copy/catalog.sqlite3",
|
|
);
|
|
if p.exists() && have_sqlite3() {
|
|
Some(p.to_path_buf())
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Strict value equality: same storage class *and* same value, so an INTEGER
|
|
/// never passes for a REAL.
|
|
fn same(a: &Value, b: &Value) -> bool {
|
|
if a.class() != b.class() {
|
|
return false;
|
|
}
|
|
match (a, b) {
|
|
(Value::Real(x), Value::Real(y)) => x.to_bits() == y.to_bits() || x == y,
|
|
_ => a == b,
|
|
}
|
|
}
|
|
|
|
/// Run the same SQL through both engines and compare row for row. Parameters
|
|
/// are substituted into the CLI text as literals.
|
|
fn compare(db_path: &Path, sql: &str, params: &[Value]) {
|
|
let mut db = Database::open(db_path).expect("open");
|
|
let ours = db.query(sql, params).expect(sql);
|
|
|
|
let mut cli_sql = String::new();
|
|
let mut rest = sql;
|
|
// Replace ?N / ? with literals, numbering bare `?` the way SQLite does:
|
|
// one more than the largest number used so far.
|
|
let mut max_seen = 0usize;
|
|
while let Some(pos) = rest.find('?') {
|
|
cli_sql.push_str(&rest[..pos]);
|
|
let after = &rest[pos + 1..];
|
|
let digits: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
|
|
let number = if digits.is_empty() {
|
|
max_seen + 1
|
|
} else {
|
|
digits.parse::<usize>().unwrap()
|
|
};
|
|
max_seen = max_seen.max(number);
|
|
cli_sql.push_str("e(¶ms[number - 1]));
|
|
rest = &after[digits.len()..];
|
|
}
|
|
cli_sql.push_str(rest);
|
|
|
|
let out = sqlite3(
|
|
db_path,
|
|
&format!(".mode quote\n.headers off\n.separator |\n{cli_sql};\n"),
|
|
);
|
|
let theirs: Vec<Vec<Value>> = out
|
|
.lines()
|
|
.filter(|l| !l.trim().is_empty())
|
|
.map(|l| l.split('|').map(parse_quoted).collect())
|
|
.collect();
|
|
|
|
assert_eq!(
|
|
ours.rows.len(),
|
|
theirs.len(),
|
|
"row count for {sql}\nours: {:?}\ncli: {:?}",
|
|
ours.to_quoted_lines().iter().take(5).collect::<Vec<_>>(),
|
|
out.lines().take(5).collect::<Vec<_>>()
|
|
);
|
|
for (i, (a, b)) in ours.rows.iter().zip(theirs.iter()).enumerate() {
|
|
assert_eq!(a.len(), b.len(), "column count row {i} of {sql}");
|
|
for (c, (x, y)) in a.iter().zip(b.iter()).enumerate() {
|
|
assert!(
|
|
same(x, y),
|
|
"row {i} column {c} of {sql}: ours {} vs cli {}",
|
|
quote(x),
|
|
quote(y)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The store's hot queries
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn catalog_hot_queries_match_cli() {
|
|
let Some(db_path) = catalog_copy() else { return };
|
|
let mut db = Database::open(&db_path).unwrap();
|
|
|
|
// A real alias and asset id to drive the parameterised queries.
|
|
let alias = db
|
|
.query("SELECT alias FROM asset_aliases ORDER BY alias LIMIT 1", &[])
|
|
.unwrap()
|
|
.scalar()
|
|
.cloned()
|
|
.expect("an alias");
|
|
let asset_id = db
|
|
.query(
|
|
"SELECT asset_id FROM asset_aliases WHERE alias = ?1",
|
|
&[alias.clone()],
|
|
)
|
|
.unwrap()
|
|
.scalar()
|
|
.cloned()
|
|
.expect("an asset id");
|
|
let term = db
|
|
.query(
|
|
"SELECT term FROM search_postings ORDER BY term LIMIT 1 OFFSET 5",
|
|
&[],
|
|
)
|
|
.unwrap()
|
|
.scalar()
|
|
.cloned()
|
|
.expect("a term");
|
|
|
|
// alias resolve (catalog.rs:538)
|
|
compare(
|
|
&db_path,
|
|
"SELECT asset_id FROM asset_aliases WHERE alias = ?1",
|
|
&[alias.clone()],
|
|
);
|
|
// alias + head revision (catalog.rs:599)
|
|
compare(
|
|
&db_path,
|
|
"SELECT asset_id, head_revision FROM asset_aliases WHERE alias = ?1",
|
|
&[alias.clone()],
|
|
);
|
|
// aliases of one asset, through the secondary index (search.rs:716)
|
|
compare(
|
|
&db_path,
|
|
"SELECT alias FROM asset_aliases WHERE asset_id = ?1 ORDER BY alias",
|
|
&[asset_id.clone()],
|
|
);
|
|
// annotation fetch (search.rs:1024)
|
|
compare(
|
|
&db_path,
|
|
"SELECT visibility, owner, title, description, creator, generator, backend, model, prompt, provenance, kind FROM search_annotations WHERE asset_id = ?1",
|
|
&[asset_id.clone()],
|
|
);
|
|
// labels (search.rs:1058)
|
|
compare(
|
|
&db_path,
|
|
"SELECT kind, label FROM search_labels WHERE asset_id = ?1 ORDER BY kind, label",
|
|
&[asset_id.clone()],
|
|
);
|
|
// term search join (search.rs:1401 shape)
|
|
compare(
|
|
&db_path,
|
|
"SELECT a.asset_id, a.namespace, a.title, a.live, a.kind, a.canon_alias FROM search_annotations a JOIN search_postings p ON p.asset_id = a.asset_id WHERE p.term = ?1 ORDER BY a.asset_id LIMIT 50",
|
|
&[term.clone()],
|
|
);
|
|
// browse page over the canon_alias keyset index (search.rs:1396 + paging)
|
|
compare(
|
|
&db_path,
|
|
"SELECT a.canon_alias, a.asset_id, a.title FROM search_annotations a WHERE a.canon_alias > ?1 ORDER BY a.canon_alias, a.asset_id LIMIT 25",
|
|
&[Value::text("")],
|
|
);
|
|
// label filter with EXISTS (search.rs:1456)
|
|
compare(
|
|
&db_path,
|
|
"SELECT COUNT(*) FROM search_annotations a WHERE EXISTS(SELECT 1 FROM search_labels l WHERE l.asset_id = a.asset_id AND l.kind = 'tag')",
|
|
&[],
|
|
);
|
|
// negated label filter (search.rs:1470)
|
|
compare(
|
|
&db_path,
|
|
"SELECT COUNT(*) FROM search_annotations a WHERE NOT EXISTS(SELECT 1 FROM search_labels l WHERE l.asset_id = a.asset_id AND l.kind = 'tag' AND l.label = ?1)",
|
|
&[Value::text("character")],
|
|
);
|
|
// live + canon_alias maintenance query (search.rs:754 read half)
|
|
compare(
|
|
&db_path,
|
|
"SELECT EXISTS(SELECT 1 FROM asset_aliases WHERE asset_id = ?1), COALESCE((SELECT MIN(alias) FROM asset_aliases WHERE asset_id = ?1), '')",
|
|
&[asset_id.clone()],
|
|
);
|
|
// imports paging (imports.rs:174)
|
|
compare(
|
|
&db_path,
|
|
"SELECT source_id FROM import_sources WHERE source_id > ?1 ORDER BY source_id LIMIT ?2",
|
|
&[Value::text(""), Value::Integer(10)],
|
|
);
|
|
// import entries (imports.rs:355)
|
|
compare(
|
|
&db_path,
|
|
"SELECT import_revision, entry_key, asset_id FROM import_entries ORDER BY import_revision, entry_key LIMIT 20",
|
|
&[],
|
|
);
|
|
// operation events window (operations.rs:1075)
|
|
compare(
|
|
&db_path,
|
|
"SELECT operation_id, seq, kind, created_ms FROM operation_events WHERE seq > ?1 ORDER BY operation_id, seq LIMIT ?2",
|
|
&[Value::Integer(0), Value::Integer(20)],
|
|
);
|
|
// jobs by state (jobs.rs:298)
|
|
compare(
|
|
&db_path,
|
|
"SELECT job_id, kind, attempts_used FROM jobs WHERE state='pending' AND not_before_ms <= ?1 ORDER BY job_id LIMIT 20",
|
|
&[Value::Integer(i64::MAX)],
|
|
);
|
|
// grants scope check (auth.rs:276)
|
|
compare(
|
|
&db_path,
|
|
"SELECT 1 FROM grants WHERE capability=?1 AND scope IN (?2, '*') LIMIT 5",
|
|
&[Value::text("publish"), Value::text("assets")],
|
|
);
|
|
// token join (auth.rs:207)
|
|
compare(
|
|
&db_path,
|
|
"SELECT t.principal_id, t.expires_ms, t.revoked, p.disabled FROM tokens t JOIN principals p ON p.principal_id = t.principal_id ORDER BY t.principal_id LIMIT 10",
|
|
&[],
|
|
);
|
|
// derivations by job (variants.rs:378)
|
|
compare(
|
|
&db_path,
|
|
"SELECT dkey, state FROM derivations ORDER BY dkey LIMIT 10",
|
|
&[],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn llm_library_questions_match_cli() {
|
|
let Some(db_path) = catalog_copy() else { return };
|
|
// The kind of question the game LLM will ask of the library.
|
|
compare(
|
|
&db_path,
|
|
"SELECT kind, COUNT(*) AS n FROM search_annotations GROUP BY kind ORDER BY n DESC, kind LIMIT 20",
|
|
&[],
|
|
);
|
|
compare(
|
|
&db_path,
|
|
"SELECT l.label, COUNT(*) AS n FROM search_labels l WHERE l.kind='tag' GROUP BY l.label HAVING COUNT(*) > 2 ORDER BY n DESC, l.label LIMIT 15",
|
|
&[],
|
|
);
|
|
compare(
|
|
&db_path,
|
|
"SELECT a.canon_alias, a.kind, LENGTH(a.prompt) FROM search_annotations a WHERE a.live = 1 AND a.kind IS NOT NULL AND LOWER(a.title) <> '' ORDER BY a.canon_alias LIMIT 20",
|
|
&[],
|
|
);
|
|
compare(
|
|
&db_path,
|
|
"SELECT namespace, COUNT(*) AS n, MIN(created_ms), MAX(created_ms) FROM assets GROUP BY namespace ORDER BY n DESC, namespace",
|
|
&[],
|
|
);
|
|
compare(
|
|
&db_path,
|
|
"SELECT a.canon_alias, COUNT(DISTINCT l.label) AS tags FROM search_annotations a JOIN search_labels l ON l.asset_id = a.asset_id GROUP BY a.canon_alias ORDER BY tags DESC, a.canon_alias LIMIT 10",
|
|
&[],
|
|
);
|
|
compare(
|
|
&db_path,
|
|
"SELECT COUNT(*) FROM blobs WHERE size > 100000",
|
|
&[],
|
|
);
|
|
compare(
|
|
&db_path,
|
|
"SELECT alias FROM asset_aliases WHERE alias LIKE 'a%' ORDER BY alias LIMIT 10",
|
|
&[],
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Plan shape
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn keyset_queries_use_index_seeks() {
|
|
let Some(db_path) = catalog_copy() else { return };
|
|
let mut db = Database::open(&db_path).unwrap();
|
|
|
|
let cases: Vec<(&str, &str)> = vec![
|
|
(
|
|
"SELECT asset_id FROM asset_aliases WHERE alias = ?1",
|
|
"SEARCH USING INDEX sqlite_autoindex_asset_aliases_1",
|
|
),
|
|
(
|
|
"SELECT alias FROM asset_aliases WHERE asset_id = ?1 ORDER BY alias",
|
|
"SEARCH USING INDEX asset_aliases_by_asset",
|
|
),
|
|
(
|
|
"SELECT canon_alias, asset_id FROM search_annotations WHERE canon_alias > ?1 ORDER BY canon_alias, asset_id LIMIT 25",
|
|
"SEARCH USING INDEX search_annotations_by_canon",
|
|
),
|
|
(
|
|
"SELECT title FROM search_annotations WHERE asset_id = ?1",
|
|
"SEARCH USING INDEX sqlite_autoindex_search_annotations_1",
|
|
),
|
|
(
|
|
"SELECT job_id FROM jobs WHERE state = 'pending' AND not_before_ms <= 10",
|
|
"SEARCH USING INDEX jobs_by_state",
|
|
),
|
|
(
|
|
"SELECT source_id FROM import_sources WHERE source_id > 'x' ORDER BY source_id LIMIT 5",
|
|
"SEARCH USING INDEX sqlite_autoindex_import_sources_1",
|
|
),
|
|
(
|
|
"SELECT seq FROM operation_events WHERE operation_id = x'00' AND seq > 3",
|
|
"SEARCH USING INDEX sqlite_autoindex_operation_events_1",
|
|
),
|
|
];
|
|
for (sql, want) in cases {
|
|
let stmt = db.prepare(sql).expect(sql);
|
|
let plan = stmt.explain();
|
|
assert!(
|
|
plan.contains(want),
|
|
"plan for {sql} was:\n{plan}\nexpected {want}"
|
|
);
|
|
}
|
|
|
|
// Join order: the constrained side drives the loop, and the other side is
|
|
// reached by an index seek rather than a scan.
|
|
let stmt = db
|
|
.prepare("SELECT a.asset_id, a.title FROM search_annotations a JOIN search_postings p ON p.asset_id = a.asset_id WHERE p.term = ?1 ORDER BY a.asset_id LIMIT 50")
|
|
.unwrap();
|
|
let plan = stmt.explain();
|
|
let mut lines = plan.lines();
|
|
assert!(
|
|
lines.next().unwrap_or("").contains("INDEX sqlite_autoindex_search_postings_1"),
|
|
"postings must drive the join:\n{plan}"
|
|
);
|
|
assert!(
|
|
lines.next().unwrap_or("").contains("INDEX sqlite_autoindex_search_annotations_1"),
|
|
"annotations must be reached by a seek:\n{plan}"
|
|
);
|
|
|
|
// The keyset page must not need a sort: the index already returns order.
|
|
let stmt = db
|
|
.prepare("SELECT canon_alias, asset_id FROM search_annotations WHERE canon_alias > ?1 ORDER BY canon_alias, asset_id LIMIT 25")
|
|
.unwrap();
|
|
assert!(
|
|
stmt.explain().contains("ORDER BY (from index)"),
|
|
"keyset page still sorts:\n{}",
|
|
stmt.explain()
|
|
);
|
|
|
|
// rowid lookups go straight to the row.
|
|
let stmt = db
|
|
.prepare("SELECT generation FROM search_state WHERE id = 1")
|
|
.unwrap();
|
|
assert!(stmt.explain().contains("SEARCH rowid=?"), "{}", stmt.explain());
|
|
|
|
// A predicate with no usable index scans, and says so.
|
|
let stmt = db
|
|
.prepare("SELECT title FROM search_annotations WHERE title LIKE 'x%'")
|
|
.unwrap();
|
|
assert!(stmt.explain().starts_with("SCAN"), "{}", stmt.explain());
|
|
}
|
|
|
|
#[test]
|
|
fn index_seek_actually_avoids_the_scan() {
|
|
let Some(db_path) = catalog_copy() else { return };
|
|
let mut db = Database::open(&db_path).unwrap();
|
|
let alias = db
|
|
.query("SELECT alias FROM asset_aliases ORDER BY alias LIMIT 1", &[])
|
|
.unwrap()
|
|
.scalar()
|
|
.cloned()
|
|
.unwrap();
|
|
// A seek visits a handful of rows; a scan would visit thousands.
|
|
let stmt = db
|
|
.prepare("SELECT asset_id FROM asset_aliases WHERE alias = ?1")
|
|
.unwrap();
|
|
let mut visited = 0;
|
|
stmt.for_each(&mut db, &[alias], |_row| {
|
|
visited += 1;
|
|
Ok(true)
|
|
})
|
|
.unwrap();
|
|
assert_eq!(visited, 1);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Semantics on a controlled fixture
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const FIXTURE: &str = r#"
|
|
CREATE TABLE t(
|
|
id INTEGER PRIMARY KEY,
|
|
name TEXT,
|
|
kind TEXT,
|
|
n INTEGER,
|
|
r REAL,
|
|
b BLOB
|
|
);
|
|
CREATE INDEX t_by_kind ON t(kind, n);
|
|
CREATE TABLE u(id INTEGER PRIMARY KEY, t_id INTEGER, tag TEXT);
|
|
CREATE INDEX u_by_t ON u(t_id);
|
|
INSERT INTO t VALUES
|
|
(1,'alpha','a',10,1.5,x'01'),
|
|
(2,'beta','b',20,2.5,x'02'),
|
|
(3,'gamma','a',30,NULL,NULL),
|
|
(4,'delta',NULL,40,4.5,x'04'),
|
|
(5,'Epsilon','b',NULL,5.5,x'05'),
|
|
(6,'zeta','c',60,6.0,x'0607');
|
|
INSERT INTO u VALUES (1,1,'red'),(2,1,'blue'),(3,2,'red'),(4,6,'green'),(5,99,'orphan');
|
|
"#;
|
|
|
|
fn fixture(scratch: &Scratch) -> PathBuf {
|
|
build_db(&scratch.dir, "fixture.db", FIXTURE)
|
|
}
|
|
|
|
#[test]
|
|
fn semantics_match_cli_on_fixture() {
|
|
if !have_sqlite3() {
|
|
return;
|
|
}
|
|
let scratch = Scratch::new("query");
|
|
let path = fixture(&scratch);
|
|
let queries: Vec<(&str, Vec<Value>)> = vec![
|
|
("SELECT * FROM t ORDER BY id", vec![]),
|
|
("SELECT id, name FROM t WHERE kind = 'a' ORDER BY id", vec![]),
|
|
("SELECT id FROM t WHERE kind IS NULL ORDER BY id", vec![]),
|
|
("SELECT id FROM t WHERE kind IS NOT NULL ORDER BY id", vec![]),
|
|
("SELECT id FROM t WHERE n > 20 ORDER BY id", vec![]),
|
|
("SELECT id FROM t WHERE n >= 20 AND n <= 40 ORDER BY id", vec![]),
|
|
("SELECT id FROM t WHERE n BETWEEN 20 AND 40 ORDER BY id", vec![]),
|
|
("SELECT id FROM t WHERE kind IN ('a','c') ORDER BY id", vec![]),
|
|
("SELECT id FROM t WHERE kind NOT IN ('a') ORDER BY id", vec![]),
|
|
("SELECT id FROM t WHERE name <> 'alpha' ORDER BY id", vec![]),
|
|
("SELECT id FROM t WHERE id = 3 ORDER BY id", vec![]),
|
|
("SELECT id FROM t WHERE id > 3 ORDER BY id", vec![]),
|
|
("SELECT COUNT(*), COUNT(n), COUNT(DISTINCT kind) FROM t", vec![]),
|
|
("SELECT MIN(n), MAX(n), SUM(n), AVG(n), TOTAL(r) FROM t", vec![]),
|
|
("SELECT kind, COUNT(*) FROM t GROUP BY kind ORDER BY kind", vec![]),
|
|
(
|
|
"SELECT kind, COUNT(*) c FROM t GROUP BY kind HAVING COUNT(*) > 1 ORDER BY kind",
|
|
vec![],
|
|
),
|
|
("SELECT DISTINCT kind FROM t ORDER BY kind", vec![]),
|
|
("SELECT id, name FROM t ORDER BY name DESC", vec![]),
|
|
("SELECT id FROM t ORDER BY n ASC", vec![]),
|
|
("SELECT id FROM t ORDER BY n DESC", vec![]),
|
|
("SELECT id FROM t ORDER BY kind, n DESC", vec![]),
|
|
("SELECT id FROM t ORDER BY id LIMIT 2 OFFSET 3", vec![]),
|
|
("SELECT COALESCE(kind,'none'), LENGTH(name), LOWER(name) FROM t ORDER BY id", vec![]),
|
|
("SELECT id, r*2, n+1, n/3, n%7 FROM t ORDER BY id", vec![]),
|
|
("SELECT name || '-' || kind FROM t ORDER BY id", vec![]),
|
|
(
|
|
"SELECT t.id, u.tag FROM t JOIN u ON u.t_id = t.id ORDER BY t.id, u.tag",
|
|
vec![],
|
|
),
|
|
(
|
|
"SELECT t.id, u.tag FROM t LEFT JOIN u ON u.t_id = t.id ORDER BY t.id, u.tag",
|
|
vec![],
|
|
),
|
|
(
|
|
"SELECT t.id, u.id FROM t, u WHERE u.t_id = t.id ORDER BY t.id, u.id",
|
|
vec![],
|
|
),
|
|
(
|
|
"SELECT id FROM t WHERE EXISTS(SELECT 1 FROM u WHERE u.t_id = t.id) ORDER BY id",
|
|
vec![],
|
|
),
|
|
(
|
|
"SELECT id FROM t WHERE NOT EXISTS(SELECT 1 FROM u WHERE u.t_id = t.id) ORDER BY id",
|
|
vec![],
|
|
),
|
|
(
|
|
"SELECT id FROM t WHERE id IN (SELECT t_id FROM u) ORDER BY id",
|
|
vec![],
|
|
),
|
|
(
|
|
"SELECT (SELECT COUNT(*) FROM u WHERE u.t_id = t.id) AS n, t.id FROM t ORDER BY t.id",
|
|
vec![],
|
|
),
|
|
("SELECT COUNT(*) FROM (SELECT id FROM t WHERE n > 15)", vec![]),
|
|
("SELECT id FROM t WHERE name LIKE 'a%' ORDER BY id", vec![]),
|
|
("SELECT id FROM t WHERE name LIKE '%a' ORDER BY id", vec![]),
|
|
("SELECT id FROM t WHERE name LIKE 'e_silon' ORDER BY id", vec![]),
|
|
("SELECT hex(b), typeof(b), typeof(r), typeof(kind) FROM t ORDER BY id", vec![]),
|
|
("SELECT CASE WHEN n > 25 THEN 'big' WHEN n IS NULL THEN 'none' ELSE 'small' END, id FROM t ORDER BY id", vec![]),
|
|
("SELECT CAST(r AS INTEGER), CAST(n AS TEXT), CAST(name AS BLOB) FROM t ORDER BY id", vec![]),
|
|
("SELECT id FROM t WHERE ?1 = kind ORDER BY id", vec![Value::text("b")]),
|
|
("SELECT id, n FROM t WHERE kind = ?1 AND n > ?2 ORDER BY id", vec![Value::text("a"), Value::Integer(5)]),
|
|
("SELECT 1 WHERE 1", vec![]),
|
|
("SELECT 1 WHERE 0", vec![]),
|
|
("SELECT NULL, 1, 1.5, 'x', x'ff'", vec![]),
|
|
("SELECT id FROM t UNION SELECT id FROM u ORDER BY id", vec![]),
|
|
("SELECT id FROM t UNION ALL SELECT id FROM u ORDER BY id", vec![]),
|
|
("SELECT id FROM t INTERSECT SELECT t_id FROM u ORDER BY id", vec![]),
|
|
("SELECT id FROM t EXCEPT SELECT t_id FROM u ORDER BY id", vec![]),
|
|
("SELECT b FROM t WHERE b > x'02' ORDER BY id", vec![]),
|
|
("SELECT id FROM t WHERE r > 2 ORDER BY id", vec![]),
|
|
("SELECT id FROM t WHERE n = '20' ORDER BY id", vec![]),
|
|
("SELECT '20' = 20, '20' = '20', 20 = 20.0, NULL = NULL, NULL IS NULL", vec![]),
|
|
];
|
|
for (sql, params) in queries {
|
|
compare(&path, sql, ¶ms);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn budgets_stop_runaway_queries() {
|
|
if !have_sqlite3() {
|
|
return;
|
|
}
|
|
let scratch = Scratch::new("budget");
|
|
let path = fixture(&scratch);
|
|
let mut db = Database::open(&path).unwrap();
|
|
db.limits_mut().max_rows = 3;
|
|
let err = db.query("SELECT id FROM t ORDER BY id", &[]);
|
|
assert!(err.is_err(), "row budget did not trip");
|
|
|
|
let mut db = Database::open(&path).unwrap();
|
|
db.limits_mut().max_steps = 2;
|
|
let err = db.query("SELECT COUNT(*) FROM t", &[]);
|
|
assert!(err.is_err(), "step budget did not trip");
|
|
}
|
|
|
|
#[test]
|
|
fn errors_are_clear() {
|
|
if !have_sqlite3() {
|
|
return;
|
|
}
|
|
let scratch = Scratch::new("errors");
|
|
let path = fixture(&scratch);
|
|
let mut db = Database::open(&path).unwrap();
|
|
for bad in [
|
|
"SELECT * FROM nope",
|
|
"SELECT nope FROM t",
|
|
"SELECT * FROM t WHERE",
|
|
"INSERT INTO t VALUES(1)",
|
|
"DELETE FROM t",
|
|
"UPDATE t SET n=1",
|
|
"DROP TABLE t",
|
|
"PRAGMA user_version",
|
|
"SELECT nosuchfunc(1) FROM t",
|
|
"",
|
|
] {
|
|
assert!(db.prepare(bad).is_err(), "{bad} should not prepare");
|
|
}
|
|
// A statement that prepares but has an unbound parameter.
|
|
let stmt = db.prepare("SELECT id FROM t WHERE id = ?1").unwrap();
|
|
assert!(stmt.query(&mut db, &[]).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn writes_are_impossible() {
|
|
if !have_sqlite3() {
|
|
return;
|
|
}
|
|
let scratch = Scratch::new("readonly");
|
|
let path = fixture(&scratch);
|
|
let before = std::fs::read(&path).unwrap();
|
|
{
|
|
let mut db = Database::open(&path).unwrap();
|
|
let _ = db.query("SELECT * FROM t", &[]).unwrap();
|
|
for bad in ["INSERT INTO t(id) VALUES(99)", "UPDATE t SET n = 1", "DELETE FROM t"] {
|
|
assert!(db.prepare(bad).is_err());
|
|
}
|
|
}
|
|
assert_eq!(before, std::fs::read(&path).unwrap(), "file changed");
|
|
}
|
|
|
|
#[test]
|
|
fn anonymous_parameters_bind_in_text_order() {
|
|
if !have_sqlite3() {
|
|
return;
|
|
}
|
|
// A bare `?` takes the next number in the order it appears in the text.
|
|
// Binding by planning order instead silently shifts every value, which is
|
|
// exactly how a search returns nothing while every table is intact.
|
|
let scratch = Scratch::new("anon-params");
|
|
let path = fixture(&scratch);
|
|
let mut db = Database::open(&path).unwrap();
|
|
let out = db
|
|
.query(
|
|
"SELECT ?, id FROM t WHERE kind = ? AND n > ? ORDER BY id",
|
|
&[Value::text("tag"), Value::text("a"), Value::Integer(5)],
|
|
)
|
|
.unwrap();
|
|
assert_eq!(out.rows.len(), 2, "{:?}", out.to_quoted_lines());
|
|
assert_eq!(out.rows[0][0].as_text(), Some("tag"));
|
|
assert_eq!(out.rows[0][1].as_integer(), Some(1));
|
|
compare(
|
|
&path,
|
|
"SELECT ?, id FROM t WHERE kind = ? AND n > ? ORDER BY id",
|
|
&[Value::text("tag"), Value::text("a"), Value::Integer(5)],
|
|
);
|
|
compare(
|
|
&path,
|
|
"SELECT id FROM t WHERE id IN (?, ?, ?) AND kind IS NOT NULL ORDER BY id",
|
|
&[Value::Integer(1), Value::Integer(2), Value::Integer(3)],
|
|
);
|
|
// Explicit numbers raise the counter for the bare ones that follow.
|
|
compare(
|
|
&path,
|
|
"SELECT ?2, ? FROM t WHERE id = ?1 ORDER BY 1",
|
|
&[Value::Integer(1), Value::text("second"), Value::text("third")],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn derived_tables_are_addressed_by_their_alias_in_every_from_position() {
|
|
if !have_sqlite3() {
|
|
return;
|
|
}
|
|
// A derived table exports its columns under the column's own name, with any
|
|
// table qualifier dropped, exactly as SQLite names an unaliased result
|
|
// column. Keeping the qualifier would export `t.name` and leave `c.name`
|
|
// unresolvable, which is how a faceted search — a derived table on the
|
|
// right of a JOIN — failed to prepare at all.
|
|
let scratch = Scratch::new("derived-position");
|
|
let path = fixture(&scratch);
|
|
let queries: Vec<(&str, Vec<Value>)> = vec![
|
|
// The failing shape: derived table on the right of the join, selecting
|
|
// qualified columns.
|
|
(
|
|
"SELECT u.tag, c.name FROM u JOIN (SELECT t.id, t.name FROM t) c ON c.id = u.t_id ORDER BY u.tag, c.name",
|
|
vec![],
|
|
),
|
|
// The same derived table in the first FROM slot.
|
|
(
|
|
"SELECT u.tag, c.name FROM (SELECT t.id, t.name FROM t) c JOIN u ON c.id = u.t_id ORDER BY u.tag, c.name",
|
|
vec![],
|
|
),
|
|
// Unqualified inner columns, both orders.
|
|
(
|
|
"SELECT u.tag, c.name FROM u JOIN (SELECT id, name FROM t) c ON c.id = u.t_id ORDER BY u.tag, c.name",
|
|
vec![],
|
|
),
|
|
(
|
|
"SELECT u.tag, c.name FROM (SELECT id, name FROM t) c JOIN u ON c.id = u.t_id ORDER BY u.tag, c.name",
|
|
vec![],
|
|
),
|
|
// An explicit alias on the inner column wins over the column name.
|
|
(
|
|
"SELECT c.who FROM u JOIN (SELECT t.name AS who, t.id FROM t) c ON c.id = u.t_id ORDER BY c.who",
|
|
vec![],
|
|
),
|
|
// `SELECT *` inside, expanded to the underlying column names.
|
|
(
|
|
"SELECT c.name FROM u JOIN (SELECT * FROM t) c ON c.id = u.t_id ORDER BY c.name",
|
|
vec![],
|
|
),
|
|
// LEFT JOIN onto a derived table.
|
|
(
|
|
"SELECT t.id, c.tag FROM t LEFT JOIN (SELECT u.t_id, u.tag FROM u WHERE u.tag <> 'red') c ON c.t_id = t.id ORDER BY t.id, c.tag",
|
|
vec![],
|
|
),
|
|
// A derived table on both sides.
|
|
(
|
|
"SELECT x.id, y.tag FROM (SELECT t.id FROM t) x JOIN (SELECT u.t_id, u.tag FROM u) y ON y.t_id = x.id ORDER BY x.id, y.tag",
|
|
vec![],
|
|
),
|
|
// Nested: a derived table whose own FROM is a derived table.
|
|
(
|
|
"SELECT c.id FROM u JOIN (SELECT i.id FROM (SELECT t.id, t.kind FROM t) i WHERE i.kind = 'a') c ON c.id = u.t_id ORDER BY c.id",
|
|
vec![],
|
|
),
|
|
// Three items with the derived table in the middle.
|
|
(
|
|
"SELECT t.id, c.tag, u.tag FROM t JOIN (SELECT u.t_id, u.tag FROM u) c ON c.t_id = t.id JOIN u ON u.t_id = t.id ORDER BY t.id, c.tag, u.tag",
|
|
vec![],
|
|
),
|
|
// A compound derived table joined on the right: the search candidate
|
|
// shape, whose arms are named by the first arm's columns.
|
|
(
|
|
"SELECT u.tag, c.id FROM u JOIN (SELECT t.id FROM t UNION ALL SELECT u.t_id FROM u) c ON c.id = u.t_id ORDER BY u.tag, c.id",
|
|
vec![],
|
|
),
|
|
// No alias at all: the columns stay reachable unqualified.
|
|
(
|
|
"SELECT name FROM u JOIN (SELECT t.id AS tid, t.name FROM t) ON tid = u.t_id ORDER BY name",
|
|
vec![],
|
|
),
|
|
// A parameter inside the derived table, one outside.
|
|
(
|
|
"SELECT c.id FROM u JOIN (SELECT t.id, t.kind FROM t WHERE t.kind = ?) c ON c.id = u.t_id WHERE u.tag = ? ORDER BY c.id",
|
|
vec![Value::text("a"), Value::text("red")],
|
|
),
|
|
];
|
|
for (sql, params) in queries {
|
|
compare(&path, sql, ¶ms);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn predicates_pushed_into_derived_tables_do_not_change_the_answer() {
|
|
if !have_sqlite3() {
|
|
return;
|
|
}
|
|
// A term that constrains only a derived table is applied inside it too, so
|
|
// the scan discards rows instead of materializing them. Each of these is a
|
|
// case where doing that naively would return something other than what
|
|
// SQLite returns.
|
|
let scratch = Scratch::new("derived-pushdown");
|
|
let path = fixture(&scratch);
|
|
let queries: Vec<(&str, Vec<Value>)> = vec![
|
|
// The plain pushable cases.
|
|
(
|
|
"SELECT c.id FROM (SELECT t.id, t.kind FROM t) c WHERE c.kind = 'a' ORDER BY c.id",
|
|
vec![],
|
|
),
|
|
(
|
|
"SELECT c.id FROM (SELECT t.id, t.kind FROM t) c WHERE c.kind IN (?, ?) ORDER BY c.id",
|
|
vec![Value::text("a"), Value::text("c")],
|
|
),
|
|
(
|
|
"SELECT c.id FROM (SELECT t.id, t.kind FROM t) c WHERE c.kind IS NULL ORDER BY c.id",
|
|
vec![],
|
|
),
|
|
(
|
|
"SELECT c.id FROM (SELECT t.id, t.kind FROM t) c WHERE c.kind IS NOT NULL ORDER BY c.id",
|
|
vec![],
|
|
),
|
|
// Through a UNION ALL, where every arm has to be narrowed by itself.
|
|
(
|
|
"SELECT c.id FROM (SELECT t.id, t.kind FROM t UNION ALL SELECT u.id, u.tag FROM u) c WHERE c.kind = 'red' ORDER BY c.id",
|
|
vec![],
|
|
),
|
|
// Not pushable, and must still answer correctly: an inequality means
|
|
// something different once a column's affinity is reintroduced, and a
|
|
// negation flips which rows a widened match keeps.
|
|
(
|
|
"SELECT c.id FROM (SELECT t.id, t.n FROM t) c WHERE c.n > 20 ORDER BY c.id",
|
|
vec![],
|
|
),
|
|
(
|
|
"SELECT c.id FROM (SELECT t.id, t.kind FROM t) c WHERE c.kind NOT IN ('a') ORDER BY c.id",
|
|
vec![],
|
|
),
|
|
(
|
|
"SELECT c.id FROM (SELECT t.id, t.name FROM t) c WHERE c.name LIKE 'a%' ORDER BY c.id",
|
|
vec![],
|
|
),
|
|
// A LIMIT inside fixes which rows exist; filtering first would keep a
|
|
// different set.
|
|
(
|
|
"SELECT c.id FROM (SELECT t.id, t.kind FROM t ORDER BY t.id LIMIT 3) c WHERE c.kind = 'a' ORDER BY c.id",
|
|
vec![],
|
|
),
|
|
(
|
|
"SELECT c.id FROM (SELECT t.id, t.kind FROM t ORDER BY t.id LIMIT 3 OFFSET 1) c WHERE c.kind = 'a' ORDER BY c.id",
|
|
vec![],
|
|
),
|
|
// An aggregate inside: filtering ahead of it would change the count,
|
|
// whether the term names a grouping key or the aggregate itself.
|
|
(
|
|
"SELECT c.kind, c.n FROM (SELECT t.kind, COUNT(*) AS n FROM t GROUP BY t.kind) c WHERE c.kind = 'a' ORDER BY c.kind",
|
|
vec![],
|
|
),
|
|
(
|
|
"SELECT c.kind, c.n FROM (SELECT t.kind, COUNT(*) AS n FROM t GROUP BY t.kind) c WHERE c.n = 2 ORDER BY c.kind",
|
|
vec![],
|
|
),
|
|
// Narrowing the right arm of an EXCEPT would *add* rows; of an
|
|
// INTERSECT, remove them.
|
|
(
|
|
"SELECT c.id FROM (SELECT id FROM t EXCEPT SELECT t_id FROM u) c WHERE c.id = 2 ORDER BY c.id",
|
|
vec![],
|
|
),
|
|
(
|
|
"SELECT c.id FROM (SELECT id FROM t INTERSECT SELECT t_id FROM u) c WHERE c.id = 2 ORDER BY c.id",
|
|
vec![],
|
|
),
|
|
// The optional side of a LEFT JOIN inside: a pushed term must remove the
|
|
// row, not turn it into a NULL-extended one.
|
|
(
|
|
"SELECT c.id, c.tag FROM (SELECT t.id, u.tag FROM t LEFT JOIN u ON u.t_id = t.id) c WHERE c.tag IS NULL ORDER BY c.id",
|
|
vec![],
|
|
),
|
|
(
|
|
"SELECT c.id, c.tag FROM (SELECT t.id, u.tag FROM t LEFT JOIN u ON u.t_id = t.id) c WHERE c.tag = 'red' ORDER BY c.id",
|
|
vec![],
|
|
),
|
|
// The derived table itself LEFT JOINed, with the term in WHERE.
|
|
(
|
|
"SELECT t.id, c.tag FROM t LEFT JOIN (SELECT u.t_id, u.tag FROM u) c ON c.t_id = t.id WHERE c.tag = 'red' ORDER BY t.id",
|
|
vec![],
|
|
),
|
|
(
|
|
"SELECT t.id, c.tag FROM t LEFT JOIN (SELECT u.t_id, u.tag FROM u) c ON c.t_id = t.id WHERE c.tag IS NULL ORDER BY t.id",
|
|
vec![],
|
|
),
|
|
// In join position, with the term on the derived side.
|
|
(
|
|
"SELECT u.tag, c.id FROM u JOIN (SELECT t.id, t.kind FROM t) c ON c.id = u.t_id WHERE c.kind = 'a' ORDER BY u.tag, c.id",
|
|
vec![],
|
|
),
|
|
// A DISTINCT inside commutes with the filter either way round.
|
|
(
|
|
"SELECT c.kind FROM (SELECT DISTINCT t.kind FROM t) c WHERE c.kind = 'a' ORDER BY c.kind",
|
|
vec![],
|
|
),
|
|
];
|
|
for (sql, params) in queries {
|
|
compare(&path, sql, ¶ms);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn compound_selects_evaluate_every_arm() {
|
|
if !have_sqlite3() {
|
|
return;
|
|
}
|
|
// A compound chains one arm per link and combines from the left. Walking
|
|
// only the first link drops the third and later arms, silently returning
|
|
// short results rather than an error.
|
|
let scratch = Scratch::new("compound-chain");
|
|
let path = fixture(&scratch);
|
|
let queries: Vec<(&str, Vec<Value>)> = vec![
|
|
(
|
|
"SELECT id FROM t UNION ALL SELECT id FROM u UNION ALL SELECT t_id FROM u ORDER BY id",
|
|
vec![],
|
|
),
|
|
(
|
|
"SELECT COUNT(*) FROM (SELECT id FROM t UNION ALL SELECT id FROM u UNION ALL SELECT t_id FROM u) x",
|
|
vec![],
|
|
),
|
|
(
|
|
"SELECT id FROM t UNION SELECT id FROM u UNION SELECT t_id FROM u ORDER BY id",
|
|
vec![],
|
|
),
|
|
(
|
|
"SELECT id FROM t EXCEPT SELECT t_id FROM u EXCEPT SELECT id FROM u ORDER BY id",
|
|
vec![],
|
|
),
|
|
(
|
|
"SELECT id FROM t UNION ALL SELECT id FROM u EXCEPT SELECT t_id FROM u ORDER BY id",
|
|
vec![],
|
|
),
|
|
(
|
|
"SELECT id FROM t UNION ALL SELECT id FROM u UNION ALL SELECT t_id FROM u UNION ALL SELECT id FROM t ORDER BY id",
|
|
vec![],
|
|
),
|
|
];
|
|
for (sql, params) in queries {
|
|
compare(&path, sql, ¶ms);
|
|
}
|
|
}
|