@malloydata/db-bigquery 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,7 @@
1
+ # Malloy
2
+
3
+ Malloy is an experimental language for describing data relationships and transformations. It is both a semantic modeling language and a querying language that runs queries against a relational database. Malloy currently connects to BigQuery and Postgres, and natively supports DuckDB. We've built a Visual Studio Code extension to facilitate building Malloy data models, querying and transforming data, and creating simple visualizations and dashboards.
4
+
5
+ ## This package
6
+
7
+ This package facilitates using the `malloydata/malloy` library with BigQuery - see [here](https://github.com/looker-open-source/malloy/blob/main/packages/malloy/README.md) for additional information.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,148 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || function (mod) {
19
+ if (mod && mod.__esModule) return mod;
20
+ var result = {};
21
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
+ __setModuleDefault(result, mod);
23
+ return result;
24
+ };
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ const malloy = __importStar(require("@malloydata/malloy"));
27
+ const bigquery_connection_1 = require("./bigquery_connection");
28
+ const bigquery_1 = require("@google-cloud/bigquery");
29
+ const util = __importStar(require("util"));
30
+ const fs = __importStar(require("fs"));
31
+ const url_1 = require("url");
32
+ describe("db:BigQuery", () => {
33
+ let bq;
34
+ let runtime;
35
+ beforeAll(() => {
36
+ bq = new bigquery_connection_1.BigQueryConnection("test");
37
+ const files = {
38
+ readURL: async (url) => {
39
+ const filePath = (0, url_1.fileURLToPath)(url);
40
+ return await util.promisify(fs.readFile)(filePath, "utf8");
41
+ },
42
+ };
43
+ runtime = new malloy.Runtime(files, bq);
44
+ });
45
+ it("runs a SQL query", async () => {
46
+ const res = await bq.runSQL(`SELECT 1 as t`);
47
+ expect(res.rows[0]["t"]).toBe(1);
48
+ });
49
+ it("costs a SQL query", async () => {
50
+ const res = await bq.costQuery(`SELECT * FROM malloy-data.faa.airports`);
51
+ expect(res).toBe(3029200);
52
+ });
53
+ it("gets table schema", async () => {
54
+ const res = await bq.getTableFieldSchema(`malloy-data.faa.carriers`);
55
+ expect(res.schema).toStrictEqual({
56
+ fields: [
57
+ { name: "code", type: "STRING" },
58
+ { name: "name", type: "STRING" },
59
+ { name: "nickname", type: "STRING" },
60
+ ],
61
+ });
62
+ });
63
+ it.todo("gets table structdefs");
64
+ it("runs a Malloy query", async () => {
65
+ const sql = await runtime
66
+ .loadModel("explore: carriers is table('malloy-data.faa.carriers') { measure: carrier_count is count() }")
67
+ .loadQuery("query: carriers -> { aggregate: carrier_count }")
68
+ .getSQL();
69
+ const res = await bq.runSQL(sql);
70
+ expect(res.rows[0]["carrier_count"]).toBe(21);
71
+ });
72
+ it("streams a Malloy query for download", async () => {
73
+ const sql = await runtime
74
+ .loadModel("explore: carriers is table('malloy-data.faa.carriers') { measure: carrier_count is count() }")
75
+ .loadQuery("query: carriers -> { group_by: name }")
76
+ .getSQL();
77
+ const res = await bq.downloadMalloyQuery(sql);
78
+ return new Promise((resolve) => {
79
+ let count = 0;
80
+ res.on("data", () => (count += 1));
81
+ res.on("end", () => {
82
+ expect(count).toBe(21);
83
+ resolve(true);
84
+ });
85
+ });
86
+ });
87
+ it("manifests a temporary table", async () => {
88
+ const fullTempTableName = await bq.manifestTemporaryTable("SELECT 1 as t");
89
+ const splitTableName = fullTempTableName.split(".");
90
+ const sdk = new bigquery_1.BigQuery();
91
+ const dataset = sdk.dataset(splitTableName[1]);
92
+ const table = dataset.table(splitTableName[2]);
93
+ const exists = await table.exists();
94
+ expect(exists).toBeTruthy();
95
+ });
96
+ describe.skip("manifests permanent table", () => {
97
+ const datasetName = "test_malloy_test_dataset";
98
+ const tableName = "test_malloy_test_table";
99
+ const sdk = new bigquery_1.BigQuery();
100
+ // delete entire dataset before each test and once tests are complete
101
+ const deleteTestDataset = async () => {
102
+ const dataset = sdk.dataset(datasetName);
103
+ if ((await dataset.exists())[0]) {
104
+ await dataset.delete({
105
+ force: true,
106
+ });
107
+ }
108
+ };
109
+ beforeEach(deleteTestDataset);
110
+ afterAll(deleteTestDataset);
111
+ it("throws if dataset does not exist and createDataset=false", async () => {
112
+ await expect(async () => {
113
+ await bq.manifestPermanentTable("SELECT 1 as t", datasetName, tableName, false, false);
114
+ }).rejects.toThrowError(`Dataset ${datasetName} does not exist`);
115
+ });
116
+ it("creates dataset if createDataset=true", async () => {
117
+ // note - dataset does not exist b/c of beforeEach()
118
+ await bq.manifestPermanentTable("SELECT 1 as t", datasetName, tableName, false, true);
119
+ const dataset = sdk.dataset(datasetName);
120
+ const [exists] = await dataset.exists();
121
+ expect(exists).toBeTruthy();
122
+ });
123
+ it("throws if table exist and overwriteExistingTable=false", async () => {
124
+ const newDatasetResponse = await sdk.createDataset(datasetName);
125
+ const dataset = newDatasetResponse[0];
126
+ const tableMeta = { name: tableName };
127
+ await dataset.createTable(tableName, tableMeta);
128
+ await expect(async () => {
129
+ await bq.manifestPermanentTable("SELECT 1 as t", datasetName, tableName, false, true);
130
+ }).rejects.toThrowError(`Table ${tableName} already exists`);
131
+ });
132
+ it("manifests a table", async () => {
133
+ const jobId = await bq.manifestPermanentTable("SELECT 1 as t", datasetName, tableName, false, true);
134
+ // wait for job to complete
135
+ const [job] = await sdk.job(jobId).get();
136
+ let [metaData] = await job.getMetadata();
137
+ while (metaData.status.state !== "DONE") {
138
+ await new Promise((resolve) => setTimeout(resolve, 1000));
139
+ [metaData] = await job.getMetadata();
140
+ }
141
+ // query the new table
142
+ const [queryJob] = await sdk.createQueryJob(`SELECT * FROM ${datasetName}.${tableName}`);
143
+ const [results] = await queryJob.getQueryResults();
144
+ expect(results[0]).toStrictEqual({ t: 1 });
145
+ });
146
+ });
147
+ });
148
+ //# sourceMappingURL=bigquery.spec.js.map
@@ -0,0 +1,91 @@
1
+ import { RowMetadata } from "@google-cloud/bigquery";
2
+ import bigquery from "@google-cloud/bigquery/build/src/types";
3
+ import { ResourceStream } from "@google-cloud/paginator";
4
+ import { QueryData, StructDef, MalloyQueryData, FieldTypeDef, SQLBlock, Connection, QueryDataRow } from "@malloydata/malloy";
5
+ import { PooledConnection } from "@malloydata/malloy";
6
+ import { FetchSchemaAndRunSimultaneously, FetchSchemaAndRunStreamSimultaneously, PersistSQLResults, StreamingConnection } from "@malloydata/malloy/src/runtime_types";
7
+ export interface BigQueryManagerOptions {
8
+ credentials?: {
9
+ clientId: string;
10
+ clientSecret: string;
11
+ refreshToken: string | null;
12
+ };
13
+ projectId?: string | undefined;
14
+ userAgent: string;
15
+ }
16
+ export interface BigQueryQueryOptions {
17
+ rowLimit: number;
18
+ }
19
+ interface BigQueryConnectionConfiguration {
20
+ defaultProject?: string;
21
+ serviceAccountKeyPath?: string;
22
+ location?: string;
23
+ maximumBytesBilled?: string;
24
+ timeoutMs?: string;
25
+ }
26
+ interface SchemaInfo {
27
+ schema: bigquery.ITableFieldSchema;
28
+ needsPartitionPsuedoColumn: boolean;
29
+ }
30
+ declare type QueryOptionsReader = Partial<BigQueryQueryOptions> | (() => Partial<BigQueryQueryOptions>);
31
+ export declare class BigQueryAuthenticationError extends Error {
32
+ constructor(message: string);
33
+ }
34
+ export declare class BigQueryConnection implements Connection, PersistSQLResults, StreamingConnection, FetchSchemaAndRunSimultaneously {
35
+ static DEFAULT_QUERY_OPTIONS: BigQueryQueryOptions;
36
+ private bigQuery;
37
+ private projectId;
38
+ private temporaryTables;
39
+ private defaultProject;
40
+ private schemaCache;
41
+ private sqlSchemaCache;
42
+ private queryOptions?;
43
+ private config;
44
+ private location;
45
+ readonly name: string;
46
+ bqToMalloyTypes: {
47
+ [key: string]: Partial<FieldTypeDef>;
48
+ };
49
+ constructor(name: string, queryOptions?: QueryOptionsReader, config?: BigQueryConnectionConfiguration);
50
+ get dialectName(): string;
51
+ private readQueryOptions;
52
+ isPool(): this is PooledConnection;
53
+ canPersist(): this is PersistSQLResults;
54
+ canStream(): this is StreamingConnection;
55
+ canFetchSchemaAndRunSimultaneously(): this is FetchSchemaAndRunSimultaneously;
56
+ canFetchSchemaAndRunStreamSimultaneously(): this is FetchSchemaAndRunStreamSimultaneously;
57
+ private _runSQL;
58
+ runSQL(sqlCommand: string, options?: Partial<BigQueryQueryOptions>, rowIndex?: number): Promise<MalloyQueryData>;
59
+ runSQLBlockAndFetchResultSchema(sqlBlock: SQLBlock, options?: {
60
+ rowLimit?: number | undefined;
61
+ }): Promise<{
62
+ data: MalloyQueryData;
63
+ schema: StructDef;
64
+ }>;
65
+ downloadMalloyQuery(sqlCommand: string): Promise<ResourceStream<RowMetadata>>;
66
+ private dryRunSQLQuery;
67
+ costQuery(sqlCommand: string): Promise<number>;
68
+ structDefFromSQL(sqlCommand: string): Promise<StructDef>;
69
+ getTableFieldSchema(tableURL: string): Promise<SchemaInfo>;
70
+ executeSQLRaw(sqlCommand: string): Promise<QueryData>;
71
+ test(): Promise<void>;
72
+ manifestTemporaryTable(sqlCommand: string): Promise<string>;
73
+ manifestPermanentTable(sqlCommand: string, datasetName: string, tableName: string, overwriteExistingTable?: boolean, createDataset?: boolean): Promise<string>;
74
+ private addFieldsToStructDef;
75
+ private tableURLtoTablePath;
76
+ private structDefFromTableSchema;
77
+ private structDefFromSQLSchema;
78
+ fetchSchemaForTables(missing: string[]): Promise<{
79
+ schemas: Record<string, StructDef>;
80
+ errors: Record<string, string>;
81
+ }>;
82
+ private getSQLBlockSchema;
83
+ fetchSchemaForSQLBlocks(sqlRefs: SQLBlock[]): Promise<{
84
+ schemas: Record<string, StructDef>;
85
+ errors: Record<string, string>;
86
+ }>;
87
+ private createBigQueryJobAndGetResults;
88
+ private createBigQueryJob;
89
+ runSQLStream(sqlCommand: string, options?: Partial<BigQueryQueryOptions>): AsyncIterableIterator<QueryDataRow>;
90
+ }
91
+ export {};
@@ -0,0 +1,577 @@
1
+ "use strict";
2
+ /*
3
+ * Copyright 2022 Google LLC
4
+ *
5
+ * This program is free software; you can redistribute it and/or
6
+ * modify it under the terms of the GNU General Public License
7
+ * version 2 as published by the Free Software Foundation.
8
+ *
9
+ * This program is distributed in the hope that it will be useful,
10
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ * GNU General Public License for more details.
13
+ */
14
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
15
+ if (k2 === undefined) k2 = k;
16
+ var desc = Object.getOwnPropertyDescriptor(m, k);
17
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
18
+ desc = { enumerable: true, get: function() { return m[k]; } };
19
+ }
20
+ Object.defineProperty(o, k2, desc);
21
+ }) : (function(o, m, k, k2) {
22
+ if (k2 === undefined) k2 = k;
23
+ o[k2] = m[k];
24
+ }));
25
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
26
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
27
+ }) : function(o, v) {
28
+ o["default"] = v;
29
+ });
30
+ var __importStar = (this && this.__importStar) || function (mod) {
31
+ if (mod && mod.__esModule) return mod;
32
+ var result = {};
33
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
34
+ __setModuleDefault(result, mod);
35
+ return result;
36
+ };
37
+ Object.defineProperty(exports, "__esModule", { value: true });
38
+ exports.BigQueryConnection = exports.BigQueryAuthenticationError = void 0;
39
+ const crypto = __importStar(require("crypto"));
40
+ const bigquery_1 = require("@google-cloud/bigquery");
41
+ const googleCommon = __importStar(require("@google-cloud/common"));
42
+ const gaxios_1 = require("gaxios");
43
+ const malloy_1 = require("@malloydata/malloy");
44
+ const malloy_2 = require("@malloydata/malloy");
45
+ // BigQuery SDK apparently throws various authentication errors from Gaxios (https://github.com/googleapis/gaxios) and from
46
+ // @google-cloud/common (https://www.npmjs.com/package/@google-cloud/common)
47
+ // catching and rewriting here a single kind of authentication error we can consistently catch further up the stack
48
+ class BigQueryAuthenticationError extends Error {
49
+ constructor(message) {
50
+ super(message);
51
+ this.name = "BigQueryAuthenticationError";
52
+ }
53
+ }
54
+ exports.BigQueryAuthenticationError = BigQueryAuthenticationError;
55
+ const maybeRewriteError = (e) => {
56
+ // GaxiosError happens if credentials are revoked (for example, client.revokeCredentials()) or if
57
+ // the refresh token is invalid
58
+ // ApiErrors happen if token is revoked (for example, client.revokeToken(creds.access_token!))
59
+ if (e instanceof Error) {
60
+ if ((e instanceof gaxios_1.GaxiosError && e.code === "400") ||
61
+ (e instanceof googleCommon.ApiError && e.code === 401)) {
62
+ return new BigQueryAuthenticationError(e.message);
63
+ }
64
+ else
65
+ return e;
66
+ }
67
+ else {
68
+ // something throw a non-Error, and we didn't expect that
69
+ throw e;
70
+ }
71
+ };
72
+ /**
73
+ * Default maximumBytesBilled value, 25GiB
74
+ */
75
+ const MAXIMUM_BYTES_BILLED = String(25 * 1024 * 1024 * 1024);
76
+ /**
77
+ * Default timeoutMs value, 10 Mins
78
+ */
79
+ const TIMEOUT_MS = 1000 * 60 * 10;
80
+ // manage access to BQ, control costs, enforce global data/API limits
81
+ class BigQueryConnection {
82
+ constructor(name, queryOptions, config = {}) {
83
+ this.temporaryTables = new Map();
84
+ this.schemaCache = new Map();
85
+ this.sqlSchemaCache = new Map();
86
+ this.bqToMalloyTypes = {
87
+ DATE: { type: "date" },
88
+ STRING: { type: "string" },
89
+ INTEGER: { type: "number", numberType: "integer" },
90
+ INT64: { type: "number", numberType: "integer" },
91
+ FLOAT: { type: "number", numberType: "float" },
92
+ FLOAT64: { type: "number", numberType: "float" },
93
+ NUMERIC: { type: "number", numberType: "float" },
94
+ BIGNUMERIC: { type: "number", numberType: "float" },
95
+ TIMESTAMP: { type: "timestamp" },
96
+ BOOLEAN: { type: "boolean" },
97
+ BOOL: { type: "boolean" },
98
+ // TODO (https://cloud.google.com/bigquery/docs/reference/rest/v2/tables#tablefieldschema):
99
+ // BYTES
100
+ // DATETIME
101
+ // TIME
102
+ // GEOGRAPHY
103
+ };
104
+ this.name = name;
105
+ this.bigQuery = new bigquery_1.BigQuery({
106
+ userAgent: `Malloy/${malloy_1.Malloy.version}`,
107
+ keyFilename: config.serviceAccountKeyPath,
108
+ });
109
+ // record project ID because for unclear reasons we have to modify the project ID on the SDK when
110
+ // we want to use the tables API
111
+ this.projectId = this.bigQuery.projectId;
112
+ this.defaultProject = config.defaultProject || this.bigQuery.projectId;
113
+ this.queryOptions = queryOptions;
114
+ this.config = config;
115
+ this.location = config.location || "US";
116
+ }
117
+ get dialectName() {
118
+ return "standardsql";
119
+ }
120
+ readQueryOptions() {
121
+ const options = BigQueryConnection.DEFAULT_QUERY_OPTIONS;
122
+ if (this.queryOptions) {
123
+ if (this.queryOptions instanceof Function) {
124
+ return { ...options, ...this.queryOptions() };
125
+ }
126
+ else {
127
+ return { ...options, ...this.queryOptions };
128
+ }
129
+ }
130
+ else {
131
+ return options;
132
+ }
133
+ }
134
+ isPool() {
135
+ return false;
136
+ }
137
+ canPersist() {
138
+ return true;
139
+ }
140
+ canStream() {
141
+ return true;
142
+ }
143
+ canFetchSchemaAndRunSimultaneously() {
144
+ return true;
145
+ }
146
+ canFetchSchemaAndRunStreamSimultaneously() {
147
+ return false;
148
+ }
149
+ async _runSQL(sqlCommand, options = {}, rowIndex = 0) {
150
+ var _a, _b, _c, _d;
151
+ const defaultOptions = this.readQueryOptions();
152
+ const pageSize = (_a = options.rowLimit) !== null && _a !== void 0 ? _a : defaultOptions.rowLimit;
153
+ try {
154
+ const queryResultsOptions = {
155
+ maxResults: pageSize,
156
+ startIndex: rowIndex.toString(),
157
+ };
158
+ const jobResult = await this.createBigQueryJobAndGetResults(sqlCommand, undefined, queryResultsOptions);
159
+ const totalRows = +(((_b = jobResult[2]) === null || _b === void 0 ? void 0 : _b.totalRows)
160
+ ? jobResult[2].totalRows
161
+ : "0");
162
+ // TODO need to probably surface the cause of the schema not present error
163
+ if (((_c = jobResult[2]) === null || _c === void 0 ? void 0 : _c.schema) === undefined) {
164
+ throw new Error("Schema not present");
165
+ }
166
+ // TODO even though we have 10 minute timeout limit, we still should confirm that resulting metadata has "jobComplete: true"
167
+ const data = { rows: jobResult[0], totalRows };
168
+ const schema = (_d = jobResult[2]) === null || _d === void 0 ? void 0 : _d.schema;
169
+ return { data, schema };
170
+ }
171
+ catch (e) {
172
+ throw maybeRewriteError(e);
173
+ }
174
+ }
175
+ async runSQL(sqlCommand, options = {}, rowIndex = 0) {
176
+ const { data } = await this._runSQL(sqlCommand, options, rowIndex);
177
+ return data;
178
+ }
179
+ async runSQLBlockAndFetchResultSchema(sqlBlock, options) {
180
+ const { data, schema: schemaRaw } = await this._runSQL(sqlBlock.select, options);
181
+ const schema = this.structDefFromSQLSchema(sqlBlock, schemaRaw);
182
+ return { data, schema };
183
+ }
184
+ async downloadMalloyQuery(sqlCommand) {
185
+ const job = await this.createBigQueryJob({
186
+ query: sqlCommand,
187
+ });
188
+ return job.getQueryResultsStream();
189
+ }
190
+ async dryRunSQLQuery(sqlCommand) {
191
+ try {
192
+ const [result] = await this.bigQuery.createQueryJob({
193
+ location: this.location,
194
+ query: sqlCommand,
195
+ dryRun: true,
196
+ });
197
+ return result;
198
+ }
199
+ catch (e) {
200
+ throw maybeRewriteError(e);
201
+ }
202
+ }
203
+ async costQuery(sqlCommand) {
204
+ const dryRunResults = await this.dryRunSQLQuery(sqlCommand);
205
+ return Number(dryRunResults.metadata.statistics.totalBytesProcessed);
206
+ }
207
+ async structDefFromSQL(sqlCommand) {
208
+ const dryRunResults = await this.dryRunSQLQuery(sqlCommand);
209
+ const destinationTable = dryRunResults.metadata.configuration.query.destinationTable;
210
+ return this.structDefFromTableSchema(`${destinationTable.projectId}.${destinationTable.datasetId}.${destinationTable.tableId}`, dryRunResults.metadata.statistics.query.schema);
211
+ }
212
+ async getTableFieldSchema(tableURL) {
213
+ var _a, _b;
214
+ const { tablePath: tableName } = (0, malloy_2.parseTableURL)(tableURL);
215
+ const segments = tableName.split(".");
216
+ // paths can have two or three segments
217
+ // if there are only two segments, assume the dataset is "local" to the current billing project
218
+ let projectId, datasetNamePart, tableNamePart;
219
+ if (segments.length === 2) {
220
+ [datasetNamePart, tableNamePart] = segments;
221
+ projectId = this.defaultProject;
222
+ }
223
+ else if (segments.length === 3)
224
+ [projectId, datasetNamePart, tableNamePart] = segments;
225
+ else
226
+ throw new Error(`Improper table path: ${tableName}. A table path requires 2 or 3 segments`);
227
+ try {
228
+ // TODO The `dataset` API has no way to set a different `projectId` than the one stored in the BQ
229
+ // instance. So we hack it until a better way exists: we set the `this.bigQuery.projectId`
230
+ // to the `projectId` for the dataset, then put it back when we're done. Importantly, we
231
+ // set it back _before_ we await the promise, thus avoiding a "concurrency" issue. We've decided
232
+ // this is better than creating a new BQ instance every time we need to get a table schema.
233
+ if (projectId)
234
+ this.bigQuery.projectId = projectId;
235
+ const table = this.bigQuery.dataset(datasetNamePart).table(tableNamePart);
236
+ const metadataPromise = table.getMetadata();
237
+ this.bigQuery.projectId = this.projectId;
238
+ const [metadata] = await metadataPromise;
239
+ return {
240
+ schema: metadata.schema,
241
+ needsPartitionPsuedoColumn: ((_a = metadata.timePartitioning) === null || _a === void 0 ? void 0 : _a.type) !== undefined &&
242
+ ((_b = metadata.timePartitioning) === null || _b === void 0 ? void 0 : _b.field) === undefined,
243
+ };
244
+ }
245
+ catch (e) {
246
+ throw maybeRewriteError(e);
247
+ }
248
+ }
249
+ async executeSQLRaw(sqlCommand) {
250
+ const result = await this.createBigQueryJobAndGetResults(sqlCommand);
251
+ return result[0];
252
+ }
253
+ async test() {
254
+ await this.dryRunSQLQuery("SELECT 1");
255
+ }
256
+ /*
257
+ * Do we need to care about multiple overlapping calls to this function? No.
258
+ *
259
+ * If two long-running jobs using the exact same SQL are started by the same user and overlap - meaning, there is
260
+ * a job running with some SQL, and while it is PENDING or RUNNING, another job with the same SQL is added -
261
+ * the ultimate destination table for those jobs is the same table, if the source table(s) haven't changed, and
262
+ * SQL didn't use some mutable functions.
263
+ *
264
+ * If second job goes to RUNNING after first job finished, it will just reuse its results (this is the caching
265
+ * behavior). But if both of them are RUNNING at the same time, they will both do the work and consume the slots.
266
+ * If they finish around same time, the second job discards its results. If second job finishes much later than
267
+ * the first one (like an hour or so), it will update this destination table to extend its TTL
268
+ *
269
+ * This means that longer-term, we may want to block multiple calls at the app level (if tons of dashboards were
270
+ * hitting this, creating many instances of the same table run, that might start to cost $$) but for now we're
271
+ * ok with this simple approach
272
+ *
273
+ */
274
+ async manifestTemporaryTable(sqlCommand) {
275
+ const hash = crypto.createHash("md5").update(sqlCommand).digest("hex");
276
+ const tempTableName = this.temporaryTables.get(hash);
277
+ if (tempTableName !== undefined) {
278
+ return tempTableName;
279
+ }
280
+ else {
281
+ try {
282
+ const [job] = await this.bigQuery.createQueryJob({
283
+ location: this.location,
284
+ query: sqlCommand,
285
+ });
286
+ let [metaData] = await job.getMetadata();
287
+ // wait for job to complete, because we need the table name
288
+ // TODO just because a job is "DONE" doesn't mean it ended correctly, should probably also confirm
289
+ // status is successful & that table was created
290
+ // TODO this needs better error handling and a timeout so that issues dont result in infinite looping
291
+ while (metaData.status.state !== "DONE") {
292
+ await new Promise((resolve) => setTimeout(resolve, 1000));
293
+ [metaData] = await job.getMetadata();
294
+ }
295
+ // save table name
296
+ if (metaData.configuration &&
297
+ metaData.configuration.query &&
298
+ metaData.configuration.query.destinationTable) {
299
+ const destinationTable = metaData.configuration.query.destinationTable;
300
+ const fullTableName = `${destinationTable.projectId}.${destinationTable.datasetId}.${destinationTable.tableId}`;
301
+ this.temporaryTables.set(hash, fullTableName);
302
+ return fullTableName;
303
+ }
304
+ else {
305
+ throw new Error("bigquery.job.getMetadata() - metadata should have configuration but does not");
306
+ }
307
+ }
308
+ catch (e) {
309
+ throw maybeRewriteError(e);
310
+ }
311
+ }
312
+ }
313
+ // TODO there is reasonable argument that this has nothing to do with Malloy, and should be implemented
314
+ // by whatever library is using Malloy.
315
+ async manifestPermanentTable(sqlCommand, datasetName, tableName, overwriteExistingTable = false, createDataset = false) {
316
+ let dataset = this.bigQuery.dataset(datasetName);
317
+ if (!(await dataset.exists())[0]) {
318
+ if (createDataset) {
319
+ const newDatasetResponse = await this.bigQuery.createDataset(datasetName);
320
+ dataset = newDatasetResponse[0];
321
+ }
322
+ else {
323
+ throw new Error(`Dataset ${datasetName} does not exist`);
324
+ }
325
+ }
326
+ const table = dataset.table(tableName);
327
+ if ((await table.exists())[0] && !overwriteExistingTable) {
328
+ throw new Error(`Table ${tableName} already exists`);
329
+ }
330
+ const [job] = await this.bigQuery.createQueryJob({
331
+ query: sqlCommand,
332
+ location: this.location,
333
+ destination: table,
334
+ });
335
+ // if creating this job didn't throw, there's an ID.
336
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
337
+ return job.id;
338
+ }
339
+ addFieldsToStructDef(structDef, tableFieldSchema) {
340
+ if (!tableFieldSchema.fields)
341
+ return;
342
+ for (const field of tableFieldSchema.fields) {
343
+ // BigQuery SDK sets type & name to optional even though they are required, assume they exist
344
+ const type = field.type;
345
+ const name = field.name;
346
+ // check for an array
347
+ if (field.mode === "REPEATED" && !["STRUCT", "RECORD"].includes(type)) {
348
+ const malloyType = this.bqToMalloyTypes[type];
349
+ if (malloyType) {
350
+ const innerStructDef = {
351
+ type: "struct",
352
+ name,
353
+ dialect: this.dialectName,
354
+ structSource: { type: "nested" },
355
+ structRelationship: { type: "nested", field: name, isArray: true },
356
+ fields: [{ ...malloyType, name: "value" }],
357
+ };
358
+ structDef.fields.push(innerStructDef);
359
+ }
360
+ }
361
+ else if (["STRUCT", "RECORD"].includes(type)) {
362
+ const innerStructDef = {
363
+ type: "struct",
364
+ name,
365
+ dialect: this.dialectName,
366
+ structSource: field.mode === "REPEATED" ? { type: "nested" } : { type: "inline" },
367
+ structRelationship: field.mode === "REPEATED"
368
+ ? { type: "nested", field: name, isArray: false }
369
+ : { type: "inline" },
370
+ fields: [],
371
+ };
372
+ this.addFieldsToStructDef(innerStructDef, field);
373
+ structDef.fields.push(innerStructDef);
374
+ }
375
+ else {
376
+ const malloyType = this.bqToMalloyTypes[type];
377
+ if (malloyType) {
378
+ structDef.fields.push({ name, ...malloyType });
379
+ }
380
+ else {
381
+ structDef.fields.push({
382
+ name,
383
+ type: "string",
384
+ e: [`BigQuery type "${type}" not supported by Malloy`],
385
+ });
386
+ }
387
+ }
388
+ }
389
+ }
390
+ tableURLtoTablePath(tableURL) {
391
+ const { tablePath } = (0, malloy_2.parseTableURL)(tableURL);
392
+ if (tablePath.split(".").length === 2) {
393
+ return `${this.defaultProject}.${tablePath}`;
394
+ }
395
+ else {
396
+ return tablePath;
397
+ }
398
+ }
399
+ structDefFromTableSchema(tableURL, schemaInfo) {
400
+ const structDef = {
401
+ type: "struct",
402
+ name: tableURL,
403
+ dialect: this.dialectName,
404
+ structSource: {
405
+ type: "table",
406
+ tablePath: this.tableURLtoTablePath(tableURL),
407
+ },
408
+ structRelationship: { type: "basetable", connectionName: this.name },
409
+ fields: [],
410
+ };
411
+ this.addFieldsToStructDef(structDef, schemaInfo.schema);
412
+ if (schemaInfo.needsPartitionPsuedoColumn) {
413
+ structDef.fields.push({
414
+ type: "timestamp",
415
+ name: "_PARTITIONTIME",
416
+ });
417
+ }
418
+ return structDef;
419
+ }
420
+ structDefFromSQLSchema(sqlBlock, tableFieldSchema) {
421
+ const structDef = {
422
+ type: "struct",
423
+ name: sqlBlock.name,
424
+ dialect: this.dialectName,
425
+ structSource: {
426
+ type: "sql",
427
+ method: "subquery",
428
+ sqlBlock,
429
+ },
430
+ structRelationship: { type: "basetable", connectionName: this.name },
431
+ fields: [],
432
+ };
433
+ this.addFieldsToStructDef(structDef, tableFieldSchema);
434
+ return structDef;
435
+ }
436
+ async fetchSchemaForTables(missing) {
437
+ const schemas = {};
438
+ const errors = {};
439
+ for (const tableURL of missing) {
440
+ let inCache = this.schemaCache.get(tableURL);
441
+ if (!inCache) {
442
+ try {
443
+ const tableFieldSchema = await this.getTableFieldSchema(tableURL);
444
+ inCache = {
445
+ schema: this.structDefFromTableSchema(tableURL, tableFieldSchema),
446
+ };
447
+ this.schemaCache.set(tableURL, inCache);
448
+ }
449
+ catch (error) {
450
+ inCache = { error: error.message };
451
+ }
452
+ }
453
+ if (inCache.schema !== undefined) {
454
+ schemas[tableURL] = inCache.schema;
455
+ }
456
+ else {
457
+ errors[tableURL] = inCache.error;
458
+ }
459
+ }
460
+ return { schemas, errors };
461
+ }
462
+ async getSQLBlockSchema(sqlRef) {
463
+ // We do a simple retry-loop here, as a temporary fix for a transient
464
+ // error in which sometimes requesting results from a job yields an
465
+ // access denied error. It seems that in these cases, simply trying again
466
+ // solves the problem. This is being currently investigated by
467
+ // @christopherswenson and @lloydtabb. Same as below.
468
+ let lastFetchError;
469
+ for (let retries = 0; retries < 3; retries++) {
470
+ try {
471
+ const [job] = await this.bigQuery.createQueryJob({
472
+ location: this.location,
473
+ query: sqlRef.select,
474
+ dryRun: true,
475
+ });
476
+ return job.metadata.statistics.query.schema;
477
+ }
478
+ catch (fetchError) {
479
+ lastFetchError = fetchError;
480
+ }
481
+ }
482
+ throw lastFetchError;
483
+ }
484
+ async fetchSchemaForSQLBlocks(sqlRefs) {
485
+ const schemas = {};
486
+ const errors = {};
487
+ for (const sqlRef of sqlRefs) {
488
+ const key = sqlRef.name;
489
+ let inCache = this.sqlSchemaCache.get(key);
490
+ if (!inCache) {
491
+ try {
492
+ const tableFieldSchema = await this.getSQLBlockSchema(sqlRef);
493
+ inCache = {
494
+ schema: this.structDefFromSQLSchema(sqlRef, tableFieldSchema),
495
+ };
496
+ this.schemaCache.set(key, inCache);
497
+ }
498
+ catch (error) {
499
+ inCache = { error: error.message };
500
+ }
501
+ }
502
+ if (inCache.schema !== undefined) {
503
+ schemas[key] = inCache.schema;
504
+ }
505
+ else {
506
+ errors[key] = inCache.error;
507
+ }
508
+ }
509
+ return { schemas, errors };
510
+ }
511
+ // TODO this needs to extend the wait for results using a timeout set by the user,
512
+ // and probably needs to loop to check for results - BQ docs now say that after ~2min of waiting,
513
+ // no matter what you set for timeoutMs, they will probably just return.
514
+ async createBigQueryJobAndGetResults(sqlCommand, createQueryJobOptions, getQueryResultsOptions) {
515
+ try {
516
+ const job = await this.createBigQueryJob({
517
+ query: sqlCommand,
518
+ ...createQueryJobOptions,
519
+ });
520
+ // TODO we should check if this is still required?
521
+ // We do a simple retry-loop here, as a temporary fix for a transient
522
+ // error in which sometimes requesting results from a job yields an
523
+ // access denied error. It seems that in these cases, simply trying again
524
+ // solves the problem. This is being currently investigated by
525
+ // @christopherswenson and @lloydtabb.
526
+ let lastFetchError;
527
+ for (let retries = 0; retries < 3; retries++) {
528
+ try {
529
+ return await job.getQueryResults({
530
+ timeoutMs: 1000 * 60 * 2,
531
+ ...getQueryResultsOptions,
532
+ });
533
+ }
534
+ catch (fetchError) {
535
+ lastFetchError = fetchError;
536
+ }
537
+ }
538
+ throw lastFetchError;
539
+ }
540
+ catch (e) {
541
+ throw maybeRewriteError(e);
542
+ }
543
+ }
544
+ async createBigQueryJob(createQueryJobOptions) {
545
+ const [job] = await this.bigQuery.createQueryJob({
546
+ location: this.location,
547
+ maximumBytesBilled: this.config.maximumBytesBilled || MAXIMUM_BYTES_BILLED,
548
+ jobTimeoutMs: Number(this.config.timeoutMs) || TIMEOUT_MS,
549
+ ...createQueryJobOptions,
550
+ });
551
+ return job;
552
+ }
553
+ runSQLStream(sqlCommand, options = {}) {
554
+ const bigQuery = this.bigQuery;
555
+ function streamBigQuery(onError, onData, onEnd) {
556
+ let index = 0;
557
+ function handleData(rowMetadata) {
558
+ onData(rowMetadata);
559
+ index += 1;
560
+ if (options.rowLimit !== undefined && index >= options.rowLimit) {
561
+ this.end();
562
+ }
563
+ }
564
+ bigQuery
565
+ .createQueryStream(sqlCommand)
566
+ .on("error", onError)
567
+ .on("data", handleData)
568
+ .on("end", onEnd);
569
+ }
570
+ return (0, malloy_1.toAsyncGenerator)(streamBigQuery);
571
+ }
572
+ }
573
+ exports.BigQueryConnection = BigQueryConnection;
574
+ BigQueryConnection.DEFAULT_QUERY_OPTIONS = {
575
+ rowLimit: 10,
576
+ };
577
+ //# sourceMappingURL=bigquery_connection.js.map
@@ -0,0 +1 @@
1
+ export { BigQueryConnection } from "./bigquery_connection";
package/dist/index.js ADDED
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ /*
3
+ * Copyright 2021 Google LLC
4
+ *
5
+ * This program is free software; you can redistribute it and/or
6
+ * modify it under the terms of the GNU General Public License
7
+ * version 2 as published by the Free Software Foundation.
8
+ *
9
+ * This program is distributed in the hope that it will be useful,
10
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ * GNU General Public License for more details.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.BigQueryConnection = void 0;
16
+ var bigquery_connection_1 = require("./bigquery_connection");
17
+ Object.defineProperty(exports, "BigQueryConnection", { enumerable: true, get: function () { return bigquery_connection_1.BigQueryConnection; } });
18
+ //# sourceMappingURL=index.js.map
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@malloydata/db-bigquery",
3
+ "version": "0.0.1",
4
+ "license": "GPL-2.0",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "scripts": {
8
+ "lint": "eslint '**/*.ts{,x}'",
9
+ "lint-fix": "eslint '**/*.ts{,x}' --fix",
10
+ "test": "jest --config=../../jest.config.js",
11
+ "build": "tsc --build",
12
+ "malloyc": "ts-node ../../scripts/malloy-to-json"
13
+ },
14
+ "dependencies": {
15
+ "@google-cloud/bigquery": "^5.5.0",
16
+ "@google-cloud/common": "^3.6.0",
17
+ "@malloydata/malloy": "^0.0.1",
18
+ "gaxios": "^4.2.0"
19
+ }
20
+ }