177 lines
7.5 KiB
Rust
177 lines
7.5 KiB
Rust
// SPDX-License-Identifier: MIT
|
|
// Copyright (c) 2026 James Maes
|
|
//! Integration tests for the crate's SQL front end.
|
|
//!
|
|
//! These tests exercise [`notion_sql::sql::parse_statement`], the public entry
|
|
//! point that turns a raw SQL string into the crate's own [`SqlStatement`]
|
|
//! model (the in-memory representation that later layers translate into Notion
|
|
//! API calls). The goal here is to pin down the *parsing contract* — which SQL
|
|
//! shapes are accepted and what structured output they produce — independently
|
|
//! of any Notion connectivity, so these run fast and offline.
|
|
//!
|
|
//! Approach: each test feeds one representative statement to the parser and
|
|
//! then `match`es on the resulting [`SqlStatement`] variant. We assert on the
|
|
//! individual fields we care about (database name, projected columns, sort
|
|
//! order, limit, inserted rows) rather than constructing a whole expected
|
|
//! `SqlStatement` value; this keeps a test focused on the behavior it owns and
|
|
//! avoids brittle coupling to fields it does not care about (hence the `..`
|
|
//! rest patterns). The non-matching arm `panic!`s with the unexpected variant
|
|
//! so a regression that changes the parsed shape fails loudly and legibly.
|
|
//!
|
|
//! Coverage spans both the happy paths (`SELECT`, `INSERT`, `COUNT(*)`) and one
|
|
//! deliberately rejected case (`COUNT(<column>)`), so the suite documents both
|
|
//! what the parser supports and where it draws its limits.
|
|
//!
|
|
//! As an integration test (in `tests/`), this file may only touch the crate's
|
|
//! public API surface, which is why everything is reached through the
|
|
//! `notion_sql::sql` module path.
|
|
|
|
use notion_sql::sql::{parse_statement, SelectColumns, SqlStatement};
|
|
|
|
/// Verifies that a full-featured `SELECT` is parsed into a `SqlStatement::Select`
|
|
/// with every clause decoded correctly.
|
|
///
|
|
/// The input combines a projection list, a `WHERE` filter, an `ORDER BY ...
|
|
/// DESC`, and a `LIMIT`, so this is the broad "does the SELECT grammar hang
|
|
/// together" check. We assert that:
|
|
/// - the source `database` resolves to the `FROM` target (`Tasks`),
|
|
/// - the projection becomes `SelectColumns::Columns` preserving column order,
|
|
/// - the `DESC` sort is captured as a `SortSpec` with `ascending: false`,
|
|
/// - the `LIMIT 5` surfaces as `Some(5)`.
|
|
///
|
|
/// The `WHERE` clause is intentionally *not* asserted on here (it is covered
|
|
/// only as part of accepting the statement), hence the `..` in the pattern.
|
|
///
|
|
/// # Panics
|
|
///
|
|
/// Panics via `unwrap()` if the statement fails to parse, and via the catch-all
|
|
/// `panic!` arm if the parser returns any variant other than `Select`.
|
|
#[test]
|
|
fn parses_select_statement() {
|
|
let parsed = parse_statement(
|
|
"SELECT Name, Status FROM Tasks WHERE Status = 'Done' ORDER BY Name DESC LIMIT 5",
|
|
)
|
|
.unwrap();
|
|
|
|
match parsed {
|
|
// Destructure only the clauses under test; `..` ignores the parsed
|
|
// `WHERE` filter and any other fields so this test stays insulated from
|
|
// unrelated additions to the `Select` variant.
|
|
SqlStatement::Select {
|
|
database,
|
|
columns,
|
|
sorts,
|
|
limit,
|
|
..
|
|
} => {
|
|
assert_eq!(database, "Tasks");
|
|
assert_eq!(
|
|
columns,
|
|
SelectColumns::Columns(vec!["Name".to_string(), "Status".to_string()])
|
|
);
|
|
// `ORDER BY Name DESC` must yield exactly one sort key whose
|
|
// `ascending` flag is `false`; this guards the DESC -> false mapping.
|
|
assert_eq!(
|
|
sorts,
|
|
vec![notion_sql::sql::SortSpec {
|
|
column: "Name".to_string(),
|
|
ascending: false
|
|
}]
|
|
);
|
|
assert_eq!(limit, Some(5));
|
|
}
|
|
// Any non-`Select` result is a parser regression; surface the actual
|
|
// variant in the failure message for quick diagnosis.
|
|
other => panic!("unexpected statement: {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// Verifies that an `INSERT ... VALUES` is parsed into a `SqlStatement::Insert`
|
|
/// with the target table, column list, and row count decoded correctly.
|
|
///
|
|
/// The input inserts a single row of two columns. We assert that:
|
|
/// - the target `database` is `Tasks`,
|
|
/// - the explicit `columns` list is preserved in order,
|
|
/// - exactly one row was parsed (`rows.len() == 1`).
|
|
///
|
|
/// The individual cell *values* are not asserted here — only the row arity — to
|
|
/// keep the test focused on statement shape rather than value coercion.
|
|
///
|
|
/// # Panics
|
|
///
|
|
/// Panics via `unwrap()` if parsing fails, and via the catch-all `panic!` arm
|
|
/// if the parser returns any variant other than `Insert`.
|
|
#[test]
|
|
fn parses_insert_values() {
|
|
let parsed =
|
|
parse_statement("INSERT INTO Tasks (Name, Status) VALUES ('New task', 'To Do')").unwrap();
|
|
|
|
match parsed {
|
|
SqlStatement::Insert {
|
|
database,
|
|
columns,
|
|
rows,
|
|
} => {
|
|
assert_eq!(database, "Tasks");
|
|
assert_eq!(columns, vec!["Name".to_string(), "Status".to_string()]);
|
|
// One `VALUES (...)` tuple was supplied, so exactly one row is
|
|
// expected; this also asserts the parser does not split or merge
|
|
// rows.
|
|
assert_eq!(rows.len(), 1);
|
|
}
|
|
other => panic!("unexpected statement: {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// Verifies that `SELECT COUNT(*)` is recognized as the dedicated count
|
|
/// projection rather than treated as a regular column list.
|
|
///
|
|
/// `COUNT(*)` is special-cased into `SelectColumns::Count` because Notion has no
|
|
/// generic aggregate facility — the crate satisfies it by counting returned
|
|
/// pages — so the parser must distinguish it from an ordinary projection. We
|
|
/// assert the `database` is `Tasks` and that `columns` is exactly
|
|
/// `SelectColumns::Count`. The `WHERE` clause is accepted but not asserted on.
|
|
///
|
|
/// # Panics
|
|
///
|
|
/// Panics via `unwrap()` if parsing fails, and via the catch-all `panic!` arm
|
|
/// if the parser returns any variant other than `Select`.
|
|
#[test]
|
|
fn parses_count_projection() {
|
|
let parsed = parse_statement("SELECT COUNT(*) FROM Tasks WHERE Status = 'Done'").unwrap();
|
|
|
|
match parsed {
|
|
SqlStatement::Select {
|
|
database, columns, ..
|
|
} => {
|
|
assert_eq!(database, "Tasks");
|
|
assert_eq!(columns, SelectColumns::Count);
|
|
}
|
|
other => panic!("unexpected statement: {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// Verifies that counting a specific column (`COUNT(Name)`) is rejected, pinning
|
|
/// down the limit of the parser's `COUNT` support.
|
|
///
|
|
/// Only `COUNT(*)` and `COUNT(1)` map cleanly onto "number of pages returned";
|
|
/// `COUNT(<column>)` would imply non-null counting semantics the crate cannot
|
|
/// honor against Notion, so it must error rather than silently mis-parse. We
|
|
/// take the `Err`, render it with `to_string()`, and assert the message
|
|
/// contains the substring `"Only COUNT(*) and COUNT(1)"` — a substring match so
|
|
/// the test tolerates surrounding wording while still proving the user is told
|
|
/// *which* forms are allowed.
|
|
///
|
|
/// # Panics
|
|
///
|
|
/// Panics via `unwrap_err()` if the statement unexpectedly parses successfully,
|
|
/// and via `assert!` if the error message does not mention the supported forms.
|
|
#[test]
|
|
fn rejects_unsupported_count_projection() {
|
|
// `unwrap_err` asserts the parse fails; a successful parse here would be the
|
|
// bug this test is designed to catch.
|
|
let error = parse_statement("SELECT COUNT(Name) FROM Tasks").unwrap_err();
|
|
|
|
assert!(error.to_string().contains("Only COUNT(*) and COUNT(1)"));
|
|
}
|