driftbrake 0.0.1__tar.gz

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 (35) hide show
  1. driftbrake-0.0.1/.gitignore +40 -0
  2. driftbrake-0.0.1/LICENSE +21 -0
  3. driftbrake-0.0.1/PKG-INFO +88 -0
  4. driftbrake-0.0.1/README.md +48 -0
  5. driftbrake-0.0.1/driftbrake.example.yml +51 -0
  6. driftbrake-0.0.1/pyproject.toml +119 -0
  7. driftbrake-0.0.1/src/driftbrake/__init__.py +64 -0
  8. driftbrake-0.0.1/src/driftbrake/classifiers/__init__.py +4 -0
  9. driftbrake-0.0.1/src/driftbrake/classifiers/impact_classifier.py +107 -0
  10. driftbrake-0.0.1/src/driftbrake/classifiers/type_compatibility.py +117 -0
  11. driftbrake-0.0.1/src/driftbrake/cli.py +328 -0
  12. driftbrake-0.0.1/src/driftbrake/comparators/__init__.py +3 -0
  13. driftbrake-0.0.1/src/driftbrake/comparators/schema_comparator.py +449 -0
  14. driftbrake-0.0.1/src/driftbrake/config/__init__.py +3 -0
  15. driftbrake-0.0.1/src/driftbrake/config/settings.py +133 -0
  16. driftbrake-0.0.1/src/driftbrake/contracts/__init__.py +6 -0
  17. driftbrake-0.0.1/src/driftbrake/contracts/loader.py +69 -0
  18. driftbrake-0.0.1/src/driftbrake/contracts/writer.py +56 -0
  19. driftbrake-0.0.1/src/driftbrake/exceptions.py +20 -0
  20. driftbrake-0.0.1/src/driftbrake/guard.py +194 -0
  21. driftbrake-0.0.1/src/driftbrake/models.py +229 -0
  22. driftbrake-0.0.1/src/driftbrake/readers/__init__.py +7 -0
  23. driftbrake-0.0.1/src/driftbrake/readers/base.py +19 -0
  24. driftbrake-0.0.1/src/driftbrake/readers/json_reader.py +84 -0
  25. driftbrake-0.0.1/src/driftbrake/readers/postgres.py +181 -0
  26. driftbrake-0.0.1/src/driftbrake/reporters/__init__.py +8 -0
  27. driftbrake-0.0.1/src/driftbrake/reporters/html_report.py +213 -0
  28. driftbrake-0.0.1/src/driftbrake/reporters/json_report.py +29 -0
  29. driftbrake-0.0.1/src/driftbrake/reporters/markdown_report.py +127 -0
  30. driftbrake-0.0.1/src/driftbrake/reporters/terminal.py +171 -0
  31. driftbrake-0.0.1/templates/base.html +306 -0
  32. driftbrake-0.0.1/templates/secao_breaking.html +21 -0
  33. driftbrake-0.0.1/templates/secao_safe.html +17 -0
  34. driftbrake-0.0.1/templates/secao_warning.html +21 -0
  35. driftbrake-0.0.1/templates/tabela.html +11 -0
@@ -0,0 +1,40 @@
1
+
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.so
5
+ .Python
6
+ *.egg-info/
7
+ dist/
8
+ build/
9
+
10
+ .env
11
+ .venv
12
+ venv/
13
+ ENV/
14
+
15
+ *.env
16
+ *.db
17
+ *.sqlite
18
+ credentials.json
19
+ secrets.json
20
+
21
+ .vscode/
22
+ .idea/
23
+ *.swp
24
+
25
+ .DS_Store
26
+ Thumbs.db
27
+
28
+ *.log
29
+ logs/
30
+
31
+ *.tmp
32
+ temp/
33
+ tmp/
34
+
35
+ *.bak
36
+ *.backup
37
+
38
+ uv.lock
39
+ examples/
40
+ testes/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yuri Pontes
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.4
2
+ Name: driftbrake
3
+ Version: 0.0.1
4
+ Summary: Detect, classify, and block schema drift in PostgreSQL before your pipelines break
5
+ Project-URL: Homepage, https://github.com/yurivski/driftbrake
6
+ Project-URL: Repository, https://github.com/yurivski/driftbrake
7
+ Project-URL: Issues, https://github.com/yurivski/driftbrake/issues
8
+ Project-URL: Changelog, https://github.com/yurivski/driftbrake/blob/main/CHANGELOG.md
9
+ Author-email: Yuri Pontes <yurilbrok23@gmail.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: contract-testing,data-engineering,database,postgresql,schema
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Database
22
+ Classifier: Topic :: Software Development :: Quality Assurance
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: jinja2>=3.0
25
+ Requires-Dist: python-dotenv>=1.0
26
+ Requires-Dist: pyyaml>=6.0
27
+ Requires-Dist: rich>=13.0
28
+ Requires-Dist: sqlalchemy<3.0,>=2.0
29
+ Requires-Dist: typer>=0.9
30
+ Provides-Extra: dev
31
+ Requires-Dist: build; extra == 'dev'
32
+ Requires-Dist: mypy; extra == 'dev'
33
+ Requires-Dist: pytest-cov; extra == 'dev'
34
+ Requires-Dist: pytest>=7.0; extra == 'dev'
35
+ Requires-Dist: ruff; extra == 'dev'
36
+ Requires-Dist: twine; extra == 'dev'
37
+ Provides-Extra: postgres
38
+ Requires-Dist: psycopg2-binary>=2.9; extra == 'postgres'
39
+ Description-Content-Type: text/markdown
40
+
41
+ ```text
42
+ DriftBrake
43
+ ==========
44
+
45
+ DriftBrake is a Python tool that validates schema contracts before data pipelines run.
46
+
47
+ It reads the current PostgreSQL schema automatically, compares it against a versioned
48
+ contract, classifies changes by impact (BREAKING, WARNING, SAFE), and blocks pipelines
49
+ when incompatible changes are detected, before they cause failures in production.
50
+
51
+
52
+ The tool
53
+ ========
54
+
55
+ DriftBrake is not a migration tool. It does not apply changes to the database and does
56
+ not generate SQL scripts.
57
+
58
+ It runs BEFORE pipelines, verifying that the actual database still respects the
59
+ contract expected by its data consumers.
60
+
61
+
62
+ Example
63
+ =======
64
+
65
+ Data pipelines fail silently when the database schema changes without warning:
66
+
67
+ - column removed or renamed
68
+ - data type altered
69
+ - NOT NULL added without a default
70
+ - foreign key modified
71
+
72
+ This tool runs an automatic validation before the pipeline starts and blocks the
73
+ execution if the database is no longer compatible with the expected contract.
74
+
75
+ Usage:
76
+
77
+ driftbrake init # creates the schema.lock.json contract
78
+ driftbrake check # checks whether the database has changed
79
+ driftbrake diff # compares two states without touching the contract
80
+
81
+
82
+ Documentation
83
+ =============
84
+
85
+ - DOCUMENTATION.md - https://github.com/yurivski/driftbrake/blob/main/DOCUMENTATION.md
86
+ - CHANGELOG.md - https://github.com/yurivski/driftbrake/blob/main/CHANGELOG.md
87
+ - YML file - https://github.com/yurivski/driftbrake/blob/main/driftbrake.example.yml
88
+ ```
@@ -0,0 +1,48 @@
1
+ ```text
2
+ DriftBrake
3
+ ==========
4
+
5
+ DriftBrake is a Python tool that validates schema contracts before data pipelines run.
6
+
7
+ It reads the current PostgreSQL schema automatically, compares it against a versioned
8
+ contract, classifies changes by impact (BREAKING, WARNING, SAFE), and blocks pipelines
9
+ when incompatible changes are detected, before they cause failures in production.
10
+
11
+
12
+ The tool
13
+ ========
14
+
15
+ DriftBrake is not a migration tool. It does not apply changes to the database and does
16
+ not generate SQL scripts.
17
+
18
+ It runs BEFORE pipelines, verifying that the actual database still respects the
19
+ contract expected by its data consumers.
20
+
21
+
22
+ Example
23
+ =======
24
+
25
+ Data pipelines fail silently when the database schema changes without warning:
26
+
27
+ - column removed or renamed
28
+ - data type altered
29
+ - NOT NULL added without a default
30
+ - foreign key modified
31
+
32
+ This tool runs an automatic validation before the pipeline starts and blocks the
33
+ execution if the database is no longer compatible with the expected contract.
34
+
35
+ Usage:
36
+
37
+ driftbrake init # creates the schema.lock.json contract
38
+ driftbrake check # checks whether the database has changed
39
+ driftbrake diff # compares two states without touching the contract
40
+
41
+
42
+ Documentation
43
+ =============
44
+
45
+ - DOCUMENTATION.md - https://github.com/yurivski/driftbrake/blob/main/DOCUMENTATION.md
46
+ - CHANGELOG.md - https://github.com/yurivski/driftbrake/blob/main/CHANGELOG.md
47
+ - YML file - https://github.com/yurivski/driftbrake/blob/main/driftbrake.example.yml
48
+ ```
@@ -0,0 +1,51 @@
1
+ # driftbrake.example.yml
2
+ # Copie este arquivo para driftbrake.yml e personalize conforme necessário.
3
+ #
4
+ # Este arquivo controla como o DriftBrake compara schemas, quais
5
+ # níveis de severidade causam falhas e quais tabelas/colunas ignorar.
6
+
7
+ # Níveis de severidade que causam código de saída não-zero (falha).
8
+ # Valores possíveis: BREAKING, WARNING, SAFE
9
+ fail_on:
10
+ - BREAKING
11
+
12
+ # Níveis de severidade que emitem avisos (mas não causam falha por padrão).
13
+ warn_on:
14
+ - WARNING
15
+
16
+ # Filtragem de schemas
17
+ schemas:
18
+ # Comparar apenas estes schemas (deixe vazio para incluir todos)
19
+ include:
20
+ - public
21
+ # Excluir estes schemas da comparação
22
+ exclude: []
23
+
24
+ # Filtragem de tabelas
25
+ tables:
26
+ # Ignorar completamente estas tabelas (nenhuma comparação realizada)
27
+ ignore:
28
+ - alembic_version
29
+ - django_migrations
30
+ - flyway_schema_history
31
+
32
+ # Filtragem de colunas
33
+ columns:
34
+ # Ignorar colunas específicas por tabela (padrões glob suportados)
35
+ ignore:
36
+ # Ignorar 'updated_at' em todas as tabelas que correspondem a 'orders*'
37
+ orders:
38
+ - updated_at
39
+ - created_at
40
+ customers:
41
+ - last_login
42
+
43
+ # Sobrescritas de regras personalizadas
44
+ # Estas substituem a classificação padrão para tipos específicos de alteração.
45
+ rules:
46
+ # Tratar alterações de posição ordinal como SAFE em vez de WARNING
47
+ ordinal_position_changed: SAFE
48
+ # Tratar alterações de valor padrão como WARNING (comportamento padrão)
49
+ default_changed: WARNING
50
+ # Tratar alterações de restrição unique como SAFE
51
+ # unique_changed: SAFE
@@ -0,0 +1,119 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "driftbrake"
7
+ version = "0.0.1"
8
+ description = "Detect, classify, and block schema drift in PostgreSQL before your pipelines break"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [
14
+ { name = "Yuri Pontes", email = "yurilbrok23@gmail.com" },
15
+ ]
16
+ keywords = ["schema", "database", "data-engineering", "postgresql", "contract-testing"]
17
+ classifiers = [
18
+ "Development Status :: 3 - Alpha",
19
+ "Intended Audience :: Developers",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Database",
27
+ "Topic :: Software Development :: Quality Assurance",
28
+ ]
29
+ dependencies = [
30
+ "sqlalchemy>=2.0,<3.0",
31
+ "python-dotenv>=1.0",
32
+ "pyyaml>=6.0",
33
+ "jinja2>=3.0",
34
+ "rich>=13.0",
35
+ "typer>=0.9",
36
+ ]
37
+
38
+ [project.optional-dependencies]
39
+ postgres = ["psycopg2-binary>=2.9"]
40
+ dev = [
41
+ "pytest>=7.0",
42
+ "pytest-cov",
43
+ "ruff",
44
+ "mypy",
45
+ "build",
46
+ "twine",
47
+ ]
48
+
49
+ [project.urls]
50
+ Homepage = "https://github.com/yurivski/driftbrake"
51
+ Repository = "https://github.com/yurivski/driftbrake"
52
+ Issues = "https://github.com/yurivski/driftbrake/issues"
53
+ Changelog = "https://github.com/yurivski/driftbrake/blob/main/CHANGELOG.md"
54
+
55
+ [project.scripts]
56
+ driftbrake = "driftbrake.cli:app"
57
+
58
+ [tool.hatch.build.targets.wheel]
59
+ packages = ["src/driftbrake"]
60
+
61
+ [tool.hatch.build]
62
+ exclude = [
63
+ # venvs
64
+ ".venv*",
65
+ "venv",
66
+
67
+ ".git",
68
+ ".github",
69
+
70
+ ".gitignore",
71
+ ".gitattributes",
72
+ ".claude",
73
+ ".vscode",
74
+ ".idea",
75
+
76
+ ".pytest_cache",
77
+ ".ruff_cache",
78
+ ".mypy_cache",
79
+ "__pycache__",
80
+ "*.pyc",
81
+ "*.pyo",
82
+
83
+ "dist",
84
+ "build",
85
+ "*.egg-info",
86
+
87
+ "tests",
88
+ "examples",
89
+ "imagens",
90
+ "fonte",
91
+ "DOCUMENTATION.md",
92
+ "CHANGELOG.md",
93
+ "Makefile",
94
+ "schema.lock.json",
95
+
96
+ "uv.lock",
97
+ "requirements.txt",
98
+
99
+ ".env",
100
+ ".env.*",
101
+ ]
102
+
103
+ [tool.pytest.ini_options]
104
+ testpaths = ["tests"]
105
+ pythonpath = ["src"]
106
+
107
+ [tool.ruff]
108
+ src = ["src"]
109
+ line-length = 100
110
+ target-version = "py311"
111
+
112
+ [tool.ruff.lint]
113
+ select = ["E", "F", "I", "N", "UP"]
114
+
115
+ [tool.mypy]
116
+ python_version = "3.11"
117
+ mypy_path = "src"
118
+ strict = false
119
+ ignore_missing_imports = true
@@ -0,0 +1,64 @@
1
+ """
2
+ DriftBrake
3
+ =========
4
+
5
+ A schema contract guard for data pipelines.
6
+ Detects, classifies, and reports schema changes in PostgreSQL databases.
7
+
8
+ Quick start:
9
+ from driftbrake import SchemaGuard
10
+
11
+ SchemaGuard.from_env(
12
+ contract_path="schema.lock.json",
13
+ fail_on=["BREAKING"],
14
+ ).assert_compatible()
15
+ """
16
+
17
+ from driftbrake.classifiers.impact_classifier import ImpactClassifier
18
+ from driftbrake.comparators.schema_comparator import SchemaComparator
19
+ from driftbrake.exceptions import (
20
+ BreakingSchemaChangeError,
21
+ ConfigurationError,
22
+ SchemaConnectionError,
23
+ SchemaContractNotFoundError,
24
+ SchemaDetectorError,
25
+ )
26
+ from driftbrake.guard import SchemaGuard
27
+ from driftbrake.models import (
28
+ ChangeType,
29
+ ColumnSchema,
30
+ DatabaseSchema,
31
+ DiffResult,
32
+ SchemaChange,
33
+ Severity,
34
+ TableSchema,
35
+ )
36
+ from driftbrake.readers.json_reader import JsonSchemaReader
37
+ from driftbrake.readers.postgres import PostgresSchemaReader
38
+
39
+ __version__ = "0.2.0"
40
+
41
+ __all__ = [
42
+ # High-level API
43
+ "SchemaGuard",
44
+ # Comparators and classifiers
45
+ "SchemaComparator",
46
+ "ImpactClassifier",
47
+ # Readers
48
+ "PostgresSchemaReader",
49
+ "JsonSchemaReader",
50
+ # Models
51
+ "DatabaseSchema",
52
+ "TableSchema",
53
+ "ColumnSchema",
54
+ "SchemaChange",
55
+ "DiffResult",
56
+ "Severity",
57
+ "ChangeType",
58
+ # Exceptions
59
+ "SchemaDetectorError",
60
+ "SchemaContractNotFoundError",
61
+ "SchemaConnectionError",
62
+ "BreakingSchemaChangeError",
63
+ "ConfigurationError",
64
+ ]
@@ -0,0 +1,4 @@
1
+ from driftbrake.classifiers.impact_classifier import ImpactClassifier
2
+ from driftbrake.classifiers.type_compatibility import classify_type_change
3
+
4
+ __all__ = ["ImpactClassifier", "classify_type_change"]
@@ -0,0 +1,107 @@
1
+ # Classificador de impacto para alterações de schema.
2
+
3
+ from __future__ import annotations
4
+
5
+ from driftbrake.classifiers.type_compatibility import classify_type_change
6
+ from driftbrake.models import ChangeType, ColumnSchema, SchemaChange, Severity
7
+
8
+
9
+ class ImpactClassifier:
10
+ """
11
+ - Objetos removidos são sempre BREAKING.
12
+ - Colunas nullable adicionadas são SAFE.
13
+ - Colunas NOT NULL adicionadas sem default são BREAKING.
14
+ - Alterações de tipo são avaliadas pela matriz de compatibilidade de tipos.
15
+ """
16
+
17
+ def __init__(self, custom_rules: dict | None = None) -> None:
18
+ self.custom_rules = custom_rules or {}
19
+
20
+ def classify_table_added(self, schema: str, table: str) -> Severity:
21
+ return Severity.SAFE
22
+
23
+ def classify_table_removed(self, schema: str, table: str) -> Severity:
24
+ return Severity.BREAKING
25
+
26
+ def classify_column_added(self, column: ColumnSchema) -> Severity:
27
+ """
28
+ - Adicionada nullable: SAFE
29
+ - Adicionada NOT NULL com default: WARNING
30
+ - Adicionada NOT NULL sem default: BREAKING
31
+ """
32
+ if column.nullable:
33
+ return Severity.SAFE
34
+ if column.default is not None:
35
+ return Severity.WARNING
36
+ return Severity.BREAKING
37
+
38
+ def classify_column_removed(self, column_name: str) -> Severity:
39
+ return Severity.BREAKING
40
+
41
+ def classify_type_change(self, old_type: str, new_type: str) -> Severity:
42
+ return classify_type_change(old_type, new_type)
43
+
44
+ def classify_nullable_change(self, old_nullable: bool, new_nullable: bool) -> Severity:
45
+ if not old_nullable and new_nullable:
46
+ # NOT NULL removido -> nullable permitido: WARNING (afrouxamento)
47
+ return Severity.WARNING
48
+ if old_nullable and not new_nullable:
49
+ # nullable removido -> NOT NULL adicionado: BREAKING
50
+ return Severity.BREAKING
51
+ return Severity.SAFE
52
+
53
+ def classify_default_change(self, old_default: object, new_default: object) -> Severity:
54
+ return Severity.WARNING
55
+
56
+ def classify_primary_key_change(self, old_pk: bool, new_pk: bool) -> Severity:
57
+ return Severity.BREAKING
58
+
59
+ def classify_unique_change(self, old_unique: bool, new_unique: bool) -> Severity:
60
+ return Severity.WARNING
61
+
62
+ def classify_foreign_key_change(
63
+ self, old_fk: list, new_fk: list
64
+ ) -> Severity:
65
+ old_has = bool(old_fk)
66
+ new_has = bool(new_fk)
67
+ if not old_has and new_has:
68
+ # FK adicionada
69
+ return Severity.WARNING
70
+ # FK alterada ou removida
71
+ return Severity.BREAKING
72
+
73
+ def classify_ordinal_position_change(
74
+ self, old_pos: int, new_pos: int
75
+ ) -> Severity:
76
+ return Severity.WARNING
77
+
78
+ def classify_possible_rename(
79
+ self, removed_col: str, added_col: str
80
+ ) -> Severity:
81
+ return Severity.WARNING
82
+
83
+ def build_change(
84
+ self,
85
+ change_type: ChangeType,
86
+ severity: Severity,
87
+ schema_name: str,
88
+ table_name: str,
89
+ column_name: str | None,
90
+ field_name: str | None,
91
+ old_value: object,
92
+ new_value: object,
93
+ description: str,
94
+ suggestion: str | None = None,
95
+ ) -> SchemaChange:
96
+ return SchemaChange(
97
+ change_type=change_type,
98
+ severity=severity,
99
+ schema_name=schema_name,
100
+ table_name=table_name,
101
+ column_name=column_name,
102
+ field_name=field_name,
103
+ old_value=old_value,
104
+ new_value=new_value,
105
+ description=description,
106
+ suggestion=suggestion,
107
+ )
@@ -0,0 +1,117 @@
1
+ """
2
+ Matriz de compatibilidade de tipos PostgreSQL.
3
+
4
+ Classifica alterações de tipo como SAFE, WARNING ou BREAKING com base nas
5
+ regras de coerção e cast implícito do PostgreSQL.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ from driftbrake.models import Severity
13
+
14
+ # Regras de compatibilidade explícitas como triplas (from_pattern, to_pattern, severity).
15
+ # Os padrões são comparados sem diferenciação de maiúsculas usando substring ou regex.
16
+ _COMPAT_RULES: list[tuple[str, str, Severity]] = [
17
+ # Expansões de VARCHAR: seguro
18
+ ("varchar", "text", Severity.SAFE),
19
+ ("character varying", "text", Severity.SAFE),
20
+
21
+ # Alargamento numérico: seguro (tipos menores promovidos)
22
+ ("smallint", "integer", Severity.SAFE),
23
+ ("smallint", "bigint", Severity.SAFE),
24
+ ("real", "double precision", Severity.SAFE),
25
+
26
+ # Estreitamento numérico: crítico
27
+ ("bigint", "integer", Severity.BREAKING),
28
+ ("bigint", "smallint", Severity.BREAKING),
29
+ ("integer", "smallint", Severity.BREAKING),
30
+ ("double precision", "real", Severity.BREAKING),
31
+
32
+ # integer -> bigint: aviso (alargamento, mas pode afetar o comportamento da app / tipos ORM)
33
+ ("integer", "bigint", Severity.WARNING),
34
+
35
+ # Data/hora: date -> timestamp é aviso (sem perda de dados, mas semântica muda)
36
+ ("date", "timestamp", Severity.WARNING),
37
+ ("timestamp", "date", Severity.BREAKING),
38
+ ("timestamp", "timestamptz", Severity.WARNING),
39
+ ("timestamptz", "timestamp", Severity.WARNING),
40
+
41
+ # Estreitamento de text/varchar: crítico
42
+ ("text", "varchar", Severity.BREAKING),
43
+ ("text", "character varying", Severity.BREAKING),
44
+ ("text", "numeric", Severity.BREAKING),
45
+ ("text", "integer", Severity.BREAKING),
46
+ ("text", "bigint", Severity.BREAKING),
47
+
48
+ # numeric para text: crítico
49
+ ("numeric", "text", Severity.BREAKING),
50
+ ("integer", "text", Severity.WARNING),
51
+ ("bigint", "text", Severity.WARNING),
52
+
53
+ # Alterações de boolean: crítico
54
+ ("boolean", "integer", Severity.BREAKING),
55
+ ("integer", "boolean", Severity.BREAKING),
56
+ ]
57
+
58
+
59
+ def _normalize_type(type_str: str) -> str:
60
+ # Normaliza a string de tipo para comparação: minúsculas e sem espaços extras.
61
+ return type_str.strip().lower()
62
+
63
+
64
+ def _extract_varchar_length(type_str: str) -> int | None:
65
+ # Extrai o comprimento de VARCHAR(n) ou CHARACTER VARYING(n).
66
+ match = re.search(r"(?:varchar|character varying)\s*\((\d+)\)", type_str, re.IGNORECASE)
67
+ if match:
68
+ return int(match.group(1))
69
+ return None
70
+
71
+
72
+ def _extract_numeric_precision(type_str: str) -> tuple[int, int] | None:
73
+ # Extrai (precisão, escala) de NUMERIC(p, s) ou DECIMAL(p, s).
74
+ match = re.search(r"(?:numeric|decimal)\s*\((\d+)\s*,\s*(\d+)\)", type_str, re.IGNORECASE)
75
+ if match:
76
+ return int(match.group(1)), int(match.group(2))
77
+ return None
78
+
79
+
80
+ def classify_type_change(old_type: str, new_type: str) -> Severity:
81
+ # Classifica uma alteração de tipo de coluna como SAFE, WARNING ou BREAKING.
82
+ if _normalize_type(old_type) == _normalize_type(new_type):
83
+ return Severity.SAFE
84
+
85
+ old_norm = _normalize_type(old_type)
86
+ new_norm = _normalize_type(new_type)
87
+
88
+ # Regras de VARCHAR(n) -> VARCHAR(m)
89
+ old_len = _extract_varchar_length(old_norm)
90
+ new_len = _extract_varchar_length(new_norm)
91
+ if old_len is not None and new_len is not None:
92
+ if new_len >= old_len:
93
+ return Severity.SAFE
94
+ return Severity.BREAKING
95
+
96
+ # VARCHAR(n) -> TEXT: seguro
97
+ if old_len is not None and "text" in new_norm:
98
+ return Severity.SAFE
99
+
100
+ # NUMERIC(p1,s) -> NUMERIC(p2,s): seguro se p2 >= p1
101
+ old_num = _extract_numeric_precision(old_norm)
102
+ new_num = _extract_numeric_precision(new_norm)
103
+ if old_num is not None and new_num is not None:
104
+ old_prec, old_scale = old_num
105
+ new_prec, new_scale = new_num
106
+ if new_scale == old_scale and new_prec >= old_prec:
107
+ return Severity.SAFE
108
+ if new_prec < old_prec or new_scale != old_scale:
109
+ return Severity.BREAKING
110
+
111
+ # Aplica regras explícitas (verifica se a substring está contida)
112
+ for from_pat, to_pat, severity in _COMPAT_RULES:
113
+ if from_pat in old_norm and to_pat in new_norm:
114
+ return severity
115
+
116
+ # Padrão: alteração de tipo desconhecida é BREAKING (conservador)
117
+ return Severity.BREAKING