7.3. Worked Example: Typed Queries
인덱싱된 패밀리는 다른 언어와 비슷한 API를 만들 때 매우 유용하다. 잘못된 HTML을 만들 수 없는 HTML 생성자 라이브러리를 작성하거나 설정 파일 형식의 규칙을 인코딩하거나 복잡한 비즈니스 제약을 모델링하는 데 사용할 수 있다. 이 절에서는 더 강력한 데이터베이스 질의 언어를 만드는 기법을 간단히 보이기 위해 인덱싱된 패밀리로 관계 대수의 일부를 Lean에 인코딩한다.
이 부분집합은 필드 이름의 서로소성 같은 요구를 타입 시스템으로 강제하고, 타입 수준 계산으로 스키마를 질의가 반환하는 값의 타입에 반영한다. 하지만 현실적인 시스템은 아니다. 데이터베이스를 연결 리스트의 연결 리스트로 나타내고 타입 시스템은 SQL보다 훨씬 단순하며 관계 대수 연산자도 SQL 연산자와 정확히 일치하지 않는다. 그래도 유용한 원리와 기법을 보이기에는 충분히 크다.
7.3.1. A Universe of Data
이 관계 대수에서 열에 저장할 수 있는 기본 데이터는 Int, String, Bool 타입을 가지며 유니버스 DBType로 기술한다:
inductive DBType where
| int | string | bool
abbrev DBType.asType : DBType → Type
| .int => Int
| .string => String
| .bool => Bool
DBType.asType을 사용하면 이 코드를 타입으로 사용할 수 있다.
예를 들어:
#eval ("Mount Hood" : DBType.string.asType)
세 데이터베이스 타입이 나타내는 값은 모두 동등성을 비교할 수 있다.
하지만 이를 Lean에 설명하려면 약간의 작업이 필요하다.
BEq를 직접 사용하면 실패한다:
def DBType.beq (t : DBType) (x y : t.asType) : Bool :=
x == y
중첩 쌍 유니버스에서와 마찬가지로 타입 클래스 탐색은 t 값의 각 가능성을 자동으로 검사하지 않는다.
해결책은 패턴 매칭으로 x와 y의 타입을 정제하는 것이다:
def DBType.beq (t : DBType) (x y : t.asType) : Bool :=
match t with
| .int => x == y
| .string => x == y
| .bool => x == y
이 함수 버전에서는 세 경우에 x와 y의 타입이 각각 Int, String, Bool이 되며 이 타입에는 모두 BEq 인스턴스가 있다.
DBType.beq 정의로 DBType가 코드화한 타입의 BEq 인스턴스를 정의할 수 있다:
instance {t : DBType} : BEq t.asType where
beq := t.beq이는 코드 자체의 인스턴스와는 다르다:
instance : BEq DBType where
beq
| .int, .int => true
| .string, .string => true
| .bool, .bool => true
| _, _ => false앞의 인스턴스는 코드가 나타내는 타입의 값을 비교하고, 뒤의 인스턴스는 코드 자체를 비교한다.
같은 기법으로 Repr 인스턴스도 작성할 수 있다.
Repr 클래스의 메서드를 reprPrec라고 하는데 값 표시에서 연산자 우선순위 등을 고려하도록 설계되었기 때문이다.
종속 패턴 매칭으로 타입을 정제하면 Int, String, Bool의 Repr 인스턴스에서 reprPrec 메서드를 사용할 수 있다:
instance {t : DBType} : Repr t.asType where
reprPrec :=
match t with
| .int => reprPrec
| .string => reprPrec
| .bool => reprPrec7.3.2. Schemas and Tables
스키마는 데이터베이스 각 열의 이름과 타입을 기술한다:
structure Column where
name : String
contains : DBType
abbrev Schema := List Column실제로 스키마는 테이블의 행을 기술하는 유니버스로 볼 수 있다. 빈 스키마는 유닛 타입을 나타내고, 열 하나인 스키마는 그 값을 그대로 나타내며, 열이 두 개 이상인 스키마는 튜플로 표현한다:
abbrev Row : Schema → Type
| [] => Unit
| [col] => col.contains.asType
| col1 :: col2 :: cols => col1.contains.asType × Row (col2::cols)곱 타입을 처음 설명한 절에서 보았듯 Lean의 곱 타입과 튜플은 오른쪽 결합이다. 따라서 중첩 쌍은 일반적인 평탄 튜플과 동등하다.
테이블은 같은 스키마를 공유하는 행의 리스트다:
abbrev Table (s : Schema) := List (Row s)
예를 들어 산봉우리 방문 기록은 peak 스키마로 나타낼 수 있다:
abbrev peak : Schema := [
⟨"name", .string⟩,
⟨"location", .string⟩,
⟨"elevation", .int⟩,
⟨"lastVisited", .int⟩
]이 책의 저자가 방문한 봉우리 일부는 일반 튜플 리스트로 나타난다:
def mountainDiary : Table peak := [
("Mount Nebo", "USA", 3637, 2013),
("Moscow Mountain", "USA", 1519, 2015),
("Himmelbjerget", "Denmark", 147, 2004),
("Mount St. Helens", "USA", 2549, 2010)
]또 다른 예는 폭포와 폭포 방문 기록이다:
abbrev waterfall : Schema := [
⟨"name", .string⟩,
⟨"location", .string⟩,
⟨"lastVisited", .int⟩
]def waterfallDiary : Table waterfall := [
("Multnomah Falls", "USA", 2018),
("Shoshone Falls", "USA", 2014)
]7.3.2.1. Recursion and Universes, Revisited
행을 튜플로 편리하게 구성하는 데에는 대가가 따른다. Row가 두 기본 경우를 별도로 처리하므로, Row를 타입에 사용하고 코드(즉 스키마)에 대해 재귀적으로 정의한 함수도 같은 구분을 해야 한다.
이 점이 중요한 한 예는 스키마를 따라 재귀하면서 행의 동등성을 검사하는 함수를 정의하는 동등성 검사다.
다음 예제는 Lean의 타입 검사를 통과하지 못한다.
def Row.bEq (r1 r2 : Row s) : Bool :=
match s with
| [] => true
| col::cols =>
match r1, r2 with
| (v1, r1'), (v2, r2') =>
v1 == v2 && bEq r1' r2'
문제는 col :: cols 패턴이 행의 타입을 충분히 정제하지 못한다는 것이다.
Lean은 아직 단일 원소 패턴 [col]과 Row 정의의 col1 :: col2 :: cols 패턴 중 어느 것이 매칭되었는지 알 수 없다. 따라서 Row 호출이 쌍 타입까지 계산되지 않는다.
해결책은 Row.bEq의 정의에서 Row의 구조를 그대로 반영하는 것이다.
def Row.bEq (r1 r2 : Row s) : Bool :=
match s with
| [] => true
| [_] => r1 == r2
| _::_::_ =>
match r1, r2 with
| (v1, r1'), (v2, r2') =>
v1 == v2 && bEq r1' r2'
instance : BEq (Row s) where
beq := Row.bEq다른 맥락과 달리 타입에 나타나는 함수는 입력과 출력의 동작만으로 생각할 수 없다. 이 타입을 사용하는 프로그램은 타입 수준 함수가 사용하는 알고리즘을 그대로 따라야 한다. 그래야 프로그램의 구조가 타입의 패턴 매칭 및 재귀 동작과 일치한다. 종속 타입으로 프로그래밍하는 능력의 큰 부분은 올바른 계산 동작을 가진 적절한 타입 수준 함수를 선택하는 것이다.
7.3.2.2. Column Pointers
일부 질의는 스키마에 특정 열이 있을 때만 의미가 있다.
예를 들어 고도가 1000미터보다 높은 산을 반환하는 질의는 정수를 담는 "elevation" 열이 있는 스키마에서만 의미가 있다.
열이 스키마에 들어 있음을 나타내는 한 가지 방법은 그 열을 직접 가리키는 포인터를 제공하는 것이다. 이 포인터를 인덱스 패밀리로 정의하면 잘못된 포인터를 배제할 수 있다.
열이 스키마에 존재하는 방식은 두 가지다. 스키마의 맨 앞에 있거나, 스키마의 뒤쪽 어딘가에 있다. 스키마의 뒤쪽에 있는 열도 결국 스키마의 어떤 꼬리 부분에서는 맨 앞에 있게 된다.
인덱스 패밀리 HasCol은 이 명세를 Lean 코드로 옮긴 것이다.
inductive HasCol : Schema → String → DBType → Type where
| here : HasCol (⟨name, t⟩ :: _) name t
| there : HasCol s name t → HasCol (_ :: s) name t
이 패밀리의 세 인자는 스키마, 열 이름, 열의 타입이다.
세 인자 모두 인덱스이지만, 열 이름과 타입 뒤에 스키마가 오도록 인자의 순서를 바꾸면 이름과 타입을 매개변수로 둘 수 있다.
스키마가 ⟨name, t⟩ 열로 시작할 때 생성자 here를 사용할 수 있다. 따라서 이는 스키마의 첫 열을 가리키는 포인터이며, 첫 열이 원하는 이름과 타입을 가질 때만 사용할 수 있다.
생성자 there는 더 작은 스키마를 가리키는 포인터를 열이 하나 더 있는 스키마를 가리키는 포인터로 바꾼다.
"elevation"은 peak의 세 번째 열이므로, there로 처음 두 열을 지나가면 찾을 수 있고 그 뒤에는 첫 번째 열이 된다.
다시 말해 HasCol peak "elevation" .int 타입을 만족하려면 .there (.there .here) 표현식을 사용하라.
HasCol을 장식된 Nat의 일종으로 생각할 수도 있다. zero는 here에 대응하고 succ는 there에 대응한다.
추가된 타입 정보 덕분에 하나 차이 나는 오류를 일으킬 수 없다.
스키마의 특정 열을 가리키는 포인터를 사용하면 행에서 그 열의 값을 추출할 수 있다.
def Row.get (row : Row s) (col : HasCol s n t) : t.asType :=
match s, col, row with
| [_], .here, v => v
| _::_::_, .here, (v, _) => v
| _::_::_, .there next, (_, r) => get r next
첫 단계는 스키마에 패턴 매칭하는 것이다. 그래야 행이 튜플인지 단일 값인지 결정할 수 있다.
빈 스키마에 대한 경우는 필요하지 않다. 빈 스키마에는 HasCol 값이 존재할 수 없고, HasCol의 두 생성자는 모두 비어 있지 않은 스키마를 지정하기 때문이다.
스키마에 열이 하나뿐이면 포인터는 그 열을 가리켜야 하므로 HasCol의 here 생성자만 매칭하면 된다.
스키마에 열이 두 개 이상이면 here에 대한 경우와 there에 대한 경우가 있어야 한다. 전자의 경우 값은 행의 첫 번째 값이고, 후자의 경우 재귀 호출을 사용한다.
HasCol 타입이 열이 행에 존재함을 보장하므로 Row.get은 Option을 반환할 필요가 없다.
HasCol은 두 가지 역할을 한다.
-
특정 이름과 타입을 가진 열이 스키마에 존재한다는 증거가 된다.
-
행에서 그 열에 대응하는 값을 찾는 데 사용할 수 있는 데이터가 된다.
첫 번째 역할인 증거의 역할은 명제를 사용하는 방식과 비슷하다.
인덱스 패밀리 HasCol의 정의는 주어진 열이 존재한다는 증거로 인정되는 것이 무엇인지 명세한 것으로 읽을 수 있다.
하지만 명제와 달리 HasCol의 어떤 생성자를 사용했는지가 중요하다.
두 번째 역할에서는 생성자를 Nat처럼 사용해 컬렉션에서 데이터를 찾는다.
인덱스 패밀리로 프로그래밍하려면 이 두 관점 사이를 능숙하게 오갈 수 있어야 하는 경우가 많다.
7.3.2.3. Subschemas
관계 대수의 중요한 연산 중 하나는 테이블이나 행을 더 작은 스키마로 프로젝션하는 것이다.
더 작은 스키마에 없는 모든 열은 버린다.
프로젝션이 의미 있으려면 더 작은 스키마가 더 큰 스키마의 서브스키마여야 한다. 즉 더 작은 스키마의 모든 열이 더 큰 스키마에 있어야 한다.
HasCol을 사용하면 실패할 수 없는 단일 열 조회를 행에 작성할 수 있었듯이, 서브스키마 관계를 인덱스 패밀리로 나타내면 실패할 수 없는 프로젝션 함수를 작성할 수 있다.
한 스키마가 다른 스키마의 서브스키마가 되는 방식을 인덱스 패밀리로 정의할 수 있다.
핵심 아이디어는 더 작은 스키마의 모든 열이 더 큰 스키마에 나타나면 더 작은 스키마가 더 큰 스키마의 서브스키마라는 것이다.
더 작은 스키마가 비어 있다면 이는 확실히 더 큰 스키마의 서브스키마이며, 생성자 nil로 나타낸다.
더 작은 스키마에 열이 있다면 그 열은 더 큰 스키마에 있어야 하고, 서브스키마에 남은 모든 열도 더 큰 스키마의 서브스키마여야 한다.
이는 생성자 cons로 나타낸다.
inductive Subschema : Schema → Schema → Type where
| nil : Subschema [] bigger
| cons :
HasCol bigger n t →
Subschema smaller bigger →
Subschema (⟨n, t⟩ :: smaller) bigger
다시 말해 Subschema는 더 작은 스키마의 각 열에 더 큰 스키마에서 그 위치를 가리키는 HasCol을 할당한다.
travelDiary 스키마는 peak과 waterfall에 공통으로 있는 필드를 나타낸다.
abbrev travelDiary : Schema :=
[⟨"name", .string⟩, ⟨"location", .string⟩, ⟨"lastVisited", .int⟩]
다음 예제에서 보이듯 이는 확실히 peak의 서브스키마다.
example : Subschema travelDiary peak :=
.cons .here
(.cons (.there .here)
(.cons (.there (.there (.there .here))) .nil))
그러나 이런 코드는 읽고 유지하기 어렵다.
개선하는 한 가지 방법은 Lean에게 Subschema와 HasCol 생성자를 자동으로 작성하게 하는 것이다.
이는 명제와 증명에 관한 막간에서 소개한 전술 기능으로 할 수 있다.
그 막간에서는 by decide와 by simp를 사용해 여러 명제의 증거를 제공한다.
이 맥락에서는 두 전술이 유용하다.
-
constructor전술은 데이터 타입의 생성자를 사용해 문제를 풀도록 Lean에게 지시한다. -
repeat전술은 전술이 실패하거나 증명이 끝날 때까지 전술을 계속 반복하도록 Lean에게 지시한다.
다음 예제에서 by constructor는 .nil만 작성한 것과 같은 효과를 낸다.
example : Subschema [] peak := ⊢ Subschema [] peak All goals completed! 🐙그러나 조금 더 복잡한 타입에 같은 전술을 시도하면 실패한다.
example : Subschema [⟨"location", .string⟩] peak := ⊢ Subschema [{ name := "location", contains := DBType.string }] peak ⊢ HasCol peak "location" DBType.string⊢ Subschema [] peak
unsolved goals로 시작하는 오류는 전술이 원래 만들어야 했던 표현식을 완전히 구성하지 못했음을 나타낸다.
Lean의 전술 언어에서 목표는 전술이 내부에서 적절한 표현식을 구성해 충족해야 하는 타입이다.
이 경우 constructor가 Subschema.cons를 적용했고, 두 목표는 cons가 기대하는 두 인자를 나타낸다.
constructor를 한 번 더 추가하면 첫 번째 목표(HasCol peak "location" DBType.string)는 HasCol.there로 처리된다. peak의 첫 열은 "location"이 아니기 때문이다.
example : Subschema [⟨"location", .string⟩] peak := ⊢ Subschema [{ name := "location", contains := DBType.string }] peak
⊢ HasCol peak "location" DBType.string⊢ Subschema [] peak
⊢ HasCol
[{ name := "location", contains := DBType.string }, { name := "elevation", contains := DBType.int },
{ name := "lastVisited", contains := DBType.int }]
"location" DBType.string⊢ Subschema [] peak
그러나 constructor를 세 번째로 추가하면 HasCol.here를 적용할 수 있으므로 첫 번째 목표가 해결된다.
example : Subschema [⟨"location", .string⟩] peak := ⊢ Subschema [{ name := "location", contains := DBType.string }] peak
⊢ HasCol peak "location" DBType.string⊢ Subschema [] peak
⊢ HasCol
[{ name := "location", contains := DBType.string }, { name := "elevation", contains := DBType.int },
{ name := "lastVisited", contains := DBType.int }]
"location" DBType.string⊢ Subschema [] peak
⊢ Subschema [] peak
constructor를 네 번째로 사용하면 Subschema peak [] 목표가 해결된다.
example : Subschema [⟨"location", .string⟩] peak := ⊢ Subschema [{ name := "location", contains := DBType.string }] peak
⊢ HasCol peak "location" DBType.string⊢ Subschema [] peak
⊢ HasCol
[{ name := "location", contains := DBType.string }, { name := "elevation", contains := DBType.int },
{ name := "lastVisited", contains := DBType.int }]
"location" DBType.string⊢ Subschema [] peak
⊢ Subschema [] peak
All goals completed! 🐙실제로 전술을 사용하지 않고 작성한 버전에는 생성자가 네 개 있다.
example : Subschema [⟨"location", .string⟩] peak :=
.cons (.there .here) .nil
constructor를 몇 번 써야 하는지 실험하는 대신, repeat 전술을 사용해 진행이 되는 동안 constructor를 계속 시도하도록 Lean에게 요청할 수 있다.
example : Subschema [⟨"location", .string⟩] peak := ⊢ Subschema [{ name := "location", contains := DBType.string }] peak repeat All goals completed! 🐙
이처럼 더 유연한 버전은 더 흥미로운 Subschema 문제에도 작동한다.
example : Subschema travelDiary peak := ⊢ Subschema travelDiary peak repeat All goals completed! 🐙
example : Subschema travelDiary waterfall := ⊢ Subschema travelDiary waterfall repeat All goals completed! 🐙
무언가 작동할 때까지 생성자를 무작정 시도하는 방식은 Nat이나 List Bool 같은 타입에는 그다지 유용하지 않다.
어떤 표현식의 타입이 Nat이라고 해서 그것이 올바른 Nat이라는 뜻은 아니기 때문이다.
하지만 HasCol과 Subschema 같은 타입은 인덱스에 의해 충분히 제약되어 있어 적용 가능한 생성자가 언제나 하나뿐이다. 따라서 프로그램 자체의 내용은 덜 중요해지고 컴퓨터가 올바른 생성자를 고를 수 있다.
한 스키마가 다른 스키마의 서브스키마라면, 열을 하나 추가해 확장한 더 큰 스키마의 서브스키마이기도 하다.
이 사실을 함수 정의로 나타낼 수 있다.
Subschema.addColumn은 smaller가 bigger의 서브스키마라는 증거를 받아, smaller가 c :: bigger, 즉 열을 하나 추가한 bigger의 서브스키마라는 증거를 반환한다.
def Subschema.addColumn :
Subschema smaller bigger →
Subschema smaller (c :: bigger)
| .nil => .nil
| .cons col sub' => .cons (.there col) sub'.addColumn
서브스키마는 더 작은 스키마의 각 열을 더 큰 스키마에서 어디서 찾을 수 있는지 기술한다.
Subschema.addColumn은 원래의 더 큰 스키마에 관한 이 기술을 열이 추가된 더 큰 스키마에 맞게 바꿔야 한다.
nil 경우에는 더 작은 스키마가 []이고, nil은 []가 c :: bigger의 서브스키마라는 증거이기도 하다.
cons 경우는 smaller의 한 열을 bigger에 배치하는 방법을 기술한다. 새 열 c를 고려해 열의 위치를 there로 조정하고, 재귀 호출로 나머지 열을 조정해야 한다.
Subschema를 생각하는 또 다른 방법은 두 스키마 사이의 관계를 정의한다고 보는 것이다. Subschema smaller bigger 타입의 표현식이 존재한다는 것은 (smaller, bigger)가 그 관계에 속한다는 뜻이다.
이 관계는 반사적이다. 즉 모든 스키마는 자기 자신의 서브스키마다.
def Subschema.reflexive : (s : Schema) → Subschema s s
| [] => .nil
| _ :: cs => .cons .here (reflexive cs).addColumn7.3.2.4. Projecting Rows
s'가 s의 서브스키마라는 증거가 주어지면 s의 행을 s'의 행으로 프로젝션할 수 있다.
이는 s'가 s의 서브스키마라는 증거를 사용해 수행한다. 이 증거가 s'의 각 열이 s에서 어디에 있는지 알려 준다.
새 s' 행은 기존 행의 적절한 위치에서 값을 가져와 한 열씩 구성한다.
이 프로젝션을 수행하는 함수 Row.project에는 Row 자체의 각 경우에 대응하는 세 경우가 있다.
Subschema 인자의 각 HasCol과 Row.get을 함께 사용해 프로젝션된 행을 구성한다.
def Row.project (row : Row s) : (s' : Schema) → Subschema s' s → Row s'
| [], .nil => ()
| [_], .cons c .nil => row.get c
| _::_::_, .cons c cs => (row.get c, row.project _ cs)7.3.3. Conditions and Selection
프로젝션은 테이블에서 원하지 않는 열을 제거하지만, 질의는 원하지 않는 행도 제거할 수 있어야 한다. 이 연산을 선택(selection)이라고 한다. 선택에는 어떤 행을 원하는지 표현하는 방법이 필요하다.
예제 질의 언어에는 표현식이 있다. 이는 SQL의 WHERE 절에 쓸 수 있는 것과 비슷하다.
표현식은 인덱스 패밀리 DBExpr로 나타낸다.
표현식은 데이터베이스의 열을 참조할 수 있지만 서로 다른 부분 표현식도 모두 같은 스키마를 가지므로, DBExpr는 데이터베이스 스키마를 매개변수로 받는다.
또한 각 표현식에는 타입이 있고 타입은 다양하므로, 이를 인덱스로 둔다.
inductive DBExpr (s : Schema) : DBType → Type where
| col (n : String) (loc : HasCol s n t) : DBExpr s t
| eq (e1 e2 : DBExpr s t) : DBExpr s .bool
| lt (e1 e2 : DBExpr s .int) : DBExpr s .bool
| and (e1 e2 : DBExpr s .bool) : DBExpr s .bool
| const : t.asType → DBExpr s t
col 생성자는 데이터베이스의 열에 대한 참조를 나타낸다.
eq 생성자는 두 표현식의 동등성을 비교하고, lt는 한쪽이 다른 쪽보다 작은지 검사하며, and는 불리언 논리곱이고, const는 어떤 타입의 상수값이다.
예를 들어 peak에서 elevation 열이 1000보다 크고 위치가 "Denmark"인지 검사하는 표현식은 다음과 같이 쓸 수 있다.
def tallInDenmark : DBExpr peak .bool :=
.and (.lt (.const 1000) (.col "elevation" (⊢ HasCol peak "elevation" DBType.int repeat All goals completed! 🐙)))
(.eq (.col "location" (⊢ HasCol peak "location" ?m.16 repeat All goals completed! 🐙)) (.const "Denmark"))
이는 조금 장황하다.
특히 열 참조마다 by repeat constructor라는 상투적인 호출이 들어간다.
Lean의 매크로(macros) 기능을 사용하면 이 상투적인 부분을 없애 표현식을 더 읽기 쉽게 만들 수 있다.
macro "c!" n:term : term => `(DBExpr.col $n (by repeat constructor))
이 선언은 Lean에 c! 키워드를 추가하고, c! 뒤에 표현식이 오는 모든 경우를 해당하는 DBExpr.col 구성으로 바꾸도록 지시한다.
여기서 term은 명령이나 전술 또는 언어의 다른 부분이 아니라 Lean 표현식을 뜻한다.
Lean 매크로는 C 전처리기 매크로와 조금 비슷하지만 언어에 더 잘 통합되어 있고 CPP의 몇 가지 함정을 자동으로 피한다.
실제로 Lean 매크로는 Scheme 및 Racket의 매크로와 매우 밀접하게 관련되어 있다.
이 매크로를 사용하면 표현식을 훨씬 읽기 쉽게 만들 수 있다.
def tallInDenmark : DBExpr peak .bool :=
.and (.lt (.const 1000) (c! "elevation"))
(.eq (c! "location") (.const "Denmark"))
주어진 행에 대한 표현식의 값을 구할 때는 Row.get으로 열 참조를 추출하고, 그 밖의 모든 표현식은 Lean의 값 연산에 맡긴다.
def DBExpr.evaluate (row : Row s) : DBExpr s t → t.asType
| .col _ loc => row.get loc
| .eq e1 e2 => evaluate row e1 == evaluate row e2
| .lt e1 e2 => evaluate row e1 < evaluate row e2
| .and e1 e2 => evaluate row e1 && evaluate row e2
| .const v => v
코펜하겐 지역에서 가장 높은 언덕인 Valby Bakke에 대해 표현식을 평가하면 해수면보다 1km도 높지 않으므로 false가 나온다.
#eval tallInDenmark.evaluate ("Valby Bakke", "Denmark", 31, 2023)
고도가 1230m인 가상의 산에 대해 평가하면 true가 나온다.
#eval tallInDenmark.evaluate ("Fictional mountain", "Denmark", 1230, 2023)
미국 아이다호주의 가장 높은 봉우리에 대해 평가하면 아이다호는 덴마크가 아니므로 false가 나온다.
#eval tallInDenmark.evaluate ("Mount Borah", "USA", 3859, 1996)7.3.4. Queries
질의 언어는 관계 대수를 바탕으로 한다. 테이블 외에도 다음 연산자를 포함한다.
-
같은 스키마를 가진 두 표현식의 합집합은 두 질의에서 나온 행을 합친다.
-
같은 스키마를 가진 두 표현식의 차집합은 첫 번째 결과의 행에서 두 번째 결과에 있는 행을 제거한다.
-
어떤 기준에 따른 선택은 표현식에 따라 질의 결과를 필터링한다.
-
서브스키마로의 프로젝션은 질의 결과에서 열을 제거한다.
-
데카르트 곱은 한 질의의 모든 행과 다른 질의의 모든 행을 결합한다.
-
질의 결과의 열 이름 바꾸기는 스키마를 변경한다.
-
질의의 모든 열 앞에 이름을 붙인다.
마지막 연산자는 반드시 필요하지는 않지만 언어를 더 편리하게 사용할 수 있게 한다.
다시 한 번 질의를 인덱스 패밀리로 나타낸다.
inductive Query : Schema → Type where
| table : Table s → Query s
| union : Query s → Query s → Query s
| diff : Query s → Query s → Query s
| select : Query s → DBExpr s .bool → Query s
| project :
Query s → (s' : Schema) →
Subschema s' s →
Query s'
| product :
Query s1 → Query s2 →
disjoint (s1.map Column.name) (s2.map Column.name) →
Query (s1 ++ s2)
| renameColumn :
Query s → (c : HasCol s n t) → (n' : String) →
!((s.map Column.name).contains n') →
Query (s.renameColumn c n')
| prefixWith :
(n : String) → Query s →
Query (s.map fun c => {c with name := n ++ "." ++ c.name})
select 생성자는 선택에 사용하는 표현식이 불리언을 반환하도록 요구한다.
product 생성자의 타입에는 disjoint 호출이 들어 있어 두 스키마에 이름이 겹치지 않도록 보장한다.
def disjoint [BEq α] (xs ys : List α) : Bool :=
not (xs.any ys.contains || ys.any xs.contains)
타입이 필요한 곳에서 Bool 타입의 표현식을 사용하면 Bool에서 Prop으로 강제 변환이 일어난다.
결정 가능한 명제를 불리언으로 볼 수 있고 명제의 증거는 true로, 반증은 false로 강제 변환되는 것처럼, 불리언은 그 표현식이 true와 같다는 명제로 강제 변환된다.
라이브러리의 모든 사용은 스키마를 미리 아는 맥락에서 이루어질 것으로 예상되므로 이 명제는 by simp로 증명할 수 있다.
마찬가지로 renameColumn 생성자는 새 이름이 스키마에 이미 존재하지 않는지 검사한다.
이 생성자는 HasCol이 가리키는 열의 이름을 바꾸기 위해 도우미 Schema.renameColumn을 사용한다.
def Schema.renameColumn : (s : Schema) → HasCol s n t → String → Schema
| c :: cs, .here, n' => {c with name := n'} :: cs
| c :: cs, .there next, n' => c :: renameColumn cs next n'7.3.5. Executing Queries
질의를 실행하려면 몇 가지 도우미 함수가 필요하다. 질의의 결과는 테이블이므로 질의 언어의 각 연산에는 테이블에서 동작하는 대응 구현이 필요하다.
7.3.5.1. Cartesian Product
두 테이블의 데카르트 곱은 첫 번째 테이블의 각 행을 두 번째 테이블의 각 행에 덧붙여 구한다.
우선 Row의 구조 때문에 행에 열 하나를 추가하려면 결과가 단일 값인지 튜플인지 결정하도록 스키마에 패턴 매칭해야 한다.
이는 자주 쓰는 연산이므로 패턴 매칭을 도우미로 분리하면 편리하다.
def addVal (v : c.contains.asType) (row : Row s) : Row (c :: s) :=
match s, row with
| [], () => v
| c' :: cs, v' => (v, v')두 행을 덧붙이는 함수는 첫 번째 스키마와 첫 번째 행의 구조 모두에 대해 재귀적이다. 행의 구조가 스키마의 구조와 보조를 맞추어 진행되기 때문이다. 첫 번째 행이 비어 있으면 덧붙이기는 두 번째 행을 반환한다. 첫 번째 행이 단일 원소이면 그 값을 두 번째 행에 추가한다. 첫 번째 행에 열이 여러 개 있으면 첫 열의 값을 행 나머지에 대한 재귀 결과에 추가한다.
def Row.append (r1 : Row s1) (r2 : Row s2) : Row (s1 ++ s2) :=
match s1, r1 with
| [], () => r2
| [_], v => addVal v r2
| _::_::_, (v, r') => (v, r'.append r2)
표준 라이브러리에 있는 List.flatMap은 입력 리스트의 각 원소에 리스트를 반환하는 함수를 적용하고, 그 결과 리스트들을 순서대로 덧붙인 결과를 반환한다.
def List.flatMap (f : α → List β) : (xs : List α) → List β
| [] => []
| x :: xs => f x ++ xs.flatMap f
타입 시그니처를 보면 List.flatMap으로 Monad List 인스턴스를 구현할 수 있을 것 같다.
실제로 pure x := [x]와 함께 사용하면 List.flatMap은 모나드를 구현한다.
그러나 이는 그다지 유용한 Monad 인스턴스가 아니다.
List 모나드는 기본적으로 Many의 한 버전이다. 사용자가 값의 개수를 요청하기도 전에 탐색 공간의 가능한 모든 경로를 미리 탐색한다.
이 성능 함정 때문에 보통 List에 Monad 인스턴스를 정의하는 것은 좋은 생각이 아니다.
하지만 여기서는 질의 언어에 반환할 결과 수를 제한하는 연산자가 없으므로 모든 가능성을 결합하는 것이 정확히 원하는 동작이다.
def Table.cartesianProduct (table1 : Table s1) (table2 : Table s2) :
Table (s1 ++ s2) :=
table1.flatMap fun r1 => table2.map r1.append
List.product와 마찬가지로 항등 모나드에서 변이를 사용하는 반복문을 대안 구현 기법으로 사용할 수도 있다.
def Table.cartesianProduct (table1 : Table s1) (table2 : Table s2) :
Table (s1 ++ s2) := Id.run do
let mut out : Table (s1 ++ s2) := []
for r1 in table1 do
for r2 in table2 do
out := (r1.append r2) :: out
pure out.reverse7.3.5.2. Difference
테이블에서 원하지 않는 행은 리스트와 Bool을 반환하는 함수를 받는 List.filter로 제거할 수 있다.
함수가 true를 반환하는 원소만 담은 새 리스트가 반환된다.
예를 들어
["Willamette", "Columbia", "Sandy", "Deschutes"].filter (·.length > 8)를 평가하면
["Willamette", "Deschutes"]
"Columbia"와 "Sandy"의 길이가 8 이하이기 때문이다.
테이블의 원소는 도우미 List.without을 사용해 제거할 수 있다.
def List.without [BEq α] (source banned : List α) : List α :=
source.filter fun r => !(banned.contains r)
질의를 해석할 때 Row의 BEq 인스턴스와 함께 사용한다.
7.3.5.3. Renaming Columns
행의 열 이름 바꾸기는 해당 열을 찾을 때까지 행을 순회하는 재귀 함수로 수행한다. 열을 찾으면 새 이름의 열에 이전 열과 같은 값을 넣는다.
def Row.rename (c : HasCol s n t) (row : Row s) :
Row (s.renameColumn c n') :=
match s, row, c with
| [_], v, .here => v
| _::_::_, (v, r), .here => (v, r)
| _::_::_, (v, r), .there next => addVal v (r.rename next)
이 함수는 인자의 타입을 바꾸지만 실제 반환값에는 원래 인자와 정확히 같은 데이터가 들어 있다.
실행 시간 관점에서 Row.rename은 느린 항등 함수에 불과하다.
인덱스 패밀리로 프로그래밍할 때의 한 가지 어려움은 성능이 중요하면 이런 연산이 방해가 될 수 있다는 점이다.
이런 “재인덱싱” 함수를 없애려면 매우 신중하고 흔히 취약한 설계가 필요하다.
7.3.5.4. Prefixing Column Names
열 이름에 접두사를 붙이는 일은 열 이름을 바꾸는 일과 매우 비슷하다.
원하는 열까지 이동한 다음 반환하는 대신 prefixRow는 모든 열을 처리해야 한다.
def prefixRow (row : Row s) :
Row (s.map fun c => {c with name := n ++ "." ++ c.name}) :=
match s, row with
| [], _ => ()
| [_], v => v
| _::_::_, (v, r) => (v, prefixRow r)
이를 List.map과 함께 사용하면 테이블의 모든 행에 접두사를 붙일 수 있다.
다시 말해 이 함수는 값의 타입을 바꾸기 위해서만 존재한다.
7.3.5.5. Putting the Pieces Together
이 도우미들을 모두 정의했으므로 질의를 실행하려면 짧은 재귀 함수 하나만 있으면 된다.
def Query.exec : Query s → Table s
| .table t => t
| .union q1 q2 => exec q1 ++ exec q2
| .diff q1 q2 => exec q1 |>.without (exec q2)
| .select q e => exec q |>.filter e.evaluate
| .project q _ sub => exec q |>.map (·.project _ sub)
| .product q1 q2 _ => exec q1 |>.cartesianProduct (exec q2)
| .renameColumn q c _ _ => exec q |>.map (·.rename c)
| .prefixWith _ q => exec q |>.map prefixRow
생성자의 일부 인자는 실행 중에 사용되지 않는다.
특히 project 생성자와 Row.project 함수는 더 작은 스키마를 명시적 인자로 받지만, 이 스키마가 더 큰 스키마의 서브스키마라는 증거의 타입에 충분한 정보가 들어 있어 Lean이 인자를 자동으로 채울 수 있다.
마찬가지로 product 생성자가 요구하는 두 테이블의 열 이름이 서로소라는 사실은 Table.cartesianProduct에 필요하지 않다.
일반적으로 종속 타입은 프로그래머를 대신해 Lean이 인자를 채우게 할 기회를 많이 제공한다.
질의 결과에는 점 표기법을 사용해 Table 및 List 네임스페이스에 정의된 List.map, List.filter, Table.cartesianProduct 같은 함수를 호출한다.
이는 Table이 abbrev로 정의되어 있기 때문에 가능하다.
타입 클래스 탐색과 마찬가지로 점 표기법은 abbrev로 만든 정의를 꿰뚫어 볼 수 있다.
select의 구현도 매우 간결하다.
질의 q를 실행한 뒤 List.filter를 사용해 표현식을 만족하지 않는 행을 제거한다.
List.filter는 Row s에서 Bool로 가는 함수를 기대하지만 DBExpr.evaluate의 타입은 Row s → DBExpr s t → t.asType이다.
select 생성자의 타입은 표현식이 DBExpr s .bool 타입이어야 한다고 요구하므로, 이 맥락에서 t.asType은 실제로 Bool이다.
고도가 500미터보다 높은 모든 산봉우리의 높이를 찾는 질의는 다음과 같이 작성할 수 있다.
open Query in
def example1 :=
table mountainDiary |>.select
(.lt (.const 500) (c! "elevation")) |>.project
[⟨"elevation", .int⟩] (⊢ Subschema [{ name := "elevation", contains := DBType.int }] peak repeat All goals completed! 🐙)이를 실행하면 예상한 정수 리스트가 반환된다.
#eval example1.exec관광 여행을 계획할 때 같은 위치에 있는 산과 폭포의 모든 쌍을 찾는 것이 유용할 수 있다. 두 테이블의 데카르트 곱을 구하고, 두 위치가 같은 행만 선택한 다음 이름만 프로젝션하면 된다.
open Query in
def example2 :=
let mountain := table mountainDiary |>.prefixWith "mountain"
let waterfall := table waterfallDiary |>.prefixWith "waterfall"
mountain.product waterfall (mountain:Query (List.map (fun c => { name := "mountain" ++ "." ++ c.name, contains := c.contains }) peak) := prefixWith "mountain" (table mountainDiary)waterfall:Query (List.map (fun c => { name := "waterfall" ++ "." ++ c.name, contains := c.contains }) _root_.waterfall) := prefixWith "waterfall" (table waterfallDiary)⊢ disjoint
(List.map Column.name (List.map (fun c => { name := "mountain" ++ "." ++ c.name, contains := c.contains }) peak))
(List.map Column.name
(List.map (fun c => { name := "waterfall" ++ "." ++ c.name, contains := c.contains }) _root_.waterfall)) =
true All goals completed! 🐙)
|>.select (.eq (c! "mountain.location") (c! "waterfall.location"))
|>.project [⟨"mountain.name", .string⟩, ⟨"waterfall.name", .string⟩]
(mountain:Query (List.map (fun c => { name := "mountain" ++ "." ++ c.name, contains := c.contains }) peak) := prefixWith "mountain" (table mountainDiary)waterfall:Query (List.map (fun c => { name := "waterfall" ++ "." ++ c.name, contains := c.contains }) _root_.waterfall) := prefixWith "waterfall" (table waterfallDiary)⊢ Subschema
[{ name := "mountain.name", contains := DBType.string }, { name := "waterfall.name", contains := DBType.string }]
(List.map (fun c => { name := "mountain" ++ "." ++ c.name, contains := c.contains }) peak ++
List.map (fun c => { name := "waterfall" ++ "." ++ c.name, contains := c.contains }) _root_.waterfall) repeat All goals completed! 🐙)예제 데이터에는 미국의 폭포만 들어 있으므로 질의를 실행하면 미국의 산과 폭포 쌍이 반환된다.
#eval example2.exec7.3.5.6. Errors You May Meet
Query의 정의는 발생 가능한 많은 오류를 배제한다.
예를 들어 "mountain.location"에서 추가한 수식어를 잊으면 컴파일 시간 오류가 발생하며 열 참조 c! "location"을 강조한다.
open Query in
def example2 :=
let mountains := table mountainDiary |>.prefixWith "mountain"
let waterfalls := table waterfallDiary |>.prefixWith "waterfall"
mountains.product waterfalls (mountains:Query (List.map (fun c => { name := "mountain" ++ "." ++ c.name, contains := c.contains }) peak) := prefixWith "mountain" (table mountainDiary)waterfalls:Query (List.map (fun c => { name := "waterfall" ++ "." ++ c.name, contains := c.contains }) waterfall) := prefixWith "waterfall" (table waterfallDiary)⊢ disjoint
(List.map Column.name (List.map (fun c => { name := "mountain" ++ "." ++ c.name, contains := c.contains }) peak))
(List.map Column.name
(List.map (fun c => { name := "waterfall" ++ "." ++ c.name, contains := c.contains }) waterfall)) =
true mountains:Query (List.map (fun c => { name := "mountain" ++ "." ++ c.name, contains := c.contains }) peak) := prefixWith "mountain" (table mountainDiary)waterfalls:Query (List.map (fun c => { name := "waterfall" ++ "." ++ c.name, contains := c.contains }) waterfall) := prefixWith "waterfall" (table waterfallDiary)⊢ disjoint ["mountain.name", "mountain.location", "mountain.elevation", "mountain.lastVisited"]
["waterfall.name", "waterfall.location", "waterfall.lastVisited"] =
true)
|>.select (.eq (c! "location") (c! "waterfall.location"))
|>.project [⟨"mountain.name", .string⟩, ⟨"waterfall.name", .string⟩]
(mountains:Query (List.map (fun c => { name := "mountain" ++ "." ++ c.name, contains := c.contains }) peak) := prefixWith "mountain" (table mountainDiary)waterfalls:Query (List.map (fun c => { name := "waterfall" ++ "." ++ c.name, contains := c.contains }) waterfall) := prefixWith "waterfall" (table waterfallDiary)⊢ Subschema
[{ name := "mountain.name", contains := DBType.string }, { name := "waterfall.name", contains := DBType.string }]
(List.map (fun c => { name := "mountain" ++ "." ++ c.name, contains := c.contains }) peak ++
List.map (fun c => { name := "waterfall" ++ "." ++ c.name, contains := c.contains }) waterfall) repeat All goals completed! 🐙)이는 훌륭한 피드백이다. 반면 오류 메시지의 내용은 실제로 문제를 고치기에는 꽤 어렵다.
마찬가지로 두 테이블 이름에 접두사를 붙이는 것을 잊으면 by decide에서 오류가 발생한다. 이 전술은 스키마가 실제로 서로소라는 증거를 제공해야 한다.
open Query in
def example2 :=
let mountains := table mountainDiary
let waterfalls := table waterfallDiary
mountains.product waterfalls (mountains:Query peak := table mountainDiarywaterfalls:Query waterfall := table waterfallDiary⊢ disjoint (List.map Column.name peak) (List.map Column.name waterfall) = true mountains:Query peak := table mountainDiarywaterfalls:Query waterfall := table waterfallDiary⊢ disjoint (List.map Column.name peak) (List.map Column.name waterfall) = true)
|>.select (.eq (c! "mountain.location") (c! "waterfall.location"))
|>.project [⟨"mountain.name", .string⟩, ⟨"waterfall.name", .string⟩]
(mountains:Query peak := table mountainDiarywaterfalls:Query waterfall := table waterfallDiary⊢ Subschema
[{ name := "mountain.name", contains := DBType.string }, { name := "waterfall.name", contains := DBType.string }]
(peak ++ waterfall) repeat mountains:Query peak := table mountainDiarywaterfalls:Query waterfall := table waterfallDiary⊢ HasCol [] "mountain.name" DBType.stringmountains:Query peak := table mountainDiarywaterfalls:Query waterfall := table waterfallDiary⊢ Subschema [{ name := "waterfall.name", contains := DBType.string }] (peak ++ waterfall))이 오류 메시지는 더 유용하다.
Lean의 매크로 시스템에는 질의를 위한 편리한 구문을 제공하는 데 필요한 것뿐 아니라 오류 메시지를 유용하게 만드는 데 필요한 것도 모두 들어 있다.
안타깝게도 Lean 매크로로 언어를 구현하는 방법을 설명하는 것은 이 책의 범위를 벗어난다.
Query 같은 인덱스 패밀리는 사용자 인터페이스보다는 타입이 지정된 데이터베이스 상호작용 라이브러리의 핵심으로 사용하는 편이 좋을 것이다.
7.3.6. Exercises
7.3.6.1. Dates
날짜를 나타내는 구조체를 정의하라. 이를 DBType 유니버스에 추가하고 나머지 코드도 그에 맞게 갱신하라. 필요해 보이는 추가 DBExpr 생성자를 제공하라.
7.3.6.2. Nullable Types
다음 구조체로 데이터베이스 타입을 나타내 질의 언어에 널 허용 열을 지원하는 기능을 추가하라.
structure NDBType where
underlying : DBType
nullable : Bool
abbrev NDBType.asType (t : NDBType) : Type :=
if t.nullable then
Option t.underlying.asType
else
t.underlying.asType
Column과 DBExpr에서 DBType 대신 이 타입을 사용하라. SQL의 NULL 및 비교 연산자 규칙을 찾아 DBExpr 생성자의 타입을 결정하라.
7.3.6.3. Experimenting with Tactics
by repeat constructor를 사용해 Lean에게 다음 타입의 값을 찾도록 요청하면 어떤 결과가 나오는가? 각각 왜 그런 결과가 나오는지 설명하라.