migrolint 0.1.0__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.
- migrolint-0.1.0/LICENSE +21 -0
- migrolint-0.1.0/PKG-INFO +140 -0
- migrolint-0.1.0/README.md +116 -0
- migrolint-0.1.0/pyproject.toml +39 -0
- migrolint-0.1.0/setup.cfg +4 -0
- migrolint-0.1.0/src/migrolint/__init__.py +3 -0
- migrolint-0.1.0/src/migrolint/__main__.py +6 -0
- migrolint-0.1.0/src/migrolint/cli.py +178 -0
- migrolint-0.1.0/src/migrolint/core.py +161 -0
- migrolint-0.1.0/src/migrolint.egg-info/PKG-INFO +140 -0
- migrolint-0.1.0/src/migrolint.egg-info/SOURCES.txt +13 -0
- migrolint-0.1.0/src/migrolint.egg-info/dependency_links.txt +1 -0
- migrolint-0.1.0/src/migrolint.egg-info/entry_points.txt +2 -0
- migrolint-0.1.0/src/migrolint.egg-info/top_level.txt +1 -0
- migrolint-0.1.0/tests/test_core.py +133 -0
migrolint-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 migrolint contributors
|
|
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.
|
migrolint-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: migrolint
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Framework-agnostic linter for database migration folders — catch duplicate version numbers, sequence gaps, and missing down-migrations before CI does. Zero dependencies.
|
|
5
|
+
Author: yyfjj
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jjdoor/migrolint-py
|
|
8
|
+
Project-URL: Repository, https://github.com/jjdoor/migrolint-py
|
|
9
|
+
Project-URL: Issues, https://github.com/jjdoor/migrolint-py/issues
|
|
10
|
+
Keywords: migration,migrations,database,linter,sql,flyway,golang-migrate,dbmate,goose,cli,pre-commit,devops
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Database
|
|
18
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
19
|
+
Classifier: Topic :: Utilities
|
|
20
|
+
Requires-Python: >=3.8
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# migrolint
|
|
26
|
+
|
|
27
|
+
**A framework-agnostic linter for your database migrations folder.** It catches
|
|
28
|
+
the boring, expensive mistakes that pass locally and only blow up when CI
|
|
29
|
+
actually runs the migrations — duplicate version numbers, sequence gaps, and
|
|
30
|
+
`up` migrations with no matching `down`. No database connection, no framework
|
|
31
|
+
lock-in, **zero dependencies** (pure standard library).
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install migrolint
|
|
35
|
+
|
|
36
|
+
migrolint # auto-detects ./migrations, db/migrations, ...
|
|
37
|
+
migrolint db/migrations --strict
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## The problem
|
|
41
|
+
|
|
42
|
+
Two developers branch off `main`, each adds `0007_add_index.sql`, both merge.
|
|
43
|
+
Now your migrations folder has two migrations claiming version `0007`. Your
|
|
44
|
+
runner picks one and silently skips the other — or aborts the whole deploy.
|
|
45
|
+
This is a [known, recurring failure](https://github.com/golang-migrate/migrate/issues/574)
|
|
46
|
+
in every sequence-numbered migration tool.
|
|
47
|
+
|
|
48
|
+
The existing linters (`django-migration-linter`, Flyway's own checks) are tied
|
|
49
|
+
to one framework or need a live database. If you use raw SQL with goose,
|
|
50
|
+
dbmate, golang-migrate, or a hand-rolled folder, there's nothing that just
|
|
51
|
+
*looks at the filenames* and tells you they're sane. That's migrolint.
|
|
52
|
+
|
|
53
|
+
## What it checks
|
|
54
|
+
|
|
55
|
+
| Rule | Severity | Meaning |
|
|
56
|
+
|------|----------|---------|
|
|
57
|
+
| `DUPE_NUM` | **error** | two migrations share a version number (the merge-collision bug) |
|
|
58
|
+
| `MISSING_DOWN` | warning | an `up` migration has no matching `down` (only flagged if the project uses up/down splits) |
|
|
59
|
+
| `SEQ_GAP` | warning | a hole in an integer sequence — usually a deleted or un-merged migration |
|
|
60
|
+
| `BAD_FORMAT` | warning | a file whose name no known convention recognizes |
|
|
61
|
+
|
|
62
|
+
## Naming conventions it understands
|
|
63
|
+
|
|
64
|
+
migrolint reads version numbers out of the filename, across the conventions
|
|
65
|
+
people actually use:
|
|
66
|
+
|
|
67
|
+
| Convention | Example |
|
|
68
|
+
|------------|---------|
|
|
69
|
+
| Flyway | `V1__init.sql`, `U1__undo.sql`, `R__refresh.sql`, `V1.1__patch.sql` |
|
|
70
|
+
| golang-migrate / dbmate | `0001_create_users.up.sql` + `0001_create_users.down.sql` |
|
|
71
|
+
| goose / Rails | `20230101120000_create_users.sql` (timestamp prefix) |
|
|
72
|
+
| minimalist | `1_init.sql`, `2-add-index.sql` |
|
|
73
|
+
|
|
74
|
+
Timestamp-style versions are recognized but exempt from `SEQ_GAP` (they're not
|
|
75
|
+
meant to be contiguous). Well-known non-migration files (`schema.rb`,
|
|
76
|
+
`structure.sql`, `seeds.rb`, …) and any non-migration extension are skipped.
|
|
77
|
+
|
|
78
|
+
## Usage
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
migrolint # scan the first migrations dir it finds
|
|
82
|
+
migrolint db/migrations # scan a specific dir
|
|
83
|
+
migrolint app/migrations svc/migrations # scan several
|
|
84
|
+
migrolint --json # machine-readable, for tooling
|
|
85
|
+
migrolint --strict # warnings become errors (exit 1) — good for CI
|
|
86
|
+
migrolint --ext .sql,.py # override which extensions count
|
|
87
|
+
migrolint --ignore baseline.sql,seed.sql
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
You can also run it as a module: `python -m migrolint db/migrations`.
|
|
91
|
+
|
|
92
|
+
### As a pre-commit / CI gate
|
|
93
|
+
|
|
94
|
+
```yaml
|
|
95
|
+
# .pre-commit-config.yaml
|
|
96
|
+
- repo: local
|
|
97
|
+
hooks:
|
|
98
|
+
- id: migrolint
|
|
99
|
+
name: migrolint
|
|
100
|
+
entry: migrolint --strict
|
|
101
|
+
language: system
|
|
102
|
+
pass_filenames: false
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
```yaml
|
|
106
|
+
# GitHub Actions
|
|
107
|
+
- run: pipx run migrolint --strict
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Example output
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
migrolint db/migrations (14 files, 12 migrations)
|
|
114
|
+
|
|
115
|
+
✗ DUPE_NUM version 0007 used by 2 migrations:
|
|
116
|
+
0007_add_index.up.sql
|
|
117
|
+
0007_add_orders_fk.up.sql
|
|
118
|
+
⚠ MISSING_DOWN 0009_drop_legacy.up.sql — no matching .down file
|
|
119
|
+
⚠ SEQ_GAP missing version(s): 5
|
|
120
|
+
|
|
121
|
+
1 error, 2 warnings.
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Exit codes
|
|
125
|
+
|
|
126
|
+
| Code | Meaning |
|
|
127
|
+
|------|---------|
|
|
128
|
+
| `0` | clean (or only warnings, without `--strict`) |
|
|
129
|
+
| `1` | errors found — or warnings, when `--strict` is set |
|
|
130
|
+
| `2` | usage / IO error (no migrations dir, unreadable path) |
|
|
131
|
+
|
|
132
|
+
## Also available for Node
|
|
133
|
+
|
|
134
|
+
Same checks, same flags: [`npx migrolint`](https://www.npmjs.com/package/migrolint)
|
|
135
|
+
(source: [migrolint](https://github.com/jjdoor/migrolint)). Both ports read
|
|
136
|
+
filenames identically, so a mixed-language team gets the same verdict.
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
MIT
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# migrolint
|
|
2
|
+
|
|
3
|
+
**A framework-agnostic linter for your database migrations folder.** It catches
|
|
4
|
+
the boring, expensive mistakes that pass locally and only blow up when CI
|
|
5
|
+
actually runs the migrations — duplicate version numbers, sequence gaps, and
|
|
6
|
+
`up` migrations with no matching `down`. No database connection, no framework
|
|
7
|
+
lock-in, **zero dependencies** (pure standard library).
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install migrolint
|
|
11
|
+
|
|
12
|
+
migrolint # auto-detects ./migrations, db/migrations, ...
|
|
13
|
+
migrolint db/migrations --strict
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## The problem
|
|
17
|
+
|
|
18
|
+
Two developers branch off `main`, each adds `0007_add_index.sql`, both merge.
|
|
19
|
+
Now your migrations folder has two migrations claiming version `0007`. Your
|
|
20
|
+
runner picks one and silently skips the other — or aborts the whole deploy.
|
|
21
|
+
This is a [known, recurring failure](https://github.com/golang-migrate/migrate/issues/574)
|
|
22
|
+
in every sequence-numbered migration tool.
|
|
23
|
+
|
|
24
|
+
The existing linters (`django-migration-linter`, Flyway's own checks) are tied
|
|
25
|
+
to one framework or need a live database. If you use raw SQL with goose,
|
|
26
|
+
dbmate, golang-migrate, or a hand-rolled folder, there's nothing that just
|
|
27
|
+
*looks at the filenames* and tells you they're sane. That's migrolint.
|
|
28
|
+
|
|
29
|
+
## What it checks
|
|
30
|
+
|
|
31
|
+
| Rule | Severity | Meaning |
|
|
32
|
+
|------|----------|---------|
|
|
33
|
+
| `DUPE_NUM` | **error** | two migrations share a version number (the merge-collision bug) |
|
|
34
|
+
| `MISSING_DOWN` | warning | an `up` migration has no matching `down` (only flagged if the project uses up/down splits) |
|
|
35
|
+
| `SEQ_GAP` | warning | a hole in an integer sequence — usually a deleted or un-merged migration |
|
|
36
|
+
| `BAD_FORMAT` | warning | a file whose name no known convention recognizes |
|
|
37
|
+
|
|
38
|
+
## Naming conventions it understands
|
|
39
|
+
|
|
40
|
+
migrolint reads version numbers out of the filename, across the conventions
|
|
41
|
+
people actually use:
|
|
42
|
+
|
|
43
|
+
| Convention | Example |
|
|
44
|
+
|------------|---------|
|
|
45
|
+
| Flyway | `V1__init.sql`, `U1__undo.sql`, `R__refresh.sql`, `V1.1__patch.sql` |
|
|
46
|
+
| golang-migrate / dbmate | `0001_create_users.up.sql` + `0001_create_users.down.sql` |
|
|
47
|
+
| goose / Rails | `20230101120000_create_users.sql` (timestamp prefix) |
|
|
48
|
+
| minimalist | `1_init.sql`, `2-add-index.sql` |
|
|
49
|
+
|
|
50
|
+
Timestamp-style versions are recognized but exempt from `SEQ_GAP` (they're not
|
|
51
|
+
meant to be contiguous). Well-known non-migration files (`schema.rb`,
|
|
52
|
+
`structure.sql`, `seeds.rb`, …) and any non-migration extension are skipped.
|
|
53
|
+
|
|
54
|
+
## Usage
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
migrolint # scan the first migrations dir it finds
|
|
58
|
+
migrolint db/migrations # scan a specific dir
|
|
59
|
+
migrolint app/migrations svc/migrations # scan several
|
|
60
|
+
migrolint --json # machine-readable, for tooling
|
|
61
|
+
migrolint --strict # warnings become errors (exit 1) — good for CI
|
|
62
|
+
migrolint --ext .sql,.py # override which extensions count
|
|
63
|
+
migrolint --ignore baseline.sql,seed.sql
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
You can also run it as a module: `python -m migrolint db/migrations`.
|
|
67
|
+
|
|
68
|
+
### As a pre-commit / CI gate
|
|
69
|
+
|
|
70
|
+
```yaml
|
|
71
|
+
# .pre-commit-config.yaml
|
|
72
|
+
- repo: local
|
|
73
|
+
hooks:
|
|
74
|
+
- id: migrolint
|
|
75
|
+
name: migrolint
|
|
76
|
+
entry: migrolint --strict
|
|
77
|
+
language: system
|
|
78
|
+
pass_filenames: false
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
```yaml
|
|
82
|
+
# GitHub Actions
|
|
83
|
+
- run: pipx run migrolint --strict
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Example output
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
migrolint db/migrations (14 files, 12 migrations)
|
|
90
|
+
|
|
91
|
+
✗ DUPE_NUM version 0007 used by 2 migrations:
|
|
92
|
+
0007_add_index.up.sql
|
|
93
|
+
0007_add_orders_fk.up.sql
|
|
94
|
+
⚠ MISSING_DOWN 0009_drop_legacy.up.sql — no matching .down file
|
|
95
|
+
⚠ SEQ_GAP missing version(s): 5
|
|
96
|
+
|
|
97
|
+
1 error, 2 warnings.
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Exit codes
|
|
101
|
+
|
|
102
|
+
| Code | Meaning |
|
|
103
|
+
|------|---------|
|
|
104
|
+
| `0` | clean (or only warnings, without `--strict`) |
|
|
105
|
+
| `1` | errors found — or warnings, when `--strict` is set |
|
|
106
|
+
| `2` | usage / IO error (no migrations dir, unreadable path) |
|
|
107
|
+
|
|
108
|
+
## Also available for Node
|
|
109
|
+
|
|
110
|
+
Same checks, same flags: [`npx migrolint`](https://www.npmjs.com/package/migrolint)
|
|
111
|
+
(source: [migrolint](https://github.com/jjdoor/migrolint)). Both ports read
|
|
112
|
+
filenames identically, so a mixed-language team gets the same verdict.
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
MIT
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "migrolint"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Framework-agnostic linter for database migration folders — catch duplicate version numbers, sequence gaps, and missing down-migrations before CI does. Zero dependencies."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "yyfjj" }]
|
|
13
|
+
keywords = ["migration", "migrations", "database", "linter", "sql", "flyway", "golang-migrate", "dbmate", "goose", "cli", "pre-commit", "devops"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Topic :: Database",
|
|
22
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
23
|
+
"Topic :: Utilities",
|
|
24
|
+
]
|
|
25
|
+
dependencies = []
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/jjdoor/migrolint-py"
|
|
29
|
+
Repository = "https://github.com/jjdoor/migrolint-py"
|
|
30
|
+
Issues = "https://github.com/jjdoor/migrolint-py/issues"
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
migrolint = "migrolint.cli:main"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools]
|
|
36
|
+
package-dir = { "" = "src" }
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.packages.find]
|
|
39
|
+
where = ["src"]
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""migrolint command-line interface."""
|
|
2
|
+
|
|
3
|
+
import json as _json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from . import core
|
|
8
|
+
|
|
9
|
+
VERSION = "0.1.0"
|
|
10
|
+
|
|
11
|
+
# ---- tiny color helpers (no dep) ----
|
|
12
|
+
_COLOR = sys.stdout.isatty() and not os.environ.get("NO_COLOR")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _c(code, s):
|
|
16
|
+
return f"\x1b[{code}m{s}\x1b[0m" if _COLOR else s
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def red(s): return _c("31", s)
|
|
20
|
+
def green(s): return _c("32", s)
|
|
21
|
+
def yellow(s): return _c("33", s)
|
|
22
|
+
def dim(s): return _c("2", s)
|
|
23
|
+
def bold(s): return _c("1", s)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Directories migrolint looks in when you don't name one.
|
|
27
|
+
CANDIDATE_DIRS = [
|
|
28
|
+
"migrations", "db/migrations", "database/migrations", "migrate",
|
|
29
|
+
"sql/migrations", "priv/repo/migrations", "supabase/migrations",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
HELP = f"""{bold('migrolint')} — framework-agnostic sanity check for your migrations folder.
|
|
33
|
+
|
|
34
|
+
{bold('Usage')}
|
|
35
|
+
migrolint [dir...] Scan migration dirs (auto-detects common paths)
|
|
36
|
+
migrolint --json Machine-readable output
|
|
37
|
+
migrolint --strict Treat warnings as errors (exit 1)
|
|
38
|
+
migrolint --ext .sql,.py Override which extensions count as migrations
|
|
39
|
+
migrolint --ignore a,b Extra filenames to skip
|
|
40
|
+
migrolint --version
|
|
41
|
+
|
|
42
|
+
{bold('Checks')}
|
|
43
|
+
{red('DUPE_NUM')} two migrations share a version number {dim('(error)')}
|
|
44
|
+
{yellow('MISSING_DOWN')} an up migration with no matching .down {dim('(warning)')}
|
|
45
|
+
{yellow('SEQ_GAP')} a hole in an integer sequence {dim('(warning)')}
|
|
46
|
+
{yellow('BAD_FORMAT')} a file whose name no convention parses {dim('(warning)')}
|
|
47
|
+
|
|
48
|
+
{bold('Recognizes')} Flyway (V1__/U1__/R__) · golang-migrate/dbmate (0001_x.up.sql) ·
|
|
49
|
+
goose/Rails timestamps · minimalist (1_x.sql)
|
|
50
|
+
|
|
51
|
+
{bold('Exit')} 0 clean · 1 errors (or warnings with --strict) · 2 usage/IO error
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def die(msg):
|
|
56
|
+
sys.stderr.write(red(f"migrolint: {msg}\n"))
|
|
57
|
+
sys.exit(2)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_args(argv):
|
|
61
|
+
opts = {"dirs": [], "json": False, "strict": False, "quiet": False, "exts": None, "ignore": None}
|
|
62
|
+
i = 0
|
|
63
|
+
while i < len(argv):
|
|
64
|
+
a = argv[i]
|
|
65
|
+
if a == "--json":
|
|
66
|
+
opts["json"] = True
|
|
67
|
+
elif a == "--strict":
|
|
68
|
+
opts["strict"] = True
|
|
69
|
+
elif a in ("--quiet", "-q"):
|
|
70
|
+
opts["quiet"] = True
|
|
71
|
+
elif a == "--ext":
|
|
72
|
+
i += 1
|
|
73
|
+
if i >= len(argv):
|
|
74
|
+
die("--ext needs a comma-separated list, e.g. --ext .sql,.py")
|
|
75
|
+
opts["exts"] = [e if e.startswith(".") else "." + e
|
|
76
|
+
for e in (x.strip().lower() for x in argv[i].split(",")) if e]
|
|
77
|
+
elif a == "--ignore":
|
|
78
|
+
i += 1
|
|
79
|
+
if i >= len(argv):
|
|
80
|
+
die("--ignore needs a comma-separated list of filenames")
|
|
81
|
+
opts["ignore"] = [s.strip() for s in argv[i].split(",") if s.strip()]
|
|
82
|
+
elif a.startswith("-"):
|
|
83
|
+
die(f"unknown flag: {a} (try --help)")
|
|
84
|
+
else:
|
|
85
|
+
opts["dirs"].append(a)
|
|
86
|
+
i += 1
|
|
87
|
+
return opts
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def resolve_dirs(requested):
|
|
91
|
+
if requested:
|
|
92
|
+
return requested
|
|
93
|
+
found = [d for d in CANDIDATE_DIRS if os.path.isdir(d)]
|
|
94
|
+
if not found:
|
|
95
|
+
die("no migrations directory found (looked for: " + ", ".join(CANDIDATE_DIRS) + ").\n"
|
|
96
|
+
" Pass a path explicitly: migrolint path/to/migrations")
|
|
97
|
+
return [found[0]]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def analyze_dir(directory, opts):
|
|
101
|
+
if not os.path.exists(directory):
|
|
102
|
+
die(f"cannot read {directory}: no such file or directory")
|
|
103
|
+
if not os.path.isdir(directory):
|
|
104
|
+
die(f"not a directory: {directory}")
|
|
105
|
+
try:
|
|
106
|
+
names = os.listdir(directory)
|
|
107
|
+
except OSError as e:
|
|
108
|
+
die(f"cannot read {directory}: {e}")
|
|
109
|
+
ignore = (core.DEFAULT_IGNORE | set(opts["ignore"])) if opts["ignore"] else core.DEFAULT_IGNORE
|
|
110
|
+
result = core.analyze(names, ignore=ignore, exts=opts["exts"] or core.MIGRATION_EXTS)
|
|
111
|
+
result["dir"] = directory
|
|
112
|
+
return result
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def fmt_missing(missing):
|
|
116
|
+
if len(missing) <= 15:
|
|
117
|
+
return ", ".join(str(v) for v in missing)
|
|
118
|
+
return ", ".join(str(v) for v in missing[:15]) + dim(f" (+{len(missing) - 15} more)")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def print_human(report, opts):
|
|
122
|
+
errors, warnings, stats = report["errors"], report["warnings"], report["stats"]
|
|
123
|
+
mig = stats["migrations"]
|
|
124
|
+
if not errors and not warnings:
|
|
125
|
+
if not opts["quiet"]:
|
|
126
|
+
tail = dim("— {} migration(s), no issues".format(mig))
|
|
127
|
+
sys.stdout.write("{} {} {}\n".format(green("✓"), report["dir"], tail))
|
|
128
|
+
return
|
|
129
|
+
info = dim("({} files, {} migrations)".format(stats["scanned"], mig))
|
|
130
|
+
sys.stdout.write("{} {} {}\n\n".format(bold("migrolint"), report["dir"], info))
|
|
131
|
+
for e in errors:
|
|
132
|
+
if e["rule"] == "DUPE_NUM":
|
|
133
|
+
sys.stdout.write(f" {red('✗ DUPE_NUM')} version {bold(e['version'])} used by {len(e['files'])} migrations:\n")
|
|
134
|
+
for f in e["files"]:
|
|
135
|
+
sys.stdout.write(f" {f}\n")
|
|
136
|
+
for w in warnings:
|
|
137
|
+
if w["rule"] == "MISSING_DOWN":
|
|
138
|
+
sys.stdout.write(f" {yellow('⚠ MISSING_DOWN')} {w['file']} {dim('— no matching .down file')}\n")
|
|
139
|
+
elif w["rule"] == "SEQ_GAP":
|
|
140
|
+
sys.stdout.write(f" {yellow('⚠ SEQ_GAP')} missing version(s): {fmt_missing(w['missing'])}\n")
|
|
141
|
+
elif w["rule"] == "BAD_FORMAT":
|
|
142
|
+
sys.stdout.write(f" {yellow('⚠ BAD_FORMAT')} {w['file']} {dim('— no recognized version prefix')}\n")
|
|
143
|
+
parts = []
|
|
144
|
+
if errors:
|
|
145
|
+
parts.append(red(f"{len(errors)} error{'s' if len(errors) > 1 else ''}"))
|
|
146
|
+
if warnings:
|
|
147
|
+
parts.append(yellow(f"{len(warnings)} warning{'s' if len(warnings) > 1 else ''}"))
|
|
148
|
+
sys.stdout.write("\n" + ", ".join(parts) + ".\n")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def main(argv=None):
|
|
152
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
153
|
+
if "-h" in argv or "--help" in argv:
|
|
154
|
+
sys.stdout.write(HELP)
|
|
155
|
+
return 0
|
|
156
|
+
if "-v" in argv or "--version" in argv:
|
|
157
|
+
sys.stdout.write(VERSION + "\n")
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
opts = parse_args(argv)
|
|
161
|
+
dirs = resolve_dirs(opts["dirs"])
|
|
162
|
+
reports = [analyze_dir(d, opts) for d in dirs]
|
|
163
|
+
|
|
164
|
+
if opts["json"]:
|
|
165
|
+
payload = [{"dir": r["dir"], "stats": r["stats"], "errors": r["errors"], "warnings": r["warnings"]}
|
|
166
|
+
for r in reports]
|
|
167
|
+
sys.stdout.write(_json.dumps(payload[0] if len(dirs) == 1 else payload, indent=2) + "\n")
|
|
168
|
+
else:
|
|
169
|
+
for i, r in enumerate(reports):
|
|
170
|
+
if i > 0:
|
|
171
|
+
sys.stdout.write("\n")
|
|
172
|
+
print_human(r, opts)
|
|
173
|
+
|
|
174
|
+
total_errors = sum(len(r["errors"]) for r in reports)
|
|
175
|
+
total_warnings = sum(len(r["warnings"]) for r in reports)
|
|
176
|
+
if total_errors > 0 or (opts["strict"] and total_warnings > 0):
|
|
177
|
+
return 1
|
|
178
|
+
return 0
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""migrolint core — pure migration-filename analysis. No fs, no DB, no clock.
|
|
2
|
+
|
|
3
|
+
Database migration folders break in three boring, expensive ways that no
|
|
4
|
+
framework-agnostic tool catches before CI runs the migrations:
|
|
5
|
+
|
|
6
|
+
- DUPE_NUM two migrations claim the same version number. The classic
|
|
7
|
+
merge-of-two-branches collision (golang-migrate #574) — the
|
|
8
|
+
runner picks one, silently skips the other, or aborts.
|
|
9
|
+
- MISSING_DOWN an ``up`` migration with no matching ``down`` (only flagged
|
|
10
|
+
for projects that use up/down splits).
|
|
11
|
+
- SEQ_GAP a hole in an integer sequence — usually a deleted/un-merged
|
|
12
|
+
migration. Advisory.
|
|
13
|
+
- BAD_FORMAT a file in the dir whose name no convention recognizes.
|
|
14
|
+
|
|
15
|
+
The parser understands the conventions people actually use: Flyway
|
|
16
|
+
(``V1__x.sql``, ``U1__x.sql``, ``R__x.sql``), golang-migrate / dbmate
|
|
17
|
+
(``0001_x.up.sql`` + ``0001_x.down.sql``), goose / Rails timestamps
|
|
18
|
+
(``20230101120000_x.sql``), and the minimalist ``1_x.sql``. This module mirrors
|
|
19
|
+
the Node port function-for-function so both produce identical verdicts.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
|
|
24
|
+
# File extensions that can hold a migration body. Anything else in the folder
|
|
25
|
+
# is ignored outright (READMEs, .gitkeep) rather than flagged.
|
|
26
|
+
MIGRATION_EXTS = [".sql", ".py", ".rb", ".js", ".ts", ".go"]
|
|
27
|
+
|
|
28
|
+
# Well-known non-migration files that legitimately live in a migrations dir.
|
|
29
|
+
DEFAULT_IGNORE = {
|
|
30
|
+
"schema.rb", "structure.sql", "schema.sql", "seeds.rb", "seed.sql", "database.sql",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# Integer versions at or above this look like dates/timestamps, not an
|
|
34
|
+
# incrementing sequence — so they're exempt from gap analysis.
|
|
35
|
+
SEQ_MAX = 1_000_000
|
|
36
|
+
|
|
37
|
+
_DIR_SUFFIX_RE = re.compile(r"\.(up|down)(\.(?:sql|py|rb|js|ts|go))$", re.IGNORECASE)
|
|
38
|
+
_FLYWAY_RE = re.compile(r"^([VUR])(\d[\d.]*)?__(.*)$")
|
|
39
|
+
_GENERIC_RE = re.compile(r"^(\d+)[_-](.*)$")
|
|
40
|
+
_BARE_NUM_RE = re.compile(r"^(\d+)$")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def ext_of(name):
|
|
44
|
+
i = name.rfind(".")
|
|
45
|
+
return name[i:].lower() if i > 0 else ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse_migration(filename):
|
|
49
|
+
"""Parse a migration filename into structured fields, or ``None`` if no
|
|
50
|
+
recognized convention matches (a BAD_FORMAT candidate).
|
|
51
|
+
|
|
52
|
+
Returns a dict with: ``versionRaw`` (str|None), ``numeric`` (int|None),
|
|
53
|
+
``direction`` ('up'|'down'|'both'), ``split`` (bool), ``repeatable`` (bool),
|
|
54
|
+
``label`` (str).
|
|
55
|
+
"""
|
|
56
|
+
# 1. Peel off an .up/.down suffix (golang-migrate / dbmate split style).
|
|
57
|
+
direction = "both"
|
|
58
|
+
split = False
|
|
59
|
+
m = _DIR_SUFFIX_RE.search(filename)
|
|
60
|
+
if m:
|
|
61
|
+
direction = m.group(1).lower()
|
|
62
|
+
split = True
|
|
63
|
+
stem = filename[: m.start()]
|
|
64
|
+
else:
|
|
65
|
+
ext = ext_of(filename)
|
|
66
|
+
stem = filename[: -len(ext)] if ext else filename
|
|
67
|
+
|
|
68
|
+
# 2. Flyway: V/U/R prefix + double-underscore description.
|
|
69
|
+
fly = _FLYWAY_RE.match(stem)
|
|
70
|
+
if fly:
|
|
71
|
+
kind = fly.group(1).upper()
|
|
72
|
+
version_raw = fly.group(2) or None
|
|
73
|
+
if kind == "R":
|
|
74
|
+
return {"versionRaw": None, "numeric": None, "direction": "both",
|
|
75
|
+
"split": False, "repeatable": True, "label": fly.group(3)}
|
|
76
|
+
numeric = int(version_raw) if version_raw and version_raw.isdigit() else None
|
|
77
|
+
return {
|
|
78
|
+
"versionRaw": version_raw,
|
|
79
|
+
"numeric": numeric,
|
|
80
|
+
"direction": "down" if kind == "U" else (direction if split else "up"),
|
|
81
|
+
"split": split,
|
|
82
|
+
"repeatable": False,
|
|
83
|
+
"label": fly.group(3),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
# 3. Generic numeric prefix: "0001_create", "20230101120000_init", "1-init".
|
|
87
|
+
gen = _GENERIC_RE.match(stem) or _BARE_NUM_RE.match(stem)
|
|
88
|
+
if gen:
|
|
89
|
+
version_raw = gen.group(1)
|
|
90
|
+
numeric = int(version_raw) if version_raw.isdigit() else None
|
|
91
|
+
label = gen.group(2) if gen.re.groups >= 2 else ""
|
|
92
|
+
return {"versionRaw": version_raw, "numeric": numeric, "direction": direction,
|
|
93
|
+
"split": split, "repeatable": False, "label": label}
|
|
94
|
+
|
|
95
|
+
return None # unrecognized — BAD_FORMAT
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def analyze(filenames, ignore=None, exts=None):
|
|
99
|
+
"""Analyze a folder's filenames. Returns ``{errors, warnings, stats}``.
|
|
100
|
+
|
|
101
|
+
Pure: give it the list of basenames, it gives you the verdict.
|
|
102
|
+
"""
|
|
103
|
+
ignore = ignore if ignore is not None else DEFAULT_IGNORE
|
|
104
|
+
exts = exts if exts is not None else MIGRATION_EXTS
|
|
105
|
+
|
|
106
|
+
# Sort first so the verdict is deterministic regardless of listdir order.
|
|
107
|
+
candidates = [f for f in sorted(filenames) if ext_of(f) in exts and f not in ignore]
|
|
108
|
+
parsed = []
|
|
109
|
+
bad_format = []
|
|
110
|
+
for f in candidates:
|
|
111
|
+
m = parse_migration(f)
|
|
112
|
+
if m is None:
|
|
113
|
+
bad_format.append(f)
|
|
114
|
+
else:
|
|
115
|
+
parsed.append(dict(file=f, **m))
|
|
116
|
+
|
|
117
|
+
versioned = [p for p in parsed if not p["repeatable"] and p["versionRaw"] is not None]
|
|
118
|
+
errors = []
|
|
119
|
+
warnings = []
|
|
120
|
+
|
|
121
|
+
# DUPE_NUM — same (version, direction) used by more than one file. up & down
|
|
122
|
+
# of the same version are a legitimate pair, so they don't collide.
|
|
123
|
+
by_ver_dir = {}
|
|
124
|
+
for p in versioned:
|
|
125
|
+
by_ver_dir.setdefault((p["versionRaw"], p["direction"]), []).append(p["file"])
|
|
126
|
+
dupe_by_version = {}
|
|
127
|
+
for (version, _direction), files in by_ver_dir.items():
|
|
128
|
+
if len(files) > 1:
|
|
129
|
+
dupe_by_version.setdefault(version, []).extend(files)
|
|
130
|
+
for version, files in dupe_by_version.items():
|
|
131
|
+
errors.append({"rule": "DUPE_NUM", "version": version, "files": sorted(files)})
|
|
132
|
+
|
|
133
|
+
# MISSING_DOWN — only when the project actually uses .up/.down splits.
|
|
134
|
+
uses_split = any(p["split"] and p["direction"] == "down" for p in versioned)
|
|
135
|
+
if uses_split:
|
|
136
|
+
down_versions = {p["versionRaw"] for p in versioned if p["split"] and p["direction"] == "down"}
|
|
137
|
+
seen_up = set()
|
|
138
|
+
for p in versioned:
|
|
139
|
+
if (p["split"] and p["direction"] == "up"
|
|
140
|
+
and p["versionRaw"] not in down_versions
|
|
141
|
+
and p["versionRaw"] not in seen_up):
|
|
142
|
+
seen_up.add(p["versionRaw"])
|
|
143
|
+
warnings.append({"rule": "MISSING_DOWN", "version": p["versionRaw"], "file": p["file"]})
|
|
144
|
+
|
|
145
|
+
# SEQ_GAP — integer sequences only (timestamps/dotted versions skipped).
|
|
146
|
+
seq = sorted({p["numeric"] for p in versioned if p["numeric"] is not None and p["numeric"] < SEQ_MAX})
|
|
147
|
+
if len(seq) >= 2:
|
|
148
|
+
present = set(seq)
|
|
149
|
+
missing = [v for v in range(seq[0], seq[-1]) if v not in present]
|
|
150
|
+
if missing:
|
|
151
|
+
warnings.append({"rule": "SEQ_GAP", "missing": missing})
|
|
152
|
+
|
|
153
|
+
# BAD_FORMAT — recognized extension but no parseable version.
|
|
154
|
+
for f in bad_format:
|
|
155
|
+
warnings.append({"rule": "BAD_FORMAT", "file": f})
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
"errors": errors,
|
|
159
|
+
"warnings": warnings,
|
|
160
|
+
"stats": {"scanned": len(filenames), "candidates": len(candidates), "migrations": len(versioned)},
|
|
161
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: migrolint
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Framework-agnostic linter for database migration folders — catch duplicate version numbers, sequence gaps, and missing down-migrations before CI does. Zero dependencies.
|
|
5
|
+
Author: yyfjj
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jjdoor/migrolint-py
|
|
8
|
+
Project-URL: Repository, https://github.com/jjdoor/migrolint-py
|
|
9
|
+
Project-URL: Issues, https://github.com/jjdoor/migrolint-py/issues
|
|
10
|
+
Keywords: migration,migrations,database,linter,sql,flyway,golang-migrate,dbmate,goose,cli,pre-commit,devops
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Database
|
|
18
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
19
|
+
Classifier: Topic :: Utilities
|
|
20
|
+
Requires-Python: >=3.8
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# migrolint
|
|
26
|
+
|
|
27
|
+
**A framework-agnostic linter for your database migrations folder.** It catches
|
|
28
|
+
the boring, expensive mistakes that pass locally and only blow up when CI
|
|
29
|
+
actually runs the migrations — duplicate version numbers, sequence gaps, and
|
|
30
|
+
`up` migrations with no matching `down`. No database connection, no framework
|
|
31
|
+
lock-in, **zero dependencies** (pure standard library).
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install migrolint
|
|
35
|
+
|
|
36
|
+
migrolint # auto-detects ./migrations, db/migrations, ...
|
|
37
|
+
migrolint db/migrations --strict
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## The problem
|
|
41
|
+
|
|
42
|
+
Two developers branch off `main`, each adds `0007_add_index.sql`, both merge.
|
|
43
|
+
Now your migrations folder has two migrations claiming version `0007`. Your
|
|
44
|
+
runner picks one and silently skips the other — or aborts the whole deploy.
|
|
45
|
+
This is a [known, recurring failure](https://github.com/golang-migrate/migrate/issues/574)
|
|
46
|
+
in every sequence-numbered migration tool.
|
|
47
|
+
|
|
48
|
+
The existing linters (`django-migration-linter`, Flyway's own checks) are tied
|
|
49
|
+
to one framework or need a live database. If you use raw SQL with goose,
|
|
50
|
+
dbmate, golang-migrate, or a hand-rolled folder, there's nothing that just
|
|
51
|
+
*looks at the filenames* and tells you they're sane. That's migrolint.
|
|
52
|
+
|
|
53
|
+
## What it checks
|
|
54
|
+
|
|
55
|
+
| Rule | Severity | Meaning |
|
|
56
|
+
|------|----------|---------|
|
|
57
|
+
| `DUPE_NUM` | **error** | two migrations share a version number (the merge-collision bug) |
|
|
58
|
+
| `MISSING_DOWN` | warning | an `up` migration has no matching `down` (only flagged if the project uses up/down splits) |
|
|
59
|
+
| `SEQ_GAP` | warning | a hole in an integer sequence — usually a deleted or un-merged migration |
|
|
60
|
+
| `BAD_FORMAT` | warning | a file whose name no known convention recognizes |
|
|
61
|
+
|
|
62
|
+
## Naming conventions it understands
|
|
63
|
+
|
|
64
|
+
migrolint reads version numbers out of the filename, across the conventions
|
|
65
|
+
people actually use:
|
|
66
|
+
|
|
67
|
+
| Convention | Example |
|
|
68
|
+
|------------|---------|
|
|
69
|
+
| Flyway | `V1__init.sql`, `U1__undo.sql`, `R__refresh.sql`, `V1.1__patch.sql` |
|
|
70
|
+
| golang-migrate / dbmate | `0001_create_users.up.sql` + `0001_create_users.down.sql` |
|
|
71
|
+
| goose / Rails | `20230101120000_create_users.sql` (timestamp prefix) |
|
|
72
|
+
| minimalist | `1_init.sql`, `2-add-index.sql` |
|
|
73
|
+
|
|
74
|
+
Timestamp-style versions are recognized but exempt from `SEQ_GAP` (they're not
|
|
75
|
+
meant to be contiguous). Well-known non-migration files (`schema.rb`,
|
|
76
|
+
`structure.sql`, `seeds.rb`, …) and any non-migration extension are skipped.
|
|
77
|
+
|
|
78
|
+
## Usage
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
migrolint # scan the first migrations dir it finds
|
|
82
|
+
migrolint db/migrations # scan a specific dir
|
|
83
|
+
migrolint app/migrations svc/migrations # scan several
|
|
84
|
+
migrolint --json # machine-readable, for tooling
|
|
85
|
+
migrolint --strict # warnings become errors (exit 1) — good for CI
|
|
86
|
+
migrolint --ext .sql,.py # override which extensions count
|
|
87
|
+
migrolint --ignore baseline.sql,seed.sql
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
You can also run it as a module: `python -m migrolint db/migrations`.
|
|
91
|
+
|
|
92
|
+
### As a pre-commit / CI gate
|
|
93
|
+
|
|
94
|
+
```yaml
|
|
95
|
+
# .pre-commit-config.yaml
|
|
96
|
+
- repo: local
|
|
97
|
+
hooks:
|
|
98
|
+
- id: migrolint
|
|
99
|
+
name: migrolint
|
|
100
|
+
entry: migrolint --strict
|
|
101
|
+
language: system
|
|
102
|
+
pass_filenames: false
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
```yaml
|
|
106
|
+
# GitHub Actions
|
|
107
|
+
- run: pipx run migrolint --strict
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Example output
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
migrolint db/migrations (14 files, 12 migrations)
|
|
114
|
+
|
|
115
|
+
✗ DUPE_NUM version 0007 used by 2 migrations:
|
|
116
|
+
0007_add_index.up.sql
|
|
117
|
+
0007_add_orders_fk.up.sql
|
|
118
|
+
⚠ MISSING_DOWN 0009_drop_legacy.up.sql — no matching .down file
|
|
119
|
+
⚠ SEQ_GAP missing version(s): 5
|
|
120
|
+
|
|
121
|
+
1 error, 2 warnings.
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Exit codes
|
|
125
|
+
|
|
126
|
+
| Code | Meaning |
|
|
127
|
+
|------|---------|
|
|
128
|
+
| `0` | clean (or only warnings, without `--strict`) |
|
|
129
|
+
| `1` | errors found — or warnings, when `--strict` is set |
|
|
130
|
+
| `2` | usage / IO error (no migrations dir, unreadable path) |
|
|
131
|
+
|
|
132
|
+
## Also available for Node
|
|
133
|
+
|
|
134
|
+
Same checks, same flags: [`npx migrolint`](https://www.npmjs.com/package/migrolint)
|
|
135
|
+
(source: [migrolint](https://github.com/jjdoor/migrolint)). Both ports read
|
|
136
|
+
filenames identically, so a mixed-language team gets the same verdict.
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/migrolint/__init__.py
|
|
5
|
+
src/migrolint/__main__.py
|
|
6
|
+
src/migrolint/cli.py
|
|
7
|
+
src/migrolint/core.py
|
|
8
|
+
src/migrolint.egg-info/PKG-INFO
|
|
9
|
+
src/migrolint.egg-info/SOURCES.txt
|
|
10
|
+
src/migrolint.egg-info/dependency_links.txt
|
|
11
|
+
src/migrolint.egg-info/entry_points.txt
|
|
12
|
+
src/migrolint.egg-info/top_level.txt
|
|
13
|
+
tests/test_core.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
migrolint
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from migrolint.core import parse_migration, analyze
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
# ---- parse_migration: the convention zoo ----------------------------------
|
|
5
|
+
|
|
6
|
+
def test_flyway_versioned_is_up():
|
|
7
|
+
assert parse_migration("V1__init.sql") == {
|
|
8
|
+
"versionRaw": "1", "numeric": 1, "direction": "up",
|
|
9
|
+
"split": False, "repeatable": False, "label": "init",
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_flyway_undo_is_down():
|
|
14
|
+
m = parse_migration("U1__rollback.sql")
|
|
15
|
+
assert m["direction"] == "down"
|
|
16
|
+
assert m["versionRaw"] == "1"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_flyway_repeatable_has_no_version():
|
|
20
|
+
m = parse_migration("R__refresh_views.sql")
|
|
21
|
+
assert m["repeatable"] is True
|
|
22
|
+
assert m["versionRaw"] is None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_flyway_dotted_version_keeps_raw_numeric_none():
|
|
26
|
+
m = parse_migration("V1.1__patch.sql")
|
|
27
|
+
assert m["versionRaw"] == "1.1"
|
|
28
|
+
assert m["numeric"] is None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_golang_migrate_up_split():
|
|
32
|
+
assert parse_migration("0001_create_users.up.sql") == {
|
|
33
|
+
"versionRaw": "0001", "numeric": 1, "direction": "up",
|
|
34
|
+
"split": True, "repeatable": False, "label": "create_users",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_golang_migrate_down_split():
|
|
39
|
+
m = parse_migration("0001_create_users.down.sql")
|
|
40
|
+
assert m["direction"] == "down"
|
|
41
|
+
assert m["split"] is True
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_goose_rails_timestamp_not_sequential():
|
|
45
|
+
m = parse_migration("20230101120000_init.rb")
|
|
46
|
+
assert m["versionRaw"] == "20230101120000"
|
|
47
|
+
assert m["numeric"] == 20230101120000
|
|
48
|
+
assert m["direction"] == "both"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_minimalist_numeric_prefix():
|
|
52
|
+
assert parse_migration("1_init.sql")["numeric"] == 1
|
|
53
|
+
assert parse_migration("2-add-index.sql")["label"] == "add-index"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_bare_number_no_label():
|
|
57
|
+
m = parse_migration("0005.sql")
|
|
58
|
+
assert m["versionRaw"] == "0005"
|
|
59
|
+
assert m["label"] == ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_unrecognized_returns_none():
|
|
63
|
+
assert parse_migration("create_users.sql") is None
|
|
64
|
+
assert parse_migration("notes.sql") is None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---- analyze: the four rules ----------------------------------------------
|
|
68
|
+
|
|
69
|
+
def test_dupe_num_two_ups_same_version_is_error():
|
|
70
|
+
r = analyze(["0001_a.up.sql", "0001_b.up.sql"])
|
|
71
|
+
assert len(r["errors"]) == 1
|
|
72
|
+
assert r["errors"][0]["rule"] == "DUPE_NUM"
|
|
73
|
+
assert r["errors"][0]["version"] == "0001"
|
|
74
|
+
assert r["errors"][0]["files"] == ["0001_a.up.sql", "0001_b.up.sql"]
|
|
75
|
+
assert len(r["warnings"]) == 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_matched_up_down_pair_is_not_duplicate():
|
|
79
|
+
r = analyze(["0001_a.up.sql", "0001_a.down.sql"])
|
|
80
|
+
assert len(r["errors"]) == 0
|
|
81
|
+
assert len(r["warnings"]) == 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_missing_down_only_when_project_uses_splits():
|
|
85
|
+
r = analyze(["0001_a.up.sql", "0001_a.down.sql", "0002_b.up.sql"])
|
|
86
|
+
assert len(r["errors"]) == 0
|
|
87
|
+
md = [w for w in r["warnings"] if w["rule"] == "MISSING_DOWN"]
|
|
88
|
+
assert len(md) == 1
|
|
89
|
+
assert md[0]["version"] == "0002"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_single_file_does_not_trigger_missing_down():
|
|
93
|
+
r = analyze(["0001_a.sql", "0002_b.sql"])
|
|
94
|
+
assert [w for w in r["warnings"] if w["rule"] == "MISSING_DOWN"] == []
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_seq_gap_reports_hole():
|
|
98
|
+
r = analyze(["0001_a.sql", "0003_c.sql"])
|
|
99
|
+
gap = [w for w in r["warnings"] if w["rule"] == "SEQ_GAP"]
|
|
100
|
+
assert len(gap) == 1
|
|
101
|
+
assert gap[0]["missing"] == [2]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_timestamps_never_produce_seq_gap():
|
|
105
|
+
r = analyze(["20230101120000_a.sql", "20230515090000_b.sql"])
|
|
106
|
+
assert [w for w in r["warnings"] if w["rule"] == "SEQ_GAP"] == []
|
|
107
|
+
assert len(r["errors"]) == 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_bad_format_flags_versionless_migration_file():
|
|
111
|
+
r = analyze(["0001_a.sql", "random.sql"])
|
|
112
|
+
bad = [w for w in r["warnings"] if w["rule"] == "BAD_FORMAT"]
|
|
113
|
+
assert len(bad) == 1
|
|
114
|
+
assert bad[0]["file"] == "random.sql"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_non_migration_and_ignored_files_skipped():
|
|
118
|
+
r = analyze(["0001_a.sql", "README.md", ".gitkeep", "schema.rb", "structure.sql"])
|
|
119
|
+
assert len(r["errors"]) == 0
|
|
120
|
+
assert len(r["warnings"]) == 0
|
|
121
|
+
assert r["stats"]["migrations"] == 1
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_stats_count_scanned_vs_migrations():
|
|
125
|
+
r = analyze(["0001_a.up.sql", "0001_a.down.sql", "README.md"])
|
|
126
|
+
assert r["stats"]["scanned"] == 3
|
|
127
|
+
assert r["stats"]["migrations"] == 2
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_flyway_dir_without_downs_is_clean():
|
|
131
|
+
r = analyze(["V1__init.sql", "V2__users.sql", "R__view.sql"])
|
|
132
|
+
assert len(r["errors"]) == 0
|
|
133
|
+
assert len(r["warnings"]) == 0
|