From 40d72599f629a1f4b1c13311d3df69b3a4e286d5 Mon Sep 17 00:00:00 2001 From: Luis Montes Date: Wed, 15 Apr 2026 09:45:21 -0700 Subject: [PATCH 1/4] more orm features --- index.js | 209 +++++++++++++++++++++++--- package-lock.json | 167 ++++++++++++++++++++- package.json | 3 +- test/integration.test.js | 317 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 672 insertions(+), 24 deletions(-) create mode 100644 test/integration.test.js 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..b5fbe72 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "breadfruit", - "version": "3.0.0", + "version": "3.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "breadfruit", - "version": "3.0.0", + "version": "3.0.1", "license": "ISC", "dependencies": { "knex": "^3.2.0" @@ -15,7 +15,8 @@ "@eslint/js": "^10.0.1", "c8": "^11.0.0", "eslint": "^10.2.0", - "globals": "^17.5.0" + "globals": "^17.5.0", + "pg": "^8.20.0" }, "engines": { "node": ">=20" @@ -1323,12 +1324,152 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/pg-connection-string": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.2.tgz", "integrity": "sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==", "license": "MIT" }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg/node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -1450,6 +1591,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -1618,6 +1769,16 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 011f28d..dcfb89d 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,8 @@ "@eslint/js": "^10.0.1", "c8": "^11.0.0", "eslint": "^10.2.0", - "globals": "^17.5.0" + "globals": "^17.5.0", + "pg": "^8.20.0" }, "publishConfig": { "access": "public" diff --git a/test/integration.test.js b/test/integration.test.js new file mode 100644 index 0000000..3a78993 --- /dev/null +++ b/test/integration.test.js @@ -0,0 +1,317 @@ +import { describe, it, before, beforeEach, after } from 'node:test'; +import assert from 'node:assert/strict'; +import breadfruit from '../index.js'; + +const DATABASE_URL = + process.env.TEST_DATABASE_URL || + 'postgres://postgres:postgres@localhost:5432/breadfruit_test'; + +const config = { + client: 'pg', + connection: DATABASE_URL, + pool: { min: 1, max: 3 }, +}; + +describe('breadfruit integration', () => { + let api; + + before(async () => { + api = breadfruit(config); + // Ensure schema exists + await api.raw(` + CREATE TABLE IF NOT EXISTS widgets ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + status TEXT, + count INT DEFAULT 0, + is_deleted BOOLEAN DEFAULT false, + deleted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() + ); + `); + await api.raw(` + CREATE OR REPLACE VIEW widgets_v AS + SELECT w.*, 'view' AS source FROM widgets w; + `); + await api.raw(` + CREATE TABLE IF NOT EXISTS upsertable ( + id SERIAL PRIMARY KEY, + key TEXT UNIQUE NOT NULL, + val TEXT + ); + `); + }); + + beforeEach(async () => { + await api.raw('DELETE FROM widgets'); + await api.raw('DELETE FROM upsertable'); + }); + + after(async () => { + await api.knex.destroy(); + }); + + describe('basic BREAD', () => { + it('add + read + browse + edit + del work together', async () => { + const added = await api.add('widgets', ['id', 'name'], { name: 'one' }); + assert.equal(added.name, 'one'); + assert.ok(added.id); + + const readRow = await api.read('widgets', '*', { id: added.id }); + assert.equal(readRow.name, 'one'); + + const rows = await api.browse('widgets', '*', {}); + assert.equal(rows.length, 1); + + const edited = await api.edit( + 'widgets', + '*', + { name: 'updated' }, + { id: added.id }, + ); + assert.equal(edited.name, 'updated'); + + const deleted = await api.del('widgets', { id: added.id }); + assert.equal(deleted, 1); + }); + }); + + describe('count', () => { + it('counts rows matching a filter', async () => { + await api.add('widgets', ['id'], { name: 'a', status: 'active' }); + await api.add('widgets', ['id'], { name: 'b', status: 'active' }); + await api.add('widgets', ['id'], { name: 'c', status: 'inactive' }); + + assert.equal(await api.count('widgets', {}), 3); + assert.equal(await api.count('widgets', { status: 'active' }), 2); + assert.equal(await api.count('widgets', { status: 'missing' }), 0); + }); + }); + + describe('composite filters', () => { + beforeEach(async () => { + await api.add('widgets', ['id'], { name: 'a', count: 1, status: 'active' }); + await api.add('widgets', ['id'], { name: 'b', count: 5, status: 'active' }); + await api.add('widgets', ['id'], { name: 'c', count: 10, status: 'inactive' }); + await api.add('widgets', ['id'], { name: 'd', count: 20, status: null }); + }); + + it('array value -> IN', async () => { + const rows = await api.browse('widgets', '*', { status: ['active', 'inactive'] }); + assert.equal(rows.length, 3); + }); + + it('null value -> IS NULL', async () => { + const rows = await api.browse('widgets', '*', { status: null }); + assert.equal(rows.length, 1); + assert.equal(rows[0].name, 'd'); + }); + + it('comparison operators work', async () => { + const gt = await api.browse('widgets', '*', { count: { gt: 5 } }); + assert.equal(gt.length, 2); + + const between = await api.browse('widgets', '*', { count: { between: [5, 15] } }); + assert.equal(between.length, 2); + + const lte = await api.browse('widgets', '*', { count: { lte: 5 } }); + assert.equal(lte.length, 2); + }); + + it('in/notIn operators', async () => { + const inRows = await api.browse('widgets', '*', { name: { in: ['a', 'c'] } }); + assert.equal(inRows.length, 2); + + const notInRows = await api.browse('widgets', '*', { name: { notIn: ['a', 'c'] } }); + assert.equal(notInRows.length, 2); + }); + + it('combined operators on one column', async () => { + const rows = await api.browse('widgets', '*', { count: { gt: 1, lte: 10 } }); + assert.equal(rows.length, 2); + }); + + it('like operator', async () => { + const rows = await api.browse('widgets', '*', { name: { like: 'a%' } }); + assert.equal(rows.length, 1); + }); + + it('throws on unknown operator', () => { + assert.throws( + () => api.browse('widgets', '*', { count: { bogus: 5 } }), + /Unknown filter operator: bogus/, + ); + }); + }); + + describe('upsert', () => { + it('inserts on new key', async () => { + const row = await api.upsert('upsertable', '*', { key: 'k1', val: 'v1' }, 'key'); + assert.equal(row.key, 'k1'); + assert.equal(row.val, 'v1'); + }); + + it('updates on existing key', async () => { + await api.upsert('upsertable', '*', { key: 'k1', val: 'v1' }, 'key'); + const row = await api.upsert('upsertable', '*', { key: 'k1', val: 'v2' }, 'key'); + assert.equal(row.val, 'v2'); + assert.equal(await api.count('upsertable', {}), 1); + }); + + it('accepts array of conflict columns', async () => { + const row = await api.upsert( + 'upsertable', + '*', + { key: 'k2', val: 'v' }, + ['key'], + ); + assert.equal(row.key, 'k2'); + }); + }); + + describe('transaction', () => { + it('commits on success', async () => { + await api.transaction(async (trx) => { + await api.add('widgets', ['id'], { name: 'tx1' }, { dbApi: trx }); + await api.add('widgets', ['id'], { name: 'tx2' }, { dbApi: trx }); + }); + assert.equal(await api.count('widgets', {}), 2); + }); + + it('rolls back on throw', async () => { + await assert.rejects(() => + api.transaction(async (trx) => { + await api.add('widgets', ['id'], { name: 'tx1' }, { dbApi: trx }); + throw new Error('nope'); + }), + ); + assert.equal(await api.count('widgets', {}), 0); + }); + }); + + describe('forTable with soft delete (boolean)', () => { + let widgets; + + before(() => { + widgets = api.forTable('widgets', { softDelete: true }); + }); + + it('browse excludes soft-deleted by default', async () => { + const active = await api.add('widgets', ['id'], { name: 'active' }); + const deleted = await api.add('widgets', ['id'], { name: 'deleted', is_deleted: true }); + + const rows = await widgets.browse('*', {}); + assert.equal(rows.length, 1); + assert.equal(rows[0].id, active.id); + + // Silence unused + assert.ok(deleted.id); + }); + + it('del soft-deletes instead of removing', async () => { + const row = await api.add('widgets', ['id'], { name: 'x' }); + await widgets.del({ id: row.id }); + + const afterBrowse = await widgets.browse('*', {}); + assert.equal(afterBrowse.length, 0); + + const withDeleted = await widgets.browse('*', {}, { withDeleted: true }); + assert.equal(withDeleted.length, 1); + assert.equal(withDeleted[0].is_deleted, true); + }); + + it('restore un-deletes', async () => { + const row = await api.add('widgets', ['id'], { name: 'x', is_deleted: true }); + await widgets.restore({ id: row.id }); + const rows = await widgets.browse('*', {}); + assert.equal(rows.length, 1); + }); + + it('count respects soft delete', async () => { + await api.add('widgets', ['id'], { name: 'a' }); + await api.add('widgets', ['id'], { name: 'b', is_deleted: true }); + assert.equal(await widgets.count({}), 1); + assert.equal(await widgets.count({}, { withDeleted: true }), 2); + }); + + it('withDeleted bypasses the filter', async () => { + await api.add('widgets', ['id'], { name: 'a' }); + await api.add('widgets', ['id'], { name: 'b', is_deleted: true }); + const rows = await widgets.browse('*', {}, { withDeleted: true }); + assert.equal(rows.length, 2); + }); + }); + + describe('forTable with soft delete (timestamp via NOW)', () => { + let widgets; + + before(() => { + widgets = api.forTable('widgets', { + softDelete: { + column: 'deleted_at', + value: 'NOW', + undeletedValue: null, + }, + }); + }); + + it('soft delete sets deleted_at to server time', async () => { + const row = await api.add('widgets', ['id'], { name: 'x' }); + await widgets.del({ id: row.id }); + + const raw = await api.read('widgets', '*', { id: row.id }); + assert.ok(raw.deleted_at, 'deleted_at should be set'); + assert.equal(raw.is_deleted, false, 'is_deleted column untouched'); + }); + + it('browse only returns rows with deleted_at IS NULL', async () => { + const active = await api.add('widgets', ['id'], { name: 'active' }); + const deletedRow = await api.add('widgets', ['id'], { name: 'old' }); + await widgets.del({ id: deletedRow.id }); + + const rows = await widgets.browse('*', {}); + assert.equal(rows.length, 1); + assert.equal(rows[0].id, active.id); + }); + }); + + describe('forTable with viewName', () => { + let widgets; + + before(() => { + widgets = api.forTable('widgets', { viewName: 'widgets_v' }); + }); + + it('reads from view, writes to table', async () => { + const added = await widgets.add(['id', 'name'], { name: 'v-test' }); + assert.ok(added.id); + + const row = await widgets.read('*', { id: added.id }); + assert.equal(row.source, 'view', 'read should come from view'); + + const rows = await widgets.browse('*', {}); + assert.ok(rows.every((r) => r.source === 'view')); + }); + }); + + describe('forTable with both softDelete and viewName', () => { + let widgets; + + before(() => { + widgets = api.forTable('widgets', { + softDelete: true, + viewName: 'widgets_v', + }); + }); + + it('soft delete filter applies when reading from view', async () => { + await api.add('widgets', ['id'], { name: 'kept' }); + await api.add('widgets', ['id'], { name: 'gone', is_deleted: true }); + + const rows = await widgets.browse('*', {}); + assert.equal(rows.length, 1); + assert.equal(rows[0].name, 'kept'); + assert.equal(rows[0].source, 'view'); + }); + }); +}); From 86b4cf8d1b3d7e90dd66cd992447a5d71f2157ca Mon Sep 17 00:00:00 2001 From: Luis Montes Date: Wed, 15 Apr 2026 09:46:01 -0700 Subject: [PATCH 2/4] more orm features --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index dcfb89d..97e0e72 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", From 16ad4b9728a9956df3e19478ddce2e725a939617 Mon Sep 17 00:00:00 2001 From: Luis Montes Date: Wed, 15 Apr 2026 09:53:09 -0700 Subject: [PATCH 3/4] tests --- .github/workflows/ci.yml | 28 ++- README.md | 153 +++++++++++++++- package-lock.json | 167 +---------------- package.json | 5 +- test/integration.test.js | 317 --------------------------------- test/unit.test.js | 376 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 558 insertions(+), 488 deletions(-) delete mode 100644 test/integration.test.js create mode 100644 test/unit.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e418600..ede111e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,17 +2,40 @@ name: CI on: push: - branches: [main, master] + branches: [main, master, orm_features] pull_request: 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/package-lock.json b/package-lock.json index b5fbe72..4e6e1db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "breadfruit", - "version": "3.0.1", + "version": "3.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "breadfruit", - "version": "3.0.1", + "version": "3.1.0", "license": "ISC", "dependencies": { "knex": "^3.2.0" @@ -15,8 +15,7 @@ "@eslint/js": "^10.0.1", "c8": "^11.0.0", "eslint": "^10.2.0", - "globals": "^17.5.0", - "pg": "^8.20.0" + "globals": "^17.5.0" }, "engines": { "node": ">=20" @@ -1324,152 +1323,12 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/pg": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", - "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pg-connection-string": "^2.12.0", - "pg-pool": "^3.13.0", - "pg-protocol": "^1.13.0", - "pg-types": "2.2.0", - "pgpass": "1.0.5" - }, - "engines": { - "node": ">= 16.0.0" - }, - "optionalDependencies": { - "pg-cloudflare": "^1.3.0" - }, - "peerDependencies": { - "pg-native": ">=3.0.1" - }, - "peerDependenciesMeta": { - "pg-native": { - "optional": true - } - } - }, - "node_modules/pg-cloudflare": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", - "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/pg-connection-string": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.2.tgz", "integrity": "sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==", "license": "MIT" }, - "node_modules/pg-int8": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/pg-pool": { - "version": "3.13.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", - "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "pg": ">=8.0" - } - }, - "node_modules/pg-protocol": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", - "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", - "dev": true, - "license": "MIT" - }, - "node_modules/pg-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", - "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pg-int8": "1.0.1", - "postgres-array": "~2.0.0", - "postgres-bytea": "~1.0.0", - "postgres-date": "~1.0.4", - "postgres-interval": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/pg/node_modules/pg-connection-string": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", - "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/pgpass": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", - "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", - "dev": true, - "license": "MIT", - "dependencies": { - "split2": "^4.1.0" - } - }, - "node_modules/postgres-array": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/postgres-bytea": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", - "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-date": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-interval": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "xtend": "^4.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -1591,16 +1450,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 10.x" - } - }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -1769,16 +1618,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4" - } - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 97e0e72..53820c6 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "README.md" ], "engines": { - "node": ">=20" + "node": ">=22" }, "scripts": { "lint": "eslint .", @@ -49,8 +49,7 @@ "@eslint/js": "^10.0.1", "c8": "^11.0.0", "eslint": "^10.2.0", - "globals": "^17.5.0", - "pg": "^8.20.0" + "globals": "^17.5.0" }, "publishConfig": { "access": "public" diff --git a/test/integration.test.js b/test/integration.test.js deleted file mode 100644 index 3a78993..0000000 --- a/test/integration.test.js +++ /dev/null @@ -1,317 +0,0 @@ -import { describe, it, before, beforeEach, after } from 'node:test'; -import assert from 'node:assert/strict'; -import breadfruit from '../index.js'; - -const DATABASE_URL = - process.env.TEST_DATABASE_URL || - 'postgres://postgres:postgres@localhost:5432/breadfruit_test'; - -const config = { - client: 'pg', - connection: DATABASE_URL, - pool: { min: 1, max: 3 }, -}; - -describe('breadfruit integration', () => { - let api; - - before(async () => { - api = breadfruit(config); - // Ensure schema exists - await api.raw(` - CREATE TABLE IF NOT EXISTS widgets ( - id SERIAL PRIMARY KEY, - name TEXT NOT NULL, - status TEXT, - count INT DEFAULT 0, - is_deleted BOOLEAN DEFAULT false, - deleted_at TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT NOW() - ); - `); - await api.raw(` - CREATE OR REPLACE VIEW widgets_v AS - SELECT w.*, 'view' AS source FROM widgets w; - `); - await api.raw(` - CREATE TABLE IF NOT EXISTS upsertable ( - id SERIAL PRIMARY KEY, - key TEXT UNIQUE NOT NULL, - val TEXT - ); - `); - }); - - beforeEach(async () => { - await api.raw('DELETE FROM widgets'); - await api.raw('DELETE FROM upsertable'); - }); - - after(async () => { - await api.knex.destroy(); - }); - - describe('basic BREAD', () => { - it('add + read + browse + edit + del work together', async () => { - const added = await api.add('widgets', ['id', 'name'], { name: 'one' }); - assert.equal(added.name, 'one'); - assert.ok(added.id); - - const readRow = await api.read('widgets', '*', { id: added.id }); - assert.equal(readRow.name, 'one'); - - const rows = await api.browse('widgets', '*', {}); - assert.equal(rows.length, 1); - - const edited = await api.edit( - 'widgets', - '*', - { name: 'updated' }, - { id: added.id }, - ); - assert.equal(edited.name, 'updated'); - - const deleted = await api.del('widgets', { id: added.id }); - assert.equal(deleted, 1); - }); - }); - - describe('count', () => { - it('counts rows matching a filter', async () => { - await api.add('widgets', ['id'], { name: 'a', status: 'active' }); - await api.add('widgets', ['id'], { name: 'b', status: 'active' }); - await api.add('widgets', ['id'], { name: 'c', status: 'inactive' }); - - assert.equal(await api.count('widgets', {}), 3); - assert.equal(await api.count('widgets', { status: 'active' }), 2); - assert.equal(await api.count('widgets', { status: 'missing' }), 0); - }); - }); - - describe('composite filters', () => { - beforeEach(async () => { - await api.add('widgets', ['id'], { name: 'a', count: 1, status: 'active' }); - await api.add('widgets', ['id'], { name: 'b', count: 5, status: 'active' }); - await api.add('widgets', ['id'], { name: 'c', count: 10, status: 'inactive' }); - await api.add('widgets', ['id'], { name: 'd', count: 20, status: null }); - }); - - it('array value -> IN', async () => { - const rows = await api.browse('widgets', '*', { status: ['active', 'inactive'] }); - assert.equal(rows.length, 3); - }); - - it('null value -> IS NULL', async () => { - const rows = await api.browse('widgets', '*', { status: null }); - assert.equal(rows.length, 1); - assert.equal(rows[0].name, 'd'); - }); - - it('comparison operators work', async () => { - const gt = await api.browse('widgets', '*', { count: { gt: 5 } }); - assert.equal(gt.length, 2); - - const between = await api.browse('widgets', '*', { count: { between: [5, 15] } }); - assert.equal(between.length, 2); - - const lte = await api.browse('widgets', '*', { count: { lte: 5 } }); - assert.equal(lte.length, 2); - }); - - it('in/notIn operators', async () => { - const inRows = await api.browse('widgets', '*', { name: { in: ['a', 'c'] } }); - assert.equal(inRows.length, 2); - - const notInRows = await api.browse('widgets', '*', { name: { notIn: ['a', 'c'] } }); - assert.equal(notInRows.length, 2); - }); - - it('combined operators on one column', async () => { - const rows = await api.browse('widgets', '*', { count: { gt: 1, lte: 10 } }); - assert.equal(rows.length, 2); - }); - - it('like operator', async () => { - const rows = await api.browse('widgets', '*', { name: { like: 'a%' } }); - assert.equal(rows.length, 1); - }); - - it('throws on unknown operator', () => { - assert.throws( - () => api.browse('widgets', '*', { count: { bogus: 5 } }), - /Unknown filter operator: bogus/, - ); - }); - }); - - describe('upsert', () => { - it('inserts on new key', async () => { - const row = await api.upsert('upsertable', '*', { key: 'k1', val: 'v1' }, 'key'); - assert.equal(row.key, 'k1'); - assert.equal(row.val, 'v1'); - }); - - it('updates on existing key', async () => { - await api.upsert('upsertable', '*', { key: 'k1', val: 'v1' }, 'key'); - const row = await api.upsert('upsertable', '*', { key: 'k1', val: 'v2' }, 'key'); - assert.equal(row.val, 'v2'); - assert.equal(await api.count('upsertable', {}), 1); - }); - - it('accepts array of conflict columns', async () => { - const row = await api.upsert( - 'upsertable', - '*', - { key: 'k2', val: 'v' }, - ['key'], - ); - assert.equal(row.key, 'k2'); - }); - }); - - describe('transaction', () => { - it('commits on success', async () => { - await api.transaction(async (trx) => { - await api.add('widgets', ['id'], { name: 'tx1' }, { dbApi: trx }); - await api.add('widgets', ['id'], { name: 'tx2' }, { dbApi: trx }); - }); - assert.equal(await api.count('widgets', {}), 2); - }); - - it('rolls back on throw', async () => { - await assert.rejects(() => - api.transaction(async (trx) => { - await api.add('widgets', ['id'], { name: 'tx1' }, { dbApi: trx }); - throw new Error('nope'); - }), - ); - assert.equal(await api.count('widgets', {}), 0); - }); - }); - - describe('forTable with soft delete (boolean)', () => { - let widgets; - - before(() => { - widgets = api.forTable('widgets', { softDelete: true }); - }); - - it('browse excludes soft-deleted by default', async () => { - const active = await api.add('widgets', ['id'], { name: 'active' }); - const deleted = await api.add('widgets', ['id'], { name: 'deleted', is_deleted: true }); - - const rows = await widgets.browse('*', {}); - assert.equal(rows.length, 1); - assert.equal(rows[0].id, active.id); - - // Silence unused - assert.ok(deleted.id); - }); - - it('del soft-deletes instead of removing', async () => { - const row = await api.add('widgets', ['id'], { name: 'x' }); - await widgets.del({ id: row.id }); - - const afterBrowse = await widgets.browse('*', {}); - assert.equal(afterBrowse.length, 0); - - const withDeleted = await widgets.browse('*', {}, { withDeleted: true }); - assert.equal(withDeleted.length, 1); - assert.equal(withDeleted[0].is_deleted, true); - }); - - it('restore un-deletes', async () => { - const row = await api.add('widgets', ['id'], { name: 'x', is_deleted: true }); - await widgets.restore({ id: row.id }); - const rows = await widgets.browse('*', {}); - assert.equal(rows.length, 1); - }); - - it('count respects soft delete', async () => { - await api.add('widgets', ['id'], { name: 'a' }); - await api.add('widgets', ['id'], { name: 'b', is_deleted: true }); - assert.equal(await widgets.count({}), 1); - assert.equal(await widgets.count({}, { withDeleted: true }), 2); - }); - - it('withDeleted bypasses the filter', async () => { - await api.add('widgets', ['id'], { name: 'a' }); - await api.add('widgets', ['id'], { name: 'b', is_deleted: true }); - const rows = await widgets.browse('*', {}, { withDeleted: true }); - assert.equal(rows.length, 2); - }); - }); - - describe('forTable with soft delete (timestamp via NOW)', () => { - let widgets; - - before(() => { - widgets = api.forTable('widgets', { - softDelete: { - column: 'deleted_at', - value: 'NOW', - undeletedValue: null, - }, - }); - }); - - it('soft delete sets deleted_at to server time', async () => { - const row = await api.add('widgets', ['id'], { name: 'x' }); - await widgets.del({ id: row.id }); - - const raw = await api.read('widgets', '*', { id: row.id }); - assert.ok(raw.deleted_at, 'deleted_at should be set'); - assert.equal(raw.is_deleted, false, 'is_deleted column untouched'); - }); - - it('browse only returns rows with deleted_at IS NULL', async () => { - const active = await api.add('widgets', ['id'], { name: 'active' }); - const deletedRow = await api.add('widgets', ['id'], { name: 'old' }); - await widgets.del({ id: deletedRow.id }); - - const rows = await widgets.browse('*', {}); - assert.equal(rows.length, 1); - assert.equal(rows[0].id, active.id); - }); - }); - - describe('forTable with viewName', () => { - let widgets; - - before(() => { - widgets = api.forTable('widgets', { viewName: 'widgets_v' }); - }); - - it('reads from view, writes to table', async () => { - const added = await widgets.add(['id', 'name'], { name: 'v-test' }); - assert.ok(added.id); - - const row = await widgets.read('*', { id: added.id }); - assert.equal(row.source, 'view', 'read should come from view'); - - const rows = await widgets.browse('*', {}); - assert.ok(rows.every((r) => r.source === 'view')); - }); - }); - - describe('forTable with both softDelete and viewName', () => { - let widgets; - - before(() => { - widgets = api.forTable('widgets', { - softDelete: true, - viewName: 'widgets_v', - }); - }); - - it('soft delete filter applies when reading from view', async () => { - await api.add('widgets', ['id'], { name: 'kept' }); - await api.add('widgets', ['id'], { name: 'gone', is_deleted: true }); - - const rows = await widgets.browse('*', {}); - assert.equal(rows.length, 1); - assert.equal(rows[0].name, 'kept'); - assert.equal(rows[0].source, 'view'); - }); - }); -}); 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'); + }); +}); From cebb3ccf1513d594d9207162603a9c7ca54ad02a Mon Sep 17 00:00:00 2001 From: Luis Montes Date: Wed, 15 Apr 2026 09:56:16 -0700 Subject: [PATCH 4/4] tests --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ede111e..98598e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main, master, orm_features] + branches: [main, master] pull_request: branches: [main, master]