weaverstack 0.1.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.
- weaver/__init__.py +59 -0
- weaver/build_bundle/__init__.py +109 -0
- weaver/build_bundle/aliases.py +325 -0
- weaver/build_bundle/bundle.py +359 -0
- weaver/build_bundle/catalogue_actions.py +275 -0
- weaver/build_bundle/changes.py +186 -0
- weaver/build_bundle/endpoints.py +83 -0
- weaver/build_bundle/executors/__init__.py +69 -0
- weaver/build_bundle/executors/alias.py +202 -0
- weaver/build_bundle/executors/base.py +132 -0
- weaver/build_bundle/executors/folder.py +71 -0
- weaver/build_bundle/executors/load_file.py +205 -0
- weaver/build_bundle/executors/spark_case.py +26 -0
- weaver/build_bundle/executors/spark_schema.py +60 -0
- weaver/build_bundle/executors/spark_sql.py +59 -0
- weaver/build_bundle/executors/spark_sql_batch.py +57 -0
- weaver/build_bundle/executors/spark_table.py +213 -0
- weaver/build_bundle/executors/sql_endpoint_refresh.py +34 -0
- weaver/build_bundle/executors/tsql.py +81 -0
- weaver/build_bundle/incremental.py +288 -0
- weaver/build_bundle/installer.py +384 -0
- weaver/build_bundle/models.py +288 -0
- weaver/build_bundle/payloads.py +34 -0
- weaver/build_bundle/physical.py +625 -0
- weaver/build_bundle/planner.py +389 -0
- weaver/build_bundle/prune.py +620 -0
- weaver/build_bundle/report.py +108 -0
- weaver/build_bundle/stages.py +196 -0
- weaver/build_bundle/targets.py +272 -0
- weaver/build_bundle/workflow.py +585 -0
- weaver/catalogue/__init__.py +73 -0
- weaver/catalogue/builtin.py +238 -0
- weaver/catalogue/claims.py +121 -0
- weaver/catalogue/projection.py +437 -0
- weaver/catalogue/reader.py +152 -0
- weaver/catalogue/reconcile.py +231 -0
- weaver/catalogue/render.py +410 -0
- weaver/catalogue/state.py +660 -0
- weaver/catalogue/tables.py +648 -0
- weaver/config.py +178 -0
- weaver/declaration/__init__.py +171 -0
- weaver/declaration/columns.py +223 -0
- weaver/declaration/ddl.py +266 -0
- weaver/declaration/dependencies.py +544 -0
- weaver/declaration/graph.py +240 -0
- weaver/declaration/item_dependencies.py +292 -0
- weaver/declaration/load.py +191 -0
- weaver/declaration/metadata.py +1405 -0
- weaver/declaration/model.py +448 -0
- weaver/declaration/references.py +294 -0
- weaver/declaration/repository.py +959 -0
- weaver/declaration/schemas.py +135 -0
- weaver/declaration/source.py +674 -0
- weaver/declaration/spark_load.py +759 -0
- weaver/declaration/sql_shaping.py +591 -0
- weaver/declaration/templates/ddl/declared_create_table.sql +64 -0
- weaver/declaration/templates/ddl/infer_create_table.sql +97 -0
- weaver/declaration/templates/ddl/metadata_column_validation.sql +30 -0
- weaver/declaration/templates/load/column_metadata.sql +40 -0
- weaver/declaration/templates/load/full_replace_body.sql +21 -0
- weaver/declaration/templates/load/install_load_procedure.sql +27 -0
- weaver/declaration/templates/load/load_procedure.sql +48 -0
- weaver/declaration/templates/load/primary_key_body.sql +113 -0
- weaver/declaration/tsql_ddl.py +468 -0
- weaver/declaration/tsql_load.py +417 -0
- weaver/declaration/warehouse_type_mapping.yml +93 -0
- weaver/diagnostics.py +247 -0
- weaver/errors.py +61 -0
- weaver/etl.py +469 -0
- weaver/fabric/__init__.py +107 -0
- weaver/fabric/auth.py +137 -0
- weaver/fabric/capacity.py +143 -0
- weaver/fabric/client.py +147 -0
- weaver/fabric/environment.py +460 -0
- weaver/fabric/livy.py +478 -0
- weaver/fabric/notebooks.py +201 -0
- weaver/fabric/onelake.py +263 -0
- weaver/fabric/resolution.py +344 -0
- weaver/fabric/resources.py +245 -0
- weaver/fabric/session.py +148 -0
- weaver/fabric/shortcuts.py +120 -0
- weaver/fabric/sql.py +118 -0
- weaver/fabric/store.py +198 -0
- weaver/initialise.py +209 -0
- weaver/lakehouse.py +386 -0
- weaver/load.py +474 -0
- weaver/load_execution.py +483 -0
- weaver/load_plan.py +912 -0
- weaver/load_report.py +330 -0
- weaver/load_resolution.py +386 -0
- weaver/locations.py +164 -0
- weaver/objects.py +392 -0
- weaver/operations.py +757 -0
- weaver/physical_wipe.py +369 -0
- weaver/push.py +76 -0
- weaver/resolution.py +292 -0
- weaver/runtime/__init__.py +30 -0
- weaver/runtime/folder_load.py +402 -0
- weaver/runtime/load_contract.py +245 -0
- weaver/runtime/load_result.py +104 -0
- weaver/runtime/spark_load.py +152 -0
- weaver/runtime/table_load.py +497 -0
- weaver/spark/__init__.py +49 -0
- weaver/spark/catalogue.py +245 -0
- weaver/spark/destination.py +195 -0
- weaver/spark/session.py +84 -0
- weaver/spark/tokens.py +138 -0
- weaver/sql/__init__.py +40 -0
- weaver/sql/authentication.py +38 -0
- weaver/sql/connection.py +90 -0
- weaver/sql/errors.py +25 -0
- weaver/sql/execution.py +123 -0
- weaver/sql/pool.py +174 -0
- weaver/sql/wipe.py +156 -0
- weaver/store.py +209 -0
- weaver/targets.py +257 -0
- weaver/task_logging.py +215 -0
- weaver/unbind.py +74 -0
- weaver/workspaces.py +175 -0
- weaver_cli/__init__.py +12 -0
- weaver_cli/__main__.py +7 -0
- weaver_cli/main.py +626 -0
- weaverstack-0.1.1.dist-info/METADATA +113 -0
- weaverstack-0.1.1.dist-info/RECORD +127 -0
- weaverstack-0.1.1.dist-info/WHEEL +4 -0
- weaverstack-0.1.1.dist-info/entry_points.txt +2 -0
- weaverstack-0.1.1.dist-info/licenses/LICENSE +201 -0
weaver/config.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Parse one Workspace configuration file into one Workspace value."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .declaration.model import LAKEHOUSE, WAREHOUSE, WeaverItemId
|
|
9
|
+
from .errors import ConfigError
|
|
10
|
+
from .workspaces import (
|
|
11
|
+
FABRIC,
|
|
12
|
+
LOCAL,
|
|
13
|
+
WORKSPACE_TYPES,
|
|
14
|
+
ExecutionSettings,
|
|
15
|
+
FabricWorkspace,
|
|
16
|
+
LocalWorkspace,
|
|
17
|
+
TargetDeclaration,
|
|
18
|
+
Workspace,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
_KEYS = {
|
|
22
|
+
"workspace",
|
|
23
|
+
"workspace_type",
|
|
24
|
+
"environment",
|
|
25
|
+
"weaver_lakehouse",
|
|
26
|
+
"execution",
|
|
27
|
+
"lakehouses",
|
|
28
|
+
"warehouses",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_workspace(path: str | Path) -> Workspace:
|
|
33
|
+
"""Load one Workspace file; local paths resolve beside that file."""
|
|
34
|
+
|
|
35
|
+
import yaml
|
|
36
|
+
|
|
37
|
+
config_path = Path(path).expanduser().resolve()
|
|
38
|
+
if not config_path.is_file():
|
|
39
|
+
raise ConfigError(f"Workspace configuration not found: {config_path}")
|
|
40
|
+
payload = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
|
41
|
+
return parse_workspace(payload, base_dir=config_path.parent)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_workspace(payload: Any, base_dir: str | Path | None = None) -> Workspace:
|
|
45
|
+
"""Parse a one-Workspace mapping."""
|
|
46
|
+
|
|
47
|
+
if not isinstance(payload, dict):
|
|
48
|
+
raise ConfigError("Workspace configuration must be a mapping")
|
|
49
|
+
unknown = set(payload) - _KEYS
|
|
50
|
+
if unknown:
|
|
51
|
+
raise ConfigError(
|
|
52
|
+
"Workspace configuration has unknown keys: " + ", ".join(sorted(unknown))
|
|
53
|
+
)
|
|
54
|
+
if "workspace" not in payload:
|
|
55
|
+
raise ConfigError("Workspace configuration must define 'workspace'")
|
|
56
|
+
|
|
57
|
+
workspace_type = payload.get("workspace_type", FABRIC)
|
|
58
|
+
if workspace_type not in WORKSPACE_TYPES:
|
|
59
|
+
raise ConfigError(
|
|
60
|
+
"workspace_type must be one of "
|
|
61
|
+
f"{', '.join(WORKSPACE_TYPES)}, got {workspace_type!r}"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
workspace_value = payload["workspace"]
|
|
65
|
+
if workspace_type == LOCAL:
|
|
66
|
+
workspace_value = _local_path(workspace_value, base_dir)
|
|
67
|
+
|
|
68
|
+
common = {
|
|
69
|
+
"workspace": workspace_value,
|
|
70
|
+
"environment": payload.get("environment"),
|
|
71
|
+
"weaver_lakehouse": payload.get("weaver_lakehouse"),
|
|
72
|
+
"execution": _execution(payload.get("execution"), where="execution"),
|
|
73
|
+
"lakehouses": _targets(payload.get("lakehouses"), item_type=LAKEHOUSE),
|
|
74
|
+
"warehouses": _targets(payload.get("warehouses"), item_type=WAREHOUSE),
|
|
75
|
+
}
|
|
76
|
+
workspace_class = LocalWorkspace if workspace_type == LOCAL else FabricWorkspace
|
|
77
|
+
try:
|
|
78
|
+
return workspace_class(**common)
|
|
79
|
+
except TypeError as exc:
|
|
80
|
+
raise ConfigError(f"Workspace configuration is incomplete: {exc}") from exc
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def resolve_workspace(
|
|
84
|
+
*,
|
|
85
|
+
workspace: str | Path | None = None,
|
|
86
|
+
workspace_type: str | None = None,
|
|
87
|
+
environment: str | None = None,
|
|
88
|
+
weaver_lakehouse: str | None = None,
|
|
89
|
+
workspace_config: str | Path | None = None,
|
|
90
|
+
) -> Workspace:
|
|
91
|
+
"""Apply CLI-over-configuration precedence and return one Workspace."""
|
|
92
|
+
|
|
93
|
+
configured = load_workspace(workspace_config) if workspace_config else None
|
|
94
|
+
resolved_type = workspace_type or (
|
|
95
|
+
configured.workspace_type if configured is not None else FABRIC
|
|
96
|
+
)
|
|
97
|
+
if resolved_type not in WORKSPACE_TYPES:
|
|
98
|
+
raise ConfigError(
|
|
99
|
+
"workspace_type must be one of "
|
|
100
|
+
f"{', '.join(WORKSPACE_TYPES)}, got {resolved_type!r}"
|
|
101
|
+
)
|
|
102
|
+
resolved_identity = workspace if workspace is not None else (
|
|
103
|
+
configured.workspace if configured is not None else None
|
|
104
|
+
)
|
|
105
|
+
if resolved_identity is None:
|
|
106
|
+
raise ConfigError("give --workspace or --workspace-config containing workspace")
|
|
107
|
+
|
|
108
|
+
common = {
|
|
109
|
+
"workspace": resolved_identity,
|
|
110
|
+
"environment": environment
|
|
111
|
+
if environment is not None
|
|
112
|
+
else (configured.environment if configured is not None else None),
|
|
113
|
+
"weaver_lakehouse": weaver_lakehouse
|
|
114
|
+
if weaver_lakehouse is not None
|
|
115
|
+
else (configured.weaver_lakehouse if configured is not None else None),
|
|
116
|
+
"execution": configured.execution
|
|
117
|
+
if configured is not None
|
|
118
|
+
else ExecutionSettings(),
|
|
119
|
+
"lakehouses": configured.lakehouses if configured is not None else {},
|
|
120
|
+
"warehouses": configured.warehouses if configured is not None else {},
|
|
121
|
+
}
|
|
122
|
+
return (LocalWorkspace if resolved_type == LOCAL else FabricWorkspace)(**common)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _local_path(value: Any, base_dir: str | Path | None) -> Path:
|
|
126
|
+
if not isinstance(value, (str, Path)) or not str(value).strip():
|
|
127
|
+
raise ConfigError("local workspace must be a non-empty folder path")
|
|
128
|
+
path = Path(str(value).strip()).expanduser()
|
|
129
|
+
if not path.is_absolute() and base_dir is not None:
|
|
130
|
+
path = Path(base_dir) / path
|
|
131
|
+
return path
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _execution(raw: Any, *, where: str) -> ExecutionSettings:
|
|
135
|
+
if raw is None:
|
|
136
|
+
return ExecutionSettings()
|
|
137
|
+
if not isinstance(raw, dict):
|
|
138
|
+
raise ConfigError(f"{where} must be a mapping")
|
|
139
|
+
unknown = set(raw) - {"parallel_workers"}
|
|
140
|
+
if unknown:
|
|
141
|
+
raise ConfigError(f"{where} has unknown keys: " + ", ".join(sorted(unknown)))
|
|
142
|
+
return ExecutionSettings(parallel_workers=raw.get("parallel_workers"))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _targets(raw: Any, *, item_type: str) -> dict[str, TargetDeclaration]:
|
|
146
|
+
field_name = "lakehouses" if item_type == LAKEHOUSE else "warehouses"
|
|
147
|
+
if raw is None:
|
|
148
|
+
return {}
|
|
149
|
+
if not isinstance(raw, dict):
|
|
150
|
+
raise ConfigError(f"{field_name} must be a mapping")
|
|
151
|
+
declarations: dict[str, TargetDeclaration] = {}
|
|
152
|
+
for physical_name, value in raw.items():
|
|
153
|
+
where = f"{field_name}[{physical_name!r}]"
|
|
154
|
+
if isinstance(value, str):
|
|
155
|
+
item_text = value
|
|
156
|
+
execution = ExecutionSettings()
|
|
157
|
+
elif isinstance(value, dict):
|
|
158
|
+
unknown = set(value) - {"item", "execution"}
|
|
159
|
+
if unknown:
|
|
160
|
+
raise ConfigError(
|
|
161
|
+
f"{where} has unknown keys: " + ", ".join(sorted(unknown))
|
|
162
|
+
)
|
|
163
|
+
if "item" not in value:
|
|
164
|
+
raise ConfigError(f"{where} must define 'item'")
|
|
165
|
+
item_text = value["item"]
|
|
166
|
+
execution = _execution(value.get("execution"), where=f"{where}.execution")
|
|
167
|
+
else:
|
|
168
|
+
raise ConfigError(f"{where} must be an item name or mapping")
|
|
169
|
+
try:
|
|
170
|
+
item = WeaverItemId.parse(item_text)
|
|
171
|
+
except (TypeError, ValueError) as exc:
|
|
172
|
+
raise ConfigError(f"{where} has invalid logical item {item_text!r}") from exc
|
|
173
|
+
if item.item_type != item_type:
|
|
174
|
+
raise ConfigError(
|
|
175
|
+
f"{where} must name a {item_type} item, got {item}"
|
|
176
|
+
)
|
|
177
|
+
declarations[str(physical_name)] = TargetDeclaration(item, execution)
|
|
178
|
+
return declarations
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Weaver document policy — the contract an authored object must satisfy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .dependencies import (
|
|
6
|
+
PythonImport,
|
|
7
|
+
RelationReference,
|
|
8
|
+
extract_python_references,
|
|
9
|
+
extract_sql_references,
|
|
10
|
+
)
|
|
11
|
+
from .columns import (
|
|
12
|
+
metadata_column_references,
|
|
13
|
+
resolve_build_columns,
|
|
14
|
+
validate_build_columns,
|
|
15
|
+
)
|
|
16
|
+
from .graph import Edge, Graph
|
|
17
|
+
from .item_dependencies import project_bound_documents, resolve_item_dependencies
|
|
18
|
+
from .model import (
|
|
19
|
+
FILES,
|
|
20
|
+
ITEM_TYPES,
|
|
21
|
+
ItemDependency,
|
|
22
|
+
LAKEHOUSE,
|
|
23
|
+
RepositoryAlias,
|
|
24
|
+
WAREHOUSE,
|
|
25
|
+
WeaverDocumentId,
|
|
26
|
+
WeaverItem,
|
|
27
|
+
WeaverItemId,
|
|
28
|
+
WeaverRepository,
|
|
29
|
+
WeaverSchemaId,
|
|
30
|
+
)
|
|
31
|
+
from .repository import (
|
|
32
|
+
build_internal_graph,
|
|
33
|
+
effective_dependencies,
|
|
34
|
+
parse_item_repository,
|
|
35
|
+
unresolved_references,
|
|
36
|
+
)
|
|
37
|
+
from .schemas import (
|
|
38
|
+
SCHEMAS_DIRECTORY,
|
|
39
|
+
SchemaSes,
|
|
40
|
+
is_schema_file,
|
|
41
|
+
parse_schema_document,
|
|
42
|
+
read_schema_document,
|
|
43
|
+
)
|
|
44
|
+
from .source import (
|
|
45
|
+
SourceDocument,
|
|
46
|
+
SqlAnalysis,
|
|
47
|
+
analyse_sql,
|
|
48
|
+
content_hash,
|
|
49
|
+
language_for_filename,
|
|
50
|
+
object_id_for_filename,
|
|
51
|
+
read_source_document,
|
|
52
|
+
)
|
|
53
|
+
from .references import (
|
|
54
|
+
IDENTITY_COLUMN_NOTE,
|
|
55
|
+
ResolvedText,
|
|
56
|
+
column_note,
|
|
57
|
+
declared_column_notes,
|
|
58
|
+
resolve_text,
|
|
59
|
+
validate_repository_metadata,
|
|
60
|
+
)
|
|
61
|
+
from .metadata import (
|
|
62
|
+
AUDIT_COLUMNS,
|
|
63
|
+
AUDIT_DELETE,
|
|
64
|
+
AUDIT_INSERT,
|
|
65
|
+
AUDIT_UPDATE,
|
|
66
|
+
FOLDER,
|
|
67
|
+
DELTA_LANGUAGES,
|
|
68
|
+
LAKEHOUSE_NAMESPACE,
|
|
69
|
+
LANGUAGES,
|
|
70
|
+
NAMESPACES,
|
|
71
|
+
OBJECT_KINDS,
|
|
72
|
+
PYTHON,
|
|
73
|
+
SPARK_SQL,
|
|
74
|
+
SQL,
|
|
75
|
+
TABLE,
|
|
76
|
+
VIEW,
|
|
77
|
+
WAREHOUSE_NAMESPACE,
|
|
78
|
+
Column,
|
|
79
|
+
ForeignKey,
|
|
80
|
+
MetadataText,
|
|
81
|
+
ObjectId,
|
|
82
|
+
Reference,
|
|
83
|
+
Revision,
|
|
84
|
+
SesDocument,
|
|
85
|
+
WeaverDocument,
|
|
86
|
+
audit_column_name,
|
|
87
|
+
extract_python_metadata,
|
|
88
|
+
extract_sql_metadata_and_body,
|
|
89
|
+
namespace_for_target,
|
|
90
|
+
parse_document,
|
|
91
|
+
parse_python_document,
|
|
92
|
+
parse_sql_document,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
__all__ = [
|
|
96
|
+
"RelationReference",
|
|
97
|
+
"PythonImport",
|
|
98
|
+
"extract_python_references",
|
|
99
|
+
"extract_sql_references",
|
|
100
|
+
"WeaverRepository",
|
|
101
|
+
"WeaverItem",
|
|
102
|
+
"WeaverItemId",
|
|
103
|
+
"WeaverSchemaId",
|
|
104
|
+
"WeaverDocumentId",
|
|
105
|
+
"RepositoryAlias",
|
|
106
|
+
"ItemDependency",
|
|
107
|
+
"LAKEHOUSE",
|
|
108
|
+
"WAREHOUSE",
|
|
109
|
+
"ITEM_TYPES",
|
|
110
|
+
"FILES",
|
|
111
|
+
"parse_item_repository",
|
|
112
|
+
"Graph",
|
|
113
|
+
"resolve_item_dependencies",
|
|
114
|
+
"project_bound_documents",
|
|
115
|
+
"Edge",
|
|
116
|
+
"build_internal_graph",
|
|
117
|
+
"effective_dependencies",
|
|
118
|
+
"unresolved_references",
|
|
119
|
+
"SchemaSes",
|
|
120
|
+
"SCHEMAS_DIRECTORY",
|
|
121
|
+
"is_schema_file",
|
|
122
|
+
"parse_schema_document",
|
|
123
|
+
"read_schema_document",
|
|
124
|
+
"LAKEHOUSE_NAMESPACE",
|
|
125
|
+
"WAREHOUSE_NAMESPACE",
|
|
126
|
+
"NAMESPACES",
|
|
127
|
+
"namespace_for_target",
|
|
128
|
+
"SourceDocument",
|
|
129
|
+
"SqlAnalysis",
|
|
130
|
+
"analyse_sql",
|
|
131
|
+
"content_hash",
|
|
132
|
+
"language_for_filename",
|
|
133
|
+
"object_id_for_filename",
|
|
134
|
+
"read_source_document",
|
|
135
|
+
"FOLDER",
|
|
136
|
+
"TABLE",
|
|
137
|
+
"VIEW",
|
|
138
|
+
"OBJECT_KINDS",
|
|
139
|
+
"PYTHON",
|
|
140
|
+
"SQL",
|
|
141
|
+
"SPARK_SQL",
|
|
142
|
+
"LANGUAGES",
|
|
143
|
+
"DELTA_LANGUAGES",
|
|
144
|
+
"AUDIT_COLUMNS",
|
|
145
|
+
"AUDIT_INSERT",
|
|
146
|
+
"AUDIT_UPDATE",
|
|
147
|
+
"AUDIT_DELETE",
|
|
148
|
+
"audit_column_name",
|
|
149
|
+
"Column",
|
|
150
|
+
"ForeignKey",
|
|
151
|
+
"MetadataText",
|
|
152
|
+
"ObjectId",
|
|
153
|
+
"Reference",
|
|
154
|
+
"Revision",
|
|
155
|
+
"SesDocument",
|
|
156
|
+
"WeaverDocument",
|
|
157
|
+
"IDENTITY_COLUMN_NOTE",
|
|
158
|
+
"ResolvedText",
|
|
159
|
+
"column_note",
|
|
160
|
+
"declared_column_notes",
|
|
161
|
+
"resolve_text",
|
|
162
|
+
"validate_repository_metadata",
|
|
163
|
+
"extract_python_metadata",
|
|
164
|
+
"extract_sql_metadata_and_body",
|
|
165
|
+
"parse_document",
|
|
166
|
+
"parse_python_document",
|
|
167
|
+
"parse_sql_document",
|
|
168
|
+
"metadata_column_references",
|
|
169
|
+
"resolve_build_columns",
|
|
170
|
+
"validate_build_columns",
|
|
171
|
+
]
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""The one column-validation model both build engines share.
|
|
2
|
+
|
|
3
|
+
A SQL-backed table's physical business columns are known in two ways. A declared
|
|
4
|
+
schema states them up front and is validated at parse time by
|
|
5
|
+
:func:`weaver.ses.metadata._validate_columns`. An *inferred* table has no
|
|
6
|
+
declared schema, so the same guards cannot run until the query's output shape is
|
|
7
|
+
known — which, per how-does-build-work §2, is at build, inside the install action.
|
|
8
|
+
|
|
9
|
+
This module is that deferred guard, expressed once so Spark and T-SQL agree. The
|
|
10
|
+
Spark executor calls :func:`resolve_build_columns` directly with the columns its
|
|
11
|
+
``DataFrame`` reported; the generated T-SQL script mirrors the identical rules in
|
|
12
|
+
SQL because a Warehouse only knows its query's shape server-side. Both draw the
|
|
13
|
+
set of column-referencing metadata from :func:`metadata_column_references`, so
|
|
14
|
+
neither can drift from the other.
|
|
15
|
+
|
|
16
|
+
A column name is an exact, case-sensitive Weaver contract, even where the target
|
|
17
|
+
engine would fold case. The rules:
|
|
18
|
+
|
|
19
|
+
- a query may not produce two columns whose names collide **case-insensitively**
|
|
20
|
+
— ``CustomerId`` and ``customerid`` together are ambiguous, and no unambiguous
|
|
21
|
+
table could be built from them;
|
|
22
|
+
- when a schema is declared, the declared column set and the query's output set
|
|
23
|
+
must be equivalent **by exact name** — every declared column returned under the
|
|
24
|
+
same spelling, no undeclared column produced — while types, order, width,
|
|
25
|
+
precision and nullability are not compared, so a declaration may deliberately
|
|
26
|
+
be wider or more stable;
|
|
27
|
+
- every column named by ``Primary key``, ``Not null``, ``Comparison columns`` or
|
|
28
|
+
``Column notes`` must exist among the business columns **under exactly its
|
|
29
|
+
declared spelling** (the ``Identity`` column counts as present here — see below).
|
|
30
|
+
|
|
31
|
+
Case is thus exact for naming and equivalence, but case-insensitive for the
|
|
32
|
+
ambiguity guard — a name that only sometimes matches is not a name Weaver can
|
|
33
|
+
rely on.
|
|
34
|
+
|
|
35
|
+
``Identity`` is not in that list: it names a **Weaver-managed** surrogate column
|
|
36
|
+
build adds, not a business column, so it must *not* clash with the query's output
|
|
37
|
+
— but the primary key may name it when the surrogate is the key.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
from ..errors import BuildError
|
|
43
|
+
from .metadata import SesDocument
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def metadata_column_references(document: SesDocument) -> tuple[tuple[str, str], ...]:
|
|
47
|
+
"""The ``(label, column)`` pairs this document's metadata references.
|
|
48
|
+
|
|
49
|
+
Every pair must resolve to a produced business column. The set is the same
|
|
50
|
+
whether the table is declared or inferred; only *when* it is checked differs.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
references: list[tuple[str, str]] = []
|
|
54
|
+
references.extend(("Primary key", column) for column in document.primary_key)
|
|
55
|
+
references.extend(
|
|
56
|
+
("Unique keys", column)
|
|
57
|
+
for unique_key in document.unique_keys
|
|
58
|
+
for column in unique_key
|
|
59
|
+
)
|
|
60
|
+
# Only this side of a relationship: the parent's columns belong to the parent
|
|
61
|
+
# and are checked when it is built, not here.
|
|
62
|
+
references.extend(
|
|
63
|
+
("Foreign keys", column)
|
|
64
|
+
for foreign_key in document.foreign_keys
|
|
65
|
+
for column in foreign_key.columns
|
|
66
|
+
)
|
|
67
|
+
references.extend(("Not null", column) for column in document.declared_not_null)
|
|
68
|
+
references.extend(
|
|
69
|
+
("Comparison columns", column)
|
|
70
|
+
for column in document.declared_comparison_columns
|
|
71
|
+
)
|
|
72
|
+
references.extend(
|
|
73
|
+
("Column notes", column.name)
|
|
74
|
+
for column in document.schema
|
|
75
|
+
if column.note is not None
|
|
76
|
+
)
|
|
77
|
+
# For an inferred table the notes live on no declared column, so read them
|
|
78
|
+
# from the raw metadata block instead.
|
|
79
|
+
if not document.has_declared_schema:
|
|
80
|
+
notes = document.raw.get("Column notes") or {}
|
|
81
|
+
if isinstance(notes, dict):
|
|
82
|
+
references.extend(("Column notes", str(name)) for name in notes)
|
|
83
|
+
return tuple(references)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def resolve_build_columns(
|
|
87
|
+
document: SesDocument, query_columns: tuple[str, ...]
|
|
88
|
+
) -> tuple[str, ...]:
|
|
89
|
+
"""Validate a built table's columns from a live document — the tests' entry.
|
|
90
|
+
|
|
91
|
+
A convenience wrapper over :func:`validate_build_columns` that reads the
|
|
92
|
+
declared columns and metadata references off ``document``. The installer never
|
|
93
|
+
uses this — it holds no document — and calls the data-level function with the
|
|
94
|
+
values the bundle froze (how-does-build-work §2).
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
declared = (
|
|
98
|
+
tuple(column.name for column in document.schema)
|
|
99
|
+
if document.has_declared_schema
|
|
100
|
+
else None
|
|
101
|
+
)
|
|
102
|
+
return validate_build_columns(
|
|
103
|
+
document.qualified,
|
|
104
|
+
query_columns,
|
|
105
|
+
declared_columns=declared,
|
|
106
|
+
references=metadata_column_references(document),
|
|
107
|
+
identity=document.identity,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def validate_build_columns(
|
|
112
|
+
qualified: str,
|
|
113
|
+
query_columns: tuple[str, ...],
|
|
114
|
+
*,
|
|
115
|
+
declared_columns: tuple[str, ...] | None,
|
|
116
|
+
references: tuple[tuple[str, str], ...],
|
|
117
|
+
identity: str | None = None,
|
|
118
|
+
) -> tuple[str, ...]:
|
|
119
|
+
"""The rule core, over plain data so the frozen payload can drive it.
|
|
120
|
+
|
|
121
|
+
``query_columns`` are what the engine reported for the object's query.
|
|
122
|
+
``declared_columns`` are the declared business-column names, or ``None`` when
|
|
123
|
+
the table is inferred. ``references`` are the ``(label, column)`` pairs from
|
|
124
|
+
:func:`metadata_column_references`. ``identity`` names the Weaver-managed
|
|
125
|
+
surrogate column, when one is declared: it is not a business column, so it may
|
|
126
|
+
not clash with the query's output, but the primary key may name it. Returns
|
|
127
|
+
the physical business columns in order — declared names when declared
|
|
128
|
+
(authoritative), else the query's own — and raises :class:`BuildError` on any
|
|
129
|
+
violation.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
_reject_duplicate_query_columns(qualified, query_columns)
|
|
133
|
+
|
|
134
|
+
if declared_columns is not None:
|
|
135
|
+
_require_set_equivalence(qualified, declared_columns, query_columns)
|
|
136
|
+
business_columns = tuple(declared_columns)
|
|
137
|
+
else:
|
|
138
|
+
business_columns = tuple(query_columns)
|
|
139
|
+
|
|
140
|
+
_reject_identity_collision(qualified, identity, business_columns)
|
|
141
|
+
# The identity column is Weaver's own, so the primary key may name it even
|
|
142
|
+
# though it is not a business column.
|
|
143
|
+
available = business_columns + ((identity,) if identity is not None else ())
|
|
144
|
+
_require_references_exist(qualified, available, references)
|
|
145
|
+
return business_columns
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _reject_identity_collision(
|
|
149
|
+
qualified: str, identity: str | None, business_columns: tuple[str, ...]
|
|
150
|
+
) -> None:
|
|
151
|
+
if identity is None:
|
|
152
|
+
return
|
|
153
|
+
if any(identity.lower() == name.lower() for name in business_columns):
|
|
154
|
+
raise BuildError(
|
|
155
|
+
f"{qualified}: Identity {identity!r} collides with a business column — "
|
|
156
|
+
"the identity column is Weaver-managed and must not be one the query "
|
|
157
|
+
"produces or the schema declares."
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _reject_duplicate_query_columns(
|
|
162
|
+
qualified: str, query_columns: tuple[str, ...]
|
|
163
|
+
) -> None:
|
|
164
|
+
# Case-insensitive on purpose: two names that differ only by case cannot be
|
|
165
|
+
# told apart reliably downstream, so they are ambiguous, not distinct.
|
|
166
|
+
groups: dict[str, list[str]] = {}
|
|
167
|
+
for column in query_columns:
|
|
168
|
+
groups.setdefault(column.lower(), []).append(column)
|
|
169
|
+
colliding = sorted(
|
|
170
|
+
", ".join(names) for names in groups.values() if len(names) > 1
|
|
171
|
+
)
|
|
172
|
+
if colliding:
|
|
173
|
+
raise BuildError(
|
|
174
|
+
f"{qualified}: the query produces columns that collide by name "
|
|
175
|
+
"(case-insensitively): "
|
|
176
|
+
+ "; ".join(colliding)
|
|
177
|
+
+ " — no unambiguous table can be built. Give them distinct names."
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _require_set_equivalence(
|
|
182
|
+
qualified: str,
|
|
183
|
+
declared: tuple[str, ...],
|
|
184
|
+
query_columns: tuple[str, ...],
|
|
185
|
+
) -> None:
|
|
186
|
+
# Exact, case-sensitive: a declared name and a query name that differ only by
|
|
187
|
+
# case are two different columns, and the declaration must win exactly.
|
|
188
|
+
declared_set = set(declared)
|
|
189
|
+
query_set = set(query_columns)
|
|
190
|
+
|
|
191
|
+
missing = [name for name in declared if name not in query_set]
|
|
192
|
+
if missing:
|
|
193
|
+
raise BuildError(
|
|
194
|
+
f"{qualified}: declared column(s) not returned by the query under the "
|
|
195
|
+
"same case: "
|
|
196
|
+
+ ", ".join(missing)
|
|
197
|
+
+ ". A declared schema must match the query's column set exactly by "
|
|
198
|
+
"name (types aside)."
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
extra = [name for name in query_columns if name not in declared_set]
|
|
202
|
+
if extra:
|
|
203
|
+
raise BuildError(
|
|
204
|
+
f"{qualified}: the query returns column(s) not in the declared schema "
|
|
205
|
+
"(names are case-sensitive): "
|
|
206
|
+
+ ", ".join(extra)
|
|
207
|
+
+ ". Declare them under the same spelling, or drop them from the query."
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _require_references_exist(
|
|
212
|
+
qualified: str,
|
|
213
|
+
business_columns: tuple[str, ...],
|
|
214
|
+
references: tuple[tuple[str, str], ...],
|
|
215
|
+
) -> None:
|
|
216
|
+
available = set(business_columns)
|
|
217
|
+
for label, column in references:
|
|
218
|
+
if column not in available:
|
|
219
|
+
raise BuildError(
|
|
220
|
+
f"{qualified}: {label} names column {column!r}, which the built "
|
|
221
|
+
"table does not have under that exact name (names are "
|
|
222
|
+
"case-sensitive)."
|
|
223
|
+
)
|