#--
# 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