5.2. Systemy ORM

Znajomość języka SQL jest oczywiście niezbędna, aby korzystać z wszystkich możliwości baz danych, niemniej w wielu niespecjalistycznych projektach można je obsługiwać inaczej, tj. za pomocą systemów ORM (ang. Object-Relational Mapping – mapowanie obiektowo-relacyjne). Pozwalają one traktować tabele w sposób obiektowy, co bywa wygodniejsze w budowaniu logiki aplikacji.

Używanie systemów ORM, takich jak Peewee czy SQLAlchemy, w prostych projektach sprowadza się do schematu, który poglądowo można opisać w trzech krokach:

  1. Nawiązanie połączenia z bazą
  2. Deklaracja modelu opisującego bazę i utworzenie struktury bazy
  3. Wykonywanie operacji CRUD

Poniżej spróbujemy pokazać, jak operacje wykonywane przy użyciu wbudowanego w Pythona modułu sqlite3 zrealizować przy użyciu technik ORM.

Informacja

Wyjaśnienia podanego niżej kodu są w wielu miejscach uproszczone. Ze względu na przejrzystość i poglądowość instrukcji nie wgłębiamy się w techniczne różnice w implementacji technik ORM w obydwu rozwiązaniach. Poznanie ich interfejsu jest wystarczające, aby efektywnie obsługiwać bazy danych. Co ciekawe, dopóki używamy bazy SQLite3, systemy ORM można traktować jako swego rodzaju nakładkę na owmówiony wyżej moduł sqlite3 wbudowany w Pythona.

5.2.1. Połączenie z bazą

W ulubionym edytorze utwórz dwa puste pliki o nazwach ormpw.py i ormsa.py. Pierwszy z nich zawierał będzie kod wykorzystujący ORM Peewee, drugi ORM SQLAlchemy.

Peewee. Kod nr
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#! /usr/bin/env python3
# -*- coding: utf-8 -*-

import os
from peewee import *

if os.path.exists('test.db'):
    os.remove('test.db')
# tworzymy instancję bazy używanej przez modele
baza = SqliteDatabase('test.db')  # ':memory:'


class BazaModel(Model):  # klasa bazowa
    class Meta:
        database = baza
SQLAlchemy. Kod nr
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#! /usr/bin/env python3
# -*- coding: utf-8 -*-

import os
from sqlalchemy import Column, ForeignKey, Integer, String, create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship, sessionmaker

if os.path.exists('test.db'):
    os.remove('test.db')
# tworzymy instancję klasy Engine do obsługi bazy
baza = create_engine('sqlite:///test.db')  # ':memory:'

# klasa bazowa
BazaModel = declarative_base()

W jednym i drugim przypadku importujemy najpierw potrzebne klasy. Następnie tworzymy instancje baza służące do nawiązania połączeń z bazą przechowywaną w pliku test.db. Jeżeli zamiast nazwy pliku, podamy :memory: bazy umieszczone zostaną w pamięci RAM (przydatne podczas testowania).

Informacja

Moduły os i sys nie są niezbędne do działania prezentowanego kodu, ale można z nich skorzystać, kiedy chcemy sprawdzić obecność pliku na dysku (os.path.ispath()) lub zatrzymać wykonywanie skryptu w dowolnym miejscu (sys.exit()). W podanych przykładach usuwamy plik bazy, jeżeli znajduje się na dysku, aby zapewnić bezproblemowe działanie kompletnych skryptów.

5.2.2. Model danych i baza

Przez model rozumiemy tutaj deklaracje klas i ich właściwości (atrybutów) opisujące obiekty, którymi się zajmujemy. Systemy ORM na podstawie klas tworzą odpowiednie tablice, pola, uwzględniając ich typy i powiązania. Wzajemne powiązanie klas i ich właściwości z tabelami i kolumnami w bazie stanowi właśnie istotę mapowania relacyjno-obiektowego.

Peewee. Kod nr
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# klasy Klasa i Uczen opisują rekordy tabel "klasa" i "uczen"
# oraz relacje między nimi
class Klasa(BazaModel):
    nazwa = CharField(null=False)
    profil = CharField(default='')


class Uczen(BazaModel):
    imie = CharField(null=False)
    nazwisko = CharField(null=False)
    klasa = ForeignKeyField(Klasa, related_name='uczniowie')


baza.connect()  # nawiązujemy połączenie z bazą
baza.create_tables([Klasa, Uczen], True)  # tworzymy tabele
SQLAlchemy. Kod nr
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# klasy Klasa i Uczen opisują rekordy tabel "klasa" i "uczen"
# oraz relacje między nimi
class Klasa(BazaModel):
    __tablename__ = 'klasa'
    id = Column(Integer, primary_key=True)
    nazwa = Column(String(100), nullable=False)
    profil = Column(String(100), default='')
    uczniowie = relationship('Uczen', backref='klasa')


class Uczen(BazaModel):
    __tablename__ = 'uczen'
    id = Column(Integer, primary_key=True)
    imie = Column(String(100), nullable=False)
    nazwisko = Column(String(100), nullable=False)
    klasa_id = Column(Integer, ForeignKey('klasa.id'))


# tworzymy tabele
BazaModel.metadata.create_all(baza)

W obydwu przypadkach deklarowanie modelu opiera się na pewnej “klasie” podstawowej, którą nazwaliśmy BazaModel. Dziedzicząc z niej, deklarujemy następnie własne klasy o nazwach Klasa i Uczen reprezentujące tabele w bazie. Właściwości tych klas odpowiadają kolumnom; w SQLAlchemy używamy nawet klasy o nazwie Column(), która wyraźnie wskazuje na rodzaj tworzonego atrybutu. Obydwa systemy wymagają określenia typu danych definiowanych pól. Służą temu odpowiednie klasy, np. CharField() lub String(). Możemy również definiować dodatkowe cechy pól, takie jak np. nie zezwalanie na wartości puste (null=False lub nullable=False) lub określenie wartości domyślnych (default='').

Warto zwrócić uwagę, na sposób określania relacji. W Peewee używamy konstruktora klasy: ForeignKeyField(Klasa, related_name = 'uczniowie'). Przyjmuje on nazwę klasy powiązanej, z którą tworzymy relację, i nazwę atrybutu określającego relację zwrotną w powiązanej klasie. Dzięki temu wywołanie w postaci Klasa.uczniowie da nam dostęp do obiektów reprezentujących uczniów przypisanych do danej klasy. Zuważmy, że Peewee nie wymaga definiowania kluczy głównych, są tworzone automatycznie pod nazwą id.

W SQLAlchemy dla odmiany nie tylko jawnie określamy klucze główne (primary_key=True), ale i podajemy nazwy tabel (__tablename__ = 'klasa'). Klucz obcy oznaczamy odpowiednim parametrem w klasie definiującej pole (Column(Integer, ForeignKey('klasa.id'))). Relację zwrotną tworzymy za pomocą konstruktora relationship('Uczen', backref='klasa'), w którym podajemy nazwę powiązanej klasy i nazwę atrybutu tworzącego powiązanie. W tym wypadku wywołanie typu uczen.klasa udostępni obiekt reprezentujący klasę, do której przypisano ucznia.

Po zdefiniowaniu przemyślanego modelu, co jest relatywnie najtrudniejsze, trzeba przetestować działanie mechanizmów ORM w praktyce, czyli utworzyć tabele i kolumny w bazie. W Peewee łączymy się z bazą i wywołujemy metodę .create_tables(), której podajemy nazwy klas reprezentujących tabele. Dodatkowy parametr True powoduje sprawdzenie przed utworzeniem, czy tablic w bazie już nie ma. SQLAlchemy wymaga tylko wywołania metody .create_all() kontenera metadata zawartego w klasie bazowej.

Podane kody można już uruchomić, oba powinny utworzyć bazę test.db w katalogu, z którego uruchamiamy skrypt.

Informacja

Warto wykorzystać interpreter sqlite3 i sprawdzić, jak wygląda kod tworzący tabele wygenerowany przez ORM-y. Poniżej przykład ilustrujący SQLAlchemy.

../../_images/sqlite3_2.png

5.2.3. Operacje CRUD

5.2.3.1. Wstawianie i odczytywanie danych

Podstawowe operacje wykonywane na bazie, np, wstawianie i odczytywanie danych, w Peewee wykonywane są za pomocą obiektów reprezentujących rekordy zdefiniowanych tabel oraz ich metod. W SQLAlchemy oprócz obiektów wykorzystujemy metody sesji, w ramach której komunikujemy się z bazą.

Peewee. Kod nr
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# dodajemy dwie klasy, jeżeli tabela jest pusta
if Klasa().select().count() == 0:
    inst_klasa = Klasa(nazwa='1A', profil='matematyczny')
    inst_klasa.save()
    inst_klasa = Klasa(nazwa='1B', profil='humanistyczny')
    inst_klasa.save()

# tworzymy instancję klasy Klasa reprezentującą klasę "1A"
inst_klasa = Klasa.select().where(Klasa.nazwa == '1A').get()

# lista uczniów, których dane zapisane są w słownikach
uczniowie = [
    {'imie': 'Tomasz', 'nazwisko': 'Nowak', 'klasa': inst_klasa},
    {'imie': 'Jan', 'nazwisko': 'Kos', 'klasa': inst_klasa},
    {'imie': 'Piotr', 'nazwisko': 'Kowalski', 'klasa': inst_klasa}
]

# dodajemy dane wielu uczniów
Uczen.insert_many(uczniowie).execute()

# odczytujemy dane z bazy


def czytajdane():
    for uczen in Uczen.select().join(Klasa):
        print(uczen.id, uczen.imie, uczen.nazwisko, uczen.klasa.nazwa)
    print()


czytajdane()
SQLAlchemy. Kod nr
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# tworzymy sesję, która przechowuje obiekty i umożliwia "rozmowę" z bazą
BDSesja = sessionmaker(bind=baza)
sesja = BDSesja()

# dodajemy dwie klasy, jeżeli tabela jest pusta
if not sesja.query(Klasa).count():
    sesja.add(Klasa(nazwa='1A', profil='matematyczny'))
    sesja.add(Klasa(nazwa='1B', profil='humanistyczny'))

# tworzymy instancję klasy Klasa reprezentującą klasę "1A"
inst_klasa = sesja.query(Klasa).filter_by(nazwa='1A').one()

# dodajemy dane wielu uczniów
sesja.add_all([
    Uczen(imie='Tomasz', nazwisko='Nowak', klasa_id=inst_klasa.id),
    Uczen(imie='Jan', nazwisko='Kos', klasa_id=inst_klasa.id),
    Uczen(imie='Piotr', nazwisko='Kowalski', klasa_id=inst_klasa.id),
])


def czytajdane():
    for uczen in sesja.query(Uczen).join(Klasa).all():
        print(uczen.id, uczen.imie, uczen.nazwisko, uczen.klasa.nazwa)
    print()


czytajdane()

Dodawanie informacji w systemach ORM polega na utworzeniu instancji odpowiedniego obiektu i podaniu w jego konstruktorze wartości atrybutów reprezentujących pola rekordu: Klasa(nazwa = '1A', profil = 'matematyczny'). Utworzony rekord zapisujemy metodą .save() obiektu w Peewee lub metodą .add() sesji w SQLAlchemy. Można również dodawać wiele rekordów na raz. Peewee oferuje metodę .insert_many(), która jako parametr przyjmuje listę słowników zawierających dane w formacie “klucz”:”wartość”, przy czym kluczem jest nazwa pola klasy (tabeli). SQLAlchemy ma metodę .add_all() wymagającą listy konstruktorów obiektów, które chcemy dodać.

Zanim dodamy pierwsze informacje sprawdzamy, czy w tabeli klasa są jakieś wpisy, a więc wykonujemy prostą kwerendę zliczającą. Peewee używa metod odpowiednich obiektów: Klasa().select().count(), natomiast SQLAlchemy korzysta metody .query() sesji, która pozwala pobierać dane z określonej jako klasa tabeli. Obydwa rozwiązania umożliwiają łańcuchowe wywoływanie charakterytycznych dla kwerend operacji poprzez “doklejanie” kolejnych metod, np. sesja.query(Klasa).count().

Tak właśnie konstruujemy kwerendy warunkowe. W Peewee definiujemy warunki jako prametry metody .where(Klasa.nazwa == '1A'). Podobnie w SQLAlchemy, tyle, że metody sesji inaczej się nazywają i przyjmują postać .filter_by(nazwa = '1A') lub .filter(Klasa.nazwa == '1A'). Pierwsza wymaga podania warunku w formacie “klucz”=”wartość”, druga w postaci wyrażenia SQL (należy uważać na użycie poprawnego operatora ==).

Pobieranie danych z wielu tabel połączonych relacjami może być w porównaniu do zapytań SQL-a bardzo proste. W zależności od ORM-a wystarcza polecenie: Uczen.select() lub sesja.query(Uczen).all(), ale przy próbie odczytu klasy, do której przypisano ucznia (inst_uczen.klasa.nazwa), wykonane zostanie dodatkowe zapytanie, co nie jest efektywne. Dlatego lepiej otwarcie wskazywać na powiązania między obiektami, czyli w zależności od ORM-u używać: Uczen.select().join(Klasa) lub sesja.query(Uczen).join(Klasa).all(). Tak właśnie postępujemy w bliźniaczych funkcjach czytajdane(), które pokazują, jak pobierać i wyświetlać wszystkie rekordy z tabel powiązanych relacjami.

Systemy ORM oferują pewne ułatwiania w zależności od tego, ile rekordów lub pól i w jakiej formie chcemy wydobyć. Metody w Peewee:

  • .get() - zwraca pojedynczy rekord pasujący do zapytania lub wyjątek DoesNotExist, jeżeli go brak;
  • .first() - zwróci z kolei pierwszy rekord ze wszystkich pasujących.

Metody SQLAlchemy:

  • .get(id) - zwraca pojedynczy rekord na podstawie podanego identyfikatora;
  • .one() - zwraca pojedynczy rekord pasujący do zapytania lub wyjątek DoesNotExist, jeżeli go brak;
  • .scalar() - zwraca pierwszy element pierwszego zwróconego rekordu lub wyjątek MultipleResultsFound;
  • .all() - zwraca pasujące rekordy w postaci listy.

Informacja

Mechanizm sesji jest unikalny dla SQLAlchemy, pozwala m. in. zarządzać transakcjami i połączeniami z wieloma bazami. Stanowi “przechowalnię” dla tworzonych obiektów, zapamiętuje wykonywane na nich operacje, które mogą zostać zapisane w bazie lub w razie potrzeby odrzucone. W prostych aplikacjach wykorzystuje się jedną instancję sesji, w bardziej złożonych można korzystać z wielu. Instancja sesji (sesja = BDSesja()) tworzona jest na podstawie klasy, która z kolei powstaje przez wywołanie konstruktora z opcjonalnym parametrem wskazującym bazę: BDSesja = sessionmaker(bind=baza). Jak pokazano wyżej, obiekt sesji zawiera metody pozwalające komunikować się z bazą. Warto również zauważyć, że po wykonaniu wszystkich zamierzonych operacji w ramach sesji zapisujemy dane do bazy wywołując polecenie sesja.commit().

5.2.3.2. Modyfikowanie i usuwanie danych

Systemy ORM ułatwiają modyfikowanie i usuwanie danych z bazy, ponieważ operacje te sprowadzają się do zmiany wartości pól klasy reprezentującej tabelę lub do usunięcia instancji danej klasy.

Peewee. Kod nr
65
66
67
68
69
70
71
72
73
74
75
# zmiana klasy ucznia o identyfikatorze 2
inst_uczen = Uczen().select().join(Klasa).where(Uczen.id == 2).get()
inst_uczen.klasa = Klasa.select().where(Klasa.nazwa == '1B').get()
inst_uczen.save()  # zapisanie zmian w bazie

# usunięcie ucznia o identyfikatorze 3
Uczen.select().where(Uczen.id == 3).get().delete_instance()

czytajdane()

baza.close()
SQLAlchemy. Kod nr
67
68
69
70
71
72
73
74
75
76
77
78
79
# zmiana klasy ucznia o identyfikatorze 2
inst_uczen = sesja.query(Uczen).filter(Uczen.id == 2).one()
inst_uczen.klasa_id = sesja.query(Klasa.id).filter(
    Klasa.nazwa == '1B').scalar()

# usunięcie ucznia o identyfikatorze 3
sesja.delete(sesja.query(Uczen).get(3))

czytajdane()

# zapisanie zmian w bazie i zamknięcie sesji
sesja.commit()
sesja.close()

Załóżmy, że chcemy zmienić przypisanie ucznia do klasy. W obydwu systemach tworzymy więc obiekt reprezentujący ucznia o identyfikatorze “2”. Stosujemy omówione wyżej metody zapytań. W następnym kroku modyfikujemy odpowiednie pole tworzące relację z tabelą “klasy”, do którego przypisujemy pobrany w zapytaniu obiekt (Peewee) lub identyfikator (SQLAlchemy). Różnice, tzn. przypisywanie obiektu lub identyfikatora, wynikają ze sposobu definiowania modeli w obu rozwiązanich.

Usuwanie jest jeszcze prostsze. W Peewee wystarczy do zapytania zwracającego obiekt reprezentujący ucznia o podanym id “dokleić” odpowiednią metodę: Uczen.select().where(Uczen.id == 3).get().delete_instance(). W SQLAlchemy korzystamy jak zwykle z metody sesji, której przekazujemy obiekt reprezentujący ucznia: sesja.delete(sesja.query(Uczen).get(3)).

Po zakończeniu operacji wykonywanych na danych powinniśmy pamiętać o zamknięciu połączenia, robimy to używając metody obiektu bazy baza.close() (Peewee) lub sesji sesja.close() (SQLAlchemy). UWAGA: operacje dokonywane podczas sesji w SQLAlchemy muszą zostać zapisane w bazie, dlatego przed zamknięciem połączenia trzeba umieścić polecenie sesja.commit().

5.2.4. Zadania

  • Spróbuj dodać do bazy korzystając z systemu Peewee lub SQLAlchemy wiele rekordów na raz pobranych z pliku. Wykorzystaj i zmodyfikuj funkcję pobierz_dane() opisaną w materiale Dane z pliku.
  • Postaraj się przedstawione aplikacje wyposażyć w konsolowy interfejs, który umożliwi operacje odczytu, zapisu, modyfikowania i usuwania rekordów. Dane powinny być pobierane z klawiatury od użytkownika.
  • Przedstawione rozwiązania warto użyć w aplikacjach internetowych jako relatywnie szybki i łatwy sposób obsługi danych. Zobacz, jak to zrobić na przykładzie scenariusza aplikacji Quiz ORM.
  • Przejrzyj scenariusz aplikacji internetowej Czat, zbudowanej z wykorzystaniem frameworku Django, korzystającego z własnego modelu ORM.

5.2.5. Źródła


Licencja Creative Commons Materiały Python 101 udostępniane przez Centrum Edukacji Obywatelskiej na licencji Creative Commons Uznanie autorstwa-Na tych samych warunkach 4.0 Międzynarodowa.

Utworzony:2017-11-17 o 06:13 w Sphinx 1.5.3
Autorzy:Patrz plik “Autorzy”