@ingestron/pack-databricks 1.0.1 → 2.0.0

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.
@@ -1,20 +1,18 @@
1
- // Template data for a PySpark transform's Python file. Python string and
2
- // dict literals are written as JSON, which Python reads the same way.
3
- import { fullNameText } from "@ingestron/definitions";
1
+ import { localName, localText } from "./sql-data.js";
4
2
  const decorators = {
5
3
  warn: "expect_all",
6
4
  drop: "expect_all_or_drop",
7
5
  fail: "expect_all_or_fail",
8
6
  };
9
- /** Everything materialised-view.py.liquid needs. */
10
- export function pythonData(table, header) {
7
+ /** Everything materialised-view.py.liquid needs, in the pipeline whose catalog is `catalog`. */
8
+ export function pythonData(table, header, catalog) {
11
9
  const store = table.store?.settings ?? {};
12
10
  const transform = table.transform;
13
11
  const properties = (store.properties ?? {});
14
12
  return {
15
13
  header,
16
14
  code: transform.code.trimEnd(),
17
- name: JSON.stringify(fullNameText(table.fullName)),
15
+ name: JSON.stringify(localName(table.fullName, catalog)),
18
16
  comment: table.contract.description ? JSON.stringify(table.contract.description) : "",
19
17
  cluster: clusterOf(store.cluster_by),
20
18
  properties: Object.keys(properties).length > 0 ? pythonDict(Object.entries(properties)) : "",
@@ -32,7 +30,7 @@ export function pythonData(table, header) {
32
30
  function: String(transform.settings.function),
33
31
  inputs: Object.entries(transform.inputs).map(([name, full]) => ({
34
32
  name: JSON.stringify(name),
35
- table: JSON.stringify(full),
33
+ table: JSON.stringify(localText(full, catalog)),
36
34
  })),
37
35
  };
38
36
  }
@@ -1,4 +1,4 @@
1
- import { type ResolvedTable } from "@ingestron/definitions";
1
+ import type { FullName, ResolvedTable } from "@ingestron/definitions";
2
2
  /** The table definition shared by every SQL file. */
3
3
  export interface TableData {
4
4
  kind: "STREAMING TABLE" | "MATERIALIZED VIEW";
@@ -28,12 +28,19 @@ export interface SourceData {
28
28
  }
29
29
  /** A SQL string literal. */
30
30
  export declare function sqlString(text: string): string;
31
- /** The definition of the table itself. */
32
- export declare function tableData(table: ResolvedTable, kind: TableData["kind"]): TableData;
31
+ /**
32
+ * A table's name in a pipeline's code: `schema.table` when the pipeline's own
33
+ * catalog holds it, as the pipeline reads names in its catalog; else all three parts.
34
+ */
35
+ export declare function localName(name: FullName, catalog: string): string;
36
+ /** A full name written `catalog.schema.table`, as transform inputs are, in a pipeline's code. */
37
+ export declare function localText(fullName: string, catalog: string): string;
38
+ /** The definition of the table itself, in the pipeline whose catalog is `catalog`. */
39
+ export declare function tableData(table: ResolvedTable, kind: TableData["kind"], catalog: string): TableData;
33
40
  /** The `read_files` call for a flow table's files. */
34
41
  export declare function sourceData(table: ResolvedTable, stream: boolean): SourceData;
35
- /** The AUTO CDC parts of a change-data table. */
36
- export declare function changeData(table: ResolvedTable): {
42
+ /** The AUTO CDC parts of a change-data table, in the pipeline whose catalog is `catalog`. */
43
+ export declare function changeData(table: ResolvedTable, catalog: string): {
37
44
  changes: string;
38
45
  flow: string;
39
46
  keys: string;
package/dist/sql-data.js CHANGED
@@ -1,19 +1,28 @@
1
- // Template data for a table's SQL file. All quoting happens here, so the
2
- // templates only arrange text. Key names avoid Liquid keywords (such as
3
- // `required` or `with`) because python-liquid refuses them after a dot.
4
- import { fullNameText } from "@ingestron/definitions";
5
1
  import { quoteName } from "./query.js";
6
2
  const onViolation = { warn: "", drop: " ON VIOLATION DROP ROW", fail: " ON VIOLATION FAIL UPDATE" };
7
3
  /** A SQL string literal. */
8
4
  export function sqlString(text) {
9
5
  return `'${text.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
10
6
  }
11
- /** The definition of the table itself. */
12
- export function tableData(table, kind) {
7
+ /**
8
+ * A table's name in a pipeline's code: `schema.table` when the pipeline's own
9
+ * catalog holds it, as the pipeline reads names in its catalog; else all three parts.
10
+ */
11
+ export function localName(name, catalog) {
12
+ return name.catalog === catalog
13
+ ? `${name.schema}.${name.table}`
14
+ : `${name.catalog}.${name.schema}.${name.table}`;
15
+ }
16
+ /** A full name written `catalog.schema.table`, as transform inputs are, in a pipeline's code. */
17
+ export function localText(fullName, catalog) {
18
+ return fullName.startsWith(`${catalog}.`) ? fullName.slice(catalog.length + 1) : fullName;
19
+ }
20
+ /** The definition of the table itself, in the pipeline whose catalog is `catalog`. */
21
+ export function tableData(table, kind, catalog) {
13
22
  const settings = table.store?.settings ?? {};
14
23
  return {
15
24
  kind,
16
- target: quoteName(fullNameText(table.fullName)),
25
+ target: quoteName(localName(table.fullName, catalog)),
17
26
  constraints: table.expectations.map((item) => ({
18
27
  name: quoteName(item.name),
19
28
  check: item.check,
@@ -46,13 +55,31 @@ function renamedColumns(table) {
46
55
  return [];
47
56
  return table.contract.columns.filter((column) => column.physicalName && column.physicalName !== column.name);
48
57
  }
49
- /** `*`, or `* EXCEPT (<source names>), <source> AS <name>, …` for renamed columns. */
58
+ /**
59
+ * Timestamps the landed files carry beyond the contract (such as ADF's audit
60
+ * columns), which the step that adds them writes as ISO 8601 text.
61
+ */
62
+ function textTimestamps(table) {
63
+ const extra = table.ingest?.output?.columns ?? [];
64
+ return extra.filter((column) => column.logicalType === "timestamp").map((column) => column.name);
65
+ }
66
+ /**
67
+ * `*`, or `* EXCEPT (<source names>), <source> AS <name>, …` for renamed
68
+ * columns, and `CAST(<name> AS TIMESTAMP) AS <name>` for timestamps written as text.
69
+ */
50
70
  function selectOf(table) {
51
71
  const renamed = renamedColumns(table);
52
- if (renamed.length === 0)
72
+ const timestamps = textTimestamps(table);
73
+ if (renamed.length === 0 && timestamps.length === 0)
53
74
  return "*";
54
- const sources = renamed.map((column) => quoteName(column.physicalName));
55
- const aliases = renamed.map((column) => `${quoteName(column.physicalName)} AS ${quoteName(column.name)}`);
75
+ const sources = [
76
+ ...renamed.map((column) => quoteName(column.physicalName)),
77
+ ...timestamps.map(quoteName),
78
+ ];
79
+ const aliases = [
80
+ ...renamed.map((column) => `${quoteName(column.physicalName)} AS ${quoteName(column.name)}`),
81
+ ...timestamps.map((name) => `CAST(${quoteName(name)} AS TIMESTAMP) AS ${quoteName(name)}`),
82
+ ];
56
83
  return `* EXCEPT (${sources.join(", ")}), ${aliases.join(", ")}`;
57
84
  }
58
85
  /** The connection's `path` joined with the table's object. */
@@ -90,8 +117,8 @@ function optionsOf(table) {
90
117
  }
91
118
  return options;
92
119
  }
93
- /** The AUTO CDC parts of a change-data table. */
94
- export function changeData(table) {
120
+ /** The AUTO CDC parts of a change-data table, in the pipeline whose catalog is `catalog`. */
121
+ export function changeData(table, catalog) {
95
122
  const settings = table.store?.settings ?? {};
96
123
  const changes = {
97
124
  ...table.fullName,
@@ -99,7 +126,7 @@ export function changeData(table) {
99
126
  };
100
127
  const except = (settings.except_columns ?? []);
101
128
  return {
102
- changes: quoteName(fullNameText(changes)),
129
+ changes: quoteName(localName(changes, catalog)),
103
130
  flow: quoteName(`${table.fullName.table}_cdc`),
104
131
  keys: table.contract.primaryKey.map(quoteName).join(", "),
105
132
  sequence: quoteName(String(settings.sequence_by)),
package/dist/sql.d.ts CHANGED
@@ -7,8 +7,6 @@ export interface SqlFile {
7
7
  data: object;
8
8
  extension: "sql" | "py";
9
9
  }
10
- /** The SQL or Python file for one table; `header` is its first comment line. */
11
- export declare function tableSql(table: ResolvedTable, header: string): string;
12
10
  /** Renders a file chosen by `sqlFile`. */
13
11
  export declare function renderSql({ template, data }: SqlFile): string;
14
12
  /** The after-refresh Python file, with its statements and checks as JSON. */
@@ -17,5 +15,8 @@ export declare function renderApply(data: {
17
15
  statements: string[];
18
16
  checks: object[];
19
17
  }): string;
20
- /** Chooses the template by transform and store method, and builds its data. */
21
- export declare function sqlFile(table: ResolvedTable, header: string): SqlFile;
18
+ /**
19
+ * Chooses the template by transform and store method, and builds its data. `catalog`
20
+ * is the pipeline's catalog, whose tables the code names with two parts.
21
+ */
22
+ export declare function sqlFile(table: ResolvedTable, header: string, catalog: string): SqlFile;
package/dist/sql.js CHANGED
@@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url";
4
4
  import { Liquid } from "liquidjs";
5
5
  import { renderQuery } from "./query.js";
6
6
  import { pythonData } from "./python-data.js";
7
- import { changeData, sourceData, tableData } from "./sql-data.js";
7
+ import { changeData, localText, sourceData, tableData } from "./sql-data.js";
8
8
  /** The templates folder, also used by the template parity check. */
9
9
  export const templatesFolder = join(dirname(fileURLToPath(import.meta.url)), "../templates/");
10
10
  const engine = new Liquid({
@@ -13,10 +13,6 @@ const engine = new Liquid({
13
13
  strictVariables: true,
14
14
  strictFilters: true,
15
15
  });
16
- /** The SQL or Python file for one table; `header` is its first comment line. */
17
- export function tableSql(table, header) {
18
- return renderSql(sqlFile(table, header));
19
- }
20
16
  /** Renders a file chosen by `sqlFile`. */
21
17
  export function renderSql({ template, data }) {
22
18
  return `${engine.renderFileSync(template, data).trimEnd()}\n`;
@@ -33,39 +29,43 @@ export function renderApply(data) {
33
29
  extension: "py",
34
30
  });
35
31
  }
36
- /** Chooses the template by transform and store method, and builds its data. */
37
- export function sqlFile(table, header) {
32
+ /**
33
+ * Chooses the template by transform and store method, and builds its data. `catalog`
34
+ * is the pipeline's catalog, whose tables the code names with two parts.
35
+ */
36
+ export function sqlFile(table, header, catalog) {
38
37
  if (table.transform?.method === "pyspark") {
39
38
  return {
40
39
  template: "materialised-view.py.liquid",
41
- data: pythonData(table, header),
40
+ data: pythonData(table, header, catalog),
42
41
  extension: "py",
43
42
  };
44
43
  }
45
44
  const store = table.store?.method;
46
45
  if (store === "streaming-table")
47
- return streamingTable(table, header);
46
+ return streamingTable(table, header, catalog);
48
47
  if (store === "materialised-view")
49
- return materialisedView(table, header);
48
+ return materialisedView(table, header, catalog);
50
49
  throw new Error(`No SQL for store method "${String(store)}" (table ${table.name}).`);
51
50
  }
52
- function streamingTable(table, header) {
51
+ function streamingTable(table, header, catalog) {
53
52
  const data = {
54
53
  header,
55
- table: tableData(table, "STREAMING TABLE"),
54
+ table: tableData(table, "STREAMING TABLE", catalog),
56
55
  source: sourceData(table, true),
57
56
  };
58
57
  if (table.store?.settings.mode === "append")
59
58
  return { template: "streaming-table.sql.liquid", data, extension: "sql" };
60
59
  return {
61
60
  template: "change-data.sql.liquid",
62
- data: { ...data, cdc: changeData(table) },
61
+ data: { ...data, cdc: changeData(table, catalog) },
63
62
  extension: "sql",
64
63
  };
65
64
  }
66
- function materialisedView(table, header) {
65
+ function materialisedView(table, header, catalog) {
66
+ const inputs = Object.entries(table.transform?.inputs ?? {}).map(([name, full]) => [name, localText(full, catalog)]);
67
67
  const rendered = table.transform
68
- ? renderQuery(table.transform.code, table.transform.inputs)
68
+ ? renderQuery(table.transform.code, Object.fromEntries(inputs))
69
69
  : { sql: "" };
70
70
  // The sql method's check has already reported SQL that cannot be rendered.
71
71
  const query = "sql" in rendered ? rendered.sql : "";
@@ -74,7 +74,7 @@ function materialisedView(table, header) {
74
74
  : { stream: false, select: "*", path: "", options: [] };
75
75
  return {
76
76
  template: "materialised-view.sql.liquid",
77
- data: { header, table: tableData(table, "MATERIALIZED VIEW"), source, query },
77
+ data: { header, table: tableData(table, "MATERIALIZED VIEW", catalog), source, query },
78
78
  extension: "sql",
79
79
  };
80
80
  }
package/dist/view.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { type ResolvedModel, type SuppliedValue } from "@ingestron/definitions";
2
+ /** What the deploying pipeline supplies for one target, as the deploy-time values' text. */
3
+ export interface Supplied {
4
+ /** Each variable the pipeline supplies as BUNDLE_VAR_<name>, by name. */
5
+ variables: Record<string, string>;
6
+ /** The host, when the pipeline supplies it as DATABRICKS_HOST. */
7
+ host?: string;
8
+ /** Each deploy-time value's text, by NAME. */
9
+ values: Record<string, string>;
10
+ }
11
+ /** What the pipeline supplies for a target, from the fold's deploy-time values. */
12
+ export declare function suppliedFor(model: ResolvedModel, deployValues: readonly SuppliedValue[]): Supplied;
13
+ /** Whether two texts at one path are the same: YAML as data, other files as text. */
14
+ export declare function sameFile(path: string, viewed: string, rendered: string): boolean;
15
+ /** The folded files as Databricks deploys them for `target`, laid out as `build` lays out one target. */
16
+ export declare function bundleView(files: Readonly<Record<string, string>>, target: string, supplied: Supplied): Record<string, string>;
package/dist/view.js ADDED
@@ -0,0 +1,173 @@
1
+ // What Databricks makes of the folded bundle for one target, in the layout of
2
+ // that target's own bundle (spec/behaviour.md, section 4): the fold property
3
+ // compares it with what `build` gives for the target alone.
4
+ import { isDeepStrictEqual } from "node:util";
5
+ import { splitDeployText } from "@ingestron/definitions";
6
+ import { parse, stringify } from "yaml";
7
+ import { pythonWithValues } from "./fold-python.js";
8
+ import { mapStrings } from "./walk.js";
9
+ /** What the pipeline supplies for a target, from the fold's deploy-time values. */
10
+ export function suppliedFor(model, deployValues) {
11
+ const prefix = "BUNDLE_VAR_";
12
+ const variables = Object.fromEntries(deployValues.flatMap((value) => value.suppliedAs
13
+ .filter((name) => name.startsWith(prefix))
14
+ .map((name) => [name.slice(prefix.length), `\${envvar:${value.name}}`])));
15
+ const host = model.platform.workspace.host ?? "";
16
+ const parts = splitDeployText(host, model.deployValues ?? []);
17
+ const whole = parts.length === 1 && typeof parts[0] !== "string";
18
+ const values = Object.fromEntries((model.deployValues ?? []).map((value) => [value.name, value.text]));
19
+ return { variables, ...(whole && { host }), values };
20
+ }
21
+ /** Whether two texts at one path are the same: YAML as data, other files as text. */
22
+ export function sameFile(path, viewed, rendered) {
23
+ return path.endsWith(".yml")
24
+ ? isDeepStrictEqual(parse(viewed), parse(rendered))
25
+ : viewed === rendered;
26
+ }
27
+ /** The folded files as Databricks deploys them for `target`, laid out as `build` lays out one target. */
28
+ export function bundleView(files, target, supplied) {
29
+ const bundle = parse(files["databricks.yml"]);
30
+ const settings = bundle.targets[target];
31
+ const values = { ...supplied.variables, ...settings.variables };
32
+ const resolve = (node) => resolved(node, values);
33
+ const presets = resolve(settings.presets ?? {});
34
+ const development = settings.mode === "development";
35
+ const code = {};
36
+ const view = {};
37
+ let continuous = false;
38
+ for (const [path, text] of Object.entries(files)) {
39
+ if (path.startsWith("resources/")) {
40
+ const data = resolve(parse(text));
41
+ continuous ||= hasContinuous(data);
42
+ applyPresets(data, presets, development);
43
+ takeValues(data, code);
44
+ view[path] = stringify(data);
45
+ }
46
+ else if (path === `prerequisites/${target}.sql`)
47
+ view[path] = deployTexts(text, supplied);
48
+ else if (!path.startsWith("prerequisites/"))
49
+ view[path] = text;
50
+ }
51
+ const running = development && continuous && presets.trigger_pause_status === "UNPAUSED";
52
+ view["databricks.yml"] = stringify(resolve(bundleOf(bundle, target, supplied, running)));
53
+ for (const [path, text] of Object.entries(view))
54
+ view[path] = withValues(path, text, code);
55
+ return view;
56
+ }
57
+ /** True when a resource file has a pipeline that runs continuously. */
58
+ function hasContinuous(data) {
59
+ const pipelines = (data.resources?.pipelines ?? {});
60
+ return Object.values(pipelines).some((pipeline) => pipeline.continuous === true);
61
+ }
62
+ /**
63
+ * The target alone. Development mode stops continuous pipelines unless an UNPAUSED preset
64
+ * replaces its own PAUSED one; no resource setting can say that, so such a preset stays.
65
+ */
66
+ function bundleOf(bundle, target, supplied, running) {
67
+ const settings = { ...bundle.targets[target] };
68
+ delete settings.variables;
69
+ delete settings.presets;
70
+ if (running)
71
+ settings.presets = { trigger_pause_status: "UNPAUSED" };
72
+ const workspace = settings.workspace;
73
+ if (supplied.host && !workspace?.host)
74
+ settings.workspace = { ...workspace, host: supplied.host };
75
+ const { bundle: name, include, sync } = bundle;
76
+ return { bundle: name, include, sync, targets: { [target]: { ...settings, default: true } } };
77
+ }
78
+ /** Each `${var.<name>}` replaced: a whole value by the variable's value, a part by its text. */
79
+ function resolved(node, values) {
80
+ return mapStrings(node, (text) => {
81
+ const whole = /^\$\{var\.([a-z0-9_]+)\}$/.exec(text);
82
+ if (whole && values[whole[1]] !== undefined)
83
+ return values[whole[1]];
84
+ return text.replace(/\$\{var\.([a-z0-9_]+)\}/g, (found, name) => values[name] === undefined ? found : String(values[name]));
85
+ });
86
+ }
87
+ /**
88
+ * Presets as Databricks applies them: tags that a resource does not set, the pause of
89
+ * schedules that do not set one (development mode pauses them), and no continuous
90
+ * pipelines under a PAUSED preset the target writes. Development mode's own default
91
+ * PAUSED also stops continuous pipelines, but it does so in the target's own bundle
92
+ * too, which keeps `continuous: true` in its files; so the view keeps it as well, and
93
+ * marks only an UNPAUSED preset that lifts that default (see `bundleOf`).
94
+ */
95
+ function applyPresets(data, presets, development) {
96
+ const resources = { jobs: {}, pipelines: {}, ...data.resources };
97
+ const jobs = Object.values(resources.jobs);
98
+ const pipelines = Object.values(resources.pipelines);
99
+ const tags = { ...presets.tags };
100
+ if (Object.keys(tags).length > 0)
101
+ for (const resource of [...jobs, ...pipelines])
102
+ resource.tags = { ...tags, ...dropEmpty(resource.tags) };
103
+ const pause = presets.trigger_pause_status;
104
+ if (pause === "PAUSED")
105
+ for (const pipeline of pipelines)
106
+ delete pipeline.continuous;
107
+ const status = pause ?? (development ? "PAUSED" : "UNPAUSED");
108
+ for (const job of jobs)
109
+ pauseSchedule(job, status);
110
+ }
111
+ function pauseSchedule(job, status) {
112
+ const schedule = job.schedule;
113
+ if (schedule && !schedule.pause_status)
114
+ schedule.pause_status = status;
115
+ }
116
+ function dropEmpty(tags) {
117
+ return Object.fromEntries(Object.entries((tags ?? {})).filter(([, value]) => value !== ""));
118
+ }
119
+ /** Takes the pipelines' `ingestron.*` configuration and the job's `ingestron.*` parameters out, by folder or file. */
120
+ function takeValues(data, code) {
121
+ const resources = (data.resources ?? {});
122
+ for (const pipeline of Object.values(resources.pipelines ?? {}))
123
+ takeConfiguration(pipeline, code);
124
+ for (const job of Object.values(resources.jobs ?? {}))
125
+ for (const task of (job.tasks ?? []))
126
+ takeParameters(task, code);
127
+ }
128
+ function takeConfiguration(pipeline, code) {
129
+ const configuration = pipeline.configuration;
130
+ if (!configuration)
131
+ return;
132
+ const folder = `${String(pipeline.root_path ?? "").replace(/^\.\.\//, "")}/`;
133
+ for (const [key, value] of Object.entries(configuration)) {
134
+ if (!key.startsWith("ingestron."))
135
+ continue;
136
+ code[folder] = { ...code[folder], [key]: value };
137
+ delete configuration[key];
138
+ }
139
+ if (Object.keys(configuration).length === 0)
140
+ delete pipeline.configuration;
141
+ }
142
+ function takeParameters(task, code) {
143
+ const python = task.spark_python_task;
144
+ const parameters = (python?.parameters ?? []);
145
+ if (!python || parameters.length === 0)
146
+ return;
147
+ const file = String(python.python_file).replace(/^\.\.\//, "");
148
+ for (const parameter of parameters) {
149
+ const [key, value] = parameter.split(/=(.*)/s);
150
+ code[file] = { ...code[file], [key]: value };
151
+ }
152
+ delete python.parameters;
153
+ }
154
+ /** Pipeline SQL with its folder's configuration values; the script with its parameters. */
155
+ function withValues(path, text, code) {
156
+ return Object.entries(code).reduce((result, [at, values]) => {
157
+ if (path === at)
158
+ return replaced(result, values, (value) => JSON.stringify(value).slice(1, -1));
159
+ if (!at.endsWith("/") || !path.startsWith(at))
160
+ return result;
161
+ if (path.endsWith(".sql"))
162
+ return replaced(result, values, (value) => value);
163
+ return path.endsWith(".py") ? pythonWithValues(result, values) : result;
164
+ }, text);
165
+ }
166
+ /** Each `${<key>}` in a text as its value, written as `write` gives it. */
167
+ function replaced(text, values, write) {
168
+ return Object.entries(values).reduce((result, [key, value]) => result.replaceAll(`\${${key}}`, () => write(value)), text);
169
+ }
170
+ /** The grants file's placeholders, as the deploy-time values they stand for. */
171
+ function deployTexts(text, supplied) {
172
+ return Object.entries(supplied.values).reduce((result, [name, value]) => result.replaceAll(`<value of ${name}>`, () => value), text);
173
+ }
package/dist/walk.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * A copy of `node` with each text replaced by what `map` gives for it. Subtrees whose
3
+ * pointer `skip` names are copied as they are.
4
+ * @example
5
+ * mapStrings({ a: ["x"] }, (text) => text.toUpperCase()); // { a: ["X"] }
6
+ */
7
+ export declare function mapStrings(node: unknown, map: (text: string, pointer: string) => unknown, skip?: (pointer: string) => boolean, pointer?: string): unknown;
package/dist/walk.js ADDED
@@ -0,0 +1,22 @@
1
+ // One tree walk for plain data: every text in a value, mapped, with its JSON
2
+ // Pointer. Folding and the view both rewrite texts this way.
3
+ /**
4
+ * A copy of `node` with each text replaced by what `map` gives for it. Subtrees whose
5
+ * pointer `skip` names are copied as they are.
6
+ * @example
7
+ * mapStrings({ a: ["x"] }, (text) => text.toUpperCase()); // { a: ["X"] }
8
+ */
9
+ export function mapStrings(node, map, skip = () => false, pointer = "") {
10
+ if (skip(pointer))
11
+ return node;
12
+ if (typeof node === "string")
13
+ return map(node, pointer);
14
+ if (Array.isArray(node))
15
+ return node.map((item, index) => mapStrings(item, map, skip, `${pointer}/${index}`));
16
+ if (node === null || typeof node !== "object")
17
+ return node;
18
+ return Object.fromEntries(Object.entries(node).map(([key, value]) => [
19
+ key,
20
+ mapStrings(value, map, skip, `${pointer}/${key.replaceAll("~", "~0").replaceAll("/", "~1")}`),
21
+ ]));
22
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingestron/pack-databricks",
3
- "version": "1.0.1",
3
+ "version": "2.0.0",
4
4
  "description": "Databricks methods for Ingestron: generates a bundle with a Lakeflow Spark Declarative Pipeline.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "IntentLabs <hello@ingestron.io>",
@@ -34,11 +34,11 @@
34
34
  "yaml": "2.9.1"
35
35
  },
36
36
  "peerDependencies": {
37
- "@ingestron/definitions": "^1.0.0"
37
+ "@ingestron/definitions": "^1.9.0"
38
38
  },
39
39
  "devDependencies": {
40
- "@ingestron/config": "^1.0.0",
41
- "@ingestron/definitions": "^1.0.0",
40
+ "@ingestron/config": "^1.5.0",
41
+ "@ingestron/definitions": "^1.9.0",
42
42
  "@microsoft/api-extractor": "7.59.4",
43
43
  "@types/node": "22.20.5",
44
44
  "ajv": "8.20.0",
@@ -2,11 +2,22 @@
2
2
  # Runs after each refresh: Lakeflow pipelines cannot set tags or column
3
3
  # comments, nor compare counts over all rows, so this task does.
4
4
  import json
5
+ import sys
5
6
 
6
7
  from pyspark.sql import SparkSession
7
8
 
8
9
  STATEMENTS = json.loads(r"""{{ statements }}""")
9
10
  CHECKS = json.loads(r"""{{ checks }}""")
11
+ # Values that differ between environments: the job passes each as a parameter
12
+ # ingestron.<name>=<value>, and ${ingestron.<name>} above stands for it.
13
+ VALUES = dict(argument.split("=", 1) for argument in sys.argv[1:] if "=" in argument)
14
+
15
+
16
+ def with_values(text):
17
+ """The text with each ${ingestron.<name>} replaced by its value."""
18
+ for name, value in VALUES.items():
19
+ text = text.replace("${" + name + "}", value)
20
+ return text
10
21
 
11
22
 
12
23
  def passes(operator, expected, measured):
@@ -30,7 +41,7 @@ def passes(operator, expected, measured):
30
41
 
31
42
  def measure(spark, check):
32
43
  """The rule's count, in rows or as a percentage of all rows."""
33
- row = spark.sql(check["query"]).first()
44
+ row = spark.sql(with_values(check["query"])).first()
34
45
  counted, total = row["counted"] or 0, row["total"] or 0
35
46
  if check["unit"] == "percent":
36
47
  return 0 if total == 0 else counted * 100 / total
@@ -40,13 +51,13 @@ def measure(spark, check):
40
51
  def main():
41
52
  spark = SparkSession.builder.getOrCreate()
42
53
  for statement in STATEMENTS:
43
- spark.sql(statement)
54
+ spark.sql(with_values(statement))
44
55
  failures = []
45
56
  for check in CHECKS:
46
57
  measured = measure(spark, check)
47
58
  if passes(check["operator"], check["value"], measured):
48
59
  continue
49
- message = f'{check["table"]}: {check["name"]} measured {measured}, expected {check["operator"]} {check["value"]}'
60
+ message = f'{with_values(check["table"])}: {check["name"]} measured {measured}, expected {check["operator"]} {check["value"]}'
50
61
  if check["action"] == "fail":
51
62
  failures.append(message)
52
63
  else: