Metadata-Version: 2.4
Name: sqla-lint
Version: 0.1.0
Summary: Add your description here
Requires-Python: >=3.13
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: sqlalchemy
Requires-Dist: sqlalchemy>=2.0; extra == "sqlalchemy"
Dynamic: license-file

# sqla-lint

A linter for SQLAlchemy queries that catches common anti-patterns — both at runtime (against live query objects) and statically (by analysing `.py` source files without executing them).

## Installation

```bash
pip install sqla-lint
```

SQLAlchemy is an optional dependency. Install it alongside the linter to use the runtime API:

```bash
pip install "sqla-lint[sqlalchemy]"
```

---

## CLI

The `sqla-lint` command performs **static analysis** on Python source files using AST parsing — no code is executed.

### Basic usage

```bash
# Lint a single file
sqla-lint path/to/queries.py

# Recurse into a directory
sqla-lint src/

# Use a glob pattern (quote it to prevent shell expansion)
sqla-lint "src/**/*.py"
```

### Options

| Flag | Description |
|---|---|
| `--skip RULE[,RULE…]` | Skip one or more rule IDs. Repeatable. |
| `--select RULE[,RULE…]` | Only report these rule IDs. Takes precedence over `--skip`. |
| `--min-severity LEVEL` | Minimum severity to report: `error`, `warning`, or `info` (default: `info`). |
| `--format text\|json` | Output format (default: `text`). |
| `-q` / `--quiet` | Suppress all output; rely on exit code only. |

### Examples

```bash
# Only report errors and warnings
sqla-lint src/ --min-severity warning

# Skip the "no LIMIT" and "count(*)" rules
sqla-lint src/ --skip SQLA004,SQLA006

# Only check for unbounded DELETEs and UPDATEs
sqla-lint src/ --select SQLA002,SQLA003

# Machine-readable JSON output (e.g. for CI pipelines)
sqla-lint src/ --format json
```

### Exit codes

| Code | Meaning |
|---|---|
| `0` | No violations found at or above `--min-severity`. |
| `1` | One or more violations found. |
| `2` | Usage / I/O error (no files found, unreadable path, etc.). |

### Text output format

```
myapp/queries.py:14:4: [WARNING] SQLA001: Query uses SELECT * — explicitly list the columns you need
myapp/queries.py:27:8: [ERROR] SQLA002: DELETE statement has no WHERE clause — this will delete all rows in the table
sqla-lint: 2 violations: 1 error, 1 warning
```

---

## Python API

### Runtime linting

Pass a SQLAlchemy query object directly to `sqla_lint.lint()`:

```python
from sqlalchemy import select, text
import sqla_lint

stmt = select(text("*")).select_from(users)
result = sqla_lint.lint(stmt)

for v in result.violations:
    print(f"[{v.severity.value.upper()}] {v.rule_id}: {v.message}")
```

`LintResult` provides filtered views:

```python
result.has_violations  # bool
result.errors          # list[LintViolation]  — Severity.ERROR only
result.warnings        # list[LintViolation]  — Severity.WARNING only
result.infos           # list[LintViolation]  — Severity.INFO only
```

### Static file analysis

Analyse source files without importing or executing them:

```python
from sqla_lint import parse_file, parse_source

# From a file path
for violation in parse_file("myapp/queries.py"):
    print(violation)  # myapp/queries.py:14:4: [WARNING] SQLA001: ...

# From a source string
violations = parse_source(source_code, filename="<inline>")
```

`FileViolation` has `rule_id`, `message`, `severity`, `filename`, `line`, and `col` attributes.

---

## Configuration

### Skipping rules

```python
import sqla_lint

config = sqla_lint.LintConfig(skip_rules={"SQLA004", "SQLA006"})
result = sqla_lint.lint(stmt, config=config)
```

### Overriding severity

```python
from sqla_lint import LintConfig, RuleConfig, Severity

config = LintConfig(
    rule_configs={
        "SQLA004": RuleConfig(severity_override=Severity.ERROR),
    }
)
result = sqla_lint.lint(stmt, config=config)
```

### Disabling a rule via `RuleConfig`

```python
config = LintConfig(
    rule_configs={
        "SQLA006": RuleConfig(enabled=False),
    }
)
```

### Using the `Linter` class directly

For repeated use (e.g. in tests or middleware), create a `Linter` instance once:

```python
from sqla_lint import Linter, LintConfig

linter = Linter(config=LintConfig(skip_rules={"SQLA004"}))

result1 = linter.lint(stmt1)
result2 = linter.lint(stmt2)
```

---

## Rules

| ID | Severity | Description |
|---|---|---|
| SQLA001 | warning | `SELECT *` — use explicit column names |
| SQLA002 | **error** | `DELETE` without `WHERE` clause — deletes every row |
| SQLA003 | **error** | `UPDATE` without `WHERE` clause — updates every row |
| SQLA004 | warning | `SELECT` without `LIMIT` — may return an unbounded result set |
| SQLA005 | warning | `LIMIT` without `ORDER BY` — row order is non-deterministic |
| SQLA006 | info | `count(*)` — prefer `count(1)` or `count(primary_key)` |
| SQLA007 | **error** | Multiple `FROM` tables without `JOIN` — Cartesian product (runtime only) |
| SQLA008 | warning | Query inside a loop — potential N+1 (static analysis only) |

> **SQLA007** requires type information and is only detected by the runtime linter (`sqla_lint.lint()`), not by the CLI static analyser.
>
> **SQLA008** is only detected by the static analyser (CLI / `parse_file`) and is not checked at runtime.

---

## Custom rules

Subclass `Rule`, set the three class attributes, implement `check()`, and pass an instance to `Linter`:

```python
from typing import Any
from sqla_lint import Linter, LintConfig, Rule, LintViolation, Severity


class NoOffsetWithoutLimitRule(Rule):
    rule_id = "CUSTOM001"
    description = "OFFSET without LIMIT is almost always a bug"
    default_severity = Severity.ERROR

    def check(self, query: Any) -> list[LintViolation]:
        offset = getattr(query, "_offset_clause", None)
        limit = getattr(query, "_limit_clause", None)
        if offset is not None and limit is None:
            return [self._violation("OFFSET used without LIMIT")]
        return []


linter = Linter(rules=[NoOffsetWithoutLimitRule()])
result = linter.lint(stmt)
```

To combine custom rules with the built-in ones:

```python
from sqla_lint import ALL_RULES

linter = Linter(rules=list(ALL_RULES) + [NoOffsetWithoutLimitRule()])
```
