Pythonライブラリ(SQL):SQLAlchemy-relationshipとテーブル設計
概要
下記記事でSQLを操作できるORMのSQLAlchemyを紹介しました。
SQLAlchemyの機能であるrelation/relationshipに関して内容が多く、かつ事前に必要な知識が多かったため別記事で作成しました。なお事前知識のキーワードは下記に一覧で紹介します。
※現在勉強中のため内容は不足しているためあくまで備忘録レベルです。
1.DB設計
文字通りデータベース(DB)の設計ですが下記を目的としております。
ここで特に重要なのが正規化となります。具体的にはDBのテーブルをひとまとめにするのではなく目的・機能に応じて(分割して)複数のテーブルを作成します(ほしいテーブルはJOIN結合して作成する)。
2.正規化
正規化に関しては本・ブログ・Youtubeなどで情報が出ているため、本章はあくまで自分用の備忘録に近い内容になります。正規化は第1~6(+第3.5:ボイス-コッド)までありますが、本章では第1~3のみ紹介します。
サンプルテーブルとしては下記をイメージしております。
2-1:第1正規化
第1正規化の定義は「一つのセルの中には一つの値しか含まない」です。1つのセルに複数データ入力しているテーブルは間違いです(下表参照)。
2-2:第2正規化~部分関数従属
第2正規化の定義は「テーブルの部分関数従属を解消して完全関数従属にする」です。関数従属性とは関数f(x)の様に、特定の値xに対して一意の値f(x)がでる関係です。例として大人テーブルよりid選択で名前が決まる({id}→{名前})ような状態です。
部分関数従属は主キーに対して関数従属性がある列が存在することであり、問題点は下記の通りです。
具体例でいうと元の表だと名前の入力間違いで{id}->{name}の整合性が取れなくなったり、必要なデータを入力するとNULLが発生したりします。
そのような問題をなくすためにテーブルを分割して第2正規化します。
2-3:第3正規化~推移的関数従属
第3正規化の定義は「推移的関数従属が存在しない」です。推移的関数従属とはテーブル内に存在する段階的な従属関係です。
現状だと{id, ch_age}->{code_school}->{type}と段階的な従属性があるため第2正規化と同様に必要なデータが入力できません。
この従属性をなくすためにテーブルを更に分割します。これにより必要なデータ(中学校、大学院)も追加することができました。
2-4.ER図(Entity-Relationship Diagram)
正規化をするとテーブルが増え管理が難しくなるため見える化が必要です。各テーブルの関係を示す図をER図と呼びます。
ここでは用語のみの紹介であり今回のケースでは(多分)下記のような図になります。
2-4.まとめ(自分流)
SQLでのテーブル作成時の注意点は下記の通りです。
3.SQLAlchemy:コード(外部キー設定なし)
まずは細かい説明を抜きにして正規化の動きを確認します。なお出力は作成した"HorizontalDisplay"を使用しますが見やすさの観点からOUTPUTの一部は切り取り加工しております。
3-1.サンプルデータ作成(Pandas)
今回使用するサンプルデータをPandasを用いて作成しました。後ほど下記データをfor文でDBに追加していきます。
[In]
import pandas as pdm
#Notebookに複数のDataFrameを(水平に)出力する
class HorizontalDisplay:
def __init__(self, *args):
self.args = args
def _repr_html_(self):
template = '<div style="float: left; padding: 10px;">{0}</div>'
return "\n".join(template.format(arg._repr_html_())
for arg in self.args)
parents =[
{'p_age':40, 'p_name':'A'},
{'p_age':30, 'p_name':'B'},
{'p_age':20, 'p_name':'C'},
]
children = [
{'ch_age':20, 'code_school': 'S005','id_Parent':1},
{'ch_age':17, 'code_school': 'S004','id_Parent':1},
{'ch_age':15, 'code_school': 'S004','id_Parent':1},
{'ch_age':10, 'code_school': 'S002','id_Parent':2},
{'ch_age':1, 'code_school': 'S001','id_Parent':2}
]
schools = [
{'code_school':'S000', 'type':'家'},
{'code_school':'S001', 'type':'保育園'},
{'code_school':'S002', 'type':'小学校'},
{'code_school':'S003', 'type':'中学校'},
{'code_school':'S004', 'type':'高校'},
{'code_school':'S005', 'type':'大学'},
{'code_school':'S006', 'type':'大学院'},
]
df_parent = pd.DataFrame(parents)
df_children = pd.DataFrame(children)
df_schools = pd.DataFrame(schools)
display(HorizontalDisplay(df_parent, df_children, df_schools))
[Out]
【class HorizontalDisplayについて】
PandasのDataFrame型をJupyterで横並びに表示させるためのクラスです。体裁を整えるためだけのため通常は記載不要です。
3-2.SQLAlchemyのコード(engine, session, table)
データベース作成のためのコードを記載します。今回はDBファイルは作成せずメモリ上('sqlite:///:memory:')で作成しました。
注意点として「relationshipにcascadeを設定していない」状態であり、これは別章で修正(追記)します。
[In]
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, scoped_session, relationship
from sqlalchemy.orm import Session #データ型取得
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import Table, Column, Integer, Float, String, DateTime, Boolean, ForeignKey #SQLテーブルのカラム設定用
from sqlalchemy import desc #降順ソート
import pandas as pd
from typing import Optional, List, Dict
engine = create_engine('sqlite:///:memory:', echo=True) #メモリ上にDB作成(一時的な処理用)
Session = scoped_session(
sessionmaker(
autocommit=False, #commit自動化の設定
autoflush=True, #flush自動化の設定
bind = engine
)
)
Base = declarative_base() #DB基底クラスの作成
Base.query = Session.query_property() #DBクエリの設定
class Parent(Base):
__tablename__ = 'parent' #テーブル名の作成
id = Column(Integer, primary_key=True) #データログid設定:主キー制約
age = Column(Float)
name = Column(String)
children = relationship('Child', backref='parent') #Childテーブルとのリレーション設定
def __init__(self, age, name):
self.age = age
self.name = name
class Child(Base):
__tablename__ = 'child' #テーブル名の作成
id = Column(Integer, primary_key=True) #データログid設定:主キー制約
ch_age = Column(Float)
code_school = Column(String, ForeignKey('school.code_school'))
id_Parent = Column(Integer, ForeignKey('parent.id')) #外部キー設定
# parents = relationship('Parent', backref='child') #backrefのため記載不要※back_populatesの場合は要記載
# schools = relationship('School', backref='child') #backrefのため記載不要※back_populatesの場合は要記載
def __init__(self, ch_age, code_school, id_Parent):
self.ch_age = ch_age
self.code_school = code_school
self.id_Parent = id_Parent
class School(Base):
__tablename__ = 'school'
code_school = Column(String, primary_key=True)
type = Column(String)
childen = relationship('Child', backref='school') #Parentクラスとの関連付け
def __init__(self, code_school, type):
self.code_school = code_school
self.type = type
def init_DB():
Base.metadata.create_all(bind=engine) #DB作成/初期化
init_DB()
[Out]
メモリ上にParent, Child, Schoolテーブルが作成されます。
※echo=Trueにしているため、詳細はそちらで確認のこと
3-3.データ追加(INSERT):Session.add(instance)
前節で作成したテーブルにデータを追加していきます。
[In]
def insert_data():
for p in parents:
p_ins = Parent(p['age'], p['name'])
Session.add(p_ins)
for chl in children:
chl = Child(chl['ch_age'], chl['code_school'], chl['id_Parent'])
Session.add(chl)
for school in schools:
school = School(school['code_school'], school['type'])
Session.add(school)
Session.commit()
insert_data()
[Out]
サンプルデータを各テーブルに登録
3-4.テーブル確認用関数:READ及びJOIN
動作確認用にテーブルを表示する関数を作成しました。テーブル同士の結合はSQL文で作成しております(参考資料の本をベースに作成)。
[IN]
def showdfs():
df_P = pd.read_sql_query('SELECT * FROM parent', engine)
df_Chl = pd.read_sql_query('SELECT * FROM child', engine)
df_Sch = pd.read_sql_query('SELECT * FROM school', engine)
display(HorizontalDisplay(df_P, df_Chl, df_Sch))
showdfs()
[OUT]
[IN]
def showjoin_2tbl():
df_INjoin = pd.read_sql_query('''SELECT chl.id, chl.ch_age, chl.code_school, chl.id_parent, P.age, P.name
FROM parent AS P INNER JOIN child AS chl
ON P.id = chl.id_Parent'''
, engine)
df_OUTjoin = pd.read_sql_query('''SELECT chl.id, chl.ch_age, chl.code_school, chl.id_parent, P.age, P.name
FROM parent AS P LEFT OUTER JOIN child AS chl
ON P.id = chl.id_Parent'''
, engine)
display(HorizontalDisplay(df_INjoin, df_OUTjoin))
showjoin_2tbl()
[OUT]
※OUTER JOINにおいてCさんのParentテーブル側のid(P.id)は3という数値がありますが、Child側に情報がないためid_ParentはNullになります。
[IN]
def showjoin_all():
df_INjoin = pd.read_sql_query('''SELECT chl.id, chl.ch_age, chl.code_school, chl.id_parent,
P.age, P.name, sch.type
FROM parent AS P INNER JOIN child AS chl
ON P.id = chl.id_Parent
INNER JOIN school As sch
ON sch.code_school = chl.code_school
''' , engine)
df_OUTjoin = pd.read_sql_query('''SELECT chl.id, chl.ch_age, chl.code_school, chl.id_parent,
P.age, P.name, sch.type
FROM parent AS P LEFT OUTER JOIN child AS chl
ON P.id = chl.id_Parent
LEFT OUTER JOIN school As sch
ON sch.code_school = chl.code_school
''' , engine)
display(HorizontalDisplay(df_INjoin, df_OUTjoin))
showjoin_all()
[OUT]
3-5.動作確認1:UPDATE
「Aさんの名前を変更(UPDATE)」した場合の動作を確認しました。
[In]
# データ更新
p1 = Session.query(Parent).filter(Parent.id == 1).first()
p1.name = "A変更"
Session.commit()
showdfs()
showjoin_all()
[OUT]
3-6.動作確認2:DELETE
次に「Aさんを削除(DELETE)」した場合の動作を確認しました。
3-7.動作確認3:CREATE(※制約条件)
次に親テーブル(Parent, School)に存在しないデータを子テーブル(Child)に登録できるか確認しました。
[IN]
child_er = Child(100, 'S100', 10)
Session.add(child_er)
Session.commit()
showdfs()
showjoin_all()
[OUT]
4.SQLAlchemy:relation設定
前章で既に使用済みですがテーブル関係の設定に関して紹介します。
4-1.★外部キーの設定(SQLite)★
大前提として「SQLite v3.6.19まで外部キー制約が機能していない」です。よって旧式のSQLiteの場合は"engine.execute('PRAGMA foreign_keys = true;')"で機能をONにする必要があります。
[IN]
engine = create_engine('sqlite:///:memory:', echo=True)
engine.execute('PRAGMA foreign_keys = true;') #外部キー制約追加
[OUT]
制約追加
4-2.relationship
relationship(relationも存在するがrelationshipと同義)は別テーブルとの紐づけ設定に使用され、テーブルの整合性を保つために重要な機能です。
記法としてrelationship("関連テーブルクラス名")とします。また引数は下記の通りです。
[IN]
class Parent(Base):
__tablename__ = 'parent'
id = Column(Integer, primary_key=True)
children = relationship("Child", back_populates="parent")
class Child(Base):
__tablename__ = 'child'
id = Column(Integer, primary_key=True)
parent_id = Column(Integer, ForeignKey('parent.id'))
parent = relationship("Parent", back_populates="children")
[OUT]
※参考用コードのため出力無し
4-3.ForeignKey(外部キー)
ForeignKey(外部キー)とは別テーブルを結合するときの”結合列”同士を紐づける設定となります。(前章では制約がかかっていませんが)外部キーを設定すると親テーブルには存在しないデータを子ファイルに登録できない(制約を課す)ように設定できます。
外部キーの記法はColumnの中にForeignKey('親テーブル名.属性')です。
[IN]
class Parent(Base):
__tablename__ = 'parent' #テーブル名称:親テーブル
id = Column(Integer, primary_key=True)
children = relationship("Child") #適当な変数 = relationship("子テーブルクラス名")
class Child(Base):
__tablename__ = 'child' #テーブル名称:子テーブル
id = Column(Integer, primary_key=True)
parent_id = Column(Integer, ForeignKey('parent.id'))
[OUT]
※参考用コードのため出力無し
4-4.CASCADE(カスケード)
カスケードは親テーブルのレコードが削除された時に、関連された子テーブルのデータの動作(そのまま、削除に合わせてNULL設定など)設定です。
理想として「子テーブルのデータを変更・削除してから親テーブルも更新」すればトラブルは避けられます。
記法はcascade="設定1, 設定2,・・"のようにカンマ区切りで文字列として入力します。
5.SQLAlchemy:コード(外部キー設定あり)
5-1.サンプルコード作成
前章でrelation設定をいろいろ記載しましたが、ここではengineオブジェクトの下に「engine.execute('PRAGMA foreign_keys = true;')」のみ追記して動作チェックします。
[IN]
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, scoped_session, relationship
from sqlalchemy.orm import Session #データ型取得
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import Table, Column, Integer, Float, String, DateTime, Boolean, ForeignKey #SQLテーブルのカラム設定用
from sqlalchemy import desc #降順ソート
import pandas as pd
from typing import Optional, List, Dict
engine = create_engine('sqlite:///:memory:', echo=True) #メモリ上にDB作成(一時的な処理用)
engine.execute('PRAGMA foreign_keys = true;') #SQLiteの外部キー制約をON
Session = scoped_session(
sessionmaker(
autocommit=False, #commit自動化の設定
autoflush=True, #flush自動化の設定
bind = engine
)
)
Base = declarative_base() #DB基底クラスの作成
Base.query = Session.query_property() #DBクエリの設定
class Parent(Base):
__tablename__ = 'parent' #テーブル名の作成
id = Column(Integer, primary_key=True) #データログid設定:主キー制約
age = Column(Float)
name = Column(String)
children = relationship('Child', backref='parent') #Childテーブルとのリレーション設定
# children = relationship('Child', backref='parent', cascade="all, delete-orphan") #Childテーブルとのリレーション設定
def __init__(self, age, name):
self.age = age
self.name = name
class Child(Base):
__tablename__ = 'child' #テーブル名の作成
id = Column(Integer, primary_key=True) #データログid設定:主キー制約
ch_age = Column(Float)
code_school = Column(String, ForeignKey('school.code_school', ondelete="SET NULL"))
id_Parent = Column(Integer, ForeignKey('parent.id', ondelete="SET NULL")) #外部キー設定
# parents = relationship('Parent', backref='child') #backrefのため記載不要※back_populatesの場合は要記載
# schools = relationship('School', backref='child') #backrefのため記載不要※back_populatesの場合は要記載
def __init__(self, ch_age, code_school, id_Parent):
self.ch_age = ch_age
self.code_school = code_school
self.id_Parent = id_Parent
class School(Base):
__tablename__ = 'school'
code_school = Column(String, primary_key=True)
type = Column(String)
childen = relationship('Child', backref='school') #Parentクラスとの関連付け
# childen = relationship('Child', backref='school', cascade="all, delete-orphan") #Parentクラスとの関連付け
def __init__(self, code_school, type):
self.code_school = code_school
self.type = type
def init_DB():
Base.metadata.create_all(bind=engine) #DB作成/初期化
init_DB()
[OUT]
メモリ上にDB作成
[IN]※上記実行でDBがリセットされるためデータ追加を再実施
def insert_data():
for p in parents:
p_ins = Parent(p['age'], p['name'])
Session.add(p_ins)
for chl in children:
chl = Child(chl['ch_age'], chl['code_school'], chl['id_Parent'])
Session.add(chl)
for school in schools:
school = School(school['code_school'], school['type'])
Session.add(school)
Session.commit()
insert_data()
[OUT]
データが追加(CREATE)
5-2.動作確認1:UPDATE
「Aさんの名前を変更(UPDATE)」した場合の動作を確認しました。
[IN]
# データ更新
p1 = Session.query(Parent).filter(Parent.id == 1).first()
p1.name = "A変更"
Session.commit()
showdfs(), showjoin_all()
[OUT]
5-3.動作確認2:DELETE
次に「Aさんを削除(DELETE)」した場合の動作を確認しました。
5-4.動作確認3:CREATE(※制約条件)
次に親テーブル(Parent, School)に存在しないデータを子テーブル(Child)に登録できるか確認しました。
[IN]
child_er = Child(100, 'S100', 10)
Session.add(child_er)
Session.commit()
showdfs(), showjoin_all()
[OUT]
IntegrityError: (sqlite3.IntegrityError) FOREIGN KEY constraint failed
[SQL: INSERT INTO child (ch_age, code_school, "id_Parent") VALUES (?, ?, ?)]
[parameters: (100.0, 'S100', 10)]
(Background on this error at: https://sqlalche.me/e/14/gkpj)
6.まとめ(自分用)
最後に備忘録用のまとめです。
7.番外編:Pandasによるデータ結合
メイン記事の説明ではSQL文のJOINを使用したデータ結合を実施しましたが参考までにPandasでも同様の結合ができることを紹介します。
データは本編でも使用した下記を使用します。
[IN]
import pandas as pd
df_P = pd.read_sql_query('SELECT * FROM parent', engine)
df_Chl = pd.read_sql_query('SELECT * FROM child', engine)
df_Sch = pd.read_sql_query('SELECT * FROM school', engine)
display(HorizontalDisplay(df_P, df_Chl, df_Sch))
[OUT]
7-1.df.merge()の記法
DataFrameの結合はdf.merge()メソッドを使用します。記法としては2種ありますがどちらでも対応可能です。
[記法1:df.merge()]
df1.merge(df2, how = <結合方法>, on = ”結合キー”)
[記法2:pd.merge()]
pd.merge(df1, df2, how = <結合方法>, on = ”結合キー”)
7-2.実践コード1:内部結合(INNER JOIN)
7-2-1.接続キー名が異なるテーブルの結合
内部結合はhow='inner'を使用します。howのデフォルトが'inner'のため下記で実行可能です。df.merge()では結合時に使用したカラム名の後ろに自動で接尾語として'_x', '_y'が接続されます。
[IN]
df = df_P.merge(df_Chl, left_on='id', right_on='id_Parent')
# pd.merge(df_P, df_Chl, left_on='id', right_on='id_Parent') #同上
df
df[['id_y', 'ch_age', 'code_school', 'id_Parent', 'age', 'name']] #表示カラムを指定
[OUT]
【注意事項】
引数のsuffixesを使用して接尾語を変更可能ですが、接尾語そのものをなくそうとすると「カラム名の重複」でエラーが出ます。
[In]
df_P.merge(df_Chl, left_on='id', right_on='id_Parent', suffixes=(False, False))
[OUT]
ValueError: columns overlap but no suffix specified: Index(['id'], dtype='object')
7-2-2.接続キー名が同じ(揃えた)テーブルの結合
接尾語が邪魔だと感じる場合は①親テーブルと子テーブルのカラム名を統一、②df.merge(on='接合キー')を使用します。
※もともと両テーブルの接続キー名が同じ場合も下記記法で実行できます。
[IN]
df_P = df_P.rename(columns={'id':'id_Parent'}) #df_Pのカラム名をid->id_Parentに変更
df_P.merge(df_Chl, on='id_Parent') #id_Parentを指定してマージ
[OUT]
7-2-3.必要なカラムを抽出して結合
結合時に必要なカラムを抽出するなら、結合時にdf[['col1', 'col2',・・・]]のようにDataFrameから必要なカラムを事前に選択します。
[IN]
df_P = df_P.rename(columns={'id':'id_Parent'}) #df_Pのカラム名をid->id_Parentに変更
df_P.merge(df_Chl[['id', 'id_Parent']], on='id_Parent') #Childのidとid_Parentのみ抽出ー>id_Parentで結合
[OUT]
7-2-4.複数(3つ以上)のテーブルを結合
すべてを応用して3つのテーブルを結合します。
[IN]
df_P = df_P.rename(columns={'id':'id_Parent'}) #df_Pのカラム名をid->id_Parentに変更
_df = df_P.merge(df_Chl, on='id_Parent') #id_Parentを指定してマージ
df = _df.merge(df_Sch, on='code_school') #code_schoolを指定してマージ
df
[OUT]
7-3.実践コード:外部結合(OUTER JOIN)
7-3-1.接続キー名が異なるテーブルの結合
外部結合の記法は内部結合と同じであり、how='left'を設定します。
[IN]
import pandas as pd
df_P = pd.read_sql_query('SELECT * FROM parent', engine)
df_Chl = pd.read_sql_query('SELECT * FROM child', engine)
df_Sch = pd.read_sql_query('SELECT * FROM school', engine)
df = df_P.merge(df_Chl, left_on='id', right_on='id_Parent', how='left')
display(df)
[OUT]
7-3-2.接続キー名が同じ(揃えた)テーブルの結合
こちらも同様に①親テーブルと子テーブルのカラム名を統一、②df.merge(on='接合キー')、③how='left' とします。
[IN]
df_P = df_P.rename(columns={'id':'id_Parent'}) #df_Pのカラム名をid->id_Parentに変更
df_P.merge(df_Chl, on='id_Parent', how='left') #Childのidとid_Parentのみ抽出ー>id_Parentで結合
[OUT]
7-2-3.複数(3つ以上)のテーブルを結合
すべてを応用して3つのテーブルを結合します。
[IN]
df_P = df_P.rename(columns={'id':'id_Parent'}) #df_Pのカラム名をid->id_Parentに変更
_df = df_P.merge(df_Chl, on='id_Parent', how='left') #Childのidとid_Parentのみ抽出ー>id_Parentで結合
df = _df.merge(df_Sch, on='code_school', how='left') #code_schoolを指定してマージ
df
[OUT]
参考資料
あとがき
いろいろ調べながら変なところをぐるぐる回ったため体系的にまとめられていない可能性が高いです。
とりあえず使えるところまで行ったのでいったん投稿。
この記事が気に入ったらサポートをしてみませんか?