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.
@@ -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,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module Cloudflare
6
+ module D1
7
+ class Railtie < Rails::Railtie
8
+ rake_tasks do
9
+ load File.expand_path("../../tasks/database.rake", __dir__)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloudflare
4
+ module D1
5
+ VERSION = "0.1.0"
6
+ end
7
+ 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