@databricks/appkit 0.41.4 → 0.41.6
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/dist/appkit/package.js +1 -1
- package/dist/shared/src/schemas/metric-fqn.js +85 -0
- package/dist/shared/src/schemas/metric-fqn.js.map +1 -0
- package/dist/type-generator/cache.js +33 -1
- package/dist/type-generator/cache.js.map +1 -1
- package/dist/type-generator/errors.js +100 -0
- package/dist/type-generator/errors.js.map +1 -0
- package/dist/type-generator/index.js +190 -2
- package/dist/type-generator/index.js.map +1 -1
- package/dist/type-generator/mv-registry/config.js +131 -0
- package/dist/type-generator/mv-registry/config.js.map +1 -0
- package/dist/type-generator/mv-registry/describe.js +230 -0
- package/dist/type-generator/mv-registry/describe.js.map +1 -0
- package/dist/type-generator/mv-registry/metadata.js +40 -0
- package/dist/type-generator/mv-registry/metadata.js.map +1 -0
- package/dist/type-generator/mv-registry/render-types.js +118 -0
- package/dist/type-generator/mv-registry/render-types.js.map +1 -0
- package/dist/type-generator/mv-registry/sync.js +96 -0
- package/dist/type-generator/mv-registry/sync.js.map +1 -0
- package/dist/type-generator/query-registry.js +126 -88
- package/dist/type-generator/query-registry.js.map +1 -1
- package/dist/type-generator/statement-result.js +116 -0
- package/dist/type-generator/statement-result.js.map +1 -0
- package/dist/type-generator/types.js.map +1 -1
- package/dist/type-generator/vite-plugin.d.ts +12 -0
- package/dist/type-generator/vite-plugin.d.ts.map +1 -1
- package/dist/type-generator/vite-plugin.js +11 -5
- package/dist/type-generator/vite-plugin.js.map +1 -1
- package/docs/development/type-generation.md +4 -0
- package/docs/plugins/analytics.md +15 -0
- package/package.json +2 -1
- package/sbom.cdx.json +1 -1
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
//#region src/type-generator/mv-registry/render-types.ts
|
|
2
|
+
/**
|
|
3
|
+
* @todo unify with query-registry.ts
|
|
4
|
+
* Map a Databricks SQL type to a TypeScript primitive.
|
|
5
|
+
* Centralized here (not imported from query-registry) so this module
|
|
6
|
+
* stays self-contained.
|
|
7
|
+
*/
|
|
8
|
+
function tsTypeFor(sqlType) {
|
|
9
|
+
switch (sqlType.toUpperCase().replace(/\(.*\)$/, "").replace(/<.*>$/, "").split(" ")[0]) {
|
|
10
|
+
case "BOOLEAN": return "boolean";
|
|
11
|
+
case "TINYINT":
|
|
12
|
+
case "SMALLINT":
|
|
13
|
+
case "INT":
|
|
14
|
+
case "INTEGER":
|
|
15
|
+
case "BIGINT":
|
|
16
|
+
case "FLOAT":
|
|
17
|
+
case "DOUBLE":
|
|
18
|
+
case "DECIMAL":
|
|
19
|
+
case "NUMERIC": return "number";
|
|
20
|
+
default: return "string";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function renderMetricEntry(schema) {
|
|
24
|
+
if (schema.degraded) return renderDegradedMetricEntry(schema);
|
|
25
|
+
const indent = " ";
|
|
26
|
+
const colsBlock = (cols) => {
|
|
27
|
+
if (cols.length === 0) return "Record<string, never>";
|
|
28
|
+
return `{
|
|
29
|
+
${cols.map((col) => {
|
|
30
|
+
const grainComment = col.timeGrains?.length ? ` @timeGrain ${col.timeGrains.join("|")}` : "";
|
|
31
|
+
return `${indent}/** @sqlType ${col.type.replace(/\*\//g, "* /")}${grainComment} */
|
|
32
|
+
${indent}${JSON.stringify(col.name)}: ${tsTypeFor(col.type)}`;
|
|
33
|
+
}).join(";\n")};
|
|
34
|
+
}`;
|
|
35
|
+
};
|
|
36
|
+
const unionOf = (keys) => keys.length > 0 ? keys.join(" | ") : "never";
|
|
37
|
+
const measuresBlock = colsBlock(schema.measures);
|
|
38
|
+
const dimensionsBlock = colsBlock(schema.dimensions);
|
|
39
|
+
const measureUnion = unionOf(schema.measures.map((m) => JSON.stringify(m.name)));
|
|
40
|
+
const dimensionUnion = unionOf(schema.dimensions.map((d) => JSON.stringify(d.name)));
|
|
41
|
+
const timeGrainSet = /* @__PURE__ */ new Set();
|
|
42
|
+
for (const d of schema.dimensions) for (const g of d.timeGrains ?? []) timeGrainSet.add(g);
|
|
43
|
+
const timeGrainUnion = timeGrainSet.size > 0 ? [...timeGrainSet].sort().map((g) => JSON.stringify(g)).join(" | ") : "never";
|
|
44
|
+
const measureMetadata = renderMetadataMap(schema.measures, indent);
|
|
45
|
+
const dimensionMetadata = renderMetadataMap(schema.dimensions, indent, true);
|
|
46
|
+
return ` ${JSON.stringify(schema.key)}: {
|
|
47
|
+
key: ${JSON.stringify(schema.key)};
|
|
48
|
+
source: ${JSON.stringify(schema.source)};
|
|
49
|
+
lane: ${JSON.stringify(schema.lane)};
|
|
50
|
+
measures: ${measuresBlock};
|
|
51
|
+
dimensions: ${dimensionsBlock};
|
|
52
|
+
measureKeys: ${measureUnion};
|
|
53
|
+
dimensionKeys: ${dimensionUnion};
|
|
54
|
+
timeGrains: ${timeGrainUnion};
|
|
55
|
+
metadata: {
|
|
56
|
+
measures: ${measureMetadata};
|
|
57
|
+
dimensions: ${dimensionMetadata};
|
|
58
|
+
};
|
|
59
|
+
}`;
|
|
60
|
+
}
|
|
61
|
+
function renderDegradedMetricEntry(schema) {
|
|
62
|
+
return ` /** Degraded: schema unavailable at type-generation time — permissive types until a successful DESCRIBE refreshes them. */
|
|
63
|
+
${JSON.stringify(schema.key)}: {
|
|
64
|
+
key: ${JSON.stringify(schema.key)};
|
|
65
|
+
source: ${JSON.stringify(schema.source)};
|
|
66
|
+
lane: ${JSON.stringify(schema.lane)};
|
|
67
|
+
measures: Record<string, unknown>;
|
|
68
|
+
dimensions: Record<string, unknown>;
|
|
69
|
+
measureKeys: string;
|
|
70
|
+
dimensionKeys: string;
|
|
71
|
+
timeGrains: string;
|
|
72
|
+
metadata: {
|
|
73
|
+
measures: Record<string, never>;
|
|
74
|
+
dimensions: Record<string, never>;
|
|
75
|
+
};
|
|
76
|
+
}`;
|
|
77
|
+
}
|
|
78
|
+
function renderMetadataMap(cols, indent, includeTimeGrain = false) {
|
|
79
|
+
if (cols.length === 0) return "Record<string, never>";
|
|
80
|
+
return `{
|
|
81
|
+
${cols.map((col) => {
|
|
82
|
+
const fields = [`type: ${JSON.stringify(col.type)}`];
|
|
83
|
+
if (col.displayName) fields.push(`display_name: ${JSON.stringify(col.displayName)}`);
|
|
84
|
+
if (col.format) fields.push(`format: ${JSON.stringify(col.format)}`);
|
|
85
|
+
if (col.description) fields.push(`description: ${JSON.stringify(col.description)}`);
|
|
86
|
+
if (includeTimeGrain && col.timeGrains && col.timeGrains.length > 0) {
|
|
87
|
+
const grainTuple = col.timeGrains.map((g) => JSON.stringify(g)).join(", ");
|
|
88
|
+
fields.push(`time_grain: readonly [${grainTuple}]`);
|
|
89
|
+
}
|
|
90
|
+
const fieldsBlock = fields.map((f) => `${indent} ${f}`).join(";\n");
|
|
91
|
+
return `${indent}${JSON.stringify(col.name)}: {
|
|
92
|
+
${fieldsBlock};
|
|
93
|
+
${indent}}`;
|
|
94
|
+
}).join(";\n")};
|
|
95
|
+
}`;
|
|
96
|
+
}
|
|
97
|
+
function renderMetricRegistry(schemas) {
|
|
98
|
+
if (schemas.length === 0) return `declare module "@databricks/appkit-ui/react" {
|
|
99
|
+
interface MetricRegistry {}
|
|
100
|
+
}
|
|
101
|
+
`;
|
|
102
|
+
return `declare module "@databricks/appkit-ui/react" {
|
|
103
|
+
interface MetricRegistry {
|
|
104
|
+
${schemas.map(renderMetricEntry).join(";\n")};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
`;
|
|
108
|
+
}
|
|
109
|
+
function generateMetricTypeDeclarations(schemas) {
|
|
110
|
+
return `// Auto-generated by AppKit - DO NOT EDIT
|
|
111
|
+
// Generated by 'npx @databricks/appkit generate-types' or Vite plugin during build
|
|
112
|
+
import "@databricks/appkit-ui/react";
|
|
113
|
+
${renderMetricRegistry(schemas)}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
//#endregion
|
|
117
|
+
export { generateMetricTypeDeclarations };
|
|
118
|
+
//# sourceMappingURL=render-types.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"render-types.js","names":[],"sources":["../../../src/type-generator/mv-registry/render-types.ts"],"sourcesContent":["import type { MetricColumnMetadata, MetricSchema } from \"./types\";\n\n/**\n * @todo unify with query-registry.ts\n * Map a Databricks SQL type to a TypeScript primitive.\n * Centralized here (not imported from query-registry) so this module\n * stays self-contained.\n */\nfunction tsTypeFor(sqlType: string): string {\n const normalized = sqlType\n .toUpperCase()\n .replace(/\\(.*\\)$/, \"\")\n .replace(/<.*>$/, \"\")\n .split(\" \")[0];\n\n switch (normalized) {\n case \"BOOLEAN\":\n return \"boolean\";\n case \"TINYINT\":\n case \"SMALLINT\":\n case \"INT\":\n case \"INTEGER\":\n case \"BIGINT\":\n case \"FLOAT\":\n case \"DOUBLE\":\n case \"DECIMAL\":\n case \"NUMERIC\":\n return \"number\";\n default:\n return \"string\";\n }\n}\n\n// Render a MetricRegistry interface entry from a MetricSchema.\nfunction renderMetricEntry(schema: MetricSchema): string {\n if (schema.degraded) {\n return renderDegradedMetricEntry(schema);\n }\n const indent = \" \";\n const colsBlock = (cols: MetricColumnMetadata[]): string => {\n if (cols.length === 0) return \"Record<string, never>\";\n const fields = cols\n .map((col) => {\n const grainComment = col.timeGrains?.length\n ? ` @timeGrain ${col.timeGrains.join(\"|\")}`\n : \"\";\n return `${indent}/** @sqlType ${col.type.replace(/\\*\\//g, \"* /\")}${grainComment} */\n${indent}${JSON.stringify(col.name)}: ${tsTypeFor(col.type)}`;\n })\n .join(\";\\n\");\n return `{\n${fields};\n }`;\n };\n const unionOf = (keys: string[]): string =>\n keys.length > 0 ? keys.join(\" | \") : \"never\";\n\n const measuresBlock = colsBlock(schema.measures);\n const dimensionsBlock = colsBlock(schema.dimensions);\n const measureUnion = unionOf(\n schema.measures.map((m) => JSON.stringify(m.name)),\n );\n const dimensionUnion = unionOf(\n schema.dimensions.map((d) => JSON.stringify(d.name)),\n );\n\n const timeGrainSet = new Set<string>();\n for (const d of schema.dimensions) {\n for (const g of d.timeGrains ?? []) {\n timeGrainSet.add(g);\n }\n }\n const timeGrainUnion =\n timeGrainSet.size > 0\n ? [...timeGrainSet]\n .sort()\n .map((g) => JSON.stringify(g))\n .join(\" | \")\n : \"never\";\n\n const measureMetadata = renderMetadataMap(schema.measures, indent);\n const dimensionMetadata = renderMetadataMap(schema.dimensions, indent, true);\n\n return ` ${JSON.stringify(schema.key)}: {\n key: ${JSON.stringify(schema.key)};\n source: ${JSON.stringify(schema.source)};\n lane: ${JSON.stringify(schema.lane)};\n measures: ${measuresBlock};\n dimensions: ${dimensionsBlock};\n measureKeys: ${measureUnion};\n dimensionKeys: ${dimensionUnion};\n timeGrains: ${timeGrainUnion};\n metadata: {\n measures: ${measureMetadata};\n dimensions: ${dimensionMetadata};\n };\n }`;\n}\n\n// Render the permissive (\"degraded-open\") entry for a schema the warehouse could not describe.\nfunction renderDegradedMetricEntry(schema: MetricSchema): string {\n return ` /** Degraded: schema unavailable at type-generation time — permissive types until a successful DESCRIBE refreshes them. */\n ${JSON.stringify(schema.key)}: {\n key: ${JSON.stringify(schema.key)};\n source: ${JSON.stringify(schema.source)};\n lane: ${JSON.stringify(schema.lane)};\n measures: Record<string, unknown>;\n dimensions: Record<string, unknown>;\n measureKeys: string;\n dimensionKeys: string;\n timeGrains: string;\n metadata: {\n measures: Record<string, never>;\n dimensions: Record<string, never>;\n };\n }`;\n}\n\n// Render the type-level shape of a column's semantic-metadata map\n// for the `metadata` field of a MetricRegistry entry.\nfunction renderMetadataMap(\n cols: MetricColumnMetadata[],\n indent: string,\n includeTimeGrain = false,\n): string {\n if (cols.length === 0) return \"Record<string, never>\";\n\n const inner = cols\n .map((col) => {\n const fields: string[] = [`type: ${JSON.stringify(col.type)}`];\n if (col.displayName) {\n fields.push(`display_name: ${JSON.stringify(col.displayName)}`);\n }\n if (col.format) {\n fields.push(`format: ${JSON.stringify(col.format)}`);\n }\n if (col.description) {\n fields.push(`description: ${JSON.stringify(col.description)}`);\n }\n if (includeTimeGrain && col.timeGrains && col.timeGrains.length > 0) {\n const grainTuple = col.timeGrains\n .map((g) => JSON.stringify(g))\n .join(\", \");\n fields.push(`time_grain: readonly [${grainTuple}]`);\n }\n const fieldsBlock = fields.map((f) => `${indent} ${f}`).join(\";\\n\");\n return `${indent}${JSON.stringify(col.name)}: {\n${fieldsBlock};\n${indent}}`;\n })\n .join(\";\\n\");\n\n return `{\n${inner};\n }`;\n}\n\n// Render the augmentation block for the appkit-ui MetricRegistry interface.\nfunction renderMetricRegistry(schemas: MetricSchema[]): string {\n if (schemas.length === 0) {\n return `declare module \"@databricks/appkit-ui/react\" {\n interface MetricRegistry {}\n}\n`;\n }\n const entries = schemas.map(renderMetricEntry).join(\";\\n\");\n return `declare module \"@databricks/appkit-ui/react\" {\n interface MetricRegistry {\n${entries};\n }\n}\n`;\n}\n\n// Build the full metric.d.ts file from a list of metric schemas.\nexport function generateMetricTypeDeclarations(\n schemas: MetricSchema[],\n): string {\n return `// Auto-generated by AppKit - DO NOT EDIT\n// Generated by 'npx @databricks/appkit generate-types' or Vite plugin during build\nimport \"@databricks/appkit-ui/react\";\n${renderMetricRegistry(schemas)}`;\n}\n"],"mappings":";;;;;;;AAQA,SAAS,UAAU,SAAyB;AAO1C,SANmB,QAChB,aAAa,CACb,QAAQ,WAAW,GAAG,CACtB,QAAQ,SAAS,GAAG,CACpB,MAAM,IAAI,CAAC,IAEd;EACE,KAAK,UACH,QAAO;EACT,KAAK;EACL,KAAK;EACL,KAAK;EACL,KAAK;EACL,KAAK;EACL,KAAK;EACL,KAAK;EACL,KAAK;EACL,KAAK,UACH,QAAO;EACT,QACE,QAAO;;;AAKb,SAAS,kBAAkB,QAA8B;AACvD,KAAI,OAAO,SACT,QAAO,0BAA0B,OAAO;CAE1C,MAAM,SAAS;CACf,MAAM,aAAa,SAAyC;AAC1D,MAAI,KAAK,WAAW,EAAG,QAAO;AAU9B,SAAO;EATQ,KACZ,KAAK,QAAQ;GACZ,MAAM,eAAe,IAAI,YAAY,SACjC,eAAe,IAAI,WAAW,KAAK,IAAI,KACvC;AACJ,UAAO,GAAG,OAAO,eAAe,IAAI,KAAK,QAAQ,SAAS,MAAM,GAAG,aAAa;EACtF,SAAS,KAAK,UAAU,IAAI,KAAK,CAAC,IAAI,UAAU,IAAI,KAAK;IACnD,CACD,KAAK,MAAM,CAET;;;CAGP,MAAM,WAAW,SACf,KAAK,SAAS,IAAI,KAAK,KAAK,MAAM,GAAG;CAEvC,MAAM,gBAAgB,UAAU,OAAO,SAAS;CAChD,MAAM,kBAAkB,UAAU,OAAO,WAAW;CACpD,MAAM,eAAe,QACnB,OAAO,SAAS,KAAK,MAAM,KAAK,UAAU,EAAE,KAAK,CAAC,CACnD;CACD,MAAM,iBAAiB,QACrB,OAAO,WAAW,KAAK,MAAM,KAAK,UAAU,EAAE,KAAK,CAAC,CACrD;CAED,MAAM,+BAAe,IAAI,KAAa;AACtC,MAAK,MAAM,KAAK,OAAO,WACrB,MAAK,MAAM,KAAK,EAAE,cAAc,EAAE,CAChC,cAAa,IAAI,EAAE;CAGvB,MAAM,iBACJ,aAAa,OAAO,IAChB,CAAC,GAAG,aAAa,CACd,MAAM,CACN,KAAK,MAAM,KAAK,UAAU,EAAE,CAAC,CAC7B,KAAK,MAAM,GACd;CAEN,MAAM,kBAAkB,kBAAkB,OAAO,UAAU,OAAO;CAClE,MAAM,oBAAoB,kBAAkB,OAAO,YAAY,QAAQ,KAAK;AAE5E,QAAO,OAAO,KAAK,UAAU,OAAO,IAAI,CAAC;aAC9B,KAAK,UAAU,OAAO,IAAI,CAAC;gBACxB,KAAK,UAAU,OAAO,OAAO,CAAC;cAChC,KAAK,UAAU,OAAO,KAAK,CAAC;kBACxB,cAAc;oBACZ,gBAAgB;qBACf,aAAa;uBACX,eAAe;oBAClB,eAAe;;oBAEf,gBAAgB;sBACd,kBAAkB;;;;AAMxC,SAAS,0BAA0B,QAA8B;AAC/D,QAAO;MACH,KAAK,UAAU,OAAO,IAAI,CAAC;aACpB,KAAK,UAAU,OAAO,IAAI,CAAC;gBACxB,KAAK,UAAU,OAAO,OAAO,CAAC;cAChC,KAAK,UAAU,OAAO,KAAK,CAAC;;;;;;;;;;;;AAe1C,SAAS,kBACP,MACA,QACA,mBAAmB,OACX;AACR,KAAI,KAAK,WAAW,EAAG,QAAO;AA2B9B,QAAO;EAzBO,KACX,KAAK,QAAQ;EACZ,MAAM,SAAmB,CAAC,SAAS,KAAK,UAAU,IAAI,KAAK,GAAG;AAC9D,MAAI,IAAI,YACN,QAAO,KAAK,iBAAiB,KAAK,UAAU,IAAI,YAAY,GAAG;AAEjE,MAAI,IAAI,OACN,QAAO,KAAK,WAAW,KAAK,UAAU,IAAI,OAAO,GAAG;AAEtD,MAAI,IAAI,YACN,QAAO,KAAK,gBAAgB,KAAK,UAAU,IAAI,YAAY,GAAG;AAEhE,MAAI,oBAAoB,IAAI,cAAc,IAAI,WAAW,SAAS,GAAG;GACnE,MAAM,aAAa,IAAI,WACpB,KAAK,MAAM,KAAK,UAAU,EAAE,CAAC,CAC7B,KAAK,KAAK;AACb,UAAO,KAAK,yBAAyB,WAAW,GAAG;;EAErD,MAAM,cAAc,OAAO,KAAK,MAAM,GAAG,OAAO,IAAI,IAAI,CAAC,KAAK,MAAM;AACpE,SAAO,GAAG,SAAS,KAAK,UAAU,IAAI,KAAK,CAAC;EAChD,YAAY;EACZ,OAAO;GACH,CACD,KAAK,MAAM,CAGR;;;AAKR,SAAS,qBAAqB,SAAiC;AAC7D,KAAI,QAAQ,WAAW,EACrB,QAAO;;;;AAMT,QAAO;;EADS,QAAQ,IAAI,kBAAkB,CAAC,KAAK,MAAM,CAGlD;;;;;AAOV,SAAgB,+BACd,SACQ;AACR,QAAO;;;EAGP,qBAAqB,QAAQ"}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { getErrorDiagnostic, isConnectivityError } from "../errors.js";
|
|
2
|
+
import { extractMetricColumns, parseDescribeTableExtendedJson } from "./describe.js";
|
|
3
|
+
|
|
4
|
+
//#region src/type-generator/mv-registry/sync.ts
|
|
5
|
+
/**
|
|
6
|
+
* Build the degraded schema emitted when an entry's columns are not
|
|
7
|
+
* available — same key/source/lane as a real schema, with empty
|
|
8
|
+
* measure/dimension allowlists and `degraded: true`.
|
|
9
|
+
*/
|
|
10
|
+
function emptyMetricSchema(entry) {
|
|
11
|
+
return {
|
|
12
|
+
key: entry.key,
|
|
13
|
+
source: entry.source,
|
|
14
|
+
lane: entry.lane,
|
|
15
|
+
measures: [],
|
|
16
|
+
dimensions: [],
|
|
17
|
+
degraded: true
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
const MV_DESCRIBE_CONCURRENCY = 10;
|
|
21
|
+
/**
|
|
22
|
+
* Run schema synchronization for every entry in `metric-views.json`.
|
|
23
|
+
*/
|
|
24
|
+
async function syncMetrics(resolution, fetcher) {
|
|
25
|
+
const { entries } = resolution;
|
|
26
|
+
const schemas = new Array(entries.length);
|
|
27
|
+
const failureSlots = new Array(entries.length);
|
|
28
|
+
const failedOutcome = (index, entry, reason, transient) => ({
|
|
29
|
+
index,
|
|
30
|
+
schema: emptyMetricSchema(entry),
|
|
31
|
+
failure: {
|
|
32
|
+
key: entry.key,
|
|
33
|
+
source: entry.source,
|
|
34
|
+
reason,
|
|
35
|
+
transient
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
const describeOne = async (entry, index) => {
|
|
39
|
+
let response;
|
|
40
|
+
try {
|
|
41
|
+
response = await fetcher(entry.source);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
return failedOutcome(index, entry, `DESCRIBE TABLE EXTENDED failed: ${getErrorDiagnostic(err)}`, isConnectivityError(err));
|
|
44
|
+
}
|
|
45
|
+
const state = response.status?.state;
|
|
46
|
+
if (state !== "SUCCEEDED" && state !== "FAILED") return {
|
|
47
|
+
index,
|
|
48
|
+
schema: emptyMetricSchema(entry)
|
|
49
|
+
};
|
|
50
|
+
let columns;
|
|
51
|
+
try {
|
|
52
|
+
columns = extractMetricColumns(parseDescribeTableExtendedJson(response));
|
|
53
|
+
} catch (err) {
|
|
54
|
+
return failedOutcome(index, entry, `Failed to extract columns from DESCRIBE response: ${err.message}`, false);
|
|
55
|
+
}
|
|
56
|
+
if (columns.length === 0) return failedOutcome(index, entry, "DESCRIBE response yielded zero columns — check the response shape (top-level `columns` array or `schema.fields`).", false);
|
|
57
|
+
const measures = columns.filter((c) => c.isMeasure);
|
|
58
|
+
const dimensions = columns.filter((c) => !c.isMeasure);
|
|
59
|
+
return {
|
|
60
|
+
index,
|
|
61
|
+
schema: {
|
|
62
|
+
key: entry.key,
|
|
63
|
+
source: entry.source,
|
|
64
|
+
lane: entry.lane,
|
|
65
|
+
measures,
|
|
66
|
+
dimensions
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
for (let offset = 0; offset < entries.length; offset += MV_DESCRIBE_CONCURRENCY) {
|
|
71
|
+
const slice = entries.slice(offset, offset + MV_DESCRIBE_CONCURRENCY);
|
|
72
|
+
const settled = await Promise.allSettled(slice.map((entry, i) => describeOne(entry, offset + i)));
|
|
73
|
+
for (let i = 0; i < settled.length; i++) {
|
|
74
|
+
const result = settled[i];
|
|
75
|
+
if (result.status === "fulfilled") {
|
|
76
|
+
const { index, schema, failure } = result.value;
|
|
77
|
+
schemas[index] = schema;
|
|
78
|
+
if (failure) failureSlots[index] = failure;
|
|
79
|
+
} else {
|
|
80
|
+
const index = offset + i;
|
|
81
|
+
const entry = entries[index];
|
|
82
|
+
const { schema, failure } = failedOutcome(index, entry, `DESCRIBE TABLE EXTENDED failed: ${getErrorDiagnostic(result.reason)}`, isConnectivityError(result.reason));
|
|
83
|
+
schemas[index] = schema;
|
|
84
|
+
failureSlots[index] = failure;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
schemas,
|
|
90
|
+
failures: failureSlots.filter((failure) => failure !== void 0)
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
//#endregion
|
|
95
|
+
export { emptyMetricSchema, syncMetrics };
|
|
96
|
+
//# sourceMappingURL=sync.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sync.js","names":[],"sources":["../../../src/type-generator/mv-registry/sync.ts"],"sourcesContent":["import { getErrorDiagnostic, isConnectivityError } from \"../errors\";\nimport type { DatabricksStatementExecutionResponse } from \"../types\";\nimport {\n extractMetricColumns,\n parseDescribeTableExtendedJson,\n} from \"./describe\";\nimport type {\n DescribeFetcher,\n MetricColumnMetadata,\n MetricConfigResolution,\n MetricSchema,\n MetricSyncFailure,\n MetricSyncResult,\n ResolvedMetricEntry,\n} from \"./types\";\n\n/**\n * Build the degraded schema emitted when an entry's columns are not\n * available — same key/source/lane as a real schema, with empty\n * measure/dimension allowlists and `degraded: true`.\n */\nexport function emptyMetricSchema(\n entry: Pick<MetricSchema, \"key\" | \"source\" | \"lane\">,\n): MetricSchema {\n return {\n key: entry.key,\n source: entry.source,\n lane: entry.lane,\n measures: [],\n dimensions: [],\n degraded: true,\n };\n}\n\n// Maximum number of in-flight DESCRIBE statements per syncMetrics pass.\nconst MV_DESCRIBE_CONCURRENCY = 10;\n\n// Outcome of describing a single metric entry.\ninterface MetricDescribeOutcome {\n index: number;\n schema: MetricSchema;\n failure?: MetricSyncFailure;\n}\n\n/**\n * Run schema synchronization for every entry in `metric-views.json`.\n */\nexport async function syncMetrics(\n resolution: MetricConfigResolution,\n fetcher: DescribeFetcher,\n): Promise<MetricSyncResult> {\n const { entries } = resolution;\n const schemas = new Array<MetricSchema>(entries.length);\n const failureSlots = new Array<MetricSyncFailure | undefined>(entries.length);\n\n const failedOutcome = (\n index: number,\n entry: ResolvedMetricEntry,\n reason: string,\n transient: boolean,\n ): MetricDescribeOutcome => ({\n index,\n schema: emptyMetricSchema(entry),\n failure: { key: entry.key, source: entry.source, reason, transient },\n });\n\n const describeOne = async (\n entry: ResolvedMetricEntry,\n index: number,\n ): Promise<MetricDescribeOutcome> => {\n let response: DatabricksStatementExecutionResponse;\n try {\n response = await fetcher(entry.source);\n } catch (err) {\n const reason = `DESCRIBE TABLE EXTENDED failed: ${getErrorDiagnostic(err)}`;\n // Connectivity blips self-converge (retry next pass); auth, a bad\n // warehouse id, a truncated / multi-chunk result, or a malformed request\n // are deterministic and must surface — the same split the query path makes.\n return failedOutcome(index, entry, reason, isConnectivityError(err));\n }\n\n const state = response.status?.state;\n if (state !== \"SUCCEEDED\" && state !== \"FAILED\") {\n return { index, schema: emptyMetricSchema(entry) };\n }\n\n let columns: MetricColumnMetadata[];\n try {\n const parsed = parseDescribeTableExtendedJson(response);\n columns = extractMetricColumns(parsed);\n } catch (err) {\n const reason = `Failed to extract columns from DESCRIBE response: ${(err as Error).message}`;\n return failedOutcome(index, entry, reason, false);\n }\n\n if (columns.length === 0) {\n const reason =\n \"DESCRIBE response yielded zero columns — check the response shape (top-level `columns` array or `schema.fields`).\";\n return failedOutcome(index, entry, reason, false);\n }\n\n const measures = columns.filter((c) => c.isMeasure);\n const dimensions = columns.filter((c) => !c.isMeasure);\n\n return {\n index,\n schema: {\n key: entry.key,\n source: entry.source,\n lane: entry.lane,\n measures,\n dimensions,\n },\n };\n };\n\n for (\n let offset = 0;\n offset < entries.length;\n offset += MV_DESCRIBE_CONCURRENCY\n ) {\n const slice = entries.slice(offset, offset + MV_DESCRIBE_CONCURRENCY);\n const settled = await Promise.allSettled(\n slice.map((entry, i) => describeOne(entry, offset + i)),\n );\n\n for (let i = 0; i < settled.length; i++) {\n const result = settled[i];\n if (result.status === \"fulfilled\") {\n const { index, schema, failure } = result.value;\n schemas[index] = schema;\n if (failure) {\n failureSlots[index] = failure;\n }\n } else {\n const index = offset + i;\n const entry = entries[index];\n const { schema, failure } = failedOutcome(\n index,\n entry,\n `DESCRIBE TABLE EXTENDED failed: ${getErrorDiagnostic(result.reason)}`,\n isConnectivityError(result.reason),\n );\n schemas[index] = schema;\n failureSlots[index] = failure;\n }\n }\n }\n\n const failures = failureSlots.filter(\n (failure): failure is MetricSyncFailure => failure !== undefined,\n );\n\n return { schemas, failures };\n}\n"],"mappings":";;;;;;;;;AAqBA,SAAgB,kBACd,OACc;AACd,QAAO;EACL,KAAK,MAAM;EACX,QAAQ,MAAM;EACd,MAAM,MAAM;EACZ,UAAU,EAAE;EACZ,YAAY,EAAE;EACd,UAAU;EACX;;AAIH,MAAM,0BAA0B;;;;AAYhC,eAAsB,YACpB,YACA,SAC2B;CAC3B,MAAM,EAAE,YAAY;CACpB,MAAM,UAAU,IAAI,MAAoB,QAAQ,OAAO;CACvD,MAAM,eAAe,IAAI,MAAqC,QAAQ,OAAO;CAE7E,MAAM,iBACJ,OACA,OACA,QACA,eAC2B;EAC3B;EACA,QAAQ,kBAAkB,MAAM;EAChC,SAAS;GAAE,KAAK,MAAM;GAAK,QAAQ,MAAM;GAAQ;GAAQ;GAAW;EACrE;CAED,MAAM,cAAc,OAClB,OACA,UACmC;EACnC,IAAI;AACJ,MAAI;AACF,cAAW,MAAM,QAAQ,MAAM,OAAO;WAC/B,KAAK;AAKZ,UAAO,cAAc,OAAO,OAJb,mCAAmC,mBAAmB,IAAI,IAI9B,oBAAoB,IAAI,CAAC;;EAGtE,MAAM,QAAQ,SAAS,QAAQ;AAC/B,MAAI,UAAU,eAAe,UAAU,SACrC,QAAO;GAAE;GAAO,QAAQ,kBAAkB,MAAM;GAAE;EAGpD,IAAI;AACJ,MAAI;AAEF,aAAU,qBADK,+BAA+B,SAAS,CACjB;WAC/B,KAAK;AAEZ,UAAO,cAAc,OAAO,OADb,qDAAsD,IAAc,WACxC,MAAM;;AAGnD,MAAI,QAAQ,WAAW,EAGrB,QAAO,cAAc,OAAO,OAD1B,qHACyC,MAAM;EAGnD,MAAM,WAAW,QAAQ,QAAQ,MAAM,EAAE,UAAU;EACnD,MAAM,aAAa,QAAQ,QAAQ,MAAM,CAAC,EAAE,UAAU;AAEtD,SAAO;GACL;GACA,QAAQ;IACN,KAAK,MAAM;IACX,QAAQ,MAAM;IACd,MAAM,MAAM;IACZ;IACA;IACD;GACF;;AAGH,MACE,IAAI,SAAS,GACb,SAAS,QAAQ,QACjB,UAAU,yBACV;EACA,MAAM,QAAQ,QAAQ,MAAM,QAAQ,SAAS,wBAAwB;EACrE,MAAM,UAAU,MAAM,QAAQ,WAC5B,MAAM,KAAK,OAAO,MAAM,YAAY,OAAO,SAAS,EAAE,CAAC,CACxD;AAED,OAAK,IAAI,IAAI,GAAG,IAAI,QAAQ,QAAQ,KAAK;GACvC,MAAM,SAAS,QAAQ;AACvB,OAAI,OAAO,WAAW,aAAa;IACjC,MAAM,EAAE,OAAO,QAAQ,YAAY,OAAO;AAC1C,YAAQ,SAAS;AACjB,QAAI,QACF,cAAa,SAAS;UAEnB;IACL,MAAM,QAAQ,SAAS;IACvB,MAAM,QAAQ,QAAQ;IACtB,MAAM,EAAE,QAAQ,YAAY,cAC1B,OACA,OACA,mCAAmC,mBAAmB,OAAO,OAAO,IACpE,oBAAoB,OAAO,OAAO,CACnC;AACD,YAAQ,SAAS;AACjB,iBAAa,SAAS;;;;AAS5B,QAAO;EAAE;EAAS,UAJD,aAAa,QAC3B,YAA0C,YAAY,OACxD;EAE2B"}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { createLogger } from "../logging/logger.js";
|
|
2
2
|
import { CACHE_VERSION, hashSQL, loadCache, saveCache } from "./cache.js";
|
|
3
|
+
import { getErrorDiagnostic, isConnectivityError } from "./errors.js";
|
|
4
|
+
import { describeAdaptive } from "./statement-result.js";
|
|
3
5
|
import { decidePreflight } from "./preflight.js";
|
|
4
6
|
import { Spinner } from "./spinner.js";
|
|
5
7
|
import { sqlTypeToHelper, sqlTypeToMarker } from "./types.js";
|
|
@@ -66,83 +68,6 @@ function parseError(raw) {
|
|
|
66
68
|
} catch {}
|
|
67
69
|
return { message: raw };
|
|
68
70
|
}
|
|
69
|
-
function isObject(value) {
|
|
70
|
-
return typeof value === "object" && value !== null;
|
|
71
|
-
}
|
|
72
|
-
function getErrorMessage(error) {
|
|
73
|
-
if (error instanceof Error) return error.message;
|
|
74
|
-
if (isObject(error) && typeof error.message === "string") return error.message;
|
|
75
|
-
return String(error);
|
|
76
|
-
}
|
|
77
|
-
function getErrorDiagnostic(error) {
|
|
78
|
-
const seen = /* @__PURE__ */ new Set();
|
|
79
|
-
const messages = [];
|
|
80
|
-
const stack = [error];
|
|
81
|
-
while (stack.length > 0) {
|
|
82
|
-
const current = stack.pop();
|
|
83
|
-
if (current === void 0 || seen.has(current)) continue;
|
|
84
|
-
seen.add(current);
|
|
85
|
-
const message = getErrorMessage(current);
|
|
86
|
-
if (message && message !== "[object Object]" && !messages.includes(message)) messages.push(message);
|
|
87
|
-
const code = getErrorCode(current);
|
|
88
|
-
if (code && !messages.includes(code)) messages.push(code);
|
|
89
|
-
stack.push(...getErrorChildren(current));
|
|
90
|
-
}
|
|
91
|
-
return messages.length > 0 ? messages.join(": ") : getErrorMessage(error);
|
|
92
|
-
}
|
|
93
|
-
function getErrorCode(error) {
|
|
94
|
-
if (!isObject(error)) return void 0;
|
|
95
|
-
const code = error.code ?? error.errno;
|
|
96
|
-
return typeof code === "string" ? code : void 0;
|
|
97
|
-
}
|
|
98
|
-
function getErrorStatus(error) {
|
|
99
|
-
if (!isObject(error)) return void 0;
|
|
100
|
-
const direct = error.status ?? error.statusCode;
|
|
101
|
-
if (typeof direct === "number") return direct;
|
|
102
|
-
if (isObject(error.response) && typeof error.response.status === "number") return error.response.status;
|
|
103
|
-
}
|
|
104
|
-
function getErrorChildren(error) {
|
|
105
|
-
if (!isObject(error)) return [];
|
|
106
|
-
const children = [];
|
|
107
|
-
if ("cause" in error) children.push(error.cause);
|
|
108
|
-
if (error instanceof AggregateError) children.push(...error.errors);
|
|
109
|
-
return children;
|
|
110
|
-
}
|
|
111
|
-
const CONNECTIVITY_ERROR_CODES = new Set([
|
|
112
|
-
"ECONNREFUSED",
|
|
113
|
-
"ECONNRESET",
|
|
114
|
-
"ENOTFOUND",
|
|
115
|
-
"ETIMEDOUT",
|
|
116
|
-
"EAI_AGAIN",
|
|
117
|
-
"EAI_NODATA",
|
|
118
|
-
"EAI_NONAME",
|
|
119
|
-
"EHOSTUNREACH",
|
|
120
|
-
"ENETUNREACH",
|
|
121
|
-
"CERT_HAS_EXPIRED",
|
|
122
|
-
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
|
123
|
-
"ERR_TLS_CERT_ALTNAME_INVALID",
|
|
124
|
-
"SELF_SIGNED_CERT_IN_CHAIN",
|
|
125
|
-
"UNABLE_TO_VERIFY_LEAF_SIGNATURE"
|
|
126
|
-
]);
|
|
127
|
-
function isConnectivityMessage(message) {
|
|
128
|
-
return /\bconnection (?:refused|reset|timed out)\b/i.test(message) || /\bsocket hang up\b/i.test(message) || /\bnetwork error\b/i.test(message) || /\bcan'?t connect to\b/i.test(message) || /\bcertificate has expired\b/i.test(message) || /\bunable to verify the first certificate\b/i.test(message) || /\bupstream connect error or disconnect\/reset before headers\b/i.test(message);
|
|
129
|
-
}
|
|
130
|
-
function isConnectivityError(error) {
|
|
131
|
-
const seen = /* @__PURE__ */ new Set();
|
|
132
|
-
const stack = [error];
|
|
133
|
-
while (stack.length > 0) {
|
|
134
|
-
const current = stack.pop();
|
|
135
|
-
if (current === void 0 || seen.has(current)) continue;
|
|
136
|
-
seen.add(current);
|
|
137
|
-
const code = getErrorCode(current);
|
|
138
|
-
if (code && (CONNECTIVITY_ERROR_CODES.has(code) || code.startsWith("UND_ERR_"))) return true;
|
|
139
|
-
const status = getErrorStatus(current);
|
|
140
|
-
if (status === 502 || status === 503 || status === 504) return true;
|
|
141
|
-
if (isConnectivityMessage(getErrorMessage(current))) return true;
|
|
142
|
-
stack.push(...getErrorChildren(current));
|
|
143
|
-
}
|
|
144
|
-
return false;
|
|
145
|
-
}
|
|
146
71
|
/**
|
|
147
72
|
* Extract parameters from a SQL query
|
|
148
73
|
* @param sql - the SQL query to extract parameters from
|
|
@@ -180,7 +105,7 @@ function convertToQueryType(result, sql, queryName) {
|
|
|
180
105
|
const resultFields = columns.map((column) => {
|
|
181
106
|
const mappedType = typeMap[normalizeTypeName(column.type_name)] || "unknown";
|
|
182
107
|
const name = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(column.name) ? column.name : `"${column.name}"`;
|
|
183
|
-
return `${column.comment ? `/** ${column.comment} */\n ` : `/** @sqlType ${column.type_name} */\n `}${name}: ${mappedType}`;
|
|
108
|
+
return `${column.comment ? `/** ${column.comment.replace(/\*\//g, "* /")} */\n ` : `/** @sqlType ${column.type_name.replace(/\*\//g, "* /")} */\n `}${name}: ${mappedType}`;
|
|
184
109
|
});
|
|
185
110
|
const hasResults = resultFields.length > 0;
|
|
186
111
|
return {
|
|
@@ -215,9 +140,11 @@ function degradedType(cache, queryName, sql, sqlHash) {
|
|
|
215
140
|
const prior = cache.queries[queryName];
|
|
216
141
|
return prior?.hash === sqlHash && !prior.retry ? prior.type : generateUnknownResultQuery(sql, queryName);
|
|
217
142
|
}
|
|
143
|
+
const PARAM_TYPE_ALTERNATION = "STRING|NUMERIC|DECIMAL|BIGINT|TINYINT|SMALLINT|INT|FLOAT|DOUBLE|BOOLEAN|DATE|TIMESTAMP_NTZ|TIMESTAMP|BINARY";
|
|
218
144
|
function extractParameterTypes(sql) {
|
|
219
145
|
const paramTypes = {};
|
|
220
|
-
const
|
|
146
|
+
const regex = new RegExp(`--\\s*@param\\s+(\\w+)\\s+(${PARAM_TYPE_ALTERNATION})\\b`, "gi");
|
|
147
|
+
const matches = sql.matchAll(regex);
|
|
221
148
|
for (const match of matches) {
|
|
222
149
|
const [, paramName, paramType] = match;
|
|
223
150
|
paramTypes[paramName] = paramType.toUpperCase();
|
|
@@ -244,6 +171,120 @@ function defaultForType(sqlType) {
|
|
|
244
171
|
}
|
|
245
172
|
}
|
|
246
173
|
/**
|
|
174
|
+
* True when `raw` is already a single, well-formed SQL single-quoted string
|
|
175
|
+
* literal — i.e. it opens and closes with `'` and every interior quote is part
|
|
176
|
+
* of an escaped `''` pair. `'2024-01-01'` and `'O''Brien'` qualify;
|
|
177
|
+
* `'a' OR 1=1 OR 'b'` does not (it has lone interior quotes), so it is treated
|
|
178
|
+
* as raw content and re-escaped rather than trusted. A backslash also
|
|
179
|
+
* disqualifies it: Databricks/Spark treats `\` as an escape inside literals (so
|
|
180
|
+
* `'a\'` is unterminated), and we never trust such input — it is re-escaped.
|
|
181
|
+
*/
|
|
182
|
+
function isWellFormedStringLiteral(raw) {
|
|
183
|
+
if (raw.length < 2 || !raw.startsWith("'") || !raw.endsWith("'")) return false;
|
|
184
|
+
const inner = raw.slice(1, -1);
|
|
185
|
+
if (inner.includes("\\")) return false;
|
|
186
|
+
return !inner.replace(/''/g, "").includes("'");
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Format a user-supplied sample value as a SQL literal for substitution into
|
|
190
|
+
* the build-time DESCRIBE statement. Returns `null` when the value isn't valid
|
|
191
|
+
* for its type, so the caller falls back to the safe type-based placeholder
|
|
192
|
+
* instead of substituting attacker-controllable text.
|
|
193
|
+
*
|
|
194
|
+
* The value comes from a `.sql` file that may be shared via a template or
|
|
195
|
+
* dependency, so it must not be able to inject SQL into `DESCRIBE QUERY`:
|
|
196
|
+
* - string-like types are always emitted as one well-formed, fully-escaped
|
|
197
|
+
* single-quoted literal (a pre-quoted literal is kept as-is, anything else is
|
|
198
|
+
* quoted with both `\` and `'` doubled so neither can terminate the literal),
|
|
199
|
+
* so the value can never break out of the string;
|
|
200
|
+
* - numeric / boolean / binary values must match a strict literal shape and are
|
|
201
|
+
* rejected (`null`) otherwise, rather than being passed through verbatim.
|
|
202
|
+
*/
|
|
203
|
+
function formatSampleValue(sqlType, raw) {
|
|
204
|
+
switch (sqlType?.toUpperCase()) {
|
|
205
|
+
case "STRING":
|
|
206
|
+
case "DATE":
|
|
207
|
+
case "TIMESTAMP":
|
|
208
|
+
case "TIMESTAMP_NTZ": return isWellFormedStringLiteral(raw) ? raw : `'${raw.replace(/\\/g, "\\\\").replace(/'/g, "''")}'`;
|
|
209
|
+
case "NUMERIC":
|
|
210
|
+
case "DECIMAL":
|
|
211
|
+
case "BIGINT":
|
|
212
|
+
case "TINYINT":
|
|
213
|
+
case "SMALLINT":
|
|
214
|
+
case "INT":
|
|
215
|
+
case "FLOAT":
|
|
216
|
+
case "DOUBLE": return /^[+-]?\d+(\.\d+)?$/.test(raw) ? raw : null;
|
|
217
|
+
case "BOOLEAN": return /^(?:true|false)$/i.test(raw) ? raw.toLowerCase() : null;
|
|
218
|
+
case "BINARY": return /^X'[0-9a-fA-F]*'$/i.test(raw) ? raw : null;
|
|
219
|
+
default: return null;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Parse optional describe-time sample values from `@param` annotations, e.g.
|
|
224
|
+
* `-- @param target_catalog STRING = main`. The value is substituted into the
|
|
225
|
+
* SQL **only during DESCRIBE QUERY** so type generation can resolve queries
|
|
226
|
+
* whose shape depends on a parameter value — most notably dynamic table names
|
|
227
|
+
* via `IDENTIFIER(:target_catalog || '.schema.table')`, where the empty-string
|
|
228
|
+
* default would otherwise produce malformed SQL. Runtime binding is unaffected:
|
|
229
|
+
* the analytics plugin still binds the real parameter at execution time, so the
|
|
230
|
+
* query stays portable across environments.
|
|
231
|
+
*
|
|
232
|
+
* Returns a map of parameter name to the formatted SQL literal to substitute.
|
|
233
|
+
*/
|
|
234
|
+
function extractParameterDefaults(sql) {
|
|
235
|
+
const defaults = {};
|
|
236
|
+
const regex = new RegExp(`--[^\\S\\r\\n]*@param[^\\S\\r\\n]+(\\w+)[^\\S\\r\\n]+(${PARAM_TYPE_ALTERNATION})[^\\S\\r\\n]*=[^\\S\\r\\n]*(.+?)[^\\S\\r\\n]*$`, "gim");
|
|
237
|
+
for (const match of sql.matchAll(regex)) {
|
|
238
|
+
const [, paramName, paramType, rawValue] = match;
|
|
239
|
+
const formatted = formatSampleValue(paramType, rawValue);
|
|
240
|
+
if (formatted !== null) defaults[paramName] = formatted;
|
|
241
|
+
}
|
|
242
|
+
return defaults;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Replace `:param` placeholders with describe-time literals so `DESCRIBE QUERY`
|
|
246
|
+
* can run without bound parameters. Resolution order per parameter:
|
|
247
|
+
* 1. An explicit `-- @param name TYPE = value` sample value (wins), which lets
|
|
248
|
+
* dynamic table names via `IDENTIFIER(...)` resolve to a real table.
|
|
249
|
+
* 2. Otherwise a placeholder default derived from the annotated/inferred type.
|
|
250
|
+
* Placeholders inside string literals or comments are left untouched.
|
|
251
|
+
*/
|
|
252
|
+
function substituteParametersForDescribe(sql) {
|
|
253
|
+
const protectedRanges = getProtectedRanges(sql);
|
|
254
|
+
const annotatedTypes = extractParameterTypes(sql);
|
|
255
|
+
const parameterTypes = {
|
|
256
|
+
...inferParameterTypes(sql, protectedRanges),
|
|
257
|
+
...annotatedTypes
|
|
258
|
+
};
|
|
259
|
+
const parameterDefaults = extractParameterDefaults(sql);
|
|
260
|
+
return sql.replace(/(?<!:):([a-zA-Z_]\w*)/g, (original, paramName, offset) => {
|
|
261
|
+
if (isInsideProtectedRange(offset, protectedRanges)) return original;
|
|
262
|
+
const sampleValue = parameterDefaults[paramName];
|
|
263
|
+
if (sampleValue !== void 0) return sampleValue;
|
|
264
|
+
return defaultForType(parameterTypes[paramName]);
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Append a remediation hint when a DESCRIBE failure looks like a dynamic
|
|
269
|
+
* identifier that couldn't be resolved: the query calls `IDENTIFIER(...)` and
|
|
270
|
+
* has at least one parameter without a describe-time sample value. These fail
|
|
271
|
+
* because typegen substitutes a placeholder default (e.g. `''`) that yields a
|
|
272
|
+
* malformed or non-existent table name. Steering the user to the `= value`
|
|
273
|
+
* annotation turns the fatal error into a one-line fix.
|
|
274
|
+
*/
|
|
275
|
+
function withIdentifierHint(error, sql) {
|
|
276
|
+
if (!/\bIDENTIFIER\s*\(/i.test(sql)) return error;
|
|
277
|
+
const params = extractParameters(sql, getProtectedRanges(sql));
|
|
278
|
+
const defaults = extractParameterDefaults(sql);
|
|
279
|
+
const unresolved = params.filter((p) => !SERVER_INJECTED_PARAMS.includes(p) && defaults[p] === void 0);
|
|
280
|
+
if (unresolved.length === 0) return error;
|
|
281
|
+
const example = unresolved[0];
|
|
282
|
+
return {
|
|
283
|
+
...error,
|
|
284
|
+
message: `${error.message}\n Hint: this query uses IDENTIFIER() with parameter(s) ${unresolved.map((p) => `:${p}`).join(", ")}. Give type generation a sample value so it can resolve the table, e.g. \`-- @param ${example} STRING = my_catalog\`. The runtime query still binds the real parameter.`
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
247
288
|
* Infer parameter types from positional context in SQL.
|
|
248
289
|
* V1 only infers NUMERIC from patterns like LIMIT, OFFSET, TOP,
|
|
249
290
|
* FETCH FIRST ... ROWS, and arithmetic operators.
|
|
@@ -310,13 +351,12 @@ async function generateQueriesFromDescribe(queryFolder, warehouseId, options = {
|
|
|
310
351
|
...inferParameterTypes(sql, protectedRanges),
|
|
311
352
|
...annotatedTypes
|
|
312
353
|
};
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
return defaultForType(parameterTypes[paramName]);
|
|
316
|
-
});
|
|
354
|
+
const parameterDefaults = extractParameterDefaults(sql);
|
|
355
|
+
const sqlWithDefaults = substituteParametersForDescribe(sql);
|
|
317
356
|
const allParams = extractParameters(sql, protectedRanges);
|
|
318
357
|
for (const param of allParams) {
|
|
319
358
|
if (SERVER_INJECTED_PARAMS.includes(param)) continue;
|
|
359
|
+
if (parameterDefaults[param]) continue;
|
|
320
360
|
if (parameterTypes[param]) continue;
|
|
321
361
|
logger.warn("%s: parameter \":%s\" has no type annotation or inference. Add %s to the query file.", queryFiles[i], param, `-- @param ${param} <TYPE>`);
|
|
322
362
|
}
|
|
@@ -399,11 +439,9 @@ async function generateQueriesFromDescribe(queryFolder, warehouseId, options = {
|
|
|
399
439
|
let completed = 0;
|
|
400
440
|
const total = uncachedQueries.length;
|
|
401
441
|
spinner.start(`Describing ${total} ${total === 1 ? "query" : "queries"} (0/${total})`);
|
|
442
|
+
const describeFormat = {};
|
|
402
443
|
const describeOne = async ({ index, queryName, sql, sqlHash, cleanedSql }) => {
|
|
403
|
-
const result = await client
|
|
404
|
-
statement: `DESCRIBE QUERY ${cleanedSql}`,
|
|
405
|
-
warehouse_id: warehouseId
|
|
406
|
-
});
|
|
444
|
+
const result = await describeAdaptive(client, `DESCRIBE QUERY ${cleanedSql}`, warehouseId, describeFormat);
|
|
407
445
|
completed++;
|
|
408
446
|
spinner.update(`Describing ${total} ${total === 1 ? "query" : "queries"} (${completed}/${total})`);
|
|
409
447
|
logger.debug("DESCRIBE result for %s: state=%s, rows=%d", queryName, result.status.state, result.result?.data_array?.length ?? 0);
|
|
@@ -416,7 +454,7 @@ async function generateQueriesFromDescribe(queryFolder, warehouseId, options = {
|
|
|
416
454
|
name: queryName,
|
|
417
455
|
type: generateUnknownResultQuery(sql, queryName)
|
|
418
456
|
},
|
|
419
|
-
error: parseError(sqlError)
|
|
457
|
+
error: withIdentifierHint(parseError(sqlError), sql)
|
|
420
458
|
};
|
|
421
459
|
}
|
|
422
460
|
if (result.status.state !== "SUCCEEDED") return {
|