본문으로 바로가기
728x90
반응형

목차

     

    개요 

    SQLAlchemy의 Imperative Mapping을 이용하다보니 “테이블의 열(column)을 모아서 Object로 표현할 수 있는 방법이 있지 않을까”를 고민하게되었다. 예를 들어 다음과 같은 코드가 있다고 가정해보자.

    class Member:
        address1:str
        address2:str

    위의 코드를 ORM 관점에서 보자면 DB 테이블에 address1과 address2라는 열(column)이 존재한다고 생각해볼 수 있는 코드이다. 그러나 위와 같은 코드를 다음과 같이도 표현할 수도 있다.

    @dataclass
    class Address:
        address1:str
        address2:Str
    
    class Member:
        address:Address

    위 코드를 통해 알 수 있는 것은 특정 리소스에 연관된 속성은 그것들을 묶어 다시 Object로 표현할 수 있다는 점이다.
     
    SQLAlchemy의 Imperative Mapping 방식을 특징을 생각해봤을때 위와 같은 방식이 지원하는 무언가가 있을 것 같다는 생각에 조사를 해봤다.

     
     

    1.  composit의 "__composit_values__"

    후술할 내용은 SQLAlchemy의 다음 문서를 참고하였다.

    Composite Column Types — SQLAlchemy 1.4 Documentation

     

    Composite Column Types —     SQLAlchemy 1.4 Documentation

    Composite Column Types Sets of columns can be associated with a single user-defined datatype. The ORM provides a single attribute which represents the group of columns using the class you provide. A simple example represents pairs of columns as a Point obj

    docs.sqlalchemy.org

    이 문서에서는 SQLALchemy 의 composit 기능에 관련된 설명을 하고 있다. 해당 문서에서도 확인할 수 있는 사항이지만 composit 기능은 declarative_base로 선언된 class 특정 속성을 모아서 별개의 객체를 통해 조작할 수 있게 만들어주는 기능이다.
     
    이 문서에 따르면 특정 column을 합성하는 객체에는  "__composit_values__()" 라는  Special Method가 구현되어있어야 함을 명시한다. “개요”에서 언급한 Address 클래스에 이를 적용하자면 다음과 같은 형태일 것이다.

    @dataclasses.dataclass(frozen=True, eq=True)
    class Address:
        address1: str
        address2: str
    
        def __composite_values__(self):
            return self.address1, self.address2


     

    2. Setting

    composit 방식을 테스트하기에 앞서 먼저 테스트에 필요한 사항들을 준비하자. Imperative Mapping 방식이기 때문에 DB Table과 이를 표현하는 Python Class가 필요하다. 각각 다음과 같이 준비하자.

     

    2.1 TABLE

    # member.sql
    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;
    

    2.2 Python object

    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 MemberEntity:
        pk: int
        nanoid: str
        name: str
        age: int
        address: Address

    MemberEntity class의 address 속성이 Member class의 속성과 1:1 대응이 되지 않는다는 것을 알 수 있다. 이를 Imperative Mapping을 사용해 Mapping 시키자. 이 단계에서 딱히 에러가 발생하진 않는다.

    2.3 imperative mapping setup

    def start_mapper():
        orm_mapper = registry()
        orm_mapper.map_imperatively(MemberEntity, orm.Member)
        return orm_mapper
    

     

    3.  `composit` 어떻게 사용하는가?

    이제 SQLAlchemy에서 특정 테이블의 column을 합쳐서 사용할 수 있는 기능인 composit을 어떻게 사용할 수 있는지 알아보자.

     

    3.1  Imperative Mapping Setup

    Imperative Mapping에서의 composit은 기본적으로 다음과 같은 방법으로 사용할 수 있다.

    orm_mapper = registry()
    orm_mapper.map_imperatively(MemberProfileEntity, orm.MemberProfile)
    
    orm_mapper.map_imperatively(
        MemberEntity, orm.Member,
        properties={
            "address": composite(
                Address,
                orm.Member.__table__.c.address1,
                orm.Member.__table__.c.address2
            ),
        }
    )

    위와 같이 적용된 설정하에 session.orm를 이용해 select 쿼리를 날리게 되면 MemberEntity의 namespace에는 “address”라는 속성이 생기게된다.

    2024-03-06 03:04:16,264 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
    FROM `member`
    WHERE `member`.nanoid = %s
    2024-03-06 03:04:16,264 INFO sqlalchemy.engine.Engine [generated in 0.00022s] ('9CT62JaYE7tqRHzXrg_Mk2IO',)
    2024-03-06 03:04:16,267 INFO sqlalchemy.engine.Engine COMMIT
    ====================
    ['...', 'address', 'address1', 'address2', 'age', 'change_address', 'name', 'nanoid', 'new', 'pk']
    

    namespace에 존재하는 “address”는 “개요”에서 언급한 Address Class를 나타낸다.
     

    3.1. INSERT도 가능한가?

    가능하다. MemberEntity Class를 다음과 같이 수정해보자.

    class MemberEntity:
        pk:int
        nanoid: str
        name: str
        age: int
        address: Address
    
        @staticmethod
        def new(name: str, age: int, address: Address = None):
            _entity = MemberEntity(
                nanoid=generate_entity_id(),
                name=name,
                age=age,
                address=address
            )
            _entity.address = address
    
            return _entity

    수정된 MemberEntity Class를 통해 Entity를 생성할 때의 코드는 다음과 같이 생겼을 것이다.

    member_entity = MemberEntity.new(
        name="test",
        age=123,
        address=Address(
            address1='address1-1',
            address2='address2-1'
        )
    )

    이를 그대로 SQLAlchemy의 session.add를 넣으면 다음과 같은 insert query가 발생한다.

    self.session.add(member_entity)
    self.session.commit()
    self.session.close()
    
    # Logging
    2024-03-06 03:31:39,934 INFO sqlalchemy.engine.Engine INSERT INTO `member` (nanoid, name, age, address1, address2) VALUES (%s, %s, %s, %s, %s)
    2024-03-06 03:31:39,934 INFO sqlalchemy.engine.Engine [generated in 0.00022s] ('0KfEmjhNLZIwpeK_ikz5wMwy', 'test', 123, 'address1-1', 'address2-1')

     

    마치며

    간단한 예제를 통해 DB Table의 특정 Column을 합성해 객체로도 사용할 수 있음을 알 수 있었다.

     

    이렇게 사용함의 목적은 특정 리소스를 기준으로 속성을 응집하여 사용할 수 있다는 점이다. 이렇게 만든 리소스를 다시 “타입”을 지정할 수 있음의 이점을 취할 수 있지만 리소스가 필요로 하는 속성이 많으면 많을 수록 설정을 하는 것이 번거롭다는 것이 단점으로 작용한다.

     

    리소스의 속성이 제어할만한 수준이며 코드 레벨에서의 표현력 부분에서 이점을 취하는 것을 목적으로 한다면 도입해볼만한 기능이라 생각된다.

    728x90
    반응형