diff --git a/lib/database.js b/lib/database.js index acd5b59d..5b927a20 100644 --- a/lib/database.js +++ b/lib/database.js @@ -75,6 +75,7 @@ const wrappers = require('./methods/wrappers'); Database.prototype.prepare = wrappers.prepare; Database.prototype.transaction = require('./methods/transaction'); Database.prototype.pragma = require('./methods/pragma'); +Database.prototype.explain = require('./methods/explain'); Database.prototype.backup = require('./methods/backup'); Database.prototype.serialize = require('./methods/serialize'); Database.prototype.function = require('./methods/function'); diff --git a/lib/methods/explain.js b/lib/methods/explain.js new file mode 100644 index 00000000..806ddafc --- /dev/null +++ b/lib/methods/explain.js @@ -0,0 +1,56 @@ +'use strict'; + +module.exports = function explain(sql) { + if (typeof sql !== 'string') throw new TypeError('Expected first argument to be a string'); + + // Prepend EXPLAIN if not already present + const explainSql = sql.trim().toUpperCase().startsWith('EXPLAIN') + ? sql + : `EXPLAIN ${sql}`; + + // Prepare the statement normally + const stmt = this.prepare(explainSql); + + // For EXPLAIN statements, we don't need actual parameter values + // We'll try to execute without parameters first + try { + return stmt.all(); + } catch (e) { + // Handle parameter errors by binding nulls + if (e.message && (e.message.includes('Too few parameter') || e.message.includes('Missing named parameter'))) { + // Extract named parameters from the SQL + const namedParams = sql.match(/:(\w+)|@(\w+)|\$(\w+)/g); + + if (namedParams && namedParams.length > 0) { + // Build an object with null values for all named parameters + const params = {}; + for (const param of namedParams) { + const name = param.substring(1); // Remove the :, @, or $ prefix + params[name] = null; + } + return stmt.all(params); + } + + // For positional parameters, use binary search + let low = 1; + let high = 100; // Reasonable maximum + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + try { + return stmt.all(Array(mid).fill(null)); + } catch (err) { + if (err.message && err.message.includes('Too few')) { + low = mid + 1; + } else if (err.message && err.message.includes('Too many')) { + high = mid - 1; + } else { + throw err; + } + } + } + } + throw e; + } +}; + diff --git a/test/15.database.explain.js b/test/15.database.explain.js new file mode 100644 index 00000000..1475d994 --- /dev/null +++ b/test/15.database.explain.js @@ -0,0 +1,89 @@ +'use strict'; +const Database = require('../lib'); + +describe('Database#explain()', function () { + beforeEach(function () { + this.db = new Database(util.next()); + this.db.exec('CREATE TABLE entries (a TEXT, b INTEGER, c REAL)'); + this.db.exec("INSERT INTO entries VALUES ('foo', 1, 3.14), ('bar', 2, 2.71)"); + }); + afterEach(function () { + this.db.close(); + }); + + it('should throw an exception if a string is not provided', function () { + expect(() => this.db.explain(123)).to.throw(TypeError); + expect(() => this.db.explain(0)).to.throw(TypeError); + expect(() => this.db.explain(null)).to.throw(TypeError); + expect(() => this.db.explain()).to.throw(TypeError); + expect(() => this.db.explain(new String('SELECT * FROM entries'))).to.throw(TypeError); + }); + + it('should execute a simple EXPLAIN query without parameters', function () { + const plan = this.db.explain('SELECT * FROM entries'); + expect(plan).to.be.an('array'); + expect(plan.length).to.be.greaterThan(0); + expect(plan[0]).to.be.an('object'); + // EXPLAIN output should have columns like 'addr', 'opcode', 'p1', 'p2', 'p3', 'p4', 'p5', 'comment' + expect(plan[0]).to.have.property('opcode'); + }); + + it('should work with EXPLAIN already in the SQL', function () { + const plan1 = this.db.explain('SELECT * FROM entries'); + const plan2 = this.db.explain('EXPLAIN SELECT * FROM entries'); + expect(plan1).to.deep.equal(plan2); + }); + + it('should work with EXPLAIN QUERY PLAN', function () { + const plan = this.db.explain("EXPLAIN QUERY PLAN SELECT * FROM entries WHERE a = 'foo'"); + expect(plan).to.be.an('array'); + expect(plan.length).to.be.greaterThan(0); + expect(plan[0]).to.be.an('object'); + }); + + it('should handle queries with parameters without throwing errors', function () { + // This is the key fix - EXPLAIN with parameters should work + const plan1 = this.db.explain('SELECT * FROM entries WHERE a = ?'); + expect(plan1).to.be.an('array'); + expect(plan1.length).to.be.greaterThan(0); + + const plan2 = this.db.explain('SELECT * FROM entries WHERE a = :name AND b = :value'); + expect(plan2).to.be.an('array'); + expect(plan2.length).to.be.greaterThan(0); + }); + + it('should handle complex queries with multiple parameters', function () { + const plan = this.db.explain('SELECT * FROM entries WHERE a = ? AND b > ? AND c < ?'); + expect(plan).to.be.an('array'); + expect(plan.length).to.be.greaterThan(0); + }); + + it('should work with JOIN queries', function () { + this.db.exec('CREATE TABLE users (id INTEGER, name TEXT)'); + const plan = this.db.explain('SELECT * FROM entries JOIN users ON entries.b = users.id WHERE users.name = ?'); + expect(plan).to.be.an('array'); + expect(plan.length).to.be.greaterThan(0); + }); + + it('should throw an exception for invalid SQL', function () { + expect(() => this.db.explain('INVALID SQL')).to.throw(Database.SqliteError); + expect(() => this.db.explain('SELECT * FROM nonexistent')).to.throw(Database.SqliteError); + }); + + it('should work with case insensitive EXPLAIN', function () { + const plan1 = this.db.explain('explain SELECT * FROM entries'); + const plan2 = this.db.explain('ExPlAiN SELECT * FROM entries'); + const plan3 = this.db.explain('EXPLAIN SELECT * FROM entries'); + expect(plan1.length).to.equal(plan2.length); + expect(plan2.length).to.equal(plan3.length); + }); + + it('should respect readonly connections', function () { + this.db.close(); + this.db = new Database(util.current(), { readonly: true, fileMustExist: true }); + const plan = this.db.explain('SELECT * FROM entries WHERE a = ?'); + expect(plan).to.be.an('array'); + expect(plan.length).to.be.greaterThan(0); + }); +}); +