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.
Files changed (127) hide show
  1. weaver/__init__.py +59 -0
  2. weaver/build_bundle/__init__.py +109 -0
  3. weaver/build_bundle/aliases.py +325 -0
  4. weaver/build_bundle/bundle.py +359 -0
  5. weaver/build_bundle/catalogue_actions.py +275 -0
  6. weaver/build_bundle/changes.py +186 -0
  7. weaver/build_bundle/endpoints.py +83 -0
  8. weaver/build_bundle/executors/__init__.py +69 -0
  9. weaver/build_bundle/executors/alias.py +202 -0
  10. weaver/build_bundle/executors/base.py +132 -0
  11. weaver/build_bundle/executors/folder.py +71 -0
  12. weaver/build_bundle/executors/load_file.py +205 -0
  13. weaver/build_bundle/executors/spark_case.py +26 -0
  14. weaver/build_bundle/executors/spark_schema.py +60 -0
  15. weaver/build_bundle/executors/spark_sql.py +59 -0
  16. weaver/build_bundle/executors/spark_sql_batch.py +57 -0
  17. weaver/build_bundle/executors/spark_table.py +213 -0
  18. weaver/build_bundle/executors/sql_endpoint_refresh.py +34 -0
  19. weaver/build_bundle/executors/tsql.py +81 -0
  20. weaver/build_bundle/incremental.py +288 -0
  21. weaver/build_bundle/installer.py +384 -0
  22. weaver/build_bundle/models.py +288 -0
  23. weaver/build_bundle/payloads.py +34 -0
  24. weaver/build_bundle/physical.py +625 -0
  25. weaver/build_bundle/planner.py +389 -0
  26. weaver/build_bundle/prune.py +620 -0
  27. weaver/build_bundle/report.py +108 -0
  28. weaver/build_bundle/stages.py +196 -0
  29. weaver/build_bundle/targets.py +272 -0
  30. weaver/build_bundle/workflow.py +585 -0
  31. weaver/catalogue/__init__.py +73 -0
  32. weaver/catalogue/builtin.py +238 -0
  33. weaver/catalogue/claims.py +121 -0
  34. weaver/catalogue/projection.py +437 -0
  35. weaver/catalogue/reader.py +152 -0
  36. weaver/catalogue/reconcile.py +231 -0
  37. weaver/catalogue/render.py +410 -0
  38. weaver/catalogue/state.py +660 -0
  39. weaver/catalogue/tables.py +648 -0
  40. weaver/config.py +178 -0
  41. weaver/declaration/__init__.py +171 -0
  42. weaver/declaration/columns.py +223 -0
  43. weaver/declaration/ddl.py +266 -0
  44. weaver/declaration/dependencies.py +544 -0
  45. weaver/declaration/graph.py +240 -0
  46. weaver/declaration/item_dependencies.py +292 -0
  47. weaver/declaration/load.py +191 -0
  48. weaver/declaration/metadata.py +1405 -0
  49. weaver/declaration/model.py +448 -0
  50. weaver/declaration/references.py +294 -0
  51. weaver/declaration/repository.py +959 -0
  52. weaver/declaration/schemas.py +135 -0
  53. weaver/declaration/source.py +674 -0
  54. weaver/declaration/spark_load.py +759 -0
  55. weaver/declaration/sql_shaping.py +591 -0
  56. weaver/declaration/templates/ddl/declared_create_table.sql +64 -0
  57. weaver/declaration/templates/ddl/infer_create_table.sql +97 -0
  58. weaver/declaration/templates/ddl/metadata_column_validation.sql +30 -0
  59. weaver/declaration/templates/load/column_metadata.sql +40 -0
  60. weaver/declaration/templates/load/full_replace_body.sql +21 -0
  61. weaver/declaration/templates/load/install_load_procedure.sql +27 -0
  62. weaver/declaration/templates/load/load_procedure.sql +48 -0
  63. weaver/declaration/templates/load/primary_key_body.sql +113 -0
  64. weaver/declaration/tsql_ddl.py +468 -0
  65. weaver/declaration/tsql_load.py +417 -0
  66. weaver/declaration/warehouse_type_mapping.yml +93 -0
  67. weaver/diagnostics.py +247 -0
  68. weaver/errors.py +61 -0
  69. weaver/etl.py +469 -0
  70. weaver/fabric/__init__.py +107 -0
  71. weaver/fabric/auth.py +137 -0
  72. weaver/fabric/capacity.py +143 -0
  73. weaver/fabric/client.py +147 -0
  74. weaver/fabric/environment.py +460 -0
  75. weaver/fabric/livy.py +478 -0
  76. weaver/fabric/notebooks.py +201 -0
  77. weaver/fabric/onelake.py +263 -0
  78. weaver/fabric/resolution.py +344 -0
  79. weaver/fabric/resources.py +245 -0
  80. weaver/fabric/session.py +148 -0
  81. weaver/fabric/shortcuts.py +120 -0
  82. weaver/fabric/sql.py +118 -0
  83. weaver/fabric/store.py +198 -0
  84. weaver/initialise.py +209 -0
  85. weaver/lakehouse.py +386 -0
  86. weaver/load.py +474 -0
  87. weaver/load_execution.py +483 -0
  88. weaver/load_plan.py +912 -0
  89. weaver/load_report.py +330 -0
  90. weaver/load_resolution.py +386 -0
  91. weaver/locations.py +164 -0
  92. weaver/objects.py +392 -0
  93. weaver/operations.py +757 -0
  94. weaver/physical_wipe.py +369 -0
  95. weaver/push.py +76 -0
  96. weaver/resolution.py +292 -0
  97. weaver/runtime/__init__.py +30 -0
  98. weaver/runtime/folder_load.py +402 -0
  99. weaver/runtime/load_contract.py +245 -0
  100. weaver/runtime/load_result.py +104 -0
  101. weaver/runtime/spark_load.py +152 -0
  102. weaver/runtime/table_load.py +497 -0
  103. weaver/spark/__init__.py +49 -0
  104. weaver/spark/catalogue.py +245 -0
  105. weaver/spark/destination.py +195 -0
  106. weaver/spark/session.py +84 -0
  107. weaver/spark/tokens.py +138 -0
  108. weaver/sql/__init__.py +40 -0
  109. weaver/sql/authentication.py +38 -0
  110. weaver/sql/connection.py +90 -0
  111. weaver/sql/errors.py +25 -0
  112. weaver/sql/execution.py +123 -0
  113. weaver/sql/pool.py +174 -0
  114. weaver/sql/wipe.py +156 -0
  115. weaver/store.py +209 -0
  116. weaver/targets.py +257 -0
  117. weaver/task_logging.py +215 -0
  118. weaver/unbind.py +74 -0
  119. weaver/workspaces.py +175 -0
  120. weaver_cli/__init__.py +12 -0
  121. weaver_cli/__main__.py +7 -0
  122. weaver_cli/main.py +626 -0
  123. weaverstack-0.1.1.dist-info/METADATA +113 -0
  124. weaverstack-0.1.1.dist-info/RECORD +127 -0
  125. weaverstack-0.1.1.dist-info/WHEEL +4 -0
  126. weaverstack-0.1.1.dist-info/entry_points.txt +2 -0
  127. 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
+ )