diff --git a/docs/integrations/overview.md b/docs/integrations/overview.md index 94b9289d21..e07313a1bc 100644 --- a/docs/integrations/overview.md +++ b/docs/integrations/overview.md @@ -24,6 +24,7 @@ SQLMesh supports the following execution engines for running SQLMesh projects (e * [Postgres](./engines/postgres.md) (postgres) * [GCP Postgres](./engines/gcp-postgres.md) (gcppostgres) * [Redshift](./engines/redshift.md) (redshift) +* [RisingWave](./engines/risingwave.md) (risingwave) * [Snowflake](./engines/snowflake.md) (snowflake) * [Spark](./engines/spark.md) (spark) * [Trino](./engines/trino.md) (trino) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index a9ce9366e1..e3fb10ee4e 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -15,6 +15,9 @@ Options: --debug Enable debug mode. --log-to-stdout Display logs in stdout. --log-file-dir TEXT The directory to write log files to. + --dotenv PATH Path to a custom .env file to load environment + variables. Can also be set via SQLMESH_DOTENV_PATH + environment variable. --help Show this message and exit. Commands: diff --git a/tests/utils/test_conversions.py b/tests/utils/test_conversions.py new file mode 100644 index 0000000000..1e1b62f77e --- /dev/null +++ b/tests/utils/test_conversions.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +from datetime import date, datetime + +import pytest + +from sqlmesh.utils.conversions import ensure_bool, make_serializable, try_str_to_bool + + +class TestTryStrToBool: + """Tests for the try_str_to_bool function.""" + + @pytest.mark.parametrize( + "input_val,expected", + [ + ("true", True), + ("True", True), + ("TRUE", True), + ("TrUe", True), + ("false", False), + ("False", False), + ("FALSE", False), + ("FaLsE", False), + ], + ) + def test_boolean_strings(self, input_val: str, expected: bool) -> None: + """Strings 'true' and 'false' (case-insensitive) convert to bool.""" + assert try_str_to_bool(input_val) is expected + + @pytest.mark.parametrize( + "input_val", + [ + "yes", + "no", + "1", + "0", + "", + "truthy", + "falsey", + "t", + "f", + "on", + "off", + ], + ) + def test_non_boolean_strings_pass_through(self, input_val: str) -> None: + """Non-boolean strings are returned unchanged.""" + assert try_str_to_bool(input_val) == input_val + + def test_return_type_for_true(self) -> None: + """Returns actual bool True, not truthy value.""" + result = try_str_to_bool("true") + assert result is True + assert isinstance(result, bool) + + def test_return_type_for_false(self) -> None: + """Returns actual bool False, not falsey value.""" + result = try_str_to_bool("false") + assert result is False + assert isinstance(result, bool) + + +class TestEnsureBool: + """Tests for the ensure_bool function.""" + + def test_bool_true_passthrough(self) -> None: + """Boolean True passes through unchanged.""" + assert ensure_bool(True) is True + + def test_bool_false_passthrough(self) -> None: + """Boolean False passes through unchanged.""" + assert ensure_bool(False) is False + + @pytest.mark.parametrize( + "input_val,expected", + [ + ("true", True), + ("True", True), + ("false", False), + ("False", False), + ], + ) + def test_boolean_strings(self, input_val: str, expected: bool) -> None: + """String 'true'/'false' converts to corresponding bool.""" + assert ensure_bool(input_val) is expected + + @pytest.mark.parametrize( + "input_val,expected", + [ + ("yes", True), # Non-empty string is truthy + ("no", True), # Non-empty string is truthy + ("", False), # Empty string is falsey + ("0", True), # String "0" is truthy (non-empty) + ], + ) + def test_other_strings_use_bool_conversion(self, input_val: str, expected: bool) -> None: + """Non-boolean strings fall back to bool() conversion.""" + assert ensure_bool(input_val) is expected + + @pytest.mark.parametrize( + "input_val,expected", + [ + (1, True), + (0, False), + (-1, True), + (100, True), + ], + ) + def test_integers(self, input_val: int, expected: bool) -> None: + """Integers convert via bool() - 0 is False, others True.""" + assert ensure_bool(input_val) is expected + + @pytest.mark.parametrize( + "input_val,expected", + [ + (1.0, True), + (0.0, False), + (-0.5, True), + ], + ) + def test_floats(self, input_val: float, expected: bool) -> None: + """Floats convert via bool() - 0.0 is False, others True.""" + assert ensure_bool(input_val) is expected + + @pytest.mark.parametrize( + "input_val,expected", + [ + ([], False), + ([1], True), + ({}, False), + ({"a": 1}, True), + (None, False), + ], + ) + def test_other_types(self, input_val: object, expected: bool) -> None: + """Other types convert via bool().""" + assert ensure_bool(input_val) is expected + + +class TestMakeSerializable: + """Tests for the make_serializable function.""" + + def test_date_to_isoformat(self) -> None: + """date objects convert to ISO format string.""" + d = date(2024, 1, 15) + assert make_serializable(d) == "2024-01-15" + + def test_datetime_to_isoformat(self) -> None: + """datetime objects convert to ISO format string.""" + dt = datetime(2024, 1, 15, 10, 30, 45) + assert make_serializable(dt) == "2024-01-15T10:30:45" + + def test_datetime_with_microseconds(self) -> None: + """datetime with microseconds preserves precision.""" + dt = datetime(2024, 1, 15, 10, 30, 45, 123456) + assert make_serializable(dt) == "2024-01-15T10:30:45.123456" + + def test_dict_recursive(self) -> None: + """Dictionaries are processed recursively.""" + obj = {"date": date(2024, 1, 15), "name": "test"} + result = make_serializable(obj) + assert result == {"date": "2024-01-15", "name": "test"} + + def test_list_recursive(self) -> None: + """Lists are processed recursively.""" + obj = [date(2024, 1, 15), "test", 123] + result = make_serializable(obj) + assert result == ["2024-01-15", "test", 123] + + def test_nested_structure(self) -> None: + """Deeply nested structures are fully processed.""" + obj = { + "dates": [date(2024, 1, 1), date(2024, 12, 31)], + "nested": {"inner": {"dt": datetime(2024, 6, 15, 12, 0, 0)}}, + } + result = make_serializable(obj) + assert result == { + "dates": ["2024-01-01", "2024-12-31"], + "nested": {"inner": {"dt": "2024-06-15T12:00:00"}}, + } + + @pytest.mark.parametrize( + "input_val", + [ + "string", + 123, + 45.67, + True, + False, + None, + ], + ) + def test_primitives_unchanged(self, input_val: object) -> None: + """Primitive types pass through unchanged.""" + assert make_serializable(input_val) == input_val + + def test_empty_dict(self) -> None: + """Empty dict returns empty dict.""" + assert make_serializable({}) == {} + + def test_empty_list(self) -> None: + """Empty list returns empty list.""" + assert make_serializable([]) == [] + + def test_dict_keys_unchanged(self) -> None: + """Dictionary keys are not modified.""" + obj = {"key_with_date": date(2024, 1, 1)} + result = make_serializable(obj) + assert "key_with_date" in result diff --git a/tests/utils/test_hashing.py b/tests/utils/test_hashing.py new file mode 100644 index 0000000000..04714010fb --- /dev/null +++ b/tests/utils/test_hashing.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import pytest + +from sqlmesh.utils.hashing import crc32, hash_data, md5 + + +class TestCrc32: + """Tests for the crc32 function.""" + + def test_crc32_single_string(self) -> None: + """CRC32 of a single string returns consistent hash.""" + result = crc32(["hello"]) + assert result == str(__import__("zlib").crc32(b"hello")) + + def test_crc32_multiple_strings(self) -> None: + """CRC32 of multiple strings joins with semicolons.""" + result = crc32(["a", "b", "c"]) + assert result == str(__import__("zlib").crc32(b"a;b;c")) + + def test_crc32_empty_iterable(self) -> None: + """CRC32 of empty iterable returns hash of empty string.""" + result = crc32([]) + assert result == str(__import__("zlib").crc32(b"")) + + def test_crc32_with_none_values(self) -> None: + """CRC32 treats None as empty string.""" + result = crc32(["a", None, "c"]) + assert result == str(__import__("zlib").crc32(b"a;;c")) + + def test_crc32_all_none(self) -> None: + """CRC32 of all None values returns hash of semicolons.""" + result = crc32([None, None]) + assert result == str(__import__("zlib").crc32(b";")) + + def test_crc32_returns_string(self) -> None: + """CRC32 always returns a string type.""" + result = crc32(["test"]) + assert isinstance(result, str) + + def test_crc32_deterministic(self) -> None: + """CRC32 returns same result for same input.""" + data = ["hello", "world"] + assert crc32(data) == crc32(data) + + +class TestMd5: + """Tests for the md5 function.""" + + def test_md5_single_string(self) -> None: + """MD5 accepts a single string directly.""" + result = md5("hello") + assert result == __import__("hashlib").md5(b"hello").hexdigest() + + def test_md5_iterable(self) -> None: + """MD5 accepts an iterable of strings.""" + result = md5(["a", "b", "c"]) + assert result == __import__("hashlib").md5(b"a;b;c").hexdigest() + + def test_md5_empty_string(self) -> None: + """MD5 of empty string returns expected hash.""" + result = md5("") + assert result == __import__("hashlib").md5(b"").hexdigest() + + def test_md5_empty_iterable(self) -> None: + """MD5 of empty iterable returns hash of empty string.""" + result = md5([]) + assert result == __import__("hashlib").md5(b"").hexdigest() + + def test_md5_with_none_values(self) -> None: + """MD5 treats None as empty string in iterable.""" + result = md5(["a", None, "c"]) + assert result == __import__("hashlib").md5(b"a;;c").hexdigest() + + def test_md5_returns_hexdigest(self) -> None: + """MD5 returns a 32-character hexadecimal string.""" + result = md5("test") + assert len(result) == 32 + assert all(c in "0123456789abcdef" for c in result) + + def test_md5_deterministic(self) -> None: + """MD5 returns same result for same input.""" + assert md5("hello") == md5("hello") + assert md5(["a", "b"]) == md5(["a", "b"]) + + +class TestHashData: + """Tests for the hash_data function.""" + + def test_hash_data_delegates_to_crc32(self) -> None: + """hash_data is an alias for crc32.""" + data = ["hello", "world"] + assert hash_data(data) == crc32(data) + + def test_hash_data_with_none(self) -> None: + """hash_data handles None values like crc32.""" + data = ["a", None, "b"] + assert hash_data(data) == crc32(data) + + def test_hash_data_empty(self) -> None: + """hash_data handles empty iterable.""" + assert hash_data([]) == crc32([]) + + +@pytest.mark.parametrize( + "data,expected_separator_count", + [ + (["a"], 0), + (["a", "b"], 1), + (["a", "b", "c"], 2), + ([None], 0), + ([None, None], 1), + ], +) +def test_concatenation_uses_semicolons( + data: list[str | None], expected_separator_count: int +) -> None: + """Verify that data is concatenated with semicolons.""" + # We verify this indirectly by checking that different orderings + # produce different hashes (which wouldn't happen if not concatenated properly) + if len(data) >= 2 and data[0] != data[-1]: + reversed_data = list(reversed(data)) + assert crc32(data) != crc32(reversed_data) diff --git a/web/client/src/library/components/graph/Graph.css b/web/client/src/library/components/graph/Graph.css index 0fd2a42a6e..222adabf16 100644 --- a/web/client/src/library/components/graph/Graph.css +++ b/web/client/src/library/components/graph/Graph.css @@ -8,7 +8,7 @@ .react-flow__node.react-flow__node-model:active { z-index: 20 !important; } -react-flow__attribution { +.react-flow__attribution { background: transparent; } .lineage__column-source b {