diff --git a/ibis/backends/duckdb/tests/test_datatypes.py b/ibis/backends/duckdb/tests/test_datatypes.py index e4337031260b..1e3f7c2c3668 100644 --- a/ibis/backends/duckdb/tests/test_datatypes.py +++ b/ibis/backends/duckdb/tests/test_datatypes.py @@ -1,11 +1,16 @@ from __future__ import annotations +import string + +import hypothesis as h +import hypothesis.strategies as st import numpy as np import pytest from pytest import param import ibis import ibis.expr.datatypes as dt +import ibis.tests.strategies as its from ibis.backends.sql.datatypes import DuckDBType @@ -115,3 +120,57 @@ def test_cast_to_floating_point_type(con, snapshot, typ): sql = str(ibis.to_sql(expr, dialect="duckdb")) snapshot.assert_match(sql, "out.sql") + + +def null_literal(array_min_size: int = 0, array_max_size: int | None = None): + true = st.just(True) + + field_names = st.text(alphabet=string.ascii_letters + string.digits, min_size=1) + num_fields = st.integers(min_value=1, max_value=20) + + recursive = st.deferred( + lambda: ( + its.primitive_dtypes(nullable=true, include_null=False) + | its.string_dtype(nullable=true) + | its.binary_dtype(nullable=true) + | st.builds(dt.JSON, binary=st.just(False), nullable=true) + | its.macaddr_dtype(nullable=true) + | its.uuid_dtype(nullable=true) + | its.temporal_dtypes(nullable=true) + | its.array_dtypes(recursive, nullable=true, length=st.none()) + | its.map_dtypes(recursive, recursive, nullable=true) + | its.struct_dtypes( + recursive, nullable=true, names=field_names, num_fields=num_fields + ) + ) + ) + + return st.builds( + ibis.null, + its.primitive_dtypes(nullable=true, include_null=False) + | its.string_dtype(nullable=true) + | its.binary_dtype(nullable=true) + | st.builds(dt.JSON, binary=st.just(False), nullable=true) + | its.macaddr_dtype(nullable=true) + | its.uuid_dtype(nullable=true) + | its.temporal_dtypes(nullable=true) + | its.array_dtypes(recursive, nullable=true, length=st.none()) + | its.map_dtypes(recursive, recursive, nullable=true) + | its.struct_dtypes( + recursive, nullable=true, names=field_names, num_fields=num_fields + ), + ) + + +@h.given(lit=null_literal(array_min_size=1, array_max_size=8192)) +@h.settings(suppress_health_check=[h.HealthCheck.function_scoped_fixture]) +def test_null_scalar(con, lit, monkeypatch): + monkeypatch.setattr(ibis.options, "default_backend", con) + monkeypatch.setattr(ibis.options, "interactive", True) + + result = repr(lit) + expected = """\ +┌──────┐ +│ NULL │ +└──────┘""" + assert result == expected diff --git a/ibis/expr/types/generic.py b/ibis/expr/types/generic.py index a86a11654ee0..0c4fc20703e8 100644 --- a/ibis/expr/types/generic.py +++ b/ibis/expr/types/generic.py @@ -2895,7 +2895,7 @@ def null(type: dt.DataType | str | None = None, /) -> Value: AttributeError: 'NullScalar' object has no attribute 'upper' >>> ibis.null(str).upper() ┌──────┐ - │ None │ + │ NULL │ └──────┘ >>> ibis.null(str).upper().isnull() ┌──────┐ diff --git a/ibis/expr/types/pretty.py b/ibis/expr/types/pretty.py index 1052c48543c8..3862343a5f01 100644 --- a/ibis/expr/types/pretty.py +++ b/ibis/expr/types/pretty.py @@ -294,14 +294,20 @@ def _to_rich_scalar( max_string: int | None = None, max_depth: int | None = None, ) -> Pretty: - value = format_values( - expr.type(), - [expr.to_pyarrow().as_py()], - max_length=max_length or ibis.options.repr.interactive.max_length, - max_string=max_string or ibis.options.repr.interactive.max_string, - max_depth=max_depth or ibis.options.repr.interactive.max_depth, - )[0] - return Panel(value, expand=False, box=box.SQUARE) + value = expr.to_pyarrow().as_py() + + if value is None: + formatted_value = Text.styled("NULL", style="dim") + else: + (formatted_value,) = format_values( + expr.type(), + [value], + max_length=max_length or ibis.options.repr.interactive.max_length, + max_string=max_string or ibis.options.repr.interactive.max_string, + max_depth=max_depth or ibis.options.repr.interactive.max_depth, + ) + + return Panel(formatted_value, expand=False, box=box.SQUARE) def _to_rich_table( diff --git a/ibis/tests/strategies.py b/ibis/tests/strategies.py index 246458659057..4af9e8d156de 100644 --- a/ibis/tests/strategies.py +++ b/ibis/tests/strategies.py @@ -128,7 +128,7 @@ def interval_dtype(interval=_interval, nullable=_nullable): return st.builds(dt.Interval, unit=interval, nullable=nullable) -def temporal_dtypes(timezone=_timezone, interval=_interval, nullable=_nullable): +def temporal_dtypes(timezone=_timezone, nullable=_nullable): return st.one_of( date_dtype(nullable=nullable), time_dtype(nullable=nullable), @@ -136,15 +136,17 @@ def temporal_dtypes(timezone=_timezone, interval=_interval, nullable=_nullable): ) -def primitive_dtypes(nullable=_nullable): - return st.one_of( - null_dtype, - boolean_dtype(nullable=nullable), - integer_dtypes(nullable=nullable), - floating_dtypes(nullable=nullable), - date_dtype(nullable=nullable), - time_dtype(nullable=nullable), +def primitive_dtypes(nullable=_nullable, include_null=True): + primitive = ( + boolean_dtype(nullable=nullable) + | integer_dtypes(nullable=nullable) + | floating_dtypes(nullable=nullable) + | date_dtype(nullable=nullable) + | time_dtype(nullable=nullable) ) + if include_null: + primitive |= null_dtype + return primitive _item_strategy = primitive_dtypes() @@ -174,7 +176,7 @@ def struct_dtypes( nullable=_nullable, ): num_fields = draw(num_fields) - names = draw(st.lists(names, min_size=num_fields, max_size=num_fields)) + names = draw(st.lists(names, min_size=num_fields, max_size=num_fields, unique=True)) types = draw(st.lists(types, min_size=num_fields, max_size=num_fields)) fields = dict(zip(names, types)) return dt.Struct(fields, nullable=draw(nullable))