• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

visgl / loaders.gl / 24509568695

16 Apr 2026 12:13PM UTC coverage: 56.692% (+0.03%) from 56.662%
24509568695

push

github

web-flow
feat(sql) New SQL module with SQLDataSource and DuckDB connector (#3389)

10064 of 19243 branches covered (52.3%)

Branch coverage included in aggregate %.

159 of 239 new or added lines in 4 files covered. (66.53%)

20686 of 34997 relevant lines covered (59.11%)

5007.32 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

39.41
/modules/sql/src/duckdb-sql-source.ts
1
// loaders.gl
2
// SPDX-License-Identifier: MIT
3
// Copyright (c) vis.gl contributors
4

5
import type {Schema} from '@loaders.gl/schema';
6
import type {SourceLoader, CoreAPI} from '@loaders.gl/loader-utils';
7
import {convertArrowToTable} from '@loaders.gl/schema-utils';
8

9
import {SQLDataSource, SQL_SOURCE_DEFAULT_OPTIONS} from './sql-source';
10
import type {
11
  SQLAdapter,
12
  SQLAdapterFactoryContext,
13
  SQLCatalogInfo,
14
  SQLColumnInfo,
15
  SQLQueryOptions,
16
  SQLSourceOptions,
17
  SQLTableInfo
18
} from './sql-types';
19
import {
20
  convertRowsToArrowTable,
21
  convertSQLColumnsToSchema,
22
  escapeSqlString,
23
  isNodeRuntime
24
} from './sql-utils';
25

26
// __VERSION__ is injected by babel-plugin-version-inline
27
// @ts-ignore TS2304: Cannot find name '__VERSION__'.
28
const VERSION = typeof __VERSION__ !== 'undefined' ? __VERSION__ : 'latest';
3!
29

30
/** DuckDB-backed SQL source. */
31
export const DuckDBSQLSource = {
3✔
32
  dataType: null as unknown as DuckDBSQLDataSource,
33
  batchType: null as never,
34
  name: 'DuckDBSQLSource',
35
  id: 'duckdb-sql',
36
  module: 'sql',
37
  version: VERSION,
38
  extensions: ['duckdb', 'db'],
39
  mimeTypes: [],
40
  type: 'duckdb-sql',
41
  fromUrl: true,
42
  fromBlob: false,
43
  options: {
44
    sql: {
45
      resultFormat: 'auto',
46
      adapterOptions: {},
47
      browserAdapterFactory: undefined,
48
      nodeAdapterFactory: undefined
49
    },
50
    duckdb: {
51
      accessMode: 'auto',
52
      bundles: undefined,
53
      databasePath: undefined,
54
      remoteUrl: undefined,
55
      workerUrl: undefined
56
    },
57
    snowflake: {
58
      account: undefined,
59
      database: undefined,
60
      schema: undefined,
61
      token: undefined,
62
      warehouse: undefined,
63
      role: undefined,
64
      authenticator: undefined
65
    }
66
  },
67
  defaultOptions: SQL_SOURCE_DEFAULT_OPTIONS,
NEW
68
  testURL: (url: string): boolean => /^duckdb:\/\//i.test(url),
×
69
  createDataSource: (
70
    data: string,
71
    options: SQLSourceOptions,
72
    coreApi?: CoreAPI
73
  ): DuckDBSQLDataSource => new DuckDBSQLDataSource(data, options, coreApi)
2✔
74
} as const satisfies SourceLoader<DuckDBSQLDataSource>;
75

76
/** SQLDataSource specialization for DuckDB. */
77
export class DuckDBSQLDataSource extends SQLDataSource {
78
  constructor(data: string, options: SQLSourceOptions, coreApi?: CoreAPI) {
79
    super(
2✔
80
      data,
81
      options,
82
      DuckDBSQLSource.type,
83
      SQL_SOURCE_DEFAULT_OPTIONS,
84
      createDuckDBAdapter,
85
      coreApi
86
    );
87
  }
88
}
89

90
async function createDuckDBAdapter(context: SQLAdapterFactoryContext): Promise<SQLAdapter> {
91
  if (context.options.duckdb?.remoteUrl) {
1!
NEW
92
    throw new Error('Remote DuckDB adapters are not implemented yet.');
×
93
  }
94
  if (isNodeRuntime()) {
1!
95
    return await createDuckDBNodeAdapter(context);
1✔
96
  }
NEW
97
  return await createDuckDBBrowserAdapter(context);
×
98
}
99

100
async function createDuckDBNodeAdapter(context: SQLAdapterFactoryContext): Promise<SQLAdapter> {
101
  const duckdb = await import('@duckdb/node-api');
1✔
102

103
  const databasePath = getDuckDBDatabasePath(context.url, context.options);
1✔
104
  const configuration =
105
    context.options.duckdb?.accessMode && context.options.duckdb.accessMode !== 'auto'
1!
106
      ? {access_mode: context.options.duckdb.accessMode}
107
      : undefined;
108

109
  let connection: any;
110

111
  const executeRows = async (
1✔
112
    sqlText: string,
113
    options: SQLQueryOptions = {}
5✔
114
  ): Promise<Record<string, unknown>[]> => {
115
    if (!connection) {
5!
NEW
116
      const instance = await duckdb.DuckDBInstance.create(databasePath, configuration);
×
NEW
117
      connection = await instance.connect();
×
118
    }
119
    const reader = await connection.runAndReadAll(sqlText, options.parameters);
5✔
120
    return reader.getRowObjectsJson();
5✔
121
  };
122

123
  return {
1✔
124
    capabilities: {
125
      supportsArrow: true,
126
      supportsMetadata: true,
127
      runtime: 'node',
128
      isDynamic: true
129
    },
130
    async connect(): Promise<void> {
131
      if (!connection) {
1!
132
        const instance = await duckdb.DuckDBInstance.create(databasePath, configuration);
1✔
133
        connection = await instance.connect();
1✔
134
      }
135
    },
136
    async close(): Promise<void> {
137
      if (connection?.closeSync) {
1!
138
        connection.closeSync();
1✔
139
      }
140
      connection = null;
1✔
141
    },
142
    async listCatalogs(): Promise<SQLCatalogInfo[]> {
NEW
143
      const rows = await executeRows(`
×
144
        SELECT DISTINCT catalog_name
145
        FROM information_schema.schemata
146
        ORDER BY catalog_name
147
      `);
NEW
148
      return rows.map(row => ({catalogName: String(row.catalog_name)}));
×
149
    },
150
    async listSchemas(catalogName?: string) {
NEW
151
      const filter = catalogName ? `WHERE catalog_name = '${escapeSqlString(catalogName)}'` : '';
×
NEW
152
      const rows = await executeRows(`
×
153
        SELECT catalog_name, schema_name
154
        FROM information_schema.schemata
155
        ${filter}
156
        ORDER BY catalog_name, schema_name
157
      `);
NEW
158
      return rows.map(row => ({
×
159
        catalogName: row.catalog_name ? String(row.catalog_name) : undefined,
×
160
        schemaName: String(row.schema_name)
161
      }));
162
    },
163
    async listTables(options?: {
164
      catalogName?: string;
165
      schemaName?: string;
166
    }): Promise<SQLTableInfo[]> {
167
      const filters = [
1✔
168
        options?.catalogName
1!
169
          ? `table_catalog = '${escapeSqlString(options.catalogName)}'`
170
          : undefined,
171
        options?.schemaName ? `table_schema = '${escapeSqlString(options.schemaName)}'` : undefined,
1!
172
        `table_schema NOT IN ('information_schema', 'pg_catalog')`
173
      ].filter(Boolean);
174
      const whereClause = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
1!
175
      const rows = await executeRows(`
1✔
176
        SELECT table_catalog, table_schema, table_name, table_type
177
        FROM information_schema.tables
178
        ${whereClause}
179
        ORDER BY table_catalog, table_schema, table_name
180
      `);
181
      return rows.map(row => ({
1✔
182
        catalogName: row.table_catalog ? String(row.table_catalog) : undefined,
1!
183
        schemaName: row.table_schema ? String(row.table_schema) : undefined,
1!
184
        tableName: String(row.table_name),
185
        tableType: row.table_type ? String(row.table_type) : undefined
1!
186
      }));
187
    },
188
    async getTableSchema(options: {
189
      catalogName?: string;
190
      schemaName?: string;
191
      tableName: string;
192
    }): Promise<Schema> {
193
      const filters = [
1✔
194
        options.catalogName
1!
195
          ? `table_catalog = '${escapeSqlString(options.catalogName)}'`
196
          : undefined,
197
        options.schemaName ? `table_schema = '${escapeSqlString(options.schemaName)}'` : undefined,
1!
198
        `table_name = '${escapeSqlString(options.tableName)}'`
199
      ].filter(Boolean);
200
      const rows = await executeRows(`
1✔
201
        SELECT table_catalog, table_schema, table_name, column_name, data_type, is_nullable, ordinal_position
202
        FROM information_schema.columns
203
        WHERE ${filters.join(' AND ')}
204
        ORDER BY ordinal_position
205
      `);
206
      const columns = rows.map(
1✔
207
        row =>
208
          ({
1✔
209
            catalogName: row.table_catalog ? String(row.table_catalog) : undefined,
1!
210
            schemaName: row.table_schema ? String(row.table_schema) : undefined,
1!
211
            tableName: row.table_name ? String(row.table_name) : undefined,
1!
212
            columnName: String(row.column_name),
213
            sqlType: String(row.data_type),
214
            nullable: row.is_nullable === 'YES',
215
            ordinalPosition: Number(row.ordinal_position)
216
          }) satisfies SQLColumnInfo
217
      );
218
      return convertSQLColumnsToSchema(columns);
1✔
219
    },
220
    async executeRows(
221
      sqlText: string,
222
      options: SQLQueryOptions = {}
2✔
223
    ): Promise<Record<string, unknown>[]> {
224
      return await executeRows(sqlText, options);
2✔
225
    },
226
    async executeArrow(sqlText: string, options: SQLQueryOptions = {}) {
1✔
227
      const rows = await executeRows(sqlText, options);
1✔
228
      return convertRowsToArrowTable(rows);
1✔
229
    }
230
  };
231
}
232

233
async function createDuckDBBrowserAdapter(context: SQLAdapterFactoryContext): Promise<SQLAdapter> {
NEW
234
  const duckdb = await import('@duckdb/duckdb-wasm');
×
235

NEW
236
  const logger = new duckdb.ConsoleLogger();
×
NEW
237
  const selectedBundles = await duckdb.selectBundle(duckdb.getJsDelivrBundles());
×
NEW
238
  const bundles = {
×
239
    ...selectedBundles,
240
    ...context.options.duckdb?.bundles
241
  };
NEW
242
  const workerUrl = context.options.duckdb?.workerUrl || selectedBundles.mainWorker;
×
NEW
243
  if (!workerUrl) {
×
NEW
244
    throw new Error('DuckDBSQLSource could not resolve a worker bundle URL.');
×
245
  }
NEW
246
  const worker = await duckdb.createWorker(workerUrl);
×
NEW
247
  const database = new duckdb.AsyncDuckDB(logger, worker);
×
NEW
248
  await database.instantiate(bundles.mainModule, bundles.pthreadWorker);
×
NEW
249
  const connection = await database.connect();
×
250

NEW
251
  const executeRows = async (sqlText: string): Promise<Record<string, unknown>[]> => {
×
NEW
252
    const result = await connection.query(sqlText);
×
NEW
253
    return convertArrowToTable(result, 'object-row-table').data;
×
254
  };
255

NEW
256
  return {
×
257
    capabilities: {
258
      supportsArrow: true,
259
      supportsMetadata: true,
260
      runtime: 'browser',
261
      isDynamic: true
262
    },
263
    async connect(): Promise<void> {},
264
    async close(): Promise<void> {
NEW
265
      await connection.close();
×
NEW
266
      await database.terminate();
×
NEW
267
      worker.terminate();
×
268
    },
269
    async listCatalogs(): Promise<SQLCatalogInfo[]> {
NEW
270
      const rows = await executeRows(`
×
271
        SELECT DISTINCT catalog_name
272
        FROM information_schema.schemata
273
        ORDER BY catalog_name
274
      `);
NEW
275
      return rows.map(row => ({catalogName: String(row.catalog_name)}));
×
276
    },
277
    async listSchemas(catalogName?: string) {
NEW
278
      const filter = catalogName ? `WHERE catalog_name = '${escapeSqlString(catalogName)}'` : '';
×
NEW
279
      const rows = await executeRows(`
×
280
        SELECT catalog_name, schema_name
281
        FROM information_schema.schemata
282
        ${filter}
283
        ORDER BY catalog_name, schema_name
284
      `);
NEW
285
      return rows.map(row => ({
×
286
        catalogName: row.catalog_name ? String(row.catalog_name) : undefined,
×
287
        schemaName: String(row.schema_name)
288
      }));
289
    },
290
    async listTables(options?: {
291
      catalogName?: string;
292
      schemaName?: string;
293
    }): Promise<SQLTableInfo[]> {
NEW
294
      const filters = [
×
295
        options?.catalogName
×
296
          ? `table_catalog = '${escapeSqlString(options.catalogName)}'`
297
          : undefined,
298
        options?.schemaName ? `table_schema = '${escapeSqlString(options.schemaName)}'` : undefined,
×
299
        `table_schema NOT IN ('information_schema', 'pg_catalog')`
300
      ].filter(Boolean);
NEW
301
      const whereClause = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
×
NEW
302
      const rows = await executeRows(`
×
303
        SELECT table_catalog, table_schema, table_name, table_type
304
        FROM information_schema.tables
305
        ${whereClause}
306
        ORDER BY table_catalog, table_schema, table_name
307
      `);
NEW
308
      return rows.map(row => ({
×
309
        catalogName: row.table_catalog ? String(row.table_catalog) : undefined,
×
310
        schemaName: row.table_schema ? String(row.table_schema) : undefined,
×
311
        tableName: String(row.table_name),
312
        tableType: row.table_type ? String(row.table_type) : undefined
×
313
      }));
314
    },
315
    async getTableSchema(options: {
316
      catalogName?: string;
317
      schemaName?: string;
318
      tableName: string;
319
    }): Promise<Schema> {
NEW
320
      const filters = [
×
321
        options.catalogName
×
322
          ? `table_catalog = '${escapeSqlString(options.catalogName)}'`
323
          : undefined,
324
        options.schemaName ? `table_schema = '${escapeSqlString(options.schemaName)}'` : undefined,
×
325
        `table_name = '${escapeSqlString(options.tableName)}'`
326
      ].filter(Boolean);
NEW
327
      const rows = await executeRows(`
×
328
        SELECT table_catalog, table_schema, table_name, column_name, data_type, is_nullable, ordinal_position
329
        FROM information_schema.columns
330
        WHERE ${filters.join(' AND ')}
331
        ORDER BY ordinal_position
332
      `);
NEW
333
      const columns = rows.map(
×
334
        row =>
NEW
335
          ({
×
336
            catalogName: row.table_catalog ? String(row.table_catalog) : undefined,
×
337
            schemaName: row.table_schema ? String(row.table_schema) : undefined,
×
338
            tableName: row.table_name ? String(row.table_name) : undefined,
×
339
            columnName: String(row.column_name),
340
            sqlType: String(row.data_type),
341
            nullable: row.is_nullable === 'YES',
342
            ordinalPosition: Number(row.ordinal_position)
343
          }) satisfies SQLColumnInfo
344
      );
NEW
345
      return convertSQLColumnsToSchema(columns);
×
346
    },
347
    async executeRows(sqlText: string): Promise<Record<string, unknown>[]> {
NEW
348
      return await executeRows(sqlText);
×
349
    },
350
    async executeArrow(sqlText: string) {
NEW
351
      const result = await connection.query(sqlText);
×
NEW
352
      return convertArrowToTable(result, 'arrow-table');
×
353
    }
354
  };
355
}
356

357
function getDuckDBDatabasePath(url: string, options: SQLSourceOptions): string {
358
  if (options.duckdb?.databasePath) {
1!
NEW
359
    return options.duckdb.databasePath;
×
360
  }
361

362
  const parsedUrl = new URL(url);
1✔
363
  const pathname = decodeURIComponent(parsedUrl.pathname || '');
1!
364
  if (!pathname || pathname === '/') {
1!
NEW
365
    return ':memory:';
×
366
  }
367
  return pathname.slice(1) || ':memory:';
1!
368
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc