From 13128a8b5d6643c6c264727bbd977816b8107b06 Mon Sep 17 00:00:00 2001 From: Hussain Sultan Date: Tue, 18 Aug 2026 14:30:27 -0400 Subject: [PATCH 1/6] refactor!: one query() and one compare_periods, on the SemanticTable base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four drifted copies (SemanticModel / SemanticJoin / SemanticFilter / op-level SemanticJoinOp — the last missing time_grains entirely) are replaced by a single documented implementation on the base class, so every semantic expression accepts the same parameters. Grouped and aggregated results gain .query()/.compare_periods() for free. Co-Authored-By: Claude Fable 5 --- src/boring_semantic_layer/expr.py | 188 ++++++++----------------- src/boring_semantic_layer/ops/_core.py | 23 --- 2 files changed, 59 insertions(+), 152 deletions(-) diff --git a/src/boring_semantic_layer/expr.py b/src/boring_semantic_layer/expr.py index b20f6ff8..394fe347 100644 --- a/src/boring_semantic_layer/expr.py +++ b/src/boring_semantic_layer/expr.py @@ -247,6 +247,65 @@ def chart( create_chart = importlib.import_module("boring_semantic_layer.chart").chart return create_chart(self, spec=spec, backend=backend, format=format) + def query( + self, + dimensions: Sequence[str] | None = None, + measures: Sequence[str] | None = None, + filters: Sequence[dict | str | Callable] | None = None, + order_by: Sequence[tuple[str, str] | str] | None = None, + limit: int | None = None, + time_grain: str | None = None, + time_grains: dict[str, str] | None = None, + time_range: dict[str, str] | None = None, + having: Sequence[dict] | None = None, + ): + """Run a declarative (JSON-style) query against this semantic table. + + Available on every semantic expression (models, joins, filters, + grouped/aggregated results). See ``boring_semantic_layer.query.query`` + for parameter semantics and worked examples. + """ + return _query_module().query( + semantic_table=self, + dimensions=dimensions, + measures=measures, + filters=filters, + order_by=order_by, + limit=limit, + time_grain=time_grain, + time_grains=time_grains, + time_range=time_range, + having=having, + ) + + def compare_periods( + self, + dimensions: Sequence[str] | None = None, + measures: Sequence[str] | None = None, + current_time_range: dict[str, str] | None = None, + previous_time_range: dict[str, str] | None = None, + filters: Sequence[dict | str | Callable] | None = None, + time_dimension: str | None = None, + time_grain: str | None = None, + time_grains: dict[str, str] | None = None, + order_by: Sequence[tuple[str, str] | str] | None = None, + limit: int | None = None, + ): + """Compare measures across two time ranges (current/previous/delta).""" + return _query_module().compare_periods( + semantic_table=self, + dimensions=dimensions, + measures=measures, + current_time_range=current_time_range, + previous_time_range=previous_time_range, + filters=filters, + time_dimension=time_dimension, + time_grain=time_grain, + time_grains=time_grains, + order_by=order_by, + limit=limit, + ) + def filter(self, predicate: Callable) -> SemanticFilter: return SemanticFilter(source=self.op(), predicate=predicate) @@ -1218,58 +1277,6 @@ def __getitem__(self, key): f"'{key}' not found in dimensions, measures, or calculated measures", ) - def query( - self, - dimensions: Sequence[str] | None = None, - measures: Sequence[str] | None = None, - filters: list | None = None, - order_by: Sequence[tuple[str, str]] | None = None, - limit: int | None = None, - time_grain: str | None = None, - time_grains: dict[str, str] | None = None, - time_range: dict[str, str] | None = None, - having: list | None = None, - ): - return _query_module().query( - semantic_table=self, - dimensions=dimensions, - measures=measures, - filters=filters, - order_by=order_by, - limit=limit, - time_grain=time_grain, - time_grains=time_grains, - time_range=time_range, - having=having, - ) - - def compare_periods( - self, - dimensions: Sequence[str] | None = None, - measures: Sequence[str] | None = None, - current_time_range: dict[str, str] | None = None, - previous_time_range: dict[str, str] | None = None, - filters: list | None = None, - time_dimension: str | None = None, - time_grain: str | None = None, - time_grains: dict[str, str] | None = None, - order_by: Sequence[tuple[str, str]] | None = None, - limit: int | None = None, - ): - return _query_module().compare_periods( - semantic_table=self, - dimensions=dimensions, - measures=measures, - current_time_range=current_time_range, - previous_time_range=previous_time_range, - filters=filters, - time_dimension=time_dimension, - time_grain=time_grain, - time_grains=time_grains, - order_by=order_by, - limit=limit, - ) - class SemanticJoin(SemanticTable): def __init__( @@ -1409,31 +1416,6 @@ def calc_measures(self): def json_definition(self): return self.op().json_definition - def query( - self, - dimensions: list[str] | None = None, - measures: list[str] | None = None, - filters: dict[str, Any] | None = None, - order_by: list[str] | None = None, - limit: int | None = None, - time_grain: str | None = None, - time_grains: dict[str, str] | None = None, - time_range: dict[str, str] | None = None, - having: list | None = None, - ): - return _query_module().query( - semantic_table=self, - dimensions=dimensions, - measures=measures, - filters=filters, - order_by=order_by, - limit=limit, - time_grain=time_grain, - time_grains=time_grains, - time_range=time_range, - having=having, - ) - def as_table(self) -> SemanticModel: all_roots = _find_all_root_models(self.op()) return _build_semantic_model_from_roots(self.op().to_untagged(), all_roots) @@ -1588,58 +1570,6 @@ def measures(self): def calc_measures(self): return dict(self.get_calculated_measures()) - def query( - self, - dimensions: Sequence[str] | None = None, - measures: Sequence[str] | None = None, - filters: list | None = None, - order_by: Sequence[tuple[str, str]] | None = None, - limit: int | None = None, - time_grain: str | None = None, - time_grains: dict[str, str] | None = None, - time_range: dict[str, str] | None = None, - having: list | None = None, - ): - return _query_module().query( - semantic_table=self, - dimensions=dimensions, - measures=measures, - filters=filters, - order_by=order_by, - limit=limit, - time_grain=time_grain, - time_grains=time_grains, - time_range=time_range, - having=having, - ) - - def compare_periods( - self, - dimensions: Sequence[str] | None = None, - measures: Sequence[str] | None = None, - current_time_range: dict[str, str] | None = None, - previous_time_range: dict[str, str] | None = None, - filters: list | None = None, - time_dimension: str | None = None, - time_grain: str | None = None, - time_grains: dict[str, str] | None = None, - order_by: Sequence[tuple[str, str]] | None = None, - limit: int | None = None, - ): - return _query_module().compare_periods( - semantic_table=self, - dimensions=dimensions, - measures=measures, - current_time_range=current_time_range, - previous_time_range=previous_time_range, - filters=filters, - time_dimension=time_dimension, - time_grain=time_grain, - time_grains=time_grains, - order_by=order_by, - limit=limit, - ) - def as_table(self) -> SemanticModel: all_roots = _find_all_root_models(self.op().source) return _build_semantic_model_from_roots(self.op().to_untagged(), all_roots) diff --git a/src/boring_semantic_layer/ops/_core.py b/src/boring_semantic_layer/ops/_core.py index b6b1e3c7..36953e75 100644 --- a/src/boring_semantic_layer/ops/_core.py +++ b/src/boring_semantic_layer/ops/_core.py @@ -3652,29 +3652,6 @@ def description(self) -> str | None: def table(self): return self.to_untagged() - def query( - self, - dimensions: Sequence[str] | None = None, - measures: Sequence[str] | None = None, - filters: list | None = None, - order_by: Sequence[tuple[str, str]] | None = None, - limit: int | None = None, - time_grain: str | None = None, - time_range: dict[str, str] | None = None, - having: list | None = None, - ): - return _query_module().query( - semantic_table=self, - dimensions=dimensions, - measures=measures, - filters=filters, - order_by=order_by, - limit=limit, - time_grain=time_grain, - time_range=time_range, - having=having, - ) - def with_dimensions(self, **dims) -> SemanticTable: return _semantic_table( table=self.to_untagged(), From 6ca1ead891acb4acc199107579b219b93afdb60a Mon Sep 17 00:00:00 2001 From: Hussain Sultan Date: Tue, 18 Aug 2026 14:32:21 -0400 Subject: [PATCH 2/6] feat: query() fails fast on unknown dimension/measure names Unknown names now raise UnknownFieldError at query construction with did-you-mean suggestions, instead of surfacing at execute() as a raw backend error listing physical columns. Raw table columns and unique suffix matches on joined models remain valid dimension spellings. Co-Authored-By: Claude Fable 5 --- src/boring_semantic_layer/query.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/boring_semantic_layer/query.py b/src/boring_semantic_layer/query.py index e49866b6..a7e25c4a 100644 --- a/src/boring_semantic_layer/query.py +++ b/src/boring_semantic_layer/query.py @@ -14,7 +14,8 @@ from ibis.common.collections import FrozenDict from toolz import curry -from .errors import QueryError, unwrap_or_raise +from .errors import QueryError, UnknownFieldError, suggest_kinded, unwrap_or_raise +from .fieldref import resolve_suffix from .safe_eval import safe_eval @@ -801,6 +802,30 @@ def query( order_by = _normalize_order_by(order_by, known_order_fields, expected_prefix=model_name) filters = list(filters or []) # Copy to avoid mutating input + # Fail fast on unknown names, with suggestions — before any compilation, + # so the user never sees a backend error listing physical columns. + raw_columns = set(getattr(result, "columns", ()) or ()) + for dim in dimensions: + if ( + dim not in known_dimensions + and dim not in raw_columns + and resolve_suffix(dim, known_dimensions, raw_columns) is None + ): + hint = suggest_kinded(dim, [("dimension", known_dimensions), ("column", raw_columns)]) + raise UnknownFieldError( + f"Unknown dimension {dim!r}. Declared dimensions: " + f"{sorted(known_dimensions)}.{' ' + hint if hint else ''}" + ) + for meas in measures or (): + if meas not in known_measures and resolve_suffix(meas, known_measures) is None: + hint = suggest_kinded( + meas, [("measure", known_measures), ("dimension", known_dimensions)] + ) + raise UnknownFieldError( + f"Unknown measure {meas!r}. Declared measures: " + f"{sorted(known_measures)}.{' ' + hint if hint else ''}" + ) + selected_fields = set(dimensions) | set(measures or []) produces_aggregate = bool(dimensions or measures) if produces_aggregate and order_by: From 948749604120b6567ff3d0d2726ca1f5523af31d Mon Sep 17 00:00:00 2001 From: Hussain Sultan Date: Tue, 18 Aug 2026 14:36:11 -0400 Subject: [PATCH 3/6] feat: YAML model configs are schema-validated at load Unknown top-level model keys (the classic 'dimension:' / 'measurez:' typos that used to load silently as empty sections) now raise DefinitionError naming the model, the key, and accepted spellings with did-you-mean hints; field sections must be mappings; and smallest_time_grain values are validated at load instead of at query time. Co-Authored-By: Claude Fable 5 --- src/boring_semantic_layer/yaml.py | 73 ++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/src/boring_semantic_layer/yaml.py b/src/boring_semantic_layer/yaml.py index 137111cd..60021412 100644 --- a/src/boring_semantic_layer/yaml.py +++ b/src/boring_semantic_layer/yaml.py @@ -8,7 +8,7 @@ from ibis import _ from .api import to_semantic_table -from .errors import unwrap_or_raise +from .errors import DefinitionError, format_suggestions, unwrap_or_raise from .expr import SemanticModel, SemanticTable from .io import read_yaml_file from .ops import Dimension, Measure @@ -338,6 +338,74 @@ def _load_table_for_yaml_model( return tables, tables[table_name] +_MODEL_KEYS = frozenset( + { + "table", + "description", + "database", + "dimensions", + "measures", + "calculated_measures", + "joins", + "filter", + "profile", + } +) + +_VALID_TIME_GRAINS = frozenset( + { + "TIME_GRAIN_YEAR", + "TIME_GRAIN_QUARTER", + "TIME_GRAIN_MONTH", + "TIME_GRAIN_WEEK", + "TIME_GRAIN_DAY", + "TIME_GRAIN_HOUR", + "TIME_GRAIN_MINUTE", + "TIME_GRAIN_SECOND", + "year", + "quarter", + "month", + "week", + "day", + "hour", + "minute", + "second", + } +) + + +def _validate_model_config(name: str, model_config: Mapping[str, Any]) -> None: + """Reject unknown keys and invalid grains loudly, at load time. + + A typo like ``dimension:`` used to load silently as a model with zero + dimensions; every mistake now names the model, the key, and the + accepted spellings. + """ + unknown = sorted(set(model_config) - _MODEL_KEYS) + if unknown: + hints = "".join(format_suggestions(k, _MODEL_KEYS) for k in unknown) + raise DefinitionError( + f"Model {name!r} has unknown key(s) {unknown}. " + f"Accepted keys: {sorted(_MODEL_KEYS)}.{hints}" + ) + for section in ("dimensions", "measures", "calculated_measures"): + entries = model_config.get(section) or {} + if not isinstance(entries, Mapping): + raise DefinitionError( + f"Model {name!r}: {section!r} must be a mapping of name -> " + f"expression/config, got {type(entries).__name__}" + ) + for field_name, cfg in entries.items(): + if isinstance(cfg, Mapping): + grain = cfg.get("smallest_time_grain") + if grain is not None and grain not in _VALID_TIME_GRAINS: + raise DefinitionError( + f"Model {name!r}, {section[:-1]} {field_name!r}: invalid " + f"smallest_time_grain {grain!r}. Accepted values: " + f"{sorted(_VALID_TIME_GRAINS)}." + ) + + def from_config( config: Mapping[str, Any], tables: Mapping[str, Any] | None = None, @@ -410,9 +478,10 @@ def from_config( # First pass: create models for name, model_config in model_configs.items(): + _validate_model_config(name, model_config) table_name = model_config.get("table") if not table_name: - raise ValueError(f"Model '{name}' must specify 'table' field") + raise DefinitionError(f"Model '{name}' must specify 'table' field") # Load table if needed and verify it exists tables, table = _load_table_for_yaml_model(model_config, tables, table_name) From d62a7d9fd995726d37a3c0b10dd6a7de6561e8f7 Mon Sep 17 00:00:00 2001 From: Hussain Sultan Date: Tue, 18 Aug 2026 14:51:17 -0400 Subject: [PATCH 4/6] refactor!: remove the vestigial how= parameter from semantic joins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit join_one/join_many accepted how= but raised for anything except 'left' — dead surface that only added a way to get an error. The parameter is gone from every wrapper, the op-level methods, YAML (where a non-left how now fails at load with the left-join+filter guidance), and serialization reconstruction (stored 'how' values are informational; semantic joins are always LEFT, join_cross carries how='cross' internally). Tests, examples, and docs updated. Two ground-truth near-misses caught by the suites and reverted: the sweep initially stripped how='left' from pandas merge / raw ibis join fixtures, which default to INNER — the soundness suite flagged the changed row counts immediately. Co-Authored-By: Claude Fable 5 --- docs/md/doc/semantic-table.md | 3 +- examples/malloy_interop.py | 126 ++++++++ examples/nested_queries.py | 93 ++++++ examples/worldcup.py | 296 ++++++++++++++++++ src/boring_semantic_layer/expr.py | 33 +- src/boring_semantic_layer/ops/_core.py | 6 +- .../serialization/reconstruct.py | 9 +- ...test_join_namespacing_and_inline_totals.py | 16 +- .../tests/test_join_pruning.py | 13 +- .../test_rewrites_projection_pushdown.py | 12 +- .../tests/test_xorq_string_serialization.py | 2 +- src/boring_semantic_layer/yaml.py | 8 +- 12 files changed, 560 insertions(+), 57 deletions(-) create mode 100644 examples/malloy_interop.py create mode 100644 examples/nested_queries.py create mode 100644 examples/worldcup.py diff --git a/docs/md/doc/semantic-table.md b/docs/md/doc/semantic-table.md index f9be7b25..993bc09b 100644 --- a/docs/md/doc/semantic-table.md +++ b/docs/md/doc/semantic-table.md @@ -347,7 +347,8 @@ all_combinations = flights_st.join_cross(carriers) ### Requiring a Match -`join_one()` and `join_many()` only support `how="left"`. When a query should +`join_one()` and `join_many()` are always LEFT joins (there is no `how=` +parameter). When a query should require a match, make the row removal explicit with a filter on a non-nullable field from the right table: diff --git a/examples/malloy_interop.py b/examples/malloy_interop.py new file mode 100644 index 00000000..7fc360bb --- /dev/null +++ b/examples/malloy_interop.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Malloy -> BSL -> versioned xorq expression with deferred file access. + +Run from the repository root with: + + uv run python examples/malloy_interop.py + +No pre-existing data, external database, or Git repository is required. +""" + +import tempfile +from pathlib import Path + +import pandas as pd +from xorq.catalog.catalog import Catalog, CatalogAlias + +from boring_semantic_layer import from_tagged, to_tagged +from boring_semantic_layer.malloy import ( + from_malloy, + to_malloy, + xorq_deferred_source_resolver, +) + +MALLOY_MODEL = """ +source: flights is duckdb.table('flights.csv') extend { + dimension: is_long is distance >= 1000 + measure: flight_count is count() + measure: total_distance is sum(distance) +} + +run: flights -> { + group_by: carrier + aggregate: flight_count, total_distance + order_by: total_distance desc +} +""" + + +def main() -> None: + # Create sample input, then keep its Xorq read deferred. The temporary + # directory only makes the example self-contained; real code can point the + # Malloy source at an existing local path or URL. + with tempfile.TemporaryDirectory(prefix="malloy-xorq-data-") as data_tmp: + flights_path = Path(data_tmp) / "flights.csv" + pd.DataFrame( + { + "carrier": ["AA", "AA", "UA", "UA", "DL"], + "distance": [500, 1500, 800, 1200, 700], + } + ).to_csv(flights_path, index=False) + + # Malloy -> executable BSL chains backed by an Xorq Read expression. + malloy_model = MALLOY_MODEL.replace("flights.csv", str(flights_path)) + document = from_malloy( + malloy_model, + source_resolver=xorq_deferred_source_resolver, + ) + + print("Malloy query executed through BSL/Xorq:") + print(document.runs[0].execute()) + + # BSL -> canonical Malloy. This also demonstrates a complete round trip. + print("\nCanonical Malloy emitted from the BSL document:") + print(to_malloy(document, table_paths={"flights": "flights.csv"})) + + # BSL -> xorq. The tag contains the semantic definitions as structured + # metadata, while the expression itself remains executable by xorq. + query_v1 = document.runs[0] + tagged_v1 = to_tagged(query_v1) + tagged_v2 = to_tagged(query_v1.limit(2)) + print("Tagged xorq expression:", type(tagged_v1).__name__) + + # Put two versions of the expression in a temporary Git-backed catalog. + # Catalog entry names are derived from expression content. The stable + # "carrier-stats" alias is first attached to v1, then advanced to v2. + with tempfile.TemporaryDirectory(prefix="malloy-xorq-catalog-") as tmp: + catalog = Catalog.from_repo_path(Path(tmp) / "catalog", init=True) + project_path = Path(__file__).resolve().parents[1] + + v1 = catalog.add( + tagged_v1, + aliases=("carrier-stats",), + project_path=project_path, + ) + v1_commit = catalog.repo.head.commit + + v2 = catalog.add(tagged_v2, project_path=project_path) + catalog.add_alias(v2.name, "carrier-stats") + v2_commit = catalog.repo.head.commit + + print("\nGit-backed xorq catalog:") + print(f" v1 content id: {v1.name}") + print(f" v2 content id: {v2.name}") + print(f" current alias: carrier-stats -> {v2.name}") + print(" commits:") + for commit in catalog.repo.iter_commits(max_count=4): + print(f" {commit.hexsha[:8]} {commit.message.strip()}") + + changed = catalog.repo.git.diff( + v1_commit.hexsha, + v2_commit.hexsha, + "--name-only", + ) + print(" files changed between v1 and v2:") + for name in changed.splitlines(): + print(f" {name}") + + alias = CatalogAlias.from_name("carrier-stats", catalog) + revisions = alias.list_revisions() + print(" alias history:") + for entry, commit in revisions: + print(f" {commit.hexsha[:8]} -> {entry.name}") + + # Loading through the stable alias returns a xorq expression. Restoring + # its BSL tag gives us the semantic query, ready to execute again. + current = catalog.get_catalog_entry("carrier-stats", maybe_alias=True) + # Keep the loaded tagged expression alive while the restored semantic + # wrapper executes; xorq ties extracted archive data to its lifetime. + loaded_expr = current.expr + restored = from_tagged(loaded_expr) + print("\nResult loaded from the catalog alias:") + print(restored.execute()) + + +if __name__ == "__main__": + main() diff --git a/examples/nested_queries.py b/examples/nested_queries.py new file mode 100644 index 00000000..fcef2f62 --- /dev/null +++ b/examples/nested_queries.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Nested Queries - Hierarchical Results with nest=. + +Malloy Reference: https://docs.malloydata.dev/documentation/language/nesting + +Replicates the canonical Malloy nesting example, producing one result set +with two levels of nested subtables (states -> top 5 counties -> facility +types): + +```malloy +run: airports -> { + group_by: state + aggregate: airport_count + nest: top_5_counties is { + limit: 5 + group_by: county + aggregate: airport_count + nest: by_facility is { + group_by: fac_type + aggregate: airport_count + } + } +} +``` + +Each `nest:` block maps to a `nest={name: lambda t: ...}` entry whose lambda +is a full semantic pipeline evaluated at the enclosing group's grain, so +`order_by`/`limit` apply per group and nests compose recursively. Malloy +orders every level by the first aggregate descending by default; BSL spells +that out explicitly. +""" + +import ibis +from ibis import _ + +from boring_semantic_layer import to_semantic_table + +BASE_URL = "https://pub-a45a6a332b4646f2a6f44775695c64df.r2.dev" + + +def main(): + con = ibis.duckdb.connect(":memory:") + airports_tbl = con.read_parquet(f"{BASE_URL}/airports.parquet") + + airports = ( + to_semantic_table(airports_tbl, name="airports") + .with_dimensions( + state=_.state, + county=_.county, + fac_type=_.fac_type, + ) + .with_measures(airport_count=_.count()) + ) + + result = ( + airports.group_by("state") + .aggregate( + "airport_count", + nest={ + "top_5_counties": lambda t: ( + t.group_by("county") + .aggregate( + "airport_count", + nest={ + "by_facility": lambda t: ( + t.group_by("fac_type") + .aggregate("airport_count") + .order_by(lambda t: t.airport_count.desc()) + ) + }, + ) + .order_by(lambda t: t.airport_count.desc()) + .limit(5) + ) + }, + ) + .order_by(lambda t: t.airport_count.desc()) + .execute() + ) + + # Each row holds a list of county structs, each with its own nested + # by_facility list -- render the hierarchy as an indented tree. + for _idx, row in result.head(5).iterrows(): + print(f"{row['state']} airport_count={row['airport_count']}") + for county in row["top_5_counties"]: + print(f" {county['county']:<16} {county['airport_count']}") + for fac in county["by_facility"]: + print(f" {fac['fac_type']:<18} {fac['airport_count']}") + print(f"\n({len(result)} states total)") + + +if __name__ == "__main__": + main() diff --git a/examples/worldcup.py b/examples/worldcup.py new file mode 100644 index 00000000..894bd8ff --- /dev/null +++ b/examples/worldcup.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 +"""FIFA World Cup semantic model — games, teams, and goals. + +Data: Fjelstul World Cup Database, 30 tournaments (men's 1930-2022, +women's 1991-2019). + + Canonical source: https://github.com/jfjelstul/worldcup (CC-BY-SA 4.0) + Also on Kaggle: https://www.kaggle.com/datasets/joshfjelstul/world-cup-database + +The parquet URLs below are a convenience mirror of the same cut. If you +republish anything derived from this data, attribute the Fjelstul World Cup +Database, not the mirror. + +This example demonstrates: + - A match-centric semantic model with joins to tournaments and stadiums + - The team-match grain (team_appearances) for W/D/L and win-rate analysis + - A goal-grain model for scorer analysis, including percent-of-total + - A penalty-kick grain model for shootout conversion analysis +""" + +import xorq.api as xo +from xorq.api import _ + +from boring_semantic_layer import entity_dimension, to_semantic_table + +BASE_URL = "https://storage.googleapis.com/malloyyo/worldcup" + + +def canonicalize_team_name(team_name): + """Roll historical team names into the successor used for analysis.""" + return (team_name == "West Germany").ifelse("Germany", team_name) + + +# -------------------------------------------------------------------- +# Tournaments — one row per World Cup edition. The source of truth for +# year, host, winner, and men's vs women's. +# Lookup models used in fact joins intentionally contain dimensions only. +# Their standalone counterparts add measures at the entity's native grain, +# preventing counts and averages from being evaluated over repeated fact rows. +tournament_dimensions = to_semantic_table( + xo.deferred_read_parquet(f"{BASE_URL}/tournaments.parquet"), name="tournaments" +).with_dimensions( + tournament_id=entity_dimension(lambda t: t.tournament_id), + tournament_name=_.tournament_name, + year=_.year, + decade=(_.year // 10) * 10, + womens=_.tournament_name.contains("Women's"), + host_country=_.host_country, + winner=_.winner, + start_date=_.start_date, +) +tournaments = tournament_dimensions.with_measures( + tournament_count=_.count(), + avg_teams=_.count_teams.mean(), + host_win_count=_.host_won.sum(), +) + +# -------------------------------------------------------------------- +# Teams — national teams with confederation and region. +team_dimensions = to_semantic_table( + xo.deferred_read_parquet(f"{BASE_URL}/teams.parquet"), name="teams" +).with_dimensions( + team_id=entity_dimension(lambda t: t.team_id), + team_name=_.team_name, + canonical_team_name=canonicalize_team_name(_.team_name), + team_code=_.team_code, + confederation_name=_.confederation_name, + confederation_code=_.confederation_code, + region_name=_.region_name, +) +teams = team_dimensions.with_measures( + team_count=_.count(), +) + +# -------------------------------------------------------------------- +# Stadiums — venues. +stadium_dimensions = to_semantic_table( + xo.deferred_read_parquet(f"{BASE_URL}/stadiums.parquet"), name="stadiums" +).with_dimensions( + stadium_id=entity_dimension(lambda t: t.stadium_id), + stadium_name=_.stadium_name, + city_name=_.city_name, + country_name=_.country_name, + stadium_capacity=_.stadium_capacity, +) +stadiums = stadium_dimensions.with_measures( + stadium_count=_.count(), + avg_capacity=_.stadium_capacity.mean(), +) + +# -------------------------------------------------------------------- +# Matches — one row per game. The central hub for match-level analysis. +# home/away scores exclude penalty shootouts (score_penalties has those). +matches = ( + to_semantic_table(xo.deferred_read_parquet(f"{BASE_URL}/matches.parquet"), name="matches") + .with_dimensions( + match_id=entity_dimension(lambda t: t.match_id), + match_name=_.match_name, + match_date=_.match_date, + stage_name=_.stage_name, + group_name=_.group_name, + knockout_stage=_.knockout_stage, + home_team_name=_.home_team_name, + away_team_name=_.away_team_name, + score=_.score, + total_goals=_.home_team_score + _.away_team_score, + result=_.result, + extra_time=_.extra_time, + penalty_shootout=_.penalty_shootout, + city_name=_.city_name, + country_name=_.country_name, + ) + .with_measures( + match_count=_.count(), + goals_scored=(_.home_team_score + _.away_team_score).sum(), + avg_goals_per_match=(_.home_team_score + _.away_team_score).mean(), + draw_count=_.draw.sum(), + draw_rate=_.draw.mean(), + extra_time_count=_.extra_time.sum(), + shootout_count=_.penalty_shootout.sum(), + ) + .join_one(tournament_dimensions, on="tournament_id") + .join_one(stadium_dimensions, on="stadium_id") +) + +# -------------------------------------------------------------------- +# Team appearances — one row per team per match (the team-match grain). +# The entry point for W/D/L records and win rates, without the +# home/away column gymnastics of the matches table. +team_appearances = ( + to_semantic_table( + xo.deferred_read_parquet(f"{BASE_URL}/team_appearances.parquet"), name="team_appearances" + ) + .with_dimensions( + match_id=entity_dimension(lambda t: t.match_id), + team_id=entity_dimension(lambda t: t.team_id), + team_name=_.team_name, + canonical_team_name=canonicalize_team_name(_.team_name), + team_code=_.team_code, + opponent_name=_.opponent_name, + stage_name=_.stage_name, + match_date=_.match_date, + home_team=_.home_team, + result=_.result, + ) + .with_measures( + game_count=_.count(), + win_count=_.win.sum(), + loss_count=_.lose.sum(), + draw_count=_.draw.sum(), + win_pct=_.win.mean(), + goals_for_total=_.goals_for.sum(), + goals_against_total=_.goals_against.sum(), + goal_difference=_.goal_differential.sum(), + avg_goals_for=_.goals_for.mean(), + clean_sheet_count=(_.goals_against == 0).sum(), + ) + .join_one(team_dimensions, on="team_id") + .join_one(tournament_dimensions, on="tournament_id") +) + +# -------------------------------------------------------------------- +# Goals — one row per goal. team_name is the team the goal counts FOR +# (the opponent for own goals); player_team_name is the scorer's team. +# Single-named players (Pelé, Marta, ...) have given_name 'not applicable'. +goals = ( + to_semantic_table(xo.deferred_read_parquet(f"{BASE_URL}/goals.parquet"), name="goals") + .with_dimensions( + goal_id=entity_dimension(lambda t: t.goal_id), + player_name=(_.given_name == "not applicable").ifelse( + _.family_name, _.given_name + " " + _.family_name + ), + team_name=_.team_name, + canonical_team_name=canonicalize_team_name(_.team_name), + player_team_name=_.player_team_name, + stage_name=_.stage_name, + match_date=_.match_date, + match_period=_.match_period, + own_goal=_.own_goal, + penalty=_.penalty, + ) + .with_measures( + goal_count=_.count(), + penalty_count=_.penalty.sum(), + own_goal_count=_.own_goal.sum(), + avg_minute=_.minute_regulation.mean(), + scorer_count=_.player_id.nunique(), + # Percent-of-total: reference the declared measure by name; + # t.all(...) computes the total across the whole query result. + pct_of_goals=lambda t: t.goal_count.cast("float64") / t.all(t.goal_count) * 100, + ) + .join_one(tournament_dimensions, on="tournament_id") +) + +# -------------------------------------------------------------------- +# Penalty kicks — one row per kick attempted in a penalty shootout. +# These kicks are separate from penalties taken during normal/extra time +# and are intentionally not included in the goals table. +penalty_kicks = ( + to_semantic_table( + xo.deferred_read_parquet(f"{BASE_URL}/penalty_kicks.parquet"), + name="penalty_kicks", + ) + .with_dimensions( + penalty_kick_id=entity_dimension(lambda t: t.penalty_kick_id), + match_id=_.match_id, + match_name=_.match_name, + match_date=_.match_date, + stage_name=_.stage_name, + group_name=_.group_name, + team_id=_.team_id, + team_name=_.team_name, + canonical_team_name=canonicalize_team_name(_.team_name), + player_id=_.player_id, + player_name=(_.given_name == "not applicable").ifelse( + _.family_name, _.given_name + " " + _.family_name + ), + home_team=_.home_team, + converted=_.converted, + ) + .with_measures( + attempt_count=_.count(), + conversion_count=_.converted.sum(), + miss_count=(_.converted == 0).sum(), + shooter_count=_.player_id.nunique(), + conversion_rate=lambda t: t.conversion_count.cast("float64") / t.attempt_count * 100, + ) + .join_one(team_dimensions, on="team_id") + .join_one(tournament_dimensions, on="tournament_id") +) + + +df1 = ( + matches.filter(lambda t: ~t.tournaments.womens) + .group_by("tournaments.decade") + .aggregate("matches.match_count", "matches.avg_goals_per_match") + .order_by("tournaments.decade") +).to_tagged() + +df2 = ( + team_appearances.group_by("team_appearances.canonical_team_name") + .aggregate( + "team_appearances.game_count", + "team_appearances.win_count", + "team_appearances.win_pct", + "team_appearances.goals_for_total", + "team_appearances.goal_difference", + ) + .order_by(lambda t: t["team_appearances.win_count"].desc()) + .limit(10) +).to_tagged() + +df3 = ( + team_appearances.group_by("teams.confederation_name") + .aggregate("team_appearances.game_count", "team_appearances.win_pct") + .order_by(lambda t: t["team_appearances.win_pct"].desc()) +).to_tagged() + +df4 = ( + goals.filter(lambda t: t.own_goal == 0) + .group_by("goals.player_name") + .aggregate("goals.goal_count", "goals.penalty_count") + .order_by(lambda t: t["goals.goal_count"].desc()) + .limit(10) +).to_tagged() + +df5 = ( + goals.group_by("goals.stage_name") + .aggregate("goals.goal_count", "goals.pct_of_goals") + .order_by(lambda t: t["goals.goal_count"].desc()) +).to_tagged() + +df6 = ( + matches.filter(lambda t: ~t.tournaments.womens) + .group_by("tournaments.year") + .aggregate( + "matches.match_count", + "matches.extra_time_count", + "matches.shootout_count", + "matches.avg_goals_per_match", + ) + .order_by(lambda t: t["tournaments.year"].desc()) + .limit(8) +).to_tagged() + +df7 = ( + penalty_kicks.group_by("penalty_kicks.canonical_team_name") + .aggregate( + "penalty_kicks.attempt_count", + "penalty_kicks.conversion_count", + "penalty_kicks.conversion_rate", + ) + .filter(lambda t: t["penalty_kicks.attempt_count"] >= 10) + .order_by(lambda t: t["penalty_kicks.conversion_rate"].desc()) + .limit(10) +).to_tagged() diff --git a/src/boring_semantic_layer/expr.py b/src/boring_semantic_layer/expr.py index 394fe347..11a392e3 100644 --- a/src/boring_semantic_layer/expr.py +++ b/src/boring_semantic_layer/expr.py @@ -798,7 +798,6 @@ def _join_one_with_detected_grain( left_op, other, on: Callable[[Any, Any], ir.BooleanValue] | str | Deferred | Sequence[str | Deferred], - how: str, ) -> SemanticJoin: """Construct ``join_one`` consistently for every semantic wrapper.""" other_op = other.op() if isinstance(other, SemanticTable) else other @@ -807,7 +806,7 @@ def _join_one_with_detected_grain( left=left_op, right=other_op, on=on, - how=how, + how="left", cardinality=cardinality, ) @@ -1146,7 +1145,6 @@ def join_one( self, other: SemanticModel, on: Callable[[Any, Any], ir.BooleanValue] | str | Deferred | Sequence[str | Deferred], - how: str = "left", ) -> SemanticJoin: """Join with one-to-one relationship semantics. @@ -1160,8 +1158,6 @@ def join_one( on: Join predicate. Accepts a lambda ``(left, right) -> bool``, a column name string, a Deferred ``_.col``, or a list of strings/Deferred for compound equi-joins. - how: Join type. Only ``"left"`` is supported. - Returns: SemanticJoin: The joined semantic model @@ -1170,13 +1166,12 @@ def join_one( >>> orders.join_one(customers, on=_.customer_id) >>> orders.join_one(customers, on=lambda o, c: o.customer_id == c.customer_id) """ - return _join_one_with_detected_grain(self.op(), other, on, how) + return _join_one_with_detected_grain(self.op(), other, on) def join_many( self, other: SemanticModel, on: Callable[[Any, Any], ir.BooleanValue] | str | Deferred | Sequence[str | Deferred], - how: str = "left", ) -> SemanticJoin: """Join with one-to-many relationship semantics. @@ -1185,8 +1180,6 @@ def join_many( on: Join predicate. Accepts a lambda ``(left, right) -> bool``, a column name string, a Deferred ``_.col``, or a list of strings/Deferred for compound equi-joins. - how: Join type. Only ``"left"`` is supported. - Returns: SemanticJoin: The joined semantic model @@ -1196,7 +1189,7 @@ def join_many( >>> customer.join_many(orders, on=lambda c, o: c.customer_id == o.customer_id) """ other_op = other.op() if isinstance(other, SemanticModel) else other - return SemanticJoin(left=self.op(), right=other_op, on=on, how=how, cardinality="many") + return SemanticJoin(left=self.op(), right=other_op, on=on, how="left", cardinality="many") def join_cross(self, other: SemanticModel) -> SemanticJoin: """Cross join (Cartesian product) with another semantic model. @@ -1220,7 +1213,7 @@ def join(self, *args, **kwargs): The generic join() method has been removed. Please use: - join_one(other, lambda l, r: condition) for one-to-one relationships - - join_many(other, lambda l, r: condition, how="left") for one-to-many relationships + - join_many(other, lambda l, r: condition) for one-to-many relationships - join_cross(other) for Cartesian product Examples: @@ -1462,23 +1455,21 @@ def join_one( self, other: SemanticModel, on: Callable[[Any, Any], ir.BooleanValue] | str | Deferred | Sequence[str | Deferred], - how: str = "left", ) -> SemanticJoin: """Join with one-to-one relationship semantics.""" - return _join_one_with_detected_grain(self.op(), other, on, how) + return _join_one_with_detected_grain(self.op(), other, on) def join_many( self, other: SemanticModel, on: Callable[[Any, Any], ir.BooleanValue] | str | Deferred | Sequence[str | Deferred], - how: str = "left", ) -> SemanticJoin: """Join with one-to-many relationship semantics.""" return SemanticJoin( left=self.op(), right=other.op() if isinstance(other, SemanticModel) else other, on=on, - how=how, + how="left", cardinality="many", ) @@ -1618,23 +1609,21 @@ def join_one( self, other: SemanticModel, on: Callable[[Any, Any], ir.BooleanValue] | str | Deferred | Sequence[str | Deferred], - how: str = "left", ) -> SemanticJoin: """Join with one-to-one relationship semantics.""" - return _join_one_with_detected_grain(self.op(), other, on, how) + return _join_one_with_detected_grain(self.op(), other, on) def join_many( self, other: SemanticModel, on: Callable[[Any, Any], ir.BooleanValue] | str | Deferred | Sequence[str | Deferred], - how: str = "left", ) -> SemanticJoin: """Join with one-to-many relationship semantics.""" return SemanticJoin( left=self.op(), right=other.op() if isinstance(other, SemanticModel) else other, on=on, - how=how, + how="left", cardinality="many", ) @@ -1979,23 +1968,21 @@ def join_one( self, other: SemanticModel, on: Callable[[Any, Any], ir.BooleanValue] | str | Deferred | Sequence[str | Deferred], - how: str = "left", ) -> SemanticJoin: """Join with one-to-one relationship semantics.""" - return _join_one_with_detected_grain(self.op(), other, on, how) + return _join_one_with_detected_grain(self.op(), other, on) def join_many( self, other: SemanticModel, on: Callable[[Any, Any], ir.BooleanValue] | str | Deferred | Sequence[str | Deferred], - how: str = "left", ) -> SemanticJoin: """Join with one-to-many relationship semantics.""" return SemanticJoin( left=self.op(), right=other.op(), on=on, - how=how, + how="left", cardinality="many", ) diff --git a/src/boring_semantic_layer/ops/_core.py b/src/boring_semantic_layer/ops/_core.py index 36953e75..fbc0672c 100644 --- a/src/boring_semantic_layer/ops/_core.py +++ b/src/boring_semantic_layer/ops/_core.py @@ -3698,23 +3698,21 @@ def join_one( self, other: SemanticTable, on: Callable[[Any, Any], ir.BooleanValue], - how: str = "left", ): """Join with one-to-one relationship semantics (left outer join).""" - return _expr_module()._join_one_with_detected_grain(self, other, on, how) + return _expr_module()._join_one_with_detected_grain(self, other, on) def join_many( self, other: SemanticTable, on: Callable[[Any, Any], ir.BooleanValue], - how: str = "left", ): """Join with one-to-many relationship semantics.""" return _expr_module().SemanticJoin( left=self, right=other.op(), on=on, - how=how, + how="left", cardinality="many", ) diff --git a/src/boring_semantic_layer/serialization/reconstruct.py b/src/boring_semantic_layer/serialization/reconstruct.py index c2a71f7d..c5b0baec 100644 --- a/src/boring_semantic_layer/serialization/reconstruct.py +++ b/src/boring_semantic_layer/serialization/reconstruct.py @@ -392,9 +392,8 @@ def _reconstruct_join(metadata: dict, xorq_expr, source, context: BSLSerializati _validate_join_leaf(left_model, left_metadata, "left") _validate_join_leaf(right_model, right_metadata, "right") - # Payloads written before ``how`` was serialized must preserve left-side - # rows rather than silently treating the relationship as an inner join. - how = metadata.get("how", "left") + # ``how`` in stored payloads is informational: semantic joins are always + # LEFT joins (join_cross carries how="cross" on the op directly). # Default to "many" for payloads serialized before cardinality was # emitted — join_many is a safe superset of join_one behaviour, while # the reverse silently skips pre-aggregation. (Fixes #223.) @@ -406,7 +405,7 @@ def _reconstruct_join(metadata: dict, xorq_expr, source, context: BSLSerializati left=left_model.op() if hasattr(left_model, "op") else left_model, right=right_model.op() if hasattr(right_model, "op") else right_model, on=None, - how=how, + how="cross" if cardinality == "cross" else "left", cardinality=cardinality, ) @@ -418,7 +417,7 @@ def _reconstruct_join(metadata: dict, xorq_expr, source, context: BSLSerializati }.get(cardinality, "join_many") if join_method == "join_cross": return left_model.join_cross(right_model) - return getattr(left_model, join_method)(right_model, on=predicate, how=how) + return getattr(left_model, join_method)(right_model, on=predicate) # --------------------------------------------------------------------------- diff --git a/src/boring_semantic_layer/tests/soundness/test_join_namespacing_and_inline_totals.py b/src/boring_semantic_layer/tests/soundness/test_join_namespacing_and_inline_totals.py index f15ee342..b1f96156 100644 --- a/src/boring_semantic_layer/tests/soundness/test_join_namespacing_and_inline_totals.py +++ b/src/boring_semantic_layer/tests/soundness/test_join_namespacing_and_inline_totals.py @@ -127,7 +127,7 @@ def test_inline_count_star_on_joined_model(self, orders_customers): order_count=_.count(), pct=lambda t: t.count() / t.all(t.count()) * 100, ) - j = orders.join_one(customers, on="cust_id", how="left") + j = orders.join_one(customers, on="cust_id") df = ( j.group_by("customers.region") .aggregate("orders.order_count", "orders.pct") @@ -149,7 +149,7 @@ def test_declared_dim_when_fact_table_has_no_dims(self, orders_customers): orders_tbl, cust_tbl = orders_customers customers = to_semantic_table(cust_tbl, name="customers").with_dimensions(cust_name=_.name) orders = to_semantic_table(orders_tbl, name="orders").with_measures(order_count=_.count()) - j = orders.join_one(customers, on="cust_id", how="left") + j = orders.join_one(customers, on="cust_id") df = ( j.group_by("customers.cust_name") .aggregate("orders.order_count") @@ -180,7 +180,7 @@ def joined(self, orders_customers): def test_raw_right_column(self, joined): orders, customers = joined - j = orders.join_one(customers, on="cust_id", how="left") + j = orders.join_one(customers, on="cust_id") df = ( j.group_by("customers.cust_id") .aggregate("orders.order_count") @@ -192,7 +192,7 @@ def test_raw_right_column(self, joined): def test_raw_left_column(self, joined): orders, customers = joined - j = orders.join_one(customers, on="cust_id", how="left") + j = orders.join_one(customers, on="cust_id") df = j.group_by("orders.amount").aggregate("orders.order_count").execute() assert len(df) == 4 assert df["orders.order_count"].sum() == 4 @@ -200,7 +200,7 @@ def test_raw_left_column(self, joined): def test_raw_colliding_right_column(self, joined): """A raw right column colliding with a left column resolves right.""" orders, customers = joined - j = orders.join_one(customers, on="cust_id", how="left") + j = orders.join_one(customers, on="cust_id") df = ( j.group_by("customers.name") .aggregate("orders.order_count") @@ -212,7 +212,7 @@ def test_raw_colliding_right_column(self, joined): def test_raw_prefixed_on_join_many_preagg(self, joined): orders, customers = joined - j = orders.join_many(customers, on="cust_id", how="left") + j = orders.join_many(customers, on="cust_id") df = ( j.group_by("customers.cust_id") .aggregate("orders.order_count") @@ -241,14 +241,14 @@ def test_declared_dimension_still_wins(self, orders_customers): region=lambda t: t.region.upper() ) orders = to_semantic_table(orders_tbl, name="orders").with_measures(order_count=_.count()) - j = orders.join_one(customers, on="cust_id", how="left") + j = orders.join_one(customers, on="cust_id") df = j.group_by("customers.region").aggregate("orders.order_count").execute() assert sorted(df["customers.region"]) == ["EAST", "WEST"] def test_unknown_key_raises_semantic_error(self, joined): """Typos raise a semantic-layer error, not a physical-schema dump.""" orders, customers = joined - j = orders.join_one(customers, on="cust_id", how="left") + j = orders.join_one(customers, on="cust_id") with pytest.raises(KeyError) as excinfo: j.group_by("customers.regoin").aggregate("orders.order_count").execute() msg = str(excinfo.value) diff --git a/src/boring_semantic_layer/tests/test_join_pruning.py b/src/boring_semantic_layer/tests/test_join_pruning.py index 2c36c0a4..c6a05ff4 100644 --- a/src/boring_semantic_layer/tests/test_join_pruning.py +++ b/src/boring_semantic_layer/tests/test_join_pruning.py @@ -101,20 +101,19 @@ def _build_star_model(star_schema): return ( facts.join_one(dates, on=lambda f, d: f.date_id == d.date_id) - .join_one(stores, on=lambda f, s: f.store_id == s.store_id, how="left") - .join_one(items, on=lambda f, i: f.item_id == i.item_id, how="left") + .join_one(stores, on=lambda f, s: f.store_id == s.store_id) + .join_one(items, on=lambda f, i: f.item_id == i.item_id) ) @pytest.mark.parametrize("join_method", ["join_one", "join_many"]) -@pytest.mark.parametrize("how", ["inner", "right", "outer", "cross"]) -def test_semantic_joins_reject_non_left_join_types(star_schema, join_method, how): - """Semantic relationships preserve left rows; cross joins use join_cross().""" +def test_semantic_joins_have_no_how_parameter(star_schema, join_method): + """Semantic joins are always LEFT joins: the parameter no longer exists.""" facts = to_semantic_table(star_schema["facts"], name="reject_facts") dates = to_semantic_table(star_schema["dates"], name="reject_dates") - with pytest.raises(ValueError, match="only support how='left'"): - getattr(facts, join_method)(dates, on="date_id", how=how) + with pytest.raises(TypeError, match="how"): + getattr(facts, join_method)(dates, on="date_id", how="inner") def test_join_cross_remains_supported(star_schema): diff --git a/src/boring_semantic_layer/tests/test_rewrites_projection_pushdown.py b/src/boring_semantic_layer/tests/test_rewrites_projection_pushdown.py index 6fda2ac0..62b553ec 100644 --- a/src/boring_semantic_layer/tests/test_rewrites_projection_pushdown.py +++ b/src/boring_semantic_layer/tests/test_rewrites_projection_pushdown.py @@ -128,7 +128,7 @@ def test_projection_pushdown_after_join(wide_tables): ) # Join flights with aircraft - joined = flights.join_many(aircraft, lambda f, a: f.tail_num == a.tail_num, how="left") + joined = flights.join_many(aircraft, lambda f, a: f.tail_num == a.tail_num) # Query: group by origin, count flights # Should ONLY need: @@ -181,7 +181,7 @@ def test_projection_pushdown_with_dimension_from_right_table(wide_tables): ) # Join flights with aircraft - joined = flights.join_many(aircraft, lambda f, a: f.tail_num == a.tail_num, how="left") + joined = flights.join_many(aircraft, lambda f, a: f.tail_num == a.tail_num) # Query: group by manufacturer (from aircraft table), count flights # Should need: @@ -255,7 +255,7 @@ def test_projection_pushdown_counts_columns(wide_tables): ) # Join and query - joined = flights.join_many(aircraft, lambda f, a: f.tail_num == a.tail_num, how="left") + joined = flights.join_many(aircraft, lambda f, a: f.tail_num == a.tail_num) query = joined.group_by("flights.origin").aggregate("flight_count") sql = str(ibis.to_sql(to_untagged(query))) @@ -305,7 +305,7 @@ def test_projection_pushdown_multiple_dimensions(wide_tables): ) # Join tables - joined = flights.join_many(aircraft, lambda f, a: f.tail_num == a.tail_num, how="left") + joined = flights.join_many(aircraft, lambda f, a: f.tail_num == a.tail_num) # Query using dimensions from both tables query = joined.group_by("flights.origin", "aircraft.manufacturer").aggregate("flight_count") @@ -418,8 +418,8 @@ def test_projection_pushdown_three_way_join_all_notations(duckdb_con): # Three-way join using raw column access in predicates joined = orders.join_many( - customers, lambda o, c: o.customer_id == c.customer_id, how="left" - ).join_many(items, lambda oc, i: oc.order_id == i.order_id, how="left") + customers, lambda o, c: o.customer_id == c.customer_id + ).join_many(items, lambda oc, i: oc.order_id == i.order_id) # Test 1: Use bracket notation with prefixes in calculated measures result1 = ( diff --git a/src/boring_semantic_layer/tests/test_xorq_string_serialization.py b/src/boring_semantic_layer/tests/test_xorq_string_serialization.py index ffcd2a48..71c5b27b 100644 --- a/src/boring_semantic_layer/tests/test_xorq_string_serialization.py +++ b/src/boring_semantic_layer/tests/test_xorq_string_serialization.py @@ -1694,7 +1694,7 @@ def test_tagged_roundtrip_join_one_left_join(): name=Dimension(expr=lambda t: t.name, description="Name"), ) - joined = orders_st.join_one(products_st, on=lambda o, p: o.product_id == p.pid, how="left") + joined = orders_st.join_one(products_st, on=lambda o, p: o.product_id == p.pid) tagged = to_tagged(joined) reconstructed = from_tagged(tagged) diff --git a/src/boring_semantic_layer/yaml.py b/src/boring_semantic_layer/yaml.py index 60021412..c09f1fa5 100644 --- a/src/boring_semantic_layer/yaml.py +++ b/src/boring_semantic_layer/yaml.py @@ -220,6 +220,12 @@ def _parse_joins( # Apply the join based on type join_type = join_config.get("type", "one") # Default to one-to-one how = join_config.get("how") or "left" + if how != "left": + raise DefinitionError( + f"Join {alias!r}: how={how!r} is not supported. Semantic joins " + "are always LEFT joins for soundness; filter afterwards for " + "inner-join semantics (e.g. filter: _.key.notnull())." + ) if join_type == "cross": # Cross join - no keys needed @@ -240,7 +246,6 @@ def make_join_condition(left_col, right_col): result_model = result_model.join_one( join_model, on=on_condition, - how=how, ) elif join_type == "many": left_on = join_config.get("left_on") @@ -258,7 +263,6 @@ def make_join_condition(left_col, right_col): result_model = result_model.join_many( join_model, on=on_condition, - how=how, ) else: raise ValueError(f"Invalid join type '{join_type}'. Must be 'one', 'many', or 'cross'") From f5cdede2ac5fa0cd6e1df5ca3a22432b4f857e79 Mon Sep 17 00:00:00 2001 From: Hussain Sultan Date: Tue, 18 Aug 2026 14:56:25 -0400 Subject: [PATCH 5/6] refactor!: slim the top-level namespace; .name works on every expression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - to_tagged/from_tagged move to boring_semantic_layer.serialization (xorq-interop jargon out of the front door); to_untagged stays — it's the documented plain-ibis escape hatch and examples use it - the five graph_* helpers demote to boring_semantic_layer.graph_utils - .name/.description defined once on the SemanticTable base: aggregates and limits now answer None instead of raising AttributeError blaming ibis's Table Examples and docs updated to the new import paths. Co-Authored-By: Claude Fable 5 --- docs/md/doc/semantic-table.md | 2 +- examples/malloy_interop.py | 2 +- src/boring_semantic_layer/__init__.py | 16 ---------------- src/boring_semantic_layer/expr.py | 15 +++++++++++++++ .../tests/test_rewrites_projection_pushdown.py | 6 +++--- 5 files changed, 20 insertions(+), 21 deletions(-) diff --git a/docs/md/doc/semantic-table.md b/docs/md/doc/semantic-table.md index 993bc09b..cfeb1480 100644 --- a/docs/md/doc/semantic-table.md +++ b/docs/md/doc/semantic-table.md @@ -170,7 +170,7 @@ flights_with_deps.get_graph()['avg_distance_per_flight']['deps'] Use `graph_predecessors()` and `graph_successors()` to navigate dependencies: ```graph_traversal -from boring_semantic_layer import graph_predecessors, graph_successors +from boring_semantic_layer.graph_utils import graph_predecessors, graph_successors graph = flights_with_deps.get_graph() diff --git a/examples/malloy_interop.py b/examples/malloy_interop.py index 7fc360bb..993a32d3 100644 --- a/examples/malloy_interop.py +++ b/examples/malloy_interop.py @@ -14,7 +14,7 @@ import pandas as pd from xorq.catalog.catalog import Catalog, CatalogAlias -from boring_semantic_layer import from_tagged, to_tagged +from boring_semantic_layer.serialization import from_tagged, to_tagged from boring_semantic_layer.malloy import ( from_malloy, to_malloy, diff --git a/src/boring_semantic_layer/__init__.py b/src/boring_semantic_layer/__init__.py index 265784e3..d412138a 100644 --- a/src/boring_semantic_layer/__init__.py +++ b/src/boring_semantic_layer/__init__.py @@ -26,16 +26,8 @@ from .expr import ( SemanticModel, SemanticTable, - to_tagged, to_untagged, ) -from .graph_utils import ( - graph_bfs, - graph_invert, - graph_predecessors, - graph_successors, - graph_to_dict, -) from .ops import ( Dimension, Measure, @@ -44,7 +36,6 @@ ProfileError, get_connection, ) -from .serialization import from_tagged from .yaml import ( from_config, from_yaml, @@ -59,9 +50,7 @@ "SerializationError", "UnknownFieldError", "to_semantic_table", - "to_tagged", "to_untagged", - "from_tagged", "entity_dimension", "time_dimension", "SemanticModel", @@ -73,11 +62,6 @@ "MCPSemanticModel", "LangGraphBackend", "options", - "graph_bfs", - "graph_invert", - "graph_predecessors", - "graph_successors", - "graph_to_dict", "ProfileError", "get_connection", ] diff --git a/src/boring_semantic_layer/expr.py b/src/boring_semantic_layer/expr.py index 11a392e3..a7c88623 100644 --- a/src/boring_semantic_layer/expr.py +++ b/src/boring_semantic_layer/expr.py @@ -165,6 +165,21 @@ def to_tagged(expr, aggregate_cache_storage=None): class SemanticTable(ir.Table): + @property + def name(self) -> str | None: + """The semantic model's name, or None where no single model applies. + + Defined on the base so every semantic expression answers `.name` + (aggregates, limits, and other derived shapes return None instead + of raising an AttributeError that blames ibis's Table). + """ + return getattr(self.op(), "name", None) + + @property + def description(self) -> str | None: + """The semantic model's description, or None.""" + return getattr(self.op(), "description", None) + def get_graph(self): """Get the dependency graph for this semantic table. diff --git a/src/boring_semantic_layer/tests/test_rewrites_projection_pushdown.py b/src/boring_semantic_layer/tests/test_rewrites_projection_pushdown.py index 62b553ec..9c004db8 100644 --- a/src/boring_semantic_layer/tests/test_rewrites_projection_pushdown.py +++ b/src/boring_semantic_layer/tests/test_rewrites_projection_pushdown.py @@ -417,9 +417,9 @@ def test_projection_pushdown_three_way_join_all_notations(duckdb_con): ) # Three-way join using raw column access in predicates - joined = orders.join_many( - customers, lambda o, c: o.customer_id == c.customer_id - ).join_many(items, lambda oc, i: oc.order_id == i.order_id) + joined = orders.join_many(customers, lambda o, c: o.customer_id == c.customer_id).join_many( + items, lambda oc, i: oc.order_id == i.order_id + ) # Test 1: Use bracket notation with prefixes in calculated measures result1 = ( From cdc810824570bb877884349210e3436696232745 Mon Sep 17 00:00:00 2001 From: Hussain Sultan Date: Tue, 18 Aug 2026 20:38:38 -0400 Subject: [PATCH 6/6] fix: un-commit WIP example scripts swept in by broad git-add malloy_interop.py imports boring_semantic_layer.malloy (exists only on feat/malloy-interop) and breaks make examples + lint on this branch standalone; all three return to untracked local files. Co-Authored-By: Claude Fable 5 --- examples/malloy_interop.py | 126 ---------------- examples/nested_queries.py | 93 ------------ examples/worldcup.py | 296 ------------------------------------- 3 files changed, 515 deletions(-) delete mode 100644 examples/malloy_interop.py delete mode 100644 examples/nested_queries.py delete mode 100644 examples/worldcup.py diff --git a/examples/malloy_interop.py b/examples/malloy_interop.py deleted file mode 100644 index 993a32d3..00000000 --- a/examples/malloy_interop.py +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env python3 -"""Malloy -> BSL -> versioned xorq expression with deferred file access. - -Run from the repository root with: - - uv run python examples/malloy_interop.py - -No pre-existing data, external database, or Git repository is required. -""" - -import tempfile -from pathlib import Path - -import pandas as pd -from xorq.catalog.catalog import Catalog, CatalogAlias - -from boring_semantic_layer.serialization import from_tagged, to_tagged -from boring_semantic_layer.malloy import ( - from_malloy, - to_malloy, - xorq_deferred_source_resolver, -) - -MALLOY_MODEL = """ -source: flights is duckdb.table('flights.csv') extend { - dimension: is_long is distance >= 1000 - measure: flight_count is count() - measure: total_distance is sum(distance) -} - -run: flights -> { - group_by: carrier - aggregate: flight_count, total_distance - order_by: total_distance desc -} -""" - - -def main() -> None: - # Create sample input, then keep its Xorq read deferred. The temporary - # directory only makes the example self-contained; real code can point the - # Malloy source at an existing local path or URL. - with tempfile.TemporaryDirectory(prefix="malloy-xorq-data-") as data_tmp: - flights_path = Path(data_tmp) / "flights.csv" - pd.DataFrame( - { - "carrier": ["AA", "AA", "UA", "UA", "DL"], - "distance": [500, 1500, 800, 1200, 700], - } - ).to_csv(flights_path, index=False) - - # Malloy -> executable BSL chains backed by an Xorq Read expression. - malloy_model = MALLOY_MODEL.replace("flights.csv", str(flights_path)) - document = from_malloy( - malloy_model, - source_resolver=xorq_deferred_source_resolver, - ) - - print("Malloy query executed through BSL/Xorq:") - print(document.runs[0].execute()) - - # BSL -> canonical Malloy. This also demonstrates a complete round trip. - print("\nCanonical Malloy emitted from the BSL document:") - print(to_malloy(document, table_paths={"flights": "flights.csv"})) - - # BSL -> xorq. The tag contains the semantic definitions as structured - # metadata, while the expression itself remains executable by xorq. - query_v1 = document.runs[0] - tagged_v1 = to_tagged(query_v1) - tagged_v2 = to_tagged(query_v1.limit(2)) - print("Tagged xorq expression:", type(tagged_v1).__name__) - - # Put two versions of the expression in a temporary Git-backed catalog. - # Catalog entry names are derived from expression content. The stable - # "carrier-stats" alias is first attached to v1, then advanced to v2. - with tempfile.TemporaryDirectory(prefix="malloy-xorq-catalog-") as tmp: - catalog = Catalog.from_repo_path(Path(tmp) / "catalog", init=True) - project_path = Path(__file__).resolve().parents[1] - - v1 = catalog.add( - tagged_v1, - aliases=("carrier-stats",), - project_path=project_path, - ) - v1_commit = catalog.repo.head.commit - - v2 = catalog.add(tagged_v2, project_path=project_path) - catalog.add_alias(v2.name, "carrier-stats") - v2_commit = catalog.repo.head.commit - - print("\nGit-backed xorq catalog:") - print(f" v1 content id: {v1.name}") - print(f" v2 content id: {v2.name}") - print(f" current alias: carrier-stats -> {v2.name}") - print(" commits:") - for commit in catalog.repo.iter_commits(max_count=4): - print(f" {commit.hexsha[:8]} {commit.message.strip()}") - - changed = catalog.repo.git.diff( - v1_commit.hexsha, - v2_commit.hexsha, - "--name-only", - ) - print(" files changed between v1 and v2:") - for name in changed.splitlines(): - print(f" {name}") - - alias = CatalogAlias.from_name("carrier-stats", catalog) - revisions = alias.list_revisions() - print(" alias history:") - for entry, commit in revisions: - print(f" {commit.hexsha[:8]} -> {entry.name}") - - # Loading through the stable alias returns a xorq expression. Restoring - # its BSL tag gives us the semantic query, ready to execute again. - current = catalog.get_catalog_entry("carrier-stats", maybe_alias=True) - # Keep the loaded tagged expression alive while the restored semantic - # wrapper executes; xorq ties extracted archive data to its lifetime. - loaded_expr = current.expr - restored = from_tagged(loaded_expr) - print("\nResult loaded from the catalog alias:") - print(restored.execute()) - - -if __name__ == "__main__": - main() diff --git a/examples/nested_queries.py b/examples/nested_queries.py deleted file mode 100644 index fcef2f62..00000000 --- a/examples/nested_queries.py +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env python3 -"""Nested Queries - Hierarchical Results with nest=. - -Malloy Reference: https://docs.malloydata.dev/documentation/language/nesting - -Replicates the canonical Malloy nesting example, producing one result set -with two levels of nested subtables (states -> top 5 counties -> facility -types): - -```malloy -run: airports -> { - group_by: state - aggregate: airport_count - nest: top_5_counties is { - limit: 5 - group_by: county - aggregate: airport_count - nest: by_facility is { - group_by: fac_type - aggregate: airport_count - } - } -} -``` - -Each `nest:` block maps to a `nest={name: lambda t: ...}` entry whose lambda -is a full semantic pipeline evaluated at the enclosing group's grain, so -`order_by`/`limit` apply per group and nests compose recursively. Malloy -orders every level by the first aggregate descending by default; BSL spells -that out explicitly. -""" - -import ibis -from ibis import _ - -from boring_semantic_layer import to_semantic_table - -BASE_URL = "https://pub-a45a6a332b4646f2a6f44775695c64df.r2.dev" - - -def main(): - con = ibis.duckdb.connect(":memory:") - airports_tbl = con.read_parquet(f"{BASE_URL}/airports.parquet") - - airports = ( - to_semantic_table(airports_tbl, name="airports") - .with_dimensions( - state=_.state, - county=_.county, - fac_type=_.fac_type, - ) - .with_measures(airport_count=_.count()) - ) - - result = ( - airports.group_by("state") - .aggregate( - "airport_count", - nest={ - "top_5_counties": lambda t: ( - t.group_by("county") - .aggregate( - "airport_count", - nest={ - "by_facility": lambda t: ( - t.group_by("fac_type") - .aggregate("airport_count") - .order_by(lambda t: t.airport_count.desc()) - ) - }, - ) - .order_by(lambda t: t.airport_count.desc()) - .limit(5) - ) - }, - ) - .order_by(lambda t: t.airport_count.desc()) - .execute() - ) - - # Each row holds a list of county structs, each with its own nested - # by_facility list -- render the hierarchy as an indented tree. - for _idx, row in result.head(5).iterrows(): - print(f"{row['state']} airport_count={row['airport_count']}") - for county in row["top_5_counties"]: - print(f" {county['county']:<16} {county['airport_count']}") - for fac in county["by_facility"]: - print(f" {fac['fac_type']:<18} {fac['airport_count']}") - print(f"\n({len(result)} states total)") - - -if __name__ == "__main__": - main() diff --git a/examples/worldcup.py b/examples/worldcup.py deleted file mode 100644 index 894bd8ff..00000000 --- a/examples/worldcup.py +++ /dev/null @@ -1,296 +0,0 @@ -#!/usr/bin/env python3 -"""FIFA World Cup semantic model — games, teams, and goals. - -Data: Fjelstul World Cup Database, 30 tournaments (men's 1930-2022, -women's 1991-2019). - - Canonical source: https://github.com/jfjelstul/worldcup (CC-BY-SA 4.0) - Also on Kaggle: https://www.kaggle.com/datasets/joshfjelstul/world-cup-database - -The parquet URLs below are a convenience mirror of the same cut. If you -republish anything derived from this data, attribute the Fjelstul World Cup -Database, not the mirror. - -This example demonstrates: - - A match-centric semantic model with joins to tournaments and stadiums - - The team-match grain (team_appearances) for W/D/L and win-rate analysis - - A goal-grain model for scorer analysis, including percent-of-total - - A penalty-kick grain model for shootout conversion analysis -""" - -import xorq.api as xo -from xorq.api import _ - -from boring_semantic_layer import entity_dimension, to_semantic_table - -BASE_URL = "https://storage.googleapis.com/malloyyo/worldcup" - - -def canonicalize_team_name(team_name): - """Roll historical team names into the successor used for analysis.""" - return (team_name == "West Germany").ifelse("Germany", team_name) - - -# -------------------------------------------------------------------- -# Tournaments — one row per World Cup edition. The source of truth for -# year, host, winner, and men's vs women's. -# Lookup models used in fact joins intentionally contain dimensions only. -# Their standalone counterparts add measures at the entity's native grain, -# preventing counts and averages from being evaluated over repeated fact rows. -tournament_dimensions = to_semantic_table( - xo.deferred_read_parquet(f"{BASE_URL}/tournaments.parquet"), name="tournaments" -).with_dimensions( - tournament_id=entity_dimension(lambda t: t.tournament_id), - tournament_name=_.tournament_name, - year=_.year, - decade=(_.year // 10) * 10, - womens=_.tournament_name.contains("Women's"), - host_country=_.host_country, - winner=_.winner, - start_date=_.start_date, -) -tournaments = tournament_dimensions.with_measures( - tournament_count=_.count(), - avg_teams=_.count_teams.mean(), - host_win_count=_.host_won.sum(), -) - -# -------------------------------------------------------------------- -# Teams — national teams with confederation and region. -team_dimensions = to_semantic_table( - xo.deferred_read_parquet(f"{BASE_URL}/teams.parquet"), name="teams" -).with_dimensions( - team_id=entity_dimension(lambda t: t.team_id), - team_name=_.team_name, - canonical_team_name=canonicalize_team_name(_.team_name), - team_code=_.team_code, - confederation_name=_.confederation_name, - confederation_code=_.confederation_code, - region_name=_.region_name, -) -teams = team_dimensions.with_measures( - team_count=_.count(), -) - -# -------------------------------------------------------------------- -# Stadiums — venues. -stadium_dimensions = to_semantic_table( - xo.deferred_read_parquet(f"{BASE_URL}/stadiums.parquet"), name="stadiums" -).with_dimensions( - stadium_id=entity_dimension(lambda t: t.stadium_id), - stadium_name=_.stadium_name, - city_name=_.city_name, - country_name=_.country_name, - stadium_capacity=_.stadium_capacity, -) -stadiums = stadium_dimensions.with_measures( - stadium_count=_.count(), - avg_capacity=_.stadium_capacity.mean(), -) - -# -------------------------------------------------------------------- -# Matches — one row per game. The central hub for match-level analysis. -# home/away scores exclude penalty shootouts (score_penalties has those). -matches = ( - to_semantic_table(xo.deferred_read_parquet(f"{BASE_URL}/matches.parquet"), name="matches") - .with_dimensions( - match_id=entity_dimension(lambda t: t.match_id), - match_name=_.match_name, - match_date=_.match_date, - stage_name=_.stage_name, - group_name=_.group_name, - knockout_stage=_.knockout_stage, - home_team_name=_.home_team_name, - away_team_name=_.away_team_name, - score=_.score, - total_goals=_.home_team_score + _.away_team_score, - result=_.result, - extra_time=_.extra_time, - penalty_shootout=_.penalty_shootout, - city_name=_.city_name, - country_name=_.country_name, - ) - .with_measures( - match_count=_.count(), - goals_scored=(_.home_team_score + _.away_team_score).sum(), - avg_goals_per_match=(_.home_team_score + _.away_team_score).mean(), - draw_count=_.draw.sum(), - draw_rate=_.draw.mean(), - extra_time_count=_.extra_time.sum(), - shootout_count=_.penalty_shootout.sum(), - ) - .join_one(tournament_dimensions, on="tournament_id") - .join_one(stadium_dimensions, on="stadium_id") -) - -# -------------------------------------------------------------------- -# Team appearances — one row per team per match (the team-match grain). -# The entry point for W/D/L records and win rates, without the -# home/away column gymnastics of the matches table. -team_appearances = ( - to_semantic_table( - xo.deferred_read_parquet(f"{BASE_URL}/team_appearances.parquet"), name="team_appearances" - ) - .with_dimensions( - match_id=entity_dimension(lambda t: t.match_id), - team_id=entity_dimension(lambda t: t.team_id), - team_name=_.team_name, - canonical_team_name=canonicalize_team_name(_.team_name), - team_code=_.team_code, - opponent_name=_.opponent_name, - stage_name=_.stage_name, - match_date=_.match_date, - home_team=_.home_team, - result=_.result, - ) - .with_measures( - game_count=_.count(), - win_count=_.win.sum(), - loss_count=_.lose.sum(), - draw_count=_.draw.sum(), - win_pct=_.win.mean(), - goals_for_total=_.goals_for.sum(), - goals_against_total=_.goals_against.sum(), - goal_difference=_.goal_differential.sum(), - avg_goals_for=_.goals_for.mean(), - clean_sheet_count=(_.goals_against == 0).sum(), - ) - .join_one(team_dimensions, on="team_id") - .join_one(tournament_dimensions, on="tournament_id") -) - -# -------------------------------------------------------------------- -# Goals — one row per goal. team_name is the team the goal counts FOR -# (the opponent for own goals); player_team_name is the scorer's team. -# Single-named players (Pelé, Marta, ...) have given_name 'not applicable'. -goals = ( - to_semantic_table(xo.deferred_read_parquet(f"{BASE_URL}/goals.parquet"), name="goals") - .with_dimensions( - goal_id=entity_dimension(lambda t: t.goal_id), - player_name=(_.given_name == "not applicable").ifelse( - _.family_name, _.given_name + " " + _.family_name - ), - team_name=_.team_name, - canonical_team_name=canonicalize_team_name(_.team_name), - player_team_name=_.player_team_name, - stage_name=_.stage_name, - match_date=_.match_date, - match_period=_.match_period, - own_goal=_.own_goal, - penalty=_.penalty, - ) - .with_measures( - goal_count=_.count(), - penalty_count=_.penalty.sum(), - own_goal_count=_.own_goal.sum(), - avg_minute=_.minute_regulation.mean(), - scorer_count=_.player_id.nunique(), - # Percent-of-total: reference the declared measure by name; - # t.all(...) computes the total across the whole query result. - pct_of_goals=lambda t: t.goal_count.cast("float64") / t.all(t.goal_count) * 100, - ) - .join_one(tournament_dimensions, on="tournament_id") -) - -# -------------------------------------------------------------------- -# Penalty kicks — one row per kick attempted in a penalty shootout. -# These kicks are separate from penalties taken during normal/extra time -# and are intentionally not included in the goals table. -penalty_kicks = ( - to_semantic_table( - xo.deferred_read_parquet(f"{BASE_URL}/penalty_kicks.parquet"), - name="penalty_kicks", - ) - .with_dimensions( - penalty_kick_id=entity_dimension(lambda t: t.penalty_kick_id), - match_id=_.match_id, - match_name=_.match_name, - match_date=_.match_date, - stage_name=_.stage_name, - group_name=_.group_name, - team_id=_.team_id, - team_name=_.team_name, - canonical_team_name=canonicalize_team_name(_.team_name), - player_id=_.player_id, - player_name=(_.given_name == "not applicable").ifelse( - _.family_name, _.given_name + " " + _.family_name - ), - home_team=_.home_team, - converted=_.converted, - ) - .with_measures( - attempt_count=_.count(), - conversion_count=_.converted.sum(), - miss_count=(_.converted == 0).sum(), - shooter_count=_.player_id.nunique(), - conversion_rate=lambda t: t.conversion_count.cast("float64") / t.attempt_count * 100, - ) - .join_one(team_dimensions, on="team_id") - .join_one(tournament_dimensions, on="tournament_id") -) - - -df1 = ( - matches.filter(lambda t: ~t.tournaments.womens) - .group_by("tournaments.decade") - .aggregate("matches.match_count", "matches.avg_goals_per_match") - .order_by("tournaments.decade") -).to_tagged() - -df2 = ( - team_appearances.group_by("team_appearances.canonical_team_name") - .aggregate( - "team_appearances.game_count", - "team_appearances.win_count", - "team_appearances.win_pct", - "team_appearances.goals_for_total", - "team_appearances.goal_difference", - ) - .order_by(lambda t: t["team_appearances.win_count"].desc()) - .limit(10) -).to_tagged() - -df3 = ( - team_appearances.group_by("teams.confederation_name") - .aggregate("team_appearances.game_count", "team_appearances.win_pct") - .order_by(lambda t: t["team_appearances.win_pct"].desc()) -).to_tagged() - -df4 = ( - goals.filter(lambda t: t.own_goal == 0) - .group_by("goals.player_name") - .aggregate("goals.goal_count", "goals.penalty_count") - .order_by(lambda t: t["goals.goal_count"].desc()) - .limit(10) -).to_tagged() - -df5 = ( - goals.group_by("goals.stage_name") - .aggregate("goals.goal_count", "goals.pct_of_goals") - .order_by(lambda t: t["goals.goal_count"].desc()) -).to_tagged() - -df6 = ( - matches.filter(lambda t: ~t.tournaments.womens) - .group_by("tournaments.year") - .aggregate( - "matches.match_count", - "matches.extra_time_count", - "matches.shootout_count", - "matches.avg_goals_per_match", - ) - .order_by(lambda t: t["tournaments.year"].desc()) - .limit(8) -).to_tagged() - -df7 = ( - penalty_kicks.group_by("penalty_kicks.canonical_team_name") - .aggregate( - "penalty_kicks.attempt_count", - "penalty_kicks.conversion_count", - "penalty_kicks.conversion_rate", - ) - .filter(lambda t: t["penalty_kicks.attempt_count"] >= 10) - .order_by(lambda t: t["penalty_kicks.conversion_rate"].desc()) - .limit(10) -).to_tagged()