Skip to content

Commit

Permalink
feat(api): promote psql to a show_sql public API
Browse files Browse the repository at this point in the history
  • Loading branch information
cpcloud committed Aug 17, 2022
1 parent 0c787d2 commit 877a05d
Show file tree
Hide file tree
Showing 9 changed files with 209 additions and 46 deletions.
1 change: 1 addition & 0 deletions docs/api/expressions/top_level.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ These methods and objects are available directly in the `ibis` module.
::: ibis.now
::: ibis.null
::: ibis.param
::: ibis.show_sql
::: ibis.random
::: ibis.range_window
::: ibis.row_number
Expand Down
93 changes: 93 additions & 0 deletions ibis/backends/tests/test_pretty.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import io

import pytest
from pytest import mark, param

import ibis
import ibis.common.exceptions as exc
from ibis import _

sa = pytest.importorskip("sqlalchemy")
pytest.importorskip("sqlglot")


@mark.never(
["dask", "pandas"],
reason="Dask and Pandas are not SQL backends",
raises=(NotImplementedError, AssertionError),
)
@mark.notimpl(
["datafusion", "pyspark"],
reason="Not clear how to extract SQL from the backend",
raises=(exc.OperationNotDefinedError, NotImplementedError, AssertionError),
)
def test_table(con):
expr = con.tables.functional_alltypes.select(c=_.int_col + 1)
buf = io.StringIO()
ibis.show_sql(expr, file=buf)
assert buf.getvalue()


simple_literal = param(
ibis.literal(1),
id="simple_literal",
)
array_literal = param(
ibis.array([1]),
marks=[
mark.never(
["mysql", "sqlite"],
raises=sa.exc.CompileError,
reason="arrays not supported in the backend",
),
mark.notyet(
["impala"],
raises=NotImplementedError,
reason="Impala hasn't implemented array literals",
),
mark.notimpl(
["postgres"],
reason="array literals are not yet implemented",
raises=NotImplementedError,
),
],
id="array_literal",
)
no_structs = mark.never(
["impala", "mysql", "sqlite"],
raises=(NotImplementedError, sa.exc.CompileError),
reason="structs not supported in the backend",
)
no_struct_literals = mark.notimpl(
["postgres"],
reason="struct literals are not yet implemented",
)
not_sql = mark.never(
["pandas", "dask"],
raises=(exc.IbisError, NotImplementedError, AssertionError),
reason="Not a SQL backend",
)
no_sql_extraction = mark.notimpl(
["datafusion", "pyspark"],
reason="Not clear how to extract SQL from the backend",
)


@mark.parametrize(
"expr",
[
simple_literal,
array_literal,
param(
ibis.struct(dict(a=1)),
marks=[no_structs, no_struct_literals],
id="struct_literal",
),
],
)
@not_sql
@no_sql_extraction
def test_literal(backend, expr):
buf = io.StringIO()
ibis.show_sql(expr, dialect=backend.name(), file=buf)
assert buf.getvalue()
90 changes: 90 additions & 0 deletions ibis/common/pretty.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
from __future__ import annotations

from typing import IO

import ibis
import ibis.common.exceptions as com
import ibis.expr.types as ir

_IBIS_TO_SQLGLOT_NAME_MAP = {
# not 100% accurate, but very close
"impala": "hive",
# for now map clickhouse to Hive so that _something_ works
"clickhouse": "mysql",
}


def show_sql(
expr: ir.Expr,
dialect: str | None = None,
file: IO[str] | None = None,
) -> None:
"""Pretty-print the compiled SQL string of an expression.
If a dialect cannot be inferred and one was not passed, duckdb
will be used as the dialect
Parameters
----------
expr
Ibis expression whose SQL will be printed
dialect
String dialect. This is typically not required, but can be useful if
ibis cannot infer the backend dialect.
file
File to write output to
Examples
--------
>>> import ibis
>>> from ibis import _
>>> t = ibis.table(dict(a="int"), name="t")
>>> expr = t.select(c=_.a * 2)
>>> ibis.show_sql(expr) # duckdb dialect by default
SELECT
t0.a * CAST(2 AS SMALLINT) AS c
FROM t AS t0
>>> ibis.show_sql(expr, dialect="mysql")
SELECT
t0.a * 2 AS c
FROM t AS t0
"""
import sqlglot

# try to infer from a non-str expression or if not possible fallback to
# the default pretty dialect for expressions
if dialect is None:
try:
backend = expr._find_backend()
except com.IbisError:
# default to duckdb for sqlalchemy compilation because it supports
# the widest array of ibis features for SQL backends
read = "duckdb"
write = ibis.options.sql.default_dialect
else:
read = write = backend.name
else:
read = write = dialect

write = _IBIS_TO_SQLGLOT_NAME_MAP.get(write, write)

try:
compiled = expr.compile()
except com.IbisError:
backend = getattr(ibis, read)
compiled = backend.compile(expr)
try:
sql = str(compiled.compile(compile_kwargs={"literal_binds": True}))
except (AttributeError, TypeError):
sql = compiled

assert isinstance(
sql, str
), f"expected `str`, got `{sql.__class__.__name__}`"
(pretty,) = sqlglot.transpile(
sql,
read=_IBIS_TO_SQLGLOT_NAME_MAP.get(read, read),
write=write,
pretty=True,
)
print(pretty, file=file)
7 changes: 7 additions & 0 deletions ibis/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ class SQL(BaseModel):
"explicit limit. [`None`][None] means no limit."
),
)
default_dialect: str = Field(
default="duckdb",
description=(
"Dialect to use for printing SQL when the backend cannot be "
"determined."
),
)


class Repr(BaseModel):
Expand Down
2 changes: 2 additions & 0 deletions ibis/expr/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import ibis.expr.schema as sch
import ibis.expr.types as ir
from ibis.backends.base import connect
from ibis.common.pretty import show_sql
from ibis.expr.deferred import Deferred
from ibis.expr.random import random
from ibis.expr.schema import Schema
Expand Down Expand Up @@ -199,6 +200,7 @@
'schema',
'Schema',
'sequence',
'show_sql',
'struct',
'table',
'time',
Expand Down
32 changes: 0 additions & 32 deletions ibis/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
import warnings
from numbers import Real
from typing import (
IO,
TYPE_CHECKING,
Any,
Hashable,
Expand All @@ -28,8 +27,6 @@
from ibis.config import options

if TYPE_CHECKING:
import sqlalchemy as sa

from ibis.expr import operations as ops
from ibis.expr import types as ir

Expand Down Expand Up @@ -513,32 +510,3 @@ def toposort(graph: Graph) -> Iterator[ops.Node]:

if any(in_degree.values()):
raise ValueError("cycle in expression graph")


def psql(
expr: ir.Expr | sa.sql.ClauseElement,
reindent: bool = True,
file: IO[str] = None,
**kwargs: Any,
) -> None:
"""Pretty-print the compiled SQL string of an expression.
Accepts both ibis and SQLAlchemy expressions.
Parameters
----------
expr
Expression whose SQL will be printed
reindent
Tell `sqlglot` to reindent the SQL string
file
File to write output to
kwargs
`sqlglot.transpile` options
"""
import sqlglot

print(
sqlglot.transpile(str(expr.compile()), pretty=reindent, **kwargs)[0],
file=file,
)
16 changes: 8 additions & 8 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 7 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -127,18 +127,19 @@ all = [
"requests",
"Shapely",
"sqlalchemy",
"sqlglot",
]
clickhouse = ["clickhouse-driver", "clickhouse-cityhash", "lz4"]
clickhouse = ["clickhouse-driver", "clickhouse-cityhash", "lz4", "sqlglot"]
dask = ["dask", "pyarrow"]
datafusion = ["datafusion"]
duckdb = ["duckdb", "duckdb-engine", "pyarrow", "sqlalchemy"]
duckdb = ["duckdb", "duckdb-engine", "pyarrow", "sqlalchemy", "sqlglot"]
geospatial = ["geoalchemy2", "geopandas", "shapely"]
impala = ["fsspec", "impyla", "requests"]
mysql = ["sqlalchemy", "pymysql"]
impala = ["fsspec", "impyla", "requests", "sqlglot"]
mysql = ["sqlalchemy", "pymysql", "sqlglot"]
pandas = []
postgres = ["psycopg2", "sqlalchemy"]
postgres = ["psycopg2", "sqlalchemy", "sqlglot"]
pyspark = ["pyarrow", "pyspark"]
sqlite = ["sqlalchemy"]
sqlite = ["sqlalchemy", "sqlglot"]
visualization = ["graphviz"]

[tool.poetry.plugins."ibis.backends"]
Expand Down
1 change: 1 addition & 0 deletions requirements.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 877a05d

Please sign in to comment.