Skip to content

Releases: FROWNINGdev/orm-lens

v0.20.0

Choose a tag to compare

@github-actions github-actions released this 31 Oct 09:31
27e23b4

What's Changed

  • feat(cli): SQLAlchemy models for scan, ER diagrams and N+1 (preview) by @FROWNINGdev in #199
  • release: py-1.16.0, v0.20.0 — django-orm-lens is now orm-lens by @FROWNINGdev in #200

Full Changelog: v0.19.6...v0.20.0

v0.19.6

Choose a tag to compare

@github-actions github-actions released this 31 Oct 04:12
cb0bbf8

What's Changed

  • ci: test Python 3.15 by @FROWNINGdev in #180
  • feat(cascade): name the delete receivers Django 6.1's DB_CASCADE skips by @FROWNINGdev in #179
  • release: py-1.13.8 — the delete handlers DB_CASCADE skips by @FROWNINGdev in #184
  • docs: article — Django 6.1's DB_CASCADE skips your post_delete handlers by @FROWNINGdev in #185
  • fix(migration-risk): stop false criticals on real-world histories (407 → 2 on six large projects) by @FROWNINGdev in #186
  • fix(nplusone): precision on real-world code — writes, duplicate clauses, test modules by @FROWNINGdev in #187
  • release: py-1.14.0 — real-world precision by @FROWNINGdev in #188
  • docs: article — 407 migration alarms on six big Django codebases, 405 wrong by @FROWNINGdev in #189
  • fix(nplusone): assignments and instance methods are not relation reads by @FROWNINGdev in #193
  • fix(signals): follow signal.connect(handler, sender=Model) receivers by @1AbdullahButt in #191
  • fix(rules): DOL013 reads ForeignKey arguments with nested calls by @1AbdullahButt in #192
  • fix(rules): DOL013 flags OneToOneField without on_delete by @1AbdullahButt in #190
  • feat(migration-risk): read Alembic revisions (preview) by @FROWNINGdev in #194
  • release: py-1.15.0, v0.19.6 — Alembic revisions by @FROWNINGdev in #195

New Contributors

Full Changelog: v0.19.5...v0.19.6

py-v1.16.0 - django-orm-lens is now orm-lens, and reads SQLAlchemy models

Choose a tag to compare

@github-actions github-actions released this 31 Oct 09:30
27e23b4

Changed

  • django-orm-lens is now orm-lens, and reads SQLAlchemy models.
    The package is orm-lens on PyPI, with the commands orm-lens and
    orm-lens-mcp and the module orm_lens. Nothing written against the old
    name breaks: pip install django-orm-lens (and [mcp]) installs the
    matching orm-lens and keeps the django-orm-lens / django-orm-lens-mcp
    commands and import django_orm_lens, and upgrading an existing install
    keeps both command names. The MCP server reads ORM_LENS_ROOT and
    ORM_LENS_ALLOWED_ROOTS, then the old DJANGO_ORM_LENS_* names. The
    repository moved to github.com/FROWNINGdev/orm-lens (the old URLs
    redirect), the docs to frowningdev.github.io/orm-lens, the MCP Registry
    entry to io.github.FROWNINGdev/orm-lens, and the image to
    ghcr.io/frowningdev/orm-lens (ghcr.io/frowningdev/django-orm-lens keeps
    getting the same builds). Kept on purpose: the pre-commit hook ids, the
    SARIF tool name (code scanning keys open alerts on it) and the blast-radius
    PR-comment marker. The GitHub Action still accepts a pre-1.16 version.

Added

  • SQLAlchemy models: scan, ER diagrams and N+1 (preview).
    scan, list, describe, hover, er and nplusone, and the matching
    MCP tools, now read SQLAlchemy models: 2.0 DeclarativeBase / Mapped[],
    1.x declarative_base() / Column, Flask-SQLAlchemy db.Model and
    SQLModel table=True classes. Static as always: ast, no import, no
    database. Bases and mixins are followed across files, table names come
    from __tablename__ or the base's naming rule, foreign keys become ER
    edges, and relationship() attributes are listed with their loader.
    nplusone flags a loop over query results that reads a lazy relationship
    without selectinload / joinedload in the query's options, and suggests
    the option. The Django analyses (drift, blast radius, cascade, signals)
    stay Django-only. Checked on Superset, Airflow, Prefect, Polar, Dispatch,
    Mealie, CTFd, Redash and the FastAPI template: 12 N+1 findings, all real.
    See docs/rules/sqlalchemy.md.

Fixed

  • SQLAlchemy and Flask classes no longer pass for Django models. A
    models.py with class User(db.Model) or Flask-AppBuilder's Model
    matched the parser's Model heuristic, so a Flask project showed up as a
    Django one: 65 phantom models in Superset, 138 in Polar, 69 in Dispatch.
    Files that are SQLAlchemy code and never import Django are now left to
    the SQLAlchemy reader. The Django projects in the census are unchanged.

py-v1.15.0 - migration-risk reads Alembic revisions (preview)

Choose a tag to compare

@github-actions github-actions released this 31 Oct 04:12
cb0bbf8

Added

  • migration-risk reads Alembic revisions (preview). SQLAlchemy projects
    without Django (FastAPI, Flask) get the same static migration review: any
    versions/ revision is parsed with ast and never run. Eleven rules cover
    a NOT NULL add_column without server_default (SQLAlchemy's default= only
    applies on INSERT), create_index without CONCURRENTLY, alter_column to NOT
    NULL or to a new type, renamed and dropped columns and tables, unique,
    foreign-key and check constraints, and multiple heads. batch_alter_table
    blocks and Flask-Migrate layouts are covered. The populated-table rules
    learned from the Django census apply too: a same-revision create_table or
    the base revision means an empty table, and a data operation first makes the
    finding a warning. Over ~1170 revisions from Superset, Airflow, Prefect,
    Polar, Dispatch, Mealie and the FastAPI template there are 9 critical
    findings, each a NOT NULL column that fails on a populated table. Rules:
    docs/rules/alembic.md.

Fixed

  • signals / MCP signal_graph missed receivers registered with
    .connect().
    Only @receiver(...) decorators were read, so
    post_delete.connect(delete_file, sender=Attachment) in an AppConfig.ready()
    was invisible, and cascade left it out of the DB_CASCADE
    skipped_receivers list. signal.connect(handler, sender=Model) is now an
    edge like a decorator, with a bare or dotted signal (post_delete,
    models.signals.post_delete), receiver= as a keyword, and no sender
    meaning every model. To keep sockets and database clients out, a .connect()
    call only counts when the signal is a Django built-in, a Signal() defined in
    the workspace, or the call passes sender=.
    (#183) Contributed by @1AbdullahButt.

  • nplusone read assignments and instance methods as relation reads.
    Two more patterns from the real-world census:

    • device.cluster = cluster inside a loop (NetBox) assigns the attribute
      and loads nothing. device.cluster.name = x still loads
      device.cluster, so that one is still reported.
    • check.matches_tag_set(tags) (Healthchecks) is a method on the loop
      variable, but its name matched the <model>_set reverse-manager
      heuristic. A call made directly on the loop variable is never a
      relation read; managers are always one attribute deeper.

v0.19.5

Choose a tag to compare

@github-actions github-actions released this 30 Oct 20:34
a980175

What's Changed

New Contributors

Full Changelog: v0.19.4...v0.19.5

py-v1.14.0 - migration-risk: 407 criticals on six big Django codebases, 2 real

Choose a tag to compare

@github-actions github-actions released this 30 Oct 21:45
cec184d

Fixed

  • migration-risk: 407 criticals on six big Django codebases, 2 real.
    Run over NetBox, PostHog, authentik, Plane, paperless-ngx and Healthchecks,
    it reported 407 critical findings, and 2 of them were real. The rules now
    follow what Django actually does to existing rows:

    • ManyToManyField, TaggableManager and GenericRelation add no column,
      so they are never a NOT NULL risk.
    • blank=True on a text field, including project subclasses such as
      ColorField(max_length=6), gets "". auto_now / auto_now_add get the
      current time, and db_default and GeneratedField fill themselves.
    • A table created earlier in the same migration is empty. A squashed
      migration (replaces = [...]) only ever runs on a fresh database.
    • unique=True with null=True and no default, or default=None, fills
      rows with NULL, which never collides.
    • A field removed and then re-added, renamed onto the same name, or a model
      deleted and re-created, in that migration or a later one, is no longer
      "still referenced".
    • The migration graph collapses squashed migrations the way Django's loader
      does, so a squash and the migrations it replaces are not reported as
      conflicting leaves.
    • A NOT NULL column added after a RunPython, or after a RunSQL that
      deletes, updates or truncates rows, is a warning instead of a certain
      critical, because that step may have emptied the table.
  • nplusone reported writes and test code as N+1 reads. Checked against
    NetBox, PostHog, authentik, Plane, paperless-ngx and Healthchecks:

    • post.tags.set(...), .add(), .remove(), .clear(), .create() and
      .get_or_create() on a relation were caught by the attribute-chain
      fallback and given a prefetch, which cannot serve a write. They are not
      reported now. Loading a single-valued relation before writing to it
      (profile.user.delete()) is still an N+1, and its fix is
      select_related. QuerySet writes such as update() and delete() on a
      related manager stay findings, as before.
    • One field is never suggested in both select_related and
      prefetch_related. .select_related("user").prefetch_related("user")
      came from a schema-less guess on x.user.delete().
    • Test modules (test_*.py, *_test.py, tests.py, conftest.py, and
      anything under tests/ or test/) are skipped, because a query per row
      in a test is not a production N+1. Pass --include-tests to scan them.
      This also halves the scan time on large repositories (PostHog: 80 s to
      39 s).

py-v1.13.8 - cascade names the delete handlers Django 6.1's DB_CASCADE skips (#179)

Choose a tag to compare

@github-actions github-actions released this 30 Oct 21:01
dad3c21

Added

  • cascade names the delete handlers Django 6.1's DB_CASCADE skips (#179).
    With on_delete=models.DB_CASCADE the database deletes the child
    rows itself, so Django never sends pre_delete / post_delete for them: a
    post_delete handler that removes the uploaded file, writes the audit log
    or busts a cache just stops running, with no error. cascade (CLI and the
    MCP cascade_preview tool) now lists each @receiver for that model, or
    for every model, under the affected relation as skipped_receivers, and
    the text output prints them with file and line. The signal graph is only
    built when a DB_CASCADE relation exists.

  • Tested on Python 3.15 (#180). CI gained a Python 3.15 + Django 6.1 leg,
    and the package metadata lists 3.15.

py-v1.13.7 - cascade and blast-radius missed a positional on_delete (#176)

Choose a tag to compare

@github-actions github-actions released this 30 Oct 20:34
a980175

Fixed

  • cascade and blast-radius missed a positional on_delete (#176).
    ForeignKey and OneToOneField take (to, on_delete, ...), and Django's
    own contrib.auth and contrib.admin pass the policy positionally:
    ForeignKey(ContentType, models.CASCADE). The parser only looked for
    on_delete=, so such relations were left out of every bucket and cascade
    could call the delete isolated. The second positional argument now counts
    for FK and O2O, with any dotted prefix and SET(...); ManyToManyField,
    whose second positional is related_name, is left alone.

  • cascade ignored on_delete=DO_NOTHING (#177). Those relations fell out
    of every bucket, so cascade could call a delete isolated that the FK
    constraint rejects: Django leaves the referencing rows, and the database
    raises IntegrityError. They now land in a new unhandled bucket (CLI JSON
    and the MCP cascade_preview tool, additive), the text output lists them as
    "left as-is by DO_NOTHING" with one line on what happens, and blast-radius
    adds "N left by DO_NOTHING" to its cascade line when there are any. With
    db_constraint=False there is no constraint to trip, so the entry carries
    "db_constraint": false and the text says the rows keep an id that points
    nowhere.

  • cascade called a delete isolated when Django 6.1 would cascade in the
    database (#175).
    Django 6.1 added on_delete=models.DB_CASCADE,
    DB_SET_NULL and DB_SET_DEFAULT, where the FK constraint carries
    ON DELETE and the database does the work. Those relations fell out of
    every bucket. They now land in cascade_kills / set_null marked
    "db_level": true, the text output tags them (db) and notes that Django
    sends no delete signals for them, and DBML export maps them to
    delete: cascade / set null / set default.

  • pip install "django-orm-lens[mcp]" failed outright on Python 3.9
    (#174).
    It never worked there: every mcp release, 1.x included, requires
    Python 3.10, so pip and uv rejected the whole install, CLI and all.
    py-1.13.0 made that explicit without noticing, by gating mcp>=1.0,<2 to
    python_version < '3.10' on the belief that 1.x runs on 3.9. The extra now
    asks for mcp only on 3.10+, so on 3.9 it installs the CLI and nothing else,
    and django-orm-lens-mcp says the server needs 3.10+ and prints a
    uvx --python 3.12 command instead of repeating the pip command that just
    ran. 3.10+ installs are unchanged (mcp 2.x), and the bootstrap still accepts
    an mcp 1.x that another tool already put in the environment.

Changed

  • Tested on every supported Python, 3.9 through 3.14, and on Django 4.2,
    5.2, 6.0 and 6.1 (#173).
    CI ran 3.10–3.12 only, which is how the 3.9
    install failure above went unnoticed, and stopped at Django 5.2. The package
    metadata now lists Django 6.0 and 6.1 and Python 3.14, and the 3.9 leg
    checks that the [mcp] extra installs.

py-v1.13.6

Choose a tag to compare

@github-actions github-actions released this 28 Oct 04:56
ce15bd8

Fixed

  • nplusone skipped loops over sliced QuerySets (#145). Iterating
    Post.objects.filter(...)[:50] is still lazy and can cause one related
    lookup per row; it now produces the same finding and eager-loading
    suggestion as an unsliced QuerySet. A slice of a slice
    (qs[:50][:10]) is unwrapped too.

  • nplusone flagged nested relations already prefetched by the outer
    queryset (#146).
    Nested loops now inherit explicit eager-loading paths
    from their enclosing queryset. Suggestions retain the full schema-validated
    relation path, such as product__category, and partial prefetches still
    report the relations that remain unloaded. Prefetch-cache coverage now follows
    supported documented queryset operations on related managers, classifying
    ordering- and emptiness-dependent cases conservatively while retaining findings
    for non-cache-served operations and using schema-backed receiver checks to
    preserve loaded relation prefixes for calls on other receivers.

py-v1.13.5

Choose a tag to compare

@github-actions github-actions released this 19 Oct 08:37
19897a8

Fixed

  • blast-radius had no target for a RemoveField once the field was gone
    from models.py (#140).
    That is the normal state after makemigrations,
    and exactly when code still reading the column breaks, but migration-risk
    emitted nothing for it, so blast-radius never scanned. It now emits an
    info risk, remove_field, which blast-radius --severity info (or all)
    turns into a target listing the remaining references. migration-risk --severity info shows one more line per dropped column. The MCP
    blast_radius tool also rejects an unknown severity with an
    INVALID_SEVERITY error instead of silently treating it as critical.

  • DBML ER export noted model line numbers 0-based (#139). The
    Note: 'line N of app/models.py' in er --format dbml pointed one line
    above the class, while every other formatter prints 1-based lines. The
    note now uses the same 1-based number.