Class: Brick::MigrationsGenerator

Inherits:
Rails::Generators::Base
  • Object
show all
Includes:
FancyGets
Defined in:
lib/generators/brick/migrations_generator.rb

Overview

Auto-generates migration files

Constant Summary collapse

SQL_TYPES =

Many SQL types are the same as their migration data type name:

text, integer, bigint, date, boolean, decimal, float

These however are not:

{ 'character varying' => 'string',
  'character' => 'string', # %%% Need to put in "limit: 1"
  'xml' => 'text',
  'bytea' => 'binary',
  'timestamp without time zone' => 'timestamp',
  'timestamp with time zone' => 'timestamp',
  'time without time zone' => 'time',
  'time with time zone' => 'time',
  'double precision' => 'float',
  'smallint' => 'integer', # %%% Need to put in "limit: 2"
  # # Oracle data types
  'VARCHAR2' => 'string',
  'CHAR' => 'string',
  ['NUMBER', 22] => 'integer',
  /^INTERVAL / => 'string', # Time interval stuff like INTERVAL YEAR(2) TO MONTH, INTERVAL '999' DAY(3), etc
  'XMLTYPE' => 'xml',
  'RAW' => 'binary',
  'SDO_GEOMETRY' => 'geometry',
  # MSSQL data types
  'int' => 'integer',
  'nvarchar' => 'string',
  'nchar' => 'string',
  'datetime2' => 'timestamp',
  'bit' => 'boolean',
  'varbinary' => 'binary',
  # Sqlite data types
  'TEXT' => 'text',
  '' => 'string',
  'INTEGER' => 'integer',
  'REAL' => 'float',
  'BLOB' => 'binary',
  'TIMESTAMP' => 'timestamp',
  'DATETIME' => 'timestamp'
}

Instance Method Summary collapse

Instance Method Details

#brick_migrationsObject



54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
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
239
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
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
# File 'lib/generators/brick/migrations_generator.rb', line 54

def brick_migrations
  # If Apartment is active, see if a default schema to analyse is indicated

  # # Load all models
  # ::Brick.eager_load_classes

  if (tables = ::Brick.relations.reject { |k, v| v.key?(:isView) && v[:isView] == true }.map(&:first).sort).empty?
    puts "No tables found in database #{ActiveRecord::Base.connection.current_database}."
    return
  end

  is_sqlite = ActiveRecord::Base.connection.adapter_name == 'SQLite'
  key_type = ((is_sqlite || ActiveRecord.version < ::Gem::Version.new('5.1')) ? 'integer' : 'bigint')
  is_4x_rails = ActiveRecord.version < ::Gem::Version.new('5.0')
  ar_version = "[#{ActiveRecord.version.segments[0..1].join('.')}]" unless is_4x_rails
  is_insert_versions = true
  is_delete_versions = false
  versions_to_delete_or_append = nil
  if Dir.exist?(mig_path = ActiveRecord::Migrator.migrations_paths.first || "#{::Rails.root}/db/migrate")
    if Dir["#{mig_path}/**/*.rb"].present?
      puts "WARNING: migrations folder #{mig_path} appears to already have ruby files present."
      mig_path2 = "#{::Rails.root}/tmp/brick_migrations"
      is_insert_versions = false unless mig_path == mig_path2
      if Dir.exist?(mig_path2)
        if Dir["#{mig_path2}/**/*.rb"].present?
          puts "As well, temporary folder #{mig_path2} also has ruby files present."
          puts "Choose a destination -- all existing .rb files will be removed:"
          mig_path2 = gets_list(list: ['Cancel operation!', "Append migration files into #{mig_path} anyway", mig_path, mig_path2])
          return if mig_path2.start_with?('Cancel')

          existing_mig_files = Dir["#{mig_path2}/**/*.rb"]
          if (is_insert_versions = mig_path == mig_path2)
            versions_to_delete_or_append = existing_mig_files.map { |ver| ver.split('/').last.split('_').first }
          end
          if mig_path2.start_with?('Append migration files into ')
            mig_path2 = mig_path
          else
            is_delete_versions = true
            existing_mig_files.each { |rb| File.delete(rb) }
          end
        else
          puts "Using temporary folder #{mig_path2} for created migration files.\n\n"
        end
      else
        puts "Creating the temporary folder #{mig_path2} for created migration files.\n\n"
        Dir.mkdir(mig_path2)
      end
      mig_path = mig_path2
    else
      puts "Using standard migration folder #{mig_path} for created migration files.\n\n"
    end
  else
    puts "Creating standard ActiveRecord migration folder #{mig_path} to hold new migration files.\n\n"
    Dir.mkdir(mig_path)
  end

  # Generate a list of tables that can be chosen
  chosen = gets_list(list: tables, chosen: tables.dup)
  schemas = chosen.each_with_object({}) do |v, s|
    if (v_parts = v.split('.')).length > 1
      s[v_parts.first] = nil unless [::Brick.default_schema, 'public'].include?(v_parts.first)
    end
  end
  # Start the timestamps back the same number of minutes from now as expected number of migrations to create
  current_mig_time = Time.now - (schemas.length + chosen.length).minutes
  done = []
  fks = {}
  stuck = {}
  indexes = {} # Track index names to make sure things are unique
  built_schemas = {} # Track all built schemas so we can place an appropriate drop_schema command only in the first
                     # migration in which that schema is referenced, thereby allowing rollbacks to function properly.
  versions_to_create = [] # Resulting versions to be used when updating the schema_migrations table
  # Start by making migrations for fringe tables (those with no foreign keys).
  # Continue layer by layer, creating migrations for tables that reference ones already done, until
  # no more migrations can be created.  (At that point hopefully all tables are accounted for.)
  while (fringe = chosen.reject do |tbl|
                    snags = ::Brick.relations.fetch(tbl, nil)&.fetch(:fks, nil)&.select do |_k, v|
                      v[:is_bt] && !v[:polymorphic] &&
                      tbl != v[:inverse_table] && # Ignore self-referencing associations (stuff like "parent_id")
                      !done.include?(v[:inverse_table])
                    end
                    stuck[tbl] = snags if snags&.present?
                  end).present?
    fringe.each do |tbl|
      next unless (relation = ::Brick.relations.fetch(tbl, nil))&.fetch(:cols, nil)&.present?

      ar_base = Object.const_defined?(:ApplicationRecord) ? ApplicationRecord : Class.new(ActiveRecord::Base)
      pkey_cols = (rpk = relation[:pkey].values.flatten) & (arpk = [ar_base.primary_key].flatten.sort)
      # In case things aren't as standard
      if pkey_cols.empty?
        pkey_cols = if rpk.empty? && relation[:cols][arpk.first]&.first == key_type
                      arpk
                    elsif rpk.first
                      rpk
                    end
      end
      schema = if (tbl_parts = tbl.split('.')).length > 1
                 if tbl_parts.first == (::Brick.default_schema || 'public')
                   tbl_parts.shift
                   nil
                 else
                   tbl_parts.first
                 end
               end
      unless schema.blank? || built_schemas.key?(schema)
        mig = +"  def change\n    create_schema(:#{schema}) unless schema_exists?(:#{schema})\n  end\n"
        migration_file_write(mig_path, "create_db_schema_#{schema.underscore}", current_mig_time += 1.minute, ar_version, mig)
        built_schemas[schema] = nil
      end

      # %%% For the moment we're skipping polymorphics
      fkey_cols = relation[:fks].values.select { |assoc| assoc[:is_bt] && !assoc[:polymorphic] }
      # If the primary key is also used as a foreign key, will need to do  id: false  and then build out
      # a column definition which includes :primary_key -- %%% also using a data type of bigserial or serial
      # if this one has come in as bigint or integer.
      pk_is_also_fk = fkey_cols.any? { |assoc| pkey_cols&.first == assoc[:fk] } ? pkey_cols&.first : nil
      # Support missing primary key (by adding:  , id: false)
      id_option = if pk_is_also_fk || !pkey_cols&.present?
                    needs_serial_col = true
                    ', id: false'
                  elsif ((pkey_col_first = (col_def = relation[:cols][pkey_cols&.first])&.first) &&
                         (pkey_col_first = SQL_TYPES[pkey_col_first] || SQL_TYPES[col_def&.[](0..1)] ||
                                           SQL_TYPES.find { |r| r.first.is_a?(Regexp) && pkey_col_first =~ r.first }&.last ||
                                           pkey_col_first
                         ) != key_type
                        )
                    case pkey_col_first
                    when 'integer'
                      ', id: :serial'
                    when 'bigint'
                      ', id: :bigserial'
                    else
                      ", id: :#{pkey_col_first}" # Something like:  id: :integer, primary_key: :businessentityid
                    end +
                      (pkey_cols.first ? ", primary_key: :#{pkey_cols.first}" : '')
                  end
      if !id_option && pkey_cols.sort != arpk
        id_option = ", primary_key: :#{pkey_cols.first}"
      end
      if !is_4x_rails && (comment = relation&.fetch(:description, nil))&.present?
        (id_option ||= +'') << ", comment: #{comment.inspect}"
      end
      # Find the ActiveRecord class in order to see if the columns have comments
      unless is_4x_rails
        klass = begin
                  tbl.tr('.', '/').singularize.camelize.constantize
                rescue StandardError
                end
        if klass
          unless ActiveRecord::Migration.table_exists?(klass.table_name)
            puts "WARNING: Unable to locate table #{klass.table_name} (for #{klass.name})."
            klass = nil
          end
        end
      end
      # Refer to this table name as a symbol or dotted string as appropriate
      tbl_code = tbl_parts.length == 1 ? ":#{tbl_parts.first}" : "'#{tbl}'"
      mig = +"  def change\n    return unless reverting? || !table_exists?(#{tbl_code})\n\n"
      mig << "    create_table #{tbl_code}#{id_option} do |t|\n"
      possible_ts = [] # Track possible generic timestamps
      add_fks = [] # Track foreign keys to add after table creation
      relation[:cols].each do |col, col_type|
        sql_type = SQL_TYPES[col_type.first] || SQL_TYPES[col_type[0..1]] ||
                   SQL_TYPES.find { |r| r.first.is_a?(Regexp) && col_type.first =~ r.first }&.last ||
                   col_type.first
        suffix = col_type[3] || pkey_cols&.include?(col) ? +', null: false' : +''
        if !is_4x_rails && klass && (comment = klass.columns_hash.fetch(col, nil)&.comment)&.present?
          suffix << ", comment: #{comment.inspect}"
        end
        # Determine if this column is used as part of a foreign key
        if (fk = fkey_cols.find { |assoc| col == assoc[:fk] })
          to_table = fk[:inverse_table].split('.')
          to_table = to_table.length == 1 ? ":#{to_table.first}" : "'#{fk[:inverse_table]}'"
          if needs_serial_col && pkey_cols&.include?(col) && (new_serial_type = {'integer' => 'serial', 'bigint' => 'bigserial'}[sql_type])
            sql_type = new_serial_type
            needs_serial_col = false
          end
          if fk[:fk] != "#{fk[:assoc_name].singularize}_id" # Need to do our own foreign_key tricks, not use references?
            column = fk[:fk]
            mig << emit_column(sql_type, column, suffix)
            add_fks << [to_table, column, ::Brick.relations[fk[:inverse_table]]]
          else
            suffix << ", type: :#{sql_type}" unless sql_type == key_type
            # Will the resulting default index name be longer than what Postgres allows?  (63 characters)
            if (idx_name = ActiveRecord::Base.connection.index_name(tbl, {column: col})).length > 63
              # Try to find a shorter name that hasn't been used yet
              unless indexes.key?(shorter = idx_name[0..62]) ||
                     indexes.key?(shorter = idx_name.tr('_', '')[0..62]) ||
                     indexes.key?(shorter = idx_name.tr('aeio', '')[0..62])
                puts "Unable to easily find unique name for index #{idx_name} that is shorter than 64 characters,"
                puts "so have resorted to this GUID-based identifier: #{shorter = "#{tbl[0..25]}_#{::SecureRandom.uuid}"}."
              end
              suffix << ", index: { name: '#{shorter || idx_name}' }"
              indexes[shorter || idx_name] = nil
            end
            mig << "      t.references :#{fk[:assoc_name]}#{suffix}, foreign_key: { to_table: #{to_table} }\n"
          end
        else
          next if !id_option&.end_with?('id: false') && pkey_cols&.include?(col)

          # See if there are generic timestamps
          if sql_type == 'timestamp' && ['created_at','updated_at'].include?(col)
            possible_ts << [col, !col_type[3]]
          else
            mig << emit_column(sql_type, col, suffix)
          end
        end
      end
      if possible_ts.length == 2 && # Both created_at and updated_at
         # Rails 5 and later timestamps default to NOT NULL
         (possible_ts.first.last == is_4x_rails && possible_ts.last.last == is_4x_rails)
        mig << "\n      t.timestamps\n"
      else # Just one or the other, or a nullability mismatch
        possible_ts.each { |ts| emit_column('timestamp', ts.first, nil) }
      end
      mig << "    end\n"
      if pk_is_also_fk
        mig << "    reversible do |dir|\n"
        mig << "      dir.up { execute('ALTER TABLE #{tbl} ADD PRIMARY KEY (#{pk_is_also_fk})') }\n"
        mig << "    end\n"
      end
      add_fks.each do |add_fk|
        is_commented = false
        # add_fk[2] holds the inverse relation
        unless (pk = add_fk[2][:pkey].values.flatten&.first)
          is_commented = true
          mig << "    # (Unable to create relationship because primary key is missing on table #{add_fk[0]})\n"
          # No official PK, but if coincidentally there's a column of the same name, take a chance on it
          pk = (add_fk[2][:cols].key?(add_fk[1]) && add_fk[1]) || '???'
        end
        #                                                                 to_table               column
        mig << "    #{'# ' if is_commented}add_foreign_key #{tbl_code}, #{add_fk[0]}, column: :#{add_fk[1]}, primary_key: :#{pk}\n"
      end
      mig << "  end\n"
      versions_to_create << migration_file_write(mig_path, "create_#{tbl_parts.map(&:underscore).join('_')}", current_mig_time += 1.minute, ar_version, mig)
    end
    done.concat(fringe)
    chosen -= done
  end

  stuck_counts = Hash.new { |h, k| h[k] = 0 }
  chosen.each do |leftover|
    puts "Can't do #{leftover} because:\n  #{stuck[leftover].map do |snag|
      stuck_counts[snag.last[:inverse_table]] += 1
      snag.last[:assoc_name]
    end.join(', ')}"
  end
  if mig_path.start_with?(cur_path = ::Rails.root.to_s)
    pretty_mig_path = mig_path[cur_path.length..-1]
  end
  puts "\n*** Created #{done.length} migration files under #{pretty_mig_path || mig_path} ***"
  if (stuck_sorted = stuck_counts.to_a.sort { |a, b| b.last <=> a.last }).length.positive?
    puts "-----------------------------------------"
    puts "Unable to create migrations for #{stuck_sorted.length} tables#{
      ".  Here's the top 5 blockers" if stuck_sorted.length > 5
    }:"
    pp stuck_sorted[0..4]
  else # Successful, and now we can update the schema_migrations table accordingly
    unless ActiveRecord::Migration.table_exists?(ActiveRecord::Base.schema_migrations_table_name)
      ActiveRecord::SchemaMigration.create_table
    end
    # Remove to_delete - to_create
    if ((versions_to_delete_or_append ||= []) - versions_to_create).present? && is_delete_versions
      ActiveRecord::Base.execute_sql("DELETE FROM #{
        ActiveRecord::Base.schema_migrations_table_name} WHERE version IN (#{
        (versions_to_delete_or_append - versions_to_create).map { |vtd| "'#{vtd}'" }.join(', ')}
      )")
    end
    # Add to_create - to_delete
    if is_insert_versions && ((versions_to_create ||= []) - versions_to_delete_or_append).present?
      ActiveRecord::Base.execute_sql("INSERT INTO #{
        ActiveRecord::Base.schema_migrations_table_name} (version) VALUES #{
        (versions_to_create - versions_to_delete_or_append).map { |vtc| "('#{vtc}')" }.join(', ')
      }")
    end
  end
end