From cdac35654f14faec220e231fcb4d7c46e996b5f5 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sun, 17 May 2026 20:21:45 -0400 Subject: [PATCH 01/17] feat: L1 to L3 SQL lowering via DataFusion with TDD --- Cargo.lock | 2999 ++++++++++++++++++++++ Cargo.toml | 1 + crates/core/src/intent_algebra/expr.rs | 23 +- crates/core/src/intent_algebra/schema.rs | 32 +- crates/lower/Cargo.toml | 11 + crates/lower/src/error.rs | 35 + crates/lower/src/lib.rs | 35 + crates/lower/src/sql.rs | 521 ++++ crates/lower/tests/sql_lowering.rs | 413 +++ 9 files changed, 4059 insertions(+), 11 deletions(-) create mode 100644 crates/lower/Cargo.toml create mode 100644 crates/lower/src/error.rs create mode 100644 crates/lower/src/lib.rs create mode 100644 crates/lower/src/sql.rs create mode 100644 crates/lower/tests/sql_lowering.rs diff --git a/Cargo.lock b/Cargo.lock index 8b1e8423..755e871e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,3005 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "arrow" +version = "53.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3a3ec4fe573f9d1f59d99c085197ef669b00b088ba1d7bb75224732d9357a74" +dependencies = [ + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-csv", + "arrow-data", + "arrow-ipc", + "arrow-json", + "arrow-ord", + "arrow-row", + "arrow-schema", + "arrow-select", + "arrow-string", +] + +[[package]] +name = "arrow-arith" +version = "53.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dcf19f07792d8c7f91086c67b574a79301e367029b17fcf63fb854332246a10" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "half", + "num", +] + +[[package]] +name = "arrow-array" +version = "53.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7845c32b41f7053e37a075b3c2f29c6f5ea1b3ca6e5df7a2d325ee6e1b4a63cf" +dependencies = [ + "ahash", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "chrono-tz", + "half", + "hashbrown 0.15.5", + "num", +] + +[[package]] +name = "arrow-buffer" +version = "53.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b5c681a99606f3316f2a99d9c8b6fa3aad0b1d34d8f6d7a1b471893940219d8" +dependencies = [ + "bytes", + "half", + "num", +] + +[[package]] +name = "arrow-cast" +version = "53.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6365f8527d4f87b133eeb862f9b8093c009d41a210b8f101f91aa2392f61daac" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "atoi", + "base64", + "chrono", + "comfy-table", + "half", + "lexical-core", + "num", + "ryu", +] + +[[package]] +name = "arrow-csv" +version = "53.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30dac4d23ac769300349197b845e0fd18c7f9f15d260d4659ae6b5a9ca06f586" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-schema", + "chrono", + "csv", + "csv-core", + "lazy_static", + "lexical-core", + "regex", +] + +[[package]] +name = "arrow-data" +version = "53.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd962fc3bf7f60705b25bcaa8eb3318b2545aa1d528656525ebdd6a17a6cd6fb" +dependencies = [ + "arrow-buffer", + "arrow-schema", + "half", + "num", +] + +[[package]] +name = "arrow-ipc" +version = "53.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3527365b24372f9c948f16e53738eb098720eea2093ae73c7af04ac5e30a39b" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-schema", + "flatbuffers", + "lz4_flex", +] + +[[package]] +name = "arrow-json" +version = "53.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdec0024749fc0d95e025c0b0266d78613727b3b3a5d4cf8ea47eb6d38afdd1" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-schema", + "chrono", + "half", + "indexmap", + "lexical-core", + "num", + "serde", + "serde_json", +] + +[[package]] +name = "arrow-ord" +version = "53.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79af2db0e62a508d34ddf4f76bfd6109b6ecc845257c9cba6f939653668f89ac" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "half", + "num", +] + +[[package]] +name = "arrow-row" +version = "53.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da30e9d10e9c52f09ea0cf15086d6d785c11ae8dcc3ea5f16d402221b6ac7735" +dependencies = [ + "ahash", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "half", +] + +[[package]] +name = "arrow-schema" +version = "53.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35b0f9c0c3582dd55db0f136d3b44bfa0189df07adcf7dc7f2f2e74db0f52eb8" + +[[package]] +name = "arrow-select" +version = "53.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92fc337f01635218493c23da81a364daf38c694b05fc20569c3193c11c561984" +dependencies = [ + "ahash", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "num", +] + +[[package]] +name = "arrow-string" +version = "53.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d596a9fc25dae556672d5069b090331aca8acb93cae426d8b7dcdf1c558fa0ce" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "memchr", + "num", + "regex", + "regex-syntax", +] + [[package]] name = "asap-control-core" version = "0.1.0" + +[[package]] +name = "asap-control-lower" +version = "0.1.0" +dependencies = [ + "asap-control-core", + "datafusion", + "tokio", +] + +[[package]] +name = "async-compression" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06575e6a9673580f52661c92107baabffbf41e2141373441cbcdc47cb733003c" +dependencies = [ + "bzip2 0.5.2", + "flate2", + "futures-core", + "futures-io", + "memchr", + "pin-project-lite", + "tokio", + "xz2", + "zstd", + "zstd-safe", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "brotli" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc97b8f16f944bba54f0433f07e30be199b6dc2bd25937444bbad560bcea29bd" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a334ef7c9e23abf0ce748e8cd309037da93e606ad52eb372e4ce327a0dcfbdfd" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "num-traits", + "windows-targets", +] + +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf", +] + +[[package]] +name = "comfy-table" +version = "7.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47" +dependencies = [ + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "datafusion" +version = "43.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbba0799cf6913b456ed07a94f0f3b6e12c62a5d88b10809e2284a0f2b915c05" +dependencies = [ + "ahash", + "arrow", + "arrow-array", + "arrow-ipc", + "arrow-schema", + "async-compression", + "async-trait", + "bytes", + "bzip2 0.4.4", + "chrono", + "dashmap", + "datafusion-catalog", + "datafusion-common", + "datafusion-common-runtime", + "datafusion-execution", + "datafusion-expr", + "datafusion-functions", + "datafusion-functions-aggregate", + "datafusion-functions-nested", + "datafusion-functions-window", + "datafusion-optimizer", + "datafusion-physical-expr", + "datafusion-physical-expr-common", + "datafusion-physical-optimizer", + "datafusion-physical-plan", + "datafusion-sql", + "flate2", + "futures", + "glob", + "half", + "hashbrown 0.14.5", + "indexmap", + "itertools", + "log", + "num_cpus", + "object_store", + "parking_lot", + "parquet", + "paste", + "pin-project-lite", + "rand", + "sqlparser", + "tempfile", + "tokio", + "tokio-util", + "url", + "uuid", + "xz2", + "zstd", +] + +[[package]] +name = "datafusion-catalog" +version = "43.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7493c5c2d40eec435b13d92e5703554f4efc7059451fcb8d3a79580ff0e45560" +dependencies = [ + "arrow-schema", + "async-trait", + "datafusion-common", + "datafusion-execution", + "datafusion-expr", + "datafusion-physical-plan", + "parking_lot", +] + +[[package]] +name = "datafusion-common" +version = "43.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24953049ebbd6f8964f91f60aa3514e121b5e81e068e33b60e77815ab369b25c" +dependencies = [ + "ahash", + "arrow", + "arrow-array", + "arrow-buffer", + "arrow-schema", + "chrono", + "half", + "hashbrown 0.14.5", + "indexmap", + "instant", + "libc", + "num_cpus", + "object_store", + "parquet", + "paste", + "sqlparser", + "tokio", +] + +[[package]] +name = "datafusion-common-runtime" +version = "43.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f06df4ef76872e11c924d3c814fd2a8dd09905ed2e2195f71c857d78abd19685" +dependencies = [ + "log", + "tokio", +] + +[[package]] +name = "datafusion-execution" +version = "43.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bbdcb628d690f3ce5fea7de81642b514486d58ff9779a51f180a69a4eadb361" +dependencies = [ + "arrow", + "chrono", + "dashmap", + "datafusion-common", + "datafusion-expr", + "futures", + "hashbrown 0.14.5", + "log", + "object_store", + "parking_lot", + "rand", + "tempfile", + "url", +] + +[[package]] +name = "datafusion-expr" +version = "43.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8036495980e3131f706b7d33ab00b4492d73dc714e3cb74d11b50f9602a73246" +dependencies = [ + "ahash", + "arrow", + "arrow-array", + "arrow-buffer", + "chrono", + "datafusion-common", + "datafusion-expr-common", + "datafusion-functions-aggregate-common", + "datafusion-functions-window-common", + "datafusion-physical-expr-common", + "indexmap", + "paste", + "serde_json", + "sqlparser", + "strum", + "strum_macros", +] + +[[package]] +name = "datafusion-expr-common" +version = "43.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4da0f3cb4669f9523b403d6b5a0ec85023e0ab3bf0183afd1517475b3e64fdd2" +dependencies = [ + "arrow", + "datafusion-common", + "itertools", + "paste", +] + +[[package]] +name = "datafusion-functions" +version = "43.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52c4012648b34853e40a2c6bcaa8772f837831019b68aca384fb38436dba162" +dependencies = [ + "arrow", + "arrow-buffer", + "base64", + "blake2", + "blake3", + "chrono", + "datafusion-common", + "datafusion-execution", + "datafusion-expr", + "hashbrown 0.14.5", + "hex", + "itertools", + "log", + "md-5", + "rand", + "regex", + "sha2", + "unicode-segmentation", + "uuid", +] + +[[package]] +name = "datafusion-functions-aggregate" +version = "43.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5b8bb624597ba28ed7446df4a9bd7c7a7bde7c578b6b527da3f47371d5f6741" +dependencies = [ + "ahash", + "arrow", + "arrow-schema", + "datafusion-common", + "datafusion-execution", + "datafusion-expr", + "datafusion-functions-aggregate-common", + "datafusion-physical-expr", + "datafusion-physical-expr-common", + "half", + "indexmap", + "log", + "paste", +] + +[[package]] +name = "datafusion-functions-aggregate-common" +version = "43.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fb06208fc470bc8cf1ce2d9a1159d42db591f2c7264a8c1776b53ad8f675143" +dependencies = [ + "ahash", + "arrow", + "datafusion-common", + "datafusion-expr-common", + "datafusion-physical-expr-common", + "rand", +] + +[[package]] +name = "datafusion-functions-nested" +version = "43.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fca25bbb87323716d05e54114666e942172ccca23c5a507e9c7851db6e965317" +dependencies = [ + "arrow", + "arrow-array", + "arrow-buffer", + "arrow-ord", + "arrow-schema", + "datafusion-common", + "datafusion-execution", + "datafusion-expr", + "datafusion-functions", + "datafusion-functions-aggregate", + "datafusion-physical-expr-common", + "itertools", + "log", + "paste", + "rand", +] + +[[package]] +name = "datafusion-functions-window" +version = "43.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ae23356c634e54c59f7c51acb7a5b9f6240ffb2cf997049a1a24a8a88598dbe" +dependencies = [ + "datafusion-common", + "datafusion-expr", + "datafusion-functions-window-common", + "datafusion-physical-expr", + "datafusion-physical-expr-common", + "log", + "paste", +] + +[[package]] +name = "datafusion-functions-window-common" +version = "43.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b3d6ff7794acea026de36007077a06b18b89e4f9c3fea7f2215f9f7dd9059b" +dependencies = [ + "datafusion-common", + "datafusion-physical-expr-common", +] + +[[package]] +name = "datafusion-optimizer" +version = "43.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec6241eb80c595fa0e1a8a6b69686b5cf3bd5fdacb8319582a0943b0bd788aa" +dependencies = [ + "arrow", + "async-trait", + "chrono", + "datafusion-common", + "datafusion-expr", + "datafusion-physical-expr", + "hashbrown 0.14.5", + "indexmap", + "itertools", + "log", + "paste", + "regex-syntax", +] + +[[package]] +name = "datafusion-physical-expr" +version = "43.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3370357b8fc75ec38577700644e5d1b0bc78f38babab99c0b8bd26bafb3e4335" +dependencies = [ + "ahash", + "arrow", + "arrow-array", + "arrow-buffer", + "arrow-ord", + "arrow-schema", + "arrow-string", + "chrono", + "datafusion-common", + "datafusion-expr", + "datafusion-expr-common", + "datafusion-functions-aggregate-common", + "datafusion-physical-expr-common", + "half", + "hashbrown 0.14.5", + "indexmap", + "itertools", + "log", + "paste", + "petgraph", +] + +[[package]] +name = "datafusion-physical-expr-common" +version = "43.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8b7734d94bf2fa6f6e570935b0ddddd8421179ce200065be97874e13d46a47b" +dependencies = [ + "ahash", + "arrow", + "datafusion-common", + "datafusion-expr-common", + "hashbrown 0.14.5", + "rand", +] + +[[package]] +name = "datafusion-physical-optimizer" +version = "43.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eee8c479522df21d7b395640dff88c5ed05361852dce6544d7c98e9dbcebffe" +dependencies = [ + "arrow", + "arrow-schema", + "datafusion-common", + "datafusion-execution", + "datafusion-expr-common", + "datafusion-physical-expr", + "datafusion-physical-plan", + "itertools", +] + +[[package]] +name = "datafusion-physical-plan" +version = "43.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e1fc2e2c239d14e8556f2622b19a726bf6bc6962cc00c71fc52626274bee24" +dependencies = [ + "ahash", + "arrow", + "arrow-array", + "arrow-buffer", + "arrow-ord", + "arrow-schema", + "async-trait", + "chrono", + "datafusion-common", + "datafusion-common-runtime", + "datafusion-execution", + "datafusion-expr", + "datafusion-functions-aggregate-common", + "datafusion-functions-window-common", + "datafusion-physical-expr", + "datafusion-physical-expr-common", + "futures", + "half", + "hashbrown 0.14.5", + "indexmap", + "itertools", + "log", + "once_cell", + "parking_lot", + "pin-project-lite", + "rand", + "tokio", +] + +[[package]] +name = "datafusion-sql" +version = "43.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e3a4ed41dbee20a5d947a59ca035c225d67dc9cbe869c10f66dcdf25e7ce51" +dependencies = [ + "arrow", + "arrow-array", + "arrow-schema", + "datafusion-common", + "datafusion-expr", + "indexmap", + "log", + "regex", + "sqlparser", + "strum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "flatbuffers" +version = "24.12.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f1baf0dbf96932ec9a3038d57900329c015b0bfb7b63d904f3bc27e2b02a096" +dependencies = [ + "bitflags 1.3.2", + "rustc_version", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "integer-encoding" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "lexical-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594" +dependencies = [ + "lexical-parse-float", + "lexical-parse-integer", + "lexical-util", + "lexical-write-float", + "lexical-write-integer", +] + +[[package]] +name = "lexical-parse-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" +dependencies = [ + "lexical-parse-integer", + "lexical-util", +] + +[[package]] +name = "lexical-parse-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "lexical-util" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" + +[[package]] +name = "lexical-write-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c438c87c013188d415fbabbb1dceb44249ab81664efbd31b14ae55dabb6361" +dependencies = [ + "lexical-util", + "lexical-write-integer", +] + +[[package]] +name = "lexical-write-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "409851a618475d2d5796377cad353802345cba92c867d9fbcde9cf4eac4e14df" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lz4_flex" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" +dependencies = [ + "twox-hash 2.1.2", +] + +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "object_store" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cfccb68961a56facde1163f9319e0d15743352344e7808a11795fb99698dcaf" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures", + "humantime", + "itertools", + "parking_lot", + "percent-encoding", + "snafu", + "tokio", + "tracing", + "url", + "walkdir", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "parquet" +version = "53.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f8cf58b29782a7add991f655ff42929e31a7859f5319e53db9e39a714cb113c" +dependencies = [ + "ahash", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-ipc", + "arrow-schema", + "arrow-select", + "base64", + "brotli", + "bytes", + "chrono", + "flate2", + "futures", + "half", + "hashbrown 0.15.5", + "lz4_flex", + "num", + "num-bigint", + "object_store", + "paste", + "seq-macro", + "snap", + "thrift", + "tokio", + "twox-hash 1.6.3", + "zstd", + "zstd-sys", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "snap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" + +[[package]] +name = "sqlparser" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fe11944a61da0da3f592e19a45ebe5ab92dc14a779907ff1f08fbb797bfefc7" +dependencies = [ + "log", + "sqlparser_derive", +] + +[[package]] +name = "sqlparser_derive" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01b2e185515564f15375f593fb966b5718bc624ba77fe49fa4616ad619690554" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thrift" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e54bc85fc7faa8bc175c4bab5b92ba8d9a3ce893d0e9f42cc455c8ab16a9e09" +dependencies = [ + "byteorder", + "integer-encoding", + "ordered-float", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "twox-hash" +version = "1.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" +dependencies = [ + "cfg-if", + "static_assertions", +] + +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54a3ab4db68cea366acc5c897c7b4d4d1b8994a9cd6e6f841f8964566a419059" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.13+zstd.1.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38ff0f21cfee8f97d94cef41359e0c89aa6113028ab0291aa8ca0038995a95aa" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index c3365c5b..7feaef0f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ "crates/core", + "crates/lower", ] resolver = "2" diff --git a/crates/core/src/intent_algebra/expr.rs b/crates/core/src/intent_algebra/expr.rs index 6860d6fc..d08d8130 100644 --- a/crates/core/src/intent_algebra/expr.rs +++ b/crates/core/src/intent_algebra/expr.rs @@ -11,10 +11,10 @@ use super::schema::{HasSchema, L3Schema, SchemaCatalog}; #[derive(Debug, Clone)] pub struct Predicate; /// One item in a SELECT projection list. #[derive(Debug, Clone)] pub struct ProjectItem; -/// A GROUP BY key reference. -#[derive(Debug, Clone)] pub struct GroupKey; +/// A GROUP BY key reference (column name). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct GroupKey(pub String); /// A reference to a column by name. -#[derive(Debug, Clone)] pub struct ColumnRef; +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct ColumnRef(pub String); /// A set of partitioning keys (sharding hint for L5 stage allocator). #[derive(Debug, Clone)] pub struct PartitionKeys; /// One key in an ORDER BY clause. @@ -25,12 +25,17 @@ use super::schema::{HasSchema, L3Schema, SchemaCatalog}; #[derive(Debug, Clone)] pub struct VectorMatch; /// Reference to a metric by name (PromQL / OTLP). #[derive(Debug, Clone)] pub struct MetricRef; -/// Closed time interval for a time-series scan. -#[derive(Debug, Clone)] pub struct TimeRange; +/// Closed time interval [start_ms, end_ms] in milliseconds since Unix epoch. +/// Either bound may be `None`, meaning unbounded on that side. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TimeRange { + pub start_ms: Option, + pub end_ms: Option, +} /// Label matchers applied to a time-series scan. #[derive(Debug, Clone)] pub struct LabelFilter; /// Reference to a relational table by name. -#[derive(Debug, Clone)] pub struct TableRef; +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct TableRef(pub String); /// Join key specification (USING / ON column reference). #[derive(Debug, Clone)] pub struct JoinKey; @@ -112,6 +117,9 @@ pub enum Source { Table { table_ref: TableRef, columns: Vec, + /// Time range extracted from a WHERE predicate on the table's designated + /// time column. `None` means no time bound (full-history scan). + time_range: Option, }, /// Recursive join over sources (multi-table tabular queries). Join { @@ -145,6 +153,9 @@ pub enum AggIntent { Sum, Min, Max, + Avg, + /// Sample stddev when `population == false`; population stddev otherwise. + Stddev { population: bool }, Quantile { q: f64, accuracy: AccuracyTarget }, /// Heavy-hitter top-k. Distinct from generic `Sort + Limit` — a /// dedicated sketch (SpaceSaving, CMS-with-heap) computes it as a single diff --git a/crates/core/src/intent_algebra/schema.rs b/crates/core/src/intent_algebra/schema.rs index 4f6d1fc0..b0abcbe3 100644 --- a/crates/core/src/intent_algebra/schema.rs +++ b/crates/core/src/intent_algebra/schema.rs @@ -1,8 +1,30 @@ -/// Opaque handle to the external data-source catalog (Prometheus metric -/// metadata, SQL `information_schema`, DataFusion catalog). Used only by -/// `Scan` schema derivation to resolve leaf column types; all other nodes -/// derive their output schemas purely from their input schemas. -pub struct SchemaCatalog; +use std::collections::HashMap; + +/// Catalog of known relational tables and their column definitions. +/// Passed to `lower_batch` so the SQL lowerer can resolve table and column +/// types and identify each table's designated time column. +#[derive(Debug, Clone, Default)] +pub struct SchemaCatalog { + pub tables: HashMap, +} + +/// Schema for a single relational table. +#[derive(Debug, Clone)] +pub struct TableSchema { + pub columns: Vec, + /// Name of the column that holds the row timestamp. When set, WHERE + /// predicates on this column are extracted into `Source::Table.time_range` + /// rather than left as opaque `Filter` predicates. + pub time_column: Option, +} + +/// One column in a `TableSchema`. +#[derive(Debug, Clone)] +pub struct ColumnDef { + pub name: String, + pub data_type: L3DataType, + pub nullable: bool, +} // ── Data types ──────────────────────────────────────────────────────────────── diff --git a/crates/lower/Cargo.toml b/crates/lower/Cargo.toml new file mode 100644 index 00000000..68593ff1 --- /dev/null +++ b/crates/lower/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "asap-control-lower" +version = "0.1.0" +edition = "2021" + +[dependencies] +asap-control-core = { path = "../core" } +datafusion = "43" + +[dev-dependencies] +tokio = { version = "1", features = ["rt", "macros", "rt-multi-thread"] } diff --git a/crates/lower/src/error.rs b/crates/lower/src/error.rs new file mode 100644 index 00000000..1f78dfe1 --- /dev/null +++ b/crates/lower/src/error.rs @@ -0,0 +1,35 @@ +use std::fmt; + +#[derive(Debug)] +pub enum LoweringError { + DataFusion(datafusion::error::DataFusionError), + TableNotFound(String), + ColumnNotFound { table: String, column: String }, + /// A SQL feature (JOIN, subquery, etc.) not supported in this version. + UnsupportedFeature(String), + UnsupportedAggregate(String), + InvalidExpression(String), +} + +impl fmt::Display for LoweringError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DataFusion(e) => write!(f, "DataFusion error: {e}"), + Self::TableNotFound(t) => write!(f, "table not found in catalog: {t}"), + Self::ColumnNotFound { table, column } => { + write!(f, "column not found: {table}.{column}") + } + Self::UnsupportedFeature(msg) => write!(f, "unsupported SQL feature: {msg}"), + Self::UnsupportedAggregate(name) => write!(f, "unsupported aggregate: {name}"), + Self::InvalidExpression(msg) => write!(f, "invalid expression: {msg}"), + } + } +} + +impl std::error::Error for LoweringError {} + +impl From for LoweringError { + fn from(e: datafusion::error::DataFusionError) -> Self { + Self::DataFusion(e) + } +} diff --git a/crates/lower/src/lib.rs b/crates/lower/src/lib.rs new file mode 100644 index 00000000..90371549 --- /dev/null +++ b/crates/lower/src/lib.rs @@ -0,0 +1,35 @@ +pub mod error; +pub mod sql; + +use asap_control_core::intent_algebra::expr::QueryExpr; +use asap_control_core::intent_algebra::schema::SchemaCatalog; +use asap_control_core::types::AccuracyTarget; +use asap_control_core::workload::QueryWorkload; + +pub use error::LoweringError; +pub use sql::SqlLowerer; + +/// Lower every SQL batch entry in `workload` to a `QueryExpr`. +/// +/// One `Result` per entry — errors are per-query, not fatal for the batch. +/// Returns an empty `Vec` if `workload.query_batch` is absent or empty. +pub async fn lower_batch( + workload: &QueryWorkload, + catalog: &SchemaCatalog, +) -> Vec> { + let entries = match &workload.query_batch { + Some(e) if !e.is_empty() => e, + _ => return vec![], + }; + let mut results = Vec::with_capacity(entries.len()); + for entry in entries { + let accuracy = entry + .requirements + .as_ref() + .and_then(|r| r.accuracy.clone()) + .unwrap_or(AccuracyTarget::Exact); + let lowerer = SqlLowerer::new(catalog, accuracy); + results.push(lowerer.lower(&entry.query.0).await); + } + results +} diff --git a/crates/lower/src/sql.rs b/crates/lower/src/sql.rs new file mode 100644 index 00000000..ee93ef14 --- /dev/null +++ b/crates/lower/src/sql.rs @@ -0,0 +1,521 @@ +use std::rc::Rc; +use std::sync::Arc; + +use datafusion::arrow::datatypes::{DataType as ArrowDataType, Field, Fields, Schema, TimeUnit}; +use datafusion::common::ScalarValue; +use datafusion::datasource::MemTable; +use datafusion::logical_expr::{ + self, BinaryExpr, Distinct, Expr, LogicalPlan, Operator, WindowFunctionDefinition, +}; +use datafusion::prelude::SessionContext; + +use asap_control_core::intent_algebra::expr::{ + AggIntent, ColumnRef, GroupKey, L3Node, Predicate, ProjectItem, QueryExpr, SortKey, Source, + TableRef, TimeRange, WindowFuncKind, +}; +use asap_control_core::intent_algebra::schema::{L3DataType, L3Schema, SchemaCatalog, TableSchema}; +use asap_control_core::types::AccuracyTarget; + +use crate::error::LoweringError; + +pub struct SqlLowerer<'a> { + catalog: &'a SchemaCatalog, + accuracy: AccuracyTarget, +} + +impl<'a> SqlLowerer<'a> { + pub fn new(catalog: &'a SchemaCatalog, accuracy: AccuracyTarget) -> Self { + Self { catalog, accuracy } + } + + pub async fn lower(&self, sql: &str) -> Result { + let ctx = self.build_context()?; + let df = ctx.sql(sql).await?; + let plan = df.into_unoptimized_plan(); + self.lower_plan(&plan) + } + + fn build_context(&self) -> Result { + let ctx = SessionContext::new(); + for (name, table_schema) in &self.catalog.tables { + let arrow_schema = Arc::new(table_schema_to_arrow(table_schema)); + let mem_table = MemTable::try_new(arrow_schema, vec![])?; + ctx.register_table(name.as_str(), Arc::new(mem_table))?; + } + Ok(ctx) + } + + fn lower_plan(&self, plan: &LogicalPlan) -> Result { + match plan { + LogicalPlan::TableScan(scan) => self.lower_table_scan(scan), + LogicalPlan::Filter(filter) => self.lower_filter(filter), + LogicalPlan::Projection(proj) => self.lower_projection(proj), + LogicalPlan::Aggregate(agg) => self.lower_aggregate(agg), + LogicalPlan::Sort(sort) => self.lower_sort(sort), + LogicalPlan::Limit(limit) => self.lower_limit(limit), + LogicalPlan::Window(window) => self.lower_window(window), + LogicalPlan::Distinct(d) => { + let input = match d { + Distinct::All(input) => input.as_ref(), + Distinct::On(on) => on.input.as_ref(), + }; + let child = self.lower_plan(input)?; + Ok(QueryExpr::Distinct { child: make_node(child), cols: vec![] }) + } + LogicalPlan::Join(_) => { + Err(LoweringError::UnsupportedFeature("JOIN".into())) + } + LogicalPlan::Subquery(_) => { + Err(LoweringError::UnsupportedFeature("subquery".into())) + } + LogicalPlan::SubqueryAlias(alias) => { + // Simple table alias (wraps only a TableScan or another alias) is + // transparent. A derived table (wraps Projection, Aggregate, etc.) + // is an inline-view subquery — unsupported in v1. + match alias.input.as_ref() { + LogicalPlan::TableScan(_) | LogicalPlan::SubqueryAlias(_) => { + self.lower_plan(&alias.input) + } + _ => Err(LoweringError::UnsupportedFeature( + "subquery (inline view / derived table)".into(), + )), + } + } + other => Err(LoweringError::UnsupportedFeature(format!( + "plan node: {}", + other.display() + ))), + } + } + + fn lower_table_scan( + &self, + scan: &logical_expr::TableScan, + ) -> Result { + let table_name = scan.table_name.to_string(); + if !self.catalog.tables.contains_key(&table_name) { + return Err(LoweringError::TableNotFound(table_name)); + } + Ok(QueryExpr::Scan { + source: Source::Table { + table_ref: TableRef(table_name), + columns: vec![], + time_range: None, + }, + predicates: vec![], + }) + } + + fn lower_filter(&self, filter: &logical_expr::Filter) -> Result { + // When the direct child is a TableScan and the table has a time column, + // split the predicate: time bounds go into Source::Table.time_range; the + // rest stays as a Filter node on top. + let inner = strip_aliases(&filter.input); + if let LogicalPlan::TableScan(scan) = inner { + let table_name = scan.table_name.to_string(); + if let Some(schema) = self.catalog.tables.get(&table_name) { + if let Some(time_col) = &schema.time_column { + let (time_range, has_non_time) = + extract_time_range(&filter.predicate, time_col); + let scan_expr = QueryExpr::Scan { + source: Source::Table { + table_ref: TableRef(table_name), + columns: vec![], + time_range, + }, + predicates: vec![], + }; + return if has_non_time { + Ok(QueryExpr::Filter { child: make_node(scan_expr), pred: Predicate }) + } else { + Ok(scan_expr) + }; + } + } + } + + let child = self.lower_plan(&filter.input)?; + Ok(QueryExpr::Filter { child: make_node(child), pred: Predicate }) + } + + fn lower_projection( + &self, + proj: &logical_expr::Projection, + ) -> Result { + let child = self.lower_plan(&proj.input)?; + let cols = proj.expr.iter().map(|_| ProjectItem).collect(); + Ok(QueryExpr::Project { child: make_node(child), cols }) + } + + fn lower_aggregate( + &self, + agg: &logical_expr::Aggregate, + ) -> Result { + let child = self.lower_plan(&agg.input)?; + let by = agg + .group_expr + .iter() + .map(expr_to_group_key) + .collect::, _>>()?; + let aggs = agg + .aggr_expr + .iter() + .map(|e| self.lower_agg_expr(e)) + .collect::, _>>()?; + Ok(QueryExpr::Aggregate { child: make_node(child), by, aggs, having: None }) + } + + fn lower_sort(&self, sort: &logical_expr::Sort) -> Result { + // TopK: Sort with a constant LIMIT folded in, all keys descending, on an Aggregate. + // Note: Sort.fetch is Option; Limit.fetch is Option>. + if let Some(k) = sort.fetch { + if sort.expr.iter().all(|s| !s.asc) { + if let Some(agg) = find_aggregate(strip_projections_and_aliases(&sort.input)) { + return self.lower_as_topk(agg, k); + } + } + } + let child = self.lower_plan(&sort.input)?; + Ok(QueryExpr::Sort { + child: make_node(child), + keys: sort.expr.iter().map(|_| SortKey).collect(), + }) + } + + fn lower_limit(&self, limit: &logical_expr::Limit) -> Result { + // TopK: Limit on top of Sort on top of Aggregate, all sort keys DESC. + if let Some(k) = eval_fetch(&limit.fetch) { + let inner = strip_aliases(&limit.input); + if let LogicalPlan::Sort(sort) = inner { + if sort.expr.iter().all(|s| !s.asc) { + if let Some(agg) = + find_aggregate(strip_projections_and_aliases(&sort.input)) + { + return self.lower_as_topk(agg, k); + } + } + } + } + let child = self.lower_plan(&limit.input)?; + Ok(QueryExpr::Limit { + child: make_node(child), + n: eval_fetch(&limit.fetch).unwrap_or(0) as u64, + offset: eval_fetch(&limit.skip).unwrap_or(0) as u64, + }) + } + + fn lower_as_topk( + &self, + agg: &logical_expr::Aggregate, + k: usize, + ) -> Result { + let child = self.lower_plan(&agg.input)?; + let by = agg + .group_expr + .iter() + .map(expr_to_col_ref) + .collect::, _>>()?; + Ok(QueryExpr::Aggregate { + child: make_node(child), + by: vec![], + aggs: vec![AggIntent::TopK { k, by, accuracy: self.accuracy.clone() }], + having: None, + }) + } + + fn lower_window(&self, window: &logical_expr::Window) -> Result { + let child = self.lower_plan(&window.input)?; + let first = window + .window_expr + .first() + .ok_or_else(|| LoweringError::InvalidExpression("empty window expressions".into()))?; + if let Expr::WindowFunction(wf) = first { + let func = lower_window_func_kind(&wf.fun)?; + let partition_by = wf + .partition_by + .iter() + .map(expr_to_group_key) + .collect::, _>>()?; + return Ok(QueryExpr::WindowFunc { + child: make_node(child), + func, + partition_by, + order_by: wf.order_by.iter().map(|_| SortKey).collect(), + frame: None, + }); + } + Err(LoweringError::InvalidExpression("expected WindowFunction expr".into())) + } + + fn lower_agg_expr(&self, expr: &Expr) -> Result { + match expr { + Expr::AggregateFunction(agg_fn) => { + let name = agg_fn.func.name().to_lowercase(); + match name.as_str() { + "count" if agg_fn.distinct => { + Ok(AggIntent::Cardinality { accuracy: self.accuracy.clone() }) + } + "count" => Ok(AggIntent::Count { accuracy: self.accuracy.clone() }), + "sum" => Ok(AggIntent::Sum), + "min" => Ok(AggIntent::Min), + "max" => Ok(AggIntent::Max), + "avg" | "mean" => Ok(AggIntent::Avg), + "stddev" | "stddev_samp" => Ok(AggIntent::Stddev { population: false }), + "stddev_pop" => Ok(AggIntent::Stddev { population: true }), + "approx_percentile_cont" | "percentile_cont" => { + let q = extract_percentile_q(&agg_fn.args)?; + Ok(AggIntent::Quantile { q, accuracy: self.accuracy.clone() }) + } + "approx_distinct" => { + Ok(AggIntent::Cardinality { accuracy: self.accuracy.clone() }) + } + _ => Err(LoweringError::UnsupportedAggregate(name)), + } + } + Expr::Alias(alias) => self.lower_agg_expr(&alias.expr), + _ => Err(LoweringError::UnsupportedAggregate(format!("{expr:?}"))), + } + } +} + +// ── Free helpers ────────────────────────────────────────────────────────────── + +fn make_node(expr: QueryExpr) -> Rc { + Rc::new(L3Node { expr, schema: L3Schema { fields: vec![], time_index: None } }) +} + +/// Evaluate a constant fetch/skip expression to a `usize`. +/// Returns `None` for parametric (non-literal) fetch expressions. +fn eval_fetch(expr_opt: &Option>) -> Option { + expr_opt.as_ref().and_then(|e| match e.as_ref() { + Expr::Literal(ScalarValue::Int64(Some(v))) => Some(*v as usize), + Expr::Literal(ScalarValue::UInt64(Some(v))) => Some(*v as usize), + Expr::Literal(ScalarValue::Int32(Some(v))) => Some(*v as usize), + _ => None, + }) +} + +fn strip_aliases(plan: &LogicalPlan) -> &LogicalPlan { + match plan { + LogicalPlan::SubqueryAlias(a) => strip_aliases(&a.input), + _ => plan, + } +} + +/// Strip Projection and SubqueryAlias for TopK pattern-matching only. +/// Do NOT use when building the output tree. +fn strip_projections_and_aliases(plan: &LogicalPlan) -> &LogicalPlan { + match plan { + LogicalPlan::SubqueryAlias(a) => strip_projections_and_aliases(&a.input), + LogicalPlan::Projection(p) => strip_projections_and_aliases(&p.input), + _ => plan, + } +} + +fn find_aggregate(plan: &LogicalPlan) -> Option<&logical_expr::Aggregate> { + match plan { + LogicalPlan::Aggregate(agg) => Some(agg), + LogicalPlan::Projection(p) => find_aggregate(&p.input), + LogicalPlan::SubqueryAlias(a) => find_aggregate(&a.input), + _ => None, + } +} + +fn expr_to_group_key(expr: &Expr) -> Result { + match expr { + Expr::Column(col) => Ok(GroupKey(col.name.clone())), + Expr::Alias(a) => expr_to_group_key(&a.expr), + _ => Ok(GroupKey(format!("{expr}"))), + } +} + +fn expr_to_col_ref(expr: &Expr) -> Result { + match expr { + Expr::Column(col) => Ok(ColumnRef(col.name.clone())), + Expr::Alias(a) => expr_to_col_ref(&a.expr), + _ => Ok(ColumnRef(format!("{expr}"))), + } +} + +fn extract_percentile_q(args: &[Expr]) -> Result { + let val = args.get(1).ok_or_else(|| { + LoweringError::InvalidExpression("percentile requires 2 arguments".into()) + })?; + match val { + Expr::Literal(ScalarValue::Float64(Some(q))) => Ok(*q), + Expr::Literal(ScalarValue::Float32(Some(q))) => Ok(*q as f64), + _ => Err(LoweringError::InvalidExpression( + "percentile value must be a float literal".into(), + )), + } +} + +fn split_conjuncts(expr: &Expr) -> Vec<&Expr> { + match expr { + Expr::BinaryExpr(BinaryExpr { left, op: Operator::And, right }) => { + let mut v = split_conjuncts(left); + v.extend(split_conjuncts(right)); + v + } + _ => vec![expr], + } +} + +/// Returns `(time_range, has_non_time_predicates)`. +pub(crate) fn extract_time_range(expr: &Expr, time_col: &str) -> (Option, bool) { + let conjuncts = split_conjuncts(expr); + let mut start_ms: Option = None; + let mut end_ms: Option = None; + let mut has_non_time = false; + + for c in conjuncts { + match classify_time_pred(c, time_col) { + TimeClass::Start(ms) => { + start_ms = Some(start_ms.map_or(ms, |s: i64| s.max(ms))); + } + TimeClass::End(ms) => { + end_ms = Some(end_ms.map_or(ms, |e: i64| e.min(ms))); + } + TimeClass::NonTime => has_non_time = true, + } + } + + let range = if start_ms.is_some() || end_ms.is_some() { + Some(TimeRange { start_ms, end_ms }) + } else { + None + }; + (range, has_non_time) +} + +enum TimeClass { + Start(i64), + End(i64), + NonTime, +} + +fn classify_time_pred(expr: &Expr, time_col: &str) -> TimeClass { + let Expr::BinaryExpr(BinaryExpr { left, op, right }) = expr else { + return TimeClass::NonTime; + }; + let (col_is_left, val_expr): (bool, &Expr) = if is_time_col(left, time_col) { + (true, right) + } else if is_time_col(right, time_col) { + (false, left) + } else { + return TimeClass::NonTime; + }; + let Some(ms) = expr_to_ms(val_expr) else { + return TimeClass::NonTime; + }; + match (op, col_is_left) { + (Operator::Gt | Operator::GtEq, true) | (Operator::Lt | Operator::LtEq, false) => { + TimeClass::Start(ms) + } + (Operator::Lt | Operator::LtEq, true) | (Operator::Gt | Operator::GtEq, false) => { + TimeClass::End(ms) + } + _ => TimeClass::NonTime, + } +} + +fn is_time_col(expr: &Expr, time_col: &str) -> bool { + match expr { + Expr::Column(col) => col.name == time_col, + Expr::Cast(c) => is_time_col(&c.expr, time_col), + _ => false, + } +} + +fn expr_to_ms(expr: &Expr) -> Option { + match expr { + Expr::Literal(sv) => scalar_to_ms(sv), + Expr::Cast(c) => expr_to_ms(&c.expr), + Expr::TryCast(c) => expr_to_ms(&c.expr), + _ => None, + } +} + +fn scalar_to_ms(sv: &ScalarValue) -> Option { + match sv { + ScalarValue::Int64(Some(v)) => Some(*v), + ScalarValue::Int32(Some(v)) => Some(*v as i64), + ScalarValue::TimestampMillisecond(Some(ms), _) => Some(*ms), + ScalarValue::TimestampNanosecond(Some(ns), _) => Some(*ns / 1_000_000), + ScalarValue::TimestampMicrosecond(Some(us), _) => Some(*us / 1_000), + ScalarValue::TimestampSecond(Some(s), _) => Some(*s * 1_000), + _ => None, + } +} + +fn table_schema_to_arrow(schema: &TableSchema) -> Schema { + let fields: Fields = schema + .columns + .iter() + .map(|c| Field::new(&c.name, l3_to_arrow(&c.data_type), c.nullable)) + .collect(); + Schema::new(fields) +} + +fn l3_to_arrow(dt: &L3DataType) -> ArrowDataType { + match dt { + L3DataType::Int64 => ArrowDataType::Int64, + L3DataType::Float64 => ArrowDataType::Float64, + L3DataType::Utf8 => ArrowDataType::Utf8, + L3DataType::Boolean => ArrowDataType::Boolean, + L3DataType::Timestamp => ArrowDataType::Timestamp(TimeUnit::Millisecond, None), + L3DataType::Duration => ArrowDataType::Duration(TimeUnit::Millisecond), + L3DataType::Map(k, v) => ArrowDataType::Map( + Arc::new(Field::new( + "entries", + ArrowDataType::Struct(Fields::from(vec![ + Field::new("key", l3_to_arrow(k), false), + Field::new("value", l3_to_arrow(v), true), + ])), + false, + )), + false, + ), + L3DataType::List(item) => { + ArrowDataType::List(Arc::new(Field::new("item", l3_to_arrow(item), true))) + } + } +} + +fn lower_window_func_kind( + fun: &WindowFunctionDefinition, +) -> Result { + match fun { + // In DataFusion 43 most ranking/nav window functions are WindowUDF. + WindowFunctionDefinition::WindowUDF(udf) => { + match udf.name().to_lowercase().as_str() { + "row_number" => Ok(WindowFuncKind::RowNumber), + "rank" => Ok(WindowFuncKind::Rank), + "dense_rank" => Ok(WindowFuncKind::DenseRank), + "lag" => Ok(WindowFuncKind::Lag), + "lead" => Ok(WindowFuncKind::Lead), + "first_value" => Ok(WindowFuncKind::FirstValue), + "last_value" => Ok(WindowFuncKind::LastValue), + "nth_value" => Ok(WindowFuncKind::NthValue(0)), + other => { + Err(LoweringError::UnsupportedFeature(format!("window fn: {other}"))) + } + } + } + WindowFunctionDefinition::AggregateUDF(udf) => { + match udf.name().to_lowercase().as_str() { + "sum" => Ok(WindowFuncKind::Sum), + "avg" | "mean" => Ok(WindowFuncKind::Avg), + "count" => Ok(WindowFuncKind::Count), + "min" => Ok(WindowFuncKind::Min), + "max" => Ok(WindowFuncKind::Max), + other => { + Err(LoweringError::UnsupportedFeature(format!("window agg: {other}"))) + } + } + } + WindowFunctionDefinition::BuiltInWindowFunction(biwf) => { + Err(LoweringError::UnsupportedFeature(format!("built-in window fn: {biwf:?}"))) + } + } +} diff --git a/crates/lower/tests/sql_lowering.rs b/crates/lower/tests/sql_lowering.rs new file mode 100644 index 00000000..6d3c4790 --- /dev/null +++ b/crates/lower/tests/sql_lowering.rs @@ -0,0 +1,413 @@ +use std::collections::HashMap; + +use asap_control_core::intent_algebra::expr::{AggIntent, QueryExpr, Source}; +use asap_control_core::intent_algebra::schema::{ColumnDef, L3DataType, SchemaCatalog, TableSchema}; +use asap_control_core::types::AccuracyTarget; +use asap_control_lower::{LoweringError, SqlLowerer}; + +// ── Catalog helpers ─────────────────────────────────────────────────────────── + +fn metrics_catalog() -> SchemaCatalog { + let mut tables = HashMap::new(); + tables.insert( + "metrics".to_string(), + TableSchema { + columns: vec![ + ColumnDef { name: "ts".to_string(), data_type: L3DataType::Int64, nullable: false }, + ColumnDef { + name: "value".to_string(), + data_type: L3DataType::Float64, + nullable: true, + }, + ColumnDef { + name: "region".to_string(), + data_type: L3DataType::Utf8, + nullable: true, + }, + ColumnDef { + name: "host".to_string(), + data_type: L3DataType::Utf8, + nullable: true, + }, + ], + time_column: Some("ts".to_string()), + }, + ); + SchemaCatalog { tables } +} + +fn no_time_catalog() -> SchemaCatalog { + let mut tables = HashMap::new(); + tables.insert( + "events".to_string(), + TableSchema { + columns: vec![ + ColumnDef { name: "id".to_string(), data_type: L3DataType::Int64, nullable: false }, + ColumnDef { + name: "value".to_string(), + data_type: L3DataType::Float64, + nullable: true, + }, + ], + time_column: None, + }, + ); + SchemaCatalog { tables } +} + +// ── Tree-walking helpers ────────────────────────────────────────────────────── + +/// Walk through Project/Filter/Sort/Limit wrappers to find the first Aggregate. +fn find_aggregate(expr: &QueryExpr) -> Option<(&Vec, &Vec)> { + match expr { + QueryExpr::Aggregate { by, aggs, .. } => Some((by, aggs)), + QueryExpr::Project { child, .. } + | QueryExpr::Filter { child, .. } + | QueryExpr::Sort { child, .. } + | QueryExpr::Limit { child, .. } => find_aggregate(&child.expr), + _ => None, + } +} + +/// Walk through wrappers to find the first Scan source. +fn find_source(expr: &QueryExpr) -> Option<&Source> { + match expr { + QueryExpr::Scan { source, .. } => Some(source), + QueryExpr::Project { child, .. } + | QueryExpr::Filter { child, .. } + | QueryExpr::Aggregate { child, .. } + | QueryExpr::Sort { child, .. } + | QueryExpr::Limit { child, .. } => find_source(&child.expr), + _ => None, + } +} + +// ── Tests: Scan / Projection ────────────────────────────────────────────────── + +#[tokio::test] +async fn test_scan_table_name() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer.lower("SELECT ts, value FROM metrics").await.unwrap(); + + let source = find_source(&result).expect("expected a Scan node"); + let Source::Table { table_ref, .. } = source else { + panic!("expected Source::Table, got {source:?}"); + }; + assert_eq!(table_ref.0, "metrics"); +} + +#[tokio::test] +async fn test_projection_wraps_scan() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer.lower("SELECT ts, value FROM metrics").await.unwrap(); + + // DataFusion always emits a Projection for explicit SELECT lists. + let QueryExpr::Project { cols, child } = result else { + panic!("expected Project at root, got {:?}", result); + }; + assert_eq!(cols.len(), 2); + assert!(matches!(child.expr, QueryExpr::Scan { .. })); +} + +// ── Tests: Filter / time extraction ────────────────────────────────────────── + +#[tokio::test] +async fn test_non_time_filter_stays_as_filter() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + // region is not the time column → stays as Filter + let result = lowerer + .lower("SELECT ts FROM metrics WHERE region = 'us-east'") + .await + .unwrap(); + + fn has_filter(e: &QueryExpr) -> bool { + match e { + QueryExpr::Filter { .. } => true, + QueryExpr::Project { child, .. } => has_filter(&child.expr), + _ => false, + } + } + assert!(has_filter(&result), "expected a Filter node"); +} + +#[tokio::test] +async fn test_time_predicate_extracted_to_source() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + // ts is the time column → extracted into Source::Table.time_range + let result = lowerer + .lower("SELECT value FROM metrics WHERE ts > 1000 AND ts < 2000") + .await + .unwrap(); + + let source = find_source(&result).unwrap(); + let Source::Table { time_range, .. } = source else { + panic!("expected Source::Table"); + }; + let tr = time_range.as_ref().expect("expected time_range to be Some"); + assert_eq!(tr.start_ms, Some(1000)); + assert_eq!(tr.end_ms, Some(2000)); +} + +#[tokio::test] +async fn test_time_predicate_no_filter_wrapper() { + // When ALL predicates are time bounds, no Filter node should appear. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT value FROM metrics WHERE ts > 1000 AND ts < 2000") + .await + .unwrap(); + + fn has_filter(e: &QueryExpr) -> bool { + match e { + QueryExpr::Filter { .. } => true, + QueryExpr::Project { child, .. } => has_filter(&child.expr), + _ => false, + } + } + assert!(!has_filter(&result), "expected no Filter node when only time predicates"); +} + +#[tokio::test] +async fn test_mixed_filter_keeps_filter_and_time_range() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT value FROM metrics WHERE ts > 1000 AND region = 'eu'") + .await + .unwrap(); + + // The Source should have a time_range... + let source = find_source(&result).unwrap(); + let Source::Table { time_range, .. } = source else { + panic!("expected Source::Table"); + }; + assert!(time_range.is_some(), "expected time_range extracted"); + + // ...and there should also be a Filter node for the non-time predicate. + fn has_filter(e: &QueryExpr) -> bool { + match e { + QueryExpr::Filter { .. } => true, + QueryExpr::Project { child, .. } => has_filter(&child.expr), + _ => false, + } + } + assert!(has_filter(&result), "expected Filter node for non-time predicate"); +} + +// ── Tests: Aggregates ───────────────────────────────────────────────────────── + +#[tokio::test] +async fn test_count_star_exact() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer.lower("SELECT COUNT(*) FROM metrics").await.unwrap(); + + let (by, aggs) = find_aggregate(&result).expect("expected Aggregate"); + assert!(by.is_empty(), "no GROUP BY expected"); + assert_eq!(aggs.len(), 1); + assert!(matches!(aggs[0], AggIntent::Count { accuracy: AccuracyTarget::Exact })); +} + +#[tokio::test] +async fn test_count_inherits_accuracy() { + let catalog = metrics_catalog(); + let lowerer = + SqlLowerer::new(&catalog, AccuracyTarget::Epsilon(0.01)); + let result = lowerer.lower("SELECT COUNT(*) FROM metrics").await.unwrap(); + + let (_, aggs) = find_aggregate(&result).unwrap(); + assert!(matches!(aggs[0], AggIntent::Count { accuracy: AccuracyTarget::Epsilon(e) } if (e - 0.01).abs() < 1e-12)); +} + +#[tokio::test] +async fn test_sum_aggregate() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer.lower("SELECT SUM(value) FROM metrics").await.unwrap(); + + let (_, aggs) = find_aggregate(&result).unwrap(); + assert_eq!(aggs.len(), 1); + assert!(matches!(aggs[0], AggIntent::Sum)); +} + +#[tokio::test] +async fn test_min_max_aggregates() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = + lowerer.lower("SELECT MIN(value), MAX(value) FROM metrics").await.unwrap(); + + let (_, aggs) = find_aggregate(&result).unwrap(); + assert_eq!(aggs.len(), 2); + assert!(aggs.iter().any(|a| matches!(a, AggIntent::Min))); + assert!(aggs.iter().any(|a| matches!(a, AggIntent::Max))); +} + +#[tokio::test] +async fn test_avg_aggregate() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer.lower("SELECT AVG(value) FROM metrics").await.unwrap(); + + let (_, aggs) = find_aggregate(&result).unwrap(); + assert!(aggs.iter().any(|a| matches!(a, AggIntent::Avg))); +} + +#[tokio::test] +async fn test_stddev_sample() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer.lower("SELECT STDDEV(value) FROM metrics").await.unwrap(); + + let (_, aggs) = find_aggregate(&result).unwrap(); + assert!(aggs + .iter() + .any(|a| matches!(a, AggIntent::Stddev { population: false }))); +} + +#[tokio::test] +async fn test_group_by_extracted() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = + lowerer.lower("SELECT region, COUNT(*) FROM metrics GROUP BY region").await.unwrap(); + + let (by, aggs) = find_aggregate(&result).unwrap(); + assert_eq!(by.len(), 1); + assert_eq!(by[0].0, "region"); + assert!(matches!(aggs[0], AggIntent::Count { .. })); +} + +#[tokio::test] +async fn test_multiple_aggregates_in_one_node() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT COUNT(*), SUM(value), MIN(value), MAX(value) FROM metrics") + .await + .unwrap(); + + let (_, aggs) = find_aggregate(&result).unwrap(); + assert_eq!(aggs.len(), 4); +} + +// ── Tests: Approximate intents ──────────────────────────────────────────────── + +#[tokio::test] +async fn test_count_distinct_becomes_cardinality() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = + lowerer.lower("SELECT COUNT(DISTINCT host) FROM metrics").await.unwrap(); + + let (_, aggs) = find_aggregate(&result).unwrap(); + assert!(aggs.iter().any(|a| matches!(a, AggIntent::Cardinality { .. }))); +} + +#[tokio::test] +async fn test_approx_percentile_becomes_quantile() { + let catalog = metrics_catalog(); + let lowerer = + SqlLowerer::new(&catalog, AccuracyTarget::EpsilonDelta { epsilon: 0.01, delta: 0.001 }); + let result = lowerer + .lower("SELECT approx_percentile_cont(value, 0.99) FROM metrics") + .await + .unwrap(); + + let (_, aggs) = find_aggregate(&result).unwrap(); + assert!(aggs.iter().any(|a| matches!(a, AggIntent::Quantile { q, .. } if (*q - 0.99).abs() < 1e-12))); +} + +// ── Test: TopK heavy-hitter pattern ────────────────────────────────────────── + +#[tokio::test] +async fn test_order_by_desc_limit_becomes_topk() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower( + "SELECT host, COUNT(*) AS cnt FROM metrics \ + GROUP BY host ORDER BY cnt DESC LIMIT 10", + ) + .await + .unwrap(); + + let (_, aggs) = find_aggregate(&result).expect("expected Aggregate"); + assert_eq!(aggs.len(), 1, "TopK should produce exactly one AggIntent"); + let AggIntent::TopK { k, by, .. } = &aggs[0] else { + panic!("expected TopK, got {:?}", aggs[0]); + }; + assert_eq!(*k, 10); + assert!(by.iter().any(|c| c.0 == "host")); +} + +// ── Test: Window functions ──────────────────────────────────────────────────── + +#[tokio::test] +async fn test_window_function_produces_window_func_node() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower( + "SELECT region, AVG(value) OVER (PARTITION BY region ORDER BY ts) \ + FROM metrics", + ) + .await + .unwrap(); + + fn has_window_func(e: &QueryExpr) -> bool { + match e { + QueryExpr::WindowFunc { .. } => true, + QueryExpr::Project { child, .. } => has_window_func(&child.expr), + _ => false, + } + } + assert!(has_window_func(&result), "expected a WindowFunc node"); +} + +// ── Tests: Error cases ──────────────────────────────────────────────────────── + +#[tokio::test] +async fn test_join_returns_error() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let err = lowerer + .lower("SELECT a.ts FROM metrics a JOIN metrics b ON a.ts = b.ts") + .await + .unwrap_err(); + assert!( + matches!(err, LoweringError::UnsupportedFeature(ref msg) if msg.contains("JOIN")), + "unexpected error: {err}" + ); +} + +#[tokio::test] +async fn test_subquery_returns_error() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let err = lowerer + .lower("SELECT * FROM (SELECT value FROM metrics) sub") + .await + .unwrap_err(); + assert!( + matches!(err, LoweringError::UnsupportedFeature(ref msg) if msg.to_lowercase().contains("subquery")), + "unexpected error: {err}" + ); +} + +#[tokio::test] +async fn test_unknown_table_returns_error() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let err = lowerer.lower("SELECT x FROM ghost_table").await.unwrap_err(); + // DataFusion will reject the unknown table during planning + assert!( + matches!(err, LoweringError::DataFusion(_) | LoweringError::TableNotFound(_)), + "unexpected error variant: {err}" + ); +} From 6334dcb9fe546fd03548b329102d3143c33e4d84 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sun, 17 May 2026 21:20:24 -0400 Subject: [PATCH 02/17] removed some stubs --- crates/core/src/intent_algebra/expr.rs | 130 ++++++- crates/core/src/intent_algebra/mod.rs | 2 +- crates/core/src/intent_algebra/schema.rs | 4 +- crates/core/tests/schema_derivation.rs | 468 +++++++++++++++++++++++ 4 files changed, 595 insertions(+), 9 deletions(-) create mode 100644 crates/core/tests/schema_derivation.rs diff --git a/crates/core/src/intent_algebra/expr.rs b/crates/core/src/intent_algebra/expr.rs index d08d8130..660164a0 100644 --- a/crates/core/src/intent_algebra/expr.rs +++ b/crates/core/src/intent_algebra/expr.rs @@ -175,12 +175,35 @@ impl AggIntent { /// Which data model this intent semantically requires. L4 rules consult /// this to skip non-applicable intents (e.g. `Rate` over a `Source::Table`). pub fn requires(&self) -> DataModel { - todo!() + match self { + Self::Rate { .. } | Self::Increase { .. } => DataModel::TimeSeries, + _ => DataModel::Any, + } } - /// Output column type — used by L3 schema derivation for `Aggregate`. - pub fn output_type(&self, _input: &super::schema::L3Field) -> super::schema::L3DataType { - todo!() + /// Output column type for a single-column aggregate result. + /// + /// `input` is the field being aggregated; used by `Min` and `Max` to + /// preserve the input type. For all other variants the input type is + /// ignored. + /// + /// **Do not call this for `TopK`** — TopK produces multiple output + /// columns; its schema is derived directly in `QueryExpr::output_schema`. + pub fn output_type(&self, input: &super::schema::L3Field) -> super::schema::L3DataType { + use super::schema::L3DataType; + match self { + Self::Count { .. } | Self::Cardinality { .. } => L3DataType::Int64, + Self::Min | Self::Max => input.dtype.clone(), + Self::Sum + | Self::Avg + | Self::Stddev { .. } + | Self::Quantile { .. } + | Self::Rate { .. } + | Self::Increase { .. } => L3DataType::Float64, + Self::TopK { .. } => { + panic!("TopK is multi-column; derive schema via QueryExpr::output_schema") + } + } } } @@ -310,7 +333,102 @@ pub enum QueryExpr { } impl HasSchema for QueryExpr { - fn output_schema(&self, _input_schemas: &[&L3Schema], _catalog: &SchemaCatalog) -> L3Schema { - todo!() + fn output_schema(&self, input_schemas: &[&L3Schema], catalog: &SchemaCatalog) -> L3Schema { + use super::schema::{L3Field, L3Schema}; + + // Shorthand: first child's schema (most nodes have exactly one child). + let child = || input_schemas[0]; + + match self { + // ── Leaf: Table scan — schema comes from the catalog ────────────── + QueryExpr::Scan { source, .. } => match source { + Source::Table { table_ref, .. } => { + let table = catalog + .tables + .get(&table_ref.0) + .unwrap_or_else(|| panic!("table '{}' not in catalog", table_ref.0)); + let fields: Vec = table + .columns + .iter() + .map(|c| L3Field { + name: c.name.clone(), + dtype: c.data_type.clone(), + nullable: c.nullable, + }) + .collect(); + let time_index = table.time_column.as_ref().and_then(|tc| { + fields.iter().position(|f| &f.name == tc) + }); + L3Schema { fields, time_index } + } + Source::TimeSeries { .. } | Source::Join { .. } => todo!( + "schema derivation for TimeSeries and Join sources (PromQL path)" + ), + }, + + // ── Pass-through: output schema == child schema ─────────────────── + QueryExpr::Filter { .. } + | QueryExpr::Sort { .. } + | QueryExpr::Limit { .. } + | QueryExpr::Distinct { .. } + | QueryExpr::Partition { .. } + | QueryExpr::TimeWindow { .. } => child().clone(), + + // ── Aggregate: GROUP BY cols + one output col per AggIntent ─────── + QueryExpr::Aggregate { by, aggs, .. } => { + let cs = child(); + + // TopK is the only multi-column AggIntent: produces the TopK + // by-columns looked up from the child schema, followed by a + // synthetic "count" Int64 column. + if let [AggIntent::TopK { by: topk_by, .. }] = aggs.as_slice() { + let mut fields: Vec = topk_by + .iter() + .filter_map(|col| { + cs.fields.iter().find(|f| f.name == col.0).cloned() + }) + .collect(); + fields.push(L3Field { + name: "count".to_string(), + dtype: super::schema::L3DataType::Int64, + nullable: false, + }); + return L3Schema { fields, time_index: None }; + } + + // General case: GROUP BY fields (preserving child type) followed + // by one output field per AggIntent. We use a Float64 dummy as + // the input field to output_type because L3 AggIntent does not + // track the aggregated column (known limitation; see TODO.md). + let dummy = L3Field { + name: String::new(), + dtype: super::schema::L3DataType::Float64, + nullable: true, + }; + let by_fields: Vec = by + .iter() + .filter_map(|key| cs.fields.iter().find(|f| f.name == key.0).cloned()) + .collect(); + let agg_fields: Vec = aggs + .iter() + .enumerate() + .map(|(i, agg)| L3Field { + name: format!("agg_{i}"), + dtype: agg.output_type(&dummy), + nullable: true, + }) + .collect(); + L3Schema { + fields: by_fields.into_iter().chain(agg_fields).collect(), + time_index: None, + } + } + + // ── Everything else: not yet implemented ────────────────────────── + _ => todo!( + "output_schema not yet implemented for {:?}", + std::mem::discriminant(self) + ), + } } } diff --git a/crates/core/src/intent_algebra/mod.rs b/crates/core/src/intent_algebra/mod.rs index a5117cb3..bcc59b82 100644 --- a/crates/core/src/intent_algebra/mod.rs +++ b/crates/core/src/intent_algebra/mod.rs @@ -7,4 +7,4 @@ pub use expr::{ SortKey, Source, TableRef, TimeRange, TimeWindowKind, VectorMatch, WindowFrame, WindowFuncKind, }; -pub use schema::{HasSchema, L3DataType, L3Field, L3Schema, SchemaCatalog}; +pub use schema::{ColumnDef, HasSchema, L3DataType, L3Field, L3Schema, SchemaCatalog, TableSchema}; diff --git a/crates/core/src/intent_algebra/schema.rs b/crates/core/src/intent_algebra/schema.rs index b0abcbe3..53bb2622 100644 --- a/crates/core/src/intent_algebra/schema.rs +++ b/crates/core/src/intent_algebra/schema.rs @@ -46,7 +46,7 @@ pub enum L3DataType { // ── Schema ──────────────────────────────────────────────────────────────────── -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct L3Field { pub name: String, pub dtype: L3DataType, @@ -57,7 +57,7 @@ pub struct L3Field { /// flowing between two operators. Type-checked at plan construction time: /// a node whose predicate references a column absent from its child's /// `L3Schema` is a plan-time error. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct L3Schema { pub fields: Vec, /// Index into `fields` for the time axis, if any. diff --git a/crates/core/tests/schema_derivation.rs b/crates/core/tests/schema_derivation.rs new file mode 100644 index 00000000..899dabcc --- /dev/null +++ b/crates/core/tests/schema_derivation.rs @@ -0,0 +1,468 @@ +use std::collections::HashMap; +use std::rc::Rc; + +use asap_control_core::intent_algebra::{ + AggIntent, ColumnRef, DataModel, GroupKey, HasSchema, L3DataType, L3Field, L3Node, L3Schema, + Predicate, ProjectItem, QueryExpr, SortKey, Source, TableRef, TimeRange, ColumnDef, + SchemaCatalog, TableSchema, +}; +use asap_control_core::types::AccuracyTarget; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +fn field(name: &str, dtype: L3DataType) -> L3Field { + L3Field { name: name.to_string(), dtype, nullable: false } +} + +fn nullable_field(name: &str, dtype: L3DataType) -> L3Field { + L3Field { name: name.to_string(), dtype, nullable: true } +} + +fn schema(fields: Vec) -> L3Schema { + L3Schema { fields, time_index: None } +} + +fn schema_with_time(fields: Vec, time_index: usize) -> L3Schema { + L3Schema { fields, time_index: Some(time_index) } +} + +fn make_node(expr: QueryExpr, s: L3Schema) -> Rc { + Rc::new(L3Node { expr, schema: s }) +} + +fn empty_catalog() -> SchemaCatalog { + SchemaCatalog { tables: HashMap::new() } +} + +fn metrics_catalog() -> SchemaCatalog { + let mut tables = HashMap::new(); + tables.insert( + "metrics".to_string(), + TableSchema { + columns: vec![ + ColumnDef { name: "ts".to_string(), data_type: L3DataType::Int64, nullable: false }, + ColumnDef { + name: "value".to_string(), + data_type: L3DataType::Float64, + nullable: true, + }, + ColumnDef { + name: "region".to_string(), + data_type: L3DataType::Utf8, + nullable: true, + }, + ], + time_column: Some("ts".to_string()), + }, + ); + tables.insert( + "events".to_string(), + TableSchema { + columns: vec![ + ColumnDef { name: "id".to_string(), data_type: L3DataType::Int64, nullable: false }, + ColumnDef { + name: "name".to_string(), + data_type: L3DataType::Utf8, + nullable: true, + }, + ], + time_column: None, + }, + ); + SchemaCatalog { tables } +} + +fn exact() -> AccuracyTarget { + AccuracyTarget::Exact +} + +fn eps(e: f64) -> AccuracyTarget { + AccuracyTarget::Epsilon(e) +} + +// ── AggIntent::requires() ───────────────────────────────────────────────────── + +#[test] +fn requires_count_is_any() { + assert_eq!(AggIntent::Count { accuracy: exact() }.requires(), DataModel::Any); +} + +#[test] +fn requires_sum_is_any() { + assert_eq!(AggIntent::Sum.requires(), DataModel::Any); +} + +#[test] +fn requires_min_is_any() { + assert_eq!(AggIntent::Min.requires(), DataModel::Any); +} + +#[test] +fn requires_max_is_any() { + assert_eq!(AggIntent::Max.requires(), DataModel::Any); +} + +#[test] +fn requires_avg_is_any() { + assert_eq!(AggIntent::Avg.requires(), DataModel::Any); +} + +#[test] +fn requires_stddev_sample_is_any() { + assert_eq!(AggIntent::Stddev { population: false }.requires(), DataModel::Any); +} + +#[test] +fn requires_stddev_population_is_any() { + assert_eq!(AggIntent::Stddev { population: true }.requires(), DataModel::Any); +} + +#[test] +fn requires_quantile_is_any() { + assert_eq!(AggIntent::Quantile { q: 0.99, accuracy: exact() }.requires(), DataModel::Any); +} + +#[test] +fn requires_cardinality_is_any() { + assert_eq!(AggIntent::Cardinality { accuracy: exact() }.requires(), DataModel::Any); +} + +#[test] +fn requires_topk_is_any() { + assert_eq!( + AggIntent::TopK { k: 10, by: vec![], accuracy: exact() }.requires(), + DataModel::Any + ); +} + +#[test] +fn requires_rate_is_timeseries() { + assert_eq!( + AggIntent::Rate { window: std::time::Duration::from_secs(60) }.requires(), + DataModel::TimeSeries + ); +} + +#[test] +fn requires_increase_is_timeseries() { + assert_eq!( + AggIntent::Increase { window: std::time::Duration::from_secs(300) }.requires(), + DataModel::TimeSeries + ); +} + +// ── AggIntent::output_type() ────────────────────────────────────────────────── + +#[test] +fn output_type_count_is_int64() { + let f = field("x", L3DataType::Float64); + assert_eq!(AggIntent::Count { accuracy: exact() }.output_type(&f), L3DataType::Int64); +} + +#[test] +fn output_type_count_ignores_input_type() { + // Count is always Int64 regardless of the aggregated column's type. + let f = field("x", L3DataType::Utf8); + assert_eq!(AggIntent::Count { accuracy: eps(0.01) }.output_type(&f), L3DataType::Int64); +} + +#[test] +fn output_type_cardinality_is_int64() { + let f = field("host", L3DataType::Utf8); + assert_eq!(AggIntent::Cardinality { accuracy: exact() }.output_type(&f), L3DataType::Int64); +} + +#[test] +fn output_type_sum_is_float64() { + let f = field("value", L3DataType::Float64); + assert_eq!(AggIntent::Sum.output_type(&f), L3DataType::Float64); +} + +#[test] +fn output_type_avg_is_float64() { + let f = field("value", L3DataType::Int64); + assert_eq!(AggIntent::Avg.output_type(&f), L3DataType::Float64); +} + +#[test] +fn output_type_stddev_sample_is_float64() { + let f = field("value", L3DataType::Float64); + assert_eq!(AggIntent::Stddev { population: false }.output_type(&f), L3DataType::Float64); +} + +#[test] +fn output_type_stddev_population_is_float64() { + let f = field("value", L3DataType::Float64); + assert_eq!(AggIntent::Stddev { population: true }.output_type(&f), L3DataType::Float64); +} + +#[test] +fn output_type_quantile_is_float64() { + let f = field("latency", L3DataType::Float64); + assert_eq!(AggIntent::Quantile { q: 0.5, accuracy: exact() }.output_type(&f), L3DataType::Float64); +} + +#[test] +fn output_type_rate_is_float64() { + let f = field("bytes", L3DataType::Float64); + assert_eq!( + AggIntent::Rate { window: std::time::Duration::from_secs(60) }.output_type(&f), + L3DataType::Float64 + ); +} + +#[test] +fn output_type_increase_is_float64() { + let f = field("counter", L3DataType::Float64); + assert_eq!( + AggIntent::Increase { window: std::time::Duration::from_secs(60) }.output_type(&f), + L3DataType::Float64 + ); +} + +#[test] +fn output_type_min_preserves_int64_input() { + let f = field("count", L3DataType::Int64); + assert_eq!(AggIntent::Min.output_type(&f), L3DataType::Int64); +} + +#[test] +fn output_type_min_preserves_float64_input() { + let f = field("value", L3DataType::Float64); + assert_eq!(AggIntent::Min.output_type(&f), L3DataType::Float64); +} + +#[test] +fn output_type_max_preserves_utf8_input() { + let f = field("name", L3DataType::Utf8); + assert_eq!(AggIntent::Max.output_type(&f), L3DataType::Utf8); +} + +// ── HasSchema::output_schema() — Scan ───────────────────────────────────────── + +#[test] +fn scan_schema_columns_from_catalog() { + let catalog = metrics_catalog(); + let scan = QueryExpr::Scan { + source: Source::Table { + table_ref: TableRef("metrics".into()), + columns: vec![], + time_range: None, + }, + predicates: vec![], + }; + let s = scan.output_schema(&[], &catalog); + assert_eq!(s.fields.len(), 3); + assert_eq!(s.fields[0].name, "ts"); + assert_eq!(s.fields[0].dtype, L3DataType::Int64); + assert_eq!(s.fields[1].name, "value"); + assert_eq!(s.fields[2].name, "region"); +} + +#[test] +fn scan_schema_time_index_set_for_time_column() { + let catalog = metrics_catalog(); + let scan = QueryExpr::Scan { + source: Source::Table { + table_ref: TableRef("metrics".into()), + columns: vec![], + time_range: None, + }, + predicates: vec![], + }; + let s = scan.output_schema(&[], &catalog); + // "ts" is at index 0 and is the time_column + assert_eq!(s.time_index, Some(0)); +} + +#[test] +fn scan_schema_no_time_index_when_no_time_column() { + let catalog = metrics_catalog(); + let scan = QueryExpr::Scan { + source: Source::Table { + table_ref: TableRef("events".into()), + columns: vec![], + time_range: None, + }, + predicates: vec![], + }; + let s = scan.output_schema(&[], &catalog); + assert_eq!(s.time_index, None); + assert_eq!(s.fields.len(), 2); +} + +// ── HasSchema::output_schema() — pass-through nodes ────────────────────────── + +fn child_schema() -> L3Schema { + schema(vec![field("ts", L3DataType::Int64), nullable_field("value", L3DataType::Float64)]) +} + +fn dummy_scan(s: L3Schema) -> Rc { + make_node( + QueryExpr::Scan { + source: Source::Table { + table_ref: TableRef("metrics".into()), + columns: vec![], + time_range: None, + }, + predicates: vec![], + }, + s, + ) +} + +#[test] +fn filter_passes_through_child_schema() { + let cs = child_schema(); + let node = QueryExpr::Filter { child: dummy_scan(cs.clone()), pred: Predicate }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.fields, cs.fields); + assert_eq!(out.time_index, cs.time_index); +} + +#[test] +fn sort_passes_through_child_schema() { + let cs = child_schema(); + let node = QueryExpr::Sort { child: dummy_scan(cs.clone()), keys: vec![SortKey] }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.fields, cs.fields); +} + +#[test] +fn limit_passes_through_child_schema() { + let cs = child_schema(); + let node = QueryExpr::Limit { child: dummy_scan(cs.clone()), n: 10, offset: 0 }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.fields, cs.fields); +} + +#[test] +fn distinct_passes_through_child_schema() { + let cs = child_schema(); + let node = QueryExpr::Distinct { child: dummy_scan(cs.clone()), cols: vec![] }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.fields, cs.fields); +} + +// ── HasSchema::output_schema() — Aggregate ──────────────────────────────────── + +fn metrics_child_schema() -> L3Schema { + schema(vec![ + field("ts", L3DataType::Int64), + nullable_field("value", L3DataType::Float64), + nullable_field("region", L3DataType::Utf8), + nullable_field("host", L3DataType::Utf8), + ]) +} + +#[test] +fn aggregate_count_star_no_group_by() { + // SELECT COUNT(*) FROM metrics + let cs = metrics_child_schema(); + let node = QueryExpr::Aggregate { + child: dummy_scan(cs.clone()), + by: vec![], + aggs: vec![AggIntent::Count { accuracy: exact() }], + having: None, + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.fields.len(), 1); + assert_eq!(out.fields[0].dtype, L3DataType::Int64); +} + +#[test] +fn aggregate_group_by_adds_by_cols_first() { + // SELECT region, COUNT(*) FROM metrics GROUP BY region + let cs = metrics_child_schema(); + let node = QueryExpr::Aggregate { + child: dummy_scan(cs.clone()), + by: vec![GroupKey("region".into())], + aggs: vec![AggIntent::Count { accuracy: exact() }], + having: None, + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + // region col + count col + assert_eq!(out.fields.len(), 2); + assert_eq!(out.fields[0].name, "region"); + assert_eq!(out.fields[0].dtype, L3DataType::Utf8); + assert_eq!(out.fields[1].dtype, L3DataType::Int64); +} + +#[test] +fn aggregate_multiple_aggs() { + // SELECT COUNT(*), SUM(value), MIN(value) FROM metrics + let cs = metrics_child_schema(); + let node = QueryExpr::Aggregate { + child: dummy_scan(cs.clone()), + by: vec![], + aggs: vec![AggIntent::Count { accuracy: exact() }, AggIntent::Sum, AggIntent::Min], + having: None, + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.fields.len(), 3); + assert_eq!(out.fields[0].dtype, L3DataType::Int64); // Count + assert_eq!(out.fields[1].dtype, L3DataType::Float64); // Sum + // Min: uses dummy Float64 input (known limitation — column not tracked at L3) + assert_eq!(out.fields[2].dtype, L3DataType::Float64); +} + +#[test] +fn aggregate_topk_produces_by_cols_plus_count() { + // TopK { k: 5, by: [host] } → [host(Utf8), count(Int64)] + let cs = metrics_child_schema(); + let node = QueryExpr::Aggregate { + child: dummy_scan(cs.clone()), + by: vec![], + aggs: vec![AggIntent::TopK { + k: 5, + by: vec![ColumnRef("host".into())], + accuracy: exact(), + }], + having: None, + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.fields.len(), 2); + assert_eq!(out.fields[0].name, "host"); + assert_eq!(out.fields[0].dtype, L3DataType::Utf8); + assert_eq!(out.fields[1].name, "count"); + assert_eq!(out.fields[1].dtype, L3DataType::Int64); +} + +#[test] +fn aggregate_topk_multi_key() { + // TopK { k: 10, by: [region, host] } → [region(Utf8), host(Utf8), count(Int64)] + let cs = metrics_child_schema(); + let node = QueryExpr::Aggregate { + child: dummy_scan(cs.clone()), + by: vec![], + aggs: vec![AggIntent::TopK { + k: 10, + by: vec![ColumnRef("region".into()), ColumnRef("host".into())], + accuracy: exact(), + }], + having: None, + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.fields.len(), 3); + assert_eq!(out.fields[0].name, "region"); + assert_eq!(out.fields[1].name, "host"); + assert_eq!(out.fields[2].name, "count"); + assert_eq!(out.fields[2].dtype, L3DataType::Int64); +} + +#[test] +fn aggregate_time_index_not_propagated() { + // Aggregating over a time-indexed child drops the time axis. + let cs = schema_with_time( + vec![field("ts", L3DataType::Int64), nullable_field("value", L3DataType::Float64)], + 0, + ); + let node = QueryExpr::Aggregate { + child: dummy_scan(cs.clone()), + by: vec![], + aggs: vec![AggIntent::Count { accuracy: exact() }], + having: None, + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.time_index, None); +} From c14ca8c4d4c0cc27ced0d30c839d526902edb301 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sun, 17 May 2026 21:54:03 -0400 Subject: [PATCH 03/17] added expression IR --- crates/core/src/intent_algebra/expr.rs | 25 ++- crates/core/src/intent_algebra/expr_ir.rs | 111 +++++++++++ crates/core/src/intent_algebra/mod.rs | 2 + crates/core/tests/expr_ir.rs | 207 ++++++++++++++++++++ crates/core/tests/schema_derivation.rs | 18 +- crates/lower/src/sql.rs | 224 ++++++++++++++++++++-- crates/lower/tests/sql_lowering.rs | 115 ++++++++++- 7 files changed, 674 insertions(+), 28 deletions(-) create mode 100644 crates/core/src/intent_algebra/expr_ir.rs create mode 100644 crates/core/tests/expr_ir.rs diff --git a/crates/core/src/intent_algebra/expr.rs b/crates/core/src/intent_algebra/expr.rs index 660164a0..22e251d7 100644 --- a/crates/core/src/intent_algebra/expr.rs +++ b/crates/core/src/intent_algebra/expr.rs @@ -2,23 +2,36 @@ use std::rc::Rc; use std::time::Duration; use crate::types::AccuracyTarget; +use super::expr_ir::L3Expr; use super::schema::{HasSchema, L3Schema, SchemaCatalog}; -// ── Stub leaf / supporting types ────────────────────────────────────────────── -// Full definitions will be added as the respective layers are implemented. +// ── Leaf / supporting types ─────────────────────────────────────────────────── /// A row-level filter predicate (WHERE clause / PromQL label matcher). -#[derive(Debug, Clone)] pub struct Predicate; +#[derive(Debug, Clone)] +pub struct Predicate(pub L3Expr); + /// One item in a SELECT projection list. -#[derive(Debug, Clone)] pub struct ProjectItem; +#[derive(Debug, Clone)] +pub struct ProjectItem { + pub expr: L3Expr, + pub alias: Option, +} + /// A GROUP BY key reference (column name). #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct GroupKey(pub String); /// A reference to a column by name. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct ColumnRef(pub String); /// A set of partitioning keys (sharding hint for L5 stage allocator). #[derive(Debug, Clone)] pub struct PartitionKeys; -/// One key in an ORDER BY clause. -#[derive(Debug, Clone)] pub struct SortKey; + +/// One key in an ORDER BY or window OVER clause. +#[derive(Debug, Clone)] +pub struct SortKey { + pub expr: L3Expr, + pub ascending: bool, + pub nulls_first: bool, +} /// An analytic window frame (ROWS / RANGE BETWEEN …). #[derive(Debug, Clone)] pub struct WindowFrame; /// PromQL vector-match modifiers (`on`/`ignoring` + `group_left`/`group_right`). diff --git a/crates/core/src/intent_algebra/expr_ir.rs b/crates/core/src/intent_algebra/expr_ir.rs new file mode 100644 index 00000000..e79119f1 --- /dev/null +++ b/crates/core/src/intent_algebra/expr_ir.rs @@ -0,0 +1,111 @@ +use super::expr::ColumnRef; +use super::schema::L3DataType; + +// ── Scalar literals ─────────────────────────────────────────────────────────── + +/// A typed scalar constant. Used in `L3Expr::Literal`. +#[derive(Debug, Clone, PartialEq)] +pub enum L3Scalar { + Int64(i64), + Float64(f64), + Utf8(String), + Boolean(bool), + Null, +} + +// ── Comparison operators ────────────────────────────────────────────────────── + +/// Binary comparison operators for `L3Expr::Compare`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CompareOp { + Eq, + Ne, + Lt, + Le, + Gt, + Ge, + Like, + NotLike, +} + +// ── Expression IR ───────────────────────────────────────────────────────────── + +/// A scalar expression used in filter predicates, projection lists, and sort +/// keys. Flat conjunctions (`BoolAnd`) and disjunctions (`BoolOr`) make +/// per-conjunct selectivity estimation and PromQL label-matcher lowering +/// straightforward without recursive descent. +#[derive(Debug, Clone, PartialEq)] +pub enum L3Expr { + /// Reference to a named column. + Column(ColumnRef), + /// A constant literal value. + Literal(L3Scalar), + /// `left op right` — binary comparison. + Compare { left: Box, op: CompareOp, right: Box }, + /// Flat conjunction (logical AND). An empty list is vacuously true. + BoolAnd(Vec), + /// Flat disjunction (logical OR). An empty list is vacuously false. + BoolOr(Vec), + /// Logical NOT. + Not(Box), + /// `expr IS NULL`. + IsNull(Box), + /// `expr IS NOT NULL`. + IsNotNull(Box), + /// `CAST(expr AS to)`. + Cast { expr: Box, to: L3DataType }, + /// `expr [NOT] IN (v1, v2, …)`. + InList { expr: Box, list: Vec, negated: bool }, + /// Scalar function call, e.g. `LOWER(col)`, `ABS(x)`. + FunctionCall { name: String, args: Vec }, +} + +impl L3Expr { + /// If this expression is a `BoolAnd`, return its elements. + /// Otherwise return a single-element slice containing `self`. + /// Lets callers iterate all top-level conjuncts without cloning. + pub fn conjuncts(&self) -> &[L3Expr] { + match self { + L3Expr::BoolAnd(v) => v.as_slice(), + _ => std::slice::from_ref(self), + } + } + + /// If this expression is a `BoolOr`, return its elements. + /// Otherwise return a single-element slice containing `self`. + pub fn disjuncts(&self) -> &[L3Expr] { + match self { + L3Expr::BoolOr(v) => v.as_slice(), + _ => std::slice::from_ref(self), + } + } + + /// Recursively collect all `ColumnRef`s referenced anywhere in this + /// expression. Used by L4 for column-lineage and selectivity estimation. + pub fn columns_referenced(&self) -> Vec<&ColumnRef> { + match self { + L3Expr::Column(c) => vec![c], + L3Expr::Literal(_) => vec![], + L3Expr::Compare { left, right, .. } => { + let mut v = left.columns_referenced(); + v.extend(right.columns_referenced()); + v + } + L3Expr::BoolAnd(parts) | L3Expr::BoolOr(parts) => { + parts.iter().flat_map(|e| e.columns_referenced()).collect() + } + L3Expr::Not(e) | L3Expr::IsNull(e) | L3Expr::IsNotNull(e) => { + e.columns_referenced() + } + L3Expr::Cast { expr, .. } => expr.columns_referenced(), + L3Expr::InList { expr, list, .. } => { + let mut v = expr.columns_referenced(); + v.extend(list.iter().flat_map(|e| e.columns_referenced())); + v + } + L3Expr::FunctionCall { args, .. } => { + args.iter().flat_map(|e| e.columns_referenced()).collect() + } + } + } +} diff --git a/crates/core/src/intent_algebra/mod.rs b/crates/core/src/intent_algebra/mod.rs index bcc59b82..f689b4d4 100644 --- a/crates/core/src/intent_algebra/mod.rs +++ b/crates/core/src/intent_algebra/mod.rs @@ -1,4 +1,5 @@ pub mod expr; +pub mod expr_ir; pub mod schema; pub use expr::{ @@ -7,4 +8,5 @@ pub use expr::{ SortKey, Source, TableRef, TimeRange, TimeWindowKind, VectorMatch, WindowFrame, WindowFuncKind, }; +pub use expr_ir::{CompareOp, L3Expr, L3Scalar}; pub use schema::{ColumnDef, HasSchema, L3DataType, L3Field, L3Schema, SchemaCatalog, TableSchema}; diff --git a/crates/core/tests/expr_ir.rs b/crates/core/tests/expr_ir.rs new file mode 100644 index 00000000..fd25282e --- /dev/null +++ b/crates/core/tests/expr_ir.rs @@ -0,0 +1,207 @@ +use asap_control_core::intent_algebra::{ColumnRef, CompareOp, L3Expr, L3Scalar}; + +// ── L3Scalar ────────────────────────────────────────────────────────────────── + +#[test] +fn l3scalar_int64_eq() { + assert_eq!(L3Scalar::Int64(42), L3Scalar::Int64(42)); + assert_ne!(L3Scalar::Int64(1), L3Scalar::Int64(2)); +} + +#[test] +fn l3scalar_float64_eq() { + assert_eq!(L3Scalar::Float64(3.14), L3Scalar::Float64(3.14)); +} + +#[test] +fn l3scalar_utf8_eq() { + assert_eq!(L3Scalar::Utf8("hello".into()), L3Scalar::Utf8("hello".into())); + assert_ne!(L3Scalar::Utf8("a".into()), L3Scalar::Utf8("b".into())); +} + +#[test] +fn l3scalar_null_eq() { + assert_eq!(L3Scalar::Null, L3Scalar::Null); +} + +// ── L3Expr construction ─────────────────────────────────────────────────────── + +#[test] +fn l3expr_column_eq() { + let a = L3Expr::Column(ColumnRef("ts".into())); + let b = L3Expr::Column(ColumnRef("ts".into())); + assert_eq!(a, b); + assert_ne!(L3Expr::Column(ColumnRef("ts".into())), L3Expr::Column(ColumnRef("x".into()))); +} + +#[test] +fn l3expr_literal_eq() { + assert_eq!(L3Expr::Literal(L3Scalar::Int64(5)), L3Expr::Literal(L3Scalar::Int64(5))); +} + +#[test] +fn l3expr_compare_eq() { + let make = || L3Expr::Compare { + left: Box::new(L3Expr::Column(ColumnRef("v".into()))), + op: CompareOp::Gt, + right: Box::new(L3Expr::Literal(L3Scalar::Float64(0.0))), + }; + assert_eq!(make(), make()); +} + +// ── conjuncts() ─────────────────────────────────────────────────────────────── + +#[test] +fn bool_and_conjuncts_returns_all_elements() { + let a = L3Expr::Column(ColumnRef("a".into())); + let b = L3Expr::Column(ColumnRef("b".into())); + let expr = L3Expr::BoolAnd(vec![a.clone(), b.clone()]); + let c = expr.conjuncts(); + assert_eq!(c.len(), 2); + assert_eq!(c[0], a); + assert_eq!(c[1], b); +} + +#[test] +fn non_and_conjuncts_returns_self_as_slice() { + let col = L3Expr::Column(ColumnRef("x".into())); + let c = col.conjuncts(); + assert_eq!(c.len(), 1); + assert_eq!(c[0], col); +} + +#[test] +fn literal_conjuncts_returns_self() { + let lit = L3Expr::Literal(L3Scalar::Boolean(true)); + assert_eq!(lit.conjuncts().len(), 1); +} + +// ── disjuncts() ─────────────────────────────────────────────────────────────── + +#[test] +fn bool_or_disjuncts_returns_all_elements() { + let a = L3Expr::Literal(L3Scalar::Int64(1)); + let b = L3Expr::Literal(L3Scalar::Int64(2)); + let expr = L3Expr::BoolOr(vec![a.clone(), b.clone()]); + let d = expr.disjuncts(); + assert_eq!(d.len(), 2); + assert_eq!(d[0], a); + assert_eq!(d[1], b); +} + +#[test] +fn non_or_disjuncts_returns_self() { + let lit = L3Expr::Literal(L3Scalar::Null); + assert_eq!(lit.disjuncts().len(), 1); +} + +// ── columns_referenced() ────────────────────────────────────────────────────── + +#[test] +fn columns_referenced_from_column_node() { + let expr = L3Expr::Column(ColumnRef("ts".into())); + let refs = expr.columns_referenced(); + assert_eq!(refs.len(), 1); + assert_eq!(refs[0].0, "ts"); +} + +#[test] +fn columns_referenced_from_literal_is_empty() { + let expr = L3Expr::Literal(L3Scalar::Int64(99)); + assert!(expr.columns_referenced().is_empty()); +} + +#[test] +fn columns_referenced_from_compare() { + let expr = L3Expr::Compare { + left: Box::new(L3Expr::Column(ColumnRef("a".into()))), + op: CompareOp::Gt, + right: Box::new(L3Expr::Column(ColumnRef("b".into()))), + }; + let refs = expr.columns_referenced(); + assert_eq!(refs.len(), 2); + assert!(refs.iter().any(|r| r.0 == "a")); + assert!(refs.iter().any(|r| r.0 == "b")); +} + +#[test] +fn columns_referenced_from_compare_with_literal() { + let expr = L3Expr::Compare { + left: Box::new(L3Expr::Column(ColumnRef("value".into()))), + op: CompareOp::Ge, + right: Box::new(L3Expr::Literal(L3Scalar::Float64(0.0))), + }; + let refs = expr.columns_referenced(); + assert_eq!(refs.len(), 1); + assert_eq!(refs[0].0, "value"); +} + +#[test] +fn columns_referenced_from_bool_and() { + let expr = L3Expr::BoolAnd(vec![ + L3Expr::Compare { + left: Box::new(L3Expr::Column(ColumnRef("region".into()))), + op: CompareOp::Eq, + right: Box::new(L3Expr::Literal(L3Scalar::Utf8("us".into()))), + }, + L3Expr::Compare { + left: Box::new(L3Expr::Column(ColumnRef("value".into()))), + op: CompareOp::Gt, + right: Box::new(L3Expr::Literal(L3Scalar::Float64(0.0))), + }, + ]); + let refs = expr.columns_referenced(); + assert_eq!(refs.len(), 2); + assert!(refs.iter().any(|r| r.0 == "region")); + assert!(refs.iter().any(|r| r.0 == "value")); +} + +#[test] +fn columns_referenced_from_not() { + let expr = L3Expr::Not(Box::new(L3Expr::Column(ColumnRef("flag".into())))); + let refs = expr.columns_referenced(); + assert_eq!(refs.len(), 1); + assert_eq!(refs[0].0, "flag"); +} + +#[test] +fn columns_referenced_from_is_null() { + let expr = L3Expr::IsNull(Box::new(L3Expr::Column(ColumnRef("x".into())))); + let refs = expr.columns_referenced(); + assert_eq!(refs.len(), 1); + assert_eq!(refs[0].0, "x"); +} + +#[test] +fn columns_referenced_from_is_not_null() { + let expr = L3Expr::IsNotNull(Box::new(L3Expr::Column(ColumnRef("y".into())))); + let refs = expr.columns_referenced(); + assert_eq!(refs.len(), 1); + assert_eq!(refs[0].0, "y"); +} + +#[test] +fn columns_referenced_from_in_list() { + let expr = L3Expr::InList { + expr: Box::new(L3Expr::Column(ColumnRef("region".into()))), + list: vec![ + L3Expr::Literal(L3Scalar::Utf8("us".into())), + L3Expr::Literal(L3Scalar::Utf8("eu".into())), + ], + negated: false, + }; + let refs = expr.columns_referenced(); + assert_eq!(refs.len(), 1); + assert_eq!(refs[0].0, "region"); +} + +#[test] +fn columns_referenced_from_function_call() { + let expr = L3Expr::FunctionCall { + name: "lower".into(), + args: vec![L3Expr::Column(ColumnRef("host".into()))], + }; + let refs = expr.columns_referenced(); + assert_eq!(refs.len(), 1); + assert_eq!(refs[0].0, "host"); +} diff --git a/crates/core/tests/schema_derivation.rs b/crates/core/tests/schema_derivation.rs index 899dabcc..a1464946 100644 --- a/crates/core/tests/schema_derivation.rs +++ b/crates/core/tests/schema_derivation.rs @@ -2,8 +2,8 @@ use std::collections::HashMap; use std::rc::Rc; use asap_control_core::intent_algebra::{ - AggIntent, ColumnRef, DataModel, GroupKey, HasSchema, L3DataType, L3Field, L3Node, L3Schema, - Predicate, ProjectItem, QueryExpr, SortKey, Source, TableRef, TimeRange, ColumnDef, + AggIntent, ColumnRef, DataModel, GroupKey, HasSchema, L3DataType, L3Expr, L3Field, L3Node, + L3Scalar, L3Schema, Predicate, QueryExpr, SortKey, Source, TableRef, ColumnDef, SchemaCatalog, TableSchema, }; use asap_control_core::types::AccuracyTarget; @@ -314,7 +314,10 @@ fn dummy_scan(s: L3Schema) -> Rc { #[test] fn filter_passes_through_child_schema() { let cs = child_schema(); - let node = QueryExpr::Filter { child: dummy_scan(cs.clone()), pred: Predicate }; + let node = QueryExpr::Filter { + child: dummy_scan(cs.clone()), + pred: Predicate(L3Expr::Literal(L3Scalar::Boolean(true))), + }; let out = node.output_schema(&[&cs], &empty_catalog()); assert_eq!(out.fields, cs.fields); assert_eq!(out.time_index, cs.time_index); @@ -323,7 +326,14 @@ fn filter_passes_through_child_schema() { #[test] fn sort_passes_through_child_schema() { let cs = child_schema(); - let node = QueryExpr::Sort { child: dummy_scan(cs.clone()), keys: vec![SortKey] }; + let node = QueryExpr::Sort { + child: dummy_scan(cs.clone()), + keys: vec![SortKey { + expr: L3Expr::Column(ColumnRef("ts".into())), + ascending: true, + nulls_first: false, + }], + }; let out = node.output_schema(&[&cs], &empty_catalog()); assert_eq!(out.fields, cs.fields); } diff --git a/crates/lower/src/sql.rs b/crates/lower/src/sql.rs index ee93ef14..84c1575b 100644 --- a/crates/lower/src/sql.rs +++ b/crates/lower/src/sql.rs @@ -14,6 +14,7 @@ use asap_control_core::intent_algebra::expr::{ TableRef, TimeRange, WindowFuncKind, }; use asap_control_core::intent_algebra::schema::{L3DataType, L3Schema, SchemaCatalog, TableSchema}; +use asap_control_core::intent_algebra::{CompareOp, L3Expr, L3Scalar}; use asap_control_core::types::AccuracyTarget; use crate::error::LoweringError; @@ -109,13 +110,13 @@ impl<'a> SqlLowerer<'a> { fn lower_filter(&self, filter: &logical_expr::Filter) -> Result { // When the direct child is a TableScan and the table has a time column, // split the predicate: time bounds go into Source::Table.time_range; the - // rest stays as a Filter node on top. + // remaining non-time conjuncts become the Filter predicate. let inner = strip_aliases(&filter.input); if let LogicalPlan::TableScan(scan) = inner { let table_name = scan.table_name.to_string(); if let Some(schema) = self.catalog.tables.get(&table_name) { if let Some(time_col) = &schema.time_column { - let (time_range, has_non_time) = + let (time_range, non_time) = extract_time_range(&filter.predicate, time_col); let scan_expr = QueryExpr::Scan { source: Source::Table { @@ -125,17 +126,22 @@ impl<'a> SqlLowerer<'a> { }, predicates: vec![], }; - return if has_non_time { - Ok(QueryExpr::Filter { child: make_node(scan_expr), pred: Predicate }) - } else { + return if non_time.is_empty() { Ok(scan_expr) + } else { + let pred_expr = conjuncts_to_l3expr(non_time)?; + Ok(QueryExpr::Filter { + child: make_node(scan_expr), + pred: Predicate(pred_expr), + }) }; } } } + let pred_expr = df_expr_to_l3(&filter.predicate)?; let child = self.lower_plan(&filter.input)?; - Ok(QueryExpr::Filter { child: make_node(child), pred: Predicate }) + Ok(QueryExpr::Filter { child: make_node(child), pred: Predicate(pred_expr) }) } fn lower_projection( @@ -143,7 +149,17 @@ impl<'a> SqlLowerer<'a> { proj: &logical_expr::Projection, ) -> Result { let child = self.lower_plan(&proj.input)?; - let cols = proj.expr.iter().map(|_| ProjectItem).collect(); + let cols = proj + .expr + .iter() + .map(|e| match e { + Expr::Alias(a) => { + df_expr_to_l3(&a.expr) + .map(|expr| ProjectItem { expr, alias: Some(a.name.clone()) }) + } + _ => df_expr_to_l3(e).map(|expr| ProjectItem { expr, alias: None }), + }) + .collect::, _>>()?; Ok(QueryExpr::Project { child: make_node(child), cols }) } @@ -176,10 +192,18 @@ impl<'a> SqlLowerer<'a> { } } let child = self.lower_plan(&sort.input)?; - Ok(QueryExpr::Sort { - child: make_node(child), - keys: sort.expr.iter().map(|_| SortKey).collect(), - }) + let keys = sort + .expr + .iter() + .map(|s| { + df_expr_to_l3(&s.expr).map(|expr| SortKey { + expr, + ascending: s.asc, + nulls_first: s.nulls_first, + }) + }) + .collect::, _>>()?; + Ok(QueryExpr::Sort { child: make_node(child), keys }) } fn lower_limit(&self, limit: &logical_expr::Limit) -> Result { @@ -236,11 +260,23 @@ impl<'a> SqlLowerer<'a> { .iter() .map(expr_to_group_key) .collect::, _>>()?; + // In DataFusion 43, WindowFunction.order_by is Vec. + let order_by = wf + .order_by + .iter() + .map(|s| { + df_expr_to_l3(&s.expr).map(|expr| SortKey { + expr, + ascending: s.asc, + nulls_first: s.nulls_first, + }) + }) + .collect::, _>>()?; return Ok(QueryExpr::WindowFunc { child: make_node(child), func, partition_by, - order_by: wf.order_by.iter().map(|_| SortKey).collect(), + order_by, frame: None, }); } @@ -361,12 +397,17 @@ fn split_conjuncts(expr: &Expr) -> Vec<&Expr> { } } -/// Returns `(time_range, has_non_time_predicates)`. -pub(crate) fn extract_time_range(expr: &Expr, time_col: &str) -> (Option, bool) { +/// Split `expr` into `(time_range, non_time_conjuncts)`. +/// Time-bound conjuncts are folded into the `TimeRange`; the rest are returned +/// as a `Vec<&Expr>` so the caller can translate them with `df_expr_to_l3`. +pub(crate) fn extract_time_range<'a>( + expr: &'a Expr, + time_col: &str, +) -> (Option, Vec<&'a Expr>) { let conjuncts = split_conjuncts(expr); let mut start_ms: Option = None; let mut end_ms: Option = None; - let mut has_non_time = false; + let mut non_time: Vec<&'a Expr> = vec![]; for c in conjuncts { match classify_time_pred(c, time_col) { @@ -376,7 +417,7 @@ pub(crate) fn extract_time_range(expr: &Expr, time_col: &str) -> (Option { end_ms = Some(end_ms.map_or(ms, |e: i64| e.min(ms))); } - TimeClass::NonTime => has_non_time = true, + TimeClass::NonTime => non_time.push(c), } } @@ -385,7 +426,156 @@ pub(crate) fn extract_time_range(expr: &Expr, time_col: &str) -> (Option) -> Result { + let parts: Result, _> = conjuncts.iter().map(|e| df_expr_to_l3(e)).collect(); + let mut parts = parts?; + if parts.len() == 1 { + Ok(parts.remove(0)) + } else { + Ok(L3Expr::BoolAnd(parts)) + } +} + +/// Translate a DataFusion `Expr` to an `L3Expr`. +/// Returns `UnsupportedFeature` for anything not needed in v1. +fn df_expr_to_l3(expr: &Expr) -> Result { + match expr { + Expr::Column(col) => Ok(L3Expr::Column(ColumnRef(col.name.clone()))), + + Expr::Literal(sv) => scalar_value_to_l3(sv).map(L3Expr::Literal), + + Expr::Alias(a) => df_expr_to_l3(&a.expr), + + Expr::BinaryExpr(BinaryExpr { left, op, right }) => match op { + Operator::And => { + let parts = split_conjuncts(expr); + let l3_parts: Result, _> = parts.iter().map(|e| df_expr_to_l3(e)).collect(); + Ok(L3Expr::BoolAnd(l3_parts?)) + } + Operator::Or => { + let parts = split_disjuncts(expr); + let l3_parts: Result, _> = parts.iter().map(|e| df_expr_to_l3(e)).collect(); + Ok(L3Expr::BoolOr(l3_parts?)) + } + Operator::Eq => compare(left, CompareOp::Eq, right), + Operator::NotEq => compare(left, CompareOp::Ne, right), + Operator::Lt => compare(left, CompareOp::Lt, right), + Operator::LtEq => compare(left, CompareOp::Le, right), + Operator::Gt => compare(left, CompareOp::Gt, right), + Operator::GtEq => compare(left, CompareOp::Ge, right), + Operator::LikeMatch => compare(left, CompareOp::Like, right), + Operator::NotLikeMatch => compare(left, CompareOp::NotLike, right), + other => Err(LoweringError::UnsupportedFeature(format!("operator: {other:?}"))), + }, + + Expr::Not(inner) => Ok(L3Expr::Not(Box::new(df_expr_to_l3(inner)?))), + + Expr::IsNull(inner) => Ok(L3Expr::IsNull(Box::new(df_expr_to_l3(inner)?))), + + Expr::IsNotNull(inner) => Ok(L3Expr::IsNotNull(Box::new(df_expr_to_l3(inner)?))), + + Expr::Cast(c) => { + let inner = df_expr_to_l3(&c.expr)?; + let to = arrow_to_l3(&c.data_type)?; + Ok(L3Expr::Cast { expr: Box::new(inner), to }) + } + + Expr::TryCast(c) => { + let inner = df_expr_to_l3(&c.expr)?; + let to = arrow_to_l3(&c.data_type)?; + Ok(L3Expr::Cast { expr: Box::new(inner), to }) + } + + Expr::InList(il) => { + let expr = df_expr_to_l3(&il.expr)?; + let list: Result, _> = il.list.iter().map(df_expr_to_l3).collect(); + Ok(L3Expr::InList { expr: Box::new(expr), list: list?, negated: il.negated }) + } + + Expr::Between(b) => { + // Normalize: `x BETWEEN low AND high` → `x >= low AND x <= high`. + // `x NOT BETWEEN low AND high` → `x < low OR x > high`. + let x_low = compare(&b.expr, CompareOp::Ge, &b.low)?; + let x_high = compare(&b.expr, CompareOp::Le, &b.high)?; + if b.negated { + // NOT BETWEEN: invert each side + let lt = compare(&b.expr, CompareOp::Lt, &b.low)?; + let gt = compare(&b.expr, CompareOp::Gt, &b.high)?; + Ok(L3Expr::BoolOr(vec![lt, gt])) + } else { + Ok(L3Expr::BoolAnd(vec![x_low, x_high])) + } + } + + Expr::ScalarFunction(sf) => { + let args: Result, _> = sf.args.iter().map(df_expr_to_l3).collect(); + Ok(L3Expr::FunctionCall { name: sf.func.name().to_string(), args: args? }) + } + + other => Err(LoweringError::UnsupportedFeature(format!("expression: {}", other))), + } +} + +fn compare(left: &Expr, op: CompareOp, right: &Expr) -> Result { + Ok(L3Expr::Compare { + left: Box::new(df_expr_to_l3(left)?), + op, + right: Box::new(df_expr_to_l3(right)?), + }) +} + +fn scalar_value_to_l3(sv: &ScalarValue) -> Result { + match sv { + ScalarValue::Int64(Some(v)) => Ok(L3Scalar::Int64(*v)), + ScalarValue::Int32(Some(v)) => Ok(L3Scalar::Int64(*v as i64)), + ScalarValue::Int16(Some(v)) => Ok(L3Scalar::Int64(*v as i64)), + ScalarValue::Int8(Some(v)) => Ok(L3Scalar::Int64(*v as i64)), + ScalarValue::UInt64(Some(v)) => Ok(L3Scalar::Int64(*v as i64)), + ScalarValue::UInt32(Some(v)) => Ok(L3Scalar::Int64(*v as i64)), + ScalarValue::Float64(Some(v)) => Ok(L3Scalar::Float64(*v)), + ScalarValue::Float32(Some(v)) => Ok(L3Scalar::Float64(*v as f64)), + ScalarValue::Utf8(Some(s)) | ScalarValue::LargeUtf8(Some(s)) => { + Ok(L3Scalar::Utf8(s.clone())) + } + ScalarValue::Boolean(Some(b)) => Ok(L3Scalar::Boolean(*b)), + // Typed nulls and untyped null both become L3Scalar::Null + _ if sv.is_null() => Ok(L3Scalar::Null), + _ => Err(LoweringError::InvalidExpression(format!("unsupported scalar: {sv:?}"))), + } +} + +fn arrow_to_l3(dt: &ArrowDataType) -> Result { + match dt { + ArrowDataType::Int64 + | ArrowDataType::Int32 + | ArrowDataType::Int16 + | ArrowDataType::Int8 => Ok(L3DataType::Int64), + ArrowDataType::Float64 | ArrowDataType::Float32 => Ok(L3DataType::Float64), + ArrowDataType::Utf8 | ArrowDataType::LargeUtf8 => Ok(L3DataType::Utf8), + ArrowDataType::Boolean => Ok(L3DataType::Boolean), + ArrowDataType::Timestamp(_, _) => Ok(L3DataType::Timestamp), + ArrowDataType::Duration(_) => Ok(L3DataType::Duration), + other => { + Err(LoweringError::UnsupportedFeature(format!("Arrow type in cast: {other:?}"))) + } + } +} + +fn split_disjuncts(expr: &Expr) -> Vec<&Expr> { + match expr { + Expr::BinaryExpr(BinaryExpr { left, op: Operator::Or, right }) => { + let mut v = split_disjuncts(left); + v.extend(split_disjuncts(right)); + v + } + _ => vec![expr], + } } enum TimeClass { diff --git a/crates/lower/tests/sql_lowering.rs b/crates/lower/tests/sql_lowering.rs index 6d3c4790..ad7d7054 100644 --- a/crates/lower/tests/sql_lowering.rs +++ b/crates/lower/tests/sql_lowering.rs @@ -1,7 +1,8 @@ use std::collections::HashMap; -use asap_control_core::intent_algebra::expr::{AggIntent, QueryExpr, Source}; +use asap_control_core::intent_algebra::expr::{AggIntent, Predicate, ProjectItem, QueryExpr, SortKey, Source}; use asap_control_core::intent_algebra::schema::{ColumnDef, L3DataType, SchemaCatalog, TableSchema}; +use asap_control_core::intent_algebra::{L3Expr}; use asap_control_core::types::AccuracyTarget; use asap_control_lower::{LoweringError, SqlLowerer}; @@ -69,6 +70,39 @@ fn find_aggregate(expr: &QueryExpr) -> Option<(&Vec Option<&L3Expr> { + match expr { + QueryExpr::Filter { pred: Predicate(e), .. } => Some(e), + QueryExpr::Project { child, .. } + | QueryExpr::Sort { child, .. } + | QueryExpr::Limit { child, .. } => find_predicate(&child.expr), + _ => None, + } +} + +/// Walk through wrappers to find the cols of the first Project node. +fn find_project_items(expr: &QueryExpr) -> Option<&[ProjectItem]> { + match expr { + QueryExpr::Project { cols, .. } => Some(cols), + QueryExpr::Sort { child, .. } + | QueryExpr::Filter { child, .. } + | QueryExpr::Limit { child, .. } => find_project_items(&child.expr), + _ => None, + } +} + +/// Walk through wrappers to find the keys of the first Sort node. +fn find_sort_keys(expr: &QueryExpr) -> Option<&[SortKey]> { + match expr { + QueryExpr::Sort { keys, .. } => Some(keys), + QueryExpr::Project { child, .. } + | QueryExpr::Filter { child, .. } + | QueryExpr::Limit { child, .. } => find_sort_keys(&child.expr), + _ => None, + } +} + /// Walk through wrappers to find the first Scan source. fn find_source(expr: &QueryExpr) -> Option<&Source> { match expr { @@ -400,6 +434,85 @@ async fn test_subquery_returns_error() { ); } +// ── Tests: Predicate / ProjectItem / SortKey content ───────────────────────── + +#[tokio::test] +async fn test_filter_predicate_references_filtered_column() { + // WHERE value > 5.0 should produce a Predicate that references "value". + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT ts FROM metrics WHERE value > 5.0") + .await + .unwrap(); + + let pred = find_predicate(&result).expect("expected Filter predicate"); + let refs = pred.columns_referenced(); + assert!(refs.iter().any(|r| r.0 == "value"), "expected 'value' column ref in predicate"); +} + +#[tokio::test] +async fn test_filter_two_non_time_predicates_is_bool_and() { + // WHERE value > 0 AND region = 'us' — both non-time → BoolAnd with 2 conjuncts. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT ts FROM metrics WHERE value > 0 AND region = 'us'") + .await + .unwrap(); + + let pred = find_predicate(&result).expect("expected Filter predicate"); + assert_eq!( + pred.conjuncts().len(), + 2, + "expected BoolAnd with 2 conjuncts, got: {pred:?}" + ); +} + +#[tokio::test] +async fn test_project_items_carry_column_refs() { + // SELECT id, value FROM events → two ProjectItems, each with a Column expr. + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer.lower("SELECT id, value FROM events").await.unwrap(); + + let items = find_project_items(&result).expect("expected Project node"); + assert_eq!(items.len(), 2, "expected 2 ProjectItems"); + + let col_names: Vec<&str> = items + .iter() + .filter_map(|pi| { + if let L3Expr::Column(c) = &pi.expr { Some(c.0.as_str()) } else { None } + }) + .collect(); + assert!(col_names.contains(&"id"), "expected 'id' in project items"); + assert!(col_names.contains(&"value"), "expected 'value' in project items"); +} + +#[tokio::test] +async fn test_sort_key_ascending_flag() { + // ORDER BY id ASC → SortKey with ascending = true. + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer.lower("SELECT id FROM events ORDER BY id ASC").await.unwrap(); + + let keys = find_sort_keys(&result).expect("expected Sort node"); + assert_eq!(keys.len(), 1); + assert!(keys[0].ascending, "expected ascending sort key"); +} + +#[tokio::test] +async fn test_sort_key_descending_flag() { + // ORDER BY value DESC → SortKey with ascending = false. + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer.lower("SELECT id FROM events ORDER BY value DESC").await.unwrap(); + + let keys = find_sort_keys(&result).expect("expected Sort node"); + assert_eq!(keys.len(), 1); + assert!(!keys[0].ascending, "expected descending sort key"); +} + #[tokio::test] async fn test_unknown_table_returns_error() { let catalog = metrics_catalog(); From 0469b01285a814f560c7389dac42306219fa2acf Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Mon, 18 May 2026 08:34:47 -0400 Subject: [PATCH 04/17] Added schema derivation logic --- crates/core/src/intent_algebra/expr.rs | 37 +++++++ crates/core/tests/schema_derivation.rs | 127 ++++++++++++++++++++++++- crates/lower/tests/sql_lowering.rs | 106 ++++++++++++++++++++- 3 files changed, 267 insertions(+), 3 deletions(-) diff --git a/crates/core/src/intent_algebra/expr.rs b/crates/core/src/intent_algebra/expr.rs index 22e251d7..eb9a2bfe 100644 --- a/crates/core/src/intent_algebra/expr.rs +++ b/crates/core/src/intent_algebra/expr.rs @@ -379,6 +379,43 @@ impl HasSchema for QueryExpr { ), }, + // ── Project: one output field per ProjectItem ───────────────────── + QueryExpr::Project { cols, .. } => { + let cs = child(); + let time_col_src = cs.time_index.map(|ti| cs.fields[ti].name.clone()); + + // Build (field, is_time_col) pairs. Non-Column exprs are deferred + // until a type-inference pass exists. + let pairs: Vec<(L3Field, bool)> = cols + .iter() + .map(|item| match &item.expr { + L3Expr::Column(col_ref) => { + let child_f = + cs.fields.iter().find(|f| f.name == col_ref.0); + let (dtype, nullable) = child_f + .map(|f| (f.dtype.clone(), f.nullable)) + .unwrap_or((super::schema::L3DataType::Float64, true)); + let out_name = item + .alias + .as_deref() + .unwrap_or(&col_ref.0) + .to_string(); + let is_time = + time_col_src.as_deref() == Some(col_ref.0.as_str()); + (L3Field { name: out_name, dtype, nullable }, is_time) + } + _ => todo!( + "Project schema derivation for non-column expressions \ + (type inference not yet implemented)" + ), + }) + .collect(); + + let time_index = pairs.iter().position(|(_, is_time)| *is_time); + let fields = pairs.into_iter().map(|(f, _)| f).collect(); + L3Schema { fields, time_index } + } + // ── Pass-through: output schema == child schema ─────────────────── QueryExpr::Filter { .. } | QueryExpr::Sort { .. } diff --git a/crates/core/tests/schema_derivation.rs b/crates/core/tests/schema_derivation.rs index a1464946..7cd31b8d 100644 --- a/crates/core/tests/schema_derivation.rs +++ b/crates/core/tests/schema_derivation.rs @@ -3,7 +3,7 @@ use std::rc::Rc; use asap_control_core::intent_algebra::{ AggIntent, ColumnRef, DataModel, GroupKey, HasSchema, L3DataType, L3Expr, L3Field, L3Node, - L3Scalar, L3Schema, Predicate, QueryExpr, SortKey, Source, TableRef, ColumnDef, + L3Scalar, L3Schema, Predicate, ProjectItem, QueryExpr, SortKey, Source, TableRef, ColumnDef, SchemaCatalog, TableSchema, }; use asap_control_core::types::AccuracyTarget; @@ -476,3 +476,128 @@ fn aggregate_time_index_not_propagated() { let out = node.output_schema(&[&cs], &empty_catalog()); assert_eq!(out.time_index, None); } + +// ── HasSchema::output_schema() — Project ────────────────────────────────────── + +#[test] +fn project_column_items_derive_schema_from_child() { + // SELECT ts, value — both columns exist in the child schema. + let cs = child_schema(); // [ts(Int64), value(Float64)] + let node = QueryExpr::Project { + child: dummy_scan(cs.clone()), + cols: vec![ + ProjectItem { expr: L3Expr::Column(ColumnRef("ts".into())), alias: None }, + ProjectItem { expr: L3Expr::Column(ColumnRef("value".into())), alias: None }, + ], + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.fields.len(), 2); + assert_eq!(out.fields[0].name, "ts"); + assert_eq!(out.fields[0].dtype, L3DataType::Int64); + assert_eq!(out.fields[1].name, "value"); + assert_eq!(out.fields[1].dtype, L3DataType::Float64); +} + +#[test] +fn project_alias_renames_output_field() { + // SELECT value AS v — output field is named "v", type preserved. + let cs = child_schema(); + let node = QueryExpr::Project { + child: dummy_scan(cs.clone()), + cols: vec![ + ProjectItem { + expr: L3Expr::Column(ColumnRef("value".into())), + alias: Some("v".into()), + }, + ], + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.fields.len(), 1); + assert_eq!(out.fields[0].name, "v"); + assert_eq!(out.fields[0].dtype, L3DataType::Float64); +} + +#[test] +fn project_subsets_columns() { + // SELECT value — only one of two child columns projected. + let cs = child_schema(); + let node = QueryExpr::Project { + child: dummy_scan(cs.clone()), + cols: vec![ProjectItem { expr: L3Expr::Column(ColumnRef("value".into())), alias: None }], + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.fields.len(), 1); + assert_eq!(out.fields[0].name, "value"); +} + +#[test] +fn project_preserves_time_index_when_time_col_included() { + // SELECT ts, value — ts is at index 0 in child (time_index=0); should be preserved. + let cs = schema_with_time( + vec![field("ts", L3DataType::Int64), nullable_field("value", L3DataType::Float64)], + 0, + ); + let node = QueryExpr::Project { + child: dummy_scan(cs.clone()), + cols: vec![ + ProjectItem { expr: L3Expr::Column(ColumnRef("ts".into())), alias: None }, + ProjectItem { expr: L3Expr::Column(ColumnRef("value".into())), alias: None }, + ], + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.time_index, Some(0)); +} + +#[test] +fn project_preserves_time_index_when_col_reordered() { + // SELECT value, ts — ts moves to index 1; time_index should update. + let cs = schema_with_time( + vec![field("ts", L3DataType::Int64), nullable_field("value", L3DataType::Float64)], + 0, + ); + let node = QueryExpr::Project { + child: dummy_scan(cs.clone()), + cols: vec![ + ProjectItem { expr: L3Expr::Column(ColumnRef("value".into())), alias: None }, + ProjectItem { expr: L3Expr::Column(ColumnRef("ts".into())), alias: None }, + ], + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.time_index, Some(1)); +} + +#[test] +fn project_drops_time_index_when_time_col_excluded() { + // SELECT value — ts not projected; time_index should be None. + let cs = schema_with_time( + vec![field("ts", L3DataType::Int64), nullable_field("value", L3DataType::Float64)], + 0, + ); + let node = QueryExpr::Project { + child: dummy_scan(cs.clone()), + cols: vec![ProjectItem { expr: L3Expr::Column(ColumnRef("value".into())), alias: None }], + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.time_index, None); +} + +#[test] +fn project_time_index_tracks_aliased_time_col() { + // SELECT ts AS t — aliased; time_index should still point at the right output position. + let cs = schema_with_time( + vec![field("ts", L3DataType::Int64), nullable_field("value", L3DataType::Float64)], + 0, + ); + let node = QueryExpr::Project { + child: dummy_scan(cs.clone()), + cols: vec![ + ProjectItem { + expr: L3Expr::Column(ColumnRef("ts".into())), + alias: Some("t".into()), + }, + ], + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.time_index, Some(0)); + assert_eq!(out.fields[0].name, "t"); +} diff --git a/crates/lower/tests/sql_lowering.rs b/crates/lower/tests/sql_lowering.rs index ad7d7054..a09d1846 100644 --- a/crates/lower/tests/sql_lowering.rs +++ b/crates/lower/tests/sql_lowering.rs @@ -2,9 +2,10 @@ use std::collections::HashMap; use asap_control_core::intent_algebra::expr::{AggIntent, Predicate, ProjectItem, QueryExpr, SortKey, Source}; use asap_control_core::intent_algebra::schema::{ColumnDef, L3DataType, SchemaCatalog, TableSchema}; -use asap_control_core::intent_algebra::{L3Expr}; +use asap_control_core::intent_algebra::{CompareOp, L3Expr}; use asap_control_core::types::AccuracyTarget; -use asap_control_lower::{LoweringError, SqlLowerer}; +use asap_control_core::workload::{BatchEntry, Query, QueryLanguage, QueryWorkload, SqlDialect}; +use asap_control_lower::{lower_batch, LoweringError, SqlLowerer}; // ── Catalog helpers ─────────────────────────────────────────────────────────── @@ -513,6 +514,107 @@ async fn test_sort_key_descending_flag() { assert!(!keys[0].ascending, "expected descending sort key"); } +// ── Tests: df_expr_to_l3 edge cases ────────────────────────────────────────── + +#[tokio::test] +async fn test_filter_is_null_predicate() { + // WHERE value IS NULL → IsNull(Column("value")) + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer.lower("SELECT id FROM events WHERE value IS NULL").await.unwrap(); + + let pred = find_predicate(&result).expect("expected Filter predicate"); + assert!( + matches!(pred, L3Expr::IsNull(inner) if matches!(inner.as_ref(), L3Expr::Column(c) if c.0 == "value")), + "expected IsNull(Column(\"value\")), got: {pred:?}" + ); +} + +#[tokio::test] +async fn test_filter_in_list_predicate() { + // WHERE id IN (1, 2, 3) → InList { expr: Column("id"), list: [..], negated: false } + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer.lower("SELECT id FROM events WHERE id IN (1, 2, 3)").await.unwrap(); + + let pred = find_predicate(&result).expect("expected Filter predicate"); + let L3Expr::InList { expr, list, negated } = pred else { + panic!("expected InList, got: {pred:?}"); + }; + assert!(matches!(expr.as_ref(), L3Expr::Column(c) if c.0 == "id")); + assert_eq!(list.len(), 3); + assert!(!negated); +} + +#[tokio::test] +async fn test_filter_between_normalizes_to_bool_and() { + // WHERE value BETWEEN 0 AND 100 → BoolAnd([Compare(Ge, 0), Compare(Le, 100)]) + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT id FROM events WHERE value BETWEEN 0.0 AND 100.0") + .await + .unwrap(); + + let pred = find_predicate(&result).expect("expected Filter predicate"); + let conjuncts = pred.conjuncts(); + assert_eq!(conjuncts.len(), 2, "BETWEEN should produce 2 conjuncts, got: {pred:?}"); + // First conjunct: value >= 0, second: value <= 100 + assert!(matches!(&conjuncts[0], L3Expr::Compare { op: CompareOp::Ge, .. })); + assert!(matches!(&conjuncts[1], L3Expr::Compare { op: CompareOp::Le, .. })); +} + +// ── Tests: lower_batch ──────────────────────────────────────────────────────── + +fn make_workload(queries: Vec<&str>) -> QueryWorkload { + QueryWorkload { + language: QueryLanguage::SQL(SqlDialect::DataFusionSQL), + query_batch: Some( + queries + .into_iter() + .map(|q| BatchEntry { query: Query(q.to_string()), requirements: None }) + .collect(), + ), + repeating_queries: None, + data_characteristics: None, + } +} + +#[tokio::test] +async fn test_lower_batch_empty_returns_empty_vec() { + let catalog = metrics_catalog(); + let workload = QueryWorkload { + language: QueryLanguage::SQL(SqlDialect::DataFusionSQL), + query_batch: None, + repeating_queries: None, + data_characteristics: None, + }; + let results = lower_batch(&workload, &catalog).await; + assert!(results.is_empty()); +} + +#[tokio::test] +async fn test_lower_batch_two_valid_queries() { + let catalog = metrics_catalog(); + let workload = + make_workload(vec!["SELECT COUNT(*) FROM metrics", "SELECT SUM(value) FROM metrics"]); + let results = lower_batch(&workload, &catalog).await; + assert_eq!(results.len(), 2); + assert!(results[0].is_ok(), "first query should succeed"); + assert!(results[1].is_ok(), "second query should succeed"); +} + +#[tokio::test] +async fn test_lower_batch_error_is_per_query() { + // A bad query in the batch should not prevent the good ones from being lowered. + let catalog = metrics_catalog(); + let workload = make_workload(vec!["SELECT COUNT(*) FROM metrics", "NOT VALID SQL !!!"]); + let results = lower_batch(&workload, &catalog).await; + assert_eq!(results.len(), 2); + assert!(results[0].is_ok(), "first (valid) query should succeed"); + assert!(results[1].is_err(), "second (invalid) query should fail"); +} + #[tokio::test] async fn test_unknown_table_returns_error() { let catalog = metrics_catalog(); From 5318afecf492b3e8eb6f114ef7ba5a83e7e4686f Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Mon, 18 May 2026 09:09:51 -0400 Subject: [PATCH 05/17] Added logic for column/schema extraction --- crates/core/tests/expr_ir.rs | 3 +- crates/lower/src/error.rs | 3 + crates/lower/src/lib.rs | 13 ++++- crates/lower/src/sql.rs | 77 +++++++++++++++++++++++--- crates/lower/tests/sql_lowering.rs | 88 ++++++++++++++++++++++++++++++ 5 files changed, 173 insertions(+), 11 deletions(-) diff --git a/crates/core/tests/expr_ir.rs b/crates/core/tests/expr_ir.rs index fd25282e..5c2869d0 100644 --- a/crates/core/tests/expr_ir.rs +++ b/crates/core/tests/expr_ir.rs @@ -10,7 +10,8 @@ fn l3scalar_int64_eq() { #[test] fn l3scalar_float64_eq() { - assert_eq!(L3Scalar::Float64(3.14), L3Scalar::Float64(3.14)); + assert_eq!(L3Scalar::Float64(1.5), L3Scalar::Float64(1.5)); + assert_ne!(L3Scalar::Float64(1.5), L3Scalar::Float64(2.5)); } #[test] diff --git a/crates/lower/src/error.rs b/crates/lower/src/error.rs index 1f78dfe1..23f3cb84 100644 --- a/crates/lower/src/error.rs +++ b/crates/lower/src/error.rs @@ -9,6 +9,8 @@ pub enum LoweringError { UnsupportedFeature(String), UnsupportedAggregate(String), InvalidExpression(String), + /// The workload's query language is not handled by this lowerer. + WrongLanguage(String), } impl fmt::Display for LoweringError { @@ -22,6 +24,7 @@ impl fmt::Display for LoweringError { Self::UnsupportedFeature(msg) => write!(f, "unsupported SQL feature: {msg}"), Self::UnsupportedAggregate(name) => write!(f, "unsupported aggregate: {name}"), Self::InvalidExpression(msg) => write!(f, "invalid expression: {msg}"), + Self::WrongLanguage(lang) => write!(f, "unsupported query language: {lang}"), } } } diff --git a/crates/lower/src/lib.rs b/crates/lower/src/lib.rs index 90371549..7f81302c 100644 --- a/crates/lower/src/lib.rs +++ b/crates/lower/src/lib.rs @@ -4,7 +4,7 @@ pub mod sql; use asap_control_core::intent_algebra::expr::QueryExpr; use asap_control_core::intent_algebra::schema::SchemaCatalog; use asap_control_core::types::AccuracyTarget; -use asap_control_core::workload::QueryWorkload; +use asap_control_core::workload::{QueryLanguage, QueryWorkload}; pub use error::LoweringError; pub use sql::SqlLowerer; @@ -13,6 +13,7 @@ pub use sql::SqlLowerer; /// /// One `Result` per entry — errors are per-query, not fatal for the batch. /// Returns an empty `Vec` if `workload.query_batch` is absent or empty. +/// Returns `WrongLanguage` for every entry if the workload language is not SQL. pub async fn lower_batch( workload: &QueryWorkload, catalog: &SchemaCatalog, @@ -21,6 +22,16 @@ pub async fn lower_batch( Some(e) if !e.is_empty() => e, _ => return vec![], }; + + // Guard: only SQL dialects are handled by this lowerer. + if !matches!(workload.language, QueryLanguage::SQL(_) | QueryLanguage::DataFusion) { + let lang = format!("{:?}", workload.language); + return entries + .iter() + .map(|_| Err(LoweringError::WrongLanguage(lang.clone()))) + .collect(); + } + let mut results = Vec::with_capacity(entries.len()); for entry in entries { let accuracy = entry diff --git a/crates/lower/src/sql.rs b/crates/lower/src/sql.rs index 84c1575b..7d0f58cb 100644 --- a/crates/lower/src/sql.rs +++ b/crates/lower/src/sql.rs @@ -94,15 +94,14 @@ impl<'a> SqlLowerer<'a> { scan: &logical_expr::TableScan, ) -> Result { let table_name = scan.table_name.to_string(); - if !self.catalog.tables.contains_key(&table_name) { - return Err(LoweringError::TableNotFound(table_name)); - } + let table_schema = self + .catalog + .tables + .get(&table_name) + .ok_or_else(|| LoweringError::TableNotFound(table_name.clone()))?; + let columns = projection_columns(scan, table_schema); Ok(QueryExpr::Scan { - source: Source::Table { - table_ref: TableRef(table_name), - columns: vec![], - time_range: None, - }, + source: Source::Table { table_ref: TableRef(table_name), columns, time_range: None }, predicates: vec![], }) } @@ -118,10 +117,11 @@ impl<'a> SqlLowerer<'a> { if let Some(time_col) = &schema.time_column { let (time_range, non_time) = extract_time_range(&filter.predicate, time_col); + let columns = projection_columns(scan, schema); let scan_expr = QueryExpr::Scan { source: Source::Table { table_ref: TableRef(table_name), - columns: vec![], + columns, time_range, }, predicates: vec![], @@ -148,6 +148,12 @@ impl<'a> SqlLowerer<'a> { &self, proj: &logical_expr::Projection, ) -> Result { + // SELECT * — all wildcards means "no column constraint". Pass through + // without a Project wrapper; an empty Scan.columns list means "all columns". + if proj.expr.iter().any(|e| matches!(e, Expr::Wildcard { .. })) { + return self.lower_plan(&proj.input); + } + let child = self.lower_plan(&proj.input)?; let cols = proj .expr @@ -160,6 +166,11 @@ impl<'a> SqlLowerer<'a> { _ => df_expr_to_l3(e).map(|expr| ProjectItem { expr, alias: None }), }) .collect::, _>>()?; + + // DataFusion's unoptimized plan never sets TableScan.projection, so we + // derive the Scan's column list from the enclosing projection instead. + let child = push_columns_into_scan(child, &cols); + Ok(QueryExpr::Project { child: make_node(child), cols }) } @@ -316,6 +327,54 @@ impl<'a> SqlLowerer<'a> { // ── Free helpers ────────────────────────────────────────────────────────────── +/// Map a DataFusion `TableScan.projection` (column index list) back to +/// `ColumnRef` names from the catalog schema. +/// Returns an empty `Vec` when the projection is absent (full scan / `SELECT *`). +fn projection_columns( + scan: &logical_expr::TableScan, + schema: &TableSchema, +) -> Vec { + match &scan.projection { + Some(indices) => indices + .iter() + .filter_map(|&i| schema.columns.get(i)) + .map(|c| ColumnRef(c.name.clone())) + .collect(), + None => vec![], + } +} + +/// If `child` is a `Scan` with an empty column list, populate it from the +/// columns referenced in `cols`. DataFusion's unoptimized plan never sets +/// `TableScan.projection`, so this compensates without requiring optimizer +/// passes that could alter other plan-node shapes our lowerer depends on. +fn push_columns_into_scan(child: QueryExpr, cols: &[ProjectItem]) -> QueryExpr { + let QueryExpr::Scan { + source: Source::Table { table_ref, columns, time_range }, + predicates, + } = child + else { + return child; + }; + if !columns.is_empty() { + return QueryExpr::Scan { + source: Source::Table { table_ref, columns, time_range }, + predicates, + }; + } + let mut seen = std::collections::HashSet::::new(); + let col_refs: Vec = cols + .iter() + .flat_map(|item| item.expr.columns_referenced()) + .map(|c| c.clone()) + .filter(|c| seen.insert(c.0.clone())) + .collect(); + QueryExpr::Scan { + source: Source::Table { table_ref, columns: col_refs, time_range }, + predicates, + } +} + fn make_node(expr: QueryExpr) -> Rc { Rc::new(L3Node { expr, schema: L3Schema { fields: vec![], time_index: None } }) } diff --git a/crates/lower/tests/sql_lowering.rs b/crates/lower/tests/sql_lowering.rs index a09d1846..b152b249 100644 --- a/crates/lower/tests/sql_lowering.rs +++ b/crates/lower/tests/sql_lowering.rs @@ -626,3 +626,91 @@ async fn test_unknown_table_returns_error() { "unexpected error variant: {err}" ); } + +// ── Tests: language guard ───────────────────────────────────────────────────── + +#[tokio::test] +async fn test_promql_workload_returns_wrong_language() { + let catalog = metrics_catalog(); + let workload = QueryWorkload { + language: QueryLanguage::PromQL, + query_batch: Some(vec![ + BatchEntry { query: Query("some_metric".into()), requirements: None }, + ]), + repeating_queries: None, + data_characteristics: None, + }; + let results = lower_batch(&workload, &catalog).await; + assert_eq!(results.len(), 1); + assert!( + matches!(results[0], Err(LoweringError::WrongLanguage(_))), + "expected WrongLanguage, got: {:?}", results[0] + ); +} + +#[tokio::test] +async fn test_elastic_dsl_workload_returns_wrong_language() { + let catalog = metrics_catalog(); + let workload = QueryWorkload { + language: QueryLanguage::ElasticDSL, + query_batch: Some(vec![ + BatchEntry { query: Query("{\"query\":{}}".into()), requirements: None }, + ]), + repeating_queries: None, + data_characteristics: None, + }; + let results = lower_batch(&workload, &catalog).await; + assert_eq!(results.len(), 1); + assert!(matches!(results[0], Err(LoweringError::WrongLanguage(_)))); +} + +#[tokio::test] +async fn test_datafusion_language_accepted() { + let catalog = metrics_catalog(); + let workload = QueryWorkload { + language: QueryLanguage::DataFusion, + query_batch: Some(vec![ + BatchEntry { query: Query("SELECT COUNT(*) FROM metrics".into()), requirements: None }, + ]), + repeating_queries: None, + data_characteristics: None, + }; + let results = lower_batch(&workload, &catalog).await; + assert_eq!(results.len(), 1); + assert!(results[0].is_ok(), "DataFusion language should be accepted"); +} + +// ── Tests: Source::Table.columns ───────────────────────────────────────────── + +#[tokio::test] +async fn test_scan_columns_populated_from_projection() { + // SELECT ts, value FROM metrics — DataFusion should push the projection + // into the TableScan; Source::Table.columns should name the two columns. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer.lower("SELECT ts, value FROM metrics").await.unwrap(); + + let source = find_source(&result).expect("expected a Scan"); + let Source::Table { columns, .. } = source else { + panic!("expected Source::Table"); + }; + // At minimum, each named column should appear in the list. + let names: Vec<&str> = columns.iter().map(|c| c.0.as_str()).collect(); + assert!(names.contains(&"ts"), "expected 'ts' in columns, got: {names:?}"); + assert!(names.contains(&"value"), "expected 'value' in columns, got: {names:?}"); +} + +#[tokio::test] +async fn test_scan_columns_empty_for_select_star() { + // SELECT * — no projection pushdown; Source::Table.columns stays empty + // (meaning "all columns"; cost estimator treats empty as unconstrained). + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer.lower("SELECT * FROM metrics").await.unwrap(); + + let source = find_source(&result).expect("expected a Scan"); + let Source::Table { columns, .. } = source else { + panic!("expected Source::Table"); + }; + assert!(columns.is_empty(), "SELECT * should produce no column constraints, got: {columns:?}"); +} From 27dac1392e3a02da961d56590b259914991a5b93 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Mon, 18 May 2026 09:31:28 -0400 Subject: [PATCH 06/17] Added better logic for time extraction, added SQL dialects --- crates/lower/src/error.rs | 3 + crates/lower/src/lib.rs | 16 +++- crates/lower/src/sql.rs | 52 ++++++++---- crates/lower/tests/sql_lowering.rs | 127 +++++++++++++++++++++++++++++ 4 files changed, 178 insertions(+), 20 deletions(-) diff --git a/crates/lower/src/error.rs b/crates/lower/src/error.rs index 23f3cb84..987cc405 100644 --- a/crates/lower/src/error.rs +++ b/crates/lower/src/error.rs @@ -11,6 +11,8 @@ pub enum LoweringError { InvalidExpression(String), /// The workload's query language is not handled by this lowerer. WrongLanguage(String), + /// The SQL dialect is not supported (only DataFusionSQL is implemented). + UnsupportedDialect(String), } impl fmt::Display for LoweringError { @@ -25,6 +27,7 @@ impl fmt::Display for LoweringError { Self::UnsupportedAggregate(name) => write!(f, "unsupported aggregate: {name}"), Self::InvalidExpression(msg) => write!(f, "invalid expression: {msg}"), Self::WrongLanguage(lang) => write!(f, "unsupported query language: {lang}"), + Self::UnsupportedDialect(d) => write!(f, "unsupported SQL dialect: {d}"), } } } diff --git a/crates/lower/src/lib.rs b/crates/lower/src/lib.rs index 7f81302c..addd5343 100644 --- a/crates/lower/src/lib.rs +++ b/crates/lower/src/lib.rs @@ -4,7 +4,7 @@ pub mod sql; use asap_control_core::intent_algebra::expr::QueryExpr; use asap_control_core::intent_algebra::schema::SchemaCatalog; use asap_control_core::types::AccuracyTarget; -use asap_control_core::workload::{QueryLanguage, QueryWorkload}; +use asap_control_core::workload::{QueryLanguage, QueryWorkload, SqlDialect}; pub use error::LoweringError; pub use sql::SqlLowerer; @@ -23,7 +23,7 @@ pub async fn lower_batch( _ => return vec![], }; - // Guard: only SQL dialects are handled by this lowerer. + // Guard: only SQL languages are handled by this lowerer. if !matches!(workload.language, QueryLanguage::SQL(_) | QueryLanguage::DataFusion) { let lang = format!("{:?}", workload.language); return entries @@ -32,6 +32,18 @@ pub async fn lower_batch( .collect(); } + // Guard: only DataFusionSQL is implemented; other SQL dialects need their + // own parser and plan-conversion layer before reaching SqlLowerer. + if let QueryLanguage::SQL(dialect) = &workload.language { + if !matches!(dialect, SqlDialect::DataFusionSQL) { + let d = format!("{dialect:?}"); + return entries + .iter() + .map(|_| Err(LoweringError::UnsupportedDialect(d.clone()))) + .collect(); + } + } + let mut results = Vec::with_capacity(entries.len()); for entry in entries { let accuracy = entry diff --git a/crates/lower/src/sql.rs b/crates/lower/src/sql.rs index 7d0f58cb..cfb23c8f 100644 --- a/crates/lower/src/sql.rs +++ b/crates/lower/src/sql.rs @@ -476,6 +476,10 @@ pub(crate) fn extract_time_range<'a>( TimeClass::End(ms) => { end_ms = Some(end_ms.map_or(ms, |e: i64| e.min(ms))); } + TimeClass::Both(lo, hi) => { + start_ms = Some(start_ms.map_or(lo, |s: i64| s.max(lo))); + end_ms = Some(end_ms.map_or(hi, |e: i64| e.min(hi))); + } TimeClass::NonTime => non_time.push(c), } } @@ -640,30 +644,42 @@ fn split_disjuncts(expr: &Expr) -> Vec<&Expr> { enum TimeClass { Start(i64), End(i64), + /// BETWEEN low AND high on the time column — contributes both bounds at once. + Both(i64, i64), NonTime, } fn classify_time_pred(expr: &Expr, time_col: &str) -> TimeClass { - let Expr::BinaryExpr(BinaryExpr { left, op, right }) = expr else { - return TimeClass::NonTime; - }; - let (col_is_left, val_expr): (bool, &Expr) = if is_time_col(left, time_col) { - (true, right) - } else if is_time_col(right, time_col) { - (false, left) - } else { - return TimeClass::NonTime; - }; - let Some(ms) = expr_to_ms(val_expr) else { - return TimeClass::NonTime; - }; - match (op, col_is_left) { - (Operator::Gt | Operator::GtEq, true) | (Operator::Lt | Operator::LtEq, false) => { - TimeClass::Start(ms) + match expr { + // `ts BETWEEN low AND high` — contributes both a start and end bound. + // `ts NOT BETWEEN …` cannot be expressed as a contiguous TimeRange; treat as non-time. + Expr::Between(b) if !b.negated && is_time_col(&b.expr, time_col) => { + match (expr_to_ms(&b.low), expr_to_ms(&b.high)) { + (Some(lo), Some(hi)) => TimeClass::Both(lo, hi), + _ => TimeClass::NonTime, + } } - (Operator::Lt | Operator::LtEq, true) | (Operator::Gt | Operator::GtEq, false) => { - TimeClass::End(ms) + + Expr::BinaryExpr(BinaryExpr { left, op, right }) => { + let (col_is_left, val_expr): (bool, &Expr) = if is_time_col(left, time_col) { + (true, right) + } else if is_time_col(right, time_col) { + (false, left) + } else { + return TimeClass::NonTime; + }; + let Some(ms) = expr_to_ms(val_expr) else { + return TimeClass::NonTime; + }; + match (op, col_is_left) { + (Operator::Gt | Operator::GtEq, true) + | (Operator::Lt | Operator::LtEq, false) => TimeClass::Start(ms), + (Operator::Lt | Operator::LtEq, true) + | (Operator::Gt | Operator::GtEq, false) => TimeClass::End(ms), + _ => TimeClass::NonTime, + } } + _ => TimeClass::NonTime, } } diff --git a/crates/lower/tests/sql_lowering.rs b/crates/lower/tests/sql_lowering.rs index b152b249..42befb95 100644 --- a/crates/lower/tests/sql_lowering.rs +++ b/crates/lower/tests/sql_lowering.rs @@ -680,6 +680,133 @@ async fn test_datafusion_language_accepted() { assert!(results[0].is_ok(), "DataFusion language should be accepted"); } +// ── Tests: BETWEEN time extraction ─────────────────────────────────────────── + +#[tokio::test] +async fn test_time_between_extracted_to_source() { + // WHERE ts BETWEEN 1000 AND 2000 should set start_ms=1000, end_ms=2000. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT value FROM metrics WHERE ts BETWEEN 1000 AND 2000") + .await + .unwrap(); + + let source = find_source(&result).unwrap(); + let Source::Table { time_range, .. } = source else { + panic!("expected Source::Table"); + }; + let tr = time_range.as_ref().expect("expected time_range from BETWEEN"); + assert_eq!(tr.start_ms, Some(1000)); + assert_eq!(tr.end_ms, Some(2000)); +} + +#[tokio::test] +async fn test_time_between_leaves_no_filter_wrapper() { + // A BETWEEN-only predicate on the time column has no non-time residual, + // so no Filter node should appear. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT value FROM metrics WHERE ts BETWEEN 1000 AND 2000") + .await + .unwrap(); + + fn has_filter(e: &QueryExpr) -> bool { + match e { + QueryExpr::Filter { .. } => true, + QueryExpr::Project { child, .. } => has_filter(&child.expr), + _ => false, + } + } + assert!(!has_filter(&result), "BETWEEN on time col should leave no Filter wrapper"); +} + +#[tokio::test] +async fn test_time_between_mixed_with_non_time_predicate() { + // WHERE ts BETWEEN 1000 AND 2000 AND region = 'us': + // time range should be extracted; Filter stays for the non-time conjunct. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT value FROM metrics WHERE ts BETWEEN 1000 AND 2000 AND region = 'us'") + .await + .unwrap(); + + let source = find_source(&result).unwrap(); + let Source::Table { time_range, .. } = source else { + panic!("expected Source::Table"); + }; + let tr = time_range.as_ref().expect("expected time_range"); + assert_eq!(tr.start_ms, Some(1000)); + assert_eq!(tr.end_ms, Some(2000)); + + fn has_filter(e: &QueryExpr) -> bool { + match e { + QueryExpr::Filter { .. } => true, + QueryExpr::Project { child, .. } => has_filter(&child.expr), + _ => false, + } + } + assert!(has_filter(&result), "expected Filter for non-time predicate"); +} + +// ── Tests: multi-dialect SQL ────────────────────────────────────────────────── + +#[tokio::test] +async fn test_clickhouse_dialect_returns_unsupported_dialect() { + let catalog = metrics_catalog(); + let workload = QueryWorkload { + language: QueryLanguage::SQL(SqlDialect::ClickhouseSQL), + query_batch: Some(vec![ + BatchEntry { query: Query("SELECT COUNT(*) FROM metrics".into()), requirements: None }, + ]), + repeating_queries: None, + data_characteristics: None, + }; + let results = lower_batch(&workload, &catalog).await; + assert_eq!(results.len(), 1); + assert!( + matches!(results[0], Err(LoweringError::UnsupportedDialect(_))), + "expected UnsupportedDialect, got: {:?}", results[0] + ); +} + +#[tokio::test] +async fn test_elastic_sql_dialect_returns_unsupported_dialect() { + let catalog = metrics_catalog(); + let workload = QueryWorkload { + language: QueryLanguage::SQL(SqlDialect::ElasticSQL), + query_batch: Some(vec![ + BatchEntry { query: Query("{\"query\":{}}".into()), requirements: None }, + ]), + repeating_queries: None, + data_characteristics: None, + }; + let results = lower_batch(&workload, &catalog).await; + assert_eq!(results.len(), 1); + assert!( + matches!(results[0], Err(LoweringError::UnsupportedDialect(_))), + "expected UnsupportedDialect, got: {:?}", results[0] + ); +} + +#[tokio::test] +async fn test_datafusion_sql_dialect_accepted() { + let catalog = metrics_catalog(); + let workload = QueryWorkload { + language: QueryLanguage::SQL(SqlDialect::DataFusionSQL), + query_batch: Some(vec![ + BatchEntry { query: Query("SELECT COUNT(*) FROM metrics".into()), requirements: None }, + ]), + repeating_queries: None, + data_characteristics: None, + }; + let results = lower_batch(&workload, &catalog).await; + assert_eq!(results.len(), 1); + assert!(results[0].is_ok(), "SQL(DataFusionSQL) should be accepted, got: {:?}", results[0]); +} + // ── Tests: Source::Table.columns ───────────────────────────────────────────── #[tokio::test] From a62ace72c2f664a0a946b40d7d86c1a3ef8f1043 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Mon, 18 May 2026 09:43:33 -0400 Subject: [PATCH 07/17] added logic for schema derivation --- crates/core/src/intent_algebra/expr.rs | 46 ++++++ crates/core/tests/schema_derivation.rs | 209 ++++++++++++++++++++++++- crates/lower/src/sql.rs | 6 + 3 files changed, 259 insertions(+), 2 deletions(-) diff --git a/crates/core/src/intent_algebra/expr.rs b/crates/core/src/intent_algebra/expr.rs index eb9a2bfe..a1dbf86e 100644 --- a/crates/core/src/intent_algebra/expr.rs +++ b/crates/core/src/intent_algebra/expr.rs @@ -329,6 +329,9 @@ pub enum QueryExpr { WindowFunc { child: Rc, func: WindowFuncKind, + /// Expressions the function operates on (e.g. `LAG(value)` → `[Column("value")]`). + /// Empty for rank-only funcs (`ROW_NUMBER`, `RANK`, `DENSE_RANK`). + args: Vec, partition_by: Vec, order_by: Vec, frame: Option, @@ -474,6 +477,49 @@ impl HasSchema for QueryExpr { } } + // ── WindowFunc: child schema + one new column ───────────────────── + QueryExpr::WindowFunc { func, args, .. } => { + use super::schema::{L3DataType, L3Field}; + let cs = child(); + + // Resolve the first arg's type from the child schema. + // Falls back to Float64 for non-column exprs or unknown columns. + let arg_field = args.first().and_then(|a| match a { + L3Expr::Column(col_ref) => cs.fields.iter().find(|f| f.name == col_ref.0), + _ => None, + }); + let arg_dtype = + || arg_field.map_or(L3DataType::Float64, |f| f.dtype.clone()); + + let (win_name, win_dtype, win_nullable) = match func { + WindowFuncKind::RowNumber => ("row_number", L3DataType::Int64, false), + WindowFuncKind::Rank => ("rank", L3DataType::Int64, false), + WindowFuncKind::DenseRank => ("dense_rank", L3DataType::Int64, false), + WindowFuncKind::Count => ("count", L3DataType::Int64, false), + WindowFuncKind::Sum => ("sum", L3DataType::Float64, true), + WindowFuncKind::Avg => ("avg", L3DataType::Float64, true), + // Navigation funcs: same type as arg, always nullable (boundary rows) + WindowFuncKind::Lag => ("lag", arg_dtype(), true), + WindowFuncKind::Lead => ("lead", arg_dtype(), true), + WindowFuncKind::FirstValue => ("first_value", arg_dtype(), true), + WindowFuncKind::LastValue => ("last_value", arg_dtype(), true), + WindowFuncKind::NthValue(_) => ("nth_value", arg_dtype(), true), + // Min/Max: preserve input type and nullability + WindowFuncKind::Min => ("min", arg_dtype(), arg_field.map_or(true, |f| f.nullable)), + WindowFuncKind::Max => ("max", arg_dtype(), arg_field.map_or(true, |f| f.nullable)), + }; + + let mut fields = cs.fields.clone(); + fields.push(L3Field { name: win_name.to_string(), dtype: win_dtype, nullable: win_nullable }); + L3Schema { fields, time_index: cs.time_index } + } + + // ── Merge: all shards have identical schemas; use first ──────────── + QueryExpr::Merge { .. } => input_schemas[0].clone(), + + // ── SetOp: output is left-shaped (SQL semantics) ────────────────── + QueryExpr::SetOp { .. } => input_schemas[0].clone(), + // ── Everything else: not yet implemented ────────────────────────── _ => todo!( "output_schema not yet implemented for {:?}", diff --git a/crates/core/tests/schema_derivation.rs b/crates/core/tests/schema_derivation.rs index 7cd31b8d..b7b7dbe6 100644 --- a/crates/core/tests/schema_derivation.rs +++ b/crates/core/tests/schema_derivation.rs @@ -3,8 +3,8 @@ use std::rc::Rc; use asap_control_core::intent_algebra::{ AggIntent, ColumnRef, DataModel, GroupKey, HasSchema, L3DataType, L3Expr, L3Field, L3Node, - L3Scalar, L3Schema, Predicate, ProjectItem, QueryExpr, SortKey, Source, TableRef, ColumnDef, - SchemaCatalog, TableSchema, + L3Scalar, L3Schema, Predicate, ProjectItem, QueryExpr, SetOpKind, SortKey, Source, TableRef, + ColumnDef, SchemaCatalog, TableSchema, WindowFuncKind, }; use asap_control_core::types::AccuracyTarget; @@ -581,6 +581,211 @@ fn project_drops_time_index_when_time_col_excluded() { assert_eq!(out.time_index, None); } +// ── HasSchema::output_schema() — WindowFunc ────────────────────────────────── + +fn timed_two_col_schema() -> L3Schema { + schema_with_time( + vec![field("ts", L3DataType::Int64), nullable_field("value", L3DataType::Float64)], + 0, + ) +} + +#[test] +fn window_func_row_number_appends_int64_column() { + let cs = timed_two_col_schema(); + let node = QueryExpr::WindowFunc { + child: dummy_scan(cs.clone()), + func: WindowFuncKind::RowNumber, + args: vec![], + partition_by: vec![], + order_by: vec![], + frame: None, + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.fields.len(), 3); + let win = out.fields.last().unwrap(); + assert_eq!(win.dtype, L3DataType::Int64); + assert!(!win.nullable); +} + +#[test] +fn window_func_rank_appends_int64_column() { + let cs = timed_two_col_schema(); + let node = QueryExpr::WindowFunc { + child: dummy_scan(cs.clone()), + func: WindowFuncKind::Rank, + args: vec![], + partition_by: vec![], + order_by: vec![], + frame: None, + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + let win = out.fields.last().unwrap(); + assert_eq!(win.dtype, L3DataType::Int64); + assert!(!win.nullable); +} + +#[test] +fn window_func_lag_uses_arg_column_type() { + // LAG(value) → output type matches value: Float64, nullable + let cs = timed_two_col_schema(); + let node = QueryExpr::WindowFunc { + child: dummy_scan(cs.clone()), + func: WindowFuncKind::Lag, + args: vec![L3Expr::Column(ColumnRef("value".into()))], + partition_by: vec![], + order_by: vec![], + frame: None, + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + let win = out.fields.last().unwrap(); + assert_eq!(win.dtype, L3DataType::Float64); + assert!(win.nullable); +} + +#[test] +fn window_func_lag_int_col_preserves_type() { + // LAG(ts) → Int64, nullable + let cs = timed_two_col_schema(); + let node = QueryExpr::WindowFunc { + child: dummy_scan(cs.clone()), + func: WindowFuncKind::Lag, + args: vec![L3Expr::Column(ColumnRef("ts".into()))], + partition_by: vec![], + order_by: vec![], + frame: None, + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + let win = out.fields.last().unwrap(); + assert_eq!(win.dtype, L3DataType::Int64); + assert!(win.nullable); +} + +#[test] +fn window_func_min_preserves_arg_type() { + // MIN(ts) OVER (...) → Int64 (same as ts) + let cs = timed_two_col_schema(); + let node = QueryExpr::WindowFunc { + child: dummy_scan(cs.clone()), + func: WindowFuncKind::Min, + args: vec![L3Expr::Column(ColumnRef("ts".into()))], + partition_by: vec![], + order_by: vec![], + frame: None, + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + let win = out.fields.last().unwrap(); + assert_eq!(win.dtype, L3DataType::Int64); +} + +#[test] +fn window_func_count_appends_int64_not_nullable() { + let cs = timed_two_col_schema(); + let node = QueryExpr::WindowFunc { + child: dummy_scan(cs.clone()), + func: WindowFuncKind::Count, + args: vec![L3Expr::Column(ColumnRef("value".into()))], + partition_by: vec![], + order_by: vec![], + frame: None, + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + let win = out.fields.last().unwrap(); + assert_eq!(win.dtype, L3DataType::Int64); + assert!(!win.nullable); +} + +#[test] +fn window_func_preserves_child_fields_and_time_index() { + let cs = timed_two_col_schema(); + let node = QueryExpr::WindowFunc { + child: dummy_scan(cs.clone()), + func: WindowFuncKind::RowNumber, + args: vec![], + partition_by: vec![], + order_by: vec![], + frame: None, + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.fields[0].name, "ts"); + assert_eq!(out.fields[1].name, "value"); + assert_eq!(out.time_index, Some(0)); +} + +// ── HasSchema::output_schema() — Merge ─────────────────────────────────────── + +#[test] +fn merge_uses_first_child_schema() { + let cs = timed_two_col_schema(); + let node = QueryExpr::Merge { children: vec![dummy_scan(cs.clone()), dummy_scan(cs.clone())] }; + let out = node.output_schema(&[&cs, &cs], &empty_catalog()); + assert_eq!(out.fields, cs.fields); + assert_eq!(out.time_index, cs.time_index); +} + +// ── HasSchema::output_schema() — SetOp ─────────────────────────────────────── + +fn right_schema() -> L3Schema { + schema(vec![field("a", L3DataType::Int64), field("b", L3DataType::Utf8)]) +} + +#[test] +fn set_op_union_uses_left_schema() { + let left = timed_two_col_schema(); + let right = right_schema(); + let node = QueryExpr::SetOp { + kind: SetOpKind::Union, + all: false, + left: dummy_scan(left.clone()), + right: dummy_scan(right.clone()), + }; + let out = node.output_schema(&[&left, &right], &empty_catalog()); + assert_eq!(out.fields[0].name, "ts"); + assert_eq!(out.fields[1].name, "value"); +} + +#[test] +fn set_op_intersect_uses_left_schema() { + let left = timed_two_col_schema(); + let right = right_schema(); + let node = QueryExpr::SetOp { + kind: SetOpKind::Intersect, + all: false, + left: dummy_scan(left.clone()), + right: dummy_scan(right.clone()), + }; + let out = node.output_schema(&[&left, &right], &empty_catalog()); + assert_eq!(out.fields, left.fields); +} + +#[test] +fn set_op_except_uses_left_schema() { + let left = timed_two_col_schema(); + let right = right_schema(); + let node = QueryExpr::SetOp { + kind: SetOpKind::Except, + all: false, + left: dummy_scan(left.clone()), + right: dummy_scan(right.clone()), + }; + let out = node.output_schema(&[&left, &right], &empty_catalog()); + assert_eq!(out.fields, left.fields); +} + +#[test] +fn set_op_preserves_time_index_from_left() { + let left = timed_two_col_schema(); // time_index = Some(0) + let right = right_schema(); // time_index = None + let node = QueryExpr::SetOp { + kind: SetOpKind::Union, + all: true, + left: dummy_scan(left.clone()), + right: dummy_scan(right.clone()), + }; + let out = node.output_schema(&[&left, &right], &empty_catalog()); + assert_eq!(out.time_index, Some(0)); +} + #[test] fn project_time_index_tracks_aliased_time_col() { // SELECT ts AS t — aliased; time_index should still point at the right output position. diff --git a/crates/lower/src/sql.rs b/crates/lower/src/sql.rs index cfb23c8f..041a081c 100644 --- a/crates/lower/src/sql.rs +++ b/crates/lower/src/sql.rs @@ -266,6 +266,11 @@ impl<'a> SqlLowerer<'a> { .ok_or_else(|| LoweringError::InvalidExpression("empty window expressions".into()))?; if let Expr::WindowFunction(wf) = first { let func = lower_window_func_kind(&wf.fun)?; + let args = wf + .args + .iter() + .map(df_expr_to_l3) + .collect::, _>>()?; let partition_by = wf .partition_by .iter() @@ -286,6 +291,7 @@ impl<'a> SqlLowerer<'a> { return Ok(QueryExpr::WindowFunc { child: make_node(child), func, + args, partition_by, order_by, frame: None, From ac22e40a2f61190e435e09db8de72611a05532a9 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Mon, 18 May 2026 12:05:08 -0400 Subject: [PATCH 08/17] added more schema derivation and support for other operators --- crates/core/src/intent_algebra/expr.rs | 34 +++- crates/core/src/intent_algebra/expr_ir.rs | 43 +++++ crates/core/src/intent_algebra/mod.rs | 2 +- crates/core/tests/expr_ir.rs | 93 +++++++++- crates/core/tests/schema_derivation.rs | 105 ++++++++++- crates/lower/src/lib.rs | 2 + crates/lower/src/schema_pass.rs | 94 ++++++++++ crates/lower/src/sql.rs | 85 ++++++++- crates/lower/tests/sql_lowering.rs | 215 +++++++++++++++++++++- 9 files changed, 656 insertions(+), 17 deletions(-) create mode 100644 crates/lower/src/schema_pass.rs diff --git a/crates/core/src/intent_algebra/expr.rs b/crates/core/src/intent_algebra/expr.rs index a1dbf86e..5085e010 100644 --- a/crates/core/src/intent_algebra/expr.rs +++ b/crates/core/src/intent_algebra/expr.rs @@ -384,11 +384,12 @@ impl HasSchema for QueryExpr { // ── Project: one output field per ProjectItem ───────────────────── QueryExpr::Project { cols, .. } => { + use super::expr_ir::L3Scalar; + use super::schema::L3DataType; + let cs = child(); let time_col_src = cs.time_index.map(|ti| cs.fields[ti].name.clone()); - // Build (field, is_time_col) pairs. Non-Column exprs are deferred - // until a type-inference pass exists. let pairs: Vec<(L3Field, bool)> = cols .iter() .map(|item| match &item.expr { @@ -397,7 +398,7 @@ impl HasSchema for QueryExpr { cs.fields.iter().find(|f| f.name == col_ref.0); let (dtype, nullable) = child_f .map(|f| (f.dtype.clone(), f.nullable)) - .unwrap_or((super::schema::L3DataType::Float64, true)); + .unwrap_or((L3DataType::Float64, true)); let out_name = item .alias .as_deref() @@ -407,10 +408,29 @@ impl HasSchema for QueryExpr { time_col_src.as_deref() == Some(col_ref.0.as_str()); (L3Field { name: out_name, dtype, nullable }, is_time) } - _ => todo!( - "Project schema derivation for non-column expressions \ - (type inference not yet implemented)" - ), + // CAST: output type is the cast target. + L3Expr::Cast { to, .. } => { + let name = item.alias.as_deref().unwrap_or("cast").to_string(); + (L3Field { name, dtype: to.clone(), nullable: true }, false) + } + // Literal: infer type from the scalar variant. + L3Expr::Literal(scalar) => { + let (dtype, nullable) = match scalar { + L3Scalar::Int64(_) => (L3DataType::Int64, false), + L3Scalar::Float64(_) => (L3DataType::Float64, false), + L3Scalar::Utf8(_) => (L3DataType::Utf8, false), + L3Scalar::Boolean(_) => (L3DataType::Boolean, false), + L3Scalar::Null => (L3DataType::Float64, true), + }; + let name = item.alias.as_deref().unwrap_or("literal").to_string(); + (L3Field { name, dtype, nullable }, false) + } + // Arithmetic, CASE, function calls, boolean exprs: + // default to Float64 (full type inference is future work). + _ => { + let name = item.alias.as_deref().unwrap_or("expr").to_string(); + (L3Field { name, dtype: L3DataType::Float64, nullable: true }, false) + } }) .collect(); diff --git a/crates/core/src/intent_algebra/expr_ir.rs b/crates/core/src/intent_algebra/expr_ir.rs index e79119f1..cdcc1392 100644 --- a/crates/core/src/intent_algebra/expr_ir.rs +++ b/crates/core/src/intent_algebra/expr_ir.rs @@ -26,6 +26,20 @@ pub enum CompareOp { Ge, Like, NotLike, + ILike, + NotILike, +} + +// ── Arithmetic operators ────────────────────────────────────────────────────── + +/// Binary arithmetic operators for `L3Expr::Arith`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ArithOp { + Add, + Sub, + Mul, + Div, + Mod, } // ── Expression IR ───────────────────────────────────────────────────────────── @@ -58,6 +72,16 @@ pub enum L3Expr { InList { expr: Box, list: Vec, negated: bool }, /// Scalar function call, e.g. `LOWER(col)`, `ABS(x)`. FunctionCall { name: String, args: Vec }, + /// Binary arithmetic: `left op right`. + Arith { op: ArithOp, left: Box, right: Box }, + /// SQL `CASE` expression (both searched and simple forms). + /// `operand` is present for simple CASE (`CASE expr WHEN ...`), + /// absent for searched CASE (`CASE WHEN condition THEN ...`). + Case { + operand: Option>, + branches: Vec<(L3Expr, L3Expr)>, + else_expr: Option>, + }, } impl L3Expr { @@ -106,6 +130,25 @@ impl L3Expr { L3Expr::FunctionCall { args, .. } => { args.iter().flat_map(|e| e.columns_referenced()).collect() } + L3Expr::Arith { left, right, .. } => { + let mut v = left.columns_referenced(); + v.extend(right.columns_referenced()); + v + } + L3Expr::Case { operand, branches, else_expr } => { + let mut v = vec![]; + if let Some(op) = operand { + v.extend(op.columns_referenced()); + } + for (when, then) in branches { + v.extend(when.columns_referenced()); + v.extend(then.columns_referenced()); + } + if let Some(e) = else_expr { + v.extend(e.columns_referenced()); + } + v + } } } } diff --git a/crates/core/src/intent_algebra/mod.rs b/crates/core/src/intent_algebra/mod.rs index f689b4d4..a4b2ea58 100644 --- a/crates/core/src/intent_algebra/mod.rs +++ b/crates/core/src/intent_algebra/mod.rs @@ -8,5 +8,5 @@ pub use expr::{ SortKey, Source, TableRef, TimeRange, TimeWindowKind, VectorMatch, WindowFrame, WindowFuncKind, }; -pub use expr_ir::{CompareOp, L3Expr, L3Scalar}; +pub use expr_ir::{ArithOp, CompareOp, L3Expr, L3Scalar}; pub use schema::{ColumnDef, HasSchema, L3DataType, L3Field, L3Schema, SchemaCatalog, TableSchema}; diff --git a/crates/core/tests/expr_ir.rs b/crates/core/tests/expr_ir.rs index 5c2869d0..eb3eccf7 100644 --- a/crates/core/tests/expr_ir.rs +++ b/crates/core/tests/expr_ir.rs @@ -1,4 +1,4 @@ -use asap_control_core::intent_algebra::{ColumnRef, CompareOp, L3Expr, L3Scalar}; +use asap_control_core::intent_algebra::{ArithOp, ColumnRef, CompareOp, L3Expr, L3Scalar}; // ── L3Scalar ────────────────────────────────────────────────────────────────── @@ -206,3 +206,94 @@ fn columns_referenced_from_function_call() { assert_eq!(refs.len(), 1); assert_eq!(refs[0].0, "host"); } + +// ── ArithOp / L3Expr::Arith ─────────────────────────────────────────────────── + +#[test] +fn arith_eq() { + let make = || L3Expr::Arith { + op: ArithOp::Mul, + left: Box::new(L3Expr::Column(ColumnRef("value".into()))), + right: Box::new(L3Expr::Literal(L3Scalar::Float64(2.0))), + }; + assert_eq!(make(), make()); +} + +#[test] +fn arith_columns_referenced_from_both_sides() { + let expr = L3Expr::Arith { + op: ArithOp::Add, + left: Box::new(L3Expr::Column(ColumnRef("a".into()))), + right: Box::new(L3Expr::Column(ColumnRef("b".into()))), + }; + let refs = expr.columns_referenced(); + assert_eq!(refs.len(), 2); + assert!(refs.iter().any(|r| r.0 == "a")); + assert!(refs.iter().any(|r| r.0 == "b")); +} + +#[test] +fn arith_columns_referenced_literal_side_is_empty() { + let expr = L3Expr::Arith { + op: ArithOp::Mul, + left: Box::new(L3Expr::Column(ColumnRef("value".into()))), + right: Box::new(L3Expr::Literal(L3Scalar::Float64(2.0))), + }; + let refs = expr.columns_referenced(); + assert_eq!(refs.len(), 1); + assert_eq!(refs[0].0, "value"); +} + +// ── L3Expr::Case ───────────────────────────────────────────────────────────── + +#[test] +fn case_searched_columns_referenced_from_all_branches() { + // CASE WHEN a > 0 THEN b ELSE c END + let expr = L3Expr::Case { + operand: None, + branches: vec![( + L3Expr::Compare { + left: Box::new(L3Expr::Column(ColumnRef("a".into()))), + op: CompareOp::Gt, + right: Box::new(L3Expr::Literal(L3Scalar::Int64(0))), + }, + L3Expr::Column(ColumnRef("b".into())), + )], + else_expr: Some(Box::new(L3Expr::Column(ColumnRef("c".into())))), + }; + let refs = expr.columns_referenced(); + assert_eq!(refs.len(), 3); + assert!(refs.iter().any(|r| r.0 == "a")); + assert!(refs.iter().any(|r| r.0 == "b")); + assert!(refs.iter().any(|r| r.0 == "c")); +} + +#[test] +fn case_simple_operand_included_in_refs() { + // CASE value WHEN 1 THEN x END + let expr = L3Expr::Case { + operand: Some(Box::new(L3Expr::Column(ColumnRef("value".into())))), + branches: vec![( + L3Expr::Literal(L3Scalar::Int64(1)), + L3Expr::Column(ColumnRef("x".into())), + )], + else_expr: None, + }; + let refs = expr.columns_referenced(); + assert!(refs.iter().any(|r| r.0 == "value")); + assert!(refs.iter().any(|r| r.0 == "x")); +} + +// ── CompareOp::ILike / NotILike ─────────────────────────────────────────────── + +#[test] +fn compare_op_ilike_eq() { + assert_eq!(CompareOp::ILike, CompareOp::ILike); + assert_ne!(CompareOp::ILike, CompareOp::Like); +} + +#[test] +fn compare_op_not_ilike_eq() { + assert_eq!(CompareOp::NotILike, CompareOp::NotILike); + assert_ne!(CompareOp::NotILike, CompareOp::NotLike); +} diff --git a/crates/core/tests/schema_derivation.rs b/crates/core/tests/schema_derivation.rs index b7b7dbe6..d2e990bd 100644 --- a/crates/core/tests/schema_derivation.rs +++ b/crates/core/tests/schema_derivation.rs @@ -2,9 +2,9 @@ use std::collections::HashMap; use std::rc::Rc; use asap_control_core::intent_algebra::{ - AggIntent, ColumnRef, DataModel, GroupKey, HasSchema, L3DataType, L3Expr, L3Field, L3Node, - L3Scalar, L3Schema, Predicate, ProjectItem, QueryExpr, SetOpKind, SortKey, Source, TableRef, - ColumnDef, SchemaCatalog, TableSchema, WindowFuncKind, + AggIntent, ArithOp, ColumnRef, DataModel, GroupKey, HasSchema, L3DataType, L3Expr, L3Field, + L3Node, L3Scalar, L3Schema, Predicate, ProjectItem, QueryExpr, SetOpKind, SortKey, Source, + TableRef, ColumnDef, SchemaCatalog, TableSchema, WindowFuncKind, }; use asap_control_core::types::AccuracyTarget; @@ -786,6 +786,105 @@ fn set_op_preserves_time_index_from_left() { assert_eq!(out.time_index, Some(0)); } +// ── HasSchema::output_schema() — Project, non-column items ─────────────────── + +#[test] +fn project_cast_item_uses_target_type() { + // SELECT CAST(ts AS FLOAT64) AS ts_f — output type is the cast target. + let cs = child_schema(); // [ts: Int64, value: Float64] + let node = QueryExpr::Project { + child: dummy_scan(cs.clone()), + cols: vec![ProjectItem { + expr: L3Expr::Cast { + expr: Box::new(L3Expr::Column(ColumnRef("ts".into()))), + to: L3DataType::Float64, + }, + alias: Some("ts_f".into()), + }], + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.fields.len(), 1); + assert_eq!(out.fields[0].name, "ts_f"); + assert_eq!(out.fields[0].dtype, L3DataType::Float64); +} + +#[test] +fn project_int_literal_item_uses_int64_type() { + // SELECT 42 AS n — output type is Int64. + let cs = child_schema(); + let node = QueryExpr::Project { + child: dummy_scan(cs.clone()), + cols: vec![ProjectItem { + expr: L3Expr::Literal(L3Scalar::Int64(42)), + alias: Some("n".into()), + }], + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.fields[0].dtype, L3DataType::Int64); + assert_eq!(out.fields[0].name, "n"); +} + +#[test] +fn project_float_literal_item_uses_float64_type() { + let cs = child_schema(); + let node = QueryExpr::Project { + child: dummy_scan(cs.clone()), + cols: vec![ProjectItem { + expr: L3Expr::Literal(L3Scalar::Float64(3.14)), + alias: Some("pi".into()), + }], + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.fields[0].dtype, L3DataType::Float64); +} + +#[test] +fn project_arith_item_defaults_to_float64() { + // SELECT value * 2 AS doubled — arithmetic defaults to Float64. + let cs = child_schema(); + let node = QueryExpr::Project { + child: dummy_scan(cs.clone()), + cols: vec![ProjectItem { + expr: L3Expr::Arith { + op: ArithOp::Mul, + left: Box::new(L3Expr::Column(ColumnRef("value".into()))), + right: Box::new(L3Expr::Literal(L3Scalar::Int64(2))), + }, + alias: Some("doubled".into()), + }], + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.fields[0].dtype, L3DataType::Float64); + assert_eq!(out.fields[0].name, "doubled"); +} + +#[test] +fn project_case_item_defaults_to_float64() { + // CASE WHEN value > 0 THEN 1 ELSE 0 END — defaults to Float64. + let cs = child_schema(); + let node = QueryExpr::Project { + child: dummy_scan(cs.clone()), + cols: vec![ProjectItem { + expr: L3Expr::Case { + operand: None, + branches: vec![( + L3Expr::Compare { + left: Box::new(L3Expr::Column(ColumnRef("value".into()))), + op: asap_control_core::intent_algebra::CompareOp::Gt, + right: Box::new(L3Expr::Literal(L3Scalar::Float64(0.0))), + }, + L3Expr::Literal(L3Scalar::Int64(1)), + )], + else_expr: Some(Box::new(L3Expr::Literal(L3Scalar::Int64(0)))), + }, + alias: Some("tier".into()), + }], + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.fields[0].dtype, L3DataType::Float64); + assert_eq!(out.fields[0].name, "tier"); +} + #[test] fn project_time_index_tracks_aliased_time_col() { // SELECT ts AS t — aliased; time_index should still point at the right output position. diff --git a/crates/lower/src/lib.rs b/crates/lower/src/lib.rs index addd5343..6b0ae8f0 100644 --- a/crates/lower/src/lib.rs +++ b/crates/lower/src/lib.rs @@ -1,4 +1,5 @@ pub mod error; +pub mod schema_pass; pub mod sql; use asap_control_core::intent_algebra::expr::QueryExpr; @@ -7,6 +8,7 @@ use asap_control_core::types::AccuracyTarget; use asap_control_core::workload::{QueryLanguage, QueryWorkload, SqlDialect}; pub use error::LoweringError; +pub use schema_pass::populate_schemas; pub use sql::SqlLowerer; /// Lower every SQL batch entry in `workload` to a `QueryExpr`. diff --git a/crates/lower/src/schema_pass.rs b/crates/lower/src/schema_pass.rs new file mode 100644 index 00000000..c79d42c3 --- /dev/null +++ b/crates/lower/src/schema_pass.rs @@ -0,0 +1,94 @@ +use std::rc::Rc; + +use asap_control_core::intent_algebra::expr::QueryExpr; +use asap_control_core::intent_algebra::schema::{HasSchema, L3Schema, SchemaCatalog}; +use asap_control_core::intent_algebra::L3Node; + +/// Recursively populate the `schema` field on every node in a `QueryExpr` tree. +/// +/// The lowerer creates every node with an empty schema (`make_node`). This +/// pass walks the tree bottom-up, computing each node's output schema from +/// its children's schemas and the catalog, and returns a fully typed +/// `Rc` tree. +pub fn populate_schemas(expr: QueryExpr, catalog: &SchemaCatalog) -> Rc { + let (rebuilt, child_schemas) = rebuild(expr, catalog); + let refs: Vec<&L3Schema> = child_schemas.iter().collect(); + let schema = rebuilt.output_schema(&refs, catalog); + Rc::new(L3Node { expr: rebuilt, schema }) +} + +/// Recursively rebuild the expression tree with populated child nodes. +/// Returns `(rebuilt_expr, child_output_schemas)` so the caller can pass +/// those schemas to `output_schema`. +fn rebuild(expr: QueryExpr, catalog: &SchemaCatalog) -> (QueryExpr, Vec) { + use QueryExpr::*; + + // Helper: process one child Rc → fresh Rc with schema set. + let proc = |node: Rc| populate_schemas(node.expr.clone(), catalog); + + match expr { + // Leaf: schema comes from the catalog, no child schemas needed. + Scan { .. } => (expr, vec![]), + + Filter { child, pred } => { + let c = proc(child); + let cs = c.schema.clone(); + (Filter { child: c, pred }, vec![cs]) + } + Project { child, cols } => { + let c = proc(child); + let cs = c.schema.clone(); + (Project { child: c, cols }, vec![cs]) + } + Aggregate { child, by, aggs, having } => { + let c = proc(child); + let cs = c.schema.clone(); + (Aggregate { child: c, by, aggs, having }, vec![cs]) + } + Sort { child, keys } => { + let c = proc(child); + let cs = c.schema.clone(); + (Sort { child: c, keys }, vec![cs]) + } + Limit { child, n, offset } => { + let c = proc(child); + let cs = c.schema.clone(); + (Limit { child: c, n, offset }, vec![cs]) + } + Distinct { child, cols } => { + let c = proc(child); + let cs = c.schema.clone(); + (Distinct { child: c, cols }, vec![cs]) + } + Partition { child, keys } => { + let c = proc(child); + let cs = c.schema.clone(); + (Partition { child: c, keys }, vec![cs]) + } + TimeWindow { child, kind, size, slide } => { + let c = proc(child); + let cs = c.schema.clone(); + (TimeWindow { child: c, kind, size, slide }, vec![cs]) + } + WindowFunc { child, func, args, partition_by, order_by, frame } => { + let c = proc(child); + let cs = c.schema.clone(); + (WindowFunc { child: c, func, args, partition_by, order_by, frame }, vec![cs]) + } + SetOp { kind, all, left, right } => { + let l = proc(left); + let r = proc(right); + let ls = l.schema.clone(); + let rs = r.schema.clone(); + (SetOp { kind, all, left: l, right: r }, vec![ls, rs]) + } + Merge { children } => { + let new_children: Vec> = + children.into_iter().map(proc).collect(); + let schemas: Vec = new_children.iter().map(|c| c.schema.clone()).collect(); + (Merge { children: new_children }, schemas) + } + // Unimplemented variants: return as-is; output_schema will todo!() if called. + other => (other, vec![]), + } +} diff --git a/crates/lower/src/sql.rs b/crates/lower/src/sql.rs index 041a081c..9bca071b 100644 --- a/crates/lower/src/sql.rs +++ b/crates/lower/src/sql.rs @@ -10,11 +10,11 @@ use datafusion::logical_expr::{ use datafusion::prelude::SessionContext; use asap_control_core::intent_algebra::expr::{ - AggIntent, ColumnRef, GroupKey, L3Node, Predicate, ProjectItem, QueryExpr, SortKey, Source, - TableRef, TimeRange, WindowFuncKind, + AggIntent, ColumnRef, GroupKey, L3Node, Predicate, ProjectItem, QueryExpr, SetOpKind, SortKey, + Source, TableRef, TimeRange, WindowFuncKind, }; use asap_control_core::intent_algebra::schema::{L3DataType, L3Schema, SchemaCatalog, TableSchema}; -use asap_control_core::intent_algebra::{CompareOp, L3Expr, L3Scalar}; +use asap_control_core::intent_algebra::{ArithOp, CompareOp, L3Expr, L3Scalar}; use asap_control_core::types::AccuracyTarget; use crate::error::LoweringError; @@ -63,6 +63,23 @@ impl<'a> SqlLowerer<'a> { let child = self.lower_plan(input)?; Ok(QueryExpr::Distinct { child: make_node(child), cols: vec![] }) } + LogicalPlan::Union(u) => { + // Fold n inputs left-associatively into SetOp { Union, all: true }. + let mut iter = u.inputs.iter(); + let first = iter.next().ok_or_else(|| { + LoweringError::InvalidExpression("empty union".into()) + })?; + let first_expr = self.lower_plan(first)?; + iter.try_fold(first_expr, |left, right_plan| { + let right = self.lower_plan(right_plan)?; + Ok(QueryExpr::SetOp { + kind: SetOpKind::Union, + all: true, + left: make_node(left), + right: make_node(right), + }) + }) + } LogicalPlan::Join(_) => { Err(LoweringError::UnsupportedFeature("JOIN".into())) } @@ -538,11 +555,65 @@ fn df_expr_to_l3(expr: &Expr) -> Result { Operator::LtEq => compare(left, CompareOp::Le, right), Operator::Gt => compare(left, CompareOp::Gt, right), Operator::GtEq => compare(left, CompareOp::Ge, right), + // BinaryExpr LIKE/ILIKE operators (from optimizer rewrites) Operator::LikeMatch => compare(left, CompareOp::Like, right), + Operator::ILikeMatch => compare(left, CompareOp::ILike, right), Operator::NotLikeMatch => compare(left, CompareOp::NotLike, right), + Operator::NotILikeMatch => compare(left, CompareOp::NotILike, right), + // Arithmetic + Operator::Plus => arith(left, ArithOp::Add, right), + Operator::Minus => arith(left, ArithOp::Sub, right), + Operator::Multiply => arith(left, ArithOp::Mul, right), + Operator::Divide => arith(left, ArithOp::Div, right), + Operator::Modulo => arith(left, ArithOp::Mod, right), other => Err(LoweringError::UnsupportedFeature(format!("operator: {other:?}"))), }, + // SQL LIKE / ILIKE (dedicated expr node from the SQL parser) + Expr::Like(like) => { + let op = match (like.negated, like.case_insensitive) { + (false, false) => CompareOp::Like, + (true, false) => CompareOp::NotLike, + (false, true) => CompareOp::ILike, + (true, true) => CompareOp::NotILike, + }; + compare(&like.expr, op, &like.pattern) + } + + // Unary minus: negate literals directly; wrap others in -1 * x. + Expr::Negative(inner) => { + let inner_l3 = df_expr_to_l3(inner)?; + match inner_l3 { + L3Expr::Literal(L3Scalar::Int64(v)) => Ok(L3Expr::Literal(L3Scalar::Int64(-v))), + L3Expr::Literal(L3Scalar::Float64(v)) => Ok(L3Expr::Literal(L3Scalar::Float64(-v))), + other => Ok(L3Expr::Arith { + op: ArithOp::Mul, + left: Box::new(L3Expr::Literal(L3Scalar::Int64(-1))), + right: Box::new(other), + }), + } + } + + // SQL CASE expression + Expr::Case(c) => { + let operand = c + .expr + .as_ref() + .map(|e| df_expr_to_l3(e).map(Box::new)) + .transpose()?; + let branches = c + .when_then_expr + .iter() + .map(|(when, then)| Ok((df_expr_to_l3(when)?, df_expr_to_l3(then)?))) + .collect::, LoweringError>>()?; + let else_expr = c + .else_expr + .as_ref() + .map(|e| df_expr_to_l3(e).map(Box::new)) + .transpose()?; + Ok(L3Expr::Case { operand, branches, else_expr }) + } + Expr::Not(inner) => Ok(L3Expr::Not(Box::new(df_expr_to_l3(inner)?))), Expr::IsNull(inner) => Ok(L3Expr::IsNull(Box::new(df_expr_to_l3(inner)?))), @@ -599,6 +670,14 @@ fn compare(left: &Expr, op: CompareOp, right: &Expr) -> Result Result { + Ok(L3Expr::Arith { + op, + left: Box::new(df_expr_to_l3(left)?), + right: Box::new(df_expr_to_l3(right)?), + }) +} + fn scalar_value_to_l3(sv: &ScalarValue) -> Result { match sv { ScalarValue::Int64(Some(v)) => Ok(L3Scalar::Int64(*v)), diff --git a/crates/lower/tests/sql_lowering.rs b/crates/lower/tests/sql_lowering.rs index 42befb95..846b745e 100644 --- a/crates/lower/tests/sql_lowering.rs +++ b/crates/lower/tests/sql_lowering.rs @@ -2,10 +2,10 @@ use std::collections::HashMap; use asap_control_core::intent_algebra::expr::{AggIntent, Predicate, ProjectItem, QueryExpr, SortKey, Source}; use asap_control_core::intent_algebra::schema::{ColumnDef, L3DataType, SchemaCatalog, TableSchema}; -use asap_control_core::intent_algebra::{CompareOp, L3Expr}; +use asap_control_core::intent_algebra::{CompareOp, L3Expr, SetOpKind}; use asap_control_core::types::AccuracyTarget; use asap_control_core::workload::{BatchEntry, Query, QueryLanguage, QueryWorkload, SqlDialect}; -use asap_control_lower::{lower_batch, LoweringError, SqlLowerer}; +use asap_control_lower::{lower_batch, populate_schemas, LoweringError, SqlLowerer}; // ── Catalog helpers ─────────────────────────────────────────────────────────── @@ -50,6 +50,11 @@ fn no_time_catalog() -> SchemaCatalog { data_type: L3DataType::Float64, nullable: true, }, + ColumnDef { + name: "name".to_string(), + data_type: L3DataType::Utf8, + nullable: true, + }, ], time_column: None, }, @@ -841,3 +846,209 @@ async fn test_scan_columns_empty_for_select_star() { }; assert!(columns.is_empty(), "SELECT * should produce no column constraints, got: {columns:?}"); } + +// ── Tests: UNION / UNION ALL ────────────────────────────────────────────────── + +#[tokio::test] +async fn test_union_all_produces_set_op_union_node() { + // UNION ALL — no dedup — maps to SetOp { kind: Union, all: true }. + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT id FROM events UNION ALL SELECT id FROM events") + .await + .unwrap(); + + fn find_set_op(e: &QueryExpr) -> Option<(&SetOpKind, bool)> { + match e { + QueryExpr::SetOp { kind, all, .. } => Some((kind, *all)), + QueryExpr::Project { child, .. } => find_set_op(&child.expr), + _ => None, + } + } + let (kind, all) = find_set_op(&result).expect("expected SetOp node"); + assert!(matches!(kind, SetOpKind::Union)); + assert!(all, "UNION ALL should set all=true"); +} + +#[tokio::test] +async fn test_union_distinct_produces_distinct_over_set_op() { + // UNION (without ALL) = UNION DISTINCT = Distinct wrapping SetOp. + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT id FROM events UNION SELECT id FROM events") + .await + .unwrap(); + + fn has_distinct(e: &QueryExpr) -> bool { + match e { + QueryExpr::Distinct { .. } => true, + QueryExpr::Project { child, .. } => has_distinct(&child.expr), + _ => false, + } + } + assert!(has_distinct(&result), "UNION DISTINCT should produce a Distinct node"); +} + +// ── Tests: arithmetic / negative / LIKE / CASE in expressions ──────────────── + +#[tokio::test] +async fn test_arithmetic_in_projection_succeeds() { + // SELECT value * 2 FROM events — should not return UnsupportedFeature. + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer.lower("SELECT value * 2.0 FROM events").await; + assert!(result.is_ok(), "arithmetic in projection should succeed, got: {result:?}"); +} + +#[tokio::test] +async fn test_arithmetic_in_predicate_succeeds() { + // WHERE value * 0.9 > 5.0 — should not return UnsupportedFeature. + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT id FROM events WHERE value * 0.9 > 5.0") + .await; + assert!(result.is_ok(), "arithmetic in predicate should succeed, got: {result:?}"); +} + +#[tokio::test] +async fn test_arithmetic_predicate_references_column() { + // Predicate from `value * 0.9 > 5` should still reference "value". + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT id FROM events WHERE value * 0.9 > 5.0") + .await + .unwrap(); + let pred = find_predicate(&result).expect("expected Filter predicate"); + let refs = pred.columns_referenced(); + assert!(refs.iter().any(|r| r.0 == "value"), "expected 'value' in predicate refs"); +} + +#[tokio::test] +async fn test_negative_literal_in_predicate_succeeds() { + // WHERE value > -1 — unary minus should not return UnsupportedFeature. + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT id FROM events WHERE value > -1.0") + .await; + assert!(result.is_ok(), "unary minus in predicate should succeed, got: {result:?}"); +} + +#[tokio::test] +async fn test_like_predicate_produces_compare_like() { + // WHERE name LIKE 'a%' → Compare { op: Like, .. } + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT id FROM events WHERE name LIKE 'a%'") + .await + .unwrap(); + let pred = find_predicate(&result).expect("expected Filter predicate"); + assert!( + matches!(pred, L3Expr::Compare { op: CompareOp::Like, .. }), + "expected Compare(Like), got: {pred:?}" + ); +} + +#[tokio::test] +async fn test_ilike_predicate_produces_compare_ilike() { + // WHERE name ILIKE 'A%' → Compare { op: ILike, .. } + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT id FROM events WHERE name ILIKE 'A%'") + .await + .unwrap(); + let pred = find_predicate(&result).expect("expected Filter predicate"); + assert!( + matches!(pred, L3Expr::Compare { op: CompareOp::ILike, .. }), + "expected Compare(ILike), got: {pred:?}" + ); +} + +#[tokio::test] +async fn test_case_in_projection_succeeds() { + // CASE WHEN value > 5 THEN 1 ELSE 0 END — should not return UnsupportedFeature. + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT CASE WHEN value > 5.0 THEN 1 ELSE 0 END AS tier FROM events") + .await; + assert!(result.is_ok(), "CASE WHEN in projection should succeed, got: {result:?}"); +} + +#[tokio::test] +async fn test_case_projection_item_is_case_expr() { + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT CASE WHEN value > 5.0 THEN 1 ELSE 0 END AS tier FROM events") + .await + .unwrap(); + let items = find_project_items(&result).expect("expected Project node"); + let has_case = items.iter().any(|pi| matches!(pi.expr, L3Expr::Case { .. })); + assert!(has_case, "expected a Case expr in project items, got: {items:?}"); +} + +// ── Tests: populate_schemas ─────────────────────────────────────────────────── + +#[tokio::test] +async fn test_populate_schemas_scan_gets_catalog_schema() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let expr = lowerer.lower("SELECT * FROM metrics").await.unwrap(); + + let typed = populate_schemas(expr, &catalog); + assert_eq!(typed.schema.fields.len(), 4, "scan schema should have 4 fields from catalog"); + assert_eq!(typed.schema.fields[0].name, "ts"); + assert_eq!(typed.schema.time_index, Some(0)); +} + +#[tokio::test] +async fn test_populate_schemas_project_gives_subset_schema() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let expr = lowerer.lower("SELECT ts, value FROM metrics").await.unwrap(); + + let typed = populate_schemas(expr, &catalog); + assert_eq!(typed.schema.fields.len(), 2); + assert_eq!(typed.schema.fields[0].name, "ts"); + assert_eq!(typed.schema.fields[1].name, "value"); + assert_eq!(typed.schema.time_index, Some(0)); +} + +#[tokio::test] +async fn test_populate_schemas_inner_nodes_are_typed() { + // The child of the root Project (a Scan) should also have a non-empty schema. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let expr = lowerer.lower("SELECT ts, value FROM metrics").await.unwrap(); + + let typed = populate_schemas(expr, &catalog); + let QueryExpr::Project { child, .. } = &typed.expr else { + panic!("expected Project at root"); + }; + assert!( + !child.schema.fields.is_empty(), + "child Scan should have a populated schema after populate_schemas" + ); +} + +#[tokio::test] +async fn test_populate_schemas_aggregate() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let expr = lowerer + .lower("SELECT region, COUNT(*) FROM metrics GROUP BY region") + .await + .unwrap(); + + let typed = populate_schemas(expr, &catalog); + // Root is Project wrapping Aggregate; two fields: region + count col. + assert_eq!(typed.schema.fields.len(), 2); + assert_eq!(typed.schema.fields[0].name, "region"); +} From 74bcd671f569b094bd1c32c8c7ecddc28a8cbe9c Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Mon, 18 May 2026 13:19:47 -0400 Subject: [PATCH 09/17] added TODO --- TODO.md | 158 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 TODO.md diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..04976fa2 --- /dev/null +++ b/TODO.md @@ -0,0 +1,158 @@ +# ASAPController — Outstanding Work + +Tracks what is built, what is stubbed, and what has not been started. +Organized from most immediate (unblocks the next step) to longest-horizon. + +--- + +## 1. Immediate code stubs (`todo!()` / unit-struct stubs) + +These are compile-time safe but will panic or silently drop information at runtime. + +### `crates/core` — expression stubs + +| Status | Location | What | Impact | +|---|---|---|---| +| ✅ done | `intent_algebra/expr.rs` | `AggIntent::requires() -> DataModel` | L4 rules can now gate on data model | +| ✅ done | `intent_algebra/expr.rs` | `AggIntent::output_type()` | L3 schema derivation for `Aggregate` works | +| ✅ done | `intent_algebra/expr.rs` | `HasSchema::output_schema()` — Scan, pass-through, Aggregate | Typed edges exist for all SQL-path nodes | +| ✅ done | `intent_algebra/expr_ir.rs` | `L3Expr` IR — `L3Scalar`, `CompareOp`, `L3Expr` with `conjuncts` / `columns_referenced` | Predicate, ProjectItem, SortKey now carry real expression content | +| ✅ done | `intent_algebra/expr.rs` | `Predicate(L3Expr)` — wraps a real filter expression | Filter nodes carry inspectable predicates | +| ✅ done | `intent_algebra/expr.rs` | `ProjectItem { expr: L3Expr, alias }` | Project nodes carry column expressions with aliases | +| ✅ done | `intent_algebra/expr.rs` | `SortKey { expr: L3Expr, ascending, nulls_first }` | Sort keys carry direction and expression | +| ✅ done | `lower/sql.rs` | `df_expr_to_l3` — DataFusion `Expr` → `L3Expr` translator | Lowerer populates predicates, project items, sort keys | +| ✅ done | `intent_algebra/expr_ir.rs` | `ArithOp` enum + `L3Expr::Arith` — binary arithmetic in expression IR | Arithmetic in predicates and projections now fully lowerable | +| ✅ done | `intent_algebra/expr_ir.rs` | `L3Expr::Case` — CASE WHEN / CASE expr WHEN | SQL CASE lowers to inspectable IR node | +| ✅ done | `intent_algebra/expr_ir.rs` | `CompareOp::ILike / NotILike` — case-insensitive LIKE | ILIKE predicates now in the IR | +| ✅ done | `intent_algebra/expr.rs` | `Project` schema for non-column items: `Cast → to`, `Literal → scalar type`, `Arith/Case/FunctionCall → Float64` default | `todo!()` removed; `populate_schemas` no longer panics on computed projections | +| ⬜ blocked | `intent_algebra/expr.rs` | `WindowFrame`, `VectorMatch`, `LabelFilter`, `MetricRef`, `PartitionKeys`, `JoinKey` — unit structs | PromQL and join paths structurally incomplete; unblocked only when those paths are implemented | + +--- + +## 2. `crates/lower` gaps + +| Status | Item | +|---|---| +| ✅ done | `lower_batch` integration tests — empty batch, per-query success, per-query error isolation | +| ✅ done | Language guard: `lower_batch` checks `workload.language`; returns `Err(LoweringError::WrongLanguage)` for non-SQL dialects. | +| ✅ done | `Source::Table.columns` — populated from the enclosing `Projection` node's column refs (DataFusion unoptimized plan never sets `TableScan.projection`; `SELECT *` leaves columns empty = "all columns"). | +| ✅ done | Time-range extraction: `BETWEEN low AND high` on the time column now folds both bounds into `Source::Table.time_range` (previously returned as non-time residual). Recursive lowering already handles `Filter → Aggregate → Filter → TableScan` correctly; the only real gap was `Expr::Between`. | +| ✅ done | Multi-dialect SQL guard — `SQL(ClickhouseSQL \| ElasticSQL)` now returns `LoweringError::UnsupportedDialect` instead of silently falling through to DataFusion's parser. Only `SQL(DataFusionSQL)` and `QueryLanguage::DataFusion` reach `SqlLowerer`. Full dialect support (sqlparser-rs parse + DF plan conversion) remains deferred. | +| ✅ done | `lower/sql.rs` — LIKE / ILIKE: `Expr::Like { case_insensitive }` → `Compare { ILike / Like }` | +| ✅ done | `lower/sql.rs` — arithmetic: `Operator::Plus/Minus/Multiply/Divide/Modulo` → `L3Expr::Arith` | +| ✅ done | `lower/sql.rs` — unary minus: `Expr::Negative` → negate literal or wrap in `Arith(Mul, -1, x)` | +| ✅ done | `lower/sql.rs` — CASE: `Expr::Case` → `L3Expr::Case` | +| ✅ done | `lower/sql.rs` — UNION: `LogicalPlan::Union` → left-associative `SetOp { Union, all: true }`; UNION DISTINCT handled by existing Distinct arm | +| ✅ done | `lower/schema_pass.rs` — `populate_schemas(expr, catalog) -> Rc` bottom-up schema pass | +| ⬜ deferred | CTEs (`WITH … AS …`) — lower to `QueryExpr::LetBinding`. DataFusion inlines or wraps in `LogicalPlan::Recursive`. | +| ⬜ deferred | Subqueries / inline views — `FROM (SELECT …) AS alias` currently returns `UnsupportedFeature`. | + +--- + +## 3. Core type system — schema derivation + +`HasSchema::output_schema` on `QueryExpr` is the full schema-derivation pass. +Node-by-node status: + +- ✅ `Scan` — reads columns from `SchemaCatalog`; sets `time_index` from `time_column` +- ✅ `Filter`, `Sort`, `Limit`, `Distinct`, `Partition`, `TimeWindow` — pass-through child schema +- ✅ `Aggregate` — `by` columns + one output column per `AggIntent`; TopK special-cased (by-cols + synthetic `count`) +- ✅ `Project` — Column items look up field in child schema (alias renames); time_index tracks the time col through reordering and aliasing. Non-column exprs (`Cast`, `FunctionCall`) remain `todo!()` until type inference exists. +- ✅ `Merge` — pass-through first child schema (all shards share the same schema) +- ✅ `WindowFunc` — child schema + one appended column; type derived from `WindowFuncKind` and `args: Vec` (added to IR); ranking funcs → `Int64` not-nullable; nav funcs (`Lag`/`Lead`/etc.) → arg type, nullable; `Min`/`Max` → preserve arg type +- ✅ `SetOp` — left schema (UNION / INTERSECT / EXCEPT output is left-shaped); `time_index` propagated from left +- ⬜ `Join` — merge left + right schemas; handle column-name collisions with table-qualified names (deferred until JOIN lowering is implemented) +- ⬜ `BinaryOp`, `LetBinding`, `Subquery`, `Ref` — blocked on those query paths existing + +--- + +## 4. L4 — sketch binding (not started) + +The entire L4 layer (`SummaryExpr` types exist in `core` but nothing produces +them yet). + +### Rule engine +A fixed-point rewrite engine that walks an L3 `QueryExpr` DAG, matches rules, +and emits an L4 `SummaryExpr` DAG. Core should own the engine; deployment +models inject rule sets. + +### Bind rules (one per AggIntent × SummaryKind pair) +Each rule matches a specific `AggIntent` variant and, given +`DeploymentConstraints` and an `AccuracyTarget`, selects a `SummaryKind` + +`SummaryParams`. Minimum set for the SQL path: + +| AggIntent | Candidate SummaryKind | +|---|---| +| `Count` | `Count` (exact), `Cms` (approx) | +| `Sum` | `Sum` (exact), `Cms` (approx) | +| `Min` / `Max` | `MinMax` (exact), `Kll` (approx) | +| `Avg` | `Sum` + `Count` pair, or `Kll` | +| `Stddev` | exact accumulator (Welford) — no sketch analog today | +| `Quantile` | `Kll`, `DDSketch` | +| `Cardinality` | `Hll` | +| `TopK` | `CmsWithHeap` | + +### Cost model trait +Trait `CostModel` with `plan_cost(plan: &SummaryExpr, constraints: &DeploymentConstraints) -> Cost`. +`Cost` should carry accuracy estimate, latency estimate, and transmission bytes. +L4 uses cost to pick among bind-rule alternatives. + +### `DeploymentConstraints` +Input to L4: memory budget per stage, sketch catalogue (which +`SummaryKind`s are available in this deployment), topology (number of stages). +Currently not defined anywhere. + +### Schema derivation for L4 +`L4Node.schema` is always empty today (same as L3). Implement +`HasL4Schema` — `SummaryAgg` emits a `Sketch(kind, params)` column; +`SummaryEstimate` collapses it back to a primitive column. + +--- + +## 5. L5 — stage allocation and emission (not started) + +### Stage allocator +Colors the L4 DAG by `StageId`. For the SQL path (single-stage), this is +trivial: assign every node `StageId(0)`. For multi-stage deployments (DC's +3-stage topology), this is the main algorithm. + +### `PlanEmitter` trait +Converts a stage-allocated L4 DAG to an output format. Needed +implementations per deployment model: +- `OpAMP RemoteConfig` YAML (asap-lifecycle) +- `StreamingConfig` POST body (asap-query) +- Rewritten DataFusion `LogicalPlan` (asap-fusion) + +--- + +## 6. PromQL lowering path (not started) + +`QueryLanguage::PromQL` workloads have no lowerer. Per the migration plan +(Phase 4), this requires: + +1. Define `PromqlLogicalPlan` (L2 tree) in `core::logical_plan::promql` — + five pattern shapes from asap-planner-rs as first-class nodes. +2. Implement `PromqlLowerer`: promql-parser AST → `PromqlLogicalPlan` (L1→L2). +3. Implement L2→L3: `PromqlLogicalPlan` → `QueryExpr` with `Source::TimeSeries` + leaves, `AggIntent::Rate` / `AggIntent::Increase` for counter-based metrics. +4. Add PromQL tests mirroring the SQL test suite. + +--- + +## 7. Deployment model crates (not started, per migration plan) + +| Crate | Phase | Scope | +|---|---|---| +| `deployment-model-asapfusion` | 3 | Thin model: picks core L4 rules, DataFusion emitter | +| `deployment-model-asapquery` | 4 | Migrates asap-planner-rs; adds PromQL L2 tree | +| `deployment-model-asaplifecycle` | 5 | DC-specific cost models, OpAMP emitter, 3-stage topology | + +--- + +## 8. Infrastructure / integration + +- **`cargo clippy` clean pass** — ✅ done for `lower` crate; dead-code warnings on stub types in `core` remain until those paths are implemented. +- **HTTP entry point** — no server exists yet; `lower_batch` is a library function with no HTTP handler wired up. +- **End-to-end test** — no test goes from a raw SQL string all the way to a `SummaryExpr` tree; blocked on L4 existing. +- **Benchmarks** — no criterion benchmarks for the lowering path; add at least one for `lower_batch` over a realistic query corpus. Unblocked now. +- **Phase 7 cleanup** — once all three deployment models land, revisit `todo!()`s and `#[allow(unused)]` in Phase 1 trait stubs. From d9ebf17fc2f9961106e586724916b5bc4a614124 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Tue, 19 May 2026 10:43:28 -0400 Subject: [PATCH 10/17] fixed comments --- crates/core/src/intent_algebra/expr.rs | 78 +++++-- crates/core/src/intent_algebra/schema.rs | 15 ++ crates/core/tests/schema_derivation.rs | 117 ++++++++-- crates/lower/src/schema_pass.rs | 2 + crates/lower/src/sql.rs | 188 +++++++++++----- crates/lower/tests/sql_lowering.rs | 267 ++++++++++++++++++++++- 6 files changed, 566 insertions(+), 101 deletions(-) diff --git a/crates/core/src/intent_algebra/expr.rs b/crates/core/src/intent_algebra/expr.rs index 02cbed54..0258ea32 100644 --- a/crates/core/src/intent_algebra/expr.rs +++ b/crates/core/src/intent_algebra/expr.rs @@ -193,12 +193,21 @@ pub enum AggIntent { Count { accuracy: AccuracyTarget, }, - Sum, - Min, - Max, - Avg, + Sum { + col: ColumnRef, + }, + Min { + col: ColumnRef, + }, + Max { + col: ColumnRef, + }, + Avg { + col: ColumnRef, + }, /// Sample stddev when `population == false`; population stddev otherwise. Stddev { + col: ColumnRef, population: bool, }, Quantile { @@ -239,6 +248,19 @@ impl AggIntent { } } + /// The column this intent aggregates, if tracked. Used by schema derivation + /// to resolve the actual field type (e.g. so `MIN(ts: Int64)` → `Int64`). + pub fn col(&self) -> Option<&ColumnRef> { + match self { + Self::Sum { col } + | Self::Min { col } + | Self::Max { col } + | Self::Avg { col } + | Self::Stddev { col, .. } => Some(col), + _ => None, + } + } + /// Output column type for a single-column aggregate result. /// /// `input` is the field being aggregated; used by `Min` and `Max` to @@ -251,9 +273,9 @@ impl AggIntent { use super::schema::L3DataType; match self { Self::Count { .. } | Self::Cardinality { .. } => L3DataType::Int64, - Self::Min | Self::Max => input.dtype.clone(), - Self::Sum - | Self::Avg + Self::Min { .. } | Self::Max { .. } => input.dtype.clone(), + Self::Sum { .. } + | Self::Avg { .. } | Self::Stddev { .. } | Self::Quantile { .. } | Self::Rate { .. } @@ -316,6 +338,12 @@ pub enum QueryExpr { by: Vec, aggs: Vec, having: Option, + /// Output column names parallel to `aggs`. When non-empty (populated by + /// the SQL lowerer from DataFusion's aggregate schema), `output_schema` + /// uses these names so that an enclosing `Project` can resolve aggregate + /// outputs by the names DataFusion assigned them (e.g. `"MIN(metrics.ts)"`). + /// Empty = fall back to synthetic `"agg_{i}"` names. + output_names: Vec, }, // ── Time / streaming windows ────────────────────────────────────────────── @@ -376,7 +404,8 @@ pub enum QueryExpr { }, Limit { child: Rc, - n: u64, + /// `None` means no upper bound (only an offset applies). + n: Option, offset: u64, }, @@ -543,7 +572,12 @@ impl HasSchema for QueryExpr { | QueryExpr::TimeWindow { .. } => child().clone(), // ── Aggregate: GROUP BY cols + one output col per AggIntent ─────── - QueryExpr::Aggregate { by, aggs, .. } => { + QueryExpr::Aggregate { + by, + aggs, + output_names, + .. + } => { let cs = child(); // TopK is the only multi-column AggIntent: produces the TopK @@ -566,10 +600,9 @@ impl HasSchema for QueryExpr { } // General case: GROUP BY fields (preserving child type) followed - // by one output field per AggIntent. We use a Float64 dummy as - // the input field to output_type because L3 AggIntent does not - // track the aggregated column (known limitation; see TODO.md). - let dummy = L3Field { + // by one output field per AggIntent. Use the tracked col to look + // up the real field type; fall back to Float64 for untracked intents. + let float64_dummy = L3Field { name: String::new(), dtype: super::schema::L3DataType::Float64, nullable: true, @@ -581,10 +614,21 @@ impl HasSchema for QueryExpr { let agg_fields: Vec = aggs .iter() .enumerate() - .map(|(i, agg)| L3Field { - name: format!("agg_{i}"), - dtype: agg.output_type(&dummy), - nullable: true, + .map(|(i, agg)| { + let col_field = agg + .col() + .and_then(|c| cs.fields.iter().find(|f| f.name == c.0)) + .cloned() + .unwrap_or_else(|| float64_dummy.clone()); + let name = output_names + .get(i) + .cloned() + .unwrap_or_else(|| format!("agg_{i}")); + L3Field { + name, + dtype: agg.output_type(&col_field), + nullable: true, + } }) .collect(); L3Schema { diff --git a/crates/core/src/intent_algebra/schema.rs b/crates/core/src/intent_algebra/schema.rs index 53bb2622..76fb3a66 100644 --- a/crates/core/src/intent_algebra/schema.rs +++ b/crates/core/src/intent_algebra/schema.rs @@ -18,6 +18,21 @@ pub struct TableSchema { pub time_column: Option, } +impl TableSchema { + /// Returns `Err` if `time_column` names a column that does not exist in `columns`. + pub fn validate(&self) -> Result<(), String> { + if let Some(tc) = &self.time_column { + if !self.columns.iter().any(|c| &c.name == tc) { + return Err(format!( + "time_column '{tc}' not found in table columns {:?}", + self.columns.iter().map(|c| &c.name).collect::>() + )); + } + } + Ok(()) + } +} + /// One column in a `TableSchema`. #[derive(Debug, Clone)] pub struct ColumnDef { diff --git a/crates/core/tests/schema_derivation.rs b/crates/core/tests/schema_derivation.rs index e6c73caf..ec3fef98 100644 --- a/crates/core/tests/schema_derivation.rs +++ b/crates/core/tests/schema_derivation.rs @@ -116,28 +116,56 @@ fn requires_count_is_any() { #[test] fn requires_sum_is_any() { - assert_eq!(AggIntent::Sum.requires(), DataModel::Any); + assert_eq!( + AggIntent::Sum { + col: ColumnRef("x".into()) + } + .requires(), + DataModel::Any + ); } #[test] fn requires_min_is_any() { - assert_eq!(AggIntent::Min.requires(), DataModel::Any); + assert_eq!( + AggIntent::Min { + col: ColumnRef("x".into()) + } + .requires(), + DataModel::Any + ); } #[test] fn requires_max_is_any() { - assert_eq!(AggIntent::Max.requires(), DataModel::Any); + assert_eq!( + AggIntent::Max { + col: ColumnRef("x".into()) + } + .requires(), + DataModel::Any + ); } #[test] fn requires_avg_is_any() { - assert_eq!(AggIntent::Avg.requires(), DataModel::Any); + assert_eq!( + AggIntent::Avg { + col: ColumnRef("x".into()) + } + .requires(), + DataModel::Any + ); } #[test] fn requires_stddev_sample_is_any() { assert_eq!( - AggIntent::Stddev { population: false }.requires(), + AggIntent::Stddev { + col: ColumnRef("x".into()), + population: false + } + .requires(), DataModel::Any ); } @@ -145,7 +173,11 @@ fn requires_stddev_sample_is_any() { #[test] fn requires_stddev_population_is_any() { assert_eq!( - AggIntent::Stddev { population: true }.requires(), + AggIntent::Stddev { + col: ColumnRef("x".into()), + population: true + } + .requires(), DataModel::Any ); } @@ -241,20 +273,36 @@ fn output_type_cardinality_is_int64() { #[test] fn output_type_sum_is_float64() { let f = field("value", L3DataType::Float64); - assert_eq!(AggIntent::Sum.output_type(&f), L3DataType::Float64); + assert_eq!( + AggIntent::Sum { + col: ColumnRef("value".into()) + } + .output_type(&f), + L3DataType::Float64 + ); } #[test] fn output_type_avg_is_float64() { let f = field("value", L3DataType::Int64); - assert_eq!(AggIntent::Avg.output_type(&f), L3DataType::Float64); + assert_eq!( + AggIntent::Avg { + col: ColumnRef("value".into()) + } + .output_type(&f), + L3DataType::Float64 + ); } #[test] fn output_type_stddev_sample_is_float64() { let f = field("value", L3DataType::Float64); assert_eq!( - AggIntent::Stddev { population: false }.output_type(&f), + AggIntent::Stddev { + col: ColumnRef("value".into()), + population: false + } + .output_type(&f), L3DataType::Float64 ); } @@ -263,7 +311,11 @@ fn output_type_stddev_sample_is_float64() { fn output_type_stddev_population_is_float64() { let f = field("value", L3DataType::Float64); assert_eq!( - AggIntent::Stddev { population: true }.output_type(&f), + AggIntent::Stddev { + col: ColumnRef("value".into()), + population: true + } + .output_type(&f), L3DataType::Float64 ); } @@ -308,19 +360,37 @@ fn output_type_increase_is_float64() { #[test] fn output_type_min_preserves_int64_input() { let f = field("count", L3DataType::Int64); - assert_eq!(AggIntent::Min.output_type(&f), L3DataType::Int64); + assert_eq!( + AggIntent::Min { + col: ColumnRef("count".into()) + } + .output_type(&f), + L3DataType::Int64 + ); } #[test] fn output_type_min_preserves_float64_input() { let f = field("value", L3DataType::Float64); - assert_eq!(AggIntent::Min.output_type(&f), L3DataType::Float64); + assert_eq!( + AggIntent::Min { + col: ColumnRef("value".into()) + } + .output_type(&f), + L3DataType::Float64 + ); } #[test] fn output_type_max_preserves_utf8_input() { let f = field("name", L3DataType::Utf8); - assert_eq!(AggIntent::Max.output_type(&f), L3DataType::Utf8); + assert_eq!( + AggIntent::Max { + col: ColumnRef("name".into()) + } + .output_type(&f), + L3DataType::Utf8 + ); } // ── HasSchema::output_schema() — Scan ───────────────────────────────────────── @@ -431,7 +501,7 @@ fn limit_passes_through_child_schema() { let cs = child_schema(); let node = QueryExpr::Limit { child: dummy_scan(cs.clone()), - n: 10, + n: Some(10), offset: 0, }; let out = node.output_schema(&[&cs], &empty_catalog()); @@ -469,6 +539,7 @@ fn aggregate_count_star_no_group_by() { by: vec![], aggs: vec![AggIntent::Count { accuracy: exact() }], having: None, + output_names: vec![], }; let out = node.output_schema(&[&cs], &empty_catalog()); assert_eq!(out.fields.len(), 1); @@ -484,6 +555,7 @@ fn aggregate_group_by_adds_by_cols_first() { by: vec![GroupKey("region".into())], aggs: vec![AggIntent::Count { accuracy: exact() }], having: None, + output_names: vec![], }; let out = node.output_schema(&[&cs], &empty_catalog()); // region col + count col @@ -502,17 +574,21 @@ fn aggregate_multiple_aggs() { by: vec![], aggs: vec![ AggIntent::Count { accuracy: exact() }, - AggIntent::Sum, - AggIntent::Min, + AggIntent::Sum { + col: ColumnRef("value".into()), + }, + AggIntent::Min { + col: ColumnRef("value".into()), + }, ], having: None, + output_names: vec![], }; let out = node.output_schema(&[&cs], &empty_catalog()); assert_eq!(out.fields.len(), 3); assert_eq!(out.fields[0].dtype, L3DataType::Int64); // Count - assert_eq!(out.fields[1].dtype, L3DataType::Float64); // Sum - // Min: uses dummy Float64 input (known limitation — column not tracked at L3) - assert_eq!(out.fields[2].dtype, L3DataType::Float64); + assert_eq!(out.fields[1].dtype, L3DataType::Float64); // Sum(value: Float64) + assert_eq!(out.fields[2].dtype, L3DataType::Float64); // Min(value: Float64) } #[test] @@ -528,6 +604,7 @@ fn aggregate_topk_produces_by_cols_plus_count() { accuracy: exact(), }], having: None, + output_names: vec![], }; let out = node.output_schema(&[&cs], &empty_catalog()); assert_eq!(out.fields.len(), 2); @@ -550,6 +627,7 @@ fn aggregate_topk_multi_key() { accuracy: exact(), }], having: None, + output_names: vec![], }; let out = node.output_schema(&[&cs], &empty_catalog()); assert_eq!(out.fields.len(), 3); @@ -574,6 +652,7 @@ fn aggregate_time_index_not_propagated() { by: vec![], aggs: vec![AggIntent::Count { accuracy: exact() }], having: None, + output_names: vec![], }; let out = node.output_schema(&[&cs], &empty_catalog()); assert_eq!(out.time_index, None); diff --git a/crates/lower/src/schema_pass.rs b/crates/lower/src/schema_pass.rs index 0a79daf3..56853a1d 100644 --- a/crates/lower/src/schema_pass.rs +++ b/crates/lower/src/schema_pass.rs @@ -48,6 +48,7 @@ fn rebuild(expr: QueryExpr, catalog: &SchemaCatalog) -> (QueryExpr, Vec { let c = proc(child); let cs = c.schema.clone(); @@ -57,6 +58,7 @@ fn rebuild(expr: QueryExpr, catalog: &SchemaCatalog) -> (QueryExpr, Vec SqlLowerer<'a> { let table_name = scan.table_name.to_string(); if let Some(schema) = self.catalog.tables.get(&table_name) { if let Some(time_col) = &schema.time_column { + debug_assert!( + schema.columns.iter().any(|c| &c.name == time_col), + "time_column '{time_col}' not found in table columns; call TableSchema::validate() on catalog construction" + ); let (time_range, non_time) = extract_time_range(&filter.predicate, time_col); let columns = projection_columns(scan, schema); let scan_expr = QueryExpr::Scan { @@ -208,11 +212,16 @@ impl<'a> SqlLowerer<'a> { .iter() .map(|e| self.lower_agg_expr(e)) .collect::, _>>()?; + // Use DataFusion's display string for each aggregate expression — this is + // exactly the name DataFusion assigns to the output column (e.g. "MIN(metrics.ts)"), + // which the enclosing Projection uses to reference aggregate outputs. + let output_names: Vec = agg.aggr_expr.iter().map(|e| format!("{e}")).collect(); Ok(QueryExpr::Aggregate { child: make_node(child), by, aggs, having: None, + output_names, }) } @@ -259,7 +268,7 @@ impl<'a> SqlLowerer<'a> { let child = self.lower_plan(&limit.input)?; Ok(QueryExpr::Limit { child: make_node(child), - n: eval_fetch(&limit.fetch).unwrap_or(0) as u64, + n: eval_fetch(&limit.fetch).map(|v| v as u64), offset: eval_fetch(&limit.skip).unwrap_or(0) as u64, }) } @@ -284,10 +293,17 @@ impl<'a> SqlLowerer<'a> { accuracy: self.accuracy.clone(), }], having: None, + output_names: vec![], }) } fn lower_window(&self, window: &logical_expr::Window) -> Result { + if window.window_expr.len() > 1 { + return Err(LoweringError::UnsupportedFeature(format!( + "multiple window functions in one Window plan node (got {}); split into separate nodes", + window.window_expr.len() + ))); + } let child = self.lower_plan(&window.input)?; let first = window .window_expr @@ -295,11 +311,28 @@ impl<'a> SqlLowerer<'a> { .ok_or_else(|| LoweringError::InvalidExpression("empty window expressions".into()))?; if let Expr::WindowFunction(wf) = first { let func = lower_window_func_kind(&wf.fun)?; - let args = wf + let mut args = wf .args .iter() .map(df_expr_to_l3) .collect::, _>>()?; + + // For NthValue, extract N from args[1] and keep only the column (args[0]). + let func = if matches!(func, WindowFuncKind::NthValue(_)) { + let n = match args.get(1) { + Some(L3Expr::Literal(L3Scalar::Int64(n))) if *n > 0 => *n as u64, + other => { + return Err(LoweringError::InvalidExpression(format!( + "NthValue requires a positive integer literal as second arg, got: {other:?}" + ))) + } + }; + args.truncate(1); + WindowFuncKind::NthValue(n) + } else { + func + }; + let partition_by = wf .partition_by .iter() @@ -342,12 +375,26 @@ impl<'a> SqlLowerer<'a> { "count" => Ok(AggIntent::Count { accuracy: self.accuracy.clone(), }), - "sum" => Ok(AggIntent::Sum), - "min" => Ok(AggIntent::Min), - "max" => Ok(AggIntent::Max), - "avg" | "mean" => Ok(AggIntent::Avg), - "stddev" | "stddev_samp" => Ok(AggIntent::Stddev { population: false }), - "stddev_pop" => Ok(AggIntent::Stddev { population: true }), + "sum" => Ok(AggIntent::Sum { + col: agg_col(&agg_fn.args), + }), + "min" => Ok(AggIntent::Min { + col: agg_col(&agg_fn.args), + }), + "max" => Ok(AggIntent::Max { + col: agg_col(&agg_fn.args), + }), + "avg" | "mean" => Ok(AggIntent::Avg { + col: agg_col(&agg_fn.args), + }), + "stddev" | "stddev_samp" => Ok(AggIntent::Stddev { + col: agg_col(&agg_fn.args), + population: false, + }), + "stddev_pop" => Ok(AggIntent::Stddev { + col: agg_col(&agg_fn.args), + population: true, + }), "approx_percentile_cont" | "percentile_cont" => { let q = extract_percentile_q(&agg_fn.args)?; Ok(AggIntent::Quantile { @@ -383,47 +430,50 @@ fn projection_columns(scan: &logical_expr::TableScan, schema: &TableSchema) -> V } } -/// If `child` is a `Scan` with an empty column list, populate it from the -/// columns referenced in `cols`. DataFusion's unoptimized plan never sets -/// `TableScan.projection`, so this compensates without requiring optimizer -/// passes that could alter other plan-node shapes our lowerer depends on. +/// If `child` (or a Filter wrapping it) contains a `Scan` with an empty +/// column list, populate it from the columns referenced in `cols`. +/// DataFusion's unoptimized plan never sets `TableScan.projection`, so this +/// compensates without requiring optimizer passes that could alter other +/// plan-node shapes our lowerer depends on. fn push_columns_into_scan(child: QueryExpr, cols: &[ProjectItem]) -> QueryExpr { - let QueryExpr::Scan { - source: - Source::Table { - table_ref, - columns, - time_range, - }, - predicates, - } = child - else { - return child; - }; - if !columns.is_empty() { - return QueryExpr::Scan { - source: Source::Table { - table_ref, - columns, - time_range, - }, + match child { + // Recurse through Filter so that Project → Filter → Scan works. + QueryExpr::Filter { child: inner, pred } => { + let updated = push_columns_into_scan(inner.expr.clone(), cols); + QueryExpr::Filter { + child: Rc::new(L3Node { + expr: updated, + schema: inner.schema.clone(), + }), + pred, + } + } + QueryExpr::Scan { + source: + Source::Table { + table_ref, + columns, + time_range, + }, predicates, - }; - } - let mut seen = std::collections::HashSet::::new(); - let col_refs: Vec = cols - .iter() - .flat_map(|item| item.expr.columns_referenced()) - .filter(|&c| seen.insert(c.0.clone())) - .cloned() - .collect(); - QueryExpr::Scan { - source: Source::Table { - table_ref, - columns: col_refs, - time_range, - }, - predicates, + } if columns.is_empty() => { + let mut seen = std::collections::HashSet::::new(); + let col_refs: Vec = cols + .iter() + .flat_map(|item| item.expr.columns_referenced()) + .filter(|&c| seen.insert(c.0.clone())) + .cloned() + .collect(); + QueryExpr::Scan { + source: Source::Table { + table_ref, + columns: col_refs, + time_range, + }, + predicates, + } + } + other => other, } } @@ -478,7 +528,9 @@ fn expr_to_group_key(expr: &Expr) -> Result { match expr { Expr::Column(col) => Ok(GroupKey(col.name.clone())), Expr::Alias(a) => expr_to_group_key(&a.expr), - _ => Ok(GroupKey(format!("{expr}"))), + other => Err(LoweringError::UnsupportedFeature(format!( + "non-column GROUP BY expression: {other}" + ))), } } @@ -486,7 +538,28 @@ fn expr_to_col_ref(expr: &Expr) -> Result { match expr { Expr::Column(col) => Ok(ColumnRef(col.name.clone())), Expr::Alias(a) => expr_to_col_ref(&a.expr), - _ => Ok(ColumnRef(format!("{expr}"))), + other => Err(LoweringError::UnsupportedFeature(format!( + "non-column reference in TopK by-list: {other}" + ))), + } +} + +/// Best-effort: extract the first column name from aggregate function args. +/// For `SUM(value)` → `ColumnRef("value")`. For `COUNT(*)` or expressions, +/// falls back to `"*"` or the expression display string. +fn agg_col(args: &[Expr]) -> ColumnRef { + match args.first() { + Some(Expr::Column(col)) => ColumnRef(col.name.clone()), + Some(Expr::Alias(a)) => match a.expr.as_ref() { + Expr::Column(col) => ColumnRef(col.name.clone()), + e => ColumnRef(format!("{e}")), + }, + Some(Expr::Cast(c)) => match c.expr.as_ref() { + Expr::Column(col) => ColumnRef(col.name.clone()), + e => ColumnRef(format!("{e}")), + }, + Some(Expr::Wildcard { .. }) | None => ColumnRef("*".into()), + Some(e) => ColumnRef(format!("{e}")), } } @@ -745,7 +818,9 @@ fn scalar_value_to_l3(sv: &ScalarValue) -> Result { ScalarValue::Int32(Some(v)) => Ok(L3Scalar::Int64(*v as i64)), ScalarValue::Int16(Some(v)) => Ok(L3Scalar::Int64(*v as i64)), ScalarValue::Int8(Some(v)) => Ok(L3Scalar::Int64(*v as i64)), - ScalarValue::UInt64(Some(v)) => Ok(L3Scalar::Int64(*v as i64)), + ScalarValue::UInt64(Some(v)) => i64::try_from(*v).map(L3Scalar::Int64).map_err(|_| { + LoweringError::InvalidExpression(format!("UInt64 value {v} overflows i64")) + }), ScalarValue::UInt32(Some(v)) => Ok(L3Scalar::Int64(*v as i64)), ScalarValue::Float64(Some(v)) => Ok(L3Scalar::Float64(*v)), ScalarValue::Float32(Some(v)) => Ok(L3Scalar::Float64(*v as f64)), @@ -927,8 +1002,15 @@ fn lower_window_func_kind(fun: &WindowFunctionDefinition) -> Result Err( - LoweringError::UnsupportedFeature(format!("built-in window fn: {biwf:?}")), - ), + // In DataFusion 43, BuiltInWindowFunction covers FirstValue, LastValue, NthValue. + // NthValue(0) is a placeholder; the real N is extracted from args in lower_window. + WindowFunctionDefinition::BuiltInWindowFunction(biwf) => { + use datafusion::logical_expr::BuiltInWindowFunction; + match biwf { + BuiltInWindowFunction::FirstValue => Ok(WindowFuncKind::FirstValue), + BuiltInWindowFunction::LastValue => Ok(WindowFuncKind::LastValue), + BuiltInWindowFunction::NthValue => Ok(WindowFuncKind::NthValue(0)), + } + } } } diff --git a/crates/lower/tests/sql_lowering.rs b/crates/lower/tests/sql_lowering.rs index 7d23097b..c718bd70 100644 --- a/crates/lower/tests/sql_lowering.rs +++ b/crates/lower/tests/sql_lowering.rs @@ -312,7 +312,7 @@ async fn test_sum_aggregate() { let (_, aggs) = find_aggregate(&result).unwrap(); assert_eq!(aggs.len(), 1); - assert!(matches!(aggs[0], AggIntent::Sum)); + assert!(matches!(aggs[0], AggIntent::Sum { .. })); } #[tokio::test] @@ -326,8 +326,8 @@ async fn test_min_max_aggregates() { let (_, aggs) = find_aggregate(&result).unwrap(); assert_eq!(aggs.len(), 2); - assert!(aggs.iter().any(|a| matches!(a, AggIntent::Min))); - assert!(aggs.iter().any(|a| matches!(a, AggIntent::Max))); + assert!(aggs.iter().any(|a| matches!(a, AggIntent::Min { .. }))); + assert!(aggs.iter().any(|a| matches!(a, AggIntent::Max { .. }))); } #[tokio::test] @@ -340,7 +340,7 @@ async fn test_avg_aggregate() { .unwrap(); let (_, aggs) = find_aggregate(&result).unwrap(); - assert!(aggs.iter().any(|a| matches!(a, AggIntent::Avg))); + assert!(aggs.iter().any(|a| matches!(a, AggIntent::Avg { .. }))); } #[tokio::test] @@ -353,9 +353,13 @@ async fn test_stddev_sample() { .unwrap(); let (_, aggs) = find_aggregate(&result).unwrap(); - assert!(aggs - .iter() - .any(|a| matches!(a, AggIntent::Stddev { population: false }))); + assert!(aggs.iter().any(|a| matches!( + a, + AggIntent::Stddev { + population: false, + .. + } + ))); } #[tokio::test] @@ -444,6 +448,7 @@ async fn test_order_by_desc_limit_becomes_topk() { panic!("expected TopK, got {:?}", aggs[0]); }; assert_eq!(*k, 10); + assert_eq!(by.len(), 1, "TopK should have exactly 1 by-column"); assert!(by.iter().any(|c| c.0 == "host")); } @@ -737,12 +742,9 @@ async fn test_unknown_table_returns_error() { .lower("SELECT x FROM ghost_table") .await .unwrap_err(); - // DataFusion will reject the unknown table during planning + // DataFusion rejects the unknown table at plan time before our lowerer runs. assert!( - matches!( - err, - LoweringError::DataFusion(_) | LoweringError::TableNotFound(_) - ), + matches!(err, LoweringError::DataFusion(_)), "unexpected error variant: {err}" ); } @@ -1245,3 +1247,244 @@ async fn test_populate_schemas_aggregate() { assert_eq!(typed.schema.fields.len(), 2); assert_eq!(typed.schema.fields[0].name, "region"); } + +// ── Tests: AggIntent column-type propagation ────────────────────────────────── + +#[tokio::test] +async fn test_min_int_col_gives_int64_in_aggregate_schema() { + // MIN(ts) where ts: Int64 → the root schema (through the outer Projection) + // should resolve the aggregate output type as Int64, not Float64. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let expr = lowerer.lower("SELECT MIN(ts) FROM metrics").await.unwrap(); + let typed = populate_schemas(expr, &catalog); + + let dtype = typed + .schema + .fields + .last() + .map(|f| f.dtype.clone()) + .expect("root schema should have at least one field"); + assert_eq!( + dtype, + L3DataType::Int64, + "MIN(ts: Int64) should propagate Int64 through to root schema, got {:?}", + dtype + ); +} + +#[tokio::test] +async fn test_max_utf8_col_gives_utf8_in_aggregate_schema() { + // MAX(region) where region: Utf8 → root schema should carry Utf8. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let expr = lowerer + .lower("SELECT MAX(region) FROM metrics") + .await + .unwrap(); + let typed = populate_schemas(expr, &catalog); + + let dtype = typed + .schema + .fields + .last() + .map(|f| f.dtype.clone()) + .expect("root schema should have at least one field"); + assert_eq!( + dtype, + L3DataType::Utf8, + "MAX(region: Utf8) should propagate Utf8 through to root schema, got {:?}", + dtype + ); +} + +#[tokio::test] +async fn test_sum_float_col_gives_float64_in_aggregate_schema() { + // SUM(value) where value: Float64 → Float64. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let expr = lowerer + .lower("SELECT SUM(value) FROM metrics") + .await + .unwrap(); + let typed = populate_schemas(expr, &catalog); + + let dtype = typed + .schema + .fields + .last() + .map(|f| f.dtype.clone()) + .expect("root schema should have at least one field"); + assert_eq!(dtype, L3DataType::Float64); +} + +#[tokio::test] +async fn test_agg_col_name_tracked_via_col_field() { + // Verify that AggIntent.col() carries the aggregated column name so + // schema derivation can resolve types without re-parsing SQL. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer.lower("SELECT MIN(ts) FROM metrics").await.unwrap(); + + let (_, aggs) = find_aggregate(&result).expect("expected Aggregate"); + let AggIntent::Min { col } = &aggs[0] else { + panic!("expected Min, got {:?}", aggs[0]); + }; + assert_eq!(col.0, "ts", "Min should track the aggregated column name"); +} + +// ── Tests: NthValue N extraction (#10) ─────────────────────────────────────── + +#[tokio::test] +async fn test_nth_value_extracts_n_from_args() { + // NTH_VALUE(value, 2) OVER (ORDER BY ts) → WindowFuncKind::NthValue(2), not NthValue(0). + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT NTH_VALUE(value, 2) OVER (ORDER BY ts) FROM metrics") + .await + .unwrap(); + + fn find_window_kind( + e: &QueryExpr, + ) -> Option<&asap_control_core::intent_algebra::expr::WindowFuncKind> { + match e { + QueryExpr::WindowFunc { func, .. } => Some(func), + QueryExpr::Project { child, .. } => find_window_kind(&child.expr), + _ => None, + } + } + let kind = find_window_kind(&result).expect("expected WindowFunc node"); + assert!( + matches!( + kind, + asap_control_core::intent_algebra::expr::WindowFuncKind::NthValue(2) + ), + "expected NthValue(2), got: {kind:?}" + ); +} + +// ── Tests: lowering-time failure (#11) ─────────────────────────────────────── + +#[tokio::test] +async fn test_unsupported_aggregate_returns_error_at_lowering_time() { + // array_agg parses fine but is not in our AggIntent mapping → UnsupportedAggregate. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let err = lowerer + .lower("SELECT array_agg(value) FROM metrics") + .await + .unwrap_err(); + assert!( + matches!(err, LoweringError::UnsupportedAggregate(_)), + "expected UnsupportedAggregate, got: {err}" + ); +} + +#[tokio::test] +async fn test_unsupported_agg_isolation_in_batch() { + // The bad query (unsupported agg) should not prevent the good one from succeeding. + let catalog = metrics_catalog(); + let workload = make_workload(vec![ + "SELECT COUNT(*) FROM metrics", + "SELECT array_agg(value) FROM metrics", + ]); + let results = lower_batch(&workload, &catalog).await; + assert_eq!(results.len(), 2); + assert!(results[0].is_ok(), "valid query should succeed"); + assert!(results[1].is_err(), "unsupported agg should fail"); + assert!( + matches!(results[1], Err(LoweringError::UnsupportedAggregate(_))), + "expected UnsupportedAggregate, got: {:?}", + results[1] + ); +} + +// ── Tests: 4-predicate WHERE with time extraction (#13) ────────────────────── + +#[tokio::test] +async fn test_four_predicate_where_extracts_time_and_keeps_filter() { + // WHERE ts > 1000 AND ts < 2000 AND region = 'us' AND value > 0.0 + // All four predicates in a single Filter node (DataFusion keeps them + // as one conjunctive Filter on the unoptimized plan). Time bounds should + // be extracted; the remaining two non-time predicates should stay as Filter. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower( + "SELECT value FROM metrics \ + WHERE ts > 1000 AND ts < 2000 AND region = 'us' AND value > 0.0", + ) + .await + .unwrap(); + + let source = find_source(&result).expect("expected a Scan node"); + let Source::Table { time_range, .. } = source else { + panic!("expected Source::Table"); + }; + let tr = time_range.as_ref().expect("expected time_range extracted"); + assert_eq!(tr.start_ms, Some(1000)); + assert_eq!(tr.end_ms, Some(2000)); + + fn has_filter(e: &QueryExpr) -> bool { + match e { + QueryExpr::Filter { .. } => true, + QueryExpr::Project { child, .. } => has_filter(&child.expr), + _ => false, + } + } + assert!( + has_filter(&result), + "expected Filter node for the two non-time predicates" + ); +} + +#[tokio::test] +async fn test_filter_predicate_conjuncts_count_for_two_non_time_preds() { + // Same query: the remaining Filter predicate should have 2 conjuncts. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower( + "SELECT value FROM metrics \ + WHERE ts > 1000 AND ts < 2000 AND region = 'us' AND value > 0.0", + ) + .await + .unwrap(); + + let pred = find_predicate(&result).expect("expected Filter predicate"); + assert_eq!( + pred.conjuncts().len(), + 2, + "expected 2 non-time conjuncts, got: {pred:?}" + ); +} + +// ── Tests: multi-window error (#14) ────────────────────────────────────────── + +#[tokio::test] +async fn test_multiple_window_funcs_same_over_returns_error() { + // DataFusion may fold two window functions with identical OVER clauses + // into one Window plan node. Our lowerer should surface an explicit error + // rather than silently dropping the second function. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower( + "SELECT \ + SUM(value) OVER (PARTITION BY region ORDER BY ts), \ + AVG(value) OVER (PARTITION BY region ORDER BY ts) \ + FROM metrics", + ) + .await; + // Either succeeds (DataFusion split them into separate Window nodes) or + // returns our UnsupportedFeature error — never silently drops one. + if let Err(e) = &result { + assert!( + matches!(e, LoweringError::UnsupportedFeature(msg) if msg.contains("multiple window")), + "unexpected error: {e}" + ); + } + // If it succeeds, both window funcs must produce output columns. + // (This case fires when DataFusion already splits them; accept it.) +} From 75037f256141e732423d6d1b7921fd19fec2ccbf Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Tue, 19 May 2026 12:23:34 -0400 Subject: [PATCH 11/17] fixed comments --- crates/core/src/intent_algebra/expr.rs | 66 ++++---- crates/core/src/sketch_algebra/expr.rs | 3 +- crates/core/tests/schema_derivation.rs | 48 ++++-- crates/lower/src/error.rs | 7 - crates/lower/src/lib.rs | 16 ++ crates/lower/src/schema_pass.rs | 14 +- crates/lower/src/sql.rs | 215 +++++++++++++++++++++---- 7 files changed, 274 insertions(+), 95 deletions(-) diff --git a/crates/core/src/intent_algebra/expr.rs b/crates/core/src/intent_algebra/expr.rs index 0258ea32..e69dd26b 100644 --- a/crates/core/src/intent_algebra/expr.rs +++ b/crates/core/src/intent_algebra/expr.rs @@ -1,4 +1,4 @@ -use std::rc::Rc; +use std::sync::Arc; use std::time::Duration; use super::expr_ir::L3Expr; @@ -263,26 +263,20 @@ impl AggIntent { /// Output column type for a single-column aggregate result. /// - /// `input` is the field being aggregated; used by `Min` and `Max` to - /// preserve the input type. For all other variants the input type is - /// ignored. - /// - /// **Do not call this for `TopK`** — TopK produces multiple output - /// columns; its schema is derived directly in `QueryExpr::output_schema`. - pub fn output_type(&self, input: &super::schema::L3Field) -> super::schema::L3DataType { + /// Returns `None` for `TopK`, which produces multiple output columns. + /// Use `QueryExpr::output_schema` for TopK schema derivation instead. + pub fn output_type(&self, input: &super::schema::L3Field) -> Option { use super::schema::L3DataType; match self { - Self::Count { .. } | Self::Cardinality { .. } => L3DataType::Int64, - Self::Min { .. } | Self::Max { .. } => input.dtype.clone(), + Self::Count { .. } | Self::Cardinality { .. } => Some(L3DataType::Int64), + Self::Min { .. } | Self::Max { .. } => Some(input.dtype.clone()), Self::Sum { .. } | Self::Avg { .. } | Self::Stddev { .. } | Self::Quantile { .. } | Self::Rate { .. } - | Self::Increase { .. } => L3DataType::Float64, - Self::TopK { .. } => { - panic!("TopK is multi-column; derive schema via QueryExpr::output_schema") - } + | Self::Increase { .. } => Some(L3DataType::Float64), + Self::TopK { .. } => None, } } } @@ -291,7 +285,7 @@ impl AggIntent { /// A node in the L3 DAG. Wraps the expression and its derived output schema /// so that every edge implicitly carries a typed schema: holding an -/// `Rc` gives you both the child expression and the schema of the +/// `Arc` gives you both the child expression and the schema of the /// data flowing on that edge. #[derive(Debug, Clone)] pub struct L3Node { @@ -321,12 +315,12 @@ pub enum QueryExpr { // ── Filtering & projection ──────────────────────────────────────────────── /// σ — row-level filter. Output schema = child schema (unchanged). Filter { - child: Rc, + child: Arc, pred: Predicate, }, /// π — column projection. Output schema = child schema projected to `cols`. Project { - child: Rc, + child: Arc, cols: Vec, }, @@ -334,7 +328,7 @@ pub enum QueryExpr { /// γ + α — GROUP BY + aggregate intents. Concrete operator (HashAgg / /// SortAgg / SketchAgg) chosen by L4; `aggs` carry intent only. Aggregate { - child: Rc, + child: Arc, by: Vec, aggs: Vec, having: Option, @@ -351,7 +345,7 @@ pub enum QueryExpr { /// the flush / reset lifecycle for aggregates in its sub-DAG. SQL analytic /// frames are a different node (`WindowFunc`). TimeWindow { - child: Rc, + child: Arc, kind: TimeWindowKind, size: Duration, slide: Option, @@ -361,19 +355,19 @@ pub enum QueryExpr { /// Logical-only partitioning marker. Output schema = child schema. /// Carries a sharding hint for the L5 stage allocator. Partition { - child: Rc, + child: Arc, keys: PartitionKeys, }, /// δ — SQL `DISTINCT` / row deduplication. Distinct { - child: Rc, + child: Arc, cols: Vec, }, /// ⊕ — exact union of sub-results from independent stages or shards. /// Sketch unions are a separate node in `SummaryExpr` because they carry /// sketch-family / params type constraints. Merge { - children: Vec>, + children: Vec>, }, // ── Joins ───────────────────────────────────────────────────────────────── @@ -382,8 +376,8 @@ pub enum QueryExpr { /// accuracy target. Join { kind: JoinKind, - left: Rc, - right: Rc, + left: Arc, + right: Arc, pred: Option, }, @@ -391,19 +385,19 @@ pub enum QueryExpr { SetOp { kind: SetOpKind, all: bool, - left: Rc, - right: Rc, + left: Arc, + right: Arc, }, // ── Ordering & limiting ─────────────────────────────────────────────────── /// Generic order-by for non-heavy-hitter cases (`ORDER BY name LIMIT 10`). /// Heavy-hitter shapes lower to `AggIntent::TopK` instead. Sort { - child: Rc, + child: Arc, keys: Vec, }, Limit { - child: Rc, + child: Arc, /// `None` means no upper bound (only an offset applies). n: Option, offset: u64, @@ -411,7 +405,7 @@ pub enum QueryExpr { // ── Subquery / CTE ──────────────────────────────────────────────────────── Subquery { - child: Rc, + child: Arc, alias: String, }, /// SQL `WITH name AS (expr) … body`; lowering target for PromQL @@ -419,8 +413,8 @@ pub enum QueryExpr { /// via `Ref(name)` in `body`, giving the DAG its fan-in. LetBinding { name: String, - expr: Rc, - body: Rc, + expr: Arc, + body: Arc, }, // ── Analytic window functions ───────────────────────────────────────────── @@ -428,7 +422,7 @@ pub enum QueryExpr { /// Distinct from `TimeWindow` — that is a streaming window over the time /// axis; this is an analytic frame over already-grouped rows. WindowFunc { - child: Rc, + child: Arc, func: WindowFuncKind, /// Expressions the function operates on (e.g. `LAG(value)` → `[Column("value")]`). /// Empty for rank-only funcs (`ROW_NUMBER`, `RANK`, `DENSE_RANK`). @@ -443,8 +437,8 @@ pub enum QueryExpr { /// including `and` / `or` / `unless`, SQL boolean composition). BinaryOp { op: BinaryOpKind, - lhs: Rc, - rhs: Rc, + lhs: Arc, + rhs: Arc, vector_match: Option, }, } @@ -626,7 +620,9 @@ impl HasSchema for QueryExpr { .unwrap_or_else(|| format!("agg_{i}")); L3Field { name, - dtype: agg.output_type(&col_field), + dtype: agg + .output_type(&col_field) + .unwrap_or(super::schema::L3DataType::Float64), nullable: true, } }) diff --git a/crates/core/src/sketch_algebra/expr.rs b/crates/core/src/sketch_algebra/expr.rs index 3f1bd904..bcfc25c4 100644 --- a/crates/core/src/sketch_algebra/expr.rs +++ b/crates/core/src/sketch_algebra/expr.rs @@ -1,4 +1,5 @@ use std::rc::Rc; +use std::sync::Arc; use super::schema::L4Schema; use super::sketch::{SketchQuery, SummaryKind, SummaryParams}; @@ -31,7 +32,7 @@ pub enum SummaryExpr { /// Any L3 node that no L4 rule rewrote (e.g. `Filter`, `Project`, `Sort`). /// Output schema is the inner L3 node's schema, lifted to `L4Schema` /// with all fields as `L4DataType::Primitive`. - Logical(Rc), + Logical(Arc), /// Sketch aggregation. L4 chose `sketch` + `params` from the catalog /// for `AggIntent` under `DeploymentConstraints`. diff --git a/crates/core/tests/schema_derivation.rs b/crates/core/tests/schema_derivation.rs index ec3fef98..24f25435 100644 --- a/crates/core/tests/schema_derivation.rs +++ b/crates/core/tests/schema_derivation.rs @@ -1,5 +1,5 @@ use std::collections::HashMap; -use std::rc::Rc; +use std::sync::Arc; use asap_control_core::intent_algebra::{ AggIntent, ArithOp, ColumnDef, ColumnRef, DataModel, GroupKey, HasSchema, L3DataType, L3Expr, @@ -40,8 +40,8 @@ fn schema_with_time(fields: Vec, time_index: usize) -> L3Schema { } } -fn make_node(expr: QueryExpr, s: L3Schema) -> Rc { - Rc::new(L3Node { expr, schema: s }) +fn make_node(expr: QueryExpr, s: L3Schema) -> Arc { + Arc::new(L3Node { expr, schema: s }) } fn empty_catalog() -> SchemaCatalog { @@ -244,7 +244,7 @@ fn output_type_count_is_int64() { let f = field("x", L3DataType::Float64); assert_eq!( AggIntent::Count { accuracy: exact() }.output_type(&f), - L3DataType::Int64 + Some(L3DataType::Int64) ); } @@ -257,7 +257,7 @@ fn output_type_count_ignores_input_type() { accuracy: eps(0.01) } .output_type(&f), - L3DataType::Int64 + Some(L3DataType::Int64) ); } @@ -266,7 +266,7 @@ fn output_type_cardinality_is_int64() { let f = field("host", L3DataType::Utf8); assert_eq!( AggIntent::Cardinality { accuracy: exact() }.output_type(&f), - L3DataType::Int64 + Some(L3DataType::Int64) ); } @@ -278,7 +278,7 @@ fn output_type_sum_is_float64() { col: ColumnRef("value".into()) } .output_type(&f), - L3DataType::Float64 + Some(L3DataType::Float64) ); } @@ -290,7 +290,7 @@ fn output_type_avg_is_float64() { col: ColumnRef("value".into()) } .output_type(&f), - L3DataType::Float64 + Some(L3DataType::Float64) ); } @@ -303,7 +303,7 @@ fn output_type_stddev_sample_is_float64() { population: false } .output_type(&f), - L3DataType::Float64 + Some(L3DataType::Float64) ); } @@ -316,7 +316,7 @@ fn output_type_stddev_population_is_float64() { population: true } .output_type(&f), - L3DataType::Float64 + Some(L3DataType::Float64) ); } @@ -329,7 +329,7 @@ fn output_type_quantile_is_float64() { accuracy: exact() } .output_type(&f), - L3DataType::Float64 + Some(L3DataType::Float64) ); } @@ -341,7 +341,7 @@ fn output_type_rate_is_float64() { window: std::time::Duration::from_secs(60) } .output_type(&f), - L3DataType::Float64 + Some(L3DataType::Float64) ); } @@ -353,7 +353,7 @@ fn output_type_increase_is_float64() { window: std::time::Duration::from_secs(60) } .output_type(&f), - L3DataType::Float64 + Some(L3DataType::Float64) ); } @@ -365,7 +365,7 @@ fn output_type_min_preserves_int64_input() { col: ColumnRef("count".into()) } .output_type(&f), - L3DataType::Int64 + Some(L3DataType::Int64) ); } @@ -377,7 +377,7 @@ fn output_type_min_preserves_float64_input() { col: ColumnRef("value".into()) } .output_type(&f), - L3DataType::Float64 + Some(L3DataType::Float64) ); } @@ -389,7 +389,21 @@ fn output_type_max_preserves_utf8_input() { col: ColumnRef("name".into()) } .output_type(&f), - L3DataType::Utf8 + Some(L3DataType::Utf8) + ); +} + +#[test] +fn output_type_topk_is_none() { + let f = field("region", L3DataType::Utf8); + assert_eq!( + AggIntent::TopK { + k: 10, + by: vec![ColumnRef("region".into())], + accuracy: exact() + } + .output_type(&f), + None ); } @@ -455,7 +469,7 @@ fn child_schema() -> L3Schema { ]) } -fn dummy_scan(s: L3Schema) -> Rc { +fn dummy_scan(s: L3Schema) -> Arc { make_node( QueryExpr::Scan { source: Source::Table { diff --git a/crates/lower/src/error.rs b/crates/lower/src/error.rs index 1e387739..113ef582 100644 --- a/crates/lower/src/error.rs +++ b/crates/lower/src/error.rs @@ -4,10 +4,6 @@ use std::fmt; pub enum LoweringError { DataFusion(datafusion::error::DataFusionError), TableNotFound(String), - ColumnNotFound { - table: String, - column: String, - }, /// A SQL feature (JOIN, subquery, etc.) not supported in this version. UnsupportedFeature(String), UnsupportedAggregate(String), @@ -23,9 +19,6 @@ impl fmt::Display for LoweringError { match self { Self::DataFusion(e) => write!(f, "DataFusion error: {e}"), Self::TableNotFound(t) => write!(f, "table not found in catalog: {t}"), - Self::ColumnNotFound { table, column } => { - write!(f, "column not found: {table}.{column}") - } Self::UnsupportedFeature(msg) => write!(f, "unsupported SQL feature: {msg}"), Self::UnsupportedAggregate(name) => write!(f, "unsupported aggregate: {name}"), Self::InvalidExpression(msg) => write!(f, "invalid expression: {msg}"), diff --git a/crates/lower/src/lib.rs b/crates/lower/src/lib.rs index 968ad0d9..8d6d0903 100644 --- a/crates/lower/src/lib.rs +++ b/crates/lower/src/lib.rs @@ -26,6 +26,10 @@ pub async fn lower_batch( }; // Guard: only SQL languages are handled by this lowerer. + // `QueryLanguage::DataFusion` is an alias for `QueryLanguage::SQL(SqlDialect::DataFusionSQL)`: + // both were kept for backward compatibility with callers that pre-date the `SQL(dialect)` + // variant. New code should prefer `SQL(DataFusionSQL)`; `DataFusion` may be removed in a + // future version once all callers have migrated. if !matches!( workload.language, QueryLanguage::SQL(_) | QueryLanguage::DataFusion @@ -49,6 +53,18 @@ pub async fn lower_batch( } } + // Validate the catalog upfront so invalid time_column names are caught in all + // build profiles (debug_assert in lower_filter fires only in debug builds). + for (name, schema) in &catalog.tables { + if let Err(e) = schema.validate() { + let msg = format!("catalog table '{name}': {e}"); + return entries + .iter() + .map(|_| Err(LoweringError::InvalidExpression(msg.clone()))) + .collect(); + } + } + let mut results = Vec::with_capacity(entries.len()); for entry in entries { let accuracy = entry diff --git a/crates/lower/src/schema_pass.rs b/crates/lower/src/schema_pass.rs index 56853a1d..1ec5b0b1 100644 --- a/crates/lower/src/schema_pass.rs +++ b/crates/lower/src/schema_pass.rs @@ -1,4 +1,4 @@ -use std::rc::Rc; +use std::sync::Arc; use asap_control_core::intent_algebra::expr::QueryExpr; use asap_control_core::intent_algebra::schema::{HasSchema, L3Schema, SchemaCatalog}; @@ -9,12 +9,12 @@ use asap_control_core::intent_algebra::L3Node; /// The lowerer creates every node with an empty schema (`make_node`). This /// pass walks the tree bottom-up, computing each node's output schema from /// its children's schemas and the catalog, and returns a fully typed -/// `Rc` tree. -pub fn populate_schemas(expr: QueryExpr, catalog: &SchemaCatalog) -> Rc { +/// `Arc` tree. +pub fn populate_schemas(expr: QueryExpr, catalog: &SchemaCatalog) -> Arc { let (rebuilt, child_schemas) = rebuild(expr, catalog); let refs: Vec<&L3Schema> = child_schemas.iter().collect(); let schema = rebuilt.output_schema(&refs, catalog); - Rc::new(L3Node { + Arc::new(L3Node { expr: rebuilt, schema, }) @@ -26,8 +26,8 @@ pub fn populate_schemas(expr: QueryExpr, catalog: &SchemaCatalog) -> Rc fn rebuild(expr: QueryExpr, catalog: &SchemaCatalog) -> (QueryExpr, Vec) { use QueryExpr::*; - // Helper: process one child Rc → fresh Rc with schema set. - let proc = |node: Rc| populate_schemas(node.expr.clone(), catalog); + // Helper: process one child Arc → fresh Arc with schema set. + let proc = |node: Arc| populate_schemas(node.expr.clone(), catalog); match expr { // Leaf: schema comes from the catalog, no child schemas needed. @@ -151,7 +151,7 @@ fn rebuild(expr: QueryExpr, catalog: &SchemaCatalog) -> (QueryExpr, Vec { - let new_children: Vec> = children.into_iter().map(proc).collect(); + let new_children: Vec> = children.into_iter().map(proc).collect(); let schemas: Vec = new_children.iter().map(|c| c.schema.clone()).collect(); ( Merge { diff --git a/crates/lower/src/sql.rs b/crates/lower/src/sql.rs index 4ec1f04f..39e9db1d 100644 --- a/crates/lower/src/sql.rs +++ b/crates/lower/src/sql.rs @@ -1,4 +1,3 @@ -use std::rc::Rc; use std::sync::Arc; use datafusion::arrow::datatypes::{DataType as ArrowDataType, Field, Fields, Schema, TimeUnit}; @@ -55,17 +54,16 @@ impl<'a> SqlLowerer<'a> { LogicalPlan::Sort(sort) => self.lower_sort(sort), LogicalPlan::Limit(limit) => self.lower_limit(limit), LogicalPlan::Window(window) => self.lower_window(window), - LogicalPlan::Distinct(d) => { - let input = match d { - Distinct::All(input) => input.as_ref(), - Distinct::On(on) => on.input.as_ref(), - }; - let child = self.lower_plan(input)?; - Ok(QueryExpr::Distinct { - child: make_node(child), - cols: vec![], - }) - } + LogicalPlan::Distinct(d) => match d { + Distinct::On(_) => Err(LoweringError::UnsupportedFeature("DISTINCT ON".into())), + Distinct::All(input) => { + let child = self.lower_plan(input)?; + Ok(QueryExpr::Distinct { + child: make_untyped_node(child), + cols: vec![], + }) + } + }, LogicalPlan::Union(u) => { // Fold n inputs left-associatively into SetOp { Union, all: true }. let mut iter = u.inputs.iter(); @@ -78,8 +76,8 @@ impl<'a> SqlLowerer<'a> { Ok(QueryExpr::SetOp { kind: SetOpKind::Union, all: true, - left: make_node(left), - right: make_node(right), + left: make_untyped_node(left), + right: make_untyped_node(right), }) }) } @@ -151,7 +149,7 @@ impl<'a> SqlLowerer<'a> { } else { let pred_expr = conjuncts_to_l3expr(non_time)?; Ok(QueryExpr::Filter { - child: make_node(scan_expr), + child: make_untyped_node(scan_expr), pred: Predicate(pred_expr), }) }; @@ -162,7 +160,7 @@ impl<'a> SqlLowerer<'a> { let pred_expr = df_expr_to_l3(&filter.predicate)?; let child = self.lower_plan(&filter.input)?; Ok(QueryExpr::Filter { - child: make_node(child), + child: make_untyped_node(child), pred: Predicate(pred_expr), }) } @@ -195,7 +193,7 @@ impl<'a> SqlLowerer<'a> { let child = push_columns_into_scan(child, &cols); Ok(QueryExpr::Project { - child: make_node(child), + child: make_untyped_node(child), cols, }) } @@ -215,9 +213,11 @@ impl<'a> SqlLowerer<'a> { // Use DataFusion's display string for each aggregate expression — this is // exactly the name DataFusion assigns to the output column (e.g. "MIN(metrics.ts)"), // which the enclosing Projection uses to reference aggregate outputs. + // Verified against DataFusion 43. If a DataFusion upgrade changes Expr::fmt output + // the aggregate schema tests in tests/sql_lowering.rs will fail loudly. let output_names: Vec = agg.aggr_expr.iter().map(|e| format!("{e}")).collect(); Ok(QueryExpr::Aggregate { - child: make_node(child), + child: make_untyped_node(child), by, aggs, having: None, @@ -248,7 +248,7 @@ impl<'a> SqlLowerer<'a> { }) .collect::, _>>()?; Ok(QueryExpr::Sort { - child: make_node(child), + child: make_untyped_node(child), keys, }) } @@ -267,7 +267,7 @@ impl<'a> SqlLowerer<'a> { } let child = self.lower_plan(&limit.input)?; Ok(QueryExpr::Limit { - child: make_node(child), + child: make_untyped_node(child), n: eval_fetch(&limit.fetch).map(|v| v as u64), offset: eval_fetch(&limit.skip).unwrap_or(0) as u64, }) @@ -285,7 +285,7 @@ impl<'a> SqlLowerer<'a> { .map(expr_to_col_ref) .collect::, _>>()?; Ok(QueryExpr::Aggregate { - child: make_node(child), + child: make_untyped_node(child), by: vec![], aggs: vec![AggIntent::TopK { k, @@ -351,7 +351,7 @@ impl<'a> SqlLowerer<'a> { }) .collect::, _>>()?; return Ok(QueryExpr::WindowFunc { - child: make_node(child), + child: make_untyped_node(child), func, args, partition_by, @@ -435,13 +435,18 @@ fn projection_columns(scan: &logical_expr::TableScan, schema: &TableSchema) -> V /// DataFusion's unoptimized plan never sets `TableScan.projection`, so this /// compensates without requiring optimizer passes that could alter other /// plan-node shapes our lowerer depends on. +/// +/// Handled topologies: `Project → Scan` and `Project → Filter → Scan`. +/// `Project → Aggregate → Filter → Scan` is NOT handled here; aggregate +/// lowering does not call this function, so Scan columns are left empty in +/// that topology (benign for the current lowerer, but worth noting for extensions). fn push_columns_into_scan(child: QueryExpr, cols: &[ProjectItem]) -> QueryExpr { match child { // Recurse through Filter so that Project → Filter → Scan works. QueryExpr::Filter { child: inner, pred } => { let updated = push_columns_into_scan(inner.expr.clone(), cols); QueryExpr::Filter { - child: Rc::new(L3Node { + child: Arc::new(L3Node { expr: updated, schema: inner.schema.clone(), }), @@ -477,8 +482,8 @@ fn push_columns_into_scan(child: QueryExpr, cols: &[ProjectItem]) -> QueryExpr { } } -fn make_node(expr: QueryExpr) -> Rc { - Rc::new(L3Node { +fn make_untyped_node(expr: QueryExpr) -> Arc { + Arc::new(L3Node { expr, schema: L3Schema { fields: vec![], @@ -491,9 +496,9 @@ fn make_node(expr: QueryExpr) -> Rc { /// Returns `None` for parametric (non-literal) fetch expressions. fn eval_fetch(expr_opt: &Option>) -> Option { expr_opt.as_ref().and_then(|e| match e.as_ref() { - Expr::Literal(ScalarValue::Int64(Some(v))) => Some(*v as usize), + Expr::Literal(ScalarValue::Int64(Some(v))) if *v >= 0 => Some(*v as usize), Expr::Literal(ScalarValue::UInt64(Some(v))) => Some(*v as usize), - Expr::Literal(ScalarValue::Int32(Some(v))) => Some(*v as usize), + Expr::Literal(ScalarValue::Int32(Some(v))) if *v >= 0 => Some(*v as usize), _ => None, }) } @@ -634,7 +639,7 @@ fn conjuncts_to_l3expr(conjuncts: Vec<&Expr>) -> Result { let parts: Result, _> = conjuncts.iter().map(|e| df_expr_to_l3(e)).collect(); let mut parts = parts?; if parts.len() == 1 { - Ok(parts.remove(0)) + Ok(parts.pop().unwrap()) } else { Ok(L3Expr::BoolAnd(parts)) } @@ -934,6 +939,8 @@ fn scalar_to_ms(sv: &ScalarValue) -> Option { match sv { ScalarValue::Int64(Some(v)) => Some(*v), ScalarValue::Int32(Some(v)) => Some(*v as i64), + ScalarValue::Float64(Some(v)) => Some(*v as i64), // ms-since-epoch stored as float + ScalarValue::Float32(Some(v)) => Some(*v as i64), ScalarValue::TimestampMillisecond(Some(ms), _) => Some(*ms), ScalarValue::TimestampNanosecond(Some(ns), _) => Some(*ns / 1_000_000), ScalarValue::TimestampMicrosecond(Some(us), _) => Some(*us / 1_000), @@ -1014,3 +1021,155 @@ fn lower_window_func_kind(fun: &WindowFunctionDefinition) -> Result Expr { + Expr::Column(datafusion::common::Column::new_unqualified(name)) + } + fn int(v: i64) -> Expr { + Expr::Literal(ScalarValue::Int64(Some(v))) + } + fn float(v: f64) -> Expr { + Expr::Literal(ScalarValue::Float64(Some(v))) + } + fn bin(left: Expr, op: Operator, right: Expr) -> Expr { + Expr::BinaryExpr(BinaryExpr { + left: Box::new(left), + op, + right: Box::new(right), + }) + } + fn and(l: Expr, r: Expr) -> Expr { + bin(l, Operator::And, r) + } + + #[test] + fn col_left_gt_lower_bound() { + let expr = bin(col("ts"), Operator::Gt, int(1000)); + let (range, non_time) = extract_time_range(&expr, "ts"); + assert_eq!( + range, + Some(TimeRange { + start_ms: Some(1000), + end_ms: None + }) + ); + assert!(non_time.is_empty()); + } + + #[test] + fn col_right_lt_is_start_bound() { + // `1000 < ts` ≡ `ts > 1000` + let expr = bin(int(1000), Operator::Lt, col("ts")); + let (range, non_time) = extract_time_range(&expr, "ts"); + assert_eq!( + range, + Some(TimeRange { + start_ms: Some(1000), + end_ms: None + }) + ); + assert!(non_time.is_empty()); + } + + #[test] + fn col_right_gt_is_end_bound() { + // `2000 > ts` ≡ `ts < 2000` + let expr = bin(int(2000), Operator::Gt, col("ts")); + let (range, non_time) = extract_time_range(&expr, "ts"); + assert_eq!( + range, + Some(TimeRange { + start_ms: None, + end_ms: Some(2000) + }) + ); + assert!(non_time.is_empty()); + } + + #[test] + fn overlapping_repeated_bounds_tighten() { + // `ts > 500 AND ts > 1000` → start = 1000 (tighter) + let expr = and( + bin(col("ts"), Operator::Gt, int(500)), + bin(col("ts"), Operator::Gt, int(1000)), + ); + let (range, _) = extract_time_range(&expr, "ts"); + assert_eq!(range.unwrap().start_ms, Some(1000)); + } + + #[test] + fn overlapping_end_bounds_tighten() { + // `ts < 2000 AND ts < 1500` → end = 1500 (tighter) + let expr = and( + bin(col("ts"), Operator::Lt, int(2000)), + bin(col("ts"), Operator::Lt, int(1500)), + ); + let (range, _) = extract_time_range(&expr, "ts"); + assert_eq!(range.unwrap().end_ms, Some(1500)); + } + + #[test] + fn between_contributes_both_bounds() { + use datafusion::logical_expr::Between; + let expr = Expr::Between(Between { + expr: Box::new(col("ts")), + negated: false, + low: Box::new(int(1000)), + high: Box::new(int(2000)), + }); + let (range, non_time) = extract_time_range(&expr, "ts"); + assert_eq!( + range, + Some(TimeRange { + start_ms: Some(1000), + end_ms: Some(2000) + }) + ); + assert!(non_time.is_empty()); + } + + #[test] + fn not_between_is_non_time() { + use datafusion::logical_expr::Between; + let expr = Expr::Between(Between { + expr: Box::new(col("ts")), + negated: true, + low: Box::new(int(1000)), + high: Box::new(int(2000)), + }); + let (range, non_time) = extract_time_range(&expr, "ts"); + assert!(range.is_none()); + assert_eq!(non_time.len(), 1); + } + + #[test] + fn float_literal_extracted_as_ms() { + let expr = bin(col("ts"), Operator::Gt, float(1_000_000.0)); + let (range, non_time) = extract_time_range(&expr, "ts"); + assert_eq!( + range, + Some(TimeRange { + start_ms: Some(1_000_000), + end_ms: None + }) + ); + assert!(non_time.is_empty()); + } + + #[test] + fn non_time_conjunct_passes_through() { + let expr = and( + bin(col("ts"), Operator::Gt, int(1000)), + bin(col("value"), Operator::Gt, int(0)), + ); + let (range, non_time) = extract_time_range(&expr, "ts"); + assert!(range.is_some()); + assert_eq!(non_time.len(), 1); + } +} From 107c10cc78d1c175aaa525083ccda3300c972765 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Tue, 19 May 2026 17:07:42 -0400 Subject: [PATCH 12/17] addressed CR --- crates/core/src/intent_algebra/expr.rs | 20 +- crates/core/src/intent_algebra/expr_ir.rs | 9 +- crates/core/tests/schema_derivation.rs | 1 + crates/lower/src/lib.rs | 4 + crates/lower/src/sql.rs | 48 +++-- crates/lower/tests/sql_lowering.rs | 238 +++++++++++++++++++++- 6 files changed, 293 insertions(+), 27 deletions(-) diff --git a/crates/core/src/intent_algebra/expr.rs b/crates/core/src/intent_algebra/expr.rs index e69dd26b..2c1d8f50 100644 --- a/crates/core/src/intent_algebra/expr.rs +++ b/crates/core/src/intent_algebra/expr.rs @@ -24,6 +24,15 @@ pub struct GroupKey(pub String); /// A reference to a column by name. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct ColumnRef(pub String); + +impl ColumnRef { + /// Returns `true` when this ref is the `"*"` wildcard sentinel produced by + /// `agg_col` for aggregate args that are not a named column (e.g. `COUNT(*)`). + /// Schema derivation skips wildcard refs and falls back to the `Float64` default. + pub fn is_wildcard(&self) -> bool { + self.0 == "*" + } +} /// A set of partitioning keys (sharding hint for L5 stage allocator). #[derive(Debug, Clone)] pub struct PartitionKeys; @@ -250,13 +259,22 @@ impl AggIntent { /// The column this intent aggregates, if tracked. Used by schema derivation /// to resolve the actual field type (e.g. so `MIN(ts: Int64)` → `Int64`). + /// Returns `None` for intents with no specific column (`Count`, `Cardinality`, + /// `TopK`, ...) and for the `"*"` wildcard sentinel produced by `agg_col` + /// when the aggregate arg is not a named column. pub fn col(&self) -> Option<&ColumnRef> { match self { Self::Sum { col } | Self::Min { col } | Self::Max { col } | Self::Avg { col } - | Self::Stddev { col, .. } => Some(col), + | Self::Stddev { col, .. } => { + if col.is_wildcard() { + None + } else { + Some(col) + } + } _ => None, } } diff --git a/crates/core/src/intent_algebra/expr_ir.rs b/crates/core/src/intent_algebra/expr_ir.rs index 131e176c..4005b8fd 100644 --- a/crates/core/src/intent_algebra/expr_ir.rs +++ b/crates/core/src/intent_algebra/expr_ir.rs @@ -70,8 +70,13 @@ pub enum L3Expr { IsNull(Box), /// `expr IS NOT NULL`. IsNotNull(Box), - /// `CAST(expr AS to)`. - Cast { expr: Box, to: L3DataType }, + /// `CAST(expr AS to)`. `try_cast` is `true` for SQL `TRY_CAST`, which + /// returns `NULL` on conversion failure instead of raising an error. + Cast { + expr: Box, + to: L3DataType, + try_cast: bool, + }, /// `expr [NOT] IN (v1, v2, …)`. InList { expr: Box, diff --git a/crates/core/tests/schema_derivation.rs b/crates/core/tests/schema_derivation.rs index 24f25435..9df7dbf1 100644 --- a/crates/core/tests/schema_derivation.rs +++ b/crates/core/tests/schema_derivation.rs @@ -1032,6 +1032,7 @@ fn project_cast_item_uses_target_type() { expr: L3Expr::Cast { expr: Box::new(L3Expr::Column(ColumnRef("ts".into()))), to: L3DataType::Float64, + try_cast: false, }, alias: Some("ts_f".into()), }], diff --git a/crates/lower/src/lib.rs b/crates/lower/src/lib.rs index 8d6d0903..29409040 100644 --- a/crates/lower/src/lib.rs +++ b/crates/lower/src/lib.rs @@ -16,6 +16,10 @@ pub use sql::SqlLowerer; /// One `Result` per entry — errors are per-query, not fatal for the batch. /// Returns an empty `Vec` if `workload.query_batch` is absent or empty. /// Returns `WrongLanguage` for every entry if the workload language is not SQL. +/// +/// **The returned trees have empty schemas on every node** (`L3Schema::fields` +/// is `vec![]`). Call [`populate_schemas`] on each successful result before +/// inspecting node schemas or passing the trees to schema-aware stages. pub async fn lower_batch( workload: &QueryWorkload, catalog: &SchemaCatalog, diff --git a/crates/lower/src/sql.rs b/crates/lower/src/sql.rs index 39e9db1d..f0d7a4e6 100644 --- a/crates/lower/src/sql.rs +++ b/crates/lower/src/sql.rs @@ -210,12 +210,19 @@ impl<'a> SqlLowerer<'a> { .iter() .map(|e| self.lower_agg_expr(e)) .collect::, _>>()?; - // Use DataFusion's display string for each aggregate expression — this is - // exactly the name DataFusion assigns to the output column (e.g. "MIN(metrics.ts)"), - // which the enclosing Projection uses to reference aggregate outputs. - // Verified against DataFusion 43. If a DataFusion upgrade changes Expr::fmt output - // the aggregate schema tests in tests/sql_lowering.rs will fail loudly. - let output_names: Vec = agg.aggr_expr.iter().map(|e| format!("{e}")).collect(); + // Use DataFusion's own aggregate output schema for column names — the same + // schema the enclosing Projection was built against when it wrote its column + // references (e.g. "MIN(metrics.ts)"). The first n_groups fields are the + // GROUP BY columns; the remaining fields are the aggregate outputs. + let n_groups = agg.group_expr.len(); + let output_names: Vec = agg + .schema + .fields() + .iter() + .skip(n_groups) + .take(agg.aggr_expr.len()) + .map(|f| f.name().to_string()) + .collect(); Ok(QueryExpr::Aggregate { child: make_untyped_node(child), by, @@ -359,8 +366,8 @@ impl<'a> SqlLowerer<'a> { frame: None, }); } - Err(LoweringError::InvalidExpression( - "expected WindowFunction expr".into(), + Err(LoweringError::UnsupportedFeature( + "unexpected non-WindowFunction expr in Window plan node".into(), )) } @@ -437,9 +444,13 @@ fn projection_columns(scan: &logical_expr::TableScan, schema: &TableSchema) -> V /// plan-node shapes our lowerer depends on. /// /// Handled topologies: `Project → Scan` and `Project → Filter → Scan`. -/// `Project → Aggregate → Filter → Scan` is NOT handled here; aggregate -/// lowering does not call this function, so Scan columns are left empty in -/// that topology (benign for the current lowerer, but worth noting for extensions). +/// +/// **Known gap**: `Project → Aggregate → * → Scan` is NOT handled. Aggregate +/// lowering does not call this function, so `Scan.columns` stays empty in any +/// topology where an Aggregate sits between the Project and the Scan. Any +/// downstream stage that uses `Scan.columns` for pruning or cost estimation +/// will see an unconstrained (full) scan in those cases. TODO: propagate +/// column refs through the Aggregate child when implementing column-pruning. fn push_columns_into_scan(child: QueryExpr, cols: &[ProjectItem]) -> QueryExpr { match child { // Recurse through Filter so that Project → Filter → Scan works. @@ -599,10 +610,7 @@ fn split_conjuncts(expr: &Expr) -> Vec<&Expr> { /// Split `expr` into `(time_range, non_time_conjuncts)`. /// Time-bound conjuncts are folded into the `TimeRange`; the rest are returned /// as a `Vec<&Expr>` so the caller can translate them with `df_expr_to_l3`. -pub(crate) fn extract_time_range<'a>( - expr: &'a Expr, - time_col: &str, -) -> (Option, Vec<&'a Expr>) { +fn extract_time_range<'a>(expr: &'a Expr, time_col: &str) -> (Option, Vec<&'a Expr>) { let conjuncts = split_conjuncts(expr); let mut start_ms: Option = None; let mut end_ms: Option = None; @@ -749,15 +757,18 @@ fn df_expr_to_l3(expr: &Expr) -> Result { Ok(L3Expr::Cast { expr: Box::new(inner), to, + try_cast: false, }) } + // TRY_CAST returns NULL on conversion failure; preserve that semantic. Expr::TryCast(c) => { let inner = df_expr_to_l3(&c.expr)?; let to = arrow_to_l3(&c.data_type)?; Ok(L3Expr::Cast { expr: Box::new(inner), to, + try_cast: true, }) } @@ -910,6 +921,9 @@ fn classify_time_pred(expr: &Expr, time_col: &str) -> TimeClass { (Operator::Lt | Operator::LtEq, true) | (Operator::Gt | Operator::GtEq, false) => { TimeClass::End(ms) } + // Eq (exact timestamp equality) and all other operators cannot be + // expressed as a contiguous half-open range, so leave them as + // regular Filter predicates rather than time-range bounds. _ => TimeClass::NonTime, } } @@ -939,8 +953,8 @@ fn scalar_to_ms(sv: &ScalarValue) -> Option { match sv { ScalarValue::Int64(Some(v)) => Some(*v), ScalarValue::Int32(Some(v)) => Some(*v as i64), - ScalarValue::Float64(Some(v)) => Some(*v as i64), // ms-since-epoch stored as float - ScalarValue::Float32(Some(v)) => Some(*v as i64), + ScalarValue::Float64(Some(v)) => Some(v.round() as i64), // ms-since-epoch stored as float + ScalarValue::Float32(Some(v)) => Some((*v as f64).round() as i64), ScalarValue::TimestampMillisecond(Some(ms), _) => Some(*ms), ScalarValue::TimestampNanosecond(Some(ns), _) => Some(*ns / 1_000_000), ScalarValue::TimestampMicrosecond(Some(us), _) => Some(*us / 1_000), diff --git a/crates/lower/tests/sql_lowering.rs b/crates/lower/tests/sql_lowering.rs index c718bd70..c7211854 100644 --- a/crates/lower/tests/sql_lowering.rs +++ b/crates/lower/tests/sql_lowering.rs @@ -1477,14 +1477,238 @@ async fn test_multiple_window_funcs_same_over_returns_error() { FROM metrics", ) .await; - // Either succeeds (DataFusion split them into separate Window nodes) or - // returns our UnsupportedFeature error — never silently drops one. - if let Err(e) = &result { - assert!( + + match &result { + Err(e) => assert!( matches!(e, LoweringError::UnsupportedFeature(msg) if msg.contains("multiple window")), "unexpected error: {e}" - ); + ), + Ok(expr) => { + // DataFusion split them into separate Window nodes — both must be present. + fn count_window_funcs(e: &QueryExpr) -> usize { + match e { + QueryExpr::WindowFunc { child, .. } => 1 + count_window_funcs(&child.expr), + QueryExpr::Project { child, .. } => count_window_funcs(&child.expr), + _ => 0, + } + } + assert_eq!( + count_window_funcs(expr), + 2, + "when DataFusion splits window funcs both must appear in the lowered tree" + ); + } } - // If it succeeds, both window funcs must produce output columns. - // (This case fires when DataFusion already splits them; accept it.) +} + +// ── Tests: missing predicate coverage ──────────────────────────────────────── + +#[tokio::test] +async fn test_filter_is_not_null_predicate() { + // WHERE value IS NOT NULL → IsNotNull(Column("value")) + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT id FROM events WHERE value IS NOT NULL") + .await + .unwrap(); + let pred = find_predicate(&result).expect("expected Filter predicate"); + assert!( + matches!(pred, L3Expr::IsNotNull(inner) if matches!(inner.as_ref(), L3Expr::Column(c) if c.0 == "value")), + "expected IsNotNull(Column(\"value\")), got: {pred:?}" + ); +} + +#[tokio::test] +async fn test_filter_not_in_list() { + // WHERE id NOT IN (1, 2, 3) → InList { negated: true } + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT id FROM events WHERE id NOT IN (1, 2, 3)") + .await + .unwrap(); + let pred = find_predicate(&result).expect("expected Filter predicate"); + let L3Expr::InList { + expr, + list, + negated, + } = pred + else { + panic!("expected InList, got: {pred:?}"); + }; + assert!(matches!(expr.as_ref(), L3Expr::Column(c) if c.0 == "id")); + assert_eq!(list.len(), 3); + assert!(negated, "NOT IN should set negated=true"); +} + +#[tokio::test] +async fn test_not_between_normalizes_to_bool_or() { + // WHERE value NOT BETWEEN 0 AND 100 → BoolOr([Compare(Lt), Compare(Gt)]) + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT id FROM events WHERE value NOT BETWEEN 0.0 AND 100.0") + .await + .unwrap(); + let pred = find_predicate(&result).expect("expected Filter predicate"); + let disjuncts = pred.disjuncts(); + assert_eq!( + disjuncts.len(), + 2, + "NOT BETWEEN should produce 2 disjuncts, got: {pred:?}" + ); + assert!(matches!( + &disjuncts[0], + L3Expr::Compare { + op: CompareOp::Lt, + .. + } + )); + assert!(matches!( + &disjuncts[1], + L3Expr::Compare { + op: CompareOp::Gt, + .. + } + )); +} + +#[tokio::test] +async fn test_try_cast_sets_try_cast_flag() { + // TRY_CAST(value AS BIGINT) → Cast { try_cast: true } + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT TRY_CAST(value AS BIGINT) FROM events") + .await + .unwrap(); + let items = find_project_items(&result).expect("expected Project node"); + let has_try_cast = items + .iter() + .any(|pi| matches!(pi.expr, L3Expr::Cast { try_cast: true, .. })); + assert!( + has_try_cast, + "TRY_CAST should produce Cast {{ try_cast: true }}, got: {items:?}" + ); +} + +#[tokio::test] +async fn test_regular_cast_sets_try_cast_false() { + // CAST(value AS BIGINT) → Cast { try_cast: false } + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT CAST(value AS BIGINT) FROM events") + .await + .unwrap(); + let items = find_project_items(&result).expect("expected Project node"); + let has_cast = items.iter().any(|pi| { + matches!( + pi.expr, + L3Expr::Cast { + try_cast: false, + .. + } + ) + }); + assert!( + has_cast, + "CAST should produce Cast {{ try_cast: false }}, got: {items:?}" + ); +} + +// ── Tests: populate_schemas for additional node types ───────────────────────── + +#[tokio::test] +async fn test_populate_schemas_filter_node_typed() { + // Project → Filter → Scan: both the root Project and the inner Filter + // should have non-empty schemas after populate_schemas. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let expr = lowerer + .lower("SELECT ts FROM metrics WHERE value > 0.0") + .await + .unwrap(); + let typed = populate_schemas(expr, &catalog); + // Root Project selects 1 column. + assert_eq!(typed.schema.fields.len(), 1); + assert_eq!(typed.schema.fields[0].name, "ts"); + // The inner Filter child should also carry the full scan schema. + let QueryExpr::Project { child, .. } = &typed.expr else { + panic!("expected Project at root"); + }; + assert!( + !child.schema.fields.is_empty(), + "Filter child schema should be populated" + ); +} + +#[tokio::test] +async fn test_populate_schemas_sort_node_typed() { + // Sort passes through its child schema. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let expr = lowerer + .lower("SELECT ts, value FROM metrics ORDER BY ts") + .await + .unwrap(); + let typed = populate_schemas(expr, &catalog); + // Root should have 2 fields regardless of whether Sort or Project is on top. + assert_eq!(typed.schema.fields.len(), 2); +} + +#[tokio::test] +async fn test_populate_schemas_limit_node_typed() { + // Limit passes through its child schema. + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let expr = lowerer + .lower("SELECT id FROM events LIMIT 5") + .await + .unwrap(); + let typed = populate_schemas(expr, &catalog); + assert_eq!(typed.schema.fields.len(), 1); + assert_eq!(typed.schema.fields[0].name, "id"); +} + +#[tokio::test] +async fn test_populate_schemas_set_op_uses_left_schema() { + // SetOp output schema matches the left child. + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let expr = lowerer + .lower("SELECT id FROM events UNION ALL SELECT id FROM events") + .await + .unwrap(); + let typed = populate_schemas(expr, &catalog); + assert!( + !typed.schema.fields.is_empty(), + "SetOp schema should be populated after populate_schemas" + ); + assert!( + typed.schema.fields.iter().any(|f| f.name == "id"), + "expected 'id' field in SetOp output schema, got: {:?}", + typed.schema.fields + ); +} + +#[tokio::test] +async fn test_populate_schemas_window_func_appends_column() { + // WindowFunc appends one column to the child schema. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let expr = lowerer + .lower( + "SELECT region, ROW_NUMBER() OVER (PARTITION BY region ORDER BY ts) \ + FROM metrics", + ) + .await + .unwrap(); + let typed = populate_schemas(expr, &catalog); + // Root may be a Project; all nodes in the tree should have non-empty schemas. + assert!( + !typed.schema.fields.is_empty(), + "root schema should be populated after populate_schemas" + ); } From 17728e35b9e28a05e6936071f5c4ef0b9f7886d1 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Tue, 19 May 2026 17:34:40 -0400 Subject: [PATCH 13/17] more fixes --- crates/core/src/intent_algebra/expr.rs | 30 ++++++++++++++++++++++-- crates/core/src/intent_algebra/schema.rs | 5 ++++ crates/core/tests/schema_derivation.rs | 29 +++++++++++++++++++++-- crates/lower/src/sql.rs | 28 ++++++++++++++++++++-- 4 files changed, 86 insertions(+), 6 deletions(-) diff --git a/crates/core/src/intent_algebra/expr.rs b/crates/core/src/intent_algebra/expr.rs index 2c1d8f50..d079c925 100644 --- a/crates/core/src/intent_algebra/expr.rs +++ b/crates/core/src/intent_algebra/expr.rs @@ -29,6 +29,11 @@ impl ColumnRef { /// Returns `true` when this ref is the `"*"` wildcard sentinel produced by /// `agg_col` for aggregate args that are not a named column (e.g. `COUNT(*)`). /// Schema derivation skips wildcard refs and falls back to the `Float64` default. + /// + /// TODO: replace this sentinel with `Option` in the `AggIntent` variants + /// that carry a `col` field (`Sum`, `Min`, `Max`, `Avg`, `Stddev`). `None` = no + /// column (wildcard / count-star); `Some(ColumnRef(...))` = real column. `agg_col` + /// would return `Option` and this method disappears entirely. pub fn is_wildcard(&self) -> bool { self.0 == "*" } @@ -508,6 +513,10 @@ impl HasSchema for QueryExpr { .iter() .map(|item| match &item.expr { L3Expr::Column(col_ref) => { + // TODO: DataFusion may qualify column names with the table name + // (e.g. "metrics.value") while child schema stores bare names + // ("value"). Strip the qualifier before lookup so the fallback + // Float64 default is not silently applied to real columns. let child_f = cs.fields.iter().find(|f| f.name == col_ref.0); let (dtype, nullable) = child_f .map(|f| (f.dtype.clone(), f.nullable)) @@ -645,9 +654,20 @@ impl HasSchema for QueryExpr { } }) .collect(); + let time_col_name = cs.time_index.map(|ti| cs.fields[ti].name.clone()); + let all_fields: Vec = by_fields.into_iter().chain(agg_fields).collect(); + // Propagate time_index if a GROUP BY key preserved the time column in + // the output. Agg output columns are never the time axis. + let time_index = time_col_name.and_then(|tc| { + if by.iter().any(|key| key.0 == tc) { + all_fields.iter().position(|f| f.name == tc) + } else { + None + } + }); L3Schema { - fields: by_fields.into_iter().chain(agg_fields).collect(), - time_index: None, + fields: all_fields, + time_index, } } @@ -664,6 +684,12 @@ impl HasSchema for QueryExpr { }); let arg_dtype = || arg_field.map_or(L3DataType::Float64, |f| f.dtype.clone()); + // TODO: the output column name should come from DataFusion's Window plan + // node schema (the actual name the enclosing Projection was built against, + // e.g. "row_number_1"), not a hardcoded string. An enclosing Project that + // references DataFusion's real name won't resolve it from this schema. + // Fix: thread the Window node's output field name through lower_window and + // store it on WindowFunc, then use it here instead of the hardcoded literals. let (win_name, win_dtype, win_nullable) = match func { WindowFuncKind::RowNumber => ("row_number", L3DataType::Int64, false), WindowFuncKind::Rank => ("rank", L3DataType::Int64, false), diff --git a/crates/core/src/intent_algebra/schema.rs b/crates/core/src/intent_algebra/schema.rs index 76fb3a66..69034018 100644 --- a/crates/core/src/intent_algebra/schema.rs +++ b/crates/core/src/intent_algebra/schema.rs @@ -86,5 +86,10 @@ pub struct L3Schema { /// its children's output schemas. The `L3Node` wrapper stores the derived /// schema so derivation runs once at construction, not on every traversal. pub trait HasSchema { + /// # Panics + /// Panics if a `Scan` node references a table that is not present in + /// `catalog`. Callers must ensure every table referenced by the expression + /// tree is registered in the catalog before calling this method. + /// `lower_batch` enforces this invariant via upfront catalog validation. fn output_schema(&self, input_schemas: &[&L3Schema], catalog: &SchemaCatalog) -> L3Schema; } diff --git a/crates/core/tests/schema_derivation.rs b/crates/core/tests/schema_derivation.rs index 9df7dbf1..c5ed9399 100644 --- a/crates/core/tests/schema_derivation.rs +++ b/crates/core/tests/schema_derivation.rs @@ -652,8 +652,8 @@ fn aggregate_topk_multi_key() { } #[test] -fn aggregate_time_index_not_propagated() { - // Aggregating over a time-indexed child drops the time axis. +fn aggregate_drops_time_index_when_time_col_not_in_group_by() { + // GROUP BY value (not ts): time axis is consumed by the aggregation. let cs = schema_with_time( vec![ field("ts", L3DataType::Int64), @@ -672,6 +672,31 @@ fn aggregate_time_index_not_propagated() { assert_eq!(out.time_index, None); } +#[test] +fn aggregate_propagates_time_index_when_time_col_in_group_by() { + // GROUP BY ts — the time column survives into the output; time_index must be set. + let cs = schema_with_time( + vec![ + field("ts", L3DataType::Int64), + nullable_field("value", L3DataType::Float64), + ], + 0, + ); + let node = QueryExpr::Aggregate { + child: dummy_scan(cs.clone()), + by: vec![GroupKey("ts".into())], + aggs: vec![AggIntent::Sum { + col: ColumnRef("value".into()), + }], + having: None, + output_names: vec!["sum_value".into()], + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + // Output fields: [ts (by), sum_value (agg)]; ts is at index 0. + assert_eq!(out.time_index, Some(0)); + assert_eq!(out.fields[0].name, "ts"); +} + // ── HasSchema::output_schema() — Project ────────────────────────────────────── #[test] diff --git a/crates/lower/src/sql.rs b/crates/lower/src/sql.rs index f0d7a4e6..7db7a5b7 100644 --- a/crates/lower/src/sql.rs +++ b/crates/lower/src/sql.rs @@ -214,6 +214,10 @@ impl<'a> SqlLowerer<'a> { // schema the enclosing Projection was built against when it wrote its column // references (e.g. "MIN(metrics.ts)"). The first n_groups fields are the // GROUP BY columns; the remaining fields are the aggregate outputs. + // TODO: output_names couples core's Aggregate IR to DataFusion's internal + // naming convention. Cleaner boundary: emit a Project on top of every + // Aggregate that renames DataFusion's names to user-visible aliases, so + // Aggregate.output_names can be removed and column resolution lives in Project. let n_groups = agg.group_expr.len(); let output_names: Vec = agg .schema @@ -339,6 +343,12 @@ impl<'a> SqlLowerer<'a> { } else { func }; + // lower_window_func_kind emits NthValue(0) as a sentinel that must + // be resolved to a real N above. Catch any bypass in debug builds. + debug_assert!( + !matches!(func, WindowFuncKind::NthValue(0)), + "NthValue(0) sentinel was not resolved; lower_window has a bug" + ); let partition_by = wf .partition_by @@ -949,12 +959,25 @@ fn expr_to_ms(expr: &Expr) -> Option { } } +/// Round `v` to the nearest millisecond and return it as `i64`. +/// Returns `None` if `v` is non-finite or outside the `i64` range. +fn float_to_ms(v: f64) -> Option { + let rounded = v.round(); + // i64::MAX as f64 rounds up to 2^63, which overflows i64 on cast. + // Use strict less-than for the upper bound. + if rounded.is_finite() && rounded >= i64::MIN as f64 && rounded < i64::MAX as f64 { + Some(rounded as i64) + } else { + None + } +} + fn scalar_to_ms(sv: &ScalarValue) -> Option { match sv { ScalarValue::Int64(Some(v)) => Some(*v), ScalarValue::Int32(Some(v)) => Some(*v as i64), - ScalarValue::Float64(Some(v)) => Some(v.round() as i64), // ms-since-epoch stored as float - ScalarValue::Float32(Some(v)) => Some((*v as f64).round() as i64), + ScalarValue::Float64(Some(v)) => float_to_ms(*v), + ScalarValue::Float32(Some(v)) => float_to_ms(*v as f64), ScalarValue::TimestampMillisecond(Some(ms), _) => Some(*ms), ScalarValue::TimestampNanosecond(Some(ns), _) => Some(*ns / 1_000_000), ScalarValue::TimestampMicrosecond(Some(us), _) => Some(*us / 1_000), @@ -1008,6 +1031,7 @@ fn lower_window_func_kind(fun: &WindowFunctionDefinition) -> Result Ok(WindowFuncKind::Lead), "first_value" => Ok(WindowFuncKind::FirstValue), "last_value" => Ok(WindowFuncKind::LastValue), + // NthValue(0) is a sentinel; lower_window extracts the real N from args. "nth_value" => Ok(WindowFuncKind::NthValue(0)), other => Err(LoweringError::UnsupportedFeature(format!( "window fn: {other}" From b00fc4a37b56628bb09edbf2099f04230d058544 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Fri, 22 May 2026 22:49:41 -0400 Subject: [PATCH 14/17] fixed comments --- crates/core/src/intent_algebra/expr.rs | 33 ++---- crates/core/tests/schema_derivation.rs | 32 +++--- crates/lower/src/lib.rs | 12 -- crates/lower/src/sql.rs | 146 +++++++++++++++++++------ crates/lower/tests/sql_lowering.rs | 105 +++++++++++++++++- 5 files changed, 235 insertions(+), 93 deletions(-) diff --git a/crates/core/src/intent_algebra/expr.rs b/crates/core/src/intent_algebra/expr.rs index d079c925..90444f3a 100644 --- a/crates/core/src/intent_algebra/expr.rs +++ b/crates/core/src/intent_algebra/expr.rs @@ -25,19 +25,6 @@ pub struct GroupKey(pub String); #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct ColumnRef(pub String); -impl ColumnRef { - /// Returns `true` when this ref is the `"*"` wildcard sentinel produced by - /// `agg_col` for aggregate args that are not a named column (e.g. `COUNT(*)`). - /// Schema derivation skips wildcard refs and falls back to the `Float64` default. - /// - /// TODO: replace this sentinel with `Option` in the `AggIntent` variants - /// that carry a `col` field (`Sum`, `Min`, `Max`, `Avg`, `Stddev`). `None` = no - /// column (wildcard / count-star); `Some(ColumnRef(...))` = real column. `agg_col` - /// would return `Option` and this method disappears entirely. - pub fn is_wildcard(&self) -> bool { - self.0 == "*" - } -} /// A set of partitioning keys (sharding hint for L5 stage allocator). #[derive(Debug, Clone)] pub struct PartitionKeys; @@ -123,7 +110,7 @@ pub enum WindowFuncKind { Lead, FirstValue, LastValue, - NthValue(u64), + NthValue(Option), Sum, Avg, Count, @@ -208,20 +195,20 @@ pub enum AggIntent { accuracy: AccuracyTarget, }, Sum { - col: ColumnRef, + col: Option, }, Min { - col: ColumnRef, + col: Option, }, Max { - col: ColumnRef, + col: Option, }, Avg { - col: ColumnRef, + col: Option, }, /// Sample stddev when `population == false`; population stddev otherwise. Stddev { - col: ColumnRef, + col: Option, population: bool, }, Quantile { @@ -273,13 +260,7 @@ impl AggIntent { | Self::Min { col } | Self::Max { col } | Self::Avg { col } - | Self::Stddev { col, .. } => { - if col.is_wildcard() { - None - } else { - Some(col) - } - } + | Self::Stddev { col, .. } => col.as_ref(), _ => None, } } diff --git a/crates/core/tests/schema_derivation.rs b/crates/core/tests/schema_derivation.rs index c5ed9399..f377ec39 100644 --- a/crates/core/tests/schema_derivation.rs +++ b/crates/core/tests/schema_derivation.rs @@ -118,7 +118,7 @@ fn requires_count_is_any() { fn requires_sum_is_any() { assert_eq!( AggIntent::Sum { - col: ColumnRef("x".into()) + col: Some(ColumnRef("x".into())) } .requires(), DataModel::Any @@ -129,7 +129,7 @@ fn requires_sum_is_any() { fn requires_min_is_any() { assert_eq!( AggIntent::Min { - col: ColumnRef("x".into()) + col: Some(ColumnRef("x".into())) } .requires(), DataModel::Any @@ -140,7 +140,7 @@ fn requires_min_is_any() { fn requires_max_is_any() { assert_eq!( AggIntent::Max { - col: ColumnRef("x".into()) + col: Some(ColumnRef("x".into())) } .requires(), DataModel::Any @@ -151,7 +151,7 @@ fn requires_max_is_any() { fn requires_avg_is_any() { assert_eq!( AggIntent::Avg { - col: ColumnRef("x".into()) + col: Some(ColumnRef("x".into())) } .requires(), DataModel::Any @@ -162,7 +162,7 @@ fn requires_avg_is_any() { fn requires_stddev_sample_is_any() { assert_eq!( AggIntent::Stddev { - col: ColumnRef("x".into()), + col: Some(ColumnRef("x".into())), population: false } .requires(), @@ -174,7 +174,7 @@ fn requires_stddev_sample_is_any() { fn requires_stddev_population_is_any() { assert_eq!( AggIntent::Stddev { - col: ColumnRef("x".into()), + col: Some(ColumnRef("x".into())), population: true } .requires(), @@ -275,7 +275,7 @@ fn output_type_sum_is_float64() { let f = field("value", L3DataType::Float64); assert_eq!( AggIntent::Sum { - col: ColumnRef("value".into()) + col: Some(ColumnRef("value".into())) } .output_type(&f), Some(L3DataType::Float64) @@ -287,7 +287,7 @@ fn output_type_avg_is_float64() { let f = field("value", L3DataType::Int64); assert_eq!( AggIntent::Avg { - col: ColumnRef("value".into()) + col: Some(ColumnRef("value".into())) } .output_type(&f), Some(L3DataType::Float64) @@ -299,7 +299,7 @@ fn output_type_stddev_sample_is_float64() { let f = field("value", L3DataType::Float64); assert_eq!( AggIntent::Stddev { - col: ColumnRef("value".into()), + col: Some(ColumnRef("value".into())), population: false } .output_type(&f), @@ -312,7 +312,7 @@ fn output_type_stddev_population_is_float64() { let f = field("value", L3DataType::Float64); assert_eq!( AggIntent::Stddev { - col: ColumnRef("value".into()), + col: Some(ColumnRef("value".into())), population: true } .output_type(&f), @@ -362,7 +362,7 @@ fn output_type_min_preserves_int64_input() { let f = field("count", L3DataType::Int64); assert_eq!( AggIntent::Min { - col: ColumnRef("count".into()) + col: Some(ColumnRef("count".into())) } .output_type(&f), Some(L3DataType::Int64) @@ -374,7 +374,7 @@ fn output_type_min_preserves_float64_input() { let f = field("value", L3DataType::Float64); assert_eq!( AggIntent::Min { - col: ColumnRef("value".into()) + col: Some(ColumnRef("value".into())) } .output_type(&f), Some(L3DataType::Float64) @@ -386,7 +386,7 @@ fn output_type_max_preserves_utf8_input() { let f = field("name", L3DataType::Utf8); assert_eq!( AggIntent::Max { - col: ColumnRef("name".into()) + col: Some(ColumnRef("name".into())) } .output_type(&f), Some(L3DataType::Utf8) @@ -589,10 +589,10 @@ fn aggregate_multiple_aggs() { aggs: vec![ AggIntent::Count { accuracy: exact() }, AggIntent::Sum { - col: ColumnRef("value".into()), + col: Some(ColumnRef("value".into())), }, AggIntent::Min { - col: ColumnRef("value".into()), + col: Some(ColumnRef("value".into())), }, ], having: None, @@ -686,7 +686,7 @@ fn aggregate_propagates_time_index_when_time_col_in_group_by() { child: dummy_scan(cs.clone()), by: vec![GroupKey("ts".into())], aggs: vec![AggIntent::Sum { - col: ColumnRef("value".into()), + col: Some(ColumnRef("value".into())), }], having: None, output_names: vec!["sum_value".into()], diff --git a/crates/lower/src/lib.rs b/crates/lower/src/lib.rs index 29409040..3fdb6e97 100644 --- a/crates/lower/src/lib.rs +++ b/crates/lower/src/lib.rs @@ -57,18 +57,6 @@ pub async fn lower_batch( } } - // Validate the catalog upfront so invalid time_column names are caught in all - // build profiles (debug_assert in lower_filter fires only in debug builds). - for (name, schema) in &catalog.tables { - if let Err(e) = schema.validate() { - let msg = format!("catalog table '{name}': {e}"); - return entries - .iter() - .map(|_| Err(LoweringError::InvalidExpression(msg.clone()))) - .collect(); - } - } - let mut results = Vec::with_capacity(entries.len()); for entry in entries { let accuracy = entry diff --git a/crates/lower/src/sql.rs b/crates/lower/src/sql.rs index 7db7a5b7..36e69ebb 100644 --- a/crates/lower/src/sql.rs +++ b/crates/lower/src/sql.rs @@ -122,19 +122,27 @@ impl<'a> SqlLowerer<'a> { } fn lower_filter(&self, filter: &logical_expr::Filter) -> Result { - // When the direct child is a TableScan and the table has a time column, - // split the predicate: time bounds go into Source::Table.time_range; the - // remaining non-time conjuncts become the Filter predicate. - let inner = strip_aliases(&filter.input); - if let LogicalPlan::TableScan(scan) = inner { + // Walk the full filter chain to find a TableScan at any depth, then + // collect predicates from all stacked filters (including the outermost). + let (inner_preds, maybe_scan) = collect_filter_chain(&filter.input); + + if let Some(scan) = maybe_scan { let table_name = scan.table_name.to_string(); if let Some(schema) = self.catalog.tables.get(&table_name) { if let Some(time_col) = &schema.time_column { - debug_assert!( - schema.columns.iter().any(|c| &c.name == time_col), - "time_column '{time_col}' not found in table columns; call TableSchema::validate() on catalog construction" - ); - let (time_range, non_time) = extract_time_range(&filter.predicate, time_col); + if let Err(e) = schema.validate() { + return Err(LoweringError::InvalidExpression(format!( + "catalog table '{table_name}': {e}" + ))); + } + // Merge outermost predicate + all inner filter predicates into one + // flat conjunct list, then classify for time-range extraction. + let all_conjuncts: Vec<&Expr> = std::iter::once(&filter.predicate) + .chain(inner_preds) + .flat_map(|p| split_conjuncts(p)) + .collect(); + let (time_range, non_time) = + extract_time_range_from_conjuncts(all_conjuncts, time_col); let columns = projection_columns(scan, schema); let scan_expr = QueryExpr::Scan { source: Source::Table { @@ -267,6 +275,12 @@ impl<'a> SqlLowerer<'a> { fn lower_limit(&self, limit: &logical_expr::Limit) -> Result { // TopK: Limit on top of Sort on top of Aggregate, all sort keys DESC. if let Some(k) = eval_fetch(&limit.fetch) { + if eval_fetch(&limit.skip).unwrap_or(0) > 0 { + return Err(LoweringError::UnsupportedFeature( + "LIMIT ... OFFSET is not supported with ORDER BY ... DESC aggregates (TopK)" + .into(), + )); + } let inner = strip_aliases(&limit.input); if let LogicalPlan::Sort(sort) = inner { if sort.expr.iter().all(|s| !s.asc) { @@ -329,7 +343,7 @@ impl<'a> SqlLowerer<'a> { .collect::, _>>()?; // For NthValue, extract N from args[1] and keep only the column (args[0]). - let func = if matches!(func, WindowFuncKind::NthValue(_)) { + let func = if matches!(func, WindowFuncKind::NthValue(None)) { let n = match args.get(1) { Some(L3Expr::Literal(L3Scalar::Int64(n))) if *n > 0 => *n as u64, other => { @@ -339,15 +353,13 @@ impl<'a> SqlLowerer<'a> { } }; args.truncate(1); - WindowFuncKind::NthValue(n) + WindowFuncKind::NthValue(Some(n)) } else { func }; - // lower_window_func_kind emits NthValue(0) as a sentinel that must - // be resolved to a real N above. Catch any bypass in debug builds. debug_assert!( - !matches!(func, WindowFuncKind::NthValue(0)), - "NthValue(0) sentinel was not resolved; lower_window has a bug" + !matches!(func, WindowFuncKind::NthValue(None)), + "NthValue sentinel not resolved; lower_window has a bug" ); let partition_by = wf @@ -570,22 +582,21 @@ fn expr_to_col_ref(expr: &Expr) -> Result { } } -/// Best-effort: extract the first column name from aggregate function args. -/// For `SUM(value)` → `ColumnRef("value")`. For `COUNT(*)` or expressions, -/// falls back to `"*"` or the expression display string. -fn agg_col(args: &[Expr]) -> ColumnRef { +/// Extract the aggregated column name from aggregate function args. +/// Returns `None` for wildcards (`COUNT(*)`) and non-column expressions. +fn agg_col(args: &[Expr]) -> Option { match args.first() { - Some(Expr::Column(col)) => ColumnRef(col.name.clone()), + Some(Expr::Column(col)) => Some(ColumnRef(col.name.clone())), Some(Expr::Alias(a)) => match a.expr.as_ref() { - Expr::Column(col) => ColumnRef(col.name.clone()), - e => ColumnRef(format!("{e}")), + Expr::Column(col) => Some(ColumnRef(col.name.clone())), + _ => None, }, Some(Expr::Cast(c)) => match c.expr.as_ref() { - Expr::Column(col) => ColumnRef(col.name.clone()), - e => ColumnRef(format!("{e}")), + Expr::Column(col) => Some(ColumnRef(col.name.clone())), + _ => None, }, - Some(Expr::Wildcard { .. }) | None => ColumnRef("*".into()), - Some(e) => ColumnRef(format!("{e}")), + Some(Expr::Wildcard { .. }) | None => None, + _ => None, } } @@ -617,11 +628,11 @@ fn split_conjuncts(expr: &Expr) -> Vec<&Expr> { } } -/// Split `expr` into `(time_range, non_time_conjuncts)`. -/// Time-bound conjuncts are folded into the `TimeRange`; the rest are returned -/// as a `Vec<&Expr>` so the caller can translate them with `df_expr_to_l3`. -fn extract_time_range<'a>(expr: &'a Expr, time_col: &str) -> (Option, Vec<&'a Expr>) { - let conjuncts = split_conjuncts(expr); +/// Core: classify a pre-split list of conjuncts into time bounds + residual. +fn extract_time_range_from_conjuncts<'a>( + conjuncts: Vec<&'a Expr>, + time_col: &str, +) -> (Option, Vec<&'a Expr>) { let mut start_ms: Option = None; let mut end_ms: Option = None; let mut non_time: Vec<&'a Expr> = vec![]; @@ -650,6 +661,32 @@ fn extract_time_range<'a>(expr: &'a Expr, time_col: &str) -> (Option, (range, non_time) } +/// Convenience wrapper: split a single expression then classify conjuncts. +#[cfg(test)] +fn extract_time_range<'a>(expr: &'a Expr, time_col: &str) -> (Option, Vec<&'a Expr>) { + extract_time_range_from_conjuncts(split_conjuncts(expr), time_col) +} + +/// Walk a `Filter(Filter(...(TableScan)))` chain. +/// Returns `(predicates_from_inner_filters, Some(scan))` when a TableScan is +/// found at any depth, or `(vec![], None)` if a non-Filter non-Scan node is +/// reached first. The outermost filter's predicate is NOT included — the +/// caller adds it. +fn collect_filter_chain(plan: &LogicalPlan) -> (Vec<&Expr>, Option<&logical_expr::TableScan>) { + let plan = strip_aliases(plan); + match plan { + LogicalPlan::TableScan(scan) => (vec![], Some(scan)), + LogicalPlan::Filter(f) => { + let (mut inner_preds, maybe_scan) = collect_filter_chain(&f.input); + if maybe_scan.is_some() { + inner_preds.push(&f.predicate); + } + (inner_preds, maybe_scan) + } + _ => (vec![], None), + } +} + /// Translate a slice of DataFusion `Expr`s (non-time conjuncts) into a single /// `L3Expr`. A single element is returned as-is; multiple elements are wrapped /// in `L3Expr::BoolAnd`. @@ -1031,8 +1068,8 @@ fn lower_window_func_kind(fun: &WindowFunctionDefinition) -> Result Ok(WindowFuncKind::Lead), "first_value" => Ok(WindowFuncKind::FirstValue), "last_value" => Ok(WindowFuncKind::LastValue), - // NthValue(0) is a sentinel; lower_window extracts the real N from args. - "nth_value" => Ok(WindowFuncKind::NthValue(0)), + // NthValue(None) is a sentinel; lower_window extracts the real N from args. + "nth_value" => Ok(WindowFuncKind::NthValue(None)), other => Err(LoweringError::UnsupportedFeature(format!( "window fn: {other}" ))), @@ -1048,13 +1085,13 @@ fn lower_window_func_kind(fun: &WindowFunctionDefinition) -> Result { use datafusion::logical_expr::BuiltInWindowFunction; match biwf { BuiltInWindowFunction::FirstValue => Ok(WindowFuncKind::FirstValue), BuiltInWindowFunction::LastValue => Ok(WindowFuncKind::LastValue), - BuiltInWindowFunction::NthValue => Ok(WindowFuncKind::NthValue(0)), + BuiltInWindowFunction::NthValue => Ok(WindowFuncKind::NthValue(None)), } } } @@ -1210,4 +1247,41 @@ mod tests { assert!(range.is_some()); assert_eq!(non_time.len(), 1); } + + // ── collect_filter_chain unit tests (Fix 3) ─────────────────────────────── + + fn empty_scan() -> LogicalPlan { + use datafusion::arrow::datatypes::{DataType, Field, Schema}; + use datafusion::logical_expr::builder::LogicalTableSource; + use datafusion::logical_expr::LogicalPlanBuilder; + + let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)])); + let source = Arc::new(LogicalTableSource::new(schema)); + LogicalPlanBuilder::scan("t", source, None) + .unwrap() + .build() + .unwrap() + } + + #[test] + fn collect_filter_chain_finds_scan_at_depth_zero() { + let scan = empty_scan(); + let (preds, maybe_scan) = collect_filter_chain(&scan); + assert!(maybe_scan.is_some(), "should find the TableScan"); + assert!(preds.is_empty(), "no inner predicates at depth zero"); + } + + #[test] + fn collect_filter_chain_returns_none_for_non_scan() { + use datafusion::common::DFSchema; + use datafusion::logical_expr::EmptyRelation; + + let empty = LogicalPlan::EmptyRelation(EmptyRelation { + produce_one_row: false, + schema: Arc::new(DFSchema::empty()), + }); + let (preds, maybe_scan) = collect_filter_chain(&empty); + assert!(maybe_scan.is_none()); + assert!(preds.is_empty()); + } } diff --git a/crates/lower/tests/sql_lowering.rs b/crates/lower/tests/sql_lowering.rs index c7211854..d5bf31f0 100644 --- a/crates/lower/tests/sql_lowering.rs +++ b/crates/lower/tests/sql_lowering.rs @@ -452,6 +452,25 @@ async fn test_order_by_desc_limit_becomes_topk() { assert!(by.iter().any(|c| c.0 == "host")); } +// ── Test: TopK + OFFSET returns error (#1) ─────────────────────────────────── + +#[tokio::test] +async fn test_order_by_desc_limit_with_offset_returns_error() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let err = lowerer + .lower( + "SELECT host, COUNT(*) FROM metrics \ + GROUP BY host ORDER BY 2 DESC LIMIT 10 OFFSET 5", + ) + .await + .unwrap_err(); + assert!( + matches!(err, LoweringError::UnsupportedFeature(ref msg) if msg.contains("OFFSET")), + "expected UnsupportedFeature(OFFSET), got: {err}" + ); +} + // ── Test: Window functions ──────────────────────────────────────────────────── #[tokio::test] @@ -749,6 +768,56 @@ async fn test_unknown_table_returns_error() { ); } +// ── Tests: per-table catalog validation (#9) ────────────────────────────────── + +#[tokio::test] +async fn test_bad_time_column_fails_query_touching_that_table() { + let mut tables = HashMap::new(); + // Valid table. + tables.insert( + "metrics".to_string(), + TableSchema { + columns: vec![ColumnDef { + name: "value".to_string(), + data_type: L3DataType::Float64, + nullable: true, + }], + time_column: None, + }, + ); + // Table whose time_column doesn't exist in its columns list. + tables.insert( + "broken".to_string(), + TableSchema { + columns: vec![ColumnDef { + name: "id".to_string(), + data_type: L3DataType::Int64, + nullable: false, + }], + time_column: Some("nonexistent".to_string()), + }, + ); + let catalog = SchemaCatalog { tables }; + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + + // Query on the valid table must succeed. + let result = lowerer.lower("SELECT value FROM metrics").await; + assert!( + result.is_ok(), + "query on valid table should succeed: {result:?}" + ); + + // Query on the broken table must fail with InvalidExpression. + let err = lowerer + .lower("SELECT id FROM broken WHERE id > 0") + .await + .unwrap_err(); + assert!( + matches!(err, LoweringError::InvalidExpression(_)), + "expected InvalidExpression for bad time_column, got: {err}" + ); +} + // ── Tests: language guard ───────────────────────────────────────────────────── #[tokio::test] @@ -997,6 +1066,32 @@ async fn test_scan_columns_empty_for_select_star() { ); } +// ── Tests: push_columns_into_scan gap for aggregate topology (#4) ──────────── + +#[tokio::test] +async fn test_scan_columns_empty_for_aggregate_topology() { + // push_columns_into_scan only rewrites a Scan that is the direct child of + // a Project. When there is an Aggregate between Project and Scan (the normal + // GROUP BY shape), column pushdown is skipped and Scan.columns stays empty + // ("all columns" semantics). This test documents the known gap so that any + // future fix must also update this assertion. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT region, COUNT(*) FROM metrics GROUP BY region") + .await + .unwrap(); + + let source = find_source(&result).expect("expected a Scan node"); + let Source::Table { columns, .. } = source else { + panic!("expected Source::Table"); + }; + assert!( + columns.is_empty(), + "Project→Aggregate→Scan: Scan.columns should be empty (all-columns semantics), got: {columns:?}" + ); +} + // ── Tests: UNION / UNION ALL ────────────────────────────────────────────────── #[tokio::test] @@ -1330,7 +1425,11 @@ async fn test_agg_col_name_tracked_via_col_field() { let AggIntent::Min { col } = &aggs[0] else { panic!("expected Min, got {:?}", aggs[0]); }; - assert_eq!(col.0, "ts", "Min should track the aggregated column name"); + assert_eq!( + col.as_ref().map(|c| c.0.as_str()), + Some("ts"), + "Min should track the aggregated column name" + ); } // ── Tests: NthValue N extraction (#10) ─────────────────────────────────────── @@ -1358,9 +1457,9 @@ async fn test_nth_value_extracts_n_from_args() { assert!( matches!( kind, - asap_control_core::intent_algebra::expr::WindowFuncKind::NthValue(2) + asap_control_core::intent_algebra::expr::WindowFuncKind::NthValue(Some(2)) ), - "expected NthValue(2), got: {kind:?}" + "expected NthValue(Some(2)), got: {kind:?}" ); } From c5613b227cfd99ae4251e29cc277c5d1f0da0c86 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Mon, 25 May 2026 13:06:07 -0400 Subject: [PATCH 15/17] added some more tests --- crates/lower/tests/sql_lowering.rs | 176 +++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) diff --git a/crates/lower/tests/sql_lowering.rs b/crates/lower/tests/sql_lowering.rs index d5bf31f0..d626b690 100644 --- a/crates/lower/tests/sql_lowering.rs +++ b/crates/lower/tests/sql_lowering.rs @@ -1811,3 +1811,179 @@ async fn test_populate_schemas_window_func_appends_column() { "root schema should be populated after populate_schemas" ); } + +// ── Tests: CR findings #1, #2, #5 ──────────────────────────────────────────── + +#[tokio::test] +async fn test_having_filter_fallback_preserves_inner_time_range() { + // Finding #1: lower_filter's collect_filter_chain stops at Aggregate + // (not a scan-chain node), so the HAVING Filter takes the fallback path. + // The WHERE Filter sitting below the Aggregate must still fold ts predicates + // into Source::Table.time_range via the normal scan-chain path. + // + // This test would catch a regression where the HAVING fallback accidentally + // short-circuits the inner time extraction. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let expr = lowerer + .lower( + "SELECT region, COUNT(*) FROM metrics \ + WHERE ts >= 1000 AND ts < 2000 \ + GROUP BY region \ + HAVING region = 'us'", + ) + .await + .unwrap(); + + // Inner WHERE predicates must still be folded into time_range. + let source = find_source(&expr).expect("expected Scan in tree"); + let Source::Table { time_range, .. } = source else { + panic!("expected Source::Table, got {source:?}"); + }; + assert!( + time_range.is_some(), + "time_range should be extracted from WHERE ts >= 1000 AND ts < 2000; \ + the HAVING-path fallback must not prevent inner time extraction" + ); + let range = time_range.as_ref().unwrap(); + assert_eq!(range.start_ms, Some(1000), "start_ms from ts >= 1000"); + assert_eq!(range.end_ms, Some(2000), "end_ms from ts < 2000"); + + // The HAVING predicate must appear as a Filter node directly above an Aggregate. + fn has_filter_over_aggregate(expr: &QueryExpr) -> bool { + match expr { + QueryExpr::Filter { child, .. } => matches!(child.expr, QueryExpr::Aggregate { .. }), + QueryExpr::Project { child, .. } | QueryExpr::Sort { child, .. } => { + has_filter_over_aggregate(&child.expr) + } + _ => false, + } + } + assert!( + has_filter_over_aggregate(&expr), + "HAVING should produce a Filter node directly wrapping an Aggregate; got: {expr:?}" + ); +} + +#[tokio::test] +async fn test_scan_columns_empty_when_aggregate_between_project_and_scan() { + // Finding #2: push_columns_into_scan recurses only through Filter nodes. + // For Project → Aggregate → Scan, push_columns_into_scan encounters the + // Aggregate and falls through to `other => other`, leaving Scan.columns empty. + // + // An empty Scan.columns means "all columns" (semantically safe), but prevents + // downstream stages from pruning unneeded columns. + // + // TODO: when this gap is fixed, update the assertion to check that columns + // contains ["value", "region"] (the columns actually referenced). + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let expr = lowerer + .lower("SELECT MAX(value) FROM metrics GROUP BY region") + .await + .unwrap(); + + let source = find_source(&expr).expect("expected Scan in tree"); + let Source::Table { columns, .. } = source else { + panic!("expected Source::Table, got {source:?}"); + }; + assert!( + columns.is_empty(), + "known gap: Scan.columns should be empty (full scan) when Aggregate sits \ + between Project and Scan — if this fails the gap has been fixed, \ + update to assert columns == [value, region]; got {columns:?}" + ); +} + +#[tokio::test] +async fn test_max_of_expression_arg_schema_reports_float64_known_bug() { + // Finding #5: agg_col() returns None for non-bare-column aggregate args + // (e.g. `ts + 1`). Schema derivation falls back to a Float64 dummy field, + // so MAX(ts + 1) reports Float64 even though ts: Int64 → correct type is Int64. + // + // This test pins the current (incorrect) Float64 output as a known bug. + // TODO: fix agg_col / output_type so MAX of an Int64 expression → Int64. + // When fixed, change the assertion below to L3DataType::Int64. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let expr = lowerer + .lower("SELECT MAX(ts + 1) FROM metrics") + .await + .unwrap(); + let typed = populate_schemas(expr, &catalog); + + // Root is Project → Aggregate → Scan; first schema field is the MAX output. + assert_eq!( + typed.schema.fields.len(), + 1, + "expected one output field for SELECT MAX(...)" + ); + let dtype = &typed.schema.fields[0].dtype; + // BUG: currently Float64 because agg_col returns None → float64_dummy fallback. + // Should be Int64 (MAX of Int64 expression preserves the type). + assert_eq!( + *dtype, + L3DataType::Float64, + "known bug: MAX(ts + 1) schema reports {dtype:?} — expected Float64 \ + (the current wrong value); update to Int64 when the agg_col bug is fixed" + ); +} + +// ── Tests: CR findings #11 and #12 ─────────────────────────────────────────── + +#[tokio::test] +async fn test_invalid_time_column_in_catalog_returns_error() { + // Finding #11: lower_filter calls schema.validate() when a table has a + // time_column set. If time_column names a column that doesn't exist in + // TableSchema.columns, validate() returns Err, which lower_filter propagates + // as LoweringError::InvalidExpression. + let mut tables = HashMap::new(); + tables.insert( + "metrics".to_string(), + TableSchema { + columns: vec![ColumnDef { + name: "value".to_string(), + data_type: L3DataType::Float64, + nullable: true, + }], + // "ghost_ts" doesn't exist in columns — validate() will reject this. + time_column: Some("ghost_ts".to_string()), + }, + ); + let catalog = SchemaCatalog { tables }; + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + // Any WHERE clause triggers lower_filter → collect_filter_chain finds the + // TableScan → catalog lookup → time_column is Some → validate() fires. + let result = lowerer + .lower("SELECT value FROM metrics WHERE value > 0.0") + .await; + assert!( + matches!(result, Err(LoweringError::InvalidExpression(_))), + "expected InvalidExpression for catalog with invalid time_column, got: {result:?}" + ); +} + +#[tokio::test] +async fn test_agg_col_is_none_for_expression_arg() { + // Finding #12: agg_col() returns None when the aggregate argument is not + // a bare column reference (e.g. SUM(value + 1) uses a BinaryExpr, not Column). + // This documents the boundary: only Column, Alias(Column), and Cast(Column) + // produce a Some(ColumnRef); any other expression shape produces None. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let expr = lowerer + .lower("SELECT SUM(value + 1) FROM metrics GROUP BY region") + .await + .unwrap(); + + let (_, aggs) = find_aggregate(&expr).expect("expected Aggregate in tree"); + assert_eq!(aggs.len(), 1); + let AggIntent::Sum { col } = &aggs[0] else { + panic!("expected AggIntent::Sum, got {:?}", aggs[0]); + }; + assert!( + col.is_none(), + "agg_col should return None for SUM(value + 1) — the arg is a BinaryExpr, \ + not a bare ColumnRef; got {col:?}" + ); +} From ca73c4b1b7828cef0b52325c8ac7a63674b5d368 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Mon, 25 May 2026 15:06:51 -0400 Subject: [PATCH 16/17] refactored --- crates/core/tests/schema_derivation.rs | 1966 ++++++------- crates/lower/src/sql/expr.rs | 226 ++ crates/lower/src/{sql.rs => sql/mod.rs} | 590 +--- crates/lower/src/sql/time.rs | 288 ++ crates/lower/src/sql/types.rs | 84 + crates/lower/tests/sql_lowering.rs | 3472 ++++++++++++----------- 6 files changed, 3365 insertions(+), 3261 deletions(-) create mode 100644 crates/lower/src/sql/expr.rs rename crates/lower/src/{sql.rs => sql/mod.rs} (58%) create mode 100644 crates/lower/src/sql/time.rs create mode 100644 crates/lower/src/sql/types.rs diff --git a/crates/core/tests/schema_derivation.rs b/crates/core/tests/schema_derivation.rs index f377ec39..84f91e16 100644 --- a/crates/core/tests/schema_derivation.rs +++ b/crates/core/tests/schema_derivation.rs @@ -104,1065 +104,1099 @@ fn eps(e: f64) -> AccuracyTarget { AccuracyTarget::Epsilon(e) } -// ── AggIntent::requires() ───────────────────────────────────────────────────── - -#[test] -fn requires_count_is_any() { - assert_eq!( - AggIntent::Count { accuracy: exact() }.requires(), - DataModel::Any - ); +fn child_schema() -> L3Schema { + schema(vec![ + field("ts", L3DataType::Int64), + nullable_field("value", L3DataType::Float64), + ]) } - -#[test] -fn requires_sum_is_any() { - assert_eq!( - AggIntent::Sum { - col: Some(ColumnRef("x".into())) - } - .requires(), - DataModel::Any - ); +fn dummy_scan(s: L3Schema) -> Arc { + make_node( + QueryExpr::Scan { + source: Source::Table { + table_ref: TableRef("metrics".into()), + columns: vec![], + time_range: None, + }, + predicates: vec![], + }, + s, + ) } - -#[test] -fn requires_min_is_any() { - assert_eq!( - AggIntent::Min { - col: Some(ColumnRef("x".into())) - } - .requires(), - DataModel::Any - ); +fn metrics_child_schema() -> L3Schema { + schema(vec![ + field("ts", L3DataType::Int64), + nullable_field("value", L3DataType::Float64), + nullable_field("region", L3DataType::Utf8), + nullable_field("host", L3DataType::Utf8), + ]) } -#[test] -fn requires_max_is_any() { - assert_eq!( - AggIntent::Max { - col: Some(ColumnRef("x".into())) - } - .requires(), - DataModel::Any - ); +fn timed_two_col_schema() -> L3Schema { + schema_with_time( + vec![ + field("ts", L3DataType::Int64), + nullable_field("value", L3DataType::Float64), + ], + 0, + ) } -#[test] -fn requires_avg_is_any() { - assert_eq!( - AggIntent::Avg { - col: Some(ColumnRef("x".into())) - } - .requires(), - DataModel::Any - ); -} +mod agg_intent_requires { + use super::*; -#[test] -fn requires_stddev_sample_is_any() { - assert_eq!( - AggIntent::Stddev { - col: Some(ColumnRef("x".into())), - population: false - } - .requires(), - DataModel::Any - ); -} + // ── AggIntent::requires() ───────────────────────────────────────────────────── -#[test] -fn requires_stddev_population_is_any() { - assert_eq!( - AggIntent::Stddev { - col: Some(ColumnRef("x".into())), - population: true - } - .requires(), - DataModel::Any - ); -} + #[test] + fn requires_count_is_any() { + assert_eq!( + AggIntent::Count { accuracy: exact() }.requires(), + DataModel::Any + ); + } -#[test] -fn requires_quantile_is_any() { - assert_eq!( - AggIntent::Quantile { - q: 0.99, - accuracy: exact() - } - .requires(), - DataModel::Any - ); -} + #[test] + fn requires_sum_is_any() { + assert_eq!( + AggIntent::Sum { + col: Some(ColumnRef("x".into())) + } + .requires(), + DataModel::Any + ); + } -#[test] -fn requires_cardinality_is_any() { - assert_eq!( - AggIntent::Cardinality { accuracy: exact() }.requires(), - DataModel::Any - ); -} + #[test] + fn requires_min_is_any() { + assert_eq!( + AggIntent::Min { + col: Some(ColumnRef("x".into())) + } + .requires(), + DataModel::Any + ); + } -#[test] -fn requires_topk_is_any() { - assert_eq!( - AggIntent::TopK { - k: 10, - by: vec![], - accuracy: exact() - } - .requires(), - DataModel::Any - ); -} + #[test] + fn requires_max_is_any() { + assert_eq!( + AggIntent::Max { + col: Some(ColumnRef("x".into())) + } + .requires(), + DataModel::Any + ); + } -#[test] -fn requires_rate_is_timeseries() { - assert_eq!( - AggIntent::Rate { - window: std::time::Duration::from_secs(60) - } - .requires(), - DataModel::TimeSeries - ); -} + #[test] + fn requires_avg_is_any() { + assert_eq!( + AggIntent::Avg { + col: Some(ColumnRef("x".into())) + } + .requires(), + DataModel::Any + ); + } -#[test] -fn requires_increase_is_timeseries() { - assert_eq!( - AggIntent::Increase { - window: std::time::Duration::from_secs(300) - } - .requires(), - DataModel::TimeSeries - ); -} + #[test] + fn requires_stddev_sample_is_any() { + assert_eq!( + AggIntent::Stddev { + col: Some(ColumnRef("x".into())), + population: false + } + .requires(), + DataModel::Any + ); + } -// ── AggIntent::output_type() ────────────────────────────────────────────────── + #[test] + fn requires_stddev_population_is_any() { + assert_eq!( + AggIntent::Stddev { + col: Some(ColumnRef("x".into())), + population: true + } + .requires(), + DataModel::Any + ); + } -#[test] -fn output_type_count_is_int64() { - let f = field("x", L3DataType::Float64); - assert_eq!( - AggIntent::Count { accuracy: exact() }.output_type(&f), - Some(L3DataType::Int64) - ); -} + #[test] + fn requires_quantile_is_any() { + assert_eq!( + AggIntent::Quantile { + q: 0.99, + accuracy: exact() + } + .requires(), + DataModel::Any + ); + } -#[test] -fn output_type_count_ignores_input_type() { - // Count is always Int64 regardless of the aggregated column's type. - let f = field("x", L3DataType::Utf8); - assert_eq!( - AggIntent::Count { - accuracy: eps(0.01) - } - .output_type(&f), - Some(L3DataType::Int64) - ); -} + #[test] + fn requires_cardinality_is_any() { + assert_eq!( + AggIntent::Cardinality { accuracy: exact() }.requires(), + DataModel::Any + ); + } -#[test] -fn output_type_cardinality_is_int64() { - let f = field("host", L3DataType::Utf8); - assert_eq!( - AggIntent::Cardinality { accuracy: exact() }.output_type(&f), - Some(L3DataType::Int64) - ); -} + #[test] + fn requires_topk_is_any() { + assert_eq!( + AggIntent::TopK { + k: 10, + by: vec![], + accuracy: exact() + } + .requires(), + DataModel::Any + ); + } -#[test] -fn output_type_sum_is_float64() { - let f = field("value", L3DataType::Float64); - assert_eq!( - AggIntent::Sum { - col: Some(ColumnRef("value".into())) - } - .output_type(&f), - Some(L3DataType::Float64) - ); -} + #[test] + fn requires_rate_is_timeseries() { + assert_eq!( + AggIntent::Rate { + window: std::time::Duration::from_secs(60) + } + .requires(), + DataModel::TimeSeries + ); + } -#[test] -fn output_type_avg_is_float64() { - let f = field("value", L3DataType::Int64); - assert_eq!( - AggIntent::Avg { - col: Some(ColumnRef("value".into())) - } - .output_type(&f), - Some(L3DataType::Float64) - ); + #[test] + fn requires_increase_is_timeseries() { + assert_eq!( + AggIntent::Increase { + window: std::time::Duration::from_secs(300) + } + .requires(), + DataModel::TimeSeries + ); + } } -#[test] -fn output_type_stddev_sample_is_float64() { - let f = field("value", L3DataType::Float64); - assert_eq!( - AggIntent::Stddev { - col: Some(ColumnRef("value".into())), - population: false - } - .output_type(&f), - Some(L3DataType::Float64) - ); -} +mod agg_intent_output_type { + use super::*; -#[test] -fn output_type_stddev_population_is_float64() { - let f = field("value", L3DataType::Float64); - assert_eq!( - AggIntent::Stddev { - col: Some(ColumnRef("value".into())), - population: true - } - .output_type(&f), - Some(L3DataType::Float64) - ); -} + // ── AggIntent::output_type() ────────────────────────────────────────────────── -#[test] -fn output_type_quantile_is_float64() { - let f = field("latency", L3DataType::Float64); - assert_eq!( - AggIntent::Quantile { - q: 0.5, - accuracy: exact() - } - .output_type(&f), - Some(L3DataType::Float64) - ); -} + #[test] + fn output_type_count_is_int64() { + let f = field("x", L3DataType::Float64); + assert_eq!( + AggIntent::Count { accuracy: exact() }.output_type(&f), + Some(L3DataType::Int64) + ); + } -#[test] -fn output_type_rate_is_float64() { - let f = field("bytes", L3DataType::Float64); - assert_eq!( - AggIntent::Rate { - window: std::time::Duration::from_secs(60) - } - .output_type(&f), - Some(L3DataType::Float64) - ); -} + #[test] + fn output_type_count_ignores_input_type() { + // Count is always Int64 regardless of the aggregated column's type. + let f = field("x", L3DataType::Utf8); + assert_eq!( + AggIntent::Count { + accuracy: eps(0.01) + } + .output_type(&f), + Some(L3DataType::Int64) + ); + } -#[test] -fn output_type_increase_is_float64() { - let f = field("counter", L3DataType::Float64); - assert_eq!( - AggIntent::Increase { - window: std::time::Duration::from_secs(60) - } - .output_type(&f), - Some(L3DataType::Float64) - ); -} + #[test] + fn output_type_cardinality_is_int64() { + let f = field("host", L3DataType::Utf8); + assert_eq!( + AggIntent::Cardinality { accuracy: exact() }.output_type(&f), + Some(L3DataType::Int64) + ); + } -#[test] -fn output_type_min_preserves_int64_input() { - let f = field("count", L3DataType::Int64); - assert_eq!( - AggIntent::Min { - col: Some(ColumnRef("count".into())) - } - .output_type(&f), - Some(L3DataType::Int64) - ); -} + #[test] + fn output_type_sum_is_float64() { + let f = field("value", L3DataType::Float64); + assert_eq!( + AggIntent::Sum { + col: Some(ColumnRef("value".into())) + } + .output_type(&f), + Some(L3DataType::Float64) + ); + } -#[test] -fn output_type_min_preserves_float64_input() { - let f = field("value", L3DataType::Float64); - assert_eq!( - AggIntent::Min { - col: Some(ColumnRef("value".into())) - } - .output_type(&f), - Some(L3DataType::Float64) - ); -} + #[test] + fn output_type_avg_is_float64() { + let f = field("value", L3DataType::Int64); + assert_eq!( + AggIntent::Avg { + col: Some(ColumnRef("value".into())) + } + .output_type(&f), + Some(L3DataType::Float64) + ); + } -#[test] -fn output_type_max_preserves_utf8_input() { - let f = field("name", L3DataType::Utf8); - assert_eq!( - AggIntent::Max { - col: Some(ColumnRef("name".into())) - } - .output_type(&f), - Some(L3DataType::Utf8) - ); -} + #[test] + fn output_type_stddev_sample_is_float64() { + let f = field("value", L3DataType::Float64); + assert_eq!( + AggIntent::Stddev { + col: Some(ColumnRef("value".into())), + population: false + } + .output_type(&f), + Some(L3DataType::Float64) + ); + } -#[test] -fn output_type_topk_is_none() { - let f = field("region", L3DataType::Utf8); - assert_eq!( - AggIntent::TopK { - k: 10, - by: vec![ColumnRef("region".into())], - accuracy: exact() - } - .output_type(&f), - None - ); -} + #[test] + fn output_type_stddev_population_is_float64() { + let f = field("value", L3DataType::Float64); + assert_eq!( + AggIntent::Stddev { + col: Some(ColumnRef("value".into())), + population: true + } + .output_type(&f), + Some(L3DataType::Float64) + ); + } -// ── HasSchema::output_schema() — Scan ───────────────────────────────────────── + #[test] + fn output_type_quantile_is_float64() { + let f = field("latency", L3DataType::Float64); + assert_eq!( + AggIntent::Quantile { + q: 0.5, + accuracy: exact() + } + .output_type(&f), + Some(L3DataType::Float64) + ); + } -#[test] -fn scan_schema_columns_from_catalog() { - let catalog = metrics_catalog(); - let scan = QueryExpr::Scan { - source: Source::Table { - table_ref: TableRef("metrics".into()), - columns: vec![], - time_range: None, - }, - predicates: vec![], - }; - let s = scan.output_schema(&[], &catalog); - assert_eq!(s.fields.len(), 3); - assert_eq!(s.fields[0].name, "ts"); - assert_eq!(s.fields[0].dtype, L3DataType::Int64); - assert_eq!(s.fields[1].name, "value"); - assert_eq!(s.fields[2].name, "region"); -} + #[test] + fn output_type_rate_is_float64() { + let f = field("bytes", L3DataType::Float64); + assert_eq!( + AggIntent::Rate { + window: std::time::Duration::from_secs(60) + } + .output_type(&f), + Some(L3DataType::Float64) + ); + } -#[test] -fn scan_schema_time_index_set_for_time_column() { - let catalog = metrics_catalog(); - let scan = QueryExpr::Scan { - source: Source::Table { - table_ref: TableRef("metrics".into()), - columns: vec![], - time_range: None, - }, - predicates: vec![], - }; - let s = scan.output_schema(&[], &catalog); - // "ts" is at index 0 and is the time_column - assert_eq!(s.time_index, Some(0)); -} + #[test] + fn output_type_increase_is_float64() { + let f = field("counter", L3DataType::Float64); + assert_eq!( + AggIntent::Increase { + window: std::time::Duration::from_secs(60) + } + .output_type(&f), + Some(L3DataType::Float64) + ); + } -#[test] -fn scan_schema_no_time_index_when_no_time_column() { - let catalog = metrics_catalog(); - let scan = QueryExpr::Scan { - source: Source::Table { - table_ref: TableRef("events".into()), - columns: vec![], - time_range: None, - }, - predicates: vec![], - }; - let s = scan.output_schema(&[], &catalog); - assert_eq!(s.time_index, None); - assert_eq!(s.fields.len(), 2); -} + #[test] + fn output_type_min_preserves_int64_input() { + let f = field("count", L3DataType::Int64); + assert_eq!( + AggIntent::Min { + col: Some(ColumnRef("count".into())) + } + .output_type(&f), + Some(L3DataType::Int64) + ); + } -// ── HasSchema::output_schema() — pass-through nodes ────────────────────────── + #[test] + fn output_type_min_preserves_float64_input() { + let f = field("value", L3DataType::Float64); + assert_eq!( + AggIntent::Min { + col: Some(ColumnRef("value".into())) + } + .output_type(&f), + Some(L3DataType::Float64) + ); + } -fn child_schema() -> L3Schema { - schema(vec![ - field("ts", L3DataType::Int64), - nullable_field("value", L3DataType::Float64), - ]) + #[test] + fn output_type_max_preserves_utf8_input() { + let f = field("name", L3DataType::Utf8); + assert_eq!( + AggIntent::Max { + col: Some(ColumnRef("name".into())) + } + .output_type(&f), + Some(L3DataType::Utf8) + ); + } + + #[test] + fn output_type_topk_is_none() { + let f = field("region", L3DataType::Utf8); + assert_eq!( + AggIntent::TopK { + k: 10, + by: vec![ColumnRef("region".into())], + accuracy: exact() + } + .output_type(&f), + None + ); + } } -fn dummy_scan(s: L3Schema) -> Arc { - make_node( - QueryExpr::Scan { +mod scan_schema { + use super::*; + + // ── HasSchema::output_schema() — Scan ───────────────────────────────────────── + + #[test] + fn scan_schema_columns_from_catalog() { + let catalog = metrics_catalog(); + let scan = QueryExpr::Scan { source: Source::Table { table_ref: TableRef("metrics".into()), columns: vec![], time_range: None, }, predicates: vec![], - }, - s, - ) -} + }; + let s = scan.output_schema(&[], &catalog); + assert_eq!(s.fields.len(), 3); + assert_eq!(s.fields[0].name, "ts"); + assert_eq!(s.fields[0].dtype, L3DataType::Int64); + assert_eq!(s.fields[1].name, "value"); + assert_eq!(s.fields[2].name, "region"); + } -#[test] -fn filter_passes_through_child_schema() { - let cs = child_schema(); - let node = QueryExpr::Filter { - child: dummy_scan(cs.clone()), - pred: Predicate(L3Expr::Literal(L3Scalar::Boolean(true))), - }; - let out = node.output_schema(&[&cs], &empty_catalog()); - assert_eq!(out.fields, cs.fields); - assert_eq!(out.time_index, cs.time_index); -} + #[test] + fn scan_schema_time_index_set_for_time_column() { + let catalog = metrics_catalog(); + let scan = QueryExpr::Scan { + source: Source::Table { + table_ref: TableRef("metrics".into()), + columns: vec![], + time_range: None, + }, + predicates: vec![], + }; + let s = scan.output_schema(&[], &catalog); + // "ts" is at index 0 and is the time_column + assert_eq!(s.time_index, Some(0)); + } -#[test] -fn sort_passes_through_child_schema() { - let cs = child_schema(); - let node = QueryExpr::Sort { - child: dummy_scan(cs.clone()), - keys: vec![SortKey { - expr: L3Expr::Column(ColumnRef("ts".into())), - ascending: true, - nulls_first: false, - }], - }; - let out = node.output_schema(&[&cs], &empty_catalog()); - assert_eq!(out.fields, cs.fields); + #[test] + fn scan_schema_no_time_index_when_no_time_column() { + let catalog = metrics_catalog(); + let scan = QueryExpr::Scan { + source: Source::Table { + table_ref: TableRef("events".into()), + columns: vec![], + time_range: None, + }, + predicates: vec![], + }; + let s = scan.output_schema(&[], &catalog); + assert_eq!(s.time_index, None); + assert_eq!(s.fields.len(), 2); + } } -#[test] -fn limit_passes_through_child_schema() { - let cs = child_schema(); - let node = QueryExpr::Limit { - child: dummy_scan(cs.clone()), - n: Some(10), - offset: 0, - }; - let out = node.output_schema(&[&cs], &empty_catalog()); - assert_eq!(out.fields, cs.fields); -} +mod passthrough_schemas { + use super::*; -#[test] -fn distinct_passes_through_child_schema() { - let cs = child_schema(); - let node = QueryExpr::Distinct { - child: dummy_scan(cs.clone()), - cols: vec![], - }; - let out = node.output_schema(&[&cs], &empty_catalog()); - assert_eq!(out.fields, cs.fields); -} + // ── HasSchema::output_schema() — pass-through nodes ────────────────────────── -// ── HasSchema::output_schema() — Aggregate ──────────────────────────────────── + #[test] + fn filter_passes_through_child_schema() { + let cs = child_schema(); + let node = QueryExpr::Filter { + child: dummy_scan(cs.clone()), + pred: Predicate(L3Expr::Literal(L3Scalar::Boolean(true))), + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.fields, cs.fields); + assert_eq!(out.time_index, cs.time_index); + } -fn metrics_child_schema() -> L3Schema { - schema(vec![ - field("ts", L3DataType::Int64), - nullable_field("value", L3DataType::Float64), - nullable_field("region", L3DataType::Utf8), - nullable_field("host", L3DataType::Utf8), - ]) -} + #[test] + fn sort_passes_through_child_schema() { + let cs = child_schema(); + let node = QueryExpr::Sort { + child: dummy_scan(cs.clone()), + keys: vec![SortKey { + expr: L3Expr::Column(ColumnRef("ts".into())), + ascending: true, + nulls_first: false, + }], + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.fields, cs.fields); + } -#[test] -fn aggregate_count_star_no_group_by() { - // SELECT COUNT(*) FROM metrics - let cs = metrics_child_schema(); - let node = QueryExpr::Aggregate { - child: dummy_scan(cs.clone()), - by: vec![], - aggs: vec![AggIntent::Count { accuracy: exact() }], - having: None, - output_names: vec![], - }; - let out = node.output_schema(&[&cs], &empty_catalog()); - assert_eq!(out.fields.len(), 1); - assert_eq!(out.fields[0].dtype, L3DataType::Int64); -} + #[test] + fn limit_passes_through_child_schema() { + let cs = child_schema(); + let node = QueryExpr::Limit { + child: dummy_scan(cs.clone()), + n: Some(10), + offset: 0, + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.fields, cs.fields); + } -#[test] -fn aggregate_group_by_adds_by_cols_first() { - // SELECT region, COUNT(*) FROM metrics GROUP BY region - let cs = metrics_child_schema(); - let node = QueryExpr::Aggregate { - child: dummy_scan(cs.clone()), - by: vec![GroupKey("region".into())], - aggs: vec![AggIntent::Count { accuracy: exact() }], - having: None, - output_names: vec![], - }; - let out = node.output_schema(&[&cs], &empty_catalog()); - // region col + count col - assert_eq!(out.fields.len(), 2); - assert_eq!(out.fields[0].name, "region"); - assert_eq!(out.fields[0].dtype, L3DataType::Utf8); - assert_eq!(out.fields[1].dtype, L3DataType::Int64); + #[test] + fn distinct_passes_through_child_schema() { + let cs = child_schema(); + let node = QueryExpr::Distinct { + child: dummy_scan(cs.clone()), + cols: vec![], + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.fields, cs.fields); + } } -#[test] -fn aggregate_multiple_aggs() { - // SELECT COUNT(*), SUM(value), MIN(value) FROM metrics - let cs = metrics_child_schema(); - let node = QueryExpr::Aggregate { - child: dummy_scan(cs.clone()), - by: vec![], - aggs: vec![ - AggIntent::Count { accuracy: exact() }, - AggIntent::Sum { - col: Some(ColumnRef("value".into())), - }, - AggIntent::Min { - col: Some(ColumnRef("value".into())), - }, - ], - having: None, - output_names: vec![], - }; - let out = node.output_schema(&[&cs], &empty_catalog()); - assert_eq!(out.fields.len(), 3); - assert_eq!(out.fields[0].dtype, L3DataType::Int64); // Count - assert_eq!(out.fields[1].dtype, L3DataType::Float64); // Sum(value: Float64) - assert_eq!(out.fields[2].dtype, L3DataType::Float64); // Min(value: Float64) -} +mod aggregate_schema { + use super::*; -#[test] -fn aggregate_topk_produces_by_cols_plus_count() { - // TopK { k: 5, by: [host] } → [host(Utf8), count(Int64)] - let cs = metrics_child_schema(); - let node = QueryExpr::Aggregate { - child: dummy_scan(cs.clone()), - by: vec![], - aggs: vec![AggIntent::TopK { - k: 5, - by: vec![ColumnRef("host".into())], - accuracy: exact(), - }], - having: None, - output_names: vec![], - }; - let out = node.output_schema(&[&cs], &empty_catalog()); - assert_eq!(out.fields.len(), 2); - assert_eq!(out.fields[0].name, "host"); - assert_eq!(out.fields[0].dtype, L3DataType::Utf8); - assert_eq!(out.fields[1].name, "count"); - assert_eq!(out.fields[1].dtype, L3DataType::Int64); -} + // ── HasSchema::output_schema() — Aggregate ──────────────────────────────────── -#[test] -fn aggregate_topk_multi_key() { - // TopK { k: 10, by: [region, host] } → [region(Utf8), host(Utf8), count(Int64)] - let cs = metrics_child_schema(); - let node = QueryExpr::Aggregate { - child: dummy_scan(cs.clone()), - by: vec![], - aggs: vec![AggIntent::TopK { - k: 10, - by: vec![ColumnRef("region".into()), ColumnRef("host".into())], - accuracy: exact(), - }], - having: None, - output_names: vec![], - }; - let out = node.output_schema(&[&cs], &empty_catalog()); - assert_eq!(out.fields.len(), 3); - assert_eq!(out.fields[0].name, "region"); - assert_eq!(out.fields[1].name, "host"); - assert_eq!(out.fields[2].name, "count"); - assert_eq!(out.fields[2].dtype, L3DataType::Int64); -} + #[test] + fn aggregate_count_star_no_group_by() { + // SELECT COUNT(*) FROM metrics + let cs = metrics_child_schema(); + let node = QueryExpr::Aggregate { + child: dummy_scan(cs.clone()), + by: vec![], + aggs: vec![AggIntent::Count { accuracy: exact() }], + having: None, + output_names: vec![], + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.fields.len(), 1); + assert_eq!(out.fields[0].dtype, L3DataType::Int64); + } -#[test] -fn aggregate_drops_time_index_when_time_col_not_in_group_by() { - // GROUP BY value (not ts): time axis is consumed by the aggregation. - let cs = schema_with_time( - vec![ - field("ts", L3DataType::Int64), - nullable_field("value", L3DataType::Float64), - ], - 0, - ); - let node = QueryExpr::Aggregate { - child: dummy_scan(cs.clone()), - by: vec![], - aggs: vec![AggIntent::Count { accuracy: exact() }], - having: None, - output_names: vec![], - }; - let out = node.output_schema(&[&cs], &empty_catalog()); - assert_eq!(out.time_index, None); -} + #[test] + fn aggregate_group_by_adds_by_cols_first() { + // SELECT region, COUNT(*) FROM metrics GROUP BY region + let cs = metrics_child_schema(); + let node = QueryExpr::Aggregate { + child: dummy_scan(cs.clone()), + by: vec![GroupKey("region".into())], + aggs: vec![AggIntent::Count { accuracy: exact() }], + having: None, + output_names: vec![], + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + // region col + count col + assert_eq!(out.fields.len(), 2); + assert_eq!(out.fields[0].name, "region"); + assert_eq!(out.fields[0].dtype, L3DataType::Utf8); + assert_eq!(out.fields[1].dtype, L3DataType::Int64); + } -#[test] -fn aggregate_propagates_time_index_when_time_col_in_group_by() { - // GROUP BY ts — the time column survives into the output; time_index must be set. - let cs = schema_with_time( - vec![ - field("ts", L3DataType::Int64), - nullable_field("value", L3DataType::Float64), - ], - 0, - ); - let node = QueryExpr::Aggregate { - child: dummy_scan(cs.clone()), - by: vec![GroupKey("ts".into())], - aggs: vec![AggIntent::Sum { - col: Some(ColumnRef("value".into())), - }], - having: None, - output_names: vec!["sum_value".into()], - }; - let out = node.output_schema(&[&cs], &empty_catalog()); - // Output fields: [ts (by), sum_value (agg)]; ts is at index 0. - assert_eq!(out.time_index, Some(0)); - assert_eq!(out.fields[0].name, "ts"); -} + #[test] + fn aggregate_multiple_aggs() { + // SELECT COUNT(*), SUM(value), MIN(value) FROM metrics + let cs = metrics_child_schema(); + let node = QueryExpr::Aggregate { + child: dummy_scan(cs.clone()), + by: vec![], + aggs: vec![ + AggIntent::Count { accuracy: exact() }, + AggIntent::Sum { + col: Some(ColumnRef("value".into())), + }, + AggIntent::Min { + col: Some(ColumnRef("value".into())), + }, + ], + having: None, + output_names: vec![], + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.fields.len(), 3); + assert_eq!(out.fields[0].dtype, L3DataType::Int64); // Count + assert_eq!(out.fields[1].dtype, L3DataType::Float64); // Sum(value: Float64) + assert_eq!(out.fields[2].dtype, L3DataType::Float64); // Min(value: Float64) + } -// ── HasSchema::output_schema() — Project ────────────────────────────────────── + #[test] + fn aggregate_topk_produces_by_cols_plus_count() { + // TopK { k: 5, by: [host] } → [host(Utf8), count(Int64)] + let cs = metrics_child_schema(); + let node = QueryExpr::Aggregate { + child: dummy_scan(cs.clone()), + by: vec![], + aggs: vec![AggIntent::TopK { + k: 5, + by: vec![ColumnRef("host".into())], + accuracy: exact(), + }], + having: None, + output_names: vec![], + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.fields.len(), 2); + assert_eq!(out.fields[0].name, "host"); + assert_eq!(out.fields[0].dtype, L3DataType::Utf8); + assert_eq!(out.fields[1].name, "count"); + assert_eq!(out.fields[1].dtype, L3DataType::Int64); + } -#[test] -fn project_column_items_derive_schema_from_child() { - // SELECT ts, value — both columns exist in the child schema. - let cs = child_schema(); // [ts(Int64), value(Float64)] - let node = QueryExpr::Project { - child: dummy_scan(cs.clone()), - cols: vec![ - ProjectItem { - expr: L3Expr::Column(ColumnRef("ts".into())), - alias: None, - }, - ProjectItem { - expr: L3Expr::Column(ColumnRef("value".into())), - alias: None, - }, - ], - }; - let out = node.output_schema(&[&cs], &empty_catalog()); - assert_eq!(out.fields.len(), 2); - assert_eq!(out.fields[0].name, "ts"); - assert_eq!(out.fields[0].dtype, L3DataType::Int64); - assert_eq!(out.fields[1].name, "value"); - assert_eq!(out.fields[1].dtype, L3DataType::Float64); -} + #[test] + fn aggregate_topk_multi_key() { + // TopK { k: 10, by: [region, host] } → [region(Utf8), host(Utf8), count(Int64)] + let cs = metrics_child_schema(); + let node = QueryExpr::Aggregate { + child: dummy_scan(cs.clone()), + by: vec![], + aggs: vec![AggIntent::TopK { + k: 10, + by: vec![ColumnRef("region".into()), ColumnRef("host".into())], + accuracy: exact(), + }], + having: None, + output_names: vec![], + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.fields.len(), 3); + assert_eq!(out.fields[0].name, "region"); + assert_eq!(out.fields[1].name, "host"); + assert_eq!(out.fields[2].name, "count"); + assert_eq!(out.fields[2].dtype, L3DataType::Int64); + } -#[test] -fn project_alias_renames_output_field() { - // SELECT value AS v — output field is named "v", type preserved. - let cs = child_schema(); - let node = QueryExpr::Project { - child: dummy_scan(cs.clone()), - cols: vec![ProjectItem { - expr: L3Expr::Column(ColumnRef("value".into())), - alias: Some("v".into()), - }], - }; - let out = node.output_schema(&[&cs], &empty_catalog()); - assert_eq!(out.fields.len(), 1); - assert_eq!(out.fields[0].name, "v"); - assert_eq!(out.fields[0].dtype, L3DataType::Float64); -} + #[test] + fn aggregate_drops_time_index_when_time_col_not_in_group_by() { + // GROUP BY value (not ts): time axis is consumed by the aggregation. + let cs = schema_with_time( + vec![ + field("ts", L3DataType::Int64), + nullable_field("value", L3DataType::Float64), + ], + 0, + ); + let node = QueryExpr::Aggregate { + child: dummy_scan(cs.clone()), + by: vec![], + aggs: vec![AggIntent::Count { accuracy: exact() }], + having: None, + output_names: vec![], + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.time_index, None); + } -#[test] -fn project_subsets_columns() { - // SELECT value — only one of two child columns projected. - let cs = child_schema(); - let node = QueryExpr::Project { - child: dummy_scan(cs.clone()), - cols: vec![ProjectItem { - expr: L3Expr::Column(ColumnRef("value".into())), - alias: None, - }], - }; - let out = node.output_schema(&[&cs], &empty_catalog()); - assert_eq!(out.fields.len(), 1); - assert_eq!(out.fields[0].name, "value"); + #[test] + fn aggregate_propagates_time_index_when_time_col_in_group_by() { + // GROUP BY ts — the time column survives into the output; time_index must be set. + let cs = schema_with_time( + vec![ + field("ts", L3DataType::Int64), + nullable_field("value", L3DataType::Float64), + ], + 0, + ); + let node = QueryExpr::Aggregate { + child: dummy_scan(cs.clone()), + by: vec![GroupKey("ts".into())], + aggs: vec![AggIntent::Sum { + col: Some(ColumnRef("value".into())), + }], + having: None, + output_names: vec!["sum_value".into()], + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + // Output fields: [ts (by), sum_value (agg)]; ts is at index 0. + assert_eq!(out.time_index, Some(0)); + assert_eq!(out.fields[0].name, "ts"); + } } -#[test] -fn project_preserves_time_index_when_time_col_included() { - // SELECT ts, value — ts is at index 0 in child (time_index=0); should be preserved. - let cs = schema_with_time( - vec![ - field("ts", L3DataType::Int64), - nullable_field("value", L3DataType::Float64), - ], - 0, - ); - let node = QueryExpr::Project { - child: dummy_scan(cs.clone()), - cols: vec![ - ProjectItem { - expr: L3Expr::Column(ColumnRef("ts".into())), - alias: None, - }, - ProjectItem { +mod project_schema { + use super::*; + + // ── HasSchema::output_schema() — Project ────────────────────────────────────── + + #[test] + fn project_column_items_derive_schema_from_child() { + // SELECT ts, value — both columns exist in the child schema. + let cs = child_schema(); // [ts(Int64), value(Float64)] + let node = QueryExpr::Project { + child: dummy_scan(cs.clone()), + cols: vec![ + ProjectItem { + expr: L3Expr::Column(ColumnRef("ts".into())), + alias: None, + }, + ProjectItem { + expr: L3Expr::Column(ColumnRef("value".into())), + alias: None, + }, + ], + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.fields.len(), 2); + assert_eq!(out.fields[0].name, "ts"); + assert_eq!(out.fields[0].dtype, L3DataType::Int64); + assert_eq!(out.fields[1].name, "value"); + assert_eq!(out.fields[1].dtype, L3DataType::Float64); + } + + #[test] + fn project_alias_renames_output_field() { + // SELECT value AS v — output field is named "v", type preserved. + let cs = child_schema(); + let node = QueryExpr::Project { + child: dummy_scan(cs.clone()), + cols: vec![ProjectItem { expr: L3Expr::Column(ColumnRef("value".into())), - alias: None, - }, - ], - }; - let out = node.output_schema(&[&cs], &empty_catalog()); - assert_eq!(out.time_index, Some(0)); -} + alias: Some("v".into()), + }], + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.fields.len(), 1); + assert_eq!(out.fields[0].name, "v"); + assert_eq!(out.fields[0].dtype, L3DataType::Float64); + } -#[test] -fn project_preserves_time_index_when_col_reordered() { - // SELECT value, ts — ts moves to index 1; time_index should update. - let cs = schema_with_time( - vec![ - field("ts", L3DataType::Int64), - nullable_field("value", L3DataType::Float64), - ], - 0, - ); - let node = QueryExpr::Project { - child: dummy_scan(cs.clone()), - cols: vec![ - ProjectItem { + #[test] + fn project_subsets_columns() { + // SELECT value — only one of two child columns projected. + let cs = child_schema(); + let node = QueryExpr::Project { + child: dummy_scan(cs.clone()), + cols: vec![ProjectItem { expr: L3Expr::Column(ColumnRef("value".into())), alias: None, - }, - ProjectItem { - expr: L3Expr::Column(ColumnRef("ts".into())), + }], + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.fields.len(), 1); + assert_eq!(out.fields[0].name, "value"); + } + + #[test] + fn project_preserves_time_index_when_time_col_included() { + // SELECT ts, value — ts is at index 0 in child (time_index=0); should be preserved. + let cs = schema_with_time( + vec![ + field("ts", L3DataType::Int64), + nullable_field("value", L3DataType::Float64), + ], + 0, + ); + let node = QueryExpr::Project { + child: dummy_scan(cs.clone()), + cols: vec![ + ProjectItem { + expr: L3Expr::Column(ColumnRef("ts".into())), + alias: None, + }, + ProjectItem { + expr: L3Expr::Column(ColumnRef("value".into())), + alias: None, + }, + ], + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.time_index, Some(0)); + } + + #[test] + fn project_preserves_time_index_when_col_reordered() { + // SELECT value, ts — ts moves to index 1; time_index should update. + let cs = schema_with_time( + vec![ + field("ts", L3DataType::Int64), + nullable_field("value", L3DataType::Float64), + ], + 0, + ); + let node = QueryExpr::Project { + child: dummy_scan(cs.clone()), + cols: vec![ + ProjectItem { + expr: L3Expr::Column(ColumnRef("value".into())), + alias: None, + }, + ProjectItem { + expr: L3Expr::Column(ColumnRef("ts".into())), + alias: None, + }, + ], + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.time_index, Some(1)); + } + + #[test] + fn project_drops_time_index_when_time_col_excluded() { + // SELECT value — ts not projected; time_index should be None. + let cs = schema_with_time( + vec![ + field("ts", L3DataType::Int64), + nullable_field("value", L3DataType::Float64), + ], + 0, + ); + let node = QueryExpr::Project { + child: dummy_scan(cs.clone()), + cols: vec![ProjectItem { + expr: L3Expr::Column(ColumnRef("value".into())), alias: None, - }, - ], - }; - let out = node.output_schema(&[&cs], &empty_catalog()); - assert_eq!(out.time_index, Some(1)); + }], + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.time_index, None); + } } -#[test] -fn project_drops_time_index_when_time_col_excluded() { - // SELECT value — ts not projected; time_index should be None. - let cs = schema_with_time( - vec![ - field("ts", L3DataType::Int64), - nullable_field("value", L3DataType::Float64), - ], - 0, - ); - let node = QueryExpr::Project { - child: dummy_scan(cs.clone()), - cols: vec![ProjectItem { - expr: L3Expr::Column(ColumnRef("value".into())), - alias: None, - }], - }; - let out = node.output_schema(&[&cs], &empty_catalog()); - assert_eq!(out.time_index, None); -} +mod window_schema { + use super::*; + + // ── HasSchema::output_schema() — WindowFunc ────────────────────────────────── + + #[test] + fn window_func_row_number_appends_int64_column() { + let cs = timed_two_col_schema(); + let node = QueryExpr::WindowFunc { + child: dummy_scan(cs.clone()), + func: WindowFuncKind::RowNumber, + args: vec![], + partition_by: vec![], + order_by: vec![], + frame: None, + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.fields.len(), 3); + let win = out.fields.last().unwrap(); + assert_eq!(win.dtype, L3DataType::Int64); + assert!(!win.nullable); + } -// ── HasSchema::output_schema() — WindowFunc ────────────────────────────────── + #[test] + fn window_func_rank_appends_int64_column() { + let cs = timed_two_col_schema(); + let node = QueryExpr::WindowFunc { + child: dummy_scan(cs.clone()), + func: WindowFuncKind::Rank, + args: vec![], + partition_by: vec![], + order_by: vec![], + frame: None, + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + let win = out.fields.last().unwrap(); + assert_eq!(win.dtype, L3DataType::Int64); + assert!(!win.nullable); + } -fn timed_two_col_schema() -> L3Schema { - schema_with_time( - vec![ - field("ts", L3DataType::Int64), - nullable_field("value", L3DataType::Float64), - ], - 0, - ) -} + #[test] + fn window_func_lag_uses_arg_column_type() { + // LAG(value) → output type matches value: Float64, nullable + let cs = timed_two_col_schema(); + let node = QueryExpr::WindowFunc { + child: dummy_scan(cs.clone()), + func: WindowFuncKind::Lag, + args: vec![L3Expr::Column(ColumnRef("value".into()))], + partition_by: vec![], + order_by: vec![], + frame: None, + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + let win = out.fields.last().unwrap(); + assert_eq!(win.dtype, L3DataType::Float64); + assert!(win.nullable); + } -#[test] -fn window_func_row_number_appends_int64_column() { - let cs = timed_two_col_schema(); - let node = QueryExpr::WindowFunc { - child: dummy_scan(cs.clone()), - func: WindowFuncKind::RowNumber, - args: vec![], - partition_by: vec![], - order_by: vec![], - frame: None, - }; - let out = node.output_schema(&[&cs], &empty_catalog()); - assert_eq!(out.fields.len(), 3); - let win = out.fields.last().unwrap(); - assert_eq!(win.dtype, L3DataType::Int64); - assert!(!win.nullable); -} + #[test] + fn window_func_lag_int_col_preserves_type() { + // LAG(ts) → Int64, nullable + let cs = timed_two_col_schema(); + let node = QueryExpr::WindowFunc { + child: dummy_scan(cs.clone()), + func: WindowFuncKind::Lag, + args: vec![L3Expr::Column(ColumnRef("ts".into()))], + partition_by: vec![], + order_by: vec![], + frame: None, + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + let win = out.fields.last().unwrap(); + assert_eq!(win.dtype, L3DataType::Int64); + assert!(win.nullable); + } -#[test] -fn window_func_rank_appends_int64_column() { - let cs = timed_two_col_schema(); - let node = QueryExpr::WindowFunc { - child: dummy_scan(cs.clone()), - func: WindowFuncKind::Rank, - args: vec![], - partition_by: vec![], - order_by: vec![], - frame: None, - }; - let out = node.output_schema(&[&cs], &empty_catalog()); - let win = out.fields.last().unwrap(); - assert_eq!(win.dtype, L3DataType::Int64); - assert!(!win.nullable); -} + #[test] + fn window_func_min_preserves_arg_type() { + // MIN(ts) OVER (...) → Int64 (same as ts) + let cs = timed_two_col_schema(); + let node = QueryExpr::WindowFunc { + child: dummy_scan(cs.clone()), + func: WindowFuncKind::Min, + args: vec![L3Expr::Column(ColumnRef("ts".into()))], + partition_by: vec![], + order_by: vec![], + frame: None, + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + let win = out.fields.last().unwrap(); + assert_eq!(win.dtype, L3DataType::Int64); + } -#[test] -fn window_func_lag_uses_arg_column_type() { - // LAG(value) → output type matches value: Float64, nullable - let cs = timed_two_col_schema(); - let node = QueryExpr::WindowFunc { - child: dummy_scan(cs.clone()), - func: WindowFuncKind::Lag, - args: vec![L3Expr::Column(ColumnRef("value".into()))], - partition_by: vec![], - order_by: vec![], - frame: None, - }; - let out = node.output_schema(&[&cs], &empty_catalog()); - let win = out.fields.last().unwrap(); - assert_eq!(win.dtype, L3DataType::Float64); - assert!(win.nullable); -} + #[test] + fn window_func_count_appends_int64_not_nullable() { + let cs = timed_two_col_schema(); + let node = QueryExpr::WindowFunc { + child: dummy_scan(cs.clone()), + func: WindowFuncKind::Count, + args: vec![L3Expr::Column(ColumnRef("value".into()))], + partition_by: vec![], + order_by: vec![], + frame: None, + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + let win = out.fields.last().unwrap(); + assert_eq!(win.dtype, L3DataType::Int64); + assert!(!win.nullable); + } -#[test] -fn window_func_lag_int_col_preserves_type() { - // LAG(ts) → Int64, nullable - let cs = timed_two_col_schema(); - let node = QueryExpr::WindowFunc { - child: dummy_scan(cs.clone()), - func: WindowFuncKind::Lag, - args: vec![L3Expr::Column(ColumnRef("ts".into()))], - partition_by: vec![], - order_by: vec![], - frame: None, - }; - let out = node.output_schema(&[&cs], &empty_catalog()); - let win = out.fields.last().unwrap(); - assert_eq!(win.dtype, L3DataType::Int64); - assert!(win.nullable); + #[test] + fn window_func_preserves_child_fields_and_time_index() { + let cs = timed_two_col_schema(); + let node = QueryExpr::WindowFunc { + child: dummy_scan(cs.clone()), + func: WindowFuncKind::RowNumber, + args: vec![], + partition_by: vec![], + order_by: vec![], + frame: None, + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.fields[0].name, "ts"); + assert_eq!(out.fields[1].name, "value"); + assert_eq!(out.time_index, Some(0)); + } } -#[test] -fn window_func_min_preserves_arg_type() { - // MIN(ts) OVER (...) → Int64 (same as ts) - let cs = timed_two_col_schema(); - let node = QueryExpr::WindowFunc { - child: dummy_scan(cs.clone()), - func: WindowFuncKind::Min, - args: vec![L3Expr::Column(ColumnRef("ts".into()))], - partition_by: vec![], - order_by: vec![], - frame: None, - }; - let out = node.output_schema(&[&cs], &empty_catalog()); - let win = out.fields.last().unwrap(); - assert_eq!(win.dtype, L3DataType::Int64); -} +mod merge_and_set_op_schema { + use super::*; -#[test] -fn window_func_count_appends_int64_not_nullable() { - let cs = timed_two_col_schema(); - let node = QueryExpr::WindowFunc { - child: dummy_scan(cs.clone()), - func: WindowFuncKind::Count, - args: vec![L3Expr::Column(ColumnRef("value".into()))], - partition_by: vec![], - order_by: vec![], - frame: None, - }; - let out = node.output_schema(&[&cs], &empty_catalog()); - let win = out.fields.last().unwrap(); - assert_eq!(win.dtype, L3DataType::Int64); - assert!(!win.nullable); -} + // ── HasSchema::output_schema() — Merge ─────────────────────────────────────── -#[test] -fn window_func_preserves_child_fields_and_time_index() { - let cs = timed_two_col_schema(); - let node = QueryExpr::WindowFunc { - child: dummy_scan(cs.clone()), - func: WindowFuncKind::RowNumber, - args: vec![], - partition_by: vec![], - order_by: vec![], - frame: None, - }; - let out = node.output_schema(&[&cs], &empty_catalog()); - assert_eq!(out.fields[0].name, "ts"); - assert_eq!(out.fields[1].name, "value"); - assert_eq!(out.time_index, Some(0)); -} + #[test] + fn merge_uses_first_child_schema() { + let cs = timed_two_col_schema(); + let node = QueryExpr::Merge { + children: vec![dummy_scan(cs.clone()), dummy_scan(cs.clone())], + }; + let out = node.output_schema(&[&cs, &cs], &empty_catalog()); + assert_eq!(out.fields, cs.fields); + assert_eq!(out.time_index, cs.time_index); + } -// ── HasSchema::output_schema() — Merge ─────────────────────────────────────── - -#[test] -fn merge_uses_first_child_schema() { - let cs = timed_two_col_schema(); - let node = QueryExpr::Merge { - children: vec![dummy_scan(cs.clone()), dummy_scan(cs.clone())], - }; - let out = node.output_schema(&[&cs, &cs], &empty_catalog()); - assert_eq!(out.fields, cs.fields); - assert_eq!(out.time_index, cs.time_index); -} + // ── HasSchema::output_schema() — SetOp ─────────────────────────────────────── -// ── HasSchema::output_schema() — SetOp ─────────────────────────────────────── + fn right_schema() -> L3Schema { + schema(vec![ + field("a", L3DataType::Int64), + field("b", L3DataType::Utf8), + ]) + } -fn right_schema() -> L3Schema { - schema(vec![ - field("a", L3DataType::Int64), - field("b", L3DataType::Utf8), - ]) -} + #[test] + fn set_op_union_uses_left_schema() { + let left = timed_two_col_schema(); + let right = right_schema(); + let node = QueryExpr::SetOp { + kind: SetOpKind::Union, + all: false, + left: dummy_scan(left.clone()), + right: dummy_scan(right.clone()), + }; + let out = node.output_schema(&[&left, &right], &empty_catalog()); + assert_eq!(out.fields[0].name, "ts"); + assert_eq!(out.fields[1].name, "value"); + } -#[test] -fn set_op_union_uses_left_schema() { - let left = timed_two_col_schema(); - let right = right_schema(); - let node = QueryExpr::SetOp { - kind: SetOpKind::Union, - all: false, - left: dummy_scan(left.clone()), - right: dummy_scan(right.clone()), - }; - let out = node.output_schema(&[&left, &right], &empty_catalog()); - assert_eq!(out.fields[0].name, "ts"); - assert_eq!(out.fields[1].name, "value"); -} + #[test] + fn set_op_intersect_uses_left_schema() { + let left = timed_two_col_schema(); + let right = right_schema(); + let node = QueryExpr::SetOp { + kind: SetOpKind::Intersect, + all: false, + left: dummy_scan(left.clone()), + right: dummy_scan(right.clone()), + }; + let out = node.output_schema(&[&left, &right], &empty_catalog()); + assert_eq!(out.fields, left.fields); + } -#[test] -fn set_op_intersect_uses_left_schema() { - let left = timed_two_col_schema(); - let right = right_schema(); - let node = QueryExpr::SetOp { - kind: SetOpKind::Intersect, - all: false, - left: dummy_scan(left.clone()), - right: dummy_scan(right.clone()), - }; - let out = node.output_schema(&[&left, &right], &empty_catalog()); - assert_eq!(out.fields, left.fields); -} + #[test] + fn set_op_except_uses_left_schema() { + let left = timed_two_col_schema(); + let right = right_schema(); + let node = QueryExpr::SetOp { + kind: SetOpKind::Except, + all: false, + left: dummy_scan(left.clone()), + right: dummy_scan(right.clone()), + }; + let out = node.output_schema(&[&left, &right], &empty_catalog()); + assert_eq!(out.fields, left.fields); + } -#[test] -fn set_op_except_uses_left_schema() { - let left = timed_two_col_schema(); - let right = right_schema(); - let node = QueryExpr::SetOp { - kind: SetOpKind::Except, - all: false, - left: dummy_scan(left.clone()), - right: dummy_scan(right.clone()), - }; - let out = node.output_schema(&[&left, &right], &empty_catalog()); - assert_eq!(out.fields, left.fields); + #[test] + fn set_op_preserves_time_index_from_left() { + let left = timed_two_col_schema(); // time_index = Some(0) + let right = right_schema(); // time_index = None + let node = QueryExpr::SetOp { + kind: SetOpKind::Union, + all: true, + left: dummy_scan(left.clone()), + right: dummy_scan(right.clone()), + }; + let out = node.output_schema(&[&left, &right], &empty_catalog()); + assert_eq!(out.time_index, Some(0)); + } } -#[test] -fn set_op_preserves_time_index_from_left() { - let left = timed_two_col_schema(); // time_index = Some(0) - let right = right_schema(); // time_index = None - let node = QueryExpr::SetOp { - kind: SetOpKind::Union, - all: true, - left: dummy_scan(left.clone()), - right: dummy_scan(right.clone()), - }; - let out = node.output_schema(&[&left, &right], &empty_catalog()); - assert_eq!(out.time_index, Some(0)); -} +mod project_non_column { + use super::*; -// ── HasSchema::output_schema() — Project, non-column items ─────────────────── - -#[test] -fn project_cast_item_uses_target_type() { - // SELECT CAST(ts AS FLOAT64) AS ts_f — output type is the cast target. - let cs = child_schema(); // [ts: Int64, value: Float64] - let node = QueryExpr::Project { - child: dummy_scan(cs.clone()), - cols: vec![ProjectItem { - expr: L3Expr::Cast { - expr: Box::new(L3Expr::Column(ColumnRef("ts".into()))), - to: L3DataType::Float64, - try_cast: false, - }, - alias: Some("ts_f".into()), - }], - }; - let out = node.output_schema(&[&cs], &empty_catalog()); - assert_eq!(out.fields.len(), 1); - assert_eq!(out.fields[0].name, "ts_f"); - assert_eq!(out.fields[0].dtype, L3DataType::Float64); -} + // ── HasSchema::output_schema() — Project, non-column items ─────────────────── -#[test] -fn project_int_literal_item_uses_int64_type() { - // SELECT 42 AS n — output type is Int64. - let cs = child_schema(); - let node = QueryExpr::Project { - child: dummy_scan(cs.clone()), - cols: vec![ProjectItem { - expr: L3Expr::Literal(L3Scalar::Int64(42)), - alias: Some("n".into()), - }], - }; - let out = node.output_schema(&[&cs], &empty_catalog()); - assert_eq!(out.fields[0].dtype, L3DataType::Int64); - assert_eq!(out.fields[0].name, "n"); -} + #[test] + fn project_cast_item_uses_target_type() { + // SELECT CAST(ts AS FLOAT64) AS ts_f — output type is the cast target. + let cs = child_schema(); // [ts: Int64, value: Float64] + let node = QueryExpr::Project { + child: dummy_scan(cs.clone()), + cols: vec![ProjectItem { + expr: L3Expr::Cast { + expr: Box::new(L3Expr::Column(ColumnRef("ts".into()))), + to: L3DataType::Float64, + try_cast: false, + }, + alias: Some("ts_f".into()), + }], + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.fields.len(), 1); + assert_eq!(out.fields[0].name, "ts_f"); + assert_eq!(out.fields[0].dtype, L3DataType::Float64); + } -#[test] -fn project_float_literal_item_uses_float64_type() { - let cs = child_schema(); - let node = QueryExpr::Project { - child: dummy_scan(cs.clone()), - cols: vec![ProjectItem { - expr: L3Expr::Literal(L3Scalar::Float64(std::f64::consts::PI)), - alias: Some("pi".into()), - }], - }; - let out = node.output_schema(&[&cs], &empty_catalog()); - assert_eq!(out.fields[0].dtype, L3DataType::Float64); -} + #[test] + fn project_int_literal_item_uses_int64_type() { + // SELECT 42 AS n — output type is Int64. + let cs = child_schema(); + let node = QueryExpr::Project { + child: dummy_scan(cs.clone()), + cols: vec![ProjectItem { + expr: L3Expr::Literal(L3Scalar::Int64(42)), + alias: Some("n".into()), + }], + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.fields[0].dtype, L3DataType::Int64); + assert_eq!(out.fields[0].name, "n"); + } -#[test] -fn project_arith_item_defaults_to_float64() { - // SELECT value * 2 AS doubled — arithmetic defaults to Float64. - let cs = child_schema(); - let node = QueryExpr::Project { - child: dummy_scan(cs.clone()), - cols: vec![ProjectItem { - expr: L3Expr::Arith { - op: ArithOp::Mul, - left: Box::new(L3Expr::Column(ColumnRef("value".into()))), - right: Box::new(L3Expr::Literal(L3Scalar::Int64(2))), - }, - alias: Some("doubled".into()), - }], - }; - let out = node.output_schema(&[&cs], &empty_catalog()); - assert_eq!(out.fields[0].dtype, L3DataType::Float64); - assert_eq!(out.fields[0].name, "doubled"); -} + #[test] + fn project_float_literal_item_uses_float64_type() { + let cs = child_schema(); + let node = QueryExpr::Project { + child: dummy_scan(cs.clone()), + cols: vec![ProjectItem { + expr: L3Expr::Literal(L3Scalar::Float64(std::f64::consts::PI)), + alias: Some("pi".into()), + }], + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.fields[0].dtype, L3DataType::Float64); + } -#[test] -fn project_case_item_defaults_to_float64() { - // CASE WHEN value > 0 THEN 1 ELSE 0 END — defaults to Float64. - let cs = child_schema(); - let node = QueryExpr::Project { - child: dummy_scan(cs.clone()), - cols: vec![ProjectItem { - expr: L3Expr::Case { - operand: None, - branches: vec![( - L3Expr::Compare { - left: Box::new(L3Expr::Column(ColumnRef("value".into()))), - op: asap_control_core::intent_algebra::CompareOp::Gt, - right: Box::new(L3Expr::Literal(L3Scalar::Float64(0.0))), - }, - L3Expr::Literal(L3Scalar::Int64(1)), - )], - else_expr: Some(Box::new(L3Expr::Literal(L3Scalar::Int64(0)))), - }, - alias: Some("tier".into()), - }], - }; - let out = node.output_schema(&[&cs], &empty_catalog()); - assert_eq!(out.fields[0].dtype, L3DataType::Float64); - assert_eq!(out.fields[0].name, "tier"); -} + #[test] + fn project_arith_item_defaults_to_float64() { + // SELECT value * 2 AS doubled — arithmetic defaults to Float64. + let cs = child_schema(); + let node = QueryExpr::Project { + child: dummy_scan(cs.clone()), + cols: vec![ProjectItem { + expr: L3Expr::Arith { + op: ArithOp::Mul, + left: Box::new(L3Expr::Column(ColumnRef("value".into()))), + right: Box::new(L3Expr::Literal(L3Scalar::Int64(2))), + }, + alias: Some("doubled".into()), + }], + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.fields[0].dtype, L3DataType::Float64); + assert_eq!(out.fields[0].name, "doubled"); + } -#[test] -fn project_time_index_tracks_aliased_time_col() { - // SELECT ts AS t — aliased; time_index should still point at the right output position. - let cs = schema_with_time( - vec![ - field("ts", L3DataType::Int64), - nullable_field("value", L3DataType::Float64), - ], - 0, - ); - let node = QueryExpr::Project { - child: dummy_scan(cs.clone()), - cols: vec![ProjectItem { - expr: L3Expr::Column(ColumnRef("ts".into())), - alias: Some("t".into()), - }], - }; - let out = node.output_schema(&[&cs], &empty_catalog()); - assert_eq!(out.time_index, Some(0)); - assert_eq!(out.fields[0].name, "t"); + #[test] + fn project_case_item_defaults_to_float64() { + // CASE WHEN value > 0 THEN 1 ELSE 0 END — defaults to Float64. + let cs = child_schema(); + let node = QueryExpr::Project { + child: dummy_scan(cs.clone()), + cols: vec![ProjectItem { + expr: L3Expr::Case { + operand: None, + branches: vec![( + L3Expr::Compare { + left: Box::new(L3Expr::Column(ColumnRef("value".into()))), + op: asap_control_core::intent_algebra::CompareOp::Gt, + right: Box::new(L3Expr::Literal(L3Scalar::Float64(0.0))), + }, + L3Expr::Literal(L3Scalar::Int64(1)), + )], + else_expr: Some(Box::new(L3Expr::Literal(L3Scalar::Int64(0)))), + }, + alias: Some("tier".into()), + }], + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.fields[0].dtype, L3DataType::Float64); + assert_eq!(out.fields[0].name, "tier"); + } + + #[test] + fn project_time_index_tracks_aliased_time_col() { + // SELECT ts AS t — aliased; time_index should still point at the right output position. + let cs = schema_with_time( + vec![ + field("ts", L3DataType::Int64), + nullable_field("value", L3DataType::Float64), + ], + 0, + ); + let node = QueryExpr::Project { + child: dummy_scan(cs.clone()), + cols: vec![ProjectItem { + expr: L3Expr::Column(ColumnRef("ts".into())), + alias: Some("t".into()), + }], + }; + let out = node.output_schema(&[&cs], &empty_catalog()); + assert_eq!(out.time_index, Some(0)); + assert_eq!(out.fields[0].name, "t"); + } } diff --git a/crates/lower/src/sql/expr.rs b/crates/lower/src/sql/expr.rs new file mode 100644 index 00000000..f7a8ddaa --- /dev/null +++ b/crates/lower/src/sql/expr.rs @@ -0,0 +1,226 @@ +use datafusion::logical_expr::{BinaryExpr, Expr, Operator}; + +use asap_control_core::intent_algebra::expr::ColumnRef; +use asap_control_core::intent_algebra::{ArithOp, CompareOp, L3Expr, L3Scalar}; + +use crate::error::LoweringError; + +use super::types::{arrow_to_l3, scalar_value_to_l3}; + +pub(super) fn split_conjuncts(expr: &Expr) -> Vec<&Expr> { + match expr { + Expr::BinaryExpr(BinaryExpr { + left, + op: Operator::And, + right, + }) => { + let mut v = split_conjuncts(left); + v.extend(split_conjuncts(right)); + v + } + _ => vec![expr], + } +} + +/// Translate a slice of DataFusion `Expr`s (non-time conjuncts) into a single +/// `L3Expr`. A single element is returned as-is; multiple elements are wrapped +/// in `L3Expr::BoolAnd`. +pub(super) fn conjuncts_to_l3expr(conjuncts: Vec<&Expr>) -> Result { + let parts: Result, _> = conjuncts.iter().map(|e| df_expr_to_l3(e)).collect(); + let mut parts = parts?; + if parts.len() == 1 { + Ok(parts.pop().unwrap()) + } else { + Ok(L3Expr::BoolAnd(parts)) + } +} + +/// Translate a DataFusion `Expr` to an `L3Expr`. +/// Returns `UnsupportedFeature` for anything not needed in v1. +pub(super) fn df_expr_to_l3(expr: &Expr) -> Result { + match expr { + Expr::Column(col) => Ok(L3Expr::Column(ColumnRef(col.name.clone()))), + + Expr::Literal(sv) => scalar_value_to_l3(sv).map(L3Expr::Literal), + + Expr::Alias(a) => df_expr_to_l3(&a.expr), + + Expr::BinaryExpr(BinaryExpr { left, op, right }) => match op { + Operator::And => { + let parts = split_conjuncts(expr); + let l3_parts: Result, _> = parts.iter().map(|e| df_expr_to_l3(e)).collect(); + Ok(L3Expr::BoolAnd(l3_parts?)) + } + Operator::Or => { + let parts = split_disjuncts(expr); + let l3_parts: Result, _> = parts.iter().map(|e| df_expr_to_l3(e)).collect(); + Ok(L3Expr::BoolOr(l3_parts?)) + } + Operator::Eq => compare(left, CompareOp::Eq, right), + Operator::NotEq => compare(left, CompareOp::Ne, right), + Operator::Lt => compare(left, CompareOp::Lt, right), + Operator::LtEq => compare(left, CompareOp::Le, right), + Operator::Gt => compare(left, CompareOp::Gt, right), + Operator::GtEq => compare(left, CompareOp::Ge, right), + // BinaryExpr LIKE/ILIKE operators (from optimizer rewrites) + Operator::LikeMatch => compare(left, CompareOp::Like, right), + Operator::ILikeMatch => compare(left, CompareOp::ILike, right), + Operator::NotLikeMatch => compare(left, CompareOp::NotLike, right), + Operator::NotILikeMatch => compare(left, CompareOp::NotILike, right), + // Arithmetic + Operator::Plus => arith(left, ArithOp::Add, right), + Operator::Minus => arith(left, ArithOp::Sub, right), + Operator::Multiply => arith(left, ArithOp::Mul, right), + Operator::Divide => arith(left, ArithOp::Div, right), + Operator::Modulo => arith(left, ArithOp::Mod, right), + other => Err(LoweringError::UnsupportedFeature(format!( + "operator: {other:?}" + ))), + }, + + // SQL LIKE / ILIKE (dedicated expr node from the SQL parser) + Expr::Like(like) => { + let op = match (like.negated, like.case_insensitive) { + (false, false) => CompareOp::Like, + (true, false) => CompareOp::NotLike, + (false, true) => CompareOp::ILike, + (true, true) => CompareOp::NotILike, + }; + compare(&like.expr, op, &like.pattern) + } + + // Unary minus: negate literals directly; wrap others in -1 * x. + Expr::Negative(inner) => { + let inner_l3 = df_expr_to_l3(inner)?; + match inner_l3 { + L3Expr::Literal(L3Scalar::Int64(v)) => Ok(L3Expr::Literal(L3Scalar::Int64(-v))), + L3Expr::Literal(L3Scalar::Float64(v)) => Ok(L3Expr::Literal(L3Scalar::Float64(-v))), + other => Ok(L3Expr::Arith { + op: ArithOp::Mul, + left: Box::new(L3Expr::Literal(L3Scalar::Int64(-1))), + right: Box::new(other), + }), + } + } + + // SQL CASE expression + Expr::Case(c) => { + let operand = c + .expr + .as_ref() + .map(|e| df_expr_to_l3(e).map(Box::new)) + .transpose()?; + let branches = c + .when_then_expr + .iter() + .map(|(when, then)| Ok((df_expr_to_l3(when)?, df_expr_to_l3(then)?))) + .collect::, LoweringError>>()?; + let else_expr = c + .else_expr + .as_ref() + .map(|e| df_expr_to_l3(e).map(Box::new)) + .transpose()?; + Ok(L3Expr::Case { + operand, + branches, + else_expr, + }) + } + + Expr::Not(inner) => Ok(L3Expr::Not(Box::new(df_expr_to_l3(inner)?))), + + Expr::IsNull(inner) => Ok(L3Expr::IsNull(Box::new(df_expr_to_l3(inner)?))), + + Expr::IsNotNull(inner) => Ok(L3Expr::IsNotNull(Box::new(df_expr_to_l3(inner)?))), + + Expr::Cast(c) => { + let inner = df_expr_to_l3(&c.expr)?; + let to = arrow_to_l3(&c.data_type)?; + Ok(L3Expr::Cast { + expr: Box::new(inner), + to, + try_cast: false, + }) + } + + // TRY_CAST returns NULL on conversion failure; preserve that semantic. + Expr::TryCast(c) => { + let inner = df_expr_to_l3(&c.expr)?; + let to = arrow_to_l3(&c.data_type)?; + Ok(L3Expr::Cast { + expr: Box::new(inner), + to, + try_cast: true, + }) + } + + Expr::InList(il) => { + let expr = df_expr_to_l3(&il.expr)?; + let list: Result, _> = il.list.iter().map(df_expr_to_l3).collect(); + Ok(L3Expr::InList { + expr: Box::new(expr), + list: list?, + negated: il.negated, + }) + } + + Expr::Between(b) => { + // Normalize: `x BETWEEN low AND high` → `x >= low AND x <= high`. + // `x NOT BETWEEN low AND high` → `x < low OR x > high`. + let x_low = compare(&b.expr, CompareOp::Ge, &b.low)?; + let x_high = compare(&b.expr, CompareOp::Le, &b.high)?; + if b.negated { + // NOT BETWEEN: invert each side + let lt = compare(&b.expr, CompareOp::Lt, &b.low)?; + let gt = compare(&b.expr, CompareOp::Gt, &b.high)?; + Ok(L3Expr::BoolOr(vec![lt, gt])) + } else { + Ok(L3Expr::BoolAnd(vec![x_low, x_high])) + } + } + + Expr::ScalarFunction(sf) => { + let args: Result, _> = sf.args.iter().map(df_expr_to_l3).collect(); + Ok(L3Expr::FunctionCall { + name: sf.func.name().to_string(), + args: args?, + }) + } + + other => Err(LoweringError::UnsupportedFeature(format!( + "expression: {}", + other + ))), + } +} + +pub(super) fn compare(left: &Expr, op: CompareOp, right: &Expr) -> Result { + Ok(L3Expr::Compare { + left: Box::new(df_expr_to_l3(left)?), + op, + right: Box::new(df_expr_to_l3(right)?), + }) +} + +pub(super) fn arith(left: &Expr, op: ArithOp, right: &Expr) -> Result { + Ok(L3Expr::Arith { + op, + left: Box::new(df_expr_to_l3(left)?), + right: Box::new(df_expr_to_l3(right)?), + }) +} + +pub(super) fn split_disjuncts(expr: &Expr) -> Vec<&Expr> { + match expr { + Expr::BinaryExpr(BinaryExpr { + left, + op: Operator::Or, + right, + }) => { + let mut v = split_disjuncts(left); + v.extend(split_disjuncts(right)); + v + } + _ => vec![expr], + } +} diff --git a/crates/lower/src/sql.rs b/crates/lower/src/sql/mod.rs similarity index 58% rename from crates/lower/src/sql.rs rename to crates/lower/src/sql/mod.rs index 36e69ebb..4b85a129 100644 --- a/crates/lower/src/sql.rs +++ b/crates/lower/src/sql/mod.rs @@ -1,23 +1,28 @@ use std::sync::Arc; -use datafusion::arrow::datatypes::{DataType as ArrowDataType, Field, Fields, Schema, TimeUnit}; use datafusion::common::ScalarValue; use datafusion::datasource::MemTable; -use datafusion::logical_expr::{ - self, BinaryExpr, Distinct, Expr, LogicalPlan, Operator, WindowFunctionDefinition, -}; +use datafusion::logical_expr::{self, Distinct, Expr, LogicalPlan, WindowFunctionDefinition}; use datafusion::prelude::SessionContext; use asap_control_core::intent_algebra::expr::{ AggIntent, ColumnRef, GroupKey, L3Node, Predicate, ProjectItem, QueryExpr, SetOpKind, SortKey, - Source, TableRef, TimeRange, WindowFuncKind, + Source, TableRef, WindowFuncKind, }; -use asap_control_core::intent_algebra::schema::{L3DataType, L3Schema, SchemaCatalog, TableSchema}; -use asap_control_core::intent_algebra::{ArithOp, CompareOp, L3Expr, L3Scalar}; +use asap_control_core::intent_algebra::schema::{L3Schema, SchemaCatalog, TableSchema}; +use asap_control_core::intent_algebra::{L3Expr, L3Scalar}; use asap_control_core::types::AccuracyTarget; use crate::error::LoweringError; +mod expr; +mod time; +mod types; + +use self::expr::{conjuncts_to_l3expr, df_expr_to_l3, split_conjuncts}; +use self::time::extract_time_range_from_conjuncts; +use self::types::table_schema_to_arrow; + pub struct SqlLowerer<'a> { catalog: &'a SchemaCatalog, accuracy: AccuracyTarget, @@ -613,60 +618,6 @@ fn extract_percentile_q(args: &[Expr]) -> Result { } } -fn split_conjuncts(expr: &Expr) -> Vec<&Expr> { - match expr { - Expr::BinaryExpr(BinaryExpr { - left, - op: Operator::And, - right, - }) => { - let mut v = split_conjuncts(left); - v.extend(split_conjuncts(right)); - v - } - _ => vec![expr], - } -} - -/// Core: classify a pre-split list of conjuncts into time bounds + residual. -fn extract_time_range_from_conjuncts<'a>( - conjuncts: Vec<&'a Expr>, - time_col: &str, -) -> (Option, Vec<&'a Expr>) { - let mut start_ms: Option = None; - let mut end_ms: Option = None; - let mut non_time: Vec<&'a Expr> = vec![]; - - for c in conjuncts { - match classify_time_pred(c, time_col) { - TimeClass::Start(ms) => { - start_ms = Some(start_ms.map_or(ms, |s: i64| s.max(ms))); - } - TimeClass::End(ms) => { - end_ms = Some(end_ms.map_or(ms, |e: i64| e.min(ms))); - } - TimeClass::Both(lo, hi) => { - start_ms = Some(start_ms.map_or(lo, |s: i64| s.max(lo))); - end_ms = Some(end_ms.map_or(hi, |e: i64| e.min(hi))); - } - TimeClass::NonTime => non_time.push(c), - } - } - - let range = if start_ms.is_some() || end_ms.is_some() { - Some(TimeRange { start_ms, end_ms }) - } else { - None - }; - (range, non_time) -} - -/// Convenience wrapper: split a single expression then classify conjuncts. -#[cfg(test)] -fn extract_time_range<'a>(expr: &'a Expr, time_col: &str) -> (Option, Vec<&'a Expr>) { - extract_time_range_from_conjuncts(split_conjuncts(expr), time_col) -} - /// Walk a `Filter(Filter(...(TableScan)))` chain. /// Returns `(predicates_from_inner_filters, Some(scan))` when a TableScan is /// found at any depth, or `(vec![], None)` if a non-Filter non-Scan node is @@ -687,376 +638,6 @@ fn collect_filter_chain(plan: &LogicalPlan) -> (Vec<&Expr>, Option<&logical_expr } } -/// Translate a slice of DataFusion `Expr`s (non-time conjuncts) into a single -/// `L3Expr`. A single element is returned as-is; multiple elements are wrapped -/// in `L3Expr::BoolAnd`. -fn conjuncts_to_l3expr(conjuncts: Vec<&Expr>) -> Result { - let parts: Result, _> = conjuncts.iter().map(|e| df_expr_to_l3(e)).collect(); - let mut parts = parts?; - if parts.len() == 1 { - Ok(parts.pop().unwrap()) - } else { - Ok(L3Expr::BoolAnd(parts)) - } -} - -/// Translate a DataFusion `Expr` to an `L3Expr`. -/// Returns `UnsupportedFeature` for anything not needed in v1. -fn df_expr_to_l3(expr: &Expr) -> Result { - match expr { - Expr::Column(col) => Ok(L3Expr::Column(ColumnRef(col.name.clone()))), - - Expr::Literal(sv) => scalar_value_to_l3(sv).map(L3Expr::Literal), - - Expr::Alias(a) => df_expr_to_l3(&a.expr), - - Expr::BinaryExpr(BinaryExpr { left, op, right }) => match op { - Operator::And => { - let parts = split_conjuncts(expr); - let l3_parts: Result, _> = parts.iter().map(|e| df_expr_to_l3(e)).collect(); - Ok(L3Expr::BoolAnd(l3_parts?)) - } - Operator::Or => { - let parts = split_disjuncts(expr); - let l3_parts: Result, _> = parts.iter().map(|e| df_expr_to_l3(e)).collect(); - Ok(L3Expr::BoolOr(l3_parts?)) - } - Operator::Eq => compare(left, CompareOp::Eq, right), - Operator::NotEq => compare(left, CompareOp::Ne, right), - Operator::Lt => compare(left, CompareOp::Lt, right), - Operator::LtEq => compare(left, CompareOp::Le, right), - Operator::Gt => compare(left, CompareOp::Gt, right), - Operator::GtEq => compare(left, CompareOp::Ge, right), - // BinaryExpr LIKE/ILIKE operators (from optimizer rewrites) - Operator::LikeMatch => compare(left, CompareOp::Like, right), - Operator::ILikeMatch => compare(left, CompareOp::ILike, right), - Operator::NotLikeMatch => compare(left, CompareOp::NotLike, right), - Operator::NotILikeMatch => compare(left, CompareOp::NotILike, right), - // Arithmetic - Operator::Plus => arith(left, ArithOp::Add, right), - Operator::Minus => arith(left, ArithOp::Sub, right), - Operator::Multiply => arith(left, ArithOp::Mul, right), - Operator::Divide => arith(left, ArithOp::Div, right), - Operator::Modulo => arith(left, ArithOp::Mod, right), - other => Err(LoweringError::UnsupportedFeature(format!( - "operator: {other:?}" - ))), - }, - - // SQL LIKE / ILIKE (dedicated expr node from the SQL parser) - Expr::Like(like) => { - let op = match (like.negated, like.case_insensitive) { - (false, false) => CompareOp::Like, - (true, false) => CompareOp::NotLike, - (false, true) => CompareOp::ILike, - (true, true) => CompareOp::NotILike, - }; - compare(&like.expr, op, &like.pattern) - } - - // Unary minus: negate literals directly; wrap others in -1 * x. - Expr::Negative(inner) => { - let inner_l3 = df_expr_to_l3(inner)?; - match inner_l3 { - L3Expr::Literal(L3Scalar::Int64(v)) => Ok(L3Expr::Literal(L3Scalar::Int64(-v))), - L3Expr::Literal(L3Scalar::Float64(v)) => Ok(L3Expr::Literal(L3Scalar::Float64(-v))), - other => Ok(L3Expr::Arith { - op: ArithOp::Mul, - left: Box::new(L3Expr::Literal(L3Scalar::Int64(-1))), - right: Box::new(other), - }), - } - } - - // SQL CASE expression - Expr::Case(c) => { - let operand = c - .expr - .as_ref() - .map(|e| df_expr_to_l3(e).map(Box::new)) - .transpose()?; - let branches = c - .when_then_expr - .iter() - .map(|(when, then)| Ok((df_expr_to_l3(when)?, df_expr_to_l3(then)?))) - .collect::, LoweringError>>()?; - let else_expr = c - .else_expr - .as_ref() - .map(|e| df_expr_to_l3(e).map(Box::new)) - .transpose()?; - Ok(L3Expr::Case { - operand, - branches, - else_expr, - }) - } - - Expr::Not(inner) => Ok(L3Expr::Not(Box::new(df_expr_to_l3(inner)?))), - - Expr::IsNull(inner) => Ok(L3Expr::IsNull(Box::new(df_expr_to_l3(inner)?))), - - Expr::IsNotNull(inner) => Ok(L3Expr::IsNotNull(Box::new(df_expr_to_l3(inner)?))), - - Expr::Cast(c) => { - let inner = df_expr_to_l3(&c.expr)?; - let to = arrow_to_l3(&c.data_type)?; - Ok(L3Expr::Cast { - expr: Box::new(inner), - to, - try_cast: false, - }) - } - - // TRY_CAST returns NULL on conversion failure; preserve that semantic. - Expr::TryCast(c) => { - let inner = df_expr_to_l3(&c.expr)?; - let to = arrow_to_l3(&c.data_type)?; - Ok(L3Expr::Cast { - expr: Box::new(inner), - to, - try_cast: true, - }) - } - - Expr::InList(il) => { - let expr = df_expr_to_l3(&il.expr)?; - let list: Result, _> = il.list.iter().map(df_expr_to_l3).collect(); - Ok(L3Expr::InList { - expr: Box::new(expr), - list: list?, - negated: il.negated, - }) - } - - Expr::Between(b) => { - // Normalize: `x BETWEEN low AND high` → `x >= low AND x <= high`. - // `x NOT BETWEEN low AND high` → `x < low OR x > high`. - let x_low = compare(&b.expr, CompareOp::Ge, &b.low)?; - let x_high = compare(&b.expr, CompareOp::Le, &b.high)?; - if b.negated { - // NOT BETWEEN: invert each side - let lt = compare(&b.expr, CompareOp::Lt, &b.low)?; - let gt = compare(&b.expr, CompareOp::Gt, &b.high)?; - Ok(L3Expr::BoolOr(vec![lt, gt])) - } else { - Ok(L3Expr::BoolAnd(vec![x_low, x_high])) - } - } - - Expr::ScalarFunction(sf) => { - let args: Result, _> = sf.args.iter().map(df_expr_to_l3).collect(); - Ok(L3Expr::FunctionCall { - name: sf.func.name().to_string(), - args: args?, - }) - } - - other => Err(LoweringError::UnsupportedFeature(format!( - "expression: {}", - other - ))), - } -} - -fn compare(left: &Expr, op: CompareOp, right: &Expr) -> Result { - Ok(L3Expr::Compare { - left: Box::new(df_expr_to_l3(left)?), - op, - right: Box::new(df_expr_to_l3(right)?), - }) -} - -fn arith(left: &Expr, op: ArithOp, right: &Expr) -> Result { - Ok(L3Expr::Arith { - op, - left: Box::new(df_expr_to_l3(left)?), - right: Box::new(df_expr_to_l3(right)?), - }) -} - -fn scalar_value_to_l3(sv: &ScalarValue) -> Result { - match sv { - ScalarValue::Int64(Some(v)) => Ok(L3Scalar::Int64(*v)), - ScalarValue::Int32(Some(v)) => Ok(L3Scalar::Int64(*v as i64)), - ScalarValue::Int16(Some(v)) => Ok(L3Scalar::Int64(*v as i64)), - ScalarValue::Int8(Some(v)) => Ok(L3Scalar::Int64(*v as i64)), - ScalarValue::UInt64(Some(v)) => i64::try_from(*v).map(L3Scalar::Int64).map_err(|_| { - LoweringError::InvalidExpression(format!("UInt64 value {v} overflows i64")) - }), - ScalarValue::UInt32(Some(v)) => Ok(L3Scalar::Int64(*v as i64)), - ScalarValue::Float64(Some(v)) => Ok(L3Scalar::Float64(*v)), - ScalarValue::Float32(Some(v)) => Ok(L3Scalar::Float64(*v as f64)), - ScalarValue::Utf8(Some(s)) | ScalarValue::LargeUtf8(Some(s)) => { - Ok(L3Scalar::Utf8(s.clone())) - } - ScalarValue::Boolean(Some(b)) => Ok(L3Scalar::Boolean(*b)), - // Typed nulls and untyped null both become L3Scalar::Null - _ if sv.is_null() => Ok(L3Scalar::Null), - _ => Err(LoweringError::InvalidExpression(format!( - "unsupported scalar: {sv:?}" - ))), - } -} - -fn arrow_to_l3(dt: &ArrowDataType) -> Result { - match dt { - ArrowDataType::Int64 - | ArrowDataType::Int32 - | ArrowDataType::Int16 - | ArrowDataType::Int8 => Ok(L3DataType::Int64), - ArrowDataType::Float64 | ArrowDataType::Float32 => Ok(L3DataType::Float64), - ArrowDataType::Utf8 | ArrowDataType::LargeUtf8 => Ok(L3DataType::Utf8), - ArrowDataType::Boolean => Ok(L3DataType::Boolean), - ArrowDataType::Timestamp(_, _) => Ok(L3DataType::Timestamp), - ArrowDataType::Duration(_) => Ok(L3DataType::Duration), - other => Err(LoweringError::UnsupportedFeature(format!( - "Arrow type in cast: {other:?}" - ))), - } -} - -fn split_disjuncts(expr: &Expr) -> Vec<&Expr> { - match expr { - Expr::BinaryExpr(BinaryExpr { - left, - op: Operator::Or, - right, - }) => { - let mut v = split_disjuncts(left); - v.extend(split_disjuncts(right)); - v - } - _ => vec![expr], - } -} - -enum TimeClass { - Start(i64), - End(i64), - /// BETWEEN low AND high on the time column — contributes both bounds at once. - Both(i64, i64), - NonTime, -} - -fn classify_time_pred(expr: &Expr, time_col: &str) -> TimeClass { - match expr { - // `ts BETWEEN low AND high` — contributes both a start and end bound. - // `ts NOT BETWEEN …` cannot be expressed as a contiguous TimeRange; treat as non-time. - Expr::Between(b) if !b.negated && is_time_col(&b.expr, time_col) => { - match (expr_to_ms(&b.low), expr_to_ms(&b.high)) { - (Some(lo), Some(hi)) => TimeClass::Both(lo, hi), - _ => TimeClass::NonTime, - } - } - - Expr::BinaryExpr(BinaryExpr { left, op, right }) => { - let (col_is_left, val_expr): (bool, &Expr) = if is_time_col(left, time_col) { - (true, right) - } else if is_time_col(right, time_col) { - (false, left) - } else { - return TimeClass::NonTime; - }; - let Some(ms) = expr_to_ms(val_expr) else { - return TimeClass::NonTime; - }; - match (op, col_is_left) { - (Operator::Gt | Operator::GtEq, true) | (Operator::Lt | Operator::LtEq, false) => { - TimeClass::Start(ms) - } - (Operator::Lt | Operator::LtEq, true) | (Operator::Gt | Operator::GtEq, false) => { - TimeClass::End(ms) - } - // Eq (exact timestamp equality) and all other operators cannot be - // expressed as a contiguous half-open range, so leave them as - // regular Filter predicates rather than time-range bounds. - _ => TimeClass::NonTime, - } - } - - _ => TimeClass::NonTime, - } -} - -fn is_time_col(expr: &Expr, time_col: &str) -> bool { - match expr { - Expr::Column(col) => col.name == time_col, - Expr::Cast(c) => is_time_col(&c.expr, time_col), - _ => false, - } -} - -fn expr_to_ms(expr: &Expr) -> Option { - match expr { - Expr::Literal(sv) => scalar_to_ms(sv), - Expr::Cast(c) => expr_to_ms(&c.expr), - Expr::TryCast(c) => expr_to_ms(&c.expr), - _ => None, - } -} - -/// Round `v` to the nearest millisecond and return it as `i64`. -/// Returns `None` if `v` is non-finite or outside the `i64` range. -fn float_to_ms(v: f64) -> Option { - let rounded = v.round(); - // i64::MAX as f64 rounds up to 2^63, which overflows i64 on cast. - // Use strict less-than for the upper bound. - if rounded.is_finite() && rounded >= i64::MIN as f64 && rounded < i64::MAX as f64 { - Some(rounded as i64) - } else { - None - } -} - -fn scalar_to_ms(sv: &ScalarValue) -> Option { - match sv { - ScalarValue::Int64(Some(v)) => Some(*v), - ScalarValue::Int32(Some(v)) => Some(*v as i64), - ScalarValue::Float64(Some(v)) => float_to_ms(*v), - ScalarValue::Float32(Some(v)) => float_to_ms(*v as f64), - ScalarValue::TimestampMillisecond(Some(ms), _) => Some(*ms), - ScalarValue::TimestampNanosecond(Some(ns), _) => Some(*ns / 1_000_000), - ScalarValue::TimestampMicrosecond(Some(us), _) => Some(*us / 1_000), - ScalarValue::TimestampSecond(Some(s), _) => Some(*s * 1_000), - _ => None, - } -} - -fn table_schema_to_arrow(schema: &TableSchema) -> Schema { - let fields: Fields = schema - .columns - .iter() - .map(|c| Field::new(&c.name, l3_to_arrow(&c.data_type), c.nullable)) - .collect(); - Schema::new(fields) -} - -fn l3_to_arrow(dt: &L3DataType) -> ArrowDataType { - match dt { - L3DataType::Int64 => ArrowDataType::Int64, - L3DataType::Float64 => ArrowDataType::Float64, - L3DataType::Utf8 => ArrowDataType::Utf8, - L3DataType::Boolean => ArrowDataType::Boolean, - L3DataType::Timestamp => ArrowDataType::Timestamp(TimeUnit::Millisecond, None), - L3DataType::Duration => ArrowDataType::Duration(TimeUnit::Millisecond), - L3DataType::Map(k, v) => ArrowDataType::Map( - Arc::new(Field::new( - "entries", - ArrowDataType::Struct(Fields::from(vec![ - Field::new("key", l3_to_arrow(k), false), - Field::new("value", l3_to_arrow(v), true), - ])), - false, - )), - false, - ), - L3DataType::List(item) => { - ArrowDataType::List(Arc::new(Field::new("item", l3_to_arrow(item), true))) - } - } -} - fn lower_window_func_kind(fun: &WindowFunctionDefinition) -> Result { match fun { // In DataFusion 43 most ranking/nav window functions are WindowUDF. @@ -1100,153 +681,6 @@ fn lower_window_func_kind(fun: &WindowFunctionDefinition) -> Result Expr { - Expr::Column(datafusion::common::Column::new_unqualified(name)) - } - fn int(v: i64) -> Expr { - Expr::Literal(ScalarValue::Int64(Some(v))) - } - fn float(v: f64) -> Expr { - Expr::Literal(ScalarValue::Float64(Some(v))) - } - fn bin(left: Expr, op: Operator, right: Expr) -> Expr { - Expr::BinaryExpr(BinaryExpr { - left: Box::new(left), - op, - right: Box::new(right), - }) - } - fn and(l: Expr, r: Expr) -> Expr { - bin(l, Operator::And, r) - } - - #[test] - fn col_left_gt_lower_bound() { - let expr = bin(col("ts"), Operator::Gt, int(1000)); - let (range, non_time) = extract_time_range(&expr, "ts"); - assert_eq!( - range, - Some(TimeRange { - start_ms: Some(1000), - end_ms: None - }) - ); - assert!(non_time.is_empty()); - } - - #[test] - fn col_right_lt_is_start_bound() { - // `1000 < ts` ≡ `ts > 1000` - let expr = bin(int(1000), Operator::Lt, col("ts")); - let (range, non_time) = extract_time_range(&expr, "ts"); - assert_eq!( - range, - Some(TimeRange { - start_ms: Some(1000), - end_ms: None - }) - ); - assert!(non_time.is_empty()); - } - - #[test] - fn col_right_gt_is_end_bound() { - // `2000 > ts` ≡ `ts < 2000` - let expr = bin(int(2000), Operator::Gt, col("ts")); - let (range, non_time) = extract_time_range(&expr, "ts"); - assert_eq!( - range, - Some(TimeRange { - start_ms: None, - end_ms: Some(2000) - }) - ); - assert!(non_time.is_empty()); - } - - #[test] - fn overlapping_repeated_bounds_tighten() { - // `ts > 500 AND ts > 1000` → start = 1000 (tighter) - let expr = and( - bin(col("ts"), Operator::Gt, int(500)), - bin(col("ts"), Operator::Gt, int(1000)), - ); - let (range, _) = extract_time_range(&expr, "ts"); - assert_eq!(range.unwrap().start_ms, Some(1000)); - } - - #[test] - fn overlapping_end_bounds_tighten() { - // `ts < 2000 AND ts < 1500` → end = 1500 (tighter) - let expr = and( - bin(col("ts"), Operator::Lt, int(2000)), - bin(col("ts"), Operator::Lt, int(1500)), - ); - let (range, _) = extract_time_range(&expr, "ts"); - assert_eq!(range.unwrap().end_ms, Some(1500)); - } - - #[test] - fn between_contributes_both_bounds() { - use datafusion::logical_expr::Between; - let expr = Expr::Between(Between { - expr: Box::new(col("ts")), - negated: false, - low: Box::new(int(1000)), - high: Box::new(int(2000)), - }); - let (range, non_time) = extract_time_range(&expr, "ts"); - assert_eq!( - range, - Some(TimeRange { - start_ms: Some(1000), - end_ms: Some(2000) - }) - ); - assert!(non_time.is_empty()); - } - - #[test] - fn not_between_is_non_time() { - use datafusion::logical_expr::Between; - let expr = Expr::Between(Between { - expr: Box::new(col("ts")), - negated: true, - low: Box::new(int(1000)), - high: Box::new(int(2000)), - }); - let (range, non_time) = extract_time_range(&expr, "ts"); - assert!(range.is_none()); - assert_eq!(non_time.len(), 1); - } - - #[test] - fn float_literal_extracted_as_ms() { - let expr = bin(col("ts"), Operator::Gt, float(1_000_000.0)); - let (range, non_time) = extract_time_range(&expr, "ts"); - assert_eq!( - range, - Some(TimeRange { - start_ms: Some(1_000_000), - end_ms: None - }) - ); - assert!(non_time.is_empty()); - } - - #[test] - fn non_time_conjunct_passes_through() { - let expr = and( - bin(col("ts"), Operator::Gt, int(1000)), - bin(col("value"), Operator::Gt, int(0)), - ); - let (range, non_time) = extract_time_range(&expr, "ts"); - assert!(range.is_some()); - assert_eq!(non_time.len(), 1); - } // ── collect_filter_chain unit tests (Fix 3) ─────────────────────────────── diff --git a/crates/lower/src/sql/time.rs b/crates/lower/src/sql/time.rs new file mode 100644 index 00000000..58efaa12 --- /dev/null +++ b/crates/lower/src/sql/time.rs @@ -0,0 +1,288 @@ +use datafusion::common::ScalarValue; +use datafusion::logical_expr::{BinaryExpr, Expr, Operator}; + +use asap_control_core::intent_algebra::expr::TimeRange; + +/// Core: classify a pre-split list of conjuncts into time bounds + residual. +pub(super) fn extract_time_range_from_conjuncts<'a>( + conjuncts: Vec<&'a Expr>, + time_col: &str, +) -> (Option, Vec<&'a Expr>) { + let mut start_ms: Option = None; + let mut end_ms: Option = None; + let mut non_time: Vec<&'a Expr> = vec![]; + + for c in conjuncts { + match classify_time_pred(c, time_col) { + TimeClass::Start(ms) => { + start_ms = Some(start_ms.map_or(ms, |s: i64| s.max(ms))); + } + TimeClass::End(ms) => { + end_ms = Some(end_ms.map_or(ms, |e: i64| e.min(ms))); + } + TimeClass::Both(lo, hi) => { + start_ms = Some(start_ms.map_or(lo, |s: i64| s.max(lo))); + end_ms = Some(end_ms.map_or(hi, |e: i64| e.min(hi))); + } + TimeClass::NonTime => non_time.push(c), + } + } + + let range = if start_ms.is_some() || end_ms.is_some() { + Some(TimeRange { start_ms, end_ms }) + } else { + None + }; + (range, non_time) +} + +/// Convenience wrapper: split a single expression then classify conjuncts. +#[cfg(test)] +fn extract_time_range<'a>(expr: &'a Expr, time_col: &str) -> (Option, Vec<&'a Expr>) { + use super::expr::split_conjuncts; + extract_time_range_from_conjuncts(split_conjuncts(expr), time_col) +} + +enum TimeClass { + Start(i64), + End(i64), + /// BETWEEN low AND high on the time column — contributes both bounds at once. + Both(i64, i64), + NonTime, +} + +fn classify_time_pred(expr: &Expr, time_col: &str) -> TimeClass { + match expr { + // `ts BETWEEN low AND high` — contributes both a start and end bound. + // `ts NOT BETWEEN …` cannot be expressed as a contiguous TimeRange; treat as non-time. + Expr::Between(b) if !b.negated && is_time_col(&b.expr, time_col) => { + match (expr_to_ms(&b.low), expr_to_ms(&b.high)) { + (Some(lo), Some(hi)) => TimeClass::Both(lo, hi), + _ => TimeClass::NonTime, + } + } + + Expr::BinaryExpr(BinaryExpr { left, op, right }) => { + let (col_is_left, val_expr): (bool, &Expr) = if is_time_col(left, time_col) { + (true, right) + } else if is_time_col(right, time_col) { + (false, left) + } else { + return TimeClass::NonTime; + }; + let Some(ms) = expr_to_ms(val_expr) else { + return TimeClass::NonTime; + }; + match (op, col_is_left) { + (Operator::Gt | Operator::GtEq, true) | (Operator::Lt | Operator::LtEq, false) => { + TimeClass::Start(ms) + } + (Operator::Lt | Operator::LtEq, true) | (Operator::Gt | Operator::GtEq, false) => { + TimeClass::End(ms) + } + // Eq (exact timestamp equality) and all other operators cannot be + // expressed as a contiguous half-open range, so leave them as + // regular Filter predicates rather than time-range bounds. + _ => TimeClass::NonTime, + } + } + + _ => TimeClass::NonTime, + } +} + +fn is_time_col(expr: &Expr, time_col: &str) -> bool { + match expr { + Expr::Column(col) => col.name == time_col, + Expr::Cast(c) => is_time_col(&c.expr, time_col), + _ => false, + } +} + +fn expr_to_ms(expr: &Expr) -> Option { + match expr { + Expr::Literal(sv) => scalar_to_ms(sv), + Expr::Cast(c) => expr_to_ms(&c.expr), + Expr::TryCast(c) => expr_to_ms(&c.expr), + _ => None, + } +} + +/// Round `v` to the nearest millisecond and return it as `i64`. +/// Returns `None` if `v` is non-finite or outside the `i64` range. +fn float_to_ms(v: f64) -> Option { + let rounded = v.round(); + // i64::MAX as f64 rounds up to 2^63, which overflows i64 on cast. + // Use strict less-than for the upper bound. + if rounded.is_finite() && rounded >= i64::MIN as f64 && rounded < i64::MAX as f64 { + Some(rounded as i64) + } else { + None + } +} + +fn scalar_to_ms(sv: &ScalarValue) -> Option { + match sv { + ScalarValue::Int64(Some(v)) => Some(*v), + ScalarValue::Int32(Some(v)) => Some(*v as i64), + ScalarValue::Float64(Some(v)) => float_to_ms(*v), + ScalarValue::Float32(Some(v)) => float_to_ms(*v as f64), + ScalarValue::TimestampMillisecond(Some(ms), _) => Some(*ms), + ScalarValue::TimestampNanosecond(Some(ns), _) => Some(*ns / 1_000_000), + ScalarValue::TimestampMicrosecond(Some(us), _) => Some(*us / 1_000), + ScalarValue::TimestampSecond(Some(s), _) => Some(*s * 1_000), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use datafusion::common::ScalarValue; + use datafusion::logical_expr::{BinaryExpr, Expr, Operator}; + + fn col(name: &str) -> Expr { + Expr::Column(datafusion::common::Column::new_unqualified(name)) + } + fn int(v: i64) -> Expr { + Expr::Literal(ScalarValue::Int64(Some(v))) + } + fn float(v: f64) -> Expr { + Expr::Literal(ScalarValue::Float64(Some(v))) + } + fn bin(left: Expr, op: Operator, right: Expr) -> Expr { + Expr::BinaryExpr(BinaryExpr { + left: Box::new(left), + op, + right: Box::new(right), + }) + } + fn and(l: Expr, r: Expr) -> Expr { + bin(l, Operator::And, r) + } + + #[test] + fn col_left_gt_lower_bound() { + let expr = bin(col("ts"), Operator::Gt, int(1000)); + let (range, non_time) = extract_time_range(&expr, "ts"); + assert_eq!( + range, + Some(TimeRange { + start_ms: Some(1000), + end_ms: None + }) + ); + assert!(non_time.is_empty()); + } + + #[test] + fn col_right_lt_is_start_bound() { + // `1000 < ts` ≡ `ts > 1000` + let expr = bin(int(1000), Operator::Lt, col("ts")); + let (range, non_time) = extract_time_range(&expr, "ts"); + assert_eq!( + range, + Some(TimeRange { + start_ms: Some(1000), + end_ms: None + }) + ); + assert!(non_time.is_empty()); + } + + #[test] + fn col_right_gt_is_end_bound() { + // `2000 > ts` ≡ `ts < 2000` + let expr = bin(int(2000), Operator::Gt, col("ts")); + let (range, non_time) = extract_time_range(&expr, "ts"); + assert_eq!( + range, + Some(TimeRange { + start_ms: None, + end_ms: Some(2000) + }) + ); + assert!(non_time.is_empty()); + } + + #[test] + fn overlapping_repeated_bounds_tighten() { + // `ts > 500 AND ts > 1000` → start = 1000 (tighter) + let expr = and( + bin(col("ts"), Operator::Gt, int(500)), + bin(col("ts"), Operator::Gt, int(1000)), + ); + let (range, _) = extract_time_range(&expr, "ts"); + assert_eq!(range.unwrap().start_ms, Some(1000)); + } + + #[test] + fn overlapping_end_bounds_tighten() { + // `ts < 2000 AND ts < 1500` → end = 1500 (tighter) + let expr = and( + bin(col("ts"), Operator::Lt, int(2000)), + bin(col("ts"), Operator::Lt, int(1500)), + ); + let (range, _) = extract_time_range(&expr, "ts"); + assert_eq!(range.unwrap().end_ms, Some(1500)); + } + + #[test] + fn between_contributes_both_bounds() { + use datafusion::logical_expr::Between; + let expr = Expr::Between(Between { + expr: Box::new(col("ts")), + negated: false, + low: Box::new(int(1000)), + high: Box::new(int(2000)), + }); + let (range, non_time) = extract_time_range(&expr, "ts"); + assert_eq!( + range, + Some(TimeRange { + start_ms: Some(1000), + end_ms: Some(2000) + }) + ); + assert!(non_time.is_empty()); + } + + #[test] + fn not_between_is_non_time() { + use datafusion::logical_expr::Between; + let expr = Expr::Between(Between { + expr: Box::new(col("ts")), + negated: true, + low: Box::new(int(1000)), + high: Box::new(int(2000)), + }); + let (range, non_time) = extract_time_range(&expr, "ts"); + assert!(range.is_none()); + assert_eq!(non_time.len(), 1); + } + + #[test] + fn float_literal_extracted_as_ms() { + let expr = bin(col("ts"), Operator::Gt, float(1_000_000.0)); + let (range, non_time) = extract_time_range(&expr, "ts"); + assert_eq!( + range, + Some(TimeRange { + start_ms: Some(1_000_000), + end_ms: None + }) + ); + assert!(non_time.is_empty()); + } + + #[test] + fn non_time_conjunct_passes_through() { + let expr = and( + bin(col("ts"), Operator::Gt, int(1000)), + bin(col("value"), Operator::Gt, int(0)), + ); + let (range, non_time) = extract_time_range(&expr, "ts"); + assert!(range.is_some()); + assert_eq!(non_time.len(), 1); + } +} diff --git a/crates/lower/src/sql/types.rs b/crates/lower/src/sql/types.rs new file mode 100644 index 00000000..5f124891 --- /dev/null +++ b/crates/lower/src/sql/types.rs @@ -0,0 +1,84 @@ +use std::sync::Arc; + +use datafusion::arrow::datatypes::{DataType as ArrowDataType, Field, Fields, Schema, TimeUnit}; +use datafusion::common::ScalarValue; + +use asap_control_core::intent_algebra::schema::{L3DataType, TableSchema}; +use asap_control_core::intent_algebra::L3Scalar; + +use crate::error::LoweringError; + +pub(super) fn scalar_value_to_l3(sv: &ScalarValue) -> Result { + match sv { + ScalarValue::Int64(Some(v)) => Ok(L3Scalar::Int64(*v)), + ScalarValue::Int32(Some(v)) => Ok(L3Scalar::Int64(*v as i64)), + ScalarValue::Int16(Some(v)) => Ok(L3Scalar::Int64(*v as i64)), + ScalarValue::Int8(Some(v)) => Ok(L3Scalar::Int64(*v as i64)), + ScalarValue::UInt64(Some(v)) => i64::try_from(*v).map(L3Scalar::Int64).map_err(|_| { + LoweringError::InvalidExpression(format!("UInt64 value {v} overflows i64")) + }), + ScalarValue::UInt32(Some(v)) => Ok(L3Scalar::Int64(*v as i64)), + ScalarValue::Float64(Some(v)) => Ok(L3Scalar::Float64(*v)), + ScalarValue::Float32(Some(v)) => Ok(L3Scalar::Float64(*v as f64)), + ScalarValue::Utf8(Some(s)) | ScalarValue::LargeUtf8(Some(s)) => { + Ok(L3Scalar::Utf8(s.clone())) + } + ScalarValue::Boolean(Some(b)) => Ok(L3Scalar::Boolean(*b)), + // Typed nulls and untyped null both become L3Scalar::Null + _ if sv.is_null() => Ok(L3Scalar::Null), + _ => Err(LoweringError::InvalidExpression(format!( + "unsupported scalar: {sv:?}" + ))), + } +} + +pub(super) fn arrow_to_l3(dt: &ArrowDataType) -> Result { + match dt { + ArrowDataType::Int64 + | ArrowDataType::Int32 + | ArrowDataType::Int16 + | ArrowDataType::Int8 => Ok(L3DataType::Int64), + ArrowDataType::Float64 | ArrowDataType::Float32 => Ok(L3DataType::Float64), + ArrowDataType::Utf8 | ArrowDataType::LargeUtf8 => Ok(L3DataType::Utf8), + ArrowDataType::Boolean => Ok(L3DataType::Boolean), + ArrowDataType::Timestamp(_, _) => Ok(L3DataType::Timestamp), + ArrowDataType::Duration(_) => Ok(L3DataType::Duration), + other => Err(LoweringError::UnsupportedFeature(format!( + "Arrow type in cast: {other:?}" + ))), + } +} + +pub(super) fn table_schema_to_arrow(schema: &TableSchema) -> Schema { + let fields: Fields = schema + .columns + .iter() + .map(|c| Field::new(&c.name, l3_to_arrow(&c.data_type), c.nullable)) + .collect(); + Schema::new(fields) +} + +pub(super) fn l3_to_arrow(dt: &L3DataType) -> ArrowDataType { + match dt { + L3DataType::Int64 => ArrowDataType::Int64, + L3DataType::Float64 => ArrowDataType::Float64, + L3DataType::Utf8 => ArrowDataType::Utf8, + L3DataType::Boolean => ArrowDataType::Boolean, + L3DataType::Timestamp => ArrowDataType::Timestamp(TimeUnit::Millisecond, None), + L3DataType::Duration => ArrowDataType::Duration(TimeUnit::Millisecond), + L3DataType::Map(k, v) => ArrowDataType::Map( + Arc::new(Field::new( + "entries", + ArrowDataType::Struct(Fields::from(vec![ + Field::new("key", l3_to_arrow(k), false), + Field::new("value", l3_to_arrow(v), true), + ])), + false, + )), + false, + ), + L3DataType::List(item) => { + ArrowDataType::List(Arc::new(Field::new("item", l3_to_arrow(item), true))) + } + } +} diff --git a/crates/lower/tests/sql_lowering.rs b/crates/lower/tests/sql_lowering.rs index d626b690..b8f3d888 100644 --- a/crates/lower/tests/sql_lowering.rs +++ b/crates/lower/tests/sql_lowering.rs @@ -141,1849 +141,1887 @@ fn find_source(expr: &QueryExpr) -> Option<&Source> { } } -// ── Tests: Scan / Projection ────────────────────────────────────────────────── - -#[tokio::test] -async fn test_scan_table_name() { - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT ts, value FROM metrics") - .await - .unwrap(); - - let source = find_source(&result).expect("expected a Scan node"); - let Source::Table { table_ref, .. } = source else { - panic!("expected Source::Table, got {source:?}"); - }; - assert_eq!(table_ref.0, "metrics"); +fn make_workload(queries: Vec<&str>) -> QueryWorkload { + QueryWorkload { + language: QueryLanguage::SQL(SqlDialect::DataFusionSQL), + query_batch: Some( + queries + .into_iter() + .map(|q| BatchEntry { + query: Query(q.to_string()), + requirements: None, + }) + .collect(), + ), + repeating_queries: None, + data_characteristics: None, + } } -#[tokio::test] -async fn test_projection_wraps_scan() { - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT ts, value FROM metrics") - .await - .unwrap(); - - // DataFusion always emits a Projection for explicit SELECT lists. - let QueryExpr::Project { cols, child } = result else { - panic!("expected Project at root, got {:?}", result); - }; - assert_eq!(cols.len(), 2); - assert!(matches!(child.expr, QueryExpr::Scan { .. })); -} +mod scan_filter { + use super::*; -// ── Tests: Filter / time extraction ────────────────────────────────────────── - -#[tokio::test] -async fn test_non_time_filter_stays_as_filter() { - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - // region is not the time column → stays as Filter - let result = lowerer - .lower("SELECT ts FROM metrics WHERE region = 'us-east'") - .await - .unwrap(); - - fn has_filter(e: &QueryExpr) -> bool { - match e { - QueryExpr::Filter { .. } => true, - QueryExpr::Project { child, .. } => has_filter(&child.expr), - _ => false, - } + // ── Tests: Scan / Projection ────────────────────────────────────────────────── + + #[tokio::test] + async fn test_scan_table_name() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT ts, value FROM metrics") + .await + .unwrap(); + + let source = find_source(&result).expect("expected a Scan node"); + let Source::Table { table_ref, .. } = source else { + panic!("expected Source::Table, got {source:?}"); + }; + assert_eq!(table_ref.0, "metrics"); } - assert!(has_filter(&result), "expected a Filter node"); -} -#[tokio::test] -async fn test_time_predicate_extracted_to_source() { - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - // ts is the time column → extracted into Source::Table.time_range - let result = lowerer - .lower("SELECT value FROM metrics WHERE ts > 1000 AND ts < 2000") - .await - .unwrap(); - - let source = find_source(&result).unwrap(); - let Source::Table { time_range, .. } = source else { - panic!("expected Source::Table"); - }; - let tr = time_range.as_ref().expect("expected time_range to be Some"); - assert_eq!(tr.start_ms, Some(1000)); - assert_eq!(tr.end_ms, Some(2000)); -} + #[tokio::test] + async fn test_projection_wraps_scan() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT ts, value FROM metrics") + .await + .unwrap(); + + // DataFusion always emits a Projection for explicit SELECT lists. + let QueryExpr::Project { cols, child } = result else { + panic!("expected Project at root, got {:?}", result); + }; + assert_eq!(cols.len(), 2); + assert!(matches!(child.expr, QueryExpr::Scan { .. })); + } -#[tokio::test] -async fn test_time_predicate_no_filter_wrapper() { - // When ALL predicates are time bounds, no Filter node should appear. - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT value FROM metrics WHERE ts > 1000 AND ts < 2000") - .await - .unwrap(); - - fn has_filter(e: &QueryExpr) -> bool { - match e { - QueryExpr::Filter { .. } => true, - QueryExpr::Project { child, .. } => has_filter(&child.expr), - _ => false, + // ── Tests: Filter / time extraction ────────────────────────────────────────── + + #[tokio::test] + async fn test_non_time_filter_stays_as_filter() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + // region is not the time column → stays as Filter + let result = lowerer + .lower("SELECT ts FROM metrics WHERE region = 'us-east'") + .await + .unwrap(); + + fn has_filter(e: &QueryExpr) -> bool { + match e { + QueryExpr::Filter { .. } => true, + QueryExpr::Project { child, .. } => has_filter(&child.expr), + _ => false, + } } + assert!(has_filter(&result), "expected a Filter node"); } - assert!( - !has_filter(&result), - "expected no Filter node when only time predicates" - ); -} -#[tokio::test] -async fn test_mixed_filter_keeps_filter_and_time_range() { - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT value FROM metrics WHERE ts > 1000 AND region = 'eu'") - .await - .unwrap(); - - // The Source should have a time_range... - let source = find_source(&result).unwrap(); - let Source::Table { time_range, .. } = source else { - panic!("expected Source::Table"); - }; - assert!(time_range.is_some(), "expected time_range extracted"); - - // ...and there should also be a Filter node for the non-time predicate. - fn has_filter(e: &QueryExpr) -> bool { - match e { - QueryExpr::Filter { .. } => true, - QueryExpr::Project { child, .. } => has_filter(&child.expr), - _ => false, + #[tokio::test] + async fn test_time_predicate_extracted_to_source() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + // ts is the time column → extracted into Source::Table.time_range + let result = lowerer + .lower("SELECT value FROM metrics WHERE ts > 1000 AND ts < 2000") + .await + .unwrap(); + + let source = find_source(&result).unwrap(); + let Source::Table { time_range, .. } = source else { + panic!("expected Source::Table"); + }; + let tr = time_range.as_ref().expect("expected time_range to be Some"); + assert_eq!(tr.start_ms, Some(1000)); + assert_eq!(tr.end_ms, Some(2000)); + } + + #[tokio::test] + async fn test_time_predicate_no_filter_wrapper() { + // When ALL predicates are time bounds, no Filter node should appear. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT value FROM metrics WHERE ts > 1000 AND ts < 2000") + .await + .unwrap(); + + fn has_filter(e: &QueryExpr) -> bool { + match e { + QueryExpr::Filter { .. } => true, + QueryExpr::Project { child, .. } => has_filter(&child.expr), + _ => false, + } } + assert!( + !has_filter(&result), + "expected no Filter node when only time predicates" + ); } - assert!( - has_filter(&result), - "expected Filter node for non-time predicate" - ); -} -// ── Tests: Aggregates ───────────────────────────────────────────────────────── - -#[tokio::test] -async fn test_count_star_exact() { - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer.lower("SELECT COUNT(*) FROM metrics").await.unwrap(); - - let (by, aggs) = find_aggregate(&result).expect("expected Aggregate"); - assert!(by.is_empty(), "no GROUP BY expected"); - assert_eq!(aggs.len(), 1); - assert!(matches!( - aggs[0], - AggIntent::Count { - accuracy: AccuracyTarget::Exact + #[tokio::test] + async fn test_mixed_filter_keeps_filter_and_time_range() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT value FROM metrics WHERE ts > 1000 AND region = 'eu'") + .await + .unwrap(); + + // The Source should have a time_range... + let source = find_source(&result).unwrap(); + let Source::Table { time_range, .. } = source else { + panic!("expected Source::Table"); + }; + assert!(time_range.is_some(), "expected time_range extracted"); + + // ...and there should also be a Filter node for the non-time predicate. + fn has_filter(e: &QueryExpr) -> bool { + match e { + QueryExpr::Filter { .. } => true, + QueryExpr::Project { child, .. } => has_filter(&child.expr), + _ => false, + } } - )); + assert!( + has_filter(&result), + "expected Filter node for non-time predicate" + ); + } } -#[tokio::test] -async fn test_count_inherits_accuracy() { - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Epsilon(0.01)); - let result = lowerer.lower("SELECT COUNT(*) FROM metrics").await.unwrap(); +mod aggregates_and_topk { + use super::*; - let (_, aggs) = find_aggregate(&result).unwrap(); - assert!( - matches!(aggs[0], AggIntent::Count { accuracy: AccuracyTarget::Epsilon(e) } if (e - 0.01).abs() < 1e-12) - ); -} + // ── Tests: Aggregates ───────────────────────────────────────────────────────── -#[tokio::test] -async fn test_sum_aggregate() { - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT SUM(value) FROM metrics") - .await - .unwrap(); - - let (_, aggs) = find_aggregate(&result).unwrap(); - assert_eq!(aggs.len(), 1); - assert!(matches!(aggs[0], AggIntent::Sum { .. })); -} + #[tokio::test] + async fn test_count_star_exact() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer.lower("SELECT COUNT(*) FROM metrics").await.unwrap(); -#[tokio::test] -async fn test_min_max_aggregates() { - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT MIN(value), MAX(value) FROM metrics") - .await - .unwrap(); - - let (_, aggs) = find_aggregate(&result).unwrap(); - assert_eq!(aggs.len(), 2); - assert!(aggs.iter().any(|a| matches!(a, AggIntent::Min { .. }))); - assert!(aggs.iter().any(|a| matches!(a, AggIntent::Max { .. }))); -} + let (by, aggs) = find_aggregate(&result).expect("expected Aggregate"); + assert!(by.is_empty(), "no GROUP BY expected"); + assert_eq!(aggs.len(), 1); + assert!(matches!( + aggs[0], + AggIntent::Count { + accuracy: AccuracyTarget::Exact + } + )); + } -#[tokio::test] -async fn test_avg_aggregate() { - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT AVG(value) FROM metrics") - .await - .unwrap(); - - let (_, aggs) = find_aggregate(&result).unwrap(); - assert!(aggs.iter().any(|a| matches!(a, AggIntent::Avg { .. }))); -} + #[tokio::test] + async fn test_count_inherits_accuracy() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Epsilon(0.01)); + let result = lowerer.lower("SELECT COUNT(*) FROM metrics").await.unwrap(); -#[tokio::test] -async fn test_stddev_sample() { - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT STDDEV(value) FROM metrics") - .await - .unwrap(); - - let (_, aggs) = find_aggregate(&result).unwrap(); - assert!(aggs.iter().any(|a| matches!( - a, - AggIntent::Stddev { - population: false, - .. - } - ))); -} + let (_, aggs) = find_aggregate(&result).unwrap(); + assert!( + matches!(aggs[0], AggIntent::Count { accuracy: AccuracyTarget::Epsilon(e) } if (e - 0.01).abs() < 1e-12) + ); + } -#[tokio::test] -async fn test_group_by_extracted() { - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT region, COUNT(*) FROM metrics GROUP BY region") - .await - .unwrap(); - - let (by, aggs) = find_aggregate(&result).unwrap(); - assert_eq!(by.len(), 1); - assert_eq!(by[0].0, "region"); - assert!(matches!(aggs[0], AggIntent::Count { .. })); -} + #[tokio::test] + async fn test_sum_aggregate() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT SUM(value) FROM metrics") + .await + .unwrap(); + + let (_, aggs) = find_aggregate(&result).unwrap(); + assert_eq!(aggs.len(), 1); + assert!(matches!(aggs[0], AggIntent::Sum { .. })); + } -#[tokio::test] -async fn test_multiple_aggregates_in_one_node() { - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT COUNT(*), SUM(value), MIN(value), MAX(value) FROM metrics") - .await - .unwrap(); - - let (_, aggs) = find_aggregate(&result).unwrap(); - assert_eq!(aggs.len(), 4); -} + #[tokio::test] + async fn test_min_max_aggregates() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT MIN(value), MAX(value) FROM metrics") + .await + .unwrap(); + + let (_, aggs) = find_aggregate(&result).unwrap(); + assert_eq!(aggs.len(), 2); + assert!(aggs.iter().any(|a| matches!(a, AggIntent::Min { .. }))); + assert!(aggs.iter().any(|a| matches!(a, AggIntent::Max { .. }))); + } -// ── Tests: Approximate intents ──────────────────────────────────────────────── - -#[tokio::test] -async fn test_count_distinct_becomes_cardinality() { - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT COUNT(DISTINCT host) FROM metrics") - .await - .unwrap(); - - let (_, aggs) = find_aggregate(&result).unwrap(); - assert!(aggs - .iter() - .any(|a| matches!(a, AggIntent::Cardinality { .. }))); -} + #[tokio::test] + async fn test_avg_aggregate() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT AVG(value) FROM metrics") + .await + .unwrap(); + + let (_, aggs) = find_aggregate(&result).unwrap(); + assert!(aggs.iter().any(|a| matches!(a, AggIntent::Avg { .. }))); + } -#[tokio::test] -async fn test_approx_percentile_becomes_quantile() { - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new( - &catalog, - AccuracyTarget::EpsilonDelta { - epsilon: 0.01, - delta: 0.001, - }, - ); - let result = lowerer - .lower("SELECT approx_percentile_cont(value, 0.99) FROM metrics") - .await - .unwrap(); - - let (_, aggs) = find_aggregate(&result).unwrap(); - assert!(aggs - .iter() - .any(|a| matches!(a, AggIntent::Quantile { q, .. } if (*q - 0.99).abs() < 1e-12))); -} + #[tokio::test] + async fn test_stddev_sample() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT STDDEV(value) FROM metrics") + .await + .unwrap(); + + let (_, aggs) = find_aggregate(&result).unwrap(); + assert!(aggs.iter().any(|a| matches!( + a, + AggIntent::Stddev { + population: false, + .. + } + ))); + } -// ── Test: TopK heavy-hitter pattern ────────────────────────────────────────── - -#[tokio::test] -async fn test_order_by_desc_limit_becomes_topk() { - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower( - "SELECT host, COUNT(*) AS cnt FROM metrics \ - GROUP BY host ORDER BY cnt DESC LIMIT 10", - ) - .await - .unwrap(); - - let (_, aggs) = find_aggregate(&result).expect("expected Aggregate"); - assert_eq!(aggs.len(), 1, "TopK should produce exactly one AggIntent"); - let AggIntent::TopK { k, by, .. } = &aggs[0] else { - panic!("expected TopK, got {:?}", aggs[0]); - }; - assert_eq!(*k, 10); - assert_eq!(by.len(), 1, "TopK should have exactly 1 by-column"); - assert!(by.iter().any(|c| c.0 == "host")); -} + #[tokio::test] + async fn test_group_by_extracted() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT region, COUNT(*) FROM metrics GROUP BY region") + .await + .unwrap(); + + let (by, aggs) = find_aggregate(&result).unwrap(); + assert_eq!(by.len(), 1); + assert_eq!(by[0].0, "region"); + assert!(matches!(aggs[0], AggIntent::Count { .. })); + } -// ── Test: TopK + OFFSET returns error (#1) ─────────────────────────────────── - -#[tokio::test] -async fn test_order_by_desc_limit_with_offset_returns_error() { - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let err = lowerer - .lower( - "SELECT host, COUNT(*) FROM metrics \ - GROUP BY host ORDER BY 2 DESC LIMIT 10 OFFSET 5", - ) - .await - .unwrap_err(); - assert!( - matches!(err, LoweringError::UnsupportedFeature(ref msg) if msg.contains("OFFSET")), - "expected UnsupportedFeature(OFFSET), got: {err}" - ); + #[tokio::test] + async fn test_multiple_aggregates_in_one_node() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT COUNT(*), SUM(value), MIN(value), MAX(value) FROM metrics") + .await + .unwrap(); + + let (_, aggs) = find_aggregate(&result).unwrap(); + assert_eq!(aggs.len(), 4); + } + + // ── Tests: Approximate intents ──────────────────────────────────────────────── + + #[tokio::test] + async fn test_count_distinct_becomes_cardinality() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT COUNT(DISTINCT host) FROM metrics") + .await + .unwrap(); + + let (_, aggs) = find_aggregate(&result).unwrap(); + assert!(aggs + .iter() + .any(|a| matches!(a, AggIntent::Cardinality { .. }))); + } + + #[tokio::test] + async fn test_approx_percentile_becomes_quantile() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new( + &catalog, + AccuracyTarget::EpsilonDelta { + epsilon: 0.01, + delta: 0.001, + }, + ); + let result = lowerer + .lower("SELECT approx_percentile_cont(value, 0.99) FROM metrics") + .await + .unwrap(); + + let (_, aggs) = find_aggregate(&result).unwrap(); + assert!(aggs + .iter() + .any(|a| matches!(a, AggIntent::Quantile { q, .. } if (*q - 0.99).abs() < 1e-12))); + } + + // ── Test: TopK heavy-hitter pattern ────────────────────────────────────────── + + #[tokio::test] + async fn test_order_by_desc_limit_becomes_topk() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower( + "SELECT host, COUNT(*) AS cnt FROM metrics \ + GROUP BY host ORDER BY cnt DESC LIMIT 10", + ) + .await + .unwrap(); + + let (_, aggs) = find_aggregate(&result).expect("expected Aggregate"); + assert_eq!(aggs.len(), 1, "TopK should produce exactly one AggIntent"); + let AggIntent::TopK { k, by, .. } = &aggs[0] else { + panic!("expected TopK, got {:?}", aggs[0]); + }; + assert_eq!(*k, 10); + assert_eq!(by.len(), 1, "TopK should have exactly 1 by-column"); + assert!(by.iter().any(|c| c.0 == "host")); + } + + // ── Test: TopK + OFFSET returns error (#1) ─────────────────────────────────── + + #[tokio::test] + async fn test_order_by_desc_limit_with_offset_returns_error() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let err = lowerer + .lower( + "SELECT host, COUNT(*) FROM metrics \ + GROUP BY host ORDER BY 2 DESC LIMIT 10 OFFSET 5", + ) + .await + .unwrap_err(); + assert!( + matches!(err, LoweringError::UnsupportedFeature(ref msg) if msg.contains("OFFSET")), + "expected UnsupportedFeature(OFFSET), got: {err}" + ); + } } -// ── Test: Window functions ──────────────────────────────────────────────────── - -#[tokio::test] -async fn test_window_function_produces_window_func_node() { - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower( - "SELECT region, AVG(value) OVER (PARTITION BY region ORDER BY ts) \ - FROM metrics", - ) - .await - .unwrap(); - - fn has_window_func(e: &QueryExpr) -> bool { - match e { - QueryExpr::WindowFunc { .. } => true, - QueryExpr::Project { child, .. } => has_window_func(&child.expr), - _ => false, +mod window_and_errors { + use super::*; + + // ── Test: Window functions ──────────────────────────────────────────────────── + + #[tokio::test] + async fn test_window_function_produces_window_func_node() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower( + "SELECT region, AVG(value) OVER (PARTITION BY region ORDER BY ts) \ + FROM metrics", + ) + .await + .unwrap(); + + fn has_window_func(e: &QueryExpr) -> bool { + match e { + QueryExpr::WindowFunc { .. } => true, + QueryExpr::Project { child, .. } => has_window_func(&child.expr), + _ => false, + } } + assert!(has_window_func(&result), "expected a WindowFunc node"); } - assert!(has_window_func(&result), "expected a WindowFunc node"); -} -// ── Tests: Error cases ──────────────────────────────────────────────────────── - -#[tokio::test] -async fn test_join_returns_error() { - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let err = lowerer - .lower("SELECT a.ts FROM metrics a JOIN metrics b ON a.ts = b.ts") - .await - .unwrap_err(); - assert!( - matches!(err, LoweringError::UnsupportedFeature(ref msg) if msg.contains("JOIN")), - "unexpected error: {err}" - ); -} + // ── Tests: Error cases ──────────────────────────────────────────────────────── + + #[tokio::test] + async fn test_join_returns_error() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let err = lowerer + .lower("SELECT a.ts FROM metrics a JOIN metrics b ON a.ts = b.ts") + .await + .unwrap_err(); + assert!( + matches!(err, LoweringError::UnsupportedFeature(ref msg) if msg.contains("JOIN")), + "unexpected error: {err}" + ); + } -#[tokio::test] -async fn test_subquery_returns_error() { - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let err = lowerer - .lower("SELECT * FROM (SELECT value FROM metrics) sub") - .await - .unwrap_err(); - assert!( - matches!(err, LoweringError::UnsupportedFeature(ref msg) if msg.to_lowercase().contains("subquery")), - "unexpected error: {err}" - ); + #[tokio::test] + async fn test_subquery_returns_error() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let err = lowerer + .lower("SELECT * FROM (SELECT value FROM metrics) sub") + .await + .unwrap_err(); + assert!( + matches!(err, LoweringError::UnsupportedFeature(ref msg) if msg.to_lowercase().contains("subquery")), + "unexpected error: {err}" + ); + } } -// ── Tests: Predicate / ProjectItem / SortKey content ───────────────────────── - -#[tokio::test] -async fn test_filter_predicate_references_filtered_column() { - // WHERE value > 5.0 should produce a Predicate that references "value". - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT ts FROM metrics WHERE value > 5.0") - .await - .unwrap(); - - let pred = find_predicate(&result).expect("expected Filter predicate"); - let refs = pred.columns_referenced(); - assert!( - refs.iter().any(|r| r.0 == "value"), - "expected 'value' column ref in predicate" - ); -} +mod predicates_and_exprs { + use super::*; -#[tokio::test] -async fn test_filter_two_non_time_predicates_is_bool_and() { - // WHERE value > 0 AND region = 'us' — both non-time → BoolAnd with 2 conjuncts. - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT ts FROM metrics WHERE value > 0 AND region = 'us'") - .await - .unwrap(); - - let pred = find_predicate(&result).expect("expected Filter predicate"); - assert_eq!( - pred.conjuncts().len(), - 2, - "expected BoolAnd with 2 conjuncts, got: {pred:?}" - ); -} + // ── Tests: Predicate / ProjectItem / SortKey content ───────────────────────── -#[tokio::test] -async fn test_project_items_carry_column_refs() { - // SELECT id, value FROM events → two ProjectItems, each with a Column expr. - let catalog = no_time_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer.lower("SELECT id, value FROM events").await.unwrap(); - - let items = find_project_items(&result).expect("expected Project node"); - assert_eq!(items.len(), 2, "expected 2 ProjectItems"); - - let col_names: Vec<&str> = items - .iter() - .filter_map(|pi| { - if let L3Expr::Column(c) = &pi.expr { - Some(c.0.as_str()) - } else { - None - } - }) - .collect(); - assert!(col_names.contains(&"id"), "expected 'id' in project items"); - assert!( - col_names.contains(&"value"), - "expected 'value' in project items" - ); -} + #[tokio::test] + async fn test_filter_predicate_references_filtered_column() { + // WHERE value > 5.0 should produce a Predicate that references "value". + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT ts FROM metrics WHERE value > 5.0") + .await + .unwrap(); -#[tokio::test] -async fn test_sort_key_ascending_flag() { - // ORDER BY id ASC → SortKey with ascending = true. - let catalog = no_time_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT id FROM events ORDER BY id ASC") - .await - .unwrap(); - - let keys = find_sort_keys(&result).expect("expected Sort node"); - assert_eq!(keys.len(), 1); - assert!(keys[0].ascending, "expected ascending sort key"); -} + let pred = find_predicate(&result).expect("expected Filter predicate"); + let refs = pred.columns_referenced(); + assert!( + refs.iter().any(|r| r.0 == "value"), + "expected 'value' column ref in predicate" + ); + } -#[tokio::test] -async fn test_sort_key_descending_flag() { - // ORDER BY value DESC → SortKey with ascending = false. - let catalog = no_time_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT id FROM events ORDER BY value DESC") - .await - .unwrap(); - - let keys = find_sort_keys(&result).expect("expected Sort node"); - assert_eq!(keys.len(), 1); - assert!(!keys[0].ascending, "expected descending sort key"); -} + #[tokio::test] + async fn test_filter_two_non_time_predicates_is_bool_and() { + // WHERE value > 0 AND region = 'us' — both non-time → BoolAnd with 2 conjuncts. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT ts FROM metrics WHERE value > 0 AND region = 'us'") + .await + .unwrap(); + + let pred = find_predicate(&result).expect("expected Filter predicate"); + assert_eq!( + pred.conjuncts().len(), + 2, + "expected BoolAnd with 2 conjuncts, got: {pred:?}" + ); + } -// ── Tests: df_expr_to_l3 edge cases ────────────────────────────────────────── - -#[tokio::test] -async fn test_filter_is_null_predicate() { - // WHERE value IS NULL → IsNull(Column("value")) - let catalog = no_time_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT id FROM events WHERE value IS NULL") - .await - .unwrap(); - - let pred = find_predicate(&result).expect("expected Filter predicate"); - assert!( - matches!(pred, L3Expr::IsNull(inner) if matches!(inner.as_ref(), L3Expr::Column(c) if c.0 == "value")), - "expected IsNull(Column(\"value\")), got: {pred:?}" - ); -} + #[tokio::test] + async fn test_project_items_carry_column_refs() { + // SELECT id, value FROM events → two ProjectItems, each with a Column expr. + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer.lower("SELECT id, value FROM events").await.unwrap(); + + let items = find_project_items(&result).expect("expected Project node"); + assert_eq!(items.len(), 2, "expected 2 ProjectItems"); + + let col_names: Vec<&str> = items + .iter() + .filter_map(|pi| { + if let L3Expr::Column(c) = &pi.expr { + Some(c.0.as_str()) + } else { + None + } + }) + .collect(); + assert!(col_names.contains(&"id"), "expected 'id' in project items"); + assert!( + col_names.contains(&"value"), + "expected 'value' in project items" + ); + } -#[tokio::test] -async fn test_filter_in_list_predicate() { - // WHERE id IN (1, 2, 3) → InList { expr: Column("id"), list: [..], negated: false } - let catalog = no_time_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT id FROM events WHERE id IN (1, 2, 3)") - .await - .unwrap(); - - let pred = find_predicate(&result).expect("expected Filter predicate"); - let L3Expr::InList { - expr, - list, - negated, - } = pred - else { - panic!("expected InList, got: {pred:?}"); - }; - assert!(matches!(expr.as_ref(), L3Expr::Column(c) if c.0 == "id")); - assert_eq!(list.len(), 3); - assert!(!negated); -} + #[tokio::test] + async fn test_sort_key_ascending_flag() { + // ORDER BY id ASC → SortKey with ascending = true. + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT id FROM events ORDER BY id ASC") + .await + .unwrap(); + + let keys = find_sort_keys(&result).expect("expected Sort node"); + assert_eq!(keys.len(), 1); + assert!(keys[0].ascending, "expected ascending sort key"); + } -#[tokio::test] -async fn test_filter_between_normalizes_to_bool_and() { - // WHERE value BETWEEN 0 AND 100 → BoolAnd([Compare(Ge, 0), Compare(Le, 100)]) - let catalog = no_time_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT id FROM events WHERE value BETWEEN 0.0 AND 100.0") - .await - .unwrap(); - - let pred = find_predicate(&result).expect("expected Filter predicate"); - let conjuncts = pred.conjuncts(); - assert_eq!( - conjuncts.len(), - 2, - "BETWEEN should produce 2 conjuncts, got: {pred:?}" - ); - // First conjunct: value >= 0, second: value <= 100 - assert!(matches!( - &conjuncts[0], - L3Expr::Compare { - op: CompareOp::Ge, - .. - } - )); - assert!(matches!( - &conjuncts[1], - L3Expr::Compare { - op: CompareOp::Le, - .. - } - )); -} + #[tokio::test] + async fn test_sort_key_descending_flag() { + // ORDER BY value DESC → SortKey with ascending = false. + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT id FROM events ORDER BY value DESC") + .await + .unwrap(); + + let keys = find_sort_keys(&result).expect("expected Sort node"); + assert_eq!(keys.len(), 1); + assert!(!keys[0].ascending, "expected descending sort key"); + } -// ── Tests: lower_batch ──────────────────────────────────────────────────────── + // ── Tests: df_expr_to_l3 edge cases ────────────────────────────────────────── + + #[tokio::test] + async fn test_filter_is_null_predicate() { + // WHERE value IS NULL → IsNull(Column("value")) + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT id FROM events WHERE value IS NULL") + .await + .unwrap(); + + let pred = find_predicate(&result).expect("expected Filter predicate"); + assert!( + matches!(pred, L3Expr::IsNull(inner) if matches!(inner.as_ref(), L3Expr::Column(c) if c.0 == "value")), + "expected IsNull(Column(\"value\")), got: {pred:?}" + ); + } -fn make_workload(queries: Vec<&str>) -> QueryWorkload { - QueryWorkload { - language: QueryLanguage::SQL(SqlDialect::DataFusionSQL), - query_batch: Some( - queries - .into_iter() - .map(|q| BatchEntry { - query: Query(q.to_string()), - requirements: None, - }) - .collect(), - ), - repeating_queries: None, - data_characteristics: None, + #[tokio::test] + async fn test_filter_in_list_predicate() { + // WHERE id IN (1, 2, 3) → InList { expr: Column("id"), list: [..], negated: false } + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT id FROM events WHERE id IN (1, 2, 3)") + .await + .unwrap(); + + let pred = find_predicate(&result).expect("expected Filter predicate"); + let L3Expr::InList { + expr, + list, + negated, + } = pred + else { + panic!("expected InList, got: {pred:?}"); + }; + assert!(matches!(expr.as_ref(), L3Expr::Column(c) if c.0 == "id")); + assert_eq!(list.len(), 3); + assert!(!negated); } -} -#[tokio::test] -async fn test_lower_batch_empty_returns_empty_vec() { - let catalog = metrics_catalog(); - let workload = QueryWorkload { - language: QueryLanguage::SQL(SqlDialect::DataFusionSQL), - query_batch: None, - repeating_queries: None, - data_characteristics: None, - }; - let results = lower_batch(&workload, &catalog).await; - assert!(results.is_empty()); + #[tokio::test] + async fn test_filter_between_normalizes_to_bool_and() { + // WHERE value BETWEEN 0 AND 100 → BoolAnd([Compare(Ge, 0), Compare(Le, 100)]) + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT id FROM events WHERE value BETWEEN 0.0 AND 100.0") + .await + .unwrap(); + + let pred = find_predicate(&result).expect("expected Filter predicate"); + let conjuncts = pred.conjuncts(); + assert_eq!( + conjuncts.len(), + 2, + "BETWEEN should produce 2 conjuncts, got: {pred:?}" + ); + // First conjunct: value >= 0, second: value <= 100 + assert!(matches!( + &conjuncts[0], + L3Expr::Compare { + op: CompareOp::Ge, + .. + } + )); + assert!(matches!( + &conjuncts[1], + L3Expr::Compare { + op: CompareOp::Le, + .. + } + )); + } } -#[tokio::test] -async fn test_lower_batch_two_valid_queries() { - let catalog = metrics_catalog(); - let workload = make_workload(vec![ - "SELECT COUNT(*) FROM metrics", - "SELECT SUM(value) FROM metrics", - ]); - let results = lower_batch(&workload, &catalog).await; - assert_eq!(results.len(), 2); - assert!(results[0].is_ok(), "first query should succeed"); - assert!(results[1].is_ok(), "second query should succeed"); -} +mod batch_and_catalog { + use super::*; -#[tokio::test] -async fn test_lower_batch_error_is_per_query() { - // A bad query in the batch should not prevent the good ones from being lowered. - let catalog = metrics_catalog(); - let workload = make_workload(vec!["SELECT COUNT(*) FROM metrics", "NOT VALID SQL !!!"]); - let results = lower_batch(&workload, &catalog).await; - assert_eq!(results.len(), 2); - assert!(results[0].is_ok(), "first (valid) query should succeed"); - assert!(results[1].is_err(), "second (invalid) query should fail"); -} + // ── Tests: lower_batch ──────────────────────────────────────────────────────── -#[tokio::test] -async fn test_unknown_table_returns_error() { - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let err = lowerer - .lower("SELECT x FROM ghost_table") - .await - .unwrap_err(); - // DataFusion rejects the unknown table at plan time before our lowerer runs. - assert!( - matches!(err, LoweringError::DataFusion(_)), - "unexpected error variant: {err}" - ); -} + #[tokio::test] + async fn test_lower_batch_empty_returns_empty_vec() { + let catalog = metrics_catalog(); + let workload = QueryWorkload { + language: QueryLanguage::SQL(SqlDialect::DataFusionSQL), + query_batch: None, + repeating_queries: None, + data_characteristics: None, + }; + let results = lower_batch(&workload, &catalog).await; + assert!(results.is_empty()); + } -// ── Tests: per-table catalog validation (#9) ────────────────────────────────── + #[tokio::test] + async fn test_lower_batch_two_valid_queries() { + let catalog = metrics_catalog(); + let workload = make_workload(vec![ + "SELECT COUNT(*) FROM metrics", + "SELECT SUM(value) FROM metrics", + ]); + let results = lower_batch(&workload, &catalog).await; + assert_eq!(results.len(), 2); + assert!(results[0].is_ok(), "first query should succeed"); + assert!(results[1].is_ok(), "second query should succeed"); + } -#[tokio::test] -async fn test_bad_time_column_fails_query_touching_that_table() { - let mut tables = HashMap::new(); - // Valid table. - tables.insert( - "metrics".to_string(), - TableSchema { - columns: vec![ColumnDef { - name: "value".to_string(), - data_type: L3DataType::Float64, - nullable: true, - }], - time_column: None, - }, - ); - // Table whose time_column doesn't exist in its columns list. - tables.insert( - "broken".to_string(), - TableSchema { - columns: vec![ColumnDef { - name: "id".to_string(), - data_type: L3DataType::Int64, - nullable: false, - }], - time_column: Some("nonexistent".to_string()), - }, - ); - let catalog = SchemaCatalog { tables }; - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - - // Query on the valid table must succeed. - let result = lowerer.lower("SELECT value FROM metrics").await; - assert!( - result.is_ok(), - "query on valid table should succeed: {result:?}" - ); + #[tokio::test] + async fn test_lower_batch_error_is_per_query() { + // A bad query in the batch should not prevent the good ones from being lowered. + let catalog = metrics_catalog(); + let workload = make_workload(vec!["SELECT COUNT(*) FROM metrics", "NOT VALID SQL !!!"]); + let results = lower_batch(&workload, &catalog).await; + assert_eq!(results.len(), 2); + assert!(results[0].is_ok(), "first (valid) query should succeed"); + assert!(results[1].is_err(), "second (invalid) query should fail"); + } - // Query on the broken table must fail with InvalidExpression. - let err = lowerer - .lower("SELECT id FROM broken WHERE id > 0") - .await - .unwrap_err(); - assert!( - matches!(err, LoweringError::InvalidExpression(_)), - "expected InvalidExpression for bad time_column, got: {err}" - ); -} + #[tokio::test] + async fn test_unknown_table_returns_error() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let err = lowerer + .lower("SELECT x FROM ghost_table") + .await + .unwrap_err(); + // DataFusion rejects the unknown table at plan time before our lowerer runs. + assert!( + matches!(err, LoweringError::DataFusion(_)), + "unexpected error variant: {err}" + ); + } -// ── Tests: language guard ───────────────────────────────────────────────────── - -#[tokio::test] -async fn test_promql_workload_returns_wrong_language() { - let catalog = metrics_catalog(); - let workload = QueryWorkload { - language: QueryLanguage::PromQL, - query_batch: Some(vec![BatchEntry { - query: Query("some_metric".into()), - requirements: None, - }]), - repeating_queries: None, - data_characteristics: None, - }; - let results = lower_batch(&workload, &catalog).await; - assert_eq!(results.len(), 1); - assert!( - matches!(results[0], Err(LoweringError::WrongLanguage(_))), - "expected WrongLanguage, got: {:?}", - results[0] - ); -} + // ── Tests: per-table catalog validation (#9) ────────────────────────────────── -#[tokio::test] -async fn test_elastic_dsl_workload_returns_wrong_language() { - let catalog = metrics_catalog(); - let workload = QueryWorkload { - language: QueryLanguage::ElasticDSL, - query_batch: Some(vec![BatchEntry { - query: Query("{\"query\":{}}".into()), - requirements: None, - }]), - repeating_queries: None, - data_characteristics: None, - }; - let results = lower_batch(&workload, &catalog).await; - assert_eq!(results.len(), 1); - assert!(matches!(results[0], Err(LoweringError::WrongLanguage(_)))); -} + #[tokio::test] + async fn test_bad_time_column_fails_query_touching_that_table() { + let mut tables = HashMap::new(); + // Valid table. + tables.insert( + "metrics".to_string(), + TableSchema { + columns: vec![ColumnDef { + name: "value".to_string(), + data_type: L3DataType::Float64, + nullable: true, + }], + time_column: None, + }, + ); + // Table whose time_column doesn't exist in its columns list. + tables.insert( + "broken".to_string(), + TableSchema { + columns: vec![ColumnDef { + name: "id".to_string(), + data_type: L3DataType::Int64, + nullable: false, + }], + time_column: Some("nonexistent".to_string()), + }, + ); + let catalog = SchemaCatalog { tables }; + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + + // Query on the valid table must succeed. + let result = lowerer.lower("SELECT value FROM metrics").await; + assert!( + result.is_ok(), + "query on valid table should succeed: {result:?}" + ); + + // Query on the broken table must fail with InvalidExpression. + let err = lowerer + .lower("SELECT id FROM broken WHERE id > 0") + .await + .unwrap_err(); + assert!( + matches!(err, LoweringError::InvalidExpression(_)), + "expected InvalidExpression for bad time_column, got: {err}" + ); + } -#[tokio::test] -async fn test_datafusion_language_accepted() { - let catalog = metrics_catalog(); - let workload = QueryWorkload { - language: QueryLanguage::DataFusion, - query_batch: Some(vec![BatchEntry { - query: Query("SELECT COUNT(*) FROM metrics".into()), - requirements: None, - }]), - repeating_queries: None, - data_characteristics: None, - }; - let results = lower_batch(&workload, &catalog).await; - assert_eq!(results.len(), 1); - assert!(results[0].is_ok(), "DataFusion language should be accepted"); -} + // ── Tests: language guard ───────────────────────────────────────────────────── + + #[tokio::test] + async fn test_promql_workload_returns_wrong_language() { + let catalog = metrics_catalog(); + let workload = QueryWorkload { + language: QueryLanguage::PromQL, + query_batch: Some(vec![BatchEntry { + query: Query("some_metric".into()), + requirements: None, + }]), + repeating_queries: None, + data_characteristics: None, + }; + let results = lower_batch(&workload, &catalog).await; + assert_eq!(results.len(), 1); + assert!( + matches!(results[0], Err(LoweringError::WrongLanguage(_))), + "expected WrongLanguage, got: {:?}", + results[0] + ); + } -// ── Tests: BETWEEN time extraction ─────────────────────────────────────────── - -#[tokio::test] -async fn test_time_between_extracted_to_source() { - // WHERE ts BETWEEN 1000 AND 2000 should set start_ms=1000, end_ms=2000. - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT value FROM metrics WHERE ts BETWEEN 1000 AND 2000") - .await - .unwrap(); - - let source = find_source(&result).unwrap(); - let Source::Table { time_range, .. } = source else { - panic!("expected Source::Table"); - }; - let tr = time_range - .as_ref() - .expect("expected time_range from BETWEEN"); - assert_eq!(tr.start_ms, Some(1000)); - assert_eq!(tr.end_ms, Some(2000)); + #[tokio::test] + async fn test_elastic_dsl_workload_returns_wrong_language() { + let catalog = metrics_catalog(); + let workload = QueryWorkload { + language: QueryLanguage::ElasticDSL, + query_batch: Some(vec![BatchEntry { + query: Query("{\"query\":{}}".into()), + requirements: None, + }]), + repeating_queries: None, + data_characteristics: None, + }; + let results = lower_batch(&workload, &catalog).await; + assert_eq!(results.len(), 1); + assert!(matches!(results[0], Err(LoweringError::WrongLanguage(_)))); + } + + #[tokio::test] + async fn test_datafusion_language_accepted() { + let catalog = metrics_catalog(); + let workload = QueryWorkload { + language: QueryLanguage::DataFusion, + query_batch: Some(vec![BatchEntry { + query: Query("SELECT COUNT(*) FROM metrics".into()), + requirements: None, + }]), + repeating_queries: None, + data_characteristics: None, + }; + let results = lower_batch(&workload, &catalog).await; + assert_eq!(results.len(), 1); + assert!(results[0].is_ok(), "DataFusion language should be accepted"); + } } -#[tokio::test] -async fn test_time_between_leaves_no_filter_wrapper() { - // A BETWEEN-only predicate on the time column has no non-time residual, - // so no Filter node should appear. - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT value FROM metrics WHERE ts BETWEEN 1000 AND 2000") - .await - .unwrap(); - - fn has_filter(e: &QueryExpr) -> bool { - match e { - QueryExpr::Filter { .. } => true, - QueryExpr::Project { child, .. } => has_filter(&child.expr), - _ => false, +mod time_and_dialects { + use super::*; + + // ── Tests: BETWEEN time extraction ─────────────────────────────────────────── + + #[tokio::test] + async fn test_time_between_extracted_to_source() { + // WHERE ts BETWEEN 1000 AND 2000 should set start_ms=1000, end_ms=2000. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT value FROM metrics WHERE ts BETWEEN 1000 AND 2000") + .await + .unwrap(); + + let source = find_source(&result).unwrap(); + let Source::Table { time_range, .. } = source else { + panic!("expected Source::Table"); + }; + let tr = time_range + .as_ref() + .expect("expected time_range from BETWEEN"); + assert_eq!(tr.start_ms, Some(1000)); + assert_eq!(tr.end_ms, Some(2000)); + } + + #[tokio::test] + async fn test_time_between_leaves_no_filter_wrapper() { + // A BETWEEN-only predicate on the time column has no non-time residual, + // so no Filter node should appear. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT value FROM metrics WHERE ts BETWEEN 1000 AND 2000") + .await + .unwrap(); + + fn has_filter(e: &QueryExpr) -> bool { + match e { + QueryExpr::Filter { .. } => true, + QueryExpr::Project { child, .. } => has_filter(&child.expr), + _ => false, + } } + assert!( + !has_filter(&result), + "BETWEEN on time col should leave no Filter wrapper" + ); } - assert!( - !has_filter(&result), - "BETWEEN on time col should leave no Filter wrapper" - ); -} -#[tokio::test] -async fn test_time_between_mixed_with_non_time_predicate() { - // WHERE ts BETWEEN 1000 AND 2000 AND region = 'us': - // time range should be extracted; Filter stays for the non-time conjunct. - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT value FROM metrics WHERE ts BETWEEN 1000 AND 2000 AND region = 'us'") - .await - .unwrap(); - - let source = find_source(&result).unwrap(); - let Source::Table { time_range, .. } = source else { - panic!("expected Source::Table"); - }; - let tr = time_range.as_ref().expect("expected time_range"); - assert_eq!(tr.start_ms, Some(1000)); - assert_eq!(tr.end_ms, Some(2000)); - - fn has_filter(e: &QueryExpr) -> bool { - match e { - QueryExpr::Filter { .. } => true, - QueryExpr::Project { child, .. } => has_filter(&child.expr), - _ => false, + #[tokio::test] + async fn test_time_between_mixed_with_non_time_predicate() { + // WHERE ts BETWEEN 1000 AND 2000 AND region = 'us': + // time range should be extracted; Filter stays for the non-time conjunct. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT value FROM metrics WHERE ts BETWEEN 1000 AND 2000 AND region = 'us'") + .await + .unwrap(); + + let source = find_source(&result).unwrap(); + let Source::Table { time_range, .. } = source else { + panic!("expected Source::Table"); + }; + let tr = time_range.as_ref().expect("expected time_range"); + assert_eq!(tr.start_ms, Some(1000)); + assert_eq!(tr.end_ms, Some(2000)); + + fn has_filter(e: &QueryExpr) -> bool { + match e { + QueryExpr::Filter { .. } => true, + QueryExpr::Project { child, .. } => has_filter(&child.expr), + _ => false, + } } + assert!( + has_filter(&result), + "expected Filter for non-time predicate" + ); } - assert!( - has_filter(&result), - "expected Filter for non-time predicate" - ); -} -// ── Tests: multi-dialect SQL ────────────────────────────────────────────────── - -#[tokio::test] -async fn test_clickhouse_dialect_returns_unsupported_dialect() { - let catalog = metrics_catalog(); - let workload = QueryWorkload { - language: QueryLanguage::SQL(SqlDialect::ClickhouseSQL), - query_batch: Some(vec![BatchEntry { - query: Query("SELECT COUNT(*) FROM metrics".into()), - requirements: None, - }]), - repeating_queries: None, - data_characteristics: None, - }; - let results = lower_batch(&workload, &catalog).await; - assert_eq!(results.len(), 1); - assert!( - matches!(results[0], Err(LoweringError::UnsupportedDialect(_))), - "expected UnsupportedDialect, got: {:?}", - results[0] - ); -} + // ── Tests: multi-dialect SQL ────────────────────────────────────────────────── + + #[tokio::test] + async fn test_clickhouse_dialect_returns_unsupported_dialect() { + let catalog = metrics_catalog(); + let workload = QueryWorkload { + language: QueryLanguage::SQL(SqlDialect::ClickhouseSQL), + query_batch: Some(vec![BatchEntry { + query: Query("SELECT COUNT(*) FROM metrics".into()), + requirements: None, + }]), + repeating_queries: None, + data_characteristics: None, + }; + let results = lower_batch(&workload, &catalog).await; + assert_eq!(results.len(), 1); + assert!( + matches!(results[0], Err(LoweringError::UnsupportedDialect(_))), + "expected UnsupportedDialect, got: {:?}", + results[0] + ); + } -#[tokio::test] -async fn test_elastic_sql_dialect_returns_unsupported_dialect() { - let catalog = metrics_catalog(); - let workload = QueryWorkload { - language: QueryLanguage::SQL(SqlDialect::ElasticSQL), - query_batch: Some(vec![BatchEntry { - query: Query("{\"query\":{}}".into()), - requirements: None, - }]), - repeating_queries: None, - data_characteristics: None, - }; - let results = lower_batch(&workload, &catalog).await; - assert_eq!(results.len(), 1); - assert!( - matches!(results[0], Err(LoweringError::UnsupportedDialect(_))), - "expected UnsupportedDialect, got: {:?}", - results[0] - ); -} + #[tokio::test] + async fn test_elastic_sql_dialect_returns_unsupported_dialect() { + let catalog = metrics_catalog(); + let workload = QueryWorkload { + language: QueryLanguage::SQL(SqlDialect::ElasticSQL), + query_batch: Some(vec![BatchEntry { + query: Query("{\"query\":{}}".into()), + requirements: None, + }]), + repeating_queries: None, + data_characteristics: None, + }; + let results = lower_batch(&workload, &catalog).await; + assert_eq!(results.len(), 1); + assert!( + matches!(results[0], Err(LoweringError::UnsupportedDialect(_))), + "expected UnsupportedDialect, got: {:?}", + results[0] + ); + } -#[tokio::test] -async fn test_datafusion_sql_dialect_accepted() { - let catalog = metrics_catalog(); - let workload = QueryWorkload { - language: QueryLanguage::SQL(SqlDialect::DataFusionSQL), - query_batch: Some(vec![BatchEntry { - query: Query("SELECT COUNT(*) FROM metrics".into()), - requirements: None, - }]), - repeating_queries: None, - data_characteristics: None, - }; - let results = lower_batch(&workload, &catalog).await; - assert_eq!(results.len(), 1); - assert!( - results[0].is_ok(), - "SQL(DataFusionSQL) should be accepted, got: {:?}", - results[0] - ); + #[tokio::test] + async fn test_datafusion_sql_dialect_accepted() { + let catalog = metrics_catalog(); + let workload = QueryWorkload { + language: QueryLanguage::SQL(SqlDialect::DataFusionSQL), + query_batch: Some(vec![BatchEntry { + query: Query("SELECT COUNT(*) FROM metrics".into()), + requirements: None, + }]), + repeating_queries: None, + data_characteristics: None, + }; + let results = lower_batch(&workload, &catalog).await; + assert_eq!(results.len(), 1); + assert!( + results[0].is_ok(), + "SQL(DataFusionSQL) should be accepted, got: {:?}", + results[0] + ); + } } -// ── Tests: Source::Table.columns ───────────────────────────────────────────── - -#[tokio::test] -async fn test_scan_columns_populated_from_projection() { - // SELECT ts, value FROM metrics — DataFusion should push the projection - // into the TableScan; Source::Table.columns should name the two columns. - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT ts, value FROM metrics") - .await - .unwrap(); - - let source = find_source(&result).expect("expected a Scan"); - let Source::Table { columns, .. } = source else { - panic!("expected Source::Table"); - }; - // At minimum, each named column should appear in the list. - let names: Vec<&str> = columns.iter().map(|c| c.0.as_str()).collect(); - assert!( - names.contains(&"ts"), - "expected 'ts' in columns, got: {names:?}" - ); - assert!( - names.contains(&"value"), - "expected 'value' in columns, got: {names:?}" - ); -} +mod column_and_set_ops { + use super::*; + + // ── Tests: Source::Table.columns ───────────────────────────────────────────── + + #[tokio::test] + async fn test_scan_columns_populated_from_projection() { + // SELECT ts, value FROM metrics — DataFusion should push the projection + // into the TableScan; Source::Table.columns should name the two columns. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT ts, value FROM metrics") + .await + .unwrap(); + + let source = find_source(&result).expect("expected a Scan"); + let Source::Table { columns, .. } = source else { + panic!("expected Source::Table"); + }; + // At minimum, each named column should appear in the list. + let names: Vec<&str> = columns.iter().map(|c| c.0.as_str()).collect(); + assert!( + names.contains(&"ts"), + "expected 'ts' in columns, got: {names:?}" + ); + assert!( + names.contains(&"value"), + "expected 'value' in columns, got: {names:?}" + ); + } -#[tokio::test] -async fn test_scan_columns_empty_for_select_star() { - // SELECT * — no projection pushdown; Source::Table.columns stays empty - // (meaning "all columns"; cost estimator treats empty as unconstrained). - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer.lower("SELECT * FROM metrics").await.unwrap(); - - let source = find_source(&result).expect("expected a Scan"); - let Source::Table { columns, .. } = source else { - panic!("expected Source::Table"); - }; - assert!( - columns.is_empty(), - "SELECT * should produce no column constraints, got: {columns:?}" - ); -} + #[tokio::test] + async fn test_scan_columns_empty_for_select_star() { + // SELECT * — no projection pushdown; Source::Table.columns stays empty + // (meaning "all columns"; cost estimator treats empty as unconstrained). + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer.lower("SELECT * FROM metrics").await.unwrap(); + + let source = find_source(&result).expect("expected a Scan"); + let Source::Table { columns, .. } = source else { + panic!("expected Source::Table"); + }; + assert!( + columns.is_empty(), + "SELECT * should produce no column constraints, got: {columns:?}" + ); + } -// ── Tests: push_columns_into_scan gap for aggregate topology (#4) ──────────── - -#[tokio::test] -async fn test_scan_columns_empty_for_aggregate_topology() { - // push_columns_into_scan only rewrites a Scan that is the direct child of - // a Project. When there is an Aggregate between Project and Scan (the normal - // GROUP BY shape), column pushdown is skipped and Scan.columns stays empty - // ("all columns" semantics). This test documents the known gap so that any - // future fix must also update this assertion. - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT region, COUNT(*) FROM metrics GROUP BY region") - .await - .unwrap(); - - let source = find_source(&result).expect("expected a Scan node"); - let Source::Table { columns, .. } = source else { - panic!("expected Source::Table"); - }; - assert!( - columns.is_empty(), - "Project→Aggregate→Scan: Scan.columns should be empty (all-columns semantics), got: {columns:?}" - ); -} + // ── Tests: push_columns_into_scan gap for aggregate topology (#4) ──────────── + + #[tokio::test] + async fn test_scan_columns_empty_for_aggregate_topology() { + // push_columns_into_scan only rewrites a Scan that is the direct child of + // a Project. When there is an Aggregate between Project and Scan (the normal + // GROUP BY shape), column pushdown is skipped and Scan.columns stays empty + // ("all columns" semantics). This test documents the known gap so that any + // future fix must also update this assertion. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT region, COUNT(*) FROM metrics GROUP BY region") + .await + .unwrap(); + + let source = find_source(&result).expect("expected a Scan node"); + let Source::Table { columns, .. } = source else { + panic!("expected Source::Table"); + }; + assert!( + columns.is_empty(), + "Project→Aggregate→Scan: Scan.columns should be empty (all-columns semantics), got: {columns:?}" + ); + } -// ── Tests: UNION / UNION ALL ────────────────────────────────────────────────── - -#[tokio::test] -async fn test_union_all_produces_set_op_union_node() { - // UNION ALL — no dedup — maps to SetOp { kind: Union, all: true }. - let catalog = no_time_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT id FROM events UNION ALL SELECT id FROM events") - .await - .unwrap(); - - fn find_set_op(e: &QueryExpr) -> Option<(&SetOpKind, bool)> { - match e { - QueryExpr::SetOp { kind, all, .. } => Some((kind, *all)), - QueryExpr::Project { child, .. } => find_set_op(&child.expr), - _ => None, + // ── Tests: UNION / UNION ALL ────────────────────────────────────────────────── + + #[tokio::test] + async fn test_union_all_produces_set_op_union_node() { + // UNION ALL — no dedup — maps to SetOp { kind: Union, all: true }. + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT id FROM events UNION ALL SELECT id FROM events") + .await + .unwrap(); + + fn find_set_op(e: &QueryExpr) -> Option<(&SetOpKind, bool)> { + match e { + QueryExpr::SetOp { kind, all, .. } => Some((kind, *all)), + QueryExpr::Project { child, .. } => find_set_op(&child.expr), + _ => None, + } } + let (kind, all) = find_set_op(&result).expect("expected SetOp node"); + assert!(matches!(kind, SetOpKind::Union)); + assert!(all, "UNION ALL should set all=true"); } - let (kind, all) = find_set_op(&result).expect("expected SetOp node"); - assert!(matches!(kind, SetOpKind::Union)); - assert!(all, "UNION ALL should set all=true"); -} -#[tokio::test] -async fn test_union_distinct_produces_distinct_over_set_op() { - // UNION (without ALL) = UNION DISTINCT = Distinct wrapping SetOp. - let catalog = no_time_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT id FROM events UNION SELECT id FROM events") - .await - .unwrap(); - - fn has_distinct(e: &QueryExpr) -> bool { - match e { - QueryExpr::Distinct { .. } => true, - QueryExpr::Project { child, .. } => has_distinct(&child.expr), - _ => false, + #[tokio::test] + async fn test_union_distinct_produces_distinct_over_set_op() { + // UNION (without ALL) = UNION DISTINCT = Distinct wrapping SetOp. + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT id FROM events UNION SELECT id FROM events") + .await + .unwrap(); + + fn has_distinct(e: &QueryExpr) -> bool { + match e { + QueryExpr::Distinct { .. } => true, + QueryExpr::Project { child, .. } => has_distinct(&child.expr), + _ => false, + } } + assert!( + has_distinct(&result), + "UNION DISTINCT should produce a Distinct node" + ); } - assert!( - has_distinct(&result), - "UNION DISTINCT should produce a Distinct node" - ); } -// ── Tests: arithmetic / negative / LIKE / CASE in expressions ──────────────── - -#[tokio::test] -async fn test_arithmetic_in_projection_succeeds() { - // SELECT value * 2 FROM events — should not return UnsupportedFeature. - let catalog = no_time_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer.lower("SELECT value * 2.0 FROM events").await; - assert!( - result.is_ok(), - "arithmetic in projection should succeed, got: {result:?}" - ); -} +mod expressions { + use super::*; -#[tokio::test] -async fn test_arithmetic_in_predicate_succeeds() { - // WHERE value * 0.9 > 5.0 — should not return UnsupportedFeature. - let catalog = no_time_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT id FROM events WHERE value * 0.9 > 5.0") - .await; - assert!( - result.is_ok(), - "arithmetic in predicate should succeed, got: {result:?}" - ); -} + // ── Tests: arithmetic / negative / LIKE / CASE in expressions ──────────────── -#[tokio::test] -async fn test_arithmetic_predicate_references_column() { - // Predicate from `value * 0.9 > 5` should still reference "value". - let catalog = no_time_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT id FROM events WHERE value * 0.9 > 5.0") - .await - .unwrap(); - let pred = find_predicate(&result).expect("expected Filter predicate"); - let refs = pred.columns_referenced(); - assert!( - refs.iter().any(|r| r.0 == "value"), - "expected 'value' in predicate refs" - ); -} + #[tokio::test] + async fn test_arithmetic_in_projection_succeeds() { + // SELECT value * 2 FROM events — should not return UnsupportedFeature. + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer.lower("SELECT value * 2.0 FROM events").await; + assert!( + result.is_ok(), + "arithmetic in projection should succeed, got: {result:?}" + ); + } -#[tokio::test] -async fn test_negative_literal_in_predicate_succeeds() { - // WHERE value > -1 — unary minus should not return UnsupportedFeature. - let catalog = no_time_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT id FROM events WHERE value > -1.0") - .await; - assert!( - result.is_ok(), - "unary minus in predicate should succeed, got: {result:?}" - ); -} + #[tokio::test] + async fn test_arithmetic_in_predicate_succeeds() { + // WHERE value * 0.9 > 5.0 — should not return UnsupportedFeature. + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT id FROM events WHERE value * 0.9 > 5.0") + .await; + assert!( + result.is_ok(), + "arithmetic in predicate should succeed, got: {result:?}" + ); + } -#[tokio::test] -async fn test_like_predicate_produces_compare_like() { - // WHERE name LIKE 'a%' → Compare { op: Like, .. } - let catalog = no_time_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT id FROM events WHERE name LIKE 'a%'") - .await - .unwrap(); - let pred = find_predicate(&result).expect("expected Filter predicate"); - assert!( - matches!( - pred, - L3Expr::Compare { - op: CompareOp::Like, - .. - } - ), - "expected Compare(Like), got: {pred:?}" - ); -} + #[tokio::test] + async fn test_arithmetic_predicate_references_column() { + // Predicate from `value * 0.9 > 5` should still reference "value". + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT id FROM events WHERE value * 0.9 > 5.0") + .await + .unwrap(); + let pred = find_predicate(&result).expect("expected Filter predicate"); + let refs = pred.columns_referenced(); + assert!( + refs.iter().any(|r| r.0 == "value"), + "expected 'value' in predicate refs" + ); + } -#[tokio::test] -async fn test_ilike_predicate_produces_compare_ilike() { - // WHERE name ILIKE 'A%' → Compare { op: ILike, .. } - let catalog = no_time_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT id FROM events WHERE name ILIKE 'A%'") - .await - .unwrap(); - let pred = find_predicate(&result).expect("expected Filter predicate"); - assert!( - matches!( - pred, - L3Expr::Compare { - op: CompareOp::ILike, - .. - } - ), - "expected Compare(ILike), got: {pred:?}" - ); -} + #[tokio::test] + async fn test_negative_literal_in_predicate_succeeds() { + // WHERE value > -1 — unary minus should not return UnsupportedFeature. + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT id FROM events WHERE value > -1.0") + .await; + assert!( + result.is_ok(), + "unary minus in predicate should succeed, got: {result:?}" + ); + } -#[tokio::test] -async fn test_case_in_projection_succeeds() { - // CASE WHEN value > 5 THEN 1 ELSE 0 END — should not return UnsupportedFeature. - let catalog = no_time_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT CASE WHEN value > 5.0 THEN 1 ELSE 0 END AS tier FROM events") - .await; - assert!( - result.is_ok(), - "CASE WHEN in projection should succeed, got: {result:?}" - ); -} + #[tokio::test] + async fn test_like_predicate_produces_compare_like() { + // WHERE name LIKE 'a%' → Compare { op: Like, .. } + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT id FROM events WHERE name LIKE 'a%'") + .await + .unwrap(); + let pred = find_predicate(&result).expect("expected Filter predicate"); + assert!( + matches!( + pred, + L3Expr::Compare { + op: CompareOp::Like, + .. + } + ), + "expected Compare(Like), got: {pred:?}" + ); + } -#[tokio::test] -async fn test_case_projection_item_is_case_expr() { - let catalog = no_time_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT CASE WHEN value > 5.0 THEN 1 ELSE 0 END AS tier FROM events") - .await - .unwrap(); - let items = find_project_items(&result).expect("expected Project node"); - let has_case = items - .iter() - .any(|pi| matches!(pi.expr, L3Expr::Case { .. })); - assert!( - has_case, - "expected a Case expr in project items, got: {items:?}" - ); + #[tokio::test] + async fn test_ilike_predicate_produces_compare_ilike() { + // WHERE name ILIKE 'A%' → Compare { op: ILike, .. } + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT id FROM events WHERE name ILIKE 'A%'") + .await + .unwrap(); + let pred = find_predicate(&result).expect("expected Filter predicate"); + assert!( + matches!( + pred, + L3Expr::Compare { + op: CompareOp::ILike, + .. + } + ), + "expected Compare(ILike), got: {pred:?}" + ); + } + + #[tokio::test] + async fn test_case_in_projection_succeeds() { + // CASE WHEN value > 5 THEN 1 ELSE 0 END — should not return UnsupportedFeature. + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT CASE WHEN value > 5.0 THEN 1 ELSE 0 END AS tier FROM events") + .await; + assert!( + result.is_ok(), + "CASE WHEN in projection should succeed, got: {result:?}" + ); + } + + #[tokio::test] + async fn test_case_projection_item_is_case_expr() { + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT CASE WHEN value > 5.0 THEN 1 ELSE 0 END AS tier FROM events") + .await + .unwrap(); + let items = find_project_items(&result).expect("expected Project node"); + let has_case = items + .iter() + .any(|pi| matches!(pi.expr, L3Expr::Case { .. })); + assert!( + has_case, + "expected a Case expr in project items, got: {items:?}" + ); + } } -// ── Tests: populate_schemas ─────────────────────────────────────────────────── +mod schemas { + use super::*; -#[tokio::test] -async fn test_populate_schemas_scan_gets_catalog_schema() { - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let expr = lowerer.lower("SELECT * FROM metrics").await.unwrap(); + // ── Tests: populate_schemas ─────────────────────────────────────────────────── - let typed = populate_schemas(expr, &catalog); - assert_eq!( - typed.schema.fields.len(), - 4, - "scan schema should have 4 fields from catalog" - ); - assert_eq!(typed.schema.fields[0].name, "ts"); - assert_eq!(typed.schema.time_index, Some(0)); -} + #[tokio::test] + async fn test_populate_schemas_scan_gets_catalog_schema() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let expr = lowerer.lower("SELECT * FROM metrics").await.unwrap(); -#[tokio::test] -async fn test_populate_schemas_project_gives_subset_schema() { - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let expr = lowerer - .lower("SELECT ts, value FROM metrics") - .await - .unwrap(); - - let typed = populate_schemas(expr, &catalog); - assert_eq!(typed.schema.fields.len(), 2); - assert_eq!(typed.schema.fields[0].name, "ts"); - assert_eq!(typed.schema.fields[1].name, "value"); - assert_eq!(typed.schema.time_index, Some(0)); -} + let typed = populate_schemas(expr, &catalog); + assert_eq!( + typed.schema.fields.len(), + 4, + "scan schema should have 4 fields from catalog" + ); + assert_eq!(typed.schema.fields[0].name, "ts"); + assert_eq!(typed.schema.time_index, Some(0)); + } -#[tokio::test] -async fn test_populate_schemas_inner_nodes_are_typed() { - // The child of the root Project (a Scan) should also have a non-empty schema. - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let expr = lowerer - .lower("SELECT ts, value FROM metrics") - .await - .unwrap(); - - let typed = populate_schemas(expr, &catalog); - let QueryExpr::Project { child, .. } = &typed.expr else { - panic!("expected Project at root"); - }; - assert!( - !child.schema.fields.is_empty(), - "child Scan should have a populated schema after populate_schemas" - ); -} + #[tokio::test] + async fn test_populate_schemas_project_gives_subset_schema() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let expr = lowerer + .lower("SELECT ts, value FROM metrics") + .await + .unwrap(); + + let typed = populate_schemas(expr, &catalog); + assert_eq!(typed.schema.fields.len(), 2); + assert_eq!(typed.schema.fields[0].name, "ts"); + assert_eq!(typed.schema.fields[1].name, "value"); + assert_eq!(typed.schema.time_index, Some(0)); + } -#[tokio::test] -async fn test_populate_schemas_aggregate() { - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let expr = lowerer - .lower("SELECT region, COUNT(*) FROM metrics GROUP BY region") - .await - .unwrap(); - - let typed = populate_schemas(expr, &catalog); - // Root is Project wrapping Aggregate; two fields: region + count col. - assert_eq!(typed.schema.fields.len(), 2); - assert_eq!(typed.schema.fields[0].name, "region"); -} + #[tokio::test] + async fn test_populate_schemas_inner_nodes_are_typed() { + // The child of the root Project (a Scan) should also have a non-empty schema. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let expr = lowerer + .lower("SELECT ts, value FROM metrics") + .await + .unwrap(); + + let typed = populate_schemas(expr, &catalog); + let QueryExpr::Project { child, .. } = &typed.expr else { + panic!("expected Project at root"); + }; + assert!( + !child.schema.fields.is_empty(), + "child Scan should have a populated schema after populate_schemas" + ); + } -// ── Tests: AggIntent column-type propagation ────────────────────────────────── - -#[tokio::test] -async fn test_min_int_col_gives_int64_in_aggregate_schema() { - // MIN(ts) where ts: Int64 → the root schema (through the outer Projection) - // should resolve the aggregate output type as Int64, not Float64. - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let expr = lowerer.lower("SELECT MIN(ts) FROM metrics").await.unwrap(); - let typed = populate_schemas(expr, &catalog); - - let dtype = typed - .schema - .fields - .last() - .map(|f| f.dtype.clone()) - .expect("root schema should have at least one field"); - assert_eq!( - dtype, - L3DataType::Int64, - "MIN(ts: Int64) should propagate Int64 through to root schema, got {:?}", - dtype - ); -} + #[tokio::test] + async fn test_populate_schemas_aggregate() { + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let expr = lowerer + .lower("SELECT region, COUNT(*) FROM metrics GROUP BY region") + .await + .unwrap(); + + let typed = populate_schemas(expr, &catalog); + // Root is Project wrapping Aggregate; two fields: region + count col. + assert_eq!(typed.schema.fields.len(), 2); + assert_eq!(typed.schema.fields[0].name, "region"); + } -#[tokio::test] -async fn test_max_utf8_col_gives_utf8_in_aggregate_schema() { - // MAX(region) where region: Utf8 → root schema should carry Utf8. - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let expr = lowerer - .lower("SELECT MAX(region) FROM metrics") - .await - .unwrap(); - let typed = populate_schemas(expr, &catalog); - - let dtype = typed - .schema - .fields - .last() - .map(|f| f.dtype.clone()) - .expect("root schema should have at least one field"); - assert_eq!( - dtype, - L3DataType::Utf8, - "MAX(region: Utf8) should propagate Utf8 through to root schema, got {:?}", - dtype - ); -} + // ── Tests: AggIntent column-type propagation ────────────────────────────────── + + #[tokio::test] + async fn test_min_int_col_gives_int64_in_aggregate_schema() { + // MIN(ts) where ts: Int64 → the root schema (through the outer Projection) + // should resolve the aggregate output type as Int64, not Float64. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let expr = lowerer.lower("SELECT MIN(ts) FROM metrics").await.unwrap(); + let typed = populate_schemas(expr, &catalog); + + let dtype = typed + .schema + .fields + .last() + .map(|f| f.dtype.clone()) + .expect("root schema should have at least one field"); + assert_eq!( + dtype, + L3DataType::Int64, + "MIN(ts: Int64) should propagate Int64 through to root schema, got {:?}", + dtype + ); + } -#[tokio::test] -async fn test_sum_float_col_gives_float64_in_aggregate_schema() { - // SUM(value) where value: Float64 → Float64. - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let expr = lowerer - .lower("SELECT SUM(value) FROM metrics") - .await - .unwrap(); - let typed = populate_schemas(expr, &catalog); - - let dtype = typed - .schema - .fields - .last() - .map(|f| f.dtype.clone()) - .expect("root schema should have at least one field"); - assert_eq!(dtype, L3DataType::Float64); -} + #[tokio::test] + async fn test_max_utf8_col_gives_utf8_in_aggregate_schema() { + // MAX(region) where region: Utf8 → root schema should carry Utf8. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let expr = lowerer + .lower("SELECT MAX(region) FROM metrics") + .await + .unwrap(); + let typed = populate_schemas(expr, &catalog); + + let dtype = typed + .schema + .fields + .last() + .map(|f| f.dtype.clone()) + .expect("root schema should have at least one field"); + assert_eq!( + dtype, + L3DataType::Utf8, + "MAX(region: Utf8) should propagate Utf8 through to root schema, got {:?}", + dtype + ); + } -#[tokio::test] -async fn test_agg_col_name_tracked_via_col_field() { - // Verify that AggIntent.col() carries the aggregated column name so - // schema derivation can resolve types without re-parsing SQL. - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer.lower("SELECT MIN(ts) FROM metrics").await.unwrap(); - - let (_, aggs) = find_aggregate(&result).expect("expected Aggregate"); - let AggIntent::Min { col } = &aggs[0] else { - panic!("expected Min, got {:?}", aggs[0]); - }; - assert_eq!( - col.as_ref().map(|c| c.0.as_str()), - Some("ts"), - "Min should track the aggregated column name" - ); -} + #[tokio::test] + async fn test_sum_float_col_gives_float64_in_aggregate_schema() { + // SUM(value) where value: Float64 → Float64. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let expr = lowerer + .lower("SELECT SUM(value) FROM metrics") + .await + .unwrap(); + let typed = populate_schemas(expr, &catalog); + + let dtype = typed + .schema + .fields + .last() + .map(|f| f.dtype.clone()) + .expect("root schema should have at least one field"); + assert_eq!(dtype, L3DataType::Float64); + } -// ── Tests: NthValue N extraction (#10) ─────────────────────────────────────── - -#[tokio::test] -async fn test_nth_value_extracts_n_from_args() { - // NTH_VALUE(value, 2) OVER (ORDER BY ts) → WindowFuncKind::NthValue(2), not NthValue(0). - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT NTH_VALUE(value, 2) OVER (ORDER BY ts) FROM metrics") - .await - .unwrap(); - - fn find_window_kind( - e: &QueryExpr, - ) -> Option<&asap_control_core::intent_algebra::expr::WindowFuncKind> { - match e { - QueryExpr::WindowFunc { func, .. } => Some(func), - QueryExpr::Project { child, .. } => find_window_kind(&child.expr), - _ => None, + #[tokio::test] + async fn test_agg_col_name_tracked_via_col_field() { + // Verify that AggIntent.col() carries the aggregated column name so + // schema derivation can resolve types without re-parsing SQL. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer.lower("SELECT MIN(ts) FROM metrics").await.unwrap(); + + let (_, aggs) = find_aggregate(&result).expect("expected Aggregate"); + let AggIntent::Min { col } = &aggs[0] else { + panic!("expected Min, got {:?}", aggs[0]); + }; + assert_eq!( + col.as_ref().map(|c| c.0.as_str()), + Some("ts"), + "Min should track the aggregated column name" + ); + } + + // ── Tests: NthValue N extraction (#10) ─────────────────────────────────────── + + #[tokio::test] + async fn test_nth_value_extracts_n_from_args() { + // NTH_VALUE(value, 2) OVER (ORDER BY ts) → WindowFuncKind::NthValue(2), not NthValue(0). + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT NTH_VALUE(value, 2) OVER (ORDER BY ts) FROM metrics") + .await + .unwrap(); + + fn find_window_kind( + e: &QueryExpr, + ) -> Option<&asap_control_core::intent_algebra::expr::WindowFuncKind> { + match e { + QueryExpr::WindowFunc { func, .. } => Some(func), + QueryExpr::Project { child, .. } => find_window_kind(&child.expr), + _ => None, + } } + let kind = find_window_kind(&result).expect("expected WindowFunc node"); + assert!( + matches!( + kind, + asap_control_core::intent_algebra::expr::WindowFuncKind::NthValue(Some(2)) + ), + "expected NthValue(Some(2)), got: {kind:?}" + ); } - let kind = find_window_kind(&result).expect("expected WindowFunc node"); - assert!( - matches!( - kind, - asap_control_core::intent_algebra::expr::WindowFuncKind::NthValue(Some(2)) - ), - "expected NthValue(Some(2)), got: {kind:?}" - ); -} -// ── Tests: lowering-time failure (#11) ─────────────────────────────────────── - -#[tokio::test] -async fn test_unsupported_aggregate_returns_error_at_lowering_time() { - // array_agg parses fine but is not in our AggIntent mapping → UnsupportedAggregate. - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let err = lowerer - .lower("SELECT array_agg(value) FROM metrics") - .await - .unwrap_err(); - assert!( - matches!(err, LoweringError::UnsupportedAggregate(_)), - "expected UnsupportedAggregate, got: {err}" - ); -} + // ── Tests: lowering-time failure (#11) ─────────────────────────────────────── + + #[tokio::test] + async fn test_unsupported_aggregate_returns_error_at_lowering_time() { + // array_agg parses fine but is not in our AggIntent mapping → UnsupportedAggregate. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let err = lowerer + .lower("SELECT array_agg(value) FROM metrics") + .await + .unwrap_err(); + assert!( + matches!(err, LoweringError::UnsupportedAggregate(_)), + "expected UnsupportedAggregate, got: {err}" + ); + } -#[tokio::test] -async fn test_unsupported_agg_isolation_in_batch() { - // The bad query (unsupported agg) should not prevent the good one from succeeding. - let catalog = metrics_catalog(); - let workload = make_workload(vec![ - "SELECT COUNT(*) FROM metrics", - "SELECT array_agg(value) FROM metrics", - ]); - let results = lower_batch(&workload, &catalog).await; - assert_eq!(results.len(), 2); - assert!(results[0].is_ok(), "valid query should succeed"); - assert!(results[1].is_err(), "unsupported agg should fail"); - assert!( - matches!(results[1], Err(LoweringError::UnsupportedAggregate(_))), - "expected UnsupportedAggregate, got: {:?}", - results[1] - ); -} + #[tokio::test] + async fn test_unsupported_agg_isolation_in_batch() { + // The bad query (unsupported agg) should not prevent the good one from succeeding. + let catalog = metrics_catalog(); + let workload = make_workload(vec![ + "SELECT COUNT(*) FROM metrics", + "SELECT array_agg(value) FROM metrics", + ]); + let results = lower_batch(&workload, &catalog).await; + assert_eq!(results.len(), 2); + assert!(results[0].is_ok(), "valid query should succeed"); + assert!(results[1].is_err(), "unsupported agg should fail"); + assert!( + matches!(results[1], Err(LoweringError::UnsupportedAggregate(_))), + "expected UnsupportedAggregate, got: {:?}", + results[1] + ); + } -// ── Tests: 4-predicate WHERE with time extraction (#13) ────────────────────── - -#[tokio::test] -async fn test_four_predicate_where_extracts_time_and_keeps_filter() { - // WHERE ts > 1000 AND ts < 2000 AND region = 'us' AND value > 0.0 - // All four predicates in a single Filter node (DataFusion keeps them - // as one conjunctive Filter on the unoptimized plan). Time bounds should - // be extracted; the remaining two non-time predicates should stay as Filter. - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower( - "SELECT value FROM metrics \ - WHERE ts > 1000 AND ts < 2000 AND region = 'us' AND value > 0.0", - ) - .await - .unwrap(); - - let source = find_source(&result).expect("expected a Scan node"); - let Source::Table { time_range, .. } = source else { - panic!("expected Source::Table"); - }; - let tr = time_range.as_ref().expect("expected time_range extracted"); - assert_eq!(tr.start_ms, Some(1000)); - assert_eq!(tr.end_ms, Some(2000)); - - fn has_filter(e: &QueryExpr) -> bool { - match e { - QueryExpr::Filter { .. } => true, - QueryExpr::Project { child, .. } => has_filter(&child.expr), - _ => false, + // ── Tests: 4-predicate WHERE with time extraction (#13) ────────────────────── + + #[tokio::test] + async fn test_four_predicate_where_extracts_time_and_keeps_filter() { + // WHERE ts > 1000 AND ts < 2000 AND region = 'us' AND value > 0.0 + // All four predicates in a single Filter node (DataFusion keeps them + // as one conjunctive Filter on the unoptimized plan). Time bounds should + // be extracted; the remaining two non-time predicates should stay as Filter. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower( + "SELECT value FROM metrics \ + WHERE ts > 1000 AND ts < 2000 AND region = 'us' AND value > 0.0", + ) + .await + .unwrap(); + + let source = find_source(&result).expect("expected a Scan node"); + let Source::Table { time_range, .. } = source else { + panic!("expected Source::Table"); + }; + let tr = time_range.as_ref().expect("expected time_range extracted"); + assert_eq!(tr.start_ms, Some(1000)); + assert_eq!(tr.end_ms, Some(2000)); + + fn has_filter(e: &QueryExpr) -> bool { + match e { + QueryExpr::Filter { .. } => true, + QueryExpr::Project { child, .. } => has_filter(&child.expr), + _ => false, + } } + assert!( + has_filter(&result), + "expected Filter node for the two non-time predicates" + ); } - assert!( - has_filter(&result), - "expected Filter node for the two non-time predicates" - ); -} -#[tokio::test] -async fn test_filter_predicate_conjuncts_count_for_two_non_time_preds() { - // Same query: the remaining Filter predicate should have 2 conjuncts. - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower( - "SELECT value FROM metrics \ - WHERE ts > 1000 AND ts < 2000 AND region = 'us' AND value > 0.0", - ) - .await - .unwrap(); - - let pred = find_predicate(&result).expect("expected Filter predicate"); - assert_eq!( - pred.conjuncts().len(), - 2, - "expected 2 non-time conjuncts, got: {pred:?}" - ); -} + #[tokio::test] + async fn test_filter_predicate_conjuncts_count_for_two_non_time_preds() { + // Same query: the remaining Filter predicate should have 2 conjuncts. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower( + "SELECT value FROM metrics \ + WHERE ts > 1000 AND ts < 2000 AND region = 'us' AND value > 0.0", + ) + .await + .unwrap(); + + let pred = find_predicate(&result).expect("expected Filter predicate"); + assert_eq!( + pred.conjuncts().len(), + 2, + "expected 2 non-time conjuncts, got: {pred:?}" + ); + } -// ── Tests: multi-window error (#14) ────────────────────────────────────────── - -#[tokio::test] -async fn test_multiple_window_funcs_same_over_returns_error() { - // DataFusion may fold two window functions with identical OVER clauses - // into one Window plan node. Our lowerer should surface an explicit error - // rather than silently dropping the second function. - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower( - "SELECT \ - SUM(value) OVER (PARTITION BY region ORDER BY ts), \ - AVG(value) OVER (PARTITION BY region ORDER BY ts) \ - FROM metrics", - ) - .await; - - match &result { - Err(e) => assert!( - matches!(e, LoweringError::UnsupportedFeature(msg) if msg.contains("multiple window")), - "unexpected error: {e}" - ), - Ok(expr) => { - // DataFusion split them into separate Window nodes — both must be present. - fn count_window_funcs(e: &QueryExpr) -> usize { - match e { - QueryExpr::WindowFunc { child, .. } => 1 + count_window_funcs(&child.expr), - QueryExpr::Project { child, .. } => count_window_funcs(&child.expr), - _ => 0, + // ── Tests: multi-window error (#14) ────────────────────────────────────────── + + #[tokio::test] + async fn test_multiple_window_funcs_same_over_returns_error() { + // DataFusion may fold two window functions with identical OVER clauses + // into one Window plan node. Our lowerer should surface an explicit error + // rather than silently dropping the second function. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower( + "SELECT \ + SUM(value) OVER (PARTITION BY region ORDER BY ts), \ + AVG(value) OVER (PARTITION BY region ORDER BY ts) \ + FROM metrics", + ) + .await; + + match &result { + Err(e) => assert!( + matches!(e, LoweringError::UnsupportedFeature(msg) if msg.contains("multiple window")), + "unexpected error: {e}" + ), + Ok(expr) => { + // DataFusion split them into separate Window nodes — both must be present. + fn count_window_funcs(e: &QueryExpr) -> usize { + match e { + QueryExpr::WindowFunc { child, .. } => 1 + count_window_funcs(&child.expr), + QueryExpr::Project { child, .. } => count_window_funcs(&child.expr), + _ => 0, + } } + assert_eq!( + count_window_funcs(expr), + 2, + "when DataFusion splits window funcs both must appear in the lowered tree" + ); } - assert_eq!( - count_window_funcs(expr), - 2, - "when DataFusion splits window funcs both must appear in the lowered tree" - ); } } -} - -// ── Tests: missing predicate coverage ──────────────────────────────────────── - -#[tokio::test] -async fn test_filter_is_not_null_predicate() { - // WHERE value IS NOT NULL → IsNotNull(Column("value")) - let catalog = no_time_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT id FROM events WHERE value IS NOT NULL") - .await - .unwrap(); - let pred = find_predicate(&result).expect("expected Filter predicate"); - assert!( - matches!(pred, L3Expr::IsNotNull(inner) if matches!(inner.as_ref(), L3Expr::Column(c) if c.0 == "value")), - "expected IsNotNull(Column(\"value\")), got: {pred:?}" - ); -} -#[tokio::test] -async fn test_filter_not_in_list() { - // WHERE id NOT IN (1, 2, 3) → InList { negated: true } - let catalog = no_time_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT id FROM events WHERE id NOT IN (1, 2, 3)") - .await - .unwrap(); - let pred = find_predicate(&result).expect("expected Filter predicate"); - let L3Expr::InList { - expr, - list, - negated, - } = pred - else { - panic!("expected InList, got: {pred:?}"); - }; - assert!(matches!(expr.as_ref(), L3Expr::Column(c) if c.0 == "id")); - assert_eq!(list.len(), 3); - assert!(negated, "NOT IN should set negated=true"); -} - -#[tokio::test] -async fn test_not_between_normalizes_to_bool_or() { - // WHERE value NOT BETWEEN 0 AND 100 → BoolOr([Compare(Lt), Compare(Gt)]) - let catalog = no_time_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT id FROM events WHERE value NOT BETWEEN 0.0 AND 100.0") - .await - .unwrap(); - let pred = find_predicate(&result).expect("expected Filter predicate"); - let disjuncts = pred.disjuncts(); - assert_eq!( - disjuncts.len(), - 2, - "NOT BETWEEN should produce 2 disjuncts, got: {pred:?}" - ); - assert!(matches!( - &disjuncts[0], - L3Expr::Compare { - op: CompareOp::Lt, - .. - } - )); - assert!(matches!( - &disjuncts[1], - L3Expr::Compare { - op: CompareOp::Gt, - .. - } - )); -} + // ── Tests: missing predicate coverage ──────────────────────────────────────── + + #[tokio::test] + async fn test_filter_is_not_null_predicate() { + // WHERE value IS NOT NULL → IsNotNull(Column("value")) + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT id FROM events WHERE value IS NOT NULL") + .await + .unwrap(); + let pred = find_predicate(&result).expect("expected Filter predicate"); + assert!( + matches!(pred, L3Expr::IsNotNull(inner) if matches!(inner.as_ref(), L3Expr::Column(c) if c.0 == "value")), + "expected IsNotNull(Column(\"value\")), got: {pred:?}" + ); + } -#[tokio::test] -async fn test_try_cast_sets_try_cast_flag() { - // TRY_CAST(value AS BIGINT) → Cast { try_cast: true } - let catalog = no_time_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT TRY_CAST(value AS BIGINT) FROM events") - .await - .unwrap(); - let items = find_project_items(&result).expect("expected Project node"); - let has_try_cast = items - .iter() - .any(|pi| matches!(pi.expr, L3Expr::Cast { try_cast: true, .. })); - assert!( - has_try_cast, - "TRY_CAST should produce Cast {{ try_cast: true }}, got: {items:?}" - ); -} + #[tokio::test] + async fn test_filter_not_in_list() { + // WHERE id NOT IN (1, 2, 3) → InList { negated: true } + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT id FROM events WHERE id NOT IN (1, 2, 3)") + .await + .unwrap(); + let pred = find_predicate(&result).expect("expected Filter predicate"); + let L3Expr::InList { + expr, + list, + negated, + } = pred + else { + panic!("expected InList, got: {pred:?}"); + }; + assert!(matches!(expr.as_ref(), L3Expr::Column(c) if c.0 == "id")); + assert_eq!(list.len(), 3); + assert!(negated, "NOT IN should set negated=true"); + } -#[tokio::test] -async fn test_regular_cast_sets_try_cast_false() { - // CAST(value AS BIGINT) → Cast { try_cast: false } - let catalog = no_time_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let result = lowerer - .lower("SELECT CAST(value AS BIGINT) FROM events") - .await - .unwrap(); - let items = find_project_items(&result).expect("expected Project node"); - let has_cast = items.iter().any(|pi| { - matches!( - pi.expr, - L3Expr::Cast { - try_cast: false, + #[tokio::test] + async fn test_not_between_normalizes_to_bool_or() { + // WHERE value NOT BETWEEN 0 AND 100 → BoolOr([Compare(Lt), Compare(Gt)]) + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT id FROM events WHERE value NOT BETWEEN 0.0 AND 100.0") + .await + .unwrap(); + let pred = find_predicate(&result).expect("expected Filter predicate"); + let disjuncts = pred.disjuncts(); + assert_eq!( + disjuncts.len(), + 2, + "NOT BETWEEN should produce 2 disjuncts, got: {pred:?}" + ); + assert!(matches!( + &disjuncts[0], + L3Expr::Compare { + op: CompareOp::Lt, .. } - ) - }); - assert!( - has_cast, - "CAST should produce Cast {{ try_cast: false }}, got: {items:?}" - ); -} + )); + assert!(matches!( + &disjuncts[1], + L3Expr::Compare { + op: CompareOp::Gt, + .. + } + )); + } -// ── Tests: populate_schemas for additional node types ───────────────────────── - -#[tokio::test] -async fn test_populate_schemas_filter_node_typed() { - // Project → Filter → Scan: both the root Project and the inner Filter - // should have non-empty schemas after populate_schemas. - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let expr = lowerer - .lower("SELECT ts FROM metrics WHERE value > 0.0") - .await - .unwrap(); - let typed = populate_schemas(expr, &catalog); - // Root Project selects 1 column. - assert_eq!(typed.schema.fields.len(), 1); - assert_eq!(typed.schema.fields[0].name, "ts"); - // The inner Filter child should also carry the full scan schema. - let QueryExpr::Project { child, .. } = &typed.expr else { - panic!("expected Project at root"); - }; - assert!( - !child.schema.fields.is_empty(), - "Filter child schema should be populated" - ); -} + #[tokio::test] + async fn test_try_cast_sets_try_cast_flag() { + // TRY_CAST(value AS BIGINT) → Cast { try_cast: true } + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT TRY_CAST(value AS BIGINT) FROM events") + .await + .unwrap(); + let items = find_project_items(&result).expect("expected Project node"); + let has_try_cast = items + .iter() + .any(|pi| matches!(pi.expr, L3Expr::Cast { try_cast: true, .. })); + assert!( + has_try_cast, + "TRY_CAST should produce Cast {{ try_cast: true }}, got: {items:?}" + ); + } -#[tokio::test] -async fn test_populate_schemas_sort_node_typed() { - // Sort passes through its child schema. - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let expr = lowerer - .lower("SELECT ts, value FROM metrics ORDER BY ts") - .await - .unwrap(); - let typed = populate_schemas(expr, &catalog); - // Root should have 2 fields regardless of whether Sort or Project is on top. - assert_eq!(typed.schema.fields.len(), 2); -} + #[tokio::test] + async fn test_regular_cast_sets_try_cast_false() { + // CAST(value AS BIGINT) → Cast { try_cast: false } + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let result = lowerer + .lower("SELECT CAST(value AS BIGINT) FROM events") + .await + .unwrap(); + let items = find_project_items(&result).expect("expected Project node"); + let has_cast = items.iter().any(|pi| { + matches!( + pi.expr, + L3Expr::Cast { + try_cast: false, + .. + } + ) + }); + assert!( + has_cast, + "CAST should produce Cast {{ try_cast: false }}, got: {items:?}" + ); + } -#[tokio::test] -async fn test_populate_schemas_limit_node_typed() { - // Limit passes through its child schema. - let catalog = no_time_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let expr = lowerer - .lower("SELECT id FROM events LIMIT 5") - .await - .unwrap(); - let typed = populate_schemas(expr, &catalog); - assert_eq!(typed.schema.fields.len(), 1); - assert_eq!(typed.schema.fields[0].name, "id"); -} + // ── Tests: populate_schemas for additional node types ───────────────────────── + + #[tokio::test] + async fn test_populate_schemas_filter_node_typed() { + // Project → Filter → Scan: both the root Project and the inner Filter + // should have non-empty schemas after populate_schemas. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let expr = lowerer + .lower("SELECT ts FROM metrics WHERE value > 0.0") + .await + .unwrap(); + let typed = populate_schemas(expr, &catalog); + // Root Project selects 1 column. + assert_eq!(typed.schema.fields.len(), 1); + assert_eq!(typed.schema.fields[0].name, "ts"); + // The inner Filter child should also carry the full scan schema. + let QueryExpr::Project { child, .. } = &typed.expr else { + panic!("expected Project at root"); + }; + assert!( + !child.schema.fields.is_empty(), + "Filter child schema should be populated" + ); + } -#[tokio::test] -async fn test_populate_schemas_set_op_uses_left_schema() { - // SetOp output schema matches the left child. - let catalog = no_time_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let expr = lowerer - .lower("SELECT id FROM events UNION ALL SELECT id FROM events") - .await - .unwrap(); - let typed = populate_schemas(expr, &catalog); - assert!( - !typed.schema.fields.is_empty(), - "SetOp schema should be populated after populate_schemas" - ); - assert!( - typed.schema.fields.iter().any(|f| f.name == "id"), - "expected 'id' field in SetOp output schema, got: {:?}", - typed.schema.fields - ); -} + #[tokio::test] + async fn test_populate_schemas_sort_node_typed() { + // Sort passes through its child schema. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let expr = lowerer + .lower("SELECT ts, value FROM metrics ORDER BY ts") + .await + .unwrap(); + let typed = populate_schemas(expr, &catalog); + // Root should have 2 fields regardless of whether Sort or Project is on top. + assert_eq!(typed.schema.fields.len(), 2); + } -#[tokio::test] -async fn test_populate_schemas_window_func_appends_column() { - // WindowFunc appends one column to the child schema. - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let expr = lowerer - .lower( - "SELECT region, ROW_NUMBER() OVER (PARTITION BY region ORDER BY ts) \ - FROM metrics", - ) - .await - .unwrap(); - let typed = populate_schemas(expr, &catalog); - // Root may be a Project; all nodes in the tree should have non-empty schemas. - assert!( - !typed.schema.fields.is_empty(), - "root schema should be populated after populate_schemas" - ); -} + #[tokio::test] + async fn test_populate_schemas_limit_node_typed() { + // Limit passes through its child schema. + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let expr = lowerer + .lower("SELECT id FROM events LIMIT 5") + .await + .unwrap(); + let typed = populate_schemas(expr, &catalog); + assert_eq!(typed.schema.fields.len(), 1); + assert_eq!(typed.schema.fields[0].name, "id"); + } -// ── Tests: CR findings #1, #2, #5 ──────────────────────────────────────────── - -#[tokio::test] -async fn test_having_filter_fallback_preserves_inner_time_range() { - // Finding #1: lower_filter's collect_filter_chain stops at Aggregate - // (not a scan-chain node), so the HAVING Filter takes the fallback path. - // The WHERE Filter sitting below the Aggregate must still fold ts predicates - // into Source::Table.time_range via the normal scan-chain path. - // - // This test would catch a regression where the HAVING fallback accidentally - // short-circuits the inner time extraction. - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let expr = lowerer - .lower( - "SELECT region, COUNT(*) FROM metrics \ - WHERE ts >= 1000 AND ts < 2000 \ - GROUP BY region \ - HAVING region = 'us'", - ) - .await - .unwrap(); - - // Inner WHERE predicates must still be folded into time_range. - let source = find_source(&expr).expect("expected Scan in tree"); - let Source::Table { time_range, .. } = source else { - panic!("expected Source::Table, got {source:?}"); - }; - assert!( - time_range.is_some(), - "time_range should be extracted from WHERE ts >= 1000 AND ts < 2000; \ - the HAVING-path fallback must not prevent inner time extraction" - ); - let range = time_range.as_ref().unwrap(); - assert_eq!(range.start_ms, Some(1000), "start_ms from ts >= 1000"); - assert_eq!(range.end_ms, Some(2000), "end_ms from ts < 2000"); - - // The HAVING predicate must appear as a Filter node directly above an Aggregate. - fn has_filter_over_aggregate(expr: &QueryExpr) -> bool { - match expr { - QueryExpr::Filter { child, .. } => matches!(child.expr, QueryExpr::Aggregate { .. }), - QueryExpr::Project { child, .. } | QueryExpr::Sort { child, .. } => { - has_filter_over_aggregate(&child.expr) + #[tokio::test] + async fn test_populate_schemas_set_op_uses_left_schema() { + // SetOp output schema matches the left child. + let catalog = no_time_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let expr = lowerer + .lower("SELECT id FROM events UNION ALL SELECT id FROM events") + .await + .unwrap(); + let typed = populate_schemas(expr, &catalog); + assert!( + !typed.schema.fields.is_empty(), + "SetOp schema should be populated after populate_schemas" + ); + assert!( + typed.schema.fields.iter().any(|f| f.name == "id"), + "expected 'id' field in SetOp output schema, got: {:?}", + typed.schema.fields + ); + } + + #[tokio::test] + async fn test_populate_schemas_window_func_appends_column() { + // WindowFunc appends one column to the child schema. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let expr = lowerer + .lower( + "SELECT region, ROW_NUMBER() OVER (PARTITION BY region ORDER BY ts) \ + FROM metrics", + ) + .await + .unwrap(); + let typed = populate_schemas(expr, &catalog); + // Root may be a Project; all nodes in the tree should have non-empty schemas. + assert!( + !typed.schema.fields.is_empty(), + "root schema should be populated after populate_schemas" + ); + } + + // ── Tests: CR findings #1, #2, #5 ──────────────────────────────────────────── + + #[tokio::test] + async fn test_having_filter_fallback_preserves_inner_time_range() { + // Finding #1: lower_filter's collect_filter_chain stops at Aggregate + // (not a scan-chain node), so the HAVING Filter takes the fallback path. + // The WHERE Filter sitting below the Aggregate must still fold ts predicates + // into Source::Table.time_range via the normal scan-chain path. + // + // This test would catch a regression where the HAVING fallback accidentally + // short-circuits the inner time extraction. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let expr = lowerer + .lower( + "SELECT region, COUNT(*) FROM metrics \ + WHERE ts >= 1000 AND ts < 2000 \ + GROUP BY region \ + HAVING region = 'us'", + ) + .await + .unwrap(); + + // Inner WHERE predicates must still be folded into time_range. + let source = find_source(&expr).expect("expected Scan in tree"); + let Source::Table { time_range, .. } = source else { + panic!("expected Source::Table, got {source:?}"); + }; + assert!( + time_range.is_some(), + "time_range should be extracted from WHERE ts >= 1000 AND ts < 2000; \ + the HAVING-path fallback must not prevent inner time extraction" + ); + let range = time_range.as_ref().unwrap(); + assert_eq!(range.start_ms, Some(1000), "start_ms from ts >= 1000"); + assert_eq!(range.end_ms, Some(2000), "end_ms from ts < 2000"); + + // The HAVING predicate must appear as a Filter node directly above an Aggregate. + fn has_filter_over_aggregate(expr: &QueryExpr) -> bool { + match expr { + QueryExpr::Filter { child, .. } => { + matches!(child.expr, QueryExpr::Aggregate { .. }) + } + QueryExpr::Project { child, .. } | QueryExpr::Sort { child, .. } => { + has_filter_over_aggregate(&child.expr) + } + _ => false, } - _ => false, } + assert!( + has_filter_over_aggregate(&expr), + "HAVING should produce a Filter node directly wrapping an Aggregate; got: {expr:?}" + ); } - assert!( - has_filter_over_aggregate(&expr), - "HAVING should produce a Filter node directly wrapping an Aggregate; got: {expr:?}" - ); -} - -#[tokio::test] -async fn test_scan_columns_empty_when_aggregate_between_project_and_scan() { - // Finding #2: push_columns_into_scan recurses only through Filter nodes. - // For Project → Aggregate → Scan, push_columns_into_scan encounters the - // Aggregate and falls through to `other => other`, leaving Scan.columns empty. - // - // An empty Scan.columns means "all columns" (semantically safe), but prevents - // downstream stages from pruning unneeded columns. - // - // TODO: when this gap is fixed, update the assertion to check that columns - // contains ["value", "region"] (the columns actually referenced). - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let expr = lowerer - .lower("SELECT MAX(value) FROM metrics GROUP BY region") - .await - .unwrap(); - - let source = find_source(&expr).expect("expected Scan in tree"); - let Source::Table { columns, .. } = source else { - panic!("expected Source::Table, got {source:?}"); - }; - assert!( - columns.is_empty(), - "known gap: Scan.columns should be empty (full scan) when Aggregate sits \ - between Project and Scan — if this fails the gap has been fixed, \ - update to assert columns == [value, region]; got {columns:?}" - ); -} -#[tokio::test] -async fn test_max_of_expression_arg_schema_reports_float64_known_bug() { - // Finding #5: agg_col() returns None for non-bare-column aggregate args - // (e.g. `ts + 1`). Schema derivation falls back to a Float64 dummy field, - // so MAX(ts + 1) reports Float64 even though ts: Int64 → correct type is Int64. - // - // This test pins the current (incorrect) Float64 output as a known bug. - // TODO: fix agg_col / output_type so MAX of an Int64 expression → Int64. - // When fixed, change the assertion below to L3DataType::Int64. - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let expr = lowerer - .lower("SELECT MAX(ts + 1) FROM metrics") - .await - .unwrap(); - let typed = populate_schemas(expr, &catalog); - - // Root is Project → Aggregate → Scan; first schema field is the MAX output. - assert_eq!( - typed.schema.fields.len(), - 1, - "expected one output field for SELECT MAX(...)" - ); - let dtype = &typed.schema.fields[0].dtype; - // BUG: currently Float64 because agg_col returns None → float64_dummy fallback. - // Should be Int64 (MAX of Int64 expression preserves the type). - assert_eq!( - *dtype, - L3DataType::Float64, - "known bug: MAX(ts + 1) schema reports {dtype:?} — expected Float64 \ - (the current wrong value); update to Int64 when the agg_col bug is fixed" - ); -} + #[tokio::test] + async fn test_scan_columns_empty_when_aggregate_between_project_and_scan() { + // Finding #2: push_columns_into_scan recurses only through Filter nodes. + // For Project → Aggregate → Scan, push_columns_into_scan encounters the + // Aggregate and falls through to `other => other`, leaving Scan.columns empty. + // + // An empty Scan.columns means "all columns" (semantically safe), but prevents + // downstream stages from pruning unneeded columns. + // + // TODO: when this gap is fixed, update the assertion to check that columns + // contains ["value", "region"] (the columns actually referenced). + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let expr = lowerer + .lower("SELECT MAX(value) FROM metrics GROUP BY region") + .await + .unwrap(); + + let source = find_source(&expr).expect("expected Scan in tree"); + let Source::Table { columns, .. } = source else { + panic!("expected Source::Table, got {source:?}"); + }; + assert!( + columns.is_empty(), + "known gap: Scan.columns should be empty (full scan) when Aggregate sits \ + between Project and Scan — if this fails the gap has been fixed, \ + update to assert columns == [value, region]; got {columns:?}" + ); + } -// ── Tests: CR findings #11 and #12 ─────────────────────────────────────────── + #[tokio::test] + async fn test_max_of_expression_arg_schema_reports_float64_known_bug() { + // Finding #5: agg_col() returns None for non-bare-column aggregate args + // (e.g. `ts + 1`). Schema derivation falls back to a Float64 dummy field, + // so MAX(ts + 1) reports Float64 even though ts: Int64 → correct type is Int64. + // + // This test pins the current (incorrect) Float64 output as a known bug. + // TODO: fix agg_col / output_type so MAX of an Int64 expression → Int64. + // When fixed, change the assertion below to L3DataType::Int64. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let expr = lowerer + .lower("SELECT MAX(ts + 1) FROM metrics") + .await + .unwrap(); + let typed = populate_schemas(expr, &catalog); + + // Root is Project → Aggregate → Scan; first schema field is the MAX output. + assert_eq!( + typed.schema.fields.len(), + 1, + "expected one output field for SELECT MAX(...)" + ); + let dtype = &typed.schema.fields[0].dtype; + // BUG: currently Float64 because agg_col returns None → float64_dummy fallback. + // Should be Int64 (MAX of Int64 expression preserves the type). + assert_eq!( + *dtype, + L3DataType::Float64, + "known bug: MAX(ts + 1) schema reports {dtype:?} — expected Float64 \ + (the current wrong value); update to Int64 when the agg_col bug is fixed" + ); + } -#[tokio::test] -async fn test_invalid_time_column_in_catalog_returns_error() { - // Finding #11: lower_filter calls schema.validate() when a table has a - // time_column set. If time_column names a column that doesn't exist in - // TableSchema.columns, validate() returns Err, which lower_filter propagates - // as LoweringError::InvalidExpression. - let mut tables = HashMap::new(); - tables.insert( - "metrics".to_string(), - TableSchema { - columns: vec![ColumnDef { - name: "value".to_string(), - data_type: L3DataType::Float64, - nullable: true, - }], - // "ghost_ts" doesn't exist in columns — validate() will reject this. - time_column: Some("ghost_ts".to_string()), - }, - ); - let catalog = SchemaCatalog { tables }; - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - // Any WHERE clause triggers lower_filter → collect_filter_chain finds the - // TableScan → catalog lookup → time_column is Some → validate() fires. - let result = lowerer - .lower("SELECT value FROM metrics WHERE value > 0.0") - .await; - assert!( - matches!(result, Err(LoweringError::InvalidExpression(_))), - "expected InvalidExpression for catalog with invalid time_column, got: {result:?}" - ); -} + // ── Tests: CR findings #11 and #12 ─────────────────────────────────────────── + + #[tokio::test] + async fn test_invalid_time_column_in_catalog_returns_error() { + // Finding #11: lower_filter calls schema.validate() when a table has a + // time_column set. If time_column names a column that doesn't exist in + // TableSchema.columns, validate() returns Err, which lower_filter propagates + // as LoweringError::InvalidExpression. + let mut tables = HashMap::new(); + tables.insert( + "metrics".to_string(), + TableSchema { + columns: vec![ColumnDef { + name: "value".to_string(), + data_type: L3DataType::Float64, + nullable: true, + }], + // "ghost_ts" doesn't exist in columns — validate() will reject this. + time_column: Some("ghost_ts".to_string()), + }, + ); + let catalog = SchemaCatalog { tables }; + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + // Any WHERE clause triggers lower_filter → collect_filter_chain finds the + // TableScan → catalog lookup → time_column is Some → validate() fires. + let result = lowerer + .lower("SELECT value FROM metrics WHERE value > 0.0") + .await; + assert!( + matches!(result, Err(LoweringError::InvalidExpression(_))), + "expected InvalidExpression for catalog with invalid time_column, got: {result:?}" + ); + } -#[tokio::test] -async fn test_agg_col_is_none_for_expression_arg() { - // Finding #12: agg_col() returns None when the aggregate argument is not - // a bare column reference (e.g. SUM(value + 1) uses a BinaryExpr, not Column). - // This documents the boundary: only Column, Alias(Column), and Cast(Column) - // produce a Some(ColumnRef); any other expression shape produces None. - let catalog = metrics_catalog(); - let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); - let expr = lowerer - .lower("SELECT SUM(value + 1) FROM metrics GROUP BY region") - .await - .unwrap(); - - let (_, aggs) = find_aggregate(&expr).expect("expected Aggregate in tree"); - assert_eq!(aggs.len(), 1); - let AggIntent::Sum { col } = &aggs[0] else { - panic!("expected AggIntent::Sum, got {:?}", aggs[0]); - }; - assert!( - col.is_none(), - "agg_col should return None for SUM(value + 1) — the arg is a BinaryExpr, \ - not a bare ColumnRef; got {col:?}" - ); + #[tokio::test] + async fn test_agg_col_is_none_for_expression_arg() { + // Finding #12: agg_col() returns None when the aggregate argument is not + // a bare column reference (e.g. SUM(value + 1) uses a BinaryExpr, not Column). + // This documents the boundary: only Column, Alias(Column), and Cast(Column) + // produce a Some(ColumnRef); any other expression shape produces None. + let catalog = metrics_catalog(); + let lowerer = SqlLowerer::new(&catalog, AccuracyTarget::Exact); + let expr = lowerer + .lower("SELECT SUM(value + 1) FROM metrics GROUP BY region") + .await + .unwrap(); + + let (_, aggs) = find_aggregate(&expr).expect("expected Aggregate in tree"); + assert_eq!(aggs.len(), 1); + let AggIntent::Sum { col } = &aggs[0] else { + panic!("expected AggIntent::Sum, got {:?}", aggs[0]); + }; + assert!( + col.is_none(), + "agg_col should return None for SUM(value + 1) — the arg is a BinaryExpr, \ + not a bare ColumnRef; got {col:?}" + ); + } } From fd68d61a5e081ef0cc9ca73cd61ffe5e6d5e1f4a Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Mon, 25 May 2026 16:56:13 -0400 Subject: [PATCH 17/17] removed TOOD --- TODO.md | 158 -------------------------------------------------------- 1 file changed, 158 deletions(-) delete mode 100644 TODO.md diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 04976fa2..00000000 --- a/TODO.md +++ /dev/null @@ -1,158 +0,0 @@ -# ASAPController — Outstanding Work - -Tracks what is built, what is stubbed, and what has not been started. -Organized from most immediate (unblocks the next step) to longest-horizon. - ---- - -## 1. Immediate code stubs (`todo!()` / unit-struct stubs) - -These are compile-time safe but will panic or silently drop information at runtime. - -### `crates/core` — expression stubs - -| Status | Location | What | Impact | -|---|---|---|---| -| ✅ done | `intent_algebra/expr.rs` | `AggIntent::requires() -> DataModel` | L4 rules can now gate on data model | -| ✅ done | `intent_algebra/expr.rs` | `AggIntent::output_type()` | L3 schema derivation for `Aggregate` works | -| ✅ done | `intent_algebra/expr.rs` | `HasSchema::output_schema()` — Scan, pass-through, Aggregate | Typed edges exist for all SQL-path nodes | -| ✅ done | `intent_algebra/expr_ir.rs` | `L3Expr` IR — `L3Scalar`, `CompareOp`, `L3Expr` with `conjuncts` / `columns_referenced` | Predicate, ProjectItem, SortKey now carry real expression content | -| ✅ done | `intent_algebra/expr.rs` | `Predicate(L3Expr)` — wraps a real filter expression | Filter nodes carry inspectable predicates | -| ✅ done | `intent_algebra/expr.rs` | `ProjectItem { expr: L3Expr, alias }` | Project nodes carry column expressions with aliases | -| ✅ done | `intent_algebra/expr.rs` | `SortKey { expr: L3Expr, ascending, nulls_first }` | Sort keys carry direction and expression | -| ✅ done | `lower/sql.rs` | `df_expr_to_l3` — DataFusion `Expr` → `L3Expr` translator | Lowerer populates predicates, project items, sort keys | -| ✅ done | `intent_algebra/expr_ir.rs` | `ArithOp` enum + `L3Expr::Arith` — binary arithmetic in expression IR | Arithmetic in predicates and projections now fully lowerable | -| ✅ done | `intent_algebra/expr_ir.rs` | `L3Expr::Case` — CASE WHEN / CASE expr WHEN | SQL CASE lowers to inspectable IR node | -| ✅ done | `intent_algebra/expr_ir.rs` | `CompareOp::ILike / NotILike` — case-insensitive LIKE | ILIKE predicates now in the IR | -| ✅ done | `intent_algebra/expr.rs` | `Project` schema for non-column items: `Cast → to`, `Literal → scalar type`, `Arith/Case/FunctionCall → Float64` default | `todo!()` removed; `populate_schemas` no longer panics on computed projections | -| ⬜ blocked | `intent_algebra/expr.rs` | `WindowFrame`, `VectorMatch`, `LabelFilter`, `MetricRef`, `PartitionKeys`, `JoinKey` — unit structs | PromQL and join paths structurally incomplete; unblocked only when those paths are implemented | - ---- - -## 2. `crates/lower` gaps - -| Status | Item | -|---|---| -| ✅ done | `lower_batch` integration tests — empty batch, per-query success, per-query error isolation | -| ✅ done | Language guard: `lower_batch` checks `workload.language`; returns `Err(LoweringError::WrongLanguage)` for non-SQL dialects. | -| ✅ done | `Source::Table.columns` — populated from the enclosing `Projection` node's column refs (DataFusion unoptimized plan never sets `TableScan.projection`; `SELECT *` leaves columns empty = "all columns"). | -| ✅ done | Time-range extraction: `BETWEEN low AND high` on the time column now folds both bounds into `Source::Table.time_range` (previously returned as non-time residual). Recursive lowering already handles `Filter → Aggregate → Filter → TableScan` correctly; the only real gap was `Expr::Between`. | -| ✅ done | Multi-dialect SQL guard — `SQL(ClickhouseSQL \| ElasticSQL)` now returns `LoweringError::UnsupportedDialect` instead of silently falling through to DataFusion's parser. Only `SQL(DataFusionSQL)` and `QueryLanguage::DataFusion` reach `SqlLowerer`. Full dialect support (sqlparser-rs parse + DF plan conversion) remains deferred. | -| ✅ done | `lower/sql.rs` — LIKE / ILIKE: `Expr::Like { case_insensitive }` → `Compare { ILike / Like }` | -| ✅ done | `lower/sql.rs` — arithmetic: `Operator::Plus/Minus/Multiply/Divide/Modulo` → `L3Expr::Arith` | -| ✅ done | `lower/sql.rs` — unary minus: `Expr::Negative` → negate literal or wrap in `Arith(Mul, -1, x)` | -| ✅ done | `lower/sql.rs` — CASE: `Expr::Case` → `L3Expr::Case` | -| ✅ done | `lower/sql.rs` — UNION: `LogicalPlan::Union` → left-associative `SetOp { Union, all: true }`; UNION DISTINCT handled by existing Distinct arm | -| ✅ done | `lower/schema_pass.rs` — `populate_schemas(expr, catalog) -> Rc` bottom-up schema pass | -| ⬜ deferred | CTEs (`WITH … AS …`) — lower to `QueryExpr::LetBinding`. DataFusion inlines or wraps in `LogicalPlan::Recursive`. | -| ⬜ deferred | Subqueries / inline views — `FROM (SELECT …) AS alias` currently returns `UnsupportedFeature`. | - ---- - -## 3. Core type system — schema derivation - -`HasSchema::output_schema` on `QueryExpr` is the full schema-derivation pass. -Node-by-node status: - -- ✅ `Scan` — reads columns from `SchemaCatalog`; sets `time_index` from `time_column` -- ✅ `Filter`, `Sort`, `Limit`, `Distinct`, `Partition`, `TimeWindow` — pass-through child schema -- ✅ `Aggregate` — `by` columns + one output column per `AggIntent`; TopK special-cased (by-cols + synthetic `count`) -- ✅ `Project` — Column items look up field in child schema (alias renames); time_index tracks the time col through reordering and aliasing. Non-column exprs (`Cast`, `FunctionCall`) remain `todo!()` until type inference exists. -- ✅ `Merge` — pass-through first child schema (all shards share the same schema) -- ✅ `WindowFunc` — child schema + one appended column; type derived from `WindowFuncKind` and `args: Vec` (added to IR); ranking funcs → `Int64` not-nullable; nav funcs (`Lag`/`Lead`/etc.) → arg type, nullable; `Min`/`Max` → preserve arg type -- ✅ `SetOp` — left schema (UNION / INTERSECT / EXCEPT output is left-shaped); `time_index` propagated from left -- ⬜ `Join` — merge left + right schemas; handle column-name collisions with table-qualified names (deferred until JOIN lowering is implemented) -- ⬜ `BinaryOp`, `LetBinding`, `Subquery`, `Ref` — blocked on those query paths existing - ---- - -## 4. L4 — sketch binding (not started) - -The entire L4 layer (`SummaryExpr` types exist in `core` but nothing produces -them yet). - -### Rule engine -A fixed-point rewrite engine that walks an L3 `QueryExpr` DAG, matches rules, -and emits an L4 `SummaryExpr` DAG. Core should own the engine; deployment -models inject rule sets. - -### Bind rules (one per AggIntent × SummaryKind pair) -Each rule matches a specific `AggIntent` variant and, given -`DeploymentConstraints` and an `AccuracyTarget`, selects a `SummaryKind` + -`SummaryParams`. Minimum set for the SQL path: - -| AggIntent | Candidate SummaryKind | -|---|---| -| `Count` | `Count` (exact), `Cms` (approx) | -| `Sum` | `Sum` (exact), `Cms` (approx) | -| `Min` / `Max` | `MinMax` (exact), `Kll` (approx) | -| `Avg` | `Sum` + `Count` pair, or `Kll` | -| `Stddev` | exact accumulator (Welford) — no sketch analog today | -| `Quantile` | `Kll`, `DDSketch` | -| `Cardinality` | `Hll` | -| `TopK` | `CmsWithHeap` | - -### Cost model trait -Trait `CostModel` with `plan_cost(plan: &SummaryExpr, constraints: &DeploymentConstraints) -> Cost`. -`Cost` should carry accuracy estimate, latency estimate, and transmission bytes. -L4 uses cost to pick among bind-rule alternatives. - -### `DeploymentConstraints` -Input to L4: memory budget per stage, sketch catalogue (which -`SummaryKind`s are available in this deployment), topology (number of stages). -Currently not defined anywhere. - -### Schema derivation for L4 -`L4Node.schema` is always empty today (same as L3). Implement -`HasL4Schema` — `SummaryAgg` emits a `Sketch(kind, params)` column; -`SummaryEstimate` collapses it back to a primitive column. - ---- - -## 5. L5 — stage allocation and emission (not started) - -### Stage allocator -Colors the L4 DAG by `StageId`. For the SQL path (single-stage), this is -trivial: assign every node `StageId(0)`. For multi-stage deployments (DC's -3-stage topology), this is the main algorithm. - -### `PlanEmitter` trait -Converts a stage-allocated L4 DAG to an output format. Needed -implementations per deployment model: -- `OpAMP RemoteConfig` YAML (asap-lifecycle) -- `StreamingConfig` POST body (asap-query) -- Rewritten DataFusion `LogicalPlan` (asap-fusion) - ---- - -## 6. PromQL lowering path (not started) - -`QueryLanguage::PromQL` workloads have no lowerer. Per the migration plan -(Phase 4), this requires: - -1. Define `PromqlLogicalPlan` (L2 tree) in `core::logical_plan::promql` — - five pattern shapes from asap-planner-rs as first-class nodes. -2. Implement `PromqlLowerer`: promql-parser AST → `PromqlLogicalPlan` (L1→L2). -3. Implement L2→L3: `PromqlLogicalPlan` → `QueryExpr` with `Source::TimeSeries` - leaves, `AggIntent::Rate` / `AggIntent::Increase` for counter-based metrics. -4. Add PromQL tests mirroring the SQL test suite. - ---- - -## 7. Deployment model crates (not started, per migration plan) - -| Crate | Phase | Scope | -|---|---|---| -| `deployment-model-asapfusion` | 3 | Thin model: picks core L4 rules, DataFusion emitter | -| `deployment-model-asapquery` | 4 | Migrates asap-planner-rs; adds PromQL L2 tree | -| `deployment-model-asaplifecycle` | 5 | DC-specific cost models, OpAMP emitter, 3-stage topology | - ---- - -## 8. Infrastructure / integration - -- **`cargo clippy` clean pass** — ✅ done for `lower` crate; dead-code warnings on stub types in `core` remain until those paths are implemented. -- **HTTP entry point** — no server exists yet; `lower_batch` is a library function with no HTTP handler wired up. -- **End-to-end test** — no test goes from a raw SQL string all the way to a `SummaryExpr` tree; blocked on L4 existing. -- **Benchmarks** — no criterion benchmarks for the lowering path; add at least one for `lower_batch` over a realistic query corpus. Unblocked now. -- **Phase 7 cleanup** — once all three deployment models land, revisit `todo!()`s and `#[allow(unused)]` in Phase 1 trait stubs.