단순 번역 글은 잘 올리지 않으려 하는데, 이 번 글은 아직 단순 번역 글입니다. 프로젝트 중에 참고할 매뉴얼로 먼저 올려 두고, 실제 사용하면서 알게된 내용들로 보완하도록 하겠습니다.
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)
(= table "personTable" (p3 ( tableField "name"
personTable "age"
, tableField "address" )) , tableField
기본값으로, “personTable” 테이블은 PostgreSQL의 디폴트 스키마 “public”을 씁니다. MySQL에서 database에 table이 소속되어 있는 것처럼, 여기선 database에 schema 분류가 있고, 이들 schema에 테이블이 속합니다.1
Opaleye 여기 저기서 boilerplate를 피하기 위해 안보이게 타입클래스가 개입합니다. 사용자는 명시적으로 이들 타입클래스를 쓸 필요는 없습니다. 타입클래스가 만들어내는 마법같은 일들은 명시적으로 “typeclass dictionary”를 통해 전달할 수도 있습니다.
personSelect :: Select (Field SqlText, Field SqlInt4, Field SqlText)
= selectTable personTable personSelect
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,
addressFROM personTable
※ printSQL
은 여기 예시에서만 쓰는 편의용 유틸리티 함수입니다. 글 끝에 정의 코드가 있습니다.
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
= table "birthdayTable"
birthdayTable Birthday { bdName = tableField "name"
(pBirthday = tableField "birthday" })
, bdDay
birthdaySelect :: Select BirthdayField
= selectTable birthdayTable birthdaySelect
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 쿼리를 손으로 쓰는 게, 직관전이고 더 낫지 않을까 싶은데요. 아마도 복잡한 쿼리를 만나, 타입 체커의 도움을 받는 예시를 봐야 수긍이 갈 것 같습니다.
select
에서 일부 필드를 무시하는 걸 Projection이라 합니다. 위 personSelect
에서 address
를 무시하길 원한다고 칩시다. 아래 첫 번째 예시처럼 Opaleye 쿼리를 생성하는데 do
표기 방식을 쓸 수 있습니다.
nameAge :: Select (Field SqlText, Field SqlInt4)
= do
nameAge <- personSelect
(name, age, _) 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
두 쿼리의 카테시안 프로덕트를 의미합니다. do
표기로 간단히 해결할 수 있습니다. personSelect
와 birthdaySelect
카테시안 프로덕트를 얻어 보겠습니다.
personBirthdayProduct ::
Select ((Field SqlText, Field SqlInt4, Field SqlText), BirthdayField)
= do
personBirthdayProduct <- personSelect
personRow <- birthdaySelect
birthdayRow
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)
특정 조건에 맞는 것들만 추리는 것. personSelect
결과에서 18살까지만으로 필터링 해보겠습니다.
youngPeople :: Select (Field SqlText, Field SqlInt4, Field SqlText)
= do
youngPeople @(_, age, _) <- personSelect
row.<= 18)
where_ (age,
pure row
복잡한 조건을 만들 때 쓸 다양한 연산자를 .
을 접두어로 정의해 뒀습니다.
twentiesAtAddress :: Select (Field SqlText, Field SqlInt4, Field SqlText)
= do
twentiesAtAddress @(_, age, address) <- personSelect
row
$ (20 .<= age) .&& (age .< 30)
where_ $ address .== sqlString "1 My Street, My Town"
where_
pure row
restriction으로 필터링 된 product를 SQL 용어로는 inner join
이라 합니다. 카테시안 곱에서 이름이 같은 row
만 가져온다든지 하는 걸 말합니다.
personAndBirthday ::
Select (Field SqlText, Field SqlInt4, Field SqlText, Field SqlData)
= do
personAndBirthday <- personSelect
(name, age, address) <- birthdaySelect
birthday
$ name .== bdName birthday
where_ pure (name, age, address, bdDay birthday)
Null
이 문제를 안 일으키게 하려면, Null
을 타입 시스템에 반영하면 됩니다. Nullable
필드는 FieldNullable
타입 생성자를 이용합니다.
employeeTable :: Table (Field SqlText, FieldNullable SqlText)
Field SqlText, FieldNullable SqlText)
(= table "employeeTable" (p2 ( tableField "name"
employeeTable "boss" )) , tableField
각 고용인들이 Boss
가 있는지 보는 예시입니다.
= do
hasBoss <- selectTable employeeTable
(name, nullableBoss) 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)
= do
bossSelect (name, nullableBoss) pure $ matchNullable (name .++ sqlString " has no boss")
-> sqlString "The boss of " .++ name)
(\boss .++ sqlString " is " .++ boss)
nullableBoss
matchNullable
은 아래 하스켈의 maybe
함수와 같은 역할을 합니다.
maybe
로 바꾸면 아래와 같습니다.
bossHaskell :: (String, Maybe String) -> String
= maybe (name ++ " has no boss")
bossHaskell (name, nullableBoss) -> "The boss of " ++ name
(\boss ++ " is " ++ boss)
nullableBoss
twentiesAtAddress
를 고치면서 Opaleye가 제공하는 다양한 Composability를 살펴 보겠습니다.
twentiesAtAddress
select
에서 특정 요소를 뽑아낼 수 있습니다. 나이가 20대면서, 주소가 “1 My Street, My Town”라는 조건을 꺼내 보겠습니다.
SelectArr a ()
은 a
타입 필드를 읽고, 어떤 필드도 리턴하지 않습니다.(Note: Select
는 SelectArr ()
의 동의어입니다. 어떤 필드도 읽지 않는 SelectArr
이란 뜻입니다.)
restrictIsTwenties :: Field SqlInt4 -> Select () -- 어떤 필드도 읽지 않는다.
= do
restrictIsTwenties age $ (20 .<= age) .&& (age .< 30)
where_
restrictAddressIs1MyStreet :: Field SqlText -> Select ()
= do
restrictAddressIs1MyStreet address $ address .== sqlString "1 My Street, My Town" where_
위 둘은 “필드를 읽어내는” Select
타입이 아니므로, 완성된 SQL을 뽑아내진 못합니다. 이렇게 분리해낸 조건들을 다음과 같이 적용해서 좀 더 보기 좋은 twentiesAtAddress
를 만들 수 있습니다.
twentiesAtAddress' :: Select (Field SqlText, Field SqlInt4, Field SqlText)
= do
twentiesAtAddress' @(_, age, address) <- personSelect
row
restrictIsTwenties age
restrictAddressIs1MyStreet address
pure row
person's name
을 birthdaySelect
에서 찾은 date of birth
와 매핑하는 SelectArr
을 뽑아내는 비슷한 작업을 personAndBirthday
에 해보겠습니다.
birthdayOfPerson :: Field SqlText -> Select (Field SqlDate)
= do
birthdayOfPerson name <- birthdaySelect
birthday
$ name .== bdName birthday
where_
pure (bdDay birthday)
personAndBirthday
를 아래와 같이 구현할 수 있습니다.
personAndBirthday' ::
Select (Field SqlText, Field SqlInt4, Field SqlText, Field SqlDate)
= do
personAndBirthday' <- personSelect
(name, age, address) <- birthdayOfPerson name
birthday
pure (name, age, address, birthday)
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))
(= table "widgetTable"
widgetTable Widget { style = tableField "style"
(pWidget = tableField "color"
, color = tableField "location"
, location = tableField "quantity"
, quantity = tableField "radius" }) , radius
style
과 color
로 묶은 widget
그룹을 원한다면, locations
가 몇 개나 있는지, widget
의 총 갯수는 얼마인지, 평균 radius
는 얼마인지 알기 원한다 할 때, aggregateWidgets
는 어떻게 이 걸 처리하는지 보여 줍니다.
aggregateWidgets :: Select (Widget (Field SqlText) (Field SqlText) (Field SqlInt8)
Field SqlInt4) (Field SqlFloat8))
(= aggregate (pWidget Widget { style = groupBy
aggregateWidgets = groupBy
, color = count
, location = sum
, quantity = avg
, radius
}) (selectTable widgetTable)
widgetTable
과 aggregateWidgets
에서 Template Haskell이 유도한 코드의 쓰임을 좀 더 정확히 볼 수 있습니다. pWidget
을 어떻게 필드들이 aggregate되는지 지정하기 위해 “adaptor”로 썼습니다?(@todo 번역이 확실치 않습니다.) count
aggregator는 Field String
을 Field Int64
로 바꾸는데, 데이터 타입을 폴리모픽하게 해서 해결하는 또 다른 예시입니다.
left join을 지원합니다. Full outer join과 right join은 아직 없습니다. left join은 nullable
필드가 아닌 걸 nullable
로 바꿀 수도 있기 때문에, 출력이 Nullability
을 지원하는지 잘 봐야 합니다. 이렇게 하기 위해 다음 타입 동의어synonym를 소개합니다.
type FieldNullableBirthday = Birthday' (FieldNullable SqlText)
FieldNullable SqlDate) (
left join은 join
할 두 table
과 join
조건으로 표현됩니다.
personBirthdayLeftJoin :: Select ((Field SqlText, Field SqlInt4, Field SqlText),
FieldNullableBirthday)
= leftJoin perseonSelect birthdaySelect eqName
personBirthdayLeftJoin where eqName ((name, _, _), birthdayRow) = name .== bdName birthdayRow
boilerplate를 피하기 위해 Opaleye는 보이지 않게 타입클래스 매직을 씁니다. 그런데, 컴파일러가 타입 추론을 잘 못하는 경우가 있다고 합니다. leftJoin
이 바로 이런 경우인데, 이럴 때는 타입 서명을 직접 써 줘야 합니다. 컴파일러가 Default
인스턴스가 뭔지 모르겠다 하면, 타입 지정을 수동으로 해줘야 합니다.
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
= table "warehouse_table"
badWarehouseTable Warehouse { wId = tableField "id"
(pWarehouse = tableField "location"
, wLocation = tableField "num_goods" }) , wNumGoods
아래와 같이 의미없는 작업을 하는 걸 막을 수 있습니다.
badComparison :: BadWarehouseField -> Field SqlBool
= wId w .== wNumGoods w badComparison w
warehouse ID
를 newtype
으로 만들어 해결할 수도 있습니다.
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
=table "warehosue_table"
goodWarehouseTable Warehouse { wId = pWarehouseId (WarehouseId (tableField "id"))
(pWarehouse = tableField "location"
, wLocation = tableField "num_goods" }) , wNumgoods
이제 위의 비교 구문은 타입 체커를 통과하지 못합니다.
-- forbiddenComparison :: GoodWarehouseField -> Field SqlBool
-- forbiddenComparison w = wId w .== wNumGoods w
-- -- => Couldn't match type `WarehouseId' (Field SqlInt4)' with `Field SqlInt4'
밑줄친 부분은 같지만, id
는 WarehouseId
가 붙어 있습니다.
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)]
= runSelect runTwentiesSelect
nullable
필드는 FieldNullable
타입 생성자로 표현하며, 실행시에 Maybe
로 변환됩니다. 아래같이 selectTable employeeTable
select를 실행할 수 있습니다.
runEmployeesSelect :: PGS.Connection
-> Select (Field SqlText, FieldNullable SqlText)
-> IO [(String, Maybe String)]
= runSelect runEmployeesSelect
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]
= runSelect runwareHouseSelect
printSql :: Default Unpackspec a a => Select a -> IO ()
= putStrLn . maybe "Empty select" id . showSql printSql
여기 튜토리얼에서는 EDSL 문법 요소? 설명을 주로 했다면, 다음 링크는 실제 DB에 읽고 쓰는 실무적인 걸 보여줍니다.
Haskell Tutorials - Opaleye Tutorials - Instant Gratification
OS로 따지면 디렉토리와 유사합니다. 스키마는 views, indexes, sequences, data types, operator, functions shcema를 포함할 수 있습니다. 보통 DB에서 스키마라 하면 DB 구조(개체, 속성, 관계)와 제약 조건에 대한 정의를 지칭합니다. PostgreSQL에서도 뜻을 달리 쓰는 건 아닙니다. PostgreSQL은 스키마 안에 테이블을 만든다고 보면 됩니다. 다음 명령어로 스키마를 생성할 수 있습니다.
CREATE SCHEMA name;
Schema안에 테이블 생성하기
CREATE TABLE myschema.mytable( ... );
다른 스키마를 지정하고 싶으면 tableWithSchema
함수를 쓰면 됩니다.
테이블의 모든 row를 선택하려면 selectTable
을 씁니다.↩︎