rubydb-activerecord 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: becb1537aee9af710911f9a816a27b272dc5bde4889dbed351c1131ba9184224
4
+ data.tar.gz: dc0e6243c319dbaaa738a0470032655f772fa98339de1af8f24066b66e321e3e
5
+ SHA512:
6
+ metadata.gz: 72aeae225241636cfa38e01553fa35a79a0d411330ef1bd894d8bbf88f5497e900e71924db4f7cf5b7bb797ab974e4d07f316cea6b3d56255a75314aaafe6db3
7
+ data.tar.gz: e09c7ce51be379034426dc82f7047b74a954b8979d6c716f37cca70d6add2669158f9f552b688adad67aa501959fa54c2a89d4e1b28dafaca3bd50b58dbb971f
data/README.md ADDED
@@ -0,0 +1,133 @@
1
+ # rubydb-activerecord
2
+
3
+ `rubydb-activerecord` is the ActiveRecord adapter for [RubyDB](https://github.com/aldanedev-create/rubydb). It lets Rails applications use RubyDB through the normal model, relation, transaction, migration, schema, and connection-pool APIs.
4
+
5
+ This gem is an adapter to RubyDB's SQL engine. It is not a PostgreSQL, MySQL, or SQLite wire-protocol compatibility layer, and it does not make arbitrary dialect-specific SQL portable. Validate your application's queries and migrations against the exact Ruby, Rails, RubyDB, operating-system, and deployment versions you will run.
6
+
7
+ ## Requirements
8
+
9
+ - Ruby 3.3 or newer
10
+ - RubyDB 0.1.x
11
+ - ActiveRecord 7.1, 7.2, or 8.0 (the dependency range is `>= 7.1`, `< 8.1`)
12
+
13
+ ## Install
14
+
15
+ Add both gems to the Rails application's `Gemfile`:
16
+
17
+ ```ruby
18
+ gem "rubydb"
19
+ gem "rubydb-activerecord"
20
+ ```
21
+
22
+ Then run:
23
+
24
+ ```sh
25
+ bundle install
26
+ ```
27
+
28
+ The adapter registers itself under the `rubydb` adapter name when it is
29
+ required by Bundler. For a manually loaded application, require it explicitly:
30
+
31
+ ```ruby
32
+ require "rubydb"
33
+ require "active_record/connection_adapters/rubydb_adapter"
34
+ ```
35
+
36
+ ## Development: embedded database
37
+
38
+ Embedded mode is convenient for local development and a controlled
39
+ single-process application. The process owns the database file:
40
+
41
+ ```yaml
42
+ # config/database.yml
43
+ development:
44
+ adapter: rubydb
45
+ embedded: true
46
+ database: <%= Rails.root.join("tmp/development.rdb") %>
47
+ ```
48
+
49
+ Run migrations normally:
50
+
51
+ ```sh
52
+ bin/rails db:migrate
53
+ bin/rails console
54
+ bin/rails server
55
+ ```
56
+
57
+ Do not open the same embedded path from multiple independent Rails processes.
58
+ For multiple web workers, job workers, or hosts, use a managed RubyDB server.
59
+
60
+ ## Production: managed server
61
+
62
+ Use a network connection when the application has more than one process or
63
+ when the database must be operated independently from the application. Keep
64
+ credentials in the platform secret manager and map them into `database.yml`:
65
+
66
+ ```yaml
67
+ # config/database.yml
68
+ production:
69
+ adapter: rubydb
70
+ embedded: false
71
+ url: <%= ENV.fetch("RUBYDB_URL") %>
72
+ pool: <%= ENV.fetch("RAILS_MAX_THREADS", "5") %>
73
+ timeout: <%= ENV.fetch("RUBYDB_TIMEOUT", "30") %>
74
+ ```
75
+
76
+ Use `rubydb://` for a plain private-network connection or `rubydbs://` for TLS:
77
+
78
+ ```text
79
+ rubydbs://app_user:URL_ENCODED_PASSWORD@db.internal.example:7432/app?verify_peer=true&ca_file=%2Fetc%2Frubydb%2Ftls%2Fca.crt
80
+ ```
81
+
82
+ The server must be deployed separately with persistent storage,
83
+ authentication, TLS, backups, monitoring, resource limits, and a tested
84
+ restore procedure. Size its connection limit for the total Rails pool across
85
+ all application processes, with headroom for administration and replication.
86
+ See the repository's [Rails database configuration](../../docs/rails/database-yml.md)
87
+ and [production guidance](../../docs/rails/production.md).
88
+
89
+ ## Adapter surface
90
+
91
+ The release test suite exercises:
92
+
93
+ - ActiveRecord CRUD, binds, quoted identifiers, type casting, and false/zero values
94
+ - transactions and savepoints
95
+ - `joins`, qualified filters, ordering, eager loading, and nested associations
96
+ - table/column/index/foreign-key introspection
97
+ - Rails migrations, defaults, indexes, schema dumps, and populated-table changes
98
+ - embedded and network connection setup paths
99
+
100
+ RubyDB currently has documented SQL and schema boundaries. Features outside
101
+ the tested surface—such as dialect-specific extensions, generated columns,
102
+ complex table rebuilds, or application-specific Arel—need explicit tests
103
+ before deployment.
104
+
105
+ ## Validation
106
+
107
+ Run the adapter's integration suite from this directory:
108
+
109
+ ```sh
110
+ bundle install
111
+ bundle exec rspec spec
112
+ ```
113
+
114
+ Run the repository's Rails-focused checks from the repository root:
115
+
116
+ ```sh
117
+ bundle exec rspec spec/rails_adapter_live_engine_spec.rb spec/rails_adapter_schema_dump_spec.rb spec/rails_schema_statements_spec.rb
118
+ ```
119
+
120
+ Before a production release, run a real migration and smoke query through the
121
+ same server URL, TLS settings, pool size, and secret-management path used by
122
+ the deployment. Keep a verified backup before migrations and rehearse restore
123
+ and rollback on a representative copy.
124
+
125
+ ## Support and release policy
126
+
127
+ The adapter version is coupled to the RubyDB 0.1.x release line. Pin both gems
128
+ in the application lockfile, upgrade them together, and run the adapter suite
129
+ before upgrading Rails. Report reproducible adapter or engine issues at the
130
+ project's [issue tracker](https://github.com/aldanedev-create/rubydb/issues)
131
+ with Ruby/Rails/RubyDB versions, the SQL or migration involved, and a redacted
132
+ configuration summary. Never include passwords, connection URLs, database
133
+ files, or private keys in reports.
@@ -0,0 +1,893 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "active_record/connection_adapters/abstract_adapter"
5
+ require "active_record/connection_adapters/abstract/schema_definitions"
6
+ require "active_record/connection_adapters/abstract/schema_statements"
7
+
8
+ require "rubydb"
9
+ require "rubydb/rails/adapter"
10
+ require "rubydb/rails/connection"
11
+ require "rubydb/rails/database_statements"
12
+ require "rubydb/rails/schema_statements"
13
+ require "rubydb/rails/quoting"
14
+ require "rubydb/rails/type"
15
+ require "rubydb/rails/result"
16
+
17
+ module ActiveRecord
18
+ module ConnectionAdapters
19
+ # RubyDB adapter for ActiveRecord
20
+ class RubyDBAdapter < AbstractAdapter
21
+ include RubyDB::Rails::DatabaseStatements
22
+ include RubyDB::Rails::SchemaStatements
23
+ include RubyDB::Rails::Quoting
24
+
25
+ ADAPTER_NAME = "RubyDB"
26
+
27
+ # ActiveRecord 7.2 uses adapter-level methods while compiling hash-form
28
+ # order clauses (for example, `order(created_at: :desc)`). Keep these
29
+ # independent of a live connection so relation construction is safe
30
+ # during schema-cache and query setup as well.
31
+ def self.quote_table_name(name)
32
+ quote_column_name(name)
33
+ end
34
+
35
+ def self.quote_column_name(name)
36
+ "\"#{name.to_s.gsub('"', '""')}\""
37
+ end
38
+
39
+ NATIVE_DATABASE_TYPES = {
40
+ primary_key: "INTEGER PRIMARY KEY AUTOINCREMENT",
41
+ string: { name: "VARCHAR", limit: 255 },
42
+ text: { name: "TEXT" },
43
+ integer: { name: "INTEGER" },
44
+ bigint: { name: "BIGINT" },
45
+ smallint: { name: "SMALLINT" },
46
+ float: { name: "FLOAT" },
47
+ decimal: { name: "DECIMAL", precision: 10, scale: 2 },
48
+ datetime: { name: "TIMESTAMP" },
49
+ timestamp: { name: "TIMESTAMP" },
50
+ time: { name: "TIME" },
51
+ date: { name: "DATE" },
52
+ binary: { name: "BLOB" },
53
+ boolean: { name: "BOOLEAN" },
54
+ json: { name: "JSON" },
55
+ uuid: { name: "UUID" }
56
+ }
57
+
58
+ # ActiveRecord 7.2 constructs adapters with a single configuration hash.
59
+ # Accept trailing deprecated arguments so applications upgrading from older
60
+ # ActiveRecord versions do not fail during connection establishment.
61
+ def initialize(config, *)
62
+ super(config)
63
+
64
+ @connection = RubyDB::Rails::Connection.new(config)
65
+ @connection.connect
66
+
67
+ @prepared_statements = {}
68
+ @transaction_depth = 0
69
+ @query_cache_enabled = false
70
+ @query_cache = {}
71
+ @statements = {}
72
+ @statement_counter = 0
73
+ # AbstractAdapter uses this monitor while creating transactions. It
74
+ # must be re-entrant because ActiveRecord acquires it recursively.
75
+ @lock = Monitor.new
76
+ end
77
+
78
+ def adapter_name
79
+ ADAPTER_NAME
80
+ end
81
+
82
+ def supports_migrations?
83
+ true
84
+ end
85
+
86
+ def supports_primary_key?
87
+ true
88
+ end
89
+
90
+ def supports_index_sort_order?
91
+ true
92
+ end
93
+
94
+ def supports_transactions?
95
+ true
96
+ end
97
+
98
+ def supports_savepoints?
99
+ true
100
+ end
101
+
102
+ def supports_foreign_keys?
103
+ true
104
+ end
105
+
106
+ def supports_views?
107
+ true
108
+ end
109
+
110
+ def supports_json?
111
+ true
112
+ end
113
+
114
+ def supports_uuid?
115
+ true
116
+ end
117
+
118
+ def supports_bulk_alter?
119
+ false
120
+ end
121
+
122
+ def native_database_types
123
+ NATIVE_DATABASE_TYPES
124
+ end
125
+
126
+ # ==================== SCHEMA METHODS ====================
127
+
128
+ def primary_key(table_name)
129
+ return @connection.engine.table_columns(table_name).find(&:primary_key?)&.name&.to_s || "id" if embedded?
130
+
131
+ result = execute("PRAGMA table_info(#{quote_table_name(table_name)})")
132
+ row = result.find { |r| r["pk"] == 1 }
133
+ row ? row["name"] : "id"
134
+ end
135
+
136
+ def tables
137
+ return @connection.engine.list_tables.map(&:to_s) if embedded?
138
+
139
+ result = execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
140
+ result.map { |row| row["name"] }
141
+ end
142
+
143
+ def table_exists?(table_name)
144
+ tables.include?(table_name.to_s)
145
+ end
146
+
147
+ # ActiveRecord's schema cache asks for these before a model is first
148
+ # instantiated. The embedded engine has catalog metadata already, so
149
+ # avoid generating unsupported SQLite catalog queries.
150
+ def data_sources
151
+ return tables if embedded?
152
+
153
+ super
154
+ end
155
+
156
+ def data_source_exists?(name)
157
+ return table_exists?(name) if embedded?
158
+
159
+ super
160
+ end
161
+
162
+ def views
163
+ return [] if embedded?
164
+
165
+ super
166
+ end
167
+
168
+ def indexes(table_name)
169
+ if embedded?
170
+ return @connection.engine.index_manager.get_indexes_for_table(table_name.to_s).map do |index|
171
+ ActiveRecord::ConnectionAdapters::IndexDefinition.new(
172
+ table_name.to_s,
173
+ index.name.to_s,
174
+ index.unique,
175
+ index.columns.map(&:to_s)
176
+ )
177
+ end
178
+ end
179
+
180
+ result = execute("SELECT name, sql FROM sqlite_master WHERE type='index' AND tbl_name=?", [table_name])
181
+ result.map do |row|
182
+ {
183
+ name: row["name"],
184
+ columns: parse_index_columns(row["sql"]),
185
+ unique: row["sql"].include?("UNIQUE")
186
+ }
187
+ end
188
+ end
189
+
190
+ def columns(table_name)
191
+ return embedded_columns(table_name) if embedded?
192
+
193
+ result = execute("PRAGMA table_info(#{quote_table_name(table_name)})")
194
+ result.map do |row|
195
+ ActiveRecord::ConnectionAdapters::Column.new(
196
+ row["name"],
197
+ row["default"],
198
+ RubyDB::Rails::Type.to_rails(row["type"]),
199
+ {
200
+ null: row["notnull"] == 0,
201
+ primary_key: row["pk"] == 1,
202
+ limit: extract_limit(row["type"])
203
+ }
204
+ )
205
+ end
206
+ end
207
+
208
+ def column_exists?(table_name, column_name)
209
+ columns(table_name).any? { |c| c.name == column_name }
210
+ end
211
+
212
+ # ==================== QUERY METHODS ====================
213
+
214
+ def execute(sql, name = nil)
215
+ sql = sql_for_execution(sql)
216
+ log(sql, name) do
217
+ @connection.execute(sql)
218
+ end
219
+ end
220
+
221
+ def exec_query(sql, name = nil, binds = [])
222
+ sql = sql_for_execution(sql)
223
+ log(sql, name) do
224
+ params = bind_values(binds)
225
+ active_record_result(@connection.execute(sql, params))
226
+ end
227
+ end
228
+
229
+ def exec_delete(sql, name = nil, binds = [])
230
+ sql = sql_for_execution(sql)
231
+ log(sql, name) do
232
+ params = bind_values(binds)
233
+ result = @connection.execute(sql, params)
234
+ result.affected_rows
235
+ end
236
+ end
237
+
238
+ def exec_update(sql, name = nil, binds = [])
239
+ sql = sql_for_execution(sql)
240
+ log(sql, name) do
241
+ params = bind_values(binds)
242
+ result = @connection.execute(sql, params)
243
+ result.affected_rows
244
+ end
245
+ end
246
+
247
+ def exec_insert(sql, name = nil, binds = [], pk = nil, sequence_name = nil, returning: nil)
248
+ sql = sql_for_execution(sql)
249
+ log(sql, name) do
250
+ params = bind_values(binds)
251
+ result = @connection.execute(sql, params)
252
+ id = result.row_id
253
+ ActiveRecord::Result.new([pk || "id"], id.nil? ? [] : [[id]])
254
+ end
255
+ end
256
+
257
+ def insert(arel, name = nil, pk = nil, id_value = nil, sequence_name = nil, binds = [], returning: nil)
258
+ sql, binds = to_sql_and_binds(arel, binds)
259
+ result = exec_insert(sql, name, binds, pk, sequence_name, returning: returning)
260
+ return returning_column_values(result) unless returning.nil?
261
+
262
+ id_value || last_inserted_id(result)
263
+ end
264
+
265
+ def update(arel, name = nil, binds = [])
266
+ sql, binds = to_sql_and_binds(arel, binds)
267
+ exec_update(sql, name, binds)
268
+ end
269
+
270
+ def delete(arel, name = nil, binds = [])
271
+ sql, binds = to_sql_and_binds(arel, binds)
272
+ exec_delete(sql, name, binds)
273
+ end
274
+
275
+ def select_all(sql, name = nil, binds = [], preparable: nil, async: false, allow_retry: false)
276
+ sql, binds = to_sql_and_binds(sql, binds)
277
+ exec_query(sql, name, binds)
278
+ end
279
+
280
+ def select_one(sql, name = nil, binds = [])
281
+ sql, binds = to_sql_and_binds(sql, binds)
282
+ result = exec_query(sql, name, binds)
283
+ result.first
284
+ end
285
+
286
+ def select_value(sql, name = nil, binds = [])
287
+ sql, binds = to_sql_and_binds(sql, binds)
288
+ result = exec_query(sql, name, binds)
289
+ result.first&.values&.first
290
+ end
291
+
292
+ def select_values(sql, name = nil, binds = [])
293
+ sql, binds = to_sql_and_binds(sql, binds)
294
+ result = exec_query(sql, name, binds)
295
+ result.map { |row| row.values.first }
296
+ end
297
+
298
+ def select_rows(sql, name = nil, binds = [])
299
+ sql, binds = to_sql_and_binds(sql, binds)
300
+ result = exec_query(sql, name, binds)
301
+ result.map { |row| row.values }
302
+ end
303
+
304
+ # ==================== TRANSACTION METHODS ====================
305
+
306
+ def begin_db_transaction
307
+ @transaction_depth += 1
308
+ @connection.begin_db_transaction if @transaction_depth == 1
309
+ end
310
+
311
+ def commit_db_transaction
312
+ return if @transaction_depth <= 0
313
+
314
+ @transaction_depth -= 1
315
+ @connection.commit_db_transaction if @transaction_depth == 0
316
+ end
317
+
318
+ def rollback_db_transaction
319
+ return if @transaction_depth <= 0
320
+
321
+ @transaction_depth -= 1
322
+ @connection.rollback_db_transaction if @transaction_depth == 0
323
+ @transaction_depth = 0 if @transaction_depth < 0
324
+ end
325
+
326
+ def create_savepoint(name)
327
+ execute("SAVEPOINT #{name}")
328
+ end
329
+
330
+ def rollback_to_savepoint(name)
331
+ execute("ROLLBACK TO SAVEPOINT #{name}")
332
+ end
333
+
334
+ def release_savepoint(name)
335
+ execute("RELEASE SAVEPOINT #{name}")
336
+ end
337
+
338
+ def in_transaction?
339
+ @transaction_depth > 0
340
+ end
341
+
342
+ def transaction_joinable?
343
+ true
344
+ end
345
+
346
+ def transactional?
347
+ true
348
+ end
349
+
350
+ # ==================== SCHEMA STATEMENT METHODS ====================
351
+
352
+ def create_table(table_name, **options, &block)
353
+ RubyDB::Rails::SchemaStatements.instance_method(:create_table).bind_call(self, table_name, options, &block)
354
+ end
355
+
356
+ def drop_table(table_name, **options)
357
+ sql = +"DROP TABLE"
358
+ sql << " IF EXISTS" if options[:if_exists]
359
+ sql << " #{quote_table_name(table_name)}"
360
+ sql << " CASCADE" if options[:cascade]
361
+ execute(sql)
362
+ end
363
+
364
+ def add_column(table_name, column_name, type, **options)
365
+ sql = "ALTER TABLE #{quote_table_name(table_name)}"
366
+ sql << " ADD COLUMN #{quote_column_name(column_name)} #{type_to_sql(type, options)}"
367
+
368
+ if options[:null] == false
369
+ sql << " NOT NULL"
370
+ end
371
+
372
+ if options[:default]
373
+ sql << " DEFAULT #{quote_default(options[:default])}"
374
+ end
375
+
376
+ if options[:primary_key]
377
+ sql << " PRIMARY KEY"
378
+ end
379
+
380
+ execute(sql)
381
+ end
382
+
383
+ def remove_column(table_name, column_name, type = nil, **options)
384
+ sql = "ALTER TABLE #{quote_table_name(table_name)}"
385
+ sql << " DROP COLUMN #{quote_column_name(column_name)}"
386
+ sql << " CASCADE" if options[:cascade]
387
+ execute(sql)
388
+ end
389
+
390
+ def change_column(table_name, column_name, type, **options)
391
+ # Change column type
392
+ sql = "ALTER TABLE #{quote_table_name(table_name)}"
393
+ sql << " ALTER COLUMN #{quote_column_name(column_name)}"
394
+ sql << " TYPE #{type_to_sql(type, options)}"
395
+ execute(sql)
396
+
397
+ # Change nullability
398
+ if options.key?(:null)
399
+ sql = "ALTER TABLE #{quote_table_name(table_name)}"
400
+ sql << " ALTER COLUMN #{quote_column_name(column_name)}"
401
+ sql << (options[:null] ? " DROP" : " SET") + " NOT NULL"
402
+ execute(sql)
403
+ end
404
+
405
+ # Change default
406
+ if options.key?(:default)
407
+ sql = "ALTER TABLE #{quote_table_name(table_name)}"
408
+ sql << " ALTER COLUMN #{quote_column_name(column_name)}"
409
+ if options[:default].nil?
410
+ sql << " DROP DEFAULT"
411
+ else
412
+ sql << " SET DEFAULT #{quote_default(options[:default])}"
413
+ end
414
+ execute(sql)
415
+ end
416
+ end
417
+
418
+ def rename_column(table_name, column_name, new_column_name)
419
+ sql = "ALTER TABLE #{quote_table_name(table_name)}"
420
+ sql << " RENAME COLUMN #{quote_column_name(column_name)}"
421
+ sql << " TO #{quote_column_name(new_column_name)}"
422
+ execute(sql)
423
+ end
424
+
425
+ def rename_table(old_name, new_name)
426
+ sql = "ALTER TABLE #{quote_table_name(old_name)}"
427
+ sql << " RENAME TO #{quote_table_name(new_name)}"
428
+ execute(sql)
429
+ end
430
+
431
+ def add_index(table_name, column_name, **options)
432
+ index_name = options[:name] || "idx_#{table_name}_#{Array(column_name).join('_')}"
433
+ sql = +"CREATE"
434
+ sql << " UNIQUE" if options[:unique]
435
+ sql << " INDEX #{quote_column_name(index_name)}"
436
+ sql << " ON #{quote_table_name(table_name)}"
437
+ sql << " (#{Array(column_name).map { |c| quote_column_name(c) }.join(', ')})"
438
+ sql << " WHERE #{options[:where]}" if options[:where]
439
+ execute(sql)
440
+ end
441
+
442
+ def remove_index(table_name, column_name = nil, **options)
443
+ index_name = options[:name]
444
+ if index_name.nil?
445
+ column_name ||= options[:column] || options[:columns]
446
+ index_name = "idx_#{table_name}_#{Array(column_name).join('_')}"
447
+ end
448
+
449
+ sql = "DROP INDEX #{quote_column_name(index_name)}"
450
+ execute(sql)
451
+ end
452
+
453
+ def add_foreign_key(from_table, to_table, **options)
454
+ fk_name = options[:name] || "fk_#{from_table}_to_#{to_table}"
455
+ sql = "ALTER TABLE #{quote_table_name(from_table)}"
456
+ sql << " ADD CONSTRAINT #{quote_column_name(fk_name)}"
457
+ sql << " FOREIGN KEY (#{quote_column_name(options[:column] || :id)})"
458
+ sql << " REFERENCES #{quote_table_name(to_table)}"
459
+ sql << " (#{quote_column_name(options[:primary_key] || :id)})"
460
+ sql << " ON DELETE #{options[:on_delete]}" if options[:on_delete]
461
+ sql << " ON UPDATE #{options[:on_update]}" if options[:on_update]
462
+ execute(sql)
463
+ end
464
+
465
+ def foreign_keys(table_name)
466
+ return super unless embedded?
467
+
468
+ constraints = @connection.engine.table_metadata[table_name.to_s]&.fetch(:constraints, []) || []
469
+ constraints.filter_map do |constraint|
470
+ type = constraint[:type] || constraint["type"]
471
+ next unless type.to_s.upcase == "FOREIGN_KEY"
472
+
473
+ columns = constraint[:columns] || constraint["columns"] || []
474
+ reference_table = constraint[:reference_table] || constraint["reference_table"]
475
+ reference_columns = constraint[:reference_columns] || constraint["reference_columns"] || ["id"]
476
+ options = {
477
+ column: Array(columns).first.to_s,
478
+ primary_key: Array(reference_columns).first.to_s,
479
+ name: constraint[:name] || constraint["name"]
480
+ }
481
+ options[:on_delete] = (constraint[:on_delete] || constraint["on_delete"]).to_s if constraint[:on_delete] || constraint["on_delete"]
482
+ options[:on_update] = (constraint[:on_update] || constraint["on_update"]).to_s if constraint[:on_update] || constraint["on_update"]
483
+ ActiveRecord::ConnectionAdapters::ForeignKeyDefinition.new(table_name.to_s, reference_table.to_s, options)
484
+ end
485
+ end
486
+
487
+ def remove_foreign_key(from_table, **options)
488
+ fk_name = options[:name] || "fk_#{from_table}_to_#{options[:to_table]}"
489
+ sql = "ALTER TABLE #{quote_table_name(from_table)}"
490
+ sql << " DROP CONSTRAINT #{quote_column_name(fk_name)}"
491
+ execute(sql)
492
+ end
493
+
494
+ def add_timestamps(table_name, **options)
495
+ add_column(table_name, :created_at, :datetime, options)
496
+ add_column(table_name, :updated_at, :datetime, options)
497
+ end
498
+
499
+ def remove_timestamps(table_name, **options)
500
+ remove_column(table_name, :updated_at, options)
501
+ remove_column(table_name, :created_at, options)
502
+ end
503
+
504
+ def change_column_null(table_name, column_name, null, default = nil)
505
+ if default
506
+ sql = "UPDATE #{quote_table_name(table_name)}"
507
+ sql << " SET #{quote_column_name(column_name)} = #{quote(default)}"
508
+ sql << " WHERE #{quote_column_name(column_name)} IS NULL"
509
+ execute(sql)
510
+ end
511
+
512
+ sql = "ALTER TABLE #{quote_table_name(table_name)}"
513
+ sql << " ALTER COLUMN #{quote_column_name(column_name)}"
514
+ sql << (null ? " DROP" : " SET") + " NOT NULL"
515
+ execute(sql)
516
+ end
517
+
518
+ def change_column_default(table_name, column_name, default)
519
+ sql = "ALTER TABLE #{quote_table_name(table_name)}"
520
+ sql << " ALTER COLUMN #{quote_column_name(column_name)}"
521
+ if default.nil?
522
+ sql << " DROP DEFAULT"
523
+ else
524
+ sql << " SET DEFAULT #{quote_default(default)}"
525
+ end
526
+ execute(sql)
527
+ end
528
+
529
+ # ==================== QUOTING METHODS ====================
530
+
531
+ def quote(value, column = nil)
532
+ @connection.quote(value, column)
533
+ end
534
+
535
+ def quote_table_name(name)
536
+ @connection.quote_table_name(name)
537
+ end
538
+
539
+ def quote_column_name(name)
540
+ @connection.quote_column_name(name)
541
+ end
542
+
543
+ def quote_default(value)
544
+ quote(value)
545
+ end
546
+
547
+ # ==================== TYPE CASTING ====================
548
+
549
+ def type_cast(value, type)
550
+ RubyDB::Rails::Type.serialize(value, type)
551
+ end
552
+
553
+ def type_cast_from_database(value, type)
554
+ RubyDB::Rails::Type.deserialize(value, type)
555
+ end
556
+
557
+ # ==================== SCHEMA VERSION ====================
558
+
559
+ def schema_version
560
+ result = execute("SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1")
561
+ result.first ? result.first["version"] : nil
562
+ end
563
+
564
+ def schema_migrations
565
+ result = execute("SELECT version FROM schema_migrations ORDER BY version")
566
+ result.map { |row| row["version"] }
567
+ end
568
+
569
+ def dump_schema
570
+ schema = +""
571
+ tables.each do |table|
572
+ table_columns = columns(table)
573
+ primary_key_name = if embedded?
574
+ @connection.engine.table_columns(table).find(&:primary_key?)&.name
575
+ else
576
+ primary_key(table)
577
+ end
578
+ primary_key = table_columns.find { |column| column.name.to_s == primary_key_name.to_s } if primary_key_name
579
+ automatic_id = primary_key && primary_key_name.to_s == "id" && primary_key.type.to_sym == :integer
580
+ table_options = automatic_id ? "" : ", id: false"
581
+ schema << "create_table \"#{table}\"#{table_options} do |t|\n"
582
+ table_columns.each do |col|
583
+ next if automatic_id && primary_key && col.name.to_s == primary_key.name.to_s
584
+
585
+ type = RubyDB::Rails::Type.to_rails(col.type)
586
+ schema << " t.#{type} \"#{col.name}\""
587
+ schema << ", primary_key: true" if primary_key && col.name.to_s == primary_key.name.to_s
588
+ schema << ", default: #{schema_literal(col.default, col.type)}" unless col.default.nil?
589
+ schema << ", null: false" unless col.null
590
+ schema << "\n"
591
+ end
592
+ schema << "end\n\n"
593
+
594
+ indexes(table).each do |index|
595
+ schema << "add_index \"#{table}\", #{index.columns.map(&:to_s).inspect}"
596
+ schema << ", unique: true" if index.unique
597
+ schema << ", name: #{index.name.to_s.inspect}\n"
598
+ end
599
+ schema << "\n" if indexes(table).any?
600
+ end
601
+ schema
602
+ end
603
+
604
+ def schema_literal(value, type = nil)
605
+ if type.to_sym == :boolean && value.is_a?(String) && %w[true false].include?(value.downcase)
606
+ return value.downcase
607
+ end
608
+
609
+ case value
610
+ when true then "true"
611
+ when false then "false"
612
+ when Numeric then value.to_s
613
+ else value.to_s.inspect
614
+ end
615
+ end
616
+
617
+ # ==================== CONNECTION MANAGEMENT ====================
618
+
619
+ def reset!
620
+ @connection.disconnect
621
+ @connection.connect
622
+ @prepared_statements.clear
623
+ @query_cache.clear
624
+ @statements.clear
625
+ end
626
+
627
+ def disconnect!
628
+ @connection.disconnect
629
+ end
630
+
631
+ def reconnect!
632
+ reset!
633
+ end
634
+
635
+ def active?
636
+ @connection.connected?
637
+ end
638
+
639
+ def close
640
+ @connection.disconnect
641
+ end
642
+
643
+ # ==================== QUERY CACHE ====================
644
+
645
+ def clear_cache!
646
+ @query_cache.clear
647
+ end
648
+
649
+ def enable_query_cache!
650
+ @query_cache_enabled = true
651
+ @query_cache.clear
652
+ end
653
+
654
+ def disable_query_cache!
655
+ @query_cache_enabled = false
656
+ @query_cache.clear
657
+ end
658
+
659
+ def query_cache_enabled
660
+ @query_cache_enabled
661
+ end
662
+
663
+ # ==================== PREPARED STATEMENTS ====================
664
+
665
+ def prepare_statement(sql)
666
+ @lock.synchronize do
667
+ stmt_id = "stmt_#{Time.now.to_i}_#{@statement_counter}"
668
+ @statement_counter += 1
669
+
670
+ result = @connection.prepare(sql)
671
+ @prepared_statements[stmt_id] = {
672
+ id: result.statement_id,
673
+ sql: sql,
674
+ created_at: Time.now
675
+ }
676
+
677
+ stmt_id
678
+ end
679
+ end
680
+
681
+ def execute_prepared_statement(stmt_id, params = [])
682
+ @lock.synchronize do
683
+ stmt = @prepared_statements[stmt_id]
684
+ return nil unless stmt
685
+
686
+ @connection.execute_prepared(stmt[:id], params)
687
+ end
688
+ end
689
+
690
+ def close_statement(stmt_id)
691
+ @lock.synchronize do
692
+ stmt = @prepared_statements.delete(stmt_id)
693
+ if stmt
694
+ @connection.close_statement(stmt[:id])
695
+ end
696
+ end
697
+ end
698
+
699
+ # ==================== VERSION INFORMATION ====================
700
+
701
+ def dbms_version
702
+ RubyDB::VERSION
703
+ end
704
+
705
+ # ==================== FEATURE SUPPORT ====================
706
+
707
+ def supports_datetime_with_precision?
708
+ true
709
+ end
710
+
711
+ def supports_materialized_views?
712
+ false
713
+ end
714
+
715
+ def supports_common_table_expressions?
716
+ false
717
+ end
718
+
719
+ # ==================== PRIVATE METHODS ====================
720
+
721
+ private
722
+
723
+ def embedded?
724
+ !@connection.engine.nil?
725
+ end
726
+
727
+ def embedded_columns(table_name)
728
+ @connection.engine.table_columns(table_name).map do |column|
729
+ ActiveRecord::ConnectionAdapters::Column.new(
730
+ column.name.to_s,
731
+ column.has_default? ? rails_default_value(column.default) : nil,
732
+ ActiveRecord::ConnectionAdapters::SqlTypeMetadata.new(
733
+ sql_type: column.type.to_s.upcase,
734
+ type: rails_type_for(column.type),
735
+ limit: column.options[:limit]
736
+ ),
737
+ column.nullable?
738
+ )
739
+ end
740
+ end
741
+
742
+ def rails_type_for(type)
743
+ case type.to_sym
744
+ when :integer, :bigint, :smallint then :integer
745
+ when :float then :float
746
+ when :decimal then :decimal
747
+ when :boolean then :boolean
748
+ when :date then :date
749
+ when :time then :time
750
+ when :datetime, :timestamp then :datetime
751
+ when :binary, :blob then :binary
752
+ when :json then :json
753
+ else :string
754
+ end
755
+ end
756
+
757
+ # ActiveRecord's generic Column deduplication is string-oriented. RubyDB
758
+ # persists typed defaults, so serialize scalar defaults at this boundary
759
+ # and let ActiveRecord cast them through the column type map.
760
+ def rails_default_value(value)
761
+ value.is_a?(String) ? value : value.to_s
762
+ end
763
+
764
+ def sql_for_execution(sql)
765
+ sql = sql.to_sql if sql.respond_to?(:to_sql)
766
+ sql
767
+ end
768
+
769
+ def active_record_result(result)
770
+ rows = result.to_a
771
+ columns = if rows.first.respond_to?(:keys)
772
+ rows.first.keys.map(&:to_s)
773
+ else
774
+ result.columns.map do |column|
775
+ column.is_a?(Hash) ? (column[:name] || column["name"] || column) : column
776
+ end.map(&:to_s)
777
+ end
778
+ values = rows.map do |row|
779
+ columns.map do |column|
780
+ if row.respond_to?(:key?) && row.key?(column)
781
+ row[column]
782
+ elsif row.respond_to?(:key?) && row.key?(column.to_sym)
783
+ row[column.to_sym]
784
+ end
785
+ end
786
+ end
787
+ ActiveRecord::Result.new(columns, values)
788
+ end
789
+
790
+ # ActiveRecord normally supplies QueryAttribute objects, but migration
791
+ # and schema code can also pass raw values or two-element bind pairs.
792
+ # Normalize all supported forms at the adapter boundary.
793
+ def bind_values(binds)
794
+ binds.map do |bind|
795
+ value = if bind.respond_to?(:value_for_database)
796
+ bind.value_for_database
797
+ elsif bind.respond_to?(:value)
798
+ bind.value
799
+ elsif bind.is_a?(Array) && bind.length == 2
800
+ bind.last
801
+ else
802
+ bind
803
+ end
804
+ value.respond_to?(:value_for_database) ? value.value_for_database : value
805
+ end
806
+ end
807
+
808
+ def parse_index_columns(sql)
809
+ if sql =~ /\(([^)]+)\)/
810
+ $1.split(",").map(&:strip)
811
+ else
812
+ []
813
+ end
814
+ end
815
+
816
+ def extract_limit(type)
817
+ if type =~ /VARCHAR\((\d+)\)/
818
+ $1.to_i
819
+ else
820
+ nil
821
+ end
822
+ end
823
+
824
+ def log(sql, name = nil, &block)
825
+ start_time = Time.now
826
+ result = block.call
827
+ elapsed_ms = (Time.now - start_time) * 1000
828
+
829
+ if @logger
830
+ @logger.debug " #{name || 'SQL'} (#{elapsed_ms.round(2)}ms) #{sql}"
831
+ end
832
+
833
+ result
834
+ end
835
+
836
+ def type_to_sql(type, options = {})
837
+ case type.to_sym
838
+ when :integer
839
+ "INTEGER"
840
+ when :bigint
841
+ "BIGINT"
842
+ when :smallint
843
+ "SMALLINT"
844
+ when :float
845
+ "FLOAT"
846
+ when :decimal
847
+ precision = options[:precision] || 10
848
+ scale = options[:scale] || 2
849
+ "DECIMAL(#{precision}, #{scale})"
850
+ when :boolean
851
+ "BOOLEAN"
852
+ when :text
853
+ "TEXT"
854
+ when :string
855
+ limit = options[:limit] || 255
856
+ "VARCHAR(#{limit})"
857
+ when :binary
858
+ "BLOB"
859
+ when :date
860
+ "DATE"
861
+ when :time
862
+ "TIME"
863
+ when :datetime, :timestamp
864
+ "TIMESTAMP"
865
+ when :json
866
+ "JSON"
867
+ when :uuid
868
+ "UUID"
869
+ else
870
+ "TEXT"
871
+ end
872
+ end
873
+ end
874
+ end
875
+ end
876
+
877
+ # Rails 7.2 introduced explicit adapter registration. Rails 7.1 loads custom
878
+ # adapters through the conventional `rubydb_connection` hook instead.
879
+ if ActiveRecord::ConnectionAdapters.respond_to?(:register)
880
+ ActiveRecord::ConnectionAdapters.register("rubydb", "ActiveRecord::ConnectionAdapters::RubyDBAdapter")
881
+ else
882
+ module ActiveRecord
883
+ module ConnectionHandling
884
+ def rubydb_adapter_class
885
+ ConnectionAdapters::RubyDBAdapter
886
+ end
887
+
888
+ def rubydb_connection(config)
889
+ rubydb_adapter_class.new(config)
890
+ end
891
+ end
892
+ end
893
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "rubydb-activerecord"
5
+ spec.version = "0.1.0"
6
+ spec.authors = ["Aldane Hutchinson"]
7
+ spec.email = ["aldanehutchinson5@gmail.com"]
8
+
9
+ spec.summary = "ActiveRecord adapter for RubyDB"
10
+ spec.description = "ActiveRecord adapter for the RubyDB database"
11
+ spec.homepage = "https://github.com/aldanedev-create/rubydb"
12
+ spec.license = "MIT"
13
+ # rubydb itself requires Ruby 3.3 or newer. Keep the adapter's runtime
14
+ # contract aligned so Bundler cannot select an unsupported combination.
15
+ spec.required_ruby_version = ">= 3.3.0"
16
+
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+ spec.metadata["source_code_uri"] = "#{spec.homepage}/tree/main/adapters/activerecord"
19
+ spec.metadata["bug_tracker_uri"] = "https://github.com/aldanedev-create/rubydb/issues"
20
+ spec.metadata["changelog_uri"] = "https://github.com/aldanedev-create/rubydb/blob/main/CHANGELOG.md"
21
+ spec.metadata["documentation_uri"] = "https://github.com/aldanedev-create/rubydb/tree/main/docs"
22
+
23
+ spec.files = Dir.glob("lib/**/*.rb") + %w[README.md rubydb-activerecord.gemspec]
24
+ spec.require_paths = ["lib"]
25
+
26
+ spec.add_dependency "activerecord", ">= 7.1", "< 8.1"
27
+ spec.add_dependency "rubydb", "~> 0.1.0"
28
+
29
+ spec.add_development_dependency "rake", "~> 13.2"
30
+ spec.add_development_dependency "rspec", "~> 3.13"
31
+ spec.add_development_dependency "rubocop", "~> 1.60"
32
+ end
metadata ADDED
@@ -0,0 +1,124 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rubydb-activerecord
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Aldane Hutchinson
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '8.1'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '7.1'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '8.1'
32
+ - !ruby/object:Gem::Dependency
33
+ name: rubydb
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - "~>"
37
+ - !ruby/object:Gem::Version
38
+ version: 0.1.0
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - "~>"
44
+ - !ruby/object:Gem::Version
45
+ version: 0.1.0
46
+ - !ruby/object:Gem::Dependency
47
+ name: rake
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - "~>"
51
+ - !ruby/object:Gem::Version
52
+ version: '13.2'
53
+ type: :development
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '13.2'
60
+ - !ruby/object:Gem::Dependency
61
+ name: rspec
62
+ requirement: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - "~>"
65
+ - !ruby/object:Gem::Version
66
+ version: '3.13'
67
+ type: :development
68
+ prerelease: false
69
+ version_requirements: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - "~>"
72
+ - !ruby/object:Gem::Version
73
+ version: '3.13'
74
+ - !ruby/object:Gem::Dependency
75
+ name: rubocop
76
+ requirement: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - "~>"
79
+ - !ruby/object:Gem::Version
80
+ version: '1.60'
81
+ type: :development
82
+ prerelease: false
83
+ version_requirements: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - "~>"
86
+ - !ruby/object:Gem::Version
87
+ version: '1.60'
88
+ description: ActiveRecord adapter for the RubyDB database
89
+ email:
90
+ - aldanehutchinson5@gmail.com
91
+ executables: []
92
+ extensions: []
93
+ extra_rdoc_files: []
94
+ files:
95
+ - README.md
96
+ - lib/active_record/connection_adapters/rubydb_adapter.rb
97
+ - rubydb-activerecord.gemspec
98
+ homepage: https://github.com/aldanedev-create/rubydb
99
+ licenses:
100
+ - MIT
101
+ metadata:
102
+ homepage_uri: https://github.com/aldanedev-create/rubydb
103
+ source_code_uri: https://github.com/aldanedev-create/rubydb/tree/main/adapters/activerecord
104
+ bug_tracker_uri: https://github.com/aldanedev-create/rubydb/issues
105
+ changelog_uri: https://github.com/aldanedev-create/rubydb/blob/main/CHANGELOG.md
106
+ documentation_uri: https://github.com/aldanedev-create/rubydb/tree/main/docs
107
+ rdoc_options: []
108
+ require_paths:
109
+ - lib
110
+ required_ruby_version: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - ">="
113
+ - !ruby/object:Gem::Version
114
+ version: 3.3.0
115
+ required_rubygems_version: !ruby/object:Gem::Requirement
116
+ requirements:
117
+ - - ">="
118
+ - !ruby/object:Gem::Version
119
+ version: '0'
120
+ requirements: []
121
+ rubygems_version: 4.0.16
122
+ specification_version: 4
123
+ summary: ActiveRecord adapter for RubyDB
124
+ test_files: []