ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Django] ORM에서 Func()를 사용해 커스텀 DB 함수 구현하기
    언어/파이썬 & 장고 2020. 3. 7. 20:22

    장고 ORM에서는 데이터베이스의 함수를 다수 지원합니다. 하지만 전부를 지원하는 것이 아니여서 지원하지 않는 함수를 쓸 땐, DB에서 데이터를 받아온 다음 파이썬 코드를 작성해야 합니다. 여기서는 Func()를 사용해 장고에서 지원하지 않는 DB 함수를 구현합니다.


    먼저 장고에서는 pg의 unnest라는 기능을 제공하고 있지 않습니다. unnest는 어레이 타입의 컬럼 데이터를 인덱스 별로 각 row로 분리시켜주는 기능입니다. 

    데이터가 {1,2,3} 이렇게 1개의 row로 되어 있다면 unnest를 사용하여 3개의 row로 표현할 수 있습니다. (각 row는 1, 2, 3 으로 3개의 row)

    from django.db.models import Func
    
    
    temp = Test.objects.annotate(
        te1=Func('test_data', function='UNNEST')
    )


    다음은 Func를 상속받아 위의 unnest와 동일한 결과를 내려주는 예시입니다.

    from django.db.models import Func
    
    class UNNEST(Func):
        function = 'UNNEST'
    
    
    temp = Test.objects.annotate(
        test_data=UNNEST('test_data')
    )


    다음은 Func() 클래스에서 받는 파라미터 및 함수에 대한 설명입니다.

    class Func(*expressions, **extra):

    function

    생성 될 함수를 설명하는 속성. 즉 데이터베이스에 어떤 함수를 호출할 지 명시 해야 합니다. 기본값은 None

    template

    function과 expression을 결합하여 DB에 질의할 query를 만드는 템플릿입니다. 기본값은 '%(function)s(%(expressions)s)' 

    만약 DB 쿼리 시, strftime('%W', 'date')와 같이 %가 필요한 경우에는 %가 두 번 입력되기 때문에 템플릿 속성에서 % 문자를 (%%%%)로 4배 늘려야 합니다.

    arg_joiner

    expressions에 입력된 컬럼들을 결합하는 데 사용되는 문자를 나타내는 속성입니다. 기본값은 ', '

    arity

    해당 함수가 몇 개의 컬럼을 받을 수 있는지 숫자를 표시합니다. 해당 옵션에 숫자가 주어지고 호출할 수 있는 컬럼의 수를 넘어가면 TypeError를 발생시킵니다. 기본값은 None

    as_sql(compiler, connection, function=None, template=None, arg_joiner=None, **extra_context)

    DB 함수에 실제 SQL을 발생시키는 함수. as_DB명()은 function, template, arg_joiner와 추가적인 **extra_context 파라미터를 선언해야 합니다. 

    이 함수를 선언하는 이유는 데이터베이스마다 동일한 결과를 내는 기능의 이름이 다를 수 있습니다.


    class AsModelTest(Func):
        function = 'CONCAT'
        _output_field = CharField()
    
        def as_mysql(self, compiler, connection, **extra_context):
            return super().as_sql(
                compiler, connection,
                function='CONCAT_WS',
                template="%(function)s('--mysql--', %(expressions)s)",
                **extra_context
            )
    
        def as_postgresql(self, compiler, connection, **extra_context):
            return super().as_sql(
                compiler, connection,
                function='CONCAT',
                template="%(function)s('--postgresql--', %(expressions)s)",
                **extra_context
            )
    
    
    temp = Test.objects.annotate(
        asd=AsModelTest('name')
    ).values('asd')[0]
    
    
    # {'asd': '--postgresql--test'}


    현재 실행환경은 PostgreSQL에서 실행했기 때문에 as_postgresql 함수가 호출된 것을 확인할 수 있습니다.

    Func() 사용 시, 주의할 점

    ** 아래는 장고 3.0 기준으로 장고 2.2 미만 버전에서는 오류가 날 수 있습니다. **

    여기서 주의할 점은 **extra_context는 key=value 쌍으로 표현할 수 있는데 이는 문자열 그대로 전달, 저장하여 사용하므로 잘못 사용하면 SQL 인젝션에 취약할 수 있습니다.

    class Position(Func):
        function = 'POSITION'
        template = "%(function)s('%(substring)s' in %(expressions)s)"
    
        def __init__(self, expression, substring):
            super().__init__(expression, substring=substring)


    위의 예시와 같이 substring이 그대로 template에 입력된다면 SQL 인젝션에 취약한 모습을 보입니다. (장고에서 escape 문자들을 치환하지 않고 받은 값 그대로 사용하기 때문) 따라서 추가적인 값 또는 표현을 할 땐 아래와 같이 사용해야 안전합니다.

    class Position(Func):
        function = 'POSITION'
        arg_joiner = ' IN '
    
        def __init__(self, expression, substring):
            super().__init__(substring, expression)

    PostgreSQL의 string_to_array, array_to_string 함수 구현하기

    개발할 때, postgresql에 있는 string 타입 컬럼을 array 타입으로 변경하는 함수를 사용하려 했는데 존재하지 않았습니다. 아래는 다른 사람들이 사용할 수 있도록 이를 공통 모듈로 만드는 예시입니다.

    ** 현재 사용하고 있는 장고의 버전이 2.2 이상이 아니라서 (1.11 버전 사용중..) 위에서 설명하는 위치 기반 파라미터로 호출할 경우 오류가 발생하여 어쩔 수 없이 키워드 인자를 사용했습니다. **

    array_to_string

    from django.db.models import Func, CharField
    
    
    
    
    class ArrayToString(Func):
        function = 'array_to_string'
        _output_field = CharField()
        arity = 1
        template = "%(function)s(%(expressions)s,'%(quote)s','%(null)s')"
    
        def __init__(self, expression, quote=',', null=''):
            super().__init__(expression, quote=quote, null=null)
    
    
    temp = Test.objects.annotate(
        asd=ArrayToString('test_data', quote='|', null='null대체구분자')
    ).values('asd')
    
    
    # [{'asd': '1418829|1381948|1380935|1379835|null대체구분자'}

    string_to_array

    from django.db.models import Func, CharField
    from django.contrib.postgres.fields import ArrayField
    
    
    
    
    class StringToArray(Func):
        function = 'string_to_array'
        _output_field = ArrayField(CharField())
        arity = 1
        template = "%(function)s(%(expressions)s,'%(quote)s','%(null)s')"
    
        def __init__(self, expression, quote=',', null=''):
            super().__init__(expression, quote=quote, null=null)
    
    
    temp = Test.objects.annotate(
        asd=StringToArray('test_data', quote='|', null='null대체구분자')
    ).values('asd')
    
    
    
    # [{'asd': ['1418829','1381948','1380935','1379835','null대체구분자']}

    요약

    위 예시와 같이 장고가 제공하지 않는 기능인 PG의 string_to_array와 array_to_string을 구현했습니다. 더 심화하여 여러 기능을 합쳐서 한 개의 기능으로 제공할 수 있습니다. 

    장고 공식 문서에서 설명하는 것처럼 키워드 인자는 SQL인젝션에 좋지 않으니 장고의 버전을 최소한 2.2 이상을 사용하는 것을 권장합니다.

    참고 문서

    https://docs.djangoproject.com/en/3.0/ref/models/expressions/#func-expressions

    https://docs.djangoproject.com/en/3.0/ref/models/expressions/#avoiding-sql-injection-in-query-expressions

    댓글