Сразу хочу отметить перед читателем, что это не просто вольные рассуждения на тему, а в том числе и презентация моей библиотеки для Python, которую можно найти на github и установить через pip, и которая трудится в моей многопользовательской игре как SQL движок проекта.

Вступление

Проблемы ORM известны всем, кто хоть раз ими пользовался. Об этом существует множество статей как у нас (1, 2, 3, 4), так и в зарубежных источниках (1, 2, 3, 4). Эти проблемы в общем можно объединить довольно сложным термином Object-relational impedance mismatch, что позволю себе вольно перевести как "Объектно-реляционная разница потенциалов".

Альтернативой использованию ORM всегда было использование чистых драйверов баз данных и написание сырых SQL запросов, которые в свою очередь очень тяжело поддерживать и рефакторить в реальных проектах.

В этой статье я не буду хаить ORM (до меня это уже делали на протяжении без малого полуторадесятка лет), но хочу предложить альтернативный путь к решению задачи доступа к базе данных из потенциально любого другого ЯП.

Итак, вот основные постулаты, от которых я отталкивался при проектировании моей библиотеки:

  1. Писать запросы будем на чистом SQL. Без промежуточного DSL, без ООП-оберток, без SQL-билдеров.

Почему?

Как известно, SQL - это необычный язык программирования. Необычен он в том числе и тем, что не имеет имплементации по-умолчанию. А все RDBMS, которые его имплементируют (postgresql, mysql, mssql, sqlite, etc.), в конечном итоге приходят к своему диалекту языка, отвечающему их бизнес требованиям. И зачастую эти диалекты несовместимы со спецификацией SQL.

Многие (если не все) ORM библиотеки пытаются решить эту проблему, предоставляя общий междиалектный интерфейс генерации SQL кода. Это частично решает проблему, но вместе с этим замыкает пользователя внутри доступных в ORM общих решений в ущерб частным случаям отдельных RDBMS.

Также ORM по-умолчанию пытается решить проблему бесшовного переноса существующего кода с одного диалекта SQL на другой. Считаю это слишком специфической и редкой проблемой, чтобы отказываться от чистого SQL диалекта в пользу объектным ограничителям всевозможных ORM и SQL-builderов.

  1. Вызывать запросы будем как стандартную функцию.

  2. Аргументы функции - это аргументы SQL запроса.

  3. Вставлять аргументы в нужные места запроса будем напрямую при помощи f-string. При этом не должно быть никаких SQL инъекций: вставку аргументов отдаем на откуп выбранному драйверу базы данных.

  4. Возвращаемое значение функции - это всегда строка, кортеж (row) или массив кортежей определенного типа данных. Исключение: одномерные данные, когда возвращаем значение или массив значений.

Пример

Например, есть таблицы User и Post. Мы сделаем INNER JOIN по user_id в таблице Post и получаем набор объектов нового типа данных с условным называнием UserPost.

  1. У всех функций должны быть известны и явно определены в коде программы входные типы аргументов и выходные типы данных. Таким образом линтер станет на нашу сторону и будет помогать нам в разработке и рефакторинге.

Очевидно, что пункты 5 и 6 вынуждают нас либо прописывать типы данных и аргументов явно в коде самостоятельно, либо искать возможность их автоматической генерации. Таким образом мы становимся на путь кодогенерации на основе текущей схемы базы данных.

Какие вещи не будут реализованы, но есть в ORM:

  1. Транспиляция на разные SQL диалекты. Существуют отдельные решения, которые транспилируют код из одного SQL диалекта в другой.

  2. Управление транзакциями. ORM предоставляют обобщенный для разных драйверов интерфейс управления транзакциями (например, объект sqlalchemy.orm.Session). Моя же библиотека только делает запросы, а транзакциями пусть занимается драйвер.

querky

Так родилась моя библиотека, которую я назвал querky (github, pypi). Само слово - это совмещение английских слов query ("запрос") и созвучного quirky ("необычный", "причудливый").

Установить ее можно через pip командой pip install querky

Вот пример скелета проекта, который можно запустить и покрутить в руках возможности кодогенерации. Все примеры взяты из README проекта.

В статье же я хочу в примерах показать, как выглядит конечный результат использования этой библиотеки на нескольких запросах.

Как это работает?

В документации есть немного информации на этот счет.

Однако в этой статье я не буду вдаваться в подробности имплементации, потому как это довольно обширная тема, требующая отдельной статьи. Если будет живой интерес к конкретным деталям, то с удовольствием ее напишу.

Могу вкратце отметить, что для всего функционала, кроме соединения с базой, используются стандартные Python модули. Основой для библиотеки послужил стандартный модуль inspect, открывающий возможности к рефлексии в рантайме. Особенно важным это оказалось при подстановке темплейтов аргументов в тело запроса и генерации сигнатур функций.

Теперь же я обязан огорчить многих моих читателей, отметив, что на данном этапе библиотека может работать только с asynpg.

Можно довольно просто сделать обертки для остальных PostgreSQL Python драйверов: psycopg2, psycopg (aka psycopg3) и aiopg. Однако возможно ли реализовать type-inference в остальных RDBMS - об этом я пока не зарекаюсь.

Конфигурация проекта

Установка

Устанавливаем библиотеку с драйвером asyncpg: pip install querky[asyncpg]

Структура проекта

Названия файлов и папок, а также общая структура проекта предоставляются лишь в качестве примера, и не являются единственной возможной конфигурацией проекта.

Создаем в корне проекта два файла querky_def.py и querky_gen.py, а также папку sql, в которой будем создавать .py файлы с SQL запросами. В папке sql создадим также пустой файл с названием __init__.py, чтобы сделать папку модулем.

Структура проекта на данный момент:

sql
|__ __init__.py
querky_def.py
querky_gen.py

querky_def

Файл настройки кодогенерации. В нем хранится единственный объект qrk, который мы будем использовать для объявления Python функций SQL запросами.

import os
from querky.presets.asyncpg import use_preset


qrk = use_preset(os.path.dirname(__file__), type_factory='dataclass+slots')

Для простоты примера используем пресет. Его хватит для 95% случаев, однако если нужна точечная настройка, то рекомендую использовать объект Querky напрямую. Подробнее в документации.

  • Первый аргумент должен быть путем к корню вашего проекта.

  • type_factory - это аргумент, определяющий какие классы будут генерироваться для результатов SQL запросов. По-умолчанию это typed_dict, но также можно использовать собственные типы, о чем подробнее есть в документации.

  • Остальные аргументы такие же как у конструктора объекта Querky. О них подронее также в документации.

querky_gen

Этот скрипт отвечает непосредственно за генерацию кода. Мы будем запускать его, чтобы сгенерировать новые функции-обертки для запросов и перегенерировать устаревшие типы и сигнатуры.

Снова используем пресет:

import asyncio

from querky.presets.asyncpg import generate

from querky_def import qrk
import sql


if __name__ == "__main__":
    asyncio.run(generate(qrk, "<ваш url для подсоединения к postgres>", base_modules=(sql, )))

Как видно, импортируем базовый модуль sql и помещаем его в кортеж base_modules. При запуске скрипт пройдется по всем .py файлам внутри папки sql , собирая все SQL функции и генерируя для каждой из них обертку. Все сгенерированные функции и типы будут помещены в подпапке queries в файл с тем же названием, что исходный.

Скрипту необходимо подключиться к живой базе данных, в которой определены используемые в запросах таблицы, типы, процедуры и т.д. Иначе нет возможности автоматически сгенерировать типы данных и аннотации к аргументам.

Схема базы данных

База данных: PostgreSQL 14.10

CREATE TABLE account (
    id BIGSERIAL PRIMARY KEY,
    username TEXT UNIQUE NOT NULL,
    first_name TEXT NOT NULL,
    last_name TEXT,
    phone_number TEXT,
    balance BIGINT NOT NULL DEFAULT 0,
    join_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    referred_by_account_id BIGINT REFERENCES account (id)
);

CREATE TABLE post (
    id BIGSERIAL PRIMARY KEY,
    poster_id BIGINT NOT NULL REFERENCES account (id),
    message TEXT NOT NULL,
    ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE post_comment (
    id BIGSERIAL PRIMARY KEY,
    post_id BIGINT NOT NULL REFERENCES post (id),
    commenter_id BIGINT NOT NULL REFERENCES account (id),
    message TEXT NOT NULL,
    ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Написание запросов

Все, что нужно сделать, для объявления обычной функции запросом - это импортировать объект qrk, объявленный в querky_def.py , и использовать его метод query в качестве декоратора.

Например, создадим в папке sql файл example.py для наших запросов. Теперь структура проекта будет выглядеть так:

sql
|__ __init__.py
|__ example.py
querky_def.py
querky_gen.py

В этом файле опишем несколько SQL функций для демонстрации основных возможностей библиотеки:

from querky_def import qrk


# запрос ничего не возвращает
@qrk.query  # или явно: @qrk.query(shape='status')
def update_account_phone_number(account_id, new_phone_number):
    return f'''
        UPDATE
            account
        SET
            phone_number = {+new_phone_number}
        WHERE
            id = {+account_id}
        '''


# запрос возвращает одно обязательное скалярное значение
@qrk.query(shape='value', optional=False)
def insert_account(username, first_name, last_name, phone_number, balance, referred_by_account_id):
    return f'''
        INSERT INTO
            account
            (
                username,
                first_name,
                last_name,
                phone_number,
                balance,
                referred_by_account_id
            )
        VALUES
            (
                {+username},
                {+first_name},
                {+last_name},
                {+phone_number},
                {+balance},
                {+referred_by_account_id}
            )
        RETURNING
            id
        '''


# запрос возвращает массив скалярных значений
@qrk.query(shape='column')
def select_top_largest_balances(limit):
    return f'''
        SELECT
            balance
        FROM
            account
        ORDER BY
            balance DESC
        LIMIT
            {+limit}
        '''

# запрос возвращает одно значение типа AccountReferrer или None (optional=True)
@qrk.query('AccountReferrer', shape='one', optional=True)
def get_account_referrer(account_id):
    return f'''
        SELECT
            referrer.id,
            referrer.username,
            referrer.first_name,
            referrer.last_name,
            referrer.join_ts

        FROM 
            account

        INNER JOIN
            account AS referrer
        ON
            account.referred_by_account_id = referrer.id

        WHERE
            account.id = {+account_id}
        '''


# запрос возвращает массив значений типа AccountPostComment
@qrk.query('AccountPostComment', shape='many')
def select_last_post_comments(post_id, limit):
    return f'''
        SELECT 
            account.first_name,
            account.last_name,
            post_comment.id,
            post_comment.message

        FROM
            post_comment

        INNER JOIN
            account
        ON
            post_comment.commenter_id = account.id

        WHERE
            post_comment.post_id = {+post_id}

        ORDER BY
            post_comment.ts DESC

        LIMIT
            {+limit}
        '''

Обратите внимание, что аргументы внутри запроса отмечены знаком +.

  • Первым аргументом декоратора будет название типа, если применимо.

    • В случае скалярных результатов указание типа надпишет сгенерированную аннотацию.

    • В случае кортежей при отсутствии явного названия типа данных название будет сгенерировано из CamelCase-отформатированного названия функции.

  • Флаг optional указывает на то, может ли функция вернуть None.

  • Также необходимо явно указать форму возвращаемого значения.

    • status - по-умолчанию. Возвращает строковый статус выполнения запроса.

    • value - возвращает одно скалярное значение. Поддерживает флаг optional.

    • column - возвращает массив скалярных значений. Поддерживает флаг optional для элементов.

    • one - возвращает один кортеж. Поддерживает флаг optional.

    • many - возвращает массив кортежей.

С учетом конфигурации эти функции уже можно вызывать, если по какой-то причине хочется пропустить процедуру кодогенерации. querky займется подстановкой аргументов, а возвращаемые типы будут зависеть от используемых настроек драйвера базы данных.

Но мы пойдем дальше и запустим скрипт querky_gen.py. Если все прошло успешно, мы получим новый файл с названием example.py в подкаталоге sql/queries. Если папки queries не существует, она будет создана автоматически.

Новая структура проекта:

sql
|__ __init__.py
|__ example.py
|__ queries
    |__ example.py
querky_def.py
querky_gen.py

Давайте заглянем внутрь сгенерированного файла sql/queries/example.py:

# ~ AUTOGENERATED BY QUERKY ~ #
import datetime
from dataclasses import dataclass
from asyncpg import Connection
from sql.example import update_account_phone_number as _q0
from sql.example import insert_account as _q1
from sql.example import select_top_largest_balances as _q2
from sql.example import get_account_referrer as _q3
from sql.example import select_last_post_comments as _q4


async def update_account_phone_number(__conn: Connection, /, account_id: int, new_phone_number: str) -> str:
    return await _q0.execute(__conn, account_id, new_phone_number)


async def insert_account(__conn: Connection, /, username: str, first_name: str, last_name: str, phone_number: str, balance: int, referred_by_account_id: int) -> int:
    return await _q1.execute(__conn, username, first_name, last_name, phone_number, balance, referred_by_account_id)


async def select_top_largest_balances(__conn: Connection, /, limit: int) -> list[int]:
    return await _q2.execute(__conn, limit)


@dataclass(slots=True)
class AccountReferrer:
    id: int
    username: str
    first_name: str
    last_name: str
    join_ts: datetime.datetime


async def get_account_referrer(__conn: Connection, /, account_id: int) -> AccountReferrer | None:
    return await _q3.execute(__conn, account_id)

_q3.bind_type(AccountReferrer)


@dataclass(slots=True)
class AccountPostComment:
    first_name: str
    last_name: str
    id: int
    message: str


async def select_last_post_comments(__conn: Connection, /, post_id: int, limit: int) -> list[AccountPostComment]:
    return await _q4.execute(__conn, post_id, limit)

_q4.bind_type(AccountPostComment)


__all__ = [
    "select_top_largest_balances",
    "select_last_post_comments",
    "AccountReferrer",
    "get_account_referrer",
    "AccountPostComment",
    "update_account_phone_number",
    "insert_account",
]

Как видите, мы получили готовые типы и аннотации, написав при этом только сами запросы и отметив их декоратором.

Теперь мы можем импортировать наши аннотированные функции-обертки, а также сгенерированные типы данных и свободно использовать их в своем проекте.

Итог

В результате мы используем SQL запросы в форме обычных вызовов функций, а аргументы в эти запросы вставляем так, как каждому интуитивно хотелось сделать в самом начале знакомства с любой SQL базой: при помощи f-string.

  • Никаких SQL билдеров, никаких ORM, никаких моделей - просто запросы и их результаты в любой удобной для проекта форме.

  • Когда появятся новые запросы, изменятся / удалятся старые или изменится схема базы данных, мы просто перезапустим querky_gen.py.

    Через секунду получим актуальные типы, не переписывая ни единой строчки кода.

  • Нам больше не нужно следить за тем, используем ли мы правильные названия колонок, существует ли колонка в кортеже, какой тип имеет возвращенная колонка и т.д.

    За нас эту работу выполняет линтер.

  • Если мы написали неправильный SQL запрос, то querky_gen.py завершится с ошибкой и указанием, в каком именно запросе она произошла.

    Этот приятный сайд-эффект происходит, благодаря тому что для инференции типов запросы отсылаются на ваш RDBMS, где происходит PREPARE запроса. Таким образом, база не может обработать неверные запросы и выбрасывает ошибку.

    Это позволяет всегда быть на 100% уверенным, что каждый запрос в целом проекте обязательно запустится в рантайме.

  • Также благодаря тому, что мы работаем на живой базе с нативным SQL, в запросах можно использовать любые доступные функции, хранимые процедуры - вообще все, на что ваш RDBMS способен - и все равно весь type inference будет работать как часы.

Внимательный читатель может отметить, что не все так просто, ведь бывают NULLABLE колонки, Optional аргументы, неточности типов данных внутри базы, случается необходимость использования одного и того же возвращаемого типа данных в нескольких разных запросах, использования значений по-умолчанию в аргументах, переиспользования запросов.

Я специально решил не перегружать эту статью деталями, а решения всех проблем сверху описаны в документации. Проблемы кликабельны :)

Отмечу также, что библиотека неинвазивна: можно использовать для работы с уже готовым проектом, если тот написан на поддерживаемом querky драйвере (только asyncpg на данный момент).

Похожие проекты

  • sqlx - для rust. Не генерирует типы, но тоже проверяет выполняемость запросов во время компиляции и поддерживает распаковку в нужные structы.

  • sqlc - для go. Вы пишете sql типы (таблицы) и запросы к ним в отдельных .sql файлах, а библиотека генерирует go код для них. На мой взгляд могут возникнуть проблемы из-за того что информация о типах берется из .sql файлов, а не из базы напрямую. Но в этом вопросе лучше меня не слушать, а спросить тех, кто имел опыт с этим инструментом.

  • pugsql - для python. Вдохновитель sqlc для go. Принцип похож но типы писать не нужно, потому как возвращаются dictы. Нет типизации.

  • hugsql - для closure. Вдохновитель sqlc для go. Вроде pugsql.

Проект на querky

Как и обещал, мой проект, в котором эта библиотека уже трудится. К сожалению, не могу похвастаться большим онлайном, но приглашаю всех оценить игру:

https://t.me/cardusbot

Игра создается в любом чате, где есть бот. Игра в WebApp, т. е. в браузере. Геймплей много кому знаком: карточная игра "Дурак", по желанию можно включить множество различных модификаций и дополнений к игре.

Весь движок игры сделан целиком на SQL запросах, каждый из которых проходит через querky. Всего в приложении на данный момент 222 уникальных запроса, из которых 129 отвечают за игровой процесс.

Подсчет общего количества запросов во всем проекте
Подсчет общего количества запросов во всем проекте
Подсчет запросов, касающихся игрового процесса
Подсчет запросов, касающихся игрового процесса
Вот как в проекте выглядит папка с запросами. В общем все просто распределено по доменам.
Вот как в проекте выглядит папка с запросами. В общем все просто распределено по доменам.

Хочу также отметить интересную фишку, которая еще более упростила мне жизнь при разработке этого проекта.

Я все-таки использовал ORM, но в качестве снапшота состояния базы. И использовал небольшой хак, чтобы делать так:

@qrk.query('Referral', shape='many', dict=True)
def update_set_referrals(game_id, host_account_id):
    return f'''
        WITH 
        locked_account_id AS (
            SELECT 
                {account.id}
            
            FROM
                {account}
            INNER JOIN
                {player}
            
            ON
                {player.account_id} = {account.id} 
            
            WHERE
                {player.game_id} = {+game_id} 
                AND {account.new}
                AND {account.id} <> {+host_account_id}
        
            ORDER BY
                {account.id}
        
            FOR UPDATE
        )
        UPDATE
            {account}
        SET
            {~account.new} = FALSE,
            {~account.referred_by} = {+host_account_id}
        FROM
            {player}
        WHERE
            {account.id} = ANY(SELECT id FROM locked_account_id)
        RETURNING
            {account.id},
            {account.name},
            {account.tg_id}
        '''

То есть видно, что вместо строковых названий столбцов и таблиц я использую какие-то объекты.

Вот код для создания таких объектов:

from __future__ import annotations

import typing


T = typing.TypeVar('T')


class Column:
    def __init__(self, t: Table, name: str):
        self.table = t
        self.name = name

    def _str(self) -> str:
        if self.table.noname__:
            return self.name
        else:
            return f"{self.table.name__}.{self.name}"

    def __invert__(self):
        return self.name

    def __str__(self):
        return self._str()

    def __repr__(self):
        return self._str()


class Table:
    def __init__(self, name: str, noname: bool):
        self.name__ = name
        self.noname__ = noname

    def __getattr__(self, item: str) -> Column:
        return Column(self, item)

    def __str__(self):
        return self.name__

    def __repr__(self):
        return self.name__


def get_database_name(t) -> str:
    return t.__tablename__


def table(t: T, name: str | None = None) -> T:
    return Table(name or get_database_name(t), noname=False)

И тогда мы используем функцию table таким образом, подавая на вход обычные sqlalchemy модели:

from mvc.models.sql.alchemy import (
    Player,
    Account
)

player = table(Player)
account = table(Account)

В этом месте мы обманываем пайчармовский линтер. Он думает, что player и account - это sqlalchemy модели из-за того, что функция table использует T на входе и как будто бы возвращает T на выходе.

Таким образом мы еще более упростили свою жизнь:

  • линтер будет подсвечивать доступ к неверным полям внутри каждого такого запроса,

  • а также будет автокомплит ко всем полям в таблице - очепяток не будет.

Я не включил этого в библиотеку, потому что это скорее хак, и вряд ли очень надежный и переносимый на все IDE метод. Но получилось очень удобно, поэтому не грех поделиться.

Контакты

Комментарии (31)


  1. ALexKud
    20.12.2023 13:54

    Транзакции?


    1. racinette Автор
      20.12.2023 13:54

      Транзакции уходят на откуп вашему драйверу.


    1. racinette Автор
      20.12.2023 13:54

      А почему вы минусанули? Это не ORM. Ваш драйвер умеет справляться с транзакциями нативно: зачем оборачивать это сверху абстракцией поверх уже имеющейся?


      1. HemulGM
        20.12.2023 13:54

        Затем, что транзакция может быть для нескольких SQL операций, а не только для одного запроса. В этом её смысл.


        1. racinette Автор
          20.12.2023 13:54

          Так в этом вся суть библиотеки, что ты только делаешь запросы. А транзакции делаешь из драйвера. Т.е. это conn.execute(...) или conn.fetch(...) под капотом просто с удобным интерфейсом поверх.


  1. bromzh
    20.12.2023 13:54

    Об этом существует множество статей

    При этом, в комментах как минимум одной статьи привели убедительные доводы за ОРМ. Просто нужно брать нормальный ОРМ.

    1. Вставлять аргументы в нужные места запроса будем напрямую при помощи f-string. При этом не должно быть никаких SQL инъекций: вставку аргументов отдаем на откуп выбранному драйверу базы данных.

    Как-то самонадеянно. Они могут быть, см. пример тут. Так что хотелось бы подтверждение от вас, что их не будет.


    1. racinette Автор
      20.12.2023 13:54

      Насчет "хороших-плохих" ORM не буду спорить. Я просто не люблю ОРМ, мне они кажутся жутко неудобными.

      Насчет второго вашего пункта могу ответить твердо: библиотека не вставляет аргументы в строку, а только плейсхолдеры. Например, в случае asyncpg $1, $2, $3, и т.д. В статье есть пример чуть подальше, когда используется subquery. Также можете сами убедиться в этом, запустив в дебагере любой ваш запрос. В следующем комментарии приведу код.


    1. racinette Автор
      20.12.2023 13:54

      @qrk.query('AccountBanReason', shape='one')
      def get_account_ban_reason(game_id, maybe_banned_account_id):
          return f'''
              SELECT 
                  {ban.reason} AS {-attr.reason}
              FROM   
                  {game}
              
              INNER JOIN
                  {player}
              ON
                  {game.host_id} = {player.id}
              
              INNER JOIN
                  {ban}
              ON
                  {player.account_id} = {ban.account_id}
              
              WHERE  
                  {game.id} = {+game_id}
                  AND {ban.banned_account_id} = {+maybe_banned_account_id}
              '''
      
      # Превращается в такой запрос:
      """
      
        SELECT 
            account_ban.reason AS reason
        FROM   
            durak_game
        
        INNER JOIN
            durak_game_player
        ON
            durak_game.host_id = durak_game_player.id
        
        INNER JOIN
            account_ban
        ON
            durak_game_player.account_id = account_ban.account_id
        
        WHERE  
            durak_game.id = $1
            AND account_ban.banned_account_id = $2
              
      """

      Как видите, аргументы не подставляются в строку, а только плейсхолдеры. Таким образом, мы имеем абсолютно экранированный запрос, без инъекций.


  1. LordDarklight
    20.12.2023 13:54

    Насколько я понял - получилась просто static compiled вариация ORM - так в чём преимущество то по сравнению с другими правильными ORM (а правильные тоже умеют код генерировать, правда не всегда это static compiled генерация)?

    Лично мне больше вот такой подход симпатизирует (почитайте и другие комментарии там же) - там тоже в основном static compile подразумевается!


    1. racinette Автор
      20.12.2023 13:54

      Ну, это не ORM. ORM дает абстракцию над базой данных. Моя библиотека дает абстракцию над запросами к базе данных. ORM объявляет, что модель -- это строка в таблице. Моя библиотека объявляет, что любая возвращенная из базы строка -- это модель. И поверх этого просто генерируются информации о типах и аннотации функции. Для каждого запроса -- отдельный тип данных. Вручную это долго и муторно (бойлерплейт-с), а вот автоматически -- раз плюнуть!

      Еще раз хочу повторить: это просто очень удобный инструмент для отправки в базу данных запросов и получения результатов этих запросов в удобной форме.

      Благодаря кодогенерации линтер всегда будет знать, что вам нужно подставить в запрос, и что вы получите из запроса, и подскажет вам, где что не так.


      1. LordDarklight
        20.12.2023 13:54

        Ну, это не ORM. ORM даёт абстракцию над базой данных. Моя библиотека дает абстракцию над запросами к базе данных. ORM объявляет, что модель -- это строка в таблице. Моя библиотека объявляет, что любая возвращённая из базы строка -- это модель

        По-моему, это просто игра слов. Существенной разницы тут нет. Если я не правильно понял - значит не смогли убедить в обратном. Вы пишете - что каждому запросу отдельная объектная модель вручную это долго и муторно - конечно - вот этим и занимаются "правильные" ORM - генерируют объектные модели под каждый запрос - просто стратегии генерации у всех свои.

        Я ничего не имею против Вашего инструмента. Просто прям чего-то нового и превосходящего правильные ORM - я тут не вижу. Хотя и считаю подход кодогенерации очень правильным. Видимо для Python ещё не хватало таких библиотек, а вот, скажем, под .NET кодогенерация уже давно не в новинку. Но я за более сложную кодогенерацию и более абстрактное и кроссплатформенное (я про СУБД) программирование


        1. racinette Автор
          20.12.2023 13:54

          Не знаю, может, я что-то упустил. Например, в Python основные ORMки могут общаться только модельками (т. е. таблицами). Если надо что-то посложнее, то подгружается SQL Builder (SQLAlchemy Core тот же) и из объектов типа Select(...).OrderBy(...) составляется запрос. А программа по сути не имеет понятия, что за тип данных вернет запрос, поэтому возвращаться будет условный dict, доступные ключи которого надо смотреть из запроса. А типы значений в ключах уже из схемы...

          Я так понимаю, что в шарпах как-то по-другому это решается?


    1. racinette Автор
      20.12.2023 13:54

      А вашего примера я совсем не понял, не обессудьте... Там совсем с виду какая-то криптография. Вот этот пример, как я понял?

      def dsHDAResFM : #dbfieldgroup //1
      {
       HDA1DAT -> DAT
       HDAUSID -> USID 
       HDAUNAM -> UNAM 
       HDABRNM -> BRNM
       HDABRN -> BRN
       HDACRD -> CRD
       HDAMBN -> MBN 
       GPVDSC -> MBND  = left(it ?? "",35) //2 
       HDA1SQN -> SQN
      }
      
      def HDAFM : #dbfieldgroup
      require {CUS,CLC,TYP,DAT,SQN} 
      
      def HDA1PFFM : HDAFM 
      with #dbfieldmap rename = 
        () -> ("HDA${it}" -> "{it}") //3
      
      def HDAPFFM : HDAFM 
      with #dbfieldmap rename = 
        () -> ("HDA1${it}" -> "{it}") //3
      
      def HDA1PFWM : #dbfieldgroup
      require {
       HDA1CUS -> &dbparameter CUS, //4
       HDA1CLC -> &dbparameter CLC,
       HDA1TYP -> &dbparameter TYP,
      } 
      
      #dbsource(path.to.database) //5
      {
      #dbtablekey(path.to.table.HDAPF) HDAPFFM  //6
      #dbtablekey(path.to.table.HDA1PF) HDA1PFFM 
      
      from HDA1PF<-HDAPF<-GPVPF.where(GPVITM=="DAMBN").on(GPVVLE==HDAMBN)  //7
      where HDA1PFWM.ToCondition() //8 
      var dsHDARes = get dsHDAResFM //9
      }
      
       (e<-dsHDARes(CUS=1, CLC=2, Typ=2)) //10
        -> { // какая-то обработка}


      1. LordDarklight
        20.12.2023 13:54

        Там же ниже даны пояснения для каждой нумерованной строки - или я плохо пояснил (указал же - что над почить и другие комментарии).

        Если кратко - то там выше, не мной, был приведён пример на SQL (очень тяжело читаемый - в основном из-за диких идентификаторов) - я его переписал на квази-языке программирования в декларативной модели (условно на коленке всё придумал - так что не претендую на красоту и точность изложения - это просто пример концепции) - тоже подразумевающей дальнейшую кодогенерацию по результату анализа данной схемы - с генерации кода, как выполняемого на стороне СУБД (не только запросы, но и хранимые процедуры (могут быть и другие операционные виды СУБД), так и код для объектной модели сервера приложений (клиента, если нет сервера). И конечные ЯП тут непринципиальны - из единой исходной формы должен генерироваться код для любой (поддерживаемой) целевой платформы.

        Цель - писать унифицировано, и как можно меньше уделяя внимания деталям (любого толка: как деталям бизнес модели, так и деталям целевой платформы, или деталям разных стратегий кодирования, например применять и как многопоточность или нет, и в каком месте реализовывать какую-то обработку данных - на стороне СУБД, или где-то ещё), т.е. повышая уровень абстракции и отложенных вычислений. При этом за результат генерации отвечает AI компилятор, и несколько этапов компиляции. А так же сбор статистики и динамическая перекомпиляция при необходимости (а непрерывный фидбэк программисту о профайлинге и проблемных местах, с рекомендациями по улучшению - если AI не может сделать это сам в силу каких-либо заданных ограничений).

        При этом, по умолчанию весь код стремится быть вынесен на сторону СУБД (насколько она позволяет на целевой платформе) - если надо - алгоритм может быть автоматически разбит на несколько этапов взаимодействия сервера приложений и СУБД (хотя это не приветствуется - и скорее всего будет являться ошибкой в алгоритме или не ходимости пересмотра архитектуры модели; об этом всё программист так же должен быть проинформирован). Но это лишь базовая стратегия - разные настройки и хинты в коде могут направлять кодогенерацию в иное русло!

        Данный квази-код может показаться очень сложным и непривычным для восприятия - хотя я старался написать его как можно ближе к современному императивному программированию, с макросами. По факту он может быть куда более далёким от императивного подхода - и больше тяготеть к декларативному, командно/функциональному.

        При этом, ниже в комментариях, я этот код ещё больше упростил - повышая уровень абстракции. В таком подходе - чем меньше деталей реализации в единице кода - тем лучше (но без потери общего смысла) - для больших программ это очень хорошо - код становится проще, а детали можно размазывать по отдельным модулям, не боясь за проблемы оптимизации и расхождений в деталях - везде стараются сохраняться неопределённости, которыми и идёт оперирование - так что, условно, ошибка в идентификаторах тут не очень страшна - когда нет повторения этих идентификаторов в куче мест.

        В остальном пониманию кода и помощи в программировании его абстракций должна активно помогать IDE c AI помощником - проводящим постоянный анализ кода, построение его полной модели, и осуществляющий выдачу детальных, а не только абстрактных подсказок!


  1. YegorP
    20.12.2023 13:54

    Это разве не "database-first" подход? Ну типа база уже есть, и по ней мы кодогенерим типы.


    1. racinette Автор
      20.12.2023 13:54

      Да, вы правы, это именно так задумывалось. Что база - это единственный источник правды.


  1. HemulGM
    20.12.2023 13:54

    Недавно я создавал похожую вещь для Дефли, за исключением того, что строить запрос можно тоже кодом.

      var Params: TSQLParams;  //Набор параметров, которые будут в итоге получены
      var Sel :=               //Тут мы получим структуру запроса
        Select([User, UserRole.Desc.Table('ur').&As('description')]).
        From(User).
        LeftJoin(
          Select('*').
          From(UserRole).Where(UserRole.RoleType = 1), 'ur').
          On(User.RoleId = UserRole.Id.Table('ur')).
        Where(not (User.Id = TGUID.NewGuid) or (User.Status in [1, 2, 3])).
        Where(User.Status and 1 = 0).
        Where(User.Name = 'Dan').
        Where(User.Status in
          Select(User.Status).From(User).Where(User.RoleId in [TGUID.NewGuid, TGUID.NewGuid])).
        OrderBy([User.Name, DESC(User.Status)]).
        GroupBy([User.Id]);
    
      writeln(Sel.Build(Params));
    Результат подобного кода
    SELECT user.*, ur.desc description
     FROM user
     LEFT JOIN (
        SELECT *
         FROM user_role
         WHERE user_role.type = :p0) ur ON user.role_id = ur.id
     WHERE (NOT (user.id = :p1)) OR (user.status in (:p2, :p3, :p4)) AND user.status & 1 = :p5 AND user.name = :p6 AND user.status in (
        SELECT user.status
         FROM user
         WHERE user.role_id in (:p7, :p8))
     ORDER BY user.name, user.status DESC
     GROUP BY user.id

    Список параметров

    p0: Integer = 1
    p1: TGUID = {8EAB037E-598C-4D6E-82E0-957126E80810}
    p2: Integer = 1
    p3: Integer = 2
    p4: Integer = 3
    p5: Integer = 0
    p6: string = Dan
    p7: TGUID = {5BA30C4A-FD12-49BD-9BF5-0710A915D4DC}
    p8: TGUID = {7F309C2C-8CE5-483B-A198-14914C49C5D8}

    Структура таблиц
      [TableName('user')]
      TUser = class(TSQLTable)
        [FieldName('id')]
        Id: TFGUID;
        [FieldName('role_id')]
        RoleId: TFGUID;
        [FieldName('status')]
        Status: TFInteger;
        [FieldName('name')]
        Name: TFString;
      end;
    
      [TableName('user_role')]
      TUserRole = class(TSQLTable)
        [FieldName('id')]
        Id: TFGUID;
        [FieldName('type')]
        RoleType: TFInteger;
        [FieldName('desc')]
        Desc: TFString;
      end;

    Осталось только передать текст запроса и набор параметров в Query.

    Как видно, набор столбцов и таблиц мы не указываем текстом, если это не псевдоним столбца или подзапроса.

    Это позволяет не только избежать ошибок связанных с опечатками, но и реализует проверку типов. Т.е. мы не можем передать строку или массив для сравнения с числовым полем и т.д.


    1. racinette Автор
      20.12.2023 13:54

      Это у вас получился Query Builder. Вы его не выкладывали на GitHub? Возможно, делфи программистов бы это очень заинтересовало, если еще нет на делфи популярных Query Builderов. Потому как они очень популярны в других языках.

      Касательно моего модуля: я именно целился в то, чтобы можно было запросы писать строками. Как бы смысл в том, чтобы выполнялось то, что написал.


    1. racinette Автор
      20.12.2023 13:54

      Не понял немного, а куда Params вставляется? Вроде объявлен пустым, и выписывается в конце, а с виду нигде больше не дергается. А TGUID - это ваш плейсхолдер для параметра?


      1. HemulGM
        20.12.2023 13:54

        Params передается в билдер запроса. Билдер наполняет его данными из запроса. Т.е. все значения будут именно параметрами, а не просто конкатенацией.

        Параметры просто передаются Query, вместе с самим запросом, т.е. например в FDQuery или ADOQuery.
        Выглядеть будет примерно так:

        FDQuery.Open(Sel.Build(Params), Params);


        1. racinette Автор
          20.12.2023 13:54

          Кажется, я вас понял.

          Хочу отметить, у меня тоже нет конкатенации параметров, а также сначала вставляются плейсхолдеры, только потом база данных сама вставляет параметры. Как у вас :p1, :p2, etc. (я так понимаю, это mssql), так и у меня будет $1, $2, etc. (у меня драйвер для postgres).

          Я так понял, TGUID - это плейсхолдер для параметра? Я в том числе долго размышлял, как сделать так, чтобы параметры из функции 1 к 1 мапились в параметры запроса, т. к. часто испытывал неудобства при рефакторинге какого-то запроса, и параметры между собой мешались в нумерации -- и все части программы, использующие этот запрос, в итоге страдали из-за неправильной последовательности.


          1. HemulGM
            20.12.2023 13:54

            У меня тоже postgres.

            TGUID - это тип данных "GUID" (уникальный идентификатор). Плейсхолдеров у меня нет. И анализа текста - тоже (ни снаружи, ни под капотом языка). Здесь все работает нативно.

            Проблема с нумерацией решается тем, что всем генераторам внутри билдера передается одна и та же ссылка на список параметров, который они пополняют, а номер параметра - это просто "кол-во параметров" + 1.


  1. Didntread
    20.12.2023 13:54

    а также будет автокомплит ко всем полям в таблице - очепяток не будет

    насколько понимаю, в ультимейт версии это работает без хаков. Подключаете к проекту БД, и plain text запросы начинают подсвечиваться, работает автокомплит, в соответствии со схемой.


    1. racinette Автор
      20.12.2023 13:54

      Ну да, тут наверняка можно так сделать. У меня, к сожалению, нет ультимейта :)
      Тем не менее, это скорее дополнительное замечание вне библиотеки, чем основа функционала, поэтому и пометил как хак и фишку.


  1. AleksejMsk
    20.12.2023 13:54

    Что-то подобное для продакшен использовании я на C# сделал.
    Идёт генерация мапперов с полным CRUD на ADO.NET.
    Поддерживается SQL Server и PostgreSQL.
    Демонстрационный сервер тут https://github.com/KlestovAlexej/Wattle3.DemoServer

    К примеру метод маппера GetAsync :

    https://github.com/KlestovAlexej/Wattle3.DemoServer/blob/31bd12fe5e68af449b3e88f8e9c6e3b2597815b2/src/DemoServer.Processing.DataAccess.Postgresql/Generated/ShtrihM.Wattle3.CodeGeneration.Generator.Implements/ShtrihM.Wattle3.CodeGeneration.Generator.Implements.SourceGenerator/DbMappers.Implements.PostgreSql.Generated.cs#L1304

        /// <summary>
        /// Получить запись с указаным идентити.
        /// </summary>
        /// <param name="mappersSession">Сессия БД.</param>
        /// <param name="id">Идентити записи.</param>
        /// <param name="cancellationToken">Токен отмены.</param>
        /// <returns>Возвращает значение если запись существует иначе возвращает <see langword="null" /> если запись не существует.</returns>
        [SuppressMessage("ReSharper", "UseObjectOrCollectionInitializer")]
        [SuppressMessage("ReSharper", "ConvertIfStatementToConditionalTernaryExpression")]
        [SuppressMessage("ReSharper", "RedundantCast")]
        public virtual async ValueTask<IMapperDto> GetAsync(IMappersSession mappersSession, long id, CancellationToken cancellationToken = default)
        {
            if (mappersSession == null)
            {
                throw new ArgumentNullException(nameof(mappersSession));
            }
    
            try
            {
                var typedSession = (IPostgreSqlMappersSession)mappersSession;
    
                // ReSharper disable once ConvertToUsingDeclaration
                var command = await typedSession.CreateCommandAsync(false, cancellationToken).ConfigureAwait(false);
                await using (command.ConfigureAwait(false))
                {
                    command.CommandType = CommandType.Text;
                    command.CommandText = @"SELECT
    

    Id
    FROM ChangeTracker WHERE
    (Id = @Id)";

                    {
                        var parameter = new NpgsqlParameter<long>("@Id", NpgsqlDbType.Bigint) { TypedValue = id };
                        command.Parameters.Add(parameter);
                    }
    
                    var reader = await command.ExecuteReaderAsync(CommandBehavior.SequentialAccess | CommandBehavior.SingleResult, cancellationToken).ConfigureAwait(false);
                    await using (reader.ConfigureAwait(false))
                    {
                        if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
                        {
                            GetColumnIndexes(
                                reader,
                                out var indexId);
    
                            var result = Read(
                                reader,
                                indexId);
    
                            return ((IMapperDto)result);
                        }
                    }
    
                    return ((IMapperDto)null);
                }
            }
            catch (Exception exception)
            {
                CatchExceptionOnGet(mappersSession, exception, id);
                CatchException(mappersSession, exception);
    
                var targetException = await m_exceptionPolicy.ApplyAsync(exception, cancellationToken).ConfigureAwait(false);
                if (ReferenceEquals(targetException, exception))
                {
                    ExceptionDispatchInfo.Capture(exception).Throw();
                }
    
                throw targetException;
            }
    



  1. september669
    20.12.2023 13:54

    https://cashapp.github.io/sqldelight/2.0.0/

    SQLDelight generates typesafe Kotlin APIs from your SQL statements. It verifies your schema, statements, and migrations at compile-time and provides IDE features like autocomplete and refactoring which make writing and maintaining SQL simple.


    1. racinette Автор
      20.12.2023 13:54

      Это очень похоже, удобная штука! Спасибо, что поделились!


  1. RinNas
    20.12.2023 13:54

    Много лет тому назад я подсмотрел очень интересную коцепцию SQL шаблонизатора с условными блоками у Дмитрия Котерова (dklab).

    Потом сделал sql-template-engine, который уже несколько лет работает на одном крупном веб-сайте РФ.


    1. racinette Автор
      20.12.2023 13:54

      У вас интересный подход. Позволяет бОльшую свободу действий, чем у меня (например, динамическое кол-во вставляемых кортежей). Но честно скажу, с первого взгляда тяжело понять!


  1. Sabrus_2
    20.12.2023 13:54

    ну ок. победа над орм. Победили.
    Какая она, победа?
    Только для PG? для других БД переписывать все 129 запросов на другой диалект?
    Не помню такого с орм...

    Дать на фронт get_params в качестве фильтров в запросе, с той же джангой или алхимией - стандартная практика. В Вашем случае - пишем новый кодогенератор? Тестами покрываем...
    Ну т.е. опять не про продакшен.

    "Хитрые" способы наследования моделей из коробки - моделирование нужного полиморфного поведения - опять ОРМ. Наследование датаклассов - я как то пробовал...Предлагаете все изобрести заново?

    Мне кажется это(ваша библиотечка) не про победу над ОРМ. Заголовок в корне не соответствует. М.б "альтернатива орм, в отдельных, необременительных случаях" ?

    В целом мне нравится. Есть определенная "ниша" - прототипы, инди-проекты, и другие кейсы когда 120тн вентилятор устанавливать не нужно, а хочется стартануть "также быстро".


    1. racinette Автор
      20.12.2023 13:54

      1. Абсолютно согласен, что для другой БД часть запросов придется переписать. Но я и не пытался решить проблему несовместимости диалектов SQL. Конечно, это решение не подойдет для проектов, где используется несколько баз данных от разных вендоров одновременно, но у меня лично к таким проектам есть вопросы относительно целесообразности и осознанности такого решения.

      2. Согласен с вашей критикой, тесты нужны. Но спешу вас обрадовать: код на выходе там довольно тривиальный, и служит скорее автоматически генерируемой документацией, чем тяжело работающим механизмом. Если же вы про запросы с несколькими условиями, то можно просто эти условия сразу учесть в запросе. Базе данных ничего не стоит сравнить десяток дополнительных полей в рекорде. А вам даже спокойнее, что запрос всего один, и вы видите какой.

      3. Не уверен, как ORM может быть мощнее SQL. Моей идеей было предоставить максимально удобный интерфейс для выполнения чистого SQL, потому как я искренне считаю, что нет никакой лучшей абстракции над SQL, чем он сам. Проверено создателями бесконечной кучи ORM, каждый из которых в конце концов начинает строить костыль на свой вкус и цвет.

      Благодарен за критику, но я не нашел в ваших тезисах ничего нового, чего еще не видел. Я уже видел миллион раз на форумах с холиварами "ORM vs raw SQL" тезис: "А что вы будете делать, если надо на другой БД запустить проект?". Ну не знаю, подумаю, если надо будет. YAGNI.