@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.
@@ -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
+ }
@@ -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")