Skip to content

Latest commit

 

History

History
179 lines (126 loc) · 11.6 KB

File metadata and controls

179 lines (126 loc) · 11.6 KB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Language policy

This is a public open source project. Everything written to the repository or to GitHub is in English: code, comments, docstrings, commit messages, branch names, PR titles and descriptions, issue comments, and documentation. No exceptions, regardless of the language used in the chat.

Commands

The Makefile is the supported interface. It wraps uv, and every target runs against the locked environment from uv.lock.

make sync                # create/refresh the dev environment from uv.lock
make test                # run the test suite
make lint                # run Ruff
make build               # build sdist + wheel into dist/
make check               # sync + lint + test
make ci                  # sync + lint + test + build (mirrors the CI jobs)
make lock                # regenerate uv.lock after editing pyproject.toml

Run a single test or a single file:

uv run --locked pytest tests/test_command.py::test_dbml_command_writes_to_output_file
uv run --locked pytest tests/test_utils.py -v

Run the suite against one specific Django series (this is exactly what each CI matrix leg does):

make test-django DJANGO_CONSTRAINT="django>=5.1,<5.2" PYTHON=3.13

Exercise the command end to end against the bundled test app:

DJANGO_SETTINGS_MODULE=tests.settings uv run --locked python -c "
import django; django.setup()
from django.core.management import call_command
call_command('dbml', 'testapp.Book', add_project_name='Library', disable_update_timestamp=True)
"

If uv cannot write to its default cache, prefix commands with UV_CACHE_DIR=.uv-cache (the Makefile already defaults to this).

Architecture

The package is a single Django management command (python manage.py dbml) that converts Django model metadata into DBML text. It never touches a database: it only reads Model._meta and uses connection.schema_editor() to compute the index names Django would have created.

The flow is a strict one-way pipeline, and each stage owns exactly one concern:

management/commands/dbml.py   argparse only, no logic
        ↓
core/options.py               GenerationOptions (frozen dataclass, from CLI kwargs)
        ↓
core/generator.py             orchestration: build() then render()
        ↓
core/selection.py             which models participate
        ↓
core/builder.py               Django metadata  →  intermediate schema
        ↓
core/schema.py                plain dataclasses (Project/Table/Field/Index/Relation/Enum)
        ↓
core/renderer.py              intermediate schema  →  DBML text

Where to make a change (also documented in docs/development.md):

  • model discovery or related-model expansion → core/selection.py
  • anything read out of Django → core/builder.py
  • anything about the shape of the emitted text → core/renderer.py

core/schema.py is the contract between builder and renderer. The renderer must never import Django model APIs, and the builder must never format DBML.

Invariants that are easy to break

Table naming is dual and it leaks everywhere. SchemaBuilder.get_table_name() returns either model._meta.label (app.Model, the default) or model._meta.db_table (with --table_names). That string is simultaneously the DBML table identifier, one half of every relation endpoint, and an input to get_enum_name(). Changing how a table is named requires checking all three.

Relation direction is reversed between the dataclass and the output. RelationDefinition(table_from=..., table_to=...) renders as:

ref: {table_to}.{table_to_field} > {table_from}.{table_from_field}

So table_to is the left side of the emitted ref. Read DbmlRenderer.render_relation before adding a relation kind.

FK and O2O columns must be emitted as {field.name}_id, and the relation endpoint must use that exact column. dbdiagram rejects the whole schema with Column 'x_id' does not exist in Table 'Y' when a ref points at a column the table never declared. This was issue #38, fixed in #41. Any change around ForeignKey/OneToOneField in _build_table needs a rendered-output assertion, not just a unit test.

Autogenerated M2M join tables become first-class DBML tables. _build_many_to_many_table synthesizes the table, its id/*_id columns, both refs, and four indexes. The table name is the physical m2m_db_table() with only the first underscore replaced by a dot (testapp_book_tags → testapp.book_tags), so it reads like schema.table. The method bails out early when the through model name has no underscore, and skips tables already present, because the same M2M is reachable from both ends.

Field types are derived from the class name, not from a mapping table. map_field_type_to_dbml_type is to_snake_case(FieldClass.__name__.removesuffix("Field")), cached. New Django field types therefore work with no code change. The initialism replacements in utils.to_snake_case (ip_, url, uuid, json) are the only special cases; add to that list rather than to a mapping.

Relation columns are typed by their target, not by the relation. SchemaBuilder.get_field_type resolves a ForeignKey or OneToOneField to field.target_field, and the synthesized many-to-many join table resolves both of its columns the same way. A relation column stores a copy of what it points at, and DBML already carries the relation in the ref, so foreign_key and one_to_one as column types said nothing about what the column holds. test_relation_columns_carry_the_type_of_the_column_they_reference holds both ends of every relation to the same type.

Private Django APIs are used on purpose. builder.py opens with a file-level # ruff: noqa: SLF001 because it calls connection.schema_editor()._create_index_name(), ._unique_constraint_name(), and reads model._meta._forward_fields_map. These are the parts most likely to break on a Django upgrade, which is why CI runs a full compatibility matrix rather than testing a single version.

Tests

tests/settings.py configures an in-memory SQLite database and sets MIGRATION_MODULES = {"testapp": None}, so no migrations exist or are needed. tests/testapp/models.py is the fixture: it deliberately carries one instance of every metadata shape the generator reads (docstring, db_comment, db_table_comment, help_text, choices, unique, db_index, Meta.indexes, unique_together, FK, O2O, M2M).

Prefer command-level assertions in tests/test_command.py over testing internals: the value of this package is the rendered DBML. To cover a new metadata shape, add a field or model to tests/testapp/models.py first, then assert on what it renders. tests/test_builder.py and tests/test_renderer.py exist only for paths that are awkward to reach through the command, such as the database-engine mapping and default formatting.

tests/dbml_parser.py reads generated DBML back into tables, columns, relations and groups, so assertions can be structural instead of substring matching. It is an assertion aid, not a DBML implementation: extend it only as far as the renderer's own output requires.

test_every_relation_endpoint_references_a_declared_column is the important one. It encodes the dbdiagram rule that broke in issue #38 rather than any single column name, so it catches the whole class of failure. Keep it passing for any new relation kind.

Pass disable_update_timestamp=True in tests; otherwise the project note embeds datetime.now(UTC) and the output is not reproducible.

make coverage reports line coverage. Two paths are knowingly uncovered: the defensive dedup return in _build_many_to_many_table, which needs the same join table to be reached twice, and the base_field.choices branch, which needs django.contrib.postgres and therefore a PostgreSQL install. Do not chase them for the percentage.

CI matrix

ci.yml runs on every pull request and on pushes to main, and is reused by both publish workflows through workflow_call. It must cover every combination the package claims to support: each Python in project.classifiers crossed with each Django series allowed by the django specifier in project.dependencies, minus the pairs upstream Django does not support. That is 17 legs today, spanning Django 4.2 to 6.1 on Python 3.11 to 3.14. docs/development.md holds the table.

Widening requires-python, the django specifier, or the classifiers without adding the matching legs ships an untested claim of support. That is how Django 6.x went unverified for several releases after the specifier was widened to <7.0. Change all of them in one pull request.

Each leg prints the Django and Python versions it actually resolved into the job summary. That guard matters because make test-django layers uv run --with <constraint> over the locked environment: a constraint that silently fails to take effect would otherwise pass as a false green against whatever uv.lock pins.

Required Checks aggregates the workflow into one stable check name. Branch protection should require that job, not the individual matrix legs, whose names change whenever a series is added or dropped.

Lint

Ruff runs with its default rule set only (E4, E7, E9, F) at line-length = 140. Several # noqa comments in the codebase reference rules that are not currently enabled (SLF001, D102, PLR0912, PLR2004); they document intent for a future stricter configuration. Do not strip them as "unused", and do not enable RUF100 without deciding on the broader rule set first.

Release and publishing

Releases are tag-driven and publish to PyPI via Trusted Publishing (GitHub OIDC). There are no API tokens.

  1. Bump project.version in pyproject.toml, then run make lock. The package is a member of its own workspace, so uv.lock records project.version, and bumping without relocking makes every uv sync --locked fail, including the release workflow's own sync step. Merge both files to main together.
  2. Run make ci locally.
  3. Tag with the bare version, no v prefix: git tag 1.2.0 && git push origin 1.2.0.

.github/workflows/production.yml then: reads project.version and fails the release if the tag does not match it exactly; reuses ci.yml via workflow_call as a gate; builds sdist+wheel once; uploads them as an artifact; and publishes those exact artifacts from a separate job bound to the pypi GitHub environment.

That environment carries a required reviewer, so the run parks at waiting once the artifacts are built and publishes nothing until someone approves the deployment. A green matrix is not a published release: check the run's own status, not just its checks.

.github/workflows/publish-to-test-pypi.yml is the same shape but workflow_dispatch-only and bound to a testpypi environment.

When touching workflows, keep third-party actions pinned to commit SHAs with the version in a trailing comment, and keep permissions: minimal, with id-token: write present only on the publishing job. Dependabot (.github/dependabot.yml) currently watches GitHub Actions only; Python dependencies are updated by hand via make lock.

Repository conventions

  • Commit subjects follow Conventional Commits; Dependabot commits are prefixed ci:.
  • Every change lands through a pull request against main; CI (test matrix, lint, package check, dependency review) must pass.
  • User-facing behavior changes need matching updates in README.md; contributor-facing ones in CONTRIBUTING.md or docs/development.md.
  • Report vulnerabilities privately per SECURITY.md, never as a public issue.