Class: Synthra::MigrationGenerator

Inherits:
Object
  • Object
show all
Defined in:
lib/synthra/migration_generator.rb

Overview

Schema Migration Generator

Generate database migrations from schema changes. Works with the ContractsRegistry to track versions and produce Rails-compatible migrations.

Examples:

Generate migration from diff

MigrationGenerator.generate(
  from: "User@v1.0.0",
  to: "User@v2.0.0",
  output: "db/migrate/"
)

CLI

$ synthra schema migrate User --from v1.0.0 --to v2.0.0

Constant Summary collapse

TYPE_MAP =

Type mapping from Synthra to ActiveRecord

{
  "text" => "string",
  "name" => "string",
  "email" => "string",
  "url" => "string",
  "paragraph" => "text",
  "uuid" => "uuid",
  "number" => "integer",
  "integer" => "integer",
  "float" => "float",
  "money" => "decimal, precision: 10, scale: 2",
  "decimal" => "decimal, precision: 10, scale: 2",
  "boolean" => "boolean",
  "timestamp" => "datetime",
  "datetime" => "datetime",
  "date" => "date",
  "time" => "time",
  "object" => "jsonb",
  "json" => "jsonb",
  "binary" => "binary",
  "array" => "text, array: true"
}.freeze

Class Method Summary collapse

Class Method Details

.build_create_table_migration(schema) ⇒ Object (private)



240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
# File 'lib/synthra/migration_generator.rb', line 240

def build_create_table_migration(schema)
  table_name = pluralize(underscore(schema.name))
  class_name = "Create#{pluralize(schema.name)}"

  columns = schema.fields.map do |field|
    ar_type = TYPE_MAP[field.type_name] || "string"
    null = field.optional? || field.nullable? ? "null: true" : "null: false"

    if field.name == "id"
      nil # Skip id, handled by primary key
    else
      "      t.#{ar_type.split(',').first} :#{field.name}, #{null}"
    end
  end.compact

  # Check for timestamps
  has_created_at = schema.fields.any? { |f| f.name == "created_at" }
  has_updated_at = schema.fields.any? { |f| f.name == "updated_at" }

  timestamps = if has_created_at && has_updated_at
                 columns.reject! { |c| c.include?(":created_at") || c.include?(":updated_at") }
                 "\n      t.timestamps"
               else
                 ""
               end

  <<~MIGRATION
    # frozen_string_literal: true

    # Generated by Synthra
    # Schema: #{schema.name}

    class #{class_name} < ActiveRecord::Migration[7.1]
      def change
        create_table :#{table_name}, id: :uuid do |t|
    #{columns.join("\n")}#{timestamps}
        end
      end
    end
  MIGRATION
end

.build_migration(old_schema, new_schema, diff, options) ⇒ Object (private)



178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
# File 'lib/synthra/migration_generator.rb', line 178

def build_migration(old_schema, new_schema, diff, options)
  table_name = pluralize(underscore(new_schema.name))
  class_name = "Migrate#{old_schema.name}To#{new_schema.name.sub(/.*/, '')}"

  # If versions exist in names
  if old_schema.name.include?("@") || new_schema.name.include?("@")
    old_version = old_schema.name.split("@").last rescue "v1"
    new_version = new_schema.name.split("@").last rescue "v2"
    class_name = "Migrate#{old_schema.name.split('@').first}#{old_version.gsub('.', '')}To#{new_version.gsub('.', '')}"
  else
    class_name = "Update#{new_schema.name}Schema"
  end

  changes = []

  # Added columns
  diff[:added].each do |name|
    field = diff[:new_fields][name]
    ar_type = TYPE_MAP[field.type_name] || "string"
    null = field.optional? || field.nullable? ? "null: true" : "null: false"
    changes << "    add_column :#{table_name}, :#{name}, :#{ar_type}, #{null}"
  end

  # Removed columns
  diff[:removed].each do |name|
    field = diff[:old_fields][name]
    ar_type = TYPE_MAP[field.type_name] || "string"
    changes << "    remove_column :#{table_name}, :#{name}, :#{ar_type}"
  end

  # Type changes
  diff[:type_changed].each do |name|
    old_field = diff[:old_fields][name]
    new_field = diff[:new_fields][name]
    new_type = TYPE_MAP[new_field.type_name] || "string"
    changes << "    change_column :#{table_name}, :#{name}, :#{new_type}  # was: #{old_field.type_name}"
  end

  # Nullable changes
  diff[:nullable_changed].each do |name|
    next if diff[:type_changed].include?(name)

    new_field = diff[:new_fields][name]
    null = new_field.optional? || new_field.nullable?
    changes << "    change_column_null :#{table_name}, :#{name}, #{null}"
  end

  <<~MIGRATION
    # frozen_string_literal: true

    # Generated by Synthra
    # From: #{old_schema.name}
    # To: #{new_schema.name}

    class #{class_name} < ActiveRecord::Migration[7.1]
      def change
    #{changes.join("\n")}
      end
    end
  MIGRATION
end

.calculate_diff(old_schema, new_schema) ⇒ Object (private)



159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
# File 'lib/synthra/migration_generator.rb', line 159

def calculate_diff(old_schema, new_schema)
  old_fields = old_schema.fields.each_with_object({}) { |f, h| h[f.name] = f }
  new_fields = new_schema.fields.each_with_object({}) { |f, h| h[f.name] = f }

  {
    added: new_fields.keys - old_fields.keys,
    removed: old_fields.keys - new_fields.keys,
    type_changed: (old_fields.keys & new_fields.keys).select do |name|
      old_fields[name].type_name != new_fields[name].type_name
    end,
    nullable_changed: (old_fields.keys & new_fields.keys).select do |name|
      old_fields[name].nullable? != new_fields[name].nullable? ||
        old_fields[name].optional? != new_fields[name].optional?
    end,
    old_fields: old_fields,
    new_fields: new_fields
  }
end

.create_table(schema, output: nil) ⇒ String

Generate initial migration for a schema

Parameters:

  • schema (Schema, String)

    schema to migrate

  • output (String) (defaults to: nil)

    output directory

Returns:

  • (String)

    migration content or file path



126
127
128
129
130
131
132
133
134
135
136
# File 'lib/synthra/migration_generator.rb', line 126

def create_table(schema, output: nil)
  schema = resolve_schema(schema) if schema.is_a?(String)

  migration = build_create_table_migration(schema)

  if output
    write_migration(migration, output, nil, schema, action: "create")
  else
    migration
  end
end

.from_files(old_path:, new_path:, output: nil) ⇒ String

Generate migration from two schema files

Parameters:

  • old_path (String)

    path to old schema

  • new_path (String)

    path to new schema

  • output (String) (defaults to: nil)

    output directory

Returns:

  • (String)

    migration content or file path



97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/synthra/migration_generator.rb', line 97

def from_files(old_path:, new_path:, output: nil)
  old_reg = Synthra::Registry.new
  old_reg.load_file(old_path)
  old_schemas = old_reg.schemas.values

  new_reg = Synthra::Registry.new
  new_reg.load_file(new_path)
  new_schemas = new_reg.schemas.values

  # Match by name
  old_schema = old_schemas.first
  new_schema = new_schemas.first

  diff = calculate_diff(old_schema, new_schema)
  migration = build_migration(old_schema, new_schema, diff, {})

  if output
    write_migration(migration, output, old_schema, new_schema)
  else
    migration
  end
end

.generate(from:, to:, output: nil, **options) ⇒ String

Generate a migration from schema changes

Parameters:

  • from (String)

    source schema/version

  • to (String)

    target schema/version

  • output (String) (defaults to: nil)

    output directory

  • options (Hash)

    generation options

Returns:

  • (String)

    migration file path



76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/synthra/migration_generator.rb', line 76

def generate(from:, to:, output: nil, **options)
  from_schema = resolve_schema(from)
  to_schema = resolve_schema(to)

  diff = calculate_diff(from_schema, to_schema)
  migration = build_migration(from_schema, to_schema, diff, options)

  if output
    write_migration(migration, output, from_schema, to_schema)
  else
    migration
  end
end

.pluralize(str) ⇒ Object (private)

Simple pluralization



56
57
58
59
60
61
62
63
64
# File 'lib/synthra/migration_generator.rb', line 56

def pluralize(str)
  if str.end_with?('s', 'x', 'z', 'ch', 'sh')
    "#{str}es"
  elsif str.end_with?('y') && !%w[a e i o u].include?(str[-2])
    "#{str[0..-2]}ies"
  else
    "#{str}s"
  end
end

.resolve_schema(identifier) ⇒ Object (private)



140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
# File 'lib/synthra/migration_generator.rb', line 140

def resolve_schema(identifier)
  # Handle versioned identifiers like "User@v1.0.0"
  if identifier.include?("@")
    name, version = identifier.split("@")
    contracts = Synthra::ContractsRegistry.new("contracts")
    contract = contracts.get(name, version: version)
    raise ArgumentError, "Contract not found: #{identifier}" unless contract

    # Rebuild schema from contract
    reg = Synthra::Registry.new
    reg.load_string(contract[:dsl_content])
    reg.schemas.values.first
  elsif Synthra.registry.schema?(identifier)
    Synthra.registry.schema(identifier)
  else
    raise ArgumentError, "Schema not found: #{identifier}"
  end
end

.underscore(str) ⇒ Object (private)

Convert CamelCase to snake_case



49
50
51
52
53
# File 'lib/synthra/migration_generator.rb', line 49

def underscore(str)
  str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
     .gsub(/([a-z\d])([A-Z])/, '\1_\2')
     .downcase
end

.write_migration(content, output_dir, old_schema, new_schema, action: "update") ⇒ Object (private)



282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
# File 'lib/synthra/migration_generator.rb', line 282

def write_migration(content, output_dir, old_schema, new_schema, action: "update")
  FileUtils.mkdir_p(output_dir)

  timestamp = Time.now.strftime("%Y%m%d%H%M%S")
  schema_name = underscore(new_schema.name)

  filename = case action
             when "create"
               "#{timestamp}_create_#{pluralize(schema_name)}.rb"
             else
               "#{timestamp}_update_#{schema_name}.rb"
             end

  path = File.join(output_dir, filename)
  File.write(path, content)
  path
end