@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,193 @@
1
+ import { Effect } from "effect"
2
+ import type { AnyModel } from "../core/model.ts"
3
+ import { escapeLiteral, quoteIdent, render, usesBounds } from "../core/sql.ts"
4
+ import { fromIso, sqlTimestamp } from "../core/interval.ts"
5
+ import { EngineAdapter } from "../engine/adapter.ts"
6
+ import { DuckDBEngineLive } from "../engine/duckdb.ts"
7
+ import { familyOfAst, type TypeFamily } from "../plan/contract.ts"
8
+
9
+ /**
10
+ * Юнит-тест модели на фикстурах (SPEC §8): `ctx.ref` рендерится в CTE
11
+ * с VALUES из фикстур, валидированных через Schema модели-источника
12
+ * (модель помнит источники значениями — model.refs), запрос выполняется
13
+ * на одноразовом in-memory DuckDB, результат сравнивается с ожидаемым.
14
+ * Живёт в bun test:
15
+ *
16
+ * test("stays", () => testModel(stays, { inputs: {...}, expect: [...] }))
17
+ */
18
+
19
+ export interface TestModelSpec {
20
+ /** Фикстуры по полным именам моделей-источников; нужны для всех deps. */
21
+ readonly inputs?: Readonly<Record<string, ReadonlyArray<Record<string, unknown>>>>
22
+ /** Границы интервала [start, end) — обязательны для incremental-моделей. */
23
+ readonly interval?: readonly [string, string]
24
+ readonly expect: ReadonlyArray<Record<string, unknown>>
25
+ /** Сравнивать порядок строк; по умолчанию порядок не важен. */
26
+ readonly strictOrder?: boolean
27
+ }
28
+
29
+ const DUCK_TYPE: Record<TypeFamily, string> = {
30
+ text: "VARCHAR",
31
+ numeric: "DOUBLE",
32
+ boolean: "BOOLEAN",
33
+ temporal: "TIMESTAMP",
34
+ any: "VARCHAR",
35
+ }
36
+
37
+ /** Литерал фикстуры по семейству колонки (ISO-строки времени → TIMESTAMP). */
38
+ const fixtureLiteral = (value: unknown, family: TypeFamily): string => {
39
+ if (value === null || value === undefined) return "NULL"
40
+ if (family === "temporal") return sqlTimestamp(fromIso(String(value)))
41
+ if (
42
+ typeof value === "string" ||
43
+ typeof value === "number" ||
44
+ typeof value === "boolean" ||
45
+ typeof value === "bigint"
46
+ ) {
47
+ return escapeLiteral(value)
48
+ }
49
+ throw new Error(`фикстура содержит нерендерируемое значение: ${String(value)}`)
50
+ }
51
+
52
+ /**
53
+ * Фикстура не может врать о форме входа: значения сверяются с семействами
54
+ * типов Schema источника (строки времени — ISO; Schema.DateTimeUtc в v4
55
+ * декодирует готовый DateTime.Utc, не строку, поэтому проверка по семейству,
56
+ * не через decode), лишние ключи — опечатки — отвергаются.
57
+ */
58
+ const validateFixture = (
59
+ source: AnyModel,
60
+ fields: ReadonlyArray<[string, { readonly ast: unknown }]>,
61
+ row: Record<string, unknown>,
62
+ ): void => {
63
+ const known = new Set(fields.map(([name]) => name))
64
+ for (const key of Object.keys(row)) {
65
+ if (!known.has(key)) {
66
+ throw new Error(`фикстура ${source.name.full}: лишняя колонка «${key}»`)
67
+ }
68
+ }
69
+ for (const [name, field] of fields) {
70
+ const value = row[name]
71
+ if (value === null || value === undefined) continue
72
+ const family = familyOfAst(field.ast)
73
+ const ok =
74
+ family === "text"
75
+ ? typeof value === "string"
76
+ : family === "numeric"
77
+ ? typeof value === "number" || typeof value === "bigint"
78
+ : family === "boolean"
79
+ ? typeof value === "boolean"
80
+ : family === "temporal"
81
+ ? typeof value === "string" && !Number.isNaN(Date.parse(value))
82
+ : true
83
+ if (!ok) {
84
+ throw new Error(
85
+ `фикстура ${source.name.full}: колонка «${name}» ждёт ${family}, получено ${JSON.stringify(value)}`,
86
+ )
87
+ }
88
+ }
89
+ }
90
+
91
+ /** CTE источника: VALUES из фикстур или пустой SELECT с типами схемы. */
92
+ const fixtureCte = (source: AnyModel, rows: ReadonlyArray<Record<string, unknown>>): string => {
93
+ const fields = Object.entries(source.schema.fields) as ReadonlyArray<
94
+ [string, { readonly ast: unknown }]
95
+ >
96
+ const columns = fields.map(([name]) => quoteIdent(name)).join(", ")
97
+ if (rows.length === 0) {
98
+ const empty = fields
99
+ .map(([name, field]) => `NULL::${DUCK_TYPE[familyOfAst(field.ast)]} AS ${quoteIdent(name)}`)
100
+ .join(", ")
101
+ return `SELECT ${empty} WHERE FALSE`
102
+ }
103
+ const tuples = rows.map((row) => {
104
+ validateFixture(source, fields, row)
105
+ return `(${fields
106
+ .map(([name, field]) => fixtureLiteral(row[name], familyOfAst(field.ast)))
107
+ .join(", ")})`
108
+ })
109
+ return `SELECT * FROM (VALUES ${tuples.join(", ")}) AS t(${columns})`
110
+ }
111
+
112
+ /** DuckDB-значения → сравнимые примитивы (bigint → number, объекты времени → строка). */
113
+ const normalize = (row: Record<string, unknown>): Record<string, unknown> =>
114
+ Object.fromEntries(
115
+ Object.entries(row).map(([key, value]) => {
116
+ if (typeof value === "bigint") return [key, Number(value)]
117
+ if (value !== null && typeof value === "object") return [key, String(value)]
118
+ return [key, value]
119
+ }),
120
+ )
121
+
122
+ const sortKeys = (row: Record<string, unknown>): Record<string, unknown> =>
123
+ Object.fromEntries(Object.entries(row).sort(([a], [b]) => (a < b ? -1 : 1)))
124
+
125
+ const canonical = (
126
+ rows: ReadonlyArray<Record<string, unknown>>,
127
+ strictOrder: boolean,
128
+ ): ReadonlyArray<string> => {
129
+ const normalized = rows.map(normalize).map((row) => JSON.stringify(sortKeys(row)))
130
+ return strictOrder ? normalized : [...normalized].sort()
131
+ }
132
+
133
+ /** Прогоняет модель на фикстурах, возвращает строки — для нестандартных проверок. */
134
+ export const runModel = async (
135
+ model: AnyModel,
136
+ spec: Pick<TestModelSpec, "inputs" | "interval">,
137
+ ): Promise<ReadonlyArray<Record<string, unknown>>> => {
138
+ const inputs = spec.inputs ?? {}
139
+ for (const dep of model.deps) {
140
+ if (!(dep in inputs)) {
141
+ throw new Error(`нет фикстуры для источника ${dep} (deps модели ${model.name.full})`)
142
+ }
143
+ }
144
+ for (const name of Object.keys(inputs)) {
145
+ if (!model.deps.has(name)) {
146
+ throw new Error(`фикстура «${name}» не является источником модели ${model.name.full}`)
147
+ }
148
+ }
149
+ if (usesBounds(model.fragment) && spec.interval === undefined) {
150
+ throw new Error(`модель ${model.name.full} использует ctx.start/ctx.end — укажи interval`)
151
+ }
152
+
153
+ const ctes = [...model.deps]
154
+ .sort()
155
+ .map((dep) => `${quoteIdent(dep)} AS (${fixtureCte(model.refs.get(dep)!, inputs[dep] ?? [])})`)
156
+ const body = render(model.fragment, {
157
+ resolveRef: (ref) => quoteIdent(ref),
158
+ ...(spec.interval !== undefined
159
+ ? {
160
+ interval: {
161
+ start: sqlTimestamp(fromIso(spec.interval[0])),
162
+ end: sqlTimestamp(fromIso(spec.interval[1])),
163
+ },
164
+ }
165
+ : {}),
166
+ })
167
+ const query = ctes.length > 0 ? `WITH ${ctes.join(", ")} ${body}` : body
168
+
169
+ return Effect.runPromise(
170
+ Effect.gen(function* () {
171
+ const engine = yield* EngineAdapter
172
+ return yield* engine.query(query)
173
+ }).pipe(Effect.provide(DuckDBEngineLive())),
174
+ )
175
+ }
176
+
177
+ /** Прогоняет модель на фикстурах и сверяет результат с ожиданием. */
178
+ export const testModel = async (model: AnyModel, spec: TestModelSpec): Promise<void> => {
179
+ const rows = await runModel(model, spec)
180
+ const got = canonical(rows, spec.strictOrder ?? false)
181
+ const want = canonical(spec.expect, spec.strictOrder ?? false)
182
+ if (got.length !== want.length || got.some((row, index) => row !== want[index])) {
183
+ throw new Error(
184
+ [
185
+ `результат модели ${model.name.full} не совпал с ожиданием:`,
186
+ `— получено (${got.length}):`,
187
+ ...got.map((row) => ` ${row}`),
188
+ `— ожидалось (${want.length}):`,
189
+ ...want.map((row) => ` ${row}`),
190
+ ].join("\n"),
191
+ )
192
+ }
193
+ }