@alt-javascript/camel-lite-component-sql 1.0.2

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,64 @@
1
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
2
+
3
+ ## What
4
+
5
+ SQL query and update producer using Node.js built-in [`node:sqlite`](https://nodejs.org/api/sqlite.html) — no native compilation required. The SQL statement is placed in the URI path (URL-encoded). Query results are set as the exchange body; for updates the row-change count is set.
6
+
7
+ > **Requires Node.js 22.5.0 or later.** Uses `node:sqlite` (DatabaseSync / StatementSync). No `better-sqlite3` or other native dependency.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ npm install camel-lite-component-sql
13
+ ```
14
+
15
+ ## URI Syntax
16
+
17
+ ```
18
+ sql:<URL-encoded SQL>[?url=jdbc:sqlite::memory:]
19
+ ```
20
+
21
+ URL-encode spaces as `+` in the SQL statement.
22
+
23
+ | Parameter | Default | Description |
24
+ |-----------|---------|-------------|
25
+ | `url` | *(required)* | SQLite connection URL. Format: `jdbc:sqlite:<file-path>` or `jdbc:sqlite::memory:` for in-memory. |
26
+
27
+ ## Usage
28
+
29
+ ```js
30
+ import { CamelContext } from 'camel-lite-core';
31
+ import { SqlComponent } from 'camel-lite-component-sql';
32
+
33
+ const context = new CamelContext();
34
+ context.addComponent('sql', new SqlComponent());
35
+
36
+ context.addRoutes({
37
+ configure(ctx) {
38
+ // Insert: exchange body provides the bound parameter value
39
+ ctx.from('direct:logEvent')
40
+ .to('sql:INSERT+INTO+events+(message)+VALUES+(?)?url=jdbc:sqlite:/tmp/events.db');
41
+
42
+ // Query: results returned as array of row objects
43
+ ctx.from('direct:fetchEvents')
44
+ .to('sql:SELECT+*+FROM+events?url=jdbc:sqlite:/tmp/events.db');
45
+ }
46
+ });
47
+
48
+ await context.start();
49
+
50
+ const template = context.createProducerTemplate();
51
+
52
+ // Insert
53
+ await template.sendBody('direct:logEvent', 'something happened');
54
+
55
+ // Query
56
+ const exchange = await template.send('direct:fetchEvents', ex => { ex.in.body = []; });
57
+ console.log('Rows:', exchange.in.body);
58
+
59
+ await context.stop();
60
+ ```
61
+
62
+ ## See Also
63
+
64
+ [camel-lite — root README](../../README.md)
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@alt-javascript/camel-lite-component-sql",
3
+ "version": "1.0.2",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": "./src/index.js"
7
+ },
8
+ "dependencies": {
9
+ "@alt-javascript/logger": "^3.0.7",
10
+ "@alt-javascript/config": "^3.0.7",
11
+ "@alt-javascript/common": "^3.0.7",
12
+ "@alt-javascript/camel-lite-core": "1.0.2"
13
+ },
14
+ "scripts": {
15
+ "test": "node --test"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/alt-javascript/camel-lite"
20
+ },
21
+ "author": "Craig Parravicini",
22
+ "contributors": [
23
+ "Claude (Anthropic)",
24
+ "Apache Camel — design inspiration and pattern source"
25
+ ],
26
+ "keywords": [
27
+ "alt-javascript",
28
+ "camel",
29
+ "camel-lite",
30
+ "eai",
31
+ "eip",
32
+ "integration",
33
+ "sql",
34
+ "sqlite",
35
+ "database",
36
+ "component"
37
+ ],
38
+ "publishConfig": {
39
+ "registry": "https://registry.npmjs.org/",
40
+ "access": "public"
41
+ }
42
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * ParameterBinder — normalises named `:param` placeholders to driver-specific syntax.
3
+ *
4
+ * Supported dialects:
5
+ * 'pg' → $1, $2, ... (positional, params returned as array)
6
+ * 'mysql2' → ?, ?, ... (positional, params returned as array)
7
+ * 'sqlite' → ?, ?, ... (positional, params returned as array)
8
+ * 'named' → :param (pass-through, params returned as object)
9
+ *
10
+ * Input template uses :paramName placeholders:
11
+ * SELECT * FROM users WHERE id = :id AND status = :status
12
+ *
13
+ * Params are sourced from (in order):
14
+ * 1. exchange.in.body if it is a plain object
15
+ * 2. exchange.in.headers (as key→value Map)
16
+ * 3. Empty object if neither is a plain object
17
+ */
18
+ export const ParameterBinder = {
19
+ /**
20
+ * Extract bind params from the exchange.
21
+ * @param {import('@alt-javascript/camel-lite-core').Exchange} exchange
22
+ * @returns {object} key→value map
23
+ */
24
+ extractParams(exchange) {
25
+ const body = exchange.in.body;
26
+ if (body !== null && typeof body === 'object' && !Array.isArray(body)) {
27
+ return body;
28
+ }
29
+ // Fallback: collect all headers as params
30
+ const params = {};
31
+ if (exchange.in.headers instanceof Map) {
32
+ for (const [k, v] of exchange.in.headers) {
33
+ params[k] = v;
34
+ }
35
+ }
36
+ return params;
37
+ },
38
+
39
+ /**
40
+ * Replace :paramName placeholders according to the dialect.
41
+ * @param {string} template - SQL with :paramName placeholders
42
+ * @param {object} params - key→value map of bind values
43
+ * @param {'pg'|'mysql2'|'sqlite'|'named'} dialect
44
+ * @returns {{ sql: string, values: Array|object }}
45
+ */
46
+ replaceParams(template, params, dialect = 'sqlite') {
47
+ if (dialect === 'named') {
48
+ return { sql: template, values: params };
49
+ }
50
+
51
+ const values = [];
52
+ let counter = 0;
53
+ const sql = template.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name) => {
54
+ values.push(params[name] ?? null);
55
+ counter++;
56
+ return dialect === 'pg' ? `$${counter}` : '?';
57
+ });
58
+
59
+ return { sql, values };
60
+ },
61
+ };
62
+
63
+ export default ParameterBinder;
@@ -0,0 +1,118 @@
1
+ import { Component } from '@alt-javascript/camel-lite-core';
2
+ import { LoggerFactory } from '@alt-javascript/logger';
3
+ import { SqlEndpoint } from './SqlEndpoint.js';
4
+
5
+ const log = LoggerFactory.getLogger('@alt-javascript/camel-lite/SqlComponent');
6
+
7
+ /**
8
+ * SqlComponent — executes SQL queries as a pipeline step.
9
+ *
10
+ * Datasource resolution (three-step chain, evaluated at send() time):
11
+ *
12
+ * 1. Component-internal map — setDatasource(name, factory) takes priority.
13
+ * Name comes from: ?datasource= URI param, then the sql: path segment.
14
+ *
15
+ * 2. Context bean by name — context.getBean(name) is checked when the
16
+ * component map has no match. The bean is the datasource object directly
17
+ * (not a factory wrapper).
18
+ *
19
+ * 3. Auto-select — when no explicit name is given (or the named
20
+ * lookup found nothing) and context.getBeans() has exactly one entry,
21
+ * that bean is used automatically.
22
+ *
23
+ * setDatasource() registers a factory (zero-arg fn → db connection/pool).
24
+ * Context beans are registered directly: context.registerBean('myDb', db).
25
+ *
26
+ * URI formats (all equivalent):
27
+ * sql:myDsName?query=SELECT+1&dialect=sqlite ← path segment as ds name
28
+ * sql:?datasource=myDsBean&query=SELECT+1 ← explicit param (overrides path)
29
+ * sql:?query=SELECT+1&dialect=sqlite ← auto-select when 1 bean in ctx
30
+ *
31
+ * Supported driver shapes (communicated via 'dialect' URI param, default 'sqlite'):
32
+ * 'sqlite' → node:sqlite (DatabaseSync) — synchronous
33
+ * 'pg' → pg Pool — async: pool.query(sql, values)
34
+ * 'mysql2' → mysql2 Pool — async: pool.query(sql, values)
35
+ * 'named' → node:sqlite with pass-through named params
36
+ */
37
+ class SqlComponent extends Component {
38
+ #datasources = new Map(); // name → factory function (explicit component-level registration)
39
+ #endpoints = new Map();
40
+
41
+ /**
42
+ * Register a named datasource factory on the component.
43
+ * This takes priority over context bean lookup.
44
+ * @param {string} name
45
+ * @param {function} factory - () => db/pool connection
46
+ */
47
+ setDatasource(name, factory) {
48
+ this.#datasources.set(name, factory);
49
+ log.info(`SqlComponent: datasource '${name}' registered on component`);
50
+ return this;
51
+ }
52
+
53
+ /**
54
+ * Resolve a datasource connection using the three-step chain.
55
+ * @param {string|null} name - bean/datasource name, or null for auto-select
56
+ * @param {CamelContext|null} context
57
+ * @returns {*} the database connection/pool
58
+ */
59
+ getDatasource(name, context = null) {
60
+ // Step 1: component-internal map
61
+ if (name && this.#datasources.has(name)) {
62
+ log.debug(`SqlComponent: datasource '${name}' resolved from component map`);
63
+ return this.#datasources.get(name)();
64
+ }
65
+
66
+ if (context) {
67
+ // Step 2: context bean by name
68
+ if (name) {
69
+ const bean = context.getBean(name);
70
+ if (bean != null) {
71
+ log.debug(`SqlComponent: datasource '${name}' resolved from context bean`);
72
+ return bean;
73
+ }
74
+ }
75
+
76
+ // Step 3: single-bean auto-select
77
+ const allBeans = context.getBeans();
78
+ if (allBeans.length === 1) {
79
+ log.debug(`SqlComponent: datasource auto-selected (single bean in context: '${allBeans[0][0]}')`);
80
+ return allBeans[0][1];
81
+ }
82
+ }
83
+
84
+ throw new Error(
85
+ `SqlComponent: cannot resolve datasource '${name ?? '(none)'}'. ` +
86
+ `Register via component.setDatasource(), context.registerBean(), ` +
87
+ `or ensure exactly one bean is registered in context.`
88
+ );
89
+ }
90
+
91
+ createEndpoint(uri, remaining, parameters, context) {
92
+ if (this.#endpoints.has(uri)) {
93
+ return this.#endpoints.get(uri);
94
+ }
95
+
96
+ // ?datasource= param takes priority over the path segment as the ds name.
97
+ // Either may be absent — null triggers auto-select at send() time.
98
+ const pathName = remaining.replace(/^\/+/, '') || null;
99
+ const datasourceName = parameters.get('datasource') ?? pathName;
100
+
101
+ const query = parameters.get('query') ?? '';
102
+ const outputType = parameters.get('outputType') ?? 'rows';
103
+ const dialect = parameters.get('dialect') ?? 'sqlite';
104
+
105
+ if (!query) {
106
+ throw new Error(`SqlEndpoint: 'query' parameter is required on URI: ${uri}`);
107
+ }
108
+
109
+ log.info(`SqlComponent creating endpoint: ds=${datasourceName ?? '(auto)'}, dialect=${dialect}, outputType=${outputType}`);
110
+
111
+ const endpoint = new SqlEndpoint(uri, datasourceName, query, outputType, dialect, context, this);
112
+ this.#endpoints.set(uri, endpoint);
113
+ return endpoint;
114
+ }
115
+ }
116
+
117
+ export { SqlComponent };
118
+ export default SqlComponent;
@@ -0,0 +1,39 @@
1
+ import { Consumer } from '@alt-javascript/camel-lite-core';
2
+ import { LoggerFactory } from '@alt-javascript/logger';
3
+
4
+ const log = LoggerFactory.getLogger('@alt-javascript/camel-lite/SqlConsumer');
5
+
6
+ /**
7
+ * SqlConsumer — stub implementation.
8
+ * sql: is primarily producer-oriented (execute SQL on-demand as a pipeline step).
9
+ * A scheduled poll consumer (timer-driven SELECT) is deferred to a future milestone.
10
+ */
11
+ class SqlConsumer extends Consumer {
12
+ #endpoint;
13
+ #pipeline;
14
+
15
+ constructor(endpoint, pipeline) {
16
+ super();
17
+ this.#endpoint = endpoint;
18
+ this.#pipeline = pipeline;
19
+ }
20
+
21
+ async start() {
22
+ const { uri, context } = this.#endpoint;
23
+ context.registerConsumer(uri, this);
24
+ log.info(`SqlConsumer started: ${uri}`);
25
+ }
26
+
27
+ async stop() {
28
+ const { uri, context } = this.#endpoint;
29
+ context.registerConsumer(uri, null);
30
+ log.info(`SqlConsumer stopped: ${uri}`);
31
+ }
32
+
33
+ async process(exchange) {
34
+ return this.#pipeline.run(exchange);
35
+ }
36
+ }
37
+
38
+ export { SqlConsumer };
39
+ export default SqlConsumer;
@@ -0,0 +1,53 @@
1
+ import { Endpoint } from '@alt-javascript/camel-lite-core';
2
+ import { SqlProducer } from './SqlProducer.js';
3
+ import { SqlConsumer } from './SqlConsumer.js';
4
+
5
+ /**
6
+ * SqlEndpoint holds the parsed URI state for a sql: endpoint.
7
+ *
8
+ * URI format:
9
+ * sql:datasourceName?query=SELECT+*+FROM+users&outputType=rows&dialect=sqlite
10
+ *
11
+ * outputType: 'rows' (default) → sets exchange.in.body = array of row objects
12
+ * 'rowCount' → sets exchange.in.body = { rowCount: N }
13
+ * dialect: 'sqlite' (default) | 'pg' | 'mysql2' | 'named'
14
+ */
15
+ class SqlEndpoint extends Endpoint {
16
+ #uri;
17
+ #datasourceName;
18
+ #query;
19
+ #outputType;
20
+ #dialect;
21
+ #context;
22
+ #component;
23
+
24
+ constructor(uri, datasourceName, query, outputType, dialect, context, component) {
25
+ super();
26
+ this.#uri = uri;
27
+ this.#datasourceName = datasourceName;
28
+ this.#query = query;
29
+ this.#outputType = outputType;
30
+ this.#dialect = dialect;
31
+ this.#context = context;
32
+ this.#component = component;
33
+ }
34
+
35
+ get uri() { return this.#uri; }
36
+ get datasourceName() { return this.#datasourceName; }
37
+ get query() { return this.#query; }
38
+ get outputType() { return this.#outputType; }
39
+ get dialect() { return this.#dialect; }
40
+ get context() { return this.#context; }
41
+ get component() { return this.#component; }
42
+
43
+ createProducer() {
44
+ return new SqlProducer(this);
45
+ }
46
+
47
+ createConsumer(pipeline) {
48
+ return new SqlConsumer(this, pipeline);
49
+ }
50
+ }
51
+
52
+ export { SqlEndpoint };
53
+ export default SqlEndpoint;
@@ -0,0 +1,86 @@
1
+ import { Producer } from '@alt-javascript/camel-lite-core';
2
+ import { LoggerFactory } from '@alt-javascript/logger';
3
+ import { ParameterBinder } from './ParameterBinder.js';
4
+
5
+ const log = LoggerFactory.getLogger('@alt-javascript/camel-lite/SqlProducer');
6
+
7
+ /**
8
+ * SqlProducer — executes the endpoint's SQL query against the registered datasource.
9
+ *
10
+ * Result mapping:
11
+ * SELECT (outputType='rows') → exchange.in.body = array of row objects
12
+ * INSERT/UPDATE/DELETE → exchange.in.body = { rowCount: N }
13
+ * outputType='rowCount' forced → exchange.in.body = { rowCount: N }
14
+ *
15
+ * Driver detection (via endpoint.dialect):
16
+ * 'sqlite' → better-sqlite3 sync API: db.prepare(sql).all(values) / .run(values)
17
+ * 'pg' → pg async API: pool.query(sql, values) → { rows, rowCount }
18
+ * 'mysql2' → mysql2 async API: pool.query(sql, values) → [rows, fields]
19
+ * 'named' → better-sqlite3 named API: db.prepare(sql).all(namedObj)
20
+ */
21
+ class SqlProducer extends Producer {
22
+ #endpoint;
23
+
24
+ constructor(endpoint) {
25
+ super();
26
+ this.#endpoint = endpoint;
27
+ }
28
+
29
+ async send(exchange) {
30
+ const { datasourceName, query, outputType, dialect, component, context } = this.#endpoint;
31
+
32
+ const db = component.getDatasource(datasourceName, context);
33
+ const params = ParameterBinder.extractParams(exchange);
34
+ const { sql, values } = ParameterBinder.replaceParams(query, params, dialect);
35
+
36
+ log.debug(`SqlProducer [${dialect}] executing: ${sql}`);
37
+
38
+ let result;
39
+ if (dialect === 'sqlite' || dialect === 'named') {
40
+ result = await SqlProducer.#execSqlite(db, sql, values, outputType);
41
+ } else if (dialect === 'pg') {
42
+ result = await SqlProducer.#execPg(db, sql, values, outputType);
43
+ } else if (dialect === 'mysql2') {
44
+ result = await SqlProducer.#execMysql2(db, sql, values, outputType);
45
+ } else {
46
+ throw new Error(`SqlProducer: unknown dialect '${dialect}'`);
47
+ }
48
+
49
+ exchange.in.body = result;
50
+ log.debug(`SqlProducer done: outputType=${outputType}`);
51
+ }
52
+
53
+ static async #execSqlite(db, sql, values, outputType) {
54
+ const stmt = db.prepare(sql);
55
+ const sqlTrimmed = sql.trimStart().toUpperCase();
56
+ const isSelect = sqlTrimmed.startsWith('SELECT') || sqlTrimmed.startsWith('WITH');
57
+
58
+ if (outputType === 'rows' && isSelect) {
59
+ // node:sqlite StatementSync.all() takes spread args, not an array.
60
+ // We spread values so both [] and [...items] forms work.
61
+ return stmt.all(...(Array.isArray(values) ? values : Object.values(values)));
62
+ } else {
63
+ const info = stmt.run(...(Array.isArray(values) ? values : Object.values(values)));
64
+ return { rowCount: info.changes };
65
+ }
66
+ }
67
+
68
+ static async #execPg(pool, sql, values, outputType) {
69
+ const result = await pool.query(sql, values);
70
+ if (outputType === 'rows') {
71
+ return result.rows;
72
+ }
73
+ return { rowCount: result.rowCount };
74
+ }
75
+
76
+ static async #execMysql2(pool, sql, values, outputType) {
77
+ const [rows] = await pool.query(sql, values);
78
+ if (outputType === 'rows' && Array.isArray(rows)) {
79
+ return rows;
80
+ }
81
+ return { rowCount: rows.affectedRows ?? 0 };
82
+ }
83
+ }
84
+
85
+ export { SqlProducer };
86
+ export default SqlProducer;
@@ -0,0 +1,20 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+
3
+ /**
4
+ * SQLite factory using Node's built-in node:sqlite module (Node 22.5+).
5
+ * No native compile required — uses V8's built-in SQLite support.
6
+ *
7
+ * Note: node:sqlite is marked experimental in Node 22/24 but is stable enough
8
+ * for our use case. No external dependency needed.
9
+ */
10
+
11
+ /**
12
+ * Open an in-memory or file-based SQLite database.
13
+ * @param {string} path - ':memory:' or a file path
14
+ * @returns {import('node:sqlite').DatabaseSync}
15
+ */
16
+ export function openDatabase(path = ':memory:') {
17
+ return new DatabaseSync(path);
18
+ }
19
+
20
+ export { DatabaseSync };
package/src/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { SqlComponent } from './SqlComponent.js';
2
+ export { SqlEndpoint } from './SqlEndpoint.js';
3
+ export { SqlProducer } from './SqlProducer.js';
4
+ export { SqlConsumer } from './SqlConsumer.js';
5
+ export { ParameterBinder } from './ParameterBinder.js';
6
+ export { openDatabase } from './SqliteClientFactory.js';
@@ -0,0 +1,397 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import { Exchange, CamelContext } from '@alt-javascript/camel-lite-core';
5
+ import { SqlComponent, ParameterBinder, openDatabase } from '@alt-javascript/camel-lite-component-sql';
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Helper: open an in-memory SQLite DB with a test table
9
+ // ---------------------------------------------------------------------------
10
+ function makeDb() {
11
+ const db = new DatabaseSync(':memory:');
12
+ db.exec(`
13
+ CREATE TABLE items (
14
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
15
+ name TEXT NOT NULL,
16
+ qty INTEGER NOT NULL DEFAULT 0
17
+ )
18
+ `);
19
+ db.prepare('INSERT INTO items (name, qty) VALUES (?, ?)').run('apple', 10);
20
+ db.prepare('INSERT INTO items (name, qty) VALUES (?, ?)').run('banana', 5);
21
+ db.prepare('INSERT INTO items (name, qty) VALUES (?, ?)').run('cherry', 20);
22
+ return db;
23
+ }
24
+
25
+ function makeExchange(body) {
26
+ const ex = new Exchange();
27
+ ex.in.body = body;
28
+ return ex;
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // ParameterBinder unit tests
33
+ // ---------------------------------------------------------------------------
34
+
35
+ describe('ParameterBinder', () => {
36
+ it('replaceParams: sqlite dialect replaces :name with ?', () => {
37
+ const { sql, values } = ParameterBinder.replaceParams(
38
+ 'SELECT * FROM items WHERE name = :name AND qty > :qty',
39
+ { name: 'apple', qty: 5 },
40
+ 'sqlite'
41
+ );
42
+ assert.equal(sql, 'SELECT * FROM items WHERE name = ? AND qty > ?');
43
+ assert.deepEqual(values, ['apple', 5]);
44
+ });
45
+
46
+ it('replaceParams: pg dialect replaces :name with $1, $2', () => {
47
+ const { sql, values } = ParameterBinder.replaceParams(
48
+ 'SELECT * FROM users WHERE id = :id AND role = :role',
49
+ { id: 42, role: 'admin' },
50
+ 'pg'
51
+ );
52
+ assert.equal(sql, 'SELECT * FROM users WHERE id = $1 AND role = $2');
53
+ assert.deepEqual(values, [42, 'admin']);
54
+ });
55
+
56
+ it('replaceParams: mysql2 dialect replaces :name with ?', () => {
57
+ const { sql, values } = ParameterBinder.replaceParams(
58
+ 'UPDATE t SET status = :status WHERE id = :id',
59
+ { status: 'active', id: 7 },
60
+ 'mysql2'
61
+ );
62
+ assert.equal(sql, 'UPDATE t SET status = ? WHERE id = ?');
63
+ assert.deepEqual(values, ['active', 7]);
64
+ });
65
+
66
+ it('replaceParams: named dialect passes through unchanged', () => {
67
+ const template = 'SELECT * FROM t WHERE x = :x';
68
+ const params = { x: 99 };
69
+ const { sql, values } = ParameterBinder.replaceParams(template, params, 'named');
70
+ assert.equal(sql, template);
71
+ assert.equal(values, params);
72
+ });
73
+
74
+ it('replaceParams: missing param defaults to null', () => {
75
+ const { values } = ParameterBinder.replaceParams(
76
+ 'SELECT * FROM t WHERE a = :a AND b = :b',
77
+ { a: 1 }, // b is missing
78
+ 'sqlite'
79
+ );
80
+ assert.deepEqual(values, [1, null]);
81
+ });
82
+
83
+ it('extractParams: returns body if it is a plain object', () => {
84
+ const ex = makeExchange({ id: 5, name: 'pear' });
85
+ assert.deepEqual(ParameterBinder.extractParams(ex), { id: 5, name: 'pear' });
86
+ });
87
+
88
+ it('extractParams: falls back to headers if body is not an object', () => {
89
+ const ex = makeExchange('string body');
90
+ ex.in.setHeader('id', 42);
91
+ const params = ParameterBinder.extractParams(ex);
92
+ assert.equal(params.id, 42);
93
+ });
94
+ });
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // SqlComponent + SqlProducer — SELECT
98
+ // ---------------------------------------------------------------------------
99
+
100
+ describe('SqlProducer: SELECT', () => {
101
+ it('executes SELECT and sets rows array on exchange.in.body', async () => {
102
+ const db = makeDb();
103
+ const comp = new SqlComponent();
104
+ comp.setDatasource('default', () => db);
105
+
106
+ const ctx = new CamelContext();
107
+ const params = new URLSearchParams('query=SELECT+*+FROM+items&dialect=sqlite');
108
+ const ep = comp.createEndpoint('sql:default?query=SELECT+*+FROM+items&dialect=sqlite', 'default', params, ctx);
109
+ const producer = ep.createProducer();
110
+
111
+ const ex = makeExchange(null);
112
+ await producer.send(ex);
113
+
114
+ assert.ok(Array.isArray(ex.in.body), 'body should be an array');
115
+ assert.equal(ex.in.body.length, 3);
116
+ assert.equal(ex.in.body[0].name, 'apple');
117
+ assert.equal(ex.in.body[1].name, 'banana');
118
+ });
119
+
120
+ it('executes parameterized SELECT with :name placeholders', async () => {
121
+ const db = makeDb();
122
+ const comp = new SqlComponent();
123
+ comp.setDatasource('myds', () => db);
124
+
125
+ const ctx = new CamelContext();
126
+ const q = 'SELECT+*+FROM+items+WHERE+name+=+:name';
127
+ const params = new URLSearchParams(`query=${q}&dialect=sqlite`);
128
+ const ep = comp.createEndpoint(`sql:myds?query=${q}&dialect=sqlite`, 'myds', params, ctx);
129
+ const producer = ep.createProducer();
130
+
131
+ const ex = makeExchange({ name: 'cherry' });
132
+ await producer.send(ex);
133
+
134
+ assert.equal(ex.in.body.length, 1);
135
+ assert.equal(ex.in.body[0].qty, 20);
136
+ });
137
+ });
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // SqlProducer — INSERT / UPDATE / DELETE
141
+ // ---------------------------------------------------------------------------
142
+
143
+ describe('SqlProducer: mutations', () => {
144
+ it('INSERT returns { rowCount: 1 }', async () => {
145
+ const db = makeDb();
146
+ const comp = new SqlComponent();
147
+ comp.setDatasource('default', () => db);
148
+
149
+ const ctx = new CamelContext();
150
+ const q = encodeURIComponent('INSERT INTO items (name, qty) VALUES (:name, :qty)');
151
+ const params = new URLSearchParams(`query=${q}&dialect=sqlite&outputType=rowCount`);
152
+ const ep = comp.createEndpoint(`sql:default?query=${q}&dialect=sqlite&outputType=rowCount`, 'default', params, ctx);
153
+ const producer = ep.createProducer();
154
+
155
+ const ex = makeExchange({ name: 'grape', qty: 7 });
156
+ await producer.send(ex);
157
+
158
+ assert.deepEqual(ex.in.body, { rowCount: 1 });
159
+
160
+ // Confirm row was inserted
161
+ const rows = db.prepare('SELECT * FROM items WHERE name = ?').all('grape');
162
+ assert.equal(rows.length, 1);
163
+ assert.equal(rows[0].qty, 7);
164
+ });
165
+
166
+ it('UPDATE returns { rowCount: N }', async () => {
167
+ const db = makeDb();
168
+ const comp = new SqlComponent();
169
+ comp.setDatasource('default', () => db);
170
+
171
+ const ctx = new CamelContext();
172
+ const q = encodeURIComponent('UPDATE items SET qty = :qty WHERE name = :name');
173
+ const params = new URLSearchParams(`query=${q}&dialect=sqlite&outputType=rowCount`);
174
+ const ep = comp.createEndpoint(`sql:default?query=${q}&dialect=sqlite&outputType=rowCount`, 'default', params, ctx);
175
+ const producer = ep.createProducer();
176
+
177
+ const ex = makeExchange({ name: 'apple', qty: 99 });
178
+ await producer.send(ex);
179
+
180
+ assert.deepEqual(ex.in.body, { rowCount: 1 });
181
+ const rows = db.prepare('SELECT qty FROM items WHERE name = ?').all('apple');
182
+ assert.equal(rows[0].qty, 99);
183
+ });
184
+
185
+ it('DELETE returns { rowCount: N }', async () => {
186
+ const db = makeDb();
187
+ const comp = new SqlComponent();
188
+ comp.setDatasource('default', () => db);
189
+
190
+ const ctx = new CamelContext();
191
+ const q = encodeURIComponent('DELETE FROM items WHERE name = :name');
192
+ const params = new URLSearchParams(`query=${q}&dialect=sqlite&outputType=rowCount`);
193
+ const ep = comp.createEndpoint(`sql:default?query=${q}&dialect=sqlite&outputType=rowCount`, 'default', params, ctx);
194
+ const producer = ep.createProducer();
195
+
196
+ const ex = makeExchange({ name: 'banana' });
197
+ await producer.send(ex);
198
+
199
+ assert.deepEqual(ex.in.body, { rowCount: 1 });
200
+ const remaining = db.prepare('SELECT COUNT(*) as cnt FROM items').all();
201
+ assert.equal(remaining[0].cnt, 2);
202
+ });
203
+ });
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // Multiple named datasources
207
+ // ---------------------------------------------------------------------------
208
+
209
+ describe('SqlComponent: multiple named datasources', () => {
210
+ it('routes queries to the correct named datasource', async () => {
211
+ const db1 = new DatabaseSync(':memory:');
212
+ db1.exec('CREATE TABLE t (val TEXT)');
213
+ db1.prepare('INSERT INTO t (val) VALUES (?)').run('from-db1');
214
+
215
+ const db2 = new DatabaseSync(':memory:');
216
+ db2.exec('CREATE TABLE t (val TEXT)');
217
+ db2.prepare('INSERT INTO t (val) VALUES (?)').run('from-db2');
218
+
219
+ const comp = new SqlComponent();
220
+ comp.setDatasource('ds1', () => db1);
221
+ comp.setDatasource('ds2', () => db2);
222
+
223
+ const ctx = new CamelContext();
224
+
225
+ const params1 = new URLSearchParams('query=SELECT+val+FROM+t&dialect=sqlite');
226
+ const ep1 = comp.createEndpoint('sql:ds1?query=SELECT+val+FROM+t&dialect=sqlite', 'ds1', params1, ctx);
227
+
228
+ const params2 = new URLSearchParams('query=SELECT+val+FROM+t&dialect=sqlite');
229
+ const ep2 = comp.createEndpoint('sql:ds2?query=SELECT+val+FROM+t&dialect=sqlite', 'ds2', params2, ctx);
230
+
231
+ const ex1 = makeExchange(null);
232
+ await ep1.createProducer().send(ex1);
233
+ assert.equal(ex1.in.body[0].val, 'from-db1');
234
+
235
+ const ex2 = makeExchange(null);
236
+ await ep2.createProducer().send(ex2);
237
+ assert.equal(ex2.in.body[0].val, 'from-db2');
238
+ });
239
+ });
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // SqlComponent: missing datasource throws
243
+ // ---------------------------------------------------------------------------
244
+
245
+ describe('SqlComponent: error cases', () => {
246
+ it('throws when query param is missing', () => {
247
+ const comp = new SqlComponent();
248
+ comp.setDatasource('default', () => null);
249
+ const ctx = new CamelContext();
250
+ const params = new URLSearchParams('dialect=sqlite'); // no query
251
+ assert.throws(
252
+ () => comp.createEndpoint('sql:default?dialect=sqlite', 'default', params, ctx),
253
+ /query.*required/i
254
+ );
255
+ });
256
+
257
+ it('throws when datasource is not registered', async () => {
258
+ const comp = new SqlComponent();
259
+ const ctx = new CamelContext();
260
+ const params = new URLSearchParams('query=SELECT+1&dialect=sqlite');
261
+ const ep = comp.createEndpoint('sql:unknown?query=SELECT+1&dialect=sqlite', 'unknown', params, ctx);
262
+ const producer = ep.createProducer();
263
+ await assert.rejects(
264
+ () => producer.send(makeExchange(null)),
265
+ /cannot resolve datasource 'unknown'/i
266
+ );
267
+ });
268
+ });
269
+
270
+ // ---------------------------------------------------------------------------
271
+ // openDatabase helper
272
+ // ---------------------------------------------------------------------------
273
+
274
+ describe('openDatabase helper', () => {
275
+ it('opens an in-memory SQLite database', () => {
276
+ const db = openDatabase(':memory:');
277
+ assert.ok(db, 'should return a DatabaseSync instance');
278
+ db.exec('CREATE TABLE t (x INTEGER)');
279
+ db.prepare('INSERT INTO t (x) VALUES (?)').run(42);
280
+ const rows = db.prepare('SELECT x FROM t').all();
281
+ assert.equal(rows[0].x, 42);
282
+ });
283
+ });
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Context-aware datasource resolution (three-step chain)
287
+ // ---------------------------------------------------------------------------
288
+
289
+ describe('SqlComponent: context bean resolution', () => {
290
+ function makeDb() {
291
+ const db = new DatabaseSync(':memory:');
292
+ db.exec('CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, val TEXT)');
293
+ db.prepare('INSERT INTO t (val) VALUES (?)').run('hello');
294
+ return db;
295
+ }
296
+
297
+ it('step 2: resolves datasource from context.getBean(name) when not in component map', async () => {
298
+ const db = makeDb();
299
+ const comp = new SqlComponent(); // no setDatasource()
300
+ const ctx = new CamelContext();
301
+ ctx.registerBean('myDb', db);
302
+
303
+ const params = new URLSearchParams('datasource=myDb&query=SELECT+val+FROM+t&dialect=sqlite');
304
+ const ep = comp.createEndpoint('sql:?datasource=myDb&query=SELECT+val+FROM+t&dialect=sqlite', '', params, ctx);
305
+
306
+ const ex = new Exchange();
307
+ ex.in.body = null;
308
+ await ep.createProducer().send(ex);
309
+
310
+ assert.ok(Array.isArray(ex.in.body));
311
+ assert.equal(ex.in.body[0].val, 'hello');
312
+ });
313
+
314
+ it('step 3: auto-selects single context bean when no name given', async () => {
315
+ const db = makeDb();
316
+ const comp = new SqlComponent();
317
+ const ctx = new CamelContext();
318
+ ctx.registerBean('theOnlyDb', db); // only one bean
319
+
320
+ const params = new URLSearchParams('query=SELECT+val+FROM+t&dialect=sqlite');
321
+ const ep = comp.createEndpoint('sql:?query=SELECT+val+FROM+t&dialect=sqlite', '', params, ctx);
322
+
323
+ const ex = new Exchange();
324
+ ex.in.body = null;
325
+ await ep.createProducer().send(ex);
326
+
327
+ assert.ok(Array.isArray(ex.in.body));
328
+ assert.equal(ex.in.body[0].val, 'hello');
329
+ });
330
+
331
+ it('step 1 takes priority over context bean when component map has same name', async () => {
332
+ const dbInMap = makeDb();
333
+ dbInMap.exec('INSERT INTO t (val) VALUES (?)', 'from-map');
334
+
335
+ const dbInContext = makeDb();
336
+
337
+ const comp = new SqlComponent();
338
+ comp.setDatasource('myDb', () => dbInMap); // component map — takes priority
339
+
340
+ const ctx = new CamelContext();
341
+ ctx.registerBean('myDb', dbInContext); // context — should be ignored
342
+
343
+ const params = new URLSearchParams('datasource=myDb&query=SELECT+COUNT(*)+AS+cnt+FROM+t&dialect=sqlite');
344
+ const ep = comp.createEndpoint('sql:?datasource=myDb&query=SELECT+COUNT(*)+AS+cnt+FROM+t&dialect=sqlite', '', params, ctx);
345
+
346
+ const ex = new Exchange();
347
+ ex.in.body = null;
348
+ await ep.createProducer().send(ex);
349
+
350
+ // dbInMap has 2 rows (original + 'from-map'), dbInContext has 1
351
+ assert.equal(ex.in.body[0].cnt, 2);
352
+ });
353
+
354
+ it('?datasource= param overrides the sql: path segment as the name', async () => {
355
+ const db = makeDb();
356
+ const comp = new SqlComponent();
357
+ const ctx = new CamelContext();
358
+ ctx.registerBean('paramBean', db);
359
+
360
+ // Path says 'pathBean' but ?datasource= says 'paramBean'
361
+ const params = new URLSearchParams('datasource=paramBean&query=SELECT+val+FROM+t&dialect=sqlite');
362
+ const ep = comp.createEndpoint('sql:pathBean?datasource=paramBean&query=SELECT+val+FROM+t&dialect=sqlite', 'pathBean', params, ctx);
363
+
364
+ const ex = new Exchange();
365
+ ex.in.body = null;
366
+ await ep.createProducer().send(ex);
367
+
368
+ assert.equal(ex.in.body[0].val, 'hello');
369
+ });
370
+
371
+ it('throws a descriptive error when no resolution path succeeds', async () => {
372
+ const comp = new SqlComponent();
373
+ const ctx = new CamelContext(); // no beans
374
+ const params = new URLSearchParams('datasource=ghost&query=SELECT+1&dialect=sqlite');
375
+ const ep = comp.createEndpoint('sql:?datasource=ghost&query=SELECT+1&dialect=sqlite', '', params, ctx);
376
+
377
+ await assert.rejects(
378
+ () => ep.createProducer().send(new Exchange()),
379
+ /cannot resolve datasource 'ghost'/i
380
+ );
381
+ });
382
+
383
+ it('throws when no name given and context has zero or multiple beans', async () => {
384
+ const comp = new SqlComponent();
385
+ const ctx = new CamelContext();
386
+ ctx.registerBean('db1', makeDb());
387
+ ctx.registerBean('db2', makeDb()); // two beans — no auto-select
388
+
389
+ const params = new URLSearchParams('query=SELECT+1&dialect=sqlite');
390
+ const ep = comp.createEndpoint('sql:?query=SELECT+1&dialect=sqlite', '', params, ctx);
391
+
392
+ await assert.rejects(
393
+ () => ep.createProducer().send(new Exchange()),
394
+ /cannot resolve datasource/i
395
+ );
396
+ });
397
+ });