// 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()`), 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()` 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)")); }