vgi-python 0.48.0__py3-none-any.whl → 0.48.2__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.
- vgi/_test_fixtures/catalog.py +4 -1
- vgi/_test_fixtures/worker.py +2 -19
- vgi/catalog/__init__.py +26 -2
- vgi/catalog/_dispatch.py +179 -0
- vgi/catalog/_expression_grammar.py +243 -0
- vgi/catalog/_legacy.py +46 -0
- vgi/catalog/capabilities.py +16 -0
- vgi/catalog/catalog_interface.py +122 -2
- vgi/catalog/constraints.py +362 -6
- vgi/catalog/descriptors.py +67 -99
- vgi/catalog/indexes.py +48 -1
- vgi/catalog/sql.py +136 -0
- vgi/client/__init__.py +7 -1
- vgi/client/catalog_mixin.py +37 -4
- vgi/client/cli_utils.py +5 -27
- vgi/typed_values.py +18 -0
- vgi/worker.py +95 -26
- {vgi_python-0.48.0.dist-info → vgi_python-0.48.2.dist-info}/METADATA +2 -1
- {vgi_python-0.48.0.dist-info → vgi_python-0.48.2.dist-info}/RECORD +22 -19
- {vgi_python-0.48.0.dist-info → vgi_python-0.48.2.dist-info}/WHEEL +0 -0
- {vgi_python-0.48.0.dist-info → vgi_python-0.48.2.dist-info}/entry_points.txt +0 -0
- {vgi_python-0.48.0.dist-info → vgi_python-0.48.2.dist-info}/licenses/LICENSE +0 -0
vgi/_test_fixtures/catalog.py
CHANGED
|
@@ -528,7 +528,10 @@ class InMemoryCatalog(CatalogInterface):
|
|
|
528
528
|
) -> None:
|
|
529
529
|
"""Create a new table."""
|
|
530
530
|
if constraint_definitions:
|
|
531
|
-
raise NotImplementedError(
|
|
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
|
|
vgi/_test_fixtures/worker.py
CHANGED
|
@@ -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
|
|
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=
|
|
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
|
|
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",
|
vgi/catalog/_dispatch.py
ADDED
|
@@ -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
|
vgi/catalog/capabilities.py
CHANGED
|
@@ -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:
|