From b5a6126852521fd25cca820aeb846e08756bd651 Mon Sep 17 00:00:00 2001 From: Abhinav Gyawali <22275402+abhizer@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:37:33 +0000 Subject: [PATCH 01/22] deps(py): bump pyJWT and pyarrow versions Bumps pyJWT to 2.13.0 in python packages, and pyarrow to 23.0.1 in iceberg test requirements. Signed-off-by: Abhinav Gyawali <22275402+abhizer@users.noreply.github.com> --- crates/iceberg/src/test/requirements.ci.txt | 6 +++--- crates/iceberg/src/test/requirements.txt | 6 +++--- python/dbt-feldera/uv.lock | 10 +++++----- python/pyproject.toml | 2 +- python/uv.lock | 10 +++++----- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/crates/iceberg/src/test/requirements.ci.txt b/crates/iceberg/src/test/requirements.ci.txt index 34810ec9daa..2ae2a45c01e 100644 --- a/crates/iceberg/src/test/requirements.ci.txt +++ b/crates/iceberg/src/test/requirements.ci.txt @@ -1,7 +1,7 @@ # Only include requirements used in CI (no s3 or glue pyiceberg features). numpy==2.2.0 pandas==2.2.3 -pyarrow==17.0.0 -pyiceberg[sql-sqlite]==0.8.1 +pyarrow==23.0.1 +pyiceberg[sql-sqlite,pyiceberg-core]==0.11.1 # Transitive dependency via pyiceberg. -pydantic==2.11.10 +pydantic==2.13.4 diff --git a/crates/iceberg/src/test/requirements.txt b/crates/iceberg/src/test/requirements.txt index 075c265c13c..4c375ca5ce5 100644 --- a/crates/iceberg/src/test/requirements.txt +++ b/crates/iceberg/src/test/requirements.txt @@ -1,6 +1,6 @@ numpy==2.2.0 pandas==2.2.3 -pyarrow==17.0.0 -pyiceberg[s3fs,glue,sql-sqlite]==0.8.1 +pyarrow==23.0.1 +pyiceberg[s3fs,glue,sql-sqlite,pyiceberg-core]==0.11.1 # Transitive dependency via pyiceberg. -pydantic==2.11.10 +pydantic==2.13.4 diff --git a/python/dbt-feldera/uv.lock b/python/dbt-feldera/uv.lock index c1bf1c7e4fe..48da35a5bd8 100644 --- a/python/dbt-feldera/uv.lock +++ b/python/dbt-feldera/uv.lock @@ -12,7 +12,7 @@ resolution-markers = [ ] [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-06-11T14:17:25.329850715Z" exclude-newer-span = "P1W" [[package]] @@ -305,7 +305,7 @@ wheels = [ [[package]] name = "dbt-feldera" -version = "0.299.0" +version = "0.313.0" source = { editable = "." } dependencies = [ { name = "agate" }, @@ -1207,14 +1207,14 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.12.1" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, ] [[package]] diff --git a/python/pyproject.toml b/python/pyproject.toml index 585396759dd..692307f3b88 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ "numpy>=2.2.4", "pretty-errors", "ruff>=0.6.9", - "PyJWT>=2.12.0", + "PyJWT>=2.13.0", "tenacity>=9.1.4", "pyarrow>=24.0", ] diff --git a/python/uv.lock b/python/uv.lock index 653b1d64532..c4d340e2946 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -12,7 +12,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-06-16T05:50:17.266848806Z" +exclude-newer = "2026-06-11T14:17:18.675358746Z" exclude-newer-span = "P1W" [[package]] @@ -360,7 +360,7 @@ requires-dist = [ { name = "pandas", specifier = ">=2.1.2" }, { name = "pretty-errors" }, { name = "pyarrow", specifier = ">=24.0" }, - { name = "pyjwt", specifier = ">=2.12.0" }, + { name = "pyjwt", specifier = ">=2.13.0" }, { name = "requests" }, { name = "ruff", specifier = ">=0.6.9" }, { name = "tenacity", specifier = ">=9.1.4" }, @@ -885,14 +885,14 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.12.1" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, ] [[package]] From 31558f6010fc643995c52cd854000319542b0d6e Mon Sep 17 00:00:00 2001 From: Mihai Budiu Date: Sun, 21 Jun 2026 17:47:50 -0700 Subject: [PATCH 02/22] [connectors] Support all CSV options for input connector Signed-off-by: Mihai Budiu --- crates/adapters/src/format/csv.rs | 479 ++++++++++++++++-- crates/adapters/src/static_compile/deinput.rs | 12 +- crates/feldera-types/src/format/csv.rs | 73 ++- docs.feldera.com/docs/formats/csv.md | 13 +- 4 files changed, 517 insertions(+), 60 deletions(-) diff --git a/crates/adapters/src/format/csv.rs b/crates/adapters/src/format/csv.rs index dca7b8a9b0b..03d6fd01f54 100644 --- a/crates/adapters/src/format/csv.rs +++ b/crates/adapters/src/format/csv.rs @@ -31,6 +31,34 @@ use super::{InputBuffer, Splitter}; /// truncate it to `MAX_RECORD_LEN_IN_ERRMSG` bytes. static MAX_RECORD_LEN_IN_ERRMSG: usize = 4096; +fn csv_trim(t: &feldera_types::format::csv::CsvTrim) -> csv::Trim { + use feldera_types::format::csv::CsvTrim; + match t { + CsvTrim::None => csv::Trim::None, + CsvTrim::Headers => csv::Trim::Headers, + CsvTrim::Fields => csv::Trim::Fields, + CsvTrim::All => csv::Trim::All, + } +} + +/// Build a [`csv::ReaderBuilder`] fully configured from `config`. +/// +/// `has_headers` is always `false` — the adapter layer skips the header row +/// itself and must not pass it to the underlying CSV reader. +pub(crate) fn csv_reader_builder(config: &CsvParserConfig) -> csv::ReaderBuilder { + let mut b = csv::ReaderBuilder::new(); + b.has_headers(false) + .delimiter(config.delimiter().0) + .quote(config.quote as u8) + .escape(config.escape.map(|c| c as u8)) + .double_quote(config.double_quote) + .quoting(config.quoting) + .comment(config.comment.map(|c| c as u8)) + .flexible(config.flexible) + .trim(csv_trim(&config.trim)); + b +} + /// CSV format parser. pub struct CsvInputFormat; @@ -65,11 +93,10 @@ impl InputFormat for CsvInputFormat { ) })?; - let headers = config.headers; let input_stream = input_stream .handle - .configure_deserializer(RecordFormat::Csv(config))?; - Ok(Box::new(CsvParser::new(input_stream, headers)) as Box) + .configure_deserializer(RecordFormat::Csv(config.clone()))?; + Ok(Box::new(CsvParser::new(input_stream, &config)) as Box) } } @@ -80,18 +107,37 @@ struct CsvParser { /// Whether the input starts with a header row. headers: bool, + /// Quote character (as a byte), used for record splitting. + quote: u8, + + /// Whether quoting is enabled. When `false`, every newline ends a record. + quoting: bool, + + /// Optional comment character. Lines whose first byte matches this value + /// are skipped and never passed to the deserializer. + comment: Option, + last_event_number: u64, } impl CsvParser { - fn new(input_stream: Box, headers: bool) -> Self { + fn new(input_stream: Box, config: &CsvParserConfig) -> Self { Self { input_stream, last_event_number: 0, - headers, + headers: config.headers, + quote: config.quote as u8, + quoting: config.quoting, + comment: config.comment.map(|c| c as u8), } } + /// Returns `true` if `record` is a comment line (its first byte matches + /// the configured comment character). + fn is_comment(&self, record: &[u8]) -> bool { + self.comment.is_some_and(|c| record.first() == Some(&c)) + } + fn parse_record( &mut self, record: &[u8], @@ -118,26 +164,15 @@ impl CsvParser { /// Tries to split `buffer` into a CSV record followed by any remaining text. /// - /// A CSV record is usually one line that ends in a new-line, but it can - /// span multiple lines if new-lines are enclosed in double quotes. + /// A CSV record is usually one line that ends in a newline, but it can + /// span multiple lines if newlines appear inside a quoted field. fn split_record<'a>(&mut self, buffer: &'a [u8]) -> Option<(&'a [u8], &'a [u8])> { - // This uses the simple rule that a new-line ends a record if it is not - // in double quotes. The "standard" format for CSV escapes double - // quotes by doubling them (e.g. `"a""b""c"` unescapes to `a"b"c`), so - // that any even number of quotes followed by a new-line ends a reocrd, - // but any odd number followed by a new-line is a continuation of the - // field. This means that this rule properly handles escapes. - // - // If we allow the user to configure the CSV format used, we'll need to - // adjust this to match the configuration. let mut quoted = false; - for (offset, c) in buffer.iter().enumerate() { - match c { - b'"' => quoted = !quoted, - b'\n' if !quoted => { - return Some(buffer.split_at(offset + 1)); - } - _ => (), + for (offset, &c) in buffer.iter().enumerate() { + if self.quoting && c == self.quote { + quoted = !quoted; + } else if c == b'\n' && !quoted { + return Some(buffer.split_at(offset + 1)); } } None @@ -146,11 +181,23 @@ impl CsvParser { impl Parser for CsvParser { fn fork(&self) -> Box { - Box::new(Self::new(self.input_stream.fork(), self.headers)) + Box::new(Self { + input_stream: self.input_stream.fork(), + headers: self.headers, + quote: self.quote, + quoting: self.quoting, + comment: self.comment, + last_event_number: 0, + }) } fn splitter(&self) -> Box { - Box::new(CsvSplitter::new(self.headers)) + Box::new(CsvSplitter::new( + self.headers, + self.quote, + self.quoting, + self.comment, + )) } fn parse( @@ -162,11 +209,13 @@ impl Parser for CsvParser { let mut errors = Vec::new(); while let Some((record, rest)) = self.split_record(data) { - self.parse_record(record, &metadata, &mut errors); - self.last_event_number += 1; + if !self.is_comment(record) { + self.parse_record(record, &metadata, &mut errors); + self.last_event_number += 1; + } data = rest; } - if !data.is_empty() { + if !data.is_empty() && !self.is_comment(data) { self.parse_record(data, &metadata, &mut errors); } (self.input_stream.take_all(), errors) @@ -180,39 +229,61 @@ impl Parser for CsvParser { struct CsvSplitter { quoted: bool, headers: bool, + quote: u8, + quoting: bool, + /// Optional comment character: lines whose first byte matches this value + /// are skipped entirely. + comment: Option, + /// True when the next byte to be processed begins a new line. + at_line_start: bool, + /// True while consuming a comment line (until the terminating newline). + in_comment: bool, } impl CsvSplitter { - fn new(headers: bool) -> Self { + fn new(headers: bool, quote: u8, quoting: bool, comment: Option) -> Self { Self { quoted: false, headers, + quote, + quoting, + comment, + at_line_start: true, + in_comment: false, } } } impl Splitter for CsvSplitter { fn input(&mut self, data: &[u8]) -> Option { - // This uses the simple rule that a new-line ends a record if it is not - // in double quotes. The "standard" format for CSV escapes double - // quotes by doubling them (e.g. `"a""b""c"` unescapes to `a"b"c`), so - // that any even number of quotes followed by a new-line ends a reocrd, - // but any odd number followed by a new-line is a continuation of the - // field. This means that this rule properly handles escapes. - // - // If we allow the user to configure the CSV format used, we'll need to - // adjust this to match the configuration. - for (offset, c) in data.iter().enumerate() { - match c { - b'"' => self.quoted = !self.quoted, - b'\n' if !self.quoted => { - if self.headers { - self.headers = false; - } else { - return Some(offset + 1); - } + for (offset, &c) in data.iter().enumerate() { + // While inside a comment line, skip everything up to the newline. + if self.in_comment { + if c == b'\n' { + self.in_comment = false; + self.at_line_start = true; + } + continue; + } + + // At the start of a line: check whether this is a comment line. + if self.at_line_start { + self.at_line_start = false; + if self.comment == Some(c) { + self.in_comment = true; + continue; + } + } + + if self.quoting && c == self.quote { + self.quoted = !self.quoted; + } else if c == b'\n' && !self.quoted { + self.at_line_start = true; + if self.headers { + self.headers = false; + } else { + return Some(offset + 1); } - _ => (), } } None @@ -220,6 +291,8 @@ impl Splitter for CsvSplitter { fn clear(&mut self) { self.quoted = false; + self.in_comment = false; + self.at_line_start = true; } } @@ -407,6 +480,10 @@ mod test { use feldera_types::deserialize_table_record; use feldera_types::serde_with_context::{DeserializeWithContext, SqlSerdeConfig}; + use super::CsvSplitter; + use crate::format::Splitter; + use feldera_types::format::csv::{CsvParserConfig, CsvTrim}; + #[derive(Debug, Eq, PartialEq)] #[allow(non_snake_case)] struct CaseSensitive { @@ -453,4 +530,310 @@ true,bar,buzz"#; ) ); } + + // ---- CsvSplitter option tests ---- + + // `headers = true`: the first newline ends the header row and is consumed + // without producing a record; subsequent newlines are record boundaries. + #[test] + fn splitter_headers() { + let mut s = CsvSplitter::new(true, b'"', true, None); + // Header row: consumed silently, no record returned. + assert_eq!(s.input(b"col1,col2\n"), None); + // First data record. + assert_eq!(s.input(b"foo,bar\n"), Some(8)); + } + + // `headers = true` with a comment before the header line. + #[test] + fn splitter_headers_with_leading_comment() { + let mut s = CsvSplitter::new(true, b'"', true, Some(b'#')); + // Comment before the header: skipped. + assert_eq!(s.input(b"# preamble\n"), None); + // Header row: consumed silently. + assert_eq!(s.input(b"col1,col2\n"), None); + // First data record. + assert_eq!(s.input(b"foo,bar\n"), Some(8)); + } + + // Default quoting: a newline inside a double-quoted span does not split. + #[test] + fn splitter_default_no_split_inside_quotes() { + let mut s = CsvSplitter::new(false, b'"', true, None); + // Opening quote is present; newline is inside the quoted span. + assert_eq!(s.input(b"\"foo\n"), None); + // Closing quote followed by newline terminates the record. + assert_eq!(s.input(b"bar\"\n"), Some(5)); + } + + // Custom quote character: single-quote acts as the quoting delimiter. + #[test] + fn splitter_custom_quote() { + let mut s = CsvSplitter::new(false, b'\'', true, None); + // Newline inside single-quoted span — no split. + assert_eq!(s.input(b"foo,'bar\n"), None); + // Closing single-quote then newline — split. + assert_eq!(s.input(b"baz'\n"), Some(5)); + } + + // `quoting = false`: every newline ends a record even when inside quotes. + #[test] + fn splitter_quoting_disabled() { + let mut s = CsvSplitter::new(false, b'"', false, None); + // Newline inside "quotes" splits immediately because quoting is off. + assert_eq!(s.input(b"\"foo\n"), Some(5)); + } + + // `comment`: comment lines are skipped; the next real record is returned. + #[test] + fn splitter_comment() { + let mut s = CsvSplitter::new(false, b'"', true, Some(b'#')); + // A comment line is consumed without being returned as a record. + assert_eq!(s.input(b"# comment\n"), None); + // The following data record is returned normally. + assert_eq!(s.input(b"foo,bar\n"), Some(8)); + } + + // Comment line interleaved with data records in a single chunk. + #[test] + fn splitter_comment_interleaved() { + let mut s = CsvSplitter::new(false, b'"', true, Some(b'#')); + // First record returned; comment + second record still pending. + assert_eq!(s.input(b"a,b\n# skip\nc,d\n"), Some(4)); + // After the first record is consumed, the comment is skipped and the + // second record is returned. + assert_eq!(s.input(b"# skip\nc,d\n"), Some(11)); + } + + // ---- End-to-end tests via mock_parser_pipeline ---- + // + // These tests push bytes through a real CsvParser (the same code path that + // a live input connector uses) and verify the deserialized records. + + use crate::FormatConfig; + use crate::format::InputBuffer; + use crate::test::mock_parser_pipeline; + use feldera_adapterlib::format::Parser; + use feldera_types::deserialize_without_context; + use feldera_types::program_schema::Relation; + use serde::{Deserialize, Serialize}; + use std::borrow::Cow; + + /// Two-string record used by the end-to-end tests below. + #[derive(Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Clone)] + struct TwoStrings { + first: String, + second: String, + } + + deserialize_without_context!(TwoStrings); + + /// Push `data` through a fully-configured `CsvParser` and return the + /// inserted `TwoStrings` records. + fn e2e_parse_csv(config: CsvParserConfig, data: &[u8]) -> Vec { + let format_config = FormatConfig { + name: Cow::from("csv"), + config: serde_json::to_value(config).unwrap(), + }; + let (_, mut parser, input_handle) = + mock_parser_pipeline::(&Relation::empty(), &format_config) + .unwrap(); + let (buf, errors) = parser.parse(data, None); + assert!(errors.is_empty(), "unexpected parse errors: {errors:?}"); + if let Some(mut buf) = buf { + buf.flush(); + } + input_handle + .state() + .flushed + .iter() + .map(|upd| upd.unwrap_insert().clone()) + .collect() + } + + // `quote`: single-quote wraps a field containing a comma. + #[test] + fn csv_e2e_quote() { + let rows = e2e_parse_csv( + CsvParserConfig { + quote: '\'', + ..Default::default() + }, + b"foo,'bar,baz'\nqux,'quux,corge'\n", + ); + assert_eq!(rows.len(), 2); + assert_eq!( + rows[0], + TwoStrings { + first: "foo".into(), + second: "bar,baz".into() + } + ); + assert_eq!( + rows[1], + TwoStrings { + first: "qux".into(), + second: "quux,corge".into() + } + ); + } + + // `escape` + `double_quote = false`: backslash escapes a literal quote. + #[test] + fn csv_e2e_escape() { + let rows = e2e_parse_csv( + CsvParserConfig { + escape: Some('\\'), + double_quote: false, + ..Default::default() + }, + // "foo\"bar" → foo"bar + b"\"foo\\\"bar\",baz\n", + ); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].first, "foo\"bar"); + assert_eq!(rows[0].second, "baz"); + } + + // `double_quote = true` (default): `""` inside a quoted field is one `"`. + #[test] + fn csv_e2e_double_quote() { + let rows = e2e_parse_csv( + CsvParserConfig::default(), + // "foo""bar" → foo"bar + b"\"foo\"\"bar\",baz\n", + ); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].first, "foo\"bar"); + assert_eq!(rows[0].second, "baz"); + } + + // `double_quote = false`: `""` is not collapsed; the first `"` ends the + // quoted field, and the remainder of the token is appended unquoted. + // (Matches csv-core's `quote_escapes_no_double` test: `"a""b"` → `a"b"`.) + #[test] + fn csv_e2e_double_quote_disabled() { + let rows = e2e_parse_csv( + CsvParserConfig { + double_quote: false, + ..Default::default() + }, + b"\"foo\"\"bar\",baz\n", + ); + assert_eq!(rows.len(), 1); + assert_ne!(rows[0].first, "foo\"bar"); // not collapsed + assert_eq!(rows[0].second, "baz"); + } + + // `trim = None` (default): leading and trailing whitespace is preserved. + #[test] + fn csv_e2e_trim_none() { + let rows = e2e_parse_csv(CsvParserConfig::default(), b" foo , bar \n"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].first, " foo "); + assert_eq!(rows[0].second, " bar "); + } + + // `quoting = false`: quote characters are treated as ordinary bytes. + #[test] + fn csv_e2e_quoting_disabled() { + let rows = e2e_parse_csv( + CsvParserConfig { + quoting: false, + ..Default::default() + }, + // The outer " chars are kept verbatim in the field value. + b"\"hello\",world\n", + ); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].first, "\"hello\""); + assert_eq!(rows[0].second, "world"); + } + + // `comment`: lines starting with `#` are skipped. + #[test] + fn csv_e2e_comment() { + let rows = e2e_parse_csv( + CsvParserConfig { + comment: Some('#'), + ..Default::default() + }, + b"# opening comment\nfoo,bar\n# mid comment\nbaz,qux\n", + ); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].first, "foo"); + assert_eq!(rows[1].first, "baz"); + } + + // `flexible = true` (default): a record with extra fields is accepted; + // only the first two are used for deserialization. + #[test] + fn csv_e2e_flexible_true() { + let rows = e2e_parse_csv( + CsvParserConfig::default(), // flexible=true + b"foo,bar\nqux,quux,extra\n", + ); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].first, "foo"); + assert_eq!(rows[1].first, "qux"); + assert_eq!(rows[1].second, "quux"); + } + + // `flexible = false`: a record whose field count differs from the first + // record's count produces a parse error. + #[test] + fn csv_e2e_flexible_false() { + let format_config = FormatConfig { + name: Cow::from("csv"), + config: serde_json::to_value(CsvParserConfig { + flexible: false, + ..Default::default() + }) + .unwrap(), + }; + let (_, mut parser, _) = + mock_parser_pipeline::(&Relation::empty(), &format_config) + .unwrap(); + + // Suppress the default panic-on-error so we can inspect the error list. + parser.on_error(Some(Box::new(|_fatal, _err| {}))); + + // Record 1: 2 fields (sets the expected count). + // Record 2: 3 fields (different count → error with flexible=false). + let (_buf, errors) = parser.parse(b"foo,bar\nqux,quux,extra\n", None); + assert!( + !errors.is_empty(), + "expected a parse error for flexible=false with mismatched field counts" + ); + } + + // `trim = Fields`: leading/trailing whitespace is stripped from field values. + #[test] + fn csv_e2e_trim_fields() { + let rows = e2e_parse_csv( + CsvParserConfig { + trim: CsvTrim::Fields, + ..Default::default() + }, + b" foo , bar \n", + ); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].first, "foo"); + assert_eq!(rows[0].second, "bar"); + } + + // `headers = true`: the first line is a header row and is skipped. + #[test] + fn csv_e2e_headers() { + let rows = e2e_parse_csv( + CsvParserConfig { + headers: true, + ..Default::default() + }, + b"first,second\nfoo,bar\n", + ); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].first, "foo"); + assert_eq!(rows[0].second, "bar"); + } } diff --git a/crates/adapters/src/static_compile/deinput.rs b/crates/adapters/src/static_compile/deinput.rs index a76109a99e8..671ab9dc701 100644 --- a/crates/adapters/src/static_compile/deinput.rs +++ b/crates/adapters/src/static_compile/deinput.rs @@ -2,7 +2,7 @@ use crate::catalog::AvroStream; #[cfg(feature = "with-avro")] use crate::format::avro::from_avro_value; -use crate::format::csv::deserializer::ByteRecordDeserializer; +use crate::format::csv::{csv_reader_builder, deserializer::ByteRecordDeserializer}; use crate::format::raw::{RawDeserializer, raw_serde_config}; use crate::{ ControllerError, DeCollectionHandle, @@ -97,13 +97,9 @@ pub struct CsvDeserializerFromBytes { impl DeserializerFromBytes<(SqlSerdeConfig, CsvParserConfig)> for CsvDeserializerFromBytes { fn create((serde_config, csv_config): (SqlSerdeConfig, CsvParserConfig)) -> Self { CsvDeserializerFromBytes { - reader: csv::ReaderBuilder::new() - // We skip the headers ourselves, without passing them to the - // reader, so we unconditionally turn off headers in the reader. - .has_headers(false) - .flexible(true) - .delimiter(csv_config.delimiter().0) - .from_reader(VecDeque::new()), + // `csv_reader_builder` sets `has_headers(false)` — the adapter + // layer handles header-row skipping itself. + reader: csv_reader_builder(&csv_config).from_reader(VecDeque::new()), record: csv::ByteRecord::new(), config: serde_config, } diff --git a/crates/feldera-types/src/format/csv.rs b/crates/feldera-types/src/format/csv.rs index 84566466f04..4f08ac2e625 100644 --- a/crates/feldera-types/src/format/csv.rs +++ b/crates/feldera-types/src/format/csv.rs @@ -3,16 +3,76 @@ use std::fmt::Debug; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; -#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +/// Whitespace trimming policy applied to CSV fields or header names. +#[derive(Clone, Debug, Default, Deserialize, Serialize, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CsvTrim { + /// Do not trim whitespace (default). + #[default] + None, + /// Trim whitespace from header names only. + Headers, + /// Trim whitespace from field values only. + Fields, + /// Trim whitespace from both header names and field values. + All, +} + +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema, PartialEq)] #[serde(default)] pub struct CsvParserConfig { /// Field delimiter (default `','`). /// - /// This must be an ASCII character. + /// Must be an ASCII character. pub delimiter: char, - /// Whether the input begins with a header line (which is ignored). + /// Whether the input begins with a header line (which is skipped). pub headers: bool, + + /// The quote character (default `'"'`). + /// + /// Must be an ASCII character. Set `quoting` to `false` to disable + /// quoting entirely. + pub quote: char, + + /// The escape character for quoted fields (default: `None`). + /// + /// When `None` (the default), the CSV parser uses the double-quote + /// convention: a literal quote inside a quoted field is written as two + /// consecutive quote characters. When set, the given character is used + /// as the escape prefix instead (e.g. `Some('\\')` for backslash + /// escaping). + /// + /// Must be an ASCII character. + pub escape: Option, + + /// Enable double-quote escaping (default `true`). + /// + /// When `true`, a quote character inside a quoted field may be escaped by + /// doubling it. Setting this to `false` disables double-quote escaping + /// (an explicit `escape` character can still be used). + pub double_quote: bool, + + /// Enable quoting (default `true`). + /// + /// When `false`, the `quote` and `escape` characters have no special + /// meaning and every newline terminates a record regardless of context. + pub quoting: bool, + + /// Comment character (default: `None`). + /// + /// When set, lines whose first byte matches this character are treated as + /// comments and skipped entirely. Must be an ASCII character. + pub comment: Option, + + /// Allow records with a variable number of fields (default `true`). + /// + /// When `true`, records that have fewer or more fields than expected are + /// accepted rather than treated as errors. + pub flexible: bool, + + /// Whitespace trimming policy (default [`CsvTrim::None`]). + pub trim: CsvTrim, } impl CsvParserConfig { @@ -26,6 +86,13 @@ impl Default for CsvParserConfig { Self { delimiter: CsvDelimiter::default().0.into(), headers: false, + quote: '"', + escape: None, + double_quote: true, + quoting: true, + comment: None, + flexible: true, + trim: CsvTrim::None, } } } diff --git a/docs.feldera.com/docs/formats/csv.md b/docs.feldera.com/docs/formats/csv.md index 8fb320799a7..3a560691e29 100644 --- a/docs.feldera.com/docs/formats/csv.md +++ b/docs.feldera.com/docs/formats/csv.md @@ -177,4 +177,15 @@ create table FAILED_BANKS ( ``` - `delimiter`: A single character that delimits fields. The default is `","`. -- `headers`: Whether the first line of input is a header line. If this is set to true, Feldera ignores the first line. This applies only to input connectors (Feldera never writes a header line for CSV output). The default is `false`. +- `headers`: Whether the first line of input is a header line. If this is set to `true`, Feldera ignores the first line. This applies only to input connectors (Feldera never writes a header line for CSV output). The default is `false`. +- `quote`: The quote character used to wrap fields that contain the delimiter or newlines. Must be an ASCII character. The default is `'"'`. Set `quoting` to `false` to disable quoting entirely. +- `escape`: An escape character that can precede a quote inside a quoted field to produce a literal quote byte (e.g., `"\\"` for backslash). When unset (the default), the `double_quote` convention is used instead. +- `double_quote`: When `true` (the default), a quote character inside a quoted field may be escaped by doubling it (e.g., `""` represents a single `"`). Setting this to `false` disables that convention; use `escape` to escape quotes in that case. +- `quoting`: When `true` (the default), the `quote` and `escape` characters have their special meaning. When `false`, every newline terminates a record regardless of context, and neither `quote` nor `escape` is treated specially. +- `comment`: A single ASCII character that marks a line as a comment. Lines whose first byte matches this character are skipped entirely. Unset by default. +- `flexible`: When `true` (the default), records may have fewer or more fields than expected. Missing fields receive empty values. When `false`, every record must have exactly the same number of fields as the first record; a mismatch is reported as an error. +- `trim`: Controls whitespace trimming. Allowed values: + - `"none"` (default) — no trimming. + - `"fields"` — strip leading and trailing ASCII whitespace from every field value. + - `"headers"` — strip whitespace from header names only (effective only when `headers` is `true`). + - `"all"` — strip whitespace from both field values and header names. From 3c721b1a94e1e67b8dbd8b41602e7707d38e47d1 Mon Sep 17 00:00:00 2001 From: "release-feldera-feldera[bot]" Date: Wed, 24 Jun 2026 19:06:39 +0000 Subject: [PATCH 03/22] ci: Prepare for v0.315.0 --- Cargo.lock | 42 +++++++++++------------ Cargo.toml | 26 +++++++------- openapi.json | 2 +- python/dbt-feldera/pyproject.toml | 2 +- python/felderize/pyproject.toml | 2 +- python/pyproject.toml | 2 +- python/uv.lock | 4 +-- sql-to-dbsp-compiler/SQL-compiler/pom.xml | 2 +- 8 files changed, 41 insertions(+), 41 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8fd21ea182d..61b9f81139f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3952,7 +3952,7 @@ dependencies = [ [[package]] name = "dbsp" -version = "0.314.0" +version = "0.315.0" dependencies = [ "anyhow", "arc-swap", @@ -4042,7 +4042,7 @@ dependencies = [ [[package]] name = "dbsp_adapters" -version = "0.314.0" +version = "0.315.0" dependencies = [ "actix", "actix-codec", @@ -4185,7 +4185,7 @@ dependencies = [ [[package]] name = "dbsp_nexmark" -version = "0.314.0" +version = "0.315.0" dependencies = [ "anyhow", "ascii_table", @@ -5157,7 +5157,7 @@ dependencies = [ [[package]] name = "fda" -version = "0.314.0" +version = "0.315.0" dependencies = [ "anyhow", "arrow", @@ -5209,7 +5209,7 @@ dependencies = [ [[package]] name = "feldera-adapterlib" -version = "0.314.0" +version = "0.315.0" dependencies = [ "actix-web", "anyhow", @@ -5243,7 +5243,7 @@ dependencies = [ [[package]] name = "feldera-buffer-cache" -version = "0.314.0" +version = "0.315.0" dependencies = [ "crossbeam-utils", "enum-map", @@ -5271,7 +5271,7 @@ dependencies = [ [[package]] name = "feldera-datagen" -version = "0.314.0" +version = "0.315.0" dependencies = [ "anyhow", "async-channel 2.5.0", @@ -5297,7 +5297,7 @@ dependencies = [ [[package]] name = "feldera-fxp" -version = "0.314.0" +version = "0.315.0" dependencies = [ "bytecheck", "dbsp", @@ -5317,7 +5317,7 @@ dependencies = [ [[package]] name = "feldera-iceberg" -version = "0.314.0" +version = "0.315.0" dependencies = [ "anyhow", "chrono", @@ -5339,7 +5339,7 @@ dependencies = [ [[package]] name = "feldera-ir" -version = "0.314.0" +version = "0.315.0" dependencies = [ "proptest", "proptest-derive", @@ -5351,7 +5351,7 @@ dependencies = [ [[package]] name = "feldera-macros" -version = "0.314.0" +version = "0.315.0" dependencies = [ "prettyplease", "proc-macro2", @@ -5361,7 +5361,7 @@ dependencies = [ [[package]] name = "feldera-observability" -version = "0.314.0" +version = "0.315.0" dependencies = [ "actix-http", "awc", @@ -5376,7 +5376,7 @@ dependencies = [ [[package]] name = "feldera-rest-api" -version = "0.314.0" +version = "0.315.0" dependencies = [ "chrono", "feldera-observability", @@ -5395,7 +5395,7 @@ dependencies = [ [[package]] name = "feldera-samply" -version = "0.314.0" +version = "0.315.0" dependencies = [ "crossbeam", "feldera-size-of", @@ -5431,7 +5431,7 @@ dependencies = [ [[package]] name = "feldera-sqllib" -version = "0.314.0" +version = "0.315.0" dependencies = [ "arcstr", "base58", @@ -5475,7 +5475,7 @@ dependencies = [ [[package]] name = "feldera-storage" -version = "0.314.0" +version = "0.315.0" dependencies = [ "anyhow", "crossbeam", @@ -5498,7 +5498,7 @@ dependencies = [ [[package]] name = "feldera-types" -version = "0.314.0" +version = "0.315.0" dependencies = [ "actix-web", "anyhow", @@ -8761,7 +8761,7 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "pipeline-manager" -version = "0.314.0" +version = "0.315.0" dependencies = [ "actix-cors", "actix-files", @@ -9971,7 +9971,7 @@ dependencies = [ [[package]] name = "readers" -version = "0.314.0" +version = "0.315.0" dependencies = [ "async-std", "csv", @@ -11670,7 +11670,7 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "sltsqlvalue" -version = "0.314.0" +version = "0.315.0" dependencies = [ "dbsp", "feldera-sqllib", @@ -12024,7 +12024,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "storage-test-compat" -version = "0.314.0" +version = "0.315.0" dependencies = [ "dbsp", "derive_more 1.0.0", diff --git a/Cargo.toml b/Cargo.toml index 17f6f29dd3a..80fd8b7a5da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace.package] authors = ["Feldera Team "] -version = "0.314.0" +version = "0.315.0" license = "MIT OR Apache-2.0" homepage = "https://github.com/feldera/feldera" repository = "https://github.com/feldera/feldera" @@ -108,7 +108,7 @@ csv = "1.2.2" csv-core = "0.1.10" dashmap = "6.1.0" datafusion = "53.1" -dbsp = { path = "crates/dbsp", version = "0.314.0" } +dbsp = { path = "crates/dbsp", version = "0.315.0" } dbsp_nexmark = { path = "crates/nexmark" } deadpool-postgres = "0.14.1" # Feldera fork of delta-rs: upstream tag `rust-v0.32.3` + 2 patches, on branch @@ -133,20 +133,20 @@ erased-serde = "0.3.31" fake = "2.10" fastbloom = "0.14.0" fdlimit = "0.3.0" -feldera-buffer-cache = { version = "0.314.0", path = "crates/buffer-cache" } +feldera-buffer-cache = { version = "0.315.0", path = "crates/buffer-cache" } feldera-cloud1-client = "0.1.2" feldera-datagen = { path = "crates/datagen" } -feldera-fxp = { version = "0.314.0", path = "crates/fxp", features = ["dbsp"] } +feldera-fxp = { version = "0.315.0", path = "crates/fxp", features = ["dbsp"] } feldera-iceberg = { path = "crates/iceberg" } -feldera-observability = { version = "0.314.0", path = "crates/feldera-observability" } -feldera-macros = { version = "0.314.0", path = "crates/feldera-macros" } -feldera-sqllib = { version = "0.314.0", path = "crates/sqllib" } -feldera-storage = { version = "0.314.0", path = "crates/storage" } -feldera-types = { version = "0.314.0", path = "crates/feldera-types" } -feldera-rest-api = { version = "0.314.0", path = "crates/rest-api" } -feldera-ir = { version = "0.314.0", path = "crates/ir" } -feldera-adapterlib = { version = "0.314.0", path = "crates/adapterlib" } -feldera-samply = { version = "0.314.0", path = "crates/samply" } +feldera-observability = { version = "0.315.0", path = "crates/feldera-observability" } +feldera-macros = { version = "0.315.0", path = "crates/feldera-macros" } +feldera-sqllib = { version = "0.315.0", path = "crates/sqllib" } +feldera-storage = { version = "0.315.0", path = "crates/storage" } +feldera-types = { version = "0.315.0", path = "crates/feldera-types" } +feldera-rest-api = { version = "0.315.0", path = "crates/rest-api" } +feldera-ir = { version = "0.315.0", path = "crates/ir" } +feldera-adapterlib = { version = "0.315.0", path = "crates/adapterlib" } +feldera-samply = { version = "0.315.0", path = "crates/samply" } flate2 = "1.1.0" form_urlencoded = "1.2.0" fs_extra = "1.3.0" diff --git a/openapi.json b/openapi.json index 79e7c09fb93..066495687e7 100644 --- a/openapi.json +++ b/openapi.json @@ -10,7 +10,7 @@ "license": { "name": "MIT OR Apache-2.0" }, - "version": "0.314.0" + "version": "0.315.0" }, "paths": { "/config/authentication": { diff --git a/python/dbt-feldera/pyproject.toml b/python/dbt-feldera/pyproject.toml index 1839054d7a9..eb93e75eff8 100644 --- a/python/dbt-feldera/pyproject.toml +++ b/python/dbt-feldera/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" name = "dbt-feldera" readme = "README.md" description = "The dbt adapter for Feldera — DBSP-native incremental view maintenance" -version = "0.314.0" +version = "0.315.0" license = "MIT" requires-python = ">=3.10" authors = [ diff --git a/python/felderize/pyproject.toml b/python/felderize/pyproject.toml index 18dbf2aa1c2..c36592f055c 100644 --- a/python/felderize/pyproject.toml +++ b/python/felderize/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" name = "felderize" readme = "README.md" description = "SQL dialect to Feldera SQL translator agent" -version = "0.314.0" +version = "0.315.0" license = "MIT" requires-python = ">=3.10" authors = [ diff --git a/python/pyproject.toml b/python/pyproject.toml index 692307f3b88..7164e49e2b5 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" name = "feldera" readme = "README.md" description = "The feldera python client" -version = "0.314.0" +version = "0.315.0" license = "MIT" requires-python = ">=3.10" authors = [ diff --git a/python/uv.lock b/python/uv.lock index c4d340e2946..bcbda57de17 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -12,7 +12,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-06-11T14:17:18.675358746Z" +exclude-newer = "2026-06-17T19:06:01.959544849Z" exclude-newer-span = "P1W" [[package]] @@ -326,7 +326,7 @@ wheels = [ [[package]] name = "feldera" -version = "0.314.0" +version = "0.315.0" source = { editable = "." } dependencies = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, diff --git a/sql-to-dbsp-compiler/SQL-compiler/pom.xml b/sql-to-dbsp-compiler/SQL-compiler/pom.xml index b771ba326bf..c1b4d8e2e98 100644 --- a/sql-to-dbsp-compiler/SQL-compiler/pom.xml +++ b/sql-to-dbsp-compiler/SQL-compiler/pom.xml @@ -19,7 +19,7 @@ 1.43.0 2.21.1 3.1.12 - 0.314.0 + 0.315.0 1.28.0 From 4372008f2b30ccd322fe08fea4fc4e68a512a25e Mon Sep 17 00:00:00 2001 From: Jyotshna Yaparla Date: Mon, 22 Jun 2026 21:41:11 -0500 Subject: [PATCH 04/22] ci: pin download-artifact to v7 to fix intermittent failures on self-hosted runners v8 of actions/download-artifact introduced a regression causing frequent failures on self-hosted (ARM64) runners: downloads redirect to Azure Blob Storage and fail after 5 retries. v7 is stable and matches the upload-artifact version already in use across these workflows. Closes #6390 --- .github/workflows/build-docker-dev.yml | 2 +- .github/workflows/build-docker.yml | 8 ++++---- .github/workflows/test-adapters.yml | 4 ++-- .github/workflows/test-integration-platform.yml | 2 +- .github/workflows/test-unit.yml | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build-docker-dev.yml b/.github/workflows/build-docker-dev.yml index e097c936b83..8929d05483c 100644 --- a/.github/workflows/build-docker-dev.yml +++ b/.github/workflows/build-docker-dev.yml @@ -96,7 +96,7 @@ jobs: needs: build-docker-ci-dev steps: - name: Download digests - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: path: ${{ runner.temp }}/digests pattern: digests-* diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 74ad8970b84..2da8edd0bb4 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -52,19 +52,19 @@ jobs: - name: Download fda id: binaries - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: fda-${{ matrix.rust_target }} path: build - name: Download pipeline-manager - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: pipeline-manager-${{ matrix.rust_target }} path: build - name: Download Compiler Binaries - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: feldera-sql-compiler path: build @@ -141,7 +141,7 @@ jobs: needs: build-docker steps: - name: Download digests - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: path: ${{ runner.temp }}/digests pattern: digests-* diff --git a/.github/workflows/test-adapters.yml b/.github/workflows/test-adapters.yml index 5a4e8a570ee..d0937fe723f 100644 --- a/.github/workflows/test-adapters.yml +++ b/.github/workflows/test-adapters.yml @@ -62,7 +62,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Download Test Binaries - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: feldera-test-binaries-${{ matrix.target }} path: build @@ -142,7 +142,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Download Test Binaries - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: feldera-test-binaries-x86_64-unknown-linux-gnu path: build diff --git a/.github/workflows/test-integration-platform.yml b/.github/workflows/test-integration-platform.yml index 61c06b4e2d1..fa022ad6b05 100644 --- a/.github/workflows/test-integration-platform.yml +++ b/.github/workflows/test-integration-platform.yml @@ -234,7 +234,7 @@ jobs: CI_K8S_MINIO_SECRET_ACCESS_KEY: ${{ secrets.CI_K8S_MINIO_TEST_BUCKET_SECRET_ACCESS_KEY }} - name: Download fda binary - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: fda-${{ matrix.target }} path: build diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index acc92e228dd..37e1152a32a 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -33,13 +33,13 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Download Test Binaries - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: feldera-test-binaries-${{ matrix.target }} path: build - name: Download Compiler Binaries - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: feldera-sql-compiler path: sql-build From 3c040c1f56564c8920ac8dea431f0f4e1223f173 Mon Sep 17 00:00:00 2001 From: Mihai Budiu Date: Tue, 23 Jun 2026 23:21:50 -0700 Subject: [PATCH 05/22] [SQL] Advance Calcite version Signed-off-by: Mihai Budiu --- sql-to-dbsp-compiler/calcite_version.env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sql-to-dbsp-compiler/calcite_version.env b/sql-to-dbsp-compiler/calcite_version.env index 2666bc1ab3a..cac5a3695d6 100644 --- a/sql-to-dbsp-compiler/calcite_version.env +++ b/sql-to-dbsp-compiler/calcite_version.env @@ -3,5 +3,5 @@ CALCITE_REPO="https://github.com/apache/calcite.git" #CALCITE_REPO="https://github.com/mihaibudiu/calcite" CALCITE_BRANCH="main" CALCITE_CURRENT="1.42.0" -CALCITE_NEXT_COMMIT="1a3173d52a41683acca60fb09a31ad6ee25e587e" +CALCITE_NEXT_COMMIT="1317946ea1c318ca7cc895031ce8ca48777fcf2d" CALCITE_NEXT="1.43.0" From 7a89f37b0344d5c8ec0b71fc8a394278b7fe2874 Mon Sep 17 00:00:00 2001 From: Mihai Budiu Date: Wed, 24 Jun 2026 08:54:17 -0700 Subject: [PATCH 06/22] [DOCS] Document new syntax supported: GROUP BY ALL, DISTINCT ON, ORDER BY ALL Signed-off-by: Mihai Budiu --- docs.feldera.com/docs/sql/grammar.md | 24 +++++++++++++++---- .../SQL-compiler/src/main/codegen/config.fmpp | 1 + 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/docs.feldera.com/docs/sql/grammar.md b/docs.feldera.com/docs/sql/grammar.md index f3f10e90ef8..d6277f3d538 100644 --- a/docs.feldera.com/docs/sql/grammar.md +++ b/docs.feldera.com/docs/sql/grammar.md @@ -339,7 +339,7 @@ query | query MINUS [ DISTINCT ] query | query INTERSECT [ DISTINCT ] query } - [ ORDER BY orderItem [, orderItem ]* ] + [ ORDER BY { ALL [ ASC | DESC ] [ NULLS FIRST | NULLS LAST ] | orderItem [, orderItem]* } ] [ LIMIT [ start, ] { count | ALL } ] [ OFFSET start [ { ROW | ROWS } ] ] [ FETCH { FIRST | NEXT } [ count ] { ROW | ROWS } ONLY ] @@ -360,15 +360,25 @@ values : { VALUES | VALUE } expression [, expression ]* select - : SELECT [ hintComment ] [ ALL | DISTINCT ] - { projectItem [, projectItem ]* } + : SELECT [ hintComment ] [ ALL | DISTINCT [ ON '(' expression [ ',' expression ]* ')' ] ] { projectItem [, projectItem ]* } FROM tableExpression [ WHERE booleanExpression ] - [ GROUP BY [ ALL | DISTINCT ] { groupItem [, groupItem ]* } ] + [ GROUP BY { ALL | [ ALL | DISTINCT ] { groupItem [, groupItem ]* } } ] [ HAVING booleanExpression ] [ QUALIFY booleanExpression ] ``` +`DISTINCT ON` allows you to eliminate duplicate rows based on +specified expressions, keeping the first row in each group as +determined by the `ORDER BY` clause. When using `DISTINCT ON`, the +expressions in the `DISTINCT ON` clause must match a prefix of the +`ORDER BY` clause. + +`GROUP BY ALL` without any groupItem specified groups by every +expression in the `SELECT` clause that is not an aggregate function. +For example, `SELECT deptno, SUM(sal) FROM emp GROUP BY ALL` is +equivalent to `SELECT deptno, SUM(sal) FROM emp GROUP BY deptno`. + ``` tablePrimary @@ -406,6 +416,12 @@ orderItem : expression [ ASC | DESC ] [ NULLS FIRST | NULLS LAST ] ``` +`ORDER BY ALL` will order on all expressions of the `SELECT` +statement, in the order they appear. E.g., `SELECT x, y FROM t ORDER +BY ALL` is equivalent to `SELECT x, y FROM t ORDER BY x, y`. An +optional trailing `ASC/DESC/NULLS FIRST/NULLS LAST` applies to all +expressions. + ``` projectItem diff --git a/sql-to-dbsp-compiler/SQL-compiler/src/main/codegen/config.fmpp b/sql-to-dbsp-compiler/SQL-compiler/src/main/codegen/config.fmpp index e1c099c4f93..f1d8e78036e 100644 --- a/sql-to-dbsp-compiler/SQL-compiler/src/main/codegen/config.fmpp +++ b/sql-to-dbsp-compiler/SQL-compiler/src/main/codegen/config.fmpp @@ -980,6 +980,7 @@ data: { includeParsingStringLiteralAsArrayLiteral: true includeIntervalWithoutQualifier: false includeStarExclude: true + includeDistinctOn: true # Method for parsing "SET [OR RESET]" calls. setOptionParserMethod: "SqlSetOption" From 431ca4efb191ecfedeb7d301a633180f273306d2 Mon Sep 17 00:00:00 2001 From: Mihai Budiu Date: Sun, 21 Jun 2026 16:37:41 -0700 Subject: [PATCH 07/22] [SQL] Support for the MAP_CONCAT SQL function Signed-off-by: Mihai Budiu --- crates/sqllib/src/map.rs | 20 ++ docs.feldera.com/docs/sql/function-index.md | 1 + docs.feldera.com/docs/sql/map.md | 3 +- .../docs/sql/unsupported-operations.md | 2 - .../compiler/frontend/ExpressionCompiler.java | 25 +- .../calciteCompiler/CalciteFunctions.java | 7 +- .../calciteCompiler/CustomFunctions.java | 16 ++ .../compiler/sql/simple/MapTests.java | 223 +++++++++--------- .../compiler/sql/tools/TableParser.java | 28 +++ 9 files changed, 204 insertions(+), 121 deletions(-) diff --git a/crates/sqllib/src/map.rs b/crates/sqllib/src/map.rs index bae0e80ebda..fc4b07e2506 100644 --- a/crates/sqllib/src/map.rs +++ b/crates/sqllib/src/map.rs @@ -313,3 +313,23 @@ where } some_function1!(map_values [I: Ord + Clone, T: Clone], Map, Array); + +#[doc(hidden)] +pub fn map_concat__(left: Map, right: Map) -> Map +where + I: Ord + Clone, + T: Clone, +{ + if right.is_empty() { + return left; + } + if left.is_empty() { + return right; + } + let mut out = BTreeMap::new(); + out.extend(left.iter().map(|(k, v)| (k.clone(), v.clone()))); + out.extend(right.iter().map(|(k, v)| (k.clone(), v.clone()))); + out.into() +} + +some_function2!(map_concat [I: Ord + Clone, T: Clone], Map, Map, Map); diff --git a/docs.feldera.com/docs/sql/function-index.md b/docs.feldera.com/docs/sql/function-index.md index 00b1ca87410..43d673ab4f0 100644 --- a/docs.feldera.com/docs/sql/function-index.md +++ b/docs.feldera.com/docs/sql/function-index.md @@ -163,6 +163,7 @@ * `MAKE_TIMESTAMP`: [datetime](datetime.md#make_timestamp) * `MAP` (aggregate): [aggregates](aggregates.md#map) * `MAP`: [map](map.md#map-literals) +* `MAP_CONCAT`: [map](map.md#map_concat) * `MAP_CONTAINS_KEY`: [map](map.md#map_contains_key) * `MAP_KEYS`: [map](map.md#map_keys) * `MAP_VALUES`: [map](map.md#map_values) diff --git a/docs.feldera.com/docs/sql/map.md b/docs.feldera.com/docs/sql/map.md index 8240a485c9e..ac4deeea377 100644 --- a/docs.feldera.com/docs/sql/map.md +++ b/docs.feldera.com/docs/sql/map.md @@ -49,9 +49,10 @@ Comparison operations (`=`, `<>`, `!=`, `>`, `<`, `>=`, `<=`) can be applied to |------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------| | _map_`[`_key_`]` | Returns the element in the map with the specified key. If there is no such key, the result is `NULL`. | `MAP['x', 4, 'y', 3]['x']` => 4 | | `CARDINALITY`(map) | Returns the number of key-value pairs in the map. | `CARDINALITY(MAP['x', 4])` => 1 | +| `MAP_CONCAT`(map1, map2) | Returns a map with the elements in both maps; keys in the second map win. Returns `NULL` if any argument is `NULL` | `MAP_CONCAT(MAP['x', 4], MAP['y', 2])` => `MAP['x', 4, 'y', 2]` | | `MAP_CONTAINS_KEY`(map, key) | Returns true when the map has an item with the specified key; `NULL` if any argument is `NULL`. | `MAP_CONTAINS_KEY(MAP['x', 4], 'x')` => `true` | | `MAP_KEYS`(map) | Returns a sorted ARRAY of the appropriate type with all the keys of the MAP. | `MAP_KEYS(MAP['x', 4, 'y', 5])` => `['x', 'y']` | -| `MAP_VALUES`(map) | Returns an ARRAY of the appropriate type with all the values of the MAP. | `MAP_VALUES(MAP['x', 4, 'y', 5])` => `[4, 5]` | +| `MAP_VALUES`(map) | Returns an ARRAY of the appropriate type with all the values of the MAP sorted by the order of the keys. | `MAP_VALUES(MAP['x', 4, 'y', 5])` => `[4, 5]` | ## The `UNNEST` Operator diff --git a/docs.feldera.com/docs/sql/unsupported-operations.md b/docs.feldera.com/docs/sql/unsupported-operations.md index a4cfe21bff8..8d4018309e6 100644 --- a/docs.feldera.com/docs/sql/unsupported-operations.md +++ b/docs.feldera.com/docs/sql/unsupported-operations.md @@ -79,12 +79,10 @@ Several `MAP` functions are not yet implemented: | Function | Status | |----------|--------| -| `MAP_CONCAT` | Not supported | | `MAP_ENTRIES` | Not supported | | `MAP_FROM_ARRAYS` | Not supported | | `MAP_FROM_ENTRIES` | Not supported | | `STR_TO_MAP` | Not supported | -| Building a `MAP` from a subquery returning a pair of columns | Not supported | A list of supported MAP operations is available [here](./map.md). See [#1907](https://github.com/feldera/feldera/issues/1907). diff --git a/sql-to-dbsp-compiler/SQL-compiler/src/main/java/org/dbsp/sqlCompiler/compiler/frontend/ExpressionCompiler.java b/sql-to-dbsp-compiler/SQL-compiler/src/main/java/org/dbsp/sqlCompiler/compiler/frontend/ExpressionCompiler.java index 811435a31af..ef59edbf0bc 100644 --- a/sql-to-dbsp-compiler/SQL-compiler/src/main/java/org/dbsp/sqlCompiler/compiler/frontend/ExpressionCompiler.java +++ b/sql-to-dbsp-compiler/SQL-compiler/src/main/java/org/dbsp/sqlCompiler/compiler/frontend/ExpressionCompiler.java @@ -2233,11 +2233,34 @@ else if (arg0Type.is(DBSPTypeMap.class)) return next; } case MAP_KEYS: - case MAP_VALUES: + case MAP_VALUES: { validateArgCount(node, operationName, ops.size(), 1); DBSPExpression arg0 = ops.get(0); String method = getArrayOrMapCallName(call, arg0); return new DBSPApplyExpression(node, method, type, arg0); + } + case MAP_CONCAT: { + if (ops.isEmpty()) { + throw new CompilationError("Function " + Utilities.singleQuote(operationName) + + " with 0 arguments is not supported", node); + } + if (ops.size() == 1) { + return ops.get(0); + } + for (int i = 0; i < ops.size(); i++) { + DBSPExpression argi = ops.get(i); + // Types may not match exactly + argi = argi.cast(argi.getNode(), type, DBSPCastExpression.CastType.SqlUnsafe); + ops.set(i, argi); + } + DBSPExpression current = ops.get(0); + for (int i = 1; i < ops.size(); i++) { + DBSPExpression argi = ops.get(i); + String method = getArrayOrMapCallName(call, current, argi); + current = new DBSPApplyExpression(node, method, type, current, argi); + } + return current; + } case DOT: default: throw new UnimplementedException("Function " + Utilities.singleQuote(call.getOperator().toString()) diff --git a/sql-to-dbsp-compiler/SQL-compiler/src/main/java/org/dbsp/sqlCompiler/compiler/frontend/calciteCompiler/CalciteFunctions.java b/sql-to-dbsp-compiler/SQL-compiler/src/main/java/org/dbsp/sqlCompiler/compiler/frontend/calciteCompiler/CalciteFunctions.java index 7777bbf4603..31c8c86f038 100644 --- a/sql-to-dbsp-compiler/SQL-compiler/src/main/java/org/dbsp/sqlCompiler/compiler/frontend/calciteCompiler/CalciteFunctions.java +++ b/sql-to-dbsp-compiler/SQL-compiler/src/main/java/org/dbsp/sqlCompiler/compiler/frontend/calciteCompiler/CalciteFunctions.java @@ -692,11 +692,8 @@ record Func(SqlOperator function, String functionName, SqlLibrary library, "runtime_aggtest/illarg_tests/test_{str_bin_type_fn,str_unicode_fn}.py", false), new Func(SqlLibraryOperators.IFNULL, "IFNULL", SqlLibrary.BIG_QUERY, "comparisons#ifnull", FunctionDocumentation.NO_FILE, false), - new Func(SqlLibraryOperators.MAP_KEYS, "MAP_KEYS", SqlLibrary.SPARK, - "map#map_keys", - "runtime_aggtest/illarg_tests/test_arr_map_type_fn.py", false), - new Func(SqlLibraryOperators.MAP_VALUES, "MAP_VALUES", SqlLibrary.SPARK, - "map#map_values", FunctionDocumentation.NO_FILE, false) + new Func(SqlLibraryOperators.MAP_CONCAT, "MAP_CONCAT", SqlLibrary.SPARK, + "map#map_concat", FunctionDocumentation.NO_FILE, false), // new Func(SqlLibraryOperators.SAFE_ORDINAL, "SAFE_ORDINAL", SqlLibrary.BIG_QUERY, "array", FunctionDocumentation.NO_FILE, false), }; diff --git a/sql-to-dbsp-compiler/SQL-compiler/src/main/java/org/dbsp/sqlCompiler/compiler/frontend/calciteCompiler/CustomFunctions.java b/sql-to-dbsp-compiler/SQL-compiler/src/main/java/org/dbsp/sqlCompiler/compiler/frontend/calciteCompiler/CustomFunctions.java index b68b3cf3dde..343b80e6894 100644 --- a/sql-to-dbsp-compiler/SQL-compiler/src/main/java/org/dbsp/sqlCompiler/compiler/frontend/calciteCompiler/CustomFunctions.java +++ b/sql-to-dbsp-compiler/SQL-compiler/src/main/java/org/dbsp/sqlCompiler/compiler/frontend/calciteCompiler/CustomFunctions.java @@ -83,6 +83,8 @@ public CustomFunctions() { this.functions.add(new MakeDateFunction()); this.functions.add(new MakeTimeFunction()); this.functions.add(new MakeTimestampFunction()); + this.functions.add(new MapKeysFunction()); + this.functions.add(new MapValuesFunction()); this.functions.add(new NowFunction()); this.functions.add(new ParseDateFunction()); this.functions.add(new ParseJsonFunction()); @@ -185,6 +187,20 @@ public CalciteFunctionClone(SqlFunction calciteFunction, String documentationFil } } + static class MapKeysFunction extends CalciteFunctionClone { + // Cannot be folded by Calcite since the result order is not deterministic in Calcite + private MapKeysFunction() { + super(SqlLibraryOperators.MAP_KEYS, "map#map_keys", "runtime_aggtest/illarg_tests/test_arr_map_type_fn.py"); + } + } + + static class MapValuesFunction extends CalciteFunctionClone { + // Cannot be folded by Calcite since the result order is not deterministic in Calcite + private MapValuesFunction() { + super(SqlLibraryOperators.MAP_VALUES, "map#map_values", FunctionDocumentation.NO_FILE); + } + } + static class FormatDateFunction extends CalciteFunctionClone { private FormatDateFunction() { super(SqlLibraryOperators.FORMAT_DATE, "datetime#format_date", FunctionDocumentation.NO_FILE); diff --git a/sql-to-dbsp-compiler/SQL-compiler/src/test/java/org/dbsp/sqlCompiler/compiler/sql/simple/MapTests.java b/sql-to-dbsp-compiler/SQL-compiler/src/test/java/org/dbsp/sqlCompiler/compiler/sql/simple/MapTests.java index 829c300854c..40b9c61b596 100644 --- a/sql-to-dbsp-compiler/SQL-compiler/src/test/java/org/dbsp/sqlCompiler/compiler/sql/simple/MapTests.java +++ b/sql-to-dbsp-compiler/SQL-compiler/src/test/java/org/dbsp/sqlCompiler/compiler/sql/simple/MapTests.java @@ -3,11 +3,10 @@ import org.dbsp.sqlCompiler.compiler.DBSPCompiler; import org.dbsp.sqlCompiler.compiler.TestUtil; import org.dbsp.sqlCompiler.compiler.frontend.calciteObject.CalciteObject; -import org.dbsp.sqlCompiler.compiler.sql.tools.BaseSQLTests; import org.dbsp.sqlCompiler.compiler.sql.tools.Change; import org.dbsp.sqlCompiler.compiler.sql.tools.InputOutputChange; import org.dbsp.sqlCompiler.compiler.sql.tools.InputOutputChangeStream; -import org.dbsp.sqlCompiler.ir.expression.DBSPArrayExpression; +import org.dbsp.sqlCompiler.compiler.sql.tools.SqlIoTest; import org.dbsp.sqlCompiler.ir.expression.DBSPTupleExpression; import org.dbsp.sqlCompiler.ir.expression.literal.DBSPI32Literal; import org.dbsp.sqlCompiler.ir.expression.DBSPMapExpression; @@ -22,7 +21,7 @@ import java.nio.charset.Charset; -public class MapTests extends BaseSQLTests { +public class MapTests extends SqlIoTest { public DBSPCompiler compileQuery(String statements, String query) { DBSPCompiler compiler = this.testCompiler(); compiler.options.languageOptions.optimizationLevel = 0; @@ -32,14 +31,14 @@ public DBSPCompiler compileQuery(String statements, String query) { return compiler; } - void testQuery(String statements, String query, InputOutputChangeStream streams) { + void testQuery(String query, InputOutputChangeStream streams) { query = "CREATE VIEW V AS " + query; - DBSPCompiler compiler = this.compileQuery(statements, query); + DBSPCompiler compiler = this.compileQuery("", query); this.getCCS(compiler, streams); } private void testQuery(String query, DBSPZSetExpression expression) { - this.testQuery("", query, + this.testQuery(query, new InputOutputChangeStream().addChange( new InputOutputChange(new Change(), new Change("V", expression)))); } @@ -47,30 +46,27 @@ private void testQuery(String query, DBSPZSetExpression expression) { @Test public void testDuplicateKeys() { var compiler = this.compileQuery("", "CREATE VIEW V AS SELECT MAP['hi', 1, 'hi', 2]"); - TestUtil.assertMessagesContain(compiler, "Duplicate MAP key"); + TestUtil.assertMessagesContain(compiler, "warning: Duplicate MAP key"); } @Test public void mapLiteralTest() { - String query = "SELECT MAP['hi',2]"; - DBSPType str = DBSPTypeString.varchar(false); - this.testQuery(query, new DBSPZSetExpression( - new DBSPTupleExpression(new DBSPMapExpression( - new DBSPTypeMap( - str, - new DBSPTypeInteger(CalciteObject.EMPTY, 32, true, false), - false), - Linq.list(new DBSPStringLiteral(CalciteObject.EMPTY, str, "hi", Charset.defaultCharset()), - new DBSPI32Literal(2)))))); + this.qst(""" + SELECT MAP['hi',2]; + r + --- + { hi: 2 } + (1 row)"""); } @Test public void mapIndexTest() { - String query = "SELECT MAP['hi',2]['hi'], MAP['hi',2]['x']"; - this.testQuery(query, new DBSPZSetExpression( - new DBSPTupleExpression( - new DBSPI32Literal(2, true), - new DBSPI32Literal()))); + this.qst(""" + SELECT MAP['hi',2]['hi'], MAP['hi',2]['x']; + e0 | e1 + --------- + 2 | + (1 row)"""); } @Test @@ -89,88 +85,50 @@ public void mapBlackboxTest() { @Test public void mapCardinalityTest() { - String query = "SELECT CARDINALITY(MAP['hi',2])"; - this.testQuery(query, new DBSPZSetExpression( - new DBSPTupleExpression( - new DBSPI32Literal(1)))); + this.qst(""" + SELECT CARDINALITY(MAP['hi',2]); + r + --- + 1 + (1 row)"""); } @Test public void testMapSubquery() { - String ddl = "CREATE TABLE T(v varchar, x int)"; - String query = "SELECT MAP(SELECT * FROM T)"; - DBSPZSetExpression input = new DBSPZSetExpression( - new DBSPTupleExpression( - new DBSPStringLiteral("hello", true), - new DBSPI32Literal(10, true)), - new DBSPTupleExpression( - new DBSPStringLiteral("there", true), - new DBSPI32Literal(5, true))); - DBSPTypeMap mapType = new DBSPTypeMap( - DBSPTypeString.varchar(true), - new DBSPTypeInteger(CalciteObject.EMPTY, 32, true ,true), false); - DBSPZSetExpression result = new DBSPZSetExpression( - new DBSPTupleExpression( - new DBSPMapExpression( - mapType, - Linq.list( - new DBSPStringLiteral("there", true), - new DBSPI32Literal(5, true), - new DBSPStringLiteral("hello", true), - new DBSPI32Literal(10, true)) - ))); - this.testQuery(ddl, query, - new InputOutputChangeStream().addPair(new Change("T", input), new Change("V", result))); + var ccs = this.getCCS(""" + CREATE TABLE T(v varchar, x int); + CREATE VIEW V AS SELECT MAP(SELECT * FROM T);"""); + ccs.stepWeightOne("INSERT INTO T VALUES('hello', 10), ('there', 5)", """ + map + ------- + { hello: 10, there: 5 }"""); } @Test public void testUnnestMap() { - String sql = "select * from UNNEST(map['a', 12])"; - DBSPZSetExpression result = new DBSPZSetExpression( - new DBSPTupleExpression( - new DBSPStringLiteral("a", false), - new DBSPI32Literal(12, false))); - this.testQuery("", sql, - new InputOutputChangeStream().addPair(new Change(), new Change("V", result))); - } - - @Test - public void testUnnestMap2() { - String sql = "select * from UNNEST(map['a', 12, 'b', 15, 'c', NULL])"; - DBSPZSetExpression result = new DBSPZSetExpression( - new DBSPTupleExpression( - new DBSPStringLiteral("a", false), - new DBSPI32Literal(12, true)), - new DBSPTupleExpression( - new DBSPStringLiteral("b", false), - new DBSPI32Literal(15, true)), - new DBSPTupleExpression( - new DBSPStringLiteral("c", false), - new DBSPI32Literal())); - this.testQuery("", sql, - new InputOutputChangeStream().addPair(new Change(), new Change("V", result))); - } - - @Test - public void testUnnestMapFields() { - String sql = - "WITH T(i, m) as (VALUES(1, MAP[1, 2, 3, 4]), (2, MAP[5, NULL])) " + - "SELECT T.i, k, v FROM T CROSS JOIN UNNEST(T.m) AS pair(k, v)"; - DBSPZSetExpression result = new DBSPZSetExpression( - new DBSPTupleExpression( - new DBSPI32Literal(1, false), - new DBSPI32Literal(1, false), - new DBSPI32Literal(2, true)), - new DBSPTupleExpression( - new DBSPI32Literal(1, false), - new DBSPI32Literal(3, false), - new DBSPI32Literal(4, true)), - new DBSPTupleExpression( - new DBSPI32Literal(2, false), - new DBSPI32Literal(5, false), - new DBSPI32Literal())); - this.testQuery("", sql, new InputOutputChangeStream() - .addPair(new Change(), new Change("V", result))); + this.qst(""" + select * from UNNEST(map['a', 12]); + k | v + ------- + a | 12 + (1 row) + + select * from UNNEST(map['a', 12, 'b', 15, 'c', NULL]); + k | v + ------- + a | 12 + b | 15 + c | + (3 rows) + + WITH T(i, m) as (VALUES(1, MAP[1, 2, 3, 4]), (2, MAP[5, NULL])) + SELECT T.i, k, v FROM T CROSS JOIN UNNEST(T.m) AS pair(k, v); + i | k | v + ----------- + 1 | 1 | 2 + 1 | 3 | 4 + 2 | 5 | + (3 rows)"""); } @Test @@ -181,14 +139,12 @@ public void nullMapKey() { @Test public void testMapKeys() { - String sql = "SELECT map_keys(map['foo', 1, 'bar', 2])"; - DBSPZSetExpression result = new DBSPZSetExpression( - new DBSPTupleExpression(new DBSPArrayExpression( - false, - new DBSPStringLiteral("bar"), - new DBSPStringLiteral("foo")))); - this.testQuery("", sql, new InputOutputChangeStream() - .addPair(new Change(), new Change("V", result))); + this.qst(""" + SELECT map_keys(map['foo', 1, 'bar', 2]); + keys + -------------- + { bar, foo } + (1 row)"""); } @Test @@ -218,14 +174,12 @@ SELECT cast(contacts as MAP) contacts @Test public void testMapValues() { - String sql = "SELECT map_values(map['foo', 1, 'bar', 2])"; - DBSPZSetExpression result = new DBSPZSetExpression( - new DBSPTupleExpression(new DBSPArrayExpression( - false, - new DBSPI32Literal(2), - new DBSPI32Literal(1)))); - this.testQuery("", sql, new InputOutputChangeStream() - .addPair(new Change(), new Change("V", result))); + this.qst(""" + SELECT map_values(map['foo', 1, 'bar', 2]); + r + -------- + { 2, 1 } + (1 row)"""); } @Test @@ -252,4 +206,49 @@ SELECT cast(contacts as MAP) contacts null {"f":1}"""); } + + @Test + public void testMapConcat() { + this.qst(""" + SELECT MAP_CONCAT( + MAP[ 1, 'a' ], + MAP[ 2, 'b' ] + ); + +--------------+ + | EXPR$0 | + +--------------+ + | {1: a, 2: b} | + +--------------+ + (1 row) + + SELECT MAP_CONCAT(CAST(NULL AS MAP), MAP[1, 2]); + r + ---- + NULL + (1 row) + + SELECT MAP_CONCAT(MAP[1, 'a'], MAP[1, 'b']); + r + ---- + {1: b} + (1 row) + + SELECT MAP_CONCAT(MAP[1, 2], MAP[1.0, NULL]); + r + --- + {1.0: NULL} + (1 row) + + SELECT MAP_CONCAT(MAP[1, 2]); + r + --- + {1: 2} + (1 row) + + SELECT MAP_CONCAT(MAP[1, 2], MAP[1, 3], MAP[1, 4]); + r + --- + {1: 4} + (1 row)"""); + } } diff --git a/sql-to-dbsp-compiler/SQL-compiler/src/test/java/org/dbsp/sqlCompiler/compiler/sql/tools/TableParser.java b/sql-to-dbsp-compiler/SQL-compiler/src/test/java/org/dbsp/sqlCompiler/compiler/sql/tools/TableParser.java index 99f09e4f85e..6ce62f798f5 100644 --- a/sql-to-dbsp-compiler/SQL-compiler/src/test/java/org/dbsp/sqlCompiler/compiler/sql/tools/TableParser.java +++ b/sql-to-dbsp-compiler/SQL-compiler/src/test/java/org/dbsp/sqlCompiler/compiler/sql/tools/TableParser.java @@ -7,6 +7,7 @@ import org.dbsp.sqlCompiler.compiler.frontend.TableData; import org.dbsp.sqlCompiler.compiler.frontend.calciteObject.CalciteObject; import org.dbsp.sqlCompiler.ir.expression.DBSPExpression; +import org.dbsp.sqlCompiler.ir.expression.DBSPMapExpression; import org.dbsp.sqlCompiler.ir.expression.DBSPTupleExpression; import org.dbsp.sqlCompiler.ir.expression.literal.DBSPBinaryLiteral; import org.dbsp.sqlCompiler.ir.expression.literal.DBSPBoolLiteral; @@ -37,6 +38,7 @@ import org.dbsp.sqlCompiler.ir.type.derived.DBSPTypeTuple; import org.dbsp.sqlCompiler.ir.type.derived.DBSPTypeTupleBase; import org.dbsp.sqlCompiler.ir.type.user.DBSPTypeArray; +import org.dbsp.sqlCompiler.ir.type.user.DBSPTypeMap; import org.dbsp.sqlCompiler.ir.type.user.DBSPTypeZSet; import org.dbsp.sqlCompiler.ir.type.primitive.DBSPTypeInteger; import org.dbsp.util.Linq; @@ -409,6 +411,32 @@ static DBSPExpression parseValue(DBSPType fieldType, String data, boolean trimTr yield new DBSPTupleExpression(CalciteObject.EMPTY, tuple, fields); } } + case MAP -> { + // This is rather fragile, but there is not fool-proof solution for parsing this + DBSPTypeMap map = fieldType.to(DBSPTypeMap.class); + if (trimmed.equals("NULL")) { + yield map.none(); + } else { + if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) + throw new UnimplementedException("Expected map constant to be bracketed: " + trimmed); + trimmed = trimmed.substring(1, trimmed.length() - 1); + Utilities.enforce(!trimmed.isEmpty()); + + String[] parts = trimmed.split(","); + List keys = new ArrayList<>(); + List values = new ArrayList<>(); + for (String part: parts) { + String[] kv = part.split(":"); + Utilities.enforce(kv.length == 2, () -> "Map entry is not in 'key: value' form"); + DBSPExpression key = parseValue(map.getKeyType(), kv[0], trimTrailingSpaces); + keys.add(key); + DBSPExpression value = parseValue(map.getValueType(), kv[1], trimTrailingSpaces); + values.add(value); + } + + yield new DBSPMapExpression(map, keys, values); + } + } default -> throw new UnimplementedException( "Support for parsing fields of type " + fieldType + " not yet implemented", fieldType); }; From 5144eb4c262e5ab6a4d9aa56bd3eb0e1d529bf55 Mon Sep 17 00:00:00 2001 From: Karakatiza666 Date: Wed, 24 Jun 2026 21:18:32 +0000 Subject: [PATCH 08/22] [web-console] Add memory pressure indicator to memory graph Signed-off-by: Karakatiza666 --- .devcontainer/devcontainer.json | 2 +- .../pipelines/PipelineMemoryGraph.svelte | 22 ++++++++++++++++--- .../pipelines/editor/MonitoringPanel.svelte | 11 ++++++++-- .../pipelines/editor/TabPerformance.svelte | 7 +++++- .../src/lib/functions/pipelineMetrics.ts | 3 +-- 5 files changed, 36 insertions(+), 9 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 2e721ea10b2..70ac3717fd3 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -34,7 +34,7 @@ "biomejs.biome" ], "settings": { - "editor.formatOnSave": true, + "editor.formatOnSave": false, "terminal.integrated.defaultProfile.linux": "bash", "files.exclude": { "**/CODE_OF_CONDUCT.md": true, diff --git a/js-packages/web-console/src/lib/components/layout/pipelines/PipelineMemoryGraph.svelte b/js-packages/web-console/src/lib/components/layout/pipelines/PipelineMemoryGraph.svelte index aff4f84b66a..67c733ebce6 100644 --- a/js-packages/web-console/src/lib/components/layout/pipelines/PipelineMemoryGraph.svelte +++ b/js-packages/web-console/src/lib/components/layout/pipelines/PipelineMemoryGraph.svelte @@ -16,6 +16,7 @@ import { humanSize } from '$lib/functions/common/string' import { tuple } from '$lib/functions/common/tuple' import { multihostMemoryLimitMb, timeSeriesAxisMax } from '$lib/functions/pipelineMetrics' + import type { MemoryPressure } from '$lib/services/manager' import type { Pipeline } from '$lib/services/pipelineManager' import type { TimeSeriesEntry } from '$lib/types/pipelineManager' @@ -23,13 +24,23 @@ pipeline, metrics, refetchMs, - keepMs + keepMs, + memoryPressure }: { pipeline: { current: Pipeline } metrics: TimeSeriesEntry[] refetchMs: number keepMs: number + memoryPressure?: MemoryPressure } = $props() + + const pressureChips: Record = { + low: { label: '', class: 'hidden' }, + moderate: { label: 'Moderate pressure', class: 'bg-warning-100-900' }, + high: { label: 'High pressure', class: 'bg-error-50-950' }, + critical: { label: 'Critical pressure', class: 'bg-error-50-950' } + } + const pressureChip = $derived(memoryPressure ? pressureChips[memoryPressure] : undefined) use([ LineChart, GridComponent, @@ -209,8 +220,13 @@
-
- Used memory: {humanSize(metrics.at(-1)?.m ?? 0)} +
+ Used memory: {humanSize(metrics.at(-1)?.m ?? 0)} + {#if pressureChip} +
+ {pressureChip.label} +
+ {/if}
{#key pipelineName} (ref = init(dom, theme, opts))} {options} /> diff --git a/js-packages/web-console/src/lib/components/pipelines/editor/MonitoringPanel.svelte b/js-packages/web-console/src/lib/components/pipelines/editor/MonitoringPanel.svelte index c43d5d5d14f..e2acfa7bd6d 100755 --- a/js-packages/web-console/src/lib/components/pipelines/editor/MonitoringPanel.svelte +++ b/js-packages/web-console/src/lib/components/pipelines/editor/MonitoringPanel.svelte @@ -140,6 +140,13 @@ const connectorsWithErrorsCount = $derived(numConnectorsWithProblems(metrics.current)) + const memoryPressureErrorCount = $derived.by(() => { + const memoryPressure = metrics.current.global?.memory_pressure + return memoryPressure === 'high' || memoryPressure === 'critical' ? 1 : 0 + }) + + const runtimeErrorsCount = $derived(connectorsWithErrorsCount + memoryPressureErrorCount) + // Local input binding. The committed search (what the list actually runs) lives in // `logSearch` and only advances on Enter — so typing doesn't search as-you-type. let logSearchInput = $state('') @@ -187,9 +194,9 @@ {#snippet TabControlPerformance()} {@render TabPerformance.Label()} - {#if connectorsWithErrorsCount > 0} + {#if runtimeErrorsCount > 0} - {connectorsWithErrorsCount} + {runtimeErrorsCount} {/if} {/snippet} diff --git a/js-packages/web-console/src/lib/components/pipelines/editor/TabPerformance.svelte b/js-packages/web-console/src/lib/components/pipelines/editor/TabPerformance.svelte index b0952d05d4e..43a78e592cf 100644 --- a/js-packages/web-console/src/lib/components/pipelines/editor/TabPerformance.svelte +++ b/js-packages/web-console/src/lib/components/pipelines/editor/TabPerformance.svelte @@ -306,7 +306,12 @@ >
-
diff --git a/js-packages/web-console/src/lib/functions/pipelineMetrics.ts b/js-packages/web-console/src/lib/functions/pipelineMetrics.ts index da509645838..68b8e38814f 100644 --- a/js-packages/web-console/src/lib/functions/pipelineMetrics.ts +++ b/js-packages/web-console/src/lib/functions/pipelineMetrics.ts @@ -228,8 +228,7 @@ export const timeSeriesAxisMax = (metrics: TimeSeriesEntry[], now: () => number export const multihostMemoryLimitMb = ( perHostMemoryMb: number | null | undefined, hosts: number | null | undefined -): number | undefined => - perHostMemoryMb ? perHostMemoryMb * Math.max(hosts ?? 1, 1) : undefined +): number | undefined => (perHostMemoryMb ? perHostMemoryMb * Math.max(hosts ?? 1, 1) : undefined) /** * @returns Time series of throughput with smoothing window over 3 data intervals From 898afcffe05a83a42bcf07e99a7f62386a13a11d Mon Sep 17 00:00:00 2001 From: Karakatiza666 Date: Fri, 19 Jun 2026 18:18:35 +0000 Subject: [PATCH 09/22] [web-console] Sort pipelines by name on the home page by default, persist order Signed-off-by: Karakatiza666 --- .../src/lib/components/pipelines/Table.svelte | 34 +++- .../components/pipelines/Table.svelte.spec.ts | 152 ++++++++++++++++++ .../performance/MetricsTables.svelte.spec.ts | 14 +- .../components/pipelines/table/ThSort.svelte | 20 ++- .../layout/useLayoutSettings.svelte.ts | 7 +- 5 files changed, 217 insertions(+), 10 deletions(-) create mode 100644 js-packages/web-console/src/lib/components/pipelines/Table.svelte.spec.ts diff --git a/js-packages/web-console/src/lib/components/pipelines/Table.svelte b/js-packages/web-console/src/lib/components/pipelines/Table.svelte index ab8d571e223..8ec51e0d53c 100644 --- a/js-packages/web-console/src/lib/components/pipelines/Table.svelte +++ b/js-packages/web-console/src/lib/components/pipelines/Table.svelte @@ -6,6 +6,7 @@ import PipelineStatus from '$lib/components/pipelines/list/PipelineStatus.svelte' import ThSort from '$lib/components/pipelines/table/ThSort.svelte' import { useElapsedTime } from '$lib/compositions/common/useElapsedTime' + import { useLayoutSettings } from '$lib/compositions/layout/useLayoutSettings.svelte' import { dateMax } from '$lib/functions/common/date' import { matchesSubstring } from '$lib/functions/common/string' import { type NamesInUnion, unionName } from '$lib/functions/common/union' @@ -84,6 +85,15 @@ const { formatElapsedTime } = useElapsedTime() const td = 'py-1 text-base border-t-[0.5px]' + + // Persist the active sort and restore it on the matching column. Each ThSort + // owns the upstream `direction`/`onSort` API; the column identity lives here. + const { pipelinesTableSort } = useLayoutSettings() + const sortColumn = (column: string) => ({ + direction: + pipelinesTableSort.value.column === column ? pipelinesTableSort.value.direction : undefined, + onSort: (direction: 'asc' | 'desc') => (pipelinesTableSort.value = { column, direction }) + })
@@ -134,13 +144,13 @@ onclick={() => table.selectAll()} /> - Pipeline name Storage - Status p.connectors?.numErrors} + {...sortColumn('numErrors')} > Errors - + Runtime - Status changed - Deployed on diff --git a/js-packages/web-console/src/lib/components/pipelines/Table.svelte.spec.ts b/js-packages/web-console/src/lib/components/pipelines/Table.svelte.spec.ts new file mode 100644 index 00000000000..9982ac08175 --- /dev/null +++ b/js-packages/web-console/src/lib/components/pipelines/Table.svelte.spec.ts @@ -0,0 +1,152 @@ +/** + * Mouse interaction tests for column sorting in the pipelines `Table`. + * + * The real pipelines `Table` is mounted with a handful of pipeline thumbs whose name order + * and "status changed" order deliberately disagree, so every assertion about row + * order can only pass if the click actually re-sorted the rows. The default sort + * (name ascending) and its persistence both live in `useLayoutSettings`, so the + * test also checks the localStorage key the component writes through. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { page } from 'vitest/browser' +import { render } from 'vitest-browser-svelte' +import type { PipelineThumb } from '$lib/services/pipelineManager' +import { useLayoutSettings } from '$lib/compositions/layout/useLayoutSettings.svelte' + +const SORT_KEY = 'layout/pipelines/table/sort' + +// `vi.mock` factories are hoisted above the imports and run before ordinary +// module-scope consts initialize, so a factory can't read them. `vi.hoisted` is +// hoisted too, so its result is available to both the factory and the fixtures. +const { PLATFORM_VERSION } = vi.hoisted(() => ({ PLATFORM_VERSION: '1.0.0' })) + +// Table and its PipelineVersion child both read `page.data.feldera`. A `version` +// equal to every thumb's platformVersion keeps the runtime column in its +// "latest" state, so no update Popover is rendered. +vi.mock('$app/state', () => ({ + page: { data: { feldera: { version: PLATFORM_VERSION, unstableFeatures: [] } } } +})) + +import Table from './Table.svelte' + +// `lastStatusSince` is derived from these timestamps. The date order +// (bravo < delta < charlie < alpha) is a rotation of the name order +// (alpha < bravo < charlie < delta), so sorting by either column yields a +// distinct, unambiguous row sequence. +const lastChange: Record = { + alpha: '2024-01-04T00:00:00Z', + bravo: '2024-01-01T00:00:00Z', + charlie: '2024-01-03T00:00:00Z', + delta: '2024-01-02T00:00:00Z' +} + +// Only the fields the Table template reads are populated; the rest of +// PipelineThumb is irrelevant to sorting, hence the cast. +const thumb = (name: string): PipelineThumb => + ({ + name, + description: '', + status: 'Stopped', + storageStatus: 'Cleared', + deploymentStatusSince: lastChange[name], + programStatusSince: lastChange[name], + deploymentError: undefined, + platformVersion: PLATFORM_VERSION, + programConfig: { runtime_version: null }, + deploymentResourcesStatus: 'Stopped', + deploymentResourcesStatusSince: new Date(lastChange[name]), + connectors: { numErrors: 0 } + }) as unknown as PipelineThumb + +// Fed deliberately unsorted so a passing default-order assertion proves the +// table sorted them rather than echoing the input order. +const pipelines = [thumb('charlie'), thumb('alpha'), thumb('delta'), thumb('bravo')] + +// Each rendered row carries data-testid="box-row-"; reading them back in +// DOM order gives the visible row sequence. +const rowOrder = () => + Array.from(document.querySelectorAll('tbody tr[data-testid^="box-row-"]')).map((tr) => + tr.getAttribute('data-testid')!.slice('box-row-'.length) + ) + +// The sortable headers render their label inside the clickable ; the +// gains the `active` class while it is the column the table is sorted by. +const header = (label: string) => page.getByText(label, { exact: true }) +const headerCell = (label: string) => header(label).element().closest('th')! + +const persistedSort = () => JSON.parse(localStorage.getItem(SORT_KEY)!) + +const mountTable = () => render(Table, { props: { pipelines, selectedPipelines: [] } } as any) + +describe('Table — column sorting', () => { + beforeEach(() => { + // The persisted sort is a process-wide singleton (useLocalStorage caches by + // key), so reset both the backing store and the cached value before each test + // to keep them independent. + localStorage.clear() + useLayoutSettings().pipelinesTableSort.value = { column: 'name', direction: 'asc' } + }) + + afterEach(async () => { + // @vincjo/datatables' setRows() defers a scroll-position restore via + // setTimeout(..., 2) that dereferences table.element. On unmount Svelte nulls + // that binding, so a timer still in flight throws "Cannot set properties of + // null (setting 'scrollTop')". vitest-browser-svelte unmounts after afterEach + // runs, so waiting out the 2 ms window here lets the timer fire while the + // component — and its element — is still alive. + await new Promise((resolve) => setTimeout(resolve, 10)) + localStorage.clear() + }) + + it('sorts by pipeline name ascending by default', async () => { + mountTable() + + await expect.poll(rowOrder).toEqual(['alpha', 'bravo', 'charlie', 'delta']) + expect(headerCell('Pipeline name').classList.contains('active')).toBe(true) + }) + + it('clicking the name header toggles ascending → descending → ascending', async () => { + mountTable() + await expect.poll(rowOrder).toEqual(['alpha', 'bravo', 'charlie', 'delta']) + + await header('Pipeline name').click() + await expect.poll(rowOrder).toEqual(['delta', 'charlie', 'bravo', 'alpha']) + + await header('Pipeline name').click() + await expect.poll(rowOrder).toEqual(['alpha', 'bravo', 'charlie', 'delta']) + }) + + it('clicking a different header sorts by that column and moves the active marker', async () => { + mountTable() + await expect.poll(rowOrder).toEqual(['alpha', 'bravo', 'charlie', 'delta']) + + // "Status changed" sorts by lastStatusSince, ascending on first click. + await header('Status changed').click() + await expect.poll(rowOrder).toEqual(['bravo', 'delta', 'charlie', 'alpha']) + + expect(headerCell('Status changed').classList.contains('active')).toBe(true) + expect(headerCell('Pipeline name').classList.contains('active')).toBe(false) + }) + + it('persists the active sort to localStorage', async () => { + mountTable() + await expect.poll(rowOrder).toEqual(['alpha', 'bravo', 'charlie', 'delta']) + + await header('Pipeline name').click() + await expect.poll(persistedSort).toEqual({ column: 'name', direction: 'desc' }) + + await header('Status changed').click() + await expect.poll(persistedSort).toEqual({ column: 'lastStatusSince', direction: 'asc' }) + }) + + it('restores a persisted non-default sort on mount', async () => { + // Simulate a returning user whose last sort was name descending. + useLayoutSettings().pipelinesTableSort.value = { column: 'name', direction: 'desc' } + + mountTable() + + await expect.poll(rowOrder).toEqual(['delta', 'charlie', 'bravo', 'alpha']) + expect(headerCell('Pipeline name').classList.contains('active')).toBe(true) + }) +}) diff --git a/js-packages/web-console/src/lib/components/pipelines/editor/performance/MetricsTables.svelte.spec.ts b/js-packages/web-console/src/lib/components/pipelines/editor/performance/MetricsTables.svelte.spec.ts index 8292fef09aa..b6ea301e951 100644 --- a/js-packages/web-console/src/lib/components/pipelines/editor/performance/MetricsTables.svelte.spec.ts +++ b/js-packages/web-console/src/lib/components/pipelines/editor/performance/MetricsTables.svelte.spec.ts @@ -402,13 +402,23 @@ describe('MetricsTables.svelte', () => { // Initially collapsed await expect.element(page.getByTestId('box-connector-row-alpha')).not.toBeInTheDocument() + const toggle = page.getByTestId('box-relation-row-t1').getByText('t1', { exact: true }) + + // `force` bypasses the pointer hit-test. Once expanded, the connector rows render + // icon tooltips (placement="top") that float up over this row; while the pointer + // travels toward the label it can open one, and the portaled tooltip's high z-index + // intercepts the hit-test so the click never resolves (flaky CI timeout on the + // second click). The toggle is wired to the row's onclick, so the forced dispatch + // still fires it. `position` keeps the dispatch over the chevron, clear of tooltips. + const click = { position: { x: 1, y: 1 }, force: true } + // Click to expand - await page.getByTestId('box-relation-row-t1').click({ position: { x: 1, y: 0 } }) + await toggle.click(click) await expect.element(page.getByTestId('box-connector-row-alpha')).toBeInTheDocument() await expect.element(page.getByTestId('box-connector-row-beta')).toBeInTheDocument() // Click again to collapse - await page.getByTestId('box-relation-row-t1').click({ position: { x: 1, y: 0 } }) + await toggle.click(click) await expect.element(page.getByTestId('box-connector-row-alpha')).not.toBeInTheDocument() }) diff --git a/js-packages/web-console/src/lib/components/pipelines/table/ThSort.svelte b/js-packages/web-console/src/lib/components/pipelines/table/ThSort.svelte index 8b6a57f1e9f..f2dbb571521 100644 --- a/js-packages/web-console/src/lib/components/pipelines/table/ThSort.svelte +++ b/js-packages/web-console/src/lib/components/pipelines/table/ThSort.svelte @@ -5,18 +5,34 @@ const { table, field, + direction, + onSort, children, class: _class }: { table: TableHandlerInterface field: Field + /** Initial sort direction, applied once when the column mounts. */ + direction?: 'asc' | 'desc' + /** Notifies the parent when the user changes this column's sort, e.g. to persist it. */ + onSort?: (direction: 'asc' | 'desc') => void children: Snippet class?: string } = $props() - const sort = table.createSort(field) + + // Create the sort once and apply its initial direction, mirroring @vincjo/datatables' ThSort. + // svelte-ignore state_referenced_locally + const sort = table.createSort(field).init(direction) + + const setSort = () => { + sort.set() + if (sort.direction === 'asc' || sort.direction === 'desc') { + onSort?.(sort.direction) + } + } - sort.set()} class={_class} class:active={sort.isActive}> +
{@render children()} diff --git a/js-packages/web-console/src/lib/compositions/layout/useLayoutSettings.svelte.ts b/js-packages/web-console/src/lib/compositions/layout/useLayoutSettings.svelte.ts index 1e7d5b63192..8ae13c0dd9a 100644 --- a/js-packages/web-console/src/lib/compositions/layout/useLayoutSettings.svelte.ts +++ b/js-packages/web-console/src/lib/compositions/layout/useLayoutSettings.svelte.ts @@ -12,6 +12,10 @@ export const useLayoutSettings = () => { const hideWarnings = useLocalStorage('layout/pipelines/logs/hideWarnings', false) const verbatimErrors = useLocalStorage('layout/pipelines/logs/verbatimErrors', false) const sqlPanelFullHeight = useLocalStorage('layout/profile-bundle/sqlPanelFullHeight', false) + const pipelinesTableSort = useLocalStorage<{ column: string; direction: 'asc' | 'desc' }>( + 'layout/pipelines/table/sort', + { column: 'name', direction: 'asc' } + ) return { showPipelinesPanel, @@ -19,6 +23,7 @@ export const useLayoutSettings = () => { showInteractionPanel, hideWarnings, verbatimErrors, - sqlPanelFullHeight + sqlPanelFullHeight, + pipelinesTableSort } } From 440cc8af854902040a3f78e0037020f7c890b2ee Mon Sep 17 00:00:00 2001 From: Dnreikronos Date: Wed, 24 Jun 2026 12:31:59 -0300 Subject: [PATCH 10/22] Add pipeline duplicate controls --- .../assets/fonts/feldera-material-icons.css | 132 ++-- .../assets/fonts/feldera-material-icons.svg | 610 +++++++++--------- .../assets/fonts/feldera-material-icons.ttf | Bin 20784 -> 20832 bytes .../assets/fonts/feldera-material-icons.woff | Bin 10132 -> 10116 bytes .../assets/fonts/feldera-material-icons.woff2 | Bin 8272 -> 8196 bytes .../src/assets/fonts/generic-icons.css | 14 +- .../src/assets/fonts/generic-icons.svg | 36 +- .../src/assets/fonts/generic-icons.ttf | Bin 3728 -> 3728 bytes .../src/assets/fonts/generic-icons.woff | Bin 2148 -> 2140 bytes .../src/assets/fonts/generic-icons.woff2 | Bin 1656 -> 1676 bytes .../feldera-material-icons/copy-plus.svg | 1 + .../components/pipelines/list/Actions.svelte | 113 +++- .../pipelines/table/AvailableActions.svelte | 42 +- .../compositions/duplicatePipeline.spec.ts | 133 ++++ .../src/lib/compositions/duplicatePipeline.ts | 82 +++ 15 files changed, 733 insertions(+), 430 deletions(-) create mode 100755 js-packages/web-console/src/assets/icons/feldera-material-icons/copy-plus.svg create mode 100644 js-packages/web-console/src/lib/compositions/duplicatePipeline.spec.ts create mode 100644 js-packages/web-console/src/lib/compositions/duplicatePipeline.ts diff --git a/js-packages/web-console/src/assets/fonts/feldera-material-icons.css b/js-packages/web-console/src/assets/fonts/feldera-material-icons.css index 8284f29ff7b..22b32d51e98 100644 --- a/js-packages/web-console/src/assets/fonts/feldera-material-icons.css +++ b/js-packages/web-console/src/assets/fonts/feldera-material-icons.css @@ -2,8 +2,8 @@ font-family: "feldera-material-icons"; font-display: block; src: - url("feldera-material-icons.woff2?7e3c6a8d23bb66b7a89348ec07b22a96") format("woff2"), - url("feldera-material-icons.woff?7e3c6a8d23bb66b7a89348ec07b22a96") format("woff"); + url("feldera-material-icons.woff2?06f8372fd6db29f5f189dc16b2f9c8af") format("woff2"), + url("feldera-material-icons.woff?06f8372fd6db29f5f189dc16b2f9c8af") format("woff"); } .fd { @@ -20,201 +20,201 @@ .fd-chevron-down:before { content: "\f101"; } -.fd-arrow-down:before { +.fd-square:before { content: "\f102"; } -.fd-pencil-line:before { +.fd-wine:before { content: "\f103"; } -.fd-save:before { +.fd-trash-2:before { content: "\f104"; } -.fd-more_horiz:before { +.fd-book-open:before { content: "\f105"; } -.fd-menu:before { +.fd-moon:before { content: "\f106"; } -.fd-panel-right:before { +.fd-circle-x:before { content: "\f107"; } -.fd-dot:before { +.fd-mic-off:before { content: "\f108"; } -.fd-chevron-left:before { +.fd-plus:before { content: "\f109"; } -.fd-server-off:before { +.fd-circle-play:before { content: "\f10a"; } -.fd-circle-x:before { +.fd-chevron-left:before { content: "\f10b"; } -.fd-server-cog:before { +.fd-triangle-alert:before { content: "\f10c"; } -.fd-log-out:before { +.fd-paintbrush:before { content: "\f10d"; } -.fd-copy:before { +.fd-circle-stop:before { content: "\f10e"; } -.fd-file-down:before { +.fd-chevron-right:before { content: "\f10f"; } -.fd-play:before { +.fd-mic:before { content: "\f110"; } -.fd-message-circle-question:before { +.fd-square-power:before { content: "\f111"; } -.fd-chevron-right:before { +.fd-circle-check-big:before { content: "\f112"; } -.fd-lock-open:before { +.fd-circle-dot:before { content: "\f113"; } -.fd-lightbulb:before { +.fd-panel-right:before { content: "\f114"; } -.fd-key:before { +.fd-user:before { content: "\f115"; } -.fd-circle-play:before { +.fd-circle-stop-locked:before { content: "\f116"; } -.fd-square-power:before { +.fd-sliders-horizontal:before { content: "\f117"; } -.fd-circle-stop:before { +.fd-panel-left:before { content: "\f118"; } -.fd-network:before { +.fd-pencil-line:before { content: "\f119"; } -.fd-square:before { +.fd-info:before { content: "\f11a"; } -.fd-panel-bottom:before { +.fd-database-off:before { content: "\f11b"; } -.fd-database-off:before { +.fd-sun:before { content: "\f11c"; } .fd-file-text:before { content: "\f11d"; } -.fd-book-marked:before { +.fd-panel-bottom:before { content: "\f11e"; } -.fd-settings:before { +.fd-x:before { content: "\f11f"; } .fd-layout-panel-top:before { content: "\f120"; } -.fd-circle-user:before { +.fd-settings:before { content: "\f121"; } -.fd-eraser:before { +.fd-play:before { content: "\f122"; } -.fd-circle-pause:before { +.fd-rocket:before { content: "\f123"; } -.fd-trash-2:before { +.fd-server:before { content: "\f124"; } -.fd-plus:before { +.fd-circle-plus:before { content: "\f125"; } -.fd-moon:before { +.fd-more_horiz:before { content: "\f126"; } -.fd-mic-off:before { +.fd-key:before { content: "\f127"; } -.fd-wine:before { +.fd-copy:before { content: "\f128"; } -.fd-sun:before { +.fd-arrow-down:before { content: "\f129"; } -.fd-database:before { +.fd-circle-pause:before { content: "\f12a"; } -.fd-paintbrush:before { +.fd-menu:before { content: "\f12b"; } -.fd-circle-check-big:before { +.fd-lightbulb:before { content: "\f12c"; } -.fd-layout-panel-left:before { +.fd-server-cog:before { content: "\f12d"; } -.fd-arrow-right:before { +.fd-save:before { content: "\f12e"; } -.fd-x:before { +.fd-database:before { content: "\f12f"; } -.fd-circle-slash:before { +.fd-lock-open:before { content: "\f130"; } -.fd-panel-left:before { +.fd-log-out:before { content: "\f131"; } -.fd-circle-dot:before { +.fd-layout-panel-left:before { content: "\f132"; } -.fd-pause:before { +.fd-server-off:before { content: "\f133"; } -.fd-triangle-alert:before { +.fd-circle-slash:before { content: "\f134"; } -.fd-circle-divide:before { +.fd-arrow-right:before { content: "\f135"; } -.fd-info:before { +.fd-construction:before { content: "\f136"; } -.fd-construction:before { +.fd-file-down:before { content: "\f137"; } -.fd-rocket:before { +.fd-circle-divide:before { content: "\f138"; } -.fd-heart-crack:before { +.fd-network:before { content: "\f139"; } -.fd-circle-plus:before { +.fd-pause:before { content: "\f13a"; } -.fd-server:before { +.fd-copy-plus:before { content: "\f13b"; } -.fd-circle-stop-locked:before { +.fd-book-marked:before { content: "\f13c"; } -.fd-receipt-text:before { +.fd-dot:before { content: "\f13d"; } -.fd-check:before { +.fd-circle-user:before { content: "\f13e"; } -.fd-book-open:before { +.fd-circle-alert:before { content: "\f13f"; } -.fd-user:before { +.fd-message-circle-question:before { content: "\f140"; } -.fd-sliders-horizontal:before { +.fd-receipt-text:before { content: "\f141"; } -.fd-mic:before { +.fd-eraser:before { content: "\f142"; } -.fd-circle-alert:before { +.fd-check:before { content: "\f143"; } diff --git a/js-packages/web-console/src/assets/fonts/feldera-material-icons.svg b/js-packages/web-console/src/assets/fonts/feldera-material-icons.svg index 2e25e399e40..88d9035756f 100644 --- a/js-packages/web-console/src/assets/fonts/feldera-material-icons.svg +++ b/js-packages/web-console/src/assets/fonts/feldera-material-icons.svg @@ -13,402 +13,402 @@ - - - + + - - + + - - + + - - + + - - + + - - + + - - + + + + + + - - - - - - - + + - - + + - - - - - - - + horiz-adv-x="128" d="M60.16 122.4373333333333C52.3573333333333 121.952 45.3546666666667 120.064 38.08 116.48C31.904 113.44 27.4186666666667 110.2293333333333 22.5973333333333 105.4026666666667C17.7706666666667 100.5813333333333 14.56 96.096 11.52 89.92C7.824 82.4213333333333 6.0533333333333 75.7013333333333 5.5466666666667 67.2693333333333C4.9333333333333 57.1413333333333 6.928 47.392 11.52 38.08C14.56 31.904 17.7706666666667 27.4186666666667 22.5973333333333 22.5973333333333C27.4133333333333 17.7813333333333 31.888 14.576 38.08 11.5093333333333C46.4693333333333 7.36 54.464 5.5093333333333 64 5.5093333333333C73.536 5.5093333333333 81.5306666666667 7.36 89.92 11.5093333333333C96.112 14.576 100.5866666666667 17.7813333333333 105.4026666666667 22.5973333333333C110.2186666666667 27.4133333333333 113.424 31.888 116.4906666666667 38.08C120.64 46.4693333333333 122.4906666666667 54.464 122.4906666666667 64C122.4906666666667 73.536 120.64 81.5306666666667 116.4906666666667 89.92C113.424 96.112 110.2186666666667 100.5866666666667 105.4026666666667 105.4026666666667C100.5813333333333 110.2293333333333 96.096 113.44 89.92 116.48C82.448 120.1653333333333 75.728 121.9413333333333 67.3173333333333 122.4586666666667C63.8026666666667 122.672 63.9786666666667 122.6773333333333 60.16 122.4373333333333M69.2906666666667 111.7706666666667C87.872 109.7226666666667 103.808 96.7626666666667 109.6586666666667 78.9333333333333C113.3706666666667 67.6213333333333 112.6986666666667 55.1893333333333 107.776 44.2133333333333C103.856 35.4666666666667 96.848 27.648 88.4266666666667 22.6346666666667C76.7253333333333 15.664 62.0906666666667 14.064 49.0666666666667 18.3413333333333C34.5706666666667 23.0986666666667 23.0986666666667 34.576 18.336 49.072C16.7306666666667 53.9626666666667 16 58.6293333333333 16 64C16 67.8133333333333 16.2613333333333 70.4426666666667 16.992 73.9146666666667C19.7386666666667 86.9866666666667 27.9893333333333 98.464 39.616 105.392C45.0613333333333 108.6346666666667 52.2133333333333 111.056 58.4533333333333 111.7653333333333C60.8 112.032 66.9013333333333 112.032 69.2906666666667 111.7706666666667M46.9333333333333 85.136C45.3653333333333 84.816 44.064 83.792 43.232 82.2453333333333C42.7733333333333 81.3866666666667 42.7733333333333 81.3813333333334 42.7733333333333 64C42.7733333333333 46.6186666666667 42.7733333333333 46.6133333333333 43.232 45.7546666666667C43.8133333333333 44.6666666666667 44.6666666666667 43.8133333333333 45.7546666666667 43.232C46.6133333333333 42.7733333333333 46.6186666666667 42.7733333333333 64 42.7733333333333C81.3813333333333 42.7733333333333 81.3866666666667 42.7733333333333 82.2453333333333 43.232C83.3333333333333 43.8133333333333 84.1866666666667 44.6666666666667 84.768 45.7546666666667C85.2266666666667 46.6133333333333 85.2266666666667 46.6186666666667 85.2266666666667 64C85.2266666666667 81.3813333333334 85.2266666666667 81.3866666666667 84.768 82.2453333333333C84.1866666666667 83.3333333333333 83.3333333333333 84.1866666666667 82.2453333333333 84.768C81.3866666666667 85.2266666666667 81.3386666666667 85.2266666666667 64.5333333333333 85.2586666666667C55.264 85.2746666666667 47.344 85.2213333333333 46.9333333333333 85.136M74.6666666666667 64L74.6666666666667 53.3333333333333L64 53.3333333333333L53.3333333333333 53.3333333333333L53.3333333333333 64L53.3333333333333 74.6666666666667L64 74.6666666666667L74.6666666666667 74.6666666666667L74.6666666666667 64" /> + - - - - - - - - + + - + + + + + + + + + + + + - - + + - - + + - - - + horiz-adv-x="128" d="M60.16 122.4373333333333C48.3626666666667 121.7066666666667 36.6826666666667 117.1786666666667 27.312 109.712C24.704 107.632 20.368 103.296 18.288 100.688C13.088 94.16 9.0293333333333 85.904 7.0666666666667 77.8346666666667C4.9173333333333 69.0346666666667 4.9173333333333 58.9653333333333 7.0666666666667 50.1653333333333C9.0293333333333 42.096 13.088 33.84 18.288 27.312C20.368 24.704 24.704 20.368 27.312 18.288C32.192 14.4 39.0506666666667 10.608 44.7253333333333 8.6666666666667C59.7493333333333 3.52 75.8186666666667 4.5333333333333 89.92 11.5093333333333C96.112 14.576 100.5866666666667 17.7813333333333 105.4026666666667 22.5973333333333C110.2186666666667 27.4133333333333 113.424 31.888 116.4906666666667 38.08C120.64 46.4693333333333 122.4906666666667 54.464 122.4906666666667 64C122.4906666666667 73.536 120.64 81.5306666666667 116.4906666666667 89.92C113.424 96.112 110.2186666666667 100.5866666666667 105.4026666666667 105.4026666666667C100.5813333333333 110.2293333333333 96.096 113.44 89.92 116.48C82.448 120.1653333333333 75.728 121.9413333333333 67.3173333333333 122.4586666666667C63.8026666666667 122.672 63.9786666666667 122.6773333333333 60.16 122.4373333333333M69.2906666666667 111.7706666666667C87.872 109.7226666666667 103.808 96.7626666666667 109.6586666666667 78.9333333333333C113.3706666666667 67.6213333333333 112.6986666666667 55.1893333333333 107.776 44.2133333333333C103.856 35.4666666666667 96.848 27.648 88.4266666666667 22.6346666666667C76.7253333333333 15.664 62.0906666666667 14.064 49.0666666666667 18.3413333333333C34.5706666666667 23.0986666666667 23.0986666666667 34.576 18.336 49.072C16.7306666666667 53.9626666666667 16 58.6293333333333 16 64C16 67.8133333333333 16.2613333333333 70.4426666666667 16.992 73.9146666666667C19.7386666666667 86.9866666666667 27.9893333333333 98.464 39.616 105.392C45.0613333333333 108.6346666666667 52.2133333333333 111.056 58.4533333333333 111.7653333333333C60.8 112.032 66.9013333333333 112.032 69.2906666666667 111.7706666666667M62.9333333333333 90.4693333333333C58.9013333333333 89.6426666666667 57.3813333333333 84.544 60.2933333333333 81.6266666666667C61.7706666666667 80.1546666666667 64.4 79.6746666666667 66.24 80.5493333333333C67.9946666666667 81.3813333333334 69.3333333333333 83.4506666666667 69.3333333333333 85.3333333333333C69.3333333333333 87.1786666666667 67.9893333333333 89.3013333333333 66.336 90.064C65.4133333333333 90.4906666666667 63.9093333333333 90.6666666666667 62.9333333333333 90.4693333333333M62.9333333333333 69.136C61.3706666666667 68.816 60.064 67.792 59.232 66.2453333333333C58.7786666666667 65.3973333333333 58.7733333333333 65.2586666666667 58.7733333333333 53.3333333333333C58.7733333333333 41.408 58.7786666666667 41.2693333333333 59.232 40.4213333333333C60.2773333333333 38.4693333333333 61.8773333333333 37.4826666666667 64 37.4826666666667C66.1226666666667 37.4826666666667 67.7226666666667 38.4693333333333 68.768 40.4213333333333C69.2213333333333 41.2693333333333 69.2266666666667 41.408 69.2266666666667 53.3333333333333C69.2266666666667 65.2586666666667 69.2213333333333 65.3973333333333 68.768 66.2453333333333C67.584 68.4533333333334 65.248 69.6106666666667 62.9333333333333 69.136" /> + + + - - - + + - + horiz-adv-x="128" d="M29.9573333333333 100.816C28.944 100.3786666666667 27.7066666666667 99.1893333333333 27.28 98.24C26.8053333333333 97.2 26.7573333333333 95.008 27.184 93.9786666666667C27.5786666666667 93.04 30.0053333333333 90.5173333333333 44.4693333333333 76L56.4266666666667 64L44.4693333333333 52C29.8613333333333 37.344 27.5786666666667 34.96 27.1626666666667 33.968C26.7413333333333 32.9653333333334 26.752 31.0453333333333 27.184 30.032C27.6053333333333 29.04 28.672 27.808 29.472 27.3973333333333C30.48 26.8746666666667 32 26.6773333333333 33.136 26.9173333333333C33.8666666666667 27.072 34.5386666666667 27.4506666666667 35.4826666666667 28.2293333333333C36.208 28.832 42.9226666666667 35.4133333333333 50.4 42.8533333333333L64 56.384L77.6 42.8533333333333C85.0826666666667 35.4133333333333 91.792 28.832 92.5173333333333 28.2293333333333C94.0533333333333 26.96 95.28 26.5813333333333 96.9013333333333 26.8906666666667C98.2453333333333 27.1413333333333 98.9973333333333 27.552 99.808 28.4746666666667C100.7413333333333 29.536 101.152 30.6186666666667 101.1573333333333 32.0106666666667C101.168 34.3893333333333 101.856 33.6106666666667 83.424 52.1066666666667L71.5733333333333 64L83.424 75.8933333333334C98.08 90.6026666666667 100.4266666666667 93.0453333333333 100.8373333333333 94.0266666666667C101.3173333333333 95.1786666666667 101.2533333333333 96.9973333333333 100.688 98.2026666666667C99.8026666666667 100.0906666666667 98.08 101.12 95.808 101.12C93.5733333333333 101.12 94.2933333333333 101.76 76 83.5306666666667L64 71.5733333333333L52.32 83.216C36.6453333333333 98.832 34.7786666666667 100.6133333333334 33.7706666666667 100.8906666666667C32.6613333333333 101.2 30.752 101.1626666666667 29.9573333333333 100.816" /> + - - - + + - - + + - - + + - - + + - - + + - - - - + + + + - - + + - - + + + + + + + + + + + + - - - - + + + + - - - - + + - - - - - - - - - - - - + horiz-adv-x="128" d="M61.9573333333333 106.1493333333333C60.944 105.712 59.7066666666667 104.5226666666667 59.28 103.5733333333333C58.8053333333333 102.528 58.7573333333333 100.336 59.184 99.312C59.5733333333333 98.3786666666667 62.064 95.792 76.544 81.2586666666667L88.3626666666667 69.3973333333333L56.7786666666667 69.2853333333333L25.1946666666667 69.1733333333334L24.2773333333333 68.6346666666667C22.3573333333333 67.4986666666667 21.5466666666667 66.128 21.5466666666667 64C21.5466666666667 61.7973333333333 22.5386666666667 60.224 24.5813333333333 59.1786666666667L25.584 58.6666666666667L57.008 58.6666666666667L88.4266666666667 58.6666666666667L76.4693333333333 46.6666666666667C61.8613333333333 32.0106666666667 59.5786666666667 29.6266666666667 59.1626666666667 28.6346666666667C58.7413333333333 27.632 58.752 25.712 59.184 24.6986666666667C59.6746666666667 23.5413333333333 60.7786666666667 22.384 61.84 21.9146666666667C62.976 21.4133333333333 64.88 21.36 65.9306666666667 21.808C67.728 22.5706666666667 105.4186666666667 60.224 106.16 62.0053333333333C106.5386666666667 62.9066666666667 106.5386666666667 65.0933333333333 106.1653333333333 65.9893333333333C105.7013333333333 67.0986666666667 87.136 86.0053333333333 73.3386666666667 99.424C67.9786666666667 104.6346666666667 66.4 106.0533333333333 65.7653333333333 106.2293333333333C64.6613333333333 106.5333333333333 62.7466666666667 106.496 61.9573333333333 106.1493333333333" /> + - + + - - + + - - + + - - + + - - + + - - + + + + + + + + + + + + - - - - - - - - - - diff --git a/js-packages/web-console/src/assets/fonts/feldera-material-icons.ttf b/js-packages/web-console/src/assets/fonts/feldera-material-icons.ttf index 2b05d6f7f9cfbf135e0cfa8b2d6eeb585c748169..8b1d7fa97691a42cfcd68a92a285c05f55312f0a 100644 GIT binary patch delta 3536 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 NESqLytl5}^{{a3&{ICE3 diff --git a/js-packages/web-console/src/assets/fonts/feldera-material-icons.woff b/js-packages/web-console/src/assets/fonts/feldera-material-icons.woff index ff6b30eee3a159bd7badd3a91f4a795adc6f0c27..3e552c9e8182d0b2eb66fac7757c82104986b86f 100644 GIT binary patch delta 9685 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 X0ftDCAVwb(L|8+&R{I5!rWhXp^b5E& delta 9701 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

nrEQHxPl#27eG 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 - - - - + horiz-adv-x="128" d="M91.818 92H36.1818C32.7818 92 30 89.2 30 85.7776V42.2224C30 38.8 32.7818 36 36.1818 36H91.818C95.218 36 98 38.8 98 42.2224V85.7776C98 89.2 95.218 92 91.818 92zM91.818 42.2224H36.1818V85.7776H91.818V42.2224zM74 82H88V45.3332H74V82z" /> + - + + - + horiz-adv-x="128" d="" /> + diff --git a/js-packages/web-console/src/assets/fonts/generic-icons.ttf b/js-packages/web-console/src/assets/fonts/generic-icons.ttf index 9f8f3f55f575ad8c0879911148e9829c70613be7..e9db6e59e2d4ca4d81e656a854fa44de5cae0ff2 100644 GIT binary patch delta 703 zcmXYtKX21e6vgj#+~>rO8|Qx_lm*llp<0D10}G;J;jdJrilq}y;y6}cI_h3KA=biJ6TKd;vZI6UX7n`uV(j&b{wF`?7P{=@Ju>MGMN&?T1G%9^Ck` z^P7n8!u@-Aw737{=c6T&)1E5(Bw~S)Dd8YvHls zR*?R8HB@DE*3ehNx{hf9r;*Lv#F!1EytXzmR-_iz2HM)|O$83EQFFDgJ?1Tgnkcf9 zTkG4D(Ijxk0yc3lV&QK2^j+?(Z8U3*Qmax$2O0{9>z0Y1l27mGi}b5zq@VQda|2FK z(S-IDHRwRmfclCObf{=S14RWoQdFU#q6|Gzw4qWVpkt!r?1k$SMT!D+qOhT{!huc| zb!einpfg4Gd2>ZR{kU-;Xqygb_(oh$9bu-oti$w!xmaz9SNdvhz1L1F(wj%K#GQC! zU%GJ^jFMtMjQi4er;!W?ri{FSAG@AR>|PYga2&Wp=}k}aqa>NQfuFEskLMimj1zWw X!ejP1=8yv(ai6C=;2sY-`v?C3D0O5+ delta 686 zcmXYty>HWC5XSEu*LLE>PMkPSh6+(sV5s>h85j{l;zJP#DnMdDbsaacYGT)MhfK{- zAtaU>FKn>F1PU7q6aN7J212YL2A){2te>Cv?w9n8rFrtpQN(PzKB#9K-qBXJ*_u%T|RHrtg8g<~RQx~^%Y9TKp zr=sGZ(nZBU-6|(Y@K|`-$m*yfdlB{uLJd|M#tzOVuCJDG%1a8)EwY!}(p}y(u!miC zZj{qB>{c!3wb8&t$3FFn_m#uGq1aBRp|{|GK}FfL`uHim;;OtTzDa8FSw4SkAhRb> zpuWI{jsy-g5OkpXf(CRf(4iAS3mOVE=z*XKjRaNbl<3*&h4qPIfeK9oZD=a!LT3UK znh7lEkzno62xG*FQ?deZzTD_ruhW z0(TZi;iwkH-Y7_2FUqXQo5x3)n|RY8a?@};$#{=r4msi}kJ;sY_ISnzJmHKJ4%p|E KM?B}%M*jgE?_K%; diff --git a/js-packages/web-console/src/assets/fonts/generic-icons.woff b/js-packages/web-console/src/assets/fonts/generic-icons.woff index 5de327d83e7a67e45f12e084b834571dfb96f1cf..102d845addbdc5da550990338e10f7cb945d208d 100644 GIT binary patch delta 1621 zcmV-b2CDhw5Zn+9cTYw}00961000PFkqj$;r2qf``Xk9F-%nF7G5`PpumAu6KL7v# zU`o?Skz;LPZ~y=S@Bjb-1ONa6@I4#Dw`Xj5W&i*J^Z)<@AOHXbRNv!Y(P(91WB>pL z6aWAKEdT%jHgjdYv1n*zVE_OIL;wH)761SMBmn^cn`muxcmMzfTmS$75C8xGXaazL z003-nV_^UQ25bNT02BZK05%=k9gb~bcyIs!27mwn03QGV03ZP{0l98rZDjxe2A}`{ z0WSam0#aURqPlQzb94Xz2hacj0Ez$r0K>sQO9ptHO^!N{wK#*K5=wqTA?GV)}C8tHd?*``2Py)0001ZoMT{bZ#X~m z3fH*dH*K{MQgo=%qQD#_D$KCJ5EEp77;|icoGV{&=71~4R;e4}ycnH1-XiVlwwRX{ zt6?!2OKEcM04w<);s43qtG9sdd|+>ulG1~8^e8nwNlVXC(2LCJRZ4o3idHhCwO9=s zu@l(z5t~AnhGM>d4>@ySK}ifQ4=yI8iJ>(ZPX!z>$^O%JmQW z0ezZsr>>qo7%)i;dwRO6ySgXQ#GX&dW0EH?k~&G2w(%Pj)e(pJ9mX!S(-4NTeS9m< z$$5GEOO`!4xw8$I!=1Y^7DTRp&wSV9X)+X7I6Pq7;@9kDmc4v!%b!nwF8{|=#4vhl ziww;c;{%52%wgz@bx{@Tb#WP)Y`wHCeHZ_6ts~dpe1A`VB>$kr0Uz)Qf8Yzg;v2p* zLnb-OYlN~cgD=YZ3iaN!PRo44ctlK$Y?D}x1>0blWX0q{=M z{Hur_xzHi0t_12uL`sNV9L zLsu@Gn?o5Zdae0lIi9-73fU8Dz75by)Hq|8Jqd>QUeAZ@H5!N1X>30Y2I$>$Bk@1o zEb8JVSQ|S-Ee~UD*e<4W^6EKzZI{aKftqbPH1g?ry09r)Le@5a6Tu1dt4$_8yDW^eDrb+!J88QM(~W3Ou)5eyTak%*?J!xC`R TG?WZfy!p^7^##4PB0B&8ktx+~ delta 1614 zcmV-U2C@0v5abXHcTYw}00961000PNkqj$;qW}N^`Xk0C{7+LaG5`PptN;K2KL7v# zU`o?Skz;LPZ~y=S>;M1(1poj7@Ju49yJu{9W&i*J^Z)<@CjbBiRNv!Y(P(91WB>pL z8~^|SEC2uiHgjdYv1n*zVE_OINB{r;761SMBmn^cn`muxcmMzfU;qFB5C8xGXaazL z003-nV_^UQ25tpnx6j9IT}hAG++JS3k3jpoMT{NkYt>bxB)tU zwbD&;f zfd&u(2Bo#eNN-vVos}!`B0c~VV zTN%<$*f)5SnD#QEgG}jNa{7>xj$-e468?qOb*w+zUI%s$I@X_U*s-2&qYk2fAK+6g zssI20c$`I4%Z}Sd5M*@^XXIUljAK4v*xCh1FL~I7v=Uzi1g~<)#b*OPEpqS=!*9a#RCSDTr$HJZ9ffDe?l7yA8yfiQ@4yKsY?QKw97Pqg!?PmQ^v5= zqzi4-K2QA?O|Aurs(ssctf{_|454L~Wt@)`BRM+|n0GwsJ=JY6jI95Jq0dmzaUju7 zI+AbL0p?!jVQH8-l;j;O(X<8aBBRuC9%|1*iVbrf!Nq>;`f#Y}V)r`Mb)%^Q5a(*X zRYZ>*=#W%b0(C3T85cAvq=1Wds$LCZf6(uoK|M`z21x~6QzsG#QBBF7k~xK>Vn`*8 zI-?>v5jKrP)0F0^a+ZhkQc}-Ep3=1>4UE`7vxdyr zsbhKeOJkAlBQ;QmL~!g;l!^B+kafg35bRApeP&H#MwV%-F?AH6_{&Okfg%-ey3{z0 z&;aljLq;E-QW=hW;x1MuBxR*ROdKQxe+Egc-twA5S1x~?o5M0z^jh=9ay)gD3fU8D zzHOjM)VRkkdny>-dp$3**JvD4r?LGs7@&90jl}z zf_M*o=EHv*X=(VqT;cElUKYP*FSG3BYa9N2dULSo|1lLYjGkJ4I1RrXej3x6!_XJ& zqAJ$w;xaPXcxhAmF8<@v$n`0CO!DMKQj@Q3{04>HbMrflU1)hV3}yTHmc5em@b;H1 zdvtPV6Ap(vcSDkY0kBL`v;cUVV_;-pU;yF{W6dDAcz&C&4BQM2K!GUE$NP|IAZP%I zGjK34fj9sZ?+JsGLR diff --git a/js-packages/web-console/src/assets/fonts/generic-icons.woff2 b/js-packages/web-console/src/assets/fonts/generic-icons.woff2 index 310e19f57ce9a43331bdcfff2a1764ed8f545eac..0a75081b4e8660dce6eeacb73aaba7d19f79633e 100644 GIT binary patch delta 1640 zcmV-u2ABEx42%sJcTYw#00961000Jz01E&B000h<000I&kr+9DGov&1-G{JJ)f;9`HQQp!D{A_rB^%a>kVG z{9w!3%Ats*x@E(uRp)0Vf%=|M#ixG6-B`6fP?64E<4Q-j0JcCFY1Kwf!*mE>*Ecund zEmYm_%n;arzJnAo7WXd@Udi=K@lGzTn)9BPxPz9K7)zXV#syd1{FVIwzbJapY3E#u zHw~3aTOKZ%q#c>VWSuXPyzxd1p!ooIGV{yti=^-i^0=Y2;Z0=UFn6-1wx2ziZx?!vh52Cvoo$PeFc*4rbw z{y4mU4gKhyQQ*Fdq}mv;cCiWUMF%z|i*7bREvlZ`rVU#fF(Q)Z6m)wCddaNitR^68 zrN(C2g2m^9j0q$}Ioo8dR?BWI;+2zS%vKv6W*VH z*jrdOS7@K?opc-P4Y+=8^gic4dvhxfe4TV{5PzJ@`GsuS=cclTg!JcTtA8Jwr&p-!c?UYGB)CZBO< zXd{-Z!r(Ar20`FJAe0+MeaM~-U1P(5Zh_^S={G-p9^Jj};=i2X7Gc-ykJqC`uPucL zZ6}_2HNtPT^?PfgTZ=z6Zyzv!2`|0@t0Pp{EPm6=THfYX7Sy%d6Uo&N6z?28FeHgH zkg8gWOx*@lEVBLNs;-M-A(@0=;@*Hr!&Ag2o*RU?OL1Xu8ZCzGK9iC*|m)jeQE^-Zg@1x=F`oiTFdKcRZhPZ!tP@wU3ISMEVTp~z}o z*!$*3*`88Lcg+*QCL&+#xF!)JrgG+24^SZ5>ny5g2Mrr9=}I5jF}?rC_kOoN{kTa8 zMPi9mCRZp`YK>N>HyBMvW{cHkcQ{?a(;2w%qHczQ@w<#fD5)z&R>B!jt_~^5AgD zPRK^eE!xpGl}-i4_!m}tU;AoRPeP^~2L%Bt8xuAtBYDTI8oIoiu+HhT}k)V=gozC3*H-oMR|w4jPgvL;BPlC>mjT_k$A zOt4^A_cFf1^_&fVHfIU>VV5wnwS(>ZE;D^ePN|Ze4Ys_qDa#ShUrCB{!YfAe-H=yZGdPqb#+~ZNHVFlJGddbA%$X zL?D&P8Ll-PrBu%|CCdLAJ1Ss#9ylfeDn_*?>#lx)O`j6>K@X`C5%Y zEtc`@tFD1vbS^Y(L%jEU!$Ge)6q6s{%e4@Olb}l+*v?$%&)TfUg0pbJIW*urI&c9sxQGN?LIf_O09W9HYskTWbp+rhLU0QnxQz~aKyHW?Gks>(36(7QrjH* zX&3u5V@=bAdIGeo_oNds>Vc477>LI3yFP1>WzJpt@NrOPG$WJy0yEHeF~4lzoMQZc ze%A^(ye>Zlb)-y?!`H)A1rqTQ?J}n$0+#naKH1PKGGeJ*`EBW$sy@r2JeywC?A@}Q zMm$mO^K&GfA-IxM&q>oX)*g-*e@wwm+BAU95doZ*0*Q9 zS#6$$%TXm-c;K7<&xxX~N-qg${KCBG5dFzCSvmquxsaI)5_Uiodbe)V(kTp4Yo!E- z5>;At0v+egG0csZFgKCWc}d=NDc9EP@KxvX2izIjh~biV#k^tRk}3gIO3-w6!376Q*zeFKgX}~ zc-%bsy>j}mW1gZV_`i`XZ+ymA+L&lG#J4)>y*1s_!JjHy3yksk>se_)C>Fpsy(Blg zZzYm+ZViSfS+K=ZYdCDbg>XcF*1?t7iO_13{Uen-CMXBU=p}=Z`T)!5lf(9#g`+hG z*XTb&$6|H)$g;S22?B6wb?GIAbO(8hhBpT \ No newline at end of file diff --git a/js-packages/web-console/src/lib/components/pipelines/list/Actions.svelte b/js-packages/web-console/src/lib/components/pipelines/list/Actions.svelte index 14c95abdd03..448a2251f97 100644 --- a/js-packages/web-console/src/lib/components/pipelines/list/Actions.svelte +++ b/js-packages/web-console/src/lib/components/pipelines/list/Actions.svelte @@ -49,9 +49,14 @@ groups related actions into multi-action dropdowns when multiple options are ava import DeleteDialog, { deleteDialogProps } from '$lib/components/dialogs/DeleteDialog.svelte' import JSONDialog from '$lib/components/dialogs/JSONDialog.svelte' import PipelineConfigurationsPopup from '$lib/components/layout/pipelines/PipelineConfigurationsPopup.svelte' + import { duplicatePipeline, duplicatePipelineTooltip } from '$lib/compositions/duplicatePipeline' import { useGlobalDialog } from '$lib/compositions/layout/useGlobalDialog.svelte' import { useIsMobile } from '$lib/compositions/layout/useIsMobile.svelte' import { usePipelineActionCallbacks } from '$lib/compositions/pipelines/usePipelineActionCallbacks.svelte' + import { + usePipelineList, + useUpdatePipelineList + } from '$lib/compositions/pipelines/usePipelineList.svelte' import { getPipelineAction } from '$lib/compositions/usePipelineAction.svelte' import { usePipelineManager } from '$lib/compositions/usePipelineManager.svelte' import { usePremiumFeatures } from '$lib/compositions/usePremiumFeatures.svelte' @@ -84,11 +89,26 @@ groups related actions into multi-action dropdowns when multiple options are ava const globalDialog = useGlobalDialog() const api = usePipelineManager() + const pipelineList = usePipelineList() + const { discardPendingListRefresh, updatePipeline, updatePipelines } = useUpdatePipelineList() const deletePipeline = async (pipelineName: string) => { await api.deletePipeline(pipelineName) onDeletePipeline?.(pipelineName) goto(resolve('/')) } + const duplicateCurrentPipeline = async () => { + const pipelines = pipelineList.pipelines + if (!pipelines || unsavedChanges) { + return + } + + const newPipeline = await duplicatePipeline(api, pipeline.current, pipelines, { + discardPendingListRefresh, + updatePipeline, + updatePipelines + }) + await goto(resolve(`/pipelines/${encodeURIComponent(newPipeline.name)}/`)) + } const { toastError } = useToast() const actions = { @@ -105,7 +125,8 @@ groups related actions into multi-action dropdowns when multiple options are ava _stop, _multiStop, _multiStart, - _delete, + _duplicate, + _more, _spacer_short, _spacer_long, _spinner, @@ -135,7 +156,7 @@ groups related actions into multi-action dropdowns when multiple options are ava '_saveFile', '_configurations', '_storage_indicator', - '_delete' + '_more' ]) .with('Preparing', 'Provisioning', 'Initializing', () => [ '_kill', @@ -143,7 +164,7 @@ groups related actions into multi-action dropdowns when multiple options are ava '_saveFile', '_configurations', '_storage_indicator', - '_delete' + '_more' ]) .with('Pausing', 'Resuming', () => [ ...stopButtons, @@ -151,7 +172,7 @@ groups related actions into multi-action dropdowns when multiple options are ava '_saveFile', '_configurations', '_storage_indicator', - '_delete' + '_more' ]) .with('Unavailable', () => [ ...stopButtons, @@ -159,7 +180,7 @@ groups related actions into multi-action dropdowns when multiple options are ava '_saveFile', '_configurations', '_storage_indicator', - '_delete' + '_more' ]) .with('Running', () => [ ...stopButtons, @@ -167,7 +188,7 @@ groups related actions into multi-action dropdowns when multiple options are ava '_saveFile', '_configurations', '_storage_indicator', - '_delete' + '_more' ]) .with('Paused', () => [ ...stopButtons, @@ -175,7 +196,7 @@ groups related actions into multi-action dropdowns when multiple options are ava '_saveFile', '_configurations', '_storage_indicator', - '_delete' + '_more' ]) .with('Suspending', () => [ '_kill', @@ -183,7 +204,7 @@ groups related actions into multi-action dropdowns when multiple options are ava '_saveFile', '_configurations', '_storage_indicator', - '_delete' + '_more' ]) .with('Suspended', () => [ '_spinner', @@ -191,7 +212,7 @@ groups related actions into multi-action dropdowns when multiple options are ava '_saveFile', '_configurations', '_storage_indicator', - '_delete' + '_more' ]) .with('Standby', () => [ '_kill', @@ -199,7 +220,7 @@ groups related actions into multi-action dropdowns when multiple options are ava '_saveFile', '_configurations', '_storage_indicator', - '_delete' + '_more' ]) .with('Bootstrapping', () => [ '_kill', @@ -207,7 +228,7 @@ groups related actions into multi-action dropdowns when multiple options are ava '_saveFile', '_configurations', '_storage_indicator', - '_delete' + '_more' ]) .with('Replaying', () => [ '_kill', @@ -215,14 +236,14 @@ groups related actions into multi-action dropdowns when multiple options are ava '_saveFile', '_configurations', '_storage_indicator', - '_delete' + '_more' ]) .with('AwaitingApproval', () => [ '_kill', '_saveFile', '_configurations', '_storage_indicator', - '_delete' + '_more' ]) .with('Stopping', () => [ '_kill', @@ -230,7 +251,7 @@ groups related actions into multi-action dropdowns when multiple options are ava '_saveFile', '_configurations', '_storage_indicator', - '_delete' + '_more' ]) .with( { Queued: P.any }, @@ -243,7 +264,7 @@ groups related actions into multi-action dropdowns when multiple options are ava '_saveFile', '_configurations', '_storage_indicator', - '_delete' + '_more' ] ) .with('SqlError', 'RustError', 'SystemError', () => [ @@ -251,7 +272,7 @@ groups related actions into multi-action dropdowns when multiple options are ava '_saveFile', '_configurations', '_storage_indicator', - '_delete' + '_more' ]) .exhaustive() } @@ -288,7 +309,7 @@ groups related actions into multi-action dropdowns when multiple options are ava const active = $derived.by(() => { const rawActions = getRawActions(pipeline.current.status) // Group actions into dropdowns - return processActionsForDropdowns(rawActions) + return [...processActionsForDropdowns(rawActions), '_duplicate' as const] }) function processActionsForDropdowns( @@ -637,18 +658,52 @@ groups related actions into multi-action dropdowns when multiple options are ava {/snippet} --> -{#snippet _delete()} -

- {#if editConfigDisabled} - Stop the pipeline to delete it - {/if} +{#snippet _more()} + + {#snippet trigger(toggle)} + + {/snippet} + {#snippet content(close)} +
+ +
+ {/snippet} +
+{/snippet} +{#snippet _duplicate()} + + + {#if unsavedChanges} + Save the program before duplicating. + {:else} + {duplicatePipelineTooltip} + {/if} + {/snippet} {#snippet start({ text, diff --git a/js-packages/web-console/src/lib/components/pipelines/table/AvailableActions.svelte b/js-packages/web-console/src/lib/components/pipelines/table/AvailableActions.svelte index 43f410c7c2d..cac7709db28 100644 --- a/js-packages/web-console/src/lib/components/pipelines/table/AvailableActions.svelte +++ b/js-packages/web-console/src/lib/components/pipelines/table/AvailableActions.svelte @@ -2,6 +2,7 @@ import { join } from 'array-join' import { match, P } from 'ts-pattern' import DeleteDialog, { deleteDialogProps } from '$lib/components/dialogs/DeleteDialog.svelte' + import { duplicatePipeline, duplicatePipelineTooltip } from '$lib/compositions/duplicatePipeline' import { useGlobalDialog } from '$lib/compositions/layout/useGlobalDialog.svelte' import { useUpdatePipelineList } from '$lib/compositions/pipelines/usePipelineList.svelte' import { getPipelineAction } from '$lib/compositions/usePipelineAction.svelte' @@ -15,9 +16,10 @@ pipelines, selectedPipelines = $bindable() }: { pipelines: PipelineThumb[]; selectedPipelines: string[] } = $props() - const { updatePipeline } = useUpdatePipelineList() + const { discardPendingListRefresh, updatePipeline, updatePipelines } = useUpdatePipelineList() const sortedSelectedPipelines = $derived([...selectedPipelines].sort()) const availableActions = [ + 'duplicate' as const, 'start' as const, 'resume' as const, 'pause' as const, @@ -26,12 +28,14 @@ 'delete' as const, 'clear' as const ] + type AvailableAction = (typeof availableActions)[number] + type StatusAction = Exclude const isPremium = usePremiumFeatures() const stop = isPremium.value ? ['stop' as const] : [] const statusActions = ({ status, storageStatus }: (typeof selected)[number]) => { const storageAction = storageStatus === 'InUse' ? ['clear' as const] : [] return match(status) - .returnType<(typeof availableActions)[number][]>() + .returnType() .with( { Queued: P.any }, { CompilingSql: P.any }, @@ -69,7 +73,7 @@ (_, p) => p ) ) - const eligibleFor = (action: (typeof availableActions)[number]) => + const eligibleFor = (action: StatusAction) => selected .filter((pipeline) => statusActions(pipeline).includes(action)) .map((p) => p.name) @@ -78,11 +82,15 @@ if (selected.length === 0) { return [] } - const supportedByAny = new Set(selected.flatMap(statusActions)) + const supportedByAny = new Set(selected.flatMap(statusActions)) + if (selected.length === 1) { + supportedByAny.add('duplicate') + } return availableActions .filter((action) => supportedByAny.has(action)) .map((action) => match(action) + .with('duplicate', () => btnDuplicate) .with('start', () => btnStart) .with('resume', () => btnResume) .with('pause', () => btnPause) @@ -96,7 +104,7 @@ const globalDialog = useGlobalDialog() const api = usePipelineManager() - const postPipelinesAction = (action: Exclude<(typeof availableActions)[number], 'delete'>) => { + const postPipelinesAction = (action: Exclude) => { selected .filter((pipeline) => statusActions(pipeline).includes(action)) .forEach((pipeline) => { @@ -128,6 +136,20 @@ }) selectedPipelines = [] } + + const duplicateSelectedPipeline = async () => { + const pipeline = selected[0] + if (!pipeline) { + return + } + + await duplicatePipeline(api, pipeline, pipelines, { + discardPendingListRefresh, + updatePipeline, + updatePipelines + }) + selectedPipelines = [] + } {#snippet btnStart()} @@ -166,6 +188,16 @@ Delete {/snippet} +{#snippet btnDuplicate()} + +{/snippet} {#snippet btnClear()}