vgi-python 0.48.0__py3-none-any.whl → 0.48.1__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.
@@ -528,7 +528,10 @@ class InMemoryCatalog(CatalogInterface):
528
528
  ) -> None:
529
529
  """Create a new table."""
530
530
  if constraint_definitions:
531
- raise NotImplementedError("this metadata-only catalog does not enforce canonical constraints")
531
+ raise NotImplementedError(
532
+ "CREATE TABLE with NOT NULL/PRIMARY KEY/UNIQUE/CHECK/FOREIGN KEY constraints is unsupported: "
533
+ "this metadata-only catalog stores no rows, so it cannot enforce them on writes"
534
+ )
532
535
  if any(value is not None for value in (comment, tags, options, layout)):
533
536
  raise NotImplementedError("table creation metadata/options/layout profiles are not supported")
534
537
 
@@ -329,7 +329,6 @@ from vgi.catalog import (
329
329
  AttachOpaqueData,
330
330
  Catalog,
331
331
  Index,
332
- IndexConstraintType,
333
332
  Macro,
334
333
  MacroType,
335
334
  ReadOnlyCatalogInterface,
@@ -346,10 +345,7 @@ from vgi.catalog import (
346
345
  View,
347
346
  )
348
347
  from vgi.catalog.capabilities import (
349
- CONSTRAINT_RECORDS_READ,
350
- INDEX_DEFINITION_READ,
351
348
  ROW_SCALAR_EXPRESSION,
352
- CatalogCapabilities,
353
349
  )
354
350
  from vgi.catalog.catalog_interface import _validate_at_params
355
351
  from vgi.catalog.constraints import ConstraintDefinition, ConstraintInfo, ConstraintKind, ForeignKeyDefinition
@@ -357,11 +353,9 @@ from vgi.catalog.dependencies import CatalogObjectRef
357
353
  from vgi.catalog.descriptors import ColumnStatisticsInput
358
354
  from vgi.catalog.duckdb_statistics import statistics_from_duckdb
359
355
  from vgi.catalog.evaluation_context import QueryEvaluationContext
360
- from vgi.catalog.indexes import IndexDefinition, IndexKey
361
- from vgi.catalog.options import OptionState
356
+ from vgi.catalog.indexes import index_definition
362
357
  from vgi.catalog.sql import SqlExpression as CanonicalSqlExpression
363
358
  from vgi.schema_utils import schema
364
- from vgi.typed_values import TypedValueBatch
365
359
  from vgi.worker import Worker
366
360
 
367
361
  # Shared scratch dir for native-branch fixtures (read_parquet / read_csv arms).
@@ -1598,14 +1592,7 @@ _EXAMPLE_CATALOG = Catalog(
1598
1592
  indexes=[
1599
1593
  Index(
1600
1594
  name=name,
1601
- definition=IndexDefinition(
1602
- table=CatalogObjectRef(kind="table", schema_path=["data"], name="numbers"),
1603
- algorithm="fixture.metadata",
1604
- keys=[IndexKey(column_path=["value"]).serialize_to_bytes()],
1605
- options=TypedValueBatch.empty(),
1606
- constraint_type=IndexConstraintType.NONE,
1607
- ),
1608
- options=OptionState(values=TypedValueBatch.empty()),
1595
+ definition=index_definition(["data"], "numbers", [["value"]], algorithm="fixture.metadata"),
1609
1596
  comment="Metadata-only index teaching fixture; no physical index or enforcement",
1610
1597
  )
1611
1598
  for name in ("idx_numbers_value", "idx_numbers_value_unique")
@@ -1625,10 +1612,6 @@ class ExampleCatalog(ReadOnlyCatalogInterface):
1625
1612
 
1626
1613
  catalog = _EXAMPLE_CATALOG
1627
1614
 
1628
- def catalog_capabilities(self, *, attach_opaque_data: AttachOpaqueData) -> CatalogCapabilities:
1629
- """Expose validated immutable facts; this catalog has no mutation path."""
1630
- return CatalogCapabilities(read=sorted([CONSTRAINT_RECORDS_READ, ROW_SCALAR_EXPRESSION, INDEX_DEFINITION_READ]))
1631
-
1632
1615
  def table_get(
1633
1616
  self,
1634
1617
  *,
vgi/catalog/__init__.py CHANGED
@@ -39,6 +39,7 @@ from vgi.catalog.catalog_interface import (
39
39
  TableInfo,
40
40
  TransactionOpaqueData,
41
41
  ViewInfo,
42
+ catalog_capabilities_for,
42
43
  deserialize_column_statistics,
43
44
  serialize_column_statistics,
44
45
  )
@@ -60,11 +61,16 @@ from vgi.catalog.constraints import (
60
61
  ConstraintValidation,
61
62
  ForeignKeyDefinition,
62
63
  ForeignKeyMatch,
64
+ ReferencedKey,
63
65
  ReferentialAction,
66
+ constraint_record,
67
+ served_table_schema,
68
+ validate_constraint_records,
69
+ validate_foreign_key_targets,
64
70
  )
65
71
  from vgi.catalog.dependencies import CatalogObjectRef, DependencySnapshot, ObjectDependency
66
72
  from vgi.catalog.descriptors import Catalog, ForeignKeyDef, Index, Macro, Schema, Sql, Table, View
67
- from vgi.catalog.indexes import IndexDefinition, IndexKey
73
+ from vgi.catalog.indexes import IndexDefinition, IndexKey, index_definition
68
74
  from vgi.catalog.macros import MacroOverload, MacroParameter
69
75
  from vgi.catalog.metadata_changes import CatalogMetadataChanges
70
76
  from vgi.catalog.query import (
@@ -77,8 +83,16 @@ from vgi.catalog.query import (
77
83
  )
78
84
  from vgi.catalog.secret_type import SecretTypeSpec
79
85
  from vgi.catalog.setting import Setting, SettingSpec
80
- from vgi.catalog.sql import ColumnExpressions, SqlStatement, column_expressions, with_column_expressions
86
+ from vgi.catalog.sql import (
87
+ ColumnExpressions,
88
+ SqlStatement,
89
+ canonical_generated_columns,
90
+ column_expressions,
91
+ validate_generated_columns,
92
+ with_column_expressions,
93
+ )
81
94
  from vgi.catalog.storage import CatalogStorage, CatalogStorageSqlite
95
+ from vgi.column_path import RequiredFilterPaths
82
96
  from vgi.write_results import WriteOperation, WriteResultMode, write_changes_batch, write_result_schema
83
97
 
84
98
  __all__ = [
@@ -88,6 +102,12 @@ __all__ = [
88
102
  "with_column_metadata",
89
103
  "IndexDefinition",
90
104
  "IndexKey",
105
+ "index_definition",
106
+ "catalog_capabilities_for",
107
+ "served_table_schema",
108
+ "validate_foreign_key_targets",
109
+ "canonical_generated_columns",
110
+ "validate_generated_columns",
91
111
  "ConstraintChange",
92
112
  "ConstraintDefinition",
93
113
  "ConstraintEnforcement",
@@ -99,7 +119,11 @@ __all__ = [
99
119
  "ConstraintValidation",
100
120
  "ForeignKeyDefinition",
101
121
  "ForeignKeyMatch",
122
+ "ReferencedKey",
102
123
  "ReferentialAction",
124
+ "constraint_record",
125
+ "validate_constraint_records",
126
+ "RequiredFilterPaths",
103
127
  "ColumnExpressions",
104
128
  "SqlStatement",
105
129
  "column_expressions",
@@ -0,0 +1,179 @@
1
+ # Copyright 2025, 2026 Query Farm LLC - https://query.farm
2
+
3
+ """Call catalog overrides written against the pre-v3 ``CatalogInterface`` signatures.
4
+
5
+ vgi.v3 added keyword arguments to several ``CatalogInterface`` methods
6
+ (``catalog_attach(client_capabilities=)``, ``schema_create(options=)``,
7
+ ``view_create(aliases=, security=, ...)``, ...) and renamed ``column_name`` to
8
+ ``column_path``. The worker always passes them, so an override with explicit
9
+ parameters and no ``**kwargs`` would raise TypeError on every call.
10
+
11
+ [`call_catalog`][] inspects the override's signature and passes only what it
12
+ accepts. A new argument is dropped only when its value carries no meaning the
13
+ old override could have honoured (``None``, empty, a top-level path); then a
14
+ DeprecationWarning (and one log line) asks for the new signature. A meaningful
15
+ value (a barrier view, a nested column path, schema options) is never silently
16
+ dropped: the call fails with an error naming the parameter.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import inspect
22
+ import logging
23
+ import threading
24
+ import warnings
25
+ from collections.abc import Callable, Mapping
26
+ from typing import Any
27
+
28
+ _logger = logging.getLogger("vgi.catalog")
29
+
30
+
31
+ def _always(value: object) -> bool:
32
+ del value
33
+ return True
34
+
35
+
36
+ def _empty(value: object) -> bool:
37
+ return value is None or (isinstance(value, (list, tuple, dict, set)) and not value)
38
+
39
+
40
+ def _top_level(value: object) -> bool:
41
+ return isinstance(value, list) and len(value) == 1
42
+
43
+
44
+ #: method -> added keyword -> predicate: may this value be dropped for an old override?
45
+ _ADDED: dict[str, dict[str, Callable[[Any], bool]]] = {
46
+ # The worker selects profiles against the client's offer after attach, so
47
+ # an override that ignores the offer loses nothing.
48
+ "catalog_attach": {"client_capabilities": _always},
49
+ "schema_create": {"options": _empty},
50
+ "view_create": {
51
+ "aliases": _empty,
52
+ "security": lambda value: value == "regular",
53
+ "comment": _empty,
54
+ "tags": _empty,
55
+ "column_comments": _empty,
56
+ },
57
+ "table_create": {
58
+ "constraint_definitions": _empty,
59
+ "comment": _empty,
60
+ "tags": _empty,
61
+ "options": _empty,
62
+ "layout": _empty,
63
+ },
64
+ "table_column_add": {"column_path": _top_level},
65
+ "table_column_type_change": {"column_path": _top_level},
66
+ }
67
+
68
+ #: Methods whose ``column_name`` became ``column_path``.
69
+ _RENAMED_COLUMN = frozenset(
70
+ {
71
+ "table_column_comment_set",
72
+ "table_column_drop",
73
+ "table_column_rename",
74
+ "table_column_default_set",
75
+ "table_column_default_drop",
76
+ "table_not_null_drop",
77
+ "table_not_null_set",
78
+ }
79
+ )
80
+
81
+ #: Removed parameters an old override may still require; passed empty when the
82
+ #: replacement is empty.
83
+ _REMOVED_EMPTY: dict[str, dict[str, Callable[[], Any]]] = {
84
+ "table_create": {
85
+ name: list
86
+ for name in (
87
+ "not_null_constraints",
88
+ "unique_constraints",
89
+ "check_constraints",
90
+ "primary_key_constraints",
91
+ "foreign_key_constraints",
92
+ )
93
+ },
94
+ }
95
+
96
+ _signatures: dict[tuple[type, str], frozenset[str] | None] = {}
97
+ _warned: set[tuple[type, str, str]] = set()
98
+ _lock = threading.Lock()
99
+
100
+
101
+ def _accepted(catalog: object, name: str) -> frozenset[str] | None:
102
+ """Keyword names the override accepts, or ``None`` when it takes ``**kwargs``."""
103
+ key = (type(catalog), name)
104
+ with _lock:
105
+ if key in _signatures:
106
+ return _signatures[key]
107
+ try:
108
+ parameters = inspect.signature(getattr(catalog, name)).parameters.values()
109
+ except (TypeError, ValueError):
110
+ result = None
111
+ else:
112
+ result = (
113
+ None
114
+ if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in parameters)
115
+ else frozenset(p.name for p in parameters if p.kind != inspect.Parameter.VAR_POSITIONAL)
116
+ )
117
+ with _lock:
118
+ _signatures[key] = result
119
+ return result
120
+
121
+
122
+ def _deprecated(catalog: object, name: str, message: str) -> None:
123
+ owner = type(catalog)
124
+ text = f"{owner.__qualname__}.{name}(): {message}; update the override to the vgi.v3 CatalogInterface signature"
125
+ warnings.warn(text, DeprecationWarning, stacklevel=3)
126
+ key = (owner, name, message)
127
+ with _lock:
128
+ first = key not in _warned
129
+ _warned.add(key)
130
+ if first:
131
+ _logger.warning(text)
132
+
133
+
134
+ def call_catalog(catalog: object, name: str, /, **kwargs: Any) -> Any:
135
+ """Call ``catalog.<name>(**kwargs)``, tolerating a pre-v3 override signature.
136
+
137
+ Args:
138
+ catalog: The catalog interface instance.
139
+ name: The ``CatalogInterface`` method name.
140
+ **kwargs: The vgi.v3 keyword arguments.
141
+
142
+ Returns:
143
+ Whatever the catalog method returns.
144
+
145
+ Raises:
146
+ NotImplementedError: A new argument carries a value the override
147
+ cannot receive (it would otherwise be silently ignored).
148
+ """
149
+ method = getattr(catalog, name)
150
+ accepted = _accepted(catalog, name)
151
+ if accepted is None:
152
+ return method(**kwargs)
153
+ call: dict[str, Any] = {}
154
+ added: Mapping[str, Callable[[Any], bool]] = _ADDED.get(name, {})
155
+ for key, value in kwargs.items():
156
+ if key in accepted:
157
+ call[key] = value
158
+ elif key in added:
159
+ if not added[key](value):
160
+ raise NotImplementedError(
161
+ f"{type(catalog).__qualname__}.{name}() has no {key!r} parameter, so it cannot honour "
162
+ f"{key}={value!r}; update the override to the vgi.v3 CatalogInterface signature"
163
+ )
164
+ _deprecated(catalog, name, f"does not accept {key}=, which was dropped")
165
+ elif key == "column_path" and name in _RENAMED_COLUMN and "column_name" in accepted:
166
+ if not _top_level(value):
167
+ raise NotImplementedError(
168
+ f"{type(catalog).__qualname__}.{name}() takes column_name, so it cannot address nested "
169
+ f"column path {value!r}; update the override to take column_path"
170
+ )
171
+ call["column_name"] = value[0]
172
+ _deprecated(catalog, name, "takes column_name; pass column_path instead")
173
+ else:
174
+ call[key] = value
175
+ for legacy, empty in _REMOVED_EMPTY.get(name, {}).items():
176
+ if legacy in accepted and legacy not in call:
177
+ call[legacy] = empty()
178
+ _deprecated(catalog, name, f"still takes the removed {legacy} parameter")
179
+ return method(**call)
@@ -0,0 +1,243 @@
1
+ # Copyright 2025, 2026 Query Farm LLC - https://query.farm
2
+
3
+ """The DuckDB extension's accepted CHECK and generated-column SQL grammars.
4
+
5
+ The extension parses canonical SQL with DuckDB's parser and accepts only a
6
+ small deterministic subset (``ParseNumericCheck`` in
7
+ ``vgi_constraint_contract.cpp`` and ``Scalar`` in
8
+ ``vgi_column_expression.cpp``). This module mirrors both walks over the same
9
+ parser's AST, obtained from ``json_serialize_sql`` on the locally installed
10
+ engine (haybarn or duckdb).
11
+
12
+ vgi does not hard-depend on an engine. Without one these checks are skipped
13
+ (the extension still rejects at ATTACH); with one, an author's own tests see
14
+ the extension's verdict before deployment.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import threading
21
+ from typing import Any
22
+
23
+ import pyarrow as pa
24
+
25
+ _NUMERIC_TYPES = frozenset(
26
+ {
27
+ "TINYINT",
28
+ "SMALLINT",
29
+ "INTEGER",
30
+ "BIGINT",
31
+ "HUGEINT",
32
+ "UTINYINT",
33
+ "USMALLINT",
34
+ "UINTEGER",
35
+ "UBIGINT",
36
+ "UHUGEINT",
37
+ "FLOAT",
38
+ "DOUBLE",
39
+ "DECIMAL",
40
+ }
41
+ )
42
+ _PRIMITIVE = frozenset({"NULL", "BOOLEAN", "TINYINT", "SMALLINT", "INTEGER", "BIGINT", "FLOAT", "DOUBLE"})
43
+ _PRIMITIVE |= {"DECIMAL", "VARCHAR"}
44
+ _GENERATED_PRIMITIVE = frozenset({"NULL", "BOOLEAN", "TINYINT", "SMALLINT", "INTEGER", "BIGINT", "VARCHAR"})
45
+ _COMPARISONS = frozenset(
46
+ {
47
+ "COMPARE_EQUAL",
48
+ "COMPARE_NOTEQUAL",
49
+ "COMPARE_LESSTHAN",
50
+ "COMPARE_GREATERTHAN",
51
+ "COMPARE_LESSTHANOREQUALTO",
52
+ "COMPARE_GREATERTHANOREQUALTO",
53
+ }
54
+ )
55
+ _OPERATORS = frozenset({"OPERATOR_COALESCE", "OPERATOR_IS_NULL", "OPERATOR_IS_NOT_NULL", "OPERATOR_NOT"})
56
+ _FUNCTIONS = frozenset({"+", "-", "*", "%", "abs", "lower", "upper"})
57
+ _CHECK_COLUMN_TYPES = (
58
+ pa.types.is_int8,
59
+ pa.types.is_int16,
60
+ pa.types.is_int32,
61
+ pa.types.is_int64,
62
+ pa.types.is_float32,
63
+ pa.types.is_float64,
64
+ )
65
+
66
+ _lock = threading.Lock()
67
+ _connection: Any = None
68
+ _unavailable = False
69
+
70
+
71
+ def _parse(sql: str) -> dict[str, Any] | None:
72
+ """Return ``json_serialize_sql`` output, or ``None`` when no engine is installed."""
73
+ global _connection, _unavailable
74
+ with _lock:
75
+ if _unavailable:
76
+ return None
77
+ if _connection is None:
78
+ try:
79
+ from vgi._duckdb import connect
80
+
81
+ _connection = connect()
82
+ except ImportError:
83
+ _unavailable = True
84
+ return None
85
+ row = _connection.execute("SELECT json_serialize_sql(?)", [sql]).fetchone()
86
+ assert row is not None
87
+ result: dict[str, Any] = json.loads(row[0])
88
+ return result
89
+
90
+
91
+ def _single_expression(sql: str, wrapped: str, error: str) -> dict[str, Any] | None:
92
+ """Parse ``wrapped`` and return its only select-list expression (extension ``Parse``)."""
93
+ if not sql or "\0" in sql or len(sql.encode()) > 1024 * 1024:
94
+ raise ValueError("invalid SQL size or text")
95
+ parsed = _parse(wrapped)
96
+ if parsed is None:
97
+ return None
98
+ if parsed.get("error"):
99
+ raise ValueError(f"{error}: {parsed.get('error_message', 'parse error')}")
100
+ statements = parsed.get("statements", [])
101
+ if len(statements) != 1 or statements[0]["node"].get("type") != "SELECT_NODE":
102
+ raise ValueError(f"{error}: expected exactly one scalar expression")
103
+ node = statements[0]["node"]
104
+ if (
105
+ len(node["select_list"]) != 1
106
+ or (node.get("from_table") or {}).get("type") != "EMPTY"
107
+ or node.get("modifiers")
108
+ or (node.get("cte_map") or {}).get("map")
109
+ or node.get("where_clause") is not None
110
+ or node.get("group_expressions")
111
+ or node.get("group_sets")
112
+ or node.get("having") is not None
113
+ or node.get("qualify") is not None
114
+ or node.get("sample") is not None
115
+ ):
116
+ raise ValueError(f"{error}: query clauses are unsupported")
117
+ expression: dict[str, Any] = node["select_list"][0]
118
+ return expression
119
+
120
+
121
+ def _children(expression: dict[str, Any]) -> list[dict[str, Any]]:
122
+ """Mirror ``ParsedExpressionIterator::EnumerateChildren`` for the accepted classes."""
123
+ kind = expression["class"]
124
+ if kind == "CAST":
125
+ return [expression["child"]]
126
+ if kind == "COMPARISON":
127
+ return [expression["left"], expression["right"]]
128
+ if kind in {"CONJUNCTION", "OPERATOR", "FUNCTION"}:
129
+ return list(expression.get("children", []))
130
+ return []
131
+
132
+
133
+ def validate_check_sql(sql: str, schema: pa.Schema, row_id_column: int | None = None) -> None:
134
+ """Accept exactly what ``ParseNumericCheck`` accepts for this table.
135
+
136
+ A CHECK must be one comparison between unqualified columns of a signed
137
+ integer or floating-point type and non-NULL numeric constants.
138
+
139
+ Args:
140
+ sql: The CHECK expression text.
141
+ schema: The table's Arrow schema.
142
+ row_id_column: Index of the ``is_row_id`` column, if any.
143
+
144
+ Raises:
145
+ ValueError: The extension would reject this CHECK at ATTACH.
146
+ """
147
+ expression = _single_expression(sql, "SELECT " + sql, "CHECK requires exactly one scalar expression")
148
+ if expression is None:
149
+ return
150
+ if expression.get("alias") or expression["class"] != "COMPARISON":
151
+ raise ValueError("the DuckDB extension reads only Boolean numeric comparisons as CHECK constraints")
152
+ if expression["type"] not in _COMPARISONS:
153
+ raise ValueError(f"unsupported CHECK comparison {expression['type']}")
154
+ for operand in (expression["left"], expression["right"]):
155
+ if operand.get("alias"):
156
+ raise ValueError("CHECK expressions cannot have aliases")
157
+ if operand["class"] == "CONSTANT":
158
+ value = operand["value"]
159
+ if value.get("is_null") or value["type"]["id"] not in _NUMERIC_TYPES:
160
+ raise ValueError("CHECK requires numeric operands")
161
+ continue
162
+ if operand["class"] != "COLUMN_REF":
163
+ raise ValueError("CHECK supports only comparisons between numeric columns and constants")
164
+ names = operand["column_names"]
165
+ if len(names) != 1:
166
+ raise ValueError("CHECK requires unqualified column references")
167
+ matches = [index for index, field in enumerate(schema) if field.name.lower() == names[0].lower()]
168
+ if len(matches) > 1:
169
+ raise ValueError(f"ambiguous CHECK column reference {names[0]!r}")
170
+ if not matches or matches[0] == row_id_column:
171
+ raise ValueError(f"CHECK references a missing or synthetic column {names[0]!r}")
172
+ field = schema.field(matches[0])
173
+ if b"ARROW:extension:name" in (field.metadata or {}) or isinstance(field.type, pa.BaseExtensionType):
174
+ raise ValueError(f"CHECK column {field.name!r} has an extension type, which is unsupported")
175
+ if not any(predicate(field.type) for predicate in _CHECK_COLUMN_TYPES):
176
+ raise ValueError(
177
+ f"CHECK column {field.name!r} is {field.type}; CHECK requires int8/16/32/64, float32 or float64"
178
+ )
179
+
180
+
181
+ def validate_generated_sql(sql: str) -> None:
182
+ """Accept exactly what the extension's generated-column ``Scalar`` walk accepts.
183
+
184
+ Args:
185
+ sql: The generated expression text.
186
+
187
+ Raises:
188
+ ValueError: The extension would reject this generated expression.
189
+ """
190
+ expression = _single_expression(sql, "SELECT (" + sql + ")", "generated column expression")
191
+ if expression is None:
192
+ return
193
+ nodes = 0
194
+
195
+ def visit(node: dict[str, Any], depth: int) -> None:
196
+ nonlocal nodes
197
+ nodes += 1
198
+ if depth > 64 or nodes > 4096:
199
+ raise ValueError("generated expression is too complex")
200
+ if node.get("alias"):
201
+ raise ValueError("generated expression aliases are unsupported")
202
+ kind, kind_type = node["class"], node["type"]
203
+ if kind == "CONSTANT":
204
+ if node["value"]["type"]["id"] not in _GENERATED_PRIMITIVE:
205
+ raise ValueError(
206
+ f"unsupported generated literal type {node['value']['type']['id']}; "
207
+ "use Boolean, signed integer, VARCHAR or NULL literals"
208
+ )
209
+ elif kind == "COLUMN_REF":
210
+ if len(node["column_names"]) != 1:
211
+ raise ValueError("generated expressions require unqualified column references")
212
+ elif kind == "CAST":
213
+ if node.get("try_cast") or node["cast_type"]["id"] not in _GENERATED_PRIMITIVE:
214
+ raise ValueError("generated expressions support only CAST to Boolean, signed integer or VARCHAR")
215
+ elif kind == "COMPARISON":
216
+ if kind_type not in _COMPARISONS:
217
+ raise ValueError(f"unsupported comparison {kind_type}")
218
+ elif kind == "CONJUNCTION":
219
+ if kind_type not in {"CONJUNCTION_AND", "CONJUNCTION_OR"}:
220
+ raise ValueError(f"unsupported conjunction {kind_type}")
221
+ elif kind == "OPERATOR":
222
+ if kind_type not in _OPERATORS:
223
+ raise ValueError(f"unsupported operator {kind_type}")
224
+ elif kind == "FUNCTION":
225
+ name = node["function_name"]
226
+ if (
227
+ node.get("schema")
228
+ or node.get("catalog")
229
+ or node.get("distinct")
230
+ or node.get("filter") is not None
231
+ or node.get("export_state")
232
+ or (node.get("order_bys") or {}).get("orders")
233
+ or not (name.lower() in _FUNCTIONS or name == "||")
234
+ ):
235
+ raise ValueError(
236
+ f"unsupported function {name!r}; generated expressions allow + - * % || abs lower upper"
237
+ )
238
+ else:
239
+ raise ValueError("expression is outside the supported deterministic generated-column grammar")
240
+ for child in _children(node):
241
+ visit(child, depth + 1)
242
+
243
+ visit(expression, 0)
vgi/catalog/_legacy.py ADDED
@@ -0,0 +1,46 @@
1
+ # Copyright 2025, 2026 Query Farm LLC - https://query.farm
2
+
3
+ """Clear errors for constructor arguments removed by the vgi.v3 protocol.
4
+
5
+ A removed dataclass field otherwise fails with Python's generic "unexpected
6
+ keyword argument". These shims keep the generated ``__init__`` (and its
7
+ signature, via ``functools.wraps``) and only replace that error with one
8
+ naming the replacement.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import functools
14
+ from collections.abc import Callable, Mapping
15
+ from typing import Any, TypeVar
16
+
17
+ T = TypeVar("T", bound=type)
18
+
19
+
20
+ def reject_removed_arguments(removed: Mapping[str, str], *, positional: str | None = None) -> Callable[[T], T]:
21
+ """Wrap a dataclass ``__init__`` so removed arguments raise a guiding TypeError.
22
+
23
+ Args:
24
+ removed: Removed keyword name to the message naming its replacement.
25
+ positional: When set, any positional argument raises this message
26
+ (for keyword-only records that used to accept positional values).
27
+
28
+ Returns:
29
+ A class decorator.
30
+ """
31
+
32
+ def decorate(cls: T) -> T:
33
+ generated: Callable[..., None] = cls.__init__ # type: ignore[misc]
34
+
35
+ @functools.wraps(generated)
36
+ def __init__(self: Any, *args: Any, **kwargs: Any) -> None:
37
+ if args and positional is not None:
38
+ raise TypeError(f"{cls.__name__}: {positional}")
39
+ for name in kwargs.keys() & removed.keys():
40
+ raise TypeError(f"{cls.__name__}({name}=...) was removed in vgi.v3: {removed[name]}")
41
+ generated(self, *args, **kwargs)
42
+
43
+ cls.__init__ = __init__ # type: ignore[misc]
44
+ return cls
45
+
46
+ return decorate
@@ -172,6 +172,22 @@ class CatalogCapabilities(ArrowSerializableDataclass):
172
172
  selected = {category: names - removed for category, names in selected.items()}
173
173
  return CatalogCapabilities(**{category: sorted(names) for category, names in selected.items()})
174
174
 
175
+ def union(self, *others: CatalogCapabilities) -> CatalogCapabilities:
176
+ """Combine advertisements, e.g. a subclass extending its base's profiles.
177
+
178
+ Args:
179
+ *others: Advertisements to merge into this one.
180
+
181
+ Returns:
182
+ The canonical (sorted, de-duplicated) union.
183
+ """
184
+ return CatalogCapabilities(
185
+ **{
186
+ category: sorted(set(getattr(self, category)).union(*(getattr(o, category) for o in others)))
187
+ for category in self._known
188
+ }
189
+ )
190
+
175
191
  def validate_selected(self) -> None:
176
192
  """Reject malformed, unknown or incomplete attachment agreements."""
177
193
  if self.select(self) != self: