Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/integrations/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
3 changes: 3 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
209 changes: 209 additions & 0 deletions tests/utils/test_conversions.py
Original file line number Diff line number Diff line change
@@ -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
123 changes: 123 additions & 0 deletions tests/utils/test_hashing.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion web/client/src/library/components/graph/Graph.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down