amaslyaev / noorm

NoORM (Not only ORM) - Python library that makes your database operations convenient and natural
MIT License
16 stars 0 forks source link

SQLite persistent connection -> 'db' parameter closure #4

Closed LecronRu closed 1 month ago

LecronRu commented 2 months ago

Работа c SQLite, за редчайшим исключением, подразумевает одно, постоянное соединение. Создаваемое в том же модуле, где описывется DB API. Указание коннекта в клиентском коде, выглядит как бойлерплейт. Работаю сразу с несколькими базами. поэтому импортирую сам DB API модуль. Отчего полный вызов выглядит как word_db.sents_by_form(word_db.db, 'параметр') Когда он один, вроде не заметно, но когда десяток...

С одной стороны, его несложно замкнуть в DB API sents_by_form = partial(sents_by_form, db) Но теряем подсказку типов в IDE. Если указывать типы для partial функции, опять получаем бойлерплейт. Только другой и в другом месте.

Хотелось бы иметь функцию nm.register_connection, после выполнения которой, остальные вызовы будут использовать это зарегистрированное соединение. Функция может быть использована как в DB API модуле (постоянное соединение), так и в клиентском коде (временное соединение). Что-то наподобии:

with sqlite3.connect("data.sqlite") as conn:
        nm.register_connection(conn)
        users = get_all_users():
        user = get_user_by_id(1)
        summary =get_users_with_order_summary()
amaslyaev commented 2 months ago

Не обещаю. Беру паузу на подумать.

LecronRu commented 2 months ago

OK. Буду ждать. Только сейчас вспомнил, что мне напоминает данная ситуация — внедрение зависимостей в FastAPI. Правда для данной библиотеки считаю это оверинжинирингом. В идеале, достаточно параметра по умолчанию. Жаль это поломает обратную совместимость. Соединение должно передаваться не первым, обязательным параметром, а последним — опциональным.

default_db = sqlite3.connect("data.sqlite")

@nm.sql_one_or_none(
    DbUser, 
    "SELECT rowid AS id, username, email FROM users WHERE rowid = :id",
    default_db
)
def get_user_by_id(id_: int):
    return nm.params(id=id_)

default_value = get_user_by_id(1)

with sqlite3.connect("mock.sqlite") as conn:
        mock_value = get_user_by_id(1, conn)
amaslyaev commented 2 months ago

Пока что мне больше нравится сделать "default_db" декоратором. Как-то так:

@nm.default_db
@nm.sql_one_or_none(
    DbUser, 
    "SELECT rowid AS id, username, email FROM users WHERE rowid = :id",
)
def get_user_by_id(id_: int):
    return nm.params(id=id_)

with sqlite3.connect("mock.sqlite") as conn:
    nm.default_db.set(conn)  # <<<< After this @nm.default_db decorator knows what to use
    mock_value = get_user_by_id(1)

Годится такое?

LecronRu commented 2 months ago

Идея фактически реализует два юзкейса.

  1. База по умолчанию, предположительно открытая в DB API модуле:
    • есть, но может перекрывать в коде бизнес-логики, например mock-содеинением
    • нет
  2. Параметр соединения в with блоке:
    • передается в каждую функцию
    • регистрируется для блока кода

Недостатки вашей версии:

  1. После выполнения nm.default_db.set для временного соединения, теряем базу используемую по умолчанию. Нужно в конце mock-блока каждый раз дописывать nm.default_db.pop и хранить в default_db стек соединений. Временную регистрацию в таком случае, лучше выполнить в виде контекстного менеджера. А для постоянной использовать .set

И думаю вы понимаете, что работа может идти с разными базами (файлами). То есть декоратор nm.default_word_db будет отличаться от nm.default_morph_db

Недостатки моей версии:

  1. Реализуется только первый юзкейс. Зато довольно просто. Соединение либо переданное, либо по умолчанию.
amaslyaev commented 2 months ago

Сделано в версии 0.1.4. Docstring для декоратора @nm.default_db:

The @nm.default_db decorator makes your DB API functions easier to use by removing the first mandatory ConnectionOrCursor argument.

Use this decorator before other @nm.sql_... decorators.

Example:

import sqlite3
import noorm.sqlite3 as nm

@nm.default_db
@nm.sql_scalar_or_none(int, "select count(*) from users")
def get_users_count():
    pass

with sqlite3.connect("my_db.sqlite") as conn, nm.set_default_db(conn):
    users_count = get_users_count()  # <<< Consider no "conn" parameter
    print(f"{users_count=}")

Without nm.set_default_db(conn) any call to get_users_count would fail with a runtime error "default_db is not set".

You can use nm.set_default_db as a function. This code also works:

conn = sqlite3.connect("my_db.sqlite")
nm.set_default_db(conn)
users_count = get_users_count()

The nm.set_default_db context can be nested:

conn1 = sqlite3.connect("my_db_1.sqlite")
conn2 = sqlite3.connect("my_db_2.sqlite")
with nm.set_default_db(conn1):
    print(f"Users count in my_db_1: {get_users_count()}")
    with nm.set_default_db(conn2):  # <<< Temporary set conn2 as a default_db
        print(f"Users count in my_db_2: {get_users_count()}")
    print(f"Check again users count in my_db_1: {get_users_count()}")
LecronRu commented 2 months ago

Есть ли возможность ограничить область действия set_default_db выполняемой как функция модулем, в котором она выполняется?

Есть два модуля разных баз. Из одного модуля (db1) запрашивается функция второго модуля (db2). Базы логически связанные. Возник конфликт, который пришлось решать через with nm.set_default_db(db2.conn), несмотря на то, что в db2 коннект conn уже установлен по умолчанию.

То есть контекстный менеджер имеет приоритет над функцией, но функции друг друга не перекрывают.

amaslyaev commented 2 months ago

Надо подумать...

amaslyaev commented 2 months ago

Пофантазируем.

В принципе, можно сделать декоратор @nm.default_db параметризуемым. Параметр называется tag, который может быть строкой или None. По умолчанию None, то есть

@nm.default_db равно @nm.default_db(None) равно @nm.default_db(tag=None)

Соответственно, nm.set_default_db получает второй необязательный параметр tag. В результате можно делать как-то так:

@nm.default_db("src")
@nm.sql_fetch_all(...
...  # this function works with default "src" database

@nm.default_db("dst")
@nm.sql_execute(...
...  # this function works with default "dst" database

nm.set_default_db(conn_source, "src")
nm.set_default_db(conn_destination, "dst")

Контекст-менеджеры и их нестинг, естественно, тоже будут работать.

LecronRu commented 2 months ago

Вариант решает совсем другую проблему. Позволит реализовать доступ к двум базам в одном модуле. А в остальном вижу минусы.

  1. Для типичного: одна база — один модуль, бойлерплейт
  2. При перекрытии коннекта в клиентском коде, нужно будет вспомнить, соединение именованное или нет. И если именнованное, как его зовут. Что невозможно без посещения DB API модуля.

Моя же проблема (если ее можно так назвать), конфликт областей видимости, при существовании в одном модуле, как обявления функций доступа к базе, так и вызова функции объявленной в другом модуле. set_default влияет на обе сущности, а хотелось влияния только на объявления.

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

amaslyaev commented 1 month ago

ОК, тогда пока оставляем как есть. Закрываю эту issue.