cloudflare-d1 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.
- checksums.yaml +7 -0
- data/.claude/d1.yaml +560 -0
- data/.claude/test-driving-rails.md +5482 -0
- data/.rubocop.yml +13 -0
- data/CHANGELOG.md +17 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/DATABASE_MANAGEMENT.md +365 -0
- data/LICENSE.txt +21 -0
- data/README.md +253 -0
- data/RELEASING.md +199 -0
- data/Rakefile +16 -0
- data/cloudflare-d1.gemspec +40 -0
- data/examples/roda/.dockerignore +14 -0
- data/examples/roda/.gitignore +11 -0
- data/examples/roda/Dockerfile +21 -0
- data/examples/roda/Gemfile +12 -0
- data/examples/roda/Gemfile.lock +35 -0
- data/examples/roda/README.md +459 -0
- data/examples/roda/app.rb +165 -0
- data/examples/roda/db/migrations/001_create_users.rb +17 -0
- data/examples/roda/setup.sh +128 -0
- data/examples/roda/worker.js +119 -0
- data/examples/roda/wrangler.jsonc +68 -0
- data/lib/active_record/connection_adapters/cloudflare_d1_adapter.rb +447 -0
- data/lib/cloudflare/d1/client.rb +149 -0
- data/lib/cloudflare/d1/model.rb +46 -0
- data/lib/cloudflare/d1/railtie.rb +13 -0
- data/lib/cloudflare/d1/version.rb +7 -0
- data/lib/cloudflare/d1.rb +39 -0
- data/lib/sequel/adapters/cloudflare_d1.rb +265 -0
- data/lib/tasks/database.rake +158 -0
- data/lib/tasks/sequel.rake +199 -0
- data/sig/cloudflare/d1.rbs +6 -0
- metadata +163 -0
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record/connection_adapters/abstract_adapter"
|
|
4
|
+
require "active_record/connection_adapters/statement_pool"
|
|
5
|
+
require "cloudflare/d1/client"
|
|
6
|
+
|
|
7
|
+
module ActiveRecord
|
|
8
|
+
module ConnectionAdapters
|
|
9
|
+
class CloudflareD1Adapter < AbstractAdapter
|
|
10
|
+
ADAPTER_NAME = "CloudflareD1"
|
|
11
|
+
|
|
12
|
+
NATIVE_DATABASE_TYPES = {
|
|
13
|
+
primary_key: "INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL",
|
|
14
|
+
string: { name: "TEXT" },
|
|
15
|
+
text: { name: "TEXT" },
|
|
16
|
+
integer: { name: "INTEGER" },
|
|
17
|
+
float: { name: "REAL" },
|
|
18
|
+
decimal: { name: "NUMERIC" },
|
|
19
|
+
datetime: { name: "TEXT" },
|
|
20
|
+
time: { name: "TEXT" },
|
|
21
|
+
date: { name: "TEXT" },
|
|
22
|
+
binary: { name: "BLOB" },
|
|
23
|
+
boolean: { name: "INTEGER" },
|
|
24
|
+
json: { name: "TEXT" }
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
def new_client(config)
|
|
29
|
+
config
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def dbconsole(config, options = {})
|
|
33
|
+
puts "D1 database console not available via adapter"
|
|
34
|
+
puts "Use: wrangler d1 execute #{config[:database_id]} --command='<SQL>'"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initialize(...)
|
|
39
|
+
super
|
|
40
|
+
|
|
41
|
+
@config = @config.symbolize_keys
|
|
42
|
+
@account_id = @config[:account_id] || ENV["CLOUDFLARE_ACCOUNT_ID"]
|
|
43
|
+
@api_token = @config[:api_token] || ENV["CLOUDFLARE_API_TOKEN"]
|
|
44
|
+
@database_id = @config[:database_id] || @config[:database]
|
|
45
|
+
|
|
46
|
+
raise ArgumentError, "account_id is required" unless @account_id
|
|
47
|
+
raise ArgumentError, "api_token is required" unless @api_token
|
|
48
|
+
raise ArgumentError, "database_id is required" unless @database_id
|
|
49
|
+
|
|
50
|
+
@client = Cloudflare::D1::Client.new(
|
|
51
|
+
account_id: @account_id,
|
|
52
|
+
api_token: @api_token
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
@connection_parameters = @config
|
|
56
|
+
@prepared_statements = false
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Connection management
|
|
60
|
+
|
|
61
|
+
def active?
|
|
62
|
+
return false unless @client
|
|
63
|
+
|
|
64
|
+
# Try a simple query to check if connection is active
|
|
65
|
+
result = @client.query(
|
|
66
|
+
database_id: @database_id,
|
|
67
|
+
sql: "SELECT 1"
|
|
68
|
+
)
|
|
69
|
+
result["success"] == true
|
|
70
|
+
rescue
|
|
71
|
+
false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def reconnect
|
|
75
|
+
@client = Cloudflare::D1::Client.new(
|
|
76
|
+
account_id: @account_id,
|
|
77
|
+
api_token: @api_token
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
alias reconnect! reconnect
|
|
81
|
+
|
|
82
|
+
def disconnect!
|
|
83
|
+
@client = nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def discard!
|
|
87
|
+
@client = nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def reset!
|
|
91
|
+
reconnect
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Adapter info
|
|
95
|
+
|
|
96
|
+
def adapter_name
|
|
97
|
+
ADAPTER_NAME
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def supports_ddl_transactions?
|
|
101
|
+
false # D1 API doesn't support transactions across HTTP requests
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def supports_savepoints?
|
|
105
|
+
false
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def supports_transaction_isolation?
|
|
109
|
+
false
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def supports_explain?
|
|
113
|
+
true
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def supports_index_sort_order?
|
|
117
|
+
true
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def supports_datetime_with_precision?
|
|
121
|
+
false
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def supports_json?
|
|
125
|
+
true
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def supports_common_table_expressions?
|
|
129
|
+
true
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def supports_virtual_columns?
|
|
133
|
+
false
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def supports_insert_returning?
|
|
137
|
+
false
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def supports_insert_on_conflict?
|
|
141
|
+
true
|
|
142
|
+
end
|
|
143
|
+
alias supports_insert_on_duplicate_skip? supports_insert_on_conflict?
|
|
144
|
+
alias supports_insert_on_duplicate_update? supports_insert_on_conflict?
|
|
145
|
+
|
|
146
|
+
def supports_concurrent_connections?
|
|
147
|
+
true
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Query execution
|
|
151
|
+
|
|
152
|
+
def execute(sql, name = nil)
|
|
153
|
+
log(sql, name) do
|
|
154
|
+
result = @client.query(
|
|
155
|
+
database_id: @database_id,
|
|
156
|
+
sql: sql
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
result["result"]
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def exec_query(sql, name = "SQL", binds = [], prepare: false)
|
|
164
|
+
log(sql, name, binds) do
|
|
165
|
+
result = @client.query(
|
|
166
|
+
database_id: @database_id,
|
|
167
|
+
sql: sql,
|
|
168
|
+
params: binds.map { |b| type_cast(b) }
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
rows = result.dig("result", 0, "results") || []
|
|
172
|
+
columns = rows.first&.keys || []
|
|
173
|
+
|
|
174
|
+
ActiveRecord::Result.new(columns, rows.map(&:values), result.dig("result", 0, "meta") || {})
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def insert(arel, name = nil, pk = nil, id_value = nil, sequence_name = nil, binds = [])
|
|
179
|
+
sql = to_sql(arel, binds)
|
|
180
|
+
|
|
181
|
+
log(sql, name, binds) do
|
|
182
|
+
result = @client.query(
|
|
183
|
+
database_id: @database_id,
|
|
184
|
+
sql: sql,
|
|
185
|
+
params: binds.map { |b| type_cast(b) }
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Return the last inserted ID wrapped in array for multi-insert support
|
|
189
|
+
last_id = result.dig("result", 0, "meta", "last_row_id") || id_value
|
|
190
|
+
[last_id]
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def affected_rows(result)
|
|
195
|
+
meta = result.instance_variable_get(:@meta)
|
|
196
|
+
return 0 unless meta
|
|
197
|
+
meta["changes"] || 0
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def select_all(arel, name = nil, binds = [], preparable: nil, async: false, allow_retry: false)
|
|
201
|
+
arel = arel_from_relation(arel)
|
|
202
|
+
sql = to_sql(arel, binds)
|
|
203
|
+
|
|
204
|
+
if preparable.nil?
|
|
205
|
+
preparable = prepared_statements
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
exec_query(sql, name, binds, prepare: preparable)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def views
|
|
212
|
+
[]
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def data_sources
|
|
216
|
+
tables
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def write_query?(sql)
|
|
220
|
+
!sql.match?(/\A\s*(SELECT|PRAGMA|EXPLAIN)/i)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def internal_exec_query(sql, name = "SQL", binds = [], prepare: false, async: false, allow_retry: false, materialize_transactions: true)
|
|
224
|
+
exec_query(sql, name, binds, prepare: prepare)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def perform_query(raw_connection, sql, binds, type_casted_binds, prepare:, notification_payload:, batch:)
|
|
228
|
+
result = @client.query(
|
|
229
|
+
database_id: @database_id,
|
|
230
|
+
sql: sql,
|
|
231
|
+
params: type_casted_binds
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
rows = result.dig("result", 0, "results") || []
|
|
235
|
+
columns = rows.first&.keys || []
|
|
236
|
+
meta = result.dig("result", 0, "meta") || {}
|
|
237
|
+
|
|
238
|
+
ar_result = ActiveRecord::Result.new(columns, rows.map(&:values), meta)
|
|
239
|
+
|
|
240
|
+
# Store meta for affected_rows to access
|
|
241
|
+
ar_result.instance_variable_set(:@meta, meta)
|
|
242
|
+
|
|
243
|
+
ar_result
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Schema introspection
|
|
247
|
+
|
|
248
|
+
def tables(name = nil)
|
|
249
|
+
sql = <<-SQL
|
|
250
|
+
SELECT name FROM sqlite_master
|
|
251
|
+
WHERE type = 'table'
|
|
252
|
+
AND name NOT LIKE 'sqlite_%'
|
|
253
|
+
AND name NOT IN ('ar_internal_metadata')
|
|
254
|
+
ORDER BY name
|
|
255
|
+
SQL
|
|
256
|
+
|
|
257
|
+
result = execute(sql, name)
|
|
258
|
+
return [] unless result && result[0] && result[0]["results"]
|
|
259
|
+
|
|
260
|
+
result[0]["results"].map { |row| row["name"] }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def table_exists?(table_name)
|
|
264
|
+
tables.include?(table_name.to_s)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def columns(table_name)
|
|
268
|
+
sql = "PRAGMA table_info(#{quote_table_name(table_name)})"
|
|
269
|
+
result = execute(sql)
|
|
270
|
+
|
|
271
|
+
return [] unless result && result[0] && result[0]["results"]
|
|
272
|
+
|
|
273
|
+
result[0]["results"].map do |column_data|
|
|
274
|
+
column_name = column_data["name"]
|
|
275
|
+
sql_type = column_data["type"]
|
|
276
|
+
nullable = column_data["notnull"] == 0
|
|
277
|
+
default = column_data["dflt_value"]
|
|
278
|
+
type_metadata = fetch_type_metadata(sql_type)
|
|
279
|
+
cast_type = lookup_cast_type(sql_type)
|
|
280
|
+
|
|
281
|
+
ActiveRecord::ConnectionAdapters::Column.new(
|
|
282
|
+
column_name,
|
|
283
|
+
cast_type,
|
|
284
|
+
default,
|
|
285
|
+
type_metadata,
|
|
286
|
+
nullable
|
|
287
|
+
)
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def primary_key(table_name)
|
|
292
|
+
sql = "PRAGMA table_info(#{quote_table_name(table_name)})"
|
|
293
|
+
result = execute(sql)
|
|
294
|
+
|
|
295
|
+
return "id" unless result && result[0] && result[0]["results"]
|
|
296
|
+
|
|
297
|
+
pk_column = result[0]["results"].find { |col| col["pk"] == 1 }
|
|
298
|
+
pk_column&.fetch("name", "id")
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def native_database_types
|
|
302
|
+
NATIVE_DATABASE_TYPES
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def indexes(table_name)
|
|
306
|
+
sql = "PRAGMA index_list(#{quote_table_name(table_name)})"
|
|
307
|
+
result = execute(sql)
|
|
308
|
+
|
|
309
|
+
return [] unless result && result[0] && result[0]["results"]
|
|
310
|
+
|
|
311
|
+
result[0]["results"].map do |index_data|
|
|
312
|
+
index_name = index_data["name"]
|
|
313
|
+
unique = index_data["unique"] == 1
|
|
314
|
+
|
|
315
|
+
# Get index columns
|
|
316
|
+
columns_sql = "PRAGMA index_info(#{quote(index_name)})"
|
|
317
|
+
columns_result = execute(columns_sql)
|
|
318
|
+
columns = []
|
|
319
|
+
|
|
320
|
+
if columns_result && columns_result[0] && columns_result[0]["results"]
|
|
321
|
+
columns = columns_result[0]["results"]
|
|
322
|
+
.sort_by { |c| c["seqno"] }
|
|
323
|
+
.map { |c| c["name"] }
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
ActiveRecord::ConnectionAdapters::IndexDefinition.new(
|
|
327
|
+
table_name,
|
|
328
|
+
index_name,
|
|
329
|
+
unique,
|
|
330
|
+
columns
|
|
331
|
+
)
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Quoting
|
|
336
|
+
|
|
337
|
+
def quote_string(s)
|
|
338
|
+
s.gsub("'", "''")
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def quote_table_name(name)
|
|
342
|
+
"\"#{name.to_s.gsub('"', '""')}\""
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def quote_column_name(name)
|
|
346
|
+
"\"#{name.to_s.gsub('"', '""')}\""
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def quoted_true
|
|
350
|
+
"1"
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def quoted_false
|
|
354
|
+
"0"
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def quoted_date(value)
|
|
358
|
+
value.utc.iso8601
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Type casting
|
|
362
|
+
|
|
363
|
+
def type_cast(value)
|
|
364
|
+
case value
|
|
365
|
+
when ActiveModel::Attribute
|
|
366
|
+
type_cast(value.value_for_database)
|
|
367
|
+
when TrueClass
|
|
368
|
+
1
|
|
369
|
+
when FalseClass
|
|
370
|
+
0
|
|
371
|
+
when Time, DateTime
|
|
372
|
+
value.utc.iso8601
|
|
373
|
+
when Date
|
|
374
|
+
value.iso8601
|
|
375
|
+
else
|
|
376
|
+
value
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
private
|
|
381
|
+
|
|
382
|
+
def lookup_cast_type(sql_type)
|
|
383
|
+
case sql_type.to_s.upcase
|
|
384
|
+
when /INT/i
|
|
385
|
+
Type::Integer.new
|
|
386
|
+
when /REAL|FLOAT|DOUBLE/i
|
|
387
|
+
Type::Float.new
|
|
388
|
+
when /TEXT|CHAR|CLOB/i
|
|
389
|
+
Type::String.new
|
|
390
|
+
when /BLOB/i
|
|
391
|
+
Type::Binary.new
|
|
392
|
+
when /NUMERIC|DECIMAL/i
|
|
393
|
+
Type::Decimal.new
|
|
394
|
+
when ""
|
|
395
|
+
Type::Value.new
|
|
396
|
+
else
|
|
397
|
+
Type::String.new
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def fetch_type_metadata(sql_type)
|
|
402
|
+
cast_type = case sql_type.to_s.upcase
|
|
403
|
+
when /INT/i
|
|
404
|
+
Type::Integer.new
|
|
405
|
+
when /REAL|FLOAT|DOUBLE/i
|
|
406
|
+
Type::Float.new
|
|
407
|
+
when /TEXT|CHAR|CLOB/i
|
|
408
|
+
Type::String.new
|
|
409
|
+
when /BLOB/i
|
|
410
|
+
Type::Binary.new
|
|
411
|
+
when /NUMERIC|DECIMAL/i
|
|
412
|
+
Type::Decimal.new
|
|
413
|
+
when ""
|
|
414
|
+
Type::Value.new
|
|
415
|
+
else
|
|
416
|
+
Type::String.new
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
ActiveRecord::ConnectionAdapters::SqlTypeMetadata.new(
|
|
420
|
+
sql_type: sql_type.to_s,
|
|
421
|
+
type: cast_type.type,
|
|
422
|
+
limit: cast_type.limit
|
|
423
|
+
)
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def arel_from_relation(relation)
|
|
427
|
+
case relation
|
|
428
|
+
when Arel::TreeManager
|
|
429
|
+
relation
|
|
430
|
+
when String
|
|
431
|
+
Arel.sql(relation)
|
|
432
|
+
else
|
|
433
|
+
relation.arel
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# Register the adapter
|
|
441
|
+
ActiveSupport.on_load(:active_record) do
|
|
442
|
+
ActiveRecord::ConnectionAdapters.register(
|
|
443
|
+
"cloudflare_d1",
|
|
444
|
+
"ActiveRecord::ConnectionAdapters::CloudflareD1Adapter",
|
|
445
|
+
"active_record/connection_adapters/cloudflare_d1_adapter"
|
|
446
|
+
)
|
|
447
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Cloudflare
|
|
8
|
+
module D1
|
|
9
|
+
class Client
|
|
10
|
+
class APIError < StandardError; end
|
|
11
|
+
|
|
12
|
+
BASE_URL = "https://api.cloudflare.com/client/v4"
|
|
13
|
+
|
|
14
|
+
attr_reader :account_id, :api_token
|
|
15
|
+
|
|
16
|
+
def initialize(account_id:, api_token:)
|
|
17
|
+
@account_id = account_id
|
|
18
|
+
@api_token = api_token
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Execute a query against a D1 database
|
|
22
|
+
# @param database_id [String] The UUID of the D1 database
|
|
23
|
+
# @param sql [String] SQL query to execute
|
|
24
|
+
# @param params [Array] Optional parameters for parameterized queries
|
|
25
|
+
# @return [Hash] Response with results, meta, and success fields
|
|
26
|
+
def query(database_id:, sql:, params: [])
|
|
27
|
+
uri = URI("#{BASE_URL}/accounts/#{account_id}/d1/database/#{database_id}/query")
|
|
28
|
+
|
|
29
|
+
request = Net::HTTP::Post.new(uri)
|
|
30
|
+
request["Authorization"] = "Bearer #{api_token}"
|
|
31
|
+
request["Content-Type"] = "application/json"
|
|
32
|
+
request.body = JSON.generate({ sql: sql, params: params })
|
|
33
|
+
|
|
34
|
+
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
|
|
35
|
+
http.request(request)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
parse_response(response)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Execute a raw query (returns results as arrays instead of objects)
|
|
42
|
+
# @param database_id [String] The UUID of the D1 database
|
|
43
|
+
# @param sql [String] SQL query to execute
|
|
44
|
+
# @param params [Array] Optional parameters for parameterized queries
|
|
45
|
+
# @return [Hash] Response with results as arrays
|
|
46
|
+
def raw_query(database_id:, sql:, params: [])
|
|
47
|
+
uri = URI("#{BASE_URL}/accounts/#{account_id}/d1/database/#{database_id}/raw")
|
|
48
|
+
|
|
49
|
+
request = Net::HTTP::Post.new(uri)
|
|
50
|
+
request["Authorization"] = "Bearer #{api_token}"
|
|
51
|
+
request["Content-Type"] = "application/json"
|
|
52
|
+
request.body = JSON.generate({ sql: sql, params: params })
|
|
53
|
+
|
|
54
|
+
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
|
|
55
|
+
http.request(request)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
parse_response(response)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# List all databases in the account
|
|
62
|
+
# @return [Array<Hash>] Array of database objects
|
|
63
|
+
def list_databases
|
|
64
|
+
uri = URI("#{BASE_URL}/accounts/#{account_id}/d1/database")
|
|
65
|
+
|
|
66
|
+
request = Net::HTTP::Get.new(uri)
|
|
67
|
+
request["Authorization"] = "Bearer #{api_token}"
|
|
68
|
+
|
|
69
|
+
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
|
|
70
|
+
http.request(request)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
data = parse_response(response)
|
|
74
|
+
data["result"] || []
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Get database details
|
|
78
|
+
# @param database_id [String] The UUID of the D1 database
|
|
79
|
+
# @return [Hash] Database object
|
|
80
|
+
def get_database(database_id:)
|
|
81
|
+
uri = URI("#{BASE_URL}/accounts/#{account_id}/d1/database/#{database_id}")
|
|
82
|
+
|
|
83
|
+
request = Net::HTTP::Get.new(uri)
|
|
84
|
+
request["Authorization"] = "Bearer #{api_token}"
|
|
85
|
+
|
|
86
|
+
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
|
|
87
|
+
http.request(request)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
data = parse_response(response)
|
|
91
|
+
data["result"]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Create a new database
|
|
95
|
+
# @param name [String] Name for the new database
|
|
96
|
+
# @return [Hash] Created database object
|
|
97
|
+
def create_database(name:)
|
|
98
|
+
uri = URI("#{BASE_URL}/accounts/#{account_id}/d1/database")
|
|
99
|
+
|
|
100
|
+
request = Net::HTTP::Post.new(uri)
|
|
101
|
+
request["Authorization"] = "Bearer #{api_token}"
|
|
102
|
+
request["Content-Type"] = "application/json"
|
|
103
|
+
request.body = JSON.generate({ name: name })
|
|
104
|
+
|
|
105
|
+
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
|
|
106
|
+
http.request(request)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
data = parse_response(response)
|
|
110
|
+
data["result"]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Delete a database
|
|
114
|
+
# @param database_id [String] The UUID of the D1 database
|
|
115
|
+
# @return [Boolean] Success status
|
|
116
|
+
def delete_database(database_id:)
|
|
117
|
+
uri = URI("#{BASE_URL}/accounts/#{account_id}/d1/database/#{database_id}")
|
|
118
|
+
|
|
119
|
+
request = Net::HTTP::Delete.new(uri)
|
|
120
|
+
request["Authorization"] = "Bearer #{api_token}"
|
|
121
|
+
|
|
122
|
+
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
|
|
123
|
+
http.request(request)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
data = parse_response(response)
|
|
127
|
+
data["success"]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def parse_response(response)
|
|
133
|
+
case response
|
|
134
|
+
when Net::HTTPSuccess
|
|
135
|
+
data = JSON.parse(response.body)
|
|
136
|
+
|
|
137
|
+
if data["success"] == false
|
|
138
|
+
errors = data["errors"]&.map { |e| e["message"] }&.join(", ")
|
|
139
|
+
raise APIError, "D1 API Error: #{errors || 'Unknown error'}"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
data
|
|
143
|
+
else
|
|
144
|
+
raise APIError, "HTTP Error #{response.code}: #{response.message}"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
|
|
5
|
+
module Cloudflare
|
|
6
|
+
module D1
|
|
7
|
+
# Base class for D1-backed ActiveRecord models
|
|
8
|
+
#
|
|
9
|
+
# Example usage:
|
|
10
|
+
#
|
|
11
|
+
# class User < Cloudflare::D1::Model
|
|
12
|
+
# # Automatically uses D1 database named "users" (inferred from table_name)
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# # Configure in database.yml:
|
|
16
|
+
# # production:
|
|
17
|
+
# # adapter: cloudflare_d1
|
|
18
|
+
# # account_id: <%= ENV['CLOUDFLARE_ACCOUNT_ID'] %>
|
|
19
|
+
# # api_token: <%= ENV['CLOUDFLARE_API_TOKEN'] %>
|
|
20
|
+
# # database_id: users_db_uuid
|
|
21
|
+
#
|
|
22
|
+
class Model < ActiveRecord::Base
|
|
23
|
+
self.abstract_class = true
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
# Returns the D1 database name inferred from the table name
|
|
27
|
+
# @return [String] The database name
|
|
28
|
+
def d1_database_name
|
|
29
|
+
table_name
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Returns the D1 database ID from the connection configuration
|
|
33
|
+
# @return [String] The database UUID
|
|
34
|
+
def d1_database_id
|
|
35
|
+
connection_db_config.configuration_hash[:database_id]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Returns the Cloudflare account ID from the connection configuration
|
|
39
|
+
# @return [String] The account ID
|
|
40
|
+
def cloudflare_account_id
|
|
41
|
+
connection_db_config.configuration_hash[:account_id]
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "d1/version"
|
|
4
|
+
require_relative "d1/client"
|
|
5
|
+
require_relative "d1/model"
|
|
6
|
+
|
|
7
|
+
module Cloudflare
|
|
8
|
+
module D1
|
|
9
|
+
class Error < StandardError; end
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
attr_accessor :account_id, :api_token
|
|
13
|
+
|
|
14
|
+
# Configure global D1 settings
|
|
15
|
+
#
|
|
16
|
+
# Example:
|
|
17
|
+
# Cloudflare::D1.configure do |config|
|
|
18
|
+
# config.account_id = ENV['CLOUDFLARE_ACCOUNT_ID']
|
|
19
|
+
# config.api_token = ENV['CLOUDFLARE_API_TOKEN']
|
|
20
|
+
# end
|
|
21
|
+
def configure
|
|
22
|
+
yield self
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Load ActiveRecord adapter if ActiveRecord is available
|
|
29
|
+
begin
|
|
30
|
+
require "active_record"
|
|
31
|
+
require_relative "../active_record/connection_adapters/cloudflare_d1_adapter"
|
|
32
|
+
|
|
33
|
+
# Load Rails integration if Rails is available
|
|
34
|
+
if defined?(Rails)
|
|
35
|
+
require_relative "d1/railtie"
|
|
36
|
+
end
|
|
37
|
+
rescue LoadError
|
|
38
|
+
# ActiveRecord not available, skip adapter loading
|
|
39
|
+
end
|