dami 1.0.0

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.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +306 -0
  3. data/LICENSE +21 -0
  4. data/README.md +106 -0
  5. data/bin/dami +10 -0
  6. data/docs/01.Getting_Started.md +220 -0
  7. data/docs/02.Models_and_Fields.md +244 -0
  8. data/docs/03.Querying.md +387 -0
  9. data/docs/04.Creating_Updating_Deleting.md +226 -0
  10. data/docs/05.Validation.md +259 -0
  11. data/docs/06.Protection.md +160 -0
  12. data/docs/07.Associations.md +239 -0
  13. data/docs/08.Scopes.md +99 -0
  14. data/docs/09.Migrations.md +165 -0
  15. data/docs/10.Flows_and_Commands.md +181 -0
  16. data/docs/11.Localization.md +435 -0
  17. data/docs/12.TheDamiWay.md +227 -0
  18. data/docs/Manifesto.md +305 -0
  19. data/docs/site.md +477 -0
  20. data/lib/dami/actions/command.rb +80 -0
  21. data/lib/dami/actions/context.rb +63 -0
  22. data/lib/dami/actions/draft.rb +36 -0
  23. data/lib/dami/actions/flow.rb +42 -0
  24. data/lib/dami/adapters/base.rb +40 -0
  25. data/lib/dami/adapters/sqlite/connection.rb +122 -0
  26. data/lib/dami/adapters/sqlite/core.rb +17 -0
  27. data/lib/dami/adapters/sqlite/query.rb +353 -0
  28. data/lib/dami/adapters/sqlite/schema.rb +135 -0
  29. data/lib/dami/cli.rb +117 -0
  30. data/lib/dami/configuration.rb +246 -0
  31. data/lib/dami/core.rb +98 -0
  32. data/lib/dami/dsl_guardrails.rb +45 -0
  33. data/lib/dami/errors.rb +89 -0
  34. data/lib/dami/inflector.rb +245 -0
  35. data/lib/dami/localization.rb +131 -0
  36. data/lib/dami/migration.rb +84 -0
  37. data/lib/dami/migrator.rb +105 -0
  38. data/lib/dami/plugins/associations.rb +284 -0
  39. data/lib/dami/plugins/nested_attributes.rb +180 -0
  40. data/lib/dami/plugins/protection.rb +30 -0
  41. data/lib/dami/plugins/validations.rb +90 -0
  42. data/lib/dami/query/builder.rb +132 -0
  43. data/lib/dami/query/enumerable.rb +62 -0
  44. data/lib/dami/query/persistence.rb +133 -0
  45. data/lib/dami/record_proxy.rb +32 -0
  46. data/lib/dami/result.rb +27 -0
  47. data/lib/dami/schema/diff.rb +84 -0
  48. data/lib/dami/schema/dumper.rb +60 -0
  49. data/lib/dami/schema/generator.rb +69 -0
  50. data/lib/dami/schema/introspector.rb +34 -0
  51. data/lib/dami/schema/loader.rb +58 -0
  52. data/lib/dami/schema.rb +13 -0
  53. data/lib/dami/validation_rules.rb +72 -0
  54. data/lib/dami/version.rb +3 -0
  55. data/lib/dami.rb +32 -0
  56. metadata +218 -0
@@ -0,0 +1,284 @@
1
+ # File: lib/dami/plugins/associations.rb
2
+
3
+ # frozen_string_literal: true
4
+ module Dami
5
+ module Plugins
6
+ module Associations
7
+ @association_modules = {}
8
+
9
+ def self.clear_cache!
10
+ @association_modules.clear
11
+ end
12
+
13
+ # Defines class-level DSL methods (e.g., .association_module_for)
14
+ module ClassMethods
15
+ def association_module_cache
16
+ Associations.instance_variable_get(:@association_modules)
17
+ end
18
+
19
+ def association_module_for(model_name)
20
+ association_module_cache[model_name] ||= create_association_module(model_name)
21
+ end
22
+
23
+ private
24
+
25
+ def create_association_module(model_name)
26
+ mod = Module.new
27
+ model_config = Dami.find_model(model_name) rescue nil
28
+ return mod unless model_config && (rels = model_config[:relationships])
29
+
30
+ rels.each do |_type, rel_config|
31
+ rel_config.each do |name, _options|
32
+ actual_name = name.is_a?(Hash) ? name.keys.first : name
33
+ mod.define_method(actual_name) do
34
+ @preloaded[actual_name] ||= fetch_association(actual_name)
35
+ end
36
+ end
37
+ end
38
+ mod
39
+ end
40
+ end
41
+
42
+ # This module now ONLY contains the lazy-loading helper methods.
43
+ # The conflicting `initialize` method has been removed.
44
+ module LazyLoadingMethods
45
+ private
46
+ def fetch_association(assoc_name)
47
+ model_config = Dami.find_model(@model_name)
48
+ type, config = find_association_config(model_config, assoc_name)
49
+ return fetch_has_many_through(config) if config[:through]
50
+
51
+ case type
52
+ when :belongs_to
53
+ return fetch_polymorphic_belongs_to(config) if config[:polymorphic]
54
+ fk = config[:foreign_key] || "#{assoc_name}_id".to_sym
55
+ return nil unless (id = @record[fk])
56
+ model_name = config[:model] || Dami::Inflector.pluralize(assoc_name.to_s).to_sym
57
+ ::Dami.db(model_name).find(id)
58
+ when :has_many
59
+ _fk, model_name = association_keys(assoc_name, config)
60
+ query = ::Dami.db(model_name).where(association_keys(assoc_name, config)[0] => @record[:id])
61
+ query = query.where("#{config[:as]}_type".to_sym => Dami::Inflector.singularize(@model_name.to_s).capitalize) if config[:as]
62
+ query
63
+ when :has_one
64
+ _fk, model_name = association_keys(assoc_name, config)
65
+ query = ::Dami.db(model_name).where(association_keys(assoc_name, config)[0] => @record[:id])
66
+ query = query.where("#{config[:as]}_type".to_sym => Dami::Inflector.singularize(@model_name.to_s).capitalize) if config[:as]
67
+ query.first
68
+ end
69
+ end
70
+
71
+ def fetch_polymorphic_belongs_to(config)
72
+ type = @record["#{config[:name]}_type".to_sym]
73
+ id = @record["#{config[:name]}_id".to_sym]
74
+ return nil unless type && id
75
+ model_name = Dami::Inflector.pluralize(type.downcase).to_sym
76
+ ::Dami.db(model_name).find(id)
77
+ end
78
+
79
+ def fetch_has_many_through(config)
80
+ through_rel = public_send(config[:through])
81
+ through_records = case through_rel
82
+ when Dami::Query::Builder then through_rel.to_a
83
+ when Dami::RecordProxy then [through_rel]
84
+ else []
85
+ end
86
+ target_model_name = config[:model] || Dami::Inflector.pluralize(config[:name].to_s).to_sym
87
+ return ::Dami.db(target_model_name).where(id: []) if through_records.empty?
88
+ target_fk = "#{Dami::Inflector.singularize(config[:name].to_s)}_id".to_sym
89
+ target_ids = through_records.map { |r| r[target_fk] }.compact.uniq
90
+ ::Dami.db(target_model_name).where(id: target_ids)
91
+ end
92
+
93
+ def find_association_config(model_config, assoc_name)
94
+ (model_config[:relationships] || {}).each do |type, rels|
95
+ rels.each do |key, opts|
96
+ actual_name = key.is_a?(Hash) ? key.keys.first : key
97
+ if actual_name == assoc_name
98
+ full_opts = opts.is_a?(Hash) ? opts : {}
99
+ full_opts = key.values.first.merge(full_opts) if key.is_a?(Hash)
100
+ return [type, full_opts.merge(name: assoc_name)]
101
+ end
102
+ end
103
+ end
104
+ raise "Association :#{assoc_name} not found on #{@model_name}"
105
+ end
106
+
107
+ def association_keys(name, config)
108
+ owner_singular = Dami::Inflector.singularize(@model_name.to_s)
109
+ fk = config[:foreign_key] || (config[:as] ? "#{config[:as]}_id".to_sym : "#{owner_singular}_id".to_sym)
110
+ model_name = config[:model] || Dami::Inflector.pluralize(name.to_s).to_sym
111
+ [fk, model_name]
112
+ end
113
+ end
114
+
115
+ # Contains the preloading logic that gets included into the database adapter.
116
+ module AdapterMethods
117
+ def preload_associations(proxies, relations)
118
+ return proxies if proxies.empty?
119
+ model_name = proxies.first.instance_variable_get(:@model_name)
120
+ model_config = Dami.find_model(model_name)
121
+ relations.each do |rel_name|
122
+ type, config = find_association_config_for_adapter(model_config, rel_name)
123
+ records = proxies.map(&:to_h)
124
+ preloaded_map = if config[:through]
125
+ preload_has_many_through(records, model_name, config)
126
+ else
127
+ case type
128
+ when :belongs_to
129
+ config[:polymorphic] ? preload_polymorphic_belongs_to(records, config) : preload_belongs_to(records, config)
130
+ when :has_many
131
+ preload_has_many(records, model_name, config)
132
+ when :has_one
133
+ preload_has_one(records, model_name, config)
134
+ end
135
+ end
136
+ proxies.each do |proxy|
137
+ preloaded_data = if config[:polymorphic] && type == :belongs_to
138
+ type_val = proxy["#{config[:name]}_type".to_sym]
139
+ id_val = proxy["#{config[:name]}_id".to_sym]
140
+ preloaded_map[[type_val, id_val]]
141
+ else
142
+ preloaded_map[proxy[:id]]
143
+ end
144
+ dynamic_config = if config[:polymorphic] && type == :belongs_to
145
+ type_val = proxy["#{config[:name]}_type".to_sym]
146
+ config.merge(model: Dami::Inflector.pluralize(type_val.downcase).to_sym) if type_val
147
+ else
148
+ config
149
+ end
150
+ proxy.instance_variable_set(:@preloaded, proxy.instance_variable_get(:@preloaded).merge(rel_name => wrap_preloaded_data_for_adapter(rel_name, preloaded_data, dynamic_config)))
151
+ end
152
+ end
153
+ proxies
154
+ end
155
+
156
+ private
157
+
158
+ def wrap_preloaded_data_for_adapter(assoc_name, data, config)
159
+ return data.is_a?(Array) ? [] : nil if data.nil? || config.nil?
160
+ model_name = config[:model] || Dami::Inflector.pluralize(assoc_name.to_s).to_sym
161
+ if data.is_a?(Array)
162
+ data.map { |r| ::Dami::RecordProxy.new(model_name, r) }
163
+ else
164
+ ::Dami::RecordProxy.new(model_name, data)
165
+ end
166
+ end
167
+
168
+ def find_association_config_for_adapter(model_config, assoc_name)
169
+ (model_config[:relationships] || {}).each do |type, rels|
170
+ rels.each do |key, opts|
171
+ actual_name = key.is_a?(Hash) ? key.keys.first : key
172
+ if actual_name == assoc_name
173
+ full_opts = opts.is_a?(Hash) ? opts : {}
174
+ full_opts = key.values.first.merge(full_opts) if key.is_a?(Hash)
175
+ return [type, full_opts.merge(name: assoc_name)]
176
+ end
177
+ end
178
+ end
179
+ raise "Association :#{assoc_name} not found on #{model_config[:name]}"
180
+ end
181
+
182
+ def preload_belongs_to(records, config)
183
+ fk = config[:foreign_key] || "#{config[:name]}_id".to_sym
184
+ model_name = config[:model] || Dami::Inflector.pluralize(config[:name].to_s).to_sym
185
+ fk_ids = records.map { |r| r[fk] }.compact.uniq
186
+ return {} if fk_ids.empty?
187
+ related_records = ::Dami.db(model_name).where(id: fk_ids).to_a.map(&:to_h)
188
+ related_map = related_records.each_with_object({}) { |r, h| h[r[:id]] = r }
189
+ records.each_with_object({}) { |r, h| h[r[:id]] = related_map[r[fk]] }
190
+ end
191
+
192
+ def preload_polymorphic_belongs_to(records, config)
193
+ name = config[:name]
194
+ records_by_type = records.group_by { |r| r["#{name}_type".to_sym] }
195
+ preloaded_map = {}
196
+ records_by_type.each do |type, recs|
197
+ next unless type
198
+ model_name = Dami::Inflector.pluralize(type.downcase).to_sym
199
+ ids = recs.map { |r| r["#{name}_id".to_sym] }.compact.uniq
200
+ next if ids.empty?
201
+ related_records = ::Dami.db(model_name).where(id: ids).to_a.map(&:to_h)
202
+ related_map = related_records.each_with_object({}) { |r, h| h[r[:id]] = r }
203
+ recs.each do |r|
204
+ preloaded_map[[type, r["#{name}_id".to_sym]]] = related_map[r["#{name}_id".to_sym]]
205
+ end
206
+ end
207
+ preloaded_map
208
+ end
209
+
210
+ def preload_has_many(records, owner_model, config)
211
+ owner_ids = records.map { |r| r[:id] }.uniq
212
+ return {} if owner_ids.empty?
213
+ owner_singular = Dami::Inflector.singularize(owner_model.to_s)
214
+ fk = config[:foreign_key] || (config[:as] ? "#{config[:as]}_id".to_sym : "#{owner_singular}_id".to_sym)
215
+ model_name = config[:model] || Dami::Inflector.pluralize(config[:name].to_s).to_sym
216
+ query = ::Dami.db(model_name).where(fk => owner_ids)
217
+ if config[:as]
218
+ type_key = "#{config[:as]}_type".to_sym
219
+ type_value = Dami::Inflector.singularize(owner_model.to_s).capitalize
220
+ query = query.where(type_key => type_value)
221
+ end
222
+ related_records = query.to_a.map(&:to_h)
223
+ grouped = related_records.group_by { |r| r[fk] }
224
+ records.each_with_object({}) { |r, h| h[r[:id]] = grouped[r[:id]] || [] }
225
+ end
226
+
227
+ def preload_has_one(records, owner_model, config)
228
+ preloaded = preload_has_many(records, owner_model, config)
229
+ preloaded.transform_values(&:first)
230
+ end
231
+
232
+ def preload_has_many_through(records, owner_model, config)
233
+ owner_ids = records.map { |r| r[:id] }.uniq
234
+ return {} if owner_ids.empty?
235
+ through_config = find_association_config_for_adapter(Dami.find_model(owner_model), config[:through])[1]
236
+ through_model_name = through_config[:model] || Dami::Inflector.pluralize(config[:through].to_s).to_sym
237
+ owner_singular = Dami::Inflector.singularize(owner_model.to_s)
238
+ through_fk_on_join = through_config[:foreign_key] || "#{owner_singular}_id".to_sym
239
+ join_records = ::Dami.db(through_model_name).where(through_fk_on_join => owner_ids).to_a.map(&:to_h)
240
+ return {} if join_records.empty?
241
+ target_model_name = config[:model] || Dami::Inflector.pluralize(config[:name].to_s).to_sym
242
+ target_fk_on_join = "#{Dami::Inflector.singularize(target_model_name.to_s)}_id".to_sym
243
+ target_ids = join_records.map { |r| r[target_fk_on_join] }.compact.uniq
244
+ return {} if target_ids.empty?
245
+ target_records = ::Dami.db(target_model_name).where(id: target_ids).to_a.map(&:to_h)
246
+ targets_by_id = target_records.each_with_object({}) { |r, h| h[r[:id]] = r }
247
+ join_records_by_owner_id = join_records.group_by { |r| r[through_fk_on_join] }
248
+ owner_ids.each_with_object({}) do |id, h|
249
+ joins = join_records_by_owner_id[id] || []
250
+ h[id] = joins.map { |j| targets_by_id[j[target_fk_on_join]] }.compact
251
+ end
252
+ end
253
+ end
254
+
255
+ # This is the single entry point for the plugin, orchestrating all enhancements.
256
+ def self.apply(dami_module)
257
+ dami_module.extend(ClassMethods)
258
+ dami_module::RecordProxy.include(LazyLoadingMethods)
259
+
260
+ # --- THE ONE AND ONLY INITIALIZE WRAPPER ---
261
+ dami_module::RecordProxy.prepend(Module.new do
262
+ def initialize(*args)
263
+ # 1. Run the original, clean Dami::RecordProxy#initialize
264
+ super(*args)
265
+
266
+ # 2. Apply the PRESENTER module first. This gives its methods
267
+ # (like a custom `def status`) the highest priority.
268
+ presenter_module = Dami.find_presenter_module(@model_name)
269
+ extend(presenter_module) if presenter_module
270
+
271
+ # 3. Apply the ASSOCIATION methods next.
272
+ extend Dami.association_module_for(@model_name)
273
+
274
+ # 4. Finally, define FALLBACK accessors for any schema fields
275
+ # that don't already have a method from the presenter or associations.
276
+ _define_fallback_accessors!
277
+ end
278
+ end)
279
+
280
+ dami_module::Adapters::Base.include(AdapterMethods)
281
+ end
282
+ end
283
+ end
284
+ end
@@ -0,0 +1,180 @@
1
+ # File: ./lib/dami/plugins/nested_attributes.rb
2
+ module Dami
3
+ module Plugins
4
+ module NestedAttributes
5
+ class Processor
6
+ def initialize(parent_model_name, parent_record, attributes, persistence_options)
7
+ @parent_model_name = parent_model_name
8
+ @parent_record = parent_record
9
+ @attributes = attributes
10
+ @persistence_options = persistence_options
11
+ @parent_model_config = Dami.find_model(parent_model_name)
12
+ @errors = {}
13
+ @to_create = Hash.new { |h, k| h[k] = [] }
14
+ @to_update = Hash.new { |h, k| h[k] = {} }
15
+ @to_delete = Hash.new { |h, k| h[k] = [] }
16
+ end
17
+
18
+ def validate
19
+ process_nested_attributes(:validate)
20
+ @errors
21
+ end
22
+
23
+ def process
24
+ process_nested_attributes(:save)
25
+ execute_operations
26
+ end
27
+
28
+ private
29
+
30
+ def process_nested_attributes(mode)
31
+ nested_configs = @parent_model_config[:nests] || {}
32
+
33
+ nested_configs.keys.each do |attr_key|
34
+ next unless @attributes.key?(attr_key)
35
+
36
+ config = nested_configs[attr_key]
37
+ records_data = @attributes[attr_key]
38
+ process_nested_records(attr_key, config, records_data, mode)
39
+ end
40
+ end
41
+
42
+ def process_nested_records(attr_key, config, records_data, mode)
43
+ assoc_name = config[:association_name]
44
+ child_rel = @parent_model_config.dig(:relationships, :has_many, assoc_name)
45
+ raise "Undefined has_many association '#{assoc_name}' for nested attributes" unless child_rel
46
+
47
+ child_model_name = child_rel[:model] || assoc_name
48
+ foreign_key = child_rel[:foreign_key] || "#{Dami::Inflector.singularize(@parent_model_name.to_s)}_id".to_sym
49
+
50
+ # Preserve the original keys when processing hashes
51
+ if records_data.is_a?(Hash)
52
+ records_data.each do |original_key, child_attrs|
53
+ # Use the original string key for error indexing
54
+ process_single_record(attr_key, original_key, child_model_name, foreign_key, config, child_attrs, mode)
55
+ end
56
+ else
57
+ records_array = Array(records_data)
58
+ records_array.each_with_index do |child_attrs, index|
59
+ process_single_record(attr_key, index.to_s, child_model_name, foreign_key, config, child_attrs, mode)
60
+ end
61
+ end
62
+ end
63
+
64
+ def process_single_record(attr_key, index_key, child_model_name, foreign_key, config, child_attrs, mode)
65
+ child_attrs = child_attrs.transform_keys(&:to_sym)
66
+
67
+ # FIX 1: Ensure ID is always an integer if present for reliable lookups.
68
+ id = child_attrs[:id]&.to_i
69
+ destroy = ['true', '1', true].include?(child_attrs[:_destroy])
70
+
71
+ # FIX 2 (Security): On update/destroy, verify the child record belongs to the parent.
72
+ if id && mode == :save
73
+ existing_record = Dami.db(child_model_name).find(id)
74
+ # If the record doesn't exist OR its foreign key doesn't match the parent, ignore it.
75
+ return unless existing_record && existing_record[foreign_key] == @parent_record[:id]
76
+ end
77
+
78
+ grandchild_attrs = extract_grandchild_attributes(child_model_name, child_attrs)
79
+
80
+ if mode == :validate || !destroy
81
+ validate_record(child_model_name, child_attrs, grandchild_attrs, attr_key, index_key, id)
82
+ end
83
+
84
+ if mode == :save
85
+ if destroy && config[:allow_destroy] && id
86
+ @to_delete[child_model_name] << id
87
+ elsif !destroy
88
+ if id
89
+ @to_update[child_model_name][id] = { attrs: child_attrs, grandchildren: grandchild_attrs }
90
+ else
91
+ child_attrs[foreign_key] = @parent_record[:id]
92
+ @to_create[child_model_name] << { attrs: child_attrs, grandchildren: grandchild_attrs }
93
+ end
94
+ end
95
+ end
96
+ end
97
+
98
+ def validate_record(child_model_name, child_attrs, grandchild_attrs, attr_key, index_key, id)
99
+ child_errors = {}
100
+
101
+ begin
102
+
103
+ Dami.validate!(child_model_name, child_attrs, on: id ? :update : :create)
104
+ rescue Dami::ValidationError => e
105
+
106
+ child_errors.merge!(e.errors)
107
+ end
108
+
109
+ # Validate grandchildren recursively
110
+ if grandchild_attrs.any?
111
+
112
+ grandchild_processor = self.class.new(child_model_name, nil, grandchild_attrs, @persistence_options)
113
+ grandchild_errors = grandchild_processor.validate
114
+
115
+ child_errors.merge!(grandchild_errors) unless grandchild_errors.empty?
116
+ end
117
+
118
+ add_error(attr_key, index_key, child_errors) unless child_errors.empty?
119
+
120
+ end
121
+
122
+ def extract_grandchild_attributes(model_name, attributes)
123
+ nested_keys = (Dami.find_model(model_name)[:nests] || {}).keys
124
+ nested_keys.each_with_object({}) do |key, hash|
125
+ hash[key] = attributes.delete(key) if attributes.key?(key)
126
+ end
127
+ end
128
+
129
+ def add_error(attr_key, index, error_hash)
130
+ (@errors[attr_key] ||= {})[index] = error_hash
131
+ end
132
+
133
+ def execute_operations
134
+ # We need to execute in this order: deletes, then updates, then creates
135
+ execute_deletes
136
+ execute_updates
137
+ execute_creates
138
+ end
139
+
140
+
141
+ def execute_deletes
142
+ @to_delete.each do |model, ids|
143
+ ids.each do |id|
144
+ before = Dami.db(model).find(id)
145
+ result = Dami.db(model).where(id: id).delete
146
+ after = Dami.db(model).find(id)
147
+ end
148
+ end
149
+ end
150
+
151
+ def execute_updates
152
+ @to_update.each do |model, updates|
153
+ updates.each do |id, data|
154
+ Dami.db(model).where(id: id).update(data[:attrs], **@persistence_options)
155
+ if data[:grandchildren].any?
156
+ child_record = Dami.db(model).find(id)
157
+ processor = self.class.new(model, child_record, data[:grandchildren], @persistence_options)
158
+ processor.process
159
+ end
160
+ end
161
+ end
162
+ end
163
+
164
+ def execute_creates
165
+ @to_create.each do |model, records|
166
+ records.each do |data|
167
+ created_child = Dami.db(model).create(data[:attrs], **@persistence_options)
168
+ if data[:grandchildren].any?
169
+ processor = self.class.new(model, created_child, data[:grandchildren], @persistence_options)
170
+ processor.process
171
+ else
172
+ #
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+ module Dami
3
+ module Plugins
4
+ module Protection
5
+ module DamiClassMethods
6
+ def filter_input!(model_name, attrs, permit: [], protect: true)
7
+ model_config = find_model(model_name)
8
+ virtual_fields = (model_config[:virtual_fields] || {}).keys
9
+ nested_attr_keys = (model_config[:nests] || {}).keys
10
+ known_fields = (model_config[:fields] || {}).keys + virtual_fields + nested_attr_keys + [:id]
11
+ unknown = attrs.keys - known_fields
12
+ raise UnknownFieldsError.new("Unknown fields: #{unknown.join(', ')}", unknown) unless unknown.empty?
13
+ if protect
14
+ protections = model_config.dig(:protection, :protect) || []
15
+ permitted_by_model = model_config.dig(:protection, :permit) || []
16
+ permitted = permit + permitted_by_model
17
+ violations = attrs.keys.select do |key|
18
+ protections.include?(key) && !permitted.include?(key)
19
+ end
20
+ raise ProtectionError.new("Protected fields not permitted: #{violations.join(', ')}", violations) unless violations.empty?
21
+ end
22
+ attrs.reject { |k, _| virtual_fields.include?(k) }
23
+ end
24
+ end
25
+ def self.apply(dami_module)
26
+ dami_module.extend(DamiClassMethods)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,90 @@
1
+ # File: lib/dami/plugins/validation.rb
2
+ module Dami
3
+ module Plugins
4
+ module Validations
5
+ class Registry
6
+ def initialize
7
+ @rules = {}
8
+ end
9
+ def register(name, rule_def)
10
+ @rules[name.to_sym] = rule_def
11
+ end
12
+ def get(name)
13
+ @rules[name.to_sym] || raise("Unknown validation rule: #{name}")
14
+ end
15
+ # File: lib/dami/plugins/validation.rb - line 16
16
+ def validate(value, rule_name, params = [], context = {})
17
+ rule = get(rule_name)
18
+ rule_config = rule.is_a?(Proc) ? rule.call(*params) : rule
19
+ check = rule_config[:check]
20
+
21
+ # --- THE FIX: Use the new Dami.translate system ---
22
+ # 1. Use an inline message if provided directly in the rule definition.
23
+ message = rule_config[:message]
24
+ # 2. Otherwise, fetch it from the new localization system.
25
+ message ||= Dami.translate("validations.#{rule_name}", *params)
26
+ # 3. Use a generic fallback if no translation is found.
27
+ message ||= "is invalid"
28
+
29
+ return message unless check
30
+
31
+ result = check.arity == 1 ? check.call(value) : check.call(value, context)
32
+ result ? nil : message
33
+ end
34
+ end
35
+ module DamiClassMethods
36
+ def validation_registry
37
+ @validation_registry ||= Registry.new
38
+ end
39
+ def rules(namespace = :default, rules_hash)
40
+ rules_hash.each do |name, rule_def|
41
+ validation_registry.register(name, rule_def)
42
+ end
43
+ end
44
+ def validate!(model_name, attrs, on: :create)
45
+ model_config = find_model(model_name)
46
+ errors = {}
47
+ (model_config.dig(:validations) || {}).each do |field, rule_definitions|
48
+ next unless on == :create || attrs.key?(field)
49
+ Array(rule_definitions).each do |rule_def|
50
+ rule_options = rule_def.is_a?(Hash) ? rule_def : { rule: rule_def }
51
+ next if should_skip_validation?(rule_options, attrs, on)
52
+ error_msg = validate_rule(attrs[field], rule_options[:rule], attrs)
53
+ (errors[field] ||= []) << error_msg if error_msg
54
+ end
55
+ end
56
+ raise ValidationError.new("Validation failed", errors) unless errors.empty?
57
+ end
58
+ private
59
+ def should_skip_validation?(rule_def, attrs, on)
60
+ return true if rule_def[:on] && !Array(rule_def[:on]).include?(on)
61
+ if (if_cond = rule_def[:if])
62
+ return true unless if_cond.is_a?(Proc) && if_cond.call(attrs)
63
+ end
64
+ if (unless_cond = rule_def[:unless])
65
+ if unless_cond.is_a?(Proc)
66
+ return true if unless_cond.call(attrs)
67
+ else
68
+ return true if unless_cond.all? { |k, v| attrs[k] == v }
69
+ end
70
+ end
71
+ if (when_cond = rule_def[:when])
72
+ return true unless when_cond.all? { |k, v| attrs[k] == v }
73
+ end
74
+ false
75
+ end
76
+ def validate_rule(value, rule, context)
77
+ case rule
78
+ when Symbol
79
+ validation_registry.validate(value, rule, [], context)
80
+ when Array
81
+ validation_registry.validate(value, rule.first, rule[1..-1], context)
82
+ end
83
+ end
84
+ end
85
+ def self.apply(dami_module)
86
+ dami_module.extend(DamiClassMethods)
87
+ end
88
+ end
89
+ end
90
+ end