@avytheone/efmesh 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +96 -0
- package/LICENSE +21 -0
- package/README.md +196 -0
- package/README.ru.md +203 -0
- package/SPEC.md +641 -0
- package/package.json +57 -0
- package/src/bin.ts +12 -0
- package/src/cli.ts +518 -0
- package/src/config.ts +44 -0
- package/src/core/audit.ts +88 -0
- package/src/core/errors.ts +27 -0
- package/src/core/graph.ts +76 -0
- package/src/core/interval.ts +119 -0
- package/src/core/model.ts +475 -0
- package/src/core/sql.ts +200 -0
- package/src/discovery.ts +77 -0
- package/src/efmesh.ts +94 -0
- package/src/engine/adapter.ts +52 -0
- package/src/engine/duckdb.ts +117 -0
- package/src/engine/postgres.ts +119 -0
- package/src/index.ts +114 -0
- package/src/init.ts +88 -0
- package/src/plan/audit-run.ts +101 -0
- package/src/plan/categorize.ts +41 -0
- package/src/plan/contract.ts +114 -0
- package/src/plan/diff.ts +36 -0
- package/src/plan/executor.ts +760 -0
- package/src/plan/fingerprint.ts +125 -0
- package/src/plan/graph-html.ts +157 -0
- package/src/plan/janitor.ts +130 -0
- package/src/plan/lineage.ts +143 -0
- package/src/plan/lock.ts +37 -0
- package/src/plan/metrics.ts +14 -0
- package/src/plan/naming.ts +91 -0
- package/src/plan/planner.ts +252 -0
- package/src/plan/run.ts +70 -0
- package/src/state/postgres.ts +396 -0
- package/src/state/sqlite.ts +407 -0
- package/src/state/store.ts +161 -0
- package/src/testing/index.ts +193 -0
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs"
|
|
2
|
+
import type { Schema } from "effect"
|
|
3
|
+
import type { Audit } from "./audit.ts"
|
|
4
|
+
import type { IntervalUnit } from "./interval.ts"
|
|
5
|
+
import { ModelDefinitionError } from "./errors.ts"
|
|
6
|
+
import type { BoundValue, IdentsValue, RefValue, SqlFragment } from "./sql.ts"
|
|
7
|
+
import { collectRefs, parseSqlText, sql, usesBounds } from "./sql.ts"
|
|
8
|
+
|
|
9
|
+
/** Вид материализации (SPEC §3.1). */
|
|
10
|
+
export type ModelKind =
|
|
11
|
+
| { readonly _tag: "full" }
|
|
12
|
+
| { readonly _tag: "view" }
|
|
13
|
+
| {
|
|
14
|
+
/** Подставляется в потребителей как подзапрос, без материализации (SPEC §3.1). */
|
|
15
|
+
readonly _tag: "embedded"
|
|
16
|
+
}
|
|
17
|
+
| {
|
|
18
|
+
readonly _tag: "incrementalByTimeRange"
|
|
19
|
+
/** Колонка времени, по которой режутся и перечитываются интервалы. */
|
|
20
|
+
readonly timeColumn: string
|
|
21
|
+
/** С какого момента бэкфиллить (ISO UTC). */
|
|
22
|
+
readonly start: string
|
|
23
|
+
/** Зерно интервала. */
|
|
24
|
+
readonly interval: IntervalUnit
|
|
25
|
+
/** Сколько интервалов зерна исполняется одним DELETE+INSERT. */
|
|
26
|
+
readonly batchSize: number
|
|
27
|
+
/** Сколько последних done-интервалов пересчитывать заново (поздние данные). */
|
|
28
|
+
readonly lookback: number
|
|
29
|
+
}
|
|
30
|
+
| { readonly _tag: "external"; readonly source: ExternalSource }
|
|
31
|
+
| {
|
|
32
|
+
readonly _tag: "seed"
|
|
33
|
+
/** CSV/JSON-файл с данными; содержимое входит в fingerprint. */
|
|
34
|
+
readonly file: string
|
|
35
|
+
readonly format: "csv" | "json"
|
|
36
|
+
}
|
|
37
|
+
| {
|
|
38
|
+
readonly _tag: "incrementalByUniqueKey"
|
|
39
|
+
/** Логический ключ upsert'а; каждый apply перегоняет запрос и заменяет строки по ключу. */
|
|
40
|
+
readonly key: ReadonlyArray<string>
|
|
41
|
+
}
|
|
42
|
+
| {
|
|
43
|
+
/**
|
|
44
|
+
* Медленно меняющееся измерение, тип 2 (SPEC §3.1): история версий
|
|
45
|
+
* строк. Каждый apply сверяет запрос с открытыми строками: изменившиеся
|
|
46
|
+
* и исчезнувшие закрываются (validTo = сейчас), новые версии
|
|
47
|
+
* вставляются открытыми (validTo IS NULL).
|
|
48
|
+
*/
|
|
49
|
+
readonly _tag: "scdType2"
|
|
50
|
+
readonly key: ReadonlyArray<string>
|
|
51
|
+
/** Колонки версионирования — ведёт efmesh: в схеме объявлены, в запросе отсутствуют. */
|
|
52
|
+
readonly validFrom: string
|
|
53
|
+
readonly validTo: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Определение внешнего источника (SPEC §9.3): таблица движка/ATTACH-базы
|
|
58
|
+
* или файлы по пути/URL (`read_parquet`/`read_csv`/`read_json`, включая
|
|
59
|
+
* HTTPS — REST-JSON ложится сюда же).
|
|
60
|
+
*/
|
|
61
|
+
export type ExternalSource =
|
|
62
|
+
| { readonly _tag: "table"; readonly table: string }
|
|
63
|
+
| {
|
|
64
|
+
readonly _tag: "files"
|
|
65
|
+
readonly path: string
|
|
66
|
+
readonly format: "parquet" | "csv" | "json"
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface IncrementalByTimeRangeOptions {
|
|
70
|
+
readonly timeColumn: string
|
|
71
|
+
readonly start: string
|
|
72
|
+
readonly interval?: IntervalUnit
|
|
73
|
+
readonly batchSize?: number
|
|
74
|
+
readonly lookback?: number
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const kind = {
|
|
78
|
+
full: (): ModelKind => ({ _tag: "full" }),
|
|
79
|
+
view: (): ModelKind => ({ _tag: "view" }),
|
|
80
|
+
embedded: (): ModelKind => ({ _tag: "embedded" }),
|
|
81
|
+
incrementalByTimeRange: (options: IncrementalByTimeRangeOptions): ModelKind => ({
|
|
82
|
+
_tag: "incrementalByTimeRange",
|
|
83
|
+
timeColumn: options.timeColumn,
|
|
84
|
+
start: options.start,
|
|
85
|
+
interval: options.interval ?? "day",
|
|
86
|
+
batchSize: options.batchSize ?? 30,
|
|
87
|
+
lookback: options.lookback ?? 0,
|
|
88
|
+
}),
|
|
89
|
+
incrementalByUniqueKey: (options: {
|
|
90
|
+
readonly key: ReadonlyArray<string>
|
|
91
|
+
}): ModelKind => ({ _tag: "incrementalByUniqueKey", key: options.key }),
|
|
92
|
+
scdType2: (options: {
|
|
93
|
+
readonly key: ReadonlyArray<string>
|
|
94
|
+
readonly validFrom?: string
|
|
95
|
+
readonly validTo?: string
|
|
96
|
+
}): ModelKind => ({
|
|
97
|
+
_tag: "scdType2",
|
|
98
|
+
key: options.key,
|
|
99
|
+
validFrom: options.validFrom ?? "valid_from",
|
|
100
|
+
validTo: options.validTo ?? "valid_to",
|
|
101
|
+
}),
|
|
102
|
+
} as const
|
|
103
|
+
|
|
104
|
+
export const external = {
|
|
105
|
+
table: (table: string): ExternalSource => ({ _tag: "table", table }),
|
|
106
|
+
files: (path: string, format: "parquet" | "csv" | "json"): ExternalSource => ({
|
|
107
|
+
_tag: "files",
|
|
108
|
+
path,
|
|
109
|
+
format,
|
|
110
|
+
}),
|
|
111
|
+
} as const
|
|
112
|
+
|
|
113
|
+
/** Имя модели: `<схема>.<таблица>`. */
|
|
114
|
+
const MODEL_NAME = /^[a-zA-Z_][a-zA-Z0-9_]*\.[a-zA-Z_][a-zA-Z0-9_]*$/
|
|
115
|
+
|
|
116
|
+
export interface ModelName {
|
|
117
|
+
readonly full: string
|
|
118
|
+
readonly schema: string
|
|
119
|
+
readonly table: string
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const parseModelName = (raw: string): ModelName => {
|
|
123
|
+
if (!MODEL_NAME.test(raw)) {
|
|
124
|
+
throw new ModelDefinitionError({
|
|
125
|
+
model: raw,
|
|
126
|
+
reason: "имя модели должно быть вида <схема>.<таблица> (латиница, цифры, _)",
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
const [schema, table] = raw.split(".") as [string, string]
|
|
130
|
+
return { full: raw, schema, table }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Куда складывать физический слой (SPEC §3.3): нативная таблица движка,
|
|
135
|
+
* parquet-файлы озера (интервал = партиция, view поверх read_parquet) или
|
|
136
|
+
* DuckLake-каталог (SPEC §14.5: таблица-на-fingerprint в ATTACH-каталоге —
|
|
137
|
+
* снапшоты и time travel самого DuckLake в довесок, версионность наша).
|
|
138
|
+
*/
|
|
139
|
+
export type MaterializationTarget = "table" | "parquet" | "ducklake"
|
|
140
|
+
|
|
141
|
+
export interface ModelConfig<Fields extends Schema.Struct.Fields> {
|
|
142
|
+
readonly name: string
|
|
143
|
+
readonly kind: ModelKind
|
|
144
|
+
readonly schema: Schema.Struct<Fields>
|
|
145
|
+
readonly description?: string
|
|
146
|
+
/** Логический первичный ключ; пока метаданные (аудит unique — F2). */
|
|
147
|
+
readonly grain?: ReadonlyArray<Extract<keyof Fields, string>>
|
|
148
|
+
/** Цель материализации; по умолчанию — таблица движка. */
|
|
149
|
+
readonly target?: MaterializationTarget
|
|
150
|
+
/** Аудиты качества (SPEC §8); в fingerprint не входят. */
|
|
151
|
+
readonly audits?: ReadonlyArray<Audit>
|
|
152
|
+
/**
|
|
153
|
+
* Экспорт наружу (SPEC §9.3): после аудитов и промоушена готовый
|
|
154
|
+
* результат уезжает в ATTACH-базу (`attach` — алиас из конфига).
|
|
155
|
+
*/
|
|
156
|
+
readonly export?: { readonly attach: string; readonly table: string }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Контекст рендера тела модели. Тело обязано быть чистым: всё изменчивое приходит отсюда. */
|
|
160
|
+
export interface ModelCtx {
|
|
161
|
+
readonly sql: typeof sql
|
|
162
|
+
readonly ref: (model: AnyModel) => RefValue
|
|
163
|
+
readonly cols: <Fields extends Schema.Struct.Fields>(
|
|
164
|
+
model: Model<Fields>,
|
|
165
|
+
...names: ReadonlyArray<Extract<keyof Fields, string>>
|
|
166
|
+
) => IdentsValue
|
|
167
|
+
/**
|
|
168
|
+
* Границы обрабатываемого интервала `[start, end)` — только для
|
|
169
|
+
* incrementalByTimeRange. При исполнении подставляются литералами,
|
|
170
|
+
* в canonical-текст попадают плейсхолдерами (SPEC §3).
|
|
171
|
+
*/
|
|
172
|
+
readonly start: BoundValue
|
|
173
|
+
readonly end: BoundValue
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface Model<Fields extends Schema.Struct.Fields = Schema.Struct.Fields> {
|
|
177
|
+
readonly _tag: "Model"
|
|
178
|
+
readonly name: ModelName
|
|
179
|
+
readonly kind: ModelKind
|
|
180
|
+
readonly schema: Schema.Struct<Fields>
|
|
181
|
+
readonly description: string | undefined
|
|
182
|
+
readonly grain: ReadonlyArray<string>
|
|
183
|
+
readonly target: MaterializationTarget
|
|
184
|
+
readonly audits: ReadonlyArray<Audit>
|
|
185
|
+
/** Тело, отрендеренное в фрагмент один раз при определении. */
|
|
186
|
+
readonly fragment: SqlFragment
|
|
187
|
+
/** Имена моделей, на которые тело ссылается через `ctx.ref`. */
|
|
188
|
+
readonly deps: ReadonlySet<string>
|
|
189
|
+
/** Сами модели-источники по имени — схемы для валидации фикстур в testModel. */
|
|
190
|
+
readonly refs: ReadonlyMap<string, AnyModel>
|
|
191
|
+
readonly export?: { readonly attach: string; readonly table: string }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
195
|
+
export type AnyModel = Model<any>
|
|
196
|
+
|
|
197
|
+
export const columnNames = (model: AnyModel): ReadonlyArray<string> =>
|
|
198
|
+
Object.keys(model.schema.fields)
|
|
199
|
+
|
|
200
|
+
/** Общие проверки конфигурации вида модели — для defineModel и defineSqlModel. */
|
|
201
|
+
const validateKindConfig = <Fields extends Schema.Struct.Fields>(
|
|
202
|
+
name: ModelName,
|
|
203
|
+
config: ModelConfig<Fields>,
|
|
204
|
+
): void => {
|
|
205
|
+
if (config.kind._tag === "external") {
|
|
206
|
+
throw new ModelDefinitionError({
|
|
207
|
+
model: name.full,
|
|
208
|
+
reason: "external-модель не имеет тела — используй defineExternal",
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
if (config.kind._tag === "seed") {
|
|
212
|
+
throw new ModelDefinitionError({
|
|
213
|
+
model: name.full,
|
|
214
|
+
reason: "seed-модель не имеет тела — используй defineSeed",
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
if (config.kind._tag === "incrementalByUniqueKey" || config.kind._tag === "scdType2") {
|
|
218
|
+
for (const keyColumn of config.kind.key) {
|
|
219
|
+
if (!(keyColumn in config.schema.fields)) {
|
|
220
|
+
throw new ModelDefinitionError({
|
|
221
|
+
model: name.full,
|
|
222
|
+
reason: `ключевой колонки «${keyColumn}» нет в схеме модели`,
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (config.kind.key.length === 0) {
|
|
227
|
+
throw new ModelDefinitionError({ model: name.full, reason: "key не может быть пустым" })
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (config.kind._tag === "scdType2") {
|
|
231
|
+
const { validFrom, validTo } = config.kind
|
|
232
|
+
if (validFrom === validTo) {
|
|
233
|
+
throw new ModelDefinitionError({
|
|
234
|
+
model: name.full,
|
|
235
|
+
reason: "validFrom и validTo не могут совпадать",
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
for (const column of [validFrom, validTo]) {
|
|
239
|
+
if (!(column in config.schema.fields)) {
|
|
240
|
+
throw new ModelDefinitionError({
|
|
241
|
+
model: name.full,
|
|
242
|
+
reason: `колонки версионирования «${column}» нет в схеме модели — потребители должны её видеть`,
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
if (config.kind.key.includes(column)) {
|
|
246
|
+
throw new ModelDefinitionError({
|
|
247
|
+
model: name.full,
|
|
248
|
+
reason: `колонка версионирования «${column}» не может входить в key`,
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (config.target === "parquet") {
|
|
253
|
+
throw new ModelDefinitionError({
|
|
254
|
+
model: name.full,
|
|
255
|
+
reason: "scdType2 закрывает строки на месте — parquet-цель неприменима",
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (
|
|
260
|
+
(config.kind._tag === "view" || config.kind._tag === "embedded") &&
|
|
261
|
+
config.target !== undefined &&
|
|
262
|
+
config.target !== "table"
|
|
263
|
+
) {
|
|
264
|
+
throw new ModelDefinitionError({
|
|
265
|
+
model: name.full,
|
|
266
|
+
reason: `${config.kind._tag} не материализуется — цель «${config.target}» к нему неприменима`,
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
if (config.kind._tag === "incrementalByUniqueKey" && config.target === "parquet") {
|
|
270
|
+
throw new ModelDefinitionError({
|
|
271
|
+
model: name.full,
|
|
272
|
+
reason: "upsert по ключу в parquet-файлы невозможен — используй target: \"table\"",
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
if (config.kind._tag === "incrementalByTimeRange") {
|
|
276
|
+
if (!(config.kind.timeColumn in config.schema.fields)) {
|
|
277
|
+
throw new ModelDefinitionError({
|
|
278
|
+
model: name.full,
|
|
279
|
+
reason: `timeColumn «${config.kind.timeColumn}» нет в схеме модели`,
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
if (Number.isNaN(Date.parse(config.kind.start))) {
|
|
283
|
+
throw new ModelDefinitionError({
|
|
284
|
+
model: name.full,
|
|
285
|
+
reason: `start «${config.kind.start}» — не ISO-время`,
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Финальная сборка модели из фрагмента — общие инварианты тела. */
|
|
292
|
+
const assembleModel = <Fields extends Schema.Struct.Fields>(
|
|
293
|
+
name: ModelName,
|
|
294
|
+
config: ModelConfig<Fields>,
|
|
295
|
+
fragment: SqlFragment,
|
|
296
|
+
refs: ReadonlyMap<string, AnyModel>,
|
|
297
|
+
): Model<Fields> => {
|
|
298
|
+
if (usesBounds(fragment) && config.kind._tag !== "incrementalByTimeRange") {
|
|
299
|
+
throw new ModelDefinitionError({
|
|
300
|
+
model: name.full,
|
|
301
|
+
reason: `ctx.start/ctx.end доступны только incrementalByTimeRange, вид модели — ${config.kind._tag}`,
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
const deps = collectRefs(fragment)
|
|
305
|
+
if (deps.has(name.full)) {
|
|
306
|
+
throw new ModelDefinitionError({ model: name.full, reason: "модель ссылается сама на себя" })
|
|
307
|
+
}
|
|
308
|
+
return {
|
|
309
|
+
_tag: "Model",
|
|
310
|
+
name,
|
|
311
|
+
kind: config.kind,
|
|
312
|
+
schema: config.schema,
|
|
313
|
+
description: config.description,
|
|
314
|
+
grain: config.grain ?? [],
|
|
315
|
+
target: config.target ?? "table",
|
|
316
|
+
audits: config.audits ?? [],
|
|
317
|
+
fragment,
|
|
318
|
+
deps,
|
|
319
|
+
refs,
|
|
320
|
+
...(config.export !== undefined ? { export: config.export } : {}),
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Определяет модель. Вызывается на верхнем уровне модуля; тело выполняется
|
|
326
|
+
* ровно один раз — сразу, поэтому ссылки (`ctx.ref`) известны статически
|
|
327
|
+
* и DAG строится без парсинга SQL.
|
|
328
|
+
*/
|
|
329
|
+
export const defineModel = <const Fields extends Schema.Struct.Fields>(
|
|
330
|
+
config: ModelConfig<Fields>,
|
|
331
|
+
body: (ctx: ModelCtx) => SqlFragment,
|
|
332
|
+
): Model<Fields> => {
|
|
333
|
+
const name = parseModelName(config.name)
|
|
334
|
+
validateKindConfig(name, config)
|
|
335
|
+
const refs = new Map<string, AnyModel>()
|
|
336
|
+
const ctx: ModelCtx = {
|
|
337
|
+
sql,
|
|
338
|
+
ref: (model) => {
|
|
339
|
+
refs.set(model.name.full, model)
|
|
340
|
+
return { _tag: "RefValue", modelName: model.name.full }
|
|
341
|
+
},
|
|
342
|
+
cols: (model, ...names) => {
|
|
343
|
+
const known = new Set(Object.keys(model.schema.fields))
|
|
344
|
+
for (const column of names) {
|
|
345
|
+
if (!known.has(column)) {
|
|
346
|
+
// недостижимо при честной типизации; защита от `as any`
|
|
347
|
+
throw new ModelDefinitionError({
|
|
348
|
+
model: config.name,
|
|
349
|
+
reason: `колонки «${column}» нет в схеме модели ${model.name.full}`,
|
|
350
|
+
})
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return { _tag: "IdentsValue", names }
|
|
354
|
+
},
|
|
355
|
+
start: { _tag: "BoundValue", which: "start" },
|
|
356
|
+
end: { _tag: "BoundValue", which: "end" },
|
|
357
|
+
}
|
|
358
|
+
return assembleModel(name, config, body(ctx), refs)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export interface SqlModelConfig<Fields extends Schema.Struct.Fields>
|
|
362
|
+
extends ModelConfig<Fields> {
|
|
363
|
+
/** Путь к .sql-файлу тела: `@ref(схема.таблица)`, `@start`, `@end`. */
|
|
364
|
+
readonly file: string
|
|
365
|
+
/**
|
|
366
|
+
* Модели, на которые SQL-текст ссылается через `@ref` — значениями,
|
|
367
|
+
* чтобы DAG и testModel работали как у обычных моделей.
|
|
368
|
+
*/
|
|
369
|
+
readonly refs?: ReadonlyArray<AnyModel>
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Модель из сырого .sql-файла (SPEC §14.1) — для миграции существующих
|
|
374
|
+
* dbt/sqlmesh-проектов. Типизация ссылок теряется (это честная цена):
|
|
375
|
+
* каждая `@ref` в тексте обязана быть объявлена в `refs`, лишние
|
|
376
|
+
* объявления — ошибка.
|
|
377
|
+
*/
|
|
378
|
+
export const defineSqlModel = <const Fields extends Schema.Struct.Fields>(
|
|
379
|
+
config: SqlModelConfig<Fields>,
|
|
380
|
+
): Model<Fields> => {
|
|
381
|
+
const name = parseModelName(config.name)
|
|
382
|
+
validateKindConfig(name, config)
|
|
383
|
+
let text: string
|
|
384
|
+
try {
|
|
385
|
+
text = readFileSync(config.file, "utf8")
|
|
386
|
+
} catch (cause) {
|
|
387
|
+
throw new ModelDefinitionError({
|
|
388
|
+
model: name.full,
|
|
389
|
+
reason: `не удалось прочитать ${config.file}: ${String(cause)}`,
|
|
390
|
+
})
|
|
391
|
+
}
|
|
392
|
+
const fragment = parseSqlText(text)
|
|
393
|
+
const declared = new Map((config.refs ?? []).map((model) => [model.name.full, model]))
|
|
394
|
+
const used = collectRefs(fragment)
|
|
395
|
+
for (const ref of used) {
|
|
396
|
+
if (!declared.has(ref)) {
|
|
397
|
+
throw new ModelDefinitionError({
|
|
398
|
+
model: name.full,
|
|
399
|
+
reason: `@ref(${ref}) в ${config.file} не объявлен в refs`,
|
|
400
|
+
})
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
for (const declaredName of declared.keys()) {
|
|
404
|
+
if (!used.has(declaredName)) {
|
|
405
|
+
throw new ModelDefinitionError({
|
|
406
|
+
model: name.full,
|
|
407
|
+
reason: `модель ${declaredName} объявлена в refs, но @ref в ${config.file} её не использует`,
|
|
408
|
+
})
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return assembleModel(name, config, fragment, declared)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export interface ExternalConfig<Fields extends Schema.Struct.Fields> {
|
|
415
|
+
readonly name: string
|
|
416
|
+
readonly source: ExternalSource
|
|
417
|
+
readonly schema: Schema.Struct<Fields>
|
|
418
|
+
readonly description?: string
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Внешний источник (SPEC §3.1, §9.3): не материализуется, но участвует
|
|
423
|
+
* в DAG и lineage, схема объявляется. В fingerprint входит только
|
|
424
|
+
* *определение* источника — содержимое меняется между запусками, и это
|
|
425
|
+
* нормально для сырья.
|
|
426
|
+
*/
|
|
427
|
+
export const defineExternal = <const Fields extends Schema.Struct.Fields>(
|
|
428
|
+
config: ExternalConfig<Fields>,
|
|
429
|
+
): Model<Fields> => ({
|
|
430
|
+
_tag: "Model",
|
|
431
|
+
name: parseModelName(config.name),
|
|
432
|
+
kind: { _tag: "external", source: config.source },
|
|
433
|
+
schema: config.schema,
|
|
434
|
+
description: config.description,
|
|
435
|
+
grain: [],
|
|
436
|
+
target: "table", // не материализуется — поле не используется
|
|
437
|
+
audits: [],
|
|
438
|
+
fragment: { _tag: "SqlFragment", nodes: [] },
|
|
439
|
+
deps: new Set(),
|
|
440
|
+
refs: new Map(),
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
export interface SeedConfig<Fields extends Schema.Struct.Fields> {
|
|
444
|
+
readonly name: string
|
|
445
|
+
/** Путь к CSV/JSON-файлу; формат — по расширению или явно. */
|
|
446
|
+
readonly file: string
|
|
447
|
+
readonly format?: "csv" | "json"
|
|
448
|
+
readonly schema: Schema.Struct<Fields>
|
|
449
|
+
readonly description?: string
|
|
450
|
+
readonly audits?: ReadonlyArray<Audit>
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Seed (SPEC §3.1): справочник из файла. В отличие от external, содержимое
|
|
455
|
+
* файла входит в fingerprint — правка данных = новая версия и пересборка;
|
|
456
|
+
* форма проверяется контрактом схемы при сборке.
|
|
457
|
+
*/
|
|
458
|
+
export const defineSeed = <const Fields extends Schema.Struct.Fields>(
|
|
459
|
+
config: SeedConfig<Fields>,
|
|
460
|
+
): Model<Fields> => {
|
|
461
|
+
const format = config.format ?? (config.file.endsWith(".json") ? "json" : "csv")
|
|
462
|
+
return {
|
|
463
|
+
_tag: "Model",
|
|
464
|
+
name: parseModelName(config.name),
|
|
465
|
+
kind: { _tag: "seed", file: config.file, format },
|
|
466
|
+
schema: config.schema,
|
|
467
|
+
description: config.description,
|
|
468
|
+
grain: [],
|
|
469
|
+
target: "table",
|
|
470
|
+
audits: config.audits ?? [],
|
|
471
|
+
fragment: { _tag: "SqlFragment", nodes: [] },
|
|
472
|
+
deps: new Set(),
|
|
473
|
+
refs: new Map(),
|
|
474
|
+
}
|
|
475
|
+
}
|
package/src/core/sql.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQL-фрагменты: дерево из текста, ссылок на модели и идентификаторов.
|
|
3
|
+
*
|
|
4
|
+
* Тело модели рендерится в фрагмент один раз при определении; в текст SQL
|
|
5
|
+
* фрагмент превращается позже — резолвером ссылок (canonical-рендер для
|
|
6
|
+
* fingerprint, физический — для исполнения).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface SqlFragment {
|
|
10
|
+
readonly _tag: "SqlFragment"
|
|
11
|
+
readonly nodes: ReadonlyArray<SqlNode>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type SqlNode =
|
|
15
|
+
| { readonly _tag: "Text"; readonly text: string }
|
|
16
|
+
| { readonly _tag: "Ref"; readonly modelName: string }
|
|
17
|
+
| { readonly _tag: "Idents"; readonly names: ReadonlyArray<string> }
|
|
18
|
+
| { readonly _tag: "Bound"; readonly which: "start" | "end" }
|
|
19
|
+
| { readonly _tag: "Self" }
|
|
20
|
+
|
|
21
|
+
/** Ссылка на модель, полученная из `ctx.ref(model)`. */
|
|
22
|
+
export interface RefValue {
|
|
23
|
+
readonly _tag: "RefValue"
|
|
24
|
+
readonly modelName: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Список колонок, полученный из `ctx.cols(model, ...)`. */
|
|
28
|
+
export interface IdentsValue {
|
|
29
|
+
readonly _tag: "IdentsValue"
|
|
30
|
+
readonly names: ReadonlyArray<string>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Граница обрабатываемого интервала — `ctx.start` / `ctx.end` (SPEC §3). */
|
|
34
|
+
export interface BoundValue {
|
|
35
|
+
readonly _tag: "BoundValue"
|
|
36
|
+
readonly which: "start" | "end"
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Результат самой модели в запросе аудита — `ctx.self` (SPEC §8). */
|
|
40
|
+
export interface SelfValue {
|
|
41
|
+
readonly _tag: "SelfValue"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Скалярные значения инлайнятся как SQL-литералы (детерминированно). */
|
|
45
|
+
export type SqlLiteral = string | number | boolean | bigint | null
|
|
46
|
+
|
|
47
|
+
export type Interpolation =
|
|
48
|
+
| SqlFragment
|
|
49
|
+
| RefValue
|
|
50
|
+
| IdentsValue
|
|
51
|
+
| BoundValue
|
|
52
|
+
| SelfValue
|
|
53
|
+
| SqlLiteral
|
|
54
|
+
|
|
55
|
+
const isSqlFragment = (v: unknown): v is SqlFragment =>
|
|
56
|
+
typeof v === "object" && v !== null && (v as any)._tag === "SqlFragment"
|
|
57
|
+
|
|
58
|
+
const isRefValue = (v: unknown): v is RefValue =>
|
|
59
|
+
typeof v === "object" && v !== null && (v as any)._tag === "RefValue"
|
|
60
|
+
|
|
61
|
+
const isIdentsValue = (v: unknown): v is IdentsValue =>
|
|
62
|
+
typeof v === "object" && v !== null && (v as any)._tag === "IdentsValue"
|
|
63
|
+
|
|
64
|
+
const isBoundValue = (v: unknown): v is BoundValue =>
|
|
65
|
+
typeof v === "object" && v !== null && (v as any)._tag === "BoundValue"
|
|
66
|
+
|
|
67
|
+
const isSelfValue = (v: unknown): v is SelfValue =>
|
|
68
|
+
typeof v === "object" && v !== null && (v as any)._tag === "SelfValue"
|
|
69
|
+
|
|
70
|
+
export const quoteIdent = (name: string): string => `"${name.replaceAll(`"`, `""`)}"`
|
|
71
|
+
|
|
72
|
+
export const escapeLiteral = (value: SqlLiteral): string => {
|
|
73
|
+
if (value === null) return "NULL"
|
|
74
|
+
switch (typeof value) {
|
|
75
|
+
case "string":
|
|
76
|
+
return `'${value.replaceAll(`'`, `''`)}'`
|
|
77
|
+
case "number": {
|
|
78
|
+
if (!Number.isFinite(value)) throw new TypeError(`non-finite number in SQL literal: ${value}`)
|
|
79
|
+
return String(value)
|
|
80
|
+
}
|
|
81
|
+
case "bigint":
|
|
82
|
+
return value.toString()
|
|
83
|
+
case "boolean":
|
|
84
|
+
return value ? "TRUE" : "FALSE"
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const sql = (
|
|
89
|
+
strings: TemplateStringsArray,
|
|
90
|
+
...values: ReadonlyArray<Interpolation>
|
|
91
|
+
): SqlFragment => {
|
|
92
|
+
const nodes: Array<SqlNode> = []
|
|
93
|
+
const pushText = (text: string) => {
|
|
94
|
+
if (text === "") return
|
|
95
|
+
const last = nodes[nodes.length - 1]
|
|
96
|
+
if (last !== undefined && last._tag === "Text") {
|
|
97
|
+
nodes[nodes.length - 1] = { _tag: "Text", text: last.text + text }
|
|
98
|
+
} else {
|
|
99
|
+
nodes.push({ _tag: "Text", text })
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for (let i = 0; i < strings.length; i++) {
|
|
103
|
+
pushText(strings[i]!)
|
|
104
|
+
if (i >= values.length) continue
|
|
105
|
+
const value = values[i]!
|
|
106
|
+
if (isSqlFragment(value)) {
|
|
107
|
+
for (const node of value.nodes) {
|
|
108
|
+
if (node._tag === "Text") pushText(node.text)
|
|
109
|
+
else nodes.push(node)
|
|
110
|
+
}
|
|
111
|
+
} else if (isRefValue(value)) {
|
|
112
|
+
nodes.push({ _tag: "Ref", modelName: value.modelName })
|
|
113
|
+
} else if (isIdentsValue(value)) {
|
|
114
|
+
nodes.push({ _tag: "Idents", names: value.names })
|
|
115
|
+
} else if (isBoundValue(value)) {
|
|
116
|
+
nodes.push({ _tag: "Bound", which: value.which })
|
|
117
|
+
} else if (isSelfValue(value)) {
|
|
118
|
+
nodes.push({ _tag: "Self" })
|
|
119
|
+
} else {
|
|
120
|
+
pushText(escapeLiteral(value))
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { _tag: "SqlFragment", nodes }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface RenderOptions {
|
|
127
|
+
/** Во что превращается ссылка на модель (physical-таблица, view окружения, логическое имя…). */
|
|
128
|
+
readonly resolveRef: (modelName: string) => string
|
|
129
|
+
/**
|
|
130
|
+
* Границы обрабатываемого интервала — готовые SQL-выражения
|
|
131
|
+
* (`TIMESTAMP '…'` при исполнении). Без них `ctx.start`/`ctx.end`
|
|
132
|
+
* рендерятся плейсхолдерами `$start`/`$end` — так канонический текст
|
|
133
|
+
* не зависит от конкретных дат и fingerprint стабилен (SPEC §3, §4).
|
|
134
|
+
*/
|
|
135
|
+
readonly interval?: { readonly start: string; readonly end: string }
|
|
136
|
+
/** Во что рендерится `ctx.self` в запросе аудита (физика или подзапрос интервала). */
|
|
137
|
+
readonly self?: string
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export const render = (fragment: SqlFragment, options: RenderOptions): string => {
|
|
141
|
+
let out = ""
|
|
142
|
+
for (const node of fragment.nodes) {
|
|
143
|
+
switch (node._tag) {
|
|
144
|
+
case "Text":
|
|
145
|
+
out += node.text
|
|
146
|
+
break
|
|
147
|
+
case "Ref":
|
|
148
|
+
out += options.resolveRef(node.modelName)
|
|
149
|
+
break
|
|
150
|
+
case "Idents":
|
|
151
|
+
out += node.names.map(quoteIdent).join(", ")
|
|
152
|
+
break
|
|
153
|
+
case "Bound":
|
|
154
|
+
out += options.interval === undefined ? `$${node.which}` : options.interval[node.which]
|
|
155
|
+
break
|
|
156
|
+
case "Self":
|
|
157
|
+
if (options.self === undefined) throw new Error("рендер ctx.self без options.self")
|
|
158
|
+
out += options.self
|
|
159
|
+
break
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return out
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Парсер сырого SQL-текста (SPEC §14.1): `@ref(схема.таблица)` — ссылка
|
|
167
|
+
* на модель, `@start`/`@end` — границы интервала. Всё остальное — текст
|
|
168
|
+
* как есть. Для миграции существующих dbt/sqlmesh-проектов: типизация
|
|
169
|
+
* ссылок теряется, зависимости объявляются рядом (defineSqlModel.refs).
|
|
170
|
+
*/
|
|
171
|
+
export const parseSqlText = (text: string): SqlFragment => {
|
|
172
|
+
const nodes: Array<SqlNode> = []
|
|
173
|
+
const pattern = /@ref\(\s*([^)\s]+)\s*\)|@start\b|@end\b/g
|
|
174
|
+
let cursor = 0
|
|
175
|
+
for (const match of text.matchAll(pattern)) {
|
|
176
|
+
const before = text.slice(cursor, match.index)
|
|
177
|
+
if (before !== "") nodes.push({ _tag: "Text", text: before })
|
|
178
|
+
if (match[0].startsWith("@ref")) {
|
|
179
|
+
nodes.push({ _tag: "Ref", modelName: match[1]! })
|
|
180
|
+
} else {
|
|
181
|
+
nodes.push({ _tag: "Bound", which: match[0] === "@start" ? "start" : "end" })
|
|
182
|
+
}
|
|
183
|
+
cursor = match.index + match[0].length
|
|
184
|
+
}
|
|
185
|
+
const tail = text.slice(cursor)
|
|
186
|
+
if (tail !== "") nodes.push({ _tag: "Text", text: tail })
|
|
187
|
+
return { _tag: "SqlFragment", nodes }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export const collectRefs = (fragment: SqlFragment): ReadonlySet<string> => {
|
|
191
|
+
const refs = new Set<string>()
|
|
192
|
+
for (const node of fragment.nodes) {
|
|
193
|
+
if (node._tag === "Ref") refs.add(node.modelName)
|
|
194
|
+
}
|
|
195
|
+
return refs
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Использует ли фрагмент границы интервала (валидация вида модели). */
|
|
199
|
+
export const usesBounds = (fragment: SqlFragment): boolean =>
|
|
200
|
+
fragment.nodes.some((node) => node._tag === "Bound")
|