rubydb 0.1.1 → 0.1.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5005f9218456b8961e7cb6c61ea685d10cc327765f9509a49a766b3389634132
4
- data.tar.gz: 878e67e40ece7e731e9aae8f412f3e0733f43e56202e83d77212d44c032ef191
3
+ metadata.gz: 3c076e3acf3d8cf15a98581eb92daafdbe2aba8a40805279c08c957aece8a601
4
+ data.tar.gz: 36eeb2c1ea39757ee1c759af4f1abefea987ba28f0929642258f289c3196df66
5
5
  SHA512:
6
- metadata.gz: c81b88aa18802979d5320534cf7ee7270e84a6c48b2a9528b8bec135439f5c84c3a0b45a7e3012d6a3fbdbdb6b226f4ed29dc9a3edbe86333b2e0eb633214b5c
7
- data.tar.gz: 1fe32bd686090053c717631b97a6e996094c5284c77c452ede991d18aefc248616d905c44f12a2dae725bb19aa7f603e235ba8c9fe6a97ca121cef38f24d3237
6
+ metadata.gz: 358853343b4781a04e72e1e9910ed4c8fe562f4e56a9d3b2c27bb6b962da78bc3eeabfca4efbfa3b83c8e8d0f3295b368701b38fbc2baab361109bfe173ca3b8
7
+ data.tar.gz: a0a90a0cd04781b3dd35bbd94a46513debdf69195e09fadd677d21d27b005db874d048b8281d432f68350f86f21c64ceb39b24fa4ae17ee6903034782ca8dc4a
data/CHANGELOG.md CHANGED
@@ -23,6 +23,22 @@ All notable changes to RubyDB are documented here. Versions follow
23
23
  vacuum, maintenance, and release workflows.
24
24
  - Clarified the tested common SQLite-style profile and production limits.
25
25
 
26
+ ## 0.1.3 - 2026-09-09
27
+
28
+ - Normalized numeric MVCC visibility-map keys after restart to prevent mixed
29
+ string/integer keys and duplicate-key warnings on Ruby 4.
30
+ - Made visibility-map persistence failures raise `RubyDB::StorageError`
31
+ instead of silently reporting success.
32
+
33
+ ## 0.1.2 - 2026-09-09
34
+
35
+ - Added SQLite/ActiveRecord-compatible `INSERT ... DEFAULT VALUES` parsing,
36
+ planning, execution, default materialization, and regression coverage.
37
+ - Normalized literal schema defaults before they reach physical rows so empty
38
+ strings and other scalar defaults are not persisted as AST wrapper objects.
39
+ - Released `rubydb-activerecord` 0.1.1 with scalar default unwrapping for
40
+ ActiveRecord column metadata.
41
+
26
42
  ## 0.1.1 - 2026-09-09
27
43
 
28
44
  - Published the Rails connection configuration fixes for embedded database
@@ -756,10 +756,15 @@ module ActiveRecord
756
756
 
757
757
  # ActiveRecord's generic Column deduplication is string-oriented. RubyDB
758
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
759
+ # and let ActiveRecord cast them through the column type map.
760
+ def rails_default_value(value)
761
+ # RubyDB exposes SQL defaults as AST literals. ActiveRecord expects
762
+ # the scalar payload when it builds its Column metadata; calling
763
+ # `to_s` on the wrapper would leak the Ruby object inspection into
764
+ # newly instantiated records.
765
+ value = value.value if value.respond_to?(:value) && !value.is_a?(String)
766
+ value.is_a?(String) ? value : value.to_s
767
+ end
763
768
 
764
769
  def sql_for_execution(sql)
765
770
  sql = sql.to_sql if sql.respond_to?(:to_sql)
@@ -2,7 +2,7 @@
2
2
 
3
3
  Gem::Specification.new do |spec|
4
4
  spec.name = "rubydb-activerecord"
5
- spec.version = "0.1.0"
5
+ spec.version = "0.1.1"
6
6
  spec.authors = ["Aldane Hutchinson"]
7
7
  spec.email = ["aldanehutchinson5@gmail.com"]
8
8
 
@@ -51,7 +51,7 @@ RSpec.describe ActiveRecord::ConnectionAdapters::RubyDBAdapter do
51
51
  expect(result.first).to include("enabled" => false, "attempts" => 0)
52
52
  end
53
53
 
54
- it "runs a Rails migration that creates a table, adds a column, and adds an index" do
54
+ it "runs a Rails migration that creates a table, adds a column, and adds an index" do
55
55
  migration = Class.new(ActiveRecord::Migration[migration_version]) do
56
56
  def change
57
57
  create_table :projects do |table|
@@ -74,10 +74,22 @@ RSpec.describe ActiveRecord::ConnectionAdapters::RubyDBAdapter do
74
74
  expect(schema).not_to include('t.integer "id"')
75
75
 
76
76
  migration.new.migrate(:down)
77
- expect(connection.table_exists?(:projects)).to be(false)
78
- end
79
-
80
- it "executes an ActiveRecord association join with qualified filtering" do
77
+ expect(connection.table_exists?(:projects)).to be(false)
78
+ end
79
+
80
+ it "exposes scalar schema defaults to ActiveRecord" do
81
+ connection = ActiveRecord::Base.connection
82
+ connection.execute("CREATE TABLE settings (id INTEGER PRIMARY KEY, label VARCHAR(255) NOT NULL DEFAULT '')")
83
+
84
+ settings_model = Class.new(ActiveRecord::Base) do
85
+ self.table_name = "settings"
86
+ end
87
+
88
+ expect(settings_model.new.label).to eq("")
89
+ expect(settings_model.create!.reload.label).to eq("")
90
+ end
91
+
92
+ it "executes an ActiveRecord association join with qualified filtering" do
81
93
  stub_const("RubydbAccount", Class.new(ActiveRecord::Base) do
82
94
  self.table_name = "accounts"
83
95
  has_many :rubydb_projects, class_name: "RubydbProject", foreign_key: :account_id
@@ -124,14 +124,15 @@ module RubyDB
124
124
  end
125
125
 
126
126
  class Insert < Plan
127
- attr_reader :values, :rows, :on_conflict
128
-
129
- def initialize(table_name, columns = [], values = [], rows: nil, on_conflict: nil)
130
- super(:insert, table_name, columns)
131
- @rows = rows || [values]
132
- @values = @rows.first || []
133
- @on_conflict = on_conflict
134
- end
127
+ attr_reader :values, :rows, :on_conflict, :default_values
128
+
129
+ def initialize(table_name, columns = [], values = [], rows: nil, on_conflict: nil, default_values: false)
130
+ super(:insert, table_name, columns)
131
+ @rows = rows || [values]
132
+ @values = @rows.first || []
133
+ @on_conflict = on_conflict
134
+ @default_values = default_values
135
+ end
135
136
  end
136
137
 
137
138
  class Update < Plan
@@ -154,12 +154,13 @@ module RubyDB
154
154
 
155
155
  def plan_insert(statement)
156
156
  Plan::Insert.new(
157
- statement.table,
158
- statement.columns,
159
- statement.values,
160
- rows: statement.rows,
161
- on_conflict: statement.on_conflict
162
- )
157
+ statement.table,
158
+ statement.columns,
159
+ statement.values,
160
+ rows: statement.rows,
161
+ on_conflict: statement.on_conflict,
162
+ default_values: statement.default_values?
163
+ )
163
164
  end
164
165
 
165
166
  def plan_update(statement)
@@ -186,10 +187,13 @@ module RubyDB
186
187
  plan
187
188
  end
188
189
 
189
- def plan_create_table(statement)
190
- columns = statement.columns.map do |col|
191
- Catalog::Column.new(col.name, col.type_class, **col.options)
192
- end
190
+ def plan_create_table(statement)
191
+ columns = statement.columns.map do |col|
192
+ options = col.options.dup
193
+ default = options[:default]
194
+ options[:default] = default.value if default.respond_to?(:value) && !default.is_a?(String)
195
+ Catalog::Column.new(col.name, col.type_class, **options)
196
+ end
193
197
 
194
198
  Plan::CreateTable.new(
195
199
  statement.name,
@@ -5,15 +5,17 @@ module RubyDB
5
5
  module AST
6
6
  # INSERT statement AST node
7
7
  class Insert < Node
8
- attr_reader :table, :columns, :values, :rows, :on_conflict
9
-
10
- def initialize(table, columns = [], values = [], rows: nil, on_conflict: nil, location: nil)
11
- super(location: location)
12
- @table = table
13
- @columns = columns
14
- @rows = rows || [values]
15
- @values = @rows.first || []
16
- @on_conflict = on_conflict
8
+ attr_reader :table, :columns, :values, :rows, :on_conflict
9
+ attr_reader :default_values
10
+
11
+ def initialize(table, columns = [], values = [], rows: nil, on_conflict: nil, default_values: false, location: nil)
12
+ super(location: location)
13
+ @table = table
14
+ @columns = columns
15
+ @rows = rows || [values]
16
+ @values = @rows.first || []
17
+ @on_conflict = on_conflict
18
+ @default_values = default_values
17
19
  end
18
20
 
19
21
  def accept(visitor)
@@ -24,8 +26,9 @@ module RubyDB
24
26
  Insert.new(
25
27
  @table,
26
28
  @columns.dup,
27
- @values.map(&:clone), rows: @rows.map { |row| row.map(&:clone) }, on_conflict: @on_conflict,
28
- location: @location
29
+ @values.map(&:clone), rows: @rows.map { |row| row.map(&:clone) }, on_conflict: @on_conflict,
30
+ default_values: @default_values,
31
+ location: @location
29
32
  )
30
33
  end
31
34
 
@@ -37,8 +40,12 @@ module RubyDB
37
40
  parts << "(#{@columns.join(", ")})"
38
41
  end
39
42
 
40
- parts << "VALUES"
41
- parts << @rows.map { |row| "(#{row.map(&:to_sql).join(", ")})" }.join(", ")
43
+ if @default_values
44
+ parts << "DEFAULT VALUES"
45
+ else
46
+ parts << "VALUES"
47
+ parts << @rows.map { |row| "(#{row.map(&:to_sql).join(", ")})" }.join(", ")
48
+ end
42
49
  parts << "ON CONFLICT DO NOTHING" if @on_conflict == :nothing
43
50
  if @on_conflict.is_a?(Hash) && @on_conflict[:action] == :update
44
51
  target = @on_conflict[:target].any? ? " (#{@on_conflict[:target].join(', ')})" : ""
@@ -56,9 +63,13 @@ module RubyDB
56
63
  end
57
64
 
58
65
  # Helper methods
59
- def has_columns?
60
- @columns.any?
61
- end
66
+ def has_columns?
67
+ @columns.any?
68
+ end
69
+
70
+ def default_values?
71
+ @default_values
72
+ end
62
73
 
63
74
  def value_count
64
75
  @values.size
@@ -539,21 +539,30 @@ module RubyDB
539
539
  expect(Token::Type::RPAREN)
540
540
  end
541
541
 
542
- expect(Token::Type::VALUES)
543
- rows = []
544
- loop do
545
- expect(Token::Type::LPAREN)
546
- values = []
547
- while true
548
- values << parse_expression
549
- break unless current_token&.type == Token::Type::COMMA
550
- advance
551
- end
552
- expect(Token::Type::RPAREN)
553
- rows << values
554
- break unless current_token&.type == Token::Type::COMMA
555
- advance
556
- end
542
+ default_values = false
543
+ if current_token&.type == Token::Type::DEFAULT
544
+ advance
545
+ expect(Token::Type::VALUES)
546
+ default_values = true
547
+ else
548
+ expect(Token::Type::VALUES)
549
+ end
550
+ rows = default_values ? [[]] : []
551
+ unless default_values
552
+ loop do
553
+ expect(Token::Type::LPAREN)
554
+ values = []
555
+ while true
556
+ values << parse_expression
557
+ break unless current_token&.type == Token::Type::COMMA
558
+ advance
559
+ end
560
+ expect(Token::Type::RPAREN)
561
+ rows << values
562
+ break unless current_token&.type == Token::Type::COMMA
563
+ advance
564
+ end
565
+ end
557
566
 
558
567
  on_conflict = nil
559
568
  if current_token&.type == Token::Type::ON
@@ -589,8 +598,9 @@ module RubyDB
589
598
  raise ParserError, "Expected NOTHING or UPDATE after ON CONFLICT DO"
590
599
  end
591
600
  end
592
- AST::Insert.new(table, columns, rows.first || [], rows: rows, on_conflict: on_conflict)
593
- end
601
+ AST::Insert.new(table, columns, rows.first || [], rows: rows, on_conflict: on_conflict,
602
+ default_values: default_values)
603
+ end
594
604
 
595
605
  def parse_update
596
606
  expect(Token::Type::UPDATE)
@@ -675,16 +675,32 @@ module RubyDB
675
675
  end
676
676
 
677
677
  # Row operations
678
- def insert_row(table_name, columns, values)
679
- ensure_writable!
680
- table_name = resolve_table_name(table_name)
681
- @lock.synchronize do
678
+ def insert_row(table_name, columns, values)
679
+ ensure_writable!
680
+ table_name = resolve_table_name(table_name)
681
+ @lock.synchronize do
682
682
  @stats[:row_inserts] += 1
683
683
 
684
- metadata = @table_metadata[table_name]
685
- raise DatabaseError, "Table '#{table_name}' does not exist" unless metadata
686
-
687
- # Rails and other SQL clients omit an INTEGER primary key on insert
684
+ metadata = @table_metadata[table_name]
685
+ raise DatabaseError, "Table '#{table_name}' does not exist" unless metadata
686
+
687
+ # SQL INSERT may omit columns entirely (DEFAULT VALUES) or omit
688
+ # only selected columns. Materialize declared defaults before
689
+ # constraint validation so NOT NULL defaults are valid and the
690
+ # physical row contains scalar values rather than AST wrappers.
691
+ if values.is_a?(Hash)
692
+ values = values.dup
693
+ columns.each do |column|
694
+ present = values.key?(column.name) || values.key?(column.name.to_sym)
695
+ next if present || !column.has_default?
696
+
697
+ default = column.default
698
+ default = default.value if default.respond_to?(:value)
699
+ values[column.name] = default
700
+ end
701
+ end
702
+
703
+ # Rails and other SQL clients omit an INTEGER primary key on insert
688
704
  # and expect the database to allocate it. Keep allocation in the
689
705
  # engine so embedded and server connections have identical behavior.
690
706
  if values.is_a?(Hash)
@@ -686,11 +686,13 @@ module RubyDB
686
686
  end
687
687
  end
688
688
 
689
- @active_transactions = parsed[:active_transactions] || {}
690
- @committed_transactions = Set.new(parsed[:committed_transactions] || [])
691
- @aborted_transactions = Set.new(parsed[:aborted_transactions] || [])
692
- @next_version_id = parsed[:next_version_id] || 1
693
- @row_version_chains = parsed[:row_version_chains] || {}
689
+ @active_transactions = normalize_numeric_keyed_hash(parsed[:active_transactions])
690
+ @committed_transactions = Set.new(parsed[:committed_transactions] || [])
691
+ @aborted_transactions = Set.new(parsed[:aborted_transactions] || [])
692
+ @next_version_id = parsed[:next_version_id] || 1
693
+ @row_version_chains = normalize_numeric_keyed_hash(parsed[:row_version_chains]) do |versions|
694
+ Array(versions).map(&:to_i)
695
+ end
694
696
 
695
697
  # Clean up any invalid data
696
698
  @active_transactions.each do |tx_id, info|
@@ -727,24 +729,35 @@ module RubyDB
727
729
 
728
730
  # Write to a temp file first, then rename
729
731
  temp_path = "#{visibility_path}.tmp"
730
- File.write(temp_path, JSON.generate(data))
731
- FileUtils.mv(temp_path, visibility_path)
732
-
733
- true
734
- rescue => e
735
- false
736
- end
737
- end
738
- end
732
+ File.write(temp_path, JSON.generate(data))
733
+ FileUtils.mv(temp_path, visibility_path)
734
+
735
+ true
736
+ rescue => e
737
+ File.delete(temp_path) if defined?(temp_path) && File.file?(temp_path)
738
+ raise StorageError, "Failed to flush visibility map: #{e.message}"
739
+ end
740
+ end
741
+ end
739
742
 
740
743
  # Remove a row from disk storage
741
744
  def remove_row_from_disk(row_id)
742
745
  true
743
746
  end
744
747
 
745
- def remember_version(row_id, info)
746
- @version_history[row_id][info[:version].to_i] = info.dup
747
- end
748
+ def remember_version(row_id, info)
749
+ @version_history[row_id][info[:version].to_i] = info.dup
750
+ end
751
+
752
+ # JSON object keys are strings. Normalize numeric-keyed runtime maps on
753
+ # load so a reopened database cannot accumulate both "1" and 1 keys.
754
+ def normalize_numeric_keyed_hash(value)
755
+ normalized = {}
756
+ (value || {}).each do |key, entry|
757
+ normalized[key.to_s.to_i] = block_given? ? yield(entry) : entry
758
+ end
759
+ normalized
760
+ end
748
761
 
749
762
  def normalize_loaded_info(info)
750
763
  info.each_with_object({}) do |(key, value), normalized|
@@ -11,13 +11,13 @@ module RubyDB
11
11
  # - MAJOR: Incompatible API changes
12
12
  # - MINOR: Backwards-compatible new functionality
13
13
  # - PATCH: Backwards-compatible bug fixes
14
- VERSION = "0.1.1"
14
+ VERSION = "0.1.3"
15
15
 
16
16
  # Version components for easy access
17
17
  module Version
18
18
  MAJOR = 0
19
19
  MINOR = 1
20
- PATCH = 1
20
+ PATCH = 3
21
21
  PRE = nil # e.g., "alpha", "beta", "rc1"
22
22
 
23
23
  def self.to_s
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubydb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Aldane Hutchinson