Skip to content

Commit

Permalink
fix(api): add ibis.map and .struct
Browse files Browse the repository at this point in the history
Prefer these instead of the more generic ibis.literal to create compound literals.
Like .array() for array types.

closes #3118
  • Loading branch information
saulpw authored and cpcloud committed Feb 1, 2022
1 parent a80b567 commit 327b342
Show file tree
Hide file tree
Showing 6 changed files with 156 additions and 18 deletions.
3 changes: 3 additions & 0 deletions docs/source/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ These methods are available directly in the ``ibis`` module namespace.

case
literal
array
struct
map
schema
table
timestamp
Expand Down
4 changes: 4 additions & 0 deletions ibis/expr/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,9 @@
ValueExpr,
array,
literal,
map,
null,
struct,
)
from ibis.expr.window import (
cumulative_window,
Expand Down Expand Up @@ -183,6 +185,7 @@
'join',
'least',
'literal',
'map',
'NA',
'negate',
'now',
Expand All @@ -197,6 +200,7 @@
'schema',
'Schema',
'sequence',
'struct',
'table',
'time',
'timestamp',
Expand Down
28 changes: 19 additions & 9 deletions ibis/expr/datatypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,7 @@ def from_dict(

@property
def pairs(self) -> Mapping:
return collections.OrderedDict(zip(self.names, self.types))
return dict(zip(self.names, self.types))

def __getitem__(self, key: str) -> DataType:
return self.pairs[key]
Expand Down Expand Up @@ -1414,6 +1414,24 @@ def can_cast_string_to_temporal(
Collection = TypeVar('Collection', Array, Set)


@castable.register(Map, Map)
def can_cast_map(source, target, **kwargs):
return castable(source.key_type, target.key_type) and castable(
source.value_type, target.value_type
)


@castable.register(Struct, Struct)
def can_cast_struct(source, target, **kwargs):
source_pairs = source.pairs
target_pairs = target.pairs
for name in {*source.names, *target.names}:
if name in target_pairs:
if not castable(source_pairs[name], target_pairs[name]):
return False
return True


@castable.register(Array, Array)
@castable.register(Set, Set)
def can_cast_variadic(
Expand Down Expand Up @@ -1460,14 +1478,6 @@ def can_cast_special_string(source, target, **kwargs):
return True


# @castable.register(Map, Map)
# def can_cast_maps(source, target):
# return (source.equals(target) or
# source.equals(Map(null, null)) or
# source.equals(Map(any, any)))
# TODO cast category


def cast(source: DataType | str, target: DataType | str, **kwargs) -> DataType:
"""Attempts to implicitly cast from source dtype to target dtype"""
source, result_target = dtype(source), dtype(target)
Expand Down
69 changes: 66 additions & 3 deletions ibis/expr/types.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import collections
import itertools
import os
import webbrowser
Expand Down Expand Up @@ -518,7 +519,7 @@ def __dir__(self):

def _resolve(self, exprs):
exprs = util.promote_list(exprs)
return list(map(self._ensure_expr, exprs))
return list(self._ensure_expr(x) for x in exprs)

def _ensure_expr(self, expr):
if isinstance(expr, str):
Expand Down Expand Up @@ -1247,7 +1248,7 @@ def literal(value, type=None):
return ops.Literal(value, dtype=dtype).to_expr()


def array(values):
def array(values, type=None):
"""Create an array expression.
If the input expressions are all column expressions, then the output will
Expand All @@ -1270,6 +1271,9 @@ def array(values):
array_value : ArrayValue
An array column (if the inputs are column expressions), or an array
scalar (if the inputs are Python literals)
type : ibis type or string, optional
An instance of :class:`ibis.expr.datatypes.DataType` or a string
indicating the ibis type of `value`.
Examples
--------
Expand All @@ -1293,7 +1297,7 @@ def array(values):
)
else:
try:
return literal(list(values))
return literal(list(values), type=type)
except com.IbisTypeError as e:
raise com.IbisTypeError(
'Could not create an array scalar from the values provided '
Expand All @@ -1302,6 +1306,65 @@ def array(values):
) from e


def struct(value, type=None):
'''Create a struct literal from a dict or other mapping.
Parameters
----------
value
the literal struct value
type
An instance of :class:`ibis.expr.datatypes.DataType` or a string
indicating the ibis type of `value`.
Returns
-------
StructValue
An expression representing a literal struct (compound type with fields
of fixed types)
Examples
--------
Create struct literal from dict with inferred type:
>>> import ibis
>>> t = ibis.struct(dict(a=1, b='foo'))
Create struct literal from dict with specified type:
>>> t = ibis.struct(dict(a=1, b='foo'), type='struct<a: float, b: string>')
'''
return literal(collections.OrderedDict(value), type=type)


def map(value, type=None):
'''Create a map literal from a dict or other mapping.
Parameters
----------
value
the literal map value
type
An instance of :class:`ibis.expr.datatypes.DataType` or a string
indicating the ibis type of `value`.
Returns
-------
MapValue
An expression representing a literal map (associative array with
key/value pairs of fixed types)
Examples
--------
Create map literal from dict with inferred type:
>>> import ibis
>>> t = ibis.map(dict(a=1, b=2))
Create map literal from dict with specified type:
>>> import ibis
>>> t = ibis.map(dict(a=1, b=2), type='map<string, double>')
'''
return literal(dict(value), type=type)


class UnnamedMarker:
pass

Expand Down
13 changes: 7 additions & 6 deletions ibis/tests/expr/test_array.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,16 @@


@pytest.mark.parametrize(
['arg', 'type'],
['arg', 'typestr', 'type'],
[
([1, 2, 3], dt.Array(dt.int8)),
([1, 2, 3.0], dt.Array(dt.double)),
(['a', 'b', 'c'], dt.Array(dt.string)),
([1, 2, 3], None, dt.Array(dt.int8)),
([1, 2, 3], 'array<int16>', dt.Array(dt.int16)),
([1, 2, 3.0], None, dt.Array(dt.double)),
(['a', 'b', 'c'], None, dt.Array(dt.string)),
],
)
def test_array_literal(arg, type):
x = ibis.literal(arg)
def test_array_literal(arg, typestr, type):
x = ibis.literal(arg, type=typestr)
assert x._arg.value == arg
assert x.type() == type

Expand Down
57 changes: 57 additions & 0 deletions ibis/tests/expr/test_literal.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,60 @@ def test_normalized_underlying_value(userinput, literal_type, expected_type):
a = ibis.literal(userinput, type=literal_type)

assert isinstance(a.op().value, expected_type)


@pytest.mark.parametrize(
'value',
[
dict(field1='value1', field2=3.14),
dict(field1='value1', field2=1), # coerceable type
dict(field2=2.72, field1='value1'), # wrong field order
dict(field1='value1', field2=3.14, field3='extra'), # extra field
],
)
def test_struct_literal(value):
typestr = "struct<field1: string, field2: float64>"
a = ibis.struct(value, type=typestr)
assert a.op().value == value
assert a.type() == datatypes.dtype(typestr)


@pytest.mark.parametrize(
'value',
[
dict(field1='value1', field2='3.14'), # non-coerceable type
dict(field1='value1', field3=3.14), # wrong field name
dict(field1='value1'), # missing field
],
)
def test_struct_literal_non_castable(value):
typestr = "struct<field1: string, field2: float64>"
with pytest.raises(
(KeyError, TypeError, ibis.common.exceptions.IbisTypeError)
):
ibis.struct(value, type=typestr)


@pytest.mark.parametrize(
'value',
[
dict(key1='value1', key2='value2'),
],
)
def test_map_literal(value):
typestr = "map<string, string>"
a = ibis.map(value, type=typestr)
assert a.op().value == value
assert a.type() == datatypes.dtype(typestr)


@pytest.mark.parametrize(
'value',
[
dict(key1='value1', key2=6.25), # heterogenous map values
],
)
def test_map_literal_non_castable(value):
typestr = "map<string, string>"
with pytest.raises(ibis.common.exceptions.IbisTypeError):
ibis.map(value, type=typestr)

0 comments on commit 327b342

Please sign in to comment.