@cleverbrush/knex-schema 0.0.0-beta-20260413145651

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 ADDED
@@ -0,0 +1,254 @@
1
+ # @cleverbrush/knex-schema
2
+
3
+ Type-safe, schema-driven query builder for [Knex](https://knexjs.org/). Use `@cleverbrush/schema` object builders to describe your PostgreSQL tables — column name mapping, eager loading, and full CRUD are handled automatically with complete TypeScript inference.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @cleverbrush/knex-schema
9
+ ```
10
+
11
+ **Peer dependency:** `knex >= 3.1.0`
12
+
13
+ ## Quick Start
14
+
15
+ ```typescript
16
+ import knex from 'knex';
17
+ import { query, object, string, number, date } from '@cleverbrush/knex-schema';
18
+
19
+ // 1. Describe your table with a schema
20
+ const UserSchema = object({
21
+ id: number(),
22
+ firstName: string().hasColumnName('first_name'),
23
+ lastName: string().hasColumnName('last_name'),
24
+ age: number().optional(),
25
+ createdAt: date().hasColumnName('created_at'),
26
+ }).hasTableName('users');
27
+
28
+ // 2. Create a Knex instance
29
+ const db = knex({ client: 'pg', connection: process.env.DB_URL });
30
+
31
+ // 3. Query — fully typed, column names resolved automatically
32
+ const adults = await query(db, UserSchema)
33
+ .where(t => t.age, '>', 18)
34
+ .orderBy(t => t.lastName);
35
+ // → typed as Array<{ id: number; firstName: string; lastName: string; age?: number; createdAt: Date }>
36
+ ```
37
+
38
+ ## Schema Definition
39
+
40
+ Import schema builders from `@cleverbrush/knex-schema` (re-exported with the database extensions applied) instead of from `@cleverbrush/schema`:
41
+
42
+ ```typescript
43
+ import { object, string, number, date, boolean, any } from '@cleverbrush/knex-schema';
44
+ // NOT from '@cleverbrush/schema' — those builders lack hasColumnName / hasTableName
45
+ ```
46
+
47
+ ### `.hasColumnName(sqlCol)`
48
+
49
+ Set the SQL column name for a property when it differs from the property key:
50
+
51
+ ```typescript
52
+ const OrderSchema = object({
53
+ id: number(),
54
+ customerId: number().hasColumnName('customer_id'), // snake_case column
55
+ totalAmount: number().hasColumnName('total_amount'),
56
+ createdAt: date().hasColumnName('created_at'),
57
+ }).hasTableName('orders');
58
+ ```
59
+
60
+ ### `.hasTableName(table)`
61
+
62
+ Set the SQL table name on an object schema. Required before calling `query()`.
63
+
64
+ ---
65
+
66
+ ## CRUD Operations
67
+
68
+ ### Fetch All Rows
69
+
70
+ ```typescript
71
+ const users = await query(db, UserSchema);
72
+ // or explicitly:
73
+ const users = await query(db, UserSchema).execute();
74
+ ```
75
+
76
+ ### Fetch First Row
77
+
78
+ ```typescript
79
+ const user = await query(db, UserSchema)
80
+ .where(t => t.id, 1)
81
+ .first();
82
+ // → UserType | undefined
83
+ ```
84
+
85
+ ### Insert
86
+
87
+ ```typescript
88
+ // Single row — returns the inserted record (including database-generated fields)
89
+ const newUser = await query(db, UserSchema).insert({
90
+ firstName: 'Alice',
91
+ lastName: 'Smith',
92
+ age: 30,
93
+ createdAt: new Date(),
94
+ });
95
+
96
+ // Multiple rows
97
+ const newUsers = await query(db, UserSchema).insertMany([
98
+ { firstName: 'Bob', lastName: 'Jones', createdAt: new Date() },
99
+ { firstName: 'Carol', lastName: 'White', createdAt: new Date() },
100
+ ]);
101
+ ```
102
+
103
+ ### Update
104
+
105
+ ```typescript
106
+ // Updates rows matching the WHERE clause, returns updated records
107
+ const updated = await query(db, UserSchema)
108
+ .where(t => t.id, userId)
109
+ .update({ firstName: 'Alicia' });
110
+ ```
111
+
112
+ ### Delete
113
+
114
+ ```typescript
115
+ // Returns the number of deleted rows
116
+ const count = await query(db, UserSchema)
117
+ .where(t => t.id, userId)
118
+ .delete();
119
+ ```
120
+
121
+ ---
122
+
123
+ ## Filtering
124
+
125
+ Column references accept either a **property accessor** (`t => t.firstName`) or a **string property key** (`'firstName'`) — both are resolved to the correct SQL column name:
126
+
127
+ ```typescript
128
+ query(db, UserSchema)
129
+ .where(t => t.firstName, 'like', 'A%')
130
+ .andWhere(t => t.age, '>', 18)
131
+ .orWhere({ lastName: 'Smith' }) // record syntax — keys mapped to columns
132
+ .whereIn(t => t.id, [1, 2, 3])
133
+ .whereNotNull(t => t.createdAt)
134
+ .whereBetween(t => t.age, [20, 40])
135
+ .whereILike(t => t.lastName, 'sm%') // case-insensitive (PostgreSQL)
136
+ .whereRaw('extract(year from created_at) = ?', [2025]);
137
+ ```
138
+
139
+ Available WHERE methods: `where`, `andWhere`, `orWhere`, `whereNot`, `whereIn`, `whereNotIn`, `orWhereIn`, `orWhereNotIn`, `whereNull`, `whereNotNull`, `orWhereNull`, `orWhereNotNull`, `whereBetween`, `whereNotBetween`, `whereLike`, `whereILike`, `whereRaw`, `whereExists`.
140
+
141
+ ---
142
+
143
+ ## Ordering, Pagination, Grouping
144
+
145
+ ```typescript
146
+ query(db, UserSchema)
147
+ .orderBy(t => t.lastName)
148
+ .orderBy(t => t.firstName, 'desc')
149
+ .limit(20)
150
+ .offset(40)
151
+ .groupBy(t => t.age)
152
+ .having(t => t.age, '>', 18)
153
+ .select(t => t.firstName, t => t.age)
154
+ .distinct(t => t.age);
155
+ ```
156
+
157
+ ---
158
+
159
+ ## Eager Loading (No N+1)
160
+
161
+ Related rows are loaded in a **single query** using PostgreSQL CTEs and `jsonb_agg`.
162
+
163
+ ### `joinOne` — one-to-one / many-to-one
164
+
165
+ ```typescript
166
+ const PostSchema = object({
167
+ id: number(),
168
+ title: string(),
169
+ authorId: number().hasColumnName('author_id'),
170
+ }).hasTableName('posts');
171
+
172
+ const posts = await query(db, PostSchema)
173
+ .joinOne({
174
+ foreignSchema: UserSchema,
175
+ localColumn: t => t.authorId,
176
+ foreignColumn: t => t.id,
177
+ as: 'author',
178
+ });
179
+ // posts[0].author.firstName — typed as string ✓
180
+ ```
181
+
182
+ ### `joinMany` — one-to-many
183
+
184
+ ```typescript
185
+ const users = await query(db, UserSchema)
186
+ .joinMany({
187
+ foreignSchema: PostSchema,
188
+ localColumn: t => t.id,
189
+ foreignColumn: t => t.authorId,
190
+ as: 'posts',
191
+ limit: 5,
192
+ orderBy: { column: t => t.id, direction: 'desc' },
193
+ });
194
+ // users[0].posts — typed as Array<{ id: number; title: string; authorId: number }> ✓
195
+ ```
196
+
197
+ The `joinMany` spec supports:
198
+ - `limit` / `offset` — per-parent pagination using `row_number()` window functions
199
+ - `orderBy` — `{ column, direction }` for the sub-collection
200
+ - `foreignQuery` — pre-filtered `Knex.QueryBuilder` (e.g. for soft-delete scopes)
201
+ - `required` (`joinOne` only) — `true` = inner join, `false` = left join (nullable result)
202
+
203
+ ---
204
+
205
+ ## Escape Hatch
206
+
207
+ When you need a Knex feature not exposed by this API, use `.apply()`:
208
+
209
+ ```typescript
210
+ const rows = await query(db, UserSchema)
211
+ .apply(qb => qb.forUpdate().noWait())
212
+ .where(t => t.id, id);
213
+ ```
214
+
215
+ ---
216
+
217
+ ## Column Reference Patterns
218
+
219
+ Both styles are equivalent and resolve to the same SQL column:
220
+
221
+ ```typescript
222
+ // 1. Property accessor (recommended — refactor-safe, IDE auto-complete)
223
+ .where(t => t.firstName, 'Alice')
224
+
225
+ // 2. String property key
226
+ .where('firstName', 'Alice')
227
+ ```
228
+
229
+ The schema's `hasColumnName()` metadata is used to map `firstName` → `first_name` in both cases.
230
+
231
+ ---
232
+
233
+ ## API Reference
234
+
235
+ ### `query(knex, schema, baseQuery?)`
236
+
237
+ Creates a `SchemaQueryBuilder`. `schema` must have `.hasTableName()` set.
238
+ Optionally pass a `baseQuery` (e.g. a scoped `knex('users').where('deleted_at', null)`) as the starting point.
239
+
240
+ ### `SchemaQueryBuilder<TLocalSchema, TResult>`
241
+
242
+ | Category | Methods |
243
+ |---|---|
244
+ | Eager loading | `.joinOne(spec)`, `.joinMany(spec)` |
245
+ | Filtering | `.where()`, `.andWhere()`, `.orWhere()`, `.whereNot()`, `.whereIn()`, `.whereNotIn()`, `.orWhereIn()`, `.orWhereNotIn()`, `.whereNull()`, `.whereNotNull()`, `.orWhereNull()`, `.orWhereNotNull()`, `.whereBetween()`, `.whereNotBetween()`, `.whereLike()`, `.whereILike()`, `.whereRaw()`, `.whereExists()` |
246
+ | Ordering | `.orderBy(col, dir?)`, `.orderByRaw(sql)` |
247
+ | Grouping | `.groupBy(...cols)`, `.groupByRaw(sql)`, `.having(col, op, val)`, `.havingRaw(sql)` |
248
+ | Pagination | `.limit(n)`, `.offset(n)` |
249
+ | Selection | `.select(...cols)`, `.distinct(...cols)` |
250
+ | Aggregates | `.count(col?)`, `.countDistinct(col?)`, `.min(col)`, `.max(col)`, `.sum(col)`, `.avg(col)` |
251
+ | Writes | `.insert(data)`, `.insertMany(data[])`, `.update(data)`, `.delete()` |
252
+ | Execution | `.execute()`, `.first()`, `await builder` (thenable) |
253
+ | Debugging | `.toQuery()`, `.toString()` |
254
+ | Escape hatch | `.apply(fn)` |