TypeQL 엔진 — define / insert / put / match / fetch / delete / update
이 문서가 답하는 질문
- TypeQL 질의는 왜 "절의 트리"가 아니라 "스테이지 파이프라인"인가?
define은 왜 5번 순회하는가?- 엔티티/관계/속성이 각각 어떤 물리 구조에 저장되는가?
match가 SQL이 되는 세 가지 조인은 무엇인가?- 속성 값 중복 제거(interning)는 어디서 강제되는가?
- Cypher 표면과 어디서 만나는가?
원문 근거: docs/typeql.md, spec 010.
1. 결정 — 파이프라인 AST
engine/src/typeql/ast.rs:1-6:
A TypeQL query is a pipeline of stages, not a tree of clauses. Each stage consumes the row stream the previous one produced. Keeping that shape in the AST is what makes
sortbeforelimitmean something different fromlimitbeforesort(FR-036) without any special-casing later.
pub struct Query { pub stages: Vec<Stage> }
pub enum Stage {
Define(Vec<Definition>), Undefine(Vec<Definition>),
Match(Vec<Pat>), Insert(Vec<Pat>), Put(Vec<Pat>), Update(Vec<Pat>), Delete(Vec<DeleteItem>),
Select(Vec<String>), Sort(Vec<(String, bool)>), Limit(i64), Offset(i64), Distinct,
Reduce { assigns: Vec<Reduction>, groupby: Vec<String> },
Fetch(FetchDoc),
}
구현에서 이 형태가 그대로 드러난다 — compile_read(typeql/mod.rs:247-368)는 스테이지마다 이전 SQL을 서브쿼리로 감싼다:
p.sql = format!("SELECT * FROM ({}) s{depth} ORDER BY {}", p.sql, order.join(", "));
sort → limit이면 SELECT * FROM (SELECT * FROM (...) s1 ORDER BY ...) s2 LIMIT n, limit → sort면 반대 중첩. 정규화하지 않는다.
2. 사실 — 렉서의 두 가지 특이점
engine/src/typeql/lexer.rs:1-10:
- 라벨에 하이픈이 들어간다.
isbn-13,order-line,start-timestamp는 뺄셈이 아니라 하나의 이름이다.-는 식별자 문자 사이에 정확히 놓였을 때만 흡수되므로1 - $d와$a-$b는 여전히 산술이다. - datetime이 따옴표 없이 온다.
2023-12-02T00:00:00이 리터럴이며, 첫 글자가 숫자다 → 숫자로 시작한다고 해서 수치 리터럴이 아니다.
토큰 종류 (lexer.rs:15-27): Ident / Var($x) / Str / Int / Float / DateTime / Sym / Eof. 키워드 토큰이 없다 — 모든 키워드는 Ident이고 파서가 위치로 판단한다. 그래서 match라는 이름의 속성을 쓸 수 있다.
기호 목록 (lexer.rs:52-55): .. == != <= >= -> = < > + - * / % ; , ( ) { } [ ] : . @ ? |.
3. 사실 — define (5 패스)
engine/src/typeql/schema.rs:56-128 run_define.
패스를 나눈 이유 (schema.rs:4-9, schema.rs:78-82): 실제 .tql 파일은 전방 참조를 마음껏 한다. entity book … owns isbn이 attribute isbn, value string보다 훨씬 앞에 온다.
| 패스 | 라인 | 하는 일 |
|---|---|---|
| 0 | 57 | ensure_has_type(gid) — 내부 $has 관계 타입 확보 |
| 1 | 59–64 | 껍데기 선언 — declare_shell(): 이름/종류/추상 여부만 og_catalog.type에 |
| 2 | 66–75 | 상속 연결 — link_sub() → 변화가 있으면 relabel_graph(gid) 1회 |
| 3 | 77–95 | 스토리지 생성 + 일반 롤 선언 |
| 4 | 97–105 | 롤 특수화 (relates author as contributor) |
| 5 | 107–124 | owns / plays / 값 제약 / 함수 저장 |
| 끝 | 126 | bump_schema_version(gid, "typeql define") |
모든 패스가 멱등이다 (schema.rs:11-12, FR-012). 같은 스키마를 두 번 로드해도 무해하다. declare_shell(schema.rs:134-165)은 이미 있으면 종류만 확인하고 반환하며, 종류가 다르면 명시적으로 거절한다.
3.1 물리 구조 매핑
| TypeQL | 종류 | 스토리지 | 근거 |
|---|---|---|---|
entity book |
'e' |
og_data.n_<tid> (id int8 PK, __ext jsonb) |
schema.rs:223-236 |
relation authoring |
'r' |
같은 노드 테이블 — 관계가 reify된다 | schema.rs:223-236 |
attribute title, value string |
'a' |
og_data.a_<tid> (id int8 PK, val text NOT NULL UNIQUE, __ext jsonb) |
schema.rs:270-278 |
| 속성 소유 | — | 내부 관계 타입 $has의 엣지 |
schema.rs:23, write.rs:381-407 |
| 롤 배정 | — | og_data.og_role_player (edge_id, role_id, player_id) |
write.rs:446-451 |
val 컬럼의 UNIQUE가 기능적으로 필수다 (schema.rs:271-272):
UNIQUE on the value is load-bearing, not defensive: it is what makes two owners of "fiction" share one instance (FR-016).
$has라는 이름은 $ 접두사 때문에 TypeQL 라벨과 절대 충돌하지 않는다 (schema.rs:21-22). 즉 언어에는 보이지 않으면서 카탈로그에서는 평범한 시민이다.
속성 타입에는 val이 프로퍼티로도 등록된다 (schema.rs:281-288) — 그래야 Cypher 표면에서 t.val로 읽힌다.
3.2 값 타입 매핑
schema.rs:28-46 value_type_sql:
| TypeQL | PostgreSQL |
|---|---|
string |
text |
integer / long |
int8 |
double |
float8 |
decimal |
numeric |
boolean |
bool |
date |
date |
datetime |
timestamp |
datetime-tz |
timestamptz |
duration |
interval |
값 타입은 og_catalog.og_constraint에 kind = 'value' 행으로 저장된다 (schema.rs:312-314). 카탈로그가 단일 진실 원천이라야 og_typeql_schema() 덤프가 입력을 재현할 수 있다.
owns / plays / values / range 어노테이션도 같은 테이블에 들어간다 (schema.rs:424,437,442, JSON 직렬화는 annotations_json schema.rs:445-466).
4. 사실 — match → SQL
engine/src/typeql/compile.rs. 모듈 주석(compile.rs:4-12)이 세 개의 조인을 명시한다.
4.1 isa — 구간 라벨로 확장된 타입 집합
pat_isa(compile.rs:261-294):
CROSS JOIN og_data.og_node n1
-- WHERE n1.type_id = ANY(ARRAY[3,7,9]::int4[])
ARRAY[...]는 labeling::og_subtypes(tid)의 결과다 (compile.rs:131-134). 재귀 없음.
속성 타입(kind == 'a')이면 노드 레지스트리가 아니라 구체 속성 테이블들의 UNION ALL을 스캔한다 (concrete_attr_tables compile.rs:136-150).
이미 바인딩된 변수에 isa가 또 붙으면 조인을 늘리지 않고 식별자에서 타입을 뽑아 비교한다 (compile.rs:267-280).
4.2 has — 인접 세그먼트 + 식별자 시프트
pat_has(compile.rs:315-380). 생성되는 SQL:
CROSS JOIN LATERAL (
SELECT h5v.id AS id, h5v.val AS val
FROM og_data.og_adj h5a
CROSS JOIN LATERAL unnest(h5a.nbr) AS h5u(nbr)
JOIN (SELECT id, val FROM og_data.a_12) h5v ON h5v.id = h5u.nbr
WHERE h5a.src = n1.id
AND h5a.dir = 'o'::"char"
AND h5a.etype = 8
AND (((h5u.nbr) >> 36) & 262143)::int4 = ANY(ARRAY[12]::int4[])
AND (h5v.val) = ('fiction')
) h5
주목할 점:
- 속성 타입 필터가 조인이 아니라 시프트-마스크다 (
type_of_idcompile.rs:161-163). spec 001이 타입을 식별자 안에 넣었기 때문에 카탈로그 조회가 필요 없다. - 값 술어가 같은 LATERAL 안에 있다 (
compile.rs:328-332). 이게 없으면 독립 변수가 여러 개인match가 값 필터를 적용하기 전에 교차곱을 만든다. has_tid는 컴파일러 생성 시점에 한 번 조회된다 (compile.rs:61).
4.3 role — og_role_player + 롤 특수화 확장
pat_role(compile.rs:382-439):
CROSS JOIN og_data.og_role_player rp7
-- WHERE rp7.edge_id = n3.id AND rp7.player_id = n1.id
-- AND rp7.role_id = ANY(ARRAY[4,5]::int4[])
롤 id 집합은 두 가지로 만들어진다:
- 롤 이름이 있으면
role_with_specialisations(base)(compile.rs:641-658) —og_catalog.role.parent_role_id를 따라 내려가는 재귀 CTE. (헌법이 금지하는 건 타입 계층 재귀이지 롤 특수화가 아니다.) - 없으면(위치 인자)
all_roles_of(rel_tid)(compile.rs:661-677).
위치 인자 중복 방지 (compile.rs:422-436): ($a, $b) isa rating이 같은 플레이어를 자기 자신과 매칭하지 않도록, 같은 관계 변수의 이전 rp 별칭들과 NOT (role_id = ... AND player_id = ...)를 건다.
4.4 부정과 분리
not { ... }→NOT EXISTS (<서브쿼리>)(compile.rs:220-224){ ... } or { ... }→(EXISTS (...) OR EXISTS (...))(compile.rs:225-232)
서브쿼리는 Compiler::nested(self)(compile.rs:73-82)로 만든다. 부모의 바인딩을 복사하되 external에 넣어 다시 물질화하지 않고 상관 참조하게 한다. 별칭 카운터도 parent.n + 1000에서 시작해 충돌을 피한다.
4.5 ★ 연결 성분 분해 — 이 파일의 가장 큰 성능 결정
compile::components(&pats) (compile.rs:735-774). 주석(compile.rs:728-734):
A
matchlisting sixteen independently-pinned cities is one conjunction of sixteen unrelated patterns. Handed to the planner as one flat join it has 16! orderings, GEQO picks badly, and the query builds a cross product before any name filter applies. Compiling each group as its own subquery removes the choice entirely — there is nothing to get wrong between groups, andfrom_collapse_limitkeeps PostgreSQL from flattening them back together.
Union-Find(compile.rs:737-744)로 변수를 공유하는 패턴끼리 묶고, 그룹마다 별도 Compiler로 컴파일한 뒤 CROSS JOIN으로 잇는다 (typeql/mod.rs:206-243).
단위 테스트 2개가 이를 고정한다 (compile.rs:791-816): independent_variables_split_into_groups, shared_variables_stay_together.
4.6 변수 투영 규약
typeql/mod.rs:167-184 — 바인딩된 변수 $x는 두 컬럼이 된다:
v_x— 값 (var_value,compile.rs:583-592)i_x— 식별자 (var_id,compile.rs:595-605) — 있는 경우에만
Bind 5종 (compile.rs:23-37): Instance / Attr / Value / Type / Column. Column은 바깥 파이프라인 스테이지가 이미 투영한 컬럼을 가리키며, 이게 있어야 fetch가 sort/limit에 감싸인 뒤에도 살아남는다.
5. 사실 — 읽기 스테이지 실행
typeql/mod.rs:247-368 compile_read.
| 스테이지 | 생성 SQL | 라인 |
|---|---|---|
select $a, $b |
SELECT v_a, i_a, v_b FROM (...) sN |
254–272 |
distinct |
SELECT DISTINCT * FROM (...) sN |
273–276 |
sort $a asc |
SELECT * FROM (...) sN ORDER BY v_a ASC |
277–288 |
limit n |
SELECT * FROM (...) sN LIMIT n |
289–292 |
offset n |
SELECT * FROM (...) sN OFFSET n |
293–296 |
reduce $c = count; groupby $a |
SELECT v_a, i_a, count(*) AS v_c FROM (...) sN GROUP BY v_a, i_a |
297–331 |
fetch { ... } |
SELECT jsonb_build_object(...) AS row FROM (...) sN |
332–349 |
| (fetch 없음) | 변수 전부를 jsonb 객체로 | 356–367 |
reduce에서 식별자 컬럼도 GROUP BY에 들어간다 (typeql/mod.rs:315-324). 함수 종속이지만 그걸 아는 건 카탈로그뿐 플래너가 아니다.
집계 매핑 (agg_sql typeql/mod.rs:370-392): count sum max min mean(→avg) median(→percentile_cont) std(→stddev) list(→jsonb_agg).
5.1 fetch
fetch_object(typeql/mod.rs:394-409)가 jsonb_build_object를 만든다. 항목 4종:
| 항목 | 컴파일 |
|---|---|
"k": $x.attr |
to_jsonb(attr_scalar(...)) — 상관 스칼라 서브쿼리 LIMIT 1 (compile.rs:497-513) |
"k": [$x.attr] |
attr_list(...) — COALESCE((SELECT jsonb_agg(...)), '[]'::jsonb) (compile.rs:516-532) |
"k": { ... } |
중첩 객체 |
"k": [ match … fetch … ] |
sub_fetch(...) (typeql/mod.rs:413-432) |
서브페치는 바깥 행을 지울 수 없다 (typeql/mod.rs:411-412): 상관 집계이므로 매치가 없으면 그냥 빈 배열이 된다 (FR-035).
COALESCE((SELECT jsonb_agg(<obj>) FROM ... WHERE ...), '[]'::jsonb)
6. 사실 — 쓰기 스테이지
typeql/mod.rs:447-510 run_write_pipeline.
선행 match가 있으면 → compile_match로 SQL 만들고 실행 → Env 목록 생성
없으면 → 빈 Env 하나
Env 행마다:
Insert → write::run_insert
Put → write::run_put
Update → write::run_update
Delete → write::run_delete
반환: [{"rows": N, "operations": M}]
Env(write.rs:20-49)는 HashMap<String, EnvVal>이고, EnvVal은 Instance(i64) / Attr(i64, Value) / Value(Value) 셋 중 하나다.
6.1 insert — 4단계 순서가 의미를 갖는다
write.rs:55-109:
| 단계 | 라인 | 이유 |
|---|---|---|
1. 인스턴스 생성 (isa) |
57–65 | 롤 플레이어와 소유자가 먼저 존재해야 한다 |
2. 롤 배정 (links) |
67–74 | |
3. 소유 (has) |
76–100 | |
4. let 계산 |
102–107 |
create_instance(write.rs:179-205): og_node 레지스트리 + 타입 테이블 삽입. 속성 타입에 isa를 쓰면 명시적으로 거절한다 (write.rs:188-193) — 속성은 값을 소유해서 만들지 isa로 만들지 않는다.
6.2 속성 interning
intern_attribute(write.rs:213-262):
1. 속성 타입 확인 (kind == 'a')
2. 소유자가 이 속성을 owns 하는지 검사 (schema::owned_attribute) ← 없으면 오류
3. 값 타입 → SQL 리터럴 (typed_literal)
4. 값 제약 검사 (@values / @range) write.rs:264-298
5. 소유 제약 검사 (@key / @unique / @card) write.rs:300-361
6. SELECT id FROM <table> WHERE val = <lit> → 있으면 그 id 재사용
7. 없으면 alloc_id + og_node + <table> 삽입
@key / @unique는 소유자 타입 계통 전체에서 검사한다 (write.rs:314-336). root_of(owner_tid)(write.rs:365-377)가 최상위 supertype을 찾고, 그 서브타입 전체가 범위다 — TypeDB의 스코프와 맞추기 위함이다.
link_has(write.rs:381-407)는 spec 001 FR-012를 그대로 따른다: 레지스트리(og_edge) + 타입 테이블 + 양방향 인접 세그먼트.
6.3 put
write.rs:112-120 — find_one으로 먼저 찾고, 없으면 run_insert.
find_one(write.rs:124-173)은 현재 Env의 값을 seed_column으로 고정점으로 박은 뒤 패턴을 컴파일해 LIMIT 1 SELECT를 만든다.
주의:
put은 원자적이지 않다. SELECT → INSERT 사이에 락이 없으므로 동시 실행 시 둘 다 삽입할 수 있다. 속성 값의UNIQUE가 마지막 방어선이지만 인스턴스에는 그런 게 없다. →07_transactions_and_concurrency.md.
6.4 update
write.rs:500-531 — has만 지원한다. 해당 속성 타입의 현재 값을 전부 끊고 run_insert.
6.5 delete
write.rs:459-497:
| 형태 | 동작 |
|---|---|
delete $x; |
delete_instance(gid, id) |
delete has $a of $x; |
unlink_has(gid, owner, attr) |
delete links (role: $p) of $r; |
og_role_player에서 해당 행 삭제 |
delete_instance(write.rs:552-604)는 연쇄적이다 (write.rs:550-551, FR-038):
- 이 인스턴스가 걸린
$has엣지를 양방향으로 모두 끊는다. - 이 인스턴스가 롤을 맡은 관계 인스턴스를 재귀적으로 삭제한다 — 플레이어가 빠진 관계는 작아진 관계가 아니라 깨진 관계이므로.
og_role_player, 타입 테이블,og_node,og_adj에서 제거.
주의: 2번의 재귀에는 깊이 제한이나 방문 집합이 없다 (
write.rs:593-595). 관계가 관계의 플레이어인 순환 구조에서 무한 재귀 가능성이 있다. →CODE-17.
7. 사실 — 미구현 / 제한
| 항목 | 상태 | 근거 |
|---|---|---|
undefine |
미구현, 명시적 오류 | typeql/mod.rs:151-153 |
redefine |
렉서 키워드 목록에만 존재 | typeql/parser.rs:282 |
사용자 정의 함수 (fun) |
파싱·저장·덤프만. 평가 없음 | typeql/schema.rs:505-519, compile.rs:484-490 |
has $a (속성 타입 생략) |
거절 | compile.rs:317-322 |
| 쓰기 스테이지 뒤의 읽기 스테이지 | 거절 | typeql/mod.rs:499-505 |
og_typeql의 _params |
무시된다 | typeql/mod.rs:52 → CODE-18 |
| 표현식 함수 | round abs floor ceil length만 |
compile.rs:476-491 |
8. 사실 — Cypher 표면과의 접점
같은 그래프다. 접점은 세 곳이다.
cypher/views.rs:55-78concrete_tables— 노드로 취급할 스토리지 테이블을og_data.n\_%또는og_data.a\_%패턴으로 고른다. 즉 TypeQL 속성 인스턴스는 Cypher에게 노드다 (views.rs:60-64, spec 010 FR-040).engine/sql/access.sql:307-338— 매핑을 SQL로 명시한 뷰 2개:og_typeql_attribute— (소유자, 속성) 쌍 하나당 1행og_typeql_role— reify된 관계의 롤 배정 하나당 1행
README가 보여주는 교차 질의:
SELECT og_cypher('bookstore', $$ MATCH (b:ebook)-[:`$has`]->(t:title) RETURN t.val $$);$has를 백틱으로 감싸면 Cypher에서 그대로 관계 타입으로 쓸 수 있다.
9. 결정 요약
| 결정 | 근거 | 대가 |
|---|---|---|
| 스테이지를 서브쿼리로 감싼다 | ast.rs:1-6 (FR-036) |
스테이지가 많으면 중첩이 깊어짐 |
| 연결 성분을 분해해 그룹별 서브쿼리 | compile.rs:728-734 |
from_collapse_limit에 의존 |
| 관계를 노드로 reify | schema.rs:223-236 |
관계에 롤이 3개 이상 가능 / Cypher에서 노드로 보임 |
| 속성을 값으로 중복 제거 | schema.rs:271-272 (FR-016) |
val에 UNIQUE → 대용량 텍스트 속성에 인덱스 부담 |
| 속성 타입 필터를 식별자 시프트로 | compile.rs:159-163 |
없음 |
| 쓰기는 행마다 Rust 절차 실행 | typeql/mod.rs:3-5 |
배치 쓰기가 느림 |
금지 / 필수
- 금지:
define의 패스 순서를 바꾸는 것. 전방 참조가 깨진다 (schema.rs:78-82). - 금지:
og_data.a_<tid>.val의UNIQUE를 제거하는 것. FR-016이 그 인덱스에 의존한다. - 금지:
$has타입 이름을 바꾸거나$접두사를 떼는 것. TypeQL 라벨과 충돌 불가능성이 그 접두사에서 온다 (schema.rs:21-22). - 금지: TypeQL 쓰기 경로에서 새로 SQL 문자열에 값을 끼워 넣는 것. 기존
typed_literal(write.rs:649-674)도 개선 대상이다 (CODE-08). - 필수: 새 스테이지를 추가하면
stage_name()(typeql/mod.rs:512-529)과compile_read의 match 양쪽을 갱신한다. - 필수: 새 패턴 종류를 추가하면
vars_of()(compile.rs:684-725)도 갱신한다. 빠뜨리면 연결 성분 분해가 관련 패턴을 다른 그룹으로 보내 답이 틀린다.