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.
Files changed (72) hide show
  1. matchlab/__init__.py +55 -0
  2. matchlab/adapters/__init__.py +24 -0
  3. matchlab/adapters/base.py +446 -0
  4. matchlab/adapters/duckdb.py +659 -0
  5. matchlab/cli.py +177 -0
  6. matchlab/core/__init__.py +1 -0
  7. matchlab/core/dataframes.py +254 -0
  8. matchlab/core/dsu.py +62 -0
  9. matchlab/core/exceptions.py +40 -0
  10. matchlab/core/hash.py +141 -0
  11. matchlab/core/kinds.py +12 -0
  12. matchlab/core/logging.py +190 -0
  13. matchlab/core/resolver_output.py +173 -0
  14. matchlab/core/schemas.py +82 -0
  15. matchlab/core/sql.py +12 -0
  16. matchlab/document.py +329 -0
  17. matchlab/eval/__init__.py +11 -0
  18. matchlab/eval/judgements.py +41 -0
  19. matchlab/eval/metrics.py +244 -0
  20. matchlab/eval/samples.py +415 -0
  21. matchlab/eval/tui/__init__.py +66 -0
  22. matchlab/eval/tui/app.py +418 -0
  23. matchlab/eval/tui/modals.py +84 -0
  24. matchlab/eval/tui/styles.tcss +184 -0
  25. matchlab/eval/tui/widgets/__init__.py +1 -0
  26. matchlab/eval/tui/widgets/assignment.py +72 -0
  27. matchlab/eval/tui/widgets/styling.py +75 -0
  28. matchlab/eval/tui/widgets/table.py +285 -0
  29. matchlab/lineage.py +239 -0
  30. matchlab/locations.py +347 -0
  31. matchlab/models/__init__.py +10 -0
  32. matchlab/models/comparison.py +67 -0
  33. matchlab/models/dedupers/__init__.py +5 -0
  34. matchlab/models/dedupers/base.py +38 -0
  35. matchlab/models/dedupers/naive.py +77 -0
  36. matchlab/models/linkers/__init__.py +9 -0
  37. matchlab/models/linkers/base.py +41 -0
  38. matchlab/models/linkers/deterministic.py +229 -0
  39. matchlab/models/linkers/splinklinker.py +262 -0
  40. matchlab/models/linkers/weighteddeterministic.py +143 -0
  41. matchlab/models/models.py +198 -0
  42. matchlab/progress.py +455 -0
  43. matchlab/recordstep.py +181 -0
  44. matchlab/resolvers/__init__.py +10 -0
  45. matchlab/resolvers/base.py +63 -0
  46. matchlab/resolvers/components.py +66 -0
  47. matchlab/resolvers/resolvers.py +409 -0
  48. matchlab/sources.py +291 -0
  49. matchlab/specs.py +129 -0
  50. matchlab/steps.py +284 -0
  51. matchlab/testkit/__init__.py +104 -0
  52. matchlab/testkit/_generate.py +240 -0
  53. matchlab/testkit/compare.py +178 -0
  54. matchlab/testkit/entities.py +290 -0
  55. matchlab/testkit/features.py +146 -0
  56. matchlab/testkit/linked.py +527 -0
  57. matchlab/testkit/matchers.py +233 -0
  58. matchlab/testkit/models.py +165 -0
  59. matchlab/testkit/sources.py +327 -0
  60. matchlab/transformers/__init__.py +24 -0
  61. matchlab/transformers/base.py +41 -0
  62. matchlab/transformers/clean.py +48 -0
  63. matchlab/transformers/explode.py +67 -0
  64. matchlab/transformers/group.py +56 -0
  65. matchlab/transformers/select.py +31 -0
  66. matchlab/transformers/transform.py +101 -0
  67. matchlab-0.1.0.dev1.dist-info/METADATA +88 -0
  68. matchlab-0.1.0.dev1.dist-info/RECORD +72 -0
  69. matchlab-0.1.0.dev1.dist-info/WHEEL +5 -0
  70. matchlab-0.1.0.dev1.dist-info/entry_points.txt +2 -0
  71. matchlab-0.1.0.dev1.dist-info/licenses/LICENSE +21 -0
  72. matchlab-0.1.0.dev1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,327 @@
1
+ """One generated source: a real plan node, its rows, and the partition they imply."""
2
+
3
+ from collections.abc import Callable
4
+ from functools import cache, wraps
5
+ from typing import Any, ParamSpec, Self, TypeVar
6
+
7
+ import polars as pl
8
+ import pyarrow as pa
9
+ from faker import Faker
10
+ from pydantic import BaseModel, ConfigDict, Field
11
+ from sqlalchemy import Engine, create_engine
12
+ from sqlglot import cast, select
13
+ from sqlglot.expressions import column
14
+
15
+ from matchlab.locations import RelationalDBLocation
16
+ from matchlab.sources import Source
17
+ from matchlab.testkit._generate import generate_entities, generate_source
18
+ from matchlab.testkit.entities import Cluster, EntityReference, TrueEntity
19
+ from matchlab.testkit.features import FeatureConfig
20
+
21
+ P = ParamSpec("P")
22
+ R = TypeVar("R")
23
+
24
+
25
+ def make_features_hashable(func: Callable[P, R]) -> Callable[P, R]:
26
+ """Let callers configure `source_factory` with plain dicts.
27
+
28
+ Converts each dict to a `FeatureConfig` before the wrapped function runs, so
29
+ `source_factory` stays hashable, which `@cache` needs, while its callers don't have
30
+ to build `FeatureConfig` objects by hand.
31
+ """
32
+
33
+ @wraps(func)
34
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
35
+ # Handle features in first positional arg
36
+ if args and args[0] is not None:
37
+ if isinstance(args[0][0], dict):
38
+ args = (tuple(FeatureConfig(**d) for d in args[0]),) + args[1:]
39
+ else:
40
+ args = (tuple(args[0]),) + args[1:]
41
+
42
+ # Handle features in kwargs
43
+ if "features" in kwargs and kwargs["features"] is not None:
44
+ if isinstance(kwargs["features"][0], dict):
45
+ kwargs["features"] = tuple(
46
+ FeatureConfig(**d) for d in kwargs["features"]
47
+ )
48
+ else:
49
+ kwargs["features"] = tuple(kwargs["features"])
50
+
51
+ return func(*args, **kwargs)
52
+
53
+ return wrapper
54
+
55
+
56
+ class GeneratedSource(BaseModel):
57
+ """Generated rows, and the `Source` plan node that reads them.
58
+
59
+ Sources that share entities are what make linking testable. Use
60
+ `linked_sources_factory` in `linked` instead when you need that.
61
+
62
+ This exposes what it knows about the *fixture*: the rows, the features they were
63
+ generated from, and the partition they imply. Anything you do to the *plan* goes
64
+ through `.source`, which is the node itself: `source.source.clean(...)`,
65
+ `source.source.name`, `source.source.spec`. There is deliberately no shortcut. A
66
+ testkit that forwarded those would be indistinguishable from the node it wraps, and
67
+ knowing which one you are holding is the whole point.
68
+ """
69
+
70
+ model_config = ConfigDict(arbitrary_types_allowed=True)
71
+
72
+ source: Source = Field(
73
+ description="The Source object containing the spec and convenience methods."
74
+ )
75
+ features: tuple[FeatureConfig, ...] | None = Field(
76
+ description=(
77
+ "The features used to generate the data. "
78
+ "If None, the source data was not generated, but set manually."
79
+ ),
80
+ default=None,
81
+ )
82
+ data: pa.Table = Field(
83
+ description="Data corresponding to the output of queries, without leaf data."
84
+ )
85
+ input_clusters: tuple[Cluster, ...] = Field(
86
+ description=(
87
+ "The partition a matcher starts from: generated rows grouped by identical "
88
+ "content. Not the answer, that is LinkedSources.true_entities."
89
+ )
90
+ )
91
+
92
+ @property
93
+ def field_names(self) -> list[str]:
94
+ """The non-key columns of this testkit's data, in generation order.
95
+
96
+ Taken from the testkit rather than from `Source.index_fields`, which would have
97
+ to read the warehouse. These names are needed before a plan is even built.
98
+ Falls back to the data's own columns when the source was set manually rather
99
+ than generated from features.
100
+ """
101
+ if self.features is not None:
102
+ return [feature.name for feature in self.features]
103
+ return [
104
+ column for column in self.data.column_names if column not in ("key", "id")
105
+ ]
106
+
107
+ def write_to_location(self, client: Any | None = None) -> Self: # noqa: ANN401
108
+ """Write the data to the source's location.
109
+
110
+ Args:
111
+ client: Write through this client instead of the source's own, rebuilding
112
+ the source's location around it. Testkits are generated against an
113
+ in-memory SQLite engine by default, so a test that wants a real
114
+ warehouse repoints one here.
115
+ """
116
+ if client:
117
+ self.source.location = type(self.source.location)(
118
+ name=self.source.location.name, client=client
119
+ )
120
+
121
+ pl.from_arrow(self.data).write_database(
122
+ table_name=self.source.name,
123
+ connection=self.source.location.client,
124
+ if_table_exists="replace",
125
+ )
126
+
127
+ return self
128
+
129
+
130
+ def values_of(
131
+ entity: TrueEntity | Cluster, sources: dict[str, GeneratedSource]
132
+ ) -> dict[str, dict[str, list[str]]]:
133
+ """Every unique value this entity took, per source and per feature.
134
+
135
+ Args:
136
+ entity: The entity whose rows to look up.
137
+ sources: The generated sources to look in, by name.
138
+
139
+ Returns:
140
+ `{source: {feature: sorted unique values}}`. Each source may vary the same base
141
+ value differently, so they are kept apart.
142
+
143
+ Raises:
144
+ ValueError: If the entity references a source that was not supplied.
145
+ """
146
+ values: dict[str, dict[str, list[str]]] = {}
147
+
148
+ for source_name, keys in entity.keys.items():
149
+ source = sources.get(source_name)
150
+ if source is None:
151
+ raise ValueError(f"Source testkit not found: {source_name}")
152
+
153
+ rows = pl.DataFrame(source.data).filter(pl.col("key").is_in(list(keys)))
154
+ values[source_name] = {
155
+ feature.name: sorted(rows[feature.name].unique().to_list())
156
+ for feature in source.features
157
+ }
158
+
159
+ return values
160
+
161
+
162
+ @make_features_hashable
163
+ @cache
164
+ def source_factory(
165
+ features: list[FeatureConfig] | list[dict] | None = None,
166
+ name: str | None = None,
167
+ location_name: str = "dbname",
168
+ engine: Engine | None = None,
169
+ n_true_entities: int = 10,
170
+ repetition: int = 0,
171
+ seed: int = 42,
172
+ ) -> GeneratedSource:
173
+ """Generate a complete source testkit from configured features.
174
+
175
+ Sources created with the factory system can only use a RelationalDBLocation,
176
+ and the data at that location will be stored in a single table.
177
+
178
+ Args:
179
+ features: List of FeatureConfig objects or dictionaries to use for generating
180
+ the source data. If None, defaults to a set of common features.
181
+ name: Name of the source. If None, a unique name is generated. This will be
182
+ used as the name of the table in the RelationalDBLocation, but also in
183
+ the str for the source.
184
+ location_name: Name of the location for the source.
185
+ engine: SQLAlchemy engine to use for the source's RelationalDBLocation. If
186
+ None, an in-memory SQLite engine is created.
187
+ n_true_entities: Number of true entities to generate. Defaults to 10.
188
+ repetition: Number of times to repeat the generated data. Defaults to 0.
189
+ seed: Random seed for reproducibility. Defaults to 42.
190
+ """
191
+ generator = Faker()
192
+ generator.seed_instance(seed)
193
+
194
+ if features is None:
195
+ features = (
196
+ FeatureConfig(
197
+ name="company_name",
198
+ base_generator="company",
199
+ ),
200
+ FeatureConfig(
201
+ name="crn",
202
+ base_generator="bothify",
203
+ parameters=(("text", "???-###-???-###"),),
204
+ ),
205
+ )
206
+
207
+ if name is None:
208
+ name = generator.unique.word()
209
+
210
+ if engine is None:
211
+ engine = create_engine("sqlite:///:memory:")
212
+
213
+ # Generate base entities
214
+ base_entities = generate_entities(
215
+ generator=generator,
216
+ features=features,
217
+ n=n_true_entities,
218
+ )
219
+
220
+ # Generate data using the base entities
221
+ data, entity_keys, row_keys = generate_source(
222
+ generator=generator,
223
+ n_true_entities=n_true_entities,
224
+ features=features,
225
+ repetition=repetition,
226
+ seed_entities=base_entities,
227
+ )
228
+
229
+ # Create true entities with references
230
+ true_entities = []
231
+ for entity in base_entities:
232
+ keys = entity_keys.get(entity.id, [])
233
+ if keys:
234
+ entity.add_source_reference(name, keys)
235
+ true_entities.append(entity)
236
+
237
+ # Create Cluster objects from row_keys
238
+ clusters = [
239
+ Cluster(
240
+ id=row_id,
241
+ keys=EntityReference({name: frozenset(keys)}),
242
+ )
243
+ for row_id, keys in row_keys.items()
244
+ ]
245
+
246
+ # Create source config
247
+ source = Source(
248
+ location=RelationalDBLocation(name=location_name, client=engine),
249
+ name=name,
250
+ extract_transform=select(
251
+ cast(column("key"), "string").as_("key"),
252
+ *[column(feature.name) for feature in features],
253
+ )
254
+ .from_(name)
255
+ .sql(),
256
+ key_field="key",
257
+ )
258
+
259
+ return GeneratedSource(
260
+ source=source,
261
+ features=features,
262
+ data=data,
263
+ input_clusters=tuple(sorted(clusters)),
264
+ )
265
+
266
+
267
+ def source_from_tuple(
268
+ data_tuple: tuple[dict[str, Any], ...],
269
+ data_keys: tuple[Any],
270
+ name: str | None = None,
271
+ location_name: str = "dbname",
272
+ engine: Engine | None = None,
273
+ seed: int = 42,
274
+ ) -> GeneratedSource:
275
+ """Generate a complete source testkit from dummy data."""
276
+ generator = Faker()
277
+ generator.seed_instance(seed)
278
+
279
+ if name is None:
280
+ name = generator.unique.word()
281
+
282
+ if engine is None:
283
+ engine = create_engine("sqlite:///:memory:")
284
+
285
+ base_entities = tuple(TrueEntity(base_values=row) for row in data_tuple)
286
+
287
+ # Create true entities with references
288
+ true_entities: list[TrueEntity] = []
289
+ for entity, key in zip(base_entities, data_keys, strict=True):
290
+ entity.add_source_reference(name, [key])
291
+ true_entities.append(entity)
292
+ # A list, not a set: these are zipped against `data_keys` below, so they must stay
293
+ # in row order. A set comprehension paired each key with an arbitrary entity.
294
+ entity_ids = [entity.id for entity in true_entities]
295
+
296
+ # Create Cluster objects from row_keys
297
+ clusters = [
298
+ Cluster(
299
+ id=entity_id,
300
+ keys=EntityReference({name: frozenset([key])}),
301
+ )
302
+ for key, entity_id in zip(data_keys, entity_ids, strict=True)
303
+ ]
304
+
305
+ # Create source config
306
+ source = Source(
307
+ location=RelationalDBLocation(name=location_name, client=engine),
308
+ name=name,
309
+ extract_transform=select(
310
+ cast(column("key"), "string").as_("key"),
311
+ *[column(field) for field in data_tuple[0]],
312
+ )
313
+ .from_(name)
314
+ .sql(),
315
+ key_field="key",
316
+ )
317
+
318
+ raw_data = pa.Table.from_pylist(list(data_tuple))
319
+ raw_keys = pa.array(data_keys)
320
+
321
+ data = raw_data.append_column("id", [entity_ids]).append_column("key", raw_keys)
322
+
323
+ return GeneratedSource(
324
+ source=source,
325
+ data=data,
326
+ input_clusters=tuple(sorted(clusters)),
327
+ )
@@ -0,0 +1,24 @@
1
+ """Pluggable, serialisable reshapings of a record step.
2
+
3
+ `Select` projects, `Clean` derives, `Group` collapses each `id` to one row, and
4
+ `Explode` cross-joins each `id`'s columns back out to every combination, each one job.
5
+ Register a custom one with `add_transformer_class`, as `add_model_class` does a
6
+ deduper.
7
+ """
8
+
9
+ from matchlab.transformers.base import Transformer
10
+ from matchlab.transformers.clean import Clean
11
+ from matchlab.transformers.explode import Explode
12
+ from matchlab.transformers.group import Group
13
+ from matchlab.transformers.select import Select
14
+ from matchlab.transformers.transform import Transform, add_transformer_class
15
+
16
+ __all__ = (
17
+ "Clean",
18
+ "Explode",
19
+ "Group",
20
+ "Select",
21
+ "Transform",
22
+ "Transformer",
23
+ "add_transformer_class",
24
+ )
@@ -0,0 +1,41 @@
1
+ """Base class for transformer methodologies, and the DuckDB query runner some share.
2
+
3
+ A `Transformer` is a pure function of a record step, called by a `Transform` step's
4
+ `apply()` on every collect. Both input and output must carry `id`, the grouping every
5
+ downstream model and resolver reads.
6
+
7
+ Transformers are declarative and serialisable. Their fields are the configuration a
8
+ `Transform` folds into its spec and cache key, so keep them to plain data, with no
9
+ callables, exactly as a `Deduper`'s settings are.
10
+ """
11
+
12
+ from abc import ABC, abstractmethod
13
+
14
+ import duckdb
15
+ import polars as pl
16
+ from pydantic import BaseModel, ConfigDict
17
+
18
+ from matchlab.core.sql import SQLQuery
19
+
20
+
21
+ class Transformer(BaseModel, ABC):
22
+ """Base contract every transformer implements.
23
+
24
+ Concrete transformers (`Select`, `Clean`, `Group`, `Explode`) carry their
25
+ configuration as flat fields, so `MyTransformer(...)` reads naturally, and
26
+ `model_dump(mode="json")` is the whole of its serialisation.
27
+ """
28
+
29
+ model_config = ConfigDict(frozen=True)
30
+
31
+ @abstractmethod
32
+ def apply(self, data: pl.DataFrame) -> pl.DataFrame:
33
+ """Return `data` reshaped. Both the input and the output carry `id`."""
34
+ ...
35
+
36
+
37
+ def run_sql(query: SQLQuery, data: pl.DataFrame) -> pl.DataFrame:
38
+ """Execute one DuckDB query against `data`, registered as `data`."""
39
+ with duckdb.connect(":memory:") as connection:
40
+ connection.register("data", data)
41
+ return connection.execute(query).pl()
@@ -0,0 +1,48 @@
1
+ """Clean derives columns. Add or replace named columns, keep the rest."""
2
+
3
+ import polars as pl
4
+ from pydantic import Field, field_validator
5
+ from sqlglot import expressions, parse_one
6
+ from sqlglot import select as sqlglot_select
7
+
8
+ from matchlab.core.sql import SQLExpression
9
+ from matchlab.transformers.base import Transformer, run_sql
10
+
11
+
12
+ class Clean(Transformer):
13
+ """Derive columns with DuckDB SQL, without dropping unrelated fields.
14
+
15
+ Each entry maps an output column name to a SQL expression over the record step's
16
+ columns
17
+ (the source-qualified names, `crn_company`). An alias that names an existing column
18
+ replaces it. A new alias is added. Every other column, and `id`, passes through
19
+ untouched. Dropping is `Select`'s job, not `Clean`'s.
20
+ """
21
+
22
+ cleaning: dict[str, SQLExpression] = Field(
23
+ description="Output column name to a DuckDB SQL expression over the step."
24
+ )
25
+
26
+ @field_validator("cleaning")
27
+ @classmethod
28
+ def _non_empty(cls, cleaning: dict[str, SQLExpression]) -> dict[str, SQLExpression]:
29
+ """A clean that derives nothing is meaningless. 'No cleaning' is no `Clean`."""
30
+ if not cleaning:
31
+ raise ValueError("Clean needs at least one expression to derive.")
32
+ return cleaning
33
+
34
+ def apply(self, data: pl.DataFrame) -> pl.DataFrame:
35
+ """Add or replace the named columns, keeping the rest.
36
+
37
+ Invalid SQL raises at build time, naming the expression.
38
+ """
39
+ collisions = [alias for alias in self.cleaning if alias in data.columns]
40
+ star = expressions.Star(except_=[expressions.column(a) for a in collisions])
41
+ projection: list[expressions.Expression] = [star]
42
+ for alias, sql in self.cleaning.items():
43
+ projection.append(
44
+ expressions.alias_(parse_one(sql, dialect="duckdb"), alias)
45
+ )
46
+
47
+ query = sqlglot_select(*projection, dialect="duckdb").from_("data")
48
+ return run_sql(query.sql(dialect="duckdb"), data)
@@ -0,0 +1,67 @@
1
+ """Explode changes granularity. Cross-join each `id`'s columns to every combination."""
2
+
3
+ from functools import reduce
4
+ from operator import mul
5
+
6
+ import polars as pl
7
+ from pydantic import Field, field_validator
8
+
9
+ from matchlab.transformers.base import Transformer
10
+
11
+
12
+ class Explode(Transformer):
13
+ """Give every combination of each `id`'s non-null column values its own row.
14
+
15
+ `Group` collapses each `id` to one row. `Explode` goes the other way, giving the
16
+ true cross product a `list` aggregate can't express. Every non-null value in one
17
+ column pairs with every non-null value in every other column, for the same `id`.
18
+ Values are deduplicated per column first, so a value repeated across records
19
+ counts once. A column that is all null for one `id` still keeps that row, with
20
+ `null` there, instead of dropping it.
21
+
22
+ An `id` with several populated columns can generate many rows fast, so
23
+ `max_combinations` caps it and raises, naming the `id`, rather than silently
24
+ building an enormous table.
25
+ """
26
+
27
+ max_combinations: int = Field(
28
+ default=100,
29
+ description="Reject a group whose cross product would exceed this many rows.",
30
+ )
31
+
32
+ @field_validator("max_combinations")
33
+ @classmethod
34
+ def _positive(cls, max_combinations: int) -> int:
35
+ """Zero or fewer combinations is a way to reject every group, not a cap."""
36
+ if max_combinations <= 0:
37
+ raise ValueError("max_combinations must be positive.")
38
+ return max_combinations
39
+
40
+ def apply(self, data: pl.DataFrame) -> pl.DataFrame:
41
+ """Cross-join each `id`'s columns to every combination of their values."""
42
+ columns = [column for column in data.columns if column != "id"]
43
+ if not columns:
44
+ return data.select("id").unique(maintain_order=True)
45
+
46
+ grouped = data.group_by("id", maintain_order=True).agg(
47
+ [pl.col(column).drop_nulls().unique() for column in columns]
48
+ )
49
+
50
+ # Length 1 stands in for an empty list too, since exploding one keeps the row.
51
+ sizes = [pl.col(column).list.len().clip(lower_bound=1) for column in columns]
52
+ counts = grouped.select("id", reduce(mul, sizes).alias("combinations"))
53
+ overflow = counts.filter(pl.col("combinations") > self.max_combinations)
54
+ if overflow.height:
55
+ row = overflow.row(0, named=True)
56
+ raise ValueError(
57
+ f"id {row['id']} would generate {row['combinations']:,} "
58
+ f"combinations, which exceeds the maximum of "
59
+ f"{self.max_combinations}. This would cause a Cartesian explosion."
60
+ )
61
+
62
+ # One column at a time compounds into the true cross product. Exploding
63
+ # several columns in one call zips them in parallel instead.
64
+ exploded = grouped
65
+ for column in columns:
66
+ exploded = exploded.explode(column, empty_as_null=True)
67
+ return exploded
@@ -0,0 +1,56 @@
1
+ """Group changes granularity. Collapse each `id` to one row."""
2
+
3
+ import polars as pl
4
+ from pydantic import Field, field_validator
5
+ from sqlglot import expressions, parse_one
6
+ from sqlglot import select as sqlglot_select
7
+
8
+ from matchlab.core.sql import SQLExpression
9
+ from matchlab.transformers.base import Transformer, run_sql
10
+
11
+
12
+ class Group(Transformer):
13
+ """Collapse each `id` to a single row, combining columns with aggregate SQL.
14
+
15
+ Reading several records per entity (through a resolver) gives a model more evidence,
16
+ but a comparison needs one row. `Group` says how each column combines. `id` is the
17
+ grouping key, and every named expression is a DuckDB aggregate, for example
18
+ `any_value(crn_company)` where records agree or `list(distinct crn_town)` where they
19
+ don't. Only `id` and the named aggregates survive, since grouping has to decide how
20
+ every column collapses.
21
+ """
22
+
23
+ aggregates: dict[str, SQLExpression] = Field(
24
+ description="Output column name to a DuckDB aggregate expression."
25
+ )
26
+
27
+ @field_validator("aggregates")
28
+ @classmethod
29
+ def _non_empty(
30
+ cls, aggregates: dict[str, SQLExpression]
31
+ ) -> dict[str, SQLExpression]:
32
+ """Grouping collapses rows, so each column must say how it combines."""
33
+ if not aggregates:
34
+ raise ValueError(
35
+ "Group needs aggregate expressions: grouping collapses several records "
36
+ "into one row, so each column has to say how it combines. Pass "
37
+ "aggregates such as {'name': 'any_value(crn_company)'}."
38
+ )
39
+ return aggregates
40
+
41
+ def apply(self, data: pl.DataFrame) -> pl.DataFrame:
42
+ """Collapse each `id` to one row using the aggregate expressions.
43
+
44
+ A non-aggregate expression raises DuckDB's own error, naming the column.
45
+ """
46
+ identifier = expressions.alias_(expressions.column("id"), "id")
47
+ projection: list[expressions.Expression] = [identifier]
48
+ for alias, sql in self.aggregates.items():
49
+ projection.append(
50
+ expressions.alias_(parse_one(sql, dialect="duckdb"), alias)
51
+ )
52
+
53
+ query = (
54
+ sqlglot_select(*projection, dialect="duckdb").from_("data").group_by("id")
55
+ )
56
+ return run_sql(query.sql(dialect="duckdb"), data)
@@ -0,0 +1,31 @@
1
+ """Select projects. Keep the named columns, drop the rest."""
2
+
3
+ import polars as pl
4
+ from pydantic import Field
5
+
6
+ from matchlab.transformers.base import Transformer
7
+
8
+
9
+ class Select(Transformer):
10
+ """Keep only the named columns, plus `id`.
11
+
12
+ Projection is `Select`'s one job. Naming a column keeps it, and every column you do
13
+ not name is dropped. `id` survives whether or not you name it, since it is the
14
+ grouping a model matches on. Columns are the source-qualified names (`crn_company`).
15
+ """
16
+
17
+ columns: tuple[str, ...] = Field(
18
+ default=(),
19
+ description="The columns to keep, by their current (qualified) names.",
20
+ )
21
+
22
+ def __init__(self, *columns: str, **data: object) -> None:
23
+ """Take the columns to keep positionally, e.g. `Select('crn_company')`."""
24
+ if columns:
25
+ data["columns"] = columns
26
+ super().__init__(**data)
27
+
28
+ def apply(self, data: pl.DataFrame) -> pl.DataFrame:
29
+ """Project to `id` and the named columns, in that order."""
30
+ kept = [column for column in self.columns if column != "id"]
31
+ return data.select("id", *kept)