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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +306 -0
- data/LICENSE +21 -0
- data/README.md +106 -0
- data/bin/dami +10 -0
- data/docs/01.Getting_Started.md +220 -0
- data/docs/02.Models_and_Fields.md +244 -0
- data/docs/03.Querying.md +387 -0
- data/docs/04.Creating_Updating_Deleting.md +226 -0
- data/docs/05.Validation.md +259 -0
- data/docs/06.Protection.md +160 -0
- data/docs/07.Associations.md +239 -0
- data/docs/08.Scopes.md +99 -0
- data/docs/09.Migrations.md +165 -0
- data/docs/10.Flows_and_Commands.md +181 -0
- data/docs/11.Localization.md +435 -0
- data/docs/12.TheDamiWay.md +227 -0
- data/docs/Manifesto.md +305 -0
- data/docs/site.md +477 -0
- data/lib/dami/actions/command.rb +80 -0
- data/lib/dami/actions/context.rb +63 -0
- data/lib/dami/actions/draft.rb +36 -0
- data/lib/dami/actions/flow.rb +42 -0
- data/lib/dami/adapters/base.rb +40 -0
- data/lib/dami/adapters/sqlite/connection.rb +122 -0
- data/lib/dami/adapters/sqlite/core.rb +17 -0
- data/lib/dami/adapters/sqlite/query.rb +353 -0
- data/lib/dami/adapters/sqlite/schema.rb +135 -0
- data/lib/dami/cli.rb +117 -0
- data/lib/dami/configuration.rb +246 -0
- data/lib/dami/core.rb +98 -0
- data/lib/dami/dsl_guardrails.rb +45 -0
- data/lib/dami/errors.rb +89 -0
- data/lib/dami/inflector.rb +245 -0
- data/lib/dami/localization.rb +131 -0
- data/lib/dami/migration.rb +84 -0
- data/lib/dami/migrator.rb +105 -0
- data/lib/dami/plugins/associations.rb +284 -0
- data/lib/dami/plugins/nested_attributes.rb +180 -0
- data/lib/dami/plugins/protection.rb +30 -0
- data/lib/dami/plugins/validations.rb +90 -0
- data/lib/dami/query/builder.rb +132 -0
- data/lib/dami/query/enumerable.rb +62 -0
- data/lib/dami/query/persistence.rb +133 -0
- data/lib/dami/record_proxy.rb +32 -0
- data/lib/dami/result.rb +27 -0
- data/lib/dami/schema/diff.rb +84 -0
- data/lib/dami/schema/dumper.rb +60 -0
- data/lib/dami/schema/generator.rb +69 -0
- data/lib/dami/schema/introspector.rb +34 -0
- data/lib/dami/schema/loader.rb +58 -0
- data/lib/dami/schema.rb +13 -0
- data/lib/dami/validation_rules.rb +72 -0
- data/lib/dami/version.rb +3 -0
- data/lib/dami.rb +32 -0
- 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
|