Lean으로 배우는 함수형 프로그래밍

7.3. 실습 예제: 타입이 지정된 쿼리🔗

인덱스 족은 다른 언어와 닮도록 설계된 API를 만들 때 매우 유용합니다. 인덱스 패밀리를 사용하면 잘못된 HTML을 생성할 수 없는 HTML 생성자 라이브러리를 작성하거나, 설정 파일 형식의 구체적인 규칙을 인코딩하거나, 복잡한 비즈니스 제약 조건을 모델링할 수 있습니다. 이 절에서는 더 강력한 데이터베이스 질의 언어를 만드는 데 쓸 수 있는 기법을 더 단순하게 보여 주기 위해, 인덱스 족을 사용하여 관계 대수의 일부를 Lean으로 인코딩하는 방법을 설명합니다.

이 부분집합은 필드 이름이 서로 겹치지 않아야 한다는 등의 요구 사항을 타입 시스템으로 강제하고, 타입 수준 계산을 사용해 쿼리가 반환하는 값의 타입에 스키마를 반영합니다. 그러나 이것은 현실적인 시스템이 아닙니다. 데이터베이스는 연결 리스트의 연결 리스트로 표현되고, 타입 시스템은 SQL의 타입 시스템보다 훨씬 단순하며, 관계 대수의 연산자는 SQL의 연산자와 딱 맞아떨어지지 않습니다. 그렇지만 유용한 원리와 기법을 보여 주기에는 충분히 큽니다.

7.3.1. 데이터의 유니버스🔗

이 관계 대수에서 열에 담길 수 있는 기본 데이터는 Int, String, Bool 타입을 가질 수 있으며, 이들은 유니버스 DBType으로 기술됩니다:

inductive DBType where | int | string | bool abbrev DBType.asType : DBType → Type | .int => Int | .string => String | .bool => Bool

DBType.asType을 사용하면 이러한 코드를 타입으로 사용할 수 있습니다. 예를 들면 다음과 같습니다.

"Mount Hood"#eval ("Mount Hood" : DBType.string.asType)
"Mount Hood"

세 가지 데이터베이스 타입 중 어느 것이 나타내는 값이든 서로 같은지 비교할 수 있습니다. 하지만 이를 Lean에게 설명하려면 약간의 작업이 필요합니다. BEq를 그냥 직접 사용하면 실패합니다:

def DBType.beq (t : DBType) (x y : t.asType) : Bool := failed to synthesize instance of type class BEq t.asType Hint: Type class instance resolution failures can be inspected with the `set_option trace.Meta.synthInstance true` command.x == y
failed to synthesize instance of type class
  BEq t.asType

Hint: Type class instance resolution failures can be inspected with the `set_option trace.Meta.synthInstance true` command.

중첩된 쌍 유니버스에서와 마찬가지로, 타입 클래스 탐색은 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 => reprPrec

7.3.2. 스키마와 테이블🔗

스키마는 데이터베이스의 각 열의 이름과 타입을 기술합니다:

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. 재귀와 유니버스 다시 살펴보기🔗

행을 튜플로 구조화하면 편리하지만 그만한 대가가 따릅니다. Row가 두 기저 사례를 따로 처리한다는 것은, 타입에 Row를 사용하면서 코드(즉, 스키마)에 대해 재귀적으로 정의되는 함수도 같은 구분을 해야 한다는 뜻입니다. 이 점이 문제가 되는 예로, 스키마에 대한 재귀를 사용해 두 행이 같은지 검사하는 함수를 정의하는 동등성 검사가 있습니다. 이 예제는 Lean의 타입 검사기를 통과하지 못합니다:

def Row.bEq (r1 r2 : Row s) : Bool := match s with | [] => true | col::cols => match r1, r2 with | Type mismatch (v1, r1') has type ?m.10 × ?m.11 but is expected to have type Row (col :: cols)(v1, r1'), (v2, r2') => v1 == v2 && bEq r1' r2'
Type mismatch
  (v1, r1')
has type
  ?m.10 × ?m.11
but is expected to have type
  Row (col :: cols)

문제는 col :: cols 패턴이 행의 타입을 충분히 구체화하지 못한다는 점입니다. 이는 Row의 정의에서 단일 원소 패턴 [col]과 col1 :: col2 :: cols 패턴 중 어느 쪽이 매칭되었는지 Lean이 아직 알 수 없기 때문이며, 그래서 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. 열 포인터🔗

어떤 쿼리는 스키마에 특정 열이 포함되어 있을 때에만 의미가 있습니다. 예를 들어, 고도가 1000미터보다 높은 산을 반환하는 쿼리는 정수를 담는 "elevation" 열이 있는 스키마의 맥락에서만 의미가 있습니다. 스키마에 어떤 열이 포함되어 있음을 나타내는 한 가지 방법은 그 열을 직접 가리키는 포인터를 제공하는 것이며, 이 포인터를 인덱스 족(indexed family)으로 정의하면 유효하지 않은 포인터를 배제할 수 있습니다.

열이 스키마에 존재할 수 있는 방식은 두 가지입니다. 스키마의 맨 앞에 있거나, 스키마의 뒤쪽 어딘가에 있는 것입니다. 어떤 열이 스키마의 뒤쪽에 있다면, 결국 그 열은 스키마의 어떤 꼬리 부분의 맨 앞이 됩니다.

인덱스 족 HasCol은 이 명세를 Lean 코드로 옮긴 것입니다.

inductive HasCol : Schema → String → DBType → Type where | here : HasCol (⟨name, t⟩ :: _) name t | there : HasCol s name t → HasCol (_ :: s) name t

이 족(family)의 세 인자는 스키마, 열 이름, 그리고 그 열의 타입입니다. 세 인자 모두 인덱스이지만, 인자의 순서를 바꾸어 스키마를 열 이름과 타입 뒤에 두면 이름과 타입을 매개변수로 만들 수 있습니다. 생성자 here는 스키마가 열 ⟨name, t⟩로 시작할 때 사용할 수 있습니다. 따라서 이 생성자는 스키마의 첫 번째 열을 가리키는 포인터이며, 첫 번째 열이 원하는 이름과 타입을 가질 때에만 사용할 수 있습니다. 생성자 there는 더 작은 스키마를 가리키는 포인터를 열이 하나 더 있는 스키마를 가리키는 포인터로 변환합니다.

"elevation"은 peak의 세 번째 열이므로 there로 처음 두 열을 건너뛰면 찾을 수 있습니다. 두 열을 건너뛰고 나면 이 열이 첫 번째 열이 됩니다. 다시 말해, HasCol peak "elevation" .int 타입을 만족시키려면 .there (.there .here) 표현식을 사용하면 됩니다. HasCol은 일종의 장식된 Nat으로 생각할 수 있습니다. zero는 here에 대응하고, succ는 there에 대응합니다. 추가된 타입 정보 덕분에 하나 차이 오류(off-by-one error)가 생길 수 없습니다.

스키마의 특정 열을 가리키는 포인터를 사용하면 행에서 해당 열의 값을 추출할 수 있습니다:

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은 두 가지 역할을 합니다:

  1. 이는 특정 이름과 타입을 가진 열이 스키마에 존재한다는 증거 역할을 합니다.

  2. 이는 행에서 해당 열과 연관된 값을 찾는 데 사용할 수 있는 데이터의 역할을 합니다.

첫 번째 역할인 증거로서의 역할은 명제가 사용되는 방식과 비슷합니다. 인덱스 족 HasCol의 정의는 주어진 열이 존재한다는 증거로 무엇이 인정되는지에 대한 명세로 읽을 수 있습니다. 그러나 명제와 달리 HasCol의 어떤 생성자가 사용되었는지가 중요합니다. 두 번째 역할에서는 컬렉션에서 데이터를 찾기 위해 생성자를 Nat처럼 사용합니다. 인덱스 족을 사용하는 프로그래밍에서는 두 관점 사이를 능숙하게 오가는 능력이 필요한 경우가 많습니다.

7.3.2.3. 부분 스키마🔗

관계 대수에서 중요한 연산 중 하나는 테이블이나 행을 더 작은 스키마로 사영하는 것입니다. 더 작은 스키마에 없는 열은 모두 버려집니다. 사영이 의미를 가지려면 더 작은 스키마가 더 큰 스키마의 부분 스키마여야 합니다. 즉, 더 작은 스키마의 모든 열이 더 큰 스키마에도 있어야 합니다. 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 := unsolved goals ⊢ HasCol peak "location" DBType.string ⊢ Subschema [] peak⊢ Subschema [{ name := "location", contains := DBType.string }] peak ⊢ HasCol peak "location" DBType.string⊢ Subschema [] peak
unsolved goals
⊢ HasCol peak "location" DBType.string

⊢ Subschema [] peak

unsolved goals로 시작하는 오류는 택틱이 구성해야 할 표현식을 완전히 구성하지 못했음을 나타냅니다. Lean의 택틱 언어에서 목표(goal)란 택틱이 내부적으로 적절한 표현식을 구성하여 충족시켜야 하는 타입입니다. 이 경우 constructor에 의해 Subschema.cons가 적용되었으며, 두 목표는 cons가 요구하는 두 인자를 나타냅니다. peak의 첫 번째 열은 "location"이 아니므로, constructor를 하나 더 추가하면 첫 번째 목표(HasCol peak "location" DBType.string)는 HasCol.there로 처리됩니다:

example : Subschema [⟨"location", .string⟩] peak := unsolved goals ⊢ HasCol [{ name := "location", contains := DBType.string }, { name := "elevation", contains := DBType.int }, { name := "lastVisited", contains := DBType.int }] "location" DBType.string ⊢ Subschema [] 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
unsolved goals
⊢ 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 := unsolved goals ⊢ Subschema [] 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
unsolved goals
⊢ 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 택틱을 사용하면 진전이 있는 한 Lean이 constructor를 계속 시도하도록 할 수 있습니다:

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).addColumn

7.3.2.4. 행 투영하기🔗

s'가 s의 부분 스키마라는 증거가 주어지면, s의 행을 s'의 행으로 사영할 수 있습니다. 이 작업은 s'가 s의 부분 스키마라는 증거를 사용하여 수행되며, 이 증거는 s'의 각 열이 s의 어디에 있는지를 알려 줍니다. s'의 새 행은 기존 행의 적절한 위치에서 값을 가져와 한 번에 한 열씩 만들어집니다.

이 사영을 수행하는 함수인 Row.project에는 Row 자체의 각 경우에 하나씩 대응하는 세 가지 경우가 있습니다. 이 함수는 Row.get과 Subschema 인자에 들어 있는 각 HasCol을 함께 사용하여 사영된 행을 구성합니다:

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. 조건과 선택🔗

프로젝션은 테이블에서 원하지 않는 열을 제거하지만, 쿼리는 원하지 않는 행도 제거할 수 있어야 합니다. 이 연산을 선택이라고 합니다. 선택을 하려면 어떤 행을 원하는지 표현할 수단이 있어야 합니다.

예제 질의 언어에는 표현식이 있으며, 이는 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 기능을 사용하면 이러한 상투적인 코드를 없애 표현식을 더 읽기 쉽게 만들 수 있습니다:

macro "c!" n:term : term => `(DBExpr.col $n (by repeat constructor))

이 선언은 Lean에 c! 키워드를 추가하고, c! 뒤에 표현식이 오는 모든 경우를 그에 대응하는 DBExpr.col 구성으로 바꾸도록 Lean에 지시합니다. 여기서 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에 대해 이 표현식을 평가하면, Valby Bakke는 해발 1km에 한참 못 미치므로 false가 나옵니다:

false#eval tallInDenmark.evaluate ("Valby Bakke", "Denmark", 31, 2023)
false

고도 1230m인 가상의 산에 대해 이를 평가하면 true가 나옵니다:

true#eval tallInDenmark.evaluate ("Fictional mountain", "Denmark", 1230, 2023)
true

미국 아이다호주의 최고봉에 대해 이를 평가하면 false가 나옵니다. 아이다호는 덴마크에 속하지 않기 때문입니다:

false#eval tallInDenmark.evaluate ("Mount Borah", "USA", 3859, 1996)
false

7.3.4. 쿼리🔗

이 질의 언어는 관계 대수를 기반으로 합니다. 테이블 외에도 다음과 같은 연산자를 포함합니다.

  1. 같은 스키마를 갖는 두 표현식의 합집합은 두 질의의 결과로 나온 행들을 결합합니다

  2. 스키마가 같은 두 표현식의 차집합은 첫 번째 결과의 행 중에서 두 번째 결과에 있는 행을 제거합니다

  3. 어떤 기준에 따른 선택은 질의의 결과를 표현식에 따라 필터링합니다

  4. 부분 스키마로의 사영, 즉 질의 결과에서 열을 제거하는 연산

  5. 데카르트 곱: 한 쿼리의 모든 행을 다른 쿼리의 모든 행과 결합합니다

  6. 쿼리 결과의 열 이름을 변경하여 스키마를 수정하기

  7. 쿼리의 모든 열 앞에 이름 붙이기

마지막 연산자는 엄밀히 말해 꼭 필요한 것은 아니지만, 언어를 더 편리하게 사용할 수 있게 해 줍니다.

다시 한번, 질의는 인덱스 족으로 표현됩니다:

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 생성자는 새 이름이 스키마에 이미 존재하지 않는지 검사합니다. 이 생성자는 보조 함수 Schema.renameColumn을 사용하여 HasCol이 가리키는 열의 이름을 변경합니다:

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. 쿼리 실행하기🔗

쿼리를 실행하려면 여러 보조 함수가 필요합니다. 질의의 결과는 테이블입니다. 이는 질의 언어의 각 연산마다 테이블을 다루는 그에 상응하는 구현이 필요하다는 뜻입니다.

7.3.5.1. 데카르트 곱🔗

두 테이블의 데카르트 곱은 첫 번째 테이블의 각 행을 두 번째 테이블의 각 행에 이어 붙여서 구합니다. 먼저, 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 인스턴스를 구현할 수 있을 것 같습니다. 실제로 List.flatMap은 pure x := [x]와 함께 모나드를 구현합니다. 하지만 이는 그다지 유용한 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.reverse

7.3.5.2. 차이🔗

테이블에서 원하지 않는 행은 List.filter를 사용하여 제거할 수 있습니다. 이 함수는 리스트와 Bool을 반환하는 함수를 인자로 받습니다. 그러면 함수가 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. 열 이름 바꾸기🔗

행에서 열 이름을 바꾸는 작업은 재귀 함수로 수행합니다. 이 함수는 대상 열을 찾을 때까지 행을 순회하며, 그 열을 찾으면 새 이름을 가진 열에 기존 이름을 가진 열과 같은 값을 부여합니다:

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. 열 이름에 접두사 붙이기🔗

열 이름에 접두사를 붙이는 작업은 열 이름을 바꾸는 작업과 매우 비슷합니다. 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. 모든 조각을 하나로 합치기🔗

이러한 보조 함수들을 모두 정의하고 나면, 쿼리를 실행하는 데는 짧은 재귀 함수 하나만 있으면 됩니다:

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이 프로그래머를 대신하여 인자를 채워 넣을 수 있는 기회를 많이 제공합니다.

점 표기법은 쿼리 결과에 사용되어 List.map, List.filter, Table.cartesianProduct처럼 Table 네임스페이스와 List 네임스페이스 양쪽에 정의된 함수를 호출합니다. 이것이 가능한 이유는 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! 🐙)

이를 실행하면 예상한 대로 정수 리스트가 반환됩니다:

[3637, 1519, 2549]#eval example1.exec
[3637, 1519, 2549]

관광 일정을 세울 때는 같은 위치에 있는 산과 폭포를 모두 짝지어 보는 것이 유용할 수 있습니다. 이는 두 테이블의 데카르트 곱을 구한 다음, 위치가 같은 행만 선택하고, 이름을 사영하여 수행할 수 있습니다:

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! 🐙)

예제 데이터에는 미국의 폭포만 포함되어 있으므로, 이 쿼리를 실행하면 미국에 있는 산과 폭포의 쌍이 반환됩니다:

[("Mount Nebo", "Multnomah Falls"), ("Mount Nebo", "Shoshone Falls"), ("Moscow Mountain", "Multnomah Falls"), ("Moscow Mountain", "Shoshone Falls"), ("Mount St. Helens", "Multnomah Falls"), ("Mount St. Helens", "Shoshone Falls")]#eval example2.exec
[("Mount Nebo", "Multnomah Falls"), ("Mount Nebo", "Shoshone Falls"), ("Moscow Mountain", "Multnomah Falls"),
  ("Moscow Mountain", "Shoshone Falls"), ("Mount St. Helens", "Multnomah Falls"),
  ("Mount St. Helens", "Shoshone Falls")]

7.3.5.6. 마주칠 수 있는 오류🔗

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 (unsolved goals 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"] = truemountains: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 (unsolved goals mountains:Query (List.map (fun c => { name := "mountain" ++ "." ++ c.name, contains := c.contains }) peak) := ⋯waterfalls:Query (List.map (fun c => { name := "waterfall" ++ "." ++ c.name, contains := c.contains }) waterfall) := ⋯⊢ HasCol (List.map (fun c => { name := "waterfall" ++ "." ++ c.name, contains := c.contains }) []) "location" ?m.31c! "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! 🐙)

훌륭한 피드백입니다! 반면, 오류 메시지의 문구만 보고는 어떻게 대처해야 할지 알기가 상당히 어렵습니다:

unsolved goals
mountains:Query (List.map (fun c => { name := "mountain" ++ "." ++ c.name, contains := c.contains }) peak) := ⋯waterfalls:Query (List.map (fun c => { name := "waterfall" ++ "." ++ c.name, contains := c.contains }) waterfall) := ⋯⊢ HasCol (List.map (fun c => { name := "waterfall" ++ "." ++ c.name, contains := c.contains }) []) "location" ?m.31

마찬가지로 두 테이블의 이름에 접두사를 붙이는 것을 잊으면 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 Tactic `decide` proved that the proposition disjoint (List.map Column.name peak) (List.map Column.name waterfall) = true is falsemountains:Query peak := table mountainDiarywaterfalls:Query waterfall := table waterfallDiary⊢ disjoint (List.map Column.name peak) (List.map Column.name waterfall) = true) |>.select (.eq (unsolved goals mountains:Query peak := ⋯waterfalls:Query waterfall := ⋯⊢ HasCol [] "mountain.location" ?m.29c! "mountain.location") (unsolved goals mountains:Query peak := ⋯waterfalls:Query waterfall := ⋯⊢ HasCol [] "waterfall.location" ?m.29c! "waterfall.location")) |>.project [⟨"mountain.name", .string⟩, ⟨"waterfall.name", .string⟩] (unsolved goals mountains:Query peak := ⋯waterfalls:Query waterfall := ⋯⊢ HasCol [] "mountain.name" DBType.string mountains:Query peak := ⋯waterfalls:Query waterfall := ⋯⊢ Subschema [{ name := "waterfall.name", contains := DBType.string }] (peak ++ waterfall)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))

이 오류 메시지가 더 유용합니다:

Tactic `decide` proved that the proposition
  disjoint (List.map Column.name peak) (List.map Column.name waterfall) = true
is false

Lean의 매크로 시스템에는 쿼리를 위한 편리한 문법을 제공하는 것은 물론, 오류 메시지가 도움이 되도록 조정하는 데 필요한 모든 것이 갖추어져 있습니다. 안타깝게도 Lean 매크로로 언어를 구현하는 방법을 설명하는 것은 이 책의 범위를 벗어납니다. Query와 같은 인덱스 족은 타입이 있는 데이터베이스 상호 작용 라이브러리의 사용자 인터페이스보다는 그 핵심으로 쓰는 것이 가장 좋을 것입니다.

7.3.6. 연습 문제🔗

7.3.6.1. 날짜🔗

날짜를 나타내는 구조체를 정의하십시오. 이를 DBType 유니버스에 추가하고, 나머지 코드도 그에 맞게 수정하십시오. 필요해 보이는 DBExpr 생성자를 추가로 제공하십시오.

7.3.6.2. Nullable 타입🔗

다음 구조로 데이터베이스 타입을 표현하여 질의 언어에 nullable 열 지원을 추가하십시오:

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 대신 이 타입을 사용하십시오. 그리고 NULL과 비교 연산자에 관한 SQL의 규칙을 찾아보고 DBExpr 생성자들의 타입을 정하십시오.

7.3.6.3. 택틱 실험하기🔗

by repeat constructor를 사용하여 다음 타입들의 값을 찾도록 Lean에 요청하면 어떤 결과가 나옵니까? 각각이 왜 그러한 결과를 내는지 설명하십시오.