#-- # Copyright (c) 2005 William T Katz # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. #++ # # todo # # Changes by Jim Morris (http://blog.wolfman.com) # -- read a DDL to get info # -- create models for all tables # -- allow for non-standard foreign key names # -- have an exceptions list (assocs.yml) with association overrides # -- belongs_to needs to check standard naming and multiple versions # -- add validate_presence_of based on NOT NULL # DBMODELVERSION = '0.1.1' if __FILE__ == $0 then require 'rubygems' end require 'getoptlong' require 'rexml/document' require 'rails_generator' require 'rails_generator/scripts/generate' require File.dirname(__FILE__) + File.join('', 'ddl.rb') # A class to handle parsing of table information from a datamodel XML file. # Since relationships are defined by table IDs, a hash is constructed using # table IDs. class Tables def initialize @table_hash = Hash.new end # Adds a table to the hash, where the table is a DDL::Table class def add_table(t) @table_hash[t.name]= {} @table_hash[t.name][:table] = t @table_hash[t.name][:relationships]= [] end # # Works out the relationships between tables based on the DDL::Table information # Only referenced tables have the relationship type, so the inverse (belongs_to) # needs to be added to the relating table # Called after all the tables have been added # def calculate_relationships # foreach table referenced @table_hash.each_key do |src_table_name| ref_tbl= @table_hash[src_table_name][:table] # foreach reference ref_tbl.associations.each do |a| dst_table_name= a.ref_table relationship= a.type column= a.column if relationship == "habtm" relationship = " has_and_belongs_to_many :#{dst_table_name}" else # If we are inserting the other side of a relationship (non-habtm), # we need to mirror the relationship. if relationship == "has_one" or relationship == "has_many" # check if it is using the standard naming scheme if column == src_table_name.classify.foreign_key b_rel= ' belongs_to :' + src_table_name.singularize else # - if field in belongs_to does not match name then spell it out # - eg parent_acct in Accounts needs to be called belongs_to :parent_acct, :class => Account, :foreign_key => parent_acct b_rel= ' belongs_to :' + "#{column}_#{src_table_name.singularize}, :class_name => '#{src_table_name.classify}', :foreign_key => '#{column}'" end @table_hash[dst_table_name][:relationships] << b_rel else raise "error: relationships must be one of: 'has_one', 'has_many', 'habtm' was: <#{relationship}>" end # check if it is using the standard naming scheme if column == src_table_name.classify.foreign_key relationship = " #{relationship} :#{dst_table_name}" else relationship = " #{relationship} :#{dst_table_name}_#{column}, :class_name => '#{dst_table_name.classify}', :foreign_key => '#{column}'" end end @table_hash[src_table_name][:relationships] << relationship end end end # Updates the Rails app files to reflect what's in the table hash. The location # of the Rails app is gleaned from +xmlfile+, which is assumed to be in /db. # If a model file doesn't exist, a generate model or generate scaffold # is called, depending on the presence of a [SCAFFOLD] tag in the table # comments. Existing files are checked to make sure the relationship # is defined. To-do: old relationships aren't removed def update_files(xmlfile) @table_hash.each do |table_name, table| # If there's no model file, create one via generate scaffold script modelfile = File.dirname(xmlfile) + File.join('', '..', 'app', 'models', table_name.singularize + '.rb') if File.exist?(modelfile) puts "Model file (#{modelfile}) already exists. Skipping generation." if not $silent else cmdline = ['model', table_name.singularize, '-f'] puts "Generating model for #{table_name.singularize}" if not $silent Rails::Generator::Scripts::Generate.new.run(cmdline) if not $dryrun if not table[:relationships].empty? update_file(modelfile, table_name) if not $dryrun end end end end ##################################### private def find_class_declaration_line(code) code.each_with_index do |line, index| return index if line =~ /class/ end puts "error: couldn't find class to insert relationships" return -2 end def prettify_comments(comment) lines = comment.split(/\[\s*SCAFFOLD.*\]/).join.split('\n') return lines end # Update model file for this table def update_file(modelfile, table_name, add_comments= false) begin File.open(modelfile, "r+") do |file| modelcode = file.readlines class_line = find_class_declaration_line(modelcode) if class_line >= 0 # Update the relationships @table_hash[table_name][:relationships].each do |relationship| our_relation = Regexp.new(relationship.strip) unless modelcode.grep(our_relation).any? puts " --> insert#{relationship} (in #{modelfile})" if not $silent modelcode.insert(class_line + 1, relationship + "\n") end end # Update the validations @table_hash[table_name][:table].validations.each do |v| our_validation = Regexp.new(v.strip) unless modelcode.grep(our_validation).any? puts " --> insert #{v} (in #{modelfile})" if not $silent modelcode.insert(class_line + 1, " #{v}\n") end end # Add comments if add_comments and not @table_hash[table_name]['comments'].empty? puts " --> modifying comments in #{modelfile}" if not $silent lines = prettify_comments(@table_hash[table_id]['comments']) lines.reverse.each do |line| modelcode.insert(class_line, '# ' + line + "\n") end end # File should always grow with our additions file.rewind file.puts(modelcode) end end rescue puts "\nCouldn't open file (#{modelfile}). Make sure your database " puts "model DDL files are in the /db directory of your Rails app." end end end ############################################################################ # DBModel main application object. When invoking +dbmodel+ from the command # line, a DBModelApp object is created and run. # class DBModelApp OPTIONS = [ ['--dry-run', '-n', GetoptLong::NO_ARGUMENT, "Do a dry run without executing actions."], ['--help', '-H', GetoptLong::NO_ARGUMENT, "Display this help message."], ['--quiet', '-q', GetoptLong::NO_ARGUMENT, "Do not log messages to standard output."], ['--usage', '-h', GetoptLong::NO_ARGUMENT, "Display usage."], ['--version', '-V', GetoptLong::NO_ARGUMENT, "Display the program version."], ] # Create a DBModelApp object. def initialize end # Display the program usage line. def usage puts "dbmodel {options} [dbmodelfiles ...]" end # Display the dbmodel command line help. def help usage puts puts "Options are ..." puts OPTIONS.sort.each do |long, short, mode, desc| if mode == GetoptLong::REQUIRED_ARGUMENT if desc =~ /\b([A-Z]{2,})\b/ long = long + "=#{$1}" end end printf " %-20s (%s)\n", long, short printf " %s\n", desc end puts "\nPlease read the rdoc README for more information." end # Return a list of the command line options supported by the # program. def command_line_options OPTIONS.collect { |lst| lst[0..-2] } end # Do the option defined by +opt+ and +value+. def do_option(opt, value) case opt when '--dry-run' $dryrun = true when '--help' help exit when '--quiet' $silent = true when '--usage' usage exit when '--version' puts "dbmodel, version #{DBMODELVERSION}" exit else fail "Unknown option: #{opt}" end end # Read and handle the command line options. def handle_options $dryrun= false $silent= false opts = GetoptLong.new(*command_line_options) opts.each { |opt, value| do_option(opt, value) } end # Collect the list of dbmodel filenames on the command line. # Default filename is "dbmodel.xml" def get_filenames filenames = [] ARGV.each { |arg| filenames << arg if arg !~ /^-+.*/ } if filenames.size == 0 if File.exist?('dbmodel.xml') filenames.push("dbmodel.xml") else puts "\nCould not find any database model XML files." end end filenames end # Run the dbmodel app def run handle_options begin get_filenames.each do |f| require File.dirname(f) + File.join('', '..', 'config', 'environment') # open and parse the DDL @ddl= DDL.new(f) puts "\nReading the DDL (#{f})..." if not $silent # Create a hash for each table with ID key tables = Tables.new @ddl.each { |t| tables.add_table(t) } tables.calculate_relationships # Modify the files for each table tables.update_files(f) end rescue Exception => ex puts "dbmodel aborted!" if ex.message =~ /config#{File::SEPARATOR}environment/ puts "Make sure your ddl file is in the Rails app /db directory\n" end puts ex.message puts ex.backtrace.find {|str| str =~ /\.rb/ } || "" exit(1) end end end if __FILE__ == $0 then DBModelApp.new.run end