duckguard 2.2.0__py3-none-any.whl → 3.0.0__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.
- duckguard/__init__.py +1 -1
- duckguard/anomaly/__init__.py +28 -0
- duckguard/anomaly/baselines.py +294 -0
- duckguard/anomaly/methods.py +16 -2
- duckguard/anomaly/ml_methods.py +724 -0
- duckguard/checks/__init__.py +26 -0
- duckguard/checks/conditional.py +796 -0
- duckguard/checks/distributional.py +524 -0
- duckguard/checks/multicolumn.py +726 -0
- duckguard/checks/query_based.py +643 -0
- duckguard/cli/main.py +257 -2
- duckguard/connectors/factory.py +30 -2
- duckguard/connectors/files.py +7 -3
- duckguard/core/column.py +851 -1
- duckguard/core/dataset.py +1035 -0
- duckguard/core/result.py +236 -0
- duckguard/freshness/__init__.py +33 -0
- duckguard/freshness/monitor.py +429 -0
- duckguard/history/schema.py +119 -1
- duckguard/notifications/__init__.py +20 -2
- duckguard/notifications/email.py +508 -0
- duckguard/profiler/distribution_analyzer.py +384 -0
- duckguard/profiler/outlier_detector.py +497 -0
- duckguard/profiler/pattern_matcher.py +301 -0
- duckguard/profiler/quality_scorer.py +445 -0
- duckguard/reports/html_reporter.py +1 -2
- duckguard/rules/executor.py +642 -0
- duckguard/rules/generator.py +4 -1
- duckguard/rules/schema.py +54 -0
- duckguard/schema_history/__init__.py +40 -0
- duckguard/schema_history/analyzer.py +414 -0
- duckguard/schema_history/tracker.py +288 -0
- duckguard/semantic/detector.py +17 -1
- duckguard-3.0.0.dist-info/METADATA +1072 -0
- {duckguard-2.2.0.dist-info → duckguard-3.0.0.dist-info}/RECORD +38 -21
- duckguard-2.2.0.dist-info/METADATA +0 -351
- {duckguard-2.2.0.dist-info → duckguard-3.0.0.dist-info}/WHEEL +0 -0
- {duckguard-2.2.0.dist-info → duckguard-3.0.0.dist-info}/entry_points.txt +0 -0
- {duckguard-2.2.0.dist-info → duckguard-3.0.0.dist-info}/licenses/LICENSE +0 -0
duckguard/rules/schema.py
CHANGED
|
@@ -60,6 +60,43 @@ class CheckType(Enum):
|
|
|
60
60
|
# Custom SQL
|
|
61
61
|
CUSTOM_SQL = "custom_sql"
|
|
62
62
|
|
|
63
|
+
# Cross-dataset / Reference checks
|
|
64
|
+
EXISTS_IN = "exists_in" # FK check: all values exist in reference
|
|
65
|
+
REFERENCES = "references" # FK check with options (allow_nulls, etc.)
|
|
66
|
+
MATCHES_VALUES = "matches_values" # Column values match between datasets
|
|
67
|
+
ROW_COUNT_MATCHES = "row_count_matches" # Row counts match between datasets
|
|
68
|
+
|
|
69
|
+
# Conditional checks (DuckGuard 3.0)
|
|
70
|
+
NOT_NULL_WHEN = "not_null_when" # Not null when condition is true
|
|
71
|
+
UNIQUE_WHEN = "unique_when" # Unique when condition is true
|
|
72
|
+
BETWEEN_WHEN = "between_when" # Between min/max when condition is true
|
|
73
|
+
ISIN_WHEN = "isin_when" # In allowed values when condition is true
|
|
74
|
+
PATTERN_WHEN = "pattern_when" # Matches pattern when condition is true
|
|
75
|
+
|
|
76
|
+
# Multi-column checks (DuckGuard 3.0)
|
|
77
|
+
COLUMN_PAIR_SATISFY = "column_pair_satisfy" # Column pair satisfies expression
|
|
78
|
+
MULTICOLUMN_UNIQUE = "multicolumn_unique" # Composite uniqueness
|
|
79
|
+
MULTICOLUMN_SUM = "multicolumn_sum" # Sum constraint
|
|
80
|
+
COLUMN_A_GT_B = "column_a_gt_b" # A > B
|
|
81
|
+
COLUMN_A_GTE_B = "column_a_gte_b" # A >= B
|
|
82
|
+
COLUMN_A_LT_B = "column_a_lt_b" # A < B
|
|
83
|
+
COLUMN_A_LTE_B = "column_a_lte_b" # A <= B
|
|
84
|
+
COLUMN_A_EQ_B = "column_a_eq_b" # A = B
|
|
85
|
+
|
|
86
|
+
# Query-based checks (DuckGuard 3.0)
|
|
87
|
+
QUERY_NO_ROWS = "query_no_rows" # Query returns no rows
|
|
88
|
+
QUERY_RETURNS_ROWS = "query_returns_rows" # Query returns at least one row
|
|
89
|
+
QUERY_RESULT_EQUALS = "query_result_equals" # Query result equals expected value
|
|
90
|
+
QUERY_RESULT_BETWEEN = "query_result_between" # Query result in range
|
|
91
|
+
QUERY_RESULT_GT = "query_result_gt" # Query result > threshold
|
|
92
|
+
QUERY_RESULT_LT = "query_result_lt" # Query result < threshold
|
|
93
|
+
|
|
94
|
+
# Distributional checks (DuckGuard 3.0)
|
|
95
|
+
DISTRIBUTION_NORMAL = "distribution_normal" # Test for normal distribution
|
|
96
|
+
DISTRIBUTION_UNIFORM = "distribution_uniform" # Test for uniform distribution
|
|
97
|
+
DISTRIBUTION_KS_TEST = "distribution_ks_test" # Kolmogorov-Smirnov test
|
|
98
|
+
DISTRIBUTION_CHI_SQUARE = "distribution_chi_square" # Chi-square goodness-of-fit test
|
|
99
|
+
|
|
63
100
|
|
|
64
101
|
class Severity(Enum):
|
|
65
102
|
"""Severity levels for rule violations."""
|
|
@@ -136,6 +173,19 @@ class Check:
|
|
|
136
173
|
return f"{col} matches '{self.value}'" if col else f"matches '{self.value}'"
|
|
137
174
|
elif self.type == CheckType.ALLOWED_VALUES or self.type == CheckType.ISIN:
|
|
138
175
|
return f"{col} in {self.value}" if col else f"in {self.value}"
|
|
176
|
+
elif self.type == CheckType.EXISTS_IN:
|
|
177
|
+
ref = self.params.get("dataset", "?") + "." + self.params.get("column", "?")
|
|
178
|
+
return f"{col} exists in {ref}" if col else f"exists in {ref}"
|
|
179
|
+
elif self.type == CheckType.REFERENCES:
|
|
180
|
+
ref = self.params.get("dataset", "?") + "." + self.params.get("column", "?")
|
|
181
|
+
return f"{col} references {ref}" if col else f"references {ref}"
|
|
182
|
+
elif self.type == CheckType.MATCHES_VALUES:
|
|
183
|
+
ref = self.params.get("dataset", "?") + "." + self.params.get("column", "?")
|
|
184
|
+
return f"{col} matches values in {ref}" if col else f"matches values in {ref}"
|
|
185
|
+
elif self.type == CheckType.ROW_COUNT_MATCHES:
|
|
186
|
+
ref = self.params.get("dataset", "?")
|
|
187
|
+
tolerance = self.params.get("tolerance", 0)
|
|
188
|
+
return f"row_count matches {ref} (tolerance: {tolerance})"
|
|
139
189
|
|
|
140
190
|
# Fallback
|
|
141
191
|
if col:
|
|
@@ -282,8 +332,12 @@ BUILTIN_PATTERNS = {
|
|
|
282
332
|
"ssn": r"^\d{3}-\d{2}-\d{4}$",
|
|
283
333
|
"zip_us": r"^\d{5}(-\d{4})?$",
|
|
284
334
|
"credit_card": r"^\d{4}[\s\-]?\d{4}[\s\-]?\d{4}[\s\-]?\d{4}$",
|
|
335
|
+
"identifier": r"^[A-Z][A-Z0-9]*[-_]?\d+$|^[A-Z]{2,}[-_][A-Z0-9]+$",
|
|
285
336
|
"slug": r"^[a-z0-9]+(?:-[a-z0-9]+)*$",
|
|
286
337
|
"alpha": r"^[a-zA-Z]+$",
|
|
287
338
|
"alphanumeric": r"^[a-zA-Z0-9]+$",
|
|
288
339
|
"numeric": r"^-?\d+\.?\d*$",
|
|
289
340
|
}
|
|
341
|
+
|
|
342
|
+
# Patterns that must be matched case-sensitively
|
|
343
|
+
CASE_SENSITIVE_PATTERNS = {"slug", "identifier"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Schema evolution tracking for DuckGuard.
|
|
2
|
+
|
|
3
|
+
This module provides functionality to track schema changes over time,
|
|
4
|
+
enabling detection of breaking changes and schema drift.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from duckguard.schema_history import SchemaTracker, SchemaChangeAnalyzer
|
|
8
|
+
|
|
9
|
+
# Track schema
|
|
10
|
+
tracker = SchemaTracker()
|
|
11
|
+
snapshot = tracker.capture(dataset)
|
|
12
|
+
|
|
13
|
+
# Detect changes
|
|
14
|
+
analyzer = SchemaChangeAnalyzer()
|
|
15
|
+
report = analyzer.detect_changes(dataset)
|
|
16
|
+
if report.has_breaking_changes:
|
|
17
|
+
print("Breaking changes detected!")
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from duckguard.schema_history.analyzer import (
|
|
21
|
+
SchemaChange,
|
|
22
|
+
SchemaChangeAnalyzer,
|
|
23
|
+
SchemaEvolutionReport,
|
|
24
|
+
)
|
|
25
|
+
from duckguard.schema_history.tracker import (
|
|
26
|
+
ColumnSchema,
|
|
27
|
+
SchemaSnapshot,
|
|
28
|
+
SchemaTracker,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
# Tracker
|
|
33
|
+
"SchemaTracker",
|
|
34
|
+
"SchemaSnapshot",
|
|
35
|
+
"ColumnSchema",
|
|
36
|
+
# Analyzer
|
|
37
|
+
"SchemaChangeAnalyzer",
|
|
38
|
+
"SchemaChange",
|
|
39
|
+
"SchemaEvolutionReport",
|
|
40
|
+
]
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
"""Schema change analysis implementation.
|
|
2
|
+
|
|
3
|
+
Provides functionality to detect and analyze schema changes between snapshots.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from duckguard.history.schema import QUERIES
|
|
14
|
+
from duckguard.history.storage import HistoryStorage
|
|
15
|
+
from duckguard.schema_history.tracker import SchemaSnapshot, SchemaTracker
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from duckguard.core.dataset import Dataset
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ChangeType(str, Enum):
|
|
22
|
+
"""Types of schema changes."""
|
|
23
|
+
|
|
24
|
+
COLUMN_ADDED = "column_added"
|
|
25
|
+
COLUMN_REMOVED = "column_removed"
|
|
26
|
+
TYPE_CHANGED = "type_changed"
|
|
27
|
+
NULLABLE_CHANGED = "nullable_changed"
|
|
28
|
+
POSITION_CHANGED = "position_changed"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ChangeSeverity(str, Enum):
|
|
32
|
+
"""Severity levels for schema changes."""
|
|
33
|
+
|
|
34
|
+
INFO = "info"
|
|
35
|
+
WARNING = "warning"
|
|
36
|
+
CRITICAL = "critical"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class SchemaChange:
|
|
41
|
+
"""Represents a single schema change.
|
|
42
|
+
|
|
43
|
+
Attributes:
|
|
44
|
+
change_type: Type of change
|
|
45
|
+
column_name: Name of affected column (None for table-level changes)
|
|
46
|
+
previous_value: Previous value (type, nullable, etc.)
|
|
47
|
+
current_value: Current value
|
|
48
|
+
is_breaking: Whether this is a breaking change
|
|
49
|
+
severity: Change severity level
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
change_type: ChangeType
|
|
53
|
+
column_name: str | None
|
|
54
|
+
previous_value: str | None
|
|
55
|
+
current_value: str | None
|
|
56
|
+
is_breaking: bool
|
|
57
|
+
severity: ChangeSeverity
|
|
58
|
+
|
|
59
|
+
def to_dict(self) -> dict[str, Any]:
|
|
60
|
+
"""Convert to dictionary."""
|
|
61
|
+
return {
|
|
62
|
+
"change_type": self.change_type.value,
|
|
63
|
+
"column_name": self.column_name,
|
|
64
|
+
"previous_value": self.previous_value,
|
|
65
|
+
"current_value": self.current_value,
|
|
66
|
+
"is_breaking": self.is_breaking,
|
|
67
|
+
"severity": self.severity.value,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
def __str__(self) -> str:
|
|
71
|
+
"""Human-readable string representation."""
|
|
72
|
+
if self.change_type == ChangeType.COLUMN_ADDED:
|
|
73
|
+
return f"Column '{self.column_name}' added (type: {self.current_value})"
|
|
74
|
+
elif self.change_type == ChangeType.COLUMN_REMOVED:
|
|
75
|
+
return f"Column '{self.column_name}' removed (was type: {self.previous_value})"
|
|
76
|
+
elif self.change_type == ChangeType.TYPE_CHANGED:
|
|
77
|
+
return f"Column '{self.column_name}' type changed: {self.previous_value} -> {self.current_value}"
|
|
78
|
+
elif self.change_type == ChangeType.NULLABLE_CHANGED:
|
|
79
|
+
return f"Column '{self.column_name}' nullable changed: {self.previous_value} -> {self.current_value}"
|
|
80
|
+
elif self.change_type == ChangeType.POSITION_CHANGED:
|
|
81
|
+
return f"Column '{self.column_name}' position changed: {self.previous_value} -> {self.current_value}"
|
|
82
|
+
return f"{self.change_type.value}: {self.column_name}"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class SchemaEvolutionReport:
|
|
87
|
+
"""Report of schema changes between snapshots.
|
|
88
|
+
|
|
89
|
+
Attributes:
|
|
90
|
+
source: Data source path
|
|
91
|
+
previous_snapshot: Previous schema snapshot (None if first)
|
|
92
|
+
current_snapshot: Current schema snapshot
|
|
93
|
+
changes: List of detected changes
|
|
94
|
+
analyzed_at: When the analysis was performed
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
source: str
|
|
98
|
+
previous_snapshot: SchemaSnapshot | None
|
|
99
|
+
current_snapshot: SchemaSnapshot
|
|
100
|
+
changes: list[SchemaChange] = field(default_factory=list)
|
|
101
|
+
analyzed_at: datetime = field(default_factory=datetime.now)
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def has_changes(self) -> bool:
|
|
105
|
+
"""Check if any changes were detected."""
|
|
106
|
+
return len(self.changes) > 0
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def has_breaking_changes(self) -> bool:
|
|
110
|
+
"""Check if any breaking changes were detected."""
|
|
111
|
+
return any(c.is_breaking for c in self.changes)
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def breaking_changes(self) -> list[SchemaChange]:
|
|
115
|
+
"""Get only breaking changes."""
|
|
116
|
+
return [c for c in self.changes if c.is_breaking]
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def non_breaking_changes(self) -> list[SchemaChange]:
|
|
120
|
+
"""Get only non-breaking changes."""
|
|
121
|
+
return [c for c in self.changes if not c.is_breaking]
|
|
122
|
+
|
|
123
|
+
def summary(self) -> str:
|
|
124
|
+
"""Generate a human-readable summary."""
|
|
125
|
+
lines = [f"Schema Evolution Report for: {self.source}"]
|
|
126
|
+
lines.append(f"Analyzed at: {self.analyzed_at.strftime('%Y-%m-%d %H:%M:%S')}")
|
|
127
|
+
lines.append("")
|
|
128
|
+
|
|
129
|
+
if not self.has_changes:
|
|
130
|
+
lines.append("No schema changes detected.")
|
|
131
|
+
return "\n".join(lines)
|
|
132
|
+
|
|
133
|
+
lines.append(f"Total changes: {len(self.changes)}")
|
|
134
|
+
lines.append(f"Breaking changes: {len(self.breaking_changes)}")
|
|
135
|
+
lines.append("")
|
|
136
|
+
|
|
137
|
+
# Group by type
|
|
138
|
+
by_type: dict[ChangeType, list[SchemaChange]] = {}
|
|
139
|
+
for change in self.changes:
|
|
140
|
+
by_type.setdefault(change.change_type, []).append(change)
|
|
141
|
+
|
|
142
|
+
for change_type, type_changes in by_type.items():
|
|
143
|
+
lines.append(f"{change_type.value.replace('_', ' ').title()}:")
|
|
144
|
+
for change in type_changes:
|
|
145
|
+
marker = "[BREAKING]" if change.is_breaking else ""
|
|
146
|
+
lines.append(f" - {change} {marker}")
|
|
147
|
+
|
|
148
|
+
return "\n".join(lines)
|
|
149
|
+
|
|
150
|
+
def to_dict(self) -> dict[str, Any]:
|
|
151
|
+
"""Convert to dictionary."""
|
|
152
|
+
return {
|
|
153
|
+
"source": self.source,
|
|
154
|
+
"previous_snapshot_id": self.previous_snapshot.snapshot_id if self.previous_snapshot else None,
|
|
155
|
+
"current_snapshot_id": self.current_snapshot.snapshot_id,
|
|
156
|
+
"has_changes": self.has_changes,
|
|
157
|
+
"has_breaking_changes": self.has_breaking_changes,
|
|
158
|
+
"total_changes": len(self.changes),
|
|
159
|
+
"breaking_changes_count": len(self.breaking_changes),
|
|
160
|
+
"changes": [c.to_dict() for c in self.changes],
|
|
161
|
+
"analyzed_at": self.analyzed_at.isoformat(),
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class SchemaChangeAnalyzer:
|
|
166
|
+
"""Analyze schema changes between snapshots.
|
|
167
|
+
|
|
168
|
+
Usage:
|
|
169
|
+
from duckguard import connect
|
|
170
|
+
from duckguard.schema_history import SchemaChangeAnalyzer
|
|
171
|
+
|
|
172
|
+
analyzer = SchemaChangeAnalyzer()
|
|
173
|
+
data = connect("data.csv")
|
|
174
|
+
|
|
175
|
+
# Detect changes (captures snapshot and compares to previous)
|
|
176
|
+
report = analyzer.detect_changes(data)
|
|
177
|
+
if report.has_breaking_changes:
|
|
178
|
+
print("Breaking changes detected!")
|
|
179
|
+
for change in report.breaking_changes:
|
|
180
|
+
print(f" - {change}")
|
|
181
|
+
|
|
182
|
+
# Compare two specific snapshots
|
|
183
|
+
changes = analyzer.compare(snapshot1, snapshot2)
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
# Type changes that are typically safe (widening)
|
|
187
|
+
SAFE_TYPE_CHANGES = {
|
|
188
|
+
("INTEGER", "BIGINT"),
|
|
189
|
+
("FLOAT", "DOUBLE"),
|
|
190
|
+
("VARCHAR", "TEXT"),
|
|
191
|
+
("SMALLINT", "INTEGER"),
|
|
192
|
+
("SMALLINT", "BIGINT"),
|
|
193
|
+
("INTEGER", "DOUBLE"),
|
|
194
|
+
("FLOAT", "DECIMAL"),
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
def __init__(self, storage: HistoryStorage | None = None):
|
|
198
|
+
"""Initialize schema change analyzer.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
storage: Optional HistoryStorage instance. Uses default if not provided.
|
|
202
|
+
"""
|
|
203
|
+
self._storage = storage or HistoryStorage()
|
|
204
|
+
self._tracker = SchemaTracker(self._storage)
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def storage(self) -> HistoryStorage:
|
|
208
|
+
"""Get the underlying storage."""
|
|
209
|
+
return self._storage
|
|
210
|
+
|
|
211
|
+
def compare(
|
|
212
|
+
self,
|
|
213
|
+
previous: SchemaSnapshot,
|
|
214
|
+
current: SchemaSnapshot,
|
|
215
|
+
) -> list[SchemaChange]:
|
|
216
|
+
"""Compare two schema snapshots and return changes.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
previous: Previous schema snapshot
|
|
220
|
+
current: Current schema snapshot
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
List of SchemaChange objects
|
|
224
|
+
"""
|
|
225
|
+
changes: list[SchemaChange] = []
|
|
226
|
+
|
|
227
|
+
prev_cols = {c.name: c for c in previous.columns}
|
|
228
|
+
curr_cols = {c.name: c for c in current.columns}
|
|
229
|
+
|
|
230
|
+
prev_names = set(prev_cols.keys())
|
|
231
|
+
curr_names = set(curr_cols.keys())
|
|
232
|
+
|
|
233
|
+
# Detect removed columns (breaking change)
|
|
234
|
+
for name in prev_names - curr_names:
|
|
235
|
+
col = prev_cols[name]
|
|
236
|
+
changes.append(SchemaChange(
|
|
237
|
+
change_type=ChangeType.COLUMN_REMOVED,
|
|
238
|
+
column_name=name,
|
|
239
|
+
previous_value=col.dtype,
|
|
240
|
+
current_value=None,
|
|
241
|
+
is_breaking=True,
|
|
242
|
+
severity=ChangeSeverity.CRITICAL,
|
|
243
|
+
))
|
|
244
|
+
|
|
245
|
+
# Detect added columns (usually not breaking)
|
|
246
|
+
for name in curr_names - prev_names:
|
|
247
|
+
col = curr_cols[name]
|
|
248
|
+
# Adding a non-nullable column without default is breaking
|
|
249
|
+
is_breaking = not col.nullable
|
|
250
|
+
changes.append(SchemaChange(
|
|
251
|
+
change_type=ChangeType.COLUMN_ADDED,
|
|
252
|
+
column_name=name,
|
|
253
|
+
previous_value=None,
|
|
254
|
+
current_value=col.dtype,
|
|
255
|
+
is_breaking=is_breaking,
|
|
256
|
+
severity=ChangeSeverity.WARNING if is_breaking else ChangeSeverity.INFO,
|
|
257
|
+
))
|
|
258
|
+
|
|
259
|
+
# Detect changes to existing columns
|
|
260
|
+
for name in prev_names & curr_names:
|
|
261
|
+
prev_col = prev_cols[name]
|
|
262
|
+
curr_col = curr_cols[name]
|
|
263
|
+
|
|
264
|
+
# Type change
|
|
265
|
+
if prev_col.dtype != curr_col.dtype:
|
|
266
|
+
is_breaking = not self._is_safe_type_change(prev_col.dtype, curr_col.dtype)
|
|
267
|
+
changes.append(SchemaChange(
|
|
268
|
+
change_type=ChangeType.TYPE_CHANGED,
|
|
269
|
+
column_name=name,
|
|
270
|
+
previous_value=prev_col.dtype,
|
|
271
|
+
current_value=curr_col.dtype,
|
|
272
|
+
is_breaking=is_breaking,
|
|
273
|
+
severity=ChangeSeverity.CRITICAL if is_breaking else ChangeSeverity.WARNING,
|
|
274
|
+
))
|
|
275
|
+
|
|
276
|
+
# Nullable change
|
|
277
|
+
if prev_col.nullable != curr_col.nullable:
|
|
278
|
+
# Changing from nullable to non-nullable is breaking
|
|
279
|
+
is_breaking = prev_col.nullable and not curr_col.nullable
|
|
280
|
+
changes.append(SchemaChange(
|
|
281
|
+
change_type=ChangeType.NULLABLE_CHANGED,
|
|
282
|
+
column_name=name,
|
|
283
|
+
previous_value=str(prev_col.nullable),
|
|
284
|
+
current_value=str(curr_col.nullable),
|
|
285
|
+
is_breaking=is_breaking,
|
|
286
|
+
severity=ChangeSeverity.WARNING if is_breaking else ChangeSeverity.INFO,
|
|
287
|
+
))
|
|
288
|
+
|
|
289
|
+
# Position change (usually not breaking, just informational)
|
|
290
|
+
if prev_col.position != curr_col.position:
|
|
291
|
+
changes.append(SchemaChange(
|
|
292
|
+
change_type=ChangeType.POSITION_CHANGED,
|
|
293
|
+
column_name=name,
|
|
294
|
+
previous_value=str(prev_col.position),
|
|
295
|
+
current_value=str(curr_col.position),
|
|
296
|
+
is_breaking=False,
|
|
297
|
+
severity=ChangeSeverity.INFO,
|
|
298
|
+
))
|
|
299
|
+
|
|
300
|
+
return changes
|
|
301
|
+
|
|
302
|
+
def detect_changes(self, dataset: Dataset) -> SchemaEvolutionReport:
|
|
303
|
+
"""Detect schema changes for a dataset.
|
|
304
|
+
|
|
305
|
+
Captures current schema and compares to the most recent snapshot.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
dataset: Dataset to analyze
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
SchemaEvolutionReport with detected changes
|
|
312
|
+
"""
|
|
313
|
+
# Get the latest snapshot before capturing new one
|
|
314
|
+
previous = self._tracker.get_latest(dataset.source)
|
|
315
|
+
|
|
316
|
+
# Capture current schema
|
|
317
|
+
current = self._tracker.capture(dataset)
|
|
318
|
+
|
|
319
|
+
# Compare if we have a previous snapshot
|
|
320
|
+
changes: list[SchemaChange] = []
|
|
321
|
+
if previous:
|
|
322
|
+
changes = self.compare(previous, current)
|
|
323
|
+
|
|
324
|
+
# Store detected changes
|
|
325
|
+
self._store_changes(dataset.source, previous, current, changes)
|
|
326
|
+
|
|
327
|
+
return SchemaEvolutionReport(
|
|
328
|
+
source=dataset.source,
|
|
329
|
+
previous_snapshot=previous,
|
|
330
|
+
current_snapshot=current,
|
|
331
|
+
changes=changes,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
def analyze_evolution(
|
|
335
|
+
self,
|
|
336
|
+
source: str,
|
|
337
|
+
since: datetime | None = None,
|
|
338
|
+
limit: int = 100,
|
|
339
|
+
) -> list[SchemaChange]:
|
|
340
|
+
"""Get all schema changes for a source.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
source: Data source path
|
|
344
|
+
since: Only get changes since this datetime
|
|
345
|
+
limit: Maximum changes to return
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
List of SchemaChange objects, most recent first
|
|
349
|
+
"""
|
|
350
|
+
conn = self._storage._get_connection()
|
|
351
|
+
|
|
352
|
+
if since:
|
|
353
|
+
cursor = conn.execute(
|
|
354
|
+
QUERIES["get_schema_changes_since"],
|
|
355
|
+
(source, since.isoformat()),
|
|
356
|
+
)
|
|
357
|
+
else:
|
|
358
|
+
cursor = conn.execute(
|
|
359
|
+
QUERIES["get_schema_changes"],
|
|
360
|
+
(source, limit),
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
changes = []
|
|
364
|
+
for row in cursor.fetchall():
|
|
365
|
+
changes.append(SchemaChange(
|
|
366
|
+
change_type=ChangeType(row["change_type"]),
|
|
367
|
+
column_name=row["column_name"],
|
|
368
|
+
previous_value=row["previous_value"],
|
|
369
|
+
current_value=row["current_value"],
|
|
370
|
+
is_breaking=bool(row["is_breaking"]),
|
|
371
|
+
severity=ChangeSeverity(row["severity"]),
|
|
372
|
+
))
|
|
373
|
+
|
|
374
|
+
return changes
|
|
375
|
+
|
|
376
|
+
def _is_safe_type_change(self, from_type: str, to_type: str) -> bool:
|
|
377
|
+
"""Check if a type change is safe (widening conversion)."""
|
|
378
|
+
from_normalized = from_type.upper().split("(")[0].strip()
|
|
379
|
+
to_normalized = to_type.upper().split("(")[0].strip()
|
|
380
|
+
|
|
381
|
+
return (from_normalized, to_normalized) in self.SAFE_TYPE_CHANGES
|
|
382
|
+
|
|
383
|
+
def _store_changes(
|
|
384
|
+
self,
|
|
385
|
+
source: str,
|
|
386
|
+
previous: SchemaSnapshot,
|
|
387
|
+
current: SchemaSnapshot,
|
|
388
|
+
changes: list[SchemaChange],
|
|
389
|
+
) -> None:
|
|
390
|
+
"""Store detected changes in the database."""
|
|
391
|
+
if not changes:
|
|
392
|
+
return
|
|
393
|
+
|
|
394
|
+
conn = self._storage._get_connection()
|
|
395
|
+
now = datetime.now().isoformat()
|
|
396
|
+
|
|
397
|
+
for change in changes:
|
|
398
|
+
conn.execute(
|
|
399
|
+
QUERIES["insert_schema_change"],
|
|
400
|
+
(
|
|
401
|
+
source,
|
|
402
|
+
now,
|
|
403
|
+
previous.snapshot_id,
|
|
404
|
+
current.snapshot_id,
|
|
405
|
+
change.change_type.value,
|
|
406
|
+
change.column_name,
|
|
407
|
+
change.previous_value,
|
|
408
|
+
change.current_value,
|
|
409
|
+
1 if change.is_breaking else 0,
|
|
410
|
+
change.severity.value,
|
|
411
|
+
),
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
conn.commit()
|