@malloydata/db-snowflake 0.0.126-dev240305182920
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/snowflake_connection.d.ts +51 -0
- package/dist/snowflake_connection.js +271 -0
- package/dist/snowflake_connection.js.map +1 -0
- package/dist/snowflake_connection.spec.d.ts +1 -0
- package/dist/snowflake_connection.spec.js +123 -0
- package/dist/snowflake_connection.spec.js.map +1 -0
- package/dist/snowflake_executor.d.ts +20 -0
- package/dist/snowflake_executor.js +205 -0
- package/dist/snowflake_executor.js.map +1 -0
- package/dist/snowflake_executor.spec.d.ts +1 -0
- package/dist/snowflake_executor.spec.js +93 -0
- package/dist/snowflake_executor.spec.js.map +1 -0
- package/package.json +30 -0
- package/src/index.ts +24 -0
- package/src/snowflake_connection.spec.ts +113 -0
- package/src/snowflake_connection.ts +359 -0
- package/src/snowflake_executor.spec.ts +108 -0
- package/src/snowflake_executor.ts +229 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2023 Google LLC
|
|
3
|
+
*
|
|
4
|
+
* Permission is hereby granted, free of charge, to any person obtaining
|
|
5
|
+
* a copy of this software and associated documentation files
|
|
6
|
+
* (the "Software"), to deal in the Software without restriction,
|
|
7
|
+
* including without limitation the rights to use, copy, modify, merge,
|
|
8
|
+
* publish, distribute, sublicense, and/or sell copies of the Software,
|
|
9
|
+
* and to permit persons to whom the Software is furnished to do so,
|
|
10
|
+
* subject to the following conditions:
|
|
11
|
+
*
|
|
12
|
+
* The above copyright notice and this permission notice shall be
|
|
13
|
+
* included in all copies or substantial portions of the Software.
|
|
14
|
+
*
|
|
15
|
+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
16
|
+
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
17
|
+
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
18
|
+
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
19
|
+
* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
20
|
+
* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
21
|
+
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import * as crypto from 'crypto';
|
|
25
|
+
import {
|
|
26
|
+
RunSQLOptions,
|
|
27
|
+
MalloyQueryData,
|
|
28
|
+
QueryRunStats,
|
|
29
|
+
Connection,
|
|
30
|
+
PersistSQLResults,
|
|
31
|
+
StreamingConnection,
|
|
32
|
+
PooledConnection,
|
|
33
|
+
SQLBlock,
|
|
34
|
+
StructDef,
|
|
35
|
+
QueryDataRow,
|
|
36
|
+
SnowflakeDialect,
|
|
37
|
+
NamedStructDefs,
|
|
38
|
+
} from '@malloydata/malloy';
|
|
39
|
+
import {SnowflakeExecutor} from './snowflake_executor';
|
|
40
|
+
import {
|
|
41
|
+
FetchSchemaOptions,
|
|
42
|
+
TestableConnection,
|
|
43
|
+
} from '@malloydata/malloy/dist/runtime_types';
|
|
44
|
+
import {ConnectionOptions} from 'snowflake-sdk';
|
|
45
|
+
import {Options as PoolOptions} from 'generic-pool';
|
|
46
|
+
|
|
47
|
+
type namespace = {database: string; schema: string};
|
|
48
|
+
|
|
49
|
+
export interface SnowflakeConnectionOptions {
|
|
50
|
+
// snowflake sdk connection options
|
|
51
|
+
connOptions?: ConnectionOptions;
|
|
52
|
+
// generic pool options to help maintain a pool of connections to snowflake
|
|
53
|
+
poolOptions?: PoolOptions;
|
|
54
|
+
|
|
55
|
+
// the database and schema where we can perform temporary table operations.
|
|
56
|
+
// for example, if we want to create a temp table for fetching schema of an sql block
|
|
57
|
+
// we could use this database & schema instead of the main database & schema
|
|
58
|
+
scratchSpace?: namespace;
|
|
59
|
+
|
|
60
|
+
queryOptions?: RunSQLOptions;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class SnowflakeConnection
|
|
64
|
+
implements
|
|
65
|
+
Connection,
|
|
66
|
+
PersistSQLResults,
|
|
67
|
+
StreamingConnection,
|
|
68
|
+
TestableConnection
|
|
69
|
+
{
|
|
70
|
+
private readonly dialect = new SnowflakeDialect();
|
|
71
|
+
private executor: SnowflakeExecutor;
|
|
72
|
+
private schemaCache = new Map<
|
|
73
|
+
string,
|
|
74
|
+
| {schema: StructDef; error?: undefined; timestamp: number}
|
|
75
|
+
| {error: string; schema?: undefined; timestamp: number}
|
|
76
|
+
>();
|
|
77
|
+
private sqlSchemaCache = new Map<
|
|
78
|
+
string,
|
|
79
|
+
| {
|
|
80
|
+
structDef: StructDef;
|
|
81
|
+
error?: undefined;
|
|
82
|
+
timestamp: number;
|
|
83
|
+
}
|
|
84
|
+
| {error: string; structDef?: undefined; timestamp: number}
|
|
85
|
+
>();
|
|
86
|
+
|
|
87
|
+
// the database & schema where we do temporary operations like creating a temp table
|
|
88
|
+
private scratchSpace?: namespace;
|
|
89
|
+
private queryOptions: RunSQLOptions;
|
|
90
|
+
|
|
91
|
+
constructor(
|
|
92
|
+
public readonly name: string,
|
|
93
|
+
options?: SnowflakeConnectionOptions
|
|
94
|
+
) {
|
|
95
|
+
let connOptions = options?.connOptions;
|
|
96
|
+
if (connOptions === undefined) {
|
|
97
|
+
// try to get connection options from ~/.snowflake/connections.toml
|
|
98
|
+
connOptions = SnowflakeExecutor.getConnectionOptionsFromToml();
|
|
99
|
+
}
|
|
100
|
+
this.executor = new SnowflakeExecutor(connOptions, options?.poolOptions);
|
|
101
|
+
this.scratchSpace = options?.scratchSpace;
|
|
102
|
+
this.queryOptions = options?.queryOptions ?? {};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
get dialectName(): string {
|
|
106
|
+
return 'snowflake';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// TODO: make it support nesting soon
|
|
110
|
+
public get supportsNesting(): boolean {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
public isPool(): this is PooledConnection {
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
public canPersist(): this is PersistSQLResults {
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
public canStream(): this is StreamingConnection {
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
public async estimateQueryCost(_sqlCommand: string): Promise<QueryRunStats> {
|
|
127
|
+
return {};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async close(): Promise<void> {
|
|
131
|
+
await this.executor.done();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private getTempTableName(sqlCommand: string): string {
|
|
135
|
+
const hash = crypto.createHash('md5').update(sqlCommand).digest('hex');
|
|
136
|
+
let tableName = `tt${hash}`;
|
|
137
|
+
if (this.scratchSpace) {
|
|
138
|
+
tableName = `${this.scratchSpace.database}.${this.scratchSpace.schema}.${tableName}`;
|
|
139
|
+
}
|
|
140
|
+
return tableName;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
public async runSQL(
|
|
144
|
+
sql: string,
|
|
145
|
+
options?: RunSQLOptions
|
|
146
|
+
): Promise<MalloyQueryData> {
|
|
147
|
+
const rowLimit = options?.rowLimit ?? this.queryOptions?.rowLimit;
|
|
148
|
+
let rows = await this.executor.batch(sql);
|
|
149
|
+
if (rowLimit !== undefined && rows.length > rowLimit) {
|
|
150
|
+
rows = rows.slice(0, rowLimit);
|
|
151
|
+
}
|
|
152
|
+
return {rows, totalRows: rows.length};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
public async *runSQLStream(
|
|
156
|
+
sqlCommand: string,
|
|
157
|
+
options: RunSQLOptions = {}
|
|
158
|
+
): AsyncIterableIterator<QueryDataRow> {
|
|
159
|
+
const streamQueryOptions = {
|
|
160
|
+
...this.queryOptions,
|
|
161
|
+
...options,
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
for await (const row of await this.executor.stream(
|
|
165
|
+
sqlCommand,
|
|
166
|
+
streamQueryOptions
|
|
167
|
+
)) {
|
|
168
|
+
yield row;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
public async test(): Promise<void> {
|
|
173
|
+
await this.executor.batch('SELECT 1');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private async schemaFromQuery(
|
|
177
|
+
infoQuery: string,
|
|
178
|
+
structDef: StructDef
|
|
179
|
+
): Promise<void> {
|
|
180
|
+
const rows = await this.executor.batch(infoQuery);
|
|
181
|
+
for (const row of rows) {
|
|
182
|
+
const snowflakeDataType = row['DATA_TYPE'] as string;
|
|
183
|
+
const s = structDef;
|
|
184
|
+
const malloyType = this.dialect.sqlTypeToMalloyType(snowflakeDataType);
|
|
185
|
+
const name = row['COLUMN_NAME'] as string;
|
|
186
|
+
if (malloyType) {
|
|
187
|
+
s.fields.push({...malloyType, name});
|
|
188
|
+
} else {
|
|
189
|
+
s.fields.push({
|
|
190
|
+
type: 'unsupported',
|
|
191
|
+
rawType: snowflakeDataType,
|
|
192
|
+
name,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private async getTableSchema(
|
|
199
|
+
tableKey: string,
|
|
200
|
+
tablePath: string
|
|
201
|
+
): Promise<StructDef> {
|
|
202
|
+
// looks like snowflake:schemaName.tableName
|
|
203
|
+
tableKey = tableKey.toLowerCase();
|
|
204
|
+
|
|
205
|
+
let [schema, tableName] = ['', tablePath];
|
|
206
|
+
const schema_and_table = tablePath.split('.');
|
|
207
|
+
if (schema_and_table.length === 2) {
|
|
208
|
+
[schema, tableName] = schema_and_table;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const structDef: StructDef = {
|
|
212
|
+
type: 'struct',
|
|
213
|
+
dialect: 'snowflake',
|
|
214
|
+
name: tableKey,
|
|
215
|
+
structSource: {type: 'table', tablePath},
|
|
216
|
+
structRelationship: {
|
|
217
|
+
type: 'basetable',
|
|
218
|
+
connectionName: this.name,
|
|
219
|
+
},
|
|
220
|
+
fields: [],
|
|
221
|
+
};
|
|
222
|
+
// This is how we get variant information
|
|
223
|
+
|
|
224
|
+
// WITH tbl as (
|
|
225
|
+
// SELECT * FROM malloytest.ga_sample
|
|
226
|
+
// )
|
|
227
|
+
// SELECT regexp_replace(PATH, '\\[.*\\]', '[]') as PATH, lower(TYPEOF(value)) as type
|
|
228
|
+
// FROM (select object_construct(*) o from tbl limit 100)
|
|
229
|
+
// ,table(flatten(input => o, recursive => true)) as meta
|
|
230
|
+
// WHERE lower(TYPEOF(value)) <> 'array'
|
|
231
|
+
// GROUP BY 1,2
|
|
232
|
+
// ORDER BY PATH
|
|
233
|
+
|
|
234
|
+
const infoQuery = `
|
|
235
|
+
SELECT
|
|
236
|
+
column_name, -- LOWER(COLUMN_NAME) AS column_name,
|
|
237
|
+
LOWER(DATA_TYPE) as data_type
|
|
238
|
+
FROM
|
|
239
|
+
INFORMATION_SCHEMA.COLUMNS
|
|
240
|
+
WHERE
|
|
241
|
+
table_schema = UPPER('${schema}')
|
|
242
|
+
AND table_name = UPPER('${tableName}');
|
|
243
|
+
`;
|
|
244
|
+
|
|
245
|
+
await this.schemaFromQuery(infoQuery, structDef);
|
|
246
|
+
return structDef;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
public async fetchSchemaForTables(
|
|
250
|
+
missing: Record<string, string>,
|
|
251
|
+
{refreshTimestamp}: FetchSchemaOptions
|
|
252
|
+
): Promise<{
|
|
253
|
+
schemas: Record<string, StructDef>;
|
|
254
|
+
errors: Record<string, string>;
|
|
255
|
+
}> {
|
|
256
|
+
const schemas: NamedStructDefs = {};
|
|
257
|
+
const errors: {[name: string]: string} = {};
|
|
258
|
+
|
|
259
|
+
for (const tableKey in missing) {
|
|
260
|
+
let inCache = this.schemaCache.get(tableKey);
|
|
261
|
+
if (
|
|
262
|
+
!inCache ||
|
|
263
|
+
(refreshTimestamp && refreshTimestamp > inCache.timestamp)
|
|
264
|
+
) {
|
|
265
|
+
const tablePath = missing[tableKey];
|
|
266
|
+
const timestamp = refreshTimestamp || Date.now();
|
|
267
|
+
try {
|
|
268
|
+
inCache = {
|
|
269
|
+
schema: await this.getTableSchema(tableKey, tablePath),
|
|
270
|
+
timestamp,
|
|
271
|
+
};
|
|
272
|
+
this.schemaCache.set(tableKey, inCache);
|
|
273
|
+
} catch (error) {
|
|
274
|
+
inCache = {error: error.message, timestamp};
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (inCache.schema !== undefined) {
|
|
278
|
+
schemas[tableKey] = inCache.schema;
|
|
279
|
+
} else {
|
|
280
|
+
errors[tableKey] = inCache.error || 'Unknown schema fetch error';
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return {schemas, errors};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private async getSQLBlockSchema(sqlRef: SQLBlock): Promise<StructDef> {
|
|
287
|
+
const structDef: StructDef = {
|
|
288
|
+
type: 'struct',
|
|
289
|
+
dialect: 'snowflake',
|
|
290
|
+
name: sqlRef.name,
|
|
291
|
+
structSource: {
|
|
292
|
+
type: 'sql',
|
|
293
|
+
method: 'subquery',
|
|
294
|
+
sqlBlock: sqlRef,
|
|
295
|
+
},
|
|
296
|
+
structRelationship: {
|
|
297
|
+
type: 'basetable',
|
|
298
|
+
connectionName: this.name,
|
|
299
|
+
},
|
|
300
|
+
fields: [],
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
// create temp table with same schema as the query
|
|
304
|
+
const tempTableName = this.getTempTableName(sqlRef.selectStr);
|
|
305
|
+
this.runSQL(
|
|
306
|
+
`
|
|
307
|
+
CREATE OR REPLACE TEMP TABLE ${tempTableName} as SELECT * FROM (
|
|
308
|
+
${sqlRef.selectStr}
|
|
309
|
+
) as x WHERE false;
|
|
310
|
+
`
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
const infoQuery = `
|
|
314
|
+
SELECT
|
|
315
|
+
column_name, -- LOWER(column_name) as column_name,
|
|
316
|
+
LOWER(data_type) as data_type
|
|
317
|
+
FROM
|
|
318
|
+
INFORMATION_SCHEMA.COLUMNS
|
|
319
|
+
WHERE
|
|
320
|
+
table_name = UPPER('${tempTableName}');
|
|
321
|
+
`;
|
|
322
|
+
await this.schemaFromQuery(infoQuery, structDef);
|
|
323
|
+
return structDef;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
public async fetchSchemaForSQLBlock(
|
|
327
|
+
sqlRef: SQLBlock,
|
|
328
|
+
{refreshTimestamp}: FetchSchemaOptions
|
|
329
|
+
): Promise<
|
|
330
|
+
| {structDef: StructDef; error?: undefined}
|
|
331
|
+
| {error: string; structDef?: undefined}
|
|
332
|
+
> {
|
|
333
|
+
const key = sqlRef.name;
|
|
334
|
+
let inCache = this.sqlSchemaCache.get(key);
|
|
335
|
+
if (
|
|
336
|
+
!inCache ||
|
|
337
|
+
(refreshTimestamp && refreshTimestamp > inCache.timestamp)
|
|
338
|
+
) {
|
|
339
|
+
const timestamp = refreshTimestamp ?? Date.now();
|
|
340
|
+
try {
|
|
341
|
+
inCache = {
|
|
342
|
+
structDef: await this.getSQLBlockSchema(sqlRef),
|
|
343
|
+
timestamp,
|
|
344
|
+
};
|
|
345
|
+
} catch (error) {
|
|
346
|
+
inCache = {error: error.message, timestamp};
|
|
347
|
+
}
|
|
348
|
+
this.sqlSchemaCache.set(key, inCache);
|
|
349
|
+
}
|
|
350
|
+
return inCache;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
public async manifestTemporaryTable(sqlCommand: string): Promise<string> {
|
|
354
|
+
const tableName = this.getTempTableName(sqlCommand);
|
|
355
|
+
const cmd = `CREATE OR REPLACE TEMP TABLE ${tableName} AS (${sqlCommand});`;
|
|
356
|
+
await this.runSQL(cmd);
|
|
357
|
+
return tableName;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2023 Google LLC
|
|
3
|
+
*
|
|
4
|
+
* Permission is hereby granted, free of charge, to any person obtaining
|
|
5
|
+
* a copy of this software and associated documentation files
|
|
6
|
+
* (the "Software"), to deal in the Software without restriction,
|
|
7
|
+
* including without limitation the rights to use, copy, modify, merge,
|
|
8
|
+
* publish, distribute, sublicense, and/or sell copies of the Software,
|
|
9
|
+
* and to permit persons to whom the Software is furnished to do so,
|
|
10
|
+
* subject to the following conditions:
|
|
11
|
+
*
|
|
12
|
+
* The above copyright notice and this permission notice shall be
|
|
13
|
+
* included in all copies or substantial portions of the Software.
|
|
14
|
+
*
|
|
15
|
+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
16
|
+
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
17
|
+
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
18
|
+
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
19
|
+
* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
20
|
+
* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
21
|
+
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {SnowflakeExecutor} from './snowflake_executor';
|
|
25
|
+
import {QueryData, RunSQLOptions} from '@malloydata/malloy';
|
|
26
|
+
|
|
27
|
+
const envDatabases =
|
|
28
|
+
process.env['MALLOY_DATABASES'] || process.env['MALLOY_DATABASE'];
|
|
29
|
+
|
|
30
|
+
let describe = globalThis.describe;
|
|
31
|
+
if (envDatabases && !envDatabases.includes('snowflake')) {
|
|
32
|
+
describe = describe.skip;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
class SnowflakeExecutorTestSetup {
|
|
36
|
+
private executor_: SnowflakeExecutor;
|
|
37
|
+
constructor(private executor: SnowflakeExecutor) {
|
|
38
|
+
this.executor_ = executor;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async runBatch(sqlText: string): Promise<QueryData> {
|
|
42
|
+
let ret: QueryData = [];
|
|
43
|
+
await (async () => {
|
|
44
|
+
const rows = await this.executor_.batch(sqlText);
|
|
45
|
+
return rows;
|
|
46
|
+
})().then((rows: QueryData) => {
|
|
47
|
+
ret = rows;
|
|
48
|
+
});
|
|
49
|
+
return ret;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async runStreaming(sqlText: string, queryOptions?: RunSQLOptions) {
|
|
53
|
+
const rows: QueryData = [];
|
|
54
|
+
await (async () => {
|
|
55
|
+
for await (const row of await this.executor_.stream(
|
|
56
|
+
sqlText,
|
|
57
|
+
queryOptions
|
|
58
|
+
)) {
|
|
59
|
+
rows.push(row);
|
|
60
|
+
}
|
|
61
|
+
})();
|
|
62
|
+
return rows;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async done() {
|
|
66
|
+
await this.executor_.done();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('db:SnowflakeExecutor', () => {
|
|
71
|
+
let db: SnowflakeExecutorTestSetup;
|
|
72
|
+
let query: string;
|
|
73
|
+
|
|
74
|
+
beforeAll(() => {
|
|
75
|
+
const connOptions =
|
|
76
|
+
SnowflakeExecutor.getConnectionOptionsFromEnv() ||
|
|
77
|
+
SnowflakeExecutor.getConnectionOptionsFromToml();
|
|
78
|
+
const executor = new SnowflakeExecutor(connOptions);
|
|
79
|
+
db = new SnowflakeExecutorTestSetup(executor);
|
|
80
|
+
query = `
|
|
81
|
+
select
|
|
82
|
+
*
|
|
83
|
+
from
|
|
84
|
+
(
|
|
85
|
+
values
|
|
86
|
+
(1, 'one'),
|
|
87
|
+
(2, 'two'),
|
|
88
|
+
(3, 'three'),
|
|
89
|
+
(4, 'four'),
|
|
90
|
+
(5, 'five')
|
|
91
|
+
);
|
|
92
|
+
`;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
afterAll(async () => {
|
|
96
|
+
await db.done();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('verifies batch execute', async () => {
|
|
100
|
+
const rows = await db.runBatch(query);
|
|
101
|
+
expect(rows.length).toBe(5);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('verifies stream iterable', async () => {
|
|
105
|
+
const rows = await db.runStreaming(query, {rowLimit: 2});
|
|
106
|
+
expect(rows.length).toBe(2);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2023 Google LLC
|
|
3
|
+
*
|
|
4
|
+
* Permission is hereby granted, free of charge, to any person obtaining
|
|
5
|
+
* a copy of this software and associated documentation files
|
|
6
|
+
* (the "Software"), to deal in the Software without restriction,
|
|
7
|
+
* including without limitation the rights to use, copy, modify, merge,
|
|
8
|
+
* publish, distribute, sublicense, and/or sell copies of the Software,
|
|
9
|
+
* and to permit persons to whom the Software is furnished to do so,
|
|
10
|
+
* subject to the following conditions:
|
|
11
|
+
*
|
|
12
|
+
* The above copyright notice and this permission notice shall be
|
|
13
|
+
* included in all copies or substantial portions of the Software.
|
|
14
|
+
*
|
|
15
|
+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
16
|
+
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
17
|
+
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
18
|
+
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
19
|
+
* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
20
|
+
* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
21
|
+
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import snowflake, {
|
|
25
|
+
SnowflakeError,
|
|
26
|
+
Statement,
|
|
27
|
+
Connection,
|
|
28
|
+
ConnectionOptions,
|
|
29
|
+
} from 'snowflake-sdk';
|
|
30
|
+
import {Pool, Options as PoolOptions} from 'generic-pool';
|
|
31
|
+
import * as toml from 'toml';
|
|
32
|
+
import * as fs from 'fs';
|
|
33
|
+
import * as path from 'path';
|
|
34
|
+
import {Readable} from 'stream';
|
|
35
|
+
import {
|
|
36
|
+
toAsyncGenerator,
|
|
37
|
+
QueryData,
|
|
38
|
+
QueryDataRow,
|
|
39
|
+
RunSQLOptions,
|
|
40
|
+
} from '@malloydata/malloy';
|
|
41
|
+
|
|
42
|
+
export interface ConnectionConfigFile {
|
|
43
|
+
// a toml file with snowflake connection settings
|
|
44
|
+
// if not provided, we will try to read ~/.snowflake/config
|
|
45
|
+
config_file_path?: string;
|
|
46
|
+
// the name of connection in the config file
|
|
47
|
+
// if not provided, we will try to use the "default" connection
|
|
48
|
+
connection_name?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// function columnNameToLowerCase(row: QueryDataRow): QueryDataRow {
|
|
52
|
+
// const ret: QueryDataRow = {};
|
|
53
|
+
// for (const key in row) {
|
|
54
|
+
// ret[key.toLowerCase()] = row[key];
|
|
55
|
+
// }
|
|
56
|
+
// return ret;
|
|
57
|
+
// }
|
|
58
|
+
|
|
59
|
+
export class SnowflakeExecutor {
|
|
60
|
+
private static defaultPoolOptions_: PoolOptions = {
|
|
61
|
+
min: 1,
|
|
62
|
+
max: 1,
|
|
63
|
+
// ensure we validate a connection before giving it to a client
|
|
64
|
+
testOnBorrow: true,
|
|
65
|
+
testOnReturn: true,
|
|
66
|
+
};
|
|
67
|
+
private static defaultConnectionOptions = {
|
|
68
|
+
clientSessionKeepAlive: true, // default = false
|
|
69
|
+
clientSessionKeepAliveHeartbeatFrequency: 900, // default = 3600
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
private pool_: Pool<Connection>;
|
|
73
|
+
constructor(connOptions: ConnectionOptions, poolOptions?: PoolOptions) {
|
|
74
|
+
this.pool_ = snowflake.createPool(connOptions, {
|
|
75
|
+
...SnowflakeExecutor.defaultPoolOptions_,
|
|
76
|
+
...(poolOptions ?? {}),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
public static getConnectionOptionsFromEnv(): ConnectionOptions | undefined {
|
|
81
|
+
const account = process.env['SNOWFLAKE_ACCOUNT'];
|
|
82
|
+
if (account) {
|
|
83
|
+
const username = process.env['SNOWFLAKE_USER'];
|
|
84
|
+
const password = process.env['SNOWFLAKE_PASSWORD'];
|
|
85
|
+
const warehouse = process.env['SNOWFLAKE_WAREHOUSE'];
|
|
86
|
+
const database = process.env['SNOWFLAKE_DATABASE'];
|
|
87
|
+
const schema = process.env['SNOWFLAKE_SCHEMA'];
|
|
88
|
+
return {
|
|
89
|
+
account,
|
|
90
|
+
username,
|
|
91
|
+
password,
|
|
92
|
+
warehouse,
|
|
93
|
+
database,
|
|
94
|
+
schema,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
public static getConnectionOptionsFromToml(
|
|
101
|
+
options?: ConnectionConfigFile
|
|
102
|
+
): ConnectionOptions {
|
|
103
|
+
let location: string | undefined = options?.config_file_path;
|
|
104
|
+
if (location === undefined) {
|
|
105
|
+
const homeDir = process.env['HOME'] || process.env['USERPROFILE'];
|
|
106
|
+
if (homeDir === undefined) {
|
|
107
|
+
throw new Error('could not find a path to connections.toml');
|
|
108
|
+
}
|
|
109
|
+
location = path.join(homeDir, '.snowflake', 'connections.toml');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!fs.existsSync(location)) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`provided snowflake connection config file: ${location} does not exist`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const tomlData = fs.readFileSync(location, 'utf-8');
|
|
119
|
+
const connections = toml.parse(tomlData);
|
|
120
|
+
const tomlConnectionName = options?.connection_name ?? 'default';
|
|
121
|
+
const connection = connections[tomlConnectionName];
|
|
122
|
+
if (connection === undefined) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`provided snowflake connection name: ${tomlConnectionName} does not exist at ${location}`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// sometimes the connection file uses "user" instead of "username"
|
|
129
|
+
// because the python api expects 'user'
|
|
130
|
+
connection['username'] = connection['username'] ?? connection['user'];
|
|
131
|
+
if (!connection || !connection.account || !connection.username) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`provided snowflake connection config file at ${location} is not valid`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
// some basic options we configure by default but can be overriden
|
|
139
|
+
...SnowflakeExecutor.defaultConnectionOptions,
|
|
140
|
+
account: connection.account,
|
|
141
|
+
username: connection.username,
|
|
142
|
+
...connection,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
public async done() {
|
|
147
|
+
await this.pool_.drain().then(() => {
|
|
148
|
+
this.pool_.clear();
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
public async _execute(sqlText: string, conn: Connection): Promise<QueryData> {
|
|
153
|
+
return new Promise((resolve, reject) => {
|
|
154
|
+
const _statment = conn.execute({
|
|
155
|
+
sqlText,
|
|
156
|
+
complete: (
|
|
157
|
+
err: SnowflakeError | undefined,
|
|
158
|
+
_stmt: Statement,
|
|
159
|
+
rows?: QueryData
|
|
160
|
+
) => {
|
|
161
|
+
if (err) {
|
|
162
|
+
reject(err);
|
|
163
|
+
} else if (rows) {
|
|
164
|
+
resolve(rows);
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private async _setSessionParams(conn: Connection) {
|
|
172
|
+
// set some default session parameters
|
|
173
|
+
// this is quite imporant for snowflake because malloy tends to add quotes to all database identifiers
|
|
174
|
+
// and snowflake is case sensitive by with quotes but matches against all caps identifiers without quotes
|
|
175
|
+
// await this._execute(
|
|
176
|
+
// 'ALTER SESSION SET QUOTED_IDENTIFIERS_IGNORE_CASE = true;',
|
|
177
|
+
// conn
|
|
178
|
+
// );
|
|
179
|
+
// set utc as the default timezone which is the malloy convention
|
|
180
|
+
await this._execute("ALTER SESSION SET TIMEZONE = 'UTC';", conn);
|
|
181
|
+
// ensure week starts on Sunday which is the malloy convention
|
|
182
|
+
await this._execute('ALTER SESSION SET WEEK_START = 7;', conn);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
public async batch(sqlText: string): Promise<QueryData> {
|
|
186
|
+
return await this.pool_.use(async (conn: Connection) => {
|
|
187
|
+
await this._setSessionParams(conn);
|
|
188
|
+
return await this._execute(sqlText, conn);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
public async stream(
|
|
193
|
+
sqlText: string,
|
|
194
|
+
options?: RunSQLOptions
|
|
195
|
+
): Promise<AsyncIterableIterator<QueryDataRow>> {
|
|
196
|
+
const pool: Pool<Connection> = this.pool_;
|
|
197
|
+
return await pool.acquire().then(async (conn: Connection) => {
|
|
198
|
+
await this._setSessionParams(conn);
|
|
199
|
+
const stmt: Statement = conn.execute({
|
|
200
|
+
sqlText,
|
|
201
|
+
streamResult: true,
|
|
202
|
+
});
|
|
203
|
+
const stream: Readable = stmt.streamRows();
|
|
204
|
+
function streamSnowflake(
|
|
205
|
+
onError: (error: Error) => void,
|
|
206
|
+
onData: (data: QueryDataRow) => void,
|
|
207
|
+
onEnd: () => void
|
|
208
|
+
) {
|
|
209
|
+
function handleEnd() {
|
|
210
|
+
onEnd();
|
|
211
|
+
pool.release(conn);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let index = 0;
|
|
215
|
+
function handleData(this: Readable, row: QueryDataRow) {
|
|
216
|
+
onData(row);
|
|
217
|
+
index += 1;
|
|
218
|
+
if (options?.rowLimit !== undefined && index >= options.rowLimit) {
|
|
219
|
+
onEnd();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
stream.on('error', onError);
|
|
223
|
+
stream.on('data', handleData);
|
|
224
|
+
stream.on('end', handleEnd);
|
|
225
|
+
}
|
|
226
|
+
return Promise.resolve(toAsyncGenerator<QueryDataRow>(streamSnowflake));
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|