interloper-sql 0.110.2__tar.gz → 0.111.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {interloper_sql-0.110.2 → interloper_sql-0.111.0}/PKG-INFO +17 -10
- {interloper_sql-0.110.2 → interloper_sql-0.111.0}/README.md +11 -6
- {interloper_sql-0.110.2 → interloper_sql-0.111.0}/pyproject.toml +9 -4
- {interloper_sql-0.110.2 → interloper_sql-0.111.0}/pyproject.toml.orig +6 -4
- interloper_sql-0.111.0/src/interloper_sql/__init__.py +24 -0
- {interloper_sql-0.110.2 → interloper_sql-0.111.0}/src/interloper_sql/connection.py +2 -2
- interloper_sql-0.111.0/src/interloper_sql/view/__init__.py +7 -0
- interloper_sql-0.111.0/src/interloper_sql/view/base.py +166 -0
- interloper_sql-0.111.0/src/interloper_sql/view/decorator.py +84 -0
- interloper_sql-0.111.0/src/interloper_sql/view/types.py +46 -0
- interloper_sql-0.110.2/src/interloper_sql/__init__.py +0 -9
- {interloper_sql-0.110.2 → interloper_sql-0.111.0}/src/interloper_sql/destination.py +0 -0
- {interloper_sql-0.110.2 → interloper_sql-0.111.0}/src/interloper_sql/types.py +0 -0
|
@@ -1,24 +1,31 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: interloper-sql
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Interloper SQL integration: a SQLAlchemy-backed destination for PostgreSQL and other SQL databases
|
|
3
|
+
Version: 0.111.0
|
|
4
|
+
Summary: Interloper SQL integration: portable views over SQL warehouses, and a SQLAlchemy-backed destination for PostgreSQL and other SQL databases
|
|
5
5
|
Author: Guillaume Onfroy
|
|
6
6
|
Author-email: Guillaume Onfroy <guillaume@digitlcloud.com>
|
|
7
|
+
Requires-Dist: ibis-framework>=12,<13
|
|
7
8
|
Requires-Dist: interloper-core
|
|
8
|
-
Requires-Dist: psycopg[binary]>=3.1
|
|
9
|
-
Requires-Dist: sqlalchemy>=2.0
|
|
9
|
+
Requires-Dist: psycopg[binary]>=3.1 ; extra == 'postgres'
|
|
10
|
+
Requires-Dist: sqlalchemy>=2.0 ; extra == 'postgres'
|
|
10
11
|
Requires-Python: >=3.10
|
|
12
|
+
Provides-Extra: postgres
|
|
11
13
|
Description-Content-Type: text/markdown
|
|
12
14
|
|
|
13
15
|
# interloper-sql
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
`
|
|
17
|
-
the
|
|
17
|
+
SQL for interloper: `ibis_view`, portable views authored as Ibis expressions,
|
|
18
|
+
and behind the `postgres` extra a SQL database destination, `SQLDestination`,
|
|
19
|
+
a `DatabaseDestination` over SQLAlchemy Core with the `SQLConnection` that
|
|
20
|
+
holds the database URL.
|
|
21
|
+
|
|
22
|
+
The base package depends on Ibis only, so a package that defines `ibis_view`s
|
|
23
|
+
pulls in no database driver. `interloper-sql[postgres]` adds SQLAlchemy and
|
|
24
|
+
`psycopg[binary]`; the destination and its connection exist only with it.
|
|
18
25
|
|
|
19
26
|
One backend serves every SQLAlchemy dialect whose driver is installed.
|
|
20
|
-
PostgreSQL works
|
|
21
|
-
|
|
27
|
+
PostgreSQL works with the extra; MySQL, SQLite, Redshift, SQL Server and the
|
|
28
|
+
rest need their own driver on top.
|
|
22
29
|
|
|
23
30
|
## Setup
|
|
24
31
|
|
|
@@ -26,7 +33,7 @@ The connection is a single SQLAlchemy URL:
|
|
|
26
33
|
|
|
27
34
|
| Database | URL | Driver to install |
|
|
28
35
|
|----------|-----|-------------------|
|
|
29
|
-
| PostgreSQL | `postgresql+psycopg://user:password@host:5432/db` |
|
|
36
|
+
| PostgreSQL | `postgresql+psycopg://user:password@host:5432/db` | `interloper-sql[postgres]` |
|
|
30
37
|
| MySQL / MariaDB | `mysql+pymysql://user:password@host:3306/db` | `pymysql` |
|
|
31
38
|
| SQL Server | `mssql+pyodbc://user:password@host/db?driver=ODBC+Driver+18+for+SQL+Server` | `pyodbc` (and the ODBC driver) |
|
|
32
39
|
| Redshift | `redshift+redshift_connector://user:password@host:5439/db` | `sqlalchemy-redshift`, `redshift_connector` |
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
# interloper-sql
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
`
|
|
5
|
-
the
|
|
3
|
+
SQL for interloper: `ibis_view`, portable views authored as Ibis expressions,
|
|
4
|
+
and behind the `postgres` extra a SQL database destination, `SQLDestination`,
|
|
5
|
+
a `DatabaseDestination` over SQLAlchemy Core with the `SQLConnection` that
|
|
6
|
+
holds the database URL.
|
|
7
|
+
|
|
8
|
+
The base package depends on Ibis only, so a package that defines `ibis_view`s
|
|
9
|
+
pulls in no database driver. `interloper-sql[postgres]` adds SQLAlchemy and
|
|
10
|
+
`psycopg[binary]`; the destination and its connection exist only with it.
|
|
6
11
|
|
|
7
12
|
One backend serves every SQLAlchemy dialect whose driver is installed.
|
|
8
|
-
PostgreSQL works
|
|
9
|
-
|
|
13
|
+
PostgreSQL works with the extra; MySQL, SQLite, Redshift, SQL Server and the
|
|
14
|
+
rest need their own driver on top.
|
|
10
15
|
|
|
11
16
|
## Setup
|
|
12
17
|
|
|
@@ -14,7 +19,7 @@ The connection is a single SQLAlchemy URL:
|
|
|
14
19
|
|
|
15
20
|
| Database | URL | Driver to install |
|
|
16
21
|
|----------|-----|-------------------|
|
|
17
|
-
| PostgreSQL | `postgresql+psycopg://user:password@host:5432/db` |
|
|
22
|
+
| PostgreSQL | `postgresql+psycopg://user:password@host:5432/db` | `interloper-sql[postgres]` |
|
|
18
23
|
| MySQL / MariaDB | `mysql+pymysql://user:password@host:3306/db` | `pymysql` |
|
|
19
24
|
| SQL Server | `mssql+pyodbc://user:password@host/db?driver=ODBC+Driver+18+for+SQL+Server` | `pyodbc` (and the ODBC driver) |
|
|
20
25
|
| Redshift | `redshift+redshift_connector://user:password@host:5439/db` | `sqlalchemy-redshift`, `redshift_connector` |
|
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "interloper-sql"
|
|
3
|
-
version = "0.
|
|
4
|
-
description = "Interloper SQL integration: a SQLAlchemy-backed destination for PostgreSQL and other SQL databases"
|
|
3
|
+
version = "0.111.0"
|
|
4
|
+
description = "Interloper SQL integration: portable views over SQL warehouses, and a SQLAlchemy-backed destination for PostgreSQL and other SQL databases"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
7
7
|
dependencies = [
|
|
8
|
+
"ibis-framework>=12,<13",
|
|
8
9
|
"interloper-core",
|
|
9
|
-
"psycopg[binary]>=3.1",
|
|
10
|
-
"sqlalchemy>=2.0",
|
|
11
10
|
]
|
|
12
11
|
|
|
13
12
|
[[project.authors]]
|
|
14
13
|
name = "Guillaume Onfroy"
|
|
15
14
|
email = "guillaume@digitlcloud.com"
|
|
16
15
|
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
postgres = [
|
|
18
|
+
"psycopg[binary]>=3.1",
|
|
19
|
+
"sqlalchemy>=2.0",
|
|
20
|
+
]
|
|
21
|
+
|
|
17
22
|
[project.entry-points."interloper.components"]
|
|
18
23
|
sql = "interloper_sql"
|
|
19
24
|
|
|
@@ -3,17 +3,19 @@
|
|
|
3
3
|
# ###############
|
|
4
4
|
[project]
|
|
5
5
|
name = "interloper-sql"
|
|
6
|
-
version = "0.
|
|
7
|
-
description = "Interloper SQL integration: a SQLAlchemy-backed destination for PostgreSQL and other SQL databases"
|
|
6
|
+
version = "0.111.0"
|
|
7
|
+
description = "Interloper SQL integration: portable views over SQL warehouses, and a SQLAlchemy-backed destination for PostgreSQL and other SQL databases"
|
|
8
8
|
readme = "README.md"
|
|
9
9
|
authors = [{ name = "Guillaume Onfroy", email = "guillaume@digitlcloud.com" }]
|
|
10
10
|
requires-python = ">=3.10"
|
|
11
11
|
dependencies = [
|
|
12
|
+
"ibis-framework>=12,<13",
|
|
12
13
|
"interloper-core",
|
|
13
|
-
"psycopg[binary]>=3.1",
|
|
14
|
-
"sqlalchemy>=2.0",
|
|
15
14
|
]
|
|
16
15
|
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
postgres = ["psycopg[binary]>=3.1", "sqlalchemy>=2.0"]
|
|
18
|
+
|
|
17
19
|
[project.entry-points."interloper.components"]
|
|
18
20
|
sql = "interloper_sql"
|
|
19
21
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Interloper SQL integration: portable views, and a SQLAlchemy-backed destination behind the postgres extra."""
|
|
2
|
+
|
|
3
|
+
from interloper_sql.view import IbisView, ibis_view, union
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from interloper_sql.connection import SQLConnection
|
|
7
|
+
from interloper_sql.destination import SQLDestination
|
|
8
|
+
except ModuleNotFoundError as error:
|
|
9
|
+
# the destination and its connection need the postgres extra; the views do not
|
|
10
|
+
if error.name != "sqlalchemy":
|
|
11
|
+
raise
|
|
12
|
+
__all__ = [
|
|
13
|
+
"IbisView",
|
|
14
|
+
"ibis_view",
|
|
15
|
+
"union",
|
|
16
|
+
]
|
|
17
|
+
else:
|
|
18
|
+
__all__ = [
|
|
19
|
+
"IbisView",
|
|
20
|
+
"SQLConnection",
|
|
21
|
+
"SQLDestination",
|
|
22
|
+
"ibis_view",
|
|
23
|
+
"union",
|
|
24
|
+
]
|
|
@@ -22,7 +22,7 @@ class SQLConnection(Connection):
|
|
|
22
22
|
"""Connection resource holding the URL of a SQL database.
|
|
23
23
|
|
|
24
24
|
Any SQLAlchemy dialect whose driver is installed works; PostgreSQL through
|
|
25
|
-
psycopg ships with the
|
|
25
|
+
psycopg ships with the ``postgres`` extra.
|
|
26
26
|
"""
|
|
27
27
|
|
|
28
28
|
model_config = SettingsConfigDict(env_prefix="sql_")
|
|
@@ -31,7 +31,7 @@ class SQLConnection(Connection):
|
|
|
31
31
|
label="Database URL",
|
|
32
32
|
description="SQLAlchemy URL, e.g. postgresql+psycopg://user:password@host:5432/db",
|
|
33
33
|
info=(
|
|
34
|
-
"The URL carries the password, so it is stored as a secret. PostgreSQL works
|
|
34
|
+
"The URL carries the password, so it is stored as a secret. PostgreSQL works with the postgres extra "
|
|
35
35
|
"(postgresql+psycopg://); other databases need their SQLAlchemy driver installed, e.g. "
|
|
36
36
|
"mysql+pymysql:// or mssql+pyodbc://."
|
|
37
37
|
),
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Portable views: Ibis expressions over typed upstream tables, compiled per warehouse dialect."""
|
|
2
|
+
|
|
3
|
+
from interloper_sql.view.base import IbisDefinition, IbisView, union
|
|
4
|
+
from interloper_sql.view.decorator import ibis_view
|
|
5
|
+
from interloper_sql.view.types import IBIS_DIALECTS, ibis_schema
|
|
6
|
+
|
|
7
|
+
__all__ = ["IBIS_DIALECTS", "IbisDefinition", "IbisView", "ibis_schema", "ibis_view", "union"]
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""IbisView: a view authored as an Ibis expression over its upstreams' declared schemas."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
import ibis
|
|
9
|
+
from interloper.asset import Upstream, View
|
|
10
|
+
from interloper.destination.database import TableReference
|
|
11
|
+
from interloper.errors import ConfigError
|
|
12
|
+
|
|
13
|
+
from interloper_sql.view.types import IBIS_DIALECTS, ibis_schema
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from interloper.asset import Asset
|
|
17
|
+
from interloper.dag.base import DAG
|
|
18
|
+
from interloper.partitioning.base import Partition, PartitionWindow
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class IbisDefinition:
|
|
22
|
+
"""A view definition that compiles an Ibis expression for a dialect."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, expression: ibis.Table) -> None:
|
|
25
|
+
"""Wrap an expression.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
expression: The view's query as an Ibis table expression.
|
|
29
|
+
"""
|
|
30
|
+
self.expression = expression
|
|
31
|
+
|
|
32
|
+
def compile(self, dialect: str) -> str:
|
|
33
|
+
"""Render the expression as SQL for a warehouse dialect.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
dialect: The destination's dialect name.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
The SQL text.
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
ConfigError: If the dialect has no Ibis counterpart.
|
|
43
|
+
"""
|
|
44
|
+
if dialect not in IBIS_DIALECTS:
|
|
45
|
+
raise ConfigError(f"No Ibis dialect for '{dialect}'")
|
|
46
|
+
return str(ibis.to_sql(self.expression, dialect=IBIS_DIALECTS[dialect]))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class IbisView(View):
|
|
50
|
+
"""A view whose ``data()`` receives Ibis tables and returns an Ibis expression.
|
|
51
|
+
|
|
52
|
+
Each upstream's ``data`` is an ``ibis.Table`` named after the table the
|
|
53
|
+
destination references and typed from the upstream's declared schema,
|
|
54
|
+
so the expression is checked as it is built. The result must carry the
|
|
55
|
+
view's declared columns with their declared types; the destination then
|
|
56
|
+
compiles it for its own dialect.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
async def _read_upstreams(
|
|
60
|
+
self,
|
|
61
|
+
name: str,
|
|
62
|
+
targets: list[Asset],
|
|
63
|
+
dag: DAG | None,
|
|
64
|
+
partition_or_window: Partition | PartitionWindow | None,
|
|
65
|
+
metadata: dict[str, Any],
|
|
66
|
+
) -> list[Upstream]:
|
|
67
|
+
"""Turn every table reference into a typed Ibis table.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
name: The ``data()`` parameter the upstreams are built for.
|
|
71
|
+
targets: The bound upstream assets.
|
|
72
|
+
dag: Forwarded to `View._read_upstreams`.
|
|
73
|
+
partition_or_window: Forwarded.
|
|
74
|
+
metadata: Forwarded.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
One `Upstream` per target, its ``data`` an ``ibis.Table``.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
ConfigError: If an upstream declares no schema, or its reference is not a table.
|
|
81
|
+
"""
|
|
82
|
+
upstreams = await super()._read_upstreams(name, targets, dag, partition_or_window, metadata)
|
|
83
|
+
tables: list[Upstream] = []
|
|
84
|
+
for upstream in upstreams:
|
|
85
|
+
reference = upstream.data
|
|
86
|
+
if not isinstance(reference, TableReference):
|
|
87
|
+
raise ConfigError(f"Upstream '{upstream.asset.qualified_key}' is not a table: {reference!r}")
|
|
88
|
+
if reference.schema is None:
|
|
89
|
+
raise ConfigError(f"Upstream '{upstream.asset.qualified_key}' declares no schema to type its table")
|
|
90
|
+
*outer, table = reference.parts
|
|
91
|
+
location = {}
|
|
92
|
+
if outer:
|
|
93
|
+
location["database"] = outer[-1]
|
|
94
|
+
if len(outer) > 1:
|
|
95
|
+
location["catalog"] = outer[-2]
|
|
96
|
+
tables.append(
|
|
97
|
+
Upstream(asset=upstream.asset, data=ibis.table(ibis_schema(reference.schema), name=table, **location))
|
|
98
|
+
)
|
|
99
|
+
return tables
|
|
100
|
+
|
|
101
|
+
def _definition(self, result: Any) -> IbisDefinition:
|
|
102
|
+
"""Check the expression against the declared schema and wrap it.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
result: The value ``data()`` returned.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
The compilable definition.
|
|
109
|
+
|
|
110
|
+
Raises:
|
|
111
|
+
ConfigError: If the result is not an Ibis table, the view
|
|
112
|
+
declares no schema, or the expression's columns differ from
|
|
113
|
+
the declared ones.
|
|
114
|
+
"""
|
|
115
|
+
if not isinstance(result, ibis.Table):
|
|
116
|
+
raise ConfigError(f"View '{self.qualified_key}' returned {type(result).__name__}, not an Ibis table")
|
|
117
|
+
if self.schema is None:
|
|
118
|
+
raise ConfigError(f"View '{self.qualified_key}' declares no schema")
|
|
119
|
+
declared = {name: str(dtype) for name, dtype in ibis_schema(self.schema).items()}
|
|
120
|
+
produced = {name: str(dtype) for name, dtype in result.schema().items()}
|
|
121
|
+
if declared != produced:
|
|
122
|
+
missing = sorted(set(declared) - set(produced))
|
|
123
|
+
extra = sorted(set(produced) - set(declared))
|
|
124
|
+
retyped = sorted(name for name in declared.keys() & produced.keys() if declared[name] != produced[name])
|
|
125
|
+
raise ConfigError(
|
|
126
|
+
f"View '{self.qualified_key}' declares columns its expression does not produce: "
|
|
127
|
+
f"missing {missing}, extra {extra}, retyped {retyped}"
|
|
128
|
+
)
|
|
129
|
+
return IbisDefinition(result)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def union(upstreams: Sequence[Upstream], *columns: str, **stamps: Callable[[Upstream], Any]) -> ibis.Table:
|
|
133
|
+
"""The UNION ALL of every upstream's table, optionally stamping a column per upstream.
|
|
134
|
+
|
|
135
|
+
Each table is projected to *columns*, or to every declared column, before
|
|
136
|
+
the union, so the branches line up by name: a bare ``SELECT *`` unions by
|
|
137
|
+
position, and two accounts' tables created at different times need not
|
|
138
|
+
share a column order.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
upstreams: The bound upstreams of a many-valued relation, each carrying an ``ibis.Table``.
|
|
142
|
+
*columns: The columns to keep from every table, in this order; every declared column by default.
|
|
143
|
+
**stamps: Column name to a function of the upstream giving that
|
|
144
|
+
upstream's constant value, such as the account it came from.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
One table over every upstream.
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
ConfigError: If no upstream is bound; a view over nothing has no columns.
|
|
151
|
+
"""
|
|
152
|
+
if not upstreams:
|
|
153
|
+
raise ConfigError("no upstream bound: a view needs at least one")
|
|
154
|
+
names = list(columns or upstreams[0].data.columns)
|
|
155
|
+
tables = []
|
|
156
|
+
for upstream in upstreams:
|
|
157
|
+
# a projection keeping every column in order compiles to SELECT *, which unions by
|
|
158
|
+
# position; selecting in reverse forces the column list, and the outer select restores it
|
|
159
|
+
table = upstream.data.select(*reversed(names))
|
|
160
|
+
values = {}
|
|
161
|
+
for name, stamp in stamps.items():
|
|
162
|
+
stamped = stamp(upstream)
|
|
163
|
+
is_integer = isinstance(stamped, int) and not isinstance(stamped, bool)
|
|
164
|
+
values[name] = ibis.literal(stamped, type="int64") if is_integer else ibis.literal(stamped)
|
|
165
|
+
tables.append(table.mutate(**values))
|
|
166
|
+
return ibis.union(*tables, distinct=False).select(*names, *stamps)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Decorator for creating IbisView subclasses from functions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any, cast, overload
|
|
7
|
+
|
|
8
|
+
from interloper.asset.decorator import build_asset_class
|
|
9
|
+
from interloper.component.decorator import decorate
|
|
10
|
+
from interloper.component.relation import Relation
|
|
11
|
+
|
|
12
|
+
from interloper_sql.view.base import IbisView
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@overload
|
|
16
|
+
def ibis_view(fn: Callable[..., Any], /) -> type[IbisView]: ...
|
|
17
|
+
@overload
|
|
18
|
+
def ibis_view(
|
|
19
|
+
*,
|
|
20
|
+
relations: dict[str, Any] = ...,
|
|
21
|
+
**overrides: Any,
|
|
22
|
+
) -> Callable[[Callable[..., Any]], type[IbisView]]: ...
|
|
23
|
+
def ibis_view(
|
|
24
|
+
fn: Callable[..., Any] | None = None,
|
|
25
|
+
/,
|
|
26
|
+
*,
|
|
27
|
+
relations: dict[str, Any] | None = None,
|
|
28
|
+
**overrides: Any,
|
|
29
|
+
) -> type[IbisView] | Callable[..., type[IbisView]]:
|
|
30
|
+
"""Create an IbisView subclass from a decorated function.
|
|
31
|
+
|
|
32
|
+
Like ``il.view``, but portable: each upstream parameter receives a typed
|
|
33
|
+
``ibis.Table`` in ``data``, the function returns an Ibis expression, and
|
|
34
|
+
the destination compiles it for its own dialect:
|
|
35
|
+
|
|
36
|
+
```py
|
|
37
|
+
@ibis_view(schema=Totals, relations={"orders": il.Relation("asset", "shop.orders", many=True)})
|
|
38
|
+
def totals(orders: list[il.Upstream]) -> ibis.Table:
|
|
39
|
+
rows = union(orders)
|
|
40
|
+
return rows.group_by(rows.date).aggregate(total=rows.total.sum())
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
fn: The function to turn into a view, passed positionally when the
|
|
45
|
+
decorator is used bare. ``None`` in the parenthesised form.
|
|
46
|
+
relations: As for ``il.asset``.
|
|
47
|
+
**overrides: As for ``il.asset``; a schema is required.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
An IbisView subclass with the function as its ``data()`` method.
|
|
51
|
+
"""
|
|
52
|
+
if fn is not None:
|
|
53
|
+
return decorate(IbisView, fn, build=_build_ibis_view_class, relations=relations, **overrides)
|
|
54
|
+
|
|
55
|
+
def wrapper(fn: Callable[..., Any]) -> type[IbisView]:
|
|
56
|
+
return decorate(IbisView, fn, build=_build_ibis_view_class, relations=relations, **overrides)
|
|
57
|
+
|
|
58
|
+
return wrapper
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# -- Internals -----------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _build_ibis_view_class(
|
|
65
|
+
fn: Any,
|
|
66
|
+
*,
|
|
67
|
+
classvars: dict[str, Any],
|
|
68
|
+
fields: dict[str, Any],
|
|
69
|
+
relations: dict[str, Relation],
|
|
70
|
+
) -> type[IbisView]:
|
|
71
|
+
"""Build an IbisView subclass from a function or method.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
fn: The function (or method) backing the view's ``data()``.
|
|
75
|
+
classvars: Class-level attributes to set on the generated subclass.
|
|
76
|
+
fields: Pydantic field values to set on the generated subclass.
|
|
77
|
+
relations: Relations the decorator declares, name to relation.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
A dynamically created IbisView subclass.
|
|
81
|
+
"""
|
|
82
|
+
return cast(
|
|
83
|
+
"type[IbisView]", build_asset_class(fn, base=IbisView, classvars=classvars, fields=fields, relations=relations)
|
|
84
|
+
)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Ibis's view of interloper's types: schema fields and warehouse dialects, in one place."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import ibis
|
|
10
|
+
from interloper.schema import Schema
|
|
11
|
+
|
|
12
|
+
IBIS_TYPES: dict[Any, str] = {
|
|
13
|
+
str: "string",
|
|
14
|
+
int: "int64",
|
|
15
|
+
float: "float64",
|
|
16
|
+
bool: "boolean",
|
|
17
|
+
dt.date: "date",
|
|
18
|
+
dt.datetime: "timestamp",
|
|
19
|
+
Decimal: "decimal",
|
|
20
|
+
bytes: "binary",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
IBIS_DIALECTS: dict[str, str] = {
|
|
24
|
+
"bigquery": "bigquery",
|
|
25
|
+
"duckdb": "duckdb",
|
|
26
|
+
"snowflake": "snowflake",
|
|
27
|
+
"clickhouse": "clickhouse",
|
|
28
|
+
"databricks": "databricks",
|
|
29
|
+
"tsql": "mssql",
|
|
30
|
+
"postgres": "postgres",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def ibis_schema(schema: type[Schema]) -> ibis.Schema:
|
|
35
|
+
"""The Ibis schema of a declared schema, every column nullable.
|
|
36
|
+
|
|
37
|
+
A field whose type has no Ibis counterpart (a nested model, ``Any``) is
|
|
38
|
+
typed ``string``: a view selects such a column through, never computes on it.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
schema: The declared schema.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
The Ibis schema, in declaration order.
|
|
45
|
+
"""
|
|
46
|
+
return ibis.schema({spec.name: IBIS_TYPES.get(spec.type, "string") for spec in schema.field_specs()})
|
|
File without changes
|
|
File without changes
|