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 cadcdf43..90444f3a 100644 --- a/crates/core/src/intent_algebra/expr.rs +++ b/crates/core/src/intent_algebra/expr.rs @@ -1,30 +1,41 @@ -use std::rc::Rc; +use std::sync::Arc; use std::time::Duration; +use super::expr_ir::L3Expr; use super::schema::{HasSchema, L3Schema, SchemaCatalog}; use crate::types::AccuracyTarget; -// ── 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; +pub struct Predicate(pub L3Expr); + /// One item in a SELECT projection list. #[derive(Debug, Clone)] -pub struct ProjectItem; -/// A GROUP BY key reference. -#[derive(Debug, Clone)] -pub struct GroupKey; +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)] -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. + +/// One key in an ORDER BY or window OVER clause. #[derive(Debug, Clone)] -pub struct SortKey; +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; @@ -34,15 +45,19 @@ 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; @@ -95,7 +110,7 @@ pub enum WindowFuncKind { Lead, FirstValue, LastValue, - NthValue(u64), + NthValue(Option), Sum, Avg, Count, @@ -144,6 +159,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 { @@ -176,9 +194,23 @@ pub enum AggIntent { Count { accuracy: AccuracyTarget, }, - Sum, - Min, - Max, + Sum { + col: Option, + }, + Min { + col: Option, + }, + Max { + col: Option, + }, + Avg { + col: Option, + }, + /// Sample stddev when `population == false`; population stddev otherwise. + Stddev { + col: Option, + population: bool, + }, Quantile { q: f64, accuracy: AccuracyTarget, @@ -211,12 +243,45 @@ 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, + } + } + + /// 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, .. } => col.as_ref(), + _ => None, + } } - /// 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. + /// + /// 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 { .. } => Some(L3DataType::Int64), + Self::Min { .. } | Self::Max { .. } => Some(input.dtype.clone()), + Self::Sum { .. } + | Self::Avg { .. } + | Self::Stddev { .. } + | Self::Quantile { .. } + | Self::Rate { .. } + | Self::Increase { .. } => Some(L3DataType::Float64), + Self::TopK { .. } => None, + } } } @@ -224,7 +289,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 { @@ -254,12 +319,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, }, @@ -267,10 +332,16 @@ 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, + /// 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 ────────────────────────────────────────────── @@ -278,7 +349,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, @@ -288,19 +359,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 ───────────────────────────────────────────────────────────────── @@ -309,8 +380,8 @@ pub enum QueryExpr { /// accuracy target. Join { kind: JoinKind, - left: Rc, - right: Rc, + left: Arc, + right: Arc, pred: Option, }, @@ -318,26 +389,27 @@ 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, - n: u64, + child: Arc, + /// `None` means no upper bound (only an offset applies). + n: Option, offset: u64, }, // ── Subquery / CTE ──────────────────────────────────────────────────────── Subquery { - child: Rc, + child: Arc, alias: String, }, /// SQL `WITH name AS (expr) … body`; lowering target for PromQL @@ -345,8 +417,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 ───────────────────────────────────────────── @@ -354,8 +426,11 @@ 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`). + args: Vec, partition_by: Vec, order_by: Vec, frame: Option, @@ -366,14 +441,281 @@ pub enum QueryExpr { /// including `and` / `or` / `unless`, SQL boolean composition). BinaryOp { op: BinaryOpKind, - lhs: Rc, - rhs: Rc, + lhs: Arc, + rhs: Arc, vector_match: Option, }, } 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)") + } + }, + + // ── 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()); + + let pairs: Vec<(L3Field, bool)> = cols + .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)) + .unwrap_or((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, + ) + } + // 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(); + + 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 { .. } + | QueryExpr::Limit { .. } + | QueryExpr::Distinct { .. } + | QueryExpr::Partition { .. } + | QueryExpr::TimeWindow { .. } => child().clone(), + + // ── Aggregate: GROUP BY cols + one output col per AggIntent ─────── + QueryExpr::Aggregate { + by, + aggs, + output_names, + .. + } => { + 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. 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, + }; + 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)| { + 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) + .unwrap_or(super::schema::L3DataType::Float64), + nullable: true, + } + }) + .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: all_fields, + time_index, + } + } + + // ── 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()); + + // 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), + 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.is_none_or(|f| f.nullable)) + } + WindowFuncKind::Max => { + ("max", arg_dtype(), arg_field.is_none_or(|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 {:?}", + std::mem::discriminant(self) + ), + } } } 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..4005b8fd --- /dev/null +++ b/crates/core/src/intent_algebra/expr_ir.rs @@ -0,0 +1,173 @@ +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, + 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 ───────────────────────────────────────────────────────────── + +/// 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)`. `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, + 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 { + /// 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() + } + 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 5cb42f17..16a53799 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::{ @@ -6,4 +7,5 @@ pub use expr::{ LabelFilter, MetricRef, PartitionKeys, Predicate, ProjectItem, QueryExpr, SetOpKind, SortKey, Source, TableRef, TimeRange, TimeWindowKind, VectorMatch, WindowFrame, WindowFuncKind, }; -pub use schema::{HasSchema, L3DataType, L3Field, L3Schema, SchemaCatalog}; +pub use expr_ir::{ArithOp, CompareOp, L3Expr, L3Scalar}; +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 4f6d1fc0..69034018 100644 --- a/crates/core/src/intent_algebra/schema.rs +++ b/crates/core/src/intent_algebra/schema.rs @@ -1,8 +1,45 @@ -/// 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, +} + +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 { + pub name: String, + pub data_type: L3DataType, + pub nullable: bool, +} // ── Data types ──────────────────────────────────────────────────────────────── @@ -24,7 +61,7 @@ pub enum L3DataType { // ── Schema ──────────────────────────────────────────────────────────────────── -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct L3Field { pub name: String, pub dtype: L3DataType, @@ -35,7 +72,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. @@ -49,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/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/expr_ir.rs b/crates/core/tests/expr_ir.rs new file mode 100644 index 00000000..68e49f7a --- /dev/null +++ b/crates/core/tests/expr_ir.rs @@ -0,0 +1,308 @@ +use asap_control_core::intent_algebra::{ArithOp, 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(1.5), L3Scalar::Float64(1.5)); + assert_ne!(L3Scalar::Float64(1.5), L3Scalar::Float64(2.5)); +} + +#[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"); +} + +// ── 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 new file mode 100644 index 00000000..84f91e16 --- /dev/null +++ b/crates/core/tests/schema_derivation.rs @@ -0,0 +1,1202 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use asap_control_core::intent_algebra::{ + AggIntent, ArithOp, ColumnDef, ColumnRef, DataModel, GroupKey, HasSchema, L3DataType, L3Expr, + L3Field, L3Node, L3Scalar, L3Schema, Predicate, ProjectItem, QueryExpr, SchemaCatalog, + SetOpKind, SortKey, Source, TableRef, TableSchema, WindowFuncKind, +}; +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) -> Arc { + Arc::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) +} + +fn child_schema() -> L3Schema { + schema(vec![ + field("ts", L3DataType::Int64), + nullable_field("value", L3DataType::Float64), + ]) +} +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, + ) +} +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), + ]) +} + +fn timed_two_col_schema() -> L3Schema { + schema_with_time( + vec![ + field("ts", L3DataType::Int64), + nullable_field("value", L3DataType::Float64), + ], + 0, + ) +} + +mod agg_intent_requires { + use super::*; + + // ── 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 { + col: Some(ColumnRef("x".into())) + } + .requires(), + DataModel::Any + ); + } + + #[test] + fn requires_min_is_any() { + assert_eq!( + AggIntent::Min { + col: Some(ColumnRef("x".into())) + } + .requires(), + DataModel::Any + ); + } + + #[test] + fn requires_max_is_any() { + assert_eq!( + AggIntent::Max { + col: Some(ColumnRef("x".into())) + } + .requires(), + DataModel::Any + ); + } + + #[test] + fn requires_avg_is_any() { + assert_eq!( + AggIntent::Avg { + col: Some(ColumnRef("x".into())) + } + .requires(), + DataModel::Any + ); + } + + #[test] + fn requires_stddev_sample_is_any() { + assert_eq!( + AggIntent::Stddev { + col: Some(ColumnRef("x".into())), + population: false + } + .requires(), + DataModel::Any + ); + } + + #[test] + fn requires_stddev_population_is_any() { + assert_eq!( + AggIntent::Stddev { + col: Some(ColumnRef("x".into())), + 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 + ); + } +} + +mod agg_intent_output_type { + use super::*; + + // ── 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), + Some(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), + Some(L3DataType::Int64) + ); + } + + #[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_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_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_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_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) + ); + } + + #[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_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_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_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_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_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 + ); + } +} + +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![], + }; + 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); + } +} + +mod passthrough_schemas { + use super::*; + + // ── HasSchema::output_schema() — pass-through nodes ────────────────────────── + + #[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 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 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 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); + } +} + +mod aggregate_schema { + use super::*; + + // ── HasSchema::output_schema() — Aggregate ──────────────────────────────────── + + #[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_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_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) + } + + #[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 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_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_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"); + } +} + +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: 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); + } +} + +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); + } + + #[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)); + } +} + +mod merge_and_set_op_schema { + use super::*; + + // ── 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)); + } +} + +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); + } + + #[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(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_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. + 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/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..113ef582 --- /dev/null +++ b/crates/lower/src/error.rs @@ -0,0 +1,37 @@ +use std::fmt; + +#[derive(Debug)] +pub enum LoweringError { + DataFusion(datafusion::error::DataFusionError), + TableNotFound(String), + /// A SQL feature (JOIN, subquery, etc.) not supported in this version. + UnsupportedFeature(String), + UnsupportedAggregate(String), + 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 { + 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::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}"), + Self::UnsupportedDialect(d) => write!(f, "unsupported SQL dialect: {d}"), + } + } +} + +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..3fdb6e97 --- /dev/null +++ b/crates/lower/src/lib.rs @@ -0,0 +1,71 @@ +pub mod error; +pub mod schema_pass; +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, 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`. +/// +/// 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, +) -> Vec> { + let entries = match &workload.query_batch { + Some(e) if !e.is_empty() => e, + _ => return vec![], + }; + + // 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 + ) { + let lang = format!("{:?}", workload.language); + return entries + .iter() + .map(|_| Err(LoweringError::WrongLanguage(lang.clone()))) + .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 + .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/schema_pass.rs b/crates/lower/src/schema_pass.rs new file mode 100644 index 00000000..1ec5b0b1 --- /dev/null +++ b/crates/lower/src/schema_pass.rs @@ -0,0 +1,166 @@ +use std::sync::Arc; + +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 +/// `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); + Arc::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 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. + 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, + output_names, + } => { + let c = proc(child); + let cs = c.schema.clone(); + ( + Aggregate { + child: c, + by, + aggs, + having, + output_names, + }, + 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/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/mod.rs b/crates/lower/src/sql/mod.rs new file mode 100644 index 00000000..4b85a129 --- /dev/null +++ b/crates/lower/src/sql/mod.rs @@ -0,0 +1,721 @@ +use std::sync::Arc; + +use datafusion::common::ScalarValue; +use datafusion::datasource::MemTable; +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, WindowFuncKind, +}; +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, +} + +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) => 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(); + 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_untyped_node(left), + right: make_untyped_node(right), + }) + }) + } + 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(); + 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, + time_range: None, + }, + predicates: vec![], + }) + } + + fn lower_filter(&self, filter: &logical_expr::Filter) -> Result { + // 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 { + 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 { + table_ref: TableRef(table_name), + columns, + time_range, + }, + predicates: vec![], + }; + return if non_time.is_empty() { + Ok(scan_expr) + } else { + let pred_expr = conjuncts_to_l3expr(non_time)?; + Ok(QueryExpr::Filter { + child: make_untyped_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_untyped_node(child), + pred: Predicate(pred_expr), + }) + } + + fn lower_projection( + &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 + .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::, _>>()?; + + // 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_untyped_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::, _>>()?; + // 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. + // 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 + .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, + aggs, + having: None, + output_names, + }) + } + + 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)?; + 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_untyped_node(child), + keys, + }) + } + + 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) { + 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_untyped_node(child), + n: eval_fetch(&limit.fetch).map(|v| v 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_untyped_node(child), + by: vec![], + aggs: vec![AggIntent::TopK { + k, + by, + 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 + .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 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(None)) { + 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(Some(n)) + } else { + func + }; + debug_assert!( + !matches!(func, WindowFuncKind::NthValue(None)), + "NthValue sentinel not resolved; lower_window has a bug" + ); + + let partition_by = wf + .partition_by + .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_untyped_node(child), + func, + args, + partition_by, + order_by, + frame: None, + }); + } + Err(LoweringError::UnsupportedFeature( + "unexpected non-WindowFunction expr in Window plan node".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 { + 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 { + 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 ────────────────────────────────────────────────────────────── + +/// 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` (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. +/// +/// Handled topologies: `Project → Scan` and `Project → Filter → Scan`. +/// +/// **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. + QueryExpr::Filter { child: inner, pred } => { + let updated = push_columns_into_scan(inner.expr.clone(), cols); + QueryExpr::Filter { + child: Arc::new(L3Node { + expr: updated, + schema: inner.schema.clone(), + }), + pred, + } + } + QueryExpr::Scan { + source: + Source::Table { + table_ref, + columns, + 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, + } +} + +fn make_untyped_node(expr: QueryExpr) -> Arc { + Arc::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))) if *v >= 0 => Some(*v as usize), + Expr::Literal(ScalarValue::UInt64(Some(v))) => Some(*v as usize), + Expr::Literal(ScalarValue::Int32(Some(v))) if *v >= 0 => 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), + other => Err(LoweringError::UnsupportedFeature(format!( + "non-column GROUP BY expression: {other}" + ))), + } +} + +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), + other => Err(LoweringError::UnsupportedFeature(format!( + "non-column reference in TopK by-list: {other}" + ))), + } +} + +/// 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)) => Some(ColumnRef(col.name.clone())), + Some(Expr::Alias(a)) => match a.expr.as_ref() { + Expr::Column(col) => Some(ColumnRef(col.name.clone())), + _ => None, + }, + Some(Expr::Cast(c)) => match c.expr.as_ref() { + Expr::Column(col) => Some(ColumnRef(col.name.clone())), + _ => None, + }, + Some(Expr::Wildcard { .. }) | None => None, + _ => None, + } +} + +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(), + )), + } +} + +/// 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), + } +} + +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), + // 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}" + ))), + }, + 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}" + ))), + }, + // In DataFusion 43, BuiltInWindowFunction covers FirstValue, LastValue, NthValue. + // NthValue(None) is a sentinel; 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(None)), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── 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/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 new file mode 100644 index 00000000..b8f3d888 --- /dev/null +++ b/crates/lower/tests/sql_lowering.rs @@ -0,0 +1,2027 @@ +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, SetOpKind}; +use asap_control_core::types::AccuracyTarget; +use asap_control_core::workload::{BatchEntry, Query, QueryLanguage, QueryWorkload, SqlDialect}; +use asap_control_lower::{lower_batch, populate_schemas, 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, + }, + ColumnDef { + name: "name".to_string(), + data_type: L3DataType::Utf8, + 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 predicate of the first Filter node. +fn find_predicate(expr: &QueryExpr) -> 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 { + 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, + } +} + +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, + } +} + +mod scan_filter { + use super::*; + + // ── 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" + ); + } +} + +mod aggregates_and_topk { + use super::*; + + // ── 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_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}" + ); + } +} + +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"); + } + + // ── 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}" + ); + } +} + +mod predicates_and_exprs { + use super::*; + + // ── 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"); + } + + // ── 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, + .. + } + )); + } +} + +mod batch_and_catalog { + use super::*; + + // ── Tests: lower_batch ──────────────────────────────────────────────────────── + + #[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(); + 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: 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] + 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"); + } +} + +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" + ); + } + + #[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] + ); + } +} + +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:?}" + ); + } + + // ── 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, + } + } + 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" + ); + } +} + +mod expressions { + use super::*; + + // ── 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:?}" + ); + } +} + +mod schemas { + use super::*; + + // ── 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"); + } + + // ── 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.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:?}" + ); + } + + // ── 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; + + 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" + ); + } + } + } + + // ── 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" + ); + } + + // ── 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:?}" + ); + } +}