이번 포스팅은 실제 Database에 외래키를 걸지 않고 python의 유명한 DB 모듈인 sqlalchemy의 orm 기능을 사용하여 외래키 없이 사용하는 방법을 설명하고자 한다.
다른 이유도 있겠지만, 간단히 이유를 정리해보자.
회원이 있고, 회원은 글을 쓸 수 있으며, 글에 태그을 작성할 수 있는 상황을 예시로 보자.
위 상황에서는
일대다 관계
다대다 관계
이런 관계를 생성해 볼 수 있다.
📦one_to_one
┣ 📂model
┃ ┣ 📜base.py
┃ ┣ 📜post.py
┃ ┗ 📜user.py
┣ 📜one_to_many.py
┗ 📜README.md
# model/base.py
from typing import Any
from sqlalchemy.ext.declarative import as_declarative, declared_attr
@as_declarative() # sqlalchemy의 declarative base로 동작하도록 클래스를 설정
class Base:
id: Any
__name__: str
@declared_attr
def __tablename__(cls) -> str:
# 자동으로 __tablename__을 상속한 클래스 이름 소문자로 만들어 주는 기능
return cls.__name__.lower()
sqlalchemy에서 모델(테이블)을 만들 기본 내용이다. 위 Base 클래스를 상속받으면 주석 내용처럼, 상속 받은 클래스의 이름을 받아 소문자로 만들어 tablename 속성이름으로 지정하게 된다.
# model/user.py
from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import relationship
from .base import Base
class User(Base):
id = Column(Integer, primary_key=True, autoincrement=True)
nickname = Column(String(100), nullable=False)
password = Column(String(100), nullable=False)
posts = relationship( # post랑 1대다 관계
'Post',
primaryjoin='User.id == Post.owner_id',
back_populates='owner',
foreign_keys='Post.owner_id',
lazy='selectin'
)
@property
def my_posts(self):
""" 해당 user가 작성한 글들 조회"""
return [post.title for post in self.posts]
일대다 관계중 일에 해당하는 회원 모델 내용이다.
# model/post.py
from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import relationship
from .base import Base
class Post(Base):
id = Column(Integer, primary_key=True, autoincrement=True)
title = Column(String(255))
description = Column(String(500))
owner_id = Column(Integer)
owner = relationship( # User랑 1대다 관계
'User',
primaryjoin='Post.owner_id == User.id',
back_populates='posts',
foreign_keys=owner_id,
lazy='selectin'
)
@property
def owner_name(self):
""" 해당 post의 작성자 조회"""
return self.owner.nickname if self.owner else None
일대다 관계중 다에 해당하는 글 모델 내용이다.
# one_to_many.py
from model.user import User
from model.post import Post
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
engine = create_engine("sqlite:///one_to_many.db")
session_factory = sessionmaker(autocommit=False,
autoflush=False,
bind=engine)
def init_db() -> None:
"""
one_to_many.db파일 생성 및 테이블 생성
"""
# 모든 model들은 Base를 상속받아서 다른 테이블은 따로 생성해주지 않아도 자동으로 생성됨
User.metadata.create_all(bind=engine)
def create_user(*, user_in: dict) -> User:
"""
사용자 만드는 기능
"""
with session_factory() as session:
db_obj = User(**user_in)
session.add(db_obj)
session.commit()
# relationship에 매핑시킨 속성 refresh
session.refresh(db_obj, attribute_names=['posts'])
return db_obj
def create_post(*, post_in: dict) -> Post:
"""
글 만드는 기능
"""
with session_factory() as session:
db_obj = Post(**post_in)
session.add(db_obj)
session.commit()
# relationship에 매핑시킨 속성 refresh
session.refresh(db_obj, attribute_names=['owner'])
return db_obj
def get_user_posts(*, id: int):
with session_factory() as session:
query = select(User).filter(User.id == id).limit(1)
result = session.execute(query)
data = result.scalars().first()
return data
if __name__ == '__main__':
init_db() # 테이블 생성
# user는 id,nickname,password 컬럼을 가짐
user = create_user(user_in={'nickname': 'dev', 'password': 'dev'})
# post는 id,title,description,owner_id 컬럼을 가짐
post1 = create_post(post_in={'title': 'o2m test1글 제목입니다',
'description': 'o2m test1 설명', 'owner_id': user.id})
post2 = create_post(post_in={'title': 'o2m test2글 제목입니다',
'description': 'o2m test2 설명', 'owner_id': user.id})
# post 에서 글 쓴 user의 nickname 출력 가능
print(f'post1 글을 쓴 사람은 : {post1.owner_name}입니다.')
print(f'post2 글을 쓴 사람은 : {post2.owner_name}입니다.')
# user가 쓴 글 목록 확인
user = get_user_posts(id=user.id)
print(
f'user가 쓴 글목록은 : {user.my_posts}입니다.')
위에 작성한 user 와 post 모델로 실제 외래키가 없는지 확인하고 , 어떻게 서로 관계된 데이터를 가져오는지에 대해 알아보자.
위 one_to_many.py 의 파일을 실행해보면, 실제 user 테이블은 init_db()에 의해
CREATE TABLE user (
id INTEGER NOT NULL,
nickname VARCHAR(100) NOT NULL,
password VARCHAR(100) NOT NULL,
PRIMARY KEY (id)
)
위 쿼리로 테이블이 생성된다.
post 테이블도 보면
CREATE TABLE post (
id INTEGER NOT NULL,
title VARCHAR(255),
description VARCHAR(500),
owner_id INTEGER,
PRIMARY KEY (id)
)
위 쿼리로 테이블이 생성되는 걸 확인 할 수 있다.
테이블을 생성하는 쿼리를 보면 서로 간의 외래키(FK)가 걸려있지 않음을 확인할 수 있다.
실제 외래키는 걸려있지 않지만, 모델을 생성할 때 작성한 코드를 봐보면
# model/user.py
posts = relationship( # post랑 1대다 관계
'Post',
primaryjoin='User.id == Post.owner_id',
back_populates='owner',
foreign_keys='Post.owner_id',
lazy='selectin'
)
# model/post.py
owner = relationship( # User랑 1대다 관계
'User',
primaryjoin='Post.owner_id == User.id',
back_populates='posts',
foreign_keys=owner_id,
lazy='selectin'
)
서로 간의 관계를 relationship()을 활용하여 지정해 주었기에 실제 Database에 외래키가 없어도,
User와 Post 객체간의 관계를 맺어줌으로써 FK대신 사용해줄 수 있다.
위 부분에서 lazy=’selectin’ 은 sqlalchemy의 전략 로딩인데 해당 내용은
sqlalchemy 로딩 전략 공식문서 에 자세히 나와있다. ‘selectin’은 eager loading 이라 부르는데, 이는 객체에 접근할 때 연관되어 있는 모든 내용을 불러오겠다라고 생각하면된다.
위 관계를 활용해 사용해보면,
print(f'post1 글을 쓴 사람은 : {post1.owner.nickname}입니다.')
print(f'post2 글을 쓴 사람은 : {post2.owner.nickname}입니다.')
출력
post1 글을 쓴 사람은 : dev입니다.
post2 글을 쓴 사람은 : dev입니다.
출력에서 볼수 있듯이. Post는 실제 table에 nickname이라는 컬럼이 없지만, relationship()로 맺어준 관계를 이용해 글쓴 사람의 이름(nickname)을 조회 할 수 있다.
반대로 user(일) 은 여러개의 post(다)를 조회할 수 있다.
user = get_user_posts(id=user.id)
print(f'user가 쓴 글목록은 : {user.my_posts}입니다.')
출력
user가 쓴 글목록은 : ['test1글 제목입니다', 'test2글 제목입니다']입니다.
위 출력 처럼, user입장에서도 post들의 제목을 조회 가능하다.
다대다 관계는 one to many와 다르게 서로를 연결해 줄 수 있는 중간 모델(테이블)이 필요하다.
post와 tag 사이의 중간 테이블 즉, post_tag 와 같은 중간 테이블이 있어야 다대다 관계 매핑 을 해줄 수 있다.
📦many_to_many
┣ 📂model
┃ ┣ 📜base.py
┃ ┣ 📜tag.py
┃ ┣ 📜post.py
┃ ┗ 📜post_tag.py
┣ 📜many_to_many.py
┗ 📜README.md
# one to many 의 base.py와 동일
# model/base.py
from typing import Any
from sqlalchemy.ext.declarative import as_declarative, declared_attr
@as_declarative() # sqlalchemy의 declarative base로 동작하도록 클래스를 설정
class Base:
id: Any
__name__: str
@declared_attr
def __tablename__(cls) -> str:
# 자동으로 __tablename__을 상속한 클래스 이름 소문자로로 만들어 주는 기능
return cls.__name__.lower()
# model/post.py
from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import relationship
from .base import Base
class Post(Base):
id = Column(Integer, primary_key=True, autoincrement=True)
title = Column(String(255), nullable=False)
description = Column(String(500), nullable=False)
tags = relationship( # tag랑 다대다 관계
'tag',
secondary='post_tag',
primaryjoin='Post.id == post_tag.c.post_id',
secondaryjoin='post_tag.c.tag_id == tag.id',
back_populates='posts',
lazy='selectin'
)
@property
def in_tags(self):
return [tag.content for tag in self.tags]
# model/tag.py
from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import relationship
from .base import Base
class tag(Base):
id = Column(Integer, primary_key=True, autoincrement=True)
content = Column(String(500), nullable=False)
posts = relationship( # post랑 다대다 관계
'Post',
secondary='post_tag',
primaryjoin='tag.id == post_tag.c.tag_id',
secondaryjoin='post_tag.c.post_id == Post.id',
back_populates='tags',
lazy='selectin'
)
@property
def in_posts(self):
return [(post.title, post.description) for post in self.posts]
# model/post_tag.py
from sqlalchemy import Column, Integer
from .base import Base
class Post_tag(Base): # 테이블명 때문에 _ 로 이어줌
post_id = Column(Integer, primary_key=True)
tag_id = Column(Integer, primary_key=True)
one to many 관계 와 비슷하다고 생각할 수 있지만, 가장 큰 차이는 relationship()을 통해 관계를 맺어줄 때, secondaryjoin 매개변수를 사용해 중간 모델(테이블)인 post_tag 테이블을 사용한다는 것이다.
# many_to_many.py
from typing import Union
from model.post import Post
from model.tag import tag
from model.post_tag import Post_tag
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker, Session
engine = create_engine("sqlite:///many_to_many.db")
session_factory = sessionmaker(autocommit=False,
autoflush=False,
bind=engine)
def init_db() -> None:
"""
many_to_many.db파일 생성 및 테이블 생성
"""
# 모든 model들은 Base를 상속받아서 다른 테이블은 따로 생성해주지 않아도 자동으로 생성됨
Post.metadata.create_all(bind=engine)
def create_post(*, session: Session, post_in: dict) -> Post:
"""
글 만드는 기능
"""
db_obj = Post(**post_in)
session.add(db_obj)
session.commit()
# relationship에 매핑시킨 속성 refresh
session.refresh(db_obj, attribute_names=['tags'])
return db_obj
def create_tag(*, session: Session, tag_in: dict) -> tag:
"""
태그 만드는 기능
"""
db_obj = tag(**tag_in)
session.add(db_obj)
session.commit()
# relationship에 매핑시킨 속성 refresh
session.refresh(db_obj, attribute_names=['posts'])
return db_obj
def get_model_multi(*, session: Session, model: Union[Post, Post_tag, tag]) -> Union[Post, Post_tag, tag]:
query = select(model)
result = session.execute(query)
data = result.scalars().all()
return data
if __name__ == '__main__':
init_db() # 테이블 생성
session = session_factory()
post1 = create_post(session=session, post_in={'title': 'm2m test1글 제목입니다',
'description': 'm2m test1 설명'})
post2 = create_post(session=session, post_in={'title': 'm2m test2글 제목입니다',
'description': 'm2m test2 설명'})
tag1 = create_tag(session=session, tag_in={
'content': 'm2m test1 태그1 입니다.'})
tag2 = create_tag(session=session, tag_in={
'content': 'm2m test1 태그2 입니다.'})
tag3 = create_tag(session=session, tag_in={
'content': 'm2m test2 태그3 입니다.'})
tag4 = create_tag(session=session, tag_in={
'content': 'm2m test2 태그4 입니다.'})
# 중간 테이블인 post_tag에 post와 tag 저장
tag1.posts.append(post1)
tag2.posts.append(post1)
tag3.posts.append(post2)
tag4.posts.append(post2)
session.commit()
# post로 확인
print('post1의 태그들')
print(post1.in_tags)
print('post2의 태그들')
print(post2.in_tags)
# tag로 확인
print(f'tag1은 이 글의{tag1.in_posts} 태그입니다.')
print(f'tag2은 이 글의{tag2.in_posts} 태그입니다.')
print(f'tag3은 이 글의{tag3.in_posts} 태그입니다.')
print(f'tag4은 이 글의{tag4.in_posts} 태그입니다.')
one to many 처럼 위 확인용 파일의 실행 결과를 보면,
post 테이블 생성 쿼리
CREATE TABLE post (
id INTEGER NOT NULL,
title VARCHAR(255) NOT NULL,
description VARCHAR(500) NOT NULL,
PRIMARY KEY (id)
)
tag 테이블 생성 쿼리
CREATE TABLE tag (
id INTEGER NOT NULL,
content VARCHAR(500) NOT NULL,
PRIMARY KEY (id)
)
post_tag 생성 쿼리
CREATE TABLE post_tag (
post_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
PRIMARY KEY (post_id, tag_id)
)
생성 쿼리에서 확인할 수 있듯이, FK가 걸려있지 않은걸 확인할 수 있다.
# model/post.py
tags = relationship( # tag랑 다대다 관계
'tag',
secondary='post_tag',
primaryjoin='Post.id == post_tag.c.post_id',
secondaryjoin='post_tag.c.tag_id == tag.id',
back_populates='posts',
lazy='selectin'
)
# model/tag.py
posts = relationship( # post랑 다대다 관계
'Post',
secondary='post_tag',
primaryjoin='tag.id == post_tag.c.tag_id',
secondaryjoin='post_tag.c.post_id == Post.id',
back_populates='tags',
lazy='selectin'
)
위 코드와 같이 secondaryjoin 매개변수를 활용해 중가테이블과의 관계도 설정해주어, 외래키 없이 다대다 매핑을 해줄 수 있다.
이의 데이터를 확인해보면,
post로 tag들 조회하기
print('post1의 태그들')
print(post1.in_tags)
print('post2의 태그들')
print(post2.in_tags)
출력
# post로 확인
post1의 태그들
['m2m test1 태그1 입니다.', 'm2m test1 태그2 입니다.']
post2의 태그들
['m2m test2 태그3 입니다.', 'm2m test2 태그4 입니다.']
tag로 post들 조회하기
# tag로 확인
print(f'tag1은 이 글의{tag1.in_posts} 태그입니다.')
print(f'tag2은 이 글의{tag2.in_posts} 태그입니다.')
print(f'tag3은 이 글의{tag3.in_posts} 태그입니다.')
print(f'tag4은 이 글의{tag4.in_posts} 태그입니다.')
출력
tag1은 이 글의[('m2m test1글 제목입니다', 'm2m test1 설명')] 태그입니다.
tag2은 이 글의[('m2m test1글 제목입니다', 'm2m test1 설명')] 태그입니다.
tag3은 이 글의[('m2m test2글 제목입니다', 'm2m test2 설명')] 태그입니다.
tag4은 이 글의[('m2m test2글 제목입니다', 'm2m test2 설명')] 태그입니다.
위 출력과 같이 다대다 관계를 외래키 없이 사용할 수 있음을 확인할 수 있다.
아주 유용한 정보네요!