Module: PhlexForms::Inference

Defined in:
lib/phlex_forms/inference.rb

Overview

Model-driven input inference for Builder#field. Pure introspection: every model touch sits behind respond_to? guards and a StandardError rescue (the same posture as Forms::Field#required?), so plain objects, Structs, and POROs fall through to the attribute-name map exactly as before. No ActiveRecord dependency.

Precedence (first hit wins):

1. explicit as:                      (caller always wins)
2. positional type modifier          (f.field :price, :number)
3. explicit choices:                 -> :select
4. model structure: rich text, attachment, enum, belongs_to
5. non-string column type            (COLUMN_TYPE_MAP)
6. attribute-name map                (Builder::INPUT_TYPE_INFERENCE)
7. :text

Validator-derived attributes (maxlength/min/max) merge orthogonally and always lose to caller-passed options. Steps 4-5 and the validator attrs are gated by PhlexForms.config.infer_from_model (default on).

Defined Under Namespace

Classes: Result

Constant Summary collapse

LABEL_METHODS =

Option-text methods tried in order when building association choices.

%i[name title label to_s].freeze
COLUMN_TYPE_MAP =

Column type -> control kind, for non-string columns only (the name map disambiguates strings; the column type is ground truth for other shapes).

{
  boolean: :toggle,
  text: :textarea,
  date: :date,
  datetime: :datetime,
  timestamptz: :datetime,
  time: :time,
  integer: :number,
  decimal: :number,
  float: :number
}.freeze
TEXT_LIKE =

Kinds that accept a maxlength attribute.

%i[text email password tel url search textarea].freeze

Class Method Summary collapse

Class Method Details

.association(model, name) ⇒ Object

A non-polymorphic belongs_to, matched by the association name (:country) or its foreign key (:country_id). The field name is rewritten to the foreign key; the label and required flag come from the association.



118
119
120
121
122
123
124
125
126
127
128
# File 'lib/phlex_forms/inference.rb', line 118

def association(model, name)
  assoc_name = name.to_s.delete_suffix("_id").to_sym
  reflection = reflect(model, name) || reflect(model, assoc_name)
  return nil unless reflection && reflection.macro == :belongs_to && !reflection.polymorphic?

  Result.blank(as: :select, name: reflection.foreign_key.to_sym).with(
    label: association_label(model, reflection),
    choices: -> { association_choices(reflection.klass) }, # lazy: only called without a choices: override
    required: presence_validated?(model, reflection.name)
  )
end

.association_choices(klass) ⇒ Object



171
172
173
174
175
176
177
# File 'lib/phlex_forms/inference.rb', line 171

def association_choices(klass)
  label_method = nil
  klass.all.map do |record|
    label_method ||= LABEL_METHODS.find { |m| record.respond_to?(m) }
    [record.public_send(label_method), record.id]
  end
end

.association_label(model, reflection) ⇒ Object



164
165
166
167
168
169
# File 'lib/phlex_forms/inference.rb', line 164

def association_label(model, reflection)
  klass = model.class
  return nil unless klass.respond_to?(:human_attribute_name)

  klass.human_attribute_name(reflection.name)
end

.attachment(model, name) ⇒ Object



97
98
99
100
101
102
103
104
105
# File 'lib/phlex_forms/inference.rb', line 97

def attachment(model, name)
  klass = model.class
  return nil unless klass.respond_to?(:reflect_on_attachment)

  reflection = klass.reflect_on_attachment(name)
  return nil unless reflection

  Result.blank(as: :file, name:).with(multiple: reflection.macro == :has_many_attached)
end

.base_result(model:, name:, as:, modifiers:, choices:) ⇒ Object



61
62
63
64
65
66
67
68
69
70
71
# File 'lib/phlex_forms/inference.rb', line 61

def base_result(model:, name:, as:, modifiers:, choices:)
  return Result.blank(as:, name:) if as

  if (explicit = modifiers.find { |m| Builder::INPUT_TYPE_MODIFIERS.include?(m) })
    return Result.blank(as: explicit, name:)
  end
  return Result.blank(as: :select, name:) if choices
  return name_map_result(name) unless infer_from_model?

  structural(model, name) || from_column(model, name) || name_map_result(name)
end

.column_type(model, name) ⇒ Object



144
145
146
147
148
149
150
151
152
153
# File 'lib/phlex_forms/inference.rb', line 144

def column_type(model, name)
  return nil unless model

  klass = model.class
  if klass.respond_to?(:type_for_attribute)
    klass.type_for_attribute(name.to_s)
  elsif klass.respond_to?(:attribute_types)
    klass.attribute_types[name.to_s]
  end
end

.conditional?(validator) ⇒ Boolean

Returns:

  • (Boolean)


234
235
236
# File 'lib/phlex_forms/inference.rb', line 234

def conditional?(validator)
  validator.options.key?(:if) || validator.options.key?(:unless) || validator.options.key?(:on)
end

.enum(model, name) ⇒ Object



107
108
109
110
111
112
113
# File 'lib/phlex_forms/inference.rb', line 107

def enum(model, name)
  klass = model.class
  return nil unless klass.respond_to?(:defined_enums) && klass.defined_enums.key?(name.to_s)

  pairs = klass.defined_enums[name.to_s].keys.map { |key| [key.humanize, key] }
  Result.blank(as: :select, name:).with(choices: pairs)
end

.from_column(model, name) ⇒ Object



130
131
132
133
134
135
136
137
138
139
140
141
142
# File 'lib/phlex_forms/inference.rb', line 130

def from_column(model, name)
  type = column_type(model, name)
  kind = COLUMN_TYPE_MAP[type&.type]
  return nil unless kind

  attrs = {}
  if kind == :number && (step = step_for(type))
    attrs[:step] = step
  end
  Result.blank(as: kind, name:).with(attributes: attrs)
rescue StandardError
  nil
end

.infer_from_model?Boolean

Returns:

  • (Boolean)


73
74
75
# File 'lib/phlex_forms/inference.rb', line 73

def infer_from_model?
  PhlexForms.config.infer_from_model
end

.name_map_result(name) ⇒ Object



77
78
79
# File 'lib/phlex_forms/inference.rb', line 77

def name_map_result(name)
  Result.blank(as: Builder::INPUT_TYPE_INFERENCE[name.to_sym] || :text, name:)
end

.numericality_attributes(options) ⇒ Object



211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
# File 'lib/phlex_forms/inference.rb', line 211

def numericality_attributes(options)
  attrs = {}
  integer = options[:only_integer]
  if (min = options[:greater_than_or_equal_to])
    attrs[:min] = min
  elsif integer && (gt = options[:greater_than])
    attrs[:min] = gt + 1
  end
  if (max = options[:less_than_or_equal_to])
    attrs[:max] = max
  elsif integer && (lt = options[:less_than])
    attrs[:max] = lt - 1
  end
  attrs
end

.presence_validated?(model, attr) ⇒ Boolean

Returns:

  • (Boolean)


179
180
181
182
183
184
185
186
# File 'lib/phlex_forms/inference.rb', line 179

def presence_validated?(model, attr)
  klass = model.class
  return false unless klass.respond_to?(:validators_on)

  klass.validators_on(attr).any? do |v|
    v.is_a?(ActiveModel::Validations::PresenceValidator) && !conditional?(v)
  end
end

.reflect(model, name) ⇒ Object



227
228
229
230
231
232
# File 'lib/phlex_forms/inference.rb', line 227

def reflect(model, name)
  klass = model.class
  return nil unless klass.respond_to?(:reflect_on_association)

  klass.reflect_on_association(name)
end

.resolve(model:, name:, as: nil, modifiers: [], choices: nil) ⇒ Object



51
52
53
54
55
56
57
58
59
# File 'lib/phlex_forms/inference.rb', line 51

def resolve(model:, name:, as: nil, modifiers: [], choices: nil)
  result = base_result(model:, name:, as:, modifiers:, choices:)
  return result unless infer_from_model?

  validator_attrs = validator_attributes(model, result.name, result.as)
  return result if validator_attrs.empty?

  result.with(attributes: validator_attrs.merge(result.attributes))
end

.rich_text(model, name) ⇒ Object

ActionText defines a has_one :rich_text_ association.



91
92
93
94
95
# File 'lib/phlex_forms/inference.rb', line 91

def rich_text(model, name)
  return nil unless reflect(model, :"rich_text_#{name}")

  Result.blank(as: :rich_textarea, name:)
end

.step_for(type) ⇒ Object



155
156
157
158
159
160
161
162
# File 'lib/phlex_forms/inference.rb', line 155

def step_for(type)
  case type.type
  when :integer then 1
  when :decimal, :float
    scale = type.respond_to?(:scale) ? type.scale : nil
    scale ? (10**-scale).to_f : "any"
  end
end

.structural(model, name) ⇒ Object



81
82
83
84
85
86
87
88
# File 'lib/phlex_forms/inference.rb', line 81

def structural(model, name)
  return nil unless model

  rich_text(model, name) || attachment(model, name) ||
    enum(model, name) || association(model, name)
rescue StandardError
  nil
end

.validator_attributes(model, name, kind) ⇒ Object

Length maximum -> maxlength (text-like kinds); numericality bounds -> min/max (number kind only; exclusive bounds map +/-1 only for only_integer, otherwise skipped).



191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
# File 'lib/phlex_forms/inference.rb', line 191

def validator_attributes(model, name, kind)
  return {} unless model.respond_to?(:class) && model.class.respond_to?(:validators_on)

  attrs = {}
  model.class.validators_on(name).each do |validator|
    next if conditional?(validator)

    case validator
    when ActiveModel::Validations::LengthValidator
      max = validator.options[:maximum]
      attrs[:maxlength] = max if max && TEXT_LIKE.include?(kind)
    when ActiveModel::Validations::NumericalityValidator
      attrs.merge!(numericality_attributes(validator.options)) if kind == :number
    end
  end
  attrs
rescue StandardError
  {}
end