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.
- package/README.md +152 -1
- package/index.js +189 -20
- 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
|
+
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
|
-
|
|
5
|
-
const
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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": ">=
|
|
18
|
+
"node": ">=22"
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
21
|
"lint": "eslint .",
|