Lean으로 하는 함수형 프로그래밍

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

인덱스 패밀리(indexed family)는 다른 언어를 닮은 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.

중첩된 쌍(pair) 유니버스에서와 마찬가지로, 타입 클래스 검색은 t의 값에 대해 각 가능성을 자동으로 확인하지 않습니다. 해결책은 패턴 매칭을 사용하여 xy의 타입을 정제하는 것입니다:

def DBType.beq (t : DBType) (x y : t.asType) : Bool := match t with | .int => x == y | .string => x == y | .bool => x == y

이 버전의 함수에서 xy는 세 가지 경우에 각각 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가 두 가지 기본 사례를 별도로 처리한다는 사실은, 자신의 타입에 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)의 타입을 충분히 정제하지 못한다는 것입니다. 이는 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" 열이 있는 스키마의 맥락에서만 의미가 있습니다. 열이 스키마에 포함되어 있음을 나타내는 한 가지 방법은 해당 열을 가리키는 포인터를 직접 제공하는 것이며, 이 포인터를 인덱스 패밀리로 정의하면 유효하지 않은 포인터를 배제할 수 있습니다.

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

인덱스 계열 HasCol은 명세를 Lean 코드로 옮긴 것입니다:

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

이 패밀리의 세 인자는 스키마, 열 이름, 그 타입입니다. 세 가지 모두 인덱스이지만, 스키마를 열 이름과 타입 뒤에 배치하도록 인자 순서를 재조정하면 이름과 타입을 매개변수로 만들 수 있습니다. 생성자 here는 스키마가 열 name, t로 시작할 때 사용할 수 있습니다. 즉, 이는 스키마의 첫 번째 열을 가리키는 포인터이며, 첫 번째 열이 원하는 이름과 타입을 가질 때에만 사용할 수 있습니다. 생성자 there는 더 작은 스키마를 가리키는 포인터를, 열이 하나 더 있는 스키마를 가리키는 포인터로 변환합니다.

"elevation"peak에서 세 번째 열이므로, there로 처음 두 열을 지나쳐서 찾을 수 있으며, 그 뒤로는 첫 번째 열이 됩니다. 다시 말해, HasCol peak "elevation" .int 타입을 만족시키려면 표현식 .there (.there .here)를 사용합니다. HasCol을 생각하는 한 가지 방법은 이를 장식된 Nat의 일종으로 보는 것입니다—zerohere에 대응하고, succthere에 대응합니다. 이 추가적인 타입 정보 덕분에 오프바이원(off-by-one) 오류가 발생할 수 없습니다.

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

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의 두 생성자 모두 비어 있지 않은 스키마를 지정하므로 빈 스키마에 대한 경우는 필요하지 않습니다. 스키마에 열이 하나만 있다면 포인터는 반드시 그 열을 가리켜야 하므로, HasColhere 생성자만 매칭하면 됩니다. 스키마에 열이 두 개 이상 있다면 here에 대한 경우가 있어야 하며, 이 경우 값은 행의 첫 번째 항목이 되고, there에 대한 경우도 있어야 하며, 이 경우에는 재귀 호출이 사용됩니다. HasCol 타입은 열이 행에 존재함을 보장하므로, Row.getOption을 반환할 필요가 없습니다.

HasCol은 두 가지 역할을 합니다:

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

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

첫 번째 역할인 증거로서의 역할은 명제가 사용되는 방식과 유사합니다. 색인화된 패밀리 HasCol의 정의는 주어진 열이 존재한다는 증거로 간주되는 것이 무엇인지에 대한 명세로 읽을 수 있습니다. 그러나 명제와 달리, HasCol의 어떤 생성자가 사용되었는지가 중요합니다. 두 번째 역할에서는 생성자가 컬렉션에서 데이터를 찾기 위해 Nat처럼 사용됩니다. 인덱스 패밀리로 프로그래밍하려면 두 관점 사이를 유창하게 전환할 수 있는 능력이 필요할 때가 많습니다.

7.3.2.3. 서브스키마🔗

관계대수학에서 중요한 연산 중 하나는 테이블이나 행을 더 작은 스키마로 사영하는 것입니다. 더 작은 스키마에 존재하지 않는 모든 열은 잊혀집니다. 사영이 성립하려면 더 작은 스키마가 더 큰 스키마의 서브스키마여야 하는데, 이는 더 작은 스키마의 모든 열이 더 큰 스키마에 존재해야 함을 의미합니다. HasCol이 실패할 수 없는 행에서의 단일 열 조회를 작성할 수 있게 해주는 것과 마찬가지로, 서브스키마 관계를 색인화된 패밀리로 표현하면 실패할 수 없는 투영 함수를 작성할 수 있습니다.

한 스키마가 다른 스키마의 서브스키마가 될 수 있는 방식들은 색인 계열(indexed family)로 정의할 수 있습니다. 기본적인 아이디어는 더 작은 스키마의 모든 열이 더 큰 스키마에 나타나는 경우, 그 더 작은 스키마가 더 큰 스키마의 서브스키마가 된다는 것입니다. 더 작은 스키마가 비어 있다면, 이는 생성자 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 스키마는 peakwaterfall 모두에 공통되는 필드를 나타냅니다:

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이 SubschemaHasCol 생성자를 자동으로 작성하도록 지시하는 것입니다. 이는 명제와 증명에 대한 간주곡에서 소개된 택틱 기능을 사용하여 수행할 수 있습니다. 이 막간에서는 by decideby 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 [] peakSubschema [{ name := "location", contains := DBType.string }] peak HasCol peak "location" DBType.stringSubschema [] peak
unsolved goals
HasCol peak "location" DBType.string

Subschema [] peak

unsolved goals로 시작하는 오류는 완성해야 할 표현식을 완전히 구성하지 못한 택틱을 설명합니다. Lean의 택틱 언어에서 goal은 택틱이 배후에서 적절한 표현식을 구성함으로써 충족시켜야 할 타입입니다. 이 경우, constructor로 인해 Subschema.cons가 적용되었으며, 두 목표는 cons가 기대하는 두 개의 인자를 나타냅니다. constructor의 인스턴스를 하나 더 추가하면 peak의 첫 번째 열이 "location"이 아니기 때문에 첫 번째 목표(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 [] peakSubschema [{ name := "location", contains := DBType.string }] peak HasCol peak "location" DBType.stringSubschema [] peak HasCol [{ name := "location", contains := DBType.string }, { name := "elevation", contains := DBType.int }, { name := "lastVisited", contains := DBType.int }] "location" DBType.stringSubschema [] 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 [] peakSubschema [{ name := "location", contains := DBType.string }] peak HasCol peak "location" DBType.stringSubschema [] peak HasCol [{ name := "location", contains := DBType.string }, { name := "elevation", contains := DBType.int }, { name := "lastVisited", contains := DBType.int }] "location" DBType.stringSubschema [] 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.stringSubschema [] peak HasCol [{ name := "location", contains := DBType.string }, { name := "elevation", contains := DBType.int }, { name := "lastVisited", contains := DBType.int }] "location" DBType.stringSubschema [] 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라는 뜻은 아니기 때문입니다. 하지만 HasColSubschema 같은 타입은 인덱스에 의해 충분히 제약되어 있어서 언제나 하나의 생성자만 적용 가능하며, 이는 프로그램 자체의 내용이 덜 흥미롭다는 것과 컴퓨터가 올바른 것을 선택할 수 있다는 것을 의미합니다.

한 스키마가 다른 스키마의 서브스키마라면, 그 스키마는 열이 하나 추가되어 확장된 더 큰 스키마의 서브스키마이기도 합니다. 이 사실은 함수 정의로 포착할 수 있습니다. Subschema.addColumnsmallerbigger의 서브스키마라는 증거를 받아, smallerc :: 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.projectRow 자체의 각 경우에 대응하는 세 가지 경우를 가집니다. 이는 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. 조건문과 선택🔗

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

예제 질의 언어는 표현식을 포함하고 있으며, 이는 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는 어떤 타입의 상수 값입니다.

예를 들어, elevation 열이 1000보다 크고 위치가 "Denmark"인지 확인하는 peak 내의 표현식은 다음과 같이 작성할 수 있습니다:

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"))

이는 다소 지저분합니다. 특히, 열(column)에 대한 참조는 by repeat constructor라는 상투적인 호출을 포함합니다. 매크로라는 Lean 기능은 이러한 보일러플레이트를 제거하여 식을 더 읽기 쉽게 만드는 데 도움이 됩니다:

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

이 선언은 c! 키워드를 Lean에 추가하고, c! 뒤에 표현식이 오는 모든 경우를 이에 대응하는 DBExpr.col 구성으로 대체하도록 Lean에 지시합니다. 여기서 term은 명령이나 택틱, 또는 언어의 다른 부분이 아니라 Lean 표현식을 나타냅니다. Lean 매크로는 C 전처리기 매크로와 다소 비슷하지만, 언어에 더 잘 통합되어 있으며 CPP의 몇 가지 함정을 자동으로 피한다는 점이 다릅니다. 사실, 이는 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에 대해 이 식을 평가하면 false가 나오는데, 이는 Valby Bakke가 해발 1km보다 훨씬 낮기 때문입니다:

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. 쿼리🔗

이 쿼리 언어는 관계 대수를 기반으로 합니다. 표(table) 외에도, 다음 연산자들을 포함합니다:

  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.flatMapMonad 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.reverse

7.3.5.2. 차이점🔗

테이블에서 원하지 않는 행을 제거하는 작업은 리스트와 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. 열 이름 변경🔗

행(row)에서 열(column) 이름을 변경하는 작업은 문제의 열을 찾을 때까지 행을 순회하는 재귀 함수로 수행되며, 열을 찾으면 새 이름을 가진 열이 기존 이름을 가진 열과 동일한 값을 갖게 됩니다:

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)

이 함수는 인자의 type을 바꾸지만, 실제 반환값은 원래 인자와 정확히 같은 데이터를 담고 있습니다. 실행 시점의 관점에서 보면, 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와 같이 TableList 네임스페이스에 정의된 함수를 호출합니다. 이것이 작동하는 이유는 Tableabbrev를 사용하여 정의되었기 때문입니다. 타입 클래스 검색과 마찬가지로, 점 표기법도 abbrev로 만든 정의를 꿰뚫어 볼 수 있습니다.

select의 구현 또한 상당히 간결합니다. 쿼리 q를 실행한 후, List.filter를 사용해 표현식을 만족하지 않는 행을 제거합니다. List.filterRow 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 waterfallDiarydisjoint (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 waterfallDiarydisjoint (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 waterfallDiarySubschema [{ name := "mountain.name", contains := DBType.string }, { name := "waterfall.name", contains := DBType.string }] (peak ++ waterfall) repeat mountains:Query peak := table mountainDiarywaterfalls:Query waterfall := table waterfallDiaryHasCol [] "mountain.name" DBType.stringmountains:Query peak := table mountainDiarywaterfalls:Query waterfall := table waterfallDiarySubschema [{ 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. Null 허용 타입🔗

다음 구조로 데이터베이스 타입을 표현하여 쿼리 언어에 널 허용 열(nullable column)에 대한 지원을 추가하십시오:

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

이 타입을 ColumnDBExpr에서 DBType 대신 사용하고, DBExpr의 생성자들의 타입을 결정하기 위해 NULL과 비교 연산자에 관한 SQL의 규칙을 찾아보십시오.

7.3.6.3. 택틱 실험해보기🔗

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