Skip to content

Commit 34dd1f9

Browse files
committed
feat: add ibis.sql_value()
1 parent e4e582a commit 34dd1f9

File tree

7 files changed

+621
-0
lines changed

7 files changed

+621
-0
lines changed

ibis/backends/sql/compilers/base.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1599,6 +1599,27 @@ def visit_DropColumns(self, op, *, parent, columns_to_drop):
15991599
)
16001600
return sg.select(*columns_to_keep).from_(parent)
16011601

1602+
def visit_TemplateSQL(
1603+
self,
1604+
op: ops.TemplateSQL,
1605+
*,
1606+
strings: tuple[str],
1607+
values: tuple[sge.Expression],
1608+
dialect: str,
1609+
):
1610+
def iter():
1611+
for s, i in itertools.zip_longest(strings, values):
1612+
if s:
1613+
yield s
1614+
if i:
1615+
yield i
1616+
1617+
str_parts = [
1618+
part if isinstance(part, str) else part.sql(dialect) for part in iter()
1619+
]
1620+
sql = "".join(str_parts)
1621+
return sg.parse_one(sql, read=dialect)
1622+
16021623
def add_query_to_expr(self, *, name: str, table: ir.Table, query: str) -> str:
16031624
dialect = self.dialect
16041625

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from __future__ import annotations
2+
3+
import pandas._testing as tm
4+
import pytest
5+
6+
import ibis
7+
from ibis.tests.tstring import t
8+
9+
five = ibis.literal(5)
10+
world = ibis.literal("world")
11+
12+
13+
@pytest.mark.notimpl(["polars"])
14+
@pytest.mark.parametrize(
15+
("template", "expected_result"),
16+
[
17+
(t("{five} + 3"), 8),
18+
(t("'hello ' || {world}"), "hello world"),
19+
],
20+
)
21+
def test_scalar(con, template, expected_result):
22+
"""Test that scalar template expressions execute correctly."""
23+
expr = ibis.sql_value(template)
24+
result = con.execute(expr)
25+
assert result == expected_result
26+
27+
28+
@pytest.mark.notimpl(["polars"])
29+
def test_column(con, alltypes):
30+
"""Test template with column interpolation."""
31+
c = alltypes.int_col # noqa: F841
32+
template = t("{c + 2} - 1")
33+
expr = ibis.sql_value(template)
34+
result = con.execute(expr)
35+
expected = con.execute(alltypes.int_col + 1)
36+
tm.assert_series_equal(result, expected, check_names=False)
37+
38+
39+
def test_dialect():
40+
pa = pytest.importorskip("pyarrow")
41+
five = ibis.literal(5) # noqa: F841
42+
template = t("CAST({five} AS REAL)")
43+
44+
expr_sqlite = ibis.sql_value(template, dialect="sqlite")
45+
expr_default = ibis.sql_value(template)
46+
47+
con_sqlite = ibis.sqlite.connect()
48+
result = con_sqlite.to_pyarrow(expr_default)
49+
assert result.type == pa.float32()
50+
assert result.as_py() == 5.0
51+
result = con_sqlite.to_pyarrow(expr_sqlite)
52+
assert result.type == pa.float64()
53+
assert result.as_py() == 5.0
54+
55+
con_duckdb = ibis.duckdb.connect()
56+
result = con_duckdb.to_pyarrow(expr_default)
57+
assert result.type == pa.float32()
58+
assert result.as_py() == 5.0
59+
result = con_duckdb.to_pyarrow(expr_sqlite)
60+
assert result.type == pa.float64()
61+
assert result.as_py() == 5.0

ibis/expr/api.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from ibis.common.temporal import normalize_datetime, normalize_timezone
2626
from ibis.expr.datatypes import DataType
2727
from ibis.expr.decompile import decompile
28+
from ibis.expr.operations.template import IntoInterpolation, IntoTemplate
2829
from ibis.expr.schema import Schema
2930
from ibis.expr.sql import parse_sql, to_sql
3031
from ibis.expr.types import (
@@ -62,6 +63,8 @@
6263
"DataType",
6364
"Deferred",
6465
"Expr",
66+
"IntoInterpolation",
67+
"IntoTemplate",
6568
"Scalar",
6669
"Schema",
6770
"Table",
@@ -120,6 +123,7 @@
120123
"schema",
121124
"selectors",
122125
"set_backend",
126+
"sql_value",
123127
"struct",
124128
"table",
125129
"time",
@@ -594,6 +598,85 @@ def _deferred_method_call(expr, method_name, **kwargs):
594598
return method(value)
595599

596600

601+
def sql_value(template: IntoTemplate, /, *, dialect: str | None = None) -> ir.Value:
602+
"""Create an ibis value from a t-string.
603+
604+
t-strings, or Template Strings, were added as builtin syntax in Python 3.14.
605+
See https://docs.python.org/3.14/library/string.templatelib.html
606+
for more information.
607+
608+
This function allows you to create an ibis value expression from a t-string.
609+
It does NOT support generic SELECT statements, only expressions that
610+
represent a single value.
611+
612+
Parameters
613+
----------
614+
template
615+
The template to use for creating the SQL expression.
616+
dialect
617+
The SQL dialect to use for the expression.
618+
Defaults to "duckdb".
619+
620+
Returns
621+
-------
622+
ValueExpr
623+
An ibis ValueExpr.
624+
625+
Examples
626+
--------
627+
>>> import ibis
628+
>>> ibis.options.interactive = True
629+
>>> con = ibis.duckdb.connect()
630+
>>> table = con.create_table("my_table", {"a": [1, 2, 3], "b": [4, 5, 6]})
631+
632+
If you are using python 3.14+, you can replace the lines
633+
below with `template = t"{table.b} + 3 - {table.a / 10}"`.
634+
Here, since we are testing on older versions,
635+
we use a tiny implementation of t-strings included in ibis that works as a replacement.
636+
If you are on python < 3.14, you should use a backport such as
637+
https://pypi.org/project/tstrings-backport and do `from tstrings import t`.
638+
639+
>>> from ibis.tests.tstring import t
640+
>>> template = t("{table.b} + 3 - {table.a / 10}")
641+
642+
Now create an ibis expression based on this.
643+
644+
>>> expr = ibis.sql_value(template)
645+
>>> print(expr.to_sql())
646+
SELECT
647+
"t0"."b" + 3 - "t0"."a" / 10 AS "TemplateSQL((), (b, Divide(a, 10)))"
648+
FROM "memory"."main"."my_table" AS "t0"
649+
>>> table.mutate(expr=expr, s=expr.cast(str) + "!")
650+
┏━━━━━━━┳━━━━━━━┳━━━━━━━━━┳━━━━━━━━┓
651+
┃ a ┃ b ┃ expr ┃ s ┃
652+
┡━━━━━━━╇━━━━━━━╇━━━━━━━━━╇━━━━━━━━┩
653+
│ int64 │ int64 │ float64 │ string │
654+
├───────┼───────┼─────────┼────────┤
655+
│ 1 │ 4 │ 6.9 │ 6.9! │
656+
│ 2 │ 5 │ 7.8 │ 7.8! │
657+
│ 3 │ 6 │ 8.7 │ 8.7! │
658+
└───────┴───────┴─────────┴────────┘
659+
660+
You can provide a `dialect` parameter if you pass in a template written in
661+
a specific SQL dialect, and then this will be transpiled to
662+
the correct dialect upon execution.
663+
664+
For example, write a template in sqlite syntax (with datatype REAL)
665+
and then execute it on duckdb (where REAL will be interpreted as DOUBLE).
666+
667+
>>> template = t("CAST({table.a} AS REAL)")
668+
>>> expr = ibis.sql_value(template, dialect="sqlite")
669+
>>> arr = con.to_pyarrow(expr)
670+
>>> arr.type
671+
DataType(double)
672+
>>> arr.to_pylist()
673+
[1.0, 2.0, 3.0]
674+
"""
675+
from ibis.expr.operations.template import TemplateSQL
676+
677+
return TemplateSQL.from_template(template, dialect=dialect).to_expr()
678+
679+
597680
def desc(expr: ir.Column | str, /, *, nulls_first: bool = False) -> ir.Value:
598681
"""Create a descending sort key from `expr` or column name.
599682

ibis/expr/operations/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from ibis.expr.operations.strings import * # noqa: F403
1717
from ibis.expr.operations.structs import * # noqa: F403
1818
from ibis.expr.operations.subqueries import * # noqa: F403
19+
from ibis.expr.operations.template import TemplateSQL # noqa: F401
1920
from ibis.expr.operations.temporal import * # noqa: F403
2021
from ibis.expr.operations.temporal_windows import * # noqa: F403
2122
from ibis.expr.operations.udf import * # noqa: F403

ibis/expr/operations/template.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""Operations for template strings (t-strings)."""
2+
3+
from __future__ import annotations
4+
5+
from itertools import zip_longest
6+
from typing import TYPE_CHECKING, Protocol
7+
8+
import sqlglot as sg
9+
import sqlglot.expressions as sge
10+
from public import public
11+
from sqlglot.optimizer.annotate_types import annotate_types
12+
from typing_extensions import runtime_checkable
13+
14+
import ibis.expr.datashape as ds
15+
import ibis.expr.datatypes as dt
16+
import ibis.expr.rules as rlz
17+
from ibis.common.annotations import attribute
18+
from ibis.common.typing import VarTuple # noqa: TC001
19+
from ibis.expr.operations.core import Value
20+
21+
if TYPE_CHECKING:
22+
from collections.abc import Iterator
23+
24+
from ibis.backends.sql.datatypes import SqlglotType
25+
from ibis.expr.operations.relations import Relation
26+
from ibis.expr.types.generic import Value as ExprValue
27+
28+
29+
@runtime_checkable
30+
class IntoInterpolation(Protocol):
31+
"""Protocol for an object that can be interpreted as a PEP 750 t-string Interpolation."""
32+
33+
value: ExprValue
34+
expression: str
35+
36+
37+
@runtime_checkable
38+
class IntoTemplate(Protocol):
39+
"""Protocol for an object that can be interpreted as a PEP 750 t-string Template."""
40+
41+
strings: tuple[str, ...]
42+
interpolations: tuple[IntoInterpolation, ...]
43+
44+
45+
@public
46+
class TemplateSQL(Value):
47+
strings: VarTuple[str]
48+
values: VarTuple[Value]
49+
dialect: str | None = None
50+
"""The SQL dialect the template was written in.
51+
52+
eg if t'CAST({val} AS REAL)', you should use 'sqlite',
53+
since REAL is a sqlite-specific concept.
54+
"""
55+
56+
def __init__(self, strings, values, dialect: str | None = None):
57+
super().__init__(strings=strings, values=values, dialect=dialect or "duckdb")
58+
if self.dtype.is_unknown():
59+
raise TypeError(
60+
f"Could not infer the dtype of the template expression with sql:\n{self.sql_for_inference}"
61+
)
62+
63+
@classmethod
64+
def from_template(
65+
cls, template: IntoTemplate, /, *, dialect: str | None = None
66+
) -> TemplateSQL:
67+
return cls(
68+
strings=template.strings,
69+
values=[interp.value for interp in template.interpolations],
70+
dialect=dialect,
71+
)
72+
73+
@attribute
74+
def shape(self):
75+
if not self.values:
76+
return ds.scalar
77+
return rlz.highest_precedence_shape(self.values)
78+
79+
@attribute
80+
def dtype(self) -> dt.DataType:
81+
parsed = sg.parse_one(self.sql_for_inference, dialect=self.dialect)
82+
annotated = annotate_types(parsed, dialect=self.dialect)
83+
sqlglot_type = annotated.type
84+
return self.type_mapper.to_ibis(sqlglot_type)
85+
86+
@attribute
87+
def relations(self) -> frozenset[Relation]:
88+
children = (n.relations for n in self.values)
89+
return frozenset().union(*children)
90+
91+
@property
92+
def sql_for_inference(self) -> str:
93+
parts: list[str] = []
94+
for part in self.parts:
95+
if isinstance(part, str):
96+
parts.append(part)
97+
else:
98+
ibis_type: dt.DataType = part.dtype
99+
null_sqlglot_value = sge.cast(
100+
sge.null(), self.type_mapper.from_ibis(ibis_type)
101+
)
102+
parts.append(null_sqlglot_value.sql(self.dialect))
103+
return "".join(parts)
104+
105+
@property
106+
def type_mapper(self) -> SqlglotType:
107+
return get_type_mapper(self.dialect)
108+
109+
@property
110+
def parts(self):
111+
def iter() -> Iterator[str | Value]:
112+
for s, i in zip_longest(self.strings, self.values):
113+
if s:
114+
yield s
115+
if i:
116+
yield i
117+
118+
return tuple(iter())
119+
120+
121+
def get_type_mapper(dialect: str | None) -> SqlglotType:
122+
"""Get the type mapper for the given SQL dialect."""
123+
import importlib
124+
125+
module = importlib.import_module(f"ibis.backends.sql.compilers.{dialect}")
126+
compiler_instance = module.compiler
127+
return compiler_instance.type_mapper

0 commit comments

Comments
 (0)