@ingestron/pack-databricks 0.0.0-stage → 1.0.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.
Files changed (45) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +4 -0
  3. package/README.md +107 -2
  4. package/dist/apply-contract.d.ts +16 -0
  5. package/dist/apply-contract.js +99 -0
  6. package/dist/build.d.ts +7 -0
  7. package/dist/build.js +78 -0
  8. package/dist/bundle.d.ts +30 -0
  9. package/dist/bundle.js +136 -0
  10. package/dist/capabilities.d.ts +7 -0
  11. package/dist/capabilities.js +77 -0
  12. package/dist/connect-method.d.ts +7 -0
  13. package/dist/connect-method.js +88 -0
  14. package/dist/connect.d.ts +27 -0
  15. package/dist/connect.js +152 -0
  16. package/dist/connectors.d.ts +3 -0
  17. package/dist/connectors.js +55 -0
  18. package/dist/contract.d.ts +3 -0
  19. package/dist/contract.js +53 -0
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.js +2 -0
  22. package/dist/methods.d.ts +9 -0
  23. package/dist/methods.js +166 -0
  24. package/dist/pack.d.ts +8 -0
  25. package/dist/pack.js +72 -0
  26. package/dist/pyspark-method.d.ts +3 -0
  27. package/dist/pyspark-method.js +40 -0
  28. package/dist/python-data.d.ts +21 -0
  29. package/dist/python-data.js +49 -0
  30. package/dist/query.d.ts +8 -0
  31. package/dist/query.js +25 -0
  32. package/dist/request.d.ts +3 -0
  33. package/dist/request.js +46 -0
  34. package/dist/sql-data.d.ts +44 -0
  35. package/dist/sql-data.js +110 -0
  36. package/dist/sql.d.ts +21 -0
  37. package/dist/sql.js +80 -0
  38. package/package.json +68 -3
  39. package/templates/apply-contract.py.liquid +58 -0
  40. package/templates/change-data.sql.liquid +20 -0
  41. package/templates/materialised-view.py.liquid +28 -0
  42. package/templates/materialised-view.sql.liquid +4 -0
  43. package/templates/read-files.liquid +7 -0
  44. package/templates/streaming-table.sql.liquid +3 -0
  45. package/templates/table.liquid +17 -0
@@ -0,0 +1,166 @@
1
+ import { renderQuery } from "./query.js";
2
+ const clusterBy = {
3
+ description: "How the table is clustered: auto, none, or a list of columns.",
4
+ anyOf: [
5
+ {
6
+ enum: ["auto", "none"],
7
+ enumDescriptions: ["Databricks chooses the clustering columns.", "No clustering."],
8
+ },
9
+ { type: "array", minItems: 1, items: { type: "string" } },
10
+ ],
11
+ examples: [["customer_id", "ordered_at"]],
12
+ };
13
+ const properties = {
14
+ type: "object",
15
+ description: "Table properties (TBLPROPERTIES).",
16
+ additionalProperties: { type: "string" },
17
+ };
18
+ /** Reads files where they already are, with Auto Loader (`read_files`). */
19
+ export const files = {
20
+ action: "ingest",
21
+ description: "Reads files in cloud storage or a volume with Auto Loader.",
22
+ connectors: ["files"],
23
+ connectivity: ["public", "workspace"],
24
+ produces: "files",
25
+ defaults: {
26
+ format: "parquet",
27
+ schema_evolution: "addNewColumns",
28
+ schema_hints: true,
29
+ options: {},
30
+ },
31
+ settings: {
32
+ type: "object",
33
+ additionalProperties: false,
34
+ properties: {
35
+ format: {
36
+ enum: ["parquet", "csv", "json", "avro", "orc", "xml", "text"],
37
+ enumDescriptions: [
38
+ "Parquet files.",
39
+ "CSV text; set options such as header: true.",
40
+ "JSON, one object per line unless options say otherwise.",
41
+ "Avro files.",
42
+ "ORC files.",
43
+ "XML; set options such as rowTag.",
44
+ "Each line as one row.",
45
+ ],
46
+ description: "The files' format.",
47
+ },
48
+ schema_evolution: {
49
+ enum: [
50
+ "addNewColumns",
51
+ "addNewColumnsWithTypeWidening",
52
+ "rescue",
53
+ "failOnNewColumns",
54
+ "none",
55
+ ],
56
+ enumDescriptions: [
57
+ "Stop when files gain columns, then go on with the columns added.",
58
+ "As addNewColumns, and also widen column types, such as int to bigint.",
59
+ "Keep the columns as they are; values that do not fit go to _rescued_data.",
60
+ "Stop when files gain columns, until the table is changed.",
61
+ "Ignore new columns.",
62
+ ],
63
+ description: "What happens when files gain columns.",
64
+ },
65
+ schema_hints: {
66
+ type: "boolean",
67
+ description: "Pass the contract's column types as schemaHints.",
68
+ },
69
+ options: {
70
+ type: "object",
71
+ description: "Other read_files options, such as header: true.",
72
+ additionalProperties: { type: ["string", "number", "boolean"] },
73
+ },
74
+ },
75
+ },
76
+ check: (table) => typeof table.ingest?.connection.settings.path === "string"
77
+ ? []
78
+ : ['the connection needs "settings.path"'],
79
+ };
80
+ /** Table properties are the organisation's, such as change data feed: written in the standard. */
81
+ const storePlacement = { standard: ["properties"] };
82
+ /** A streaming table: each file is read once; optionally kept current with AUTO CDC. */
83
+ export const streamingTable = {
84
+ action: "store",
85
+ description: "A streaming table. mode append adds rows; upsert (SCD type 1) and history (SCD type 2) apply changes by key.",
86
+ accepts: ["files"],
87
+ produces: "table",
88
+ defaults: { mode: "append", cluster_by: "auto", properties: {}, changes_suffix: "_changes" },
89
+ placement: storePlacement,
90
+ settings: {
91
+ type: "object",
92
+ additionalProperties: false,
93
+ properties: {
94
+ mode: {
95
+ enum: ["append", "upsert", "history"],
96
+ enumDescriptions: [
97
+ "Add every row as it arrives.",
98
+ "Keep the latest row per key (SCD type 1), ordered by sequence_by.",
99
+ "Keep every version of a row with when it was valid (SCD type 2), ordered by sequence_by.",
100
+ ],
101
+ description: "How rows are kept.",
102
+ },
103
+ sequence_by: {
104
+ type: "string",
105
+ description: "Column that orders changes; needed for upsert and history.",
106
+ examples: ["updated_at"],
107
+ },
108
+ delete_when: {
109
+ type: "string",
110
+ description: "SQL condition for rows that are deletions, such as op = 'D'.",
111
+ examples: ["op = 'D'"],
112
+ },
113
+ except_columns: {
114
+ type: "array",
115
+ items: { type: "string" },
116
+ description: "Change columns not to keep, such as op.",
117
+ },
118
+ changes_suffix: {
119
+ type: "string",
120
+ pattern: "^_[a-z0-9_]+$",
121
+ description: "Suffix of the table that keeps changes as received.",
122
+ examples: ["_changes"],
123
+ },
124
+ cluster_by: clusterBy,
125
+ properties,
126
+ },
127
+ },
128
+ check: checkChangeData,
129
+ };
130
+ /** A materialised view, recomputed (incrementally where possible) on each update. */
131
+ export const materialisedView = {
132
+ action: "store",
133
+ description: "A materialised view. Expectations make every refresh a full one.",
134
+ accepts: ["files", "table", "query"],
135
+ produces: "table",
136
+ defaults: { cluster_by: "auto", properties: {} },
137
+ placement: storePlacement,
138
+ settings: {
139
+ type: "object",
140
+ additionalProperties: false,
141
+ properties: { cluster_by: clusterBy, properties },
142
+ },
143
+ };
144
+ /** The transform's own SQL, with `{{ tables.<input> }}` replaced by full names. */
145
+ export const sql = {
146
+ action: "transform",
147
+ description: "Runs the transform's SQL query.",
148
+ accepts: ["table"],
149
+ produces: "query",
150
+ settings: { type: "object", additionalProperties: false },
151
+ check: (table) => {
152
+ const result = renderQuery(table.transform?.code ?? "", table.transform?.inputs ?? {});
153
+ return "problem" in result ? [result.problem] : [];
154
+ },
155
+ };
156
+ function checkChangeData(table) {
157
+ const settings = table.store?.settings ?? {};
158
+ if (settings.mode === "append")
159
+ return [];
160
+ const problems = [];
161
+ if (table.contract.primaryKey.length === 0)
162
+ problems.push(`mode ${String(settings.mode)} needs primary key columns in the contract`);
163
+ if (typeof settings.sequence_by !== "string")
164
+ problems.push(`mode ${String(settings.mode)} needs "sequence_by"`);
165
+ return problems;
166
+ }
package/dist/pack.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ import type { Pack } from "@ingestron/definitions";
2
+ /**
3
+ * The Databricks pack, version 1. Name it in a standard as `pack: databricks@1`.
4
+ * @example
5
+ * const result = loadDefinitions("definitions", { pack: databricks, environment: "dev" });
6
+ * if (result.model) console.log(Object.keys(databricks.build(result.model)));
7
+ */
8
+ export declare const databricks: Pack;
package/dist/pack.js ADDED
@@ -0,0 +1,72 @@
1
+ // The pack: methods, smart defaults, rules and the build function.
2
+ import { contract } from "./contract.js";
3
+ import { connectors } from "./connectors.js";
4
+ import { build } from "./build.js";
5
+ import { request } from "./request.js";
6
+ import { capabilities, tagProblems } from "./capabilities.js";
7
+ import { lakeflowConnect } from "./connect-method.js";
8
+ import { connectGroups, sharedProblems } from "./connect.js";
9
+ import { files, materialisedView, sql, streamingTable } from "./methods.js";
10
+ import { pyspark } from "./pyspark-method.js";
11
+ /**
12
+ * The Databricks pack, version 1. Name it in a standard as `pack: databricks@1`.
13
+ * @example
14
+ * const result = loadDefinitions("definitions", { pack: databricks, environment: "dev" });
15
+ * if (result.model) console.log(Object.keys(databricks.build(result.model)));
16
+ */
17
+ export const databricks = {
18
+ id: "databricks",
19
+ version: 1,
20
+ defaults: {
21
+ naming: {
22
+ catalog: "{{ environment }}",
23
+ schema: "{{ layer }}",
24
+ table: "{{ table }}",
25
+ pipeline: "{{ project }}",
26
+ job: "{{ project }}_refresh",
27
+ },
28
+ layers: ["bronze", "silver", "gold"],
29
+ types: {
30
+ string: "STRING",
31
+ integer: "BIGINT",
32
+ number: "DOUBLE",
33
+ boolean: "BOOLEAN",
34
+ date: "DATE",
35
+ timestamp: "TIMESTAMP",
36
+ },
37
+ quality: { required: "warn", default: "warn" },
38
+ },
39
+ rules: {
40
+ ingest: {
41
+ default: "files",
42
+ when: [
43
+ { connector: "sqlserver", use: "lakeflow-connect" },
44
+ { connector: "salesforce", use: "lakeflow-connect" },
45
+ { connector: "kafka", use: "lakeflow-connect", with: { mode: "append" } },
46
+ ],
47
+ },
48
+ transform: { default: "sql" },
49
+ store: { default: "streaming-table", when: [{ input: "query", use: "materialised-view" }] },
50
+ },
51
+ methods: {
52
+ files,
53
+ "lakeflow-connect": lakeflowConnect,
54
+ sql,
55
+ pyspark,
56
+ "streaming-table": streamingTable,
57
+ "materialised-view": materialisedView,
58
+ },
59
+ connectors,
60
+ capabilities,
61
+ contract,
62
+ check(model) {
63
+ const problems = [];
64
+ if (!model.platform.compute.serverless)
65
+ problems.push("classic compute is not supported yet; leave compute.serverless at true");
66
+ if (model.tables.length === 0)
67
+ problems.push("there is nothing to generate: add a flow or a transform");
68
+ return [...problems, ...tagProblems(model), ...sharedProblems(connectGroups(model))];
69
+ },
70
+ build,
71
+ request,
72
+ };
@@ -0,0 +1,3 @@
1
+ import type { Method } from "@ingestron/definitions";
2
+ /** A PySpark transform: the code defines a function that gets a DataFrame per input. */
3
+ export declare const pyspark: Method;
@@ -0,0 +1,40 @@
1
+ /** A PySpark transform: the code defines a function that gets a DataFrame per input. */
2
+ export const pyspark = {
3
+ action: "transform",
4
+ description: "Calls a function in the transform's Python code with a dict of input DataFrames; it returns one DataFrame.",
5
+ accepts: ["table"],
6
+ produces: "query",
7
+ defaults: { function: "transform" },
8
+ settings: {
9
+ type: "object",
10
+ additionalProperties: false,
11
+ properties: {
12
+ function: {
13
+ type: "string",
14
+ pattern: "^[A-Za-z_][A-Za-z0-9_]*$",
15
+ description: "The function to call. Default: transform.",
16
+ examples: ["build_daily_sales"],
17
+ },
18
+ },
19
+ },
20
+ check: (table) => {
21
+ const code = table.transform?.code ?? "";
22
+ const name = String(table.transform?.settings.function);
23
+ const defined = new RegExp(`^def ${name}\\(`, "m").test(code);
24
+ return [
25
+ ...(defined ? [] : [`the code has no function "def ${name}(tables)"`]),
26
+ ...unknownInputs(code, Object.keys(table.transform?.inputs ?? {})),
27
+ ];
28
+ },
29
+ };
30
+ /**
31
+ * Each `tables["x"]` in the code whose `x` is not an input: the function gets
32
+ * a dict with one DataFrame per input, so reading another key fails.
33
+ */
34
+ function unknownInputs(code, inputs) {
35
+ const lines = code.split("\n").map((line) => line.replace(/#.*/, ""));
36
+ const read = [...lines.join("\n").matchAll(/\btables\[\s*["']([^"']+)["']\s*\]/g)].map((match) => match[1]);
37
+ return [...new Set(read)]
38
+ .filter((key) => !inputs.includes(key))
39
+ .map((key) => `the code reads tables["${key}"], which is not an input; the inputs are ${inputs.map((input) => `"${input}"`).join(", ") || "none"}`);
40
+ }
@@ -0,0 +1,21 @@
1
+ import { type ResolvedTable } from "@ingestron/definitions";
2
+ /** Everything materialised-view.py.liquid needs. */
3
+ export declare function pythonData(table: ResolvedTable, header: string): {
4
+ header: string;
5
+ code: string;
6
+ name: string;
7
+ comment: string;
8
+ cluster: string;
9
+ properties: string;
10
+ expectations: {
11
+ decorator: "expect_all" | "expect_all_or_drop" | "expect_all_or_fail";
12
+ checks: string;
13
+ count: number;
14
+ }[];
15
+ wrapper: string;
16
+ function: string;
17
+ inputs: {
18
+ name: string;
19
+ table: string;
20
+ }[];
21
+ };
@@ -0,0 +1,49 @@
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";
4
+ const decorators = {
5
+ warn: "expect_all",
6
+ drop: "expect_all_or_drop",
7
+ fail: "expect_all_or_fail",
8
+ };
9
+ /** Everything materialised-view.py.liquid needs. */
10
+ export function pythonData(table, header) {
11
+ const store = table.store?.settings ?? {};
12
+ const transform = table.transform;
13
+ const properties = (store.properties ?? {});
14
+ return {
15
+ header,
16
+ code: transform.code.trimEnd(),
17
+ name: JSON.stringify(fullNameText(table.fullName)),
18
+ comment: table.contract.description ? JSON.stringify(table.contract.description) : "",
19
+ cluster: clusterOf(store.cluster_by),
20
+ properties: Object.keys(properties).length > 0 ? pythonDict(Object.entries(properties)) : "",
21
+ expectations: ["warn", "drop", "fail"]
22
+ .map((action) => {
23
+ const checks = table.expectations.filter((item) => item.action === action);
24
+ return {
25
+ decorator: decorators[action],
26
+ checks: pythonDict(checks.map((item) => [item.name, item.check])),
27
+ count: checks.length,
28
+ };
29
+ })
30
+ .filter((group) => group.count > 0),
31
+ wrapper: `ingestron_${table.name}`,
32
+ function: String(transform.settings.function),
33
+ inputs: Object.entries(transform.inputs).map(([name, full]) => ({
34
+ name: JSON.stringify(name),
35
+ table: JSON.stringify(full),
36
+ })),
37
+ };
38
+ }
39
+ function clusterOf(value) {
40
+ if (value === "auto")
41
+ return "cluster_by_auto=True";
42
+ if (Array.isArray(value))
43
+ return `cluster_by=${JSON.stringify(value)}`;
44
+ return "";
45
+ }
46
+ /** `{"a": "b", …}`: a Python dict of strings, written the way Python code usually is. */
47
+ function pythonDict(entries) {
48
+ return `{${entries.map(([key, value]) => `${JSON.stringify(key)}: ${JSON.stringify(value)}`).join(", ")}}`;
49
+ }
@@ -0,0 +1,8 @@
1
+ /** The SQL with inputs replaced, or a problem to report. A trailing `;` is removed. */
2
+ export declare function renderQuery(code: string, inputs: Record<string, string>): {
3
+ sql: string;
4
+ } | {
5
+ problem: string;
6
+ };
7
+ /** `a.b.c` → `` `a`.`b`.`c` ``, so any valid name is safe in SQL. */
8
+ export declare function quoteName(fullName: string): string;
package/dist/query.js ADDED
@@ -0,0 +1,25 @@
1
+ // Renders a transform's SQL: `{{ tables.<input> }}` becomes the input's full name.
2
+ import { Liquid } from "liquidjs";
3
+ // Strict, so an input that the transform did not declare is an error.
4
+ const engine = new Liquid({ strictVariables: true, strictFilters: true });
5
+ /** The SQL with inputs replaced, or a problem to report. A trailing `;` is removed. */
6
+ export function renderQuery(code, inputs) {
7
+ const quoted = Object.fromEntries(Object.entries(inputs).map(([name, full]) => [name, quoteName(full)]));
8
+ try {
9
+ const sql = engine.parseAndRenderSync(code, { tables: quoted });
10
+ return { sql: sql.trim().replace(/;\s*$/, "") };
11
+ }
12
+ catch (cause) {
13
+ const reason = cause instanceof Error ? cause.message.split("\n")[0] : String(cause);
14
+ return {
15
+ problem: `the SQL cannot be rendered (${reason}); inputs are written {{ tables.<name> }} and must be listed in inputs`,
16
+ };
17
+ }
18
+ }
19
+ /** `a.b.c` → `` `a`.`b`.`c` ``, so any valid name is safe in SQL. */
20
+ export function quoteName(fullName) {
21
+ return fullName
22
+ .split(".")
23
+ .map((part) => `\`${part.replaceAll("`", "``")}\``)
24
+ .join(".");
25
+ }
@@ -0,0 +1,3 @@
1
+ import type { RequestSection, ResolvedModel } from "@ingestron/definitions";
2
+ /** The Databricks sections of the platform request. Nothing here is checked. */
3
+ export declare function request(model: ResolvedModel): RequestSection[];
@@ -0,0 +1,46 @@
1
+ /** The Databricks sections of the platform request. Nothing here is checked. */
2
+ export function request(model) {
3
+ return [
4
+ {
5
+ title: "Must exist before deploying",
6
+ intro: "Ingestron has not checked these; the generated files expect them.",
7
+ lines: prerequisites(model),
8
+ },
9
+ { title: "Schedules", lines: [schedule(model)] },
10
+ ];
11
+ }
12
+ function prerequisites(model) {
13
+ const { platform } = model;
14
+ const names = model.tables.map((table) => table.fullName);
15
+ const catalogs = unique(names.map((name) => name.catalog));
16
+ const schemas = platform.manage.schemas
17
+ ? []
18
+ : unique(names.map((name) => `${name.catalog}.${name.schema}`));
19
+ const folders = unique(model.tables.flatMap((table) => {
20
+ const folder = table.ingest?.settings.landing ?? table.ingest?.connection.settings.path;
21
+ return typeof folder === "string" ? [folder] : [];
22
+ }));
23
+ return [
24
+ ...catalogs.map((catalog) => `Catalog \`${catalog}\`, which the deploying identity can use.`),
25
+ ...schemas.map((schema) => `Schema \`${schema}\`, in which it can create tables and views.`),
26
+ ...folders.map((folder) => `Files at \`${folder}\`, which the pipeline can read.`),
27
+ platform.compute.serverless
28
+ ? "Serverless compute for pipelines and jobs, turned on in the workspace."
29
+ : "Classic compute that the pipelines may use.",
30
+ ...(platform.workspace.host
31
+ ? []
32
+ : [
33
+ "No workspace host is set, so `databricks bundle deploy` uses the workspace of the Databricks CLI profile that runs it.",
34
+ ]),
35
+ ];
36
+ }
37
+ function schedule(model) {
38
+ const { schedule } = model.platform;
39
+ if (!schedule)
40
+ return "No schedule: the pipelines run only when someone starts them.";
41
+ const state = schedule.paused ? "paused" : "runs";
42
+ return `Job \`${model.names.job}\`: \`${schedule.cron}\` (${schedule.timezone}), ${state}.`;
43
+ }
44
+ function unique(values) {
45
+ return [...new Set(values)];
46
+ }
@@ -0,0 +1,44 @@
1
+ import { type ResolvedTable } from "@ingestron/definitions";
2
+ /** The table definition shared by every SQL file. */
3
+ export interface TableData {
4
+ kind: "STREAMING TABLE" | "MATERIALIZED VIEW";
5
+ target: string;
6
+ constraints: {
7
+ name: string;
8
+ check: string;
9
+ on_violation: string;
10
+ }[];
11
+ comment: string;
12
+ cluster: string;
13
+ properties: {
14
+ key: string;
15
+ value: string;
16
+ }[];
17
+ }
18
+ /** A `read_files` call. */
19
+ export interface SourceData {
20
+ stream: boolean;
21
+ /** The select list: `*`, or with source columns renamed to their contract names. */
22
+ select: string;
23
+ path: string;
24
+ options: {
25
+ key: string;
26
+ value: string;
27
+ }[];
28
+ }
29
+ /** A SQL string literal. */
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;
33
+ /** The `read_files` call for a flow table's files. */
34
+ 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): {
37
+ changes: string;
38
+ flow: string;
39
+ keys: string;
40
+ sequence: string;
41
+ delete_when: string;
42
+ except_columns: string;
43
+ scd: number;
44
+ };
@@ -0,0 +1,110 @@
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
+ import { quoteName } from "./query.js";
6
+ const onViolation = { warn: "", drop: " ON VIOLATION DROP ROW", fail: " ON VIOLATION FAIL UPDATE" };
7
+ /** A SQL string literal. */
8
+ export function sqlString(text) {
9
+ return `'${text.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
10
+ }
11
+ /** The definition of the table itself. */
12
+ export function tableData(table, kind) {
13
+ const settings = table.store?.settings ?? {};
14
+ return {
15
+ kind,
16
+ target: quoteName(fullNameText(table.fullName)),
17
+ constraints: table.expectations.map((item) => ({
18
+ name: quoteName(item.name),
19
+ check: item.check,
20
+ on_violation: onViolation[item.action],
21
+ })),
22
+ comment: table.contract.description ? sqlString(table.contract.description) : "",
23
+ cluster: clusterOf(settings.cluster_by),
24
+ properties: Object.entries((settings.properties ?? {})).map(([key, value]) => ({ key: sqlString(key), value: sqlString(value) })),
25
+ };
26
+ }
27
+ function clusterOf(value) {
28
+ if (value === "auto")
29
+ return "AUTO";
30
+ if (Array.isArray(value))
31
+ return `(${value.map((column) => quoteName(String(column))).join(", ")})`;
32
+ return "";
33
+ }
34
+ /** The `read_files` call for a flow table's files. */
35
+ export function sourceData(table, stream) {
36
+ return {
37
+ stream,
38
+ select: selectOf(table),
39
+ path: sqlString(pathOf(table)),
40
+ options: optionsOf(table),
41
+ };
42
+ }
43
+ /** The columns read under another name at the source, unless the ingest step renamed them. */
44
+ function renamedColumns(table) {
45
+ if (table.ingest?.renamed)
46
+ return [];
47
+ return table.contract.columns.filter((column) => column.physicalName && column.physicalName !== column.name);
48
+ }
49
+ /** `*`, or `* EXCEPT (<source names>), <source> AS <name>, …` for renamed columns. */
50
+ function selectOf(table) {
51
+ const renamed = renamedColumns(table);
52
+ if (renamed.length === 0)
53
+ return "*";
54
+ const sources = renamed.map((column) => quoteName(column.physicalName));
55
+ const aliases = renamed.map((column) => `${quoteName(column.physicalName)} AS ${quoteName(column.name)}`);
56
+ return `* EXCEPT (${sources.join(", ")}), ${aliases.join(", ")}`;
57
+ }
58
+ /** The connection's `path` joined with the table's object. */
59
+ function pathOf(table) {
60
+ // Files copied by another pack (such as adf-copy) are read where it lands them.
61
+ const ingest = table.ingest;
62
+ const folder = ingest?.settings.landing ?? ingest?.connection.settings.path ?? "";
63
+ const base = String(folder).replace(/\/+$/, "");
64
+ const object = (table.ingest?.object ?? "").replace(/^\/+/, "");
65
+ return `${base}/${object}`;
66
+ }
67
+ /** Format, schema hints from the contract, schema evolution, then the user's own options. */
68
+ function optionsOf(table) {
69
+ const settings = table.ingest?.settings ?? {};
70
+ const options = [
71
+ { key: "format", value: sqlString(String(settings.format)) },
72
+ ];
73
+ // Hints name the columns as the files have them.
74
+ const source = new Set(renamedColumns(table));
75
+ const hints = table.contract.columns
76
+ .filter((column) => column.type)
77
+ .map((column) => {
78
+ const name = source.has(column) ? column.physicalName : column.name;
79
+ return `${quoteName(name)} ${column.type}`;
80
+ });
81
+ if (settings.schema_hints === true && hints.length > 0)
82
+ options.push({ key: "schemaHints", value: sqlString(hints.join(", ")) });
83
+ if (settings.schema_evolution)
84
+ options.push({
85
+ key: "schemaEvolutionMode",
86
+ value: sqlString(String(settings.schema_evolution)),
87
+ });
88
+ for (const [key, value] of Object.entries((settings.options ?? {}))) {
89
+ options.push({ key, value: typeof value === "string" ? sqlString(value) : String(value) });
90
+ }
91
+ return options;
92
+ }
93
+ /** The AUTO CDC parts of a change-data table. */
94
+ export function changeData(table) {
95
+ const settings = table.store?.settings ?? {};
96
+ const changes = {
97
+ ...table.fullName,
98
+ table: `${table.fullName.table}${String(settings.changes_suffix)}`,
99
+ };
100
+ const except = (settings.except_columns ?? []);
101
+ return {
102
+ changes: quoteName(fullNameText(changes)),
103
+ flow: quoteName(`${table.fullName.table}_cdc`),
104
+ keys: table.contract.primaryKey.map(quoteName).join(", "),
105
+ sequence: quoteName(String(settings.sequence_by)),
106
+ delete_when: typeof settings.delete_when === "string" ? settings.delete_when : "",
107
+ except_columns: except.map(quoteName).join(", "),
108
+ scd: settings.mode === "history" ? 2 : 1,
109
+ };
110
+ }
package/dist/sql.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ import type { ResolvedTable } from "@ingestron/definitions";
2
+ /** The templates folder, also used by the template parity check. */
3
+ export declare const templatesFolder: string;
4
+ /** Which template renders a table's file, its data, and the file's extension. */
5
+ export interface SqlFile {
6
+ template: string;
7
+ data: object;
8
+ extension: "sql" | "py";
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
+ /** Renders a file chosen by `sqlFile`. */
13
+ export declare function renderSql({ template, data }: SqlFile): string;
14
+ /** The after-refresh Python file, with its statements and checks as JSON. */
15
+ export declare function renderApply(data: {
16
+ header: string;
17
+ statements: string[];
18
+ checks: object[];
19
+ }): string;
20
+ /** Chooses the template by transform and store method, and builds its data. */
21
+ export declare function sqlFile(table: ResolvedTable, header: string): SqlFile;