breadfruit 3.0.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +152 -1
  2. package/index.js +189 -20
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -17,7 +17,7 @@ Not really bread. Not really fruit. Just like this package. Simple CRUD helpers
17
17
  npm install breadfruit
18
18
  ```
19
19
 
20
- Requires Node.js `>=20`.
20
+ Requires Node.js `>=22`.
21
21
 
22
22
  ## Usage
23
23
 
@@ -102,6 +102,157 @@ Runs a raw SQL statement and returns rows.
102
102
  const rows = await raw('select * from users');
103
103
  ```
104
104
 
105
+ ### `count(table, filter, options?)`
106
+
107
+ Returns the count of matching rows as a number.
108
+
109
+ ```js
110
+ const activeUsers = await count('users', { active: true });
111
+ ```
112
+
113
+ ### `upsert(table, returnFields, data, conflictColumns, options?)`
114
+
115
+ Inserts a row, or updates on conflict. `conflictColumns` can be a string or array.
116
+
117
+ ```js
118
+ const row = await upsert(
119
+ 'users',
120
+ '*',
121
+ { email: 'luis@example.com', name: 'Luis' },
122
+ 'email',
123
+ );
124
+ ```
125
+
126
+ ### `transaction(callback)`
127
+
128
+ Wraps `knex.transaction()`. Pass the `trx` object as `dbApi` in your method calls.
129
+
130
+ ```js
131
+ await transaction(async (trx) => {
132
+ await add('users', ['id'], { name: 'a' }, { dbApi: trx });
133
+ await add('users', ['id'], { name: 'b' }, { dbApi: trx });
134
+ });
135
+ ```
136
+
137
+ ## Advanced
138
+
139
+ ### Passing an existing Knex instance
140
+
141
+ 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.
142
+
143
+ ```js
144
+ import knex from './db.js';
145
+ import breadfruit from 'breadfruit';
146
+
147
+ const bf = breadfruit(knex);
148
+ ```
149
+
150
+ ### Composite filters
151
+
152
+ Filter values accept operators beyond simple equality.
153
+
154
+ | Shape | SQL |
155
+ |---|---|
156
+ | `{ col: value }` | `col = value` |
157
+ | `{ col: [a, b, c] }` | `col IN (a, b, c)` |
158
+ | `{ col: null }` | `col IS NULL` |
159
+ | `{ col: { eq: x } }` | `col = x` |
160
+ | `{ col: { ne: x } }` | `col != x` |
161
+ | `{ col: { gt: x } }` | `col > x` |
162
+ | `{ col: { gte: x } }` | `col >= x` |
163
+ | `{ col: { lt: x } }` | `col < x` |
164
+ | `{ col: { lte: x } }` | `col <= x` |
165
+ | `{ col: { like: 'x%' } }` | `col LIKE 'x%'` |
166
+ | `{ col: { ilike: 'x%' } }` | `col ILIKE 'x%'` |
167
+ | `{ col: { in: [a, b] } }` | `col IN (a, b)` |
168
+ | `{ col: { notIn: [a, b] } }` | `col NOT IN (a, b)` |
169
+ | `{ col: { between: [a, b] } }` | `col BETWEEN a AND b` |
170
+ | `{ col: { notBetween: [a, b] } }` | `col NOT BETWEEN a AND b` |
171
+ | `{ col: { null: true } }` | `col IS NULL` |
172
+ | `{ col: { null: false } }` | `col IS NOT NULL` |
173
+
174
+ Multiple operators on the same column AND together:
175
+
176
+ ```js
177
+ await browse('events', '*', {
178
+ count: { gt: 1, lte: 100 },
179
+ created_at: { gte: '2026-01-01' },
180
+ });
181
+ ```
182
+
183
+ ### `forTable(tableName, options?)` — table-bound helpers
184
+
185
+ Returns an object with the same BREAD methods but bound to a specific table, with optional **soft delete** and **view-for-reads** behavior.
186
+
187
+ ```js
188
+ const users = bf.forTable('users', {
189
+ softDelete: true,
190
+ viewName: 'users_v',
191
+ });
192
+
193
+ await users.browse('*', { active: true }); // reads from users_v
194
+ await users.del({ id: 42 }); // soft-deletes in users
195
+ await users.restore({ id: 42 }); // un-soft-deletes
196
+ const total = await users.count({}); // respects soft delete
197
+ ```
198
+
199
+ #### Soft delete
200
+
201
+ Three options for the `softDelete` config:
202
+
203
+ ```js
204
+ // 1. Boolean shorthand — uses is_deleted column, true/false
205
+ softDelete: true
206
+
207
+ // 2. Full config
208
+ softDelete: {
209
+ column: 'is_deleted',
210
+ value: true, // set on delete
211
+ undeletedValue: false, // the "active" value for filtering
212
+ }
213
+
214
+ // 3. Timestamp style — deleted_at IS NULL means active
215
+ softDelete: {
216
+ column: 'deleted_at',
217
+ value: 'NOW', // special string -> knex.fn.now()
218
+ undeletedValue: null,
219
+ }
220
+ ```
221
+
222
+ The `value` field accepts:
223
+ - a literal (`true`, `false`, `Date`, etc.)
224
+ - the string `'NOW'` — becomes `knex.fn.now()` so the DB generates the timestamp
225
+ - a Knex raw expression like `knex.fn.now()` or `knex.raw('...')`
226
+ - a function — called at delete time (runs in JS, not DB)
227
+
228
+ #### Reads from a view, writes to the table
229
+
230
+ Pass `viewName` to read from a view while writing to the underlying table. Great for denormalized read paths.
231
+
232
+ ```js
233
+ bf.forTable('users', { viewName: 'user_groups_v' });
234
+ ```
235
+
236
+ #### `withDeleted`
237
+
238
+ Bypass the soft-delete filter for admin or audit views:
239
+
240
+ ```js
241
+ const allUsers = await users.browse('*', {}, { withDeleted: true });
242
+ const count = await users.count({}, { withDeleted: true });
243
+ ```
244
+
245
+ ### Transactions with `forTable`
246
+
247
+ Pass `dbApi: trx` through just like the top-level API:
248
+
249
+ ```js
250
+ await bf.transaction(async (trx) => {
251
+ await users.add('*', { email: 'a@b.c' }, { dbApi: trx });
252
+ await users.edit('*', { active: true }, { email: 'a@b.c' }, { dbApi: trx });
253
+ });
254
+ ```
255
+
105
256
  ## License
106
257
 
107
258
  ISC
package/index.js CHANGED
@@ -1,13 +1,92 @@
1
1
  import knexConstructor from 'knex';
2
2
 
3
+ const defaults = {
4
+ dateField: 'created_at',
5
+ limit: 1000,
6
+ offset: 0,
7
+ sortOrder: 'ASC',
8
+ };
9
+
10
+ const OPERATORS = {
11
+ eq: '=',
12
+ ne: '!=',
13
+ gt: '>',
14
+ gte: '>=',
15
+ lt: '<',
16
+ lte: '<=',
17
+ like: 'like',
18
+ ilike: 'ilike',
19
+ };
20
+
21
+ // Apply a filter object to a Knex query. Supports:
22
+ // { col: value } -> WHERE col = value
23
+ // { col: [a, b, c] } -> WHERE col IN (...)
24
+ // { col: null } -> WHERE col IS NULL
25
+ // { col: { gt: x, lte: y } } -> WHERE col > x AND col <= y
26
+ // { col: { in: [a, b] } } -> WHERE col IN (...)
27
+ // { col: { notIn: [a, b] } } -> WHERE col NOT IN (...)
28
+ // { col: { between: [a, b] } } -> WHERE col BETWEEN a AND b
29
+ function applyFilter(query, filter) {
30
+ if (!filter) return query;
31
+ for (const [key, value] of Object.entries(filter)) {
32
+ if (value === undefined) continue;
33
+ if (value === null) {
34
+ query = query.whereNull(key);
35
+ } else if (Array.isArray(value)) {
36
+ query = query.whereIn(key, value);
37
+ } else if (typeof value === 'object' && !(value instanceof Date) && !value.toSQL) {
38
+ for (const [op, operand] of Object.entries(value)) {
39
+ if (operand === undefined) continue;
40
+ if (op === 'in') query = query.whereIn(key, operand);
41
+ else if (op === 'notIn') query = query.whereNotIn(key, operand);
42
+ else if (op === 'between') query = query.whereBetween(key, operand);
43
+ else if (op === 'notBetween') query = query.whereNotBetween(key, operand);
44
+ else if (op === 'null') {
45
+ query = operand ? query.whereNull(key) : query.whereNotNull(key);
46
+ } else if (OPERATORS[op]) {
47
+ query = query.where(key, OPERATORS[op], operand);
48
+ } else {
49
+ throw new Error(`Unknown filter operator: ${op}`);
50
+ }
51
+ }
52
+ } else {
53
+ query = query.where(key, value);
54
+ }
55
+ }
56
+ return query;
57
+ }
58
+
59
+ // Resolve a soft-delete config to { column, value, undeletedValue }.
60
+ // Accepts:
61
+ // true -> { column: 'is_deleted', value: true, undeletedValue: false }
62
+ // { column, value, undeletedValue } -> used as-is
63
+ // value can be:
64
+ // - a literal (true, false, Date, etc.)
65
+ // - the string 'NOW' -> knex.fn.now()
66
+ // - a Knex raw expression (e.g. knex.fn.now())
67
+ // - a function -> called at delete time
68
+ function resolveSoftDelete(config, knex) {
69
+ if (!config) return null;
70
+ if (config === true) {
71
+ return { column: 'is_deleted', value: true, undeletedValue: false };
72
+ }
73
+ if (typeof config !== 'object') {
74
+ throw new Error('softDelete must be true or an object');
75
+ }
76
+ const column = config.column || 'is_deleted';
77
+ const undeletedValue = 'undeletedValue' in config ? config.undeletedValue : false;
78
+ let value = 'value' in config ? config.value : true;
79
+ if (value === 'NOW') value = knex.fn.now();
80
+ return { column, value, undeletedValue };
81
+ }
82
+
83
+ function resolveValue(value) {
84
+ return typeof value === 'function' ? value() : value;
85
+ }
86
+
3
87
  export default function connect(settings) {
4
- const knex = knexConstructor(settings);
5
- const defaults = {
6
- dateField: 'created_at',
7
- limit: 1000,
8
- offset: 0,
9
- sortOrder: 'ASC',
10
- };
88
+ // Allow passing an existing knex instance or a config
89
+ const knex = typeof settings === 'function' ? settings : knexConstructor(settings);
11
90
 
12
91
  function browse(table, fields, filter, options = {}) {
13
92
  const dbApi = options.dbApi || knex;
@@ -16,11 +95,9 @@ export default function connect(settings) {
16
95
  const dateField = options.dateField || defaults.dateField;
17
96
  const sortOrder = options.sortOrder || defaults.sortOrder;
18
97
 
19
- let query = dbApi(table)
20
- .where(filter)
21
- .select(fields)
22
- .limit(limit)
23
- .offset(offset);
98
+ let query = dbApi(table).select(fields).limit(limit).offset(offset);
99
+
100
+ query = applyFilter(query, filter);
24
101
 
25
102
  if (options.search_start_date && options.search_end_date) {
26
103
  query = query.whereBetween(dateField, [
@@ -45,8 +122,10 @@ export default function connect(settings) {
45
122
 
46
123
  async function read(table, fields, filter, options = {}) {
47
124
  const dbApi = options.dbApi || knex;
48
- const [row] = await dbApi(table).where(filter).select(fields);
49
- return row;
125
+ let query = dbApi(table).select(fields);
126
+ query = applyFilter(query, filter);
127
+ const row = await query.first();
128
+ return row || null;
50
129
  }
51
130
 
52
131
  async function add(table, fields, data, options = {}) {
@@ -57,16 +136,36 @@ export default function connect(settings) {
57
136
 
58
137
  async function edit(table, fields, data, filter, options = {}) {
59
138
  const dbApi = options.dbApi || knex;
60
- const [row] = await dbApi(table)
61
- .where(filter)
62
- .returning(fields)
63
- .update(data);
139
+ let query = dbApi(table).returning(fields).update(data);
140
+ query = applyFilter(query, filter);
141
+ const [row] = await query;
64
142
  return row;
65
143
  }
66
144
 
67
145
  function del(table, filter, options = {}) {
68
146
  const dbApi = options.dbApi || knex;
69
- return dbApi(table).where(filter).del();
147
+ let query = dbApi(table).del();
148
+ query = applyFilter(query, filter);
149
+ return query;
150
+ }
151
+
152
+ async function count(table, filter, options = {}) {
153
+ const dbApi = options.dbApi || knex;
154
+ let query = dbApi(table).count('* as total');
155
+ query = applyFilter(query, filter);
156
+ const [row] = await query;
157
+ return Number(row.total);
158
+ }
159
+
160
+ async function upsert(table, fields, data, conflictColumns, options = {}) {
161
+ const dbApi = options.dbApi || knex;
162
+ const cols = Array.isArray(conflictColumns) ? conflictColumns : [conflictColumns];
163
+ const [row] = await dbApi(table)
164
+ .insert(data)
165
+ .onConflict(cols)
166
+ .merge()
167
+ .returning(fields);
168
+ return row;
70
169
  }
71
170
 
72
171
  async function raw(sql, options = {}) {
@@ -75,5 +174,75 @@ export default function connect(settings) {
75
174
  return res.rows || res;
76
175
  }
77
176
 
78
- return { browse, read, add, edit, del, raw, knex };
177
+ function transaction(callback) {
178
+ return knex.transaction(callback);
179
+ }
180
+
181
+ // Table-bound API with optional softDelete and viewName support.
182
+ // Returns an object with the same BREAD methods but bound to a specific table,
183
+ // with soft-delete filtering baked into browse/read/del, and optional
184
+ // separate readTable (view) for reads vs writes.
185
+ function forTable(tableName, tableOptions = {}) {
186
+ const softDelete = resolveSoftDelete(tableOptions.softDelete, knex);
187
+ const readTable = tableOptions.viewName || tableName;
188
+ const writeTable = tableName;
189
+
190
+ function addSoftDeleteFilter(filter = {}, options = {}) {
191
+ if (!softDelete) return filter;
192
+ if (options.withDeleted) return filter;
193
+ // Don't override if caller explicitly set the column
194
+ if (filter[softDelete.column] !== undefined) return filter;
195
+ return { ...filter, [softDelete.column]: softDelete.undeletedValue };
196
+ }
197
+
198
+ return {
199
+ browse(fields, filter, options = {}) {
200
+ return browse(readTable, fields, addSoftDeleteFilter(filter, options), options);
201
+ },
202
+ read(fields, filter, options = {}) {
203
+ return read(readTable, fields, addSoftDeleteFilter(filter, options), options);
204
+ },
205
+ add(fields, data, options = {}) {
206
+ return add(writeTable, fields, data, options);
207
+ },
208
+ edit(fields, data, filter, options = {}) {
209
+ return edit(writeTable, fields, data, addSoftDeleteFilter(filter, options), options);
210
+ },
211
+ del(filter, options = {}) {
212
+ if (softDelete) {
213
+ const dbApi = options.dbApi || knex;
214
+ let query = dbApi(writeTable).update({
215
+ [softDelete.column]: resolveValue(softDelete.value),
216
+ });
217
+ query = applyFilter(query, addSoftDeleteFilter(filter, options));
218
+ return query;
219
+ }
220
+ return del(writeTable, filter, options);
221
+ },
222
+ restore(filter, options = {}) {
223
+ if (!softDelete) {
224
+ throw new Error('restore() requires softDelete to be configured');
225
+ }
226
+ const dbApi = options.dbApi || knex;
227
+ let query = dbApi(writeTable).update({
228
+ [softDelete.column]: softDelete.undeletedValue,
229
+ });
230
+ // When restoring, look only at deleted rows
231
+ const restoreFilter = { ...filter, [softDelete.column]: { ne: softDelete.undeletedValue } };
232
+ query = applyFilter(query, restoreFilter);
233
+ return query;
234
+ },
235
+ count(filter, options = {}) {
236
+ return count(readTable, addSoftDeleteFilter(filter, options), options);
237
+ },
238
+ upsert(fields, data, conflictColumns, options = {}) {
239
+ return upsert(writeTable, fields, data, conflictColumns, options);
240
+ },
241
+ softDelete,
242
+ tableName: writeTable,
243
+ readTable,
244
+ };
245
+ }
246
+
247
+ return { browse, read, add, edit, del, raw, count, upsert, transaction, forTable, knex };
79
248
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "breadfruit",
3
- "version": "3.0.1",
3
+ "version": "3.1.0",
4
4
  "description": "Boilerplate SQL query helpers for Node.js using Knex",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -15,7 +15,7 @@
15
15
  "README.md"
16
16
  ],
17
17
  "engines": {
18
- "node": ">=20"
18
+ "node": ">=22"
19
19
  },
20
20
  "scripts": {
21
21
  "lint": "eslint .",