@effectuate/cubejs-mongosql-driver 1.0.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.
@@ -0,0 +1,745 @@
1
+ "use strict";
2
+ /**
3
+ * MongoSqlDriver — Cube data source driver for MongoDB via MongoSQL.
4
+ *
5
+ * See SPEC.md FR-1 (driver protocol), FR-7 (env-var configuration), and
6
+ * ARCHITECTURE.md §2.1.
7
+ *
8
+ * BaseDriver method-list audit (against
9
+ * `@cubejs-backend/base-driver/dist/src/BaseDriver.d.ts`):
10
+ *
11
+ * Required by DriverInterface (we override these):
12
+ * - query(sql, values?, options?) — REQUIRED, override (delegates + flattens)
13
+ * - testConnection() — REQUIRED, override (lazy native init)
14
+ * - release() — override (closes native client)
15
+ * - tablesSchema() — override (delegate to native)
16
+ * - downloadQueryResults(sql, ...) — override → memory shape; streamImport ignored
17
+ * (capabilities().streamImport=false; see below)
18
+ * - capabilities() — override → no export bucket / streaming source
19
+ * - nowTimestamp() — INHERIT (Date.now())
20
+ * - stream(table, values, opts) — NOT implemented; `streamImport: false`
21
+ * advertises this. Cube routes the
22
+ * pre-agg upload path through
23
+ * `downloadQueryResults` instead.
24
+ *
25
+ * Schema-introspection helpers (BaseDriver default reads information_schema —
26
+ * MongoSQL doesn't expose one, so we route every shape through the native
27
+ * cached `tablesSchema()` rendering):
28
+ * - tablesSchema() — override (full snapshot, bulk path)
29
+ * - getSchemas() — override (DB list from tablesSchema)
30
+ * - getTablesForSpecificSchemas(...) — override (filter tablesSchema by db)
31
+ * - getColumnsForSpecificTables(...) — override (filter tablesSchema by table)
32
+ * - capabilities().incrementalSchemaLoading=true — opts Cube into the
33
+ * granular three-method path; SQL
34
+ * information_schema queries are
35
+ * never issued against mongosql.
36
+ * - getTablesQuery(schema) — N/A — runs SQL against information_schema
37
+ * - informationSchemaQuery() — N/A (protected default)
38
+ * - tableColumnTypes(table) — INHERIT (uses query()); only needed for
39
+ * uploadTableWithIndexes path which we reject
40
+ *
41
+ * Mutation methods (read-only driver — explicit refusal beats silent no-op):
42
+ * - createSchemaIfNotExists(schemaName) — throw
43
+ * - dropTable(tableName, options?) — throw
44
+ * - uploadTable(...) — throw
45
+ * - uploadTableWithIndexes(...) — throw
46
+ * - createTable(...) — throw
47
+ * - createTableRaw(...) — throw
48
+ * - loadPreAggregationIntoTable(...) — INHERIT default (delegates to query())
49
+ * so partitioned/incremental pre-aggs work
50
+ *
51
+ * Bucket-export (no MongoDB equivalent — SPEC FR-6 says EXPORT_BUCKET unsupported):
52
+ * - downloadTable(...) — INHERIT (uses query(); fine for memory path)
53
+ * - parseBucketUrl / extractFilesFromS3 / extractFilesFromGCS / extractFilesFromAzure
54
+ * — N/A (only invoked via unload paths we don't expose)
55
+ * - readOnly() — override → true (signals to Cube/Cube Store)
56
+ *
57
+ * SQL-shape helpers used by Cube but irrelevant to MongoSQL (the dialect class
58
+ * handles quoting; the driver does not echo SQL strings):
59
+ * - quoteIdentifier(id) — INHERIT (default uses '"', dialect class
60
+ * in MongoSqlQuery overrides to backticks)
61
+ * - param(i) — INHERIT
62
+ * - wrapQueryWithLimit({query, limit}) — INHERIT (mutates the object in place)
63
+ *
64
+ * Static:
65
+ * - dialectClass() — override → MongoSqlQuery
66
+ */
67
+ Object.defineProperty(exports, "__esModule", { value: true });
68
+ exports._normalizeRowShapeForTests = exports.MongoSqlDriver = void 0;
69
+ const base_driver_1 = require("@cubejs-backend/base-driver");
70
+ const native_js_1 = require("./native.js");
71
+ const MongoSqlQuery_js_1 = require("./MongoSqlQuery.js");
72
+ const config_js_1 = require("./config.js");
73
+ /**
74
+ * Cube driver class. Cube instantiates this when CUBEJS_DB_TYPE=mongosql.
75
+ *
76
+ * Construction is cheap and pure: env vars are read and validated, but no
77
+ * native client is created. The native client is created lazily on the first
78
+ * `testConnection()` / `query()` / `tablesSchema()` call so that throwing in
79
+ * the constructor reports config errors crisply (Cube wraps construction
80
+ * exceptions less helpfully than runtime ones).
81
+ *
82
+ * **Configuration env vars (see `./config.ts` and README "Configuration"):**
83
+ *
84
+ * Cube-standard (`CUBEJS_DB_*`):
85
+ * - `CUBEJS_DB_URL` / `CUBEJS_DB_URI` — full MongoDB connection string
86
+ * - `CUBEJS_DB_HOST` / `_PORT` / `_USER` / `_PASS` / `_NAME` — composed URI parts
87
+ * - `CUBEJS_DB_NAME` — database (also required separately by the driver)
88
+ * - `CUBEJS_DB_SSL` — `tls=true|false`
89
+ * - `CUBEJS_DB_MAX_POOL` / `_MIN_POOL` — `maxPoolSize` / `minPoolSize`
90
+ * - `CUBEJS_DB_QUERY_TIMEOUT` — per-query `maxTimeMS` (duration string or ms)
91
+ * - `CUBEJS_DB_IDLE_TIMEOUT` — `maxIdleTimeMS` (duration string or ms)
92
+ *
93
+ * MongoDB-specific (`CUBEJS_MONGOSQL_*`):
94
+ * - `_SCHEMA_SOURCE` — `collection` (default), `file`, or `atlas-sql`
95
+ * - `_SCHEMA_FILE` — path (required if `_SCHEMA_SOURCE=file`)
96
+ * - `_SCHEMA_REFRESH_SEC` — background refresh cadence in seconds
97
+ * - `_SCHEMA_FAIL_OPEN` — `true` to soft-fail initial schema load
98
+ * - `_QUERY_TIMEOUT_MS` — (legacy) bare-ms timeout; overridden by
99
+ * `CUBEJS_DB_QUERY_TIMEOUT` when both set
100
+ * - `_MAX_ROWS` — row cap per query
101
+ * - `_APP_NAME` — `appName` (shows up in `serverStatus().connections`)
102
+ * - `_MAX_CONNECTING` — `maxConnecting`
103
+ * - `_WAIT_QUEUE_TIMEOUT_MS` — `waitQueueTimeoutMS`
104
+ * - `_CONNECT_TIMEOUT_MS` — `connectTimeoutMS`
105
+ * - `_SOCKET_TIMEOUT_MS` — `socketTimeoutMS`
106
+ * - `_SERVER_SELECTION_TIMEOUT_MS` — `serverSelectionTimeoutMS`
107
+ * - `_HEARTBEAT_FREQUENCY_MS` — `heartbeatFrequencyMS`
108
+ * - `_RETRY_WRITES` / `_RETRY_READS` — `retryWrites` / `retryReads`
109
+ * - `_COMPRESSORS` — `compressors`
110
+ *
111
+ * User-set URI params (those already encoded in `CUBEJS_DB_URL/URI`) ALWAYS
112
+ * win — we only append env-driven params for keys the URI doesn't already
113
+ * specify.
114
+ */
115
+ class MongoSqlDriver extends base_driver_1.BaseDriver {
116
+ resolvedConfig;
117
+ client;
118
+ constructor(config) {
119
+ super();
120
+ this.resolvedConfig = buildConfig(config, process.env);
121
+ }
122
+ /** Test hook: surface the config the driver resolved from constructor + env. */
123
+ _config() {
124
+ return this.resolvedConfig;
125
+ }
126
+ // ---------- BaseDriver overrides ----------
127
+ async testConnection() {
128
+ const client = this.ensureClient();
129
+ await client.testConnection();
130
+ }
131
+ async query(sql, values, options) {
132
+ // Mongosql v1.8.5 does not accept SQL parameters via the wire (no
133
+ // `?` / `$N` placeholder substitution at the translator layer). Cube
134
+ // passes a values array on pre-aggregation build paths
135
+ // (partitioned/incremental rollups with `WHERE created_at >= CAST(? AS TIMESTAMP)`).
136
+ // We substitute the values into the SQL as quoted literals BEFORE
137
+ // sending to mongosql — equivalent to what CubeJS's `BaseQuery.paramAllocator`
138
+ // would emit when the dialect declares no param support.
139
+ const finalSql = values !== undefined && values.length > 0 ? substituteParameters(sql, values) : sql;
140
+ if (projectionHasNameCollision(finalSql)) {
141
+ throw translateFailed('JOIN projection contains two or more qualified columns with the same name ' +
142
+ '(e.g. `SELECT a.col, b.col` where the column names match). Mongosql ' +
143
+ 'collapses these into an empty-string envelope `{"": {col, col}}` and BSON ' +
144
+ 'document keys silently overwrite — losing data. Use `SELECT *` (returns ' +
145
+ '`<table>__<column>` prefixes) or alias each column explicitly ' +
146
+ '(`SELECT a.col AS a_col, b.col AS b_col`).');
147
+ }
148
+ // Cube's QueryOptions is `{ [key: string]: any }`; consumers that want
149
+ // cancellation can pass a plain AbortSignal via `options.signal`.
150
+ // Cube core does not pass one today, but `release()` cancellation flows
151
+ // through the parent close-token on the native side regardless — so
152
+ // SIGTERM cleanup works whether or not Cube ever wires this up.
153
+ const signal = extractAbortSignal(options);
154
+ const client = this.ensureClient();
155
+ const raw = await client.query(finalSql, signal);
156
+ const flat = flattenRows(raw);
157
+ return normalizeRowShape(flat);
158
+ }
159
+ async tablesSchema() {
160
+ const client = this.ensureClient();
161
+ return client.tablesSchema();
162
+ }
163
+ /**
164
+ * Cube's incremental-schema-loading entry point #1.
165
+ *
166
+ * Returns the list of schemas (databases) we expose. BaseDriver's
167
+ * default issues `SELECT table_schema ... FROM information_schema.tables`,
168
+ * which MongoSQL has no equivalent for. We re-render from the cached
169
+ * `tablesSchema()` snapshot — which the native side already keeps
170
+ * fresh via the background refresh task (SPEC FR-3 / ARCHITECTURE §3.2).
171
+ * Each call into `client.tablesSchema()` returns a fresh clone of the
172
+ * cached snapshot, so the cost is O(N) in catalog column count — for
173
+ * the typical few-thousand-cell catalog this is sub-millisecond on the
174
+ * hot path (no native I/O, no `__sql_schemas` round-trip).
175
+ *
176
+ * Driver only ever exposes the database configured at construction
177
+ * (FR-7 — `CUBEJS_DB_NAME`), so the returned list has at most one
178
+ * entry. An empty list is possible if no schemas have been loaded
179
+ * yet (e.g. `testConnection()` was never called or the schema source
180
+ * is empty); Cube handles that case the same way it would for the
181
+ * SQL path's empty resultset.
182
+ */
183
+ async getSchemas() {
184
+ const snapshot = await this.tablesSchema();
185
+ return Object.keys(snapshot).map((schema_name) => ({ schema_name }));
186
+ }
187
+ /**
188
+ * Cube's incremental-schema-loading entry point #2.
189
+ *
190
+ * For each requested schema, returns its table list from the cached
191
+ * `tablesSchema()`. Schemas the snapshot doesn't know about are
192
+ * silently dropped — mirrors the SQL path's "WHERE schema IN (...)"
193
+ * which would naturally exclude unknown names. Passing an empty
194
+ * `schemas` array returns an empty result with no native I/O cost
195
+ * beyond a single cache read.
196
+ */
197
+ async getTablesForSpecificSchemas(schemas) {
198
+ if (schemas.length === 0)
199
+ return [];
200
+ const snapshot = await this.tablesSchema();
201
+ const out = [];
202
+ for (const { schema_name } of schemas) {
203
+ const tables = snapshot[schema_name];
204
+ if (!tables)
205
+ continue;
206
+ for (const table_name of Object.keys(tables)) {
207
+ out.push({ schema_name, table_name });
208
+ }
209
+ }
210
+ return out;
211
+ }
212
+ /**
213
+ * Cube's incremental-schema-loading entry point #3.
214
+ *
215
+ * For each requested `(schema_name, table_name)`, returns one row
216
+ * per column with `column_name` and `data_type`. `data_type` is the
217
+ * Cube generic-type string emitted by the native side (`string`,
218
+ * `int`, `bigint`, `decimal`, `boolean`, `timestamp`, `text`) — the
219
+ * same values that surface in `tablesSchema()`. Unknown tables are
220
+ * silently dropped, same contract as `getTablesForSpecificSchemas`.
221
+ *
222
+ * `attributes` is forwarded verbatim from the column descriptor IF
223
+ * a future native version supplies one — but the Rust `do_tables_schema`
224
+ * (crates/native/src/client.rs:314) always emits `attributes: []` today,
225
+ * so in production this is effectively always an empty array. We do
226
+ * not emit `primaryKey` automatically — MongoDB's implicit `_id` IS a
227
+ * primary key, but tagging it without explicit schema annotation would
228
+ * surface as a foreign-key target in Cube's relationship inference and
229
+ * break heuristic joins. The TS-side forwarding path exists for a
230
+ * future Rust change where `__sql_schemas` documents' attribute fields
231
+ * are propagated; callers that need the tag today still must encode
232
+ * it explicitly in their __sql_schemas document AND in a Rust patch
233
+ * that surfaces it through `ColumnSchema::attributes`.
234
+ *
235
+ * `foreign_keys` is not derivable from MongoDB schema documents — the
236
+ * field is omitted (Cube's `QueryColumnsResult` declares it as
237
+ * optional via the spread of `TableColumnQueryResult`).
238
+ */
239
+ async getColumnsForSpecificTables(tables) {
240
+ if (tables.length === 0)
241
+ return [];
242
+ const snapshot = await this.tablesSchema();
243
+ const out = [];
244
+ for (const { schema_name, table_name } of tables) {
245
+ const cols = snapshot[schema_name]?.[table_name];
246
+ if (!cols)
247
+ continue;
248
+ for (const col of cols) {
249
+ out.push({
250
+ schema_name,
251
+ table_name,
252
+ column_name: col.name,
253
+ data_type: col.type,
254
+ attributes: col.attributes,
255
+ });
256
+ }
257
+ }
258
+ return out;
259
+ }
260
+ async release() {
261
+ if (this.client) {
262
+ const c = this.client;
263
+ this.client = undefined;
264
+ await c.close();
265
+ }
266
+ }
267
+ async downloadQueryResults(sql, values, options) {
268
+ // Pass through any caller-supplied AbortSignal so downloadQueryResults
269
+ // is cancellable on the same terms as query().
270
+ const signal = extractAbortSignal(options);
271
+ // The DownloadQueryResultsOptions interface includes `streamImport`
272
+ // (driver advertises whether it can stream rows during pre-aggregation
273
+ // builds). We advertise `streamImport: false` in `capabilities()` —
274
+ // mongosql v1.8.5 has no streaming cursor wired through napi-rs
275
+ // (napi-rs's ThreadsafeFunction round-trip is post-MVP per SPEC
276
+ // NFR-1) and the result is buffered up to `CUBEJS_MONGOSQL_MAX_ROWS`.
277
+ //
278
+ // Cube's contract: when a driver advertises `streamImport: false`,
279
+ // Cube does NOT call `downloadQueryResults` with `streamImport:
280
+ // true` (the option is reserved for `streamImport: true` drivers).
281
+ // We honor the contract by ignoring the flag entirely: callers that
282
+ // pass `streamImport: true` get the same memory `{rows, types}`
283
+ // shape — the option has no effect. This mirrors the BaseDriver
284
+ // default (`base-driver/dist/src/BaseDriver.js`), which also ignores
285
+ // `streamImport`.
286
+ //
287
+ // If a future caller wants streaming, the right shape is for the
288
+ // driver to ALSO implement the optional `stream(table, values,
289
+ // options) -> StreamTableData` method on `DriverInterface` (see
290
+ // `driver.interface.d.ts`). We do not implement it; until we do,
291
+ // `streamImport: false` is the right capability flag and the only
292
+ // honest answer is to keep returning the memory shape.
293
+ // Cube's pre-aggregation upload path passes `types` (a `[{name, type},
294
+ // ...]` list) to Cube Store to drive the LOAD ROWS column list. The
295
+ // types come from `mongosql::Translation::{select_order, result_set_schema}`
296
+ // — the authoritative projection order and BSON type mapping. We no
297
+ // longer sniff from row values: that path was non-deterministic in
298
+ // multi-partition pre-aggregations because mongosql constructs its
299
+ // `$project` stage by iterating a HashMap-backed `Schema::Document`,
300
+ // so two translations of the same SQL produced different field
301
+ // orders. Divergent column orders across partition rebuilds caused
302
+ // Cube Store UNIONs to fail with `type_coercion ... Timestamp ... Int64`.
303
+ const finalSql = values !== undefined && values.length > 0 ? substituteParameters(sql, values) : sql;
304
+ if (projectionHasNameCollision(finalSql)) {
305
+ throw translateFailed('JOIN projection contains two or more qualified columns with the same name ' +
306
+ '(e.g. `SELECT a.col, b.col` where the column names match). Mongosql ' +
307
+ 'collapses these into an empty-string envelope `{"": {col, col}}` and BSON ' +
308
+ 'document keys silently overwrite — losing data. Use `SELECT *` (returns ' +
309
+ '`<table>__<column>` prefixes) or alias each column explicitly ' +
310
+ '(`SELECT a.col AS a_col, b.col AS b_col`).');
311
+ }
312
+ const client = this.ensureClient();
313
+ const { rows, types } = await client.queryWithTypes(finalSql, signal);
314
+ // Use the authoritative type list (from `mongosql::Translation::select_order`)
315
+ // to null-fill any key that's expected by the projection but missing
316
+ // from some/all rows. Same root cause as `normalizeRowShape` — see its
317
+ // doc-comment. Here we prefer the type list over union-of-keys because
318
+ // (a) it's deterministic, and (b) it covers the edge case where a
319
+ // column is missing from EVERY row (a union would miss it; the type
320
+ // list still names it).
321
+ const flat = flattenRows(rows);
322
+ const expected = types.map((t) => t.name);
323
+ // NOTE: Mutates `flat` in place (see `normalizeRowShape` doc-comment for the
324
+ // mutation contract). First pass: null-fill every projected column named in
325
+ // the authoritative type list — covers the rare case where a column is
326
+ // missing from EVERY row (a pure union-of-keys would miss it). If `expected`
327
+ // is empty (e.g. an empty native response with no types), the for-loop is a
328
+ // no-op and the union pass below alone honors the FR-1 contract.
329
+ for (const k of expected) {
330
+ for (const r of flat) {
331
+ if (!Object.hasOwn(r, k))
332
+ r[k] = null;
333
+ }
334
+ }
335
+ // Belt-and-braces union-of-keys pass — handles any row keys outside
336
+ // `expected` (e.g. a future mongosql version emitting an extra envelope
337
+ // field) so this path honors the FR-1 "every row has the same key set"
338
+ // contract symmetrically with `query()`.
339
+ normalizeRowShape(flat);
340
+ return { rows: flat, types: types.map(normalizeColumnType) };
341
+ }
342
+ capabilities() {
343
+ return {
344
+ // No EXPORT_BUCKET, no streaming source, no CSV/stream import path.
345
+ unloadWithoutTempTable: false,
346
+ streamingSource: false,
347
+ // We implement getSchemas / getTablesForSpecificSchemas /
348
+ // getColumnsForSpecificTables on top of the cached native
349
+ // `tablesSchema()` snapshot. Cube uses the granular three-method
350
+ // path when this flag is true, which means it never falls back to
351
+ // the BaseDriver default that issues SQL against
352
+ // `information_schema.*` (MongoSQL has no such schema).
353
+ incrementalSchemaLoading: true,
354
+ csvImport: false,
355
+ // Driver has no streaming cursor wired through napi-rs
356
+ // (ThreadsafeFunction round-trip is post-MVP per SPEC NFR-1) so
357
+ // pre-aggregation builds use `downloadQueryResults`'s memory shape
358
+ // capped at `CUBEJS_MONGOSQL_MAX_ROWS`. The option is honored by
359
+ // `downloadQueryResults` as a no-op — see that method's comment.
360
+ streamImport: false,
361
+ };
362
+ }
363
+ readOnly() {
364
+ return true;
365
+ }
366
+ // ---------- Methods CubeJS expects but MongoSQL cannot fulfil ----------
367
+ async createSchemaIfNotExists(_schemaName) {
368
+ throw notSupported('createSchemaIfNotExists');
369
+ }
370
+ async dropTable(_tableName, _options) {
371
+ throw notSupported('dropTable');
372
+ }
373
+ async uploadTable(_table, _columns, _tableData) {
374
+ throw notSupported('uploadTable');
375
+ }
376
+ async uploadTableWithIndexes(_table, _columns, _tableData, _indexesSql, _uniqueKeyColumns, _queryTracingObj, _externalOptions) {
377
+ throw notSupported('uploadTableWithIndexes');
378
+ }
379
+ async createTable(_quotedTableName, _columns) {
380
+ throw notSupported('createTable');
381
+ }
382
+ async createTableRaw(_query) {
383
+ throw notSupported('createTableRaw');
384
+ }
385
+ // ---------- Static ----------
386
+ static dialectClass() {
387
+ return MongoSqlQuery_js_1.MongoSqlQuery;
388
+ }
389
+ // ---------- Internals ----------
390
+ ensureClient() {
391
+ if (!this.client) {
392
+ this.client = new native_js_1.MongoSqlClient(this.resolvedConfig);
393
+ }
394
+ return this.client;
395
+ }
396
+ }
397
+ exports.MongoSqlDriver = MongoSqlDriver;
398
+ /**
399
+ * Mongosql wraps each result row in a per-collection envelope:
400
+ * `[{ users: { _id, email, ... } }, ...]`
401
+ *
402
+ * Cube expects flat rows (`Record<string, unknown>`). Strategy:
403
+ * - Single top-level key whose value is a plain object → unwrap.
404
+ * - Multiple top-level keys (JOIN result) → merge with `<table>__<column>`.
405
+ * - Anything else (scalar at top level, no envelope) → pass through.
406
+ *
407
+ * Discovery: see IMPLEMENTATION_PLAN.md → 2026-05-09 — T08 (mongosql per-
408
+ * collection envelope) for the pipeline-shape source.
409
+ *
410
+ * **Critic v2 — Issue 2: empty-string envelope collision**.
411
+ *
412
+ * Mongosql emits `{"": {col: ..., col: ...}}` for explicit projections in
413
+ * JOINs (e.g. `SELECT users.col, orders.col FROM users JOIN orders ...`).
414
+ * If both sides project a column with the same name (`account_id`,
415
+ * `created_at`, `_id`), the BSON document keys collide and silently
416
+ * collapse — by the time JS has parsed the row we cannot detect the loss.
417
+ *
418
+ * Mitigation: input-side heuristic check in `query()` — if the SQL has a
419
+ * JOIN AND projects multiple qualified columns with the same trailing
420
+ * name, throw `MONGOSQL_TRANSLATE_FAILED` before executing. The flatten
421
+ * path itself stays permissive so single-collection queries with the
422
+ * naturally-occurring empty-string envelope (`SELECT col1, col2 FROM
423
+ * users` → `{"": {col1, col2}}`) keep working.
424
+ *
425
+ * Callers hitting the heuristic must either: (a) use `SELECT *` (multi-
426
+ * key envelope preserves `<table>__<col>` prefixes), or (b) alias the
427
+ * conflicting columns explicitly (`SELECT a.col AS a_col, b.col AS
428
+ * b_col`).
429
+ */
430
+ function flattenRows(rows) {
431
+ return rows.map((row) => flattenRow(row));
432
+ }
433
+ /**
434
+ * Make every row's key set identical by null-filling missing keys.
435
+ *
436
+ * **Mutates `rows` in place** — callers must not retain references to
437
+ * the pre-normalize array if they need the original sparse shape. The
438
+ * returned reference is the same array.
439
+ *
440
+ * **Why this exists.** Mongosql's `$project` stage that references a
441
+ * nested-path expression (e.g. `agent.displayName` on a docs collection)
442
+ * OMITS the field entirely when the source path is missing on the
443
+ * underlying document — it does not emit `null`. This bites downstream
444
+ * consumers whenever the row at index 0 happens to be sparser than later
445
+ * rows; `ORDER BY <nested-field> ASC` is the most common way this
446
+ * surfaces, but any query whose row 0 lacks a key that later rows have
447
+ * triggers the same.
448
+ *
449
+ * Downstream consumers (notably Cube's native `getFinalQueryResult`
450
+ * transform in `@cubejs-backend/native`) compile their row→member
451
+ * extraction plan from the keys present in **row 0**. A sparse row 0
452
+ * causes Cube to drop the column from every row in the response — even
453
+ * rows that DO have the value. Reproduced empirically against the
454
+ * `configs` collection (`SELECT id, agent_display_name FROM configs ...
455
+ * ORDER BY agent_display_name ASC LIMIT 500` → 500 rows, all missing
456
+ * `configs.agent_display_name`, even though 497/500 source docs have it).
457
+ *
458
+ * **Fix shape.** Take the union of keys across all rows and null-fill
459
+ * each row so every row has every key. We can't simply look at `row[0]`
460
+ * because that's the symptom. Iteration is O(rows × cols). At the
461
+ * `MAX_ROWS=100000` pre-agg cap with a ~20-column projection this is
462
+ * roughly 2M property-existence checks and stays under 100ms in practice.
463
+ *
464
+ * `downloadQueryResults` uses the authoritative type list instead of
465
+ * a key union (the union would miss columns absent from every row); that
466
+ * variant is implemented inline at the call site, not via this helper.
467
+ */
468
+ function normalizeRowShape(rows) {
469
+ if (rows.length === 0)
470
+ return rows;
471
+ const union = new Set();
472
+ for (const r of rows) {
473
+ for (const k of Object.keys(r))
474
+ union.add(k);
475
+ }
476
+ for (const r of rows) {
477
+ for (const k of union) {
478
+ // `Object.hasOwn` over `k in r` — the `in` operator traverses
479
+ // the prototype chain, which would diverge from `Object.keys(r)`
480
+ // above (own enumerable keys only) on rows that inherit through a
481
+ // non-default prototype.
482
+ if (!Object.hasOwn(r, k))
483
+ r[k] = null;
484
+ }
485
+ }
486
+ return rows;
487
+ }
488
+ /**
489
+ * Test-only export. Lets `tests/unit/driver.test.ts` exercise the
490
+ * key-union null-fill in isolation without spinning up the driver.
491
+ * Not part of the public API.
492
+ */
493
+ exports._normalizeRowShapeForTests = normalizeRowShape;
494
+ function flattenRow(row) {
495
+ const keys = Object.keys(row);
496
+ if (keys.length === 1) {
497
+ const only = row[keys[0]];
498
+ if (isPlainObject(only))
499
+ return only;
500
+ return row;
501
+ }
502
+ const out = {};
503
+ for (const [tbl, val] of Object.entries(row)) {
504
+ if (isPlainObject(val)) {
505
+ for (const [col, v] of Object.entries(val)) {
506
+ out[`${tbl}__${col}`] = v;
507
+ }
508
+ }
509
+ else {
510
+ out[tbl] = val;
511
+ }
512
+ }
513
+ return out;
514
+ }
515
+ /**
516
+ * Heuristic detector for the empty-string-envelope column-collision risk
517
+ * (Critic v2 — Issue 2). Returns true iff `sql` contains a JOIN AND
518
+ * projects two or more *qualified* columns (`<ident>.<column>`) where
519
+ * the trailing column name appears more than once. False negatives are
520
+ * acceptable (we can only see source SQL, not the full algebra); false
521
+ * positives are bounded by the JOIN gate so plain single-table queries
522
+ * are never blocked.
523
+ *
524
+ * The check is deliberately conservative — it does not parse SQL,
525
+ * doesn't strip comments, and only flags the obvious shape. Cube's own
526
+ * generated SQL always uses aliases or `SELECT *`-via-cube-views; users
527
+ * writing raw SQL who hit this should add `AS <alias>` and re-run.
528
+ */
529
+ function projectionHasNameCollision(sql) {
530
+ if (!/\bjoin\b/i.test(sql))
531
+ return false;
532
+ const head = sql.match(/^\s*select\s+([\s\S]+?)\s+from\b/i);
533
+ if (!head)
534
+ return false;
535
+ const projection = head[1];
536
+ if (projection.trim() === '*')
537
+ return false;
538
+ // Strip parenthesised expressions (function args, subqueries) so we
539
+ // don't pick up nested column refs as projection items.
540
+ const flat = projection.replace(/\([^()]*\)/g, '');
541
+ const items = flat.split(',');
542
+ const trailingNames = [];
543
+ for (const raw of items) {
544
+ const item = raw.trim();
545
+ if (!item)
546
+ continue;
547
+ // If the projection item has an alias (`AS xxx` or bare alias),
548
+ // collisions are no longer possible — skip.
549
+ if (/\bas\b/i.test(item))
550
+ continue;
551
+ // Look for `<ident>.<column>` at the end of the item.
552
+ const m = item.match(/[A-Za-z_][\w]*\.([A-Za-z_][\w]*)\s*$/);
553
+ if (m)
554
+ trailingNames.push(m[1].toLowerCase());
555
+ }
556
+ const seen = new Set();
557
+ for (const n of trailingNames) {
558
+ if (seen.has(n))
559
+ return true;
560
+ seen.add(n);
561
+ }
562
+ return false;
563
+ }
564
+ /**
565
+ * Substitute `?` placeholders in `sql` with literal values from `values`.
566
+ *
567
+ * Mongosql v1.8.5 has no wire-level parameter protocol, so Cube's
568
+ * pre-aggregation paths (which emit `WHERE col >= CAST(? AS TIMESTAMP)` +
569
+ * `[Date, Date]`) need their placeholders inlined before translation.
570
+ *
571
+ * Skips `?` that appear inside single-quoted string literals. Doubled
572
+ * single-quote (`''`) is treated as a literal `'` inside the current
573
+ * string — matches the standard SQL escape convention.
574
+ *
575
+ * Throws `MONGOSQL_CONFIG_INVALID` on placeholder/value count mismatch.
576
+ */
577
+ function substituteParameters(sql, values) {
578
+ let out = '';
579
+ let inString = false;
580
+ let idx = 0;
581
+ for (let i = 0; i < sql.length; i++) {
582
+ const c = sql[i];
583
+ if (c === "'") {
584
+ // Doubled single-quote inside a string is an escaped quote — pass
585
+ // through both characters and stay in-string.
586
+ if (inString && sql[i + 1] === "'") {
587
+ out += "''";
588
+ i++;
589
+ continue;
590
+ }
591
+ inString = !inString;
592
+ out += c;
593
+ }
594
+ else if (c === '?' && !inString) {
595
+ if (idx >= values.length) {
596
+ throw configInvalid(`SQL has more '?' placeholders than provided values (consumed ${idx} of ${values.length})`);
597
+ }
598
+ out += formatSqlLiteral(values[idx]);
599
+ idx++;
600
+ }
601
+ else {
602
+ out += c;
603
+ }
604
+ }
605
+ if (idx < values.length) {
606
+ throw configInvalid(`parameter list has more values (${values.length}) than '?' placeholders in SQL (${idx})`);
607
+ }
608
+ return out;
609
+ }
610
+ function formatSqlLiteral(v) {
611
+ if (v === null || v === undefined)
612
+ return 'NULL';
613
+ if (typeof v === 'boolean')
614
+ return v ? 'TRUE' : 'FALSE';
615
+ if (typeof v === 'number')
616
+ return Number.isFinite(v) ? String(v) : 'NULL';
617
+ if (typeof v === 'bigint')
618
+ return v.toString();
619
+ if (v instanceof Date) {
620
+ if (Number.isNaN(v.getTime()))
621
+ return 'NULL';
622
+ // ISO-8601, no quoting wrapper — callers usually wrap with `CAST(... AS TIMESTAMP)`.
623
+ return `'${v.toISOString()}'`;
624
+ }
625
+ return `'${String(v).replace(/'/g, "''")}'`;
626
+ }
627
+ /**
628
+ * Defensive passthrough for the native-side `(name, type)` entry.
629
+ *
630
+ * The Rust layer already returns one of Cube's documented generic-type
631
+ * strings (`timestamp`, `int`, `bigint`, `decimal`, `boolean`, `string`,
632
+ * `text`). We re-shape into a plain object so consumers depending on
633
+ * structural typing don't end up with the napi-rs deserialised view.
634
+ */
635
+ function normalizeColumnType(c) {
636
+ return { name: c.name, type: c.type };
637
+ }
638
+ function isPlainObject(v) {
639
+ return typeof v === 'object' && v !== null && !Array.isArray(v) && !(v instanceof Date);
640
+ }
641
+ /**
642
+ * Extract a caller-supplied AbortSignal from Cube's open `QueryOptions`
643
+ * shape. Cube core does not currently pass one, but the contract is
644
+ * `[key: string]: any`, so any caller that wires `options.signal` will
645
+ * have it propagate through to the native cancel token. Returns
646
+ * `undefined` if no signal is present or the value is not an AbortSignal.
647
+ */
648
+ function extractAbortSignal(options) {
649
+ if (!options)
650
+ return undefined;
651
+ const candidate = options.signal;
652
+ // Defensive check: AbortSignal is the standard browser/Node API.
653
+ // Reject plain objects so we don't crash inside the native bridge.
654
+ if (typeof AbortSignal !== 'undefined' && candidate instanceof AbortSignal) {
655
+ return candidate;
656
+ }
657
+ return undefined;
658
+ }
659
+ /**
660
+ * Resolve the runtime `MongoSqlConfig` from constructor overrides + env.
661
+ *
662
+ * URI building lives in `./config.ts` — see that module's header for
663
+ * the env vars honoured, the precedence rules (constructor uri >
664
+ * `CUBEJS_DB_URL` > `CUBEJS_DB_URI` > composed from `CUBEJS_DB_HOST`
665
+ * + parts), and the duration-string format accepted by
666
+ * `CUBEJS_DB_QUERY_TIMEOUT` / `CUBEJS_DB_IDLE_TIMEOUT`.
667
+ *
668
+ * Everything else stays here:
669
+ * - `database` (required): explicit > `CUBEJS_DB_NAME`.
670
+ * - `schemaSource`, `schemaRefreshSec`, `schemaFailOpen`, `maxRows`:
671
+ * existing `CUBEJS_MONGOSQL_*` semantics, unchanged.
672
+ * - `queryTimeoutMs`: explicit > `CUBEJS_DB_QUERY_TIMEOUT` >
673
+ * `CUBEJS_MONGOSQL_QUERY_TIMEOUT_MS` (resolved in config.ts).
674
+ */
675
+ function buildConfig(override, env) {
676
+ const { uri, queryTimeoutMs: envQueryTimeoutMs } = (0, config_js_1.resolveUriConfig)(override?.uri, env);
677
+ const database = override?.database ?? env.CUBEJS_DB_NAME;
678
+ if (!database) {
679
+ throw configInvalidMissing('database (set CUBEJS_DB_NAME or pass `database` to the constructor)');
680
+ }
681
+ const schemaSource = override?.schemaSource ?? schemaSourceFromEnv(env);
682
+ return {
683
+ uri,
684
+ database,
685
+ schemaSource,
686
+ schemaRefreshSec: override?.schemaRefreshSec ?? numEnv(env.CUBEJS_MONGOSQL_SCHEMA_REFRESH_SEC),
687
+ schemaFailOpen: override?.schemaFailOpen ?? boolEnv(env.CUBEJS_MONGOSQL_SCHEMA_FAIL_OPEN),
688
+ queryTimeoutMs: override?.queryTimeoutMs ?? envQueryTimeoutMs,
689
+ maxRows: override?.maxRows ?? numEnv(env.CUBEJS_MONGOSQL_MAX_ROWS),
690
+ };
691
+ }
692
+ function schemaSourceFromEnv(env) {
693
+ const kind = env.CUBEJS_MONGOSQL_SCHEMA_SOURCE;
694
+ if (!kind)
695
+ return undefined;
696
+ if (kind === 'collection')
697
+ return { kind: 'collection' };
698
+ if (kind === 'file') {
699
+ const path = env.CUBEJS_MONGOSQL_SCHEMA_FILE;
700
+ if (!path) {
701
+ throw configInvalid('CUBEJS_MONGOSQL_SCHEMA_FILE must be set when CUBEJS_MONGOSQL_SCHEMA_SOURCE=file');
702
+ }
703
+ return { kind: 'file', path };
704
+ }
705
+ // Atlas SQL endpoints (`*.a.query.mongodb.net`) do not expose
706
+ // `__sql_schemas` as a queryable collection — schemas live in an
707
+ // internal store reachable only via the `sqlGetSchema` admin command.
708
+ // See https://www.mongodb.com/docs/sql-interface/schema/view/ and
709
+ // `crates/native/src/schema.rs` module docs.
710
+ if (kind === 'atlas-sql')
711
+ return { kind: 'atlas-sql' };
712
+ throw configInvalid(`CUBEJS_MONGOSQL_SCHEMA_SOURCE must be 'collection', 'file', or 'atlas-sql' (got '${kind}')`);
713
+ }
714
+ function numEnv(v) {
715
+ if (v === undefined || v === '')
716
+ return undefined;
717
+ const n = Number(v);
718
+ if (!Number.isFinite(n))
719
+ return undefined;
720
+ return n;
721
+ }
722
+ function boolEnv(v) {
723
+ if (v === undefined || v === '')
724
+ return undefined;
725
+ return v.toLowerCase() === 'true' || v === '1';
726
+ }
727
+ function configInvalidMissing(detail) {
728
+ return configInvalid(`missing required config: ${detail}`);
729
+ }
730
+ function configInvalid(detail) {
731
+ const err = new Error(`MONGOSQL_CONFIG_INVALID: ${detail}`);
732
+ err.code = 'MONGOSQL_CONFIG_INVALID';
733
+ err.name = 'MongoSqlError';
734
+ return err;
735
+ }
736
+ function translateFailed(detail) {
737
+ const err = new Error(`MONGOSQL_TRANSLATE_FAILED: ${detail}`);
738
+ err.code = 'MONGOSQL_TRANSLATE_FAILED';
739
+ err.name = 'MongoSqlError';
740
+ return err;
741
+ }
742
+ function notSupported(method) {
743
+ return new Error(`MongoSqlDriver: '${method}' is not supported by the MongoSQL driver (read-only / no EXPORT_BUCKET path)`);
744
+ }
745
+ //# sourceMappingURL=MongoSqlDriver.js.map