Skip to content

Instantly share code, notes, and snippets.

@karmajunkie
Last active February 24, 2016 00:52
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save karmajunkie/cfbf6fc91aed7a00f551 to your computer and use it in GitHub Desktop.
Save karmajunkie/cfbf6fc91aed7a00f551 to your computer and use it in GitHub Desktop.

Down the rabbit hole

(or, "The story of a pomodoro gone horribly wrong")


Keith Gaddis ( @karmajunkie)

http://karmajunkie.com keith@karmajunkie.com

I like to:

  • hack on stuff.
  • fix stuff.
  • make businesses work better.

How this started


I like Elixir and Phoenix.


Bunch of legacy Rails code.


(I don't like this as much.)


Porting to Phoenix


I am lazy and converting AR models to Ecto seemed like a lot of typing.


ActiveRecord version

class Event < ActiveRecord::Base
  include BatchTouchable
  belongs_to :round
  has_one :chapter, through: :round
  has_one :chapter_season, through: :round

  has_many :event_invitations, dependent: :delete_all
  has_many :pnms, :through => :event_invitations, :source => :pnm

  delegate :voting_scheme, to: :round

  def invited?(pnm)
    event_invitations.where(:pnm_id => pnm.id).exists?
  end
end


defmodule Bling.Event do
  @moduledoc """
    Originally imported from Event
  """
  use Bling.Web, :model

  schema "events" do
     belongs_to :round, Bling.Round
     has_many :event_invitations, Bling.EventInvitation

     field :external_id, :string
     field :name, :string
     field :description, :string
     field :chapter_id, :integer
     field :start_at, Ecto.DateTime
     field :end_at, Ecto.DateTime
     field :university_id, :integer
     field :status, :string
     field :open_for_voting, :boolean
     field :created_at, Ecto.DateTime
     field :updated_at, Ecto.DateTime

  end
end


Oh, I know, lets write a script!


(Rake seemed like the way to go at the time.)


require 'fileutils'
TEMPLATE= <<-END_TEMPLATE
defmodule <%= mod %>.<%= model.to_ecto_model_name %> do
  @moduledoc """
    Originally imported from <%=model.name %>
  """
  use <%= mod %>.Web, :model

  schema "<%= model.table_name %>" do
    <% model.associations.reject(&:through?).each do |assoc| %> <%= assoc.to_declaration %>
    <% end %>
    <% model.fields.each do |field| %> <%= field.to_declaration %>
    <% end %>
  end

end
END_TEMPLATE

class Association
  attr_reader :assoc
  def initialize(mod, assoc)
    @mod=mod
    @assoc=assoc
  end

  def <=>(other)
    begin
    if through?
      if through_klass == other.terminal_association
        1
      else
        -1
      end
    else
      0
    end
    rescue Exception  => e
      debugger
      0
    end
  end
  def through?
    @assoc.options[:through]
  end
  def foreign_key
    if through?
      @assoc.through_reflection.foreign_key
    else
      @assoc.association_foreign_key
    end
  end
  def through_assoc
    @ta ||= @assoc.through_reflection.macro.to_s.classify.constantize.new(@mod, @assoc.through_reflection)
  end
  def terminal_association
    if through?
      through_assoc.terminal_association
    else
      self
    end
  end
  def macro
    @assoc.macro
  end
  def to_declaration
    "#{@assoc.macro} :#{@assoc.name}, #{@mod}.#{to_ecto_model_name}"
  end

  def to_ecto_model_name
    if @assoc.klass
      @assoc.klass.name.gsub("::", ".")
    end
  end
end
class BelongsTo < Association
  def foreign_key
    @assoc.foreign_key
  end
end
class HasMany < Association
  def source_name
    @assoc.source_reflection.name
  rescue Exception  => e
    @assoc.name
  end

  def through_klass
    @assoc.through_reflection.klass
  end
  def to_declaration
    if through?
      [through_assoc.to_declaration, "has_many :#{@assoc.name}, through: [:#{@assoc.options[:through]}, :#{source_name}]"].join("\n")
    else
      super
    end
  end

  def to_ecto_model_name
    if @assoc.options.has_key?(:through)
      through_source=through_klass.reflections[@assoc.name.to_s.singularize.to_sym].klass.name.gsub("::", ".")
    else
      super
    end
  end
end
class HasOne < Association
  def source_name
    @assoc.source_reflection.name
  rescue Exception  => e
    @assoc.name
  end
  def through_klass
    @assoc.through_reflection.klass
  end


  def to_ecto_model_name
    if @assoc.options.has_key?(:through)
      through_source=@assoc.through_reflection.klass.reflections[@assoc.name].klass.name.gsub("::", ".")
      through_source
    else
      super
    end
  end
  def to_declaration
    if through?
      [
        through_assoc.to_declaration,
        "has_one :#{@assoc.name}, through: [:#{@assoc.options[:through]}, :#{source_name}]"
      ].join("\n")
    else
      super
    end
  end
end
class HasAndBelongsToMany < Association
end
class PolymorphicAssoc < Association
  def initialize(mod, assoc)
    @mod=mod
    @assoc=assoc
  end
  def to_declaration
    "# polymorphic associations are tricky so we're not enabling this yet\n##{@assoc.macro} :#{@assoc.name.to_s}, #{@mod}.#{self.to_ecto_model_name}"
  end

  def name
    @assoc.name.to_s
  end

  def to_ecto_model_name
    @assoc.name.to_s.classify.gsub("::", ".")
  end
end

class Field
  def initialize(f)
    @field=f
  end

  def type_label
    if ['json', 'jsonb'].include?(@field.sql_type )
      ':map'
    else
      ":#{@field.type.to_s}"
    end
  end

  def to_declaration
    "field :#{@field.name}, #{type_label}"
  end
end

class TextField < Field
  def type_label
    ":string"
  end
end
class UuidField < Field
  def type_label
    "Ecto.UUID"
  end
end

class InetField < Field
  def type_label
    ":string"
  end
end
class DateField < Field
  def type_label
    "Ecto.Date"
  end
end

class DatetimeField < Field
  def type_label
    "Ecto.DateTime"
  end
end

class ModelWrapper
  attr_accessor :klass
  def initialize(mod, klass)
    @mod=mod
    @klass=klass
  end

  def to_ecto_model_name
    @klass.name.gsub("::", ".")
  end

  def table_name
    @klass.table_name
  end

  def name
    @klass.name
  end

  def associations
    @assocs=klass.reflect_on_all_associations.map{ |a| a.polymorphic? ? ::PolymorphicAssoc.new(@mod, a) : a.macro.to_s.classify.constantize.new(@mod, a) }
  end

  def fields
    @fields ||= begin
                  fkeys=associations.map(&:foreign_key).map(&:to_s)
                  fields=klass.columns.reject{ |col| col.name.to_s=='id' || fkeys.include?(col.name.to_s)}
                 # fields = klass.columns.map(&:name) - ['id'] - associations.map(&:foreign_key)
                  fields.map do |f|
                    begin
                      "#{f.type.to_s.classify}Field".constantize.new(f)
                    rescue Exception => e
                      Field.new(f)
                    end
                  end
                end
  end
end
desc "export model classes to something in Elixir that Just Might Work(tm)"
namespace :ex do
  task :export  => :environment do
    mod=ENV['MODULE']
    output_dir=ENV['OUTPUT']
    unless !mod.blank? && !output_dir.blank?
      $stdout.puts "You must supply both a module to put the Elixir models in and an output directory for them to go in, e.g.\n\t rake ex:export MODULE='MyApp.Models' OUTPUT='/path/to/my/elixir/project'"
      exit
    end
    not_loadable=[]
    not_ar=[]
    template=ERB.new(TEMPLATE)
    requested_files = ENV['FILES'].to_s.split(",")
    klasses=(requested_files.any? ? requested_files : Dir.glob("app/models/**/*")).
             each do |cl|
      subdir=File.dirname(cl.gsub(%r|app/models/|, ''))
        cname= cl.gsub(%r|app/models/|, '').gsub(%r|/[a-z]|){|m| m}.gsub(".rb", '').classify
      begin
        klass=cname.constantize
        if !ActiveRecord::Base.subclasses.include?(klass)
          not_ar  << [cl, klass.name]
          next
        end

        klass.connection
        model=ModelWrapper.new(mod, klass)
        write_dir=File.join(output_dir, 'web/models', subdir)
        filename=File.join(write_dir, File.basename(cl).gsub(/\.rb/, '.ex'))
        FileUtils.mkdir_p(write_dir)
        if File.exists?(filename) && ENV['OVERWRITE'].blank?
          $stdout.puts "Not writing to file #{filename} because it already exists. The generated schema for this file is: \n#{template.result(binding)}"
        else
          $stdout.puts "writing to #{filename}"
           File.open(filename, 'w'){ |f| f.write(template.result(binding)) } unless ENV['DRY_RUN']
        end
       # puts template.result binding
      rescue Exception  => e
        puts "Error processing #{cname} from #{cl}: #{ e }" if ENV['DEBUG']
        debugger if ENV['DEBUG']
        not_loadable << [cl, cname]
        next
      end

    end
    if not_loadable.any?
      $stdout.puts "The following classes were unable to be loaded based on the filename convention used in Rails:\n\t#{not_loadable.map{ |arr| %|#{arr.last} not found in #{arr.first}| }.join("\n\t")}"
    end
    if not_ar.any?
      $stdout.puts "The following classes were not subclasses of ActiveRecord::Base, so could not be exported into Ecto schema:\n\t#{not_loadable.map{ |arr| %|#{arr.last} not found in #{arr.first}| }.join("\n\t")}"
    end
  end
end


desc "export model classes to something in Elixir that Just Might Work(tm)"
namespace :ex do
  task :export  => :environment do
    mod=ENV['MODULE']
    output_dir=ENV['OUTPUT']
    unless !mod.blank? && !output_dir.blank?
      $stdout.puts "You must supply both a module to put the Elixir models in and an output directory for them to go in, e.g.\n\t rake ex:export MODULE='MyApp.Models' OUTPUT='/path/to/my/elixir/project'"
      exit
    end
    not_loadable=[]
    not_ar=[]
    template=ERB.new(TEMPLATE)
    requested_files = ENV['FILES'].to_s.split(",")
    klasses=(requested_files.any? ? requested_files : Dir.glob("app/models/**/*")).
             each do |cl|
      subdir=File.dirname(cl.gsub(%r|app/models/|, ''))
        cname= cl.gsub(%r|app/models/|, '').gsub(%r|/[a-z]|){|m| m}.gsub(".rb", '').classify
      begin
        klass=cname.constantize
        if !ActiveRecord::Base.subclasses.include?(klass)
          not_ar  << [cl, klass.name]
          next
        end

        klass.connection
        model=ModelWrapper.new(mod, klass)
        write_dir=File.join(output_dir, 'web/models', subdir)
        filename=File.join(write_dir, File.basename(cl).gsub(/\.rb/, '.ex'))
        FileUtils.mkdir_p(write_dir)
        if File.exists?(filename) && ENV['OVERWRITE'].blank?
          $stdout.puts "Not writing to file #{filename} because it already exists. The generated schema for this file is: \n#{template.result(binding)}"
        else
          $stdout.puts "writing to #{filename}"
           File.open(filename, 'w'){ |f| f.write(template.result(binding)) } unless ENV['DRY_RUN']
        end
       # puts template.result binding
      rescue Exception  => e
        puts "Error processing #{cname} from #{cl}: #{ e }" if ENV['DEBUG']
        debugger if ENV['DEBUG']
        not_loadable << [cl, cname]
        next
      end

    end
    if not_loadable.any?
      $stdout.puts "The following classes were unable to be loaded based on the filename convention used in Rails:\n\t#{not_loadable.map{ |arr| %|#{arr.last} not found in #{arr.first}| }.join("\n\t")}"
    end
    if not_ar.any?
      $stdout.puts "The following classes were not subclasses of ActiveRecord::Base, so could not be exported into Ecto schema:\n\t#{not_loadable.map{ |arr| %|#{arr.last} not found in #{arr.first}| }.join("\n\t")}"
    end


TEMPLATE= <<-END_TEMPLATE
defmodule <%= mod %>.<%= model.to_ecto_model_name %> do
  @moduledoc """
    Originally imported from <%=model.name %>
  """
  use <%= mod %>.Web, :model

  schema "<%= model.table_name %>" do
    <% model.associations.reject(&:through?).each do |assoc| %> <%= assoc.to_declaration %>
    <% end %>
    <% model.fields.each do |field| %> <%= field.to_declaration %>
    <% end %>
  end

end
END_TEMPLATE

Stuff to know

  • This code really, really sucks
    • (Seriously, thinking about writing a book on un-sucking it)
  • Plenty of todo's
    • gem it up, clean it up, use actual options parsing, make it a legit executable
  • Doesn't handle polymorphic associations
    • (don't use polymorphic associations in the first place)
  • order dependencies in associations
  • This will get you like, 80% of the way there. Maybe.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment