diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e418600..98598e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,12 +7,35 @@ on: branches: [main, master] jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run lint + + audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - name: Audit (fail on critical) + run: npm audit --audit-level=critical + test: runs-on: ubuntu-latest strategy: fail-fast: false matrix: - node-version: [20, 22, 24] + node-version: [22, 24] steps: - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} @@ -21,7 +44,6 @@ jobs: node-version: ${{ matrix.node-version }} cache: npm - run: npm ci - - run: npm run lint - run: npm run test:coverage - name: Upload coverage if: matrix.node-version == 22 diff --git a/README.md b/README.md index eba2555..9aaf9ff 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Not really bread. Not really fruit. Just like this package. Simple CRUD helpers npm install breadfruit ``` -Requires Node.js `>=20`. +Requires Node.js `>=22`. ## Usage @@ -102,6 +102,157 @@ Runs a raw SQL statement and returns rows. const rows = await raw('select * from users'); ``` +### `count(table, filter, options?)` + +Returns the count of matching rows as a number. + +```js +const activeUsers = await count('users', { active: true }); +``` + +### `upsert(table, returnFields, data, conflictColumns, options?)` + +Inserts a row, or updates on conflict. `conflictColumns` can be a string or array. + +```js +const row = await upsert( + 'users', + '*', + { email: 'luis@example.com', name: 'Luis' }, + 'email', +); +``` + +### `transaction(callback)` + +Wraps `knex.transaction()`. Pass the `trx` object as `dbApi` in your method calls. + +```js +await transaction(async (trx) => { + await add('users', ['id'], { name: 'a' }, { dbApi: trx }); + await add('users', ['id'], { name: 'b' }, { dbApi: trx }); +}); +``` + +## Advanced + +### Passing an existing Knex instance + +Instead of a config object, you can pass a Knex instance. Useful when you already have a Knex connection in your app and want breadfruit to use it rather than open a second pool. + +```js +import knex from './db.js'; +import breadfruit from 'breadfruit'; + +const bf = breadfruit(knex); +``` + +### Composite filters + +Filter values accept operators beyond simple equality. + +| Shape | SQL | +|---|---| +| `{ col: value }` | `col = value` | +| `{ col: [a, b, c] }` | `col IN (a, b, c)` | +| `{ col: null }` | `col IS NULL` | +| `{ col: { eq: x } }` | `col = x` | +| `{ col: { ne: x } }` | `col != x` | +| `{ col: { gt: x } }` | `col > x` | +| `{ col: { gte: x } }` | `col >= x` | +| `{ col: { lt: x } }` | `col < x` | +| `{ col: { lte: x } }` | `col <= x` | +| `{ col: { like: 'x%' } }` | `col LIKE 'x%'` | +| `{ col: { ilike: 'x%' } }` | `col ILIKE 'x%'` | +| `{ col: { in: [a, b] } }` | `col IN (a, b)` | +| `{ col: { notIn: [a, b] } }` | `col NOT IN (a, b)` | +| `{ col: { between: [a, b] } }` | `col BETWEEN a AND b` | +| `{ col: { notBetween: [a, b] } }` | `col NOT BETWEEN a AND b` | +| `{ col: { null: true } }` | `col IS NULL` | +| `{ col: { null: false } }` | `col IS NOT NULL` | + +Multiple operators on the same column AND together: + +```js +await browse('events', '*', { + count: { gt: 1, lte: 100 }, + created_at: { gte: '2026-01-01' }, +}); +``` + +### `forTable(tableName, options?)` — table-bound helpers + +Returns an object with the same BREAD methods but bound to a specific table, with optional **soft delete** and **view-for-reads** behavior. + +```js +const users = bf.forTable('users', { + softDelete: true, + viewName: 'users_v', +}); + +await users.browse('*', { active: true }); // reads from users_v +await users.del({ id: 42 }); // soft-deletes in users +await users.restore({ id: 42 }); // un-soft-deletes +const total = await users.count({}); // respects soft delete +``` + +#### Soft delete + +Three options for the `softDelete` config: + +```js +// 1. Boolean shorthand — uses is_deleted column, true/false +softDelete: true + +// 2. Full config +softDelete: { + column: 'is_deleted', + value: true, // set on delete + undeletedValue: false, // the "active" value for filtering +} + +// 3. Timestamp style — deleted_at IS NULL means active +softDelete: { + column: 'deleted_at', + value: 'NOW', // special string -> knex.fn.now() + undeletedValue: null, +} +``` + +The `value` field accepts: +- a literal (`true`, `false`, `Date`, etc.) +- the string `'NOW'` — becomes `knex.fn.now()` so the DB generates the timestamp +- a Knex raw expression like `knex.fn.now()` or `knex.raw('...')` +- a function — called at delete time (runs in JS, not DB) + +#### Reads from a view, writes to the table + +Pass `viewName` to read from a view while writing to the underlying table. Great for denormalized read paths. + +```js +bf.forTable('users', { viewName: 'user_groups_v' }); +``` + +#### `withDeleted` + +Bypass the soft-delete filter for admin or audit views: + +```js +const allUsers = await users.browse('*', {}, { withDeleted: true }); +const count = await users.count({}, { withDeleted: true }); +``` + +### Transactions with `forTable` + +Pass `dbApi: trx` through just like the top-level API: + +```js +await bf.transaction(async (trx) => { + await users.add('*', { email: 'a@b.c' }, { dbApi: trx }); + await users.edit('*', { active: true }, { email: 'a@b.c' }, { dbApi: trx }); +}); +``` + ## License ISC diff --git a/index.js b/index.js index c10636d..0aeaa85 100644 --- a/index.js +++ b/index.js @@ -1,13 +1,92 @@ import knexConstructor from 'knex'; +const defaults = { + dateField: 'created_at', + limit: 1000, + offset: 0, + sortOrder: 'ASC', +}; + +const OPERATORS = { + eq: '=', + ne: '!=', + gt: '>', + gte: '>=', + lt: '<', + lte: '<=', + like: 'like', + ilike: 'ilike', +}; + +// Apply a filter object to a Knex query. Supports: +// { col: value } -> WHERE col = value +// { col: [a, b, c] } -> WHERE col IN (...) +// { col: null } -> WHERE col IS NULL +// { col: { gt: x, lte: y } } -> WHERE col > x AND col <= y +// { col: { in: [a, b] } } -> WHERE col IN (...) +// { col: { notIn: [a, b] } } -> WHERE col NOT IN (...) +// { col: { between: [a, b] } } -> WHERE col BETWEEN a AND b +function applyFilter(query, filter) { + if (!filter) return query; + for (const [key, value] of Object.entries(filter)) { + if (value === undefined) continue; + if (value === null) { + query = query.whereNull(key); + } else if (Array.isArray(value)) { + query = query.whereIn(key, value); + } else if (typeof value === 'object' && !(value instanceof Date) && !value.toSQL) { + for (const [op, operand] of Object.entries(value)) { + if (operand === undefined) continue; + if (op === 'in') query = query.whereIn(key, operand); + else if (op === 'notIn') query = query.whereNotIn(key, operand); + else if (op === 'between') query = query.whereBetween(key, operand); + else if (op === 'notBetween') query = query.whereNotBetween(key, operand); + else if (op === 'null') { + query = operand ? query.whereNull(key) : query.whereNotNull(key); + } else if (OPERATORS[op]) { + query = query.where(key, OPERATORS[op], operand); + } else { + throw new Error(`Unknown filter operator: ${op}`); + } + } + } else { + query = query.where(key, value); + } + } + return query; +} + +// Resolve a soft-delete config to { column, value, undeletedValue }. +// Accepts: +// true -> { column: 'is_deleted', value: true, undeletedValue: false } +// { column, value, undeletedValue } -> used as-is +// value can be: +// - a literal (true, false, Date, etc.) +// - the string 'NOW' -> knex.fn.now() +// - a Knex raw expression (e.g. knex.fn.now()) +// - a function -> called at delete time +function resolveSoftDelete(config, knex) { + if (!config) return null; + if (config === true) { + return { column: 'is_deleted', value: true, undeletedValue: false }; + } + if (typeof config !== 'object') { + throw new Error('softDelete must be true or an object'); + } + const column = config.column || 'is_deleted'; + const undeletedValue = 'undeletedValue' in config ? config.undeletedValue : false; + let value = 'value' in config ? config.value : true; + if (value === 'NOW') value = knex.fn.now(); + return { column, value, undeletedValue }; +} + +function resolveValue(value) { + return typeof value === 'function' ? value() : value; +} + export default function connect(settings) { - const knex = knexConstructor(settings); - const defaults = { - dateField: 'created_at', - limit: 1000, - offset: 0, - sortOrder: 'ASC', - }; + // Allow passing an existing knex instance or a config + const knex = typeof settings === 'function' ? settings : knexConstructor(settings); function browse(table, fields, filter, options = {}) { const dbApi = options.dbApi || knex; @@ -16,11 +95,9 @@ export default function connect(settings) { const dateField = options.dateField || defaults.dateField; const sortOrder = options.sortOrder || defaults.sortOrder; - let query = dbApi(table) - .where(filter) - .select(fields) - .limit(limit) - .offset(offset); + let query = dbApi(table).select(fields).limit(limit).offset(offset); + + query = applyFilter(query, filter); if (options.search_start_date && options.search_end_date) { query = query.whereBetween(dateField, [ @@ -45,8 +122,10 @@ export default function connect(settings) { async function read(table, fields, filter, options = {}) { const dbApi = options.dbApi || knex; - const [row] = await dbApi(table).where(filter).select(fields); - return row; + let query = dbApi(table).select(fields); + query = applyFilter(query, filter); + const row = await query.first(); + return row || null; } async function add(table, fields, data, options = {}) { @@ -57,16 +136,36 @@ export default function connect(settings) { async function edit(table, fields, data, filter, options = {}) { const dbApi = options.dbApi || knex; - const [row] = await dbApi(table) - .where(filter) - .returning(fields) - .update(data); + let query = dbApi(table).returning(fields).update(data); + query = applyFilter(query, filter); + const [row] = await query; return row; } function del(table, filter, options = {}) { const dbApi = options.dbApi || knex; - return dbApi(table).where(filter).del(); + let query = dbApi(table).del(); + query = applyFilter(query, filter); + return query; + } + + async function count(table, filter, options = {}) { + const dbApi = options.dbApi || knex; + let query = dbApi(table).count('* as total'); + query = applyFilter(query, filter); + const [row] = await query; + return Number(row.total); + } + + async function upsert(table, fields, data, conflictColumns, options = {}) { + const dbApi = options.dbApi || knex; + const cols = Array.isArray(conflictColumns) ? conflictColumns : [conflictColumns]; + const [row] = await dbApi(table) + .insert(data) + .onConflict(cols) + .merge() + .returning(fields); + return row; } async function raw(sql, options = {}) { @@ -75,5 +174,75 @@ export default function connect(settings) { return res.rows || res; } - return { browse, read, add, edit, del, raw, knex }; + function transaction(callback) { + return knex.transaction(callback); + } + + // Table-bound API with optional softDelete and viewName support. + // Returns an object with the same BREAD methods but bound to a specific table, + // with soft-delete filtering baked into browse/read/del, and optional + // separate readTable (view) for reads vs writes. + function forTable(tableName, tableOptions = {}) { + const softDelete = resolveSoftDelete(tableOptions.softDelete, knex); + const readTable = tableOptions.viewName || tableName; + const writeTable = tableName; + + function addSoftDeleteFilter(filter = {}, options = {}) { + if (!softDelete) return filter; + if (options.withDeleted) return filter; + // Don't override if caller explicitly set the column + if (filter[softDelete.column] !== undefined) return filter; + return { ...filter, [softDelete.column]: softDelete.undeletedValue }; + } + + return { + browse(fields, filter, options = {}) { + return browse(readTable, fields, addSoftDeleteFilter(filter, options), options); + }, + read(fields, filter, options = {}) { + return read(readTable, fields, addSoftDeleteFilter(filter, options), options); + }, + add(fields, data, options = {}) { + return add(writeTable, fields, data, options); + }, + edit(fields, data, filter, options = {}) { + return edit(writeTable, fields, data, addSoftDeleteFilter(filter, options), options); + }, + del(filter, options = {}) { + if (softDelete) { + const dbApi = options.dbApi || knex; + let query = dbApi(writeTable).update({ + [softDelete.column]: resolveValue(softDelete.value), + }); + query = applyFilter(query, addSoftDeleteFilter(filter, options)); + return query; + } + return del(writeTable, filter, options); + }, + restore(filter, options = {}) { + if (!softDelete) { + throw new Error('restore() requires softDelete to be configured'); + } + const dbApi = options.dbApi || knex; + let query = dbApi(writeTable).update({ + [softDelete.column]: softDelete.undeletedValue, + }); + // When restoring, look only at deleted rows + const restoreFilter = { ...filter, [softDelete.column]: { ne: softDelete.undeletedValue } }; + query = applyFilter(query, restoreFilter); + return query; + }, + count(filter, options = {}) { + return count(readTable, addSoftDeleteFilter(filter, options), options); + }, + upsert(fields, data, conflictColumns, options = {}) { + return upsert(writeTable, fields, data, conflictColumns, options); + }, + softDelete, + tableName: writeTable, + readTable, + }; + } + + return { browse, read, add, edit, del, raw, count, upsert, transaction, forTable, knex }; } diff --git a/package-lock.json b/package-lock.json index 8a96b88..4e6e1db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "breadfruit", - "version": "3.0.0", + "version": "3.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "breadfruit", - "version": "3.0.0", + "version": "3.1.0", "license": "ISC", "dependencies": { "knex": "^3.2.0" diff --git a/package.json b/package.json index 011f28d..53820c6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "breadfruit", - "version": "3.0.1", + "version": "3.1.0", "description": "Boilerplate SQL query helpers for Node.js using Knex", "type": "module", "main": "./index.js", @@ -15,7 +15,7 @@ "README.md" ], "engines": { - "node": ">=20" + "node": ">=22" }, "scripts": { "lint": "eslint .", diff --git a/test/unit.test.js b/test/unit.test.js new file mode 100644 index 0000000..52ef82a --- /dev/null +++ b/test/unit.test.js @@ -0,0 +1,376 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import breadfruit from '../index.js'; + +// Tests that assert on generated SQL without hitting a database. +// Uses Knex's query builder in pg dialect. No connection needed. +// +// For browse/del/edit (which return query builders), we call .toSQL() directly. +// For read/add/count/upsert (which await internally), we mock the knex instance +// with chainable stubs that record the final query shape. + +const config = { client: 'pg' }; + +describe('browse SQL generation', () => { + const { browse } = breadfruit(config); + + it('simple filter', () => { + const { sql, bindings } = browse('users', '*', { active: true }).toSQL(); + assert.match(sql, /select \* from "users"/); + assert.match(sql, /"active" = \?/); + assert.ok(bindings.includes(true)); + }); + + it('array value -> whereIn', () => { + const { sql, bindings } = browse('users', '*', { status: ['a', 'b'] }).toSQL(); + assert.match(sql, /"status" in \(\?, \?\)/); + assert.deepEqual(bindings.slice(0, 2), ['a', 'b']); + }); + + it('null value -> whereNull', () => { + const { sql } = browse('users', '*', { email: null }).toSQL(); + assert.match(sql, /"email" is null/); + }); + + it('gt operator', () => { + const { sql, bindings } = browse('users', '*', { count: { gt: 5 } }).toSQL(); + assert.match(sql, /"count" > \?/); + assert.ok(bindings.includes(5)); + }); + + it('between operator', () => { + const { sql, bindings } = browse('users', '*', { count: { between: [1, 10] } }).toSQL(); + assert.match(sql, /"count" between \? and \?/); + assert.ok(bindings.includes(1)); + assert.ok(bindings.includes(10)); + }); + + it('in operator', () => { + const { sql } = browse('users', '*', { name: { in: ['a', 'b'] } }).toSQL(); + assert.match(sql, /"name" in \(\?, \?\)/); + }); + + it('notIn operator', () => { + const { sql } = browse('users', '*', { name: { notIn: ['a', 'b'] } }).toSQL(); + assert.match(sql, /"name" not in \(\?, \?\)/); + }); + + it('notBetween operator', () => { + const { sql } = browse('users', '*', { count: { notBetween: [1, 10] } }).toSQL(); + assert.match(sql, /"count" not between \? and \?/); + }); + + it('null: true operator', () => { + const { sql } = browse('users', '*', { email: { null: true } }).toSQL(); + assert.match(sql, /"email" is null/); + }); + + it('null: false operator', () => { + const { sql } = browse('users', '*', { email: { null: false } }).toSQL(); + assert.match(sql, /"email" is not null/); + }); + + it('combined operators on same column', () => { + const { sql } = browse('users', '*', { count: { gt: 1, lte: 10 } }).toSQL(); + assert.match(sql, /"count" > \?/); + assert.match(sql, /"count" <= \?/); + }); + + it('like operator', () => { + const { sql } = browse('users', '*', { name: { like: 'a%' } }).toSQL(); + assert.match(sql, /"name" like \?/i); + }); + + it('ilike operator', () => { + const { sql } = browse('users', '*', { name: { ilike: 'a%' } }).toSQL(); + assert.match(sql, /"name" ilike \?/i); + }); + + it('undefined values are skipped', () => { + const { sql } = browse('users', '*', { a: 1, b: undefined }).toSQL(); + assert.match(sql, /"a" = \?/); + assert.doesNotMatch(sql, /"b"/); + }); + + it('limit and offset', () => { + const { sql, bindings } = browse('users', '*', {}, { limit: 50, offset: 10 }).toSQL(); + assert.ok(bindings.includes(50)); + assert.ok(bindings.includes(10)); + assert.match(sql, /limit \?/); + assert.match(sql, /offset \?/); + }); + + it('single orderBy', () => { + const { sql } = browse('users', '*', {}, { orderBy: 'name' }).toSQL(); + assert.match(sql, /order by "name"/i); + }); + + it('array orderBy', () => { + const { sql } = browse('users', '*', {}, { + orderBy: ['name', 'created_at'], + sortOrder: ['asc', 'desc'], + }).toSQL(); + assert.match(sql, /order by "name" asc/i); + assert.match(sql, /"created_at" desc/i); + }); + + it('search date range uses dateField', () => { + const { sql } = browse('events', '*', {}, { + search_start_date: '2026-01-01', + search_end_date: '2026-12-31', + dateField: 'happened_at', + }).toSQL(); + assert.match(sql, /"happened_at" between \? and \?/); + }); + + it('throws on unknown operator', () => { + assert.throws( + () => browse('users', '*', { count: { bogus: 5 } }), + /Unknown filter operator: bogus/, + ); + }); +}); + +describe('del SQL generation', () => { + const { del } = breadfruit(config); + + it('plain delete', () => { + const { sql } = del('users', { id: 1 }).toSQL(); + assert.match(sql, /delete from "users"/); + assert.match(sql, /"id" = \?/); + }); + + it('del with composite filter', () => { + const { sql } = del('users', { id: { in: [1, 2, 3] } }).toSQL(); + assert.match(sql, /"id" in \(\?, \?, \?\)/); + }); +}); + +describe('accepts an existing knex instance', () => { + it('passes a knex function through', () => { + // Pass a minimal knex-like function; confirm it's used + const k = breadfruit(config).knex; + const api = breadfruit(k); + assert.equal(api.knex, k); + }); +}); + +describe('forTable SQL generation', () => { + const bf = breadfruit(config); + + it('browse applies soft-delete filter (boolean default)', () => { + const users = bf.forTable('users', { softDelete: true }); + const { sql } = users.browse('*', {}).toSQL(); + assert.match(sql, /"is_deleted" = \?/); + }); + + it('browse can override soft-delete with withDeleted', () => { + const users = bf.forTable('users', { softDelete: true }); + const { sql } = users.browse('*', {}, { withDeleted: true }).toSQL(); + assert.doesNotMatch(sql, /"is_deleted"/); + }); + + it('browse reads from viewName', () => { + const users = bf.forTable('users', { viewName: 'users_v' }); + const { sql } = users.browse('*', {}).toSQL(); + assert.match(sql, /from "users_v"/); + }); + + it('del soft-deletes with UPDATE instead of DELETE', () => { + const users = bf.forTable('users', { softDelete: true }); + const { sql } = users.del({ id: 1 }).toSQL(); + assert.match(sql, /update "users"/); + assert.match(sql, /set "is_deleted" = \?/); + assert.doesNotMatch(sql, /delete from/); + }); + + it('del without soft-delete uses DELETE', () => { + const users = bf.forTable('users', {}); + const { sql } = users.del({ id: 1 }).toSQL(); + assert.match(sql, /delete from "users"/); + }); + + it('custom soft-delete column', () => { + const users = bf.forTable('users', { + softDelete: { + column: 'deleted_at', + value: new Date('2026-01-01'), + undeletedValue: null, + }, + }); + const { sql: browseSql } = users.browse('*', {}).toSQL(); + assert.match(browseSql, /"deleted_at" is null/); + + const { sql: delSql } = users.del({ id: 1 }).toSQL(); + assert.match(delSql, /set "deleted_at" = \?/); + }); + + it('restore updates soft-deleted rows', () => { + const users = bf.forTable('users', { softDelete: true }); + const { sql } = users.restore({ id: 1 }).toSQL(); + assert.match(sql, /update "users"/); + assert.match(sql, /set "is_deleted" = \?/); + }); + + it('restore throws without softDelete', () => { + const users = bf.forTable('users', {}); + assert.throws( + () => users.restore({ id: 1 }), + /softDelete to be configured/, + ); + }); + + it('writes always go to table even with viewName', () => { + const users = bf.forTable('users', { viewName: 'users_v' }); + const { sql } = users.del({ id: 1 }).toSQL(); + assert.match(sql, /delete from "users"/); + assert.doesNotMatch(sql, /"users_v"/); + }); + + it('exposes tableName, readTable, softDelete config', () => { + const users = bf.forTable('users', { viewName: 'users_v', softDelete: true }); + assert.equal(users.tableName, 'users'); + assert.equal(users.readTable, 'users_v'); + assert.equal(users.softDelete.column, 'is_deleted'); + }); + + it('softDelete: "NOW" resolves to knex.fn.now()', () => { + const users = bf.forTable('users', { + softDelete: { column: 'deleted_at', value: 'NOW', undeletedValue: null }, + }); + const { sql } = users.del({ id: 1 }).toSQL(); + // knex.fn.now() produces CURRENT_TIMESTAMP in pg + assert.match(sql, /set "deleted_at" = CURRENT_TIMESTAMP/); + }); +}); + +describe('softDelete config validation', () => { + const bf = breadfruit(config); + + it('rejects non-object non-boolean softDelete', () => { + assert.throws( + () => bf.forTable('users', { softDelete: 'yes' }), + /softDelete must be true or an object/, + ); + }); +}); + +describe('await-based methods (mocked knex)', () => { + // These methods await internally, so we can't use .toSQL(). + // We mock the knex instance to intercept and record calls. + + function createMockKnex() { + const calls = []; + function mockTable(name) { + const chain = { + _table: name, + _calls: [], + select(...args) { this._calls.push(['select', args]); return this; }, + where(...args) { this._calls.push(['where', args]); return this; }, + whereNull(...args) { this._calls.push(['whereNull', args]); return this; }, + whereNotNull(...args) { this._calls.push(['whereNotNull', args]); return this; }, + whereIn(...args) { this._calls.push(['whereIn', args]); return this; }, + whereNotIn(...args) { this._calls.push(['whereNotIn', args]); return this; }, + whereBetween(...args) { this._calls.push(['whereBetween', args]); return this; }, + whereNotBetween(...args) { this._calls.push(['whereNotBetween', args]); return this; }, + insert(data) { this._calls.push(['insert', data]); return this; }, + update(data) { this._calls.push(['update', data]); return this; }, + returning(fields) { this._calls.push(['returning', fields]); return this; }, + onConflict(cols) { this._calls.push(['onConflict', cols]); return this; }, + merge() { this._calls.push(['merge']); return this; }, + count(expr) { this._calls.push(['count', expr]); return this; }, + first() { + this._calls.push(['first']); + calls.push({ table: this._table, ops: this._calls }); + return Promise.resolve(null); + }, + then(resolve) { + calls.push({ table: this._table, ops: this._calls }); + // Simulate a result shape that matches what each operation expects + if (this._calls.some(([op]) => op === 'count')) { + return Promise.resolve([{ total: 0 }]).then(resolve); + } + return Promise.resolve([{}]).then(resolve); + }, + }; + return chain; + } + const k = Object.assign(mockTable, { + fn: { now: () => 'NOW_FN' }, + raw: (sql) => Promise.resolve({ rows: [{ raw: sql }] }), + transaction: (cb) => cb(mockTable), + }); + return { knex: k, calls }; + } + + it('read calls first() and applies filter', async () => { + const { knex, calls } = createMockKnex(); + const { read } = breadfruit(knex); + await read('users', '*', { id: 42 }); + const call = calls[0]; + assert.equal(call.table, 'users'); + assert.ok(call.ops.some(([op, args]) => op === 'where' && args[0] === 'id' && args[1] === 42)); + assert.ok(call.ops.some(([op]) => op === 'first')); + }); + + it('add calls insert().returning()', async () => { + const { knex, calls } = createMockKnex(); + const { add } = breadfruit(knex); + await add('users', '*', { name: 'luis' }); + const call = calls[0]; + assert.ok(call.ops.some(([op, args]) => op === 'insert' && args.name === 'luis')); + assert.ok(call.ops.some(([op]) => op === 'returning')); + }); + + it('edit calls update() with filter', async () => { + const { knex, calls } = createMockKnex(); + const { edit } = breadfruit(knex); + await edit('users', '*', { name: 'new' }, { id: 1 }); + const call = calls[0]; + assert.ok(call.ops.some(([op, args]) => op === 'update' && args.name === 'new')); + assert.ok(call.ops.some(([op, args]) => op === 'where' && args[0] === 'id')); + }); + + it('count calls count("* as total") and returns Number', async () => { + const { knex, calls } = createMockKnex(); + const { count } = breadfruit(knex); + const n = await count('users', { active: true }); + assert.equal(n, 0); + const call = calls[0]; + assert.ok(call.ops.some(([op, args]) => op === 'count' && args === '* as total')); + }); + + it('upsert calls insert+onConflict+merge+returning', async () => { + const { knex, calls } = createMockKnex(); + const { upsert } = breadfruit(knex); + await upsert('users', '*', { email: 'a@b.c' }, 'email'); + const ops = calls[0].ops.map(([op]) => op); + assert.ok(ops.includes('insert')); + assert.ok(ops.includes('onConflict')); + assert.ok(ops.includes('merge')); + assert.ok(ops.includes('returning')); + }); + + it('upsert accepts array of conflict columns', async () => { + const { knex, calls } = createMockKnex(); + const { upsert } = breadfruit(knex); + await upsert('users', '*', { a: 1 }, ['x', 'y']); + const onConflict = calls[0].ops.find(([op]) => op === 'onConflict'); + assert.deepEqual(onConflict[1], ['x', 'y']); + }); + + it('raw returns .rows from result', async () => { + const { knex } = createMockKnex(); + const { raw } = breadfruit(knex); + const rows = await raw('select 1'); + assert.equal(rows[0].raw, 'select 1'); + }); + + it('transaction wraps knex.transaction', async () => { + const { knex } = createMockKnex(); + const { transaction } = breadfruit(knex); + let got; + await transaction((trx) => { got = trx; return Promise.resolve(); }); + assert.equal(typeof got, 'function'); + }); +});