Module: DeclareSchema::Model::ClassMethods

Defined in:
lib/declare_schema/model.rb

Instance Method Summary collapse

Instance Method Details

#_declared_primary_keyObject

returns the primary key (String) as declared with primary_key = unlike the ‘primary_key` method, DOES NOT query the database to find the actual primary key in use right now if no explicit primary key set, returns the _default_declared_primary_key



310
311
312
313
314
315
316
317
318
# File 'lib/declare_schema/model.rb', line 310

def _declared_primary_key
  if !defined?(@primary_key) ||
     (ActiveSupport.version >= Gem::Version.new('7.1.0') &&
       @primary_key == ActiveRecord::AttributeMethods::PrimaryKey::ClassMethods::PRIMARY_KEY_NOT_SET)
    _default_declared_primary_key
  else
    @primary_key&.to_s
  end
end

#_foreign_key_field_spec(model, foreign_key, position:, null:) ⇒ Object

Returns a FieldSpec for a foreign key pointing to the primary key of this model. Exactly matches the primary key type.



322
323
324
# File 'lib/declare_schema/model.rb', line 322

def _foreign_key_field_spec(model, foreign_key, position:, null:)
  _primary_key_field_spec.foreign_key_field_spec(model, foreign_key, position:, null:)
end

#_infer_foreign_key_field_spec(foreign_key_column_name, reflection, column_options) ⇒ Object

Returns a FieldSpec for the foreign key column of a belongs_to association.

  • For a polymorphic association, the FK uses ‘DeclareSchema.default_generated_primary_key_type` (mirroring `config.generators.primary_key_type`, default :bigint), or :integer with the existing column’s limit if the column already exists in the database.

  • For a non-polymorphic association, the FK should mirror the primary key it points at (same data type, same options like limit:, charset:, etc.). However we cannot load the parent model right now (at ‘belongs_to` time) without risking dependency cycles between models, so we install a `resolver:` callback. The migration generator calls that resolver at generation time – after all models are eager-loaded – and the resolver returns a fully-mirrored FieldSpec that the generator swaps in for this default_spec.



240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
# File 'lib/declare_schema/model.rb', line 240

def _infer_foreign_key_field_spec(foreign_key_column_name, reflection, column_options)
  if reflection.options[:polymorphic]
    if (foreign_key_column = _column(foreign_key_column_name)) && foreign_key_column.type == :integer
      # grandfather foreign key column to match what's in the database
      column_options = column_options.merge(limit: foreign_key_column.limit)
    end
    FieldSpec.new(self, foreign_key_column_name, DeclareSchema.default_generated_primary_key_type, position: field_specs.size, **column_options)
  else
    # Capture only what we need from `reflection` (no `reflection.klass` here -- that
    # would force the parent model to load, which is exactly the cycle we are avoiding).
    # `reflection.klass` is resolved lazily inside the block below.
    resolver = ->(default_spec) do
      _resolve_belongs_to_foreign_key_field_spec(reflection, default_spec)
    end
    FieldSpec.new(self, foreign_key_column_name, DeclareSchema.default_generated_primary_key_type,
                  position: field_specs.size, resolver:, **column_options)
  end
end

#_mirror_parent_primary_key(klass, default_spec) ⇒ Object

Build a FieldSpec for the FK by mirroring the parent’s declared primary key, then reconciling against the live DB column when it differs only in :limit (see _resolve_belongs_to_foreign_key_field_spec for the full rationale).



285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
# File 'lib/declare_schema/model.rb', line 285

def _mirror_parent_primary_key(klass, default_spec)
  spec = klass._primary_key_field_spec.foreign_key_field_spec(
    default_spec.model, default_spec.name,
    position: default_spec.position, null: default_spec.null
  )

  # Look up the parent's live PK column directly (not via _column, whose
  # @table_exists memoization can pin to a stale value when the parent table
  # is created after the model class is first defined). The rescue covers
  # the table-doesn't-exist-yet case (greenfield migration).
  live_pk_column = klass.columns_hash[klass._declared_primary_key.to_s] rescue nil
  if live_pk_column && live_pk_column.type == spec.type && live_pk_column.limit && live_pk_column.limit != spec.limit
    FieldSpec.new(
      spec.model, spec.name, spec.type,
      position: spec.position,
      **spec.options.merge(limit: live_pk_column.limit)
    )
  else
    spec
  end
end

#_primary_key_field_specObject



326
327
328
329
330
# File 'lib/declare_schema/model.rb', line 326

def _primary_key_field_spec
  declared_primary_key = _declared_primary_key
  field_specs[declared_primary_key] || _primary_key_field_spec_from_table_options(declared_primary_key) or
    raise "Declared primary key #{declared_primary_key.inspect} not found in field_specs or _table_options #{_table_options.inspect} for #{name}"
end

#_primary_key_field_spec_from_table_options(declared_primary_key) ⇒ Object



332
333
334
335
336
337
338
339
340
341
# File 'lib/declare_schema/model.rb', line 332

def _primary_key_field_spec_from_table_options(declared_primary_key)
  # _table_options is nil on STI subclasses that never call `declare_schema` themselves:
  # they inherit `field_specs` etc. via `inheriting_cattr_reader`, but `@_table_options`
  # is a plain class-instance variable on each class, so the subclass's reader returns
  # nil. Treat that the same as an empty options hash and fall through to
  # `default_generated_primary_key_type` below.
  type, options = _parse_pk_table_options(_table_options&.[](declared_primary_key.to_sym))
  type ||= DeclareSchema.default_generated_primary_key_type
  FieldSpec.new(self, declared_primary_key, type, **options)
end

#_resolve_belongs_to_foreign_key_field_spec(reflection, default_spec) ⇒ Object

Called at migration generation time to mirror the parent model’s primary key. Always returns a FieldSpec: the default_spec unchanged when the parent class is not a declare_schema model (we can’t ask for its PK spec, so the configured default PK type is the best we can offer without inspecting the DB), otherwise a fully mirrored FieldSpec.

Reconciliation with the live DB: if the parent’s PK column already exists in the database with the same Rails type but a different :limit (e.g. a legacy table where ‘id` is INT(4) but the model now declares the default :bigint), prefer the live column’s :limit so the FK matches what’s actually on disk. This preserves the behavior of the old DB-column lookup (formerly ‘fk_field_options`) without overriding intentional type changes.



271
272
273
274
275
276
277
278
279
280
# File 'lib/declare_schema/model.rb', line 271

def _resolve_belongs_to_foreign_key_field_spec(reflection, default_spec)
  klass = reflection.klass or
    raise "Couldn't find belongs_to klass for #{reflection.name} on #{name} in #{reflection.inspect}"

  if klass.respond_to?(:_primary_key_field_spec)
    _mirror_parent_primary_key(klass, default_spec)
  else
    default_spec
  end
end

#attr_type(name) ⇒ Object

Returns the type (a class) for a given field or association. If the association is a collection (has_many or habtm) return the AssociationReflection instead



461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
# File 'lib/declare_schema/model.rb', line 461

public \
def attr_type(name)
  if attr_types.nil? && self != self.name.constantize
    raise "attr_types called on a stale class object (#{self.name}). Avoid storing persistent references to classes"
  end

  attr_types[name] ||
    if (reflection = reflections[name.to_s])
      if reflection.macro.in?([:has_one, :belongs_to]) && !reflection.options[:polymorphic]
        reflection.klass
      else
        reflection
      end
    end ||
    if (col = _column(name.to_s))
      DeclareSchema::PLAIN_TYPES[col.type] || col.klass
    end
end

#belongs_to(name, scope = nil, **options) ⇒ Object

Extend belongs_to so that it

  1. creates a FieldSpec for the foreign key

  2. declares an index on the foreign key (optional)

  3. declares a foreign_key constraint (optional)

Other options are passed through to super

Parameters:

  • name (Symbol)

    the name of the association to pass to super

  • scope (Proc) (defaults to: nil)

    the scope of the association to pass to super

  • options (Hash)

    a customizable set of options

Options Hash (**options):

  • :optional (Boolean) — default: default: false

    whether the foreign key column should be nullable and whether ActiveRecord should validate presence of the foreign key (passed through to super)

  • :null (Boolean) — default: default: inferred from options[:optional]

    whether the foreign key column should be nullable (‘null:` should only be passed if it is the inverse of `optional:`; otherwise it is redundant)

  • :limit (Integer) — default: default: inferred from the primary key limit:

    the limit of the foreign key column size (4 or 8)

  • :index (Boolean|Hash<Symbol>) — default: default: true

    whether to create an index on the foreign key; can be true or false or a hash of options to pass to the index declaration, with keys like { name: …, unique: … }

  • :allow_equivalent (Boolean) — default: default: false

    whether to allow an existing index with a different name

  • :constraint (Boolean|String) — default: default: true

    whether to create a foreign key constraint on the foreign key; may be true or false or a string to use as the constraint name

  • :polymorphic (Boolean) — default: default: false

    whether this is a polymorphic belongs_to with a _type column next to the foreign key _id column (also passed through to super)

  • :far_end_dependent (Boolean) — default: default: nil

    whether to add a dependent: :delete to the far end of the foreign key constraint

  • :foreign_type (String) — default: default: "#{name}_type"

    the name prefix for the _type column for a polymorphic belongs_to (passed through to super)



139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
# File 'lib/declare_schema/model.rb', line 139

def belongs_to(name, scope = nil, **options)
  if options[:null].in?([true, false]) && options[:optional] == options[:null]
    STDERR.puts("[declare_schema warning] belongs_to #{name.inspect}, null: with the same value as optional: is redundant; omit null: #{options[:null]} (called from #{caller[0]})")
  elsif !options.has_key?(:optional)
    case options[:null]
    when true
      STDERR.puts("[declare_schema] belongs_to #{name.inspect}, null: true is deprecated in favor of optional: true (called from #{caller[0]})")
    when false
      STDERR.puts("[declare_schema] belongs_to #{name.inspect}, null: false is implied and can be omitted (called from #{caller[0]})")
    end
  end

  column_options = {}

  column_options[:null] = if options.has_key?(:null)
                            options.delete(:null)
                          elsif options.has_key?(:optional)
                            options[:optional] # infer :null from :optional
                          end || false
  column_options[:default] = options.delete(:default) if options.has_key?(:default)
  if options.has_key?(:limit)
    limit = options.delete(:limit)
    DeclareSchema.deprecator.warn("belongs_to #{name.inspect}, limit: #{limit} is deprecated since it is now inferred")
  end

  # index: true means create an index on the foreign key
  # index: false means do not create an index on the foreign key
  # index: { ... } means create an index on the foreign key with the given options
  index_value = options.delete(:index)
  if index_value == false # don't create an index
    options.delete(:unique)
    options.delete(:allow_equivalent)
  else
    index_options = {} # create an index
    case index_value
    when String, Symbol
      DeclareSchema.deprecator.warn("[declare_schema] belongs_to #{name.inspect}, index: 'name' is deprecated; use index: { name: 'name' } instead (in #{self.name})")
      index_options[:name] = index_value.to_s
    when true
    when nil
    when Hash
      index_options = index_value
    else
      raise ArgumentError, "[declare_schema] belongs_to #{name.inspect}, index: must be true or false or a Hash; got #{index_value.inspect} (in #{self.name})"
    end

    if options.has_key?(:unique)
      DeclareSchema.deprecator.warn("[declare_schema] belongs_to #{name.inspect}, unique: true|false is deprecated; use index: { unique: true|false } instead (in #{self.name})")
      index_options[:unique] = options.delete(:unique)
    end

    index_options[:allow_equivalent] = options.delete(:allow_equivalent) if options.has_key?(:allow_equivalent)
  end

  constraint_name = options.delete(:constraint)

  dependent_delete = :delete if options.delete(:far_end_dependent) == :delete

  # infer :optional from :null
  if !options.has_key?(:optional)
    options[:optional] = column_options[:null]
  end

  super

  reflection = reflections[name.to_s] or raise "Couldn't find reflection #{name} in #{reflections.keys}"
  foreign_key_column_name = reflection.foreign_key or raise "Couldn't find foreign_key for #{name} in #{reflection.inspect}"

  field_specs[foreign_key_column_name] = _infer_foreign_key_field_spec(foreign_key_column_name, reflection, column_options)

  if reflection.options[:polymorphic]
    foreign_type = options[:foreign_type] || "#{name}_type"
    _declare_polymorphic_type_field(foreign_type, column_options)
    if ::DeclareSchema.default_generate_indexing && index_options
      index([foreign_type, foreign_key_column_name], **index_options)
    end
  else
    if ::DeclareSchema.default_generate_indexing && index_options
      index([foreign_key_column_name], **index_options)
    end

    if ::DeclareSchema.default_generate_foreign_keys && constraint_name != false
      constraint(foreign_key_column_name,
                 constraint_name: constraint_name || index_options&.[](:name),
                 parent_class_name: reflection.class_name,
                 dependent: dependent_delete)
    end
  end
end

#constraint(foreign_key_column, parent_table_name: nil, constraint_name: nil, parent_class_name: nil, dependent: nil) ⇒ Object



76
77
78
79
80
81
82
83
84
# File 'lib/declare_schema/model.rb', line 76

def constraint(foreign_key_column, parent_table_name: nil, constraint_name: nil, parent_class_name: nil, dependent: nil)
  constraint_definition = ::DeclareSchema::Model::ForeignKeyDefinition.new(
    foreign_key_column.to_s,
    constraint_name: constraint_name,
    child_table_name: table_name, parent_table_name: parent_table_name, parent_class_name: parent_class_name, dependent: dependent
  )

  constraint_definitions << constraint_definition # Set<> implements idempotent insert.
end

#declare_field(name, type, *args, **options) ⇒ Object

Declare named field with a type and an arbitrary set of arguments. The arguments are forwarded to the #field_added callback, allowing custom metadata to be added to field declarations.



96
97
98
99
100
101
102
103
104
105
106
# File 'lib/declare_schema/model.rb', line 96

def declare_field(name, type, *args, **options)
  try(:field_added, name, type, args, options)
  _add_serialize_for_field(name, type, options)
  _add_formatting_for_field(name, type)
  _add_validations_for_field(name, type, args, options)
  _add_index_for_field(name, args, **options)
  _add_scopes_for_field(name, type, **options)
  name.to_s == _declared_primary_key and raise ArgumentError, "no need to declare a field spec for the primary key #{name.inspect}"
  field_specs[name] = ::DeclareSchema::Model::FieldSpec.new(self, name, type, position: field_specs.size, **options)
  attr_order << name unless attr_order.include?(name)
end

#ignore_index(index_name) ⇒ Object

tell the migration generator to ignore the named index. Useful for existing indexes, or for indexes that can’t be automatically generated.



88
89
90
# File 'lib/declare_schema/model.rb', line 88

def ignore_index(index_name)
  ignore_indexes << index_name.to_s
end

#index(columns, name: nil, allow_equivalent: true, ignore_equivalent_definitions: false, unique: false, where: nil, length: nil) ⇒ Object



52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# File 'lib/declare_schema/model.rb', line 52

def index(columns, name: nil, allow_equivalent: true, ignore_equivalent_definitions: false, unique: false, where: nil, length: nil)
  index_definition = ::DeclareSchema::Model::IndexDefinition.new(
    columns,
    name: name, table_name: table_name, allow_equivalent: allow_equivalent, unique: unique, where: where, length: length
  )

  if (equivalent = index_definitions.find { index_definition.equivalent?(_1) }) # differs only by name
    if equivalent == index_definition
      # identical is always idempotent
    else
      # equivalent is idempotent iff ignore_equivalent_definitions: true passed
      ignore_equivalent_definitions or
        raise ArgumentError, "equivalent index definition found (pass ignore_equivalent_definitions: true to ignore):\n" \
                             "#{index_definition.inspect}\n#{equivalent.inspect}"
    end
  else
    index_definitions << index_definition
  end
end

#index_definitions_with_primary_keyObject



108
109
110
111
112
113
114
# File 'lib/declare_schema/model.rb', line 108

def index_definitions_with_primary_key
  if index_definitions.any?(&:primary_key?)
    index_definitions
  else
    index_definitions + [_rails_default_primary_key]
  end
end

#primary_key_index(*columns) ⇒ Object



72
73
74
# File 'lib/declare_schema/model.rb', line 72

def primary_key_index(*columns)
  index(columns.flatten, unique: true, name: ::DeclareSchema::Model::IndexDefinition::PRIMARY_KEY_NAME)
end