Создание диалекта: SQL¶
Шаги 3–6 создания диалекта: имена, литералы и параметры, типы колонок и значения, выражения и операторы фильтра, клаузы запроса.
3. Имена, литералы и параметры: SqlLiterals, SqlParameters¶
class ColstoreLiterals(SqlLiterals):
identifier_quote_char = "`"
alias_quote_char = "`"
class ColstoreParameters(SqlParameters):
placeholder_template = "{{p{}}}" # {p1}, {p2}, ...
class ColstoreDialect(Dialect):
name = "colstore"
otel_system_name = "colstore" # db.system.name в OpenTelemetry
features = Features(
supports_transactions=False,
supports_select_for_update=False,
supports_update_limit_order_by=False,
supports_foreign_keys=False,
supports_unique_constraints=False,
)
def build_literals(self):
return ColstoreLiterals(self)
def build_parameters(self):
return ColstoreParameters(self)
SqlLiterals записывает имена и значения в текст SQL; имена и литералы экранируются только здесь —
редактор схемы, отрисовка SQL и чтение схемы спрашивают его:
| Член класса | Что задаёт |
|---|---|
identifier_quote_char, alias_quote_char, quote_identifier(name), qualify_table_name(table, schema) |
Кавычки для имён таблиц, колонок, индексов и ограничений и для псевдонимов в SELECT. |
get_identifier_key(name) |
По чему различаются имена — два имени с одним ключом называют одну таблицу или колонку; по умолчанию имя как написано, в нижнем регистре — там, где база сравнивает имена без учёта регистра. |
get_string_literal_sql(text), get_literal_sql(value), get_boolean_literal_sql(value), get_bytes_literal_sql(value), get_array_literal_sql(element_sqls) |
Литералы, записываемые в текст SQL, — строка, значение колонки по умолчанию, логическое значение запроса, байты, массив. |
SqlParameters передаёт значения параметрами:
| Член класса | Что задаёт |
|---|---|
placeholder_template, get_placeholder(index), numbers_parameters |
Место для параметра запроса; {} — его номер, начиная с 1 ($1 в PostgreSQL, ? в SQLite). Кэш вида запроса подставляет значения по позиции, поэтому с ним работает любой вид мест для параметров. |
get_parameter_cast_type(value, position), get_field_parameter_cast_type(field), get_row_key_cast_type(field), get_json_object_value_cast_type(value, value_type), get_cast_parameter_sql(sql, value) |
Тип, к которому приводится значение-параметр там, где его тип ничто не задаёт — см. get_parameter_cast_type() |
get_bindable_number(value) |
Как передаётся число, означающее число JSON. |
single_parameter_in_list_min_length |
Длина, начиная с которой список __in/__not_in передаётся одним параметром. |
get_default_rows_source_sql(row_count), get_column_arrays_rows_source_sql(column_types, first_index) |
Источники, из которых многострочный INSERT читает строки значений по умолчанию или по одному массиву на колонку, — None для команды на строку и VALUES. |
get_copy_column_type(field) |
Тип, который массовой загрузке (copy()) называют для колонки поля, — по умолчанию тот, что объявлен в таблице. |
supports_copy_column_type(column_type) |
Загружает ли массовая загрузка колонку такого типа. |
get_parameter_cast_type()¶
Тип, к которому приводится значение-параметр там, где его тип ничто не задаёт: по месту в запросе
(ParameterPosition: ветка CASE, выбранная или сравниваемая константа, аргумент функции), как значение
колонки, как значение ключевой колонки в строке bulk_update() (по умолчанию — как значение колонки),
как значение объекта JSON или в готовом SQL. По умолчанию None или SQL без изменений; PostgreSQL,
который определяет тип параметра только по окружению, добавляет приведение; ClickHouse приводит ключи,
потому что константа без приведения получает самый узкий тип, в который помещается.
Dialect.sql_context — контекст, в котором выводится каждая команда; класс запроса задаёт
SQL_CONTEXT = dialect.sql_context.
4. Типы колонок и значения: build_types()¶
build_types() возвращает TypeRegistry (hare.dialects.base.types), который сопоставляет классам
полей то, как диалект их хранит. Класс поля использует запись ближайшего зарегистрированного базового
класса, поэтому стороннее поле наследует хранение своего родителя:
def build_types(self):
types = TypeRegistry()
types.register(BigIntField, TypeMapping(column_type="Int64"))
types.register(DatetimeField, TypeMapping(column_type="DateTime64(6, 'UTC')"))
types.register(UUIDField, TypeMapping(column_type="UUID"))
types.register(CharField, TypeMapping(column_type=lambda field: "String"))
types.register(
DecimalField,
TypeMapping(column_type=lambda field: f"Decimal({field.max_digits}, {field.decimal_places})"),
)
return types
Член TypeMapping |
Что задаёт |
|---|---|
column_type |
Тип колонки или функция (field) -> str. Без него — собственный SQL_TYPE поля. |
generated_sql |
Команда создания колонки первичного ключа, который генерирует база. |
function_cast |
Функция (field, term) -> term, которой колонка оборачивается везде, где её сравнивают, сортируют или копируют. |
to_db, to_lookup, to_python |
Заменяют to_db_value(), to_lookup_value() и from_db_value() поля — каждое значение передаётся и читается через подключение запроса. |
to_db_refuses_only |
to_db только отклоняет значение — то, которое native_write_check поля оставляет ему, — а любое другое пишет так же, как поле: ускоритель записи сам пишет все остальные значения. |
json_term |
Текст, которым значение колонки записывается в объект JSON, — для значения, которое хранится в виде, непригодном для JSON (UUID, хранимый 16 байтами). |
extension |
Расширение базы, которое нужно типу колонки (postgis для пространственных типов PostgreSQL), — создаётся везде, где используется поле этого класса, и попадает в миграции как CreateExtension. |
naive_datetime_is_utc |
to_python — собственный from_db_value() поля, а дата-время без пояса от драйвера читается как момент в UTC. |
inserted_by_select |
INSERT, записывающий колонку, пишет строки как SELECT из них, а не VALUES, — для значения, записанного выражением, которое VALUES диалекта не читает; или (field) -> bool, решающая это для каждого поля. |
Features описывают поведение значений: divides_integers_exactly (/ целых даёт и дробь),
computes_integer_statistics_exactly (среднее целых — точное десятичное),
supports_virtual_generated_columns. Там, где значение хранится или вычисляется
иначе, SQL дают TermRenderers диалекта (следующий раздел). Функция, которая
есть в базе только для части типов аргументов (ROUND(numeric, int) в PostgreSQL), — тоже забота
рендерера.
Пакет, добавляющий поле, может зарегистрировать его хранение на любом диалекте:
DialectRegistry.get_dialect("colstore").types.register(MoneyField, TypeMapping(column_type="Decimal(18, 2)"))
База, драйверы которой выводят SQL в нескольких объектах диалекта, — ClickHouse, где clickhouse-connect
пишет JSONField колонкой JSON, — называет остальные в variants зарегистрированного диалекта
(ClickhouseDialect(variants=(...))): тип, способ вывода (register(), register_function(),
register_name()) или оператор фильтра (register_operator()), зарегистрированные на нём,
регистрируются и на каждом варианте — одна регистрация доходит до всех драйверов базы. Собственные
регистрации варианта остаются на нём.
5. Выражения и операторы фильтров: TermRenderers, FilterOperators¶
По умолчанию каждое выражение выводится стандартным SQL. build_renderers() возвращает TermRenderers —
замены для того, что база записывает иначе; они находятся по иерархии классов выражения:
def build_renderers(self):
renderers = TermRenderers(self)
renderers.register_function("LENGTH", self.render_length) # (function, sql_context) -> sql
renderers.register_name(functions.Coalesce, lambda term, sql_context: "ifNull")
return renderers
@staticmethod
def render_length(length, sql_context):
return f"lengthUTF8({length.get_arg_sql(length.args[0], sql_context)})"
Собственные диалекты hare строят свои рендереры на CheckedTermRenderers
(hare.dialects.base.renderers): его add_dialect_renderers() регистрирует рендереры диалекта,
сгруппированные по темам в классы с одним register(renderers) — <Dialect>JsonRenderers,
<Dialect>TemporalRenderers, <Dialect>NumberRenderers, <Dialect>TextRenderers, а также рендереры
пространственных терминов, векторов и полнотекстового поиска. При создании он проверяет, что у каждого
термина, который должен отрисовывать каждый диалект hare, — терминов JSON, дат и времени, чисел и текста,
которые ядро оставляет диалекту, — есть рендерер, и даёт TypeError с именами недостающих. Сторонний
диалект может начать с него так же или с обычного TermRenderers.
Подкласс TermRenderers даёт ещё и выражения, которые диалект подставляет вместо исходного там, где его
SQL нужно другое:
| Член класса | Что задаёт |
|---|---|
is_distinct_from_operator |
Неравенство, правильное при NULL (IS NOT в SQLite). |
get_distinct_from_sql(left_sql, right_sql) |
Неравенство двух значений, правильное при NULL, — по умолчанию left <is_distinct_from_operator> right. |
get_enum_number_term() |
Колонка целочисленного поля-перечисления там, где с её числом считают, — операнд арифметики, аргумент числовой функции — на базе, чей тип перечисления не принимает ни того, ни другого. |
get_same_type_math_result() |
Результат математической функции типа её аргумента (Abs, Ceil, Floor) — приводится обратно к типу аргумента там, где функция базы даёт другой. |
get_float_operand() |
Целый операнд арифметики с числом с плавающей точкой — приводится там, где в базе нет арифметики широкого целого и такого числа. |
get_json_path_comparand() |
С чем сравнивается значение JSON по пути — JSON, хранящийся текстом. |
get_statistic_as_float() |
Среднее или статистика, которые база считает числом с плавающей точкой, — читаются как оно. |
get_datetime_part_comparand() |
Дата или время суток, сравниваемые с частью момента времени. |
get_concatenated_argument_sql(sql, argument) |
Аргумент склейки текста — с приведением там, где база определяет его тип только по окружению. |
get_order_sql(order) |
SQL направления сортировки — ASC, DESC NULLS LAST, ... — по умолчанию его собственный текст. |
get_ordering_clause_sql(term, term_sql, order, sql_context) |
Один ключ ORDER BY — по умолчанию выражение и его направление. |
get_window_ordering_clause_sql(function, term, term_sql, order, sql_context) |
Один ключ ORDER BY окна — по умолчанию get_window_ordering_sql(). |
get_window_ordering_sql(function, term_sql, order) |
SQL одного упорядочения окна — по умолчанию выражение и его направление. |
get_result_branch_term(term, output_field) |
Ветвь CASE или COALESCE в том виде, какой нужен типу всего результата. |
get_null_if_result(term, field) |
Вызов NULLIF либо отказ до выполнения, если тип аргумента не может представить nullable-результат. По умолчанию возвращает терм без изменений. |
get_assigned_integer_term(term) |
Что UPDATE записывает в целочисленную колонку для выражения с дробью — с приведением там, где тип колонки её не округляет. |
get_assigned_enum_term(term, member_numbers) |
Что UPDATE записывает в колонку целочисленного перечисления для выражения — там, где колонка отклоняет число, не принадлежащее ни одному члену. |
get_scaled_duration(term) |
Микросекунды длительности, умноженной или делённой на число, — целые микросекунды, половина к чётному, чтобы следующая арифметика и фильтр брали прочитанное значение; по умолчанию сам терм — для базы, арифметика которой уже даёт целые микросекунды. |
get_duration_quotient(dividend, divisor) |
Микросекунды длительности, делённой на целое, — округлённые до ближайшего, половина — к чётному, как Python делит timedelta. |
get_composite_distinct_key() |
По чему COUNT(DISTINCT ...) считает составной ключ. |
get_never_null_column_count_argument(term) |
Что COUNT получает для колонки запрашиваемой таблицы, в которой нет NULL, — по умолчанию саму колонку; * там, где посчитать строки дешевле, чем читать колонку. |
build_filter_operators() возвращает FilterOperators — соответствие между функцией сравнения, которую
задаёт оператор фильтра, и её заменой в диалекте
(FilterOperators(self, {Lookups.is_in: colstore_is_in, ...}), {} — без замен). Оператор, который
реализуют только диалекты (DialectImplementedOperators), должен быть заменён, иначе оператор фильтра не
поддерживается: FilterOperators.supports_lookup() — а с ним и Model._meta.get_lookups(path, dialect)
— его не включает, а фильтр с ним даёт ошибку до построения SQL.
Замены собираются из блоков hare.query.filters: Lookups (операторы сравнения, каждый строит
field <оператор> value), FieldLookups (операторы фильтра поля по суффиксу, в том числе добавленные
register_lookup()), DatePartLookups (__year, __date, ... — часть сравнивается как своя колонка),
JsonLookups (операторы для всего значения JSON), ValueEncoders (то, что превращает значение Python
оператора в то, с чем он сравнивает, вызывается как (value, model, field, dialect)), FieldLookup
(один оператор: его условие и кодирование значения) и Like с DEFAULT_LIKE_ESCAPE_CLAUSE
(ESCAPE '\') для шаблона LIKE.
Слишком длинный список значений, чтобы привязывать по параметру на значение, сокращает диалект: создайте
подкласс LargeInList (hare.dialects.base.parameters.large_in_list) и в build_filter_operators()
сопоставьте Lookups.is_in/not_in/row_is_in/row_not_in его одноимённым методам. Алгоритм написан
один раз в базовом классе — когда применяется короткая форма, как отдельно сравниваются NULL из списка,
как отрицается not_in, форма «строка значений» для составного ключа; диалект даёт только контейнер, в
который значения привязываются одним параметром:
get_membership_criterion(field, values, non_null_values, element_type) (= ANY($1::type[]) в
PostgreSQL, IN (SELECT value FROM json_each(?)) в SQLite) и
get_row_container(value_rows, element_types) для строк значений. Возврат None оставляет обычный
список IN (...).
Рендерер меняет то, как пишется SQL терма, но никогда не то, по чему составлен ключ плана запроса, —
диалект входит в каждый ключ. Собственный класс выражения диалекта (функции массивов, триграмм и
пространственные функции PostgreSQL) объявляет в plan_parts, как его атрибуты входят в план, и
разрешает аргументы через ExpressionArguments.get_result(), как любое своё
выражение.
Пакет, объявляющий такие выражения, при импорте регистрирует их за своим диалектом —
DialectExpressions.register(MyDialect, (MyFunction, ...)) (hare.query.expressions.dialect_expressions);
подкласс зарегистрированного класса зарегистрирован вместе с ним. Запрос к другой базе отвергает каждое из
них с UnSupportedError до построения SQL: Function.get_result() проверяет это для любой функции, а
выражение со своим get_result() первым делом вызывает
DialectExpressions.raise_if_dialect_lacks(self, expression_context.dialect).
6. Части команд: QueryClauses¶
QueryBuilder один на все базы. Он выводит SQL по стандарту ISO и спрашивает QueryClauses диалекта о
каждой части, которую базы записывают по-разному; базовый QueryClauses пишет SQL по стандарту
(OFFSET ... ROWS FETCH FIRST ... ROWS ONLY) и даёт UnSupportedError для части, которой в стандарте
нет:
| Член класса | Что записывает |
|---|---|
get_statement_context(builder, sql_context) |
Контекст, в котором выводятся термы самой команды, — по умолчанию тот же; диалект, рендерерам которого нужно знать что-то о команде вокруг терма (что в ней есть группировка), возвращает свой контекст с этим знанием. |
get_bulk_load_statement_sql(table, columns) |
Команда, под которой массовая загрузка (copy()) сообщается наблюдателям, спанам и счётчику запросов. |
get_distinct_sql(builder, sql_context) |
DISTINCT / DISTINCT ON (...). |
get_row_lock_sql(builder, sql_context) |
Блокировку строк (select_for_update()): FOR UPDATE, NO KEY, OF, NOWAIT, SKIP LOCKED. |
get_statement_end_sql(builder, sql_context) |
Окончание подзапроса или запроса с клаузами методов QuerySet диалекта после его LIMIT — по умолчанию пустое; ClickHouse пишет туда свои SETTINGS и даёт подзапросу с соединениями join_use_nulls = 1: иначе условие мутации выполняется без этой настройки сессии. |
get_main_table_suffix_sql(builder, sql_context) |
То, что идёт после первой таблицы FROM, — по умолчанию её выборка (get_table_sample_sql()); ClickHouse пишет туда FINAL и SAMPLE ... OFFSET .... |
get_prewhere_sql(builder, sql_context), get_limit_by_sql(builder, sql_context) |
Условие, читаемое до WHERE, после соединений; ограничение числа строк в группе, после ORDER BY — по умолчанию пустые. |
get_limit_offset_sql(limit_sql, offset_sql) |
Границы строк запроса. |
get_returning_sql(returned) |
RETURNING из ReturnedValue — SQL значения, псевдоним, под которым оно возвращается, имя обычной колонки. |
get_lateral_sql(subquery_sql) |
Подзапрос, присоединённый как LATERAL, — по умолчанию ISO LATERAL (...). |
get_table_sample_sql(method, percent_sql, seed_sql) |
Выборку таблицы во FROM — по умолчанию ISO TABLESAMPLE ... REPEATABLE (...). |
get_merge_sql(...), get_merge_when_sql(when), get_merge_returning_sql(returned, action_alias_sql) |
MERGE из готовых частей (MergeWhenSql) — по умолчанию ISO MERGE; ветка DO NOTHING, WHEN NOT MATCHED BY SOURCE и RETURNING там дают UnSupportedError. |
get_on_conflict_sql(builder, sql_context) |
Обработку конфликта у INSERT. |
get_update_sql(builder, sql_context) |
UPDATE — с чтением других таблиц во FROM, сортировкой и ограничением строк, если база это умеет. |
get_delete_sql(builder, sql_context) |
DELETE — по умолчанию ISO DELETE FROM с условиями; база, которая удаляет строки иначе (мутация ALTER TABLE ... DELETE), пишет свой. |
get_typed_placeholder_template(), get_values_table_columns_sql(), get_update_from_values_sql(), get_insert_rows_source_sql() |
Команды, которые hare пишет текстом: UPDATE из таблицы VALUES (bulk_update()), INSERT строк из источника. |
get_upsert_inserted_flag_sql() |
Выражение RETURNING, отличающее строку, вставленную вставкой-или-обновлением, от изменённой. |
get_explain_sql(sql, output_format, options, features) |
Команду EXPLAIN, которую используют QuerySet.sql(explain=True) и explain(). |
join_keywords, get_join_keyword_sql(join_type) |
Ключевое слово соединения каждого типа — по умолчанию JOIN и LEFT OUTER JOIN; тип, которого нет в соответствии, даёт UnSupportedError. Диалект добавляет в соответствие свои соединения (ASOF и ARRAY JOIN ClickHouse). |
get_select_all_except_sql(quoted_columns) |
Начало запроса, выбирающего все колонки источника, кроме некоторых, — производная таблица, в которой выполняется запрос с коррелированными подзапросами там, где база выполняет их так; по умолчанию UnSupportedError. |
get_values_source_sql(row_sqls, column_names, alias_sql, *, matches_no_row) |
Подзапрос строк, заданных значениями, — источник MERGE. |
Класс запроса только привязывает команды к диалекту:
ColstoreQuery = DeclaredSubclass.make(
Query, "ColstoreQuery", __package__, "A query in Colstore's SQL.", SQL_CONTEXT=COLSTORE_DIALECT.sql_context
)
Методы QuerySet от диалекта¶
Собственные модификаторы запросов базы становятся методами QuerySet, и hare о них знать не нужно.
Зарегистрируйте их в install() функцией, которая получает построитель запроса и аргументы вызова и
возвращает построитель, — например, random_share() у Colstore:
def install(self):
super().install()
QuerySetExtensions.register("random_share", self.name, self.apply_random_share)
def apply_random_share(self, builder, percent):
return builder.where(LiteralValue(f"abs(random()) % 100 < {int(percent)}"))
Event.objects.filter(...).random_share(10) запоминает вызов — в том числе до Hare.init(), — а запрос
применяет его, когда строится для своего подключения; на подключении другого диалекта это даёт
UnSupportedError. Пакет может зарегистрировать метод и для диалекта, который определил не он.
QuerySetExtensions.register(name, dialect_class, apply) (из hare.query.queryset.extensions) обычно
вызывается в install() диалекта с type(self) — метод получает каждый объект класса диалекта.
apply(builder, *args, **kwargs) возвращает построитель запроса с применённым вызовом. Вызов запоминается в
запросе (в том числе до Hare.init()) и переносится в values(), count() и другие запросы, построенные из
него; когда запрос строится для своего подключения, вызов применяет реализация диалекта этого подключения. На
подключении, диалект которого такой метод не зарегистрировал, запрос даёт UnSupportedError, а не молча
пропускает вызов. Имя, которое у QuerySet уже есть, или имя, начинающееся с _, отклоняется с
ConfigurationError. Имя и аргументы каждого вызова входят в ключ плана: они вписываются в запрос после его
построения и поэтому — часть текста SQL; вызов с аргументом, который не может быть частью ключа (список,
словарь), лишает запрос плана. Аргумент, который сам описывает себя для плана, — Q, выражение, — входит в
ключ своей структурой, а его значения подставляются, как значения фильтров.
register() принимает ещё четыре именованных аргумента — для метода, которому мало построителя:
| Аргумент | Что задаёт |
|---|---|
reads_query=True |
apply(builder, extension_query, *args, **kwargs) получает и запрос — QuerySetExtensionQuery: его model, connection, dialect, table, признаки is_write и is_summary, а также get_condition(q), get_expression(name_or_expression) и join(builder, joins), которые разрешают условие или поле так же, как собственные фильтры запроса, и записывают их значения в его план. |
takes_condition=True |
Метод принимает аргументы filter() — объекты Q и фильтры, — записанные одним Q, который получает apply (prewhere() ClickHouse). Все диалекты, регистрирующие это имя, объявляют его одинаково, иначе register() даёт ConfigurationError. |
changes_rows=True |
Вызов меняет, какие строки возвращает запрос, помимо его условий (limit_by() ClickHouse): count() и exists() считают строки запроса как производной таблицы. |
run_query= |
await run_query(extension_query, *args, **kwargs) выполняет запрос строк с этим вызовом вместо его обычного выполнения и возвращает то, что вернёт запрос (with_totals() ClickHouse читает строки и итоговую строку своей одной команды). |
QueryBuilder.set_dialect_clause(name, value) хранит то, что задаёт вызов, — это пишет QueryClauses
диалекта в get_main_table_suffix_sql(), get_prewhere_sql(), get_group_by_modifier_sql() (то, что
идёт после GROUP BY, WITH TOTALS ClickHouse), get_limit_by_sql() и get_statement_end_sql(); их
спрашивают только у запроса, где такие клаузы есть. hare stubs и плагин
mypy объявляют методы диалектов соединений проекта с параметрами apply после построителя (и запроса).