@avytheone/efmesh 0.1.0-beta.2 → 0.2.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 +171 -92
- package/README.md +83 -9
- package/README.ru.md +80 -5
- package/SPEC.md +542 -459
- package/package.json +4 -1
- package/src/bin.ts +39 -2
- package/src/cli.ts +505 -85
- package/src/config.ts +15 -15
- package/src/core/audit.ts +15 -11
- package/src/core/errors.ts +37 -7
- package/src/core/graph.ts +6 -6
- package/src/core/interval.ts +18 -18
- package/src/core/model.ts +76 -75
- package/src/core/sql.ts +21 -21
- package/src/discovery.ts +16 -8
- package/src/efmesh.ts +28 -15
- package/src/engine/adapter.ts +29 -12
- package/src/engine/duckdb.ts +7 -7
- package/src/engine/postgres.ts +17 -17
- package/src/error-text.ts +35 -0
- package/src/index.ts +44 -13
- package/src/init.ts +100 -28
- package/src/plan/audit-run.ts +21 -13
- package/src/plan/categorize.ts +10 -7
- package/src/plan/contract.ts +23 -19
- package/src/plan/diff.ts +228 -4
- package/src/plan/executor.ts +223 -121
- package/src/plan/explain.ts +114 -0
- package/src/plan/fingerprint.ts +84 -31
- package/src/plan/graph-html.ts +7 -7
- package/src/plan/janitor.ts +30 -25
- package/src/plan/lineage.ts +20 -16
- package/src/plan/lock.ts +27 -10
- package/src/plan/metrics.ts +4 -4
- package/src/plan/naming.ts +23 -23
- package/src/plan/planner.ts +238 -52
- package/src/plan/run.ts +72 -26
- package/src/plan/schedule.ts +217 -0
- package/src/plan/status.ts +96 -0
- package/src/state/postgres.ts +68 -19
- package/src/state/sqlite.ts +74 -27
- package/src/state/store.ts +88 -45
- package/src/testing/index.ts +27 -26
package/src/plan/audit-run.ts
CHANGED
|
@@ -8,13 +8,13 @@ import type { EngineError } from "../engine/adapter.ts"
|
|
|
8
8
|
import { externalSourceRef, viewRef } from "./naming.ts"
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* Standalone audit run (SPEC §8, F4): checks what the environment serves to
|
|
12
|
+
* consumers RIGHT NOW — the whole view-layer, not the freshly loaded interval
|
|
13
|
+
* as in apply. Catches degradation after the fact: late data via lookback,
|
|
14
|
+
* physical storage edited by hand, drift of external sources.
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* Changes and marks nothing; the report is complete — a failed blocking audit
|
|
17
|
+
* does not hide the ones that follow it.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
export interface AuditRunResult {
|
|
@@ -26,21 +26,29 @@ export interface AuditRunResult {
|
|
|
26
26
|
|
|
27
27
|
export interface AuditRunReport {
|
|
28
28
|
readonly results: ReadonlyArray<AuditRunResult>
|
|
29
|
-
/**
|
|
29
|
+
/** Total blocking-audit violations — nonzero ⇒ the environment cannot be trusted. */
|
|
30
30
|
readonly blockingViolations: number
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
/**
|
|
33
|
+
/** The requested model is not in the project (or has no audits — nothing to run). */
|
|
34
34
|
export class AuditTargetError extends Data.TaggedError("AuditTargetError")<{
|
|
35
35
|
readonly model: string
|
|
36
36
|
readonly reason: string
|
|
37
|
-
}> {
|
|
37
|
+
}> {
|
|
38
|
+
override get message(): string {
|
|
39
|
+
return `audit target «${this.model}»: ${this.reason}`
|
|
40
|
+
}
|
|
41
|
+
}
|
|
38
42
|
|
|
39
|
-
/**
|
|
43
|
+
/** Result for the CLI: the environment's blocking audits are violated. */
|
|
40
44
|
export class EnvironmentAuditError extends Data.TaggedError("EnvironmentAuditError")<{
|
|
41
45
|
readonly env: string
|
|
42
46
|
readonly blockingViolations: number
|
|
43
|
-
}> {
|
|
47
|
+
}> {
|
|
48
|
+
override get message(): string {
|
|
49
|
+
return `environment «${this.env}»: ${this.blockingViolations} blocking audit violation(s)`
|
|
50
|
+
}
|
|
51
|
+
}
|
|
44
52
|
|
|
45
53
|
const selfFor = (graph: ModelGraph, env: string, model: AnyModel): string => {
|
|
46
54
|
const resolve = (ref: string): string => {
|
|
@@ -51,7 +59,7 @@ const selfFor = (graph: ModelGraph, env: string, model: AnyModel): string => {
|
|
|
51
59
|
}
|
|
52
60
|
return viewRef(env, source.name)
|
|
53
61
|
}
|
|
54
|
-
// embedded
|
|
62
|
+
// embedded is not materialized — the subquery is audited against the environment's view
|
|
55
63
|
if (model.kind._tag === "embedded") {
|
|
56
64
|
return `(${render(model.fragment, { resolveRef: resolve })})`
|
|
57
65
|
}
|
|
@@ -68,7 +76,7 @@ export const auditEnvironment = (
|
|
|
68
76
|
const graph = yield* buildGraph(models)
|
|
69
77
|
for (const name of only ?? []) {
|
|
70
78
|
if (!graph.models.has(name)) {
|
|
71
|
-
return yield* new AuditTargetError({ model: name, reason: "
|
|
79
|
+
return yield* new AuditTargetError({ model: name, reason: "model is not in the project" })
|
|
72
80
|
}
|
|
73
81
|
}
|
|
74
82
|
const wanted = only === undefined ? undefined : new Set(only)
|
package/src/plan/categorize.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* Change categorization by canonical ASTs (SPEC §5.2): non-breaking is adding
|
|
3
|
+
* columns TO THE END of the top SELECT with the rest of the tree entirely
|
|
4
|
+
* unchanged (consumers select by name, descendants' INSERT is by position, so
|
|
5
|
+
* only a suffix is safe). Everything else is breaking. On any unexpected
|
|
6
|
+
* structure — conservatively breaking.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
type AstNode = Record<string, unknown>
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
/** The canon's top SELECT: the column list + the rest of the tree (for explain.ts). */
|
|
12
|
+
export const topSelect = (
|
|
13
|
+
astJson: string,
|
|
14
|
+
): { list: ReadonlyArray<string>; rest: string } | null => {
|
|
12
15
|
try {
|
|
13
16
|
const ast = JSON.parse(astJson) as {
|
|
14
17
|
readonly statements?: ReadonlyArray<{ readonly node?: AstNode }>
|
|
@@ -34,7 +37,7 @@ export const categorizeAstChange = (
|
|
|
34
37
|
if (before === null || after === null) return "breaking"
|
|
35
38
|
if (before.rest !== after.rest) return "breaking"
|
|
36
39
|
if (after.list.length <= before.list.length) return "breaking"
|
|
37
|
-
//
|
|
40
|
+
// the old select_list is an exact prefix of the new one
|
|
38
41
|
return before.list.every((item, index) => item === after.list[index])
|
|
39
42
|
? "non-breaking"
|
|
40
43
|
: "breaking"
|
package/src/plan/contract.ts
CHANGED
|
@@ -3,24 +3,28 @@ import type { AnyModel } from "../core/model.ts"
|
|
|
3
3
|
import type { Engine, EngineError } from "../engine/adapter.ts"
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Schema contract (SPEC §3.2): the declared Schema is not documentation.
|
|
7
|
+
* Before building a snapshot efmesh runs a DESCRIBE of the query (the engine
|
|
8
|
+
* returns names and types without executing it) and checks it against the
|
|
9
|
+
* declaration. Type drift is caught before the backfill, not after.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* Checked by type families: an exact match of TS types to engine types is
|
|
12
|
+
* impossible (Number is both INTEGER and DOUBLE), while names are checked
|
|
13
|
+
* strictly. DESCRIBE does not report nullability — that is audit territory (§14.2).
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
export class SchemaMismatchError extends Data.TaggedError("SchemaMismatchError")<{
|
|
17
17
|
readonly model: string
|
|
18
18
|
readonly problems: ReadonlyArray<string>
|
|
19
|
-
}> {
|
|
19
|
+
}> {
|
|
20
|
+
override get message(): string {
|
|
21
|
+
return `schema contract of model «${this.model}»: ${this.problems.join("; ")}`
|
|
22
|
+
}
|
|
23
|
+
}
|
|
20
24
|
|
|
21
25
|
export type TypeFamily = "text" | "numeric" | "boolean" | "temporal" | "any"
|
|
22
26
|
|
|
23
|
-
/**
|
|
27
|
+
/** Family expected from an Effect Schema field (by AST). Reuse: testModel renders fixtures from it. */
|
|
24
28
|
export const familyOfAst = (ast: unknown): TypeFamily => {
|
|
25
29
|
const node = ast as {
|
|
26
30
|
readonly _tag: string
|
|
@@ -40,7 +44,7 @@ export const familyOfAst = (ast: unknown): TypeFamily => {
|
|
|
40
44
|
return constructorTag.startsWith("effect/DateTime") ? "temporal" : "any"
|
|
41
45
|
}
|
|
42
46
|
case "Union": {
|
|
43
|
-
// NullOr(X)
|
|
47
|
+
// NullOr(X) and the like: the single recognized family among the members
|
|
44
48
|
const families = new Set(
|
|
45
49
|
(node.types ?? [])
|
|
46
50
|
.map(familyOfAst)
|
|
@@ -53,7 +57,7 @@ export const familyOfAst = (ast: unknown): TypeFamily => {
|
|
|
53
57
|
}
|
|
54
58
|
}
|
|
55
59
|
|
|
56
|
-
/**
|
|
60
|
+
/** Family of an actual DuckDB type from DESCRIBE. */
|
|
57
61
|
const familyOfEngineType = (engineType: string): TypeFamily => {
|
|
58
62
|
const base = engineType.toUpperCase()
|
|
59
63
|
if (base === "VARCHAR" || base.startsWith("VARCHAR(")) return "text"
|
|
@@ -69,11 +73,11 @@ const familyOfEngineType = (engineType: string): TypeFamily => {
|
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
/**
|
|
72
|
-
*
|
|
73
|
-
* `renderedSql` —
|
|
74
|
-
* `managed` —
|
|
75
|
-
* valid_to):
|
|
76
|
-
*
|
|
76
|
+
* Checks the model's declared schema against the query's actual result.
|
|
77
|
+
* `renderedSql` — the executable render of the body (references already point
|
|
78
|
+
* at physical storage/sources). `managed` — columns efmesh maintains itself
|
|
79
|
+
* (scdType2: valid_from/valid_to): declared in the schema — consumers see
|
|
80
|
+
* them — but the query does not return them.
|
|
77
81
|
*/
|
|
78
82
|
export const checkContract = (
|
|
79
83
|
engine: Engine,
|
|
@@ -92,19 +96,19 @@ export const checkContract = (
|
|
|
92
96
|
for (const [name, field] of declared) {
|
|
93
97
|
const engineType = actualByName.get(name)
|
|
94
98
|
if (engineType === undefined) {
|
|
95
|
-
problems.push(
|
|
99
|
+
problems.push(`column «${name}» is declared in the schema but the query does not return it`)
|
|
96
100
|
continue
|
|
97
101
|
}
|
|
98
102
|
const expected = familyOfAst(field.ast)
|
|
99
103
|
const got = familyOfEngineType(engineType)
|
|
100
104
|
if (expected !== "any" && got !== "any" && expected !== got) {
|
|
101
|
-
problems.push(
|
|
105
|
+
problems.push(`column «${name}»: schema expects ${expected}, query returns ${engineType}`)
|
|
102
106
|
}
|
|
103
107
|
}
|
|
104
108
|
const declaredNames = new Set(declared.map(([name]) => name))
|
|
105
109
|
for (const column of actual) {
|
|
106
110
|
if (!declaredNames.has(column.name)) {
|
|
107
|
-
problems.push(
|
|
111
|
+
problems.push(`query returns column «${column.name}» which is not in the schema`)
|
|
108
112
|
}
|
|
109
113
|
}
|
|
110
114
|
|
package/src/plan/diff.ts
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
|
-
import { Effect } from "effect"
|
|
1
|
+
import { Data, Effect } from "effect"
|
|
2
|
+
import type { GraphError } from "../core/graph.ts"
|
|
3
|
+
import { buildGraph } from "../core/graph.ts"
|
|
4
|
+
import type { AnyModel } from "../core/model.ts"
|
|
5
|
+
import { quoteIdent } from "../core/sql.ts"
|
|
6
|
+
import { EngineAdapter } from "../engine/adapter.ts"
|
|
7
|
+
import type { EngineError } from "../engine/adapter.ts"
|
|
8
|
+
import { viewRef } from "./naming.ts"
|
|
2
9
|
import { StateStore } from "../state/store.ts"
|
|
3
10
|
import type { StateError } from "../state/store.ts"
|
|
4
11
|
|
|
5
|
-
/**
|
|
12
|
+
/** How two environments differ (SPEC §11: `efmesh diff <envA> <envB>`). */
|
|
6
13
|
export interface EnvDiff {
|
|
7
|
-
/**
|
|
14
|
+
/** Model present only in A. */
|
|
8
15
|
readonly onlyInA: ReadonlyArray<string>
|
|
9
16
|
readonly onlyInB: ReadonlyArray<string>
|
|
10
|
-
/**
|
|
17
|
+
/** Differing versions: name + fp8 of both sides. */
|
|
11
18
|
readonly different: ReadonlyArray<{ readonly name: string; readonly a: string; readonly b: string }>
|
|
12
19
|
readonly same: ReadonlyArray<string>
|
|
13
20
|
}
|
|
@@ -34,3 +41,220 @@ export const diffEnvironments = (
|
|
|
34
41
|
|
|
35
42
|
return { onlyInA, onlyInB, different, same }
|
|
36
43
|
})
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* DATA comparison of two environments (#6, sqlmesh's table_diff class): row
|
|
47
|
+
* counts, intersection by key, per-model column-mismatch rates — between the
|
|
48
|
+
* A and B view-layers of one database. On DuckDB-class data this is cheap;
|
|
49
|
+
* for large tables — a deterministic sample by md5 of the key: both sides are
|
|
50
|
+
* filtered by the same buckets, so the sample is aligned and key pairs are
|
|
51
|
+
* not lost.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
export interface ColumnDrift {
|
|
55
|
+
readonly column: string
|
|
56
|
+
/** How many matched keys diverged in this column. */
|
|
57
|
+
readonly mismatches: number
|
|
58
|
+
/** Fraction of matched, 0..1. */
|
|
59
|
+
readonly rate: number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ModelDataDiff {
|
|
63
|
+
readonly model: string
|
|
64
|
+
/** Full row counts (not affected by sampling). */
|
|
65
|
+
readonly rowsA: number
|
|
66
|
+
readonly rowsB: number
|
|
67
|
+
/** Matching key: grain or a kind's key (uniqueKey/scdType2). */
|
|
68
|
+
readonly key?: ReadonlyArray<string>
|
|
69
|
+
/** Below — only when there is a key; under sampling, counts over selected buckets. */
|
|
70
|
+
readonly onlyInA?: number
|
|
71
|
+
readonly onlyInB?: number
|
|
72
|
+
readonly matched?: number
|
|
73
|
+
readonly columns?: ReadonlyArray<ColumnDrift>
|
|
74
|
+
/** Columns present on only one side — schema drift between environments. */
|
|
75
|
+
readonly columnsOnlyInA?: ReadonlyArray<string>
|
|
76
|
+
readonly columnsOnlyInB?: ReadonlyArray<string>
|
|
77
|
+
/** Percentage of the key's md5 buckets compared; no field — full comparison. */
|
|
78
|
+
readonly sampledPercent?: number
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface DataDiffReport {
|
|
82
|
+
readonly envA: string
|
|
83
|
+
readonly envB: string
|
|
84
|
+
readonly models: ReadonlyArray<ModelDataDiff>
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface DataDiffOptions {
|
|
88
|
+
/** Only these models; by default — all materializable ones from both environments. */
|
|
89
|
+
readonly models?: ReadonlyArray<string>
|
|
90
|
+
/** 1–99: compare a fraction of keys (md5 buckets, aligned across sides). */
|
|
91
|
+
readonly samplePercent?: number
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export class DataDiffError extends Data.TaggedError("DataDiffError")<{
|
|
95
|
+
readonly model: string
|
|
96
|
+
readonly reason: string
|
|
97
|
+
}> {
|
|
98
|
+
override get message(): string {
|
|
99
|
+
return `data diff of model «${this.model}»: ${this.reason}`
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Kinds with a view-layer in the environment — their data is comparable. */
|
|
104
|
+
const COMPARABLE_KINDS: ReadonlySet<string> = new Set([
|
|
105
|
+
"full",
|
|
106
|
+
"view",
|
|
107
|
+
"incrementalByTimeRange",
|
|
108
|
+
"incrementalByUniqueKey",
|
|
109
|
+
"scdType2",
|
|
110
|
+
"seed",
|
|
111
|
+
])
|
|
112
|
+
|
|
113
|
+
const keyOf = (model: AnyModel): ReadonlyArray<string> | undefined => {
|
|
114
|
+
if (model.grain !== undefined && model.grain.length > 0) return model.grain
|
|
115
|
+
if (model.kind._tag === "incrementalByUniqueKey" || model.kind._tag === "scdType2") {
|
|
116
|
+
return model.kind.key
|
|
117
|
+
}
|
|
118
|
+
return undefined
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Deterministic sampling filter: md5 of the concatenated key, first two
|
|
123
|
+
* hex chars = 256 buckets. Works on both DuckDB and Postgres, identically
|
|
124
|
+
* on both sides of the diff.
|
|
125
|
+
*/
|
|
126
|
+
const samplePredicate = (key: ReadonlyArray<string>, percent: number): string => {
|
|
127
|
+
const buckets = Math.max(1, Math.min(255, Math.floor((256 * percent) / 100)))
|
|
128
|
+
const threshold = buckets.toString(16).padStart(2, "0")
|
|
129
|
+
return `substr(md5(concat_ws('|', ${key.map(quoteIdent).join(", ")})), 1, 2) < '${threshold}'`
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const asCount = (value: unknown): number => Number(value ?? 0)
|
|
133
|
+
|
|
134
|
+
export const dataDiffEnvironments = (
|
|
135
|
+
envA: string,
|
|
136
|
+
envB: string,
|
|
137
|
+
models: Iterable<AnyModel>,
|
|
138
|
+
options?: DataDiffOptions,
|
|
139
|
+
): Effect.Effect<
|
|
140
|
+
DataDiffReport,
|
|
141
|
+
GraphError | StateError | EngineError | DataDiffError,
|
|
142
|
+
StateStore | EngineAdapter
|
|
143
|
+
> =>
|
|
144
|
+
Effect.gen(function* () {
|
|
145
|
+
const store = yield* StateStore
|
|
146
|
+
const engine = yield* EngineAdapter
|
|
147
|
+
const graph = yield* buildGraph(models)
|
|
148
|
+
const inA = new Set((yield* store.getEnvironment(envA)).map((row) => row.name))
|
|
149
|
+
const inB = new Set((yield* store.getEnvironment(envB)).map((row) => row.name))
|
|
150
|
+
for (const name of options?.models ?? []) {
|
|
151
|
+
if (!graph.models.has(name)) {
|
|
152
|
+
return yield* new DataDiffError({ model: name, reason: "model is not in the project" })
|
|
153
|
+
}
|
|
154
|
+
if (!inA.has(name) || !inB.has(name)) {
|
|
155
|
+
return yield* new DataDiffError({
|
|
156
|
+
model: name,
|
|
157
|
+
reason: `model is not in environment "${inA.has(name) ? envB : envA}"`,
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const wanted = options?.models === undefined ? undefined : new Set(options.models)
|
|
162
|
+
const percent = options?.samplePercent
|
|
163
|
+
|
|
164
|
+
const reports: Array<ModelDataDiff> = []
|
|
165
|
+
for (const name of graph.order) {
|
|
166
|
+
if (wanted !== undefined && !wanted.has(name)) continue
|
|
167
|
+
if (!inA.has(name) || !inB.has(name)) continue
|
|
168
|
+
const model = graph.models.get(name)!
|
|
169
|
+
if (!COMPARABLE_KINDS.has(model.kind._tag)) continue
|
|
170
|
+
const refA = viewRef(envA, model.name)
|
|
171
|
+
const refB = viewRef(envB, model.name)
|
|
172
|
+
|
|
173
|
+
const rowsA = asCount(
|
|
174
|
+
(yield* engine.query(`SELECT count(*) AS n FROM ${refA}`))[0]?.["n"],
|
|
175
|
+
)
|
|
176
|
+
const rowsB = asCount(
|
|
177
|
+
(yield* engine.query(`SELECT count(*) AS n FROM ${refB}`))[0]?.["n"],
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
// actual columns of both sides: environments may point at different
|
|
181
|
+
// model versions — only common ones are compared, schema drift is recorded
|
|
182
|
+
const colsA = (yield* engine.describe(`SELECT * FROM ${refA}`)).map((c) => c.name)
|
|
183
|
+
const colsB = (yield* engine.describe(`SELECT * FROM ${refB}`)).map((c) => c.name)
|
|
184
|
+
const setB = new Set(colsB)
|
|
185
|
+
const setA = new Set(colsA)
|
|
186
|
+
const common = colsA.filter((column) => setB.has(column))
|
|
187
|
+
const columnsOnlyInA = colsA.filter((column) => !setB.has(column))
|
|
188
|
+
const columnsOnlyInB = colsB.filter((column) => !setA.has(column))
|
|
189
|
+
|
|
190
|
+
const key = keyOf(model)
|
|
191
|
+
const commonSet = new Set(common)
|
|
192
|
+
if (key === undefined || !key.every((column) => commonSet.has(column))) {
|
|
193
|
+
// nothing to match on (no grain/key, or the key is not on both sides) —
|
|
194
|
+
// honest row counts without pairing
|
|
195
|
+
reports.push({
|
|
196
|
+
model: name,
|
|
197
|
+
rowsA,
|
|
198
|
+
rowsB,
|
|
199
|
+
...(columnsOnlyInA.length > 0 ? { columnsOnlyInA } : {}),
|
|
200
|
+
...(columnsOnlyInB.length > 0 ? { columnsOnlyInB } : {}),
|
|
201
|
+
})
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const keySet = new Set(key)
|
|
206
|
+
const compared = common.filter((column) => !keySet.has(column))
|
|
207
|
+
const sample = percent === undefined ? "" : ` WHERE ${samplePredicate(key, percent)}`
|
|
208
|
+
const pairs = compared
|
|
209
|
+
.map(
|
|
210
|
+
(column, index) =>
|
|
211
|
+
`, a.${quoteIdent(column)} AS a_${index}, b.${quoteIdent(column)} AS b_${index}`,
|
|
212
|
+
)
|
|
213
|
+
.join("")
|
|
214
|
+
const mismatches = compared
|
|
215
|
+
.map(
|
|
216
|
+
(_, index) =>
|
|
217
|
+
`, count(*) FILTER (WHERE in_a AND in_b AND (a_${index} IS DISTINCT FROM b_${index})) AS mm_${index}`,
|
|
218
|
+
)
|
|
219
|
+
.join("")
|
|
220
|
+
const keyList = key.map(quoteIdent).join(", ")
|
|
221
|
+
const [row] = yield* engine.query(`
|
|
222
|
+
SELECT
|
|
223
|
+
count(*) FILTER (WHERE NOT in_b) AS only_a,
|
|
224
|
+
count(*) FILTER (WHERE NOT in_a) AS only_b,
|
|
225
|
+
count(*) FILTER (WHERE in_a AND in_b) AS matched
|
|
226
|
+
${mismatches}
|
|
227
|
+
FROM (
|
|
228
|
+
SELECT coalesce(a.in_a, FALSE) AS in_a, coalesce(b.in_b, FALSE) AS in_b${pairs}
|
|
229
|
+
FROM (SELECT TRUE AS in_a, * FROM ${refA}${sample}) a
|
|
230
|
+
FULL OUTER JOIN (SELECT TRUE AS in_b, * FROM ${refB}${sample}) b
|
|
231
|
+
USING (${keyList})
|
|
232
|
+
) j
|
|
233
|
+
`)
|
|
234
|
+
const matched = asCount(row?.["matched"])
|
|
235
|
+
const columns = compared
|
|
236
|
+
.map((column, index) => {
|
|
237
|
+
const drifted = asCount(row?.[`mm_${index}`])
|
|
238
|
+
return {
|
|
239
|
+
column,
|
|
240
|
+
mismatches: drifted,
|
|
241
|
+
rate: matched === 0 ? 0 : drifted / matched,
|
|
242
|
+
}
|
|
243
|
+
})
|
|
244
|
+
.filter((drift) => drift.mismatches > 0)
|
|
245
|
+
reports.push({
|
|
246
|
+
model: name,
|
|
247
|
+
rowsA,
|
|
248
|
+
rowsB,
|
|
249
|
+
key,
|
|
250
|
+
onlyInA: asCount(row?.["only_a"]),
|
|
251
|
+
onlyInB: asCount(row?.["only_b"]),
|
|
252
|
+
matched,
|
|
253
|
+
columns,
|
|
254
|
+
...(columnsOnlyInA.length > 0 ? { columnsOnlyInA } : {}),
|
|
255
|
+
...(columnsOnlyInB.length > 0 ? { columnsOnlyInB } : {}),
|
|
256
|
+
...(percent !== undefined ? { sampledPercent: percent } : {}),
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
return { envA, envB, models: reports }
|
|
260
|
+
}).pipe(Effect.withSpan("efmesh.diff.data", { attributes: { envA, envB } }))
|