sarj-sql-lint 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.
- sarj_sql_lint-0.1.0/.gitignore +32 -0
- sarj_sql_lint-0.1.0/PKG-INFO +53 -0
- sarj_sql_lint-0.1.0/README.md +34 -0
- sarj_sql_lint-0.1.0/pyproject.toml +57 -0
- sarj_sql_lint-0.1.0/src/sarj_sql_lint/__init__.py +1 -0
- sarj_sql_lint-0.1.0/src/sarj_sql_lint/__main__.py +87 -0
- sarj_sql_lint-0.1.0/src/sarj_sql_lint/py.typed +0 -0
- sarj_sql_lint-0.1.0/src/sarj_sql_lint/rule_base.py +27 -0
- sarj_sql_lint-0.1.0/src/sarj_sql_lint/rules/__init__.py +11 -0
- sarj_sql_lint-0.1.0/src/sarj_sql_lint/rules/enforce_timestamptz.py +46 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
dist/
|
|
3
|
+
build/
|
|
4
|
+
*.tgz
|
|
5
|
+
.npm/
|
|
6
|
+
*.tsbuildinfo
|
|
7
|
+
.turbo/
|
|
8
|
+
.tsup/
|
|
9
|
+
.vitest-cache/
|
|
10
|
+
.eslintcache
|
|
11
|
+
coverage/
|
|
12
|
+
|
|
13
|
+
__pycache__/
|
|
14
|
+
*.py[co]
|
|
15
|
+
.pytest_cache/
|
|
16
|
+
.ruff_cache/
|
|
17
|
+
.mypy_cache/
|
|
18
|
+
.basedpyright/
|
|
19
|
+
.coverage
|
|
20
|
+
.coverage.*
|
|
21
|
+
htmlcov/
|
|
22
|
+
.tox/
|
|
23
|
+
.venv/
|
|
24
|
+
venv/
|
|
25
|
+
*.egg-info/
|
|
26
|
+
|
|
27
|
+
.DS_Store
|
|
28
|
+
Thumbs.db
|
|
29
|
+
.idea/
|
|
30
|
+
.vscode/
|
|
31
|
+
.zed/
|
|
32
|
+
*.swp
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sarj-sql-lint
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Custom SQL lint rules for Postgres migrations — sqlfluff + pygrep, pre-commit-friendly.
|
|
5
|
+
Project-URL: Homepage, https://github.com/sarj-ai/linting/tree/main/packages/sql
|
|
6
|
+
Project-URL: Repository, https://github.com/sarj-ai/linting
|
|
7
|
+
Project-URL: Issues, https://github.com/sarj-ai/linting/issues
|
|
8
|
+
Author: sarj-ai
|
|
9
|
+
License: MIT
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
16
|
+
Requires-Python: >=3.13
|
|
17
|
+
Requires-Dist: sqlfluff>=3.0
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# sarj-sql-lint
|
|
21
|
+
|
|
22
|
+
Custom SQL lint rules for Postgres migrations. AST-based via [sqlfluff](https://sqlfluff.com) for the SARJ101 rule + zero-dependency pygrep regex hooks for migration-hygiene checks. Designed for pre-commit.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
uv tool install sarj-sql-lint
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Pre-commit
|
|
29
|
+
|
|
30
|
+
```yaml
|
|
31
|
+
- repo: https://github.com/sarj-ai/linting
|
|
32
|
+
rev: sql-v0.1.0
|
|
33
|
+
hooks:
|
|
34
|
+
- id: sarj-enforce-timestamptz
|
|
35
|
+
files: '\.sql$'
|
|
36
|
+
- id: sarj-ban-postgres-enums
|
|
37
|
+
files: '\.sql$'
|
|
38
|
+
- id: sarj-ban-create-trigger
|
|
39
|
+
files: '\.sql$'
|
|
40
|
+
- id: sarj-prefer-text-over-varchar
|
|
41
|
+
files: '\.sql$'
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## CLI
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
sarj-sql-lint check --rule enforce-timestamptz svcs/db/db/migrations/*.sql
|
|
48
|
+
sarj-sql-lint list-rules
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Diagnostic format is `path:line:col: CODE message` — Ruff-compatible.
|
|
52
|
+
|
|
53
|
+
Each rule's source under `src/sarj_sql_lint/rules/` carries its own `description` and diagnostic message.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# sarj-sql-lint
|
|
2
|
+
|
|
3
|
+
Custom SQL lint rules for Postgres migrations. AST-based via [sqlfluff](https://sqlfluff.com) for the SARJ101 rule + zero-dependency pygrep regex hooks for migration-hygiene checks. Designed for pre-commit.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
uv tool install sarj-sql-lint
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Pre-commit
|
|
10
|
+
|
|
11
|
+
```yaml
|
|
12
|
+
- repo: https://github.com/sarj-ai/linting
|
|
13
|
+
rev: sql-v0.1.0
|
|
14
|
+
hooks:
|
|
15
|
+
- id: sarj-enforce-timestamptz
|
|
16
|
+
files: '\.sql$'
|
|
17
|
+
- id: sarj-ban-postgres-enums
|
|
18
|
+
files: '\.sql$'
|
|
19
|
+
- id: sarj-ban-create-trigger
|
|
20
|
+
files: '\.sql$'
|
|
21
|
+
- id: sarj-prefer-text-over-varchar
|
|
22
|
+
files: '\.sql$'
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## CLI
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
sarj-sql-lint check --rule enforce-timestamptz svcs/db/db/migrations/*.sql
|
|
29
|
+
sarj-sql-lint list-rules
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Diagnostic format is `path:line:col: CODE message` — Ruff-compatible.
|
|
33
|
+
|
|
34
|
+
Each rule's source under `src/sarj_sql_lint/rules/` carries its own `description` and diagnostic message.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sarj-sql-lint"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Custom SQL lint rules for Postgres migrations — sqlfluff + pygrep, pre-commit-friendly."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [{ name = "sarj-ai" }]
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
requires-python = ">=3.13"
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 4 - Beta",
|
|
11
|
+
"Intended Audience :: Developers",
|
|
12
|
+
"License :: OSI Approved :: MIT License",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3.13",
|
|
15
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
16
|
+
]
|
|
17
|
+
dependencies = [
|
|
18
|
+
"sqlfluff>=3.0",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.scripts]
|
|
22
|
+
sarj-sql-lint = "sarj_sql_lint.__main__:main"
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/sarj-ai/linting/tree/main/packages/sql"
|
|
26
|
+
Repository = "https://github.com/sarj-ai/linting"
|
|
27
|
+
Issues = "https://github.com/sarj-ai/linting/issues"
|
|
28
|
+
|
|
29
|
+
[dependency-groups]
|
|
30
|
+
dev = [
|
|
31
|
+
"pytest>=8.0",
|
|
32
|
+
"pytest-benchmark>=4.0",
|
|
33
|
+
"ruff>=0.6",
|
|
34
|
+
"basedpyright>=1.20",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[build-system]
|
|
38
|
+
requires = ["hatchling"]
|
|
39
|
+
build-backend = "hatchling.build"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["src/sarj_sql_lint"]
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.sdist]
|
|
45
|
+
include = [
|
|
46
|
+
"src",
|
|
47
|
+
"README.md",
|
|
48
|
+
"pyproject.toml",
|
|
49
|
+
]
|
|
50
|
+
exclude = [
|
|
51
|
+
"tests/",
|
|
52
|
+
"**/__pycache__/",
|
|
53
|
+
"**/*.pyc",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
[tool.pytest.ini_options]
|
|
57
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""CLI: sarj-sql-lint check --rule <id> [--rule <id2>] <files>"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from sarj_sql_lint import __version__
|
|
9
|
+
from sarj_sql_lint.rule_base import Diagnostic
|
|
10
|
+
from sarj_sql_lint.rules import REGISTRY
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
SKIP_DIR_NAMES = {
|
|
14
|
+
"node_modules", ".venv", "venv", ".git", "dist", "build", ".next",
|
|
15
|
+
"coverage", "__pycache__", ".pytest_cache", ".ruff_cache", ".mypy_cache",
|
|
16
|
+
".turbo", ".yarn", ".pnpm-store",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _expand_paths(paths: list[Path]) -> list[Path]:
|
|
21
|
+
out: list[Path] = []
|
|
22
|
+
for p in paths:
|
|
23
|
+
if not p.exists():
|
|
24
|
+
continue
|
|
25
|
+
if p.is_file():
|
|
26
|
+
out.append(p)
|
|
27
|
+
continue
|
|
28
|
+
for child in p.rglob("*.sql"):
|
|
29
|
+
if not child.is_file():
|
|
30
|
+
continue
|
|
31
|
+
if any(part in SKIP_DIR_NAMES for part in child.parts):
|
|
32
|
+
continue
|
|
33
|
+
try:
|
|
34
|
+
if child.stat().st_size > 500_000:
|
|
35
|
+
continue
|
|
36
|
+
except OSError:
|
|
37
|
+
continue
|
|
38
|
+
out.append(child)
|
|
39
|
+
return out
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _check(rule_ids: list[str], paths: list[Path]) -> list[Diagnostic]:
|
|
43
|
+
unknown = [rid for rid in rule_ids if rid not in REGISTRY]
|
|
44
|
+
if unknown:
|
|
45
|
+
sys.stderr.write(f"unknown rule(s): {', '.join(unknown)}\n")
|
|
46
|
+
sys.stderr.write(f"available: {', '.join(sorted(REGISTRY))}\n")
|
|
47
|
+
raise SystemExit(2)
|
|
48
|
+
rules = [REGISTRY[rid]() for rid in rule_ids]
|
|
49
|
+
expanded = _expand_paths(paths)
|
|
50
|
+
diags: list[Diagnostic] = []
|
|
51
|
+
for p in expanded:
|
|
52
|
+
try:
|
|
53
|
+
source = p.read_text(encoding="utf-8", errors="replace")
|
|
54
|
+
except OSError:
|
|
55
|
+
continue
|
|
56
|
+
for rule in rules:
|
|
57
|
+
diags.extend(rule.check(p, source))
|
|
58
|
+
return diags
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main(argv: list[str] | None = None) -> int:
|
|
62
|
+
parser = argparse.ArgumentParser(prog="sarj-sql-lint", description="Custom SQL lint rules.")
|
|
63
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
64
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
65
|
+
|
|
66
|
+
check_p = sub.add_parser("check", help="Run rules over .sql files.")
|
|
67
|
+
check_p.add_argument("--rule", action="append", required=True, help="Rule ID (repeat for multiple).")
|
|
68
|
+
check_p.add_argument("files", nargs="+", type=Path)
|
|
69
|
+
|
|
70
|
+
sub.add_parser("list-rules", help="List available rule IDs.")
|
|
71
|
+
|
|
72
|
+
args = parser.parse_args(argv)
|
|
73
|
+
|
|
74
|
+
if args.cmd == "list-rules":
|
|
75
|
+
for rid, cls in sorted(REGISTRY.items()):
|
|
76
|
+
inst = cls()
|
|
77
|
+
sys.stdout.write(f"{inst.code:8} {rid:40} {inst.description}\n")
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
diags = _check(args.rule, args.files)
|
|
81
|
+
for d in diags:
|
|
82
|
+
sys.stdout.write(d.format() + "\n")
|
|
83
|
+
return 1 if diags else 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
sys.exit(main())
|
|
File without changes
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class Diagnostic:
|
|
10
|
+
path: Path
|
|
11
|
+
line: int
|
|
12
|
+
col: int
|
|
13
|
+
code: str
|
|
14
|
+
message: str
|
|
15
|
+
|
|
16
|
+
def format(self) -> str:
|
|
17
|
+
return f"{self.path}:{self.line}:{self.col}: {self.code} {self.message}"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Rule(ABC):
|
|
21
|
+
id: str
|
|
22
|
+
code: str
|
|
23
|
+
description: str
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
27
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from sarj_sql_lint.rule_base import Rule
|
|
4
|
+
from sarj_sql_lint.rules.enforce_timestamptz import EnforceTimestamptz
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
REGISTRY: dict[str, type[Rule]] = {
|
|
8
|
+
EnforceTimestamptz.id: EnforceTimestamptz,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
__all__ = ["REGISTRY"]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""SARJ101: detect TIMESTAMP columns missing `WITH TIME ZONE`.
|
|
2
|
+
|
|
3
|
+
Postgres `TIMESTAMP` without `WITH TIME ZONE` discards offset on INSERT,
|
|
4
|
+
silently producing wrong timestamps for non-UTC clients. Use TIMESTAMPTZ.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from sarj_sql_lint.rule_base import Diagnostic, Rule
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
PATTERN = re.compile(
|
|
15
|
+
r"\bTIMESTAMP\b(?!\s*TZ\b)(?!\s+WITH\s+TIME\s+ZONE\b)",
|
|
16
|
+
re.IGNORECASE,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EnforceTimestamptz(Rule):
|
|
21
|
+
"""Postgres TIMESTAMP without WITH TIME ZONE — use TIMESTAMPTZ."""
|
|
22
|
+
|
|
23
|
+
id = "enforce-timestamptz"
|
|
24
|
+
code = "SARJ101"
|
|
25
|
+
description = "TIMESTAMP without TIME ZONE — use TIMESTAMPTZ."
|
|
26
|
+
|
|
27
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
28
|
+
diags: list[Diagnostic] = []
|
|
29
|
+
for lineno, line in enumerate(source.splitlines(), start=1):
|
|
30
|
+
stripped = line.lstrip()
|
|
31
|
+
if stripped.startswith("--") or stripped.startswith("/*"):
|
|
32
|
+
continue
|
|
33
|
+
for match in PATTERN.finditer(line):
|
|
34
|
+
diags.append(
|
|
35
|
+
Diagnostic(
|
|
36
|
+
path=path,
|
|
37
|
+
line=lineno,
|
|
38
|
+
col=match.start() + 1,
|
|
39
|
+
code=self.code,
|
|
40
|
+
message=(
|
|
41
|
+
"Use `TIMESTAMPTZ` (or `TIMESTAMP WITH TIME ZONE`) — "
|
|
42
|
+
"naive TIMESTAMP discards offset and is rarely correct."
|
|
43
|
+
),
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
return diags
|