Skip to content

19. Використання ORM SQLALCHEMY

У попередньому розділі було показано, що в деяких випадках можна писати код, незалежний від SGBD, який використовується з такою архітектурою:

Image

У цьому розділі ми будемо використовувати ORM (об’єктно-реляційний мапер) [sqlalchemy] для уніфікованого доступу до SGBD незалежно від того, який SGBD використовується. ORM дає дві можливості:

  • воно дозволяє скрипту взаємодіяти з SGBD без відправлення команд SQL;
  • він приховує від скрипта особливості кожного SGBD;

Архітектура виглядає наступним чином:

Тепер скрипт відокремлений від коннекторів за допомогою ORM. Він взаємодіє з ORM за допомогою класів і методів. Він не виконує код SQL. Це робить ORM за допомогою коннекторів, до яких він підключений. Він приховує від скрипта особливості цих коннекторів. Тому код скрипта не залежить від зміни коннектора (а отже, і від SGBD);

Структура досліджуваних скриптів буде такою:

Image

19.1. Встановлення ORM [sqlalchemy]

ORM [sqlalchemy] постачається у вигляді пакета Python, який потрібно встановити в терміналі Python:


(venv) C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\databases\sqlalchemy>pip install sqlalchemy
Collecting sqlalchemy
  Downloading SQLAlchemy-1.3.18-cp38-cp38-win_amd64.whl (1.2 MB)
     || 1.2 MB 3.3 MB/s
Installing collected packages: sqlalchemy
Successfully installed sqlalchemy-1.3.18

19.2. Скрипти 01: основи

Image

  • у [1] — скрипти, які будуть розглядатися. Ці скрипти використовуватимуть класи з [2]: BaseEntity, MyException, Personne, Utils;

19.2.1. Конфігурація

Файл [config] налаштовує додаток наступним чином:


def configure():
    # root_dir
    # абсолютний шлях, що є посиланням на відносні шляхи конфігурації
    root_dir = "C:/Data/st-2020/dev/python/cours-2020/python3-flask-2020"
    # абсолютні шляхи залежностей
    absolute_dependencies = [
        # BaseEntity, MyException, Особа, Утиліти
        f"{root_dir}/classes/02/entities",
    ]

    # встановлюємо syspath
    from myutils import set_syspath
    set_syspath(absolute_dependencies)

    # налаштування класів
    from Personne import Personne
    Personne.excluded_keys = ['_sa_instance_state']

    # застосовуємо конфігурацію
    return {}

Коментарі

  • рядок 8: додаємо до Python Path папку, що містить класи [BaseEntity, MyException, Personne, Utils];
  • рядки 12–13: встановлюється Python Path додатка;
  • рядки 16–17: можливо, ви пам’ятаєте, що клас |BaseEntity| має атрибут класу з назвою [excluded_keys]. Цей атрибут є списком, до якого додаються властивості класу, які не повинні з’являтися у його словнику (функція asdict). Тут ми виключаємо властивість [_sa_instance_state] зі стану класу [Personne]. Незабаром ми побачимо, чому;

19.2.2. Скрипт [démo]

Скрипт [démo] демонструє перше використання ORM [sqlalchemy]:


# завантажуємо конфігурацію додатка
import config

config = config.configure()

# імпортує
from sqlalchemy import Table, Column, Integer, String, MetaData, UniqueConstraint
from sqlalchemy.orm import mapper

from Personne import Personne

# метадані
metadata = MetaData()

# таблиця
personnes_table = Table("personnes", metadata,
                        Column('id', Integer, primary_key=True),
                        Column('prenom', String(30), nullable=False),
                        Column("nom", String(30), nullable=False),
                        Column("age", Integer, nullable=False),
                        UniqueConstraint('nom', 'prenom', name='uix_1')
                        )
# клас «Personne» перед маппінгом
personne1 = Personne().fromdict({"id": 67, "prénom": "x", "nom": "y", "âge": 10})
print(f"personne1={personne1.__dict__}")

# маппінг
mapper(Personne, personnes_table, properties={
    'id': personnes_table.c.id,
    'ім'я: personnes_table.c.prenom,
    'прізвище: personnes_table.c.nom,
    'вік: personnes_table.c.age
})

# особа1 не була змінена
print(f"personne1={personne1.__dict__}")
# клас «Особа» було змінено — його «розширено»
personne2 = Personne().fromdict({"id": 68, "prénom": "x1", "nom": "y1", "âge": 11})
print(f"personne2={personne2.__dict__}")

Коментарі

  • рядки 1–4: налаштовуємо програму;
  • рядки 6–10: імпортуються модулі, необхідні для роботи скрипта;
  • рядок 13: [MetaData] є класом [sqlalchemy];
  • рядки 15–22: [Table] є класом [sqlalchemy]. Він дозволяє описати таблицю бази даних. Тут ми опишемо таблицю [personnes] з бази даних MySQL [dbpersonnes], розглянутої в розділі |MySQL|;
    • рядок 16: перший параметр [personnes] — це ім’я описуваної таблиці;
    • рядок 16: другий параметр [metadata] — це екземпляр [MetaData], створений у рядку 13;
    • рядки 17–22: кожен із наступних параметрів описує стовпець таблиці за допомогою синтаксису, властивого [sqlalchemy], але близького до синтаксису SQL;
    • кожен стовпець описується екземпляром класу [Column] класу [sqlalchemy];
      • перший параметр — це назва стовпця;
      • другий параметр — її тип;
      • наступні параметри є іменованими:
        • рядок 17: [primary_key=True], що вказує на те, що стовпець [id] є первинним ключем таблиці [personnes];
        • рядок 18: [nullable=False], що вказує на те, що стовпець обов’язково повинен мати значення під час вставки рядка в таблицю;
    • рядок 21: нарешті, клас [UniqueConstraint] дозволяє описати обмеження унікальності. Тут вказується, що стовпці (прізвище, ім’я) мають бути унікальними в таблиці. Властивість із назвою [name] дозволяє надати ім’я цьому обмеженню. Тут слід розрізняти два випадки:
      • описується існуюча таблиця. У цьому випадку потрібно знайти ім’я обмеження у властивостях таблиці (phpMyAdmin або pgAdmin);
      • описується таблиця, яку буде створено. У цьому випадку вказується бажане ім’я;
  • рядки 23–25: створюємо особу [personne1] і відображаємо її словник [__dict__]. Тут ми отримаємо:
personne1={'_BaseEntity__id': 67, '_Personne__prénom': 'x', '_Personne__nom': 'y', '_Personne__âge': 10}
  • рядки 27–33: виконується маппінг, тобто створюється відповідність між класом [Personne] і таблицею [personnes]. По суті, це відповідність [propriétés de la classe  colonnes de la table]. Функція [mapper] приймає тут три параметри:
    • рядок 28: перший параметр — це назва класу, для якого виконується маппінг;
    • рядок 28: другим параметром є таблиця, з якою вона буде пов’язана. Це об’єкт [Table], створений у рядку 16;
    • рядок 28: третім параметром тут є параметр із назвою [properties]. Це словник, у якому ключі — це властивості класу, що відображається, а значення — стовпці таблиці, що відображається. Щоб позначити стовпець X таблиці [personnes_table], записуємо [personnes_table.c.X];
  • рядки 35–36: після завершення зіставлення знову відображається особа [personne1]. Бачимо, що вона не змінилася:
personne1={'_BaseEntity__id': 67, '_Personne__prénom': 'x', '_Personne__nom': 'y', '_Personne__âge': 10}
  • рядки 37–39: створюється нова особа [personne2] і вона відображається. У результаті отримуємо таке відображення:
personne2={'_sa_instance_state': <sqlalchemy.orm.state.InstanceState object at 0x00000259A6747FA0>, 'id': 68, 'prénom': 'x1', 'nom': 'y1', 'âge': 11}

Бачимо, що словник [__dict__] зазнав суттєвих змін:

  • (продовження)
    • з’являється нова властивість [_sa_instance_state]. Бачимо, що це об’єкт класу ORM [sqlalchemy];
    • інші властивості позбулися префікса, який вказував, до якого класу вони належали;

Отже, можна зробити висновок, що операція мапування рядків 27–33 змінила клас [Personne].

Коли ми захочемо відобразити стан об’єкта [Personne], нам, як правило, не знадобиться властивість [_sa_instance_state]. Вона, по суті, існує лише для внутрішніх потреб [sqlalchemy] і, як правило, нас не цікавить. Саме тому у скрипті [config] ми написали:


    # конфігурація класів
    from Personne import Personne
    Personne.excluded_keys = ['_sa_instance_state']

19.2.3. Скрипт [main]

Скрипт [main] буде обробляти таблицю [personnes] у базі даних MySQL [dbpersonnes], взаємодіючи з [sqlalchemy]. Щоб зрозуміти подальший хід подій, слід згадати архітектуру, яка тут використовується:

Image

Якщо [Database1] є базою [dbpersonnes], то видно, що зв’язок між скриптом і цією базою проходить через два елементи:

  • коннектор Python до SGBD MySQL;
  • SGBD та MySQL;

Скрипт [main] взаємодіє з ORM, який, у свою чергу, взаємодіє з коннектором Python. ORM взаємодіє з цим коннектором за допомогою інструментів, описаних у параграфах |MySQL| та |PostgreSQL|, зокрема, надсилаючи команди SQL. Скрипт [main] не використовуватиме команди SQL. Він буде спиратися на API (інтерфейс прикладного програмування) ORM, що складається з класів та інтерфейсів.

Скрипт [main] має такий вигляд:


# налаштовуємо додаток
import config

config = config.configure()

# імпорт
from sqlalchemy import create_engine, Table, Column, Integer, String, MetaData, UniqueConstraint
from sqlalchemy.exc import IntegrityError, InterfaceError
from sqlalchemy.orm import mapper, sessionmaker

from Personne import Personne

# рядок підключення до бази даних MySQL
engine = create_engine("mysql+mysqlconnector://admpersonnes:nobody@localhost/dbpersonnes")

# метадані
metadata = MetaData()

# таблиця
personnes_table = Table("personnes", metadata,
                        Column('id', Integer, primary_key=True),
                        Column('prenom', String(30), nullable=False),
                        Column("nom", String(30), nullable=False),
                        Column("age", Integer, nullable=False),
                        UniqueConstraint('nom', 'prenom', name='uix_1')
                        )

# маппінг
mapper(Personne, personnes_table, properties={
    'id': personnes_table.c.id,
    'ім'я: personnes_table.c.prenom,
    'прізвище: personnes_table.c.nom,
    'вік: personnes_table.c.age
})

# фабрика сеансів
Session = sessionmaker()
Session.configure(bind=engine)

session = None
try:
    # сесія
    session = Session()

    # видалення таблиці [personnes]
    session.execute("drop table if exists personnes")

    # відновлення таблиці на основі маппінгу
    metadata.create_all(engine)

    # вставка
    session.add(Personne().fromdict({"id": 67, "prénom": "x", "nom": "y", "âge": 10}))
    # session.commit()

    # запит
    personnes = session.query(Personne).all()

    # виведення
    print("Liste des personnes ---------")
    for personne in personnes:
        print(personne)

    # ще два введення, друге з яких завершилося невдачею через порушення унікальності (ім'я, прізвище)
    session.add(Personne().fromdict({"id": 68, "prénom": "x1", "nom": "y1", "âge": 10}))
    session.add(Personne().fromdict({"id": 69, "prénom": "x1", "nom": "y1", "âge": 10}))

    # запит
    personnes = session.query(Personne).all()

    # відображення
    print("Liste des personnes ---------")
    for personne in personnes:
        print(personne)

    # підтвердження сеансу
    session.commit()

except (InterfaceError, IntegrityError) as erreur:
    # відображення
    print(f"L'erreur suivante s'est produite : {erreur}")
    # скасування останньої сесії
    if session:
        print("rollback...")
        session.rollback()
finally:
    # звільнення ресурсів сеансу
    if session:
        session.close()

Коментарі

  • рядки 1–4: налаштовується додаток;
  • рядки 7–9: імпортується ціла низка класів та інтерфейсів із бібліотеки [sqlalchemy];
  • рядок 11: імпортується клас [Personne];
  • рядок 14: рядок підключення до бази даних. У ньому вказано:
    • використовуваний SGBD (mysql);
    • використовуваний коннектор Python (mysql.connector без крапки);
    • користувача, що підключається (admpersonnes);
    • його пароль (nobody);
    • комп’ютер, на якому знаходиться SGBD (localhost = комп’ютер, на якому виконується скрипт);
    • назва бази даних (dbpersonnes);

За допомогою цієї інформації [sqlalchemy] може підключитися до бази даних. Слід зауважити, що використовуваний коннектор Python має бути вже встановлений. [sqlalchemy] цього не робить.

  • рядки 19–26: опис таблиці [personnes];
  • рядки 28–34: відповідність між класом [Personne] та таблицею [personnes];
  • рядки 36–38: більшість операцій [sqlalchemy] виконуються в рамках сеансу. Поняття сеансу [sqlalchemy] близьке до поняття транзакції SQL. Сесії створюються на основі класу [Session], який повертається функцією [sessionmaker] у рядку 37;
  • рядок 38: клас [Session] пов’язаний з базою [dbpersonnes] через ланцюг з’єднання з рядка 14;
  • рядок 43: створюється сесія. Як уже зазначалося, сесію можна порівняти з транзакцією;
  • рядки 45–46: метод [Session.execute] дозволяє виконати замовлення SQL. Це не є типовим випадком, оскільки, як уже зазначалося, ORM дозволяє уникнути використання мови SQL;
  • рядки 48–49: метод [metadata.create_all] дозволяє створити всі таблиці, що використовують екземпляр [MetaData] із рядка 17. У нас є лише одна: таблиця [personnes], визначена у рядках 20–26. [sqlalchemy] використовуватиме інформацію з цих рядків для створення таблиці. У цьому полягає перша перевага ORM: вона приховує особливості SGBD. Дійсно, порядок SQL [create] може суттєво відрізнятися від одного SGBD до іншого через типи даних, задані для стовпців. Уніфікації типів даних не відбулося. Тому порядок [create] варіюється від одного SGBD до іншого. Тут, завдяки [sqlalchemy]:
    • ми однозначно описуємо потрібну нам таблицю;
    • [sqlalchemy] самостійно генерує [create], який підходить до SGBD, що знаходиться перед ним;
  • рядок 52: до сесії додається об’єкт [Personne]. Це не означає, що він автоматично додається до бази даних. Справа в тому, що ORM дотримується власних правил синхронізації з базою даних. Він завжди намагатиметься оптимізувати кількість запитів, які він виконує. Розглянемо приклад. Скрипт додає (add) двох осіб (особа1, особа2) до сесії, а потім виконує запит: він хоче переглянути всіх осіб, присутніх у таблиці. [sqlalchemy] може діяти таким чином:
    • додавання [personne1] може відбуватися в пам’яті. Наразі немає потреби вносити його до бази даних;
    • те саме стосується [personne2];
    • далі йде запит типу [select]. Тоді потрібно отримати всі рядки з таблиці [personnes]. [sqlalchemy] додасть [personne1, personne2] до бази даних, а потім виконає запит;

[sqlalchemy] таким чином виконає оптимізацію, непомітну для розробника.

  • рядок 56: щоб виконати запит типу [select] (я хочу переглянути…), використовується метод [Session.query]. Параметром методу [query] є клас, який відповідає запитуваній таблиці. Цей метод повертає тип [Query]. Метод [Query.all] запитує всі об’єкти [Personne] сеансу. Йому повертаються всі рядки таблиці [personnes], кожен у вигляді об’єкта [Personne]. Для цього [sqlalchemy] використовує зіставлення, яке було створено між класом [Personne] та таблицею [personnes]. Результатом рядка 56 є список об’єктів [Personne];
  • рядки 58–61: виводяться елементи списку [personnes]. Оскільки клас [Personne] є похідним від класу [BaseEntity], метод [Personne.__str__], який тут неявно використовується в рядку 61, насправді є методом [BaseEntity.__str__], який повертає рядок jSON об’єкта, що викликає. Цей рядок є рядком jSON зі словника [Personne.asdict] (див. |BaseEntity|). Ми зазначали, що після маппінгу у кожному об’єкті [Personne] буде знайдено властивість [_sa_instance_state]. Однак значення цієї властивості не є типом [BaseEntity]. Тому її потрібно виключити зі словника класу [Personne], інакше відображення «зависне». Саме це було зроблено у скрипті [config];
  • рядки 63–65: додаємо ще двох осіб, які мають однакові ім’я та прізвище. Однак на об’єднанні цих двох стовпців діє обмеження унікальності. Тому має статися помилка. Саме це ми й намагаємося перевірити;
  • рядки 67–68: знову запитуємо список усіх осіб із бази даних;
  • рядки 70–73: і виводимо їх на екран;
  • рядки 75–76: сесія підтверджується («commit»). Як випливає з назви, відповідна транзакція буде підтверджена;
  • під час виконання ми побачимо, що рядки 67–76 не будуть виконані через виняток, спричинений рядком 65. Тоді ми перейдемо до рядків 78–84 для обробки винятку;
  • рядок 78: виняток [InterfaceError] виникає, якщо [sqlalchemy] не вдається підключитися до бази даних [dbpersonnes]. Виняток [IntegrityError] виникає у рядку 65;
  • рядок 80: виводиться повідомлення про помилку;
  • рядки 82–84: якщо сесія існує, її скасовують. Це еквівалентно скасуванню базової транзакції;
  • рядки 85–88: у будь-якому випадку, незалежно від наявності помилки, сесія закривається для звільнення ресурсів;

Результати виконання такі:


C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\venv\Scripts\python.exe C:/Data/st-2020/dev/python/cours-2020/python3-flask-2020/databases/sqlalchemy/01/main.py
Liste des personnes ---------
{"nom": "y", "prénom": "x", "id": 67, "âge": 10}
L'erreur suivante s'est produite : (raised as a result of Query-invoked autoflush; consider using a session.no_autoflush block if this flush is occurring prematurely)
(mysql.connector.errors.IntegrityError) 1062 (23000): Duplicate entry 'y1-x1' for key 'uix_1'
[SQL: INSERT INTO personnes (id, prenom, nom, age) VALUES (%(id)s, %(prenom)s, %(nom)s, %(age)s)]
[parameters: ({'id': 68, 'prenom': 'x1', 'nom': 'y1', 'age': 10}, {'id': 69, 'prenom': 'x1', 'nom': 'y1', 'age': 10})]
(Background on this error at: http://sqlalche.me/e/13/gkpj)
rollback...

Process finished with exit code 0
  • рядки 2–3: список осіб після першого введення;
  • рядок 5: виняток [IntegrityError], що виник під час додавання двох осіб з однаковими іменами та прізвищами;
  • рядки 6–7: зверніть увагу на команду SQL, виконання якої завершилося невдачею. Це налаштований запит INSERT: запит [sqlalchemy] вніс обох осіб за допомогою єдиного запиту INSERT. Тут видно, що він намагався оптимізувати видані замовлення SQL;

Тепер давайте подивимося, за допомогою phpMyAdmin, на вміст таблиці [personnes]:

Image

У [6] видно, що таблиця порожня. Там немає навіть першої особи, яку скрипт додав до сесії. Це пов’язано з тим, що сесія відбувалася в рамках транзакції, яка була скасована у клаузулі [except] скрипта [main].

Тепер внесімо наступну зміну в [main]:


    # вставка
    session.add(Personne().fromdict({"id": 67, "prénom": "x", "nom": "y", "âge": 10}))
    # session.commit()

Після додавання особи у рядку 2 ми знімаємо коментар із рядка 3. Операція [session.commit] підтвердить базову транзакцію, і розпочнеться нова транзакція. Після виконання вміст таблиці [personnes] є таким:

Image

У таблиці [6] видно, що перше вставлення було збережено. Це пов’язано з тим, що воно було виконано в рамках транзакції 1, а помилка, що сталася згодом, — у рамках транзакції 2.

19.3. Скрипти 02: відповідності [sqlalchemy]

Image

Скрипти 02 є варіантом скриптів 01. Ми намагаємося виконати якомога більше налаштувань у [config.py]. Тепер ми налаштовуємо в ньому середовище [sqlalchemy] додатка:


def configure():
    # абсолютний шлях, що є посиланням на відносні шляхи конфігурації
    root_dir = "C:/Data/st-2020/dev/python/cours-2020/python3-flask-2020"
    # абсолютні шляхи залежностей
    absolute_dependencies = [
        # BaseEntity, MyException, Personne, Utils
        f"{root_dir}/classes/02/entities",
    ]

    # встановлюємо syspath
    from myutils import set_syspath
    set_syspath(absolute_dependencies)

    # імпорт
    from sqlalchemy import create_engine, Table, Column, Integer, String, MetaData, UniqueConstraint
    from sqlalchemy.orm import mapper, sessionmaker

    # посилання на базу даних MySQL
    engine = create_engine("mysql+mysqlconnector://admpersonnes:nobody@localhost/dbpersonnes")

    # метадані
    metadata = MetaData()

    # таблиця
    personnes_table = Table("personnes", metadata,
                            Column('id', Integer, primary_key=True),
                            Column('prenom', String(30), nullable=False),
                            Column("nom", String(30), nullable=False),
                            Column("age", Integer, nullable=False),
                            UniqueConstraint('nom''prenom', name='uix_1')
                            )

    # маппінг
    from Personne import Personne

    mapper(Personne, personnes_table, properties={
        'id': personnes_table.c.id,
        'ім'я: personnes_table.c.prenom,
        'прізвище: personnes_table.c.nom,
        'вік: personnes_table.c.age
    })

    # фабрика сеансів
    Session = sessionmaker()
    Session.configure(bind=engine)

    # цю інформацію вносимо до конфігурації
    config = {}
    config["Session"] = Session
    config["metadata"] = metadata
    config["engine"] = engine
    config["personnes_table"] = personnes_table

    # конфігурація класів
    from Personne import Personne
    Personne.excluded_keys = ['_sa_instance_state']

    # застосовуємо конфігурацію
    return config

Коментарі

  • рядки 2–12: налаштування Python Path;
  • рядки 14–45: налаштовуємо середовище [sqlalchemy];
  • рядки 47–52: середовище [sqlalchemy] додається до словника конфігурації;
  • рядки 54–56: налаштовується клас [Personne];

З цією конфігурацією скрипт [main] виглядає наступним чином:


# налаштовуємо додаток
import config

config = config.configure()

# syspath налаштовано — виконуємо імпорт
from sqlalchemy.exc import IntegrityError, DatabaseError, InterfaceError
from sqlalchemy.orm.exc import FlushError

from Personne import Personne

session = None
try:
    # сесія
    session = config["Session"]()

    # видалення таблиці [personnes]
    session.execute("drop table if exists personnes")

    # відновлення таблиці на основі маппінгу
    config["metadata"].create_all(config["engine"])

    # дві операції вставки
    session.add(Personne().fromdict({"prénom": "x", "nom": "y", "âge": 10}))
    personne = Personne().fromdict({"prénom": "x1", "nom": "y1", "âge": 7})
    session.add(personne)

    # перевірка двох вставлень
    session.commit()

    # запит
    personnes = session.query(Personne).all()

    # виведення
    print("Liste des personnes-----------")
    for personne in personnes:
        print(personne)

    # ще два вставки, друга з яких завершилася невдачею
    session.add(Personne().fromdict({"prénom": "x2", "nom": "y2", "âge": 10}))
    session.add(Personne().fromdict({"prénom": "x2", "nom": "y2", "âge": 10}))

    # запит
    personnes = session.query(Personne).all()

    # відображення
    print("Liste des personnes-----------")
    for personne in personnes:
        print(personne)

    # підтвердження сеансу
    session.commit()

except (FlushError, DatabaseError, InterfaceError, IntegrityError) as erreur:
    # відображення
    print(f"L'erreur suivante s'est produite : {erreur}")
    # скасування останньої сесії
    if session:
        print("rollback...")
        session.rollback()
finally:
    # відображення
    print("Travail terminé...")
    # звільнення ресурсів сеансу
    if session:
        session.close()

Результати виконання такі:


C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\venv\Scripts\python.exe C:/Data/st-2020/dev/python/cours-2020/python3-flask-2020/databases/sqlalchemy/02/main.py
Liste des personnes-----------
{"âge": 10, "nom": "y", "prénom": "x", "id": 1}
{"âge": 7, "nom": "y1", "prénom": "x1", "id": 2}
L'erreur suivante s'est produite : (raised as a result of Query-invoked autoflush; consider using a session.no_autoflush block if this flush is occurring prematurely)
(mysql.connector.errors.IntegrityError) 1062 (23000): Duplicate entry 'y2-x2' for key 'uix_1'
[SQL: INSERT INTO personnes (prenom, nom, age) VALUES (%(prenom)s, %(nom)s, %(age)s)]
[parameters: {'prenom': 'x2', 'nom': 'y2', 'age': 10}]
(Background on this error at: http://sqlalche.me/e/13/gkpj)
rollback...
Travail terminé...

Process finished with exit code 0

У phpMyAdmin таблиця [personnes] набула такого вигляду:

Image

Тепер розглянемо таблицю [personnes], згенеровану [sqlalchemy]:

Image

  • у [6] — типи, використані для різних стовпців;
  • у [7] видно, що стовпець [id] має атрибут [AUTO_INCREMENT]. Це означає, що під час вставки рядка в таблицю, якщо цей рядок не має значення для стовпця [id], воно буде згенеровано MySQL послідовно: 1, 2, 3, … Ця властивість дозволяє нам не турбуватися про значення первинного ключа під час вставки даних у таблицю: ми дозволяємо MySQL генерувати його;
  • у [8] бачимо, що стовпець [id] є первинним ключем;
  • у [9] ми бачимо обмеження унікальності для полів [nom, prenom];

19.4. Скрипти 03: робота з сутностями сесії [sqlalchemy]

Image

Файл конфігурації [config] такий самий, як у попередньому прикладі. У скрипті [main] виконуються стандартні операції [INSERT, UPDATE, DELETE, SELECT] над таблицею [personnes] за допомогою методів [sqlalchemy]:


# налаштування програми
import config

config = config.configure()

# імпортування
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError, DatabaseError, InterfaceError
from sqlalchemy.orm.session import Session
from Personne import Personne

# відображає вміст таблиці [personnes]
def affiche_table(session: Session):
    print("----------------")
    # запит
    personnes = session.query(Personne).all()
    # відображення
    affiche_personnes(personnes)

# відображає список осіб
def affiche_personnes(personnes: list):
    print("----------------")
    # відображення
    for personne in personnes:
        print(personne)


# головна ---------------------------
session = None
try:
    # сесія
    session = config["Session"]()

    # видалення таблиці [personnes]
    # checkfirst=True: спочатку перевіряє, чи існує таблиця
    config["personnes_table"].drop(config["engine"], checkfirst=True)

    # відновлення таблиці на основі маппінгу
    config["metadata"].create_all(config["engine"])

    # вставки
    session.add(Personne().fromdict({"prénom": "Pierre", "nom": "Nicazou", "âge": 35}))
    session.add(Personne().fromdict({"prénom": "Géraldine", "nom": "Colou", "âge": 26}))
    session.add(Personne().fromdict({"prénom": "Paulette", "nom": "Girondé", "âge": 56}))

    # відображення вмісту сесії
    affiche_table(session)

    # список осіб у алфавітному порядку за прізвищами, а при однакових прізвищах — у алфавітному порядку за іменами
    personnes = session.query(Personne).order_by(Personne.nom.desc(), Personne.prénom.desc())

    # виведення
    affiche_personnes(personnes)

    # список осіб, вік яких знаходиться в діапазоні [20,40], у порядку спадання віку
    # а потім за рівним віком у алфавітному порядку за прізвищами, а при однакових прізвищах — у алфавітному порядку за іменами
    personnes = session.query(Personne). \
        filter(Personne.âge >= 20, Personne.âge <= 40). \
        order_by(Personne.âge.desc(), Personne.nom.asc(), Personne.prénom.asc())

    # відображення
    affiche_personnes(personnes)

    # додавання пані Брюно
    bruneau = Personne().fromdict({"prénom": "Josette", "nom": "Bruneau", "âge": 46})
    session.add(bruneau)
    # зміна її віку
    bruneau.âge = 47

    # список осіб із прізвищем Брюно
    personne = session.query(Personne).filter(func.lower(Personne.nom) == "bruneau").first()

    # перегляд
    affiche_personnes([personne])

    # видалення пані Брюно
    session.delete(personne)

    # список осіб із прізвищем Брюно
    personnes = session.query(Personne).filter(func.lower(Personne.nom) == "bruneau")

    # перегляд
    affiche_personnes(personnes)

    # підтвердження сеансу
    session.commit()

except (DatabaseError, InterfaceError, IntegrityError) as erreur:
    # перегляд
    print(f"L'erreur suivante s'est produite : {erreur}")
    # скасування останньої сесії
    if session:
        session.rollback()

finally:
    # перегляд
    print("Travail terminé...")
    # звільнення ресурсів сеансу
    if session:
        session.close()

Коментарі

  • рядки 20–25: функція [affiche_personnes] виводить елементи списку осіб;
  • рядки 12–18: функція [affiche_table] виводить вміст таблиці [personnes];
  • рядки 34–36: видаляється таблиця [personnes]. На відміну від попередніх версій, використовується не команда SQL, а метод [sqlalchemy]:
    • config["personnes_table"] — це об’єкт [Table], що описує таблицю [personnes];
    • config["engine"] — це рядок підключення до бази даних [dbpersonnes];
    • параметр із назвою [checkfirst=True] вимагає, щоб операція виконувалася лише за умови існування таблиці [personnes];
  • рядки 38–39: таблиця [personnes] створюється заново;
  • рядки 41–44: до сесії додаються три особи. Нагадаємо, що вони не обов’язково відразу вставляються до таблиці [personnes]. Це залежить від стратегії [sqlalchemy], спрямованої на підвищення продуктивності;
  • рядки 46–47: відображається вміст таблиці [personnes]. Якщо введення даних про цих трьох осіб ще не відбулося, то завдяки цьому запиту вони вводяться саме зараз;
  • рядки 49–50: приклад використання методу [order_by], який дозволяє відображати результати запиту в певному порядку. Синтаксис [order_by(critère1, critère2)] спочатку відображає результати за критерієм [critère1], а коли рядки мають однакове значення [critère1], вони сортуються за критерієм [critère2]. Можна задати кілька критеріїв таким чином:
  • рядки 55–59: вводять поняття фільтра за допомогою методу [filter]. Позначення [filter(critère1, critère2)] створює логічний ET (AND) між використовуваними критеріями;
  • рядки 64–67: підключено нового користувача;
  • рядки 70–71: ще один приклад відфільтрованого запиту. Функція [func.lower(param)] перетворює [param] на малі літери. Існують також інші доступні функції, позначені як [func.xx]. У виразі в рядку 71:
    • [session.query.filter] повертає список об’єктів [Personne];
    • [session.query.filter.first] повертає перший елемент цього списку;
  • рядок 77: видаляється елемент із сесії;
  • рядок 86: сесія підтверджується;

Результати виконання такі:

C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\venv\Scripts\python.exe C:/Data/st-2020/dev/python/cours-2020/python3-flask-2020/databases/sqlalchemy/03/main.py
----------------
----------------
{"âge": 35, "nom": "Nicazou", "prénom": "Pierre", "id": 1}
{"âge": 26, "nom": "Colou", "prénom": "Géraldine", "id": 2}
{"âge": 56, "nom": "Girondé", "prénom": "Paulette", "id": 3}
----------------
{"âge": 35, "nom": "Nicazou", "prénom": "Pierre", "id": 1}
{"âge": 56, "nom": "Girondé", "prénom": "Paulette", "id": 3}
{"âge": 26, "nom": "Colou", "prénom": "Géraldine", "id": 2}
----------------
{"âge": 35, "nom": "Nicazou", "prénom": "Pierre", "id": 1}
{"âge": 26, "nom": "Colou", "prénom": "Géraldine", "id": 2}
----------------
{"prénom": "Josette", "nom": "Bruneau", "âge": 47, "id": 4}
----------------
Travail terminé...

Process finished with exit code 0
  • рядки 4–6: вміст сесії;
  • рядки 8–10: вміст сесії в порядку спадання імен;
  • рядки 12–13: вміст сесії для осіб, вік яких знаходиться в інтервалі [20, 40];
  • рядок 15: особа з іменем «bruneau»;

У phpMyAdmin вміст таблиці [personnes] наприкінці виконання є таким:

Image

19.5. Скрипти 04: використання бази даних [PostgreSQL]

Image

Файл [04] є копією файлу [03]. Змінюється лише одне — рядок підключення у файлі [config]:


    # посилання на базу даних PostgreSQL
    engine = create_engine("postgresql+psycopg2://admpersonnes:nobody@localhost/dbpersonnes")

Відтепер цей рядок підключення вказує на базу даних [dbpersonnes] з SGBD [PostgreSQL]. Зверніть увагу на використання коннектора [psycopg2]. Він має бути встановлений.

Виконання скрипта [main] дає такі результати:

C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\venv\Scripts\python.exe C:/Data/st-2020/dev/python/cours-2020/python3-flask-2020/databases/sqlalchemy/04/main.py
----------------
----------------
{"nom": "Nicazou", "prénom": "Pierre", "id": 1, "âge": 35}
{"nom": "Colou", "prénom": "Géraldine", "id": 2, "âge": 26}
{"nom": "Girondé", "prénom": "Paulette", "id": 3, "âge": 56}
----------------
{"nom": "Nicazou", "prénom": "Pierre", "id": 1, "âge": 35}
{"nom": "Girondé", "prénom": "Paulette", "id": 3, "âge": 56}
{"nom": "Colou", "prénom": "Géraldine", "id": 2, "âge": 26}
----------------
{"nom": "Nicazou", "prénom": "Pierre", "id": 1, "âge": 35}
{"nom": "Colou", "prénom": "Géraldine", "id": 2, "âge": 26}
----------------
{"prénom": "Josette", "nom": "Bruneau", "âge": 47, "id": 4}
----------------
Travail terminé...

Process finished with exit code 0

За допомогою інструменту [pgAdmin] (див. параграф |pgAdmin|) таблиця [personnes] має такий стан:

Image

Таблиця [personnes] була згенерована за допомогою наступного коду SQL:

Image

  • у [4-5] видно, що стовпець [id] є первинним ключем. Також видно, що вона має значення за замовчуванням [mot clé DEFAULT], завдяки чому при вставці рядка без первинного ключа він буде згенерований за допомогою SGBD. Такий підхід є досить поширеним: первинні ключі генеруються системою на основі SGBD;

Ця версія 05 скриптів [sqlalchemy] наочно демонструє, наскільки легко перейти від одного SGBD до іншого: достатньо було змінити рядок підключення в конфігураційному скрипті. Нічого іншого не змінилося. Якщо порівняти типи стовпців [id, nom, prenom, age], наведених вище, з типами стовпців таблиці MySQL із прикладу |02|, то можна побачити, що вони відрізняються. [sqlalchemy] адаптує їх до використовуваного SGBD. Ця можливість адаптації до нового SGBD є достатньою причиною для переходу на [sqlalchemy] або інший ORM.

19.6. Скрипти 05: повний приклад

Image

Розглянутий приклад є повторенням прикладу, розглянутого в розділі |troiscouches-v01|. У цьому прикладі була представлена трирівнева архітектура [ui, métier, dao], яка оперувала сутностями [Classe, Elève, Matière, Note]. Елементи були жорстко закодовані в одному шарі [dao]. Тепер ми розміщуємо їх у базі даних. Ми будемо використовувати два шари SGBD: MySQL та PostgreSQL.

19.6.1. Архітектура додатка

Архітектура додатка буде такою:

Image

  • у [1-3] містяться шари [ui, métier, dao], які вже були присутні у прикладі |troiscouches-v01|. Шар [dao] тепер взаємодіє з шаром [ORM];
  • шари [1-5] реалізовані за допомогою коду Python;

19.6.2. Бази даних

Ми створюємо базу даних MySQL з назвою [dbecole], що належить користувачеві [admecole] з паролем [mdpecole]. Для цього ми дотримуємося процедури, описаної в розділі |створення бази даних|:

Image

Image

  • у [1] — база [dbecole] без таблиць [3];
  • у [7] користувач [admecole] має всі права на цю базу даних;

Те саме робимо з SGBD та PostgreSQL. Ми створюємо базу даних з назвою [dbecole], що належить користувачеві [admecole] з паролем [mdpecole]. Для цього дотримуємося процедури, описаної в розділі |створення бази даних|:

Image

  • у [1] — база [dbecole];
  • [2] — користувач [admecole];
  • в [3-4] база [dbecole] належить користувачеві [admecole];

19.6.3. Об’єкти, з якими працює додаток

У додатку |troiscouches v01| оброблялися такі об’єкти (див. |об’єкти|). Саме ці об’єкти будуть зберігатися у вищезазначених базах даних. Ми не будемо дублювати ці об’єкти в новому додатку. Ми будемо отримувати їх з тих місць, де вони вже визначені.

Клас [Classe]:


# імпорт
from BaseEntity import BaseEntity
from MyException import MyException
from Utils import Utils


class Classe(BaseEntity):
    # атрибути, виключені зі стану класу
    excluded_keys = []

    # властивості класу
    @staticmethod
    def get_allowed_keys() -> list:
        # id: ідентифікатор класу
        # назва: назва класу
        return BaseEntity.get_allowed_keys() + ["nom"]

    # геттер
    @property
    def nom(self: object) -> str:
        return self.__nom

    # сетери
    @nom.setter
    def nom(self: object, nom: str):
        # ім'я має бути непорожнім рядком
        if Utils.is_string_ok(nom):
            self.__nom = nom
        else:
            raise MyException(11, f"Le nom de la classe {self.id} doit être une chaîne de caractères non vide")

Клас [Elève]:


# імпорти
from BaseEntity import BaseEntity
from Classe import Classe
from MyException import MyException

from Utils import Utils


class Elève(BaseEntity):
    # атрибути, виключені зі стану класу
    excluded_keys = []

    # властивості класу
    @staticmethod
    def get_allowed_keys() -> list:
        # id: ідентифікатор учня
        # прізвище: прізвище учня
        # ім'я: ім'я учня
        # клас: клас учня
        return BaseEntity.get_allowed_keys() + ["nom", "prénom", "classe"]

    # гетери
    @property
    def nom(self: object) -> str:
        return self.__nom

    @property
    def prénom(self: object) -> str:
        return self.__prénom

    @property
    def classe(self: object) -> Classe:
        return self.__classe

    # сетери
    @nom.setter
    def nom(self: object, nom: str) -> str:
        # прізвище має бути непорожнім рядком
        if Utils.is_string_ok(nom):
            self.__nom = nom
        else:
            raise MyException(41, f"Le nom de l'élève {self.id} doit être une chaîne de caractères non vide")

    @prénom.setter
    def prénom(self: object, prénom: str) -> str:
        # ім'я має бути непорожнім рядком
        if Utils.is_string_ok(prénom):
            self.__prénom = prénom
        else:
            raise MyException(42, f"Le prénom de l'élève {self.id} doit être une chaîne de caractères non vide")

    @classe.setter
    def classe(self: object, value):
        try:
            # очікується тип «Клас»
            if isinstance(value, Classe):
                self.__classe = value
            # або тип dict
            elif isinstance(value,dict):
                self.__classe=Classe().fromdict(value)
            # або типу json
            elif isinstance(value,str):
                self.__classe = Classe().fromjson(value)
        except BaseException as erreur:
            raise MyException(43, f"L'attribut [{value}] de l'élève {self.id} doit être de type Classe ou dict ou json. Erreur : {erreur}")

Клас [Matière]:


# імпорти
from BaseEntity import BaseEntity
from MyException import MyException
from Utils import Utils


class Matière(BaseEntity):
    # атрибути, виключені зі стану класу
    excluded_keys = []

    # властивості класу
    @staticmethod
    def get_allowed_keys() -> list:
        # id: ідентифікатор предмета
        # назва: назва предмета
        # коефіцієнт: коефіцієнт дисципліни
        return BaseEntity.get_allowed_keys() + ["nom", "coefficient"]

    # геттер
    @property
    def nom(self: object) -> str:
        return self.__nom

    @property
    def coefficient(self: object) -> float:
        return self.__coefficient

    # сетери
    @nom.setter
    def nom(self: object, nom: str):
        # назва має бути непорожнім рядком
        if Utils.is_string_ok(nom):
            self.__nom = nom
        else:
            raise MyException(21, f"Le nom de la matière {self.id} doit être une chaîne de caractères non vide")

    @coefficient.setter
    def coefficient(self, coefficient: float):
        # коефіцієнт повинен бути дійсним числом >=0
        erreur = False
        if isinstance(coefficient, (int, float)):
            if coefficient >= 0:
                self.__coefficient = coefficient
            else:
                erreur = True
        else:
            erreur = True
        # помилка?
        if erreur:
            raise MyException(22, f"Le coefficient de la matière {self.nom} doit être un réel >=0")

Клас [Note]:


# імпорти
from BaseEntity import BaseEntity
from Elève import Elève
from Matière import Matière
from MyException import MyException


class Note(BaseEntity):
    # атрибути, виключені зі стану класу
    excluded_keys = []

    # властивості класу
    @staticmethod
    def get_allowed_keys() -> list:
        # id: ідентифікатор оцінки
        # значення: сама оцінка
        # учень: учень (типу «Учень»), якого стосується оцінка
        # предмет: предмет (типу «Предмет»), до якого відноситься оцінка
        # отже, об’єкт «Оцінка» — це оцінка учня з певного предмета
        return BaseEntity.get_allowed_keys() + ["valeur", "élève", "matière"]

    # гетери
    @property
    def valeur(self: object) -> float:
        return self.__valeur

    @property
    def élève(self: object) -> Elève:
        return self.__élève

    @property
    def matière(self: object) -> Matière:
        return self.__matière

    # геттери
    @valeur.setter
    def valeur(self: object, valeur: float):
        # оцінка має бути дійсним числом у діапазоні від 0 до 20
        if isinstance(valeur, (int, float)) and 0 <= valeur <= 20:
            self.__valeur = valeur
        else:
            raise MyException(31,
                f"L'attribut {valeur} de la note {self.id} doit être un nombre dans l'intervalle [0,20]")

    @élève.setter
    def élève(self: object, value):
        try:
            # очікується тип «Учень»
            if isinstance(value, Elève):
                self.__élève = value
            # або типу dict
            elif isinstance(value, dict):
                self.__élève = Elève().fromdict(value)
            # або типу json
            elif isinstance(value, str):
                self.__élève = Elève().fromjson(value)
        except BaseException as erreur:
            raise MyException(32,
                f"L'attribut [{value}] de la note {self.id} doit être de type Elève ou dict ou json. Erreur : {erreur}")

    @matière.setter
    def matière(self: object, value):
        try:
            # очікується тип «Предмет»
            if isinstance(value, Matière):
                self.__matière = value
            # або тип dict
            elif isinstance(value, dict):
                self.__matière = Matière().fromdict(value)
            # або типу json
            elif isinstance(value, str):
                self.__matière = Matière().fromjson(value)
        except BaseException as erreur:
            raise MyException(33,
                f"L'attribut [{value}] de la note {self.id} doit être de type Matière ou dict ou json. Erreur : {erreur}")

19.6.4. Конфігурація

Image

Конфігурація була розділена на кілька файлів:

  • загальна конфігурація у файлі [config.py]: вона встановлює Python Path додатка та створює екземпляри рівнів архітектури;
  • конфігурація [sqlalchemy] у файлі [config_database]: вона здійснює зіставлення класів та таблиць;
  • шари додатка налаштовуються у файлі [config_layers];

Файл [config] має такий вигляд:


def configure(config: dict) -> dict:
    import os

    # крок 1 ---
    # встановлюємо Python Path для додатка
    # абсолютний шлях до папки цього скрипта
    script_dir = os.path.dirname(os.path.abspath(__file__))

    # абсолютний шлях, на який посилаються відносні шляхи конфігурації
    root_dir = "C:/Data/st-2020/dev/python/cours-2020/python3-flask-2020"

    # абсолютні шляхи до залежностей
    absolute_dependencies = [
        # BaseEntity, MyException
        f"{root_dir}/classes/02/entities",
        # трирівневий проєкт v01
        f"{root_dir}/troiscouches/v01/interfaces",
        f"{root_dir}/troiscouches/v01/services",
        f"{root_dir}/troiscouches/v01/entities",
        # матеріали цього проєкту
        script_dir,
        f"{script_dir}/../services",
    ]

    # оновлення syspath
    from myutils import set_syspath
    set_syspath(absolute_dependencies)

    # етап 2 ------
    # налаштування бази даних
    import config_database
    config = config_database.configure(config)

    # етап 3 ------
    # інстанціювання рівнів додатка
    import config_layers
    config = config_layers.configure(config)

    # застосовуємо конфігурацію
    return config
  • рядки 4–27: побудова Python Path додатка;
  • рядки 29–32: налаштування [sqlalchemy];
  • рядки 34–37: налаштування шарів додатка;

Файл [config_database] має такий вигляд:


def configure(config: dict) -> dict:
    # config['sgbd'] — це ім'я використовуваного SGBD
    # MySQL: MySQL
    # pgres: PostgreSQL

    # конфігурація SQLAlchemy
    from sqlalchemy import Table, Column, Integer, MetaData, String, Float, ForeignKey, create_engine

    from sqlalchemy.orm import mapper, relationship, sessionmaker

    # ланцюги підключення до використовуваних баз даних
    engines = {
        'mysql': "mysql+mysqlconnector://admecole:mdpecole@localhost/dbecole",
        'pgres': "postgresql+psycopg2://admecole:mdpecole@localhost/dbecole"
    }
    # рядок підключення до використовуваної бази даних
    engine = create_engine(engines[config['sgbd']])

    # метадані
    metadata = MetaData()

    # таблиці бази даних
    tables = {}
    # відображені класи
    from Classe import Classe
    from Elève import Elève
    from Note import Note
    from Matière import Matière

    # таблиця класів
    tables['classes'] = classes_table = \
        Table("classes", metadata,
              Column('id', Integer, primary_key=True),
              Column('nom', String(30), nullable=False),
              )

    mapper(Classe, tables['classes'], properties={
        'id': classes_table.c.id,
        'назва: classes_table.c.nom
    })

    # таблиця учнів
    tables['élèves'] = élèves_table = \
        Table("élèves", metadata,
              Column('id', Integer, primary_key=True),
              Column('nom', String(30), nullable=False),
              Column('prénom', String(30), nullable=False),
              # учень належить до класу
              Column('classe_id', Integer, ForeignKey('classes.id')),
              )
    # відповідність
    mapper(Elève, tables['élèves'], properties={
        'id': élèves_table.c.id,
        'прізвище: élèves_table.c.nom,
        'ім'я': élèves_table.c.prénom,
        'клас: relationship(Клас, backref="учні", lazy="select")
    })

    # зміст
    tables['matières'] = matières_table = \
        Table("matières", metadata,
              Column('id', Integer, primary_key=True),
              Column('nom', String(30), nullable=False),
              Column('coefficient', Float, nullable=False)
              )
    # маппінг
    mapper(Matière, tables['matières'], properties={
        'id': matières_table.c.id,
        'назва: matières_table.c.nom,
        "coefficient": matières_table.c.coefficient
    })

    # таблиця оцінок
    tables['notes'] = notes_table = \
        Table("notes", metadata,
              Column('id', Integer, primary_key=True),
              Column('valeur', Float, nullable=False),
              # оцінка — це оцінка учня
              Column('élève_id', Integer, ForeignKey('élèves.id')),
              # оцінка з певного предмета
              Column('matière_id', Integer, ForeignKey('matières.id')),
              )

    # відповідність
    mapper(Note, tables['notes'], properties={
        'id': notes_table.c.id,
        'значення': notes_table.c.valeur,
        'учень': relationship(Учень, backref="notes", lazy="select"),
        'предмет': relationship(Предмет, backref="оцінки", lazy="select")
    })

    # конфігурація сутностей [BaseEntity]
    Elève.excluded_keys = ['_sa_instance_state', 'notes', 'classe']
    Classe.excluded_keys = ['_sa_instance_state', 'élèves']
    Matière.excluded_keys = ['_sa_instance_state', 'notes']
    Note.excluded_keys = ['_sa_instance_state', 'matière', 'élève']

    # фабрика сеансів
    Session = sessionmaker()
    Session.configure(bind=engine)

    # сесія
    session = Session()

    # запис певної інформації у словник конфігурації
    config['database'] = {"engine": engine, "metadata": metadata, "tables": tables, "session": session}

    # повертається конфігурація
    return config

Коментарі

  • рядки 1–4: функція [configure] отримує словник як параметр. Використовується лише ключ [sgbd]. Його значенням є [mysql], якщо базою є база MySQL, або [pgres], якщо базою є база PostgreSQL;
  • рядки 6–9: імпорт елементів із [sqlalchemy]. Скрипт [config_database] здійснює зіставлення між таблицями бази даних [dbecole] та сутностями [Classes, Elève, Matière, Note]. У таблиці дані сутності інкапсульовані в один рядок. У коді Python вони інкапсульовані в об’єкт. Звідси й походить назва ORM (Object Relational Mapper): ORM здійснює зіставлення (зв’язок) між рядками реляційної бази даних та об’єктами. У цьому додатку ми маємо чотири сутності [Classe, Elève, Matière, Note], які будуть пов’язані з чотирма таблицями [classes, élèves, matières, notes]. Зверніть увагу, що назви таблиць можуть містити символи з діакритичними знаками;
  • рядки 11–17: рядок підключення до використовуваної бази даних. Він залежить від елемента config[‘sgbd’];
  • рядки 24–28: сутності додатка, які будуть підлягати маппінгу [sqlalchemy]. На момент виконання цих рядків шлях Python Path вже буде встановлено скриптом [config];
  • рядки 30–40: зіставлення між сутністю [Classe] та таблицею [classes];
  • рядки 30–35: таблиця [classes] визначається за допомогою класу [Table] з [sqlalchemy]. Вказуємо, що ця таблиця має два стовпці:
    • стовпець [id], який є первинним ключем і містить номер класу, рядок 33;
    • стовпець [nom], що містить назву класу, рядок 34;
  • рядки 31–32: зверніть увагу, що синтаксис x=y=z є допустимим у Python: значення z присвоюється y, а потім значення y — x;
  • рядки 37–40: наводиться перелік відповідностей між стовпцями таблиці [classes] та властивостями сутності [Classe];
  • рядки 42–57: відповідність між сутністю [Elève] та таблицею [élèves];
  • рядки 51–57: таблиця [élèves] визначена за допомогою класу [Table] з [sqlalchemy]. Вказуємо, що ця таблиця має чотири стовпці:
    • стовпець [id], який є первинним ключем і містить номер учня, рядок 45;
    • стовпець [nom], що містить прізвище учня, рядок 46;
    • стовпець [prénom], що містить ім’я учня, рядок 47. Зверніть увагу, що назва стовпця може містити символи з діакритичними знаками;
    • рядок 49, стовпець [classe_id], який міститиме номер класу, до якого належить учень. Це називається зовнішнім ключем. [élèves.classe_id] є зовнішнім ключем (ForeignKey) для стовпця [classes.id]. Це означає, що значення [élèves.classe_id] має існувати у стовпці [classes.id];
  • рядки 51–57: наведено відповідності між стовпцями таблиці [élèves] та властивостями сутності [Elève]:
    • рядки 53–55 зрозумілі;
    • рядок 56 є складнішим: він визначає значення властивості [Elève.classe] як таке, що обчислюється за допомогою відношення (relationship) зовнішнього ключа, яке пов’язує таблиці [élèves] та [classes]. Параметри функції [relationship] такі:
      • [Classe] — це назва сутності, з якою сутність [Elève] має зв’язок через зовнішній ключ. Цей зв’язок має бути реалізований у таблиці [élèves] шляхом наявності зовнішнього ключа з таблиці [classes]. Ми знаємо, що такий ключ існує;
      • [backref="élèves"] — ім’я властивості, яка буде додана до сутності [Classe]. [Classe.élèves] буде списком усіх учнів класу. Ця властивість не повинна вже існувати. Якщо вона вже існує, тут просто потрібно вибрати іншу назву для [backref]. Розробник не повинен керувати цим атрибутом. Це зробить [sqlalchemy]. Він повинен лише знати, що цей атрибут існує, доданий [sqlalchemy], і що він може використовувати його у своєму коді;
      • [lazy=’select’]: це означає, що ORM не повинен намагатися відразу присвоїти значення властивості [Elève.classe]. Він повинен шукати її значення лише тоді, коли код явно цього вимагає. Отже:
      • якщо код запитує список усіх учнів, їхні дані будуть повернуті, але їхня властивість [classe] не буде обчислена;
      • трохи пізніше код звертається до конкретного учня [e] і посилається на його клас [e.classe]. Ця посилання змусить [sqlalchemy] виконати запит до бази даних, щоб отримати клас учня, і це відбуватиметься прозоро для розробника;
      • також додавання [lazy=’select’] має на меті уникнути зайвих запитів до бази даних;
  • рядок 56: коли ORM отримує рядок із таблиці [élèves], він отримує інформацію [id, nom, prénom, classe_id]. На основі цього він повинен побудувати об’єкт «Учень» (id, прізвище, ім’я, клас). Щодо властивостей [id, nom, prénom], тут немає труднощів. Щодо властивості [classe], то тут все складніше. Її значенням є посилання на об’єкт типу [Classe]. Однак ORM містить лише інформацію [élèves.classe_id]. Оскільки [élèves.classe_id] є зовнішнім ключем у стовпці [classes.id], тут вказується, що слід використати цю залежність, щоб отримати з таблиці [classes] рядок з ідентифікатором id=[élèves.classe_id] (вона обов’язково існує) та створити на основі цього рядка об’єкт [Classe], який очікується властивістю [Elève.classe];
  • рядки 59–71: відповідність між сутністю [Matière] та таблицею [matières];
  • рядки 59–65: визначення таблиці [sqlalchemy], названої [matières];
  • рядки 66–71: наведено відповідності між стовпцями таблиці [matières] та властивостями сутності [Matière]. Тут немає жодних складнощів;
  • рядки 73–90: відповідність між сутністю [Note] та таблицею [notes];
  • рядки 73–82: визначення таблиці [sqlalchemy], названої [notes]. Вона має два зовнішні ключі:
    • у рядку 79 стовпець [notes.élève_id] отримує свої значення зі стовпця [élèves.id]]. Цей зовнішній ключ відображає той факт, що оцінка належить конкретному учневі;
    • рядок 81, стовпець [notes.matière_id] отримує свої значення зі стовпця [matières.id]. Цей зовнішній ключ відображає той факт, що оцінка є оцінкою з конкретного предмета;
  • рядки 84–90: відповідність між сутністю [Note] та таблицею [notes]:
    • рядок 88: властивість [Note.élève] повинна мати значення екземпляра типу [Elève]. ORM містить у рядку таблиці [notes] лише стовпець [notes.élève_id], який посилається на стовпець [élèves.id]. Тут йдеться про те, щоб використати цю залежність зовнішнього ключа для пошуку екземпляра [Elève], оцінка якого відома. Крім того, [relationship(Elève, backref="notes", …)] створить нову властивість [Elève.notes], яка буде списком оцінок учня. Ця властивість не повинна вже існувати у класі [Elève];
    • рядок 89: властивість [Note.matière] повинна мати значення екземпляра типу [Matière]. ORM містить у рядку таблиці [notes] лише стовпець [notes.matière_id], який посилається на стовпець [matières.id]. Тут йдеться про те, щоб використати цей зв’язок зовнішнього ключа для пошуку екземпляра [Matière], для якого є оцінка. Крім того, [relationship(Matière, backref="notes", …)] створить нову властивість [Matière.notes], яка буде списком оцінок з даного предмета. Ця властивість не повинна вже існувати в класі [Matière];
  • рядки 92–96: для кожної сутності, похідної від [BaseEntity], визначається список властивостей, які слід виключити зі словника властивостей сутності (BaseEntity.asdict). Ми бачили, що [sqlalchemy] додавав властивість [_sa_instance_state] до всіх об’єктів, що підлягають відображенню. Ми не хочемо, щоб вона була у словнику властивостей. Крім того, ми бачили, що попередні відображення додавали нові властивості до об’єктів:
    • [Elève.notes]: усі оцінки учня;
    • [Classe.élèves]: усі учні класу;
    • [Matière.notes]: усі оцінки з предмета;

Як правило, ми не хочемо, щоб ці властивості додавалися до стану об’єкта. Адже обчислення їхнього значення має певну вартість SQL, і це значення часто є непотрібним. Отже, якщо ми отримуємо учня на ім’я «X»:

  • (продовження)
    • ORM поверне суть [Elève(id, nom, prénom, classe, notes)]. Через [lazy=’select’] властивості [classe, notes], пов’язані з чужими ключами бази даних, не будуть обчислені;
    • тепер, якщо я відображу ланцюжок jSON цього учня, ми знаємо, що це буде ланцюжок jSON зі словника [asdict] цього об’єкта. Якщо властивості [classe] та [notes] містяться в ньому, [sqlalchemy] буде змушений виконувати запити до SQL для обчислення їхніх значень. Це дорого. Якщо можна уникнути цих запитів, це краще;
    • тут ми виключили всі властивості, пов’язані з чужорідним ключем;
  • рядки 98–100: інстанціювання та налаштування [Session factory] (factory = фабрика). Об’єкт [Session] слугує для створення сесій [sqlalchemy], пов’язаних із транзакціями;
  • рядки 102–103: створення сесії sqlalchemy];
  • рядок 106: деякі елементи конфігурації [sqlalchemy] додаються до глобального словника конфігурації додатка;
  • рядок 109: цей словник повертається;

Файл [config_layers] налаштовує рівні додатка:


def configure(config: dict) -> dict:
    # інстанціювання шару [dao]
    from DatabaseDao import DatabaseDao
    dao = DatabaseDao(config)

    # інстанціювання шару [métier]
    from Métier import Métier
    métier = Métier(dao)

    # інстанціювання шару [ui]
    from Console import Console
    ui = Console(métier)

    # додаємо шари до конфігурації
    config['dao'] = dao
    config['métier'] = métier
    config['ui'] = ui

    # виконується конфігурація
    return config
  • рядок 1: функція [configure] отримує словник загальної конфігурації додатка;
  • рядки 2–12: створюються екземпляри шарів додатка;
  • рядки 15–17: посилання на шари додаються до загальної конфігурації;
  • рядок 20: повертається нова конфігурація;

19.6.5. Шар [dao] — 1

Image

Тут слід розуміти, що шар [dao] [3] взаємодіє зORM [sqlalchemy] [4], налаштованим так, як описано в попередньому абзаці. З трьох шарів [ui, métier, dao] додатка |troiscouches v01| переписати потрібно лише шар [dao]. Шари [ui, métier] зберігаються.

Реалізація шару [dao] була розміщена в папці [services]:

Image

[InterfaceDatabaseDao] є інтерфейсом шару [dao]:


from abc import ABC, abstractmethod

from InterfaceDao import InterfaceDao


class InterfaceDatabaseDao(InterfaceDao, ABC):

    # ініціалізація бази даних
    @abstractmethod
    def init_database(self, data: dict):
        pass
  • рядок 6: інтерфейс [InterfaceDatabaseDao] походить як від класу [ABC] (як абстрактний клас), так і від інтерфейсу [InterfaceDao] з проєкту |troiscouches v01|;
  • рядки 8–11: до методів, успадкованих від [InterfaceDao], додається метод [init_database]. Його завданням буде ініціалізація бази даних за допомогою даних зі словника [data], які передаються йому як параметр у рядку 10;

Нагадаємо, що інтерфейс [InterfaceDao] мав такий вигляд:


# імпорт даних
from abc import ABC, abstractmethod

# інтерфейс DAO
from Elève import Elève


class InterfaceDao(ABC):
    # список класів
    @abstractmethod
    def get_classes(self: object) -> list:
        pass

    # список учнів
    @abstractmethod
    def get_élèves(self: object) -> list:
        pass

    # список предметів
    @abstractmethod
    def get_matières(self: object) -> list:
        pass

    # список оцінок
    @abstractmethod
    def get_notes(self: object) -> list:
        pass

    # список оцінок учня
    @abstractmethod
    def get_notes_for_élève_by_id(self: object, élève_id: int) -> list:
        pass

    # пошук учня за його ідентифікатором
    @abstractmethod
    def get_élève_by_id(self: object, élève_id: int) -> Elève:
        pass

Реалізація шару [dao] виглядає так:


from sqlalchemy.exc import DatabaseError, IntegrityError, InterfaceError

from Classe import Classe
from Elève import Elève
from InterfaceDatabaseDao import InterfaceDatabaseDao
from Matière import Matière
from MyException import MyException
from Note import Note


class DatabaseDao(InterfaceDatabaseDao):

    def __init__(self, config: dict):
        # database = {"engine": engine, "metadata": metadata, "tables": tables, "session": session}
        self.database = config['database']
        self.session = self.database['session']

    def init_database(self, data: dict):
        

  • рядок 11: клас [DatabaseDao] реалізує інтерфейс [InterfaceDatabaseDao];
  • рядки 13–16: конструктор класу. Він отримує як параметр словник конфігурації додатка;
  • рядок 15: зберігається конфігурація [sqlalchemy];
  • рядок 16: зберігається сесія [sqlalchemy], за допомогою якої буде здійснюватися робота з базою даних;
  • рядок 18: метод [init_database] ініціалізує базу даних за допомогою словника [data];

Словник [data] реалізовано за допомогою наступного скрипта [data.py]:


def configure():
    from Classe import Classe
    from Elève import Elève
    from Matière import Matière
    from Note import Note

    # ініціалізуємо класи
    classe1 = Classe().fromdict({"id": 1, "nom": "classe1"})
    classe2 = Classe().fromdict({"id": 2, "nom": "classe2"})
    classes = [classe1, classe2]
    # предмети
    matière1 = Matière().fromdict({"id": 1, "nom": "matière1", "coefficient": 1})
    matière2 = Matière().fromdict({"id": 2, "nom": "matière2", "coefficient": 2})
    matières = [matière1, matière2]
    # учні
    élève11 = Elève().fromdict({"id": 11, "nom": "nom1", "prénom": "prénom1", "classe": classe1})
    élève21 = Elève().fromdict({"id": 21, "nom": "nom2", "prénom": "prénom2", "classe": classe1})
    élève32 = Elève().fromdict({"id": 32, "nom": "nom3", "prénom": "prénom3", "classe": classe2})
    élève42 = Elève().fromdict({"id": 42, "nom": "nom4", "prénom": "prénom4", "classe": classe2})
    élèves = [élève11, élève21, élève32, élève42]
    # оцінки учнів з різних предметів
    note1 = Note().fromdict({"id": 1, "valeur": 10, "élève": élève11, "matière": matière1})
    note2 = Note().fromdict({"id": 2, "valeur": 12, "élève": élève21, "matière": matière1})
    note3 = Note().fromdict({"id": 3, "valeur": 14, "élève": élève32, "matière": matière1})
    note4 = Note().fromdict({"id": 4, "valeur": 16, "élève": élève42, "matière": matière1})
    note5 = Note().fromdict({"id": 5, "valeur": 6, "élève": élève11, "matière": matière2})
    note6 = Note().fromdict({"id": 6, "valeur": 8, "élève": élève21, "matière": matière2})
    note7 = Note().fromdict({"id": 7, "valeur": 10, "élève": élève32, "matière": matière2})
    note8 = Note().fromdict({"id": 8, "valeur": 12, "élève": élève42, "matière": matière2})
    notes = [note1, note2, note3, note4, note5, note6, note7, note8]
    # об'єднуємо сукупність
    data = {"élèves": élèves, "classes": classes, "matières": matières, "notes": notes}
    # виводимо дані
    return data
  • рядок 34: словник, який буде передано методу [init_database]. Цей словник складається з таких ключів (рядок 32):
    • [élèves]: список учнів;
    • [classes]: список класів;
    • [matières]: список предметів;
    • [notes]: список оцінок усіх учнів з усіх предметів;

Повернемося до методу [init_database]:


def init_database(self, data: dict):
        # конфігурація БД
        database = self.database
        engine = database['engine']
        metadata = database['metadata']
        tables = database['tables']

        try:
            # видалення існуючих таблиць
            # checkfirst=True: спочатку перевіряє, чи існує таблиця
            tables["notes"].drop(engine, checkfirst=True)
            tables["matières"].drop(engine, checkfirst=True)
            tables["élèves"].drop(engine, checkfirst=True)
            tables["classes"].drop(engine, checkfirst=True)

            # відновлення таблиць на основі маппінгу
            metadata.create_all(engine)

            # заповнення таблиць
            session = self.session

            # класи
            classes = data["classes"]
            for classe in classes:
                session.add(classe)

            # предмети
            matières = data["matières"]
            for matière in matières:
                session.add(matière)

            # учні
            élèves = data["élèves"]
            for élève in élèves:
                session.add(élève)

            # оцінки
            notes = data["notes"]
            for note in notes:
                session.add(note)

            # фіксація
            session.commit()
        except (DatabaseError, InterfaceError, IntegrityError) as erreur:
            # скасування сесії
            if session:
                session.rollback()
            # видається виняток
            raise MyException(23, f"{erreur}")
  • рядки 3–6: отримуємо інформацію з конфігурації бази даних;
  • рядки 9–14: ми бачили, що конфігурація [sqlalchemy] зіставила чотири сутності з чотирма таблицями [élèves, matières, classes, notes]. Спочатку видаляємо ці таблиці, якщо вони існують;
  • рядки 16–17: відтворюємо чотири таблиці, які щойно видалили;
  • рядки 22–25: додаємо всі класи до сесії;
  • рядки 27–30: додаємо всі предмети до сесії;
  • рядки 32–35: додаємо всіх учнів до сесії;
  • рядки 37–40: додаємо всі оцінки до сесії;
  • щоб виконати ці додавання, ми дотримувалися певного порядку. Ми почали з об’єктів, що не мають зв’язків з іншими об’єктами, а закінчили тими, що мають такі зв’язки. Таким чином, коли ми додаємо учнів до сесії, класи, до яких вони належать, вже знаходяться в сесії;
  • рядок 43: сесія [sqlalchemy] підтверджена. Після цієї операції ми маємо впевненість, що всі дані сесії були синхронізовані з базою даних. Іншими словами, вони потрапили до таблиць. Це стало можливим завдяки відповідностям, які були налаштовані в конфігурації [sqlalchemy]. [sqlalchemy] знає, як кожна суть має зберігатися в таблицях. [sqlalchemy] також згенерував зовнішні ключі, які можуть міститися в таблицях;
  • рядки 44–49: якщо виникає проблема, сесія [sqlalchemy] скасовується, а в рядку 49 генерується виняток;

19.6.6. Ініціалізація бази даних

Image

Скрипт [main_init_database] ініціалізує базу даних за допомогою вмісту скрипта [data.py]. Його код такий:


# очікується параметр mysql або pgres
import sys

syntaxe = f"{sys.argv[0]} mysql / pgres"
erreur = len(sys.argv) != 2
if not erreur:
    sgbd = sys.argv[1].lower()
    erreur = sgbd != "mysql" and sgbd != "pgres"
if erreur:
    print(f"syntaxe : {syntaxe}")
    sys.exit()

# налаштування програми
import config
config = config.configure({'sgbd': sgbd})

# syspath налаштовано — можна виконувати імпорт
from MyException import MyException

# отримуємо дані для завантаження в базу
import data
data = data.configure()

# завантажується рівень [dao]
dao = config["dao"]

# ----------- головна
try:
    # створення та ініціалізація таблиць бази даних
    dao.init_database(data)
except MyException as ex:
    # виводиться повідомлення про помилку
    print(f"L'erreur suivante s'est produite : {ex}")
finally:
    # звільнення ресурсів, задіяних додатком
    import shutdown
    shutdown.execute(config)
# кінець
print("Travail terminé...")
  • рядки 1–11: скрипт очікує на параметр [mysql] або [pgres] залежно від того, чи потрібно ініціалізувати базу MySQL чи PostgreSQL;
  • рядки 13–15: додаток налаштовано на параметр SGBD, переданий у якості параметра;
  • рядки 20–22: отримуються дані, які потрібно внести до бази;
  • рядок 25: шар [dao] вже створено та доступний у конфігурації програми;
  • рядок 30: база даних ініціалізована;
  • рядки 34–37: незалежно від того, чи сталася помилка, ресурси додатка звільняються за допомогою модуля [shutdown];

Модуль [shutdown.py] має такий вигляд:


def execute(config: dict):
    # звільнення ресурсів, задіяних додатком
    sqlalchemy_session = config['database']['session']
    if sqlalchemy_session:
        sqlalchemy_session.close()

Функція [shutdown.execute] закриває сесію [sqlalchemy], яка використовувалася для ініціалізації бази даних.

Ми створюємо першу конфігурацію виконання (див. |конфігурація виконання|) для виконання [main_init_database] разом із SGBD та MySQL:

Image

Результати виконання цієї конфігурації у phpMyAdmin такі:

Image

Image

Image

Для SGBD та [PostgreSQL] ми використовуємо таку конфігурацію виконання:

Image

Під час виконання результати у файлі [pgAdmin] є такими:

Image

Image

Image

Варто відзначити, з якою легкістю вдалося змінити SGBD.

19.6.7. Рівень [dao] – 2

Повернемося до класу [DatabaseDao], який реалізує рівень [dao]. Поки що ми показали лише реалізацію методу [init_database]. Тепер покажемо реалізацію інших методів:


from sqlalchemy.exc import DatabaseError, IntegrityError, InterfaceError

from Classe import Classe
from Elève import Elève
from InterfaceDatabaseDao import InterfaceDatabaseDao
from Matière import Matière
from MyException import MyException
from Note import Note


class DatabaseDao(InterfaceDatabaseDao):

    def __init__(self, config: dict):
        # database = {"engine": engine, "metadata": metadata, "tables": tables, "session": session}
        self.database = config['database']
        self.session = self.database['session']

    def init_database(self, data: dict):
        

    # список усіх класів
    def get_classes(self: object) -> list:
        # запит
        return self.session.query(Classe).all()

    # список усіх учнів
    def get_élèves(self: object) -> list:
        # запит
        return self.session.query(Elève).all()

    # список усіх предметів
    def get_matières(self: object) -> list:
        # запит
        return self.session.query(Matière).all()

    # список оцінок усіх учнів
    def get_notes(self: object) -> list:
        # запит
        return self.session.query(Note).all()

    # перелік оцінок конкретного учня
    def get_notes_for_élève_by_id(self: object, élève_id: int) -> list:
        # пошук учня — якщо його немає, генерується виняток
        # дозволяється повернути результат
        élève = self.get_élève_by_id(élève_id)
        # отримуємо його оцінки (відкладене завантаження)
        notes = élève.notes
        # повертається словник
        return {"élève": élève, "notes": notes}

    # учень, визначений за номером
    def get_élève_by_id(self, élève_id: int) -> Elève:
        # шукаємо учня
        élèves = self.session.query(Elève).filter(Elève.id == élève_id).all()
        # чи знайшли?
        if élèves:
            return élèves[0]
        else:
            raise MyException(11, f"L'élève d'identifiant {élève_id} n'existe pas")

    # учень, якого можна знайти за ім’ям
    def get_élève_by_name(self, élève_name: str) -> Elève:
        # шукаємо учня
        élèves = self.session.query(Elève).filter(Elève.nom == élève_name).all()
        # чи знайшли?
        if élèves:
            return élèves[0]
        else:
            raise MyException(12, f"L'élève de nom {élève_name} n'existe pas")

    # клас, визначений за номером
    def get_classe_by_id(self, classe_id: int) -> Classe:
        # шукаємо клас
        classes = self.session.query(Classe).filter(Classe.id == classe_id).all()
        # знайдено?
        if classes:
            return classes[0]
        else:
            raise MyException(13, f"La classe d'identifiant {classe_id} n'existe pas")

    # клас, позначений за назвою
    def get_classe_by_name(self, classe_name: str) -> Classe:
        # шукаємо клас
        classes = self.session.query(Classe).filter(Classe.nom == classe_name).all()
        # чи знайшли ми його?
        if classes:
            return classes[0]
        else:
            raise MyException(14, f"La classe de nom {classe_name} n'existe pas")

    # предмет, визначений за номером
    def get_matière_by_id(self, matière_id: int) -> Matière:
        # шукаємо предмет
        matières = self.session.query(Matière).filter(Matière.id == matière_id).all()
        # знайдено?
        if matières:
            return matières[0]
        else:
            raise MyException(11, f"La matière d'identifiant {matière_id} n'existe pas")

    # матеріал, позначений за назвою
    def get_matière_by_name(self, matière_name: str) -> Matière:
        # шукаємо речовину
        matières = self.session.query(Matière).filter(Matière.nom == matière_name).all()
        # чи знайшли ми її?
        if matières:
            return matières[0]
        else:
            raise MyException(15, f"La matière de nom {matière_name} n'existe pas")
  • рядки 21–24: метод [get_classes] повинен повернути список класів школи. У рядку 20 ми використовуємо запит, з яким ми вже зустрічалися;
  • рядки 26–39: ще три подібні методи для отримання списків учнів, предметів та оцінок;
  • рядки 51–59: метод [get_élève_by_id] повинен повернути учня, ідентифікованого за його номером. Він генерує виняток, якщо такого учня не існує;
    • рядок 54: використовується фільтрований запит. Отримується порожній список або список з одним елементом;
    • рядок 57: якщо отриманий список не порожній, повертається перший елемент списку;
    • інакше, у рядку 59, генерується виняток;
  • рядки 41–49: метод [get_notes_for_élève_by_id] повинен повернути оцінки учня, ідентифікованого за номером:
    • рядок 45: використовується метод [get_élève_by_id] для отримання сутності «Учень» цього учня;
    • у рядку 47 використовується властивість [Elève.notes], створена за допомогою маппінгу між сутністю [Note] та таблицею [notes] (див. розділ |конфігурація SQLAlchemy|), яка представляє оцінки учня;
    • рядок 49: повертається словник;
  • рядки 61–109: низка аналогічних методів, що дозволяють:
    • знайти учня за іменем, рядки 61–69;
    • знайти клас, рядки 71–89;
    • знайти предмет, рядки 91–109;

19.6.8. Скрипт [main_joined_queries]

Image

Скрипт [main_joined_queries] має таку назву, оскільки його метою є висвітлення запитів, які неявним чином виконує [sqlalchemy] для отримання інформації з декількох таблиць. Ці запити, приховані від програміста, виконуються щоразу, коли властивість сутності пов’язується з функцією [relationship] у маппінгу сутності. Наприклад:


    # картування
    mapper(Note, tables['notes'], properties={
        'id': notes_table.c.id,
        'значення: notes_table.c.valeur,
        'учень': relationship(Учень, backref="notes", lazy="select"),
        'предмет': relationship(Предмет, backref="оцінки", lazy="select")
    })

Вище наведено зіставлення між сутністю [Note] та таблицею [notes]:

  • у рядку 5, коли властивість [élève] сутності [Note] запитується вперше, її шукатимуть у таблиці [élèves] за допомогою запиту SQL. Доки ця властивість не була запитана, вона залишається невизначеною (lazy load). Після її отримання її значення зберігається в пам’яті ORM. Коли на неї буде зроблено посилання вдруге, ORM негайно надасть її значення, не звертаючись до нового запиту SQL. Все це є прозорим для розробника;
  • те саме стосується зворотної властивості [Elève.notes] (backref), рядок 5;
  • те саме стосується властивості [Note.matière] та її зворотної властивості [Matière.notes] (backref), рядок 6;

Скрипт [main_joined_queries] має такий вигляд:


# очікується параметр mysql або pgres
import sys

syntaxe = f"{sys.argv[0]} mysql / pgres"
erreur = len(sys.argv) != 2
if not erreur:
    sgbd = sys.argv[1].lower()
    erreur = sgbd != "mysql" and sgbd != "pgres"
if erreur:
    print(f"syntaxe : {syntaxe}")
    sys.exit()

# налагоджуємо додаток
import config
config = config.configure({"sgbd": sgbd})

# syspath налаштовано — можна виконувати імпорт
from MyException import MyException

# шар [dao]
dao = config["dao"]
try:
    # учень за ідентифікатором
    print("élève id=11 -----------")
    élève = dao.get_élève_by_id(11)
    print(f"élève={élève}")
    # клас учня (відкладене завантаження)
    classe = élève.classe
    print(f"classe de l'élève : {classe}")
    # учні одного класу (відкладене завантаження)
    print("élèves dans la même classe :")
    for élève in classe.élèves:
        print(f"élève={élève}")

    # учень за іменем
    print("élève nom='nom2' -----------")
    print(f"élève={dao.get_élève_by_name('nom2')}")
    # його клас (відкладене завантаження)
    print(f"classe de l'élève : {élève.classe}")

    # оцінки учня
    print("notes de l'élève id=11 -----------")
    # спочатку учень
    élève = dao.get_élève_by_id(11)
    # потім його оцінки (відкладене завантаження)
    for note in élève.notes:
        # оцінка
        print(f"note={note}, "
              # предмет, з якого виставлено оцінку (lazy loading)
              f"matière={note.matière}")

    # учні класу
    print("élèves de la classe nom='classe1' -----------")
    # спочатку клас
    classe = dao.get_classe_by_name('classe1')
    # потім учні (відкладене завантаження)
    for élève in classe.élèves:
        print(élève)

    # те саме для [classe2]
    print("élèves de la classe de nom 'classe2' -----------")
    classe = dao.get_classe_by_name('classe2')
    for élève in classe.élèves:
        print(élève)

    # оцінки з предмета
    print("matière de nom='matière1' -----------")
    # спочатку предмет
    matière = dao.get_matière_by_name('matière1')
    print(f"matière={matière}")
    # потім оцінки з цього предмета (відкладене завантаження)
    print("Notes dans la matière : ")
    for note in matière.notes:
        print(note)

    # те саме для предмета 2
    print("matière de nom='matière2' -----------")
    matière = dao.get_matière_by_name('matière2')
    print(f"matière={matière}")
    print("Notes dans la matière : ")
    for note in matière.notes:
        print(f"note={note}")
except MyException as ex1:
    # виводиться повідомлення про помилку
    print(f"L'erreur 1 suivante s'est produite : {ex1}")
except BaseException as ex2:
    # виводиться повідомлення про помилку
    print(f"L'erreur 2 suivante s'est produite : {ex2}")
finally:
    # звільняються ресурси
    import shutdown
    shutdown.execute(config)

Коментарів достатньо для розуміння коду.

Створюємо конфігурацію виконання для MySQL:

Image

Результати виконання такі:

C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\venv\Scripts\python.exe C:/Data/st-2020/dev/python/cours-2020/python3-flask-2020/databases/sqlalchemy/05/main/main_joined_queries.py mysql
élève id=11 -----------
élève={"classe_id": 1, "nom": "nom1", "prénom": "prénom1", "id": 11}
classe de l'élève : {"nom": "classe1", "id": 1}
élèves dans la même classe :
élève={"classe_id": 1, "nom": "nom1", "prénom": "prénom1", "id": 11}
élève={"classe_id": 1, "nom": "nom2", "prénom": "prénom2", "id": 21}
élève nom='nom2' -----------
élève={"classe_id": 1, "nom": "nom2", "prénom": "prénom2", "id": 21}
classe de l'élève : {"nom": "classe1", "id": 1}
notes de l'élève id=11 -----------
note={"matière_id": 1, "valeur": 10.0, "élève_id": 11, "id": 1}, matière={"coefficient": 1.0, "nom": "matière1", "id": 1}
note={"matière_id": 2, "valeur": 6.0, "élève_id": 11, "id": 5}, matière={"coefficient": 2.0, "nom": "matière2", "id": 2}
élèves de la classe nom='classe1' -----------
{"classe_id": 1, "nom": "nom1", "prénom": "prénom1", "id": 11}
{"classe_id": 1, "nom": "nom2", "prénom": "prénom2", "id": 21}
élèves de la classe de nom 'classe2' -----------
{"classe_id": 2, "nom": "nom3", "prénom": "prénom3", "id": 32}
{"classe_id": 2, "nom": "nom4", "prénom": "prénom4", "id": 42}
matière de nom='matière1' -----------
matière={"coefficient": 1.0, "nom": "matière1", "id": 1}
Notes dans la matière : 
{"matière_id": 1, "valeur": 10.0, "élève_id": 11, "id": 1}
{"matière_id": 1, "valeur": 12.0, "élève_id": 21, "id": 2}
{"matière_id": 1, "valeur": 14.0, "élève_id": 32, "id": 3}
{"matière_id": 1, "valeur": 16.0, "élève_id": 42, "id": 4}
matière de nom='matière2' -----------
matière={"coefficient": 2.0, "nom": "matière2", "id": 2}
Notes dans la matière : 
note={"matière_id": 2, "valeur": 6.0, "élève_id": 11, "id": 5}
note={"matière_id": 2, "valeur": 8.0, "élève_id": 21, "id": 6}
note={"matière_id": 2, "valeur": 10.0, "élève_id": 32, "id": 7}
note={"matière_id": 2, "valeur": 12.0, "élève_id": 42, "id": 8}

Process finished with exit code 0

Щоб зрозуміти ці результати, слід пам’ятати, що ми виключили певні властивості зі словника сутностей (див. |конфігурація|):


    # конфігурація сутностей [BaseEntity]
    Elève.excluded_keys = ['_sa_instance_state', 'notes', 'classe']
    Classe.excluded_keys = ['_sa_instance_state', 'élèves']
    Matière.excluded_keys = ['_sa_instance_state', 'notes']
    Note.excluded_keys = ['_sa_instance_state', 'matière', 'élève']

Отже, коли ми вводимо [print(f"élève={élève}")] у 26-му рядку коду, 1-й рядок вище вказує, що властивості ['_sa_instance_state', 'notes', 'classe'] не будуть відображатися. Саме це ми бачимо у 3-му рядку результатів. Усі інші властивості відображаються. Отже, у тому ж рядку 3 ми виявляємо нову властивість [classe_id], якої спочатку не було в об’єкті [Elève]. Ця властивість безпосередньо відповідає стовпцю [classe_id] таблиці [élèves]. Отже, [sqlalchemy] додала до сутності [Elève] такі властивості: [classe_id, _sa_instance_state, notes]. Про це слід пам’ятати, зокрема тому, що вони не повинні вже існувати в відображеній сутності.

Властивості, виключені зі словника сутностей, мають важливе значення. Наприклад, якщо не виключити властивості [notes, élève] із сутності [Elève], то операція [print(f"élève={élève}")] відобразить їх і, отже, як щойно було пояснено, спричинить неявні запити SQL (відкладене завантаження) для отримання значень цих властивостей. Якщо, як у цьому випадку, відображається список учнів, неявні операції SQL виконуються для кожного учня. З одного боку, це може бути зайвим, а з іншого — безсумнівно, витратно з точки зору часу виконання.

Щоб виконати скрипт із базою PostgreSQL, створюємо таку конфігурацію виконання:

Image

Виконання дає ті самі результати, що й із MySQL.

19.6.9. Скрипт [main_stats_for_élève]

Image

Скрипт [main_stats_for_élève] — це той самий, що вже використовувався в додатку |troiscouches v01]. Тоді він називався [main]. Це консольний додаток, що дозволяє отримати певні показники щодо оцінок учня: [moyenne pondérée, min, max, liste]. Він вписується в таку архітектуру:

Image

У цій багатошаровій архітектурі між версією додатка |troiscouches v01| та цією версією було змінено лише шар [dao]. Оскільки новий шар [dao] відповідає інтерфейсу [InterfaceDao] старого шару [dao], шари [ui, métier] змінювати не потрібно. Отже, можна продовжувати використовувати ті, що визначені в додатку |troiscouches v01|.

Скрипт [main_stats_for_élève] реалізує шар [main] із наведеної вище схеми таким чином:


# очікується параметр mysql або pgres
import sys

syntaxe = f"{sys.argv[0]} mysql / pgres"
erreur = len(sys.argv) != 2
if not erreur:
    sgbd = sys.argv[1].lower()
    erreur = sgbd != "mysql" and sgbd != "pgres"
if erreur:
    print(f"syntaxe : {syntaxe}")
    sys.exit()

# налагодження програми
import config
config = config.configure({'sgbd': sgbd})

# syspath налаштовано — можна виконувати імпорт
from MyException import MyException

# шар [ui]
ui = config["ui"]
try:
    # виконання шару [ui]
    ui.run()
except MyException as ex1:
    # виводиться повідомлення про помилку
    print(f"L'erreur 1 suivante s'est produite : {ex1}")
except BaseException as ex2:
    # виводиться повідомлення про помилку
    print(f"L'erreur 2 suivante s'est produite : {ex2}")
finally:
    # звільняються ресурси
    import shutdown
    shutdown.execute(config)
  • рядок 20: отримується посилання на шар [ui] у конфігурації додатка;
  • рядок 24: запускається діалог з користувачем за допомогою єдиного методу шару [ui];

Конфігурація виконання для PostgreSQL виглядатиме так:

Image

Ось приклад виконання з цією конфігурацією:


C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\venv\Scripts\python.exe C:/Data/st-2020/dev/python/cours-2020/python3-flask-2020/databases/sqlalchemy/05/main/main_stats_for_élève.py pgres
Numéro de l'élève (>=1 et * pour arrêter) : 11
Elève={"prénom": "prénom1", "id": 11, "classe_id": 1, "nom": "nom1"}, notes=[10.0 6.0], max=10.0, min=6.0, moyenne pondérée=7.33
Numéro de l'élève (>=1 et * pour arrêter) : 1
L'erreur suivante s'est produite : MyException[11, L'élève d'identifiant 1 n'existe pas]
Numéro de l'élève (>=1 et * pour arrêter) : *

Process finished with exit code 0