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,132 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'enumerable'
3
+ require_relative 'persistence'
4
+
5
+ module Dami
6
+ module Query
7
+ class Builder
8
+ include ::Enumerable
9
+ include Dami::Query::Enumerable
10
+ include Dami::Query::Persistence
11
+
12
+ def initialize(model_name, db_name: :default, conditions: [], order_by: nil, limit: nil, offset: nil, preload: [], select_columns: nil, joins: [])
13
+ @model_name = model_name
14
+ @db_name = db_name
15
+ @conditions = conditions
16
+ @order_by = order_by
17
+ @limit = limit
18
+ @offset = offset
19
+ @preload = preload
20
+ @select_columns = select_columns
21
+ @joins = joins
22
+ end
23
+
24
+ def where(*args, &block)
25
+ if block
26
+ sub_query = self.class.new(@model_name)
27
+ block.call(sub_query)
28
+ clone_with(conditions: @conditions + [[:and, sub_query.instance_variable_get(:@conditions)]])
29
+ else
30
+ # If multiple arguments are passed (e.g., from a raw SQL scope),
31
+ # keep them as an array. Otherwise, it's a hash.
32
+ conds = args.length > 1 ? args : args.first
33
+ clone_with(conditions: @conditions + [[:and, conds]])
34
+ end
35
+ end
36
+
37
+ def or(conds = nil, &block)
38
+ if block
39
+ sub_query = self.class.new(@model_name, db_name: @db_name)
40
+ block.call(sub_query)
41
+ clone_with(conditions: @conditions + [[:or, sub_query.instance_variable_get(:@conditions)]])
42
+ else
43
+ clone_with(conditions: @conditions + [[:or, conds]])
44
+ end
45
+ end
46
+
47
+ def join(table, conditions)
48
+ join_clause = { type: :inner, table: table, conditions: conditions }
49
+ clone_with(joins: @joins + [join_clause])
50
+ end
51
+
52
+ def left_join(table, conditions)
53
+ join_clause = { type: :left, table: table, conditions: conditions }
54
+ clone_with(joins: @joins + [join_clause])
55
+ end
56
+
57
+ def select(*columns)
58
+ clone_with(select_columns: columns)
59
+ end
60
+
61
+ def order(field)
62
+ clone_with(order_by: field)
63
+ end
64
+
65
+ def limit(count)
66
+ clone_with(limit: count.to_i)
67
+ end
68
+
69
+ def offset(count)
70
+ clone_with(offset: count.to_i)
71
+ end
72
+
73
+ def preload(*relations)
74
+ clone_with(preload: @preload + relations)
75
+ end
76
+
77
+ private
78
+
79
+ def reverse_order_query
80
+ return order(id: :desc) unless @order_by
81
+
82
+ reversed_order = case @order_by
83
+ when Hash
84
+ @order_by.transform_values { |dir| dir == :asc ? :desc : :asc }
85
+ when String, Symbol
86
+ parts = @order_by.to_s.split(',').map do |part|
87
+ field, dir = part.strip.split(/\s+/)
88
+ new_dir = (dir&.upcase == 'DESC') ? 'ASC' : 'DESC'
89
+ "#{field} #{new_dir}"
90
+ end
91
+ parts.join(', ')
92
+ else
93
+ { id: :desc }
94
+ end
95
+
96
+ clone_with(order_by: reversed_order)
97
+ end
98
+
99
+ def adapter
100
+ Dami.database(@db_name)
101
+ end
102
+
103
+ def clone_with(**new_opts)
104
+ self.class.new(
105
+ @model_name,
106
+ db_name: @db_name,
107
+ conditions: @conditions,
108
+ order_by: @order_by,
109
+ limit: @limit,
110
+ offset: @offset,
111
+ preload: @preload,
112
+ select_columns: @select_columns,
113
+ joins: @joins,
114
+ **new_opts
115
+ )
116
+ end
117
+
118
+ def build_query_structure
119
+ {
120
+ model_name: @model_name,
121
+ conditions: @conditions,
122
+ order_by: @order_by,
123
+ limit: @limit,
124
+ offset: @offset,
125
+ select_columns: @select_columns,
126
+ joins: @joins,
127
+ preload: @preload
128
+ }
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,62 @@
1
+ module Dami
2
+ module Query
3
+ module Enumerable
4
+ def each(&block)
5
+ to_a.each(&block)
6
+ end
7
+
8
+ def to_a
9
+ query_structure = build_query_structure
10
+ records = adapter.query_records(query_structure)
11
+ proxies = records.map { |r| Dami.wrap_record(@model_name, r) }
12
+ @preload.empty? ? proxies : adapter.preload_associations(proxies, @preload)
13
+ end
14
+ alias_method :all, :to_a
15
+
16
+ def find(id)
17
+ record = adapter.find_record(@model_name, id)
18
+ Dami.wrap_record(@model_name, record)
19
+ end
20
+ def find_each(batch_size: 1000, &block)
21
+ find_in_batches(batch_size: batch_size) do |batch|
22
+ batch.each(&block)
23
+ end
24
+ end
25
+
26
+ def find_in_batches(batch_size: 1000)
27
+ query = @order_by ? self : order(:id)
28
+ offset = 0
29
+ loop do
30
+ records = query.limit(batch_size).offset(offset).to_a
31
+ break if records.empty?
32
+ yield records
33
+ break if records.length < batch_size
34
+ offset += records.length
35
+ end
36
+ end
37
+
38
+ def first
39
+ limit(1).to_a.first
40
+ end
41
+
42
+ def last
43
+ reverse_order_query.first
44
+ end
45
+
46
+ def any?
47
+ adapter.query_exists?(build_query_structure)
48
+ end
49
+
50
+ def exists?(conds = nil)
51
+ conds ? where(conds).any? : any?
52
+ end
53
+
54
+ # This is the updated method
55
+ def count
56
+ adapter.count_records(build_query_structure)
57
+ end
58
+ alias_method :length, :count
59
+ alias_method :size, :count
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+ module Dami
3
+ module Query
4
+ module Persistence
5
+ def create(attrs = {}, **options, &block)
6
+ attributes = attrs.merge(options)
7
+ if block_given?
8
+ draft = ::Dami::Draft.new(original: nil, data: attributes)
9
+ yield(draft)
10
+ raise ::Dami::ValidationError.new("Validation failed", draft.errors) unless draft.valid?
11
+ attributes = draft.data
12
+ end
13
+ prepared = _prepare_persistence(attributes, :create)
14
+ return nil if prepared[:db_parent_attrs].empty? && prepared[:nested_attributes].empty?
15
+ parent_proxy = nil
16
+ adapter.transaction do
17
+ parent_record_hash = adapter.insert_record(@model_name, prepared[:db_parent_attrs])
18
+ parent_proxy = ::Dami::RecordProxy.new(@model_name, parent_record_hash)
19
+ if prepared[:nested_attributes].any?
20
+ processor = ::Dami::Plugins::NestedAttributes::Processor.new(@model_name, parent_proxy, prepared[:nested_attributes], prepared[:persistence_opts])
21
+ processor.process
22
+ end
23
+ end
24
+ parent_proxy ? find(parent_proxy[:id]) : nil
25
+ end
26
+ def update(attrs = {}, **options, &block)
27
+ original_record = self.first
28
+ return nil unless original_record
29
+ attributes = attrs.merge(options)
30
+ if block_given?
31
+ draft = ::Dami::Draft.new(original: original_record.to_h, data: attributes)
32
+ yield(draft)
33
+ return nil unless draft.valid?
34
+ attributes = draft.data
35
+ end
36
+ return original_record if attributes.empty?
37
+ prepared = _prepare_persistence(attributes, :update, original_record)
38
+ adapter.transaction do
39
+ adapter.update_records(build_query_structure, prepared[:db_parent_attrs]) if prepared[:db_parent_attrs].any?
40
+ if prepared[:nested_attributes].any?
41
+ processor = ::Dami::Plugins::NestedAttributes::Processor.new(@model_name, original_record, prepared[:nested_attributes], prepared[:persistence_opts])
42
+ processor.process
43
+ end
44
+ end
45
+ find(original_record[:id])
46
+ end
47
+ # File: lib/dami/query/persistence.rb
48
+
49
+ def create_many(records, permit: [], protect: true)
50
+ raise ArgumentError, "create_many requires an array of hashes" unless records.is_a?(Array)
51
+ return [] if records.empty?
52
+
53
+ # Phase 1: Validate and prepare ALL records first
54
+ prepared_records = []
55
+ all_errors = {}
56
+
57
+ records.each_with_index do |attrs, index|
58
+ begin
59
+ attributes = attrs.merge(permit: permit, protect: protect)
60
+ prepared = _prepare_persistence(attributes, :create)
61
+
62
+ if prepared[:nested_attributes].any?
63
+ raise ArgumentError, "create_many does not support nested attributes. Use create() for complex records."
64
+ end
65
+
66
+ if prepared[:db_parent_attrs].empty?
67
+ raise ArgumentError, "Record #{index} has no valid attributes after filtering"
68
+ end
69
+
70
+ prepared_records << prepared[:db_parent_attrs]
71
+ rescue Dami::ValidationError => e
72
+ # FIX: Just store the error object, not try to set errors
73
+ all_errors[index] = e.errors
74
+ rescue Dami::ProtectionError, Dami::UnknownFieldsError => e
75
+ raise e
76
+ end
77
+ end
78
+
79
+ # If ANY validations failed, raise with collected errors
80
+ unless all_errors.empty?
81
+ # FIX: Create a custom message and pass errors in the initializer
82
+ message = "Validation failed for #{all_errors.size} record(s): " +
83
+ all_errors.map { |idx, errs| "Record #{idx}: #{errs}" }.join("; ")
84
+ raise Dami::ValidationError.new(message, all_errors)
85
+ end
86
+
87
+ # Phase 2: Bulk insert in transaction
88
+ result_ids = adapter.transaction do
89
+ adapter.insert_many(@model_name, prepared_records)
90
+ end
91
+
92
+ # Phase 3: Return wrapped records
93
+ result_ids.map.with_index do |id, index|
94
+ record_hash = prepared_records[index].merge(id: id)
95
+ ::Dami::RecordProxy.new(@model_name, record_hash)
96
+ end
97
+ end
98
+ def delete
99
+ adapter.delete_records(build_query_structure)
100
+ end
101
+ private
102
+
103
+ def _prepare_persistence(attributes, operation, parent_record = nil)
104
+ persistence_opts = {
105
+ permit: attributes.delete(:permit) || [],
106
+ protect: attributes.key?(:protect) ? attributes.delete(:protect) : true
107
+ }
108
+ model_config = Dami.find_model(@model_name)
109
+ parent_attributes = attributes.dup
110
+ nested_attributes = (model_config[:nests] || {}).keys.each_with_object({}) do |key, hash|
111
+ hash[key] = parent_attributes.delete(key) if parent_attributes.key?(key)
112
+ end
113
+ all_errors = {}
114
+ if nested_attributes.any?
115
+ processor = ::Dami::Plugins::NestedAttributes::Processor.new(@model_name, parent_record, nested_attributes, persistence_opts)
116
+ nested_errors = processor.validate
117
+ nested_errors.each do |nested_attr_key, nested_error_value|
118
+ all_errors[nested_attr_key] = nested_error_value
119
+ end
120
+ end
121
+ begin
122
+ Dami.validate!(@model_name, parent_attributes, on: operation)
123
+ rescue Dami::ValidationError => e
124
+ all_errors.merge!(e.errors)
125
+ end
126
+ raise Dami::ValidationError.new("Validation failed", all_errors) unless all_errors.empty?
127
+ filtered_parent_attrs = Dami.filter_input!(@model_name, parent_attributes, **persistence_opts)
128
+ db_parent_attrs = filtered_parent_attrs.reject { |k, _| (model_config[:virtual_fields] || {}).key?(k) }
129
+ { db_parent_attrs: db_parent_attrs, nested_attributes: nested_attributes, persistence_opts: persistence_opts }
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,32 @@
1
+ # File: lib/dami/record_proxy.rb
2
+
3
+ # frozen_string_literal: true
4
+ module Dami
5
+ class RecordProxy
6
+ def initialize(model_name, record, preloaded = {})
7
+ @model_name = model_name
8
+ @record = record
9
+ @preloaded = preloaded
10
+ end
11
+
12
+ def [](key)
13
+ @record[key]
14
+ end
15
+
16
+ def to_h
17
+ @record
18
+ end
19
+
20
+ # This helper is called by the associations plugin's initialize wrapper.
21
+ def _define_fallback_accessors!
22
+ return unless @record.is_a?(Hash)
23
+ @record.each_key do |key|
24
+ unless respond_to?(key)
25
+ define_singleton_method(key) do
26
+ @record[key]
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+ module Dami
3
+ class Success
4
+ attr_reader :value
5
+ def initialize(value)
6
+ @value = value
7
+ end
8
+ def success?
9
+ true
10
+ end
11
+ def failure?
12
+ false
13
+ end
14
+ end
15
+ class Failure
16
+ attr_reader :error
17
+ def initialize(error)
18
+ @error = error
19
+ end
20
+ def success?
21
+ false
22
+ end
23
+ def failure?
24
+ true
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dami
4
+ module Schema
5
+ class Diff
6
+ def initialize(old_schema, new_schema)
7
+ @old_schema = old_schema
8
+ @new_schema = new_schema
9
+ @up_commands = []
10
+ @down_commands = []
11
+ end
12
+
13
+ # The main public method that computes the differences.
14
+ def diff
15
+ find_added_tables
16
+ find_removed_tables
17
+ find_changed_tables
18
+
19
+ { up: @up_commands, down: @down_commands }
20
+ end
21
+
22
+ private
23
+
24
+ def find_added_tables
25
+ (@new_schema.keys - @old_schema.keys).each do |table_name|
26
+ @up_commands << { command: :create_table, name: table_name, columns: @new_schema[table_name][:columns] }
27
+ @down_commands.unshift({ command: :drop_table, name: table_name })
28
+ # Also add any indexes for the new table
29
+ (@new_schema[table_name][:indexes] || {}).each do |column_name, _|
30
+ @up_commands << { command: :add_index, table: table_name, column: column_name }
31
+ end
32
+ end
33
+ end
34
+
35
+ def find_removed_tables
36
+ (@old_schema.keys - @new_schema.keys).each do |table_name|
37
+ @up_commands << { command: :drop_table, name: table_name }
38
+ @down_commands.unshift({ command: :create_table, name: table_name, columns: @old_schema[table_name][:columns] })
39
+ # Restore indexes for the dropped table on rollback
40
+ (@old_schema[table_name][:indexes] || {}).each do |column_name, _|
41
+ @down_commands.unshift({ command: :add_index, table: table_name, column: column_name })
42
+ end
43
+ end
44
+ end
45
+
46
+ def find_changed_tables
47
+ (@old_schema.keys & @new_schema.keys).each do |table_name|
48
+ diff_columns(table_name)
49
+ diff_indexes(table_name)
50
+ end
51
+ end
52
+
53
+ def diff_columns(table_name)
54
+ old_cols = @old_schema[table_name][:columns]
55
+ new_cols = @new_schema[table_name][:columns]
56
+
57
+ (new_cols.keys - old_cols.keys).each do |col_name|
58
+ @up_commands << { command: :add_column, table: table_name, name: col_name, type: new_cols[col_name][:type] }
59
+ @down_commands.unshift({ command: :remove_column, table: table_name, name: col_name })
60
+ end
61
+
62
+ (old_cols.keys - new_cols.keys).each do |col_name|
63
+ @up_commands << { command: :remove_column, table: table_name, name: col_name }
64
+ @down_commands.unshift({ command: :add_column, table: table_name, name: col_name, type: old_cols[col_name][:type] })
65
+ end
66
+ end
67
+
68
+ def diff_indexes(table_name)
69
+ old_indexes = @old_schema[table_name][:indexes] || {}
70
+ new_indexes = @new_schema[table_name][:indexes] || {}
71
+
72
+ (new_indexes.keys - old_indexes.keys).each do |col_name|
73
+ @up_commands << { command: :add_index, table: table_name, column: col_name }
74
+ @down_commands.unshift({ command: :remove_index, table: table_name, column: col_name })
75
+ end
76
+
77
+ (old_indexes.keys - new_indexes.keys).each do |col_name|
78
+ @up_commands << { command: :remove_index, table: table_name, column: col_name }
79
+ @down_commands.unshift({ command: :add_index, table: table_name, column: col_name })
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dami
4
+ module Schema
5
+ class Dumper
6
+ def initialize(adapter)
7
+ @adapter = adapter
8
+ end
9
+
10
+ # The main public method. It inspects the database and returns
11
+ # a string containing the full schema definition.
12
+ def dump
13
+ output = []
14
+ output << "Dami.define_schema do\n"
15
+
16
+ tables = @adapter.tables
17
+ tables.each do |table_name|
18
+ next if table_name == 'schema_migrations' # Skip the internal migrations table
19
+
20
+ output << dump_table(table_name)
21
+ end
22
+
23
+ tables.each do |table_name|
24
+ output << dump_indexes(table_name)
25
+ end
26
+
27
+ output << "end\n"
28
+ output.join("\n")
29
+ end
30
+
31
+ private
32
+
33
+ def dump_table(table_name)
34
+ columns = @adapter.columns(table_name)
35
+ return "" if columns.empty?
36
+
37
+ parts = []
38
+ parts << " create_table \"#{table_name}\" do |t|"
39
+ columns.each do |column|
40
+ # We skip the 'id' column as it's created by default.
41
+ next if column[:name] == "id"
42
+ parts << " t.field \"#{column[:name]}\", :#{column[:type]}"
43
+ end
44
+ parts << " end"
45
+ parts.join("\n")
46
+ end
47
+
48
+ def dump_indexes(table_name)
49
+ indexes = @adapter.indexes(table_name)
50
+ return "" if indexes.empty?
51
+
52
+ parts = []
53
+ indexes.each do |index|
54
+ parts << " add_index \"#{index[:table]}\", \"#{index[:column]}\""
55
+ end
56
+ parts.join("\n")
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,69 @@
1
+ # File: ./lib/dami/schema/generator.rb
2
+
3
+ # frozen_string_literal: true
4
+
5
+ module Dami
6
+ module Schema
7
+ class Generator
8
+ def generate(diff_result, name)
9
+ return if diff_result[:up].empty?
10
+
11
+ timestamp = Time.now.utc.strftime('%Y%m%d%H%M%S')
12
+
13
+ # THE FIX: This converts "AddEmailToUsers" into "add_email_to_users"
14
+ snake_case_name = name.gsub(/::/, '/')
15
+ .gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2')
16
+ .gsub(/([a-z\d])([A-Z])/,'\1_\2')
17
+ .tr("-", "_")
18
+ .downcase
19
+
20
+ filename = "#{timestamp}_#{snake_case_name}.rb"
21
+
22
+ migrations_dir = Dami.configuration.migrations_path
23
+ path = File.join(migrations_dir, filename)
24
+
25
+ FileUtils.mkdir_p(File.dirname(path))
26
+ File.write(path, generate_content(diff_result))
27
+
28
+ path
29
+ end
30
+
31
+ private
32
+
33
+ def generate_content(diff_result)
34
+ up_content = diff_result[:up].map { |cmd| command_to_string(cmd) }.join("\n ")
35
+ down_content = diff_result[:down].map { |cmd| command_to_string(cmd) }.join("\n ")
36
+
37
+ <<~RUBY
38
+ # This file is auto-generated. You can edit it before running migrations.
39
+
40
+ def up
41
+ #{up_content}
42
+ end
43
+
44
+ def down
45
+ #{down_content}
46
+ end
47
+ RUBY
48
+ end
49
+
50
+ def command_to_string(cmd)
51
+ case cmd[:command]
52
+ when :create_table
53
+ cols = cmd[:columns].map { |name, opts| "t.field :#{name}, :#{opts[:type]}" }.join("\n ")
54
+ "create_table(:#{cmd[:name]}) do |t|\n #{cols}\n end"
55
+ when :drop_table
56
+ "drop_table(:#{cmd[:name]})"
57
+ when :add_column
58
+ "add_column(:#{cmd[:table]}, :#{cmd[:name]}, :#{cmd[:type]})"
59
+ when :remove_column
60
+ "remove_column(:#{cmd[:table]}, :#{cmd[:name]})"
61
+ when :add_index
62
+ "add_index(:#{cmd[:table]}, :#{cmd[:column]})"
63
+ when :remove_index
64
+ "remove_index(:#{cmd[:table]}, :#{cmd[:column]})"
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dami
4
+ module Schema
5
+ # Introspects live Dami.model definitions to build a canonical hash representation.
6
+ class Introspector
7
+ def introspect
8
+ schema = {}
9
+ Dami.instance_variable_get(:@models).each do |model_name, config|
10
+ table_name = model_name.to_s
11
+ schema[table_name] = { columns: {}, indexes: {} }
12
+
13
+ # 1. Parse all defined fields
14
+ (config[:fields] || {}).each do |field_name, field_config|
15
+ schema[table_name][:columns][field_name.to_s] = { type: field_config[:type] }
16
+ end
17
+
18
+ # 2. Introspect relationships to infer indexes
19
+ (config[:relationships] || {}).each do |type, relations|
20
+ next unless type == :belongs_to
21
+ relations.each_key do |rel_name|
22
+ # A `belongs_to :user` implies a `user_id` column and an index on it.
23
+ # We assume the column is already defined in `fields`.
24
+ # Here, we just add the desired index.
25
+ fk_name = "#{rel_name}_id"
26
+ schema[table_name][:indexes][fk_name] = {}
27
+ end
28
+ end
29
+ end
30
+ schema
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,58 @@
1
+ # File: ./lib/dami/schema/loader.rb
2
+
3
+ # frozen_string_literal: true
4
+
5
+ module Dami
6
+ module Schema
7
+ # Parses a db/schema.rb file into a canonical hash representation.
8
+ class Loader
9
+ attr_reader :schema
10
+
11
+ def initialize
12
+ @schema = {}
13
+ end
14
+
15
+ def load_from_path(path)
16
+ return unless File.exist?(path)
17
+
18
+ # THE FIX: Capture `self` (the loader instance) into a variable.
19
+ loader_instance = self
20
+
21
+ # Now, the monkey-patched method uses the captured variable, ensuring
22
+ # the block is always evaluated in the context of the correct loader instance.
23
+ Dami.define_singleton_method(:define_schema) { |&block| loader_instance.instance_eval(&block) }
24
+
25
+ load(path)
26
+ ensure
27
+ # Always restore the original method to avoid side effects.
28
+ Dami.define_singleton_method(:define_schema) { |_| }
29
+ end
30
+
31
+ private
32
+
33
+ # --- DSL Methods available inside db/schema.rb ---
34
+
35
+ def create_table(name, &block)
36
+ @schema[name] ||= { columns: {}, indexes: {} }
37
+ proxy = TableProxy.new(@schema[name][:columns])
38
+ yield(proxy)
39
+ end
40
+
41
+ def add_index(table_name, column_name, **options)
42
+ @schema[table_name] ||= { columns: {}, indexes: {} }
43
+ @schema[table_name][:indexes][column_name] = options
44
+ end
45
+
46
+ # A simple proxy to handle the `t.field` calls inside `create_table`
47
+ class TableProxy
48
+ def initialize(columns_hash)
49
+ @columns = columns_hash
50
+ end
51
+
52
+ def field(name, type, **options)
53
+ @columns[name] = { type: type }.merge(options)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end