@malloy-publisher/server 0.0.382-dev → 0.2.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.
- package/README.docker.md +145 -20
- package/README.md +65 -12
- package/dist/app/api-doc.html +5 -0
- package/dist/app/api-doc.yaml +3903 -583
- package/dist/app/assets/EnvironmentPage-DiUgLuCH.js +1 -0
- package/dist/app/assets/HomePage-MhhjhnFj.js +1 -0
- package/dist/app/assets/LightMode-BEyvNUzT.js +1 -0
- package/dist/app/assets/MainPage-D4Cfee9o.js +2 -0
- package/dist/app/assets/MaterializationsPage-nGgGqwSN.js +1 -0
- package/dist/app/assets/ModelPage-DLAcEw-F.js +1 -0
- package/dist/app/assets/PackagePage-Df0ikH7w.js +1 -0
- package/dist/app/assets/RouteError-BFQwtroH.js +1 -0
- package/dist/app/assets/ThemeEditorPage-OoskoQYN.js +1 -0
- package/dist/app/assets/WorkbookPage-CpFgvUDC.js +1 -0
- package/dist/app/assets/{core-CjeTkq8O.es-BqRc6yhC.js → core-lb28vQkR.es-BoVSOc75.js} +1 -1
- package/dist/app/assets/github-dark-DenFmJkN.es-DHJKELXO.js +1 -0
- package/dist/app/assets/index-5eLCcNmP.css +1 -0
- package/dist/app/assets/index-6ypuFH6g.js +1774 -0
- package/dist/app/assets/index-B8pnU_V_.js +615 -0
- package/dist/app/assets/index-BImcHmVP.js +1 -0
- package/dist/app/assets/index-DSoCUF-f.js +2725 -0
- package/dist/app/assets/index-DfqzSItA.js +2 -0
- package/dist/app/assets/index-w_0OQJgZ.js +23 -0
- package/dist/app/index.html +8 -4
- package/dist/cpufeatures-1yrn0vtw.node +0 -0
- package/dist/default-publisher.config.json +23 -0
- package/dist/{instrumentation.js → instrumentation.mjs} +10631 -10573
- package/dist/package_load_worker.mjs +15219 -0
- package/dist/runtime/publisher.js +331 -0
- package/dist/{server.js → server.mjs} +115548 -49590
- package/dist/sshcrypto-vd2k5hq9.node +0 -0
- package/package.json +41 -28
- package/publisher.config.example.bigquery.json +33 -0
- package/publisher.config.example.duckdb.json +23 -0
- package/.eslintrc.json +0 -44
- package/.prettierignore +0 -1
- package/.prettierrc +0 -4
- package/build.ts +0 -38
- package/dist/app/assets/HomePage-Dn3E4CuB.js +0 -1
- package/dist/app/assets/MainPage-BzB3yoqi.js +0 -2
- package/dist/app/assets/ModelPage-C9O_sAXT.js +0 -1
- package/dist/app/assets/PackagePage-DcxKEjBX.js +0 -1
- package/dist/app/assets/ProjectPage-BDj307rF.js +0 -1
- package/dist/app/assets/RouteError-DAShbVCG.js +0 -1
- package/dist/app/assets/WorkbookPage-Cs_XYEaB.js +0 -1
- package/dist/app/assets/index-15BOvhp0.js +0 -456
- package/dist/app/assets/index-Bb2jqquW.js +0 -676
- package/dist/app/assets/index-CMlGQMcl.css +0 -1
- package/dist/app/assets/index-D68X76-7.js +0 -1276
- package/dist/app/assets/index.umd-DGBekgSu.js +0 -1145
- package/dxt/malloy_bridge.py +0 -354
- package/dxt/manifest.json +0 -22
- package/eslint.config.mjs +0 -76
- package/k6-tests/README.md +0 -144
- package/k6-tests/bun.lock +0 -797
- package/k6-tests/load-test/load-test-crud-connections.ts +0 -263
- package/k6-tests/load-test/load-test-crud-packages.ts +0 -461
- package/k6-tests/load-test/load-test-crud-projects.ts +0 -160
- package/k6-tests/load-test/load-test-crud.ts +0 -142
- package/k6-tests/load-test/load-test.ts +0 -784
- package/k6-tests/package.json +0 -27
- package/k6-tests/scripts/run-with-otel.sh +0 -50
- package/k6-tests/smoke-test/smoke-test.ts +0 -158
- package/k6-tests/tsconfig.json +0 -29
- package/k6-tests/utils/client_factory.ts +0 -128
- package/k6-tests/utils/common.ts +0 -489
- package/k6-tests/utils/logger.ts +0 -62
- package/k6-tests/utils/types.d.ts +0 -35
- package/malloy_mcp.dxt +0 -0
- package/publisher.config.json +0 -33
- package/src/config.spec.ts +0 -850
- package/src/config.ts +0 -305
- package/src/constants.ts +0 -10
- package/src/controller/compile.controller.ts +0 -35
- package/src/controller/connection.controller.ts +0 -515
- package/src/controller/database.controller.ts +0 -21
- package/src/controller/manifest.controller.ts +0 -29
- package/src/controller/materialization.controller.ts +0 -125
- package/src/controller/model.controller.ts +0 -107
- package/src/controller/package.controller.ts +0 -182
- package/src/controller/query.controller.ts +0 -60
- package/src/controller/watch-mode.controller.ts +0 -98
- package/src/data_styles.ts +0 -71
- package/src/dto/connection.dto.spec.ts +0 -131
- package/src/dto/connection.dto.ts +0 -174
- package/src/dto/index.ts +0 -2
- package/src/dto/package.dto.spec.ts +0 -42
- package/src/dto/package.dto.ts +0 -12
- package/src/dto/validate.spec.ts +0 -76
- package/src/dto/validate.ts +0 -31
- package/src/errors.ts +0 -116
- package/src/health.ts +0 -189
- package/src/instrumentation.ts +0 -148
- package/src/logger.ts +0 -154
- package/src/mcp/error_messages.ts +0 -199
- package/src/mcp/handler_utils.ts +0 -239
- package/src/mcp/mcp_constants.ts +0 -22
- package/src/mcp/prompts/handlers.ts +0 -84
- package/src/mcp/prompts/index.ts +0 -11
- package/src/mcp/prompts/prompt_definitions.ts +0 -160
- package/src/mcp/prompts/prompt_service.ts +0 -67
- package/src/mcp/prompts/utils.ts +0 -62
- package/src/mcp/resource_metadata.ts +0 -47
- package/src/mcp/resources/model_resource.ts +0 -153
- package/src/mcp/resources/notebook_resource.ts +0 -137
- package/src/mcp/resources/package_resource.ts +0 -370
- package/src/mcp/resources/project_resource.ts +0 -184
- package/src/mcp/resources/query_resource.ts +0 -117
- package/src/mcp/resources/source_resource.ts +0 -141
- package/src/mcp/resources/view_resource.ts +0 -136
- package/src/mcp/server.ts +0 -62
- package/src/mcp/tools/discovery_tools.ts +0 -258
- package/src/mcp/tools/execute_query_tool.ts +0 -265
- package/src/server.ts +0 -1195
- package/src/service/connection.spec.ts +0 -1331
- package/src/service/connection.ts +0 -1511
- package/src/service/connection_service.spec.ts +0 -641
- package/src/service/connection_service.ts +0 -181
- package/src/service/db_utils.spec.ts +0 -714
- package/src/service/db_utils.ts +0 -1227
- package/src/service/filter.spec.ts +0 -447
- package/src/service/filter.ts +0 -337
- package/src/service/filter_integration.spec.ts +0 -825
- package/src/service/gcs_s3_utils.ts +0 -427
- package/src/service/manifest_service.spec.ts +0 -201
- package/src/service/manifest_service.ts +0 -106
- package/src/service/materialization_service.spec.ts +0 -648
- package/src/service/materialization_service.ts +0 -929
- package/src/service/materialized_table_gc.spec.ts +0 -383
- package/src/service/materialized_table_gc.ts +0 -279
- package/src/service/model.spec.ts +0 -257
- package/src/service/model.ts +0 -1069
- package/src/service/package.spec.ts +0 -236
- package/src/service/package.ts +0 -463
- package/src/service/project.ts +0 -681
- package/src/service/project_compile.spec.ts +0 -197
- package/src/service/project_store.spec.ts +0 -859
- package/src/service/project_store.ts +0 -1531
- package/src/service/quoting.ts +0 -41
- package/src/service/resolve_project.ts +0 -13
- package/src/storage/BaseRepository.ts +0 -31
- package/src/storage/DatabaseInterface.ts +0 -180
- package/src/storage/StorageManager.mock.ts +0 -59
- package/src/storage/StorageManager.ts +0 -210
- package/src/storage/duckdb/ConnectionRepository.ts +0 -141
- package/src/storage/duckdb/DuckDBConnection.ts +0 -177
- package/src/storage/duckdb/DuckDBManifestStore.ts +0 -70
- package/src/storage/duckdb/DuckDBRepository.ts +0 -224
- package/src/storage/duckdb/ManifestRepository.ts +0 -119
- package/src/storage/duckdb/MaterializationRepository.ts +0 -249
- package/src/storage/duckdb/PackageRepository.ts +0 -135
- package/src/storage/duckdb/ProjectRepository.ts +0 -148
- package/src/storage/duckdb/manifest_store.spec.ts +0 -133
- package/src/storage/duckdb/schema.ts +0 -147
- package/src/storage/ducklake/DuckLakeManifestStore.ts +0 -146
- package/src/utils.ts +0 -13
- package/tests/fixtures/persist-test/data/orders.csv +0 -5
- package/tests/fixtures/persist-test/persist_test.malloy +0 -11
- package/tests/fixtures/persist-test/publisher.json +0 -5
- package/tests/fixtures/publisher.config.json +0 -15
- package/tests/harness/e2e.ts +0 -96
- package/tests/harness/mcp_test_setup.ts +0 -344
- package/tests/harness/mocks.ts +0 -37
- package/tests/harness/rest_e2e.ts +0 -68
- package/tests/harness/uris.ts +0 -31
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +0 -470
- package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +0 -434
- package/tests/integration/mcp/mcp_resource.integration.spec.ts +0 -663
- package/tests/integration/mcp/mcp_transport.integration.spec.ts +0 -122
- package/tests/integration/mcp/setup.spec.ts +0 -5
- package/tests/unit/duckdb/attached_databases.test.ts +0 -1132
- package/tests/unit/ducklake/ducklake.test.ts +0 -950
- package/tests/unit/mcp/prompt_definitions.test.ts +0 -102
- package/tests/unit/mcp/prompt_happy.test.ts +0 -51
- package/tsconfig.json +0 -32
package/src/service/db_utils.ts
DELETED
|
@@ -1,1227 +0,0 @@
|
|
|
1
|
-
import { ClientSecretCredential } from "@azure/identity";
|
|
2
|
-
import { ContainerClient } from "@azure/storage-blob";
|
|
3
|
-
import { BigQuery } from "@google-cloud/bigquery";
|
|
4
|
-
import { Connection, TableSourceDef } from "@malloydata/malloy";
|
|
5
|
-
import { components } from "../api";
|
|
6
|
-
import { logger } from "../logger";
|
|
7
|
-
import {
|
|
8
|
-
CloudStorageCredentials,
|
|
9
|
-
gcsConnectionToCredentials,
|
|
10
|
-
getCloudTablesWithColumns,
|
|
11
|
-
listCloudDirectorySchemas,
|
|
12
|
-
listDataFilesInDirectory,
|
|
13
|
-
parseCloudUri,
|
|
14
|
-
s3ConnectionToCredentials,
|
|
15
|
-
} from "./gcs_s3_utils";
|
|
16
|
-
import { ApiConnection } from "./model";
|
|
17
|
-
|
|
18
|
-
type ApiSchema = components["schemas"]["Schema"];
|
|
19
|
-
type ApiTable = components["schemas"]["Table"];
|
|
20
|
-
type ApiAzureConnection = components["schemas"]["AzureConnection"];
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Build a SQL `AND column IN (...)` fragment for optional table-name filtering.
|
|
24
|
-
* Returns an empty string when `values` is undefined or empty.
|
|
25
|
-
*/
|
|
26
|
-
export function sqlInFilter(columnName: string, values?: string[]): string {
|
|
27
|
-
if (!values || values.length === 0) return "";
|
|
28
|
-
const escaped = values.map((v) => `'${v.replace(/'/g, "''")}'`);
|
|
29
|
-
return `AND ${columnName} IN (${escaped.join(", ")})`;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Group INFORMATION_SCHEMA.COLUMNS rows into ApiTable objects.
|
|
34
|
-
* Handles both upper-case (Snowflake) and lower-case (Postgres/DuckDB) column names.
|
|
35
|
-
*/
|
|
36
|
-
function groupColumnRowsIntoTables(
|
|
37
|
-
rows: unknown[],
|
|
38
|
-
buildResource: (tableName: string) => string,
|
|
39
|
-
): ApiTable[] {
|
|
40
|
-
const tableMap = new Map<string, { name: string; type: string }[]>();
|
|
41
|
-
for (const row of rows) {
|
|
42
|
-
const r = row as Record<string, unknown>;
|
|
43
|
-
const tableName = String(r.TABLE_NAME ?? r.table_name ?? "");
|
|
44
|
-
const columnName = String(r.COLUMN_NAME ?? r.column_name ?? "");
|
|
45
|
-
const dataType = String(r.DATA_TYPE ?? r.data_type ?? "").toLowerCase();
|
|
46
|
-
if (!tableName) continue;
|
|
47
|
-
if (!tableMap.has(tableName)) tableMap.set(tableName, []);
|
|
48
|
-
tableMap.get(tableName)!.push({ name: columnName, type: dataType });
|
|
49
|
-
}
|
|
50
|
-
const tables: ApiTable[] = [];
|
|
51
|
-
for (const [tableName, columns] of tableMap) {
|
|
52
|
-
tables.push({ resource: buildResource(tableName), columns });
|
|
53
|
-
}
|
|
54
|
-
return tables;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function createBigQueryClient(connection: ApiConnection): BigQuery {
|
|
58
|
-
if (!connection.bigqueryConnection) {
|
|
59
|
-
throw new Error("BigQuery connection is required");
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const config: {
|
|
63
|
-
projectId: string;
|
|
64
|
-
credentials?: object;
|
|
65
|
-
keyFilename?: string;
|
|
66
|
-
} = {
|
|
67
|
-
projectId: connection.bigqueryConnection.defaultProjectId || "",
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
// Add service account key if provided
|
|
71
|
-
if (connection.bigqueryConnection.serviceAccountKeyJson) {
|
|
72
|
-
let credentials: Record<string, unknown>;
|
|
73
|
-
try {
|
|
74
|
-
credentials = JSON.parse(
|
|
75
|
-
connection.bigqueryConnection.serviceAccountKeyJson,
|
|
76
|
-
);
|
|
77
|
-
} catch (parseError) {
|
|
78
|
-
throw new Error(
|
|
79
|
-
`Failed to parse BigQuery service account key JSON: ${(parseError as Error).message}`,
|
|
80
|
-
);
|
|
81
|
-
}
|
|
82
|
-
config.credentials = credentials;
|
|
83
|
-
|
|
84
|
-
if (!config.projectId && credentials.project_id) {
|
|
85
|
-
config.projectId = credentials.project_id as string;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
if (!config.projectId) {
|
|
89
|
-
throw new Error(
|
|
90
|
-
"BigQuery project ID is required. Either set the defaultProjectId in the connection configuration or the project_id in the service account key JSON.",
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
} else if (
|
|
94
|
-
Object.keys(connection.bigqueryConnection).length === 0 &&
|
|
95
|
-
process.env.GOOGLE_APPLICATION_CREDENTIALS
|
|
96
|
-
) {
|
|
97
|
-
// Note: The BigQuery client will infer the project ID from the ADC file.
|
|
98
|
-
config.keyFilename = process.env.GOOGLE_APPLICATION_CREDENTIALS || "";
|
|
99
|
-
} else {
|
|
100
|
-
throw new Error(
|
|
101
|
-
"BigQuery connection is required, either set the bigqueryConnection in the connection configuration or set the GOOGLE_APPLICATION_CREDENTIALS environment variable.",
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
return new BigQuery(config);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function standardizeRunSQLResult(result: unknown): unknown[] {
|
|
109
|
-
// Handle different result formats from malloyConnection.runSQL
|
|
110
|
-
return Array.isArray(result)
|
|
111
|
-
? result
|
|
112
|
-
: (result as { rows?: unknown[] }).rows || [];
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function getCloudCredentialsFromAttachedDatabases(
|
|
116
|
-
attachedDatabases: components["schemas"]["AttachedDatabase"][],
|
|
117
|
-
storageType: "gcs" | "s3",
|
|
118
|
-
): CloudStorageCredentials | null {
|
|
119
|
-
for (const attachedDb of attachedDatabases) {
|
|
120
|
-
if (
|
|
121
|
-
attachedDb.type === "gcs" &&
|
|
122
|
-
storageType === "gcs" &&
|
|
123
|
-
attachedDb.gcsConnection
|
|
124
|
-
) {
|
|
125
|
-
return gcsConnectionToCredentials(attachedDb.gcsConnection);
|
|
126
|
-
}
|
|
127
|
-
if (
|
|
128
|
-
attachedDb.type === "s3" &&
|
|
129
|
-
storageType === "s3" &&
|
|
130
|
-
attachedDb.s3Connection
|
|
131
|
-
) {
|
|
132
|
-
return s3ConnectionToCredentials(attachedDb.s3Connection);
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
return null;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
async function getSchemasForBigQuery(
|
|
139
|
-
connection: ApiConnection,
|
|
140
|
-
): Promise<ApiSchema[]> {
|
|
141
|
-
if (!connection.bigqueryConnection) {
|
|
142
|
-
throw new Error("BigQuery connection is required");
|
|
143
|
-
}
|
|
144
|
-
try {
|
|
145
|
-
const bigquery = createBigQueryClient(connection);
|
|
146
|
-
const [datasets] = await bigquery.getDatasets();
|
|
147
|
-
|
|
148
|
-
return await Promise.all(
|
|
149
|
-
datasets.map(async (dataset) => {
|
|
150
|
-
const [metadata] = await dataset.getMetadata();
|
|
151
|
-
return {
|
|
152
|
-
name: dataset.id,
|
|
153
|
-
isHidden: false,
|
|
154
|
-
isDefault: false,
|
|
155
|
-
description: (metadata as { description?: string })?.description,
|
|
156
|
-
};
|
|
157
|
-
}),
|
|
158
|
-
);
|
|
159
|
-
} catch (error) {
|
|
160
|
-
logger.error(
|
|
161
|
-
`Error getting schemas for BigQuery connection ${connection.name}`,
|
|
162
|
-
{ error },
|
|
163
|
-
);
|
|
164
|
-
throw new Error(
|
|
165
|
-
`Failed to get schemas for BigQuery connection ${connection.name}: ${(error as Error).message}`,
|
|
166
|
-
);
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
async function getSchemasForPostgres(
|
|
171
|
-
connection: ApiConnection,
|
|
172
|
-
malloyConnection: Connection,
|
|
173
|
-
): Promise<ApiSchema[]> {
|
|
174
|
-
if (!connection.postgresConnection) {
|
|
175
|
-
throw new Error("Postgres connection is required");
|
|
176
|
-
}
|
|
177
|
-
try {
|
|
178
|
-
// Wrap in row_to_json because the Malloy Postgres driver's runSQL
|
|
179
|
-
// de-JSONs each row via row.row (matching Malloy-generated queries).
|
|
180
|
-
const result = await malloyConnection.runSQL(
|
|
181
|
-
"SELECT row_to_json(t) as row FROM (SELECT schema_name FROM information_schema.schemata ORDER BY schema_name) t",
|
|
182
|
-
);
|
|
183
|
-
const rows = standardizeRunSQLResult(result);
|
|
184
|
-
return rows.map((row: unknown) => {
|
|
185
|
-
const typedRow = row as Record<string, unknown>;
|
|
186
|
-
const schemaName = String(
|
|
187
|
-
typedRow.schema_name ?? typedRow.SCHEMA_NAME ?? "",
|
|
188
|
-
);
|
|
189
|
-
return {
|
|
190
|
-
name: schemaName,
|
|
191
|
-
isHidden: ["information_schema", "pg_catalog", "pg_toast"].includes(
|
|
192
|
-
schemaName,
|
|
193
|
-
),
|
|
194
|
-
isDefault: schemaName === "public",
|
|
195
|
-
};
|
|
196
|
-
});
|
|
197
|
-
} catch (error) {
|
|
198
|
-
logger.error(
|
|
199
|
-
`Error getting schemas for Postgres connection ${connection.name}`,
|
|
200
|
-
{ error },
|
|
201
|
-
);
|
|
202
|
-
throw new Error(
|
|
203
|
-
`Failed to get schemas for Postgres connection ${connection.name}: ${(error as Error).message}`,
|
|
204
|
-
);
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
async function getSchemasForMySQL(
|
|
209
|
-
connection: ApiConnection,
|
|
210
|
-
): Promise<ApiSchema[]> {
|
|
211
|
-
if (!connection.mysqlConnection) {
|
|
212
|
-
throw new Error("Mysql connection is required");
|
|
213
|
-
}
|
|
214
|
-
return [
|
|
215
|
-
{
|
|
216
|
-
name: connection.mysqlConnection.database || "mysql",
|
|
217
|
-
isHidden: false,
|
|
218
|
-
isDefault: true,
|
|
219
|
-
},
|
|
220
|
-
];
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
async function getSchemasForSnowflake(
|
|
224
|
-
connection: ApiConnection,
|
|
225
|
-
malloyConnection: Connection,
|
|
226
|
-
): Promise<ApiSchema[]> {
|
|
227
|
-
if (!connection.snowflakeConnection) {
|
|
228
|
-
throw new Error("Snowflake connection is required");
|
|
229
|
-
}
|
|
230
|
-
try {
|
|
231
|
-
const database = connection.snowflakeConnection.database;
|
|
232
|
-
const schema = connection.snowflakeConnection.schema;
|
|
233
|
-
|
|
234
|
-
const filters: string[] = [];
|
|
235
|
-
if (database) {
|
|
236
|
-
filters.push(`CATALOG_NAME = '${database}'`);
|
|
237
|
-
}
|
|
238
|
-
if (schema) {
|
|
239
|
-
filters.push(`SCHEMA_NAME = '${schema}'`);
|
|
240
|
-
}
|
|
241
|
-
const whereClause =
|
|
242
|
-
filters.length > 0 ? `WHERE ${filters.join(" AND ")}` : "";
|
|
243
|
-
|
|
244
|
-
const result = await malloyConnection.runSQL(
|
|
245
|
-
`SELECT CATALOG_NAME, SCHEMA_NAME, SCHEMA_OWNER FROM ${database ? `${database}.` : ""}INFORMATION_SCHEMA.SCHEMATA ${whereClause} ORDER BY SCHEMA_NAME`,
|
|
246
|
-
);
|
|
247
|
-
const rows = standardizeRunSQLResult(result);
|
|
248
|
-
return rows.map((row: unknown) => {
|
|
249
|
-
const typedRow = row as Record<string, unknown>;
|
|
250
|
-
const catalogName = String(
|
|
251
|
-
typedRow.CATALOG_NAME ?? typedRow.catalog_name ?? "",
|
|
252
|
-
);
|
|
253
|
-
const schemaName = String(
|
|
254
|
-
typedRow.SCHEMA_NAME ?? typedRow.schema_name ?? "",
|
|
255
|
-
);
|
|
256
|
-
const owner = String(
|
|
257
|
-
typedRow.SCHEMA_OWNER ?? typedRow.schema_owner ?? "",
|
|
258
|
-
);
|
|
259
|
-
return {
|
|
260
|
-
name: `${catalogName}.${schemaName}`,
|
|
261
|
-
isHidden:
|
|
262
|
-
["SNOWFLAKE", ""].includes(owner) ||
|
|
263
|
-
schemaName === "INFORMATION_SCHEMA",
|
|
264
|
-
isDefault: schema ? schemaName === schema : false,
|
|
265
|
-
};
|
|
266
|
-
});
|
|
267
|
-
} catch (error) {
|
|
268
|
-
logger.error(
|
|
269
|
-
`Error getting schemas for Snowflake connection ${connection.name}`,
|
|
270
|
-
{ error },
|
|
271
|
-
);
|
|
272
|
-
throw new Error(
|
|
273
|
-
`Failed to get schemas for Snowflake connection ${connection.name}: ${(error as Error).message}`,
|
|
274
|
-
);
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
async function getSchemasForTrino(
|
|
279
|
-
connection: ApiConnection,
|
|
280
|
-
malloyConnection: Connection,
|
|
281
|
-
): Promise<ApiSchema[]> {
|
|
282
|
-
if (!connection.trinoConnection) {
|
|
283
|
-
throw new Error("Trino connection is required");
|
|
284
|
-
}
|
|
285
|
-
try {
|
|
286
|
-
const configuredSchema = connection.trinoConnection.schema;
|
|
287
|
-
let allRows: { catalog: string; schema: string }[] = [];
|
|
288
|
-
|
|
289
|
-
if (connection.trinoConnection.catalog) {
|
|
290
|
-
const catalog = connection.trinoConnection.catalog;
|
|
291
|
-
const result = await malloyConnection.runSQL(
|
|
292
|
-
`SELECT schema_name FROM ${catalog}.information_schema.schemata ORDER BY schema_name`,
|
|
293
|
-
);
|
|
294
|
-
const rows = standardizeRunSQLResult(result);
|
|
295
|
-
allRows = rows.map((row: unknown) => {
|
|
296
|
-
const r = row as Record<string, unknown>;
|
|
297
|
-
return {
|
|
298
|
-
catalog,
|
|
299
|
-
schema: String(r.schema_name ?? r.Schema ?? ""),
|
|
300
|
-
};
|
|
301
|
-
});
|
|
302
|
-
} else {
|
|
303
|
-
const catalogsResult = await malloyConnection.runSQL(`SHOW CATALOGS`);
|
|
304
|
-
const catalogNames = standardizeRunSQLResult(catalogsResult).map(
|
|
305
|
-
(row: unknown) => {
|
|
306
|
-
const r = row as Record<string, unknown>;
|
|
307
|
-
return String(r.Catalog ?? r.catalog ?? "");
|
|
308
|
-
},
|
|
309
|
-
);
|
|
310
|
-
|
|
311
|
-
for (const catalog of catalogNames) {
|
|
312
|
-
try {
|
|
313
|
-
const result = await malloyConnection.runSQL(
|
|
314
|
-
`SELECT schema_name FROM ${catalog}.information_schema.schemata ORDER BY schema_name`,
|
|
315
|
-
);
|
|
316
|
-
const rows = standardizeRunSQLResult(result);
|
|
317
|
-
for (const row of rows) {
|
|
318
|
-
const r = row as Record<string, unknown>;
|
|
319
|
-
allRows.push({
|
|
320
|
-
catalog,
|
|
321
|
-
schema: String(r.schema_name ?? r.Schema ?? ""),
|
|
322
|
-
});
|
|
323
|
-
}
|
|
324
|
-
} catch (catalogError) {
|
|
325
|
-
logger.warn(
|
|
326
|
-
`Failed to list schemas for Trino catalog ${catalog}`,
|
|
327
|
-
{ error: catalogError },
|
|
328
|
-
);
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
return allRows.map(({ catalog, schema }) => {
|
|
334
|
-
const name = connection.trinoConnection?.catalog
|
|
335
|
-
? schema
|
|
336
|
-
: `${catalog}.${schema}`;
|
|
337
|
-
return {
|
|
338
|
-
name,
|
|
339
|
-
isHidden: ["information_schema", "performance_schema"].includes(
|
|
340
|
-
schema,
|
|
341
|
-
),
|
|
342
|
-
isDefault: configuredSchema ? schema === configuredSchema : false,
|
|
343
|
-
};
|
|
344
|
-
});
|
|
345
|
-
} catch (error) {
|
|
346
|
-
logger.error(
|
|
347
|
-
`Error getting schemas for Trino connection ${connection.name}`,
|
|
348
|
-
{ error },
|
|
349
|
-
);
|
|
350
|
-
throw new Error(
|
|
351
|
-
`Failed to get schemas for Trino connection ${connection.name}: ${(error as Error).message}`,
|
|
352
|
-
);
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
async function getSchemasForDuckDB(
|
|
357
|
-
connection: ApiConnection,
|
|
358
|
-
malloyConnection: Connection,
|
|
359
|
-
): Promise<ApiSchema[]> {
|
|
360
|
-
if (!connection.duckdbConnection) {
|
|
361
|
-
throw new Error("DuckDB connection is required");
|
|
362
|
-
}
|
|
363
|
-
try {
|
|
364
|
-
const result = await malloyConnection.runSQL(
|
|
365
|
-
"SELECT DISTINCT schema_name,catalog_name FROM information_schema.schemata ORDER BY catalog_name,schema_name",
|
|
366
|
-
{ rowLimit: 1000 },
|
|
367
|
-
);
|
|
368
|
-
|
|
369
|
-
const rows = standardizeRunSQLResult(result);
|
|
370
|
-
|
|
371
|
-
const schemas: ApiSchema[] = rows.map((row: unknown) => {
|
|
372
|
-
const typedRow = row as Record<string, unknown>;
|
|
373
|
-
const schemaName = String(typedRow.schema_name ?? "");
|
|
374
|
-
const catalogName = String(typedRow.catalog_name ?? "");
|
|
375
|
-
|
|
376
|
-
return {
|
|
377
|
-
name: `${catalogName}.${schemaName}`,
|
|
378
|
-
isHidden:
|
|
379
|
-
[
|
|
380
|
-
"information_schema",
|
|
381
|
-
"performance_schema",
|
|
382
|
-
"pg_catalog",
|
|
383
|
-
"pg_toast",
|
|
384
|
-
"",
|
|
385
|
-
].includes(schemaName) ||
|
|
386
|
-
["md_information_schema", "system"].includes(catalogName),
|
|
387
|
-
isDefault: catalogName === "main",
|
|
388
|
-
};
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
const attachedDatabases =
|
|
392
|
-
connection.duckdbConnection.attachedDatabases || [];
|
|
393
|
-
|
|
394
|
-
const cloudDatabases = attachedDatabases.filter(
|
|
395
|
-
(attachedDb) =>
|
|
396
|
-
(attachedDb.type === "gcs" || attachedDb.type === "s3") &&
|
|
397
|
-
(attachedDb.gcsConnection || attachedDb.s3Connection),
|
|
398
|
-
);
|
|
399
|
-
|
|
400
|
-
const cloudDbPromises = cloudDatabases.map(async (attachedDb) => {
|
|
401
|
-
const dbType = attachedDb.type as "gcs" | "s3";
|
|
402
|
-
const credentials =
|
|
403
|
-
dbType === "gcs"
|
|
404
|
-
? gcsConnectionToCredentials(attachedDb.gcsConnection!)
|
|
405
|
-
: s3ConnectionToCredentials(attachedDb.s3Connection!);
|
|
406
|
-
|
|
407
|
-
try {
|
|
408
|
-
return await listCloudDirectorySchemas(credentials);
|
|
409
|
-
} catch (cloudError) {
|
|
410
|
-
logger.warn(
|
|
411
|
-
`Failed to list ${dbType.toUpperCase()} directory schemas for ${attachedDb.name}`,
|
|
412
|
-
{ error: cloudError },
|
|
413
|
-
);
|
|
414
|
-
return [];
|
|
415
|
-
}
|
|
416
|
-
});
|
|
417
|
-
|
|
418
|
-
const cloudSchemaArrays = await Promise.all(cloudDbPromises);
|
|
419
|
-
for (const cloudSchemas of cloudSchemaArrays) {
|
|
420
|
-
schemas.push(...cloudSchemas);
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
const azureDatabases = attachedDatabases.filter(
|
|
424
|
-
(attachedDb) =>
|
|
425
|
-
attachedDb.type === "azure" && attachedDb.azureConnection,
|
|
426
|
-
);
|
|
427
|
-
for (const attachedDb of azureDatabases) {
|
|
428
|
-
if (attachedDb.name) {
|
|
429
|
-
schemas.push({
|
|
430
|
-
name: attachedDb.name,
|
|
431
|
-
isHidden: false,
|
|
432
|
-
isDefault: false,
|
|
433
|
-
});
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
return schemas;
|
|
438
|
-
} catch (error) {
|
|
439
|
-
logger.error(
|
|
440
|
-
`Error getting schemas for DuckDB connection ${connection.name}`,
|
|
441
|
-
{ error },
|
|
442
|
-
);
|
|
443
|
-
throw new Error(
|
|
444
|
-
`Failed to get schemas for DuckDB connection ${connection.name}: ${(error as Error).message}`,
|
|
445
|
-
);
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
async function getSchemasForMotherDuck(
|
|
450
|
-
connection: ApiConnection,
|
|
451
|
-
malloyConnection: Connection,
|
|
452
|
-
): Promise<ApiSchema[]> {
|
|
453
|
-
if (!connection.motherduckConnection) {
|
|
454
|
-
throw new Error("MotherDuck connection is required");
|
|
455
|
-
}
|
|
456
|
-
try {
|
|
457
|
-
const database = connection.motherduckConnection.database;
|
|
458
|
-
const whereClause = database ? `WHERE catalog_name = '${database}'` : "";
|
|
459
|
-
const result = await malloyConnection.runSQL(
|
|
460
|
-
`SELECT DISTINCT schema_name FROM information_schema.schemata ${whereClause} ORDER BY schema_name`,
|
|
461
|
-
);
|
|
462
|
-
const rows = standardizeRunSQLResult(result);
|
|
463
|
-
return rows.map((row: unknown) => {
|
|
464
|
-
const typedRow = row as Record<string, unknown>;
|
|
465
|
-
const schemaName = String(
|
|
466
|
-
typedRow.schema_name ?? typedRow.SCHEMA_NAME ?? "",
|
|
467
|
-
);
|
|
468
|
-
return {
|
|
469
|
-
name: schemaName,
|
|
470
|
-
isHidden: ["information_schema", "performance_schema", ""].includes(
|
|
471
|
-
schemaName,
|
|
472
|
-
),
|
|
473
|
-
isDefault: schemaName === "main",
|
|
474
|
-
};
|
|
475
|
-
});
|
|
476
|
-
} catch (error) {
|
|
477
|
-
logger.error(
|
|
478
|
-
`Error getting schemas for MotherDuck connection ${connection.name}`,
|
|
479
|
-
{ error },
|
|
480
|
-
);
|
|
481
|
-
throw new Error(
|
|
482
|
-
`Failed to get schemas for MotherDuck connection ${connection.name}: ${(error as Error).message}`,
|
|
483
|
-
);
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
async function getSchemasForDuckLake(
|
|
488
|
-
connection: ApiConnection,
|
|
489
|
-
malloyConnection: Connection,
|
|
490
|
-
): Promise<ApiSchema[]> {
|
|
491
|
-
try {
|
|
492
|
-
// The catalog is attached with the connection name (see attachDuckLake in connection.ts)
|
|
493
|
-
const catalogName = connection.name;
|
|
494
|
-
const result = await malloyConnection.runSQL(
|
|
495
|
-
`SELECT schema_name FROM information_schema.schemata WHERE catalog_name = '${catalogName}' ORDER BY schema_name`,
|
|
496
|
-
{ rowLimit: 1000 },
|
|
497
|
-
);
|
|
498
|
-
const rows = standardizeRunSQLResult(result);
|
|
499
|
-
|
|
500
|
-
return rows.map((row: unknown) => {
|
|
501
|
-
const typedRow = row as Record<string, unknown>;
|
|
502
|
-
const schemaName = typedRow.schema_name as string;
|
|
503
|
-
const shouldShow = schemaName === "main" || schemaName === "public";
|
|
504
|
-
return {
|
|
505
|
-
name: schemaName,
|
|
506
|
-
isHidden: !shouldShow,
|
|
507
|
-
isDefault: false,
|
|
508
|
-
};
|
|
509
|
-
});
|
|
510
|
-
} catch (error) {
|
|
511
|
-
logger.error(
|
|
512
|
-
`Error getting schemas for DuckLake connection ${connection.name}`,
|
|
513
|
-
{ error },
|
|
514
|
-
);
|
|
515
|
-
throw new Error(
|
|
516
|
-
`Failed to get schemas for DuckLake connection ${connection.name}: ${(error as Error).message}`,
|
|
517
|
-
);
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
export async function getSchemasForConnection(
|
|
522
|
-
connection: ApiConnection,
|
|
523
|
-
malloyConnection: Connection,
|
|
524
|
-
): Promise<ApiSchema[]> {
|
|
525
|
-
switch (connection.type) {
|
|
526
|
-
case "bigquery":
|
|
527
|
-
return getSchemasForBigQuery(connection);
|
|
528
|
-
case "postgres":
|
|
529
|
-
return getSchemasForPostgres(connection, malloyConnection);
|
|
530
|
-
case "mysql":
|
|
531
|
-
return getSchemasForMySQL(connection);
|
|
532
|
-
case "snowflake":
|
|
533
|
-
return getSchemasForSnowflake(connection, malloyConnection);
|
|
534
|
-
case "trino":
|
|
535
|
-
return getSchemasForTrino(connection, malloyConnection);
|
|
536
|
-
case "duckdb":
|
|
537
|
-
return getSchemasForDuckDB(connection, malloyConnection);
|
|
538
|
-
case "motherduck":
|
|
539
|
-
return getSchemasForMotherDuck(connection, malloyConnection);
|
|
540
|
-
case "ducklake":
|
|
541
|
-
return getSchemasForDuckLake(connection, malloyConnection);
|
|
542
|
-
default:
|
|
543
|
-
throw new Error(`Unsupported connection type: ${connection.type}`);
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
function getFileType(key: string): string {
|
|
548
|
-
const lowerKey = key.toLowerCase();
|
|
549
|
-
if (lowerKey.endsWith(".csv")) return "csv";
|
|
550
|
-
if (lowerKey.endsWith(".parquet")) return "parquet";
|
|
551
|
-
if (lowerKey.endsWith(".json")) return "json";
|
|
552
|
-
if (lowerKey.endsWith(".jsonl") || lowerKey.endsWith(".ndjson"))
|
|
553
|
-
return "jsonl";
|
|
554
|
-
return "unknown";
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
/**
|
|
558
|
-
* Lists blobs in an Azure container matching a glob-like prefix/extension filter.
|
|
559
|
-
* Parses an HTTPS SAS URL like:
|
|
560
|
-
* https://account.blob.core.windows.net/container/path/*.parquet?sasToken
|
|
561
|
-
* Returns individual file URLs with the SAS token appended.
|
|
562
|
-
*/
|
|
563
|
-
async function listAzureBlobs(
|
|
564
|
-
fileUrl: string,
|
|
565
|
-
azureConnection?: ApiAzureConnection,
|
|
566
|
-
): Promise<{ url: string; blobName: string }[]> {
|
|
567
|
-
// Split URL and SAS token carefully to avoid encoding issues with signatures
|
|
568
|
-
const queryStart = fileUrl.indexOf("?");
|
|
569
|
-
const baseUrl = queryStart >= 0 ? fileUrl.substring(0, queryStart) : fileUrl;
|
|
570
|
-
const sasToken = queryStart >= 0 ? fileUrl.substring(queryStart) : "";
|
|
571
|
-
|
|
572
|
-
// Parse the URL to extract account, container, and blob path
|
|
573
|
-
let accountUrl: string;
|
|
574
|
-
let container: string;
|
|
575
|
-
let blobPath: string;
|
|
576
|
-
|
|
577
|
-
if (baseUrl.startsWith("abfss://")) {
|
|
578
|
-
// abfss://container/path or abfss://account.dfs.core.windows.net/container/path
|
|
579
|
-
const withoutScheme = baseUrl.substring("abfss://".length);
|
|
580
|
-
const parts = withoutScheme.split("/").filter(Boolean);
|
|
581
|
-
if (parts[0].includes(".")) {
|
|
582
|
-
// Fully qualified: abfss://account.dfs.core.windows.net/container/path
|
|
583
|
-
const accountName = parts[0].split(".")[0];
|
|
584
|
-
accountUrl = `https://${accountName}.blob.core.windows.net`;
|
|
585
|
-
container = parts[1];
|
|
586
|
-
blobPath = parts.slice(2).join("/");
|
|
587
|
-
} else {
|
|
588
|
-
// Short form: abfss://container/path — need accountName from config
|
|
589
|
-
if (!azureConnection?.accountName) {
|
|
590
|
-
throw new Error(
|
|
591
|
-
"accountName is required to list blobs with abfss:// URLs",
|
|
592
|
-
);
|
|
593
|
-
}
|
|
594
|
-
accountUrl = `https://${azureConnection.accountName}.blob.core.windows.net`;
|
|
595
|
-
container = parts[0];
|
|
596
|
-
blobPath = parts.slice(1).join("/");
|
|
597
|
-
}
|
|
598
|
-
} else {
|
|
599
|
-
// https://account.blob.core.windows.net/container/path
|
|
600
|
-
const url = new URL(baseUrl);
|
|
601
|
-
const pathParts = url.pathname.split("/").filter(Boolean);
|
|
602
|
-
container = pathParts[0];
|
|
603
|
-
blobPath = pathParts.slice(1).join("/");
|
|
604
|
-
accountUrl = `${url.protocol}//${url.host}`;
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
// Three supported glob patterns:
|
|
608
|
-
// path/file.ext → single file (handled upstream by isAzureSingleFileUrl)
|
|
609
|
-
// path/*.ext → files directly in path/ with that extension (no subdirs)
|
|
610
|
-
// path/** → all valid data files in path/ and nested dirs (recursive)
|
|
611
|
-
let prefix: string;
|
|
612
|
-
let extensionFilter = ""; // for *.ext pattern
|
|
613
|
-
let recursive = true; // for ** pattern
|
|
614
|
-
|
|
615
|
-
if (blobPath.endsWith("**")) {
|
|
616
|
-
// Recursive listing: everything under this prefix
|
|
617
|
-
prefix = blobPath.slice(0, -2);
|
|
618
|
-
recursive = true;
|
|
619
|
-
} else if (blobPath.includes("*")) {
|
|
620
|
-
// Single-level glob: path/*.ext — files directly in that dir only
|
|
621
|
-
const starIndex = blobPath.indexOf("*");
|
|
622
|
-
prefix = blobPath.substring(0, starIndex);
|
|
623
|
-
extensionFilter = blobPath.substring(starIndex + 1); // e.g. ".parquet"
|
|
624
|
-
recursive = false;
|
|
625
|
-
} else {
|
|
626
|
-
// No glob — use blobPath as prefix (container-level listing)
|
|
627
|
-
prefix = blobPath;
|
|
628
|
-
recursive = true;
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
// Create ContainerClient with appropriate authentication
|
|
632
|
-
let containerClient: ContainerClient;
|
|
633
|
-
if (
|
|
634
|
-
azureConnection?.authType === "service_principal" &&
|
|
635
|
-
azureConnection.tenantId &&
|
|
636
|
-
azureConnection.clientId &&
|
|
637
|
-
azureConnection.clientSecret
|
|
638
|
-
) {
|
|
639
|
-
const credential = new ClientSecretCredential(
|
|
640
|
-
azureConnection.tenantId,
|
|
641
|
-
azureConnection.clientId,
|
|
642
|
-
azureConnection.clientSecret,
|
|
643
|
-
);
|
|
644
|
-
containerClient = new ContainerClient(
|
|
645
|
-
`${accountUrl}/${container}`,
|
|
646
|
-
credential,
|
|
647
|
-
);
|
|
648
|
-
} else {
|
|
649
|
-
// SAS token auth — append token to container URL
|
|
650
|
-
const containerUrl = `${accountUrl}/${container}${sasToken}`;
|
|
651
|
-
containerClient = new ContainerClient(containerUrl);
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
const matchingFiles: { url: string; blobName: string }[] = [];
|
|
655
|
-
for await (const blob of containerClient.listBlobsFlat({
|
|
656
|
-
prefix: prefix || undefined,
|
|
657
|
-
})) {
|
|
658
|
-
if (extensionFilter && !blob.name.endsWith(extensionFilter)) continue;
|
|
659
|
-
// For *.ext (non-recursive): only allow files directly in prefix dir
|
|
660
|
-
if (!recursive) {
|
|
661
|
-
const nameAfterPrefix = blob.name.substring(prefix.length);
|
|
662
|
-
if (nameAfterPrefix.includes("/")) continue;
|
|
663
|
-
}
|
|
664
|
-
if (!isDataFile(blob.name)) continue;
|
|
665
|
-
// For SPN: use abfss:// URLs that DuckDB's azure extension can read
|
|
666
|
-
// For SAS: use https:// URLs with token appended
|
|
667
|
-
let url: string;
|
|
668
|
-
if (azureConnection?.authType === "service_principal") {
|
|
669
|
-
const account =
|
|
670
|
-
azureConnection.accountName ||
|
|
671
|
-
accountUrl.split("//")[1]?.split(".")[0];
|
|
672
|
-
url = `abfss://${account}.dfs.core.windows.net/${container}/${blob.name}`;
|
|
673
|
-
} else {
|
|
674
|
-
url = `${accountUrl}/${container}/${blob.name}${sasToken}`;
|
|
675
|
-
}
|
|
676
|
-
matchingFiles.push({ url, blobName: blob.name });
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
logger.info(
|
|
680
|
-
`Listed ${matchingFiles.length} matching blobs in Azure container ${container} with prefix "${prefix}"`,
|
|
681
|
-
);
|
|
682
|
-
return matchingFiles;
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
function isDataFile(key: string): boolean {
|
|
686
|
-
const lowerKey = key.toLowerCase();
|
|
687
|
-
return (
|
|
688
|
-
lowerKey.endsWith(".csv") ||
|
|
689
|
-
lowerKey.endsWith(".parquet") ||
|
|
690
|
-
lowerKey.endsWith(".json") ||
|
|
691
|
-
lowerKey.endsWith(".jsonl") ||
|
|
692
|
-
lowerKey.endsWith(".ndjson")
|
|
693
|
-
);
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
async function describeRemoteFile(
|
|
697
|
-
malloyConnection: Connection,
|
|
698
|
-
fileUri: string,
|
|
699
|
-
): Promise<ApiTable> {
|
|
700
|
-
const pathWithoutQuery = fileUri.split("?")[0];
|
|
701
|
-
const fileType = getFileType(pathWithoutQuery);
|
|
702
|
-
|
|
703
|
-
let describeQuery: string;
|
|
704
|
-
switch (fileType) {
|
|
705
|
-
case "csv":
|
|
706
|
-
describeQuery = `DESCRIBE SELECT * FROM read_csv('${fileUri}', auto_detect=true) LIMIT 1`;
|
|
707
|
-
break;
|
|
708
|
-
case "parquet":
|
|
709
|
-
describeQuery = `DESCRIBE SELECT * FROM read_parquet('${fileUri}') LIMIT 1`;
|
|
710
|
-
break;
|
|
711
|
-
case "json":
|
|
712
|
-
describeQuery = `DESCRIBE SELECT * FROM read_json('${fileUri}', auto_detect=true) LIMIT 1`;
|
|
713
|
-
break;
|
|
714
|
-
case "jsonl":
|
|
715
|
-
describeQuery = `DESCRIBE SELECT * FROM read_json('${fileUri}', format='newline_delimited', auto_detect=true) LIMIT 1`;
|
|
716
|
-
break;
|
|
717
|
-
default:
|
|
718
|
-
logger.warn(`Unsupported file type for file: ${fileUri}`);
|
|
719
|
-
return { resource: fileUri, columns: [] };
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
const result = await malloyConnection.runSQL(describeQuery);
|
|
723
|
-
const rows = standardizeRunSQLResult(result);
|
|
724
|
-
const columns = rows.map((row: unknown) => {
|
|
725
|
-
const typedRow = row as Record<string, unknown>;
|
|
726
|
-
return {
|
|
727
|
-
name: (typedRow.column_name || typedRow.name) as string,
|
|
728
|
-
type: (typedRow.column_type || typedRow.type) as string,
|
|
729
|
-
};
|
|
730
|
-
});
|
|
731
|
-
|
|
732
|
-
const fileName = pathWithoutQuery.split("/").pop() || fileUri;
|
|
733
|
-
return { resource: fileName, columns };
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
function isAzureSingleFileUrl(fileUri: string): boolean {
|
|
737
|
-
const pathWithoutQuery = fileUri.split("?")[0];
|
|
738
|
-
// Has a glob — not a single file
|
|
739
|
-
if (pathWithoutQuery.includes("*")) return false;
|
|
740
|
-
// Ends with / — directory listing
|
|
741
|
-
if (pathWithoutQuery.endsWith("/")) return false;
|
|
742
|
-
// Check if the last path segment has a data file extension
|
|
743
|
-
const lastSegment = pathWithoutQuery.split("/").pop() || "";
|
|
744
|
-
return isDataFile(lastSegment);
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
async function describeAzureFile(
|
|
748
|
-
malloyConnection: Connection,
|
|
749
|
-
fileUri: string,
|
|
750
|
-
azureConnection?: ApiAzureConnection,
|
|
751
|
-
): Promise<ApiTable[]> {
|
|
752
|
-
try {
|
|
753
|
-
if (isAzureSingleFileUrl(fileUri)) {
|
|
754
|
-
// Single file — describe directly via DuckDB
|
|
755
|
-
return [await describeRemoteFile(malloyConnection, fileUri)];
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
// Glob pattern or container/directory URL — list blobs via Azure SDK
|
|
759
|
-
const blobs = await listAzureBlobs(fileUri, azureConnection);
|
|
760
|
-
if (blobs.length === 0) {
|
|
761
|
-
return [{ resource: fileUri, columns: [] }];
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
const results = await Promise.all(
|
|
765
|
-
blobs.map(async ({ url, blobName }) => {
|
|
766
|
-
try {
|
|
767
|
-
const table = await describeRemoteFile(malloyConnection, url);
|
|
768
|
-
return { ...table, resource: blobName };
|
|
769
|
-
} catch (error) {
|
|
770
|
-
logger.warn(`Failed to describe Azure blob: ${url}`, { error });
|
|
771
|
-
return { resource: blobName, columns: [] } as ApiTable;
|
|
772
|
-
}
|
|
773
|
-
}),
|
|
774
|
-
);
|
|
775
|
-
return results;
|
|
776
|
-
} catch (error) {
|
|
777
|
-
logger.error(`Failed to describe Azure file: ${fileUri}`, { error });
|
|
778
|
-
throw new Error(
|
|
779
|
-
`Failed to describe Azure file: ${error instanceof Error ? error.message : String(error)}`,
|
|
780
|
-
);
|
|
781
|
-
}
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
export async function listTablesForSchema(
|
|
785
|
-
connection: ApiConnection,
|
|
786
|
-
schemaName: string,
|
|
787
|
-
malloyConnection: Connection,
|
|
788
|
-
tableNames?: string[],
|
|
789
|
-
): Promise<ApiTable[]> {
|
|
790
|
-
switch (connection.type) {
|
|
791
|
-
case "bigquery":
|
|
792
|
-
return listTablesForBigQuery(
|
|
793
|
-
connection,
|
|
794
|
-
schemaName,
|
|
795
|
-
malloyConnection,
|
|
796
|
-
tableNames,
|
|
797
|
-
);
|
|
798
|
-
case "mysql":
|
|
799
|
-
return listTablesForMySQL(
|
|
800
|
-
connection,
|
|
801
|
-
schemaName,
|
|
802
|
-
malloyConnection,
|
|
803
|
-
tableNames,
|
|
804
|
-
);
|
|
805
|
-
case "postgres":
|
|
806
|
-
return listTablesForPostgres(
|
|
807
|
-
connection,
|
|
808
|
-
schemaName,
|
|
809
|
-
malloyConnection,
|
|
810
|
-
tableNames,
|
|
811
|
-
);
|
|
812
|
-
case "snowflake":
|
|
813
|
-
return listTablesForSnowflake(
|
|
814
|
-
connection,
|
|
815
|
-
schemaName,
|
|
816
|
-
malloyConnection,
|
|
817
|
-
tableNames,
|
|
818
|
-
);
|
|
819
|
-
case "trino":
|
|
820
|
-
return listTablesForTrino(
|
|
821
|
-
connection,
|
|
822
|
-
schemaName,
|
|
823
|
-
malloyConnection,
|
|
824
|
-
tableNames,
|
|
825
|
-
);
|
|
826
|
-
case "duckdb":
|
|
827
|
-
return listTablesForDuckDB(
|
|
828
|
-
connection,
|
|
829
|
-
schemaName,
|
|
830
|
-
malloyConnection,
|
|
831
|
-
tableNames,
|
|
832
|
-
);
|
|
833
|
-
case "motherduck":
|
|
834
|
-
return listTablesForMotherDuck(
|
|
835
|
-
connection,
|
|
836
|
-
schemaName,
|
|
837
|
-
malloyConnection,
|
|
838
|
-
tableNames,
|
|
839
|
-
);
|
|
840
|
-
case "ducklake":
|
|
841
|
-
return listTablesForDuckLake(
|
|
842
|
-
connection,
|
|
843
|
-
schemaName,
|
|
844
|
-
malloyConnection,
|
|
845
|
-
tableNames,
|
|
846
|
-
);
|
|
847
|
-
default:
|
|
848
|
-
throw new Error(`Unsupported connection type: ${connection.type}`);
|
|
849
|
-
}
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
/**
|
|
853
|
-
* BigQuery: list tables via API client, then fetch each table's schema
|
|
854
|
-
* individually since BigQuery's INFORMATION_SCHEMA is region-scoped.
|
|
855
|
-
*/
|
|
856
|
-
async function listTablesForBigQuery(
|
|
857
|
-
connection: ApiConnection,
|
|
858
|
-
schemaName: string,
|
|
859
|
-
malloyConnection: Connection,
|
|
860
|
-
tableNames?: string[],
|
|
861
|
-
): Promise<ApiTable[]> {
|
|
862
|
-
try {
|
|
863
|
-
const bigquery = createBigQueryClient(connection);
|
|
864
|
-
const dataset = bigquery.dataset(schemaName);
|
|
865
|
-
const [tables] = await dataset.getTables();
|
|
866
|
-
|
|
867
|
-
let names = tables
|
|
868
|
-
.map((table) => table.id)
|
|
869
|
-
.filter((id): id is string => id !== undefined);
|
|
870
|
-
if (tableNames) {
|
|
871
|
-
const allowed = new Set(tableNames);
|
|
872
|
-
names = names.filter((id) => allowed.has(id));
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
const results = await Promise.all(
|
|
876
|
-
names.map(async (tableName) => {
|
|
877
|
-
const tablePath = `${schemaName}.${tableName}`;
|
|
878
|
-
try {
|
|
879
|
-
const source = await (
|
|
880
|
-
malloyConnection as Connection & {
|
|
881
|
-
fetchTableSchema: (
|
|
882
|
-
tableKey: string,
|
|
883
|
-
tablePath: string,
|
|
884
|
-
) => Promise<TableSourceDef | undefined>;
|
|
885
|
-
}
|
|
886
|
-
).fetchTableSchema(tableName, tablePath);
|
|
887
|
-
const columns =
|
|
888
|
-
source?.fields?.map((field) => ({
|
|
889
|
-
name: field.name,
|
|
890
|
-
type: field.type,
|
|
891
|
-
})) || [];
|
|
892
|
-
return { resource: tablePath, columns };
|
|
893
|
-
} catch (error) {
|
|
894
|
-
logger.warn(`Failed to get schema for table ${tableName}`, {
|
|
895
|
-
error: extractErrorDataFromError(error),
|
|
896
|
-
schemaName,
|
|
897
|
-
tableName,
|
|
898
|
-
});
|
|
899
|
-
return { resource: tablePath, columns: [] };
|
|
900
|
-
}
|
|
901
|
-
}),
|
|
902
|
-
);
|
|
903
|
-
return results;
|
|
904
|
-
} catch (error) {
|
|
905
|
-
logger.error(
|
|
906
|
-
`Error getting tables for BigQuery schema ${schemaName} in connection ${connection.name}`,
|
|
907
|
-
{ error },
|
|
908
|
-
);
|
|
909
|
-
throw new Error(
|
|
910
|
-
`Failed to get tables for BigQuery schema ${schemaName} in connection ${connection.name}: ${(error as Error).message}`,
|
|
911
|
-
);
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
async function listTablesForMySQL(
|
|
916
|
-
connection: ApiConnection,
|
|
917
|
-
schemaName: string,
|
|
918
|
-
malloyConnection: Connection,
|
|
919
|
-
tableNames?: string[],
|
|
920
|
-
): Promise<ApiTable[]> {
|
|
921
|
-
if (!connection.mysqlConnection) {
|
|
922
|
-
throw new Error("Mysql connection is required");
|
|
923
|
-
}
|
|
924
|
-
try {
|
|
925
|
-
const result = await malloyConnection.runSQL(
|
|
926
|
-
`SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE FROM information_schema.columns WHERE table_schema = '${schemaName}' ${sqlInFilter("TABLE_NAME", tableNames)} ORDER BY TABLE_NAME, ORDINAL_POSITION`,
|
|
927
|
-
);
|
|
928
|
-
const rows = standardizeRunSQLResult(result);
|
|
929
|
-
return groupColumnRowsIntoTables(rows, (t) => `${schemaName}.${t}`);
|
|
930
|
-
} catch (error) {
|
|
931
|
-
logger.error(
|
|
932
|
-
`Error getting tables for MySQL schema ${schemaName} in connection ${connection.name}`,
|
|
933
|
-
{ error },
|
|
934
|
-
);
|
|
935
|
-
throw new Error(
|
|
936
|
-
`Failed to get tables for MySQL schema ${schemaName} in connection ${connection.name}: ${(error as Error).message}`,
|
|
937
|
-
);
|
|
938
|
-
}
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
async function listTablesForPostgres(
|
|
942
|
-
connection: ApiConnection,
|
|
943
|
-
schemaName: string,
|
|
944
|
-
malloyConnection: Connection,
|
|
945
|
-
tableNames?: string[],
|
|
946
|
-
): Promise<ApiTable[]> {
|
|
947
|
-
if (!connection.postgresConnection) {
|
|
948
|
-
throw new Error("Postgres connection is required");
|
|
949
|
-
}
|
|
950
|
-
try {
|
|
951
|
-
// Wrap in row_to_json because the Malloy Postgres driver's runSQL
|
|
952
|
-
// de-JSONs each row via row.row (matching Malloy-generated queries).
|
|
953
|
-
const result = await malloyConnection.runSQL(
|
|
954
|
-
`SELECT row_to_json(t) as row FROM (SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_schema = '${schemaName}' ${sqlInFilter("table_name", tableNames)} ORDER BY table_name, ordinal_position) t`,
|
|
955
|
-
);
|
|
956
|
-
const rows = standardizeRunSQLResult(result);
|
|
957
|
-
return groupColumnRowsIntoTables(rows, (t) => `${schemaName}.${t}`);
|
|
958
|
-
} catch (error) {
|
|
959
|
-
logger.error(
|
|
960
|
-
`Error getting tables for Postgres schema ${schemaName} in connection ${connection.name}`,
|
|
961
|
-
{ error },
|
|
962
|
-
);
|
|
963
|
-
throw new Error(
|
|
964
|
-
`Failed to get tables for Postgres schema ${schemaName} in connection ${connection.name}: ${(error as Error).message}`,
|
|
965
|
-
);
|
|
966
|
-
}
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
async function listTablesForSnowflake(
|
|
970
|
-
connection: ApiConnection,
|
|
971
|
-
schemaName: string,
|
|
972
|
-
malloyConnection: Connection,
|
|
973
|
-
tableNames?: string[],
|
|
974
|
-
): Promise<ApiTable[]> {
|
|
975
|
-
if (!connection.snowflakeConnection) {
|
|
976
|
-
throw new Error("Snowflake connection is required");
|
|
977
|
-
}
|
|
978
|
-
try {
|
|
979
|
-
const parts = schemaName.split(".");
|
|
980
|
-
let databaseName: string;
|
|
981
|
-
let schemaOnly: string;
|
|
982
|
-
|
|
983
|
-
if (parts.length >= 2) {
|
|
984
|
-
databaseName = parts[0];
|
|
985
|
-
schemaOnly = parts[1];
|
|
986
|
-
} else {
|
|
987
|
-
databaseName = connection.snowflakeConnection.database ?? "";
|
|
988
|
-
schemaOnly = parts[0];
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
if (!databaseName) {
|
|
992
|
-
throw new Error(
|
|
993
|
-
`Cannot resolve database for schema "${schemaName}": provide DATABASE.SCHEMA or configure a database on the connection`,
|
|
994
|
-
);
|
|
995
|
-
}
|
|
996
|
-
|
|
997
|
-
const qualifiedSchema = `${databaseName}.${schemaOnly}`;
|
|
998
|
-
const result = await malloyConnection.runSQL(
|
|
999
|
-
`SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE FROM ${databaseName}.INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = '${schemaOnly}' ${sqlInFilter("TABLE_NAME", tableNames)} ORDER BY TABLE_NAME, ORDINAL_POSITION`,
|
|
1000
|
-
);
|
|
1001
|
-
const rows = standardizeRunSQLResult(result);
|
|
1002
|
-
return groupColumnRowsIntoTables(rows, (t) => `${qualifiedSchema}.${t}`);
|
|
1003
|
-
} catch (error) {
|
|
1004
|
-
logger.error(
|
|
1005
|
-
`Error getting tables for Snowflake schema ${schemaName} in connection ${connection.name}`,
|
|
1006
|
-
{ error },
|
|
1007
|
-
);
|
|
1008
|
-
throw new Error(
|
|
1009
|
-
`Failed to get tables for Snowflake schema ${schemaName} in connection ${connection.name}: ${(error as Error).message}`,
|
|
1010
|
-
);
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
async function listTablesForTrino(
|
|
1015
|
-
connection: ApiConnection,
|
|
1016
|
-
schemaName: string,
|
|
1017
|
-
malloyConnection: Connection,
|
|
1018
|
-
tableNames?: string[],
|
|
1019
|
-
): Promise<ApiTable[]> {
|
|
1020
|
-
if (!connection.trinoConnection) {
|
|
1021
|
-
throw new Error("Trino connection is required");
|
|
1022
|
-
}
|
|
1023
|
-
try {
|
|
1024
|
-
let catalogPrefix: string;
|
|
1025
|
-
let schemaOnly: string;
|
|
1026
|
-
let resourcePrefix: string;
|
|
1027
|
-
|
|
1028
|
-
if (connection.trinoConnection.catalog) {
|
|
1029
|
-
catalogPrefix = `${connection.trinoConnection.catalog}.`;
|
|
1030
|
-
schemaOnly = schemaName;
|
|
1031
|
-
resourcePrefix = `${connection.trinoConnection.catalog}.${schemaName}`;
|
|
1032
|
-
} else {
|
|
1033
|
-
const dotIdx = schemaName.indexOf(".");
|
|
1034
|
-
if (dotIdx > 0) {
|
|
1035
|
-
catalogPrefix = `${schemaName.substring(0, dotIdx)}.`;
|
|
1036
|
-
schemaOnly = schemaName.substring(dotIdx + 1);
|
|
1037
|
-
} else {
|
|
1038
|
-
catalogPrefix = "";
|
|
1039
|
-
schemaOnly = schemaName;
|
|
1040
|
-
}
|
|
1041
|
-
resourcePrefix = schemaName;
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
const result = await malloyConnection.runSQL(
|
|
1045
|
-
`SELECT table_name, column_name, data_type FROM ${catalogPrefix}information_schema.columns WHERE table_schema = '${schemaOnly}' ${sqlInFilter("table_name", tableNames)} ORDER BY table_name, ordinal_position`,
|
|
1046
|
-
);
|
|
1047
|
-
const rows = standardizeRunSQLResult(result);
|
|
1048
|
-
return groupColumnRowsIntoTables(rows, (t) => `${resourcePrefix}.${t}`);
|
|
1049
|
-
} catch (error) {
|
|
1050
|
-
logger.error(
|
|
1051
|
-
`Error getting tables for Trino schema ${schemaName} in connection ${connection.name}`,
|
|
1052
|
-
{ error },
|
|
1053
|
-
);
|
|
1054
|
-
throw new Error(
|
|
1055
|
-
`Failed to get tables for Trino schema ${schemaName} in connection ${connection.name}: ${(error as Error).message}`,
|
|
1056
|
-
);
|
|
1057
|
-
}
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
async function listTablesForDuckDB(
|
|
1061
|
-
connection: ApiConnection,
|
|
1062
|
-
schemaName: string,
|
|
1063
|
-
malloyConnection: Connection,
|
|
1064
|
-
tableNames?: string[],
|
|
1065
|
-
): Promise<ApiTable[]> {
|
|
1066
|
-
if (!connection.duckdbConnection) {
|
|
1067
|
-
throw new Error("DuckDB connection is required");
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
const attachedDbs = connection.duckdbConnection.attachedDatabases || [];
|
|
1071
|
-
|
|
1072
|
-
// Azure attached database matched by name
|
|
1073
|
-
const azureDb = attachedDbs.find(
|
|
1074
|
-
(db) =>
|
|
1075
|
-
db.type === "azure" && db.name === schemaName && db.azureConnection,
|
|
1076
|
-
);
|
|
1077
|
-
if (azureDb) {
|
|
1078
|
-
const azureConn = azureDb.azureConnection!;
|
|
1079
|
-
const fileUrl =
|
|
1080
|
-
azureConn.authType === "sas_token"
|
|
1081
|
-
? azureConn.sasUrl
|
|
1082
|
-
: azureConn.fileUrl;
|
|
1083
|
-
if (fileUrl) {
|
|
1084
|
-
return describeAzureFile(malloyConnection, fileUrl, azureConn);
|
|
1085
|
-
}
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
// Azure ADLS file path (abfss://, https://, az://)
|
|
1089
|
-
if (
|
|
1090
|
-
schemaName.startsWith("abfss://") ||
|
|
1091
|
-
schemaName.startsWith("https://") ||
|
|
1092
|
-
schemaName.startsWith("az://")
|
|
1093
|
-
) {
|
|
1094
|
-
return describeAzureFile(malloyConnection, schemaName);
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
// Cloud storage (GCS/S3)
|
|
1098
|
-
const parsedUri = parseCloudUri(schemaName);
|
|
1099
|
-
if (parsedUri) {
|
|
1100
|
-
const {
|
|
1101
|
-
type: cloudType,
|
|
1102
|
-
bucket: bucketName,
|
|
1103
|
-
path: directoryPath,
|
|
1104
|
-
} = parsedUri;
|
|
1105
|
-
const credentials = getCloudCredentialsFromAttachedDatabases(
|
|
1106
|
-
attachedDbs,
|
|
1107
|
-
cloudType,
|
|
1108
|
-
);
|
|
1109
|
-
if (!credentials) {
|
|
1110
|
-
throw new Error(
|
|
1111
|
-
`${cloudType.toUpperCase()} credentials not found in attached databases`,
|
|
1112
|
-
);
|
|
1113
|
-
}
|
|
1114
|
-
const fileKeys = await listDataFilesInDirectory(
|
|
1115
|
-
credentials,
|
|
1116
|
-
bucketName,
|
|
1117
|
-
directoryPath,
|
|
1118
|
-
);
|
|
1119
|
-
return getCloudTablesWithColumns(
|
|
1120
|
-
malloyConnection,
|
|
1121
|
-
credentials,
|
|
1122
|
-
bucketName,
|
|
1123
|
-
fileKeys,
|
|
1124
|
-
);
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
// Regular DuckDB schema — query information_schema.columns
|
|
1128
|
-
const dotIdx = schemaName.indexOf(".");
|
|
1129
|
-
if (dotIdx < 0) {
|
|
1130
|
-
throw new Error(
|
|
1131
|
-
`DuckDB schema name must be qualified as "catalog.schema", got "${schemaName}"`,
|
|
1132
|
-
);
|
|
1133
|
-
}
|
|
1134
|
-
const catalogName = schemaName.substring(0, dotIdx);
|
|
1135
|
-
const actualSchemaName = schemaName.substring(dotIdx + 1);
|
|
1136
|
-
|
|
1137
|
-
try {
|
|
1138
|
-
const result = await malloyConnection.runSQL(
|
|
1139
|
-
`SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_schema = '${actualSchemaName}' AND table_catalog = '${catalogName}' ${sqlInFilter("table_name", tableNames)} ORDER BY table_name, ordinal_position`,
|
|
1140
|
-
);
|
|
1141
|
-
const rows = standardizeRunSQLResult(result);
|
|
1142
|
-
return groupColumnRowsIntoTables(rows, (t) => `${schemaName}.${t}`);
|
|
1143
|
-
} catch (error) {
|
|
1144
|
-
logger.error(
|
|
1145
|
-
`Error getting tables for DuckDB schema ${schemaName} in connection ${connection.name}`,
|
|
1146
|
-
{ error },
|
|
1147
|
-
);
|
|
1148
|
-
throw new Error(
|
|
1149
|
-
`Failed to get tables for DuckDB schema ${schemaName} in connection ${connection.name}: ${(error as Error).message}`,
|
|
1150
|
-
);
|
|
1151
|
-
}
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
|
-
async function listTablesForMotherDuck(
|
|
1155
|
-
connection: ApiConnection,
|
|
1156
|
-
schemaName: string,
|
|
1157
|
-
malloyConnection: Connection,
|
|
1158
|
-
tableNames?: string[],
|
|
1159
|
-
): Promise<ApiTable[]> {
|
|
1160
|
-
if (!connection.motherduckConnection) {
|
|
1161
|
-
throw new Error("MotherDuck connection is required");
|
|
1162
|
-
}
|
|
1163
|
-
try {
|
|
1164
|
-
const result = await malloyConnection.runSQL(
|
|
1165
|
-
`SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_schema = '${schemaName}' ${sqlInFilter("table_name", tableNames)} ORDER BY table_name, ordinal_position`,
|
|
1166
|
-
);
|
|
1167
|
-
const rows = standardizeRunSQLResult(result);
|
|
1168
|
-
return groupColumnRowsIntoTables(rows, (t) => `${schemaName}.${t}`);
|
|
1169
|
-
} catch (error) {
|
|
1170
|
-
logger.error(
|
|
1171
|
-
`Error getting tables for MotherDuck schema ${schemaName} in connection ${connection.name}`,
|
|
1172
|
-
{ error },
|
|
1173
|
-
);
|
|
1174
|
-
throw new Error(
|
|
1175
|
-
`Failed to get tables for MotherDuck schema ${schemaName} in connection ${connection.name}: ${(error as Error).message}`,
|
|
1176
|
-
);
|
|
1177
|
-
}
|
|
1178
|
-
}
|
|
1179
|
-
|
|
1180
|
-
async function listTablesForDuckLake(
|
|
1181
|
-
connection: ApiConnection,
|
|
1182
|
-
schemaName: string,
|
|
1183
|
-
malloyConnection: Connection,
|
|
1184
|
-
tableNames?: string[],
|
|
1185
|
-
): Promise<ApiTable[]> {
|
|
1186
|
-
// Prefix bare schema names with the catalog (connection) name.
|
|
1187
|
-
// Two-part names like "catalog.schema" are already qualified.
|
|
1188
|
-
if (!schemaName.includes(".")) {
|
|
1189
|
-
schemaName = `${connection.name}.${schemaName}`;
|
|
1190
|
-
}
|
|
1191
|
-
|
|
1192
|
-
const catalogName = schemaName.split(".")[0];
|
|
1193
|
-
const actualSchemaName = schemaName.split(".")[1];
|
|
1194
|
-
try {
|
|
1195
|
-
const result = await malloyConnection.runSQL(
|
|
1196
|
-
`SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_schema = '${actualSchemaName}' AND table_catalog = '${catalogName}' ${sqlInFilter("table_name", tableNames)} ORDER BY table_name, ordinal_position`,
|
|
1197
|
-
);
|
|
1198
|
-
const rows = standardizeRunSQLResult(result);
|
|
1199
|
-
return groupColumnRowsIntoTables(rows, (t) => `${schemaName}.${t}`);
|
|
1200
|
-
} catch (error) {
|
|
1201
|
-
logger.error(
|
|
1202
|
-
`Error getting tables for DuckLake schema ${schemaName} in connection ${connection.name}`,
|
|
1203
|
-
{ error },
|
|
1204
|
-
);
|
|
1205
|
-
throw new Error(
|
|
1206
|
-
`Failed to get tables for DuckLake schema ${schemaName} in connection ${connection.name}: ${(error as Error).message}`,
|
|
1207
|
-
);
|
|
1208
|
-
}
|
|
1209
|
-
}
|
|
1210
|
-
|
|
1211
|
-
export function extractErrorDataFromError(error: unknown): {
|
|
1212
|
-
error: string;
|
|
1213
|
-
stack?: string;
|
|
1214
|
-
task?: unknown;
|
|
1215
|
-
} {
|
|
1216
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1217
|
-
const errorData: { error: string; stack?: string; task?: unknown } = {
|
|
1218
|
-
error: errorMessage,
|
|
1219
|
-
};
|
|
1220
|
-
if (error instanceof Error && logger.level === "debug") {
|
|
1221
|
-
errorData.stack = error.stack;
|
|
1222
|
-
}
|
|
1223
|
-
if (error && typeof error === "object" && "task" in error) {
|
|
1224
|
-
errorData.task = (error as { task?: unknown }).task;
|
|
1225
|
-
}
|
|
1226
|
-
return errorData;
|
|
1227
|
-
}
|