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
-
.association(model, name) ⇒ Object
A non-polymorphic belongs_to, matched by the association name (:country) or its foreign key (:country_id).
- .association_choices(klass) ⇒ Object
- .association_label(model, reflection) ⇒ Object
- .attachment(model, name) ⇒ Object
- .base_result(model:, name:, as:, modifiers:, choices:) ⇒ Object
- .column_type(model, name) ⇒ Object
- .conditional?(validator) ⇒ Boolean
- .enum(model, name) ⇒ Object
- .from_column(model, name) ⇒ Object
- .infer_from_model? ⇒ Boolean
- .name_map_result(name) ⇒ Object
- .numericality_attributes(options) ⇒ Object
- .presence_validated?(model, attr) ⇒ Boolean
- .reflect(model, name) ⇒ Object
- .resolve(model:, name:, as: nil, modifiers: [], choices: nil) ⇒ Object
-
.rich_text(model, name) ⇒ Object
ActionText defines a has_one :rich_text_
association. - .step_for(type) ⇒ Object
- .structural(model, name) ⇒ Object
-
.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).
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 (model, name) klass = model.class return nil unless klass.respond_to?(:reflect_on_attachment) reflection = klass.(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
234 235 236 |
# File 'lib/phlex_forms/inference.rb', line 234 def conditional?(validator) validator..key?(:if) || validator..key?(:unless) || validator..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
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() attrs = {} integer = [:only_integer] if (min = [:greater_than_or_equal_to]) attrs[:min] = min elsif integer && (gt = [:greater_than]) attrs[:min] = gt + 1 end if (max = [:less_than_or_equal_to]) attrs[:max] = max elsif integer && (lt = [:less_than]) attrs[:max] = lt - 1 end attrs end |
.presence_validated?(model, attr) ⇒ 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_
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) || (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.[:maximum] attrs[:maxlength] = max if max && TEXT_LIKE.include?(kind) when ActiveModel::Validations::NumericalityValidator attrs.merge!(numericality_attributes(validator.)) if kind == :number end end attrs rescue StandardError {} end |