Language/Python

[SQLAlchemy] Imperative Mapping, One to Many Mapping

j4ko 2024. 3. 15. 00:34
728x90
반응형

목차

     

    관련글

    2024.02.25 - [Language/Python] - [SQLALchemy] Imperative Mapping, Eager/Lazy Loading

     

    [SQLALchemy] Imperative Mapping, Eager/Lazy Loading

    HTML 삽입 미리보기할 수 없는 소스 개요 SQLAlchemy의 imperative mapping 방식을 계속 다루다 보니 Repository라고 정의한 계층에서 Session Query를 많이 작성하게 된다. 예를 들어 다음과 같은 형식이다. sessio

    jakpentest.tistory.com

     

    개요 

    이 글은 SQLAlchemy의 Imperative Mapping 방식을 이용해 1:N의 관계를 다루는 방법에 관해 기술한 것이다.

     

    Imperarive Mapping에 관해 참고할만한 자료가 드물기에 이 글에서 다루는 내용이 SQLAlchemy의 공식 가이드에 나오는 내용은 아니다.

     

    1.  Schema

    다루조가 하는 Schema는 Member와 MemberProfile이다. Member가 가질 수 있는 MemberProfile은 N개이며 MemberProfile은 1개의 Member를 가진다.

    CREATE TABLE `member` (
        `pk`         int(1) unsigned NOT NULL AUTO_INCREMENT,
        `nanoid`     char(24) NOT NULL,
        `name`       varchar(32) DEFAULT NULL,
        `age`        int(1) unsigned DEFAULT NULL,
        `address1`   varchar(1024) DEFAULT NULL,
        `address2`   varchar(1024) DEFAULT NULL,
        PRIMARY KEY (`pk`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
    
    CREATE TABLE `member_profile` (
        `pk`          int(1) unsigned NOT NULL AUTO_INCREMENT,
        `nanoid`      char(24) NOT NULL COMMENT 'ref, member.nanoid',
        `description` varchar(32) DEFAULT NULL,
        PRIMARY KEY (`pk`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8;

     

     

    2. Imperative Mapping Class

    앞에서 정의한 Schema를 DataBase에 테이블을 생성한 뒤 "sqlacodgen"으로 SQLAlchemy의 Model로 변환하면 다음과 같다.

    # coding: utf-8
    # base_model.py
    from sqlalchemy import CHAR, Column, String
    from sqlalchemy.dialects.mysql import INTEGER
    from sqlalchemy.ext.declarative import declarative_base
    
    Base = declarative_base()
    metadata = Base.metadata
    
    class Member(Base):
        __tablename__ = 'member'
    
        pk = Column(INTEGER(1), primary_key=True)
        nanoid = Column(CHAR(24), nullable=False)
        name = Column(String(32))
        age = Column(INTEGER(1))
        address1 = Column(String(1024))
        address2 = Column(String(1024))
    
    
    class MemberProfile(Base):
        __tablename__ = 'member_profile'
    
        pk = Column(INTEGER(1), primary_key=True)
        nanoid = Column(CHAR(24), nullable=False, comment='ref, member.nanoid')
        description = Column(String(32))

    SQLAlchemy의 Model로 변환된 Python Class를 Imperative Mapping 방식과 관계를 설정할 Class는 다음과 같이 정의했다.

    from __future__ import annotations
    
    class MemberEntity:
        nanoid: str
        name: str
        age: int
    
        profile: List[MemberProfileEntity]
    
    class MemberProfileEntity:
        nanoid: str
        description: str

    SQLAlchemy의 Imperative Mapping의 가이드라인과 다른 점은 Typing이 적용되어 Object 간의 관계가 드러났다는 점이다.

     

    3.  start_mapper

    앞서 준비한 내용을 가지고 orm mapping과 1:N 관계를 설정하기 위한 registry는 다음과 같이 작성할 수 있다.

    def start_mapper():
        orm_mapper = registry(base_model.metadata)
    
        orm_mapper.map_imperatively(MemberProfileEntity, base_model.MemberProfile)
        orm_mapper.map_imperatively(
            MemberEntity, base_model.Member,
            properties={
                'profile': relationship(
                    MemberProfileEntity,
                    primaryjoin=(
                           base_model.MemberProfile.nanoid == foreign(base_model.Member.nanoid)
                    ),
                    lazy='joined',
                    uselist=True
                )
            }
        )
    
        return orm_mapper

    SQLAlchemy의 Session ORM 대신 위와 같이 설정하면 MemberEntity의 namspace에 'profile'이라는 attribute가 생성되고 이는 추후 MemberEntity를 통해서 MemberProfileEntity를 update 할 수 있게 만드는 것이 가능하다.

     

    4. Session ORM 

    Session ORM을 통해 앞서 설정한 imperarive mapping이 어떻게 동작하는지 알아보자.

     

    4.1 Read

    우선적으로 MemberEntity를 읽어 들인 ORM 코드를 살펴보자. 아래 코드는 SQLAlchemy 1.4 버전의 스타일이다.

    query = self.session.query(MemberEntity).filter(MemberEntity.nanoid == member_id).one_or_none()

    "one_or_none()" 메서드를 통해 얻은 결과 즉, "query"라는 변수의 namespace는 다음과 같다.

    [..., '_sa_instance_state', 'address1', 'address2', 'age', 'name', 'nanoid', 'pk', 'profile']

    "profile"이라는 속성이 존재하는 걸 확인할 수 있는데 이 profile의 타입은 MembeProfileEntity이다.

    print(query.profile)
    
    [<MemberProfileEntity object at 0x107727bb0>, <MemberProfileEntity object at 0x107727c40>]
    2024-03-15 00:07:12,010 INFO sqlalchemy.engine.Engine COMMIT

    또한 "profile"을 읽어 들이기 위해 "LEFT OUTER JOIN" 쿼리가 발생한 걸 확인할 수 있다.

    INFO sqlalchemy.engine.Engine SELECT `member`.pk AS member_pk, `member`.nanoid AS member_nanoid, `member`.name AS member_name, `member`.age AS member_age, `member`.address1 AS member_address1, `member`.address2 AS member_address2, member_profile_1.pk AS member_profile_1_pk, member_profile_1.nanoid AS member_profile_1_nanoid, member_profile_1.description AS member_profile_1_description 
    FROM `member` LEFT OUTER JOIN member_profile AS member_profile_1 ON member_profile_1.nanoid = `member`.nanoid 
    WHERE `member`.nanoid = %(nanoid_1)s

     

     

    4.2 Update

    앞에서 했던 행위를 다시 정리하면 Member 테이블의 데이터를 가져오면서 그에 연관된 MemberProfile 테이블의 데이터도 같이 가져왔는데 이를 Session ORM을 통해 시도한 것이다. 그렇다면 Member와 MemberProfile의 정보를 수정한 다음 이를 반영하면 각각의 Entity가 Update도 같이 이뤄지는지 살펴보자.

     

    앞에서 작성한 코드를 다음과 같이 작성하자.

    member = self.session.query(MemberEntity).filter(MemberEntity.nanoid == member_id).one_or_none()
    
    member.name = "this is update name?"
    
    member.profile[0].description = "this is update description?"

    몇 가지 테스트를 거치고 보니 다음과 같이 session.add()를 사용해도 update가 가능하단 걸 알 수 있었다.

    def update(self, member: MemberEntity):
        self.session.add(member)
        for profile in member.profile:
            self.session.add(profile)
        self.session.commit()
        
    # Update Query
    2024-03-15 00:22:43,555 INFO sqlalchemy.engine.Engine UPDATE member_profile SET description=%(description)s WHERE member_profile.pk = %(member_profile_pk)s
    2024-03-15 00:22:43,555 INFO sqlalchemy.engine.Engine [generated in 0.00008s] {'description': 'this is update description?', 'member_profile_pk': 2}
    2024-03-15 00:22:43,557 INFO sqlalchemy.engine.Engine UPDATE `member` SET name=%(name)s WHERE `member`.pk = %(member_pk)s
    2024-03-15 00:22:43,557 INFO sqlalchemy.engine.Engine [generated in 0.00012s] {'name': 'this is update name?', 'member_pk': 160}
    2024-03-15 00:22:43,558 INFO sqlalchemy.engine.Engine COMMIT

     

    4.3 Insert

    4.2에서 설명한 방식은 이미 데이터베이스에 존재하는 데이터를 읽어와서 Update를 처리하는 방식이었다. Insert도 가능한지 확인해 보자.

    # Insert ?
    member = MemberEntity(
        nanoid=_nanoid.generate(size=24),
        name="this new member"
    )
    member.profile.append(MemberProfileEntity(
        nanoid=member.nanoid,
        description='this new description'
    ))

    4.2에서 테스트한 update() 함수를 다시 사용하면 다음과 같은 쿼리가 발생한다.

    2024-03-15 00:28:11,321 INFO sqlalchemy.engine.Engine BEGIN (implicit)
    2024-03-15 00:28:11,322 INFO sqlalchemy.engine.Engine INSERT INTO member_profile (nanoid, description) VALUES (%(nanoid)s, %(description)s)
    2024-03-15 00:28:11,322 INFO sqlalchemy.engine.Engine [generated in 0.00006s] {'nanoid': 'ElkaG-XLHqp-A-vpUXaKF1Yq', 'description': 'this new description'}
    2024-03-15 00:28:11,328 INFO sqlalchemy.engine.Engine INSERT INTO `member` (nanoid, name, age, address1, address2) VALUES (%(nanoid)s, %(name)s, %(age)s, %(address1)s, %(address2)s)
    2024-03-15 00:28:11,328 INFO sqlalchemy.engine.Engine [generated in 0.00013s] {'nanoid': 'ElkaG-XLHqp-A-vpUXaKF1Yq', 'name': 'this new member', 'age': None, 'address1': None, 'address2': None}
    2024-03-15 00:28:11,330 INFO sqlalchemy.engine.Engine COMMIT

     

     

    마치며

    Imperative Mapping 방식에서 1:N 관계의 메커니즘을 조사하게 된 계기는 본 글에서 언급한 Member와 MemberProfile처럼 객체 관계 그대로를 Read/Write/Update 할 수 있을까에 대한 궁금증을 해소하기 위함이었다. 이는 DDD에서 말하는 Aggregate의 개념을 테스트해 보자라는 아이디어에서 출발한 것이다.

     

    Imperative Mapping 방식이 자료가 많이 없어서 내용을 조사하고 테스트해 보는데 꽤나 시간이 소비됐다. Imperative Mapping 방식에 관한 예제들이 많아졌으면 하는 바람이다.

    728x90
    반응형