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,245 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module Dami
|
|
3
|
+
module Inflector
|
|
4
|
+
# Caches for performance
|
|
5
|
+
@plural_cache = {}
|
|
6
|
+
@singular_cache = {}
|
|
7
|
+
|
|
8
|
+
UNCOUNTABLE = %w(
|
|
9
|
+
equipment information rice money species series fish sheep jeans police deer news homework
|
|
10
|
+
air water sugar tea coffee milk butter cheese bread jam chocolate wine beer music art
|
|
11
|
+
software love happiness furniture luggage advice work traffic weather accommodation
|
|
12
|
+
education knowledge research progress health safety violence peace chaos math physics
|
|
13
|
+
chemistry biology moose swine bison aircraft spacecraft salmon trout offspring
|
|
14
|
+
).freeze
|
|
15
|
+
|
|
16
|
+
# These singular words end in 's' but are NOT plural!
|
|
17
|
+
SINGULAR_ENDING_IN_S = %w(
|
|
18
|
+
bus gas lens glass class mass grass brass canvas atlas bias cosmos dais iris oasis pancreas
|
|
19
|
+
alias status thesis basis crisis analysis diagnosis emphasis hypothesis neurosis osmosis
|
|
20
|
+
paralysis parenthesis synopsis
|
|
21
|
+
).freeze
|
|
22
|
+
|
|
23
|
+
IRREGULAR = {
|
|
24
|
+
'person' => 'people', 'man' => 'men', 'woman' => 'women', 'child' => 'children',
|
|
25
|
+
'tooth' => 'teeth', 'foot' => 'feet', 'mouse' => 'mice', 'goose' => 'geese',
|
|
26
|
+
'ox' => 'oxen', 'quiz' => 'quizzes', 'matrix' => 'matrices', 'vertex' => 'vertices',
|
|
27
|
+
'index' => 'indices', 'sex' => 'sexes', 'move' => 'moves', 'zombie' => 'zombies',
|
|
28
|
+
'cactus' => 'cacti', 'focus' => 'foci', 'fungus' => 'fungi', 'nucleus' => 'nuclei',
|
|
29
|
+
'radius' => 'radii', 'stimulus' => 'stimuli', 'axis' => 'axes', 'analysis' => 'analyses',
|
|
30
|
+
'basis' => 'bases', 'crisis' => 'crises', 'diagnosis' => 'diagnoses',
|
|
31
|
+
'ellipsis' => 'ellipses', 'hypothesis' => 'hypotheses', 'oasis' => 'oases',
|
|
32
|
+
'paralysis' => 'paralyses', 'parenthesis' => 'parentheses', 'synopsis' => 'synopses',
|
|
33
|
+
'thesis' => 'theses', 'phenomenon' => 'phenomena', 'criterion' => 'criteria',
|
|
34
|
+
'datum' => 'data',
|
|
35
|
+
# Add the common -s words as irregular to handle them explicitly
|
|
36
|
+
'bus' => 'buses', 'gas' => 'gases', 'lens' => 'lenses', 'glass' => 'glasses',
|
|
37
|
+
'class' => 'classes', 'mass' => 'masses', 'grass' => 'grasses', 'brass' => 'brasses',
|
|
38
|
+
'canvas' => 'canvases', 'atlas' => 'atlases', 'bias' => 'biases', 'cosmos' => 'cosmoses',
|
|
39
|
+
'dais' => 'daises', 'iris' => 'irises', 'pancreas' => 'pancreases',
|
|
40
|
+
'alias' => 'aliases', 'status' => 'statuses'
|
|
41
|
+
}.freeze
|
|
42
|
+
|
|
43
|
+
IRREGULAR_REVERSE = IRREGULAR.invert.freeze
|
|
44
|
+
|
|
45
|
+
# Rules ordered from most specific to least specific
|
|
46
|
+
PLURAL_RULES = [
|
|
47
|
+
# Irregular patterns
|
|
48
|
+
[/^(ox)$/i, '\1en'],
|
|
49
|
+
[/^(m|l)ouse$/i, '\1ice'],
|
|
50
|
+
[/(quiz)$/i, '\1zes'],
|
|
51
|
+
|
|
52
|
+
# Words ending in s, ss, x, z, ch, sh
|
|
53
|
+
[/(ss)$/i, '\1es'],
|
|
54
|
+
[/(x|z|ch|sh)$/i, '\1es'],
|
|
55
|
+
|
|
56
|
+
# Words ending in o
|
|
57
|
+
[/(tomat|potat|ech|her|vet)o$/i, '\1oes'],
|
|
58
|
+
[/o$/i, 'os'],
|
|
59
|
+
|
|
60
|
+
# Words ending in f or fe
|
|
61
|
+
[/(lea|loa|thie|shel|wol|hal|cal|kni)f$/i, '\1ves'],
|
|
62
|
+
[/(wi|li)fe$/i, '\1ves'],
|
|
63
|
+
|
|
64
|
+
# Words ending in y
|
|
65
|
+
[/([^aeiou])y$/i, '\1ies'],
|
|
66
|
+
[/([aeiou]y)$/i, '\1s'],
|
|
67
|
+
|
|
68
|
+
# Words ending in is
|
|
69
|
+
[/(ax|test)is$/i, '\1es'],
|
|
70
|
+
[/sis$/i, 'ses'],
|
|
71
|
+
|
|
72
|
+
# Words ending in us
|
|
73
|
+
[/(octop|vir|radi|nucle|fung|cact|stimul)us$/i, '\1i'],
|
|
74
|
+
[/us$/i, 'uses'],
|
|
75
|
+
|
|
76
|
+
# Words ending in um
|
|
77
|
+
[/([ti])um$/i, '\1a'],
|
|
78
|
+
|
|
79
|
+
# Words ending in ix or ex
|
|
80
|
+
[/(matr|vert|ind)(ix|ex)$/i, '\1ices'],
|
|
81
|
+
|
|
82
|
+
# Single 's' at the end (but not ss)
|
|
83
|
+
[/([^s])s$/i, '\1ses'],
|
|
84
|
+
|
|
85
|
+
# Default: just add s
|
|
86
|
+
[/$/, 's']
|
|
87
|
+
].freeze
|
|
88
|
+
|
|
89
|
+
SINGULAR_RULES = [
|
|
90
|
+
# Irregular patterns first
|
|
91
|
+
[/^oxen$/i, 'ox'],
|
|
92
|
+
[/^(m|l)ice$/i, '\1ouse'],
|
|
93
|
+
[/(quiz)zes$/i, '\1'],
|
|
94
|
+
|
|
95
|
+
# Words ending in sses
|
|
96
|
+
[/(ss)es$/i, '\1'],
|
|
97
|
+
|
|
98
|
+
# Words ending in xes, zes, ches, shes
|
|
99
|
+
[/(x|z|ch|sh)es$/i, '\1'],
|
|
100
|
+
|
|
101
|
+
# Words ending in oes
|
|
102
|
+
[/(tomat|potat|ech|her|vet)oes$/i, '\1o'],
|
|
103
|
+
[/oes$/i, 'o'],
|
|
104
|
+
|
|
105
|
+
# Words ending in ves
|
|
106
|
+
[/(lea|loa|thie|shel|wol|hal|cal|kni)ves$/i, '\1f'],
|
|
107
|
+
[/(wi|li)ves$/i, '\1fe'],
|
|
108
|
+
|
|
109
|
+
# Words ending in ies
|
|
110
|
+
[/([^aeiou])ies$/i, '\1y'],
|
|
111
|
+
|
|
112
|
+
# Words ending in uses (from -us)
|
|
113
|
+
[/([^aeiouy]|qu)uses$/i, '\1us'],
|
|
114
|
+
|
|
115
|
+
# Words ending in i (Latin plurals)
|
|
116
|
+
[/(octop|vir|radi|nucle|fung|cact|stimul)i$/i, '\1us'],
|
|
117
|
+
|
|
118
|
+
# Words ending in a (Greek/Latin plurals)
|
|
119
|
+
[/([ti])a$/i, '\1um'],
|
|
120
|
+
|
|
121
|
+
# Words ending in ices
|
|
122
|
+
[/(matr|vert|ind)ices$/i, '\1ex'],
|
|
123
|
+
|
|
124
|
+
# Words ending in es
|
|
125
|
+
[/(ax|test)es$/i, '\1is'],
|
|
126
|
+
[/ses$/i, 'sis'],
|
|
127
|
+
|
|
128
|
+
# Words ending in s (default - MUST be last)
|
|
129
|
+
[/s$/i, '']
|
|
130
|
+
].freeze
|
|
131
|
+
|
|
132
|
+
def self.apply_case(original, new_word)
|
|
133
|
+
return new_word if original == new_word
|
|
134
|
+
|
|
135
|
+
if original == original.upcase
|
|
136
|
+
new_word.upcase
|
|
137
|
+
elsif original == original.capitalize
|
|
138
|
+
new_word.capitalize
|
|
139
|
+
elsif original[0] == original[0].upcase
|
|
140
|
+
new_word[0].upcase + new_word[1..-1]
|
|
141
|
+
else
|
|
142
|
+
new_word
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def self.pluralize(word)
|
|
147
|
+
str = word.to_s.strip
|
|
148
|
+
return str if str.empty?
|
|
149
|
+
return @plural_cache[str] if @plural_cache.key?(str)
|
|
150
|
+
|
|
151
|
+
lower = str.downcase
|
|
152
|
+
|
|
153
|
+
# Uncountable
|
|
154
|
+
if UNCOUNTABLE.include?(lower)
|
|
155
|
+
return @plural_cache[str] = str
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Irregular singular -> plural (includes bus, gas, lens, etc.)
|
|
159
|
+
if irregular = IRREGULAR[lower]
|
|
160
|
+
return @plural_cache[str] = apply_case(str, irregular)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Already irregular plural
|
|
164
|
+
if IRREGULAR_REVERSE.key?(lower)
|
|
165
|
+
return @plural_cache[str] = str
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Already plural check - a simple heuristic
|
|
169
|
+
# If it ends in 's' but isn't in our singular list, it might be plural
|
|
170
|
+
if lower.end_with?('s') && !lower.end_with?('ss') && !SINGULAR_ENDING_IN_S.include?(lower)
|
|
171
|
+
# Try to singularize and re-pluralize to check
|
|
172
|
+
test_singular = apply_singular_rules(str)
|
|
173
|
+
if test_singular != str
|
|
174
|
+
test_plural = apply_plural_rules(test_singular)
|
|
175
|
+
if test_plural.downcase == lower
|
|
176
|
+
return @plural_cache[str] = str # Already plural
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Apply plural rules
|
|
182
|
+
result = apply_plural_rules(str)
|
|
183
|
+
@plural_cache[str] = result
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def self.singularize(word)
|
|
187
|
+
str = word.to_s.strip
|
|
188
|
+
return str if str.empty?
|
|
189
|
+
return @singular_cache[str] if @singular_cache.key?(str)
|
|
190
|
+
|
|
191
|
+
lower = str.downcase
|
|
192
|
+
|
|
193
|
+
# Uncountable
|
|
194
|
+
if UNCOUNTABLE.include?(lower)
|
|
195
|
+
return @singular_cache[str] = str
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Irregular plural -> singular (includes buses, gases, lenses, etc.)
|
|
199
|
+
if irregular = IRREGULAR_REVERSE[lower]
|
|
200
|
+
return @singular_cache[str] = apply_case(str, irregular)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Already irregular singular
|
|
204
|
+
if IRREGULAR.key?(lower)
|
|
205
|
+
return @singular_cache[str] = str
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Check if it's a known singular word ending in 's'
|
|
209
|
+
if SINGULAR_ENDING_IN_S.include?(lower)
|
|
210
|
+
return @singular_cache[str] = str
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Already singular check - if it doesn't end in 's', it's probably singular
|
|
214
|
+
if !lower.end_with?('s')
|
|
215
|
+
return @singular_cache[str] = str
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Apply singular rules
|
|
219
|
+
result = apply_singular_rules(str)
|
|
220
|
+
@singular_cache[str] = result
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
private
|
|
224
|
+
|
|
225
|
+
def self.apply_plural_rules(str)
|
|
226
|
+
PLURAL_RULES.each do |regex, replacement|
|
|
227
|
+
if str =~ regex
|
|
228
|
+
result = str.sub(regex, replacement)
|
|
229
|
+
return apply_case(str, result)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
str
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def self.apply_singular_rules(str)
|
|
236
|
+
SINGULAR_RULES.each do |regex, replacement|
|
|
237
|
+
if str =~ regex
|
|
238
|
+
result = str.sub(regex, replacement)
|
|
239
|
+
return apply_case(str, result)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
str
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# File: lib/dami/localization.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
#raise "LOCALIZATION FILE RELOADED AT #{Time.now.to_f}"
|
|
4
|
+
|
|
5
|
+
module Dami
|
|
6
|
+
class << self
|
|
7
|
+
attr_reader :translations
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
@translations = {}
|
|
11
|
+
@locale = nil
|
|
12
|
+
|
|
13
|
+
# Process-wide default locale (set once at boot).
|
|
14
|
+
def self.locale
|
|
15
|
+
instance_variable_get(:@locale)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.locale=(new_locale)
|
|
19
|
+
instance_variable_set(:@locale, new_locale&.to_sym)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Per-thread override. Web servers run requests on threads, so a request's
|
|
23
|
+
# locale must never leak into another request: set it here (or use
|
|
24
|
+
# with_locale), never via Dami.locale= from inside a request.
|
|
25
|
+
def self.thread_locale
|
|
26
|
+
Thread.current[:dami_locale]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.thread_locale=(new_locale)
|
|
30
|
+
Thread.current[:dami_locale] = new_locale&.to_sym
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.current_locale
|
|
34
|
+
locale = Thread.current[:dami_locale] || instance_variable_get(:@locale)
|
|
35
|
+
|
|
36
|
+
# Only convert to symbol if locale exists and is not empty
|
|
37
|
+
return locale.to_sym if locale && !locale.to_s.empty?
|
|
38
|
+
|
|
39
|
+
if defined?(I18n) && I18n.respond_to?(:locale)
|
|
40
|
+
i18n_locale = I18n.locale
|
|
41
|
+
return i18n_locale.to_sym if i18n_locale
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
:en
|
|
45
|
+
end
|
|
46
|
+
def self.localize(scope, &block)
|
|
47
|
+
scope_storage = (@translations[scope] ||= {})
|
|
48
|
+
LocalizationProxy.new(scope_storage).instance_eval(&block)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.translate(key, *args)
|
|
52
|
+
keys = key.to_s.split('.').map(&:to_sym)
|
|
53
|
+
locale = current_locale
|
|
54
|
+
|
|
55
|
+
message = @translations.dig(*keys.insert(1, locale))
|
|
56
|
+
if message.is_a?(Proc)
|
|
57
|
+
message.call(*args)
|
|
58
|
+
else
|
|
59
|
+
message || "translation missing: #{locale}.#{key}"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Thread-local: only the calling thread sees the temporary locale.
|
|
64
|
+
def self.with_locale(temp_locale, &block)
|
|
65
|
+
original_locale = Thread.current[:dami_locale]
|
|
66
|
+
Thread.current[:dami_locale] = temp_locale&.to_sym
|
|
67
|
+
yield
|
|
68
|
+
ensure
|
|
69
|
+
Thread.current[:dami_locale] = original_locale
|
|
70
|
+
end
|
|
71
|
+
# --- Helper Methods ---
|
|
72
|
+
|
|
73
|
+
def self.localize_model(model_name, locale: nil)
|
|
74
|
+
locale ||= current_locale
|
|
75
|
+
@translations.dig(:models, locale, model_name, :model_name)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
class << self
|
|
79
|
+
alias_method :lm, :localize_model
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.localize_field(model_name, field_name, locale: nil)
|
|
83
|
+
locale ||= current_locale
|
|
84
|
+
@translations.dig(:models, locale, model_name, field_name)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
class << self
|
|
88
|
+
alias_method :lf, :localize_field
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# --- DSL Proxy Classes ---
|
|
92
|
+
|
|
93
|
+
class LocalizationProxy
|
|
94
|
+
def initialize(storage); @storage = storage; end
|
|
95
|
+
def en(&block); locale(:en, &block); end
|
|
96
|
+
def es(&block); locale(:es, &block); end
|
|
97
|
+
def fr(&block); locale(:fr, &block); end
|
|
98
|
+
def de(&block); locale(:de, &block); end
|
|
99
|
+
def ja(&block); locale(:ja, &block); end
|
|
100
|
+
def zh(&block); locale(:zh, &block); end
|
|
101
|
+
def pt(&block); locale(:pt, &block); end
|
|
102
|
+
def it(&block); locale(:it, &block); end
|
|
103
|
+
def ru(&block); locale(:ru, &block); end
|
|
104
|
+
def ar(&block); locale(:ar, &block); end
|
|
105
|
+
|
|
106
|
+
def locale(lang_code, &block)
|
|
107
|
+
locale_storage = (@storage[lang_code.to_sym] ||= {})
|
|
108
|
+
LocaleProxy.new(locale_storage).instance_eval(&block)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
class LocaleProxy
|
|
113
|
+
def initialize(storage); @storage = storage; end
|
|
114
|
+
def set(key, value); @storage[key.to_sym] = value; end
|
|
115
|
+
|
|
116
|
+
def attributes_for(model_name, &block)
|
|
117
|
+
model_storage = (@storage[model_name.to_sym] ||= {})
|
|
118
|
+
AttributesProxy.new(model_storage).instance_eval(&block)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def enum_for(model_name, attribute, &block)
|
|
122
|
+
enum_storage = ((@storage[model_name.to_sym] ||= {})[attribute.to_sym] = {})
|
|
123
|
+
AttributesProxy.new(enum_storage).instance_eval(&block)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
class AttributesProxy
|
|
128
|
+
def initialize(storage); @storage = storage; end
|
|
129
|
+
def set(key, value); @storage[key.to_sym] = value; end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# lib/dami/migration.rb - Fix create_table to support block parameter syntax
|
|
2
|
+
|
|
3
|
+
module Dami
|
|
4
|
+
class Migration
|
|
5
|
+
def initialize(adapter)
|
|
6
|
+
@adapter = adapter
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def up
|
|
10
|
+
# This should be overridden by the migration file
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def down
|
|
14
|
+
# This should be overridden by the migration file
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Schema methods
|
|
18
|
+
def create_table(name, &block)
|
|
19
|
+
table_definition = TableDefinition.new
|
|
20
|
+
|
|
21
|
+
# Support both styles: do |t| ... end and do ... end
|
|
22
|
+
if block.arity == 0
|
|
23
|
+
table_definition.instance_eval(&block)
|
|
24
|
+
else
|
|
25
|
+
yield table_definition
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
@adapter.create_table(name, table_definition.columns)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def drop_table(name)
|
|
32
|
+
@adapter.execute("DROP TABLE IF EXISTS #{name}")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def add_column(table, column, type, **options)
|
|
36
|
+
@adapter.add_column(table, column, type, **options)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def remove_column(table, column)
|
|
40
|
+
# SQLite has limited DROP COLUMN support
|
|
41
|
+
# This will work in SQLite 3.35.0+ but fail gracefully in older versions
|
|
42
|
+
begin
|
|
43
|
+
@adapter.execute("ALTER TABLE #{table} DROP COLUMN #{column}")
|
|
44
|
+
rescue SQLite3::Exception => e
|
|
45
|
+
# Log the limitation but don't crash
|
|
46
|
+
puts "Note: DROP COLUMN not supported in this SQLite version: #{e.message}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
def add_index(table_name, column_name, **options)
|
|
50
|
+
@adapter.add_index(table_name, column_name, options)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def remove_index(table_name, column_name, **options)
|
|
54
|
+
@adapter.remove_index(table_name, column_name, options)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
class TableDefinition
|
|
61
|
+
attr_reader :columns
|
|
62
|
+
|
|
63
|
+
def initialize
|
|
64
|
+
@columns = []
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def field(name, type, **options)
|
|
68
|
+
@columns << { name: name, type: type, **options }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# created_at / updated_at columns. Dami fills them automatically on
|
|
72
|
+
# create/update when the model declares the same two fields.
|
|
73
|
+
def timestamps
|
|
74
|
+
field(:created_at, :datetime)
|
|
75
|
+
field(:updated_at, :datetime)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Shorthand for a foreign-key column: t.references :user -> user_id INTEGER REFERENCES users(id)
|
|
79
|
+
def references(name, **options)
|
|
80
|
+
table = options.delete(:table) || Dami::Inflector.pluralize(name.to_s)
|
|
81
|
+
field(:"#{name}_id", :integer, references: table, **options)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# lib/dami/migrator.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
module Dami
|
|
4
|
+
class Migrator
|
|
5
|
+
def initialize(adapter, path: Dami.configuration.migrations_path)
|
|
6
|
+
@adapter = adapter
|
|
7
|
+
@path = path
|
|
8
|
+
ensure_schema_migrations_table
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def migrate
|
|
12
|
+
pending_migrations.each { |m| run_migration(m) }
|
|
13
|
+
dump_schema # ADD THIS LINE
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def rollback(steps = 1)
|
|
17
|
+
completed_migrations.reverse.first(steps).each { |m| reverse_migration(m) }
|
|
18
|
+
dump_schema
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def ensure_schema_migrations_table
|
|
24
|
+
return if @adapter.table_exists?(:schema_migrations)
|
|
25
|
+
|
|
26
|
+
@adapter.execute <<-SQL
|
|
27
|
+
CREATE TABLE schema_migrations (
|
|
28
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
29
|
+
version VARCHAR(255) NOT NULL UNIQUE,
|
|
30
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
31
|
+
)
|
|
32
|
+
SQL
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def all_migrations
|
|
36
|
+
return [] unless Dir.exist?(@path)
|
|
37
|
+
Dir.glob("#{@path}/*.rb").sort.map do |file|
|
|
38
|
+
version = File.basename(file).match(/^(\d+)_/)[1]
|
|
39
|
+
{ version: version, file: file }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def completed_versions
|
|
44
|
+
query = {
|
|
45
|
+
model_name: :schema_migrations,
|
|
46
|
+
conditions: [],
|
|
47
|
+
order_by: nil,
|
|
48
|
+
limit: nil,
|
|
49
|
+
offset: nil
|
|
50
|
+
}
|
|
51
|
+
@adapter.query_records(query).map { |r| r[:version] }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def pending_migrations
|
|
55
|
+
all_migrations.reject { |m| completed_versions.include?(m[:version]) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def completed_migrations
|
|
59
|
+
all_migrations.select { |m| completed_versions.include?(m[:version]) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def load_migration(file)
|
|
63
|
+
migration = Migration.new(@adapter)
|
|
64
|
+
# This part is flexible; it will define up/down methods if they exist.
|
|
65
|
+
migration.instance_eval(File.read(file), file)
|
|
66
|
+
migration
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def run_migration(migration_info)
|
|
70
|
+
migration = load_migration(migration_info[:file])
|
|
71
|
+
@adapter.transaction do
|
|
72
|
+
# THE FIX: Call the new method name.
|
|
73
|
+
migration.up
|
|
74
|
+
@adapter.insert_record(:schema_migrations, { version: migration_info[:version] })
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def reverse_migration(migration_info)
|
|
79
|
+
migration = load_migration(migration_info[:file])
|
|
80
|
+
@adapter.transaction do
|
|
81
|
+
# THE FIX: Call the new method name.
|
|
82
|
+
migration.down
|
|
83
|
+
|
|
84
|
+
@adapter.execute(
|
|
85
|
+
"DELETE FROM schema_migrations WHERE version = ?",
|
|
86
|
+
[migration_info[:version]]
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
rescue => e
|
|
90
|
+
puts "Error in reverse_migration for #{migration_info[:version]}: #{e.message}"
|
|
91
|
+
puts e.backtrace.first(5)
|
|
92
|
+
raise
|
|
93
|
+
end
|
|
94
|
+
def dump_schema
|
|
95
|
+
require_relative 'schema/dumper'
|
|
96
|
+
dumper = Dami::Schema::Dumper.new(@adapter)
|
|
97
|
+
schema_content = dumper.dump
|
|
98
|
+
|
|
99
|
+
# Use the configured path.
|
|
100
|
+
schema_path = Dami.configuration.schema_path
|
|
101
|
+
FileUtils.mkdir_p(File.dirname(schema_path))
|
|
102
|
+
File.write(schema_path, schema_content)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|