@rip-lang/db 1.1.8 → 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.
Files changed (4) hide show
  1. package/README.md +172 -0
  2. package/client.rip +289 -0
  3. package/db.rip +3 -3
  4. package/package.json +6 -1
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
- query = sql.replace(/\s+/g, ' ').trim()
129
- console.log "SQL: #{query}"
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', 'string'
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.1.8",
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
  },
@@ -39,6 +43,7 @@
39
43
  },
40
44
  "files": [
41
45
  "db.rip",
46
+ "client.rip",
42
47
  "lib/",
43
48
  "bin/",
44
49
  "README.md"