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

Не бывало ли вам интересно, как работает изнутри такая идейно простая концепция? Благодаря чему достигается удобство работы? Сегодня мы напишем ORM самостоятельно и узнаем, какие инструменты python нам для этого понадобятся.

Ремарка: sqlite3 выбран из-за простоты, нетрудно заменить обращения к нему на обращения к любой удобной для вас базе данных. По синтаксису я ориентировался на джанго.

▍ Базовые типы данных


В sqlite3 существуют: INTEGER — вещественное число с указанной точностью, TEXT — текст, BLOB — двоичные данные, REAL — число с плавающей запятой(float24), NUMERIC — то же, что и INTEGER.

CREATE TABLE "example" (
    "Field1"    INTEGER NOT NULL,
    "Field2"    TEXT UNIQUE,
    "Field3"    BLOB,
    "Field4"    REAL DEFAULT 123,
    "Field5"    NUMERIC
);

У каждого из них есть параметры NULL, UNIQUE, DEFAULT, так что первым делом пишем класс, который будут наследовать все остальные:

class BaseType:
  field_type: str #название типа данных поля, например, "INTEGER"

  def __init__(self, unique: bool = False, null: bool = True, default: int = None):
      self.unique = unique
      self.null = null
      self.default = default

На основе него прописываем остальные базовые классы:

class IntegerField(BaseType):
  field_type = 'INTEGER'


class TextField(BaseType):
  field_type = 'TEXT'


class BlobField(BaseType):
  field_type = 'BLOB'


class RealField(BaseType):
  field_type = 'REAL'


class NumericField(BaseType):
  field_type = 'NUMERIC'

▍ Пользовательские модели


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

class Box(models.Model):
  name = TextField()
  width = IntegerField()
  height = IntegerField()

Для этого реализуем родительский класс Model. Он должен задавать объекты с заданными переменными. Для этого пишем инициализатор:

class Model:
  def __init__(self, *args, **kwargs):
      fields = [el for el in vars(self.__class__) if not el.startswith("__")] #поля, которые мы создали в модели (в данном случае name, width, height)
      for i, value in enumerate(args):
          setattr(self, fields[i], value)#задаем переменные переданные с помощью args

      for field, value in kwargs.items():#задаем переменные переданные с помощью kwargs
          setattr(self, field, value)

Все методы, которые, мы напишем для класса Model будут работать с любым объектом, который мы зададим.

Про args и kwargs
1. *args:
— *args позволяет передавать произвольное количество позиционных аргументов в функцию.
— Аргументы, переданные как *args, собираются в кортеж (tuple) внутри функции.
— Вы можете использовать любое имя вместо «args», но общепринято использовать именно «args».
Пример:

def print_args(*args):
for arg in args:
    print(arg)

print_args(1, 2, 3)  # Выводит: 1, 2, 3

2. **kwargs:
— **kwargs позволяет передавать произвольное количество именованных аргументов (ключ-значение) в функцию.
— Аргументы, переданные как **kwargs, собираются в словарь (dictionary) внутри функции.
— Вы можете использовать любое имя вместо «kwargs», но общепринято использовать именно «kwargs».
Пример:

def print_kwargs(**kwargs):
for key, value in kwargs.items():
    print(f"{key}: {value}")

print_kwargs(name="John", age=30, city="New York")  # Выводит: name: John, age: 30, city: New York


Используя *args и **kwargs, вы можете создавать более гибкие функции, которые могут обрабатывать разные наборы аргументов.

Давайте также создадим сразу метод json(), чтобы возвращать объект в виде словаря, он понадобится нам, например, для api или удобного вывода в консоль.

def json(self):
  attributes = {}
  for key, value in vars(self).items():
      if not key.startswith("__") and not callable(value):#проверка на системные методы и поля
          attributes[key] = value

  return attributes

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

class Box(models.Model):
  name = TextField()
  width = IntegerField()
  height = IntegerField()


print(Box('Box 1', 1, 1).json())#выведет {'name': 'Box 1', 'width': 1, 'height': 1}

▍ Добавляем sqlite3


Для использования базы данных я хочу, чтобы каждый объект имел поле objects — менеджер объектов, через который мы и будем обращаться к sqlite.
Например, так:

Box.objects.add(Box('BOX 1', 1, 1))
Box.objects.get(name='BOX 1')
Box.objects.filter(width=1, height=1)
Box.objects.delete(name="BOX 1")

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

class ProxyObjects:
  def __get__(self, instance, owner):
      return Object(owner)

Про __get__
__get__ — это метод в Python, который используется для определения поведения при доступе к атрибуту объекта. Он является частью протокола дескрипторов в Python и позволяет объектам контролировать доступ к своим атрибутам.

__get__ определяется внутри класса, который также может иметь методы __set__ и __delete__, если необходимо управлять операциями присваивания и удаления атрибутов.

Пример использования __get__:

class MyDescriptor:
def __get__(self, instance, owner):
    if instance is None:
        # Если доступ к дескриптору осуществляется через класс, а не через экземпляр,
        # то instance будет равен None, и мы можем вернуть сам дескриптор или другое значение.
        return self
    else:
        # В этом случае instance - это экземпляр объекта, owner - это класс, к которому относится атрибут.
        # Мы можем вернуть значение атрибута или выполнить другие действия при доступе к нему.
        return instance._value

class MyClass:
def __init__(self, value):
    self._value = value

# Используем дескриптор MyDescriptor для атрибута 'my_attribute'
my_attribute = MyDescriptor()

# Создаём экземпляр класса
obj = MyClass(42)

# Доступ к атрибуту 'my_attribute' будет вызывать метод __get__ дескриптора MyDescriptor
print(obj.my_attribute)  # Выведет: 42

В этом примере MyDescriptor является дескриптором, который определяет поведение при доступе к атрибуту my_attribute класса MyClass. Метод __get__ определяет, что происходит при чтении значения этого атрибута через экземпляр obj.

Первое, что нужно сделать для работы с базой данных — создать таблицу. Для этого пишем метод:

import sqlite3  # Необходимо импортировать библиотеку для работы с SQLite

class Object:
def __init__(self, object_type: type):
    # Конструктор класса принимает тип объекта (класс) и сохраняет его в атрибуте object_type.
    self.object_type = object_type

def __createTable__(self):
    # Метод для создания таблицы в базе данных, основанной на атрибутах класса object_type.

    # Устанавливаем соединение с базой данных
    conn = sqlite3.connect(db_name)
    cursor = conn.cursor()

    # Создаём список custom_fields для хранения определений полей таблицы.
    custom_fields = []

    # Проходимся по атрибутам класса object_type и извлекаем информацию о полях.
    for key, value in vars(self.object_type).items():
        if not key.startswith("__") and not callable(value):
            field_name = key
            field_type = value.field_type
            is_unique = value.unique
            is_null = value.null
            default_value = value.default

            # Создаём строку с определением поля и добавляем её в список custom_fields.
            field_declaration = [f'"{field_name}" {field_type}']

            if is_unique:
                field_declaration.append('UNIQUE')
            if not is_null:
                field_declaration.append('NOT NULL')
            if default_value is not None:
                field_declaration.append(f'DEFAULT {default_value}')

            custom_fields.append(' '.join(field_declaration))

    # Создаём SQL-запрос для создания таблицы с определёнными полями.
    create_table_sql = f'''
    CREATE TABLE IF NOT EXISTS {self.object_type.__name__} (
        {", ".join(custom_fields)}
    );
    '''

    # Выполняем SQL-запрос.
    cursor.execute(create_table_sql)

    # Фиксируем изменения и закрываем соединение с базой данных.
    conn.commit()
    conn.close()

Создавать таблицу нужно для каждой пользовательской модели — для этого удобно использовать простой декоратор для класса:

def simple_orm(class_: type):
  EXTERN_TYPES[class_.__name__] = class_ # Сохраняем в словарь моделей
  class_.objects.__createTable__() # Создаём таблицу в бд

  return class_

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

Декораторы используются с помощью символа "@" перед определением функции, которую они декорируют. Вот пример использования декоратора:

def my_decorator(func):
def wrapper():
    print("Что-то происходит перед вызовом функции")
    func()
    print("Что-то происходит после вызова функции")
return wrapper

@my_decorator
def say_hello():
print("Привет, мир!")

say_hello()

В этом примере my_decorator — это декоратор, который добавляет вывод текста до и после вызова функции say_hello. Затем декоратор @my_decorator применяется к функции say_hello, и при вызове say_hello() будет выполнено дополнительное действие, предусмотренное декоратором.

Декораторы часто используются для следующих задач:

  1. Логирования: Запись логов для функций или методов.
  2. Аутентификации: Проверка прав доступа перед вызовом функции.
  3. Кэширования: Сохранение результатов функции для ускорения будущих вызовов с теми же аргументами.
  4. Измерения времени выполнения: Оценка производительности функции.
  5. Модификации поведения: Изменение или расширение функциональности функции.

Python предоставляет множество встроенных декораторов, таких как @staticmethod, @classmethod, @property и другие, а также вы можете создавать свои собственные декораторы в соответствии с вашими потребностями.

Теперь при инициализации модели в бд создаётся таблица с соответствующими полями:



▍ JsonField


В sqlite3 нет JsonField по умолчанию, так что мы реализуем его на основе текста. Для начала добавляем его в базовые типы:

class JsonField(BaseType):
  field_type = 'JSON'

Json, по сути, ведёт себя, как текст, за исключением того, что при создании и изменении надо использовать json.dumps(), а при получении — json.loads().

▍ ForeignKey


В реляционных базах данных, «foreign key» (внешний ключ) — это структурный элемент, который используется для установления связей между двумя таблицами. Внешний ключ представляет собой один или несколько столбцов в одной таблице, которые связаны с первичным ключом (обычно) в другой таблице. Эта связь позволяет базе данных поддерживать целостность данных и обеспечивать связи между данными в разных таблицах.

ForeignKey должен возвращать объект заданного типа по значению заданного поля.

class ForeignKey(BaseType):
  field_type = 'FOREIGN_KEY'

  def __init__(self, object_class: type, foreign_field: str, unique: bool = False,
                null: bool = True, default=None):
      self.object_class = object_class,
      self.foreign_field = foreign_field,
      self.unique = unique
      self.null = null
      self.default = default

Я реализую его просто, как json объект с параметрами type, key, value, например:

{"type": "Box", "key": "name", "value": "BOX 1"}.

Теперь мы с помощью него можем делать так:

@simple_orm
class Box(models.Model):
  name = TextField()
  width = IntegerField()
  height = IntegerField()


@simple_orm
class Circle(models.Model):
  box = ForeignKey(object_class=Box, foreign_field='name')
  name = TextField()
  radius = IntegerField()
  data = JsonField()


box = Box.objects.add(Box('BOX 1', 1, 1))
circle = Circle.objects.add(Circle(box, "CIRCLE 1", 5, {'data': 5}))
print(circle.json()) #{'box': <__main__.Box object at 0x7f7637f6d850>, 'name': 'CIRCLE 1', 'radius': 5, 'data': {'data': 5}}

▍ Добавляем CRUD


Полный код Object
class Object:
  def __init__(self, object_type):
      self.object_type = object_type

  def add(self, obj):
      conn = sqlite3.connect(db_name)
      cursor = conn.cursor()

      d = copy.copy(obj.__dict__)

      object_type_name = self.object_type.__name__
      for key, value in vars(self.object_type).items():
          if not key.startswith("__") and not callable(value):
              if type(value) in BASIC_TYPES:
                  continue
              if type(value) == JsonField:
                  d[key] = json.dumps(d[key])
              if type(value) == ForeignKey:
                  d[key] = json.dumps({'type': value.object_class[0].__name__, 'key': value.foreign_field[0], 'value': getattr(d[key], value.foreign_field[0])})

      insert_sql = f'INSERT INTO {object_type_name} ({", ".join(obj.__dict__.keys())}) VALUES ({", ".join(["?"] * len(obj.__dict__))});'

      values = tuple(d.values())
      cursor.execute(insert_sql, values)
      conn.commit()
      conn.close()
      return obj

  def save(self, obj):
      conn = sqlite3.connect(db_name)
      cursor = conn.cursor()

      d = copy.copy(obj.__dict__)

      object_type_name = self.object_type.__name__
      for key, value in vars(self.object_type).items():
          if not key.startswith("__") and not callable(value):
              if type(value) in BASIC_TYPES:
                  continue
              if type(value) == JsonField:
                  d[key] = json.dumps(d[key])
              if type(value) == ForeignKey:
                  d[key] = json.dumps({'type': value.object_class[0].__name__, 'key': value.foreign_field[0], 'value': getattr(d[key], value.foreign_field[0])})

      upsert_sql = f'INSERT OR REPLACE INTO {object_type_name} ({", ".join(obj.__dict__.keys())}) VALUES ({", ".join(["?"] * len(obj.__dict__))});'

      values = tuple(d.values())
      cursor.execute(upsert_sql, values)
      conn.commit()
      conn.close()
      return obj

  def get(self, **kwargs):
      conn = sqlite3.connect(db_name)
      cursor = conn.cursor()

      object_type_name = self.object_type.__name__

      attr_value_pairs = [(attr, value) for attr, value in kwargs.items()]

      where_clauses = [f'{attr} = ?' for attr, _ in attr_value_pairs]
      where_clause = ' AND '.join(where_clauses)
      select_by_attrs_sql = f'SELECT * FROM {object_type_name} WHERE {where_clause};'

      values = tuple(value for _, value in attr_value_pairs)

      cursor.execute(select_by_attrs_sql, values)
      row = cursor.fetchone()
      conn.close()

      if row:
          obj = self.object_type()
          for i, value in enumerate(row):
              if type(getattr(obj, cursor.description[i][0])) == JsonField:
                  setattr(obj, cursor.description[i][0], json.loads(value))
              elif type(getattr(obj, cursor.description[i][0])) == ForeignKey:
                  value = json.loads(value)
                  setattr(obj, cursor.description[i][0], EXTERN_TYPES[value['type']].objects.get(**{value['key']: value['value']}))
              else:
                  setattr(obj, cursor.description[i][0], value)

          return obj
      else:
          return None

  def delete(self, **kwargs):
      conn = sqlite3.connect(db_name)
      cursor = conn.cursor()

      object_type_name = self.object_type.__name__

      attr_value_pairs = [(attr, value) for attr, value in kwargs.items()]

      where_clauses = [f'{attr} = ?' for attr, _ in attr_value_pairs]
      where_clause = ' AND '.join(where_clauses)
      delete_by_attrs_sql = f'DELETE FROM {object_type_name} WHERE {where_clause};'
      values = tuple(value for _, value in attr_value_pairs)

      cursor.execute(delete_by_attrs_sql, values)
      conn.commit()
      conn.close()

  def filter(self, **kwargs):
      conn = sqlite3.connect(db_name)
      cursor = conn.cursor()

      object_type_name = self.object_type.__name__

      attr_value_pairs = [(attr, value) for attr, value in kwargs.items()]

      where_clauses = [f'{attr} = ?' for attr, _ in attr_value_pairs]
      where_clause = ' AND '.join(where_clauses)
      select_by_attrs_sql = f'SELECT * FROM {object_type_name} WHERE {where_clause};'

      values = tuple(value for _, value in attr_value_pairs)

      cursor.execute(select_by_attrs_sql, values)
      rows = cursor.fetchall()
      conn.close()

      objects = []
      for row in rows:
          obj = self.object_type()
          for i, value in enumerate(row):
              if type(getattr(obj, cursor.description[i][0])) == JsonField:
                  setattr(obj, cursor.description[i][0], json.loads(value))
              elif type(getattr(obj, cursor.description[i][0])) == ForeignKey:
                  value = json.loads(value)
                  setattr(obj, cursor.description[i][0],
                          EXTERN_TYPES[value['type']].objects.get(**{value['key']: value['value']}))
              else:
                  setattr(obj, cursor.description[i][0], value)

          objects.append(obj)

      return objects

  def all(self):
      conn = sqlite3.connect(db_name)
      cursor = conn.cursor()

      object_type_name = self.object_type.__name__
      select_all_sql = f'SELECT * FROM {object_type_name};'

      cursor.execute(select_all_sql)
      rows = cursor.fetchall()
      conn.close()

      objects = []
      for row in rows:
          obj = self.object_type()
          for i, value in enumerate(row):
              if type(getattr(obj, cursor.description[i][0])) == JsonField:
                  setattr(obj, cursor.description[i][0], json.loads(value))
              elif type(getattr(obj, cursor.description[i][0])) == ForeignKey:
                  value = json.loads(value)
                  setattr(obj, cursor.description[i][0],
                          EXTERN_TYPES[value['type']].objects.get(**{value['key']: value['value']}))
              else:
                  setattr(obj, cursor.description[i][0], value)
          objects.append(obj)

      return objects

  def __createTable__(self):
      conn = sqlite3.connect(db_name)
      cursor = conn.cursor()
      custom_fields = []
      for key, value in vars(self.object_type).items():
          if not key.startswith("__") and not callable(value):
              field_name = key
              field_type = value.field_type
              is_unique = value.unique
              is_null = value.null
              default_value = value.default

              if value.field_type == 'FOREIGN_KEY':
                  field_type = "TEXT"
              if value.field_type == 'JSON':
                  field_type = 'TEXT'

              field_declaration = [f'"{field_name}" {field_type}']

              if is_unique:
                  field_declaration.append('UNIQUE')
              if not is_null:
                  field_declaration.append('NOT NULL')
              if default_value is not None:
                  field_declaration.append(f'DEFAULT {default_value}')

              custom_fields.append(' '.join(field_declaration))

      create_table_sql = f'''
      CREATE TABLE IF NOT EXISTS {self.object_type.__name__} (
          {", ".join(custom_fields)}
      );
      '''
      cursor.execute(create_table_sql)

      conn.commit()
      conn.close()


▍ Список объектов


Сейчас функции all() и filter() возвращают list состоящий из объектов. Это неудобно, ведь нельзя, например, удалить все объекты. Исправим это, добавив класс ListOfObjects:

class ListOfObjects:
  def __init__(self, objects):
      self.objects = objects

  def filter(self, **kwargs):
      filtered_objects = []
      for obj in self.objects:
          if all(getattr(obj, attr, None) == value for attr, value in kwargs.items()):
              filtered_objects.append(obj)
      return ListOfObjects(filtered_objects)

  def delete(self):
      for obj in self.objects:
          obj.delete()

  def json(self):
      object_dicts = [obj.json() for obj in self.objects]
      return object_dicts

▍ Примеры


models.py

import simple_orm.models as models
from simple_orm.models import IntegerField, TextField, ForeignKey, JsonField
from simple_orm.models import simple_orm
from pathlib import Path
import sys

PATH = Path(__file__).absolute().parent.parent
sys.path.append(str(PATH))


@simple_orm
class Box(models.Model):
name = TextField()
width = IntegerField()
height = IntegerField()


@simple_orm
class Circle(models.Model):
box = ForeignKey(object_class=Box, foreign_field='name')
name = TextField()
radius = IntegerField()
data = JsonField()

main.py

from models import Box, Circle

box = Box.objects.add(Box('BOX 1', 1, 1))
circle = Circle.objects.add(Circle(box, "CIRCLE 1", 5, {'data': 5}))
print(circle.json())

print(Box.objects.filter(width=1, height=1).json())
print(Circle.objects.get(name="CIRCLE 1").json())
Box.objects.delete(name="BOX 1")
print(Box.objects.all().json())

▍ Заключение


В процессе создания своего ORM мы использовали много сложных инструментов языка python, которые помогли написать короткий и красивый код, решающий довольно сложную задачу.
Да, он не идеальный, вы можете предложить его улучшения в комментариях.

Полный код есть на https://github.com/leo-need-more-coffee/simple-orm
Библиотека для python: https://pypi.org/project/sqlite3-simple-orm
Скачать с помощью pip: pip install sqlite3-simple-orm

Узнавайте о новых акциях и промокодах первыми из нашего Telegram-канала ????

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


  1. Stawros
    17.10.2023 13:05
    +4

    BLOB — булевая переменная

    Булевая - это true/false. Блобы - это массивы двоичных данных обычно.


    1. pzrnqt1vrss Автор
      17.10.2023 13:05

      Спасибо, исправил


  1. PabloP
    17.10.2023 13:05

    Цитата:
    "В sqlite3 существуют: INTEGER — вещественное число с указанной точностью, TEXT — текст, BLOB — двоичные данные, REAL — число с плавающей запятой(float24), NUMERIC — то же, что и INTEGER.
    ...
    У каждого из них есть параметры NULL, UNIQUE, DEFAULT, "
    Вопросы:

    1. Существуют кто? Типы данных полей таблиц?

    2. У кого есть параметры у типов данных, или конкретных полей таблиц?


    1. Ingulf
      17.10.2023 13:05
      +4

      INTEGER — вещественное число с указанной точностью,

      а это вот совсем не смущает?


      1. TIEugene
        17.10.2023 13:05

        Автору платят за буквы, а не за фидбэки.
        Не стреляйте в пианиста.
        PS. и не ищите смысла там, где его нет


  1. Akina
    17.10.2023 13:05
    +3

    У каждого из них есть параметры NULL, UNIQUE, DEFAULT

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

    Во-вторых, корректно [NOT] NULL. А с учётом того, что при отсутствии этого ограничения вообще значением по умолчанию является именно NULL, то корректнее указать хотя бы только NOT NULL. Но уж никак не только NULL.

    В третьих, поле может иметь "параметр" CHECK. Допускаю, что он опущен по причине того, что его обработка куда как сложнее указанных "параметров", да и вообще он по сути есть ограничение именно таблицы, а не поля, но вообще о нём не сказать - неправильно. То же касается и PRIMARY / FOREIGN KEY.


  1. taras_82
    17.10.2023 13:05
    +14

    IMHO для sqlite проще писать запросы на sql. Мне кажется, что так удобнее, чем изучать сто двадцать пятый по счету ORM.


    1. Emulyator
      17.10.2023 13:05
      -2

      К сожалению без знания того же Room будет трудно вписаться в команду какой-нибудь андроид разработки. Мне иногда кажется, что sql воспринимается более сложным для многих начинающих разработчиков, чем основной язык программирования, и ORM в какой-то степени решает эту проблему, наряду с другими.


    1. dtkbrbq
      17.10.2023 13:05

      мне однажды пришлось переносить базу из sqlite на postgresql, а потом соответственно исправлять код приложения, и знаете, я очень пожалел что не использовал ORM, переписывать пришлось абсолютно все запросы и код обрабатывавший результат


      1. taras_82
        17.10.2023 13:05
        +1

        Ну, если в будущем стоит вопрос масшабирования, то имеет смысл писать запросы на чистом SQL, не используя диалекты. Это минимизирует подобные проблемы. Но это исключительно мое мнение и мое видение. Конечно, ситуации могут быть разными.


        1. CrazyOpossum
          17.10.2023 13:05

          Много ли на "чистом" sql можно написать такого что нельзя сделать через orm? Зато orm даёт больше безопасности.


          1. Akina
            17.10.2023 13:05
            +2

            Много ли на "чистом" sql можно написать такого что нельзя сделать через orm?

            Ой, много! Достаточно регулярны вопросы на форумах типа "как вот это реализовать в моём ОРМ" - и изрядная их часть решается исключительно использованием RAW SQL..


  1. mcferden
    17.10.2023 13:05
    +12

    В целом очень хорошая демонстрация, как НЕ нужно реализовывать ORM:

    • ActiveRecord довольно проблемная вещь, но даже закрывая глаза на это:

    • Первичных ключей нет, как ваш INSERT OR REPLACE поймет, что заменять? Точнее он-то поймет по UNIQUE, но у вас в примерах он нигде не указан. А если будет несколько полей с UNIQUE?

    • Внешние ключи реализованы через JSON, ломая весь смысл проверки целостности. Ваш же пример с удалением Box ломает данные.

    • Из-за этого же гарантированный N+1 на любой запрос.

    • filter сразу же выполняет запрос, последующие фильтры выполняются на стороне ORM. То есть Foo.objects.filter(a=1).filter(b=2) сначала прочитает лишние записи (еще помним про N+1), съест RAM, потому будет тратить такты CPU на фильтрацию.

    • filter поддерживает только сравнение на =, а как быть с другими операторами?

    • На каждый запрос делается новое подключение к БД.

    • ... тут можно еще писать и писать ...

    Если это учебный материал, то ИМХО стоит в нем показывать как делать правильно, а не писать код ради "использования много сложных инструментов языка python, которые помогли написать короткий и красивый код, решающий довольно сложную задачу", тем более что код не слишком "короткий и красивый" и задачу решает, мягко говоря, так себе.

    Если задача была продемонстрировать, как устроены другие ORM, то с уверенностью могу заявить, что ни одна популярная ТАК не устроена.


    1. lebedec
      17.10.2023 13:05
      +3

      Хотя SQLite называют СУБД, фактически это просто библиотека для работы с массивом данных через SQL подобный интерфейс. Поэтому реализация некоторых аспектов ORM на уровне приложения может быть не ошибкой, а необходимостью.

      Вот для примера, несколько интересных моментов по вашим замечаниям:

      • SQLite может вообще не контролировать соответствие схемы таблицы и типов передаваемых данных. Если добавить сюда динамичную природу Python, то инкапсуляция всей работы с данными в ActiveRecord уже не плохая идея.

      • Первичного ключа может не быть, физически даже ROWID в таблице может не быть. Так что использование UNIQUE частая и уместная практика в SQLite.

      • Проверка целостности по внешним ключам - опциональная для SQLite фича, полностью по желанию клиента. Поэтому использование собственной реализации через JSON может быть оправдано.

      • Подключение к SQLite это просто получение ручки на файл или оперативную память. Дорогостоящих операций установки соединения или прогрева отдельного процесса тут нет. Зато бывают сценарии когда нужно закрывать соединение для слаженной работы с локом файла.

      Стоит учитывать специфику. Некоторые практики, привычные нам по опыту работы только с "большими СУБД", могут быть просто не применимы для SQLite.


      1. mcferden
        17.10.2023 13:05
        +2

        То что специфику учитывать нужно спору нет, но я процитирую автора:

        sqlite3 выбран из-за простоты, нетрудно заменить обращения к нему на обращения к любой удобной для вас базе данных

        и вот тут с большими СУБД будут проблемы. Не хочется, чтобы новички переносили такой опыт на серьезные проекты.


  1. kpmy
    17.10.2023 13:05
    +1

    Можно было бы поспорить с тем, что это получился ORM, скорее это типобезопасный DSL для SQL-генератора (в Java например есть QueryDSL, очень похоже). Добавьте автогенератор классов по схеме, будет один в один, нишевая библиотека для своих задач.


  1. CrazyOpossum
    17.10.2023 13:05
    +3

    Прямо сейчас пишу себе микро-орм для Монги с поддержкой motor_asyncio. Мне не нравится дизайн многих орм в которых параметры соединения и сессии передаются через глобальные переменные и плохо работают с asyncio. Поэтому я начал с класса Adapter, который хранит в себе сессию и является фабрикой для моделей. Хочу чтобы интерфейс выглядел примерно так:

    class User(metaclass=orm.Model):
      id_ = orm.Field(index=ASCENDING)
      name = orm.Field()
    
    adapter = Adapter("mongodb://localhost:27017")
    # protected определяет, будут ли методы типа insert, delete, update среди методов user_class
    user_class = adapter.retrieve_model(User, database="test", collection="users", protected=False)
    await user = user_class.find(name="test")
    await user_class.insert(User(name="test2"))
    

    orm.Model делает свои классы наследуемыми от ProtectedModel / FreeModel, которые имеют разный набор CRUD операций. Adapter.retrieve_model добавляет инстанс AsyncIOMotorCollection в новый класс.
    В принципе, всё вроде получается, но если кто-то подскажет хороший проект для референсов или ещё чего - будет круто.


  1. Sap_ru
    17.10.2023 13:05
    +1

    Вы точно уверены, что NUMERIC это то же самое, что INTEGER? Потому, что авторы sqlite с вами категорически несогласны: "column with NUMERIC affinity may contain values using all five storage classes".
    NUMERIC - специальный тип, который может хранить любой из всех остальных типов. Согласитесь, что это совсем не то же самое, что и "INTEGER".


    1. lebedec
      17.10.2023 13:05

      SQLite умеет динамически выбирать размер целочисленных данных в зависимости от фактической размерности значений. Это актуально только для процесса сериализации данных на хранение.

      Клиентский драйвер вам всегда будет распаковывать и выдавать 64 битное знаковое число.

      Его название в схеме таблицы не более чем сахар для адаптации различных диалектов SQL, можете называть как хотите: NUMERIC, INTEGER, INT, BIGINT, etc.


      1. lebedec
        17.10.2023 13:05

        А нет же. Сам сказанул не то, сам себя поправлю.

        По официальной документации SQLite, действительно есть NUMERIC тип, и это совсем не то же самое что и INTEGER.

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


  1. Karopka
    17.10.2023 13:05
    +1

    До чего люди ленивы. Чтобы не разбираться с изучением SQL и особенностями конкретной СУБД , строят свои или используют готовые ORM. Столкнувшись с проблемами (например, производительности), они лезут внутрь кода ORM, но бестолку:SQL и особенности работы с конкретной СУБД они не знают...

    А ведь достаточно выучить SQL "в общем" и таки разобраться с особенностями работы с конкретной. СУБД.


    1. economist75
      17.10.2023 13:05
      +1

      Недостаточно. DS-ники еще более ленивы чем люди. Они неплохо знают SQL, но предпочитают выдернуть все данные по SELECT * FROM целиком. И вместо написания утомительных CTE/JOIN - всё остальное делают в Pandas/аналогах, в небольших ячейках кода Jupyter/Lab. Просто потому что там быстрее, удобнее, "однострочнее" выходит, не теряется контекст (вычисленные значения висят в RAM).

      Трех-экранные SELECT-запросы в той же 1С задолбали, работать с ними невероятно тяжело, даже их авторам. Прямое подтверждение - в современных типовых конфигурация 1С количество таблиц/views превысило разумное (10000+). При том что бухучет на бумаге ведется всего в трех таблицах: Главной книге, пяти однотипных Журналах-ордерах и Кассовой книге. И с этим учетом были сделаны все великие стройки страны.

      Поэтому ORM и Pandas - никуда не уйдут (как и SQL), т.к. по-своему удобны на разных задачах. Они дают вам те самые "три таблицы", если не перетягивать в одну сторону одеяло технологий, а использовать все.


    1. CrazyOpossum
      17.10.2023 13:05

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