Files

292 lines
12 KiB
Rust

// SPDX-License-Identifier: MIT
// Copyright (c) 2026 James Maes
//! Integration tests for SQL `WHERE` to Notion query filter translation.
//!
//! These tests exercise [`notion_sql::filter::translate_where`], the function
//! that converts a parsed SQL boolean expression (the `WHERE` clause) into the
//! JSON filter object that the Notion "query a database" API expects. The crate
//! lets callers query a Notion database using SQL-ish syntax; this file pins
//! down the exact shape of the translation for the supported predicate forms.
//!
//! # Testing approach
//!
//! Each test follows the same pattern:
//! 1. Build a fixed [`DatabaseSchema`] via [`schema`] so column names resolve to
//! known Notion property types (the property type dictates which filter
//! operators are legal, e.g. `status` vs `number` vs `date`).
//! 2. Parse a SQL fragment into an [`sqlparser::ast::Expr`] via [`where_expr`].
//! 3. Translate it and assert on the resulting `serde_json::Value`, comparing
//! against a hand-written `json!` literal that mirrors the documented Notion
//! filter format exactly.
//!
//! The behaviors under test cover: boolean grouping (`AND`/`OR` nesting),
//! the supported and explicitly-rejected `LIKE`/`ILIKE` patterns, `IN` lists,
//! `IS [NOT] NULL` checks, date comparisons, and case-insensitive column-name
//! matching. Because Notion filters are deeply nested JSON, the assertions are
//! intentionally written as full literals rather than partial checks so that
//! any structural drift in the translator is caught.
use serde_json::json;
use sqlparser::dialect::GenericDialect;
use sqlparser::parser::Parser;
use notion_sql::{filter::translate_where, schema::DatabaseSchema};
/// Builds the fixed [`DatabaseSchema`] fixture shared by every test.
///
/// The schema declares one column of each Notion property type that the tests
/// need so that [`translate_where`] can resolve a column name to its property
/// type and pick the correct filter operator family. The property names and
/// types here are the source of truth the assertions are written against:
/// `Name`/title, `Status`/status, `Tags`/multi_select, `Priority`/number,
/// `Done`/checkbox, `Due`/date.
///
/// # Returns
///
/// A fully-parsed [`DatabaseSchema`] mirroring a Notion database `properties`
/// payload.
///
/// # Panics
///
/// Panics (via `unwrap`) if [`DatabaseSchema::from_notion_database`] rejects the
/// literal — that would indicate a bug in the fixture itself, so failing loudly
/// is the desired behavior in a test.
fn schema() -> DatabaseSchema {
DatabaseSchema::from_notion_database(&json!({
"properties": {
"Name": { "type": "title", "title": {} },
"Status": { "type": "status", "status": {} },
"Tags": { "type": "multi_select", "multi_select": {} },
"Priority": { "type": "number", "number": {} },
"Done": { "type": "checkbox", "checkbox": {} },
"Due": { "type": "date", "date": {} }
}
}))
.unwrap()
}
/// Parses a SQL `WHERE`-clause fragment into its AST expression node.
///
/// The translator under test operates on a single boolean [`Expr`], not on a
/// whole statement, so this helper wraps the fragment in a minimal
/// `SELECT * FROM Tasks WHERE <sql>` statement, parses it with the permissive
/// [`GenericDialect`], and digs the `selection` (the `WHERE` predicate) back out
/// of the resulting AST. The table name `Tasks` is arbitrary — only the
/// predicate is used downstream.
///
/// [`Expr`]: sqlparser::ast::Expr
///
/// # Parameters
///
/// - `sql`: the `WHERE`-clause body without the `WHERE` keyword, e.g.
/// `"Status = 'Done'"`.
///
/// # Returns
///
/// The parsed predicate as an [`sqlparser::ast::Expr`].
///
/// # Panics
///
/// Panics if the fragment fails to parse, if the parse yields no statement, or
/// if the statement is not a `SELECT` carrying a `WHERE` clause. All of these
/// indicate a malformed test input rather than a translator bug, so panicking
/// surfaces the mistake immediately.
fn where_expr(sql: &str) -> sqlparser::ast::Expr {
let parsed = Parser::parse_sql(
&GenericDialect {},
&format!("SELECT * FROM Tasks WHERE {sql}"),
)
.unwrap();
let statement = parsed.into_iter().next().unwrap();
// Walk the AST down to the SELECT body and pull out its `WHERE` predicate.
// `selection` is `None` only when there is no WHERE clause, which the
// wrapper guarantees we always have, so the inner `unwrap` is safe here.
if let sqlparser::ast::Statement::Query(query) = statement {
if let sqlparser::ast::SetExpr::Select(select) = query.body.as_ref() {
return select.selection.clone().unwrap();
}
}
// Reached only if the AST shape is unexpectedly not a plain SELECT.
panic!("failed to extract WHERE expression")
}
/// Verifies that `AND`/`OR` with explicit parenthesised grouping maps onto
/// Notion's nested `and`/`or` compound-filter arrays.
///
/// The input mixes a top-level `AND` with a parenthesised `OR`, exercising the
/// translator's ability to (a) preserve operator precedence/grouping and (b)
/// emit the correct per-property operator for each leaf (`status.equals`,
/// `number.greater_than_or_equal_to`, `checkbox.equals`). The `2` literal is
/// asserted as `2.0` because numbers are normalised to JSON floats.
#[test]
fn translates_and_or_grouping() {
let filter = translate_where(
&where_expr("Status = 'Done' AND (Priority >= 2 OR Done = true)"),
&schema(),
)
.unwrap();
assert_eq!(
filter,
json!({
"and": [
{ "property": "Status", "status": { "equals": "Done" } },
{
"or": [
{ "property": "Priority", "number": { "greater_than_or_equal_to": 2.0 } },
{ "property": "Done", "checkbox": { "equals": true } }
]
}
]
})
);
}
/// Pins down the mapping from the *supported* `LIKE`/`ILIKE` wildcard shapes to
/// Notion's text operators.
///
/// Each case asserts one shape: a bare literal becomes `equals`, a trailing `%`
/// becomes `starts_with`, a leading `%` becomes `ends_with`, both becomes
/// `contains`, and the negated `NOT LIKE` forms produce the corresponding
/// `does_not_*` operators. `ILIKE` is accepted alongside `LIKE`; the translator
/// does not distinguish case-sensitivity here because Notion text matching is
/// already case-insensitive. The rejected shapes are covered separately by
/// [`rejects_unsupported_like_patterns`].
#[test]
fn translates_supported_like_patterns() {
// (sql_fragment, expected_notion_filter) — driven as a table so each
// wildcard shape is asserted independently in the loop below.
let cases = [
(
"Name LIKE 'task'",
json!({ "property": "Name", "title": { "equals": "task" } }),
),
(
"Name LIKE 'task%'",
json!({ "property": "Name", "title": { "starts_with": "task" } }),
),
(
"Name ILIKE '%task'",
json!({ "property": "Name", "title": { "ends_with": "task" } }),
),
(
"Name LIKE '%task%'",
json!({ "property": "Name", "title": { "contains": "task" } }),
),
(
"Name NOT LIKE 'task'",
json!({ "property": "Name", "title": { "does_not_equal": "task" } }),
),
(
"Name NOT LIKE '%task%'",
json!({ "property": "Name", "title": { "does_not_contain": "task" } }),
),
];
// Translate each fragment and assert it matches its expected filter.
for (sql, expected) in cases {
assert_eq!(
translate_where(&where_expr(sql), &schema()).unwrap(),
expected
);
}
}
/// Confirms that `LIKE`/`ILIKE` shapes Notion cannot express are rejected with a
/// descriptive error rather than silently mistranslated.
///
/// Notion only supports prefix/suffix/contains/equals text matching, so wildcard
/// shapes such as an interior `%` (`'ta%sk'`), `_` single-char wildcards, escaped
/// wildcards, explicit `ESCAPE` clauses, a wildcard-only pattern (`'%'`), and
/// negated prefix/suffix forms have no faithful translation. Each case asserts
/// the error message *contains* a human-readable fragment naming the
/// unsupported feature; substring matching keeps the test robust to surrounding
/// wording while still verifying the right rejection reason fired.
#[test]
fn rejects_unsupported_like_patterns() {
// (sql_fragment, expected_error_substring) for each unsupported shape.
let cases = [
("Name LIKE 'ta%sk'", "leading and trailing '%'"),
("Name LIKE 'ta_sk'", "LIKE '_' wildcards"),
("Name LIKE 'ta\\%sk'", "Escaped LIKE wildcard patterns"),
("Name LIKE 'ta#%sk' ESCAPE '#'", "LIKE ESCAPE clauses"),
("Name LIKE '%'", "wildcard-only patterns"),
("Name NOT LIKE 'task%'", "NOT LIKE prefix patterns"),
("Name NOT LIKE '%task'", "NOT LIKE suffix patterns"),
];
// Each fragment must translate to an `Err`; `unwrap_err` panics (failing
// the test) if the translator unexpectedly accepted an unsupported pattern.
for (sql, expected_error) in cases {
let error = translate_where(&where_expr(sql), &schema())
.unwrap_err()
.to_string();
assert!(
error.contains(expected_error),
"expected '{error}' to contain '{expected_error}'"
);
}
}
/// Verifies that a SQL `IN (...)` list against a `multi_select` column expands
/// into an `or` of `contains` filters — one per list element.
///
/// Notion has no native "value in set" operator, so membership is modelled as a
/// disjunction. The test asserts both the `or` wrapper and the per-element
/// `multi_select.contains` leaves, and implicitly that list order is preserved.
#[test]
fn translates_in_list() {
let in_list = translate_where(&where_expr("Tags IN ('A', 'B')"), &schema()).unwrap();
assert_eq!(
in_list,
json!({
"or": [
{ "property": "Tags", "multi_select": { "contains": "A" } },
{ "property": "Tags", "multi_select": { "contains": "B" } }
]
})
);
}
/// Covers two date-column behaviors: null checks and ordered comparisons.
///
/// `IS NOT NULL` maps to Notion's emptiness operator (`date.is_not_empty`),
/// which is the inverse of `is_empty` — Notion expresses presence/absence via a
/// boolean flag rather than a NULL sentinel. A `<` comparison against a date
/// literal maps to `date.before`, with the date string passed through verbatim
/// (no reformatting). Both are asserted in one test because they share the date
/// property type and reinforce that the same column supports multiple operator
/// families.
#[test]
fn translates_null_checks_and_dates() {
let null_filter = translate_where(&where_expr("Due IS NOT NULL"), &schema()).unwrap();
let date_filter = translate_where(&where_expr("Due < '2026-01-01'"), &schema()).unwrap();
assert_eq!(
null_filter,
json!({ "property": "Due", "date": { "is_not_empty": true } })
);
assert_eq!(
date_filter,
json!({ "property": "Due", "date": { "before": "2026-01-01" } })
);
}
/// Verifies that column references resolve to schema properties regardless of
/// case.
///
/// The SQL uses lowercase `status` while the schema property is `Status`; the
/// translator must still resolve the column and, importantly, emit the
/// canonical property name (`"Status"`) from the schema in the output filter —
/// Notion's API keys on the exact stored property name, so the original casing
/// must be restored rather than echoed from the query.
#[test]
fn matches_column_names_case_insensitively() {
let filter = translate_where(&where_expr("status = 'Done'"), &schema()).unwrap();
assert_eq!(
filter,
json!({ "property": "Status", "status": { "equals": "Done" } })
);
}