このサイトについて

みんなのPython Webアプリ編 - シンプルなO/Rマッパーを作る

みんなのPython Webアプリ編 - シンプルなO/Rマッパーを作る

シンプルなO/Rマッパーを作る

O/Rマッパーは一見とても高度なもののように見えますが、内部ではそれほど難しいことをしているわけではありません。簡単な機能を持ったO/Rマッパーなら、Pythonのクラスやオブジェクト指向について基本的な知識を使えば、簡単に作ることができます。ここでは、Pythonでシンプルな機能を持つO/Rマッパーを作りながら、仕組みやはたらきについて学んでみましょう。

設計の指針

まずは簡単に、どのようなO/Rマッパーを作るかを決めることから始めましょう。

基本となる考え方は、テーブルをクラスで表現し、データ(列)をインスタンスオブジェクトで表現するということです。

テーブルを表現するクラスには、テーブルのカラムの種類などを定義するようにします。テーブルとクラスが対応するので、1つのテーブルごとに1つのクラスができることになります。

図02 O/Rマッパーでは、テーブルがクラスに対応し、データがインスタンスに対応する

図02 O/Rマッパーでは、テーブルがクラスに対応し、データがインスタンスに対応する

同じようなコードがいたるところに氾濫することを避けるため、テーブルを表現するクラスの親となるクラスを定義します。この親クラスの名前をBaseMapperとしましょう。汎用的な処理を行うメソッドは、この親クラスに定義しておくことにします。この親クラスの機能は、必ず子供のクラスが継承した上で利用します。

このように、継承して利用することを前提に定義するクラスのことを抽象クラスと呼ぶことがあります。

データベース上のデータは、インスタンスオブジェクトのアトリビュートに保存することにします。データベース上で、データを保存しているテーブルを表現するクラスを元に、クラスインスタンスを作ることになります。

今回作るO/Rマッパーでは、以下の機能を実装することにします。

  • クラスの定義を元にテーブルを作る機能
  • テーブルにデータを追加(INSERT)する機能
  • データを更新する機能(UPDATE)
  • テーブルから条件に合うデータを取り出す機能(SELECT)

またBaseMapperクラスを定義するモジュール名はsimplemapperとします。

テーブルを作成する

まずは、テーブルを作成するメソッドについて解説します。データベースでは、データを保存する前にテーブルを作っておく必要があります。O/Rマッパーを使うときには、このクラスを使って事前にテーブルを作っておくようにします。

テーブルを作るにはCREATE TABLE文を使用します。テーブルに含まれるカラムの名前とデータ型が分かっていればCREATE TABLE文に相当するSQL文字列を作ることができます。つまり、テーブルに含まれるカラムの情報から、テーブルを作るSQLを自動生成できる、ということです。

今回のO/Rマッパーでは、rowsという決まった名前のクラスアトリビュートにテーブルに含まれるカラムの情報を保存しておくようにします。アトリビュートの内容はタプルのタプルです。カラム名とカラムのデータ型が並んだタプルを、カラムの数だけ並べるようにします。カラムの定義はテーブルごとに異なります。そのため、カラムの定義は抽象クラスを継承したクラス上で、以下のように行うことになります。

:::python
rows=(('foo', 'int'), ('bar', 'text'))

クラスに対応するテーブル名はクラス名からとることにします。また、テーブル上に登録された個々のデータを識別するため、idという名前のカラムを暗黙に追加するようにします。

以下が、抽象クラスに定義した、テーブルを作成するメソッドです。メソッド内で行っていることは、SQL文に相当する文字列の生成と、SQLを送信する処理となります。

createtable()メソッドの実装(simplemapper.py)

:::python
    @classmethod                              # (1)
    def createtable(cls, ignore_error=False):
        """
        定義に基づいてテーブルを作る
        """
        sql="""CREATE TABLE %s (
               id INTEGER PRIMARY KEY, %s );"""
        columns=', '.join(["%s %s"%(k, v) for k, v in cls.rows])  # (2)
        sql=sql%(cls.__name__, columns)
        cur=cls.getconnection().cursor()
        try:
            cur.execute(sql)
        except Exception, e:
            if not ignore_error:
                raise e
        cur.close()
        cls.getconnection().commit()

メソッドの定義をするdef文の上には、アットマークから始まる見慣れない構文があります(1)。この構文はデコレータと呼ばれています。Python2.4から追加された比較的新しい構文です。デコレータは、関数やメソッドに特殊な働きを追加するためなどに利用されます。@classmethodという宣言は、メソッドをクラスメソッドとして定義するために利用されます。

クラスメソッドはメソッドの一種ですが、通常のメソッドとは機能が異なっています。簡単に言うと、普通のメソッドはとクラスメソッドはメソッドの持ち主が異なります。普通のメソッドの持ち主はインスタンスですが、クラスメソッドの持ち主はクラス自体(クラスオブジェクト)となります。たいていのメソッドは持ち主を対象に処理を行いますので、持ち主が異なれば処理の対象も異なります。

Pythonのメソッドでは、第1引数にクラスインスタンスをとります。しかし、クラスメソッドでは第1引数にクラス自体(クラスオブジェクト)をとります。クラスメソッドは、「ins.clsmethod()」のようにインスタンスオブジェクトから呼び出すこともできますし、「Class.clsmethod()」のようにクラス自体から呼び出すこともできます。どちらの場合でも、第1引数に渡ってくるのはクラス自体のオブジェクトです。

なぜ、テーブルを作るメソッドをクラスメソッドとして定義する必要があるのでしょうか。このメソッドがどのような場面で利用されるか、このメソッドではどのような情報が必要かを考えると答えが見えてきます。

テーブルは、テーブルが存在しない状態で作ります。テーブルが存在しなければ、テーブル上にデータも存在しません。そのため、テーブルを作る前にはO/Rマッパークラスのインスタンスは存在しないのです。この時点ではテーブル用のクラスしか存在しないため、クラスオブジェクトから呼び出せるメソッドを定義する必要があります。

また、テーブルを作成するSQL文字列を作るためには、クラスに定義された情報が必要です。そのため、メソッドでクラスオブジェクトを受け取った方が都合がよいのです。

メソッドの前半はSQL文字列を組み立てている部分です。このO/Rマッパーではクラス名をテーブル名とします。クラスオブジェクトの__name__というアトリビュートを使って、クラス名を取得しています。その後は、クラスオブジェクトに定義されたrowsというアトリビュートを使って、カラム名とデータ型を並べています(2)。

SQL文字列が組み上がったら、カーソルオブジェクトを取得してデータベースにSQLを送信します。メソッドの引数にはフラグが渡ってきます。このフラグがTrueのとき、テーブル作成時に起こるエラー(例外)を無視します。テーブルがすでに存在したときに発生するエラー(例外)を無視し、テーブルがあってもなくてもとにかく作ってみるという処理を実現しています。

テーブルにデータを追加(INSERT)する

次に、テーブルにデータを追加するメソッドについて見てみましょう。テーブルにデータを追加するときには、INSERT文を使用します。INSERT文を作るときに必要な情報はテーブル名、カラム名、追加するデータ(カラムごとに必要)の3種類です。このうちテーブル名とカラム名はクラスオブジェクトから得ることができます。追加するデータは状況依存なので、引数としてメソッドで受け取ることにしましょう。

なお、このメソッドもデコレータを使ってクラスメソッドとして定義しています。データの登録はテーブルに対して行います。O/Rマッパーではテーブル=クラスですので、メソッドをクラスの持ち物として定義しています。

insertメソッドの実装(simplemapper.py)

:::python
    @classmethod
    def insert(cls, **kws):
        """
        データを追加し,IDを返す
        """
        sql="""INSERT INTO %s(%s) VALUES(%s)"""
        rownames=', '.join([v[0] for v in cls.rows])
        holders=', '.join(['?' for v in cls.rows])
        sql=sql%(cls.__name__,rownames, holders)
        values=[kws[v[0]] for v in cls.rows]           # (1)
        cur=cls.getconnection().cursor()
        cur.execute(sql, values)
        cur.execute("SELECT max(id) FROM %s"%cls.__name__)
        newid=cur.fetchone()[0]
        cls.getconnection().commit()
        cur.close()
        return newid

このメソッドでも、前半でSQL文字列の組み立てを行っています。テーブル名となるクラス名は__name__アトリビュートから、カラム名はrowsアトリビュートから取得できます。

登録を行うデータは、メソッドに引数として渡します。どのカラムにどのデータを登録したいかは、引数名で指定します。たとえば、次のようにメソッドを呼び出したとします。

:::python
ORClass.insert(foo=1, bar='test')

この場合は、fooというカラムに数値の1を、barというカラムに文字列の"test"を登録することになります。

メソッドの定義には**kwsというアスタリスクが2つ付いた引数が見えます。関数やメソッドにこのような引数が定義されていると、任意のキーワード引数を受け取れるようになります。受け取ったキーワード引数は辞書の形式で渡ってきます。つまり、次のようなメソッド呼び出しを行った場合は、kwsという引数に「{'foo':1, 'bar':2}」という内容の辞書が代入されます。

:::python
insert(foo=1, bar=2)

メソッドの内部では、kwsという辞書とカラムの情報が入ったrowsアトリビュートを比較して、SQL文字列を組み立てています(1)。SQL文字列の組み立てが終わったら、カーソルオブジェクトを取得してデータベースにSQLを送信します。

インスタンスオブジェクトの初期化

以下のコードが、O/Rマッパーの抽象クラスの宣言と、データベースへのコネクションオブジェクトを管理するためのメソッドの定義です。テーブルごとにカラムを定義するためのアトリビュート(rows)があります。このアトリビュートには、カラム名とカラムの型をタプルにして並べます。基底クラスそのものが利用されることはないので、ここでは空のタプルが代入されています。基底クラスを継承したクラスで、同名のアトリビュートを定義する必要があります。

BaseMapperクラスの宣言とコネクションメソッド(simplemapper.py)

:::python
class BaseMapper(object):
    """
    シンプルな機能を持つO/Rマッパーのベースクラス
    """
    rows=()

    connection=sqlite3.connect(':memory:')

    @classmethod
    def setconnection(cls, con):
        cls.connection=con

    @classmethod
    def getconnection(cls):
        return cls.connection

ところで、O/Rマッパーでは、クラスから作られるインスタンスオブジェクトはどのように生成されるでしょうか。O/Rマッパーの設計にもよりますが、インスタンスの生成のされ方はおおまかに2種類に分かれます。1つは、データベースに新しいデータを登録する意味でインスタンスを作る場合。インスタンス生成時には、引数として登録するデータを渡すようになるはずです。もう1つは、データベースにすでに登録されているデータを元に、インスタンスを生成する場合です。インスタンス生成時には、既存データ1つを特定するための情報(ID)を引数に渡します。

Pythonのクラスでは、init()という初期化メソッドでインスタンスの初期化を行います。O/Rマッパーの場合は、どのような目的でインスタンスを得たいのかによって、初期化の手法が分かれる、ということになります。メソッドには、引数として情報を渡すことができます。この引数を使って、新しいデータを登録するのか、既存データを参照するのかを切り分けることができるはずです。

既存データをデータベースから引き出してインスタンスオブジェクトを作る場合、どのデータを使うかを特定するための情報をメソッドに渡す必要があります。いま作っているO/Rマッパーでは、それぞれのデータを判別するためにidというカラムを追加しています。このidを引数に渡したときに、既存データからインスタンスを作る、という場合分けをすることにしましょう。id以外の引数が渡されていたら、新規登録を意味することになります。

以下のコードが、O/Rマッパーの初期化メソッドの定義です。

__init__メソッドの実装(simplemapper.py)

:::python
    def __init__(self, **kws):
        """
        クラスを初期化する
        idを引数に渡された場合は,既存データをSELECTして返す
        その他のキーワード引数を渡された場合は,データをDBにInsertする
        """
        if 'id' in kws.keys():                                # (1)
            rownames=[v[0] for v in self.__class__.rows]
            rownamestr=', '.join(rownames)
            cn=self.__class__.__name__
            sql="""SELECT %s FROM %s WHERE id=?"""%(rownamestr, cn)
            cur=self.getconnection().cursor()
            cur.execute(sql, (kws['id'],))
            for rowname, v in zip(rownames, cur.fetchone()):  # (2)
                setattr(self, rowname, v)
            self.id=kws['id']
            cur.close()
        elif kws:
            self.id=self.insert(**kws)                        # (3)
            rownames=[v[0] for v in self.__class__.rows]
            for k in kws.keys():
                if k in rownames:
                    setattr(self, k, kws[k])

初期化メソッド(init())の内容を簡単に見てみましょう。このクラスの初期化メソッドはいろいろな種類の引数を受け付ける必要があるため、アスタリスクを2つ先頭に持つキーワード引数を定義しています。

メソッドの中では、引数の種類を判断して、処理を振り分けています(1)。

もし、引数名の中にidという名前が見つかったら、既存のデータからインスタンスオブジェクトを作ります。ここではSELECT文に相当するSQL文字列を組み立ててデータベースに送信し、データを得ています。

データベースから取得したデータは、カラム名に相当するインスタンスのアトリビュートにデータを代入します。カラム名自体は文字列として与えられます。そのため、ここではsetattr()という関数を使ってインスタンスオブジェクト(self)にアトリビュートを設定しています(2)。

もし、idという名前を持つ引数が見付からなかった場合は、クラスメソッドのinsert()を使ってデータをデータベースに登録します(3)。その後、メソッドに渡された引数を元にアトリビュートを設定します。

データを更新する機能(UPDATE)

次に、データを更新するメソッドについて解説しましょう。このO/Rマッパーの場合は、データベースから取得したデータをインスタンスオブジェクトとして表現します。O/Rマッパーを使うプログラム側では、インスタンスのアトリビュートを通じてデータにアクセスします。アトリビュートを参照したり、必要があればアトリビュートのデータを更新します。

データを更新した場合は、更新した内容をデータベースに反映する必要があります。そのときに呼ばれるのがupdate()メソッドです。プログラム側では、インスタンスを一通り操作した後、メソッドを呼び出して更新内容を保存することになります。

setattr()という特殊メソッドを定義すると、アトリビュートを更新したらすぐ、データベースに更新内容を反映するような仕組みを作ることもできます。このO/Rマッパーは機能を最小限度にとどめているため、そのような機能は実装していません。

update()メソッドの実装(simplemapper.py)

:::python
    def update(self):
        """
        データを更新する
        """
        sql="""UPDATE %s SET %s WHERE id=?"""
        rownames=[v[0] for v in self.__class__.rows]
        holders=', '.join(['%s=?'%v for v in rownames])
        sql=sql%(self.__class__.__name__, holders)
        values=[getattr(self, n) for n in rownames]
        values.append(self.id)
        cur=self.getconnection().cursor()
        cur.execute(sql, values)
        self.getconnection().commit()
        cur.close()

このメソッドも、処理のほとんどはSQL文字列を組み立てる処理を実行しています。データベース上のデータを更新するためのUPDATE文を組み立てるために必要な情報はすべてクラスインスタンスが持っています。テーブル名はクラスオブジェクトから得ることができますし、カラムの名前はrowsというクラスのアトリビュートから得ることができます。更新に利用するデータはインスタンスオブジェクトのアトリビュートが持っています。このような文字列を元に、SQL文字列を組み立てて、データベースに送信してデータの更新を行っています。

テーブルから条件に合うデータを取り出す機能(SELECT)

データベース上のテーブルからデータを取り出すには、SELECT文というSQLを利用します。SELECT文には、テーブル名やデータを取り出すカラム名の他に、取り出すデータの条件を指定することができます。条件はWHERE句に記述します。ANDやORを使った複雑な条件を指定することも可能です。以下にWHERE句を使ったSQLの例を示します。

:::sql
SELECT foo, bar FROM testtable WHERE foo > 2 AND foo < 100;

WHERE句に含まれる条件を考えると、テーブルからデータを取り出すために必要なSQL文字列にはとても多くのバリエーションがあることが分かります。この条件をうまく扱い、テーブルからデータを取り出すことができるメソッドがO/Rマッパーにあると、とても便利に利用できそうです。

メソッドに何らかの情報を渡したいときには引数を使います。この引数を使って、データを取り出すときの条件を指定する方法があれば、そのようなメソッドが作れそうです。

Pythonにはキーワード引数という機能があります。この機能を使うと、メソッドや関数で任意の引数を受け取ることができます。この機能を使って、データベースからデータを選択する条件を指定できそうです。たとえば、「fooという名前のカラムが数値の1である」という条件は、次のように表現できます。

:::python
ins.select(foo=1)

ただし、このままでは「等しくない(!=)」や「より大きい(>)」というような条件が指定できません。そこで、引数の名前付けのルールを少々拡張することにしましょう。カラム名の後に_neという文字列を追加すると「!=」という条件にし、また_gtという文字列を追加すると「>」という条件が指定されているものと解釈します。複数条件が指定されていたら、AND条件として扱うことにします。

TestTableというO/Rマッパーのクラスがあり、ここからfoo > 2 and foo <= 100というWHERE句に相当するデータを選択するときには、以下のようなメソッド呼び出しを行うことになります。

:::python
TestTable.select(foo_gt=2, foo_lte=100)

以下に、テーブルからデータを選択するselect()メソッドの定義を示します。 なお、テーブルからデータを選択する処理は、テーブルを対象に行います。そのため、データを選択するメソッドもクラスメソッドとして実装しています。

select()メソッドの実装(simplemapper.py)

:::python
    where_conditions={                      # (1)
        '_gt':'>', '_lt':'<',
        '_gte':'>=', '_lte':'<=',
        '_like':'LIKE' }

    @classmethod
    def select(cls, **kws):
        """
        テーブルからデータをSELECTする
        """
        order=''
        if "order_by" in kws.keys():
            order=" ORDER BY "+kws['order_by']
            del kws['order_by']
        where=[]
        values=[]
        for key in kws.keys():
            ct='='
            kwkeys=cls.where_conditions.keys()
            for ckey in kwkeys:
                if key.endswith(ckey):
                    ct=cls.where_conditions[ckey]
                    kws[key.replace(ckey, '')]=kws[key]
                    del kws[key]
                    key=key.replace(ckey, '')
                    break
            where.append(' '.join((key, ct, '? ')))
            values.append(kws[key])
        wherestr="AND ".join(where)
        sql="SELECT id FROM "+cls.__name__
        if wherestr:
            sql+=" WHERE "+wherestr
        sql+=order
        cur=cls.getconnection().cursor()
        cur.execute(sql, values)
        for item in cur.fetchall():
            ins= cls(id=item[0])
            yield ins                      # (2)
        cur.close()

メソッドの大部分は、キーワード引数からWHERE句に相当するSQL文字列を作っている部分です。_gtや_nなど引数名の末尾に付ける文字列は辞書として定義してあります(1)。対応する比較演算子を辞書の値としてあら かじめ定義しておくわけです。

その後、キーワード引数の名前を見ながら、WHERE句の条件に相当する文字列を組み立てていきます。_gtなど特別な働きを持つ文字列が末尾にあるときは、末尾の文字列を取り除いた上で比較用の文字列を作っていきます。

order_byという引数を追加すると、取り出すデータの並び順を指定できます。SQLにはORDERBY句があり、この句を利用します。

WHERE句に相当する文字列ができ上がったら、カーソルオブジェクトを使ってデータベースにSQLを送信、結果を得ます。得た結果から、O/Rマッパークラスのインスタンスオブジェクトを作って返します。

O/Rマッパークラスのインスタンスを返すときには、return文の代わりにyield文を使います(2)。プログラムの書き方としては、見つかったインスタンスを1つずつ返しているように見えます。メソッドの呼び出し元では、イテレータとして処理します。このようにyield文を使って戻り値を返すメソッドや関数はジェネレータと呼ばれています。

そのほかの処理

O/Rマッパーの機能とは関係ありませんが、このBaseMapperクラスに、次の特殊メソッドrepr()を定義しておきます。このメソッドは、インスタンスオブジェクトの概要を簡易に表示するためのもので、動作チェックなどで使用します。

repr()メソッド(simplemapper.py)

:::python
    def __repr__(self):
        """
        オブジェクトの文字列表記を定義
        """
        rep=str(self.__class__.__name__)+':'
        rownames=[v[0] for v in self.__class__.rows]
        rep+=', '.join(["%s=%s"%(x, repr(getattr(self, x))) for x in rownames])
        return "<%s>"%rep

これでBaseMapperクラスは完成です。

2014-09-03 15:00