Перейти к содержанию

Модели по существующей базе

hare inspectdb [tables...] [--connection ALIAS] [--schema SCHEMA] читает таблицы существующей базы — PostgreSQL, SQLite или ClickHouse — и печатает модуль моделей для них: поля, связи, индексы, ограничения и опции таблиц, так же, как их написал бы makemigrations. То же доступно из кода (ниже).

hare inspectdb --connection default --schema public books authors > library/models.py

Что восстанавливается

inspectdb сообщает об ошибке, а не создаёт молча неверную модель, если встречает совпадение имён, которое нельзя выразить: две таблицы, различающиеся только регистром, иначе дали бы два одинаковых имени классов Python (один молча заменил бы другой), а колонка внешнего ключа, имя атрибута которой после отбрасывания суффикса (author_id → author) совпадает с другой обычной колонкой, иначе потеряла бы одно из двух полей. Оба случая дают явную ошибку. Он также восстанавливает составной внешний ключ одним ForeignKeyField (а не полем на каждую колонку) и индекс по выражению SQL, а не по списку колонок, — оба превращаются в настоящий код модели, а не в комментарий # TODO. Составной внешний ключ становится связью, когда он ссылается на весь первичный ключ цели под именами колонок, которые есть у составной связи, — <field>_<target key column> (owner_a, owner_b для цели с ключом a, b); ключ под другими именами (a, b) остаётся обычными полями с # TODO, где он назван: составная связь называет свои колонки только по имени поля, поэтому чтобы объявить её, колонки нужно переименовать. Индекс или ограничение уникальности по колонкам связи называет саму связь (Index(fields=["owner", "width"])), если они идут подряд в порядке её ключа и в одном направлении, — вразбивку, в обратном порядке или в разных направлениях каждая колонка называется сама (["owner_b", "owner_a"]), и такой индекс остаётся отдельным, а не индексом связи и не связью один-к-одному; комментарий, записанный на каждую её колонку, становится её description. hare drift читает индексы так же. Таблица, в имени которой есть точка (её не может содержать Meta.table), пропускается с комментарием об этом.

Внешний ключ, объявленный с ON DELETE SET DEFAULT, восстанавливается как on_delete=SET_DEFAULT с DEFAULT колонки в качестве db_default (ForeignKeyField с настоящим ограничением его требует). Если у колонки нет DEFAULT, база на самом деле сбрасывает её в NULL (или останавливает удаление, если колонка NOT NULL), поэтому inspectdb записывает SET_NULL (или NO_ACTION) с комментарием # TODO вместо поля SET_DEFAULT, которое нельзя было бы импортировать. ON DELETE SET NULL у колонки NOT NULL, на котором удаление в базе останавливается, так же восстанавливается как NO_ACTION с # TODO.

ON DELETE NO ACTION DEFERRABLE INITIALLY IMMEDIATE в PostgreSQL — ограничение, которое создаёт on_delete=PROTECT, — восстанавливается как PROTECT. То, что связь не объявляет, — действие ON UPDATE, другая отложенность (DEFERRABLE INITIALLY DEFERRED), — называется в # TODO: миграция модели создаёт ограничение без этого, а drift сообщает о расхождении (SQLite сообщает только действие ON UPDATE, без отложенности).

Другие правила восстановления:

  • Колонка внешнего ключа, которая одновременно первичный ключ таблицы, становится OneToOneField(..., primary_key=True); колонка внешнего ключа без суффикса _id (owner_code) получает source_field="owner_code".
  • Если у таблицы две и больше связей с одной и той же таблицей (в том числе пара ссылок таблицы на саму себя), каждая получает related_name="<table>_<field>_set", чтобы их обратные связи не совпадали.
  • Частичный индекс сохраняет своё условие WHERE как PartialIndex(condition=RawSQLTerm(...)) — в том виде, в каком его сообщает база, и в PostgreSQL, и в SQLite. Индекс по выражению (lower(note)) восстанавливается как Index(RawSQLTerm(...)) на обоих диалектах — ключ без скобок, которые SQLite хранит вокруг него ((lower(note))), — а составной внешний ключ восстанавливается и в SQLite.
  • Индекс или ограничение сохраняют своё имя в базе (Index(..., name="book_published_idx")), если это не имя, которое hare создаёт для такого объявления, и не имя, которое PostgreSQL даёт безымянному индексу или ограничению UNIQUE (<table>_<columns>_idx/_key); обычный индекс по одной колонке со своим именем становится именованной записью Meta.indexes, а не db_index=True.
  • Уникальный индекс восстанавливается тем объявлением, которому принадлежит его имя: безымянным UniqueConstraint(fields=...) при имени, которое hare создаёт для такого ограничения (или которое PostgreSQL даёт безымянному UNIQUE), Index(unique=True) при имени, которое hare создаёт для такого индекса, иначе — именованным UniqueConstraint со своим condition= (частичный уникальный индекс, условие — готовый SQL) и deferrable=/initially_deferred= (в том числе у DEFERRABLE UNIQUE по одной колонке).
  • Условие CHECK восстанавливается без пары скобок, в которую его оборачивает PostgreSQL (CheckConstraint(check=RawSQLTerm("age >= 0"))); список IN по колонке varchar — у CharEnumField — восстанавливается так, как был объявлен (color IN ('red', 'blue')), а не как (color)::text = ANY (...) PostgreSQL: объявленный заново, этот текст PostgreSQL выводит иначе.
  • Индекс восстанавливается с колонками INCLUDE (...) (include=), порядком DESC/NULLS FIRST каждого ключа ("-a", F("a").desc(nulls_last=True), ключ-выражение — как OrderedIndexKey(RawSQLTerm("lower(c)"), Order.DESC_NULLS_FIRST)), ключом с COLLATE как выражением (RawSQLTerm('d COLLATE "C"')) и NULLS NOT DISTINCT (UniqueConstraint(nulls_distinct=False)). То, что не может объявить ни один индекс hare, — порядок ключа с COLLATE в SQLite, — в восстановленный индекс не попадает и перечисляется в # TODO.
  • Внешний ключ на таблицу другой схемы, чем проверяемая, остаётся обычной колонкой с # TODO, называющим цель: связь может указывать только на модель из того же запуска.
  • Партиция партиционированной таблицы PostgreSQL не показывается (её заменяет сама партиционированная таблица), если её не назвать явно; drift тоже не считает партиции неизвестными таблицами. Таблица, партиционированная так, как не объявляет ни один PostgresqlTableOptions(partitioning=...), — по выражению или с партицией, партиционированной в свою очередь, — получает # TODO с её PARTITION BY: миграция модели создаёт таблицу без партиций.
  • Представление и материализованное представление — не модели: inspectdb всей схемы называет каждое в # NOTE в конце модуля — его объявляют в Meta.views/Meta.materialized_views модели, которую оно читает, — вместе с триггерами на нём (INSTEAD OF), которые добавляет миграция RunSQL.
  • Колонка идентификатора PostgreSQL (GENERATED ... AS IDENTITY, не первичный ключ) становится целочисленным полем с generated=True и # TODO: hare никогда не записывает её и читает присвоенное значение обратно, но собственных команд для колонки идентификатора у него нет: миграция, которая её создаёт, и hare drift, который создаёт таблицы моделей в своей тени, такое поле отклоняют — объявите её модель с Meta.managed = False, чтобы drift работал для остальной схемы. Числовое значение по умолчанию в кавычках ('-1.5'::double precision) восстанавливается числом. Литеральное значение по умолчанию — значение, которым его читает поле ('7' целочисленной колонки — 7, 1 или 't' булевой — True); то, которого не может хранить ни одно значение поля, — 'NaN' вещественной, 'infinity' даты, '1 day' интервала, — восстанавливается своим текстом: SqlDefault("'NaN'").
  • Целочисленный первичный ключ, которому база не даёт значения, — в PostgreSQL без последовательности и без идентификатора, в SQLite другого типа, чем INTEGER (только он — идентификатор строки), — восстанавливается с generated=False, чтобы create() передавал ключ.
  • Вычисляемая колонка становится GeneratedField со своим выражением — кроме хранимого tsvector PostgreSQL, выражение которого записывает TSVectorField: он восстанавливается этим TSVectorField с его source_fields, config и weights, и __search сохраняет свою конфигурацию.
  • Колонка UNIQUE или с индексом, тип поля которой нельзя индексировать (JSONField), теряет индекс с # TODO, а не даёт модель, которую нельзя импортировать.
  • Внешний ключ с UNIQUE по одной колонке восстанавливается как OneToOneField.
  • Собственный индекс внешнего ключа (обычный индекс ровно по его колонкам ключа) не записывается — у ForeignKeyField он есть по умолчанию; внешний ключ без такого индекса и без другого индекса, начинающегося с его колонок, получает db_index=False.
  • Значение по умолчанию колонки-массива ('{1,2}'::integer[]) восстанавливается списком (db_default=[1, 2]), элементы читаются по типу элемента массива.
  • Колонка-массив PostgreSQL восстанавливается с объявленным числом измерений: integer[][] — как ArrayField(base_field=ArrayField(base_field=IntField())). У колонки, созданной без объявления (через CREATE TABLE ... AS), число измерений не записано, и она восстанавливается одномерной.
  • Колонка типа, который не пишет в точности ни одно поле hare (real, json, numeric без размеров, BOOLEAN/NUMERIC/DECIMAL в SQLite — десятичная колонка SQLite хранит целое или число с плавающей точкой, поэтому она становится FloatField), восстанавливается ближайшим полем с # TODO, где названы оба типа: drift сообщает о разнице, пока колонку не изменят на тип поля. Так же восстанавливается колонка домена PostgreSQL — полем типа домена — и первичный ключ identity (GENERATED ... AS IDENTITY) — генерируемым ключом, который hare объявляет через последовательность.
  • В SQLite hare объявляет колонку BooleanField как INT, так же, как IntField: такая колонка восстанавливается как IntField с # TODO, где сказано, что она может быть BooleanField. Колонка SQLite с синонимом, который SQLite хранит так же, — DATETIME для TIMESTAMP hare, DOUBLE/DOUBLE PRECISION/FLOAT для его REAL — читается так, как её объявляет hare: её поле восстанавливается без заметки, а drift ничего не сообщает.
  • Промежуточная таблица связи многие-ко-многим — две колонки, каждая внешний ключ на первичный ключ таблицы того же запуска — восстанавливается как ManyToManyField на модели таблицы её первой колонки: through= называет таблицу, forward_key/backward_key — её колонки, поле названо по таблице без имени таблицы владельца (booking_people → people), related_name="<таблица>_set". Сама таблица вместо класса получает # NOTE с именем поля. Таблица связи другой формы или такая, чьей второй стороны нет в запуске, пропускается с # NOTE, где сказано, как её объявить.
  • Колонка типа ENUM PostgreSQL — и массив такого типа — восстанавливается как NativeEnumField над перечислением, которое модуль объявляет из меток типа в их порядке (Mood = StrEnum('Mood', {'SAD': 'sad', 'OK': 'ok'}); метка, которая не является именем, превращается в имя: so-so → SO_SO, 1st → VALUE_1ST), с type_name= и description='' — иначе поле описало бы свои члены в комментарии, которого у колонки нет. Перечисление и его члены можно переименовать; значения — это метки. Другой пользовательский тип (составной) восстанавливается как TextField с # TODO, где он назван.
  • Колонка Enum8/Enum16 ClickHouse восстанавливается полем-перечислением, которое пишет тот же тип, над перечислением, которое модуль объявляет и называет по колонке (StatusLabels), с description='': CharEnumField над StrEnum, когда у меток те номера, которые CharEnumField даёт своим значениям (таблица, созданная hare), и IntEnumField над IntEnum из меток и их номеров, когда каждая метка — имя (Enum8('LOW' = 1, 'HIGH' = 2) → IntEnum('LevelLabels', {'LOW': 1, 'HIGH': 2})). Любое другое перечисление — метки, которые не являются именами, или Enum16, номера которого помещаются в Enum8, — восстанавливается как CharField своих меток с # TODO.
  • timestamp и time без часового пояса, interval и character(n) в PostgreSQL восстанавливаются как NaiveDatetimeField, NaiveTimeField, IntervalField и FixedCharField(max_length=n), массив таких значений (и inet, cidr, macaddr) — как ArrayField этого поля, а элементы точности, которую не хранит ни одно поле (timestamptz(2)[]), получают # TODO; Date, DateTime и DateTime64 в ClickHouse, кроме DateTime64(6, 'UTC') поля DatetimeField, — как ClickhouseDateField и ClickhouseDatetimeField с точностью и часовым поясом колонки.
  • Колонка типа, для которого у hare нет поля, — bit, money, xml в PostgreSQL, ANY в STRICT-таблице SQLite — восстанавливается как TextField с # TODO, где назван тип.
  • Защита на уровне строк (row level security) таблицы PostgreSQL восстанавливается как Meta.row_level_security (RowLevelSecurity.ENABLED или FORCED, если она фильтрует и строки владельца), её политики — как Meta.policies (каждое условие — RawSQLTerm с текстом, который хранит сервер), привилегии на неё и на её колонки, выданные ролям, кроме владельца, — как Meta.grants (один Grant на роль и право передачи; колоночный — на набор колонок с одинаковыми привилегиями; привилегия, которой нет в Privilege, получает # TODO), а последовательности, которыми владеет её колонка (OWNED BY) или из которых, без владельца, она берёт значение по умолчанию, — как Meta.sequences; последовательность SERIAL или identity первичного ключа из одной колонки к ним не относится: генерируемый ключ берёт свою, — если только из неё не берёт значение по умолчанию и другая колонка. Другая колонка SERIAL — вне первичного ключа или колонка составного ключа — сохраняет там свою последовательность и db_default=SqlDefault("nextval(...)"). Последовательность без владельца, из которой берут значения по умолчанию несколько таблиц, объявляет модель первой из них по имени — из тех, что читают её не первичным ключом из одной колонки; у остальных — # TODO с именем этой таблицы.
  • Движок ClickHouse Distributed, читающий таблицы инспектируемой базы, называет её currentDatabase(), как пишет hare, — модель создаёт его над таблицами той базы, в которой её мигрируют.
  • Колонка ClickHouse EPHEMERAL, которую пишут, но нигде не хранят, восстанавливается своим полем с # TODO: ни одно поле её не объявляет, и миграция модели создаёт её хранимой. Запуск по всей схеме называет каждый словарь ClickHouse в # NOTE, как представление.
  • Запуск по всей схеме называет каждую функцию и каждую последовательность, которой не владеет и из которой не берёт значение по умолчанию ни одна колонка, в # NOTE с её объявлением DatabaseFunction(...)/DatabaseSequence(...) (аргументы функции — со значениями по умолчанию и OUT-аргументами) — его кладут в Meta.functions/Meta.sequences модели, которая её использует: таблицы, к которой она относится, нет, — и с Grant(...) на каждую выданную на неё привилегию — для Meta.grants.
  • Значение по умолчанию PostgreSQL «момент оператора» (statement_timestamp()) восстанавливается как Now(), который так и пишется; now() и CURRENT_TIMESTAMP — начало транзакции — остаются SqlDefault("now()"), чтобы миграция сохранила время колонки.
  • Таблица без первичного ключа, единственный ключ которой — один составной внешний ключ под UNIQUE, — это отдельная модель (OneToOneField и Meta.primary_key = None), а не промежуточная таблица связи многие-ко-многим, которая соединяет две стороны.
  • Значение по умолчанию колонки JSON ('{}'::jsonb) восстанавливается самим документом (db_default={}), а выражение по умолчанию в SQLite (DEFAULT (datetime('now'))) сохраняет свои скобки: SqlDefault("(datetime('now'))").
  • Значение по умолчанию ClickHouse, которое hare записал типизированным литералом — число с плавающей точкой по его байтам, toDecimal128('1.50', 2), toIPv6('::1'), приведение к типу Dynamic или Variant, — восстанавливается самим значением (db_default=1.5), а значение enum — так, как его хранит поле: у CharEnumField меткой, у IntEnumField числом, какую бы сторону члена ни хранил сервер.
  • Колонка или таблица, имя которой в Python совпало бы с одним из импортов создаваемого модуля или с атрибутом модели (колонка fields или pk, таблица model), переименовывается — к полю добавляется _field (fields_field с source_field="fields"), к классу — подчёркивание в конце (Model_ с Meta.table = "model"), — а связи указывают на переименованный класс. Колонка с именем ключевого слова тоже получает _field (from_field), а серия подчёркиваний в имени колонки становится одним: имя поля не содержит __ и не оканчивается на _ (см. Типы полей).

Код моделей из программы

hare inspectdb печатает то, что возвращает hare.inspectdb.SchemaInspector:

from hare.inspectdb import SchemaInspector

source = await SchemaInspector.inspect(connection, ["books", "authors"], schema="public")
class SchemaInspector:
    @staticmethod
    async def inspect(connection: DatabaseClient, tables: list[str] | None = None, schema: str | None = None) -> str: ...

    @staticmethod
    def render_module(
        table_infos: list[TableInfo], dialect: str, views: list[tuple[str, bool, tuple[str, ...]]] | None = None
    ) -> str: ...

inspect() создаёт один модуль, готовый к импорту, для этих таблиц (без tables — для всех таблиц схемы schema, кроме служебных таблиц hare hare_migrations и hare_distributed_decisions; schema при None — схема подключения по умолчанию; в SQLite это подключённая база, в ClickHouse — база сервера; таблица не схемы по умолчанию получает свой Meta.schema). Ошибки: UnsupportedDialectError для диалекта, который не умеет читать схему базы, SchemaNotFoundError/TableNotFoundError для отсутствующей схемы или таблицы и DuplicateModelClassNameError для двух таблиц с одним именем класса. render_module() строит код для таблиц, уже прочитанных через DatabaseCatalog (см. Чтение таблицы: DatabaseCatalog); views — имя каждого представления, материализовано ли оно и его триггеры — это представления, которые он называет в # NOTE в конце.

Каждая таблица сначала превращается в такое же состояние модели, какое есть у объявленной модели (hare.inspectdb.InspectedModelBuilder(table_info, ModelGenerationOptions(dialect, app_label)).build() возвращает InspectedModel, где state — ModelState миграций из настоящих объектов полей, индексов и ограничений, — а также заметки # TODO и раскладку Meta). Код — это то же состояние, записанное так, как его записывает файл миграции: каждое поле, индекс и ограничение — вызовом из своего deconstruct(); ModelFactory строит класс из того же самого состояния, поэтому сгенерированный код и класс, построенный во время работы, никогда не расходятся.