declare_schema 3.0.0 → 3.1.0.colin.1

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: d9bc6d7a94a1064cdeca883fbd1f40063771b12aff133925c1b83fcb83affebf
4
- data.tar.gz: 6cc522f1d5b5bf855451e5985e8f8083ac610489625c71fdb9e1cb9d713ed751
3
+ metadata.gz: 1c6d94bcff3ece2896188d31c3e5cf4d66474ab4ef5082537037b9c037bfe364
4
+ data.tar.gz: fea64238065955c2a3b6a16f5d5f3ee1da5b65d09da8a808b5f11c66b575b602
5
5
  SHA512:
6
- metadata.gz: f66aa50f211b7252ca146b72af859b90b5802025c99f2395379f0bdc7511b32b01918d56ed752edce00fdcfab17c47b2c218816581bd71ed148a026961e8fe54
7
- data.tar.gz: d8b7b93ab90a18b3dda1ed6b19083b3f087aa8c35143e901247f9a44b67fac81f1c40be2e4d980fc4b38fcd29605912ee53a868451f3648ac9d210ae879c696f
6
+ metadata.gz: 675ddf024002056463139774a6cdca78f7b157f8de5e07978b945552858d462e3ba59284a42fe169f4395adb793ce4027207e272d721085c638987c49819a6cd
7
+ data.tar.gz: 667e85cc287311fa014a0729da207ec763749196b07a0692a746aaa5348e3f48d23fb18860b7ee0ce7a8b50b151a9003f0fb7f460e9b39ce5d14a72838adc0cd
data/CHANGELOG.md CHANGED
@@ -4,6 +4,10 @@ Inspired by [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
4
4
 
5
5
  Note: this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [3.1.0] - Unreleased
8
+ ### Added
9
+ - Add HABTM support for arbitrary primary key in the referenced table (rather than just :bigint).
10
+
7
11
  ## [3.0.0] - 2025-04-08
8
12
  ### Changed
9
13
  - The `timestamps` DSL method to create `created_at` and `updated_at` columns now defaults to `null: false` for `datetime` columns
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- declare_schema (3.0.0)
4
+ declare_schema (3.1.0.colin.1)
5
5
  rails (>= 6.0)
6
6
 
7
7
  GEM
@@ -3,7 +3,6 @@
3
3
  require 'active_record'
4
4
  require 'declare_schema/dsl'
5
5
  require 'declare_schema/model'
6
- require 'declare_schema/field_declaration_dsl'
7
6
 
8
7
  module DeclareSchema
9
8
  module Macros
@@ -47,10 +47,6 @@ module DeclareSchema
47
47
  end
48
48
 
49
49
  def initialize(model, name, type, position: 0, **options)
50
- _declared_primary_key = model._declared_primary_key
51
-
52
- name.to_s == _declared_primary_key and raise ArgumentError, "you may not provide a field spec for the primary key #{name.inspect}"
53
-
54
50
  @model = model
55
51
  @name = name.to_sym
56
52
  type.is_a?(Symbol) or raise ArgumentError, "type must be a Symbol; got #{type.inspect}"
@@ -124,6 +120,10 @@ module DeclareSchema
124
120
  @sql_options = @options.slice(*SQL_OPTIONS)
125
121
  end
126
122
 
123
+ def foreign_key_field_spec(model, name, position: 0, null: nil)
124
+ self.class.new(model, name, @type, position:, **@options.merge(null.nil? ? {} : { null: }))
125
+ end
126
+
127
127
  # returns the attributes for schema migrations as a Hash
128
128
  # omits name and position since those are meta-data above the schema
129
129
  # omits keys with nil values
@@ -6,22 +6,28 @@ module DeclareSchema
6
6
  class << self
7
7
  def from_reflection(reflection)
8
8
  new(reflection.join_table,
9
- [reflection.foreign_key, reflection.association_foreign_key],
10
- [reflection.active_record.table_name, reflection.klass.table_name],
9
+ [
10
+ [reflection.foreign_key, reflection.active_record],
11
+ [reflection.association_foreign_key, reflection.klass]
12
+ ],
11
13
  connection: reflection.active_record.connection)
12
14
  end
13
15
  end
14
16
 
15
- attr_reader :join_table, :foreign_keys, :parent_table_names, :connection
17
+ attr_reader :join_table, :foreign_keys, :parent_models, :parent_table_names, :connection
16
18
 
17
- def initialize(join_table, foreign_keys, parent_table_names, connection:)
18
- foreign_keys.is_a?(Array) && foreign_keys.size == 2 or
19
- raise ArgumentError, "foreign_keys must be <Array[2]>; got #{foreign_keys.inspect}"
20
- parent_table_names.is_a?(Array) && parent_table_names.size == 2 or
21
- raise ArgumentError, "parent_table_names must be <Array[2]>; got #{parent_table_names.inspect}"
19
+ def initialize(join_table, parents, connection:)
22
20
  @join_table = join_table
23
- @foreign_keys = foreign_keys.sort # Rails requires these be in alphabetical order
24
- @parent_table_names = @foreign_keys == foreign_keys ? parent_table_names : parent_table_names.reverse # match the above sort
21
+
22
+ parents.is_a?(Array) && parents.size == 2 or
23
+ raise ArgumentError, "parents must be <Array[2]>; got #{parents.inspect}"
24
+
25
+ # Rails requires HABTM foreign keys to be in alphabetical order, so we start by sorting by those
26
+ parents.sort_by!(&:first)
27
+ @foreign_keys = parents.map(&:first)
28
+ @parent_models = parents.map(&:last)
29
+ @parent_table_names = parent_models.map(&:table_name)
30
+
25
31
  @connection = connection
26
32
  end
27
33
 
@@ -35,11 +41,12 @@ module DeclareSchema
35
41
 
36
42
  def field_specs
37
43
  foreign_keys.each_with_index.each_with_object({}) do |(foreign_key, i), result|
38
- result[foreign_key] = ::DeclareSchema::Model::FieldSpec.new(self, foreign_key, :bigint, position: i, null: false)
44
+ result[foreign_key] = parent_models[i]._foreign_key_field_spec(self, foreign_key, position: i, null: false)
39
45
  end
40
46
  end
41
47
 
42
48
  def primary_key
49
+ # TODO: ActiveRecord now supports composite primary keys, so we could return that here.
43
50
  false # no single-column primary key in database
44
51
  end
45
52
 
@@ -100,6 +100,7 @@ module DeclareSchema
100
100
  _add_validations_for_field(name, type, args, options)
101
101
  _add_index_for_field(name, args, **options)
102
102
  _add_scopes_for_field(name, type, **options)
103
+ name.to_s == _declared_primary_key and raise ArgumentError, "no need to declare a field spec for the primary key #{name.inspect}"
103
104
  field_specs[name] = ::DeclareSchema::Model::FieldSpec.new(self, name, type, position: field_specs.size, **options)
104
105
  attr_order << name unless attr_order.include?(name)
105
106
  end
@@ -201,62 +202,44 @@ module DeclareSchema
201
202
  super
202
203
 
203
204
  reflection = reflections[name.to_s] or raise "Couldn't find reflection #{name} in #{reflections.keys}"
204
- foreign_key_column = reflection.foreign_key or raise "Couldn't find foreign_key for #{name} in #{reflection.inspect}"
205
- foreign_key_column_options = column_options.dup
206
-
207
- # Note: the foreign key limit: should match the primary key limit:. (If there is a foreign key constraint,
208
- # those limits _must_ match.) We'd like to call _infer_fk_limit and get the limit right from the PK.
209
- # But we can't here, because that will mess up the autoloader to follow every belongs_to association right
210
- # when it is declared. So instead we assume :bigint (integer limit: 8) below, while also registering this
211
- # pre_migration: callback to double-check that assumption Just In Time--right before we generate a migration.
212
- #
213
- # The one downside of this approach is that application code that asks the field_spec for the declared
214
- # foreign key limit: will always get 8 back even if this is a grandfathered foreign key that points to
215
- # a limit: 4 primary key. It seems unlikely that any application code would do this.
216
- foreign_key_column_options[:pre_migration] = ->(field_spec) do
217
- if (inferred_limit = _infer_fk_limit(foreign_key_column, reflection))
218
- field_spec.sql_options[:limit] = inferred_limit
219
- end
220
- end
205
+ foreign_key_column_name = reflection.foreign_key or raise "Couldn't find foreign_key for #{name} in #{reflection.inspect}"
221
206
 
222
- declare_field(foreign_key_column.to_sym, :bigint, **foreign_key_column_options)
207
+ field_specs[foreign_key_column_name] = _infer_foreign_key_field_spec(foreign_key_column_name, reflection, column_options)
223
208
 
224
209
  if reflection.options[:polymorphic]
225
210
  foreign_type = options[:foreign_type] || "#{name}_type"
226
211
  _declare_polymorphic_type_field(foreign_type, column_options)
227
212
  if ::DeclareSchema.default_generate_indexing && index_options
228
- index([foreign_type, foreign_key_column], **index_options)
213
+ index([foreign_type, foreign_key_column_name], **index_options)
229
214
  end
230
215
  else
231
216
  if ::DeclareSchema.default_generate_indexing && index_options
232
- index([foreign_key_column], **index_options)
217
+ index([foreign_key_column_name], **index_options)
233
218
  end
234
219
 
235
220
  if ::DeclareSchema.default_generate_foreign_keys && constraint_name != false
236
- constraint(foreign_key_column, constraint_name: constraint_name || index_options&.[](:name), parent_class_name: reflection.class_name, dependent: dependent_delete)
221
+ constraint(foreign_key_column_name,
222
+ constraint_name: constraint_name || index_options&.[](:name),
223
+ parent_class_name: reflection.class_name,
224
+ dependent: dependent_delete)
237
225
  end
238
226
  end
239
227
  end
240
228
 
241
- def _infer_fk_limit(foreign_key_column, reflection)
229
+ # Returns a FieldSpec for the foreign key column of a belongs_to association.
230
+ # If the association is polymorphic, the foreign key column is a bigint, or possibly a 4-byte integer
231
+ # if the foreign key column is already defined that way in the database.
232
+ # If the association is not polymorphic, the foreign key column matches the primary key type of the associated model.
233
+ def _infer_foreign_key_field_spec(foreign_key_column_name, reflection, column_options)
242
234
  if reflection.options[:polymorphic]
243
- if (foreign_key_column = _column(foreign_key_column)) && foreign_key_column.type == :integer
244
- foreign_key_column.limit
235
+ if (foreign_key_column = _column(foreign_key_column_name)) && foreign_key_column.type == :integer
236
+ # grandfather foreign key column to match what's in the database
237
+ column_options = column_options.merge(limit: foreign_key_column.limit)
245
238
  end
239
+ FieldSpec.new(self, foreign_key_column_name, :bigint, position: field_specs.size, **column_options)
246
240
  else
247
241
  klass = reflection.klass or raise "Couldn't find belongs_to klass for #{name} in #{reflection.inspect}"
248
- if (pk_id_type = klass._table_options&.[](:id))
249
- if pk_id_type == :integer
250
- 4
251
- end
252
- else
253
- if klass.table_exists? && (pk_column = klass.columns_hash[klass._declared_primary_key])
254
- pk_id_type = pk_column.type
255
- if pk_id_type == :integer
256
- pk_column.limit
257
- end
258
- end
259
- end
242
+ klass._foreign_key_field_spec(self, foreign_key_column_name, position: field_specs.size, **column_options)
260
243
  end
261
244
  end
262
245
 
@@ -273,6 +256,25 @@ module DeclareSchema
273
256
  end
274
257
  end
275
258
 
259
+ # Returns a FieldSpec for a foreign key pointing to the primary key of this model.
260
+ # Exactly matches the primary key type.
261
+ def _foreign_key_field_spec(model, foreign_key, position:, null:)
262
+ _primary_key_field_spec.foreign_key_field_spec(model, foreign_key, position:, null:)
263
+ end
264
+
265
+ def _primary_key_field_spec
266
+ declared_primary_key = _declared_primary_key
267
+ field_specs[declared_primary_key] || _primary_key_field_spec_from_table_options(declared_primary_key) or
268
+ raise "Declared primary key #{declared_primary_key.inspect} not found in field_specs or _table_options #{_table_options.inspect} for #{name}"
269
+ end
270
+
271
+ def _primary_key_field_spec_from_table_options(declared_primary_key)
272
+ primary_key_options = _table_options[declared_primary_key.to_sym] || _table_options[declared_primary_key]
273
+ options = primary_key_options.is_a?(Hash) ? primary_key_options.dup : {}
274
+ type = options.delete(:type) || Rails.application.config.generators.options.dig(:active_record, :primary_key_type) || :bigint
275
+ FieldSpec.new(self, declared_primary_key, type, **options)
276
+ end
277
+
276
278
  private
277
279
 
278
280
  # if this is a derived class, returns the base class's _declared_primary_key
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeclareSchema
4
- VERSION = "3.0.0"
4
+ VERSION = "3.1.0.colin.1"
5
5
  end
@@ -65,7 +65,7 @@ module DeclareSchema
65
65
  if mysql_version && mysql_version >= SEMVER_8 && charset == 'utf8'
66
66
  'utf8mb3'
67
67
  else
68
- charset
68
+ charset.downcase
69
69
  end
70
70
  end
71
71
 
@@ -74,7 +74,7 @@ module DeclareSchema
74
74
  collation.sub(/\Autf8_/, 'utf8mb3_')
75
75
  else
76
76
  collation
77
- end
77
+ end.downcase
78
78
  end
79
79
 
80
80
  def default_charset=(charset)
@@ -172,7 +172,6 @@ module DeclareSchema
172
172
  end
173
173
 
174
174
  require 'declare_schema/extensions/active_record/fields_declaration'
175
- require 'declare_schema/field_declaration_dsl'
176
175
  require 'declare_schema/model'
177
176
  require 'declare_schema/model/field_spec'
178
177
  require 'declare_schema/model/index_definition'
@@ -80,15 +80,13 @@ module Generators
80
80
  self.class.native_types
81
81
  end
82
82
 
83
- # list habtm join tables
83
+ # return habtm reflections keyed by join table name (so that habtm from each side has just one entry)
84
84
  def habtm_tables
85
- reflections = Hash.new { |h, k| h[k] = [] }
86
- ActiveRecord::Base.send(:descendants).map do |c|
85
+ ActiveRecord::Base.send(:descendants).each_with_object({}) do |c, result|
87
86
  c.reflect_on_all_associations(:has_and_belongs_to_many).each do |a|
88
- reflections[a.join_table] << a
87
+ result[a.join_table] ||= a
89
88
  end
90
89
  end
91
- reflections
92
90
  end
93
91
 
94
92
  # Returns an array of model classes and an array of table names
@@ -204,8 +202,8 @@ module Generators
204
202
  end
205
203
  end
206
204
  # generate shims for HABTM models
207
- habtm_tables.each do |name, reflections|
208
- models_by_table_name[name] = ::DeclareSchema::Model::HabtmModelShim.from_reflection(reflections.first)
205
+ habtm_tables.each do |name, reflection|
206
+ models_by_table_name[name] = ::DeclareSchema::Model::HabtmModelShim.from_reflection(reflection)
209
207
  end
210
208
  model_table_names = models_by_table_name.keys
211
209
 
@@ -376,21 +374,21 @@ module Generators
376
374
  ::DeclareSchema::SchemaChange::ColumnRename.new(new_table_name, old_name, new_name)
377
375
  end
378
376
 
379
- to_add.sort_by! { |c| model.field_specs[c]&.position || 0 }
377
+ to_add.sort_by! { model.field_specs[_1]&.position || 0 }
380
378
 
381
- adds = to_add.map do |c|
379
+ adds = to_add.map do |col_name_to_add|
382
380
  type, options =
383
- if (spec = model.field_specs[c])
384
- [spec.type, spec.sql_options.merge(fk_field_options(model, c)).compact]
381
+ if (spec = model.field_specs[col_name_to_add])
382
+ [spec.type, spec.sql_options.merge(fk_field_options(model, col_name_to_add)).compact]
385
383
  else
386
384
  [:integer, {}]
387
385
  end
388
- ::DeclareSchema::SchemaChange::ColumnAdd.new(new_table_name, c, type, **options)
386
+ ::DeclareSchema::SchemaChange::ColumnAdd.new(new_table_name, col_name_to_add, type, **options)
389
387
  end
390
388
 
391
- removes = to_remove.map do |c|
392
- old_type, old_options = add_column_back(model, current_table_name, c)
393
- ::DeclareSchema::SchemaChange::ColumnRemove.new(new_table_name, c, old_type, **old_options)
389
+ removes = to_remove.map do |col_name_to_remove|
390
+ old_type, old_options = add_column_back(model, current_table_name, col_name_to_remove)
391
+ ::DeclareSchema::SchemaChange::ColumnRemove.new(new_table_name, col_name_to_remove, old_type, **old_options)
394
392
  end
395
393
 
396
394
  old_names = to_rename.invert
@@ -546,11 +544,14 @@ module Generators
546
544
  end
547
545
  end
548
546
 
547
+ # TODO: switch this to depend on _infer_foreign_key_field_spec instead
549
548
  def fk_field_options(model, field_name)
550
- if (foreign_key = model.constraint_definitions.find { |fk| field_name == fk.foreign_key_column })
549
+ # check if the field_name is a foreign key
550
+ if (foreign_key = model.constraint_definitions.find { field_name == _1.foreign_key_column })
551
+ # if so, look up the target table's primary key column to get its limit (note: this is looking in the DB, not the spec)
551
552
  parent_columns = connection.columns(foreign_key.parent_table_name) rescue []
552
553
  pk_limit =
553
- if (pk_column = parent_columns.find { |column| column.name.to_s == "id" }) # right now foreign keys assume id is the target
554
+ if (pk_column = parent_columns.find { _1.name.to_s == "id" }) # right now foreign keys assume id is the target
554
555
  pk_column.limit
555
556
  else
556
557
  8
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: declare_schema
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.0
4
+ version: 3.1.0.colin.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Invoca Development adapted from hobo_fields by Tom Locke
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-04-08 00:00:00.000000000 Z
11
+ date: 2025-08-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -66,7 +66,6 @@ files:
66
66
  - lib/declare_schema/dsl.rb
67
67
  - lib/declare_schema/extensions/active_record/fields_declaration.rb
68
68
  - lib/declare_schema/extensions/module.rb
69
- - lib/declare_schema/field_declaration_dsl.rb
70
69
  - lib/declare_schema/model.rb
71
70
  - lib/declare_schema/model/column.rb
72
71
  - lib/declare_schema/model/field_spec.rb
@@ -108,7 +107,6 @@ files:
108
107
  - spec/fixtures/migrations/sqlite3/will_generate_unique_constraint_names_rails_6.txt
109
108
  - spec/fixtures/migrations/sqlite3/will_generate_unique_constraint_names_rails_7.txt
110
109
  - spec/lib/declare_schema/api_spec.rb
111
- - spec/lib/declare_schema/field_declaration_dsl_spec.rb
112
110
  - spec/lib/declare_schema/field_spec_spec.rb
113
111
  - spec/lib/declare_schema/generator_spec.rb
114
112
  - spec/lib/declare_schema/interactive_primary_key_spec.rb
@@ -158,7 +156,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
158
156
  - !ruby/object:Gem::Version
159
157
  version: 1.3.6
160
158
  requirements: []
161
- rubygems_version: 3.4.16
159
+ rubygems_version: 3.4.10
162
160
  signing_key:
163
161
  specification_version: 4
164
162
  summary: Database schema declaration and migration generator for Rails
@@ -1,37 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module DeclareSchema
4
- class FieldDeclarationDsl < BasicObject # avoid Object because that gets extended by lots of gems
5
- include ::Kernel # but we need the basic class methods
6
-
7
- instance_methods.each do |m|
8
- unless m.to_s.starts_with?('__') || m.in?([:object_id, :instance_eval])
9
- undef_method(m)
10
- end
11
- end
12
-
13
- def initialize(model, **options)
14
- @model = model
15
- @options = options
16
- end
17
-
18
- attr_reader :model
19
-
20
- def timestamps
21
- field(:created_at, :datetime, null: true)
22
- field(:updated_at, :datetime, null: true)
23
- end
24
-
25
- def optimistic_lock
26
- field(:lock_version, :integer, default: 1, null: false)
27
- end
28
-
29
- def field(name, type, *args, **options)
30
- @model.declare_field(name, type, *args, **@options.merge(options))
31
- end
32
-
33
- def method_missing(name, *args, **options)
34
- field(name, *args, **options)
35
- end
36
- end
37
- end
@@ -1,34 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative '../../../lib/declare_schema/field_declaration_dsl'
4
-
5
- RSpec.describe DeclareSchema::FieldDeclarationDsl do
6
- include_context 'prepare test app'
7
-
8
- let(:model) { TestModel.new }
9
- subject { declared_class.new(model) }
10
-
11
- context 'Using declare_schema' do
12
- before do
13
- class TestModel < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock
14
- declare_schema do
15
- string :name, limit: 127
16
-
17
- timestamps
18
- end
19
- end
20
- end
21
-
22
- it 'has fields' do
23
- expect(TestModel.field_specs).to be_kind_of(Hash)
24
- expect(TestModel.field_specs.keys).to eq(['name', 'created_at', 'updated_at'])
25
- expect(TestModel.field_specs.values.map(&:type)).to eq([:string, :datetime, :datetime])
26
- end
27
-
28
- it 'stores limits' do
29
- expect(TestModel.field_specs['name'].limit).to eq(127), TestModel.field_specs['name'].inspect
30
- end
31
- end
32
-
33
- # TODO: fill out remaining tests
34
- end