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,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
|
data/lib/dami/result.rb
ADDED
|
@@ -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
|