@basicbenframework/core 0.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/.github/workflows/publish.yml +35 -0
- package/README.md +588 -0
- package/bin/cli.js +8 -0
- package/create-basicben-app/index.js +205 -0
- package/create-basicben-app/package.json +30 -0
- package/create-basicben-app/template/.env.example +24 -0
- package/create-basicben-app/template/README.md +59 -0
- package/create-basicben-app/template/basicben.config.js +33 -0
- package/create-basicben-app/template/index.html +54 -0
- package/create-basicben-app/template/migrations/001_create_users.js +15 -0
- package/create-basicben-app/template/migrations/002_create_posts.js +18 -0
- package/create-basicben-app/template/public/.gitkeep +0 -0
- package/create-basicben-app/template/seeds/01_users.js +29 -0
- package/create-basicben-app/template/seeds/02_posts.js +43 -0
- package/create-basicben-app/template/src/client/components/Alert.jsx +11 -0
- package/create-basicben-app/template/src/client/components/Avatar.jsx +11 -0
- package/create-basicben-app/template/src/client/components/BackLink.jsx +10 -0
- package/create-basicben-app/template/src/client/components/Button.jsx +19 -0
- package/create-basicben-app/template/src/client/components/Card.jsx +10 -0
- package/create-basicben-app/template/src/client/components/Empty.jsx +6 -0
- package/create-basicben-app/template/src/client/components/Input.jsx +12 -0
- package/create-basicben-app/template/src/client/components/Loading.jsx +6 -0
- package/create-basicben-app/template/src/client/components/Logo.jsx +40 -0
- package/create-basicben-app/template/src/client/components/Nav/DarkModeToggle.jsx +23 -0
- package/create-basicben-app/template/src/client/components/Nav/DesktopNav.jsx +32 -0
- package/create-basicben-app/template/src/client/components/Nav/MobileNav.jsx +107 -0
- package/create-basicben-app/template/src/client/components/NavLink.jsx +10 -0
- package/create-basicben-app/template/src/client/components/PageHeader.jsx +8 -0
- package/create-basicben-app/template/src/client/components/PostCard.jsx +19 -0
- package/create-basicben-app/template/src/client/components/Textarea.jsx +12 -0
- package/create-basicben-app/template/src/client/components/ThemeContext.jsx +5 -0
- package/create-basicben-app/template/src/client/contexts/ToastContext.jsx +94 -0
- package/create-basicben-app/template/src/client/layouts/AppLayout.jsx +60 -0
- package/create-basicben-app/template/src/client/layouts/AuthLayout.jsx +33 -0
- package/create-basicben-app/template/src/client/layouts/DocsLayout.jsx +60 -0
- package/create-basicben-app/template/src/client/layouts/RootLayout.jsx +25 -0
- package/create-basicben-app/template/src/client/pages/Auth.jsx +55 -0
- package/create-basicben-app/template/src/client/pages/Authentication.jsx +236 -0
- package/create-basicben-app/template/src/client/pages/Database.jsx +426 -0
- package/create-basicben-app/template/src/client/pages/Feed.jsx +34 -0
- package/create-basicben-app/template/src/client/pages/FeedPost.jsx +37 -0
- package/create-basicben-app/template/src/client/pages/GettingStarted.jsx +136 -0
- package/create-basicben-app/template/src/client/pages/Home.jsx +206 -0
- package/create-basicben-app/template/src/client/pages/PostForm.jsx +69 -0
- package/create-basicben-app/template/src/client/pages/Posts.jsx +59 -0
- package/create-basicben-app/template/src/client/pages/Profile.jsx +68 -0
- package/create-basicben-app/template/src/client/pages/Routing.jsx +207 -0
- package/create-basicben-app/template/src/client/pages/Testing.jsx +251 -0
- package/create-basicben-app/template/src/client/pages/Validation.jsx +210 -0
- package/create-basicben-app/template/src/controllers/AuthController.js +81 -0
- package/create-basicben-app/template/src/controllers/HomeController.js +17 -0
- package/create-basicben-app/template/src/controllers/PostController.js +86 -0
- package/create-basicben-app/template/src/controllers/ProfileController.js +66 -0
- package/create-basicben-app/template/src/helpers/api.js +24 -0
- package/create-basicben-app/template/src/main.jsx +9 -0
- package/create-basicben-app/template/src/middleware/auth.js +16 -0
- package/create-basicben-app/template/src/models/Post.js +63 -0
- package/create-basicben-app/template/src/models/User.js +42 -0
- package/create-basicben-app/template/src/routes/App.jsx +38 -0
- package/create-basicben-app/template/src/routes/api/auth.js +7 -0
- package/create-basicben-app/template/src/routes/api/posts.js +15 -0
- package/create-basicben-app/template/src/routes/api/profile.js +8 -0
- package/create-basicben-app/template/src/server/index.js +16 -0
- package/create-basicben-app/template/vite.config.js +18 -0
- package/database.sqlite +0 -0
- package/my-test-app/.env.example +24 -0
- package/my-test-app/README.md +59 -0
- package/my-test-app/basicben.config.js +33 -0
- package/my-test-app/database.sqlite-shm +0 -0
- package/my-test-app/database.sqlite-wal +0 -0
- package/my-test-app/index.html +54 -0
- package/my-test-app/migrations/001_create_users.js +15 -0
- package/my-test-app/migrations/002_create_posts.js +18 -0
- package/my-test-app/package-lock.json +2160 -0
- package/my-test-app/package.json +29 -0
- package/my-test-app/public/.gitkeep +0 -0
- package/my-test-app/seeds/01_users.js +29 -0
- package/my-test-app/seeds/02_posts.js +43 -0
- package/my-test-app/src/client/components/Alert.jsx +11 -0
- package/my-test-app/src/client/components/Avatar.jsx +11 -0
- package/my-test-app/src/client/components/BackLink.jsx +10 -0
- package/my-test-app/src/client/components/Button.jsx +19 -0
- package/my-test-app/src/client/components/Card.jsx +10 -0
- package/my-test-app/src/client/components/Empty.jsx +6 -0
- package/my-test-app/src/client/components/Input.jsx +12 -0
- package/my-test-app/src/client/components/Loading.jsx +6 -0
- package/my-test-app/src/client/components/Logo.jsx +40 -0
- package/my-test-app/src/client/components/Nav/DarkModeToggle.jsx +23 -0
- package/my-test-app/src/client/components/Nav/DesktopNav.jsx +32 -0
- package/my-test-app/src/client/components/Nav/MobileNav.jsx +107 -0
- package/my-test-app/src/client/components/NavLink.jsx +10 -0
- package/my-test-app/src/client/components/PageHeader.jsx +8 -0
- package/my-test-app/src/client/components/PostCard.jsx +19 -0
- package/my-test-app/src/client/components/Textarea.jsx +12 -0
- package/my-test-app/src/client/components/ThemeContext.jsx +5 -0
- package/my-test-app/src/client/contexts/AppContext.jsx +13 -0
- package/my-test-app/src/client/contexts/ToastContext.jsx +94 -0
- package/my-test-app/src/client/layouts/AppLayout.jsx +60 -0
- package/my-test-app/src/client/layouts/AuthLayout.jsx +33 -0
- package/my-test-app/src/client/layouts/DocsLayout.jsx +60 -0
- package/my-test-app/src/client/layouts/RootLayout.jsx +25 -0
- package/my-test-app/src/client/pages/Auth.jsx +55 -0
- package/my-test-app/src/client/pages/Authentication.jsx +236 -0
- package/my-test-app/src/client/pages/Database.jsx +426 -0
- package/my-test-app/src/client/pages/Feed.jsx +34 -0
- package/my-test-app/src/client/pages/FeedPost.jsx +37 -0
- package/my-test-app/src/client/pages/GettingStarted.jsx +136 -0
- package/my-test-app/src/client/pages/Home.jsx +206 -0
- package/my-test-app/src/client/pages/PostForm.jsx +69 -0
- package/my-test-app/src/client/pages/Posts.jsx +59 -0
- package/my-test-app/src/client/pages/Profile.jsx +68 -0
- package/my-test-app/src/client/pages/Routing.jsx +207 -0
- package/my-test-app/src/client/pages/Testing.jsx +251 -0
- package/my-test-app/src/client/pages/Validation.jsx +210 -0
- package/my-test-app/src/controllers/AuthController.js +81 -0
- package/my-test-app/src/controllers/HomeController.js +17 -0
- package/my-test-app/src/controllers/PostController.js +86 -0
- package/my-test-app/src/controllers/ProfileController.js +66 -0
- package/my-test-app/src/helpers/api.js +24 -0
- package/my-test-app/src/main.jsx +9 -0
- package/my-test-app/src/middleware/auth.js +16 -0
- package/my-test-app/src/models/Post.js +63 -0
- package/my-test-app/src/models/User.js +42 -0
- package/my-test-app/src/routes/App.jsx +38 -0
- package/my-test-app/src/routes/api/auth.js +7 -0
- package/my-test-app/src/routes/api/posts.js +15 -0
- package/my-test-app/src/routes/api/profile.js +8 -0
- package/my-test-app/src/server/index.js +16 -0
- package/my-test-app/vite.config.js +18 -0
- package/package.json +61 -0
- package/scripts/test-app.sh +59 -0
- package/src/auth/jwt.js +195 -0
- package/src/auth/password.js +132 -0
- package/src/cli/colors.js +31 -0
- package/src/cli/dispatcher.js +168 -0
- package/src/cli/parser.js +91 -0
- package/src/client/context.js +4 -0
- package/src/client/hooks.js +50 -0
- package/src/client/index.js +3 -0
- package/src/client/router.js +184 -0
- package/src/commands/build.js +155 -0
- package/src/commands/dev.js +206 -0
- package/src/commands/help.js +84 -0
- package/src/commands/make-controller.js +36 -0
- package/src/commands/make-middleware.js +44 -0
- package/src/commands/make-migration.js +51 -0
- package/src/commands/make-model.js +38 -0
- package/src/commands/make-route.js +36 -0
- package/src/commands/make-seed.js +38 -0
- package/src/commands/migrate-fresh.js +32 -0
- package/src/commands/migrate-rollback.js +30 -0
- package/src/commands/migrate-status.js +41 -0
- package/src/commands/migrate.js +30 -0
- package/src/commands/seed.js +47 -0
- package/src/commands/start.js +69 -0
- package/src/commands/test.js +46 -0
- package/src/db/Grammar.js +125 -0
- package/src/db/QueryBuilder.js +476 -0
- package/src/db/adapters/neon.js +170 -0
- package/src/db/adapters/planetscale.js +146 -0
- package/src/db/adapters/postgres.js +166 -0
- package/src/db/adapters/sqlite.js +125 -0
- package/src/db/adapters/turso.js +165 -0
- package/src/db/index.js +156 -0
- package/src/db/migrator.js +250 -0
- package/src/db/seeder.js +124 -0
- package/src/index.js +12 -0
- package/src/scaffolding/index.js +152 -0
- package/src/server/body-parser.js +159 -0
- package/src/server/cors.js +63 -0
- package/src/server/default-entry.js +13 -0
- package/src/server/http.js +221 -0
- package/src/server/index.js +168 -0
- package/src/server/loader.js +128 -0
- package/src/server/router.js +281 -0
- package/src/server/static.js +139 -0
- package/src/validation/index.js +436 -0
- package/src/vite/config.js +49 -0
- package/stubs/controller.stub +48 -0
- package/stubs/middleware-auth.stub +29 -0
- package/stubs/middleware.stub +9 -0
- package/stubs/migration.stub +17 -0
- package/stubs/model.stub +77 -0
- package/stubs/route.stub +13 -0
- package/stubs/seed.stub +16 -0
- package/stubs/vite.config.stub +18 -0
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query Builder - fluent API for building safe SQL queries.
|
|
3
|
+
* Provides mass assignment protection and identifier escaping.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Grammar } from './Grammar.js'
|
|
7
|
+
|
|
8
|
+
export class QueryBuilder {
|
|
9
|
+
/**
|
|
10
|
+
* Create a new QueryBuilder instance.
|
|
11
|
+
*
|
|
12
|
+
* @param {Object} db - Database adapter instance
|
|
13
|
+
* @param {string} table - Table name
|
|
14
|
+
* @param {string} driver - Database driver ('sqlite' or 'postgres')
|
|
15
|
+
*/
|
|
16
|
+
constructor(db, table, driver = 'sqlite') {
|
|
17
|
+
this.db = db
|
|
18
|
+
this.grammar = new Grammar(driver)
|
|
19
|
+
|
|
20
|
+
// Validate and store table name
|
|
21
|
+
this.grammar.validateId(table)
|
|
22
|
+
this.table = table
|
|
23
|
+
|
|
24
|
+
// Mass assignment protection
|
|
25
|
+
this._fillable = null // Whitelist (null = allow all except guarded)
|
|
26
|
+
this._guarded = ['id'] // Blacklist (always protected by default)
|
|
27
|
+
|
|
28
|
+
// Query state
|
|
29
|
+
this._select = ['*']
|
|
30
|
+
this._wheres = []
|
|
31
|
+
this._orderBy = []
|
|
32
|
+
this._limit = null
|
|
33
|
+
this._offset = null
|
|
34
|
+
this._params = []
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Set fillable columns (whitelist).
|
|
39
|
+
* Only these columns will be allowed in insert/update operations.
|
|
40
|
+
*
|
|
41
|
+
* @param {...string} columns - Column names to allow
|
|
42
|
+
* @returns {QueryBuilder}
|
|
43
|
+
*/
|
|
44
|
+
only(...columns) {
|
|
45
|
+
this._fillable = columns.flat()
|
|
46
|
+
return this
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Set guarded columns (blacklist).
|
|
51
|
+
* These columns will be excluded from insert/update operations.
|
|
52
|
+
*
|
|
53
|
+
* @param {...string} columns - Column names to exclude
|
|
54
|
+
* @returns {QueryBuilder}
|
|
55
|
+
*/
|
|
56
|
+
except(...columns) {
|
|
57
|
+
this._guarded = columns.flat()
|
|
58
|
+
return this
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Filter data object to only allowed columns.
|
|
63
|
+
* Validates identifiers and applies fillable/guarded rules.
|
|
64
|
+
*
|
|
65
|
+
* @param {Object} data - Data object to filter
|
|
66
|
+
* @returns {Object} Filtered data
|
|
67
|
+
* @throws {Error} If an invalid identifier is found
|
|
68
|
+
*/
|
|
69
|
+
filterData(data) {
|
|
70
|
+
if (!data || typeof data !== 'object') {
|
|
71
|
+
throw new Error('Data must be an object')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const filtered = {}
|
|
75
|
+
|
|
76
|
+
for (const [key, value] of Object.entries(data)) {
|
|
77
|
+
// Validate identifier (throws if invalid)
|
|
78
|
+
this.grammar.validateId(key)
|
|
79
|
+
|
|
80
|
+
// Check fillable whitelist
|
|
81
|
+
if (this._fillable !== null && !this._fillable.includes(key)) {
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Check guarded blacklist
|
|
86
|
+
if (this._guarded.includes(key)) {
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
filtered[key] = value
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return filtered
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Set columns to select.
|
|
98
|
+
*
|
|
99
|
+
* @param {...string} columns - Column names
|
|
100
|
+
* @returns {QueryBuilder}
|
|
101
|
+
*/
|
|
102
|
+
select(...columns) {
|
|
103
|
+
const cols = columns.flat()
|
|
104
|
+
|
|
105
|
+
this._select = cols.map(col => {
|
|
106
|
+
if (col === '*') return '*'
|
|
107
|
+
return this.grammar.escapeId(col)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
return this
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Add a WHERE clause.
|
|
115
|
+
*
|
|
116
|
+
* @param {string} column - Column name
|
|
117
|
+
* @param {string} [operator='='] - Comparison operator
|
|
118
|
+
* @param {*} value - Value to compare
|
|
119
|
+
* @returns {QueryBuilder}
|
|
120
|
+
*/
|
|
121
|
+
where(column, operator, value) {
|
|
122
|
+
// Support where(column, value) shorthand
|
|
123
|
+
if (value === undefined) {
|
|
124
|
+
value = operator
|
|
125
|
+
operator = '='
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
this.grammar.validateId(column)
|
|
129
|
+
const validOp = this.grammar.validateOperator(operator)
|
|
130
|
+
|
|
131
|
+
this._wheres.push({
|
|
132
|
+
column,
|
|
133
|
+
operator: validOp,
|
|
134
|
+
paramIndex: this._params.length
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
this._params.push(value)
|
|
138
|
+
|
|
139
|
+
return this
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Add a WHERE NULL clause.
|
|
144
|
+
*
|
|
145
|
+
* @param {string} column - Column name
|
|
146
|
+
* @returns {QueryBuilder}
|
|
147
|
+
*/
|
|
148
|
+
whereNull(column) {
|
|
149
|
+
this.grammar.validateId(column)
|
|
150
|
+
|
|
151
|
+
this._wheres.push({
|
|
152
|
+
column,
|
|
153
|
+
operator: 'IS',
|
|
154
|
+
value: 'NULL',
|
|
155
|
+
raw: true
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
return this
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Add a WHERE NOT NULL clause.
|
|
163
|
+
*
|
|
164
|
+
* @param {string} column - Column name
|
|
165
|
+
* @returns {QueryBuilder}
|
|
166
|
+
*/
|
|
167
|
+
whereNotNull(column) {
|
|
168
|
+
this.grammar.validateId(column)
|
|
169
|
+
|
|
170
|
+
this._wheres.push({
|
|
171
|
+
column,
|
|
172
|
+
operator: 'IS NOT',
|
|
173
|
+
value: 'NULL',
|
|
174
|
+
raw: true
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
return this
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Add ORDER BY clause.
|
|
182
|
+
*
|
|
183
|
+
* @param {string} column - Column name
|
|
184
|
+
* @param {string} [direction='ASC'] - Sort direction
|
|
185
|
+
* @returns {QueryBuilder}
|
|
186
|
+
*/
|
|
187
|
+
orderBy(column, direction = 'ASC') {
|
|
188
|
+
this.grammar.validateId(column)
|
|
189
|
+
const dir = this.grammar.validateDirection(direction)
|
|
190
|
+
|
|
191
|
+
this._orderBy.push({ column, direction: dir })
|
|
192
|
+
|
|
193
|
+
return this
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Set LIMIT.
|
|
198
|
+
*
|
|
199
|
+
* @param {number} n - Number of rows
|
|
200
|
+
* @returns {QueryBuilder}
|
|
201
|
+
*/
|
|
202
|
+
limit(n) {
|
|
203
|
+
const num = parseInt(n, 10)
|
|
204
|
+
if (isNaN(num) || num < 0) {
|
|
205
|
+
throw new Error('Limit must be a non-negative integer')
|
|
206
|
+
}
|
|
207
|
+
this._limit = num
|
|
208
|
+
return this
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Set OFFSET.
|
|
213
|
+
*
|
|
214
|
+
* @param {number} n - Number of rows to skip
|
|
215
|
+
* @returns {QueryBuilder}
|
|
216
|
+
*/
|
|
217
|
+
offset(n) {
|
|
218
|
+
const num = parseInt(n, 10)
|
|
219
|
+
if (isNaN(num) || num < 0) {
|
|
220
|
+
throw new Error('Offset must be a non-negative integer')
|
|
221
|
+
}
|
|
222
|
+
this._offset = num
|
|
223
|
+
return this
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Build the WHERE clause SQL.
|
|
228
|
+
*
|
|
229
|
+
* @returns {{ sql: string, startIndex: number }}
|
|
230
|
+
*/
|
|
231
|
+
_buildWhereClause(startIndex = 0) {
|
|
232
|
+
if (this._wheres.length === 0) {
|
|
233
|
+
return { sql: '', startIndex }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const clauses = this._wheres.map((w, i) => {
|
|
237
|
+
const col = this.grammar.escapeId(w.column)
|
|
238
|
+
|
|
239
|
+
if (w.raw) {
|
|
240
|
+
return `${col} ${w.operator} ${w.value}`
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const placeholder = this.grammar.placeholder(startIndex + i)
|
|
244
|
+
return `${col} ${w.operator} ${placeholder}`
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
sql: ` WHERE ${clauses.join(' AND ')}`,
|
|
249
|
+
startIndex: startIndex + this._wheres.filter(w => !w.raw).length
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Build SELECT query SQL.
|
|
255
|
+
*
|
|
256
|
+
* @returns {string}
|
|
257
|
+
*/
|
|
258
|
+
toSql() {
|
|
259
|
+
const table = this.grammar.escapeId(this.table)
|
|
260
|
+
let sql = `SELECT ${this._select.join(', ')} FROM ${table}`
|
|
261
|
+
|
|
262
|
+
// WHERE
|
|
263
|
+
const { sql: whereClause } = this._buildWhereClause(0)
|
|
264
|
+
sql += whereClause
|
|
265
|
+
|
|
266
|
+
// ORDER BY
|
|
267
|
+
if (this._orderBy.length > 0) {
|
|
268
|
+
const orders = this._orderBy.map(o =>
|
|
269
|
+
`${this.grammar.escapeId(o.column)} ${o.direction}`
|
|
270
|
+
)
|
|
271
|
+
sql += ` ORDER BY ${orders.join(', ')}`
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// LIMIT
|
|
275
|
+
if (this._limit !== null) {
|
|
276
|
+
sql += ` LIMIT ${this._limit}`
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// OFFSET
|
|
280
|
+
if (this._offset !== null) {
|
|
281
|
+
sql += ` OFFSET ${this._offset}`
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return sql
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Get the parameters for the current query.
|
|
289
|
+
*
|
|
290
|
+
* @returns {Array}
|
|
291
|
+
*/
|
|
292
|
+
getParams() {
|
|
293
|
+
return this._wheres.filter(w => !w.raw).map(w => this._params[w.paramIndex])
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Execute SELECT and return all rows.
|
|
298
|
+
*
|
|
299
|
+
* @returns {Promise<Array>}
|
|
300
|
+
*/
|
|
301
|
+
async get() {
|
|
302
|
+
const sql = this.toSql()
|
|
303
|
+
const params = this.getParams()
|
|
304
|
+
return this.db.all(sql, params)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Execute SELECT and return first row.
|
|
309
|
+
*
|
|
310
|
+
* @returns {Promise<Object|undefined>}
|
|
311
|
+
*/
|
|
312
|
+
async first() {
|
|
313
|
+
this._limit = 1
|
|
314
|
+
const sql = this.toSql()
|
|
315
|
+
const params = this.getParams()
|
|
316
|
+
return this.db.get(sql, params)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Find a record by ID.
|
|
321
|
+
*
|
|
322
|
+
* @param {number|string} id - The ID to find
|
|
323
|
+
* @returns {Promise<Object|undefined>}
|
|
324
|
+
*/
|
|
325
|
+
async find(id) {
|
|
326
|
+
return this.where('id', id).first()
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Execute INSERT.
|
|
331
|
+
*
|
|
332
|
+
* @param {Object} data - Data to insert
|
|
333
|
+
* @returns {Promise<{ lastInsertRowid: number, changes: number }>}
|
|
334
|
+
*/
|
|
335
|
+
async insert(data) {
|
|
336
|
+
const filtered = this.filterData(data)
|
|
337
|
+
const keys = Object.keys(filtered)
|
|
338
|
+
const values = Object.values(filtered)
|
|
339
|
+
|
|
340
|
+
if (keys.length === 0) {
|
|
341
|
+
throw new Error('No valid columns to insert')
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const table = this.grammar.escapeId(this.table)
|
|
345
|
+
const columns = keys.map(k => this.grammar.escapeId(k)).join(', ')
|
|
346
|
+
const placeholders = keys.map((_, i) => this.grammar.placeholder(i)).join(', ')
|
|
347
|
+
|
|
348
|
+
const sql = `INSERT INTO ${table} (${columns}) VALUES (${placeholders})`
|
|
349
|
+
|
|
350
|
+
return this.db.run(sql, values)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Execute UPDATE.
|
|
355
|
+
*
|
|
356
|
+
* @param {Object} data - Data to update
|
|
357
|
+
* @returns {Promise<{ lastInsertRowid: number, changes: number }>}
|
|
358
|
+
*/
|
|
359
|
+
async update(data) {
|
|
360
|
+
const filtered = this.filterData(data)
|
|
361
|
+
const keys = Object.keys(filtered)
|
|
362
|
+
const values = Object.values(filtered)
|
|
363
|
+
|
|
364
|
+
if (keys.length === 0) {
|
|
365
|
+
throw new Error('No valid columns to update')
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const table = this.grammar.escapeId(this.table)
|
|
369
|
+
const whereParams = this.getParams()
|
|
370
|
+
|
|
371
|
+
// Build SET clause with correct placeholder indices
|
|
372
|
+
const setClause = keys.map((k, i) => {
|
|
373
|
+
const placeholder = this.grammar.placeholder(i)
|
|
374
|
+
return `${this.grammar.escapeId(k)} = ${placeholder}`
|
|
375
|
+
}).join(', ')
|
|
376
|
+
|
|
377
|
+
let sql = `UPDATE ${table} SET ${setClause}`
|
|
378
|
+
|
|
379
|
+
// WHERE clause with offset indices
|
|
380
|
+
if (this._wheres.length > 0) {
|
|
381
|
+
const clauses = this._wheres.map((w, i) => {
|
|
382
|
+
if (w.raw) {
|
|
383
|
+
return `${this.grammar.escapeId(w.column)} ${w.operator} ${w.value}`
|
|
384
|
+
}
|
|
385
|
+
const placeholder = this.grammar.placeholder(keys.length + i)
|
|
386
|
+
return `${this.grammar.escapeId(w.column)} ${w.operator} ${placeholder}`
|
|
387
|
+
})
|
|
388
|
+
sql += ` WHERE ${clauses.join(' AND ')}`
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return this.db.run(sql, [...values, ...whereParams])
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Execute DELETE.
|
|
396
|
+
*
|
|
397
|
+
* @returns {Promise<{ lastInsertRowid: number, changes: number }>}
|
|
398
|
+
*/
|
|
399
|
+
async delete() {
|
|
400
|
+
const table = this.grammar.escapeId(this.table)
|
|
401
|
+
let sql = `DELETE FROM ${table}`
|
|
402
|
+
|
|
403
|
+
const { sql: whereClause } = this._buildWhereClause(0)
|
|
404
|
+
sql += whereClause
|
|
405
|
+
|
|
406
|
+
return this.db.run(sql, this.getParams())
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Get COUNT of matching rows.
|
|
411
|
+
*
|
|
412
|
+
* @returns {Promise<number>}
|
|
413
|
+
*/
|
|
414
|
+
async count() {
|
|
415
|
+
const table = this.grammar.escapeId(this.table)
|
|
416
|
+
let sql = `SELECT COUNT(*) as count FROM ${table}`
|
|
417
|
+
|
|
418
|
+
const { sql: whereClause } = this._buildWhereClause(0)
|
|
419
|
+
sql += whereClause
|
|
420
|
+
|
|
421
|
+
const result = await this.db.get(sql, this.getParams())
|
|
422
|
+
return result?.count || 0
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Check if any matching rows exist.
|
|
427
|
+
*
|
|
428
|
+
* @returns {Promise<boolean>}
|
|
429
|
+
*/
|
|
430
|
+
async exists() {
|
|
431
|
+
const count = await this.count()
|
|
432
|
+
return count > 0
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Paginate results.
|
|
437
|
+
*
|
|
438
|
+
* @param {number} page - Page number (1-indexed)
|
|
439
|
+
* @param {number} perPage - Items per page
|
|
440
|
+
* @returns {Promise<{ data: Array, total: number, page: number, perPage: number, totalPages: number }>}
|
|
441
|
+
*/
|
|
442
|
+
async paginate(page = 1, perPage = 15) {
|
|
443
|
+
const pageNum = Math.max(1, parseInt(page, 10))
|
|
444
|
+
const perPageNum = Math.max(1, parseInt(perPage, 10))
|
|
445
|
+
|
|
446
|
+
// Get total count (clone query state)
|
|
447
|
+
const total = await this.count()
|
|
448
|
+
|
|
449
|
+
// Get paginated data
|
|
450
|
+
this._limit = perPageNum
|
|
451
|
+
this._offset = (pageNum - 1) * perPageNum
|
|
452
|
+
|
|
453
|
+
const data = await this.get()
|
|
454
|
+
|
|
455
|
+
return {
|
|
456
|
+
data,
|
|
457
|
+
total,
|
|
458
|
+
page: pageNum,
|
|
459
|
+
perPage: perPageNum,
|
|
460
|
+
totalPages: Math.ceil(total / perPageNum)
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Create a new QueryBuilder instance.
|
|
467
|
+
* Factory function for cleaner syntax.
|
|
468
|
+
*
|
|
469
|
+
* @param {Object} db - Database adapter
|
|
470
|
+
* @param {string} table - Table name
|
|
471
|
+
* @param {string} driver - Database driver
|
|
472
|
+
* @returns {QueryBuilder}
|
|
473
|
+
*/
|
|
474
|
+
export function query(db, table, driver = 'sqlite') {
|
|
475
|
+
return new QueryBuilder(db, table, driver)
|
|
476
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Neon adapter using @neondatabase/serverless.
|
|
3
|
+
* Provides serverless Postgres with WebSocket support.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
let neon = null
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Load @neondatabase/serverless dynamically
|
|
10
|
+
*/
|
|
11
|
+
async function loadDriver() {
|
|
12
|
+
if (neon) return neon
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
neon = await import('@neondatabase/serverless')
|
|
16
|
+
return neon
|
|
17
|
+
} catch {
|
|
18
|
+
throw new Error(
|
|
19
|
+
'@neondatabase/serverless is required for Neon support.\n' +
|
|
20
|
+
'Install it with: npm install @neondatabase/serverless'
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Create Neon adapter
|
|
27
|
+
*
|
|
28
|
+
* @param {string} url - Neon connection string (postgres://...)
|
|
29
|
+
* @param {Object} options - Additional options
|
|
30
|
+
*/
|
|
31
|
+
export async function createNeonAdapter(url, options = {}) {
|
|
32
|
+
const { neon: createNeon, neonConfig } = await loadDriver()
|
|
33
|
+
|
|
34
|
+
// Configure for serverless environment
|
|
35
|
+
if (options.fetchConnectionCache !== undefined) {
|
|
36
|
+
neonConfig.fetchConnectionCache = options.fetchConnectionCache
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const sql = createNeon(url)
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
/**
|
|
43
|
+
* Driver name for query builder
|
|
44
|
+
*/
|
|
45
|
+
driver: 'neon',
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Run INSERT/UPDATE/DELETE
|
|
49
|
+
*/
|
|
50
|
+
async run(sqlStr, params = []) {
|
|
51
|
+
const normalizedParams = normalizeParams(params)
|
|
52
|
+
const result = await sql(sqlStr, normalizedParams)
|
|
53
|
+
|
|
54
|
+
// Try to get lastInsertRowid from RETURNING clause
|
|
55
|
+
let lastInsertRowid = null
|
|
56
|
+
if (result[0] && result[0].id !== undefined) {
|
|
57
|
+
lastInsertRowid = result[0].id
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
lastInsertRowid,
|
|
62
|
+
changes: result.count || 0
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get single row
|
|
68
|
+
*/
|
|
69
|
+
async get(sqlStr, params = []) {
|
|
70
|
+
const normalizedParams = normalizeParams(params)
|
|
71
|
+
const result = await sql(sqlStr, normalizedParams)
|
|
72
|
+
return result[0] || undefined
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Get all rows
|
|
77
|
+
*/
|
|
78
|
+
async all(sqlStr, params = []) {
|
|
79
|
+
const normalizedParams = normalizeParams(params)
|
|
80
|
+
return sql(sqlStr, normalizedParams)
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Execute raw SQL
|
|
85
|
+
*/
|
|
86
|
+
async exec(sqlStr) {
|
|
87
|
+
// Split by semicolon and execute each statement
|
|
88
|
+
const statements = sqlStr
|
|
89
|
+
.split(';')
|
|
90
|
+
.map(s => s.trim())
|
|
91
|
+
.filter(s => s.length > 0)
|
|
92
|
+
|
|
93
|
+
for (const statement of statements) {
|
|
94
|
+
await sql(statement)
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Run function in transaction
|
|
100
|
+
*/
|
|
101
|
+
async transaction(fn) {
|
|
102
|
+
const { Pool } = await loadDriver()
|
|
103
|
+
const pool = new Pool({ connectionString: url })
|
|
104
|
+
const client = await pool.connect()
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
await client.query('BEGIN')
|
|
108
|
+
|
|
109
|
+
const txAdapter = {
|
|
110
|
+
async run(sqlStr, params = []) {
|
|
111
|
+
const result = await client.query(sqlStr, normalizeParams(params))
|
|
112
|
+
let lastInsertRowid = null
|
|
113
|
+
if (result.rows && result.rows[0] && result.rows[0].id !== undefined) {
|
|
114
|
+
lastInsertRowid = result.rows[0].id
|
|
115
|
+
}
|
|
116
|
+
return { lastInsertRowid, changes: result.rowCount }
|
|
117
|
+
},
|
|
118
|
+
async get(sqlStr, params = []) {
|
|
119
|
+
const result = await client.query(sqlStr, normalizeParams(params))
|
|
120
|
+
return result.rows[0]
|
|
121
|
+
},
|
|
122
|
+
async all(sqlStr, params = []) {
|
|
123
|
+
const result = await client.query(sqlStr, normalizeParams(params))
|
|
124
|
+
return result.rows
|
|
125
|
+
},
|
|
126
|
+
async exec(sqlStr) {
|
|
127
|
+
await client.query(sqlStr)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const result = await fn(txAdapter)
|
|
132
|
+
await client.query('COMMIT')
|
|
133
|
+
return result
|
|
134
|
+
} catch (err) {
|
|
135
|
+
await client.query('ROLLBACK')
|
|
136
|
+
throw err
|
|
137
|
+
} finally {
|
|
138
|
+
client.release()
|
|
139
|
+
await pool.end()
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Close connection (no-op for serverless)
|
|
145
|
+
*/
|
|
146
|
+
async close() {
|
|
147
|
+
// Serverless connections are stateless
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Get underlying sql function
|
|
152
|
+
*/
|
|
153
|
+
get raw() {
|
|
154
|
+
return sql
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Normalize params to array format
|
|
161
|
+
*/
|
|
162
|
+
function normalizeParams(params) {
|
|
163
|
+
if (Array.isArray(params)) {
|
|
164
|
+
return params
|
|
165
|
+
}
|
|
166
|
+
if (params === undefined || params === null) {
|
|
167
|
+
return []
|
|
168
|
+
}
|
|
169
|
+
return [params]
|
|
170
|
+
}
|