Elixir - łatwy sposób na SQLAlchemy

Elixir to nakładka na ORMa SQLAlchemy pozwalająca za pomocą klas opisywać tabele w relacyjnych bazach danych. W porównaniu do API dostępnego w samym SQLAlchemy wersja dostępna w Eliksirze stosuje wzorzec Active Record i może być czytelniejsza/łatwiejsza do zastosowania.

Elixir nie jest już aktywnie rozwijany i może stać się niekompatybilne z przyszłymi wersjami SQLAlchemy
Instalacja jest prosta, wystarczy wykonać:
pip install Elixir
Lub skorzystać z pakietów na stronie projektu.

Prosty model na dobry początek

Eliksira przedstawię za pomocą kilku części przedstawiających kolejne fragmenty tej biblioteki. Na początek tworzymy prostą klasę opisującą tabelę w bazie. Stwórz plik model.py a w nim taki oto kod:
from elixir import *

metadata.bind = "sqlite:///movies.sqlite"
metadata.bind.echo = True

class Movie(Entity):
    title = Field(Unicode(30))
    year = Field(Integer)
    description = Field(UnicodeText)
    
    def __repr__(self):
        return '<Movie "%s" (%d)>' % (self.title, self.year)
Na początku wybieramy bazę danych. Żeby było prościej - SQLite. Flaga echo włącza logowanie czynności przez co zobaczymy m.in. wykonane zapytania SQL. Następnie definiujemy klasę "Movie" dziedziczącą klasę Entity. Wewnątrz tej klasy definiujemy pola określające kolumny bazy danych:
  • title: pole tekstowe, do 30 znaków; na tytuł filmu
  • year: pole liczbowe; przechowujące rok wydania filmu
  • description: pole tekstowe; na opis lub podobną zawartość

Metoda __repr__ jest opcjonalna i służy do wyświetlania obiektów w czytelnej dla ludzi postaci.

Mając gotowy model warto go użyć. Stwórz drugi skrypt o kodzie (lub wykorzystaj interaktywny interpreter Pythona):
from model import *

setup_all()
create_all()

Wywołanie setup_all tworzy obiekt SQLAlchemy typu Table jak i obiekt Mapper dla naszej klasy "Movie". Obiekt "Table" jest pythonowym obiektem opisującym wygląda tabeli w bazie danych. Mapper jest obiektem, który opisuje jak twoja klasa mapuje się na tą tabelę. Oba te obiekty tworzone są automatycznie.

Natomiast create_all spowoduje stworzenie wszystkich brakujących tabel w bazie danych - tabel opisanych przez nasze modele. W tym przypadku zobaczymy że Elixir wykonał takie zapytanie:

CREATE TABLE model_movie (
    id INTEGER NOT NULL, 
    title VARCHAR(30), 
    year INTEGER, 
    description TEXT, 
    PRIMARY KEY (id)
)

Domyślnie Eliksir nazywa tabele wykorzystując nazwę modułu i klasy (co można oczywiście zmienić, ale o tym później). Co ważne to obecność pola id, które jest także kluczem głównym w tej tabeli. Kolumna ta została stworzona bo nie zdefiniowaliśmy żadnego własnego pola z kluczem głównym.

Czas coś dodać do bazy danych. Na początek stwórzmy taki oto obiekt Movie:
from model import *

setup_all()
create_all()

m = Movie(title=u"Blade Runner", year=1982)
print m
Mamy obiekt Movie z podanymi przez nas danymi. SQLAlchemy optymalizując osiągi stara się grupować operacje i nie wykona zapytań dopóki jawnie tego nie zażądamy za pomocą:
session.commit()
from model import *

setup_all()
create_all()

m = Movie(title=u"Blade Runner", year=1982)
session.commit()

print Movie.query.all()

Tak oto dodaliśmy pierwszy rekord do bazy za pomocą Eliksira jak i pobraliśmy wszystkie (aż jeden) rekordy za pomocą metody query.

Możemy też w prosty sposób modyfikować istniejące rekordy:
from model import *

setup_all()
create_all()

# .first() is equivalent to (but nicer than) .all()[0]
movie = Movie.query.first()
movie.year = 1983
session.commit()

print Movie.query.all()

Pobieramy pierwszy (jedyny jak na razie) rekord, który mapowany jest do obiektu Movie. Możemy np. zmienić rok i wykonać zmiany za pomocą metody commit. Kasowanie odbywa się za pomocą metody delete.

Proste relacje tabel w Eliksirze

Teraz zajmiemy się prostymi relacjami - jeden do wielu i wiele do jednego. Skasuj istniejący plik z bazą SQLite, bo zaczynamy z nieco innymi modelami:

from elixir import *

metadata.bind = "sqlite:///movies.sqlite"
metadata.bind.echo = True

class Movie(Entity):
    title = Field(Unicode(30))
    year = Field(Integer)
    description = Field(UnicodeText)
    director = ManyToOne('Director')    # <-- add this line

    def __repr__(self):
        return '<Movie "%s" (%d)>' % (self.title, self.year)

class Director(Entity):
    name = Field(Unicode(60))
    movies = OneToMany('Movie')         # <-- and this one

    def __repr__(self):
        return '<Director "%s">' % self.name

Doszedł nam nowy model "Director" oraz relacja. Jeden do wielu w "Director" i wiele do jednego w "Movie". Oznacza to że jedna osoba może być reżyserem wielu filmów, czyli film może mieć jednego reżysera. W odróżnieniu od np. modeli Django tutaj relacja definiowana jest w obu modelach (z obu stron).

Czas stworzyć tabele. Można zrobić to jak poprzednio za pomocą create_all, lub też skrótem:
from model import *

setup_all(True)
Stworzone zostaną dwie tabele:
CREATE TABLE model_director (
	id INTEGER NOT NULL, 
	name VARCHAR(60), 
	PRIMARY KEY (id)
)

CREATE TABLE model_movie (
	id INTEGER NOT NULL, 
	title VARCHAR(30), 
	year INTEGER, 
	description TEXT, 
	director_id INTEGER, 
	PRIMARY KEY (id), 
	CONSTRAINT model_movie_director_id_fk FOREIGN KEY(director_id) REFERENCES model_director (id)
)

Jak widzimy w tabeli model_movie stworzona została m.in. kolumna director_id odpowiedzialna za tą relację.

Teraz dla przykładu stwórzmy kilka filmów, reżyserów i powiążmy ich w relacji - każdy film będzie miał swojego reżysera:

from model import *

setup_all(True)

# add directors
rscott = Director(name=u"Ridley Scott")
glucas = Director(name=u"George Lucas")

# add movies
alien = Movie(title=u"Alien", year=1979)
swars = Movie(title=u"Star Wars", year=1977)
brunner = Movie(title=u"Blade Runner", year=1982)

# bind director to movies
# like so:
rscott.movies.append(brunner) 
rscott.movies.append(alien)
# or so:
swars.director = glucas

# show the result
print glucas.movies

# commit to database
session.commit()

Jak widać relację można tworzyć na dwa sposoby. Dla modelu mającego relację jeden do wielu dodajemy elementy jak do listy. W przypadku relacji wiele do jednego po prostu przypisujemy obiekt do pola.

Pobieranie danych z bazy danych

We wcześniejszych przykładach stosowaliśmy np. Movie.query.all() by pobrać rekordy z bazy. Atrybut query daje nam obiekt typu Query ze SQLAlchemy. Pozwala on nam na różnorakie formułowanie zapytań za pomocą składni Pythona. Oto przykłady:

from model import *

setup_all(True)

print Movie.query.filter_by(title=u"Alien").one()
print Movie.query.filter(Movie.year > 1980).all()
print Movie.query.filter(Movie.director.has(name=u'Ridley Scott')).all()
print Movie.query.filter(Movie.director.has(Director.name.endswith(u'Scott'))).all()
Co zwróci takie oto wyniki:
<Movie "Alien" (1979)>
[<Movie "Blade Runner" (1982)>]
[<Movie "Alien" (1979)>, <Movie "Blade Runner" (1982)>]
[<Movie "Alien" (1979)>, <Movie "Blade Runner" (1982)>]

Relacje wiele do wielu

Kolejny element Eliksira to relacje wiele do wielu jak i złożone klucze. Na początek załóżmy że chcemy dodać kategorie do filmów ("Genre"). Film może należeć do wielu kategorii, a dana kategoria może zawierać również wiele filmów. Mamy relację wiele do wielu.

Drugi element na naszej liście to brak domyślnego klucza głównego. Załóżmy że chcemy dopuszczać filmy o tym samym tytule, ale wydane w różnych latach. By to osiągnąć potrzebujemy złożony klucz główny (composite primary key) - na tytule i roku wydania.

Usuń plik bazy danych i zmodyfikuj modele do postaci:
from elixir import *

metadata.bind = "sqlite:///movies.sqlite"
metadata.bind.echo = True

class Genre(Entity):
    name = Field(Unicode(15), primary_key=True)
    movies = ManyToMany('Movie')
    
    def __repr__(self):
        return '<Genre "%s">' % self.name

class Movie(Entity):
    title = Field(Unicode(30), primary_key=True)   # <-- modify this line
    year = Field(Integer, primary_key=True)        # <-- and this one
    description = Field(UnicodeText)
    director = ManyToOne('Director')
    genres = ManyToMany('Genre')                   # <-- and add this one

    def __repr__(self):
        return '<Movie "%s" (%d)>' % (self.title, self.year)

class Director(Entity):
    name = Field(Unicode(60))
    movies = OneToMany('Movie')         # <-- and this one

    def __repr__(self):
        return '<Director "%s">' % self.name

Pojawił się model Genre z relacją wiele do wielu do modelu Movie. Podobna definicja znalazła sie w modelu Movie. Dodatkowo title i year zostały zdefiniowane jako klucze główne - poprzez primary_key.

Przy tworzeniu tabel zauważymy że stworzona zostanie tabela pośrednicząca w relacji wiele do wielu:
CREATE TABLE model_movie_genres__model_genre_movies (
	model_genre_name VARCHAR(15) NOT NULL, 
	model_movie_title VARCHAR(30) NOT NULL, 
	model_movie_year INTEGER NOT NULL, 
	PRIMARY KEY (model_genre_name, model_movie_title, model_movie_year), 
	CONSTRAINT model_genre_movies_fk FOREIGN KEY(model_genre_name) REFERENCES model_genre (name), 
	CONSTRAINT model_movie_genres_fk FOREIGN KEY(model_movie_title, model_movie_year) REFERENCES model_movie (title, year)
)
Z poziomu Pythona wykorzystywanie tej relacji jest dość proste. Oto przykład dodający kilka rekordów:
from model import *

setup_all(True)

scifi = Genre(name=u"Science-Fiction")
rscott = Director(name=u"Ridley Scott")
glucas = Director(name=u"George Lucas")

alien = Movie(title=u"Alien", year=1979, director=rscott, genres=[scifi, Genre(name=u"Horror")])
brunner = Movie(title=u"Blade Runner", year=1982, director=rscott, genres=[scifi])
swars = Movie(title=u"Star Wars", year=1977, director=glucas, genres=[scifi])

session.commit()
Pobieranie rekordów filtrowanych po wartości takiej relacji wygląda następująco:
from model import *

setup_all(True)

print Movie.query.filter(Movie.genres.any(name=u"Horror")).all()

Dodatkowe opcje

Za pomocą dodatkowych opcji możemy np. podać własną nazwę dla tabeli:

class Movie(Entity):
    using_options(tablename='movies')

    title = Field(Unicode(30))
    year = Field(Integer)
    description = Field(UnicodeText)

Dziedziczenie

W przykładzie z relacjami stworzyliśmy model reżyserów. Gdybyśmy teraz chcieli jeszcze dodać aktorów to możemy stworzyć zwykły model dla aktorów i połączyć go relacją z modelem filmów. Patrząc na to z innej strony zauważymy że i aktorzy i reżyserzy to ludzie, co można objąć modelami i wykorzystać dziedziczenie. Dostaniemy wtedy model "Person" zawierający wszystkich ludzi. Modele Actor i Director dziedziczące go będą zawierały tylko ludzi określonego typu.

Skasuj plik z bazą danych i zmodyfikuj modele do postaci:
from elixir import *

metadata.bind = "sqlite:///movies.sqlite"
metadata.bind.echo = True

class Person(Entity):
    using_options(inheritance='multi')
    name = Field(Unicode(60))

    def __repr__(self):
        return '<Person "%s">' % self.name

class Actor(Person):
    using_options(inheritance='multi')
    movies = ManyToMany('Movie')

    def __repr__(self):
        return '<Actor "%s">' % self.name

class Director(Person):
    using_options(inheritance='multi')
    movies = OneToMany('Movie')

    def __repr__(self):
        return '<Director "%s">' % self.name

class Genre(Entity):
    name = Field(Unicode(15), primary_key=True)
    movies = ManyToMany('Movie')
    
    def __repr__(self):
        return '<Genre "%s">' % self.name

class Movie(Entity):
    title = Field(Unicode(30), primary_key=True)
    year = Field(Integer, primary_key=True)
    description = Field(UnicodeText)
    director = ManyToOne('Director')
    actors = ManyToMany('Actor') # new one
    genres = ManyToMany('Genre')

    def __repr__(self):
        return '<Movie "%s" (%d)>' % (self.title, self.year)
Teraz dodajmy kilka rekordów i zobaczmy jak to działa:
from model import *

setup_all(True)

rscott = Director(name=u"Ridley Scott")
glucas = Director(name=u"George Lucas")
hford = Actor(name=u"Harrison Ford")
mhamill = Actor(name=u"Mark Hamill")
sweaver = Actor(name=u"Sigourney Weaver")
session.commit()

print Person.query.all()

print Actor.query.all()
Pierwsze zapytanie - Person.query.all() zwróci wszystkie osoby:
[<Director "Ridley Scott">, <Director "George Lucas">, <Actor "Harrison Ford">, <Actor "Mark Hamill">, <Actor "Sigourney Weaver">]
Natomiast Actor.query.all() zwróci tylko aktorów:
[<Actor "Harrison Ford">, <Actor "Mark Hamill">, <Actor "Sigourney Weaver">]
RkBlog

Programowanie Sieciowe, 14 July 2008

Comment article
Comment article RkBlog main page Search RSS Contact