# Flask實作_ext_02_Flask-SQLAlchemy_關聯
###### tags: `flask` `flask_ext` `python` `SQLAlchemy`
:::danger
官方文件:
* [Declaring Models](http://flask-sqlalchemy.pocoo.org/2.3/models/)
* [Relationships-backref](http://docs.sqlalchemy.org/en/latest/orm/backref.html#relationships-backref)
:::
在[Flask實作_ext_01_Flask-SQLAchemy_初探](https://hackmd.io/s/SJ9x3N9zz)中,我們瞭解到如何透過類別的建置來產生表單,此篇主要討論資料表之間的關聯設置。
關聯式資料庫中,不外乎一對一、一對多、多對一與多對多的關聯方式,關聯的設置影響到我們對物件的操作,老話一句,需求推動功能,先瞭解基本,有進階需求的時候自然會尋找答案,另外,`flask-sqlalchemy`的官方文件對幾種關聯方式都有案例,如果臨時想不到如何設置的時候記得查詢官方文件<sub>如官方文件Declaring Models</sub>。
## 範例
### 範例_一對多
我們利用上一篇的範例來做調整,如下修正:
```python=
from flask import Flask
# from flask.ext.sqlalchemy import SQLAlchemy<--新版取消了
from flask_sqlalchemy import SQLAlchemy
import os
# 取得目前文件資料夾路徑
pjdir = os.path.abspath(os.path.dirname(__file__))
app = Flask(__name__)
# 新版本的部份預設為none,會有異常,再設置True即可。
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = True
# 設置sqlite檔案路徑
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + \
os.path.join(pjdir, 'data.sqlite')
db = SQLAlchemy(app)
class User(db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
# 設置關聯,relationship設置於一對多的『一』
contacts = db.relationship('Contact', backref='user')
def __repr__(self):
return '<User %r>' % self.username
class Contact(db.Model):
__tablename__ = 'contacts'
id = db.Column(db.Integer, primary_key=True)
contact_style = db.Column(db.String)
contact_context = db.Column(db.String)
# 設置外來鍵,ForeignKey設置於一對多的『多』
user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
def __repr__(self):
return 'contact_style:%s, contact_context:%s' % \
(self.contact_style, self.contact_context)
```
:::info
`SQLAlchemy`中,每個Model都要求要設置主鍵,名稱通常設置為id
:::
範例中設置了兩個類別,一個使用者(User),一個聯絡方式(Contact),在寫程式的時候我會特別註記好設置關聯的那一方是『一對多的一或一對多的多』,透過兩邊的設置讓`SQLAlchemy`瞭解關聯的關係。
在Python Shell中執行`db.create_all()`之後,可以透過`sqlite`圖形介面查看,已經確實的產生了兩個Table,如下圖:

```python=24
contacts = db.relationship('Contact', backref='user')
```
`db.relationship`,透過關聯設置讓人家知道`Contact`跟`User`相愛,兩個人之間恩愛透過`backref`來連接關係。<sub>(也可以透過`back_populates`設置)</sub>不過實際上這是一個在雙方都設置監聽器的作法,透過`db.relationship`讓`SQLAlchemy`知道`Contact`跟`User`是有關聯的,但前提是必需設置`ForeignKey`,更深入的說明可參閱參考文件中`Relationships-backref`
```python=35
user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
```
`db.ForeignKey`可很以直觀的以資料庫的角度去看,這代表我們在保存聯絡方式的時候會在資料庫內存儲使用者的ID`users.id`
透過Python Shell來`improt`設定的兩個資料類別,並新增使用者,範例如下:
```python=
>>> from model_login import User
>>> from model_login import Contact
>>> from model_login import db
>>> admin=User(username='admin',email='admin@abc.com')
>>> admin.contacts
[]
```
第4行:建立一個新的使用者,使用者名稱為admin
第5行:查詢關聯,回傳為空的list,這是因為我們尚未建置任何關聯資料
現在我們來建立聯絡方式,範例如下:
```python=
>>> contact = Contact(contact_style='email',contact_context='admin2@abc.com')
>>> admin.contacts.append(contact)
>>> admin.contacts
[contact_style:email, contact_context:admin2@abc.com]
>>> contact.user
<User 'admin'>
```
第1行:建立新的聯絡方式
第2行:利用`append`的方式將該物件寫入`list`,也就是我們設置的關聯`contacts`
第3行:查詢目前使用者的聯絡方式
第5行:查詢這聯絡方式的使用者,注意到`user`即是我們在`db.relationship`中設置的`backref=user`
到這邊,應該可以理解相關的設置邏輯,也可能很驚訝就這樣就已經完成了一個表單的關聯,而且是雙向的設置,這代表如果我們要清除資料的話,只需要刪除一邊即可,如下:
```python=
>>> contact.user = None
>>> admin.contacts
[]
```
上面的案例可以看的出來,將`contact.user`設置為`None`之後,`admin.contacts`也變空的`list`了,非常令人驚豔。
現在我們來看將資料正式的寫入資料庫的情況,如下:
```python=
admin=User(username='admin',email='admin@abc.com')
contact = Contact(contact_style='email',contact_context='admin2@abc.com')
admin.contacts.append(contact)
db.session.add(admin)
db.session.commit()
```
執行之後會發現,雖然`session.add`只有將`admin`這個類寫入,但是在關聯設置之下`contact`也一併寫入資料庫。
#### 另一種關聯寫入資料的方式
```python=
>>> from model_login import db,User,Contact
>>> db.create_all()
>>> user_admin = User(username='admin',email='admin@abc.com')
>>> contact_admin = Contact(contact_style='phone',
contact_context='3345678', user=user_admin)
>>> user_admin.contacts
[contact_style:phone, contact_context:3345678]
>>> contact_admin.user
<User 'admin'>
>>> db.session.add_all([user_admin, contact_admin])
>>> db.session.commit()
```
第5行:實作中加入關聯物件
上面範例,在實作『多』的物件時直接利用`backref`屬性設置關聯之後寫入資料庫。
#### relationship_lazy
在建立好`User`跟`Contact`的關聯之後,我們嚐試著`select`一筆使用者資料,如下:
```python=
>>> user_query = User.query.filter_by(username='admin').first()
>>> user_query.contacts
[contact_style:phone, contact_context:3345678, contact_style:email, contact_context:admin2@abc.com]
```
透過`user_query.contacts`查詢會發現相關關聯資料已經幫你一起帶出了,很不錯吧,又一次的驚豔!
只是這種情況下會有一個問題,如果你想再透過查詢式`query`來查詢你的`contacts`就沒有辦法了,因為生米已成熟飯,它的`type`已經是`list`,我們必需保留它是物件的狀態,在有需要的時候我們再來對關聯資料做二次查詢。
這時候就需要調整`db.relationship`的參數,加入`lazy`,調整如下:
```python=
class User(db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
# 設置關聯,relationship設置於一對多的『一』
contacts = db.relationship('Contact', backref='user', lazy='dynamic')
# def __init__(self, username, email):
# self.username = username
# self.email = email
def __repr__(self):
return '<User %r>' % self.username
```
第7行:加入參數`lazy='dynamic'`
透過參數`lazy='dynamic'`可以讓`SQLAlchemy`在搜尋關聯資料的時候保留物件狀態,如下說明:
```python
>>> user_query = User.query.filter_by(username='admin').first()
>>> user_query.contacts()
<sqlalchemy.orm.dynamic.AppenderBaseQuery object at 0x0000000003ACFC50>
```
這時候回傳資料是一個`SQLAlchemy`物件類別,這樣就可以透過`filter`的方式再來做二次搜尋,或是透過`order_by`做排序,如下範例:
```python=
# 透過all做全部呈現
>>> user_query.contacts.all()
[contact_style:phone, contact_context:3345678, contact_style:email, contact_context:admin2@abc.com]
# 透過order by做排序
>>> user_query.contacts.order_by(Contact.contact_style).all()
[contact_style:email, contact_context:admin2@abc.com, contact_style:phone, contact_context:3345678]
>>> user_query.contacts.order_by(Contact.id).all()
[contact_style:phone, contact_context:3345678, contact_style:email, contact_context:admin2@abc.com]
# 透過filter做過濾條件
>>> user_query.contacts.filter_by(contact_style='email').all()
[contact_style:email, contact_context:admin2@abc.com]
```
### 範例_一對一
兩個Model之間的關聯如果是一對一的話,宣告方式與一對多相同,只需要在主要Model的`db.relationship`中加入參數`uselist=False`就可以,相同於一對多,也可以利用`backref`在主要Model中設置`uselist`。
```python=
class User(db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
# 一對一,設置關聯於主要model
contacts = db.relationship('Contact', backref=backref('user', uselist=False))
def __repr__(self):
return '<User %r>' % self.username
class Contact(db.Model):
__tablename__ = 'contacts'
id = db.Column(db.Integer, primary_key=True)
contact_style = db.Column(db.String)
contact_context = db.Column(db.String)
# 於次要model設置foreign key
user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
def __repr__(self):
return 'contact_style:%s, contact_context:%s' % \
(self.contact_style, self.contact_context)
```
### 範例_多對多
多對多關聯使用情境,舉例來說:
1. 一篇文章可以有多個分類,一個分類可以存在於多篇文章中,這是一個tag設置的問題
2. 一個使用者可以有多個聯絡方式,而一個聯絡方式可能對應到多個人
在`SQLAlchemy`中設置多對多我們並不需要去特別的定義一個Model來做中繼,而是透過`Table`,設罝`MetaData`記錄兩個Model的`ForeignKey`,再於`db.relationship`加入參數`secondary`來設置關聯表,因為我們是利用`flask-sqlalchemy`來操作資料庫,所以設置上可以參考[flask-sqlalchemy的範例](http://flask-sqlalchemy.pocoo.org/2.3/models/#many-to-many-relationships)。
:::danger
flask-sqlalchemy強烈建議,以真正的資料表來記錄多對多的關聯
:::
相關建置說明如下註解:
```python=
# 設置中繼的關聯表
# flask-sqlalchemy會自動的在資料庫中產生相對應的table
relations = db.Table('relations',
db.Column('user_id', db.Integer, db.ForeignKey('users.id')),
db.Column('contact_id', db.Integer, db.ForeignKey('contacts.id'))
)
class User(db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
# 利用secondary設置關聯中繼表
# lazy的部份可以依需求設置為動態與否
contacts = db.relationship('Contact', secondary=relations, lazy='subquery',
backref=db.backref('user', lazy=True))
def __repr__(self):
return '<User %r>' % self.username
class Contact(db.Model):
__tablename__ = 'contacts'
id = db.Column(db.Integer, primary_key=True)
contact_style = db.Column(db.String)
contact_context = db.Column(db.String)
# 不需要設置foreign key
# user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
def __repr__(self):
return 'contact_style:%s, contact_context:%s' % \
(self.contact_style, self.contact_context)
```
在利用Python Shell產生資料庫之後,我們來看一下資料庫的狀態,如下圖:

可以看到,雖然我們沒有利用Model來設置`table`,但`SQLAlchemy`會依`db.Table`的設置來產生中繼表,接著產生資料看看它的存取狀態為何,如下:
```python=
# 我們先import設置的model
from app import User
from app import Contact
# 增加一個使用者為admin
user = User(username='admin',email='admin@abc.com')
# 增加一個聯絡人
contact = Contact(contact_style='mobile', contact_context='3345678')
# 將聯絡人寫入使用者
user.contacts.append(contact)
# 將資料寫入
db.session.add(user)
db.session.commit()
```
首先確認資料庫的記錄狀態,可以發現資料庫的中繼表確實的記錄的雙邊的關聯,如下圖:

現在,手動再新增一個使用者,並且套用相同的聯絡人看看,如下:
```python=
user = User(username='Cust', email='Cust@abc.com')
contact = Contact.query.filter_by(id=1).first()
user.contacts.append(contact)
db.session.add(user)
db.session.commit()
```
資料庫的變化狀況如下:

接著測試搜尋`Contact`,結果如下,直接取下文字貼上:
```python=
>>> contact = Contact.query.filter_by(id=1).first()
>>> contact
contact_style:mobile, contact_context:3345678
>>> contact.user
[<User 'admin'>, <User 'Cust'>]
```
利用`Contact`回查`user`會依我們設置的`relation lazy`參數來回傳,因為我們設置`lazy=True`,意義等同於`lazy=select`,如果我們希望在必要時候再透過`filter`來取值的話,那就記得使用`lazy=dynamic`,這時候就會如下方一般的回傳物件:
```python=
>>> contact.user
<sqlalchemy.orm.dynamic.AppenderBaseQuery object at 0x030DF790>
```
最後,測試一下刪除,如果我們將聯絡人刪除,這時候的資料庫狀況為何?
```python=
>>> db.session.delete(contact)
>>> db.session.commit()
```

這時候的中繼表相關資料也被刪除了,如果不想要這樣連動刪除資料的話可以透過參數設置來避免。
[sqlalchemy的官方說明](http://docs.sqlalchemy.org/en/latest/orm/basic_relationships.html#association-object),雖然說我們使用的是`flask-sqlalchemy`,但本質上還是小調整之後可以完成的。
## 其它
### 多筆寫入
多筆寫入的情況下可以透過`db.session.add_all([...list...])`的方式來寫入,如果是大量資料要寫入的話,可以利用`bulk`,這部份也可以查詢`SQLAlchemy`官方文件
## 總結
幾個關聯方式練習下來相信對`SQLAlchemy`都有了初步的瞭解,這已經足夠我們建置系統使用了,不足的在有需求的時候查詢官方文件相信是會有相對應的答案。
**Flask-SQLAlchemy_初探**:[Flask實作_ext_01_Flask-SQLAlchemy_初探](https://hackmd.io/s/SJ9x3N9zz)
**Flask-SQLAlchemy_Query**:[Flask實作_ext_18_Flask-SQLAlchemy_Query](https://hackmd.io/s/rkFtMQg47)
## 延伸閱讀
tag設置並非本篇的主旨,但是tag並非一定要設置為多對多,也可以設置一個字串欄位來保存,這邊提供二篇用於mysql中的設置說明,讓人驚訝的是,在tag愈見增長的時候,多對多的效能反而是較不好的。
[設置說明](http://howto.philippkeller.com/2005/04/24/Tags-Database-schemas/)
[效能測試](http://howto.philippkeller.com/2005/06/19/Tagsystems-performance-tests/)