Przejdź do treści

INW016 orm-naming

Co robi

Opt-in, a po włączeniu błąd. Pilnuje, żeby nazwy tabel w bazie danych oraz kolumn z datą i czasem trzymały się jednego schematu. Domyślnie jest to schemat z fastapi-best-practices: nazwy tabel w lower_case_snake i w liczbie pojedynczej (post, post_like), _at na końcu kolumny z datą i czasem (created_at) i _date na końcu kolumny z samą datą (published_date). Jedno zgłoszenie na każdą nazwę, która łamie schemat, na samej nazwie, razem z nazwą, której należy użyć.

Co czyta:

Konstrukcja Nazwa tabeli Kolumny daty i czasu
SQLAlchemy, styl deklaratywny __tablename__ = "post" Mapped[datetime], Mapped[date] (także wewnątrz Optional[...] albo ... \| None); mapped_column(DateTime), Column(Date), a także TIMESTAMP, DATETIME, DATE
SQLAlchemy Core Table("post", metadata, ...) Column("created_at", DateTime)
SQLModel __tablename__ albo nazwa, którą SQLModel wyprowadza dla klasy table=True bez niego: nazwa klasy małymi literami (Heroes daje heroes) pola created_at: datetime i published_date: date klasy table=True
Django class Meta: db_table = "post" models.DateTimeField(), models.DateField()

Nazwa kolumny to ta, którą dostaje baza danych: napis przekazany w wywołaniu (mapped_column("created_at", DateTime), db_column="created_at"), a jeśli go nie ma, nazwa atrybutu. Jawny typ wygrywa z adnotacją: Mapped[datetime] = mapped_column(Date) to kolumna z samą datą.

Uruchomienie dla całego projektu (inwards check bez ścieżek albo Stop gate z stop-gate = "project") zgłasza też jedno naruszenie, gdy projekt ma tabele SQLAlchemy albo SQLModel i nigdzie nie ma MetaData(naming_convention=...): na pierwszej tabeli w plikach objętych regułą. Liczy się też przypisanie do <coś>.naming_convention, na przykład SQLModel.metadata.naming_convention = {...}. Tabele Django się nie liczą: Django samo nazywa swoje ograniczenia.

Projekt z innym schematem

Uruchomienie dla całego projektu zauważa też projekt, który celowo trzyma się innego schematu. Gdy sprawdzanych nazw jest co najmniej 5, a 90% lub więcej z nich łamie skonfigurowany schemat w ten sam sposób, zgłasza je jednym naruszeniem, na pierwszej takiej nazwie, zamiast naruszenia na każdej nazwie. Zgłoszenie mówi, co robi projekt i która wartość opcji do tego pasuje:

Co robi projekt Zgłoszenie prosi o
90% jego nazw tabel w lower_case_snake wygląda na liczbę mnogą, przy table-name = "snake_singular" table-name = "snake"
90% nazw jego kolumn z datą i czasem (albo z samą datą) kończy się jednym innym sufiksem czasu, na przykład _on datetime-suffix = "_on" (albo date-suffix)
90% nazw jego kolumn z datą i czasem (albo z samą datą) nie ma sufiksu, a żaden jeden sufiks nie jest wspólny datetime-suffix = false (albo date-suffix)

Nazwy, które i tak trzymają się skonfigurowanego schematu (tabela w liczbie pojedynczej w projekcie z mnogimi), nie są zgłaszane. Schemat nie jest zgłaszany, gdy trzyma się go mniej niż 90% nazw: projekt w połowie zmiany nazw dostaje naruszenie na każdej nazwie, żeby widzieć, co zostało. Nazwa, która łamie schemat inaczej, na przykład nazwa tabeli spoza lower_case_snake, zachowuje własne zgłoszenie.

Sprawdzenie wybranych ścieżek, hook po każdej edycji i edytor widzą jeden plik albo kilka, a nie zwyczaj projektu, więc każdą nazwę sprawdzają względem skonfigurowanego schematu, także domyślnego. Czytanie wszystkich modeli przy każdej edycji, żeby poznać zwyczaj, uzależniłoby wynik edycji od plików, których nie dotknęła, i kosztowałoby parsowanie każdego modelu przy każdej edycji. Zgłoszenie dla całego projektu podaje zamiast tego opcję: gdy użytkownik ją ustawi, sprawdzenia po edycji zgadzają się z projektem. Do tego czasu poprawka zgłoszenia mnogiej tabeli wspomina table-name = "snake".

Pliki są sprawdzane niezależnie od warstwy, poza modułami testowymi (przywraca je check-tests = true, zob. tabele opcji); modules zawęża regułę do części z nich, na przykład do pakietów z modelami. Nazwy liczone przez zgłoszenie dla całego projektu pochodzą z tych samych plików, więc modele testów nie wliczają się do 90%.

Dlaczego to źle

Schemat bazy żyje dłużej niż kod, który go pierwszy zapisał, a każde zapytanie, migracja, raport i dashboard powtarza jego nazwy. Pomieszane nazwy (posts obok post_like, created obok updated_at, start_date z datą i czasem) każą każdemu czytelnikowi się zatrzymać i sprawdzić, a zmiana nazwy tabeli albo kolumny później wymaga migracji i poprawki w każdym miejscu, które jej używa. Agenci kopiują to, co robi model obok, albo to, co pamiętają z tutoriala, więc projekt rozjeżdża się model po modelu. Sufiksy niosą też typ: _at mówi „moment w czasie, z pytaniem o strefę czasową”, _date mówi „dzień w kalendarzu”.

SQLAlchemy nadaje nazwę indeksowi, ale nie ograniczeniu unikalności, check, klucza obcego ani klucza głównego, chyba że MetaData ma konwencję nazw: ani Alembic, ani SQLAlchemy nie tworzą nazw ograniczeń, którym nikt nazwy nie podał (SQLAlchemy: Configuring Constraint Naming Conventions). Nazwy wybiera wtedy baza danych, a późniejsza migracja, która usuwa albo zmienia takie ograniczenie, musi je zgadywać. fastapi-best-practices ustawia konwencję właśnie z tego powodu (Set DB keys naming conventions).

Żaden inny linter nie czyta semantyki tabel i kolumn SQLAlchemy pod kątem nazw: flake8-sqlalchemy sprawdza co innego, a reguły nazewnictwa Ruffa patrzą na identyfikatory Pythona, nie na napisy, którymi nazwano tabelę.

Przykład

pyproject.toml
[tool.inwards]
layers = [
  { name = "db", modules = ["blog.db"] },
]

[tool.inwards.rules]
extend-select = ["INW016"]
blog/db/models.py
from datetime import datetime

from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class Base(DeclarativeBase):
    pass


class Post(Base):
    __tablename__ = "posts"

    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str]
    created: Mapped[datetime]
inwards check blog
blog/db/models.py:11:21: INW016 Table name "posts" is plural; this project names tables in the singular: "post".
  fix: Rename the table "posts" to "post".
    1. Change the name where it is written to "post": `__tablename__`, the first argument of `Table(...)`, or `db_table`.
    2. If the table already exists, add a migration that renames it, such as Alembic's `op.rename_table("posts", "post")`, and update raw SQL that names it.
    3. If "posts" is singular in fact (a mass noun such as "news"), ask the user to add it to `allow-tables` in [tool.inwards.rules.orm-naming]; if the project names all its tables in the plural, ask for `table-name = "snake"` there instead. Don't edit [tool.inwards] yourself.
  docs: https://sircypkowskyy.github.io/inwards/rules/INW016/

blog/db/models.py:15:5: INW016 Datetime column `created` doesn't end with `_at`: name it `created_at`.
  fix: Rename the column `created` to `created_at`.

Uruchomienie dla całego projektu dodaje brak konwencji nazw, na pierwszej tabeli:

blog/db/models.py:11:21: INW016 No `MetaData(naming_convention=...)` in the project, so SQLAlchemy leaves unique, check, foreign key and primary key constraints unnamed and the database picks their names.
  fix: Give the declarative base's MetaData a naming convention.

W projekcie, którego tabele są w liczbie mnogiej, jak 98 ze 104 tabel Polara, uruchomienie dla całego projektu zgłasza je raz, na pierwszej, zamiast zgłoszenia liczby mnogiej na każdej:

server/polar/models/account.py:35:21: INW016 This project names its tables in the plural, which the singular scheme reports on every table: `table-name = "snake"` matches it.
  fix: Ask the user to set `table-name = "snake"` in [tool.inwards.rules.orm-naming], or rename the tables to the singular.

Po poprawce: tabela w liczbie pojedynczej, created_at i konwencja nazw na bazie deklaratywnej. Cały projekt przechodzi:

blog/db/models.py
from datetime import datetime

from sqlalchemy import MetaData
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

POSTGRES_INDEXES_NAMING_CONVENTION = {
    "ix": "%(column_0_label)s_idx",
    "uq": "%(table_name)s_%(column_0_name)s_key",
    "ck": "%(table_name)s_%(constraint_name)s_check",
    "fk": "%(table_name)s_%(column_0_name)s_fkey",
    "pk": "%(table_name)s_pkey",
}


class Base(DeclarativeBase):
    metadata = MetaData(naming_convention=POSTGRES_INDEXES_NAMING_CONVENTION)


class Post(Base):
    __tablename__ = "post"

    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str]
    created_at: Mapped[datetime]
inwards check

Jak działa

Najpierw działa filtr tekstowy: plik, który nie wymienia żadnego z __tablename__, Table(, table=True, Mapped, Column(, mapped_column, db_table, DateTimeField i DateField, nie jest parsowany. Plik, który przejdzie, dostaje jedno własne parsowanie.

  • Nazwy są kwalifikowane przez importy pliku, więc import sqlalchemy as sa i sa.DateTime(timezone=True) czytają się jako DateTime z SQLAlchemy, a from datetime import datetime as dt i Mapped[dt] jako data z czasem. Table, Column, mapped_column, MetaData albo typ kolumny liczą się, gdy pochodzą z sqlalchemy albo sqlmodel; pole Django, gdy pochodzi z django.db.models.
  • Nazwy tabel muszą być literałami napisowymi albo stałymi z tego samego pliku; nazwa budowana w czasie działania, na przykład funkcja declared_attr, nie jest czytana.
  • lower_case_snake to słowa z małych liter i cyfr połączone pojedynczymi podkreśleniami: post, post_like, oauth2_token. Podpowiedź rozdziela camel case i zamienia inne znaki: UserAccount daje user_account.
  • Liczba pojedyncza to heurystyka na ostatnim słowie nazwy. Słowo zakończone na s uchodzi za liczbę mnogą, chyba że kończy się na ss, us, is albo ics (address, status, analysis, analytics), ma najwyżej trzy litery albo jest jednym z kilku słów, które są takie same w obu liczbach (news, series, species, alias). Liczy się też kilka form mnogich bez s (people, children, men). Podpowiedź cofa -ies, -es po głosce syczącej i -s: categories daje category, addresses daje address, post_likes daje post_like.
  • Podpowiedź sufiksu zamienia inny sufiks czasu, na który kończy się nazwa (_date, _time, _on, _ts, _dt, _datetime, _timestamp, _at), na skonfigurowany, przenosi początkowe date_, date_of_, time_ albo timestamp_ na koniec jako sufiks albo go dopisuje: due_date z datą i czasem daje due_at, date_joined daje joined_at, date_of_birth z samą datą daje birth_date, created daje created_at. Nigdy nie powtarza słowa: nazwa złożona tylko ze słów o czasie (date, timestamp, date_time) nie dostaje podpowiedzi, a zgłoszenie prosi o nazwę, która mówi, co ten czas zapisuje; tak samo nazwa, która wciąż zawiera słowo sufiksu, na przykład start_date_utc przy _date.
  • Schemat projektu wynika z każdej tabeli i kolumny, które uruchomienie dla całego projektu przeczytało w plikach objętych regułą. Korzysta z tego, co przeczytały sprawdzenia poszczególnych plików, więc niczego nie parsuje ponownie: na Polarze uruchomienie trwało około 25 ms dłużej (mediana z 7 uruchomień, 674 ms wobec 649 ms).
  • Konwencji nazw szuka w każdym pliku uruchomienia, objętym regułą albo nie, więc konwencja w app/database.py liczy się dla modeli w app/*/models.py, które wybiera modules. Plik jest pod tym kątem parsowany tylko wtedy, gdy w jego tekście jest naming_convention.

Jak naprawić

  1. Zmień nazwę tabeli albo kolumny tam, gdzie jest zadeklarowana, i w każdym miejscu, które używa starej nazwy.
  2. Jeśli tabela albo kolumna już istnieje, dodaj migrację, która zmienia jej nazwę: op.rename_table("posts", "post") albo op.alter_column("post", "created", new_column_name="created_at") z Alembica.
  3. Przy konwencji nazw daj MetaData, które dzielą wszystkie modele, naming_convention, a potem wygeneruj migrację: istniejące ograniczenia zachowują swoje nazwy, dopóki migracja ich nie zmieni.

Bezpieczeństwo poprawki

Brak automatycznej poprawki (autofix: false). Zmiana nazwy zmienia schemat bazy danych: wymaga migracji, a każde zapytanie, serializer, fixture i raport, który nazywa tabelę albo kolumnę, zmienia się razem z nią.

Konfiguracja

Ważność: błąd. Opt-in: włącz ją przez extend-select. Opcje są w [tool.inwards.rules.orm-naming] (tabele opcji):

pyproject.toml
[tool.inwards.rules]
extend-select = ["INW016"]

[tool.inwards.rules.orm-naming]
modules = ["shop.*.models"]          # optional: only these modules
table-name = "snake_singular"        # or "snake", or false
datetime-suffix = "_at"              # or false
date-suffix = "_date"                # or false
allow-tables = ["user_settings"]     # names accepted as written
require-naming-convention = true     # whole-project runs only
  • table-name: "snake_singular" (domyślnie) wymaga nazw w lower_case_snake i w liczbie pojedynczej, "snake" tylko lower_case_snake, a false wyłącza sprawdzanie tabel.
  • datetime-suffix, date-suffix: sufiks, na który kończy się nazwa każdej kolumny z datą i czasem albo z samą datą, z małych liter, cyfr i podkreśleń; false wyłącza to sprawdzenie. Domyślnie "_at" i "_date".
  • allow-tables: nazwy tabel, które reguła przyjmuje tak, jak są zapisane, na przykład rzeczownik niepoliczalny, który heurystyka bierze za liczbę mnogą, albo stara tabela, której nie da się jeszcze przemianować.
  • require-naming-convention: false usuwa ogólnoprojektowe zgłoszenie braku MetaData(naming_convention=...).

Pojedynczą nazwę można wyciszyć w jej linii: # inwards: ignore[INW016] reason="...". Zgłoszeń dla całego projektu nie da się tak wyciszyć, bo nie mają własnej linii:

  • Zgłoszenie o konwencji nazw wyłącza require-naming-convention = false.
  • Zgłoszenie schematu znika po ustawieniu wartości opcji, którą podaje; ta sama wartość mówi o schemacie sprawdzeniom po edycji. inwards baseline też je przyjmuje: jego komunikat nie ma liczb ani przykładów, więc zostaje przyjęte, gdy przybywa tabel i kolumn, dopóki pierwsza nazwa, na której stoi, zostaje w tym samym module. Liczby i przykłady są w jego poprawce.
  • Komentarz na nazwie, którą zastępuje zgłoszenie schematu, przy uruchomieniu dla całego projektu nie ma już czego ukrywać.

Szablon może włączyć INW016 dla jednej ze swoich ról: models = { orm-naming = true } ustawia modules na moduły roli. inwards init --style fastapi wpisuje models = { orm-naming = "warning" } do swojego szablonu fastapi-domain, więc modele każdej domeny od początku trzymają się tego schematu (przewodnik instalacji).

Znane ograniczenia

  • Tylko angielski. Heurystyka liczby pojedynczej zna angielskie formy mnogie, i to nie wszystkie: caches daje cach, a settings uchodzi za liczbę mnogą. Takie nazwy dopisz do allow-tables.
  • Tylko ostatnie słowo nazwy tabeli jest sprawdzane pod kątem liczby mnogiej: posts_like przechodzi.
  • Nazwy wyprowadzane. Nazwa tabeli z declared_attr, z funkcji __tablename__ w mixinie albo z napisu budowanego w czasie działania nie jest czytana, podobnie jak domyślna nazwa Django <app>_<model>, która wymaga etykiety aplikacji.
  • Typy, których reguła nie widzi. Kolumna, której typ pochodzi z wpisu w type_annotation_map, z własnego TypeDecorator, z aliasu typu importowanego z innego modułu albo z sa_column=Column(DateTime) w SQLModel bez nazwy, nie jest czytana jako kolumna daty i czasu.
  • Konwencji nazw reguła szuka tylko przy uruchomieniu dla całego projektu: sprawdzenie wybranych ścieżek, hook po każdej edycji i edytor jej nie zgłaszają. Konwencja ustawiona w sposób, którego reguła nie czyta (MetaData z **kwargs, konwencja w bibliotece), daje fałszywe zgłoszenie; ustaw wtedy require-naming-convention = false.
  • Migracje Alembica nie są czytane. Plik, który importuje alembic, zapisuje historię schematu: zmiana nazwy należy do modelu i nowej migracji, a prosi o nią zgłoszenie samego modelu.
  • Próg 90% to przybliżenie dopasowane do jednego projektu: tabele Polara są w 94% w liczbie mnogiej. Projekt z 85% dostaje zgłoszenie na każdej nazwie.
  • Zmierzona na korpusie (#182, rozdział 6) z ustawieniami domyślnymi: 173 zgłoszenia przed regułą o migracjach, 152 po niej (121 w Polarze, 26 w Saleorze), każde zgłoszenie kolumny i każda nazwa tabeli obejrzane; wszystkie 152 łamią domyślny schemat tak, jak podają. Ze zgłoszeniem schematu dla całego projektu (#361) 98 mnogich nazw tabel Polara stało się jednym zgłoszeniem: 121 → 24. Saleor zostaje przy 26: 46 z jego 72 kolumn z datą i czasem kończy się na _at, więc jego date_joined i start_date to mieszanka, a nie schemat. 13 z 50 pozostałych zgłoszeń kolumn nie podpowiada już nazwy złożonej tylko ze słów o czasie (date_at, timestamp_at), a date_of_birth dostaje birth_date.

Źródła