interloper-sql 0.110.2__tar.gz → 0.113.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.113.0}/PKG-INFO +17 -10
- {interloper_sql-0.110.2 → interloper_sql-0.113.0}/README.md +11 -6
- {interloper_sql-0.110.2 → interloper_sql-0.113.0}/pyproject.toml +9 -4
- {interloper_sql-0.110.2 → interloper_sql-0.113.0}/pyproject.toml.orig +6 -4
- interloper_sql-0.113.0/src/interloper_sql/__init__.py +24 -0
- {interloper_sql-0.110.2 → interloper_sql-0.113.0}/src/interloper_sql/connection.py +3 -3
- {interloper_sql-0.110.2 → interloper_sql-0.113.0}/src/interloper_sql/destination.py +14 -13
- interloper_sql-0.113.0/src/interloper_sql/view/__init__.py +7 -0
- interloper_sql-0.113.0/src/interloper_sql/view/base.py +154 -0
- interloper_sql-0.113.0/src/interloper_sql/view/decorator.py +29 -0
- interloper_sql-0.113.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.113.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.113.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.113.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.113.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
|
+
]
|
|
@@ -6,7 +6,7 @@ from functools import cached_property
|
|
|
6
6
|
|
|
7
7
|
import sqlalchemy
|
|
8
8
|
from interloper.connection import Connection, connection
|
|
9
|
-
from interloper.resource
|
|
9
|
+
from interloper.resource import SecretField, fetch_field_provider
|
|
10
10
|
from pydantic_settings import SettingsConfigDict
|
|
11
11
|
from sqlalchemy.engine import Engine
|
|
12
12
|
|
|
@@ -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
|
),
|
|
@@ -10,11 +10,11 @@ from collections.abc import Iterator, Sequence
|
|
|
10
10
|
from contextlib import contextmanager
|
|
11
11
|
from typing import Any
|
|
12
12
|
|
|
13
|
-
from interloper.destination import IOContext, destination
|
|
14
|
-
from interloper.destination.database import DatabaseDestination, PartitionFilter
|
|
13
|
+
from interloper.destination import DatabaseDestination, IOContext, destination
|
|
15
14
|
from interloper.errors import DataNotFoundError
|
|
16
|
-
from interloper.
|
|
17
|
-
from interloper.
|
|
15
|
+
from interloper.partitioning import PartitionFilter
|
|
16
|
+
from interloper.representation import TableData
|
|
17
|
+
from interloper.resource import InputField
|
|
18
18
|
from interloper.schema import FieldSpec
|
|
19
19
|
from pydantic import PrivateAttr
|
|
20
20
|
from sqlalchemy import Column, MetaData, String, Table, and_, cast, func, inspect, select
|
|
@@ -186,13 +186,13 @@ class SQLDestination(DatabaseDestination):
|
|
|
186
186
|
context: IO context carrying the effective schema.
|
|
187
187
|
"""
|
|
188
188
|
dataset = self._resolve_dataset(dataset)
|
|
189
|
-
|
|
189
|
+
table_data = TableData.from_data(data)
|
|
190
190
|
with self._begin() as conn:
|
|
191
191
|
sql_table = self._table(table, dataset, conn)
|
|
192
192
|
if sql_table is None:
|
|
193
|
-
schema = context.schema if context.schema is not None else
|
|
193
|
+
schema = context.schema if context.schema is not None else table_data.infer()
|
|
194
194
|
sql_table = self._create_table(table, dataset, schema.field_specs(), conn)
|
|
195
|
-
rows = _align(
|
|
195
|
+
rows = _align(table_data.rows, [c.name for c in sql_table.columns], self._ref(table, dataset))
|
|
196
196
|
for start in range(0, len(rows), _BATCH_SIZE):
|
|
197
197
|
conn.execute(sql_table.insert(), rows[start : start + _BATCH_SIZE])
|
|
198
198
|
|
|
@@ -312,22 +312,23 @@ def _predicate(table: Table, where: PartitionFilter) -> ColumnElement[bool]:
|
|
|
312
312
|
|
|
313
313
|
Args:
|
|
314
314
|
table: The reflected table.
|
|
315
|
-
where: The filter:
|
|
315
|
+
where: The filter: membership of ``values``, or half-open ``bounds``.
|
|
316
316
|
|
|
317
317
|
Returns:
|
|
318
318
|
The ``WHERE`` clause.
|
|
319
319
|
"""
|
|
320
320
|
column = table.c[where.column]
|
|
321
|
-
if where.
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
321
|
+
if where.values is not None:
|
|
322
|
+
return column.in_([_coerce(column, value) for value in where.values])
|
|
323
|
+
assert where.bounds is not None
|
|
324
|
+
start, end = where.bounds
|
|
325
|
+
return and_(column >= _coerce(column, start), column < _coerce(column, end))
|
|
325
326
|
|
|
326
327
|
|
|
327
328
|
def _coerce(column: Column[Any], value: Any) -> Any:
|
|
328
329
|
"""Coerce a filter value to the Python type a date or timestamp column binds.
|
|
329
330
|
|
|
330
|
-
A partition
|
|
331
|
+
A partition key arrives as a string (``"2024-01-01"``) and a day's bounds
|
|
331
332
|
as dates; bound as-is against a ``DATE`` or ``TIMESTAMP`` column, they
|
|
332
333
|
would be sent as ``VARCHAR`` or rejected by the dialect's type.
|
|
333
334
|
|
|
@@ -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,154 @@
|
|
|
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 Output, Upstream, View
|
|
10
|
+
from interloper.destination 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.partitioning import PartitionWindow
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class IbisDefinition:
|
|
21
|
+
"""A view definition that compiles an Ibis expression for a dialect."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, expression: ibis.Table) -> None:
|
|
24
|
+
"""Wrap an expression.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
expression: The view's query as an Ibis table expression.
|
|
28
|
+
"""
|
|
29
|
+
self.expression = expression
|
|
30
|
+
|
|
31
|
+
def compile(self, dialect: str) -> str:
|
|
32
|
+
"""Render the expression as SQL for a warehouse dialect.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
dialect: The destination's dialect name.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
The SQL text.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
ConfigError: If the dialect has no Ibis counterpart.
|
|
42
|
+
"""
|
|
43
|
+
if dialect not in IBIS_DIALECTS:
|
|
44
|
+
raise ConfigError(f"No Ibis dialect for '{dialect}'")
|
|
45
|
+
return str(ibis.to_sql(self.expression, dialect=IBIS_DIALECTS[dialect]))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class IbisView(View):
|
|
49
|
+
"""A view whose ``data()`` receives Ibis tables and returns an Ibis expression.
|
|
50
|
+
|
|
51
|
+
Each upstream's ``data`` is an ``ibis.Table`` named after the table the
|
|
52
|
+
destination references and typed from the upstream's declared schema,
|
|
53
|
+
so the expression is checked as it is built. The result must carry the
|
|
54
|
+
view's declared columns with their declared types; the destination then
|
|
55
|
+
compiles it for its own dialect.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
async def _upstream(
|
|
59
|
+
self, name: str, target: Asset, window: PartitionWindow | None, metadata: dict[str, Any]
|
|
60
|
+
) -> Upstream:
|
|
61
|
+
"""Turn the upstream's table reference into a typed Ibis table.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
name: The ``data()`` parameter the upstream is bound to.
|
|
65
|
+
target: The upstream asset.
|
|
66
|
+
window: Forwarded; a reference has no scope.
|
|
67
|
+
metadata: Forwarded.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
The upstream, its ``data`` an ``ibis.Table``.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
ConfigError: If the upstream declares no schema, or its reference is not a table.
|
|
74
|
+
"""
|
|
75
|
+
upstream = await super()._upstream(name, target, window, metadata)
|
|
76
|
+
reference = upstream.data
|
|
77
|
+
if not isinstance(reference, TableReference):
|
|
78
|
+
raise ConfigError(f"Upstream '{upstream.asset.qualified_key}' is not a table: {reference!r}")
|
|
79
|
+
if reference.schema is None:
|
|
80
|
+
raise ConfigError(f"Upstream '{upstream.asset.qualified_key}' declares no schema to type its table")
|
|
81
|
+
*outer, table = reference.parts
|
|
82
|
+
location = {}
|
|
83
|
+
if outer:
|
|
84
|
+
location["database"] = outer[-1]
|
|
85
|
+
if len(outer) > 1:
|
|
86
|
+
location["catalog"] = outer[-2]
|
|
87
|
+
return Upstream(asset=upstream.asset, data=ibis.table(ibis_schema(reference.schema), name=table, **location))
|
|
88
|
+
|
|
89
|
+
def _output(self, result: Any) -> Output:
|
|
90
|
+
"""Check the expression against the declared schema and wrap it.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
result: The value ``data()`` returned.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
The output, its data the compilable definition.
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
ConfigError: If the result is not an Ibis table, the view
|
|
100
|
+
declares no schema, or the expression's columns differ from
|
|
101
|
+
the declared ones.
|
|
102
|
+
"""
|
|
103
|
+
if not isinstance(result, ibis.Table):
|
|
104
|
+
raise ConfigError(f"View '{self.qualified_key}' returned {type(result).__name__}, not an Ibis table")
|
|
105
|
+
if self.schema is None:
|
|
106
|
+
raise ConfigError(f"View '{self.qualified_key}' declares no schema")
|
|
107
|
+
declared = {name: str(dtype) for name, dtype in ibis_schema(self.schema).items()}
|
|
108
|
+
produced = {name: str(dtype) for name, dtype in result.schema().items()}
|
|
109
|
+
if declared != produced:
|
|
110
|
+
missing = sorted(set(declared) - set(produced))
|
|
111
|
+
extra = sorted(set(produced) - set(declared))
|
|
112
|
+
retyped = sorted(name for name in declared.keys() & produced.keys() if declared[name] != produced[name])
|
|
113
|
+
raise ConfigError(
|
|
114
|
+
f"View '{self.qualified_key}' declares columns its expression does not produce: "
|
|
115
|
+
f"missing {missing}, extra {extra}, retyped {retyped}"
|
|
116
|
+
)
|
|
117
|
+
return Output(IbisDefinition(result))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def union(upstreams: Sequence[Upstream], *columns: str, **stamps: Callable[[Upstream], Any]) -> ibis.Table:
|
|
121
|
+
"""The UNION ALL of every upstream's table, optionally stamping a column per upstream.
|
|
122
|
+
|
|
123
|
+
Each table is projected to *columns*, or to every declared column, before
|
|
124
|
+
the union, so the branches line up by name: a bare ``SELECT *`` unions by
|
|
125
|
+
position, and two accounts' tables created at different times need not
|
|
126
|
+
share a column order.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
upstreams: The bound upstreams of a many-valued relation, each carrying an ``ibis.Table``.
|
|
130
|
+
*columns: The columns to keep from every table, in this order; every declared column by default.
|
|
131
|
+
**stamps: Column name to a function of the upstream giving that
|
|
132
|
+
upstream's constant value, such as the account it came from.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
One table over every upstream.
|
|
136
|
+
|
|
137
|
+
Raises:
|
|
138
|
+
ConfigError: If no upstream is bound; a view over nothing has no columns.
|
|
139
|
+
"""
|
|
140
|
+
if not upstreams:
|
|
141
|
+
raise ConfigError("no upstream bound: a view needs at least one")
|
|
142
|
+
names = list(columns or upstreams[0].data.columns)
|
|
143
|
+
tables = []
|
|
144
|
+
for upstream in upstreams:
|
|
145
|
+
# a projection keeping every column in order compiles to SELECT *, which unions by
|
|
146
|
+
# position; selecting in reverse forces the column list, and the outer select restores it
|
|
147
|
+
table = upstream.data.select(*reversed(names))
|
|
148
|
+
values = {}
|
|
149
|
+
for name, stamp in stamps.items():
|
|
150
|
+
stamped = stamp(upstream)
|
|
151
|
+
is_integer = isinstance(stamped, int) and not isinstance(stamped, bool)
|
|
152
|
+
values[name] = ibis.literal(stamped, type="int64") if is_integer else ibis.literal(stamped)
|
|
153
|
+
tables.append(table.mutate(**values))
|
|
154
|
+
return ibis.union(*tables, distinct=False).select(*names, *stamps)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""The ibis_view decorator: an IbisView subclass from a decorated function."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from interloper.component import Decorator
|
|
6
|
+
|
|
7
|
+
from interloper_sql.view.base import IbisView
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class IbisViewDecorator(Decorator[IbisView]):
|
|
11
|
+
"""Create an IbisView subclass from a decorated function.
|
|
12
|
+
|
|
13
|
+
Like ``il.view``, but portable: each upstream parameter receives a typed
|
|
14
|
+
``ibis.Table`` in ``data``, the function returns an Ibis expression, and the
|
|
15
|
+
destination compiles it for its own dialect:
|
|
16
|
+
|
|
17
|
+
```py
|
|
18
|
+
@ibis_view(schema=Totals, relations={"orders": il.Relation("asset", "shop.orders", many=True)})
|
|
19
|
+
def totals(orders: list[il.Upstream]) -> ibis.Table:
|
|
20
|
+
rows = union(orders)
|
|
21
|
+
return rows.group_by(rows.date).aggregate(total=rows.total.sum())
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
It takes the settings ``il.asset`` takes (a schema is required) and returns an
|
|
25
|
+
IbisView subclass with the function as its ``data()``.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
ibis_view = IbisViewDecorator(IbisView)
|
|
@@ -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
|