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
data/lib/dami/cli.rb ADDED
@@ -0,0 +1,117 @@
1
+ # File: ./lib/dami/cli.rb
2
+
3
+ # frozen_string_literal: true
4
+ require 'thor'
5
+ require 'fileutils'
6
+ require_relative '../dami' # Load the main Dami library
7
+
8
+ module Dami
9
+ # 1. Define the parent namespace first to prevent loading errors.
10
+ class CLI < Thor; end
11
+
12
+ # 2. Define a base class for our subcommands to share common helper methods.
13
+ class CLI::Base < Thor
14
+ private
15
+
16
+ # This helper loads the user's application environment. It looks for a
17
+ # standard initializer and then loads all model files from the configured path.
18
+ def load_user_app
19
+ initializer_path = File.expand_path("config/initializers/dami.rb", Dir.pwd)
20
+ require initializer_path if File.exist?(initializer_path)
21
+
22
+ model_paths = Array(Dami.configuration.models_path)
23
+ model_paths.each do |path|
24
+ search_path = File.expand_path(path)
25
+ Dir.glob(File.join(search_path, '**', '*.rb')).each { |file| load file }
26
+ end
27
+ end
28
+ end
29
+
30
+ # 3. Define the subcommand classes.
31
+
32
+ # Handles all `dami db:*` subcommands.
33
+ class DB < CLI::Base
34
+ desc "migrate", "Run all pending database migrations"
35
+ def migrate
36
+ load_user_app
37
+ puts "Running migrations..."
38
+
39
+ db = Dami.database(:default) # Assumes the app has already connected.
40
+ migrator = Dami::Migrator.new(db)
41
+ migrator.migrate
42
+
43
+ puts "✅ Migrations complete. Schema has been updated in #{Dami.configuration.schema_path}."
44
+ end
45
+
46
+ desc "rollback [STEPS]", "Revert the last migration (or the last STEPS migrations)"
47
+ def rollback(steps = 1)
48
+ load_user_app
49
+ db = Dami.database(:default)
50
+ Dami::Migrator.new(db).rollback(steps.to_i)
51
+ puts "✅ Rolled back #{steps} migration(s). Schema has been updated in #{Dami.configuration.schema_path}."
52
+ end
53
+
54
+ desc "schema_dump", "Generate a schema file from the current database state"
55
+ def schema_dump
56
+ load_user_app
57
+ puts "Dumping schema..."
58
+
59
+ db = Dami.database(:default)
60
+ dumper = Dami::Schema::Dumper.new(db)
61
+ schema_content = dumper.dump
62
+
63
+ schema_path = Dami.configuration.schema_path
64
+ FileUtils.mkdir_p(File.dirname(schema_path))
65
+ File.write(schema_path, schema_content)
66
+ puts "✅ Schema dumped to #{schema_path}"
67
+ end
68
+ end
69
+
70
+ # Handles all `dami generate *` subcommands.
71
+ class Generate < CLI::Base
72
+ desc "migration NAME", "Generate a new migration by comparing models to the schema"
73
+ def migration(name)
74
+ load_user_app
75
+
76
+ loader = Dami::Schema::Loader.new
77
+ loader.load_from_path(Dami.configuration.schema_path)
78
+
79
+ introspector = Dami::Schema::Introspector.new
80
+
81
+ differ = Dami::Schema::Diff.new(loader.schema, introspector.introspect)
82
+ diff_result = differ.diff
83
+
84
+ if diff_result[:up].empty?
85
+ puts "✅ Schema is up to date. No migration generated."
86
+ else
87
+ generator = Dami::Schema::Generator.new
88
+ path = generator.generate(diff_result, name)
89
+ puts "✅ New migration created: #{path}"
90
+ end
91
+ end
92
+ end
93
+
94
+ # 4. Finally, reopen the main CLI class to add the subcommands.
95
+ # This works because `DB` and `Generate` are now fully defined.
96
+ class CLI < Thor
97
+ desc "db", "Manage database tasks (migrate, rollback, schema_dump)"
98
+ subcommand "db", DB
99
+
100
+ desc "generate", "Generate new files (e.g., migrations)"
101
+ subcommand "generate", Generate
102
+ end
103
+ end
104
+
105
+ # This modification to the Configuration class is still needed.
106
+ # It ensures the CLI has a place to get database connection details from.
107
+ class Dami::Configuration
108
+ attr_accessor :database_config
109
+
110
+ # We redefine initialize to add the new attribute with a default.
111
+ def initialize
112
+ @models_path = 'app/models'
113
+ @migrations_path = 'db/migrations'
114
+ @schema_path = 'db/schema.rb'
115
+ @database_config = {}
116
+ end
117
+ end
@@ -0,0 +1,246 @@
1
+ module Dami
2
+ def self.model(name, &block)
3
+ config = ModelConfig.new(name)
4
+ config.instance_eval(&block)
5
+ (@models ||= {})[name] = config.to_h.merge(name: name).freeze
6
+ end
7
+ def self.behavior(model_name, &block)
8
+ model_config = find_model(model_name)
9
+ BehaviorProxy.new(model_config).instance_eval(&block)
10
+ end
11
+ def self.scopes(model_name, &block)
12
+ model_config = find_model(model_name)
13
+ ScopesProxy.new(model_config.fetch(:scopes, {})).instance_eval(&block)
14
+ end
15
+ class Configuration
16
+ attr_accessor :models_path, :migrations_path, :schema_path, :database_config
17
+ def initialize
18
+ @models_path = 'app/models'
19
+ @migrations_path = 'db/migrations'
20
+ @schema_path = 'db/schema.rb'
21
+ @database_config = {}
22
+ end
23
+ end
24
+ def self.configuration
25
+ @configuration ||= Configuration.new
26
+ end
27
+ def self.configure
28
+ yield(configuration)
29
+ end
30
+ class ModelConfig
31
+ def initialize(name)
32
+ @config = {
33
+ database: :default,
34
+ fields: {},
35
+ virtual_fields: {},
36
+ protection: {},
37
+ relationships: {},
38
+ validations: {},
39
+ scopes: {},
40
+ nests: {}
41
+ }
42
+ end
43
+ def database(name)
44
+ @config[:database] = name
45
+ end
46
+ def fields(&block)
47
+ FieldsProxy.new(@config[:fields]).instance_eval(&block)
48
+ end
49
+ def virtual(&block)
50
+ FieldsProxy.new(@config[:virtual_fields]).instance_eval(&block)
51
+ end
52
+ def relationships(&block)
53
+ RelationshipProxy.new(@config[:relationships]).instance_eval(&block)
54
+ end
55
+ def nests(*names, **options)
56
+ proxy = NestsProxy.new(@config[:nests])
57
+ names.each { |name| proxy.nest(name, **options) }
58
+ end
59
+ def validate(*args, &block)
60
+ raise Dami::InvalidDSLError.new('validate', 'behavior')
61
+ end
62
+ def protection(*args, &block)
63
+ raise Dami::InvalidDSLError.new('protection', 'behavior')
64
+ end
65
+ def scope(*args, &block)
66
+ raise Dami::InvalidDSLError.new('scope', 'scopes')
67
+ end
68
+ def to_h
69
+ @config
70
+ end
71
+ end
72
+ class FieldsProxy
73
+ def initialize(target)
74
+ @target = target
75
+ end
76
+ def field(name, type, **options)
77
+ @target[name] = { type: type, **options }
78
+ end
79
+ end
80
+ class RelationshipProxy
81
+ def initialize(target)
82
+ @target = target
83
+ end
84
+ def belongs_to(*names, **options)
85
+ (@target[:belongs_to] ||= {}).merge!(parse_relations(names, options))
86
+ end
87
+ def has_one(*names, **options)
88
+ (@target[:has_one] ||= {}).merge!(parse_relations(names, options))
89
+ end
90
+ def has_many(*names, **options)
91
+ (@target[:has_many] ||= {}).merge!(parse_relations(names, options))
92
+ end
93
+ private
94
+ def parse_relations(names, options)
95
+ relations = {}
96
+ options.each { |key, value| relations[key] = value if value.is_a?(Hash) }
97
+ non_rel_opts = options.reject { |_, v| v.is_a?(Hash) }
98
+ names.each { |name| relations[name] = non_rel_opts }
99
+ relations
100
+ end
101
+ end
102
+ class BehaviorProxy
103
+ def initialize(model_config)
104
+ @model_config = model_config
105
+ @model_config[:validations] ||= {}
106
+ @model_config[:protection] ||= {}
107
+ @model_config[:protection][:protect] ||= []
108
+ @model_config[:protection][:permit] ||= []
109
+ end
110
+ def validate(&block)
111
+ validator = ValidationBuilder.new(@model_config[:validations], model_config: @model_config)
112
+ validator.instance_eval(&block)
113
+ end
114
+ def protection(&block)
115
+ protector = ProtectionBuilder.new(@model_config[:protection])
116
+ protector.instance_eval(&block)
117
+ end
118
+ def on(operation, &block)
119
+ validator = ValidationBuilder.new(@model_config[:validations], context: operation, model_config: @model_config)
120
+ validator.instance_eval(&block)
121
+ end
122
+ def fields(*args, &block)
123
+ raise Dami::InvalidDSLError.new('fields', 'model')
124
+ end
125
+ def virtual(*args, &block)
126
+ raise Dami::InvalidDSLError.new('virtual', 'model')
127
+ end
128
+ def relationships(*args, &block)
129
+ raise Dami::InvalidDSLError.new('relationships', 'model')
130
+ end
131
+ def nests(*args, &block)
132
+ raise Dami::InvalidDSLError.new('nests', 'model')
133
+ end
134
+ def scope(*args, &block)
135
+ raise Dami::InvalidDSLError.new('scope', 'scopes')
136
+ end
137
+ class ValidationBuilder
138
+ def initialize(config, context: nil, model_config: nil)
139
+ @config = config
140
+ @context = context
141
+ @model_config = model_config
142
+ end
143
+ def rule(field, *rules, **options, &block)
144
+ field = field.to_sym
145
+ @config[field] ||= []
146
+ if_cond = options.delete(:if)
147
+ unless_cond = options.delete(:unless)
148
+ when_cond = options.delete(:when)
149
+ rules.concat(options.map { |k, v| v == true ? k : [k, v] })
150
+ rules.each do |rule_def|
151
+ rule_entry = { rule: rule_def, on: @context ? [@context] : [:create, :update] }
152
+ rule_entry[:if] = if_cond if if_cond
153
+ rule_entry[:unless] = unless_cond if unless_cond
154
+ rule_entry[:when] = when_cond if when_cond
155
+ @config[field] << rule_entry
156
+ end
157
+ end
158
+ def all(only: nil, except: nil, **kwargs, &block)
159
+ all_fields = []
160
+ if @model_config
161
+ all_fields.concat(@model_config[:fields].keys) if @model_config[:fields]
162
+ all_fields.concat(@model_config[:virtual_fields].keys) if @model_config[:virtual_fields]
163
+ end
164
+ fields_to_validate = if only
165
+ Array(only).map(&:to_sym) & all_fields
166
+ elsif except
167
+ all_fields - Array(except).map(&:to_sym)
168
+ else
169
+ all_fields
170
+ end
171
+ if kwargs.any?
172
+ fields_to_validate.each do |field|
173
+ rule(field, **kwargs)
174
+ end
175
+ end
176
+ if block_given?
177
+ temp_builder = TempRuleBuilder.new(@context)
178
+ temp_builder.instance_eval(&block)
179
+ fields_to_validate.each do |field|
180
+ temp_builder.rules.each do |rule_def|
181
+ rule(field, *rule_def[:args], **rule_def[:kwargs])
182
+ end
183
+ end
184
+ end
185
+ end
186
+ def on(operation, &block)
187
+ nested = ValidationBuilder.new(@config, context: operation, model_config: @model_config)
188
+ nested.instance_eval(&block)
189
+ end
190
+ class TempRuleBuilder
191
+ attr_reader :rules
192
+ def initialize(context)
193
+ @context = context
194
+ @rules = []
195
+ end
196
+ def rule(*args, **kwargs)
197
+ @rules << { args: args, kwargs: kwargs }
198
+ end
199
+ end
200
+ end
201
+ class ProtectionBuilder
202
+ def initialize(config)
203
+ @config = config
204
+ @config[:protect] ||= []
205
+ @config[:permit] ||= []
206
+ end
207
+ def protect(*fields)
208
+ @config[:protect].concat(fields.map(&:to_sym))
209
+ @config[:protect].uniq!
210
+ end
211
+ def permit(*fields)
212
+ @config[:permit].concat(fields.map(&:to_sym))
213
+ @config[:permit].uniq!
214
+ end
215
+ end
216
+ end
217
+ class ScopesProxy
218
+ def initialize(target)
219
+ @target = target
220
+ end
221
+ def scope(name, body)
222
+ @target[name] = body
223
+ end
224
+ def fields(*args, &block)
225
+ raise Dami::InvalidDSLError.new('fields', 'model')
226
+ end
227
+ def validate(*args, &block)
228
+ raise Dami::InvalidDSLError.new('validate', 'behavior')
229
+ end
230
+ def protection(*args, &block)
231
+ raise Dami::InvalidDSLError.new('protection', 'behavior')
232
+ end
233
+ end
234
+ class NestsProxy
235
+ def initialize(target)
236
+ @target = target
237
+ end
238
+ def nest(association_name, **options)
239
+ attributes_key = "#{association_name}_attributes".to_sym
240
+ @target[attributes_key] = {
241
+ association_name: association_name,
242
+ allow_destroy: options.fetch(:allow_destroy, false)
243
+ }.freeze
244
+ end
245
+ end
246
+ end
data/lib/dami/core.rb ADDED
@@ -0,0 +1,98 @@
1
+ # File: lib/dami/core.rb
2
+
3
+ require_relative 'adapters/sqlite/connection'
4
+
5
+ module Dami
6
+ @databases = {}
7
+ @models = {}
8
+ @flows = {}
9
+ @presenter_modules = {}
10
+
11
+ def self.plugin(mod)
12
+ mod.apply(self) if mod.respond_to?(:apply)
13
+ end
14
+
15
+ def self.clear_all!
16
+ @databases.clear
17
+ @models.clear
18
+ @flows.clear
19
+ @presenter_modules&.clear
20
+ (@translations || {}).clear
21
+ Plugins::Associations.clear_cache!
22
+ end
23
+
24
+
25
+ def self.present(model_name, &block)
26
+ presenter_module = Module.new
27
+ presenter_module.module_eval(&block) # Changed from instance_eval
28
+ (@presenter_modules ||= {})[model_name] = presenter_module
29
+ end
30
+
31
+ def self.find_presenter_module(model_name)
32
+ (@presenter_modules || {})[model_name]
33
+ end
34
+
35
+ # RecordProxy#initialize (extended by the Associations plugin) already applies
36
+ # the presenter, the association accessors and the field accessors.
37
+ def self.wrap_record(model_name, record_hash)
38
+ return nil unless record_hash
39
+ ::Dami::RecordProxy.new(model_name, record_hash)
40
+ end
41
+
42
+ def self.connect(name = :default, adapter:, **config)
43
+ adapter_class = Dami::Adapters.const_get(adapter.to_s.capitalize)
44
+ db_instance = adapter_class.new(config)
45
+ db_instance.connect
46
+ @databases[name] = db_instance
47
+ db_instance
48
+ end
49
+
50
+ def self.database(name = :default)
51
+ @databases.fetch(name) { raise "Database :#{name} not connected" }
52
+ end
53
+
54
+ def self.find_model(name)
55
+ @models.fetch(name) { raise "Model :#{name} not defined" }
56
+ end
57
+
58
+ def self.db(model_name)
59
+ model_config = find_model(model_name)
60
+ db_name = model_config[:database] || :default
61
+ builder_class = create_builder_with_scopes(model_name, model_config[:scopes] || {})
62
+ builder_class.new(model_name, db_name: db_name)
63
+ end
64
+
65
+ # Runs a flow inside one transaction.
66
+ # * A Success halt commits.
67
+ # * A Failure halt ROLLS BACK every write the flow made (raising Rollback
68
+ # out of the transaction block is what triggers it).
69
+ # * An unhandled exception rolls back and re-raises.
70
+ # after-commit hooks run only once the transaction has actually committed.
71
+ def self.run(flow_name, **params)
72
+ flow = find_flow(flow_name)
73
+ context = FlowContext.new(params)
74
+ result = nil
75
+ begin
76
+ Dami.database.transaction do
77
+ result = catch(:halt) { flow.call(context) }
78
+ raise Rollback if result.is_a?(Failure)
79
+ end
80
+ rescue Rollback
81
+ # Intentional: the transaction was rolled back; `result` is the Failure.
82
+ end
83
+ context.run_after_commit_hooks! if result.is_a?(Success)
84
+ result
85
+ end
86
+
87
+ def self.create_builder_with_scopes(model_name, scopes)
88
+ return Dami::Query::Builder if scopes.empty?
89
+ Class.new(Dami::Query::Builder) do
90
+ scopes.each do |name, body|
91
+ define_method(name) do |*args|
92
+ instance_exec(*args, &body)
93
+ end
94
+ end
95
+ end
96
+ end
97
+ private_class_method :create_builder_with_scopes
98
+ end
@@ -0,0 +1,45 @@
1
+ # File: lib/dami/dsl_guardrails.rb
2
+
3
+ module Dami
4
+ module DSLGuardrails
5
+ # Methods that belong in Dami.behavior
6
+ def validate(*args, &block)
7
+ raise InvalidDSLError.new('validate', 'behavior')
8
+ end
9
+
10
+ def protection(*args, &block)
11
+ raise InvalidDSLError.new('protection', 'behavior')
12
+ end
13
+
14
+ def on(*args, &block)
15
+ raise InvalidDSLError.new('on', 'behavior')
16
+ end
17
+
18
+ # Methods that belong in Dami.model
19
+ def fields(*args, &block)
20
+ raise InvalidDSLError.new('fields', 'model')
21
+ end
22
+
23
+ def virtual(*args, &block)
24
+ raise InvalidDSLError.new('virtual', 'model')
25
+ end
26
+
27
+ def relationships(*args, &block)
28
+ raise InvalidDSLError.new('relationships', 'model')
29
+ end
30
+
31
+ def nests(*args, &block)
32
+ raise InvalidDSLError.new('nests', 'model')
33
+ end
34
+
35
+ # Methods that belong in Dami.scopes
36
+ def scope(*args, &block)
37
+ raise InvalidDSLError.new('scope', 'scopes')
38
+ end
39
+
40
+ # Methods that belong in Dami.present
41
+ def helper(*args, &block)
42
+ raise InvalidDSLError.new('helper', 'present')
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,89 @@
1
+ # File: lib/dami/errors.rb
2
+
3
+ module Dami
4
+ # Base error class
5
+ class Error < StandardError; end
6
+
7
+ # Validation errors
8
+ class ValidationError < Error
9
+ attr_reader :errors
10
+
11
+ def initialize(message = "Validation failed", errors = {})
12
+ @errors = errors
13
+ super(message)
14
+ end
15
+ end
16
+
17
+ # Protection errors - backward compatible
18
+ class ProtectionError < Error
19
+ attr_reader :fields
20
+
21
+ def initialize(message, fields = nil)
22
+ @fields = fields
23
+ super(message)
24
+ end
25
+ end
26
+
27
+ class UnknownFieldsError < Error
28
+ attr_reader :fields
29
+
30
+ def initialize(message, fields = nil)
31
+ @fields = fields
32
+ super(message)
33
+ end
34
+ end
35
+
36
+ # Database errors
37
+ class ForeignKeyViolation < Error
38
+ attr_reader :table, :column, :value
39
+
40
+ def initialize(message, table: nil, column: nil, value: nil)
41
+ @table = table
42
+ @column = column
43
+ @value = value
44
+ super("Foreign key violation: #{message}")
45
+ end
46
+ end
47
+
48
+ class UniqueConstraintViolation < Error
49
+ attr_reader :table, :column, :value
50
+
51
+ def initialize(message, table: nil, column: nil, value: nil)
52
+ @table = table
53
+ @column = column
54
+ @value = value
55
+ super("Unique constraint violation: #{message}")
56
+ end
57
+ end
58
+
59
+ class NotNullViolation < Error
60
+ attr_reader :table, :column
61
+
62
+ def initialize(message, table: nil, column: nil)
63
+ @table = table
64
+ @column = column
65
+ super("Not-null constraint violation: #{message}")
66
+ end
67
+ end
68
+
69
+ # DSL Architecture errors
70
+ class InvalidDSLError < Error
71
+ def initialize(method_name, correct_location)
72
+ super(
73
+ "'#{method_name}' is not allowed here. " \
74
+ "Please define it in a Dami.#{correct_location} block."
75
+ )
76
+ end
77
+ end
78
+
79
+ # Command errors
80
+ class InvalidCommand < Error; end
81
+
82
+ # Raised internally by Dami.run to abort the surrounding transaction when a
83
+ # flow halts with a Failure. Never escapes Dami.run.
84
+ class Rollback < Error; end
85
+
86
+ # Raised when a caller passes something that is not a plain SQL identifier
87
+ # (letters, digits, underscore, dot) where a column or table name is expected.
88
+ class InvalidIdentifier < Error; end
89
+ end