@rip-lang/db 1.1.7 → 1.2.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 +172 -0
- package/client.rip +289 -0
- package/db.rip +3 -3
- package/package.json +8 -3
package/README.md
CHANGED
|
@@ -108,6 +108,178 @@ Response format:
|
|
|
108
108
|
| `/tables` | GET | List all tables |
|
|
109
109
|
| `/schema/:table` | GET | Table schema |
|
|
110
110
|
|
|
111
|
+
## Database Client
|
|
112
|
+
|
|
113
|
+
Rip DB includes an ActiveRecord-style database client for use in Rip
|
|
114
|
+
applications. Import it from `@rip-lang/db/client` — it talks to a running
|
|
115
|
+
`rip-db` server over HTTP with parameterized queries.
|
|
116
|
+
|
|
117
|
+
```rip
|
|
118
|
+
import { connect, query, findOne, findAll, Model } from '@rip-lang/db/client'
|
|
119
|
+
|
|
120
|
+
connect 'http://localhost:4213' # optional — defaults to DB_URL env or localhost:4213
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Low-Level Queries
|
|
124
|
+
|
|
125
|
+
Every query uses parameterized placeholders (`$1`, `$2`, ...) to prevent SQL
|
|
126
|
+
injection. Results are automatically materialized into plain objects.
|
|
127
|
+
|
|
128
|
+
```rip
|
|
129
|
+
# Raw result (meta + data arrays)
|
|
130
|
+
result = query! "SELECT * FROM users WHERE active = $1", [true]
|
|
131
|
+
|
|
132
|
+
# Single object or null
|
|
133
|
+
user = findOne! "SELECT * FROM users WHERE id = $1", [42]
|
|
134
|
+
|
|
135
|
+
# Array of objects
|
|
136
|
+
users = findAll! "SELECT * FROM users WHERE role = $1", ['admin']
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Model Factory
|
|
140
|
+
|
|
141
|
+
Create a Model for any table to get a full set of CRUD operations and a
|
|
142
|
+
chainable query builder.
|
|
143
|
+
|
|
144
|
+
```rip
|
|
145
|
+
User = Model 'users'
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
#### Find & Count
|
|
149
|
+
|
|
150
|
+
```rip
|
|
151
|
+
user = User.find! 42 # SELECT * FROM "users" WHERE id = $1
|
|
152
|
+
count = User.count! # SELECT COUNT(*) ...
|
|
153
|
+
users = User.all! # SELECT * FROM "users"
|
|
154
|
+
users = User.all! 10 # SELECT * FROM "users" LIMIT 10
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
#### Chainable Queries
|
|
158
|
+
|
|
159
|
+
All query methods return a new builder — chains are immutable and reusable.
|
|
160
|
+
|
|
161
|
+
```rip
|
|
162
|
+
User.where(active: true).order('name').limit(10).all!
|
|
163
|
+
User.where(active: true).offset(20).limit(10).all!
|
|
164
|
+
User.where('age > $1', [21]).all!
|
|
165
|
+
User.select('id, name').where(role: 'admin').first!
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
#### Where, Or, Not
|
|
169
|
+
|
|
170
|
+
```rip
|
|
171
|
+
# Object-style (null-aware — generates IS NULL / IS NOT NULL)
|
|
172
|
+
User.where(role: 'admin').all!
|
|
173
|
+
User.where(deleted_at: null).all! # WHERE "deleted_at" IS NULL
|
|
174
|
+
|
|
175
|
+
# String-style with params
|
|
176
|
+
User.where('age > $1', [21]).all!
|
|
177
|
+
|
|
178
|
+
# OR conditions
|
|
179
|
+
User.where(active: true).or(role: 'admin').all!
|
|
180
|
+
User.where(active: true).or('role = $1', ['admin']).all!
|
|
181
|
+
|
|
182
|
+
# NOT conditions
|
|
183
|
+
User.where(active: true).not(role: 'banned').all!
|
|
184
|
+
User.not(deleted_at: null).all! # WHERE "deleted_at" IS NOT NULL
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
#### Group & Having
|
|
188
|
+
|
|
189
|
+
```rip
|
|
190
|
+
User.group('role').select('role, count(*) as n').all!
|
|
191
|
+
User.group('role').having('count(*) > $1', [5]).select('role, count(*) as n').all!
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
#### Insert, Update, Upsert, Destroy
|
|
195
|
+
|
|
196
|
+
All mutations return the affected row(s) via `RETURNING *`.
|
|
197
|
+
|
|
198
|
+
```rip
|
|
199
|
+
# Insert — returns the new record
|
|
200
|
+
user = User.insert! { first_name: 'Alice', email: 'alice@example.com' }
|
|
201
|
+
|
|
202
|
+
# Update by id — returns the updated record
|
|
203
|
+
user = User.update! 42, { email: 'newemail@example.com' }
|
|
204
|
+
|
|
205
|
+
# Upsert — insert or update on conflict
|
|
206
|
+
user = User.upsert! { email: 'alice@example.com', name: 'Alice' }, on: 'email'
|
|
207
|
+
|
|
208
|
+
# Destroy by id — returns the deleted record
|
|
209
|
+
user = User.destroy! 42
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
#### Bulk Update & Destroy via Query Builder
|
|
213
|
+
|
|
214
|
+
Chain `.where()` with `.update!` or `.destroy!` for bulk operations.
|
|
215
|
+
|
|
216
|
+
```rip
|
|
217
|
+
# Update all matching rows
|
|
218
|
+
User.where(role: 'guest').update! { role: 'member' }
|
|
219
|
+
|
|
220
|
+
# Delete all matching rows
|
|
221
|
+
User.where(active: false).destroy!
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
#### Raw Parameterized Queries
|
|
225
|
+
|
|
226
|
+
For anything the builder doesn't cover, drop down to raw SQL.
|
|
227
|
+
|
|
228
|
+
```rip
|
|
229
|
+
users = User.query! "SELECT * FROM users WHERE dob > $1", ['2000-01-01']
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
#### Cross-Database Queries
|
|
233
|
+
|
|
234
|
+
Pass a database name to query attached DuckDB databases.
|
|
235
|
+
|
|
236
|
+
```rip
|
|
237
|
+
Archive = Model 'orders', 'archive_db'
|
|
238
|
+
order = Archive.find! 99 # SELECT * FROM "archive_db"."orders" WHERE id = $1
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### Query Builder Reference
|
|
242
|
+
|
|
243
|
+
| Method | Description |
|
|
244
|
+
|--------|-------------|
|
|
245
|
+
| `.where(obj)` | AND conditions from object (`null` becomes `IS NULL`) |
|
|
246
|
+
| `.where(sql, params)` | AND with raw SQL fragment |
|
|
247
|
+
| `.or(obj)` | OR conditions from object |
|
|
248
|
+
| `.or(sql, params)` | OR with raw SQL fragment |
|
|
249
|
+
| `.not(obj)` | AND NOT conditions (`null` becomes `IS NOT NULL`) |
|
|
250
|
+
| `.not(sql, params)` | AND NOT with raw SQL fragment |
|
|
251
|
+
| `.select(cols)` | Columns to select (string or array) |
|
|
252
|
+
| `.order(expr)` | ORDER BY expression |
|
|
253
|
+
| `.group(expr)` | GROUP BY expression |
|
|
254
|
+
| `.having(sql, params)` | HAVING clause with params |
|
|
255
|
+
| `.limit(n)` | LIMIT |
|
|
256
|
+
| `.offset(n)` | OFFSET |
|
|
257
|
+
| `.all!` | Execute, return array of objects |
|
|
258
|
+
| `.first!` | Execute with LIMIT 1, return object or null |
|
|
259
|
+
| `.count!` | Execute COUNT(*), return number |
|
|
260
|
+
| `.update!(data)` | Bulk UPDATE matching rows, return array |
|
|
261
|
+
| `.destroy!` | Bulk DELETE matching rows, return array |
|
|
262
|
+
|
|
263
|
+
### Model Reference
|
|
264
|
+
|
|
265
|
+
| Method | Description |
|
|
266
|
+
|--------|-------------|
|
|
267
|
+
| `Model.find!(id)` | Find by primary key |
|
|
268
|
+
| `Model.all!(limit?)` | All rows, optional limit |
|
|
269
|
+
| `Model.count!` | Count all rows |
|
|
270
|
+
| `Model.where(...)` | Start a query chain |
|
|
271
|
+
| `Model.or(...)` | Start a chain with OR |
|
|
272
|
+
| `Model.not(...)` | Start a chain with NOT |
|
|
273
|
+
| `Model.select(...)` | Start a chain with SELECT |
|
|
274
|
+
| `Model.order(...)` | Start a chain with ORDER BY |
|
|
275
|
+
| `Model.group(...)` | Start a chain with GROUP BY |
|
|
276
|
+
| `Model.limit(n)` | Start a chain with LIMIT |
|
|
277
|
+
| `Model.insert!(data)` | Insert and return new row |
|
|
278
|
+
| `Model.update!(id, data)` | Update by id and return row |
|
|
279
|
+
| `Model.upsert!(data, on:)` | Insert or update on conflict |
|
|
280
|
+
| `Model.destroy!(id)` | Delete by id and return row |
|
|
281
|
+
| `Model.query!(sql, params)` | Raw parameterized query |
|
|
282
|
+
|
|
111
283
|
## DuckDB UI
|
|
112
284
|
|
|
113
285
|
The official DuckDB UI is available at the root URL. It provides:
|
package/client.rip
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# @rip-lang/db/client — Database client with chainable Model factory
|
|
2
|
+
#
|
|
3
|
+
# Connects to a rip-db server over HTTP. Provides parameterized queries
|
|
4
|
+
# and an ActiveRecord-style Model for common CRUD operations.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# import { query, findOne, findAll, Model } from '@rip-lang/db/client'
|
|
8
|
+
#
|
|
9
|
+
# # Low-level
|
|
10
|
+
# result = query! "SELECT * FROM users WHERE id = $1", [42]
|
|
11
|
+
# user = findOne! "SELECT * FROM users WHERE id = $1", [42]
|
|
12
|
+
# users = findAll! "SELECT * FROM users WHERE active = $1", [true]
|
|
13
|
+
#
|
|
14
|
+
# # Model
|
|
15
|
+
# User = Model 'users'
|
|
16
|
+
# user = User.find! 42
|
|
17
|
+
# users = User.where(active: true).order('name').limit(10).all!
|
|
18
|
+
# user = User.insert! { name: 'Alice', email: 'alice@example.com' }
|
|
19
|
+
# user = User.upsert! { email: 'alice@example.com', name: 'Alice' }, on: 'email'
|
|
20
|
+
# user = User.update! 42, { name: 'Bob' }
|
|
21
|
+
# User.destroy! 42
|
|
22
|
+
# users = User.where(active: true).or('role = $1', ['admin']).all!
|
|
23
|
+
# users = User.where(active: true).not(role: 'banned').all!
|
|
24
|
+
# stats = User.group('role').select('role, count(*) as n').all!
|
|
25
|
+
|
|
26
|
+
# ==============================================================================
|
|
27
|
+
# Connection
|
|
28
|
+
# ==============================================================================
|
|
29
|
+
|
|
30
|
+
_dbUrl = null
|
|
31
|
+
|
|
32
|
+
export connect = (url) -> _dbUrl = url
|
|
33
|
+
|
|
34
|
+
dbUrl = -> _dbUrl or process.env.DB_URL or 'http://localhost:4213'
|
|
35
|
+
|
|
36
|
+
# ==============================================================================
|
|
37
|
+
# Low-Level Helpers
|
|
38
|
+
# ==============================================================================
|
|
39
|
+
|
|
40
|
+
export esc = (v) ->
|
|
41
|
+
return 'NULL' unless v?
|
|
42
|
+
return String(v) if typeof v is 'number'
|
|
43
|
+
"'" + String(v).replace(/'/g, "''") + "'"
|
|
44
|
+
|
|
45
|
+
export query = (sql, params = []) ->
|
|
46
|
+
body = if params.length > 0 then { sql, params } else { sql }
|
|
47
|
+
res = fetch! "#{dbUrl()}/sql",
|
|
48
|
+
method: 'POST'
|
|
49
|
+
headers: { 'Content-Type': 'application/json' }
|
|
50
|
+
body: JSON.stringify body
|
|
51
|
+
data = res.json!
|
|
52
|
+
throw new Error data.error if data.error
|
|
53
|
+
data
|
|
54
|
+
|
|
55
|
+
materialize = (meta, row) ->
|
|
56
|
+
obj = {}
|
|
57
|
+
for col, i in meta
|
|
58
|
+
obj[col.name] = row[i]
|
|
59
|
+
obj
|
|
60
|
+
|
|
61
|
+
export materializeAll = (result) ->
|
|
62
|
+
return [] if result.rows is 0
|
|
63
|
+
result.data.map (row) -> materialize result.meta, row
|
|
64
|
+
|
|
65
|
+
export findOne = (sql, params = []) ->
|
|
66
|
+
result = query! sql, params
|
|
67
|
+
return null if result.rows is 0
|
|
68
|
+
materialize result.meta, result.data[0]
|
|
69
|
+
|
|
70
|
+
export findAll = (sql, params = []) ->
|
|
71
|
+
result = query! sql, params
|
|
72
|
+
materializeAll result
|
|
73
|
+
|
|
74
|
+
# ==============================================================================
|
|
75
|
+
# Query Builder
|
|
76
|
+
# ==============================================================================
|
|
77
|
+
|
|
78
|
+
class QueryBuilder
|
|
79
|
+
constructor: (@table, @database = null) ->
|
|
80
|
+
@_tableName = if @database then "\"#{@database}\".\"#{@table}\"" else "\"#{@table}\""
|
|
81
|
+
@_wheres = []
|
|
82
|
+
@_params = []
|
|
83
|
+
@_order = null
|
|
84
|
+
@_group = null
|
|
85
|
+
@_having = null
|
|
86
|
+
@_limit = null
|
|
87
|
+
@_offset = null
|
|
88
|
+
@_select = '*'
|
|
89
|
+
|
|
90
|
+
select: (cols) ->
|
|
91
|
+
b = @_clone()
|
|
92
|
+
b._select = if Array.isArray(cols) then cols.join(', ') else cols
|
|
93
|
+
b
|
|
94
|
+
|
|
95
|
+
where: (conditions, params = []) ->
|
|
96
|
+
b = @_clone()
|
|
97
|
+
if typeof conditions is 'string'
|
|
98
|
+
b._wheres.push { clause: conditions, join: 'AND' }
|
|
99
|
+
b._params = b._params.concat params
|
|
100
|
+
else if typeof conditions is 'object'
|
|
101
|
+
for own key, val of conditions
|
|
102
|
+
if val is null
|
|
103
|
+
b._wheres.push { clause: "\"#{key}\" IS NULL", join: 'AND' }
|
|
104
|
+
else
|
|
105
|
+
b._wheres.push { clause: "\"#{key}\" = $#{b._params.length + 1}", join: 'AND' }
|
|
106
|
+
b._params.push val
|
|
107
|
+
b
|
|
108
|
+
|
|
109
|
+
or: (conditions, params = []) ->
|
|
110
|
+
b = @_clone()
|
|
111
|
+
if typeof conditions is 'string'
|
|
112
|
+
b._wheres.push { clause: conditions, join: 'OR' }
|
|
113
|
+
b._params = b._params.concat params
|
|
114
|
+
else if typeof conditions is 'object'
|
|
115
|
+
parts = []
|
|
116
|
+
for own key, val of conditions
|
|
117
|
+
if val is null
|
|
118
|
+
parts.push "\"#{key}\" IS NULL"
|
|
119
|
+
else
|
|
120
|
+
parts.push "\"#{key}\" = $#{b._params.length + 1}"
|
|
121
|
+
b._params.push val
|
|
122
|
+
b._wheres.push { clause: "(#{parts.join(' AND ')})", join: 'OR' }
|
|
123
|
+
b
|
|
124
|
+
|
|
125
|
+
not: (conditions, params = []) ->
|
|
126
|
+
b = @_clone()
|
|
127
|
+
if typeof conditions is 'string'
|
|
128
|
+
b._wheres.push { clause: "NOT (#{conditions})", join: 'AND' }
|
|
129
|
+
b._params = b._params.concat params
|
|
130
|
+
else if typeof conditions is 'object'
|
|
131
|
+
for own key, val of conditions
|
|
132
|
+
if val is null
|
|
133
|
+
b._wheres.push { clause: "\"#{key}\" IS NOT NULL", join: 'AND' }
|
|
134
|
+
else
|
|
135
|
+
b._wheres.push { clause: "\"#{key}\" != $#{b._params.length + 1}", join: 'AND' }
|
|
136
|
+
b._params.push val
|
|
137
|
+
b
|
|
138
|
+
|
|
139
|
+
order: (expr) ->
|
|
140
|
+
b = @_clone()
|
|
141
|
+
b._order = expr
|
|
142
|
+
b
|
|
143
|
+
|
|
144
|
+
group: (expr) ->
|
|
145
|
+
b = @_clone()
|
|
146
|
+
b._group = expr
|
|
147
|
+
b
|
|
148
|
+
|
|
149
|
+
having: (clause, params = []) ->
|
|
150
|
+
b = @_clone()
|
|
151
|
+
b._having = clause
|
|
152
|
+
b._params = b._params.concat params
|
|
153
|
+
b
|
|
154
|
+
|
|
155
|
+
limit: (n) ->
|
|
156
|
+
b = @_clone()
|
|
157
|
+
b._limit = n
|
|
158
|
+
b
|
|
159
|
+
|
|
160
|
+
offset: (n) ->
|
|
161
|
+
b = @_clone()
|
|
162
|
+
b._offset = n
|
|
163
|
+
b
|
|
164
|
+
|
|
165
|
+
_buildWhere: ->
|
|
166
|
+
return '' if @_wheres.length is 0
|
|
167
|
+
parts = []
|
|
168
|
+
for w, i in @_wheres
|
|
169
|
+
if i is 0
|
|
170
|
+
parts.push w.clause
|
|
171
|
+
else
|
|
172
|
+
parts.push "#{w.join} #{w.clause}"
|
|
173
|
+
" WHERE #{parts.join(' ')}"
|
|
174
|
+
|
|
175
|
+
_toSQL: ->
|
|
176
|
+
sql = "SELECT #{@_select} FROM #{@_tableName}"
|
|
177
|
+
sql += @_buildWhere()
|
|
178
|
+
sql += " GROUP BY #{@_group}" if @_group
|
|
179
|
+
sql += " HAVING #{@_having}" if @_having
|
|
180
|
+
sql += " ORDER BY #{@_order}" if @_order
|
|
181
|
+
sql += " LIMIT #{@_limit}" if @_limit?
|
|
182
|
+
sql += " OFFSET #{@_offset}" if @_offset?
|
|
183
|
+
sql
|
|
184
|
+
|
|
185
|
+
all: ->
|
|
186
|
+
findAll! @_toSQL(), @_params
|
|
187
|
+
|
|
188
|
+
first: ->
|
|
189
|
+
b = @_clone()
|
|
190
|
+
b._limit = 1
|
|
191
|
+
findOne! b._toSQL(), b._params
|
|
192
|
+
|
|
193
|
+
count: ->
|
|
194
|
+
sql = "SELECT COUNT(*) as count FROM #{@_tableName}"
|
|
195
|
+
sql += @_buildWhere()
|
|
196
|
+
sql += " GROUP BY #{@_group}" if @_group
|
|
197
|
+
sql += " HAVING #{@_having}" if @_having
|
|
198
|
+
result = query! sql, @_params
|
|
199
|
+
result.data[0][0]
|
|
200
|
+
|
|
201
|
+
update: (data) ->
|
|
202
|
+
keys = Object.keys(data).filter (k) -> data[k] isnt undefined
|
|
203
|
+
sets = keys.map((k, i) -> "\"#{k}\" = $#{@_params.length + i + 1}").join(', ')
|
|
204
|
+
vals = keys.map (k) -> data[k]
|
|
205
|
+
allParams = [...@_params, ...vals]
|
|
206
|
+
sql = "UPDATE #{@_tableName} SET #{sets}"
|
|
207
|
+
sql += @_buildWhere()
|
|
208
|
+
sql += " RETURNING *"
|
|
209
|
+
findAll! sql, allParams
|
|
210
|
+
|
|
211
|
+
destroy: ->
|
|
212
|
+
sql = "DELETE FROM #{@_tableName}"
|
|
213
|
+
sql += @_buildWhere()
|
|
214
|
+
sql += " RETURNING *"
|
|
215
|
+
findAll! sql, @_params
|
|
216
|
+
|
|
217
|
+
_clone: ->
|
|
218
|
+
b = new QueryBuilder(@table, @database)
|
|
219
|
+
b._wheres = [...@_wheres]
|
|
220
|
+
b._params = [...@_params]
|
|
221
|
+
b._order = @_order
|
|
222
|
+
b._group = @_group
|
|
223
|
+
b._having = @_having
|
|
224
|
+
b._limit = @_limit
|
|
225
|
+
b._offset = @_offset
|
|
226
|
+
b._select = @_select
|
|
227
|
+
b
|
|
228
|
+
|
|
229
|
+
# ==============================================================================
|
|
230
|
+
# Model Factory
|
|
231
|
+
# ==============================================================================
|
|
232
|
+
|
|
233
|
+
export Model = (table, database = null) ->
|
|
234
|
+
tableName = if database then "\"#{database}\".\"#{table}\"" else "\"#{table}\""
|
|
235
|
+
_qb = -> new QueryBuilder(table, database)
|
|
236
|
+
|
|
237
|
+
find: (id) ->
|
|
238
|
+
findOne! "SELECT * FROM #{tableName} WHERE id = $1", [id]
|
|
239
|
+
|
|
240
|
+
all: (limit = null) ->
|
|
241
|
+
sql = "SELECT * FROM #{tableName}"
|
|
242
|
+
sql += " LIMIT #{limit}" if limit?
|
|
243
|
+
findAll! sql
|
|
244
|
+
|
|
245
|
+
where: (conditions, params = []) -> _qb().where(conditions, params)
|
|
246
|
+
or: (conditions, params = []) -> _qb().or(conditions, params)
|
|
247
|
+
not: (conditions, params = []) -> _qb().not(conditions, params)
|
|
248
|
+
select: (cols) -> _qb().select(cols)
|
|
249
|
+
order: (expr) -> _qb().order(expr)
|
|
250
|
+
group: (expr) -> _qb().group(expr)
|
|
251
|
+
limit: (n) -> _qb().limit(n)
|
|
252
|
+
|
|
253
|
+
count: ->
|
|
254
|
+
result = query! "SELECT COUNT(*) as count FROM #{tableName}"
|
|
255
|
+
result.data[0][0]
|
|
256
|
+
|
|
257
|
+
query: (sql, params = []) ->
|
|
258
|
+
findAll! sql, params
|
|
259
|
+
|
|
260
|
+
insert: (data) ->
|
|
261
|
+
keys = Object.keys(data).filter (k) -> data[k] isnt undefined
|
|
262
|
+
cols = keys.map((k) -> "\"#{k}\"").join(', ')
|
|
263
|
+
refs = keys.map((_, i) -> "$#{i + 1}").join(', ')
|
|
264
|
+
vals = keys.map (k) -> data[k]
|
|
265
|
+
findOne! "INSERT INTO #{tableName} (#{cols}) VALUES (#{refs}) RETURNING *", vals
|
|
266
|
+
|
|
267
|
+
update: (id, data) ->
|
|
268
|
+
keys = Object.keys(data).filter (k) -> data[k] isnt undefined
|
|
269
|
+
sets = keys.map((k, i) -> "\"#{k}\" = $#{i + 2}").join(', ')
|
|
270
|
+
vals = keys.map (k) -> data[k]
|
|
271
|
+
findOne! "UPDATE #{tableName} SET #{sets} WHERE id = $1 RETURNING *", [id, ...vals]
|
|
272
|
+
|
|
273
|
+
upsert: (data, opts = {}) ->
|
|
274
|
+
keys = Object.keys(data).filter (k) -> data[k] isnt undefined
|
|
275
|
+
cols = keys.map((k) -> "\"#{k}\"").join(', ')
|
|
276
|
+
refs = keys.map((_, i) -> "$#{i + 1}").join(', ')
|
|
277
|
+
vals = keys.map (k) -> data[k]
|
|
278
|
+
|
|
279
|
+
conflict = opts.on or opts.conflict
|
|
280
|
+
sql = "INSERT INTO #{tableName} (#{cols}) VALUES (#{refs})"
|
|
281
|
+
if conflict
|
|
282
|
+
updateCols = keys.filter((k) -> k isnt conflict)
|
|
283
|
+
sets = updateCols.map((k) -> "\"#{k}\" = EXCLUDED.\"#{k}\"").join(', ')
|
|
284
|
+
sql += " ON CONFLICT (\"#{conflict}\") DO UPDATE SET #{sets}"
|
|
285
|
+
sql += " RETURNING *"
|
|
286
|
+
findOne! sql, vals
|
|
287
|
+
|
|
288
|
+
destroy: (id) ->
|
|
289
|
+
findOne! "DELETE FROM #{tableName} WHERE id = $1 RETURNING *", [id]
|
package/db.rip
CHANGED
|
@@ -125,8 +125,8 @@ isSelectQuery = (sql) ->
|
|
|
125
125
|
|
|
126
126
|
# Log SQL query (truncated if long)
|
|
127
127
|
logSQL = (sql) ->
|
|
128
|
-
|
|
129
|
-
console.log
|
|
128
|
+
# sql = sql.replace(/\s+/g, ' ').trim()
|
|
129
|
+
console.log sql
|
|
130
130
|
|
|
131
131
|
# Execute SQL and return result (uses persistent connection)
|
|
132
132
|
executeSQL = (sql, params = []) ->
|
|
@@ -224,7 +224,7 @@ getUIParams = (req) ->
|
|
|
224
224
|
# POST /ddb/run — Execute SQL and return binary result (DuckDB UI protocol)
|
|
225
225
|
post '/ddb/run' ->
|
|
226
226
|
try
|
|
227
|
-
sql = read 'body', '
|
|
227
|
+
sql = read 'body', 'raw'
|
|
228
228
|
return binaryResponse serializeErrorResult 'Empty query' unless sql
|
|
229
229
|
|
|
230
230
|
logSQL sql
|
package/package.json
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rip-lang/db",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "DuckDB server with official DuckDB UI — pure Bun FFI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "db.rip",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./db.rip",
|
|
9
|
+
"./client": "./client.rip"
|
|
10
|
+
},
|
|
7
11
|
"bin": {
|
|
8
12
|
"rip-db": "./bin/rip-db"
|
|
9
13
|
},
|
|
@@ -34,11 +38,12 @@
|
|
|
34
38
|
"author": "Steve Shreeve <steve.shreeve@gmail.com>",
|
|
35
39
|
"license": "MIT",
|
|
36
40
|
"dependencies": {
|
|
37
|
-
"rip-lang": "^3.
|
|
38
|
-
"@rip-lang/api": "^1.1.
|
|
41
|
+
"rip-lang": "^3.10.0",
|
|
42
|
+
"@rip-lang/api": "^1.1.10"
|
|
39
43
|
},
|
|
40
44
|
"files": [
|
|
41
45
|
"db.rip",
|
|
46
|
+
"client.rip",
|
|
42
47
|
"lib/",
|
|
43
48
|
"bin/",
|
|
44
49
|
"README.md"
|