matchlab 0.1.0.dev1__py3-none-any.whl
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.
- matchlab/__init__.py +55 -0
- matchlab/adapters/__init__.py +24 -0
- matchlab/adapters/base.py +446 -0
- matchlab/adapters/duckdb.py +659 -0
- matchlab/cli.py +177 -0
- matchlab/core/__init__.py +1 -0
- matchlab/core/dataframes.py +254 -0
- matchlab/core/dsu.py +62 -0
- matchlab/core/exceptions.py +40 -0
- matchlab/core/hash.py +141 -0
- matchlab/core/kinds.py +12 -0
- matchlab/core/logging.py +190 -0
- matchlab/core/resolver_output.py +173 -0
- matchlab/core/schemas.py +82 -0
- matchlab/core/sql.py +12 -0
- matchlab/document.py +329 -0
- matchlab/eval/__init__.py +11 -0
- matchlab/eval/judgements.py +41 -0
- matchlab/eval/metrics.py +244 -0
- matchlab/eval/samples.py +415 -0
- matchlab/eval/tui/__init__.py +66 -0
- matchlab/eval/tui/app.py +418 -0
- matchlab/eval/tui/modals.py +84 -0
- matchlab/eval/tui/styles.tcss +184 -0
- matchlab/eval/tui/widgets/__init__.py +1 -0
- matchlab/eval/tui/widgets/assignment.py +72 -0
- matchlab/eval/tui/widgets/styling.py +75 -0
- matchlab/eval/tui/widgets/table.py +285 -0
- matchlab/lineage.py +239 -0
- matchlab/locations.py +347 -0
- matchlab/models/__init__.py +10 -0
- matchlab/models/comparison.py +67 -0
- matchlab/models/dedupers/__init__.py +5 -0
- matchlab/models/dedupers/base.py +38 -0
- matchlab/models/dedupers/naive.py +77 -0
- matchlab/models/linkers/__init__.py +9 -0
- matchlab/models/linkers/base.py +41 -0
- matchlab/models/linkers/deterministic.py +229 -0
- matchlab/models/linkers/splinklinker.py +262 -0
- matchlab/models/linkers/weighteddeterministic.py +143 -0
- matchlab/models/models.py +198 -0
- matchlab/progress.py +455 -0
- matchlab/recordstep.py +181 -0
- matchlab/resolvers/__init__.py +10 -0
- matchlab/resolvers/base.py +63 -0
- matchlab/resolvers/components.py +66 -0
- matchlab/resolvers/resolvers.py +409 -0
- matchlab/sources.py +291 -0
- matchlab/specs.py +129 -0
- matchlab/steps.py +284 -0
- matchlab/testkit/__init__.py +104 -0
- matchlab/testkit/_generate.py +240 -0
- matchlab/testkit/compare.py +178 -0
- matchlab/testkit/entities.py +290 -0
- matchlab/testkit/features.py +146 -0
- matchlab/testkit/linked.py +527 -0
- matchlab/testkit/matchers.py +233 -0
- matchlab/testkit/models.py +165 -0
- matchlab/testkit/sources.py +327 -0
- matchlab/transformers/__init__.py +24 -0
- matchlab/transformers/base.py +41 -0
- matchlab/transformers/clean.py +48 -0
- matchlab/transformers/explode.py +67 -0
- matchlab/transformers/group.py +56 -0
- matchlab/transformers/select.py +31 -0
- matchlab/transformers/transform.py +101 -0
- matchlab-0.1.0.dev1.dist-info/METADATA +88 -0
- matchlab-0.1.0.dev1.dist-info/RECORD +72 -0
- matchlab-0.1.0.dev1.dist-info/WHEEL +5 -0
- matchlab-0.1.0.dev1.dist-info/entry_points.txt +2 -0
- matchlab-0.1.0.dev1.dist-info/licenses/LICENSE +21 -0
- matchlab-0.1.0.dev1.dist-info/top_level.txt +1 -0
matchlab/locations.py
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""Interface to locations where source data is stored.
|
|
2
|
+
|
|
3
|
+
Location classes are registered by name.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from collections.abc import Callable, Generator, Iterator
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
from typing import Any, ClassVar, Literal, overload
|
|
11
|
+
|
|
12
|
+
import polars as pl
|
|
13
|
+
import sqlglot
|
|
14
|
+
from adbc_driver_manager.dbapi import Connection as AdbcConnection
|
|
15
|
+
from pandas import DataFrame as PandasDataFrame
|
|
16
|
+
from polars import DataFrame as PolarsDataFrame
|
|
17
|
+
from pyarrow import Table as ArrowTable
|
|
18
|
+
from sqlalchemy import Connection, Engine
|
|
19
|
+
from sqlalchemy.exc import OperationalError
|
|
20
|
+
from sqlglot.errors import ParseError
|
|
21
|
+
|
|
22
|
+
from matchlab.core.dataframes import (
|
|
23
|
+
DataFrameClass,
|
|
24
|
+
DataFrameType,
|
|
25
|
+
sql_to_df,
|
|
26
|
+
)
|
|
27
|
+
from matchlab.core.exceptions import ExtractTransformError
|
|
28
|
+
from matchlab.core.logging import logger
|
|
29
|
+
from matchlab.core.sql import SQLQuery
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ClientType(StrEnum):
|
|
33
|
+
"""The client libraries a relational location can be driven by."""
|
|
34
|
+
|
|
35
|
+
SQLALCHEMY = "sqlalchemy"
|
|
36
|
+
ADBC = "adbc"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_LOCATION_CLASSES: dict[str, "type[Location]"] = {}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def add_location_class(location_class: "type[Location]") -> None:
|
|
43
|
+
"""Register a custom location so it can be named in a plan document."""
|
|
44
|
+
if not issubclass(location_class, Location):
|
|
45
|
+
raise ValueError("The argument is not a subclass of Location.")
|
|
46
|
+
_LOCATION_CLASSES[location_class.__name__] = location_class
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build_location(location_class: str, name: str, client: Any) -> "Location": # noqa: ANN401
|
|
50
|
+
"""Build a registered location class, bound to a name and a client.
|
|
51
|
+
|
|
52
|
+
How `matchlab.document` rebuilds the locations a plan reads. Both arguments come
|
|
53
|
+
from outside any spec: the class because a `SourceSpec` deliberately says
|
|
54
|
+
nothing about where its rows came from, the client because a document carries no
|
|
55
|
+
credentials.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
ValueError: If no location class of that name is registered here.
|
|
59
|
+
"""
|
|
60
|
+
if location_class not in _LOCATION_CLASSES:
|
|
61
|
+
raise ValueError(
|
|
62
|
+
f"No location class named '{location_class}' is registered. Register it "
|
|
63
|
+
"with `add_location_class` before loading a document that names it."
|
|
64
|
+
)
|
|
65
|
+
return _LOCATION_CLASSES[location_class](name=name, client=client)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Location(ABC):
|
|
69
|
+
"""A named place data is read from, bound to the client that reads it."""
|
|
70
|
+
|
|
71
|
+
client_classes: ClassVar[tuple[type, ...]]
|
|
72
|
+
"""The client types this location can be driven by, checked on construction."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, name: str, client: Any) -> None: # noqa: ANN401
|
|
75
|
+
"""Initialise a location.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
name: How a plan refers to this location. A handle for the client, not a
|
|
79
|
+
setting. It never reaches a fingerprint.
|
|
80
|
+
client: The already-configured client to read through. Required: a location
|
|
81
|
+
without one can answer nothing, not even which SQL dialect it speaks.
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
ValueError: If the client is not a type this location can use.
|
|
85
|
+
"""
|
|
86
|
+
if not isinstance(client, self.client_classes):
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f"{type(client).__name__} is not a valid client for "
|
|
89
|
+
f"{type(self).__name__}. Expected one of: "
|
|
90
|
+
f"{', '.join(accepted.__name__ for accepted in self.client_classes)}."
|
|
91
|
+
)
|
|
92
|
+
self.name = name
|
|
93
|
+
self._client = client
|
|
94
|
+
|
|
95
|
+
def __str__(self) -> str:
|
|
96
|
+
"""Identify the location by class and name."""
|
|
97
|
+
return f"{type(self).__name__} '{self.name}'"
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def client(self) -> Any: # noqa: ANN401
|
|
101
|
+
"""The client this location reads through. Set once, at construction."""
|
|
102
|
+
return self._client
|
|
103
|
+
|
|
104
|
+
@abstractmethod
|
|
105
|
+
def connect(self) -> bool:
|
|
106
|
+
"""Check the location can actually be reached."""
|
|
107
|
+
...
|
|
108
|
+
|
|
109
|
+
@abstractmethod
|
|
110
|
+
def validate_extract_transform(self, extract_transform: SQLQuery) -> None:
|
|
111
|
+
"""Validate ET logic against this location's query language.
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
ExtractTransformError: If the ET logic is invalid.
|
|
115
|
+
"""
|
|
116
|
+
...
|
|
117
|
+
|
|
118
|
+
@abstractmethod
|
|
119
|
+
def execute(
|
|
120
|
+
self,
|
|
121
|
+
extract_transform: SQLQuery,
|
|
122
|
+
batch_size: int | None = None,
|
|
123
|
+
rename: dict[str, str] | Callable | None = None,
|
|
124
|
+
return_type: DataFrameType = DataFrameType.POLARS,
|
|
125
|
+
keys: tuple[str, list[str]] | None = None,
|
|
126
|
+
schema_overrides: dict[str, pl.DataType] | None = None,
|
|
127
|
+
) -> Iterator[DataFrameClass]:
|
|
128
|
+
"""Execute ET logic against location and return batches.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
extract_transform: The ET logic to execute.
|
|
132
|
+
batch_size: The size used for internal batching. Overrides environment
|
|
133
|
+
variable if set.
|
|
134
|
+
rename: Renaming to apply after the ET logic is executed.
|
|
135
|
+
|
|
136
|
+
* If a dictionary is provided, it will be used to rename the columns.
|
|
137
|
+
* If a callable is provided, it will take the old name as input and
|
|
138
|
+
return the new name.
|
|
139
|
+
return_type: The type of data to return. Defaults to "polars".
|
|
140
|
+
keys: Rule to only retrieve rows by specific keys.
|
|
141
|
+
The key of the dictionary is a field name on which to filter.
|
|
142
|
+
Filters source entries where the key field is in the dict values.
|
|
143
|
+
schema_overrides: Types to force on the columns that come back, rather
|
|
144
|
+
than letting the client infer them.
|
|
145
|
+
"""
|
|
146
|
+
...
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class RelationalDBLocation(Location):
|
|
150
|
+
"""A location for a relational database."""
|
|
151
|
+
|
|
152
|
+
client_classes: ClassVar[tuple[type, ...]] = (Engine, AdbcConnection)
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def client(self) -> Engine | AdbcConnection:
|
|
156
|
+
"""The SQLAlchemy engine or ADBC connection this location reads through."""
|
|
157
|
+
return self._client
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def client_type(self) -> ClientType:
|
|
161
|
+
"""Which client type this location was built with.
|
|
162
|
+
|
|
163
|
+
One `isinstance` decides it, because `client_classes` has already rejected
|
|
164
|
+
anything that is neither an `Engine` nor an ADBC connection.
|
|
165
|
+
"""
|
|
166
|
+
return (
|
|
167
|
+
ClientType.SQLALCHEMY
|
|
168
|
+
if isinstance(self.client, Engine)
|
|
169
|
+
else ClientType.ADBC
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
@contextmanager
|
|
173
|
+
def _get_connection(self) -> Generator[Connection | AdbcConnection, None, None]:
|
|
174
|
+
"""Get a database connection, and close or release it properly afterwards."""
|
|
175
|
+
if self.client_type == ClientType.ADBC:
|
|
176
|
+
# Cloning avoids memory corruption in the ADBC driver, which happens if
|
|
177
|
+
# a single connection is reused for multiple queries.
|
|
178
|
+
with self.client.adbc_clone() as connection:
|
|
179
|
+
yield connection
|
|
180
|
+
else: # SQLAlchemy Engine
|
|
181
|
+
connection = self.client.connect()
|
|
182
|
+
try:
|
|
183
|
+
yield connection
|
|
184
|
+
finally:
|
|
185
|
+
connection.close()
|
|
186
|
+
|
|
187
|
+
def connect(self) -> bool: # noqa: D102
|
|
188
|
+
try:
|
|
189
|
+
with self._get_connection() as conn:
|
|
190
|
+
_ = sql_to_df(stmt="select 1", connection=conn)
|
|
191
|
+
return True
|
|
192
|
+
except OperationalError:
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def _sqlglot_dialect(self) -> str | None:
|
|
197
|
+
"""The dialect to parse extract/transform SQL as, if we recognise it."""
|
|
198
|
+
if self.client_type == ClientType.SQLALCHEMY:
|
|
199
|
+
client_dialect = self.client.dialect.name
|
|
200
|
+
else:
|
|
201
|
+
vendor = self.client.adbc_get_info().get("vendor_name")
|
|
202
|
+
client_dialect = vendor.lower() if vendor else None
|
|
203
|
+
|
|
204
|
+
match client_dialect:
|
|
205
|
+
case "postgresql":
|
|
206
|
+
return "postgres"
|
|
207
|
+
case "sqlite":
|
|
208
|
+
return "sqlite"
|
|
209
|
+
case _:
|
|
210
|
+
logger.warning("Could not validate specific dialect.")
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
def validate_extract_transform(self, extract_transform: SQLQuery) -> None:
|
|
214
|
+
"""Check that the SQL statement only contains a single data-extracting command.
|
|
215
|
+
|
|
216
|
+
This does not fully sanitise the SQL statement. Validation only guards
|
|
217
|
+
against accidental mistakes, not malicious actors. Only run indexing using
|
|
218
|
+
sources you trust and have read, with least-privilege credentials.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
extract_transform: The SQL statement to validate.
|
|
222
|
+
|
|
223
|
+
Raises:
|
|
224
|
+
ParseError: If the SQL statement cannot be parsed.
|
|
225
|
+
ExtractTransformError: If validation requirements are not met.
|
|
226
|
+
"""
|
|
227
|
+
if not extract_transform.strip():
|
|
228
|
+
raise ExtractTransformError(
|
|
229
|
+
"SQL statement is empty or only contains whitespace."
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
expressions = sqlglot.parse(
|
|
234
|
+
extract_transform, dialect=self._sqlglot_dialect
|
|
235
|
+
)
|
|
236
|
+
except ParseError as e:
|
|
237
|
+
raise ExtractTransformError("SQL statement could not be parsed.") from e
|
|
238
|
+
|
|
239
|
+
if len(expressions) > 1:
|
|
240
|
+
raise ExtractTransformError("SQL statement contains multiple commands.")
|
|
241
|
+
|
|
242
|
+
if not expressions:
|
|
243
|
+
raise ExtractTransformError(
|
|
244
|
+
"SQL statement does not contain any valid expressions."
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
expr = expressions[0]
|
|
248
|
+
|
|
249
|
+
if not isinstance(expr, sqlglot.expressions.Select | sqlglot.expressions.Union):
|
|
250
|
+
raise ExtractTransformError(
|
|
251
|
+
"SQL statement must start with a SELECT or WITH command."
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
forbidden = (
|
|
255
|
+
sqlglot.expressions.DDL,
|
|
256
|
+
sqlglot.expressions.DML,
|
|
257
|
+
sqlglot.expressions.Into,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
if len(list(expr.find_all(forbidden))) > 0:
|
|
261
|
+
raise ExtractTransformError(
|
|
262
|
+
"SQL statement must not contain DDL or DML commands."
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
@overload
|
|
266
|
+
def execute(
|
|
267
|
+
self,
|
|
268
|
+
extract_transform: SQLQuery,
|
|
269
|
+
batch_size: int | None = None,
|
|
270
|
+
rename: dict[str, str] | Callable | None = None,
|
|
271
|
+
return_type: Literal[DataFrameType.POLARS] = ...,
|
|
272
|
+
keys: tuple[str, list[str]] | None = None,
|
|
273
|
+
schema_overrides: dict[str, pl.DataType] | None = None,
|
|
274
|
+
) -> Generator[PolarsDataFrame, None, None]: ...
|
|
275
|
+
|
|
276
|
+
@overload
|
|
277
|
+
def execute(
|
|
278
|
+
self,
|
|
279
|
+
extract_transform: SQLQuery,
|
|
280
|
+
batch_size: int | None = None,
|
|
281
|
+
rename: dict[str, str] | Callable | None = None,
|
|
282
|
+
return_type: Literal[DataFrameType.PANDAS] = ...,
|
|
283
|
+
keys: tuple[str, list[str]] | None = None,
|
|
284
|
+
schema_overrides: dict[str, pl.DataType] | None = None,
|
|
285
|
+
) -> Generator[PandasDataFrame, None, None]: ...
|
|
286
|
+
|
|
287
|
+
@overload
|
|
288
|
+
def execute(
|
|
289
|
+
self,
|
|
290
|
+
extract_transform: SQLQuery,
|
|
291
|
+
batch_size: int | None = None,
|
|
292
|
+
rename: dict[str, str] | Callable | None = None,
|
|
293
|
+
return_type: Literal[DataFrameType.ARROW] = ...,
|
|
294
|
+
keys: tuple[str, list[str]] | None = None,
|
|
295
|
+
schema_overrides: dict[str, pl.DataType] | None = None,
|
|
296
|
+
) -> Generator[ArrowTable, None, None]: ...
|
|
297
|
+
|
|
298
|
+
def execute( # noqa: D102
|
|
299
|
+
self,
|
|
300
|
+
extract_transform: SQLQuery,
|
|
301
|
+
batch_size: int | None = None,
|
|
302
|
+
rename: dict[str, str] | Callable | None = None,
|
|
303
|
+
return_type: DataFrameType = DataFrameType.POLARS,
|
|
304
|
+
keys: tuple[str, list[str]] | None = None,
|
|
305
|
+
schema_overrides: dict[str, pl.DataType] | None = None,
|
|
306
|
+
) -> Generator[DataFrameClass, None, None]:
|
|
307
|
+
# Strip semicolon, as it can block extended query protocol
|
|
308
|
+
# and slow some engines down
|
|
309
|
+
extract_transform = extract_transform.replace(";", "")
|
|
310
|
+
|
|
311
|
+
# We always work in batches to simplify higher level logic
|
|
312
|
+
batch_size: int = batch_size or 10_000
|
|
313
|
+
|
|
314
|
+
# TODO(adbc-batching): this size is honoured for SQLAlchemy clients and
|
|
315
|
+
# silently ignored for ADBC ones. Polars registers ADBC with
|
|
316
|
+
# `exact_batch_size: False` (see `polars/io/database/_arrow_registry.py`), so
|
|
317
|
+
# it calls `fetch_record_batch()` with no size and the driver chunks however
|
|
318
|
+
# it likes. In-process drivers return the whole result in one batch.
|
|
319
|
+
# `Source.sample(n)` takes the first batch and so can return the entire
|
|
320
|
+
# source, and `_read_warehouse`'s stream-to-Parquet stops bounding memory.
|
|
321
|
+
# Fix by slicing the record-batch stream to `batch_size` in `sql_to_df`.
|
|
322
|
+
|
|
323
|
+
with self._get_connection() as conn:
|
|
324
|
+
if keys:
|
|
325
|
+
key_field, filter_values = keys
|
|
326
|
+
quoted_key_values = [f"'{key_val}'" for key_val in filter_values]
|
|
327
|
+
# Only filter original SQL if keys are provided
|
|
328
|
+
if quoted_key_values:
|
|
329
|
+
comma_separated_values = ", ".join(quoted_key_values)
|
|
330
|
+
# This "IN" expression is a SQL standard, though that is hard to
|
|
331
|
+
# prove, since the standard is behind a paywall.
|
|
332
|
+
extract_transform = (
|
|
333
|
+
f"select * from ({extract_transform}) as sub "
|
|
334
|
+
f"where {key_field} in ({comma_separated_values})"
|
|
335
|
+
)
|
|
336
|
+
yield from sql_to_df(
|
|
337
|
+
stmt=extract_transform,
|
|
338
|
+
schema_overrides=schema_overrides,
|
|
339
|
+
connection=conn,
|
|
340
|
+
rename=rename,
|
|
341
|
+
batch_size=batch_size,
|
|
342
|
+
return_batches=True,
|
|
343
|
+
return_type=return_type,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
add_location_class(RelationalDBLocation)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Functions to compare fields in different sources."""
|
|
2
|
+
|
|
3
|
+
import sqlglot.expressions as exp
|
|
4
|
+
from sqlglot import parse_one
|
|
5
|
+
from sqlglot.errors import ParseError
|
|
6
|
+
|
|
7
|
+
from matchlab.core.logging import logger
|
|
8
|
+
from matchlab.core.sql import SQLCondition
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def comparison(sql_condition: SQLCondition, dialect: str = "duckdb") -> SQLCondition:
|
|
12
|
+
"""Validate a SQL WHERE-clause condition and recompile it for `dialect`.
|
|
13
|
+
|
|
14
|
+
Every column must be qualified with the alias `l` or `r`, the two sides a
|
|
15
|
+
[`Model`][matchlab.models.Model] compares. Raises `ParseError` if a column isn't
|
|
16
|
+
qualified this way, if the condition isn't a valid boolean expression, or if it
|
|
17
|
+
doesn't reference both sides.
|
|
18
|
+
|
|
19
|
+
`dialect` only changes how the validated condition renders back to SQL, not how
|
|
20
|
+
it's parsed, so `sql_condition` must already be syntax `sqlglot` can parse
|
|
21
|
+
without help.
|
|
22
|
+
|
|
23
|
+
Logs a warning for `OR` conditions, since DuckDB can't use an index to prune them.
|
|
24
|
+
"""
|
|
25
|
+
parsed_sql = parse_one(sql_condition)
|
|
26
|
+
|
|
27
|
+
for node in parsed_sql.walk():
|
|
28
|
+
# `walk()` yields each expression directly (older sqlglot yielded a
|
|
29
|
+
# `(node, parent, key)` tuple, which is why this once read `node[0]`).
|
|
30
|
+
if not isinstance(
|
|
31
|
+
node,
|
|
32
|
+
exp.Connector | exp.Predicate | exp.Condition | exp.Identifier,
|
|
33
|
+
):
|
|
34
|
+
raise ParseError(
|
|
35
|
+
f"Must be valid WHERE clause statements. Found {type(node)}"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if isinstance(node, exp.Or):
|
|
39
|
+
logger.warning(
|
|
40
|
+
"Or conditions are computationally expensive. Expect slow performance."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
left = False
|
|
44
|
+
right = False
|
|
45
|
+
for column in parsed_sql.find_all(exp.Column):
|
|
46
|
+
if column.table == "l":
|
|
47
|
+
left = True
|
|
48
|
+
elif column.table == "r":
|
|
49
|
+
right = True
|
|
50
|
+
elif column.table == "":
|
|
51
|
+
raise ParseError(
|
|
52
|
+
"Columns must be explicitly declared as one of table "
|
|
53
|
+
'"l" or "r".\n\n'
|
|
54
|
+
f"No table declared for column {column.name}."
|
|
55
|
+
)
|
|
56
|
+
else:
|
|
57
|
+
raise ParseError(
|
|
58
|
+
"Columns must be explicitly declared as one of table "
|
|
59
|
+
'"l" or "r".\n\n'
|
|
60
|
+
f"Found column {column.table}."
|
|
61
|
+
)
|
|
62
|
+
if not (left and right):
|
|
63
|
+
raise ParseError('Conditions must reference both "l" and "r".')
|
|
64
|
+
|
|
65
|
+
pg_sql = parsed_sql.sql(dialect=dialect)
|
|
66
|
+
|
|
67
|
+
return pg_sql
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Base class for deduplication methodologies."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
import polars as pl
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DeduperSettings(BaseModel):
|
|
11
|
+
"""Settings shared by every Deduper methodology."""
|
|
12
|
+
|
|
13
|
+
id: Literal["id"] = Field(
|
|
14
|
+
default="id", description="The unique ID field in the data to dedupe"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Deduper(BaseModel, ABC):
|
|
19
|
+
"""A methodology that finds candidate duplicate pairs within one record step.
|
|
20
|
+
|
|
21
|
+
A `Model` step calls `prepare()` once, then `dedupe()`, each time it collects. Put
|
|
22
|
+
one-off setup in `prepare()` instead, for example fitting a model over the whole
|
|
23
|
+
dataset, so it doesn't repeat on every call to `dedupe()`. `dedupe()` must return
|
|
24
|
+
a table with `left_id`, `right_id`, and `score` columns. `normalise_model_scores`
|
|
25
|
+
casts that table to `SCHEMA_MODEL_EDGES`.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
settings: DeduperSettings
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def prepare(self, data: pl.DataFrame) -> None:
|
|
32
|
+
"""Run once before `dedupe()`, for setup that shouldn't repeat per call."""
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def dedupe(self, data: pl.DataFrame) -> pl.DataFrame:
|
|
37
|
+
"""Score candidate duplicate pairs within `data`."""
|
|
38
|
+
...
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""A deduplication methodology based on a deterministic set of conditions."""
|
|
2
|
+
|
|
3
|
+
import duckdb
|
|
4
|
+
import polars as pl
|
|
5
|
+
from pydantic import Field
|
|
6
|
+
|
|
7
|
+
from matchlab.models.dedupers.base import Deduper, DeduperSettings
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class NaiveSettings(DeduperSettings):
|
|
11
|
+
"""Settings for `NaiveDeduper`."""
|
|
12
|
+
|
|
13
|
+
unique_fields: list[str] = Field(
|
|
14
|
+
description="A list of fields that will form a unique, deduplicated record"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class NaiveDeduper(Deduper):
|
|
19
|
+
"""Groups records that match exactly on every field in `unique_fields`."""
|
|
20
|
+
|
|
21
|
+
settings: NaiveSettings
|
|
22
|
+
|
|
23
|
+
_id_dtype: pl.DataType
|
|
24
|
+
|
|
25
|
+
def prepare(self, data: pl.DataFrame) -> None:
|
|
26
|
+
"""No preparation needed."""
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
def dedupe(self, data: pl.DataFrame) -> pl.DataFrame:
|
|
30
|
+
"""Deduplicate the dataframe. Every match scores 1.0."""
|
|
31
|
+
self._id_dtype = data[self.settings.id].dtype
|
|
32
|
+
|
|
33
|
+
df = data.clone()
|
|
34
|
+
|
|
35
|
+
join_clause = []
|
|
36
|
+
for field in self.settings.unique_fields:
|
|
37
|
+
join_clause.append(f"l.{field} = r.{field}")
|
|
38
|
+
join_clause_compiled = " and ".join(join_clause)
|
|
39
|
+
|
|
40
|
+
# Add a row index so a genuine duplicate row (identical values, different
|
|
41
|
+
# index) isn't discarded as a self-match.
|
|
42
|
+
df = df.with_row_index("_unique_e4003b")
|
|
43
|
+
|
|
44
|
+
# If `id` isn't unique per row (for example, after unnesting an array of
|
|
45
|
+
# keys), two different rows can still share the same left_id/right_id value.
|
|
46
|
+
# Drop those pairs too.
|
|
47
|
+
sql = f"""
|
|
48
|
+
select distinct on (list_sort([raw.left_id, raw.right_id]))
|
|
49
|
+
raw.left_id,
|
|
50
|
+
raw.right_id,
|
|
51
|
+
1.0 as score
|
|
52
|
+
from (
|
|
53
|
+
select
|
|
54
|
+
l.{self.settings.id} as left_id,
|
|
55
|
+
r.{self.settings.id} as right_id
|
|
56
|
+
from
|
|
57
|
+
df l
|
|
58
|
+
inner join df r on
|
|
59
|
+
(
|
|
60
|
+
{join_clause_compiled}
|
|
61
|
+
) and
|
|
62
|
+
l._unique_e4003b != r._unique_e4003b
|
|
63
|
+
) raw
|
|
64
|
+
where raw.left_id != raw.right_id;
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
duckdb.sql(sql)
|
|
69
|
+
.pl()
|
|
70
|
+
.with_columns(
|
|
71
|
+
[
|
|
72
|
+
pl.col("left_id").cast(self._id_dtype),
|
|
73
|
+
pl.col("right_id").cast(self._id_dtype),
|
|
74
|
+
pl.col("score").cast(pl.Float32),
|
|
75
|
+
]
|
|
76
|
+
)
|
|
77
|
+
)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Linking methodologies."""
|
|
2
|
+
|
|
3
|
+
from matchlab.models.linkers.deterministic import DeterministicLinker
|
|
4
|
+
from matchlab.models.linkers.splinklinker import SplinkLinker
|
|
5
|
+
from matchlab.models.linkers.weighteddeterministic import (
|
|
6
|
+
WeightedDeterministicLinker,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__all__ = ("DeterministicLinker", "WeightedDeterministicLinker", "SplinkLinker")
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Base class for linkers."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
import polars as pl
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LinkerSettings(BaseModel):
|
|
11
|
+
"""Settings shared by every Linker methodology."""
|
|
12
|
+
|
|
13
|
+
left_id: Literal["id"] = Field(
|
|
14
|
+
default="id", description="The unique ID field in the left data"
|
|
15
|
+
)
|
|
16
|
+
right_id: Literal["id"] = Field(
|
|
17
|
+
default="id", description="The unique ID field in the right data"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Linker(BaseModel, ABC):
|
|
22
|
+
"""A methodology that finds candidate matches between two record steps.
|
|
23
|
+
|
|
24
|
+
A `Model` step calls `prepare()` once, then `link()`, each time it collects. Put
|
|
25
|
+
one-off setup in `prepare()` instead, for example fitting a model over both
|
|
26
|
+
datasets, so it doesn't repeat on every call to `link()`. `link()` must return a
|
|
27
|
+
table with `left_id`, `right_id`, and `score` columns. `normalise_model_scores`
|
|
28
|
+
casts that table to `SCHEMA_MODEL_EDGES`.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
settings: LinkerSettings
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def prepare(self, left: pl.DataFrame, right: pl.DataFrame) -> None:
|
|
35
|
+
"""Run once before `link()`, for setup that shouldn't repeat per call."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def link(self, left: pl.DataFrame, right: pl.DataFrame) -> pl.DataFrame:
|
|
40
|
+
"""Score candidate matches between `left` and `right`."""
|
|
41
|
+
...
|