쿼리를 작성 할 때도 타입 체커를 써먹자 - Opaleye (작성 중)

Posted on September 16, 2023

단순 번역 글은 잘 올리지 않으려 하는데, 이 번 글은 아직 단순 번역 글입니다. 프로젝트 중에 참고할 매뉴얼로 먼저 올려 두고, 실제 사용하면서 알게된 내용들로 보완하도록 하겠습니다.

SQL-생성generating EDSL을 제공하는 라이브러리 (현재2023까지는 PostgreSQL만 지원)
하스켈 코드로 써 놓으면 SQL을 생성해 주는데, 이렇게 하면 장점이 타입 체커의 도움을 받을 수 있습니다. Opaleye를 쓰면 PostgreSQL DB 쿼리를 쓸 때 타입 체커를 부려 먹고(typesafe), 합성 가능한(composable) 코드를 작성할 수 있습니다. 당장은 현란한 쿼리를 쓰지 않아서, 고작?! 타입 체커의 안정성을 위해서 또 새로운 걸 익혀야 하는가란 생각도 들지만, 다른 예시들에서 쓰는 걸 가끔 만나니, 일단 맛을 보기로 했습니다. Opaleye를 써서 하스켈 코드로 쿼리를 작성하고, 컴파일을 통과하면 런타임에 뻗지 않는다는 신뢰가 생깁니다.

타입 체커를 적극적으로 쓰는 예시로 봐 둘만 합니다.

다음과 같은 SQL 기능을 제공합니다.
inner join,
outer join,
restriction,
aggregation,
distinct,
sorting and limiting,
unions,
differences

DB 생성이나 Table 생성은 아직 안됩니다.

아래는 오피셜 Repo에서 제공하는 Basic 튜토리얼을 보며 노트한 내용입니다. 그대로, 번역한 게 많은데, 번역 오류가 있을 수 있습니다. 직접 프로젝트에 Opaleye를 쓰려 하고 있습니다. 사용하면서 얻게 되는 노하우들로 좀 더 채워 넣도록 하겠습니다.

haskell-opaleye/Doc/Tutorial/TutorialBasic.lhs

스키마

MySQL, Oracle, MSSQL 등에 익숙한 분들에겐 PostgreSQL의 스키마가 혼동을 줄 수 있습니다. 단어 뜻에 어긋나게 쓰는 건 아니지만 조금 다른 걸 지칭하고 있습니다. Database, Table 사이에서 한 번 더 그룹화하는 개념 혹은 절차가 있다고 볼 수 있습니다.

“PostgreSQL의 스키마”가 낯선 분은 아래 글을 보시면 도움이 됩니다.
참고) kimDuBiA님의 PostgreSQL schema 의미 및 권한관리

table 함수로 테이블을 정의합니다.

data Table writeFields viewFields
  = Table String (TableFields writeFields viewFields)
  | tableWithSchema String String (TableFields writeFields viewFields)

table :: String -> TableFields writeFields viewFields -> Table writeFields viewFields

쓸 수 있는 필드, 읽을 수 있는 필드 각각 지정하는데 보통은 같으니까, 대부분의 경우 같은 인자를 두 번 반복합니다.

“필드가 들어 있는 컨테이너의 모양을 설명하는 별도의 컴비네이터”를 써서 간단한 모양으로 테이블을 정의합니다. 여기서는 3튜플을 받는 p3 컴비네이터를 썼습니다.

personTable :: Table (Field SqlText, Field SqlInt4, Field SqlText)
                     (Field SqlText, Field SqlInt4, Field SqlText)
personTable = table "personTable" (p3 ( tableField "name"
                                      , tableField "age"
                                      , tableField "address" ))

기본값으로, “personTable” 테이블은 PostgreSQL의 디폴트 스키마 “public”을 씁니다. MySQL에서 database에 table이 소속되어 있는 것처럼, 여기선 database에 schema 분류가 있고, 이들 schema에 테이블이 속합니다.1

Opaleye 여기 저기서 boilerplate를 피하기 위해 안보이게 타입클래스가 개입합니다. 사용자는 명시적으로 이들 타입클래스를 쓸 필요는 없습니다. 타입클래스가 만들어내는 마법같은 일들은 명시적으로 “typeclass dictionary”를 통해 전달할 수도 있습니다.

personSelect :: Select (Field SqlText, Field SqlInt4, Field SqlText)
personSelect = selectTable personTable

personSelect는 아래같은 SQL을 생성합니다.

ghci> printSql personSelect
SELECT name0_1 as result1,
       age1_1 as result2,
       address2_1 as result3
FROM (SELECT *
      FROM (SELECT name as name0_1,
                   age as age1_1,
                   address as address2_1
            FROM personTable as T1) as T1) as T1

왜 저렇게 뎁스가 들어간 모양이 나오는지 아직 이해하지 못했습니다. 튜토리얼에서는 이렇게 실제 생성한 SQL이 아닌, 아래같이 idealized 버전을 같이 보여주며 설명합니다. Opaleye가 최대한 idealized 버전과 같은 쿼리를 뽑아내면 좋은데, 지금처럼 뽑아내도 PostgreSQL이 쿼리 최적화를 하기 때문에 퍼포먼스에서는 그다지 차이가 없다고 합니다.

SELECT name,
       age,
       address
FROM personTable

printSQL은 여기 예시에서만 쓰는 편의용 유틸리티 함수입니다. 글 끝에 정의 코드가 있습니다.

Record 타입

Opaleye는 쿼리에 하스켈 record 타입같은 사용자 정의 타입을 쓸 수 있습니다. 데이터 타입의 모든 필드를 폴리모픽하게 만들면 편리합니다. (아래 예시들을 보면 오히려 폴리모픽 안하게 만들면 안될 것 같은데, 텍스트에선 아예 안된다는 말은 또 따로 없습니다.) 특정 코드에서 구체 타입을 사용하려면 대부분 타입 동의어synonym를 씁니다.

예시가 좀 특이한데, 주인공은 Birthday가 아니라 Birthday'입니다.

data Birthday' a b = Birthday { bdName :: a, bdDay :: b }
type Birthday      = Birthday' String Day
type BirthdayField = Birthday' (Field SqlText) (Field SqlDate)

Birthday, BirthdayField 둘 모두 Birthday'의 synonym입니다. 이렇게 쓰려면 모든 필드를 폴리모픽하게 써야합니다. 반드시 이렇게 해야만 할 것 같은데, 아니게 하는 방법이 있는지 없는지 아직 잘 모르겠습니다.
사용자 정의 타입이, 뒷단에서 안보이게 돌아가는 “타입클래스 매직”과 작동하게 하려면 (어떤 타입클래스?) 인스턴스를 가져야 합니다. 인스턴스는 템플릿 하스켈을 통해 만들 수 있습니다.

$(makeAdaptorAndInstance "pBirthday" ''Birthday')

이렇게 사용자 정의 타입이 준비되면, 위에서 했던 방식으로 테이블을 만들 수 있습니다. 위 예시에서 p3 컴비네이터가 빠지고, 그 자리에 pBirthday가 들어갔습니다. 그냥 3튜플을 쓸 때는 p3같은 것들의 도움을 받고, 테이블 스키마와 유사한 레코드 타입을 만들면, 그 걸 바로 써도 되는 것 같습니다.

-- 위에서 쓸 수 있는 필드, 읽을 수 있는 필드를 지정하는 걸
-- 아래와 같이 쓸 수 있다.
birthdayTable :: Table BirthdayField BirthdayField
birthdayTable = table "birthdayTable"
                      (pBirthday Birthday { bdName = tableField "name"
                                          , bdDay = tableField "birthday" })

birthdaySelect :: Select BirthdayField
birthdaySelect = selectTable birthdayTable
ghci> printSql birthdaySelect
SELECT name0_1 as result1
       birthday1_1 as result2
FROM (SELECT *
      FROM (SELECT name as name0_1
                   birthday as birthday1_1
            FROM birthdayTable as T1) as T1) as T1

idealized SQL:

SELECT name,
       birthday
FROM birthdayTable

아직은 너무 단순한 쿼리들만 보다 보니 이렇게까지 해야만 하나 싶은 생각이 들긴 합니다. 그냥 idealized 쿼리를 손으로 쓰는 게, 직관전이고 더 낫지 않을까 싶은데요. 아마도 복잡한 쿼리를 만나, 타입 체커의 도움을 받는 예시를 봐야 수긍이 갈 것 같습니다.

Projection

select에서 일부 필드를 무시하는 걸 Projection이라 합니다. 위 personSelect에서 address를 무시하길 원한다고 칩시다. 아래 첫 번째 예시처럼 Opaleye 쿼리를 생성하는데 do표기 방식을 쓸 수 있습니다.

nameAge :: Select (Field SqlText, Field SqlInt4)
nameAge = do
  (name, age, _) <- personSelect
  pure (name, age)
ghci> printSql nameAge
SELECT name0_1 as result1
       age1_1 as result2
FROM (SELECT *
      FROM (SELECT name as name0_1,
                   age as age1_1,
                   address as address2_1
            FROM personTable as T1) as T1) as T1

idealized SQL:

SELECT name,
       age
FROM personTable

Product

두 쿼리의 카테시안 프로덕트를 의미합니다. do표기로 간단히 해결할 수 있습니다. personSelectbirthdaySelect 카테시안 프로덕트를 얻어 보겠습니다.

personBirthdayProduct ::
  Select ((Field SqlText, Field SqlInt4, Field SqlText), BirthdayField)
personBirthdayProduct = do
  personRow <- personSelect
  birthdayRow <- birthdaySelect

  pure (personRow, birthdayRow)
ghci> printSql personBirthdayProduct
SELECT name0_1 as result1,
       age1_1 as result2,
       address2_1 as result3,
       name0_2 as result4,
       birthday1_2 as result5
FROM (SELECT *
      FROM (SELECT name as name0_1,
                   age as age1_1,
                   address as address2_1
            FROM personTable as T1) as T1,
           (SELECT name as name0_2,
                   birthday as birthday1_2
            FROM birthdayTable as T1) as T2) as T1

Idealized SQL:

SELECT name0,
       age0,
       address0,
       name1,
       birthday1
FROM (SELECT name as name0,
             age as age0,
             address as address0
      FROM personTable as T1),
     (SELECT name as name1,
             birthday as birthday1
      FROM birthdayTable as T1)

Restriction

특정 조건에 맞는 것들만 추리는 것. personSelect 결과에서 18살까지만으로 필터링 해보겠습니다.

youngPeople :: Select (Field SqlText, Field SqlInt4, Field SqlText)
youngPeople = do
  row@(_, age, _) <- personSelect
  where_ (age, .<= 18)

  pure row

복잡한 조건을 만들 때 쓸 다양한 연산자를 .을 접두어로 정의해 뒀습니다.

twentiesAtAddress :: Select (Field SqlText, Field SqlInt4, Field SqlText)
twentiesAtAddress = do
  row@(_, age, address) <- personSelect

  where_ $ (20 .<= age) .&& (age .< 30)
  where_ $ address .== sqlString "1 My Street, My Town"

  pure row

Inner join

restriction으로 필터링 된 product를 SQL 용어로는 inner join이라 합니다. 카테시안 곱에서 이름이 같은 row만 가져온다든지 하는 걸 말합니다.

personAndBirthday ::
  Select (Field SqlText, Field SqlInt4, Field SqlText, Field SqlData)
personAndBirthday = do
  (name, age, address) <- personSelect
  birthday             <- birthdaySelect

  where_ $ name .== bdName birthday
  pure (name, age, address, bdDay birthday)

Nullability

Null이 문제를 안 일으키게 하려면, Null을 타입 시스템에 반영하면 됩니다. Nullable 필드는 FieldNullable 타입 생성자를 이용합니다.

employeeTable :: Table (Field SqlText, FieldNullable SqlText)
                       (Field SqlText, FieldNullable SqlText)
employeeTable = table "employeeTable" (p2 ( tableField "name"
                                          , tableField "boss" ))

각 고용인들이 Boss가 있는지 보는 예시입니다.

hasBoss = do
  (name, nullableBoss) <- selectTable employeeTable
  let aOrNo = ifThenElse (isNull nullableBoss) (sqlString "no") (sqlString "a")

  pure $ name .++ sqlString " has " .++ aOrNo .++ sqlString " boss"
idealized SQL:

SELECT name || ' has '
            || CASE WHEN boss IS NULL THEN 'no' ELSE 'a' END || ' boss'
FROM emplyeeTable

null인지 체크하는데 그치지 않고 matchNullable로 좀 더 기능을 추가할 수도 있습니다.

bossSelect :: (Field sqlText, FieldNullable SqlText) -> Select (Field SqlText)
bossSelect (name, nullableBoss) = do
  pure $ matchNullable (name .++ sqlString " has no boss")
                       (\boss -> sqlString "The boss of " .++ name)
                                 .++ sqlString " is " .++ boss)
                       nullableBoss

matchNullable은 아래 하스켈의 maybe 함수와 같은 역할을 합니다.
maybe로 바꾸면 아래와 같습니다.

bossHaskell :: (String, Maybe String) -> String
bossHaskell (name, nullableBoss) = maybe (name ++ " has no boss")
                                         (\boss -> "The boss of " ++ name 
                                                   ++ " is " ++ boss)
                                         nullableBoss

Composability

twentiesAtAddress를 고치면서 Opaleye가 제공하는 다양한 Composability를 살펴 보겠습니다.

twentiesAtAddress select에서 특정 요소를 뽑아낼 수 있습니다. 나이가 20대면서, 주소가 “1 My Street, My Town”라는 조건을 꺼내 보겠습니다.
SelectArr a ()a타입 필드를 읽고, 어떤 필드도 리턴하지 않습니다.(Note: SelectSelectArr ()의 동의어입니다. 어떤 필드도 읽지 않는 SelectArr이란 뜻입니다.)

restrictIsTwenties :: Field SqlInt4 -> Select () -- 어떤 필드도 읽지 않는다.
restrictIsTwenties age = do
  where_ $ (20 .<= age) .&& (age .< 30)

restrictAddressIs1MyStreet :: Field SqlText -> Select ()
restrictAddressIs1MyStreet address = do
  where_ $ address .== sqlString "1 My Street, My Town"

위 둘은 “필드를 읽어내는” Select 타입이 아니므로, 완성된 SQL을 뽑아내진 못합니다. 이렇게 분리해낸 조건들을 다음과 같이 적용해서 좀 더 보기 좋은 twentiesAtAddress를 만들 수 있습니다.

twentiesAtAddress' :: Select (Field SqlText, Field SqlInt4, Field SqlText)
twentiesAtAddress' = do
  row@(_, age, address) <- personSelect

  restrictIsTwenties age
  restrictAddressIs1MyStreet address

  pure row

Composability of join

person's namebirthdaySelect에서 찾은 date of birth와 매핑하는 SelectArr을 뽑아내는 비슷한 작업을 personAndBirthday에 해보겠습니다.

birthdayOfPerson :: Field SqlText -> Select (Field SqlDate)
birthdayOfPerson name = do
  birthday <- birthdaySelect

  where_ $ name .== bdName birthday

  pure (bdDay birthday)

personAndBirthday를 아래와 같이 구현할 수 있습니다.

personAndBirthday' ::
  Select (Field SqlText, Field SqlInt4, Field SqlText, Field SqlDate)
personAndBirthday' = do
  (name, age, address) <- personSelect
  birthday <- birthdayOfPerson name

  pure (name, age, address, birthday)

Aggregation

Type safe aggregation이 Opaleye이 가장 자랑하는 기능입니다. 다른 SQL 생성 API들도 이 기능은 어려워 하는 것 같다는데, 써보질 않아 잘 모르겠습니다. Opaleye aggregation은 항상 의미있는 SQL을 생성합니다.

-- 보통의 하스켈 데이터 타입
data Widget a b c d e = Widget { style    :: a
                               , color    :: b
                               , location :: c
                               , quantity :: d
                               , radius   :: e 
                               }

-- 위 타입을 Opaleye에서 쓸 때 필요한 코드 생성                               
$(makeAdaptorAndInstance "pWidget" ''Widget)

widgetTable :: Table (Widget (Field SqlText) (Field SqlText) (Field SqlTest)
                             (Field SqlInt4) (Field SqlFloat8))
                     (Widget (Field SqlText) (Field SqlText) (Field SqlTest)
                             (Field SqlInt4) (Field SqlFloat8))
widgetTable = table "widgetTable"
                    (pWidget Widget { style = tableField "style"
                                    , color = tableField "color"
                                    , location = tableField "location"
                                    , quantity = tableField "quantity"
                                    , radius = tableField "radius" })

stylecolor로 묶은 widget 그룹을 원한다면, locations가 몇 개나 있는지, widget의 총 갯수는 얼마인지, 평균 radius는 얼마인지 알기 원한다 할 때, aggregateWidgets는 어떻게 이 걸 처리하는지 보여 줍니다.


aggregateWidgets :: Select (Widget (Field SqlText) (Field SqlText) (Field SqlInt8)
                                   (Field SqlInt4) (Field SqlFloat8))
aggregateWidgets = aggregate (pWidget Widget { style = groupBy
                                             , color = groupBy
                                             , location = count
                                             , quantity = sum
                                             , radius = avg
                                             })
                             (selectTable widgetTable)

widgetTableaggregateWidgets에서 Template Haskell이 유도한 코드의 쓰임을 좀 더 정확히 볼 수 있습니다. pWidget을 어떻게 필드들이 aggregate되는지 지정하기 위해 “adaptor”로 썼습니다?(@todo 번역이 확실치 않습니다.) count aggregator는 Field StringField Int64로 바꾸는데, 데이터 타입을 폴리모픽하게 해서 해결하는 또 다른 예시입니다.

Outer join

left join을 지원합니다. Full outer join과 right join은 아직 없습니다. left join은 nullable 필드가 아닌 걸 nullable로 바꿀 수도 있기 때문에, 출력이 Nullability을 지원하는지 잘 봐야 합니다. 이렇게 하기 위해 다음 타입 동의어synonym를 소개합니다.

type FieldNullableBirthday = Birthday' (FieldNullable SqlText)
                                       (FieldNullable SqlDate)

left join은 join할 두 tablejoin 조건으로 표현됩니다.

personBirthdayLeftJoin :: Select ((Field SqlText, Field SqlInt4, Field SqlText),
                                 FieldNullableBirthday)
personBirthdayLeftJoin = leftJoin perseonSelect birthdaySelect eqName
  where eqName ((name, _, _), birthdayRow) = name .== bdName birthdayRow

boilerplate를 피하기 위해 Opaleye는 보이지 않게 타입클래스 매직을 씁니다. 그런데, 컴파일러가 타입 추론을 잘 못하는 경우가 있다고 합니다. leftJoin이 바로 이런 경우인데, 이럴 때는 타입 서명을 직접 써 줘야 합니다. 컴파일러가 Default 인스턴스가 뭔지 모르겠다 하면, 타입 지정을 수동으로 해줘야 합니다.

Newtypes

newtype은, 예를 들어, 창고 ID는 integer지만, WarehouseId란 새로운 타입으로 만들어, 창고를 나타내지 않는 integer들과 섞이는 걸 막는 역할을 합니다. Opaleye에서도 비슷한 일을 할 수 있습니다.

data Warehouse' a b c = Warehouse { wId       :: a
                                  , wLocation :: b
                                  , wNumGoods :: c }

$(makeAdaptorAndInstance "pWarehouse" ''Warehouse)

Opaleye에선 integer ID를 SqlInt4로 쓸 수 있습니다.

type BadWarehouseField = Warehouse' (Field SqlInt4)
                                    (Field SqlText)
                                    (Field SqlInt4)

badWarehouseTable :: Table BadWarehouseField BadWarehouseField
badWarehouseTable = table "warehouse_table"
  (pWarehouse Warehouse { wId = tableField "id"
                        , wLocation = tableField "location"
                        , wNumGoods = tableField "num_goods" })

아래와 같이 의미없는 작업을 하는 걸 막을 수 있습니다.

badComparison :: BadWarehouseField -> Field SqlBool
badComparison w = wId w .== wNumGoods w

warehouse IDnewtype으로 만들어 해결할 수도 있습니다.

newtype WarehouseId' a = WarehouseId a
$(makeAdaptorAndInstance "pWarehouseId" ''WarehouseId')

type WarehouseIdField = WarehouseId' (Field SqlInt4)

type GoodWarehouseField = Warehouse' WarehouseIdField
                                     (Field SqlText)
                                     (Field SqlInt4)

goodWarehouseTable :: Table GoodWarehouseField GoodWarehouseField
goodWarehouseTable =table "warehosue_table"
  (pWarehouse Warehouse { wId = pWarehouseId (WarehouseId (tableField "id"))
                        , wLocation = tableField "location"
                        , wNumgoods = tableField "num_goods" })

이제 위의 비교 구문은 타입 체커를 통과하지 못합니다.

-- forbiddenComparison :: GoodWarehouseField -> Field SqlBool
-- forbiddenComparison w = wId w .== wNumGoods w
--
-- => Couldn't match type `WarehouseId' (Field SqlInt4)' with `Field SqlInt4'

밑줄친 부분은 같지만, idWarehouseId가 붙어 있습니다.

PostgreSQL로 쿼리 날리기

runSelect는 PostgreSQL에 효과적으로 쿼리를 날려주는 타입클래스 폴리모픽 함수입니다.

-- runSelect :: Database.PostgreSQL.Simple.Connection
--           -> Select fields -> IO [haskells]

Opaleye의 레코드를 하스켈 Value의 레코드의 리스트로 변환합니다. leftJoin처럼 이 특별한 동작은 타입클래스를 이용합니다. error 메시지를 보기 좋게 하려면, 최대한 모든 것에 타입 서명을 붙여 줘야 합니다.

twentiesAtAddress는 다음 타입을 가진 runSelect를 고릅니다.

runTwentiesSelect :: PGS.Connection
                  -> Select (Field SqlText, Field SqlInt4, Field SqlText)
                  -> IO [(String, Int, String)]
runTwentiesSelect = runSelect

nullable 필드는 FieldNullable 타입 생성자로 표현하며, 실행시에 Maybe로 변환됩니다. 아래같이 selectTable employeeTable select를 실행할 수 있습니다.

runEmployeesSelect :: PGS.Connection
                   -> Select (Field SqlText, FieldNullable SqlText)
                   -> IO [(String, Maybe String)]
runEmployeesSelect = runSelect

makeAdaptorAndInstance가 생성한 타입클래스 인스턴스에 의해 newtype은 자동으로 관리 됩니다. select가 실행되면 WarehoustId' (Field SqlInt4)WarehoustId' Int가 됩니다. 아래처럼 selectTable goodWarehoustTable select를 실행합니다.

type WarehouseId = WarehouseId' Int
type GoodWarehouse = Warehouse' warehouseId Strinng Int

runwareHouseSelect :: PSG.Connection
                   -> Select GoodWarehouseField
                   -> IO [Goodwarehousee]
runwareHouseSelect = runSelect

위에서 사용한 printSql 함수

printSql :: Default Unpackspec a a => Select a -> IO ()
printSql = putStrLn . maybe "Empty select" id . showSql

여기 튜토리얼에서는 EDSL 문법 요소? 설명을 주로 했다면, 다음 링크는 실제 DB에 읽고 쓰는 실무적인 걸 보여줍니다.
Haskell Tutorials - Opaleye Tutorials - Instant Gratification


  1. OS로 따지면 디렉토리와 유사합니다. 스키마는 views, indexes, sequences, data types, operator, functions shcema를 포함할 수 있습니다. 보통 DB에서 스키마라 하면 DB 구조(개체, 속성, 관계)와 제약 조건에 대한 정의를 지칭합니다. PostgreSQL에서도 뜻을 달리 쓰는 건 아닙니다. PostgreSQL은 스키마 안에 테이블을 만든다고 보면 됩니다. 다음 명령어로 스키마를 생성할 수 있습니다.

    CREATE SCHEMA name;

    Schema안에 테이블 생성하기

    CREATE TABLE myschema.mytable( ... );

    다른 스키마를 지정하고 싶으면 tableWithSchema 함수를 쓰면 됩니다.
    테이블의 모든 row를 선택하려면 selectTable을 씁니다.↩︎

Github 계정이 없는 분은 메일로 보내주세요. lionhairdino at gmail.com