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.
@@ -1,24 +1,31 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: interloper-sql
3
- Version: 0.110.2
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
- A SQL database destination for interloper: `SQLDestination`, a
16
- `DatabaseDestination` over SQLAlchemy Core, and the `SQLConnection` that holds
17
- the database URL.
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 out of the box (the package depends on `psycopg[binary]`);
21
- MySQL, SQLite, Redshift, SQL Server and the rest need their own driver.
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` | none, ships with the package |
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
- A SQL database destination for interloper: `SQLDestination`, a
4
- `DatabaseDestination` over SQLAlchemy Core, and the `SQLConnection` that holds
5
- the database URL.
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 out of the box (the package depends on `psycopg[binary]`);
9
- MySQL, SQLite, Redshift, SQL Server and the rest need their own driver.
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` | none, ships with the package |
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.110.2"
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.110.2"
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.fields import SecretField, fetch_field_provider
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 package.
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 out of the box "
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.representation import Representation
17
- from interloper.resource.fields import InputField
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
- view = Representation.of(data)
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 view.infer()
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(view.records, [c.name for c in sql_table.columns], self._ref(table, dataset))
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: equality on ``value``, or half-open ``bounds``.
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.bounds is not None:
322
- start, end = where.bounds
323
- return and_(column >= _coerce(column, start), column < _coerce(column, end))
324
- return column == _coerce(column, where.value)
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 id arrives as a string (``"2024-01-01"``) and a day's bounds
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()})
@@ -1,9 +0,0 @@
1
- """Interloper SQL integration: a SQLAlchemy-backed destination and its connection."""
2
-
3
- from interloper_sql.connection import SQLConnection
4
- from interloper_sql.destination import SQLDestination
5
-
6
- __all__ = [
7
- "SQLConnection",
8
- "SQLDestination",
9
- ]