diff --git a/.rubocop.yml b/.rubocop.yml index 02faf18d..76ac30e1 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -2,6 +2,7 @@ AllCops: TargetRubyVersion: 3.1 Exclude: - vendor/bundle/**/* + - lib/rage/templates/**/* DisabledByDefault: true SuggestExtensions: false diff --git a/lib/rage-rb.rb b/lib/rage-rb.rb index 59bc7aec..4e2ff0c4 100644 --- a/lib/rage-rb.rb +++ b/lib/rage-rb.rb @@ -82,6 +82,10 @@ def self.patch_active_record_connection_pool end end + def self.load_tasks + Rage::Tasks.init + end + # @private def self.with_middlewares(app, middlewares) middlewares.reverse.inject(app) do |next_in_chain, (middleware, args, block)| @@ -113,6 +117,7 @@ module ActiveRecord end end + autoload :Tasks, "rage/tasks" autoload :Cookies, "rage/cookies" autoload :Session, "rage/session" autoload :Cable, "rage/cable/cable" diff --git a/lib/rage/cli.rb b/lib/rage/cli.rb index 6a7a3c0b..371d387a 100644 --- a/lib/rage/cli.rb +++ b/lib/rage/cli.rb @@ -5,15 +5,54 @@ require "rage/version" module Rage + class CLICodeGenerator < Thor + include Thor::Actions + + def self.source_root + File.expand_path("templates", __dir__) + end + + desc "migration NAME", "Generate a new migration." + def migration(name = nil) + return help("migration") if name.nil? + + setup + Rake::Task["db:new_migration"].invoke(name) + end + + desc "model NAME", "Generate a new model." + def model(name = nil) + return help("model") if name.nil? + + setup + migration("create_#{name.pluralize}") + @model_name = name.classify + template("model-template/model.rb", "app/models/#{name.singularize.underscore}.rb") + end + + private + + def setup + @setup ||= begin + require "rake" + load "Rakefile" + end + end + end + class CLI < Thor def self.exit_on_failure? true end desc "new PATH", "Create a new application." - def new(path) + option :database, aliases: "-d", desc: "Preconfigure for selected database.", enum: %w(mysql trilogy postgresql sqlite3) + option :help, aliases: "-h", desc: "Show this message." + def new(path = nil) + return help("new") if options.help? || path.nil? + require "rage/all" - NewAppGenerator.start([path]) + CLINewAppGenerator.start([path, options[:database]]) end desc "s", "Start the app server." @@ -130,6 +169,43 @@ def version puts Rage::VERSION end + map "generate" => :g + desc "g TYPE", "Generate new code." + subcommand "g", CLICodeGenerator + + map "--tasks" => :tasks + desc "--tasks", "See the list of available tasks." + def tasks + require "io/console" + + tasks = linked_rake_tasks + return if tasks.empty? + + _, max_width = IO.console.winsize + max_task_name = tasks.max_by { |task| task.name.length }.name.length + 2 + max_comment = max_width - max_task_name - 8 + + tasks.each do |task| + comment = task.comment.length <= max_comment ? task.comment : "#{task.comment[0...max_comment - 5]}..." + puts sprintf("rage %-#{max_task_name}s # %s", task.name, comment) + end + end + + def method_missing(method_name, *, &) + set_env({}) + + if respond_to?(method_name) + Rake::Task[method_name].invoke + else + suggestions = linked_rake_tasks.map(&:name) + raise UndefinedCommandError.new(method_name.to_s, suggestions, nil) + end + end + + def respond_to_missing?(method_name, include_private = false) + linked_rake_tasks.any? { |task| task.name == method_name.to_s } || super + end + private def environment @@ -141,30 +217,85 @@ def environment end def set_env(options) - ENV["RAGE_ENV"] = options[:environment] if options[:environment] + if options[:environment] + ENV["RAGE_ENV"] = ENV["RAILS_ENV"] = options[:environment] + elsif ENV["RAGE_ENV"] + ENV["RAILS_ENV"] = ENV["RAGE_ENV"] + elsif ENV["RAILS_ENV"] + ENV["RAGE_ENV"] = ENV["RAILS_ENV"] + else + ENV["RAGE_ENV"] = ENV["RAILS_ENV"] = "development" + end + end + + def linked_rake_tasks + require "rake" + Rake::TaskManager.record_task_metadata = true + load "Rakefile" - # at this point we don't know whether the app is running in standalone or Rails mode; - # we set both variables to make sure applications are running in the same environment; - ENV["RAILS_ENV"] = ENV["RAGE_ENV"] if ENV["RAGE_ENV"] && ENV["RAILS_ENV"] != ENV["RAGE_ENV"] + Rake::Task.tasks.select { |task| !task.comment.nil? && task.name.start_with?("db:") } end end - class NewAppGenerator < Thor::Group + class CLINewAppGenerator < Thor::Group include Thor::Actions argument :path, type: :string + argument :database, type: :string, required: false def self.source_root File.expand_path("templates", __dir__) end + def setup + @use_database = !database.nil? + end + def create_directory empty_directory(path) end def copy_files - Dir.glob("*", base: self.class.source_root).each do |template| + inject_templates + end + + def install_database + return unless @use_database + + @app_name = path.tr("-", "_").downcase + append_to_file "#{path}/Gemfile", <<~RUBY + + gem "#{get_db_gem_name}" + gem "activerecord" + gem "standalone_migrations", require: false + RUBY + + inject_templates("db-templates") + inject_templates("db-templates/#{database}") + end + + private + + def inject_templates(from = nil) + root = "#{self.class.source_root}/#{from}" + + Dir.glob("*", base: root).each do |template| + next if File.directory?("#{root}/#{template}") + *template_path_parts, template_name = template.split("-") - template(template, "#{path}/#{template_path_parts.join("/")}/#{template_name}") + template("#{root}/#{template}", [path, *template_path_parts, template_name].join("/")) + end + end + + def get_db_gem_name + case database + when "mysql" + "mysql2" + when "trilogy" + "trilogy" + when "postgresql" + "pg" + when "sqlite3" + "sqlite3" end end end diff --git a/lib/rage/ext/setup.rb b/lib/rage/ext/setup.rb index 3aa386b3..99b94f8a 100644 --- a/lib/rage/ext/setup.rb +++ b/lib/rage/ext/setup.rb @@ -56,7 +56,44 @@ def connection_cache_key(_) end end +# connect to the database in standalone mode +database_url, database_file = ENV["DATABASE_URL"], Rage.root.join("config/database.yml") +if defined?(ActiveRecord) && !Rage.config.internal.rails_mode && (database_url || database_file.exist?) + # transform database URL to an object + database_url_config = if database_url.nil? + {} + elsif ActiveRecord.version >= Gem::Version.create("6.1.0") + ActiveRecord::Base.configurations + ActiveRecord::DatabaseConfigurations::ConnectionUrlResolver.new(database_url).to_hash + else + ActiveRecord::ConnectionAdapters::ConnectionSpecification::ConnectionUrlResolver.new(database_url).to_hash + end + database_url_config.transform_keys!(&:to_s) + + # load config/database.yml + if database_file.exist? + database_file_config = begin + require "yaml" + require "erb" + YAML.safe_load(ERB.new(database_file.read).result, aliases: true) + end + + # merge database URL config into the file config (only if we have one database) + database_file_config.transform_values! do |env_config| + env_config.all? { |_, v| v.is_a?(Hash) } ? env_config : env_config.merge(database_url_config) + end + end + + ActiveRecord::Base.configurations = database_file_config || { Rage.env.to_s => database_url_config } + ActiveRecord::Base.establish_connection(Rage.env.to_sym) + + unless defined?(Rake) + ActiveRecord::Base.logger = Rage.logger if Rage.logger.debug? + ActiveRecord::Base.connection_pool.with_connection {} # validate the connection + end +end + # patch `ActiveRecord::ConnectionPool` -if defined?(ActiveRecord) && Rage.config.internal.patch_ar_pool? +if defined?(ActiveRecord) && !defined?(Rake) && Rage.config.internal.patch_ar_pool? Rage.patch_active_record_connection_pool end diff --git a/lib/rage/logger/logger.rb b/lib/rage/logger/logger.rb index 338e4139..bc1b853f 100644 --- a/lib/rage/logger/logger.rb +++ b/lib/rage/logger/logger.rb @@ -82,7 +82,7 @@ def initialize(log, level: Logger::DEBUG, formatter: Rage::TextFormatter.new, sh end @formatter = formatter - @level = level + @level = @logdev ? level : Logger::UNKNOWN define_log_methods end diff --git a/lib/rage/setup.rb b/lib/rage/setup.rb index dfb4f7b5..41c06bac 100644 --- a/lib/rage/setup.rb +++ b/lib/rage/setup.rb @@ -9,9 +9,9 @@ # Run application initializers Dir["#{Rage.root}/config/initializers/**/*.rb"].each { |initializer| load(initializer) } +require "rage/ext/setup" + # Load application classes Rage.code_loader.setup require_relative "#{Rage.root}/config/routes" - -require "rage/ext/setup" diff --git a/lib/rage/tasks.rb b/lib/rage/tasks.rb new file mode 100644 index 00000000..a8606d9e --- /dev/null +++ b/lib/rage/tasks.rb @@ -0,0 +1,33 @@ +begin + require "standalone_migrations" +rescue LoadError +end + +class Rage::Tasks + class << self + def init + load_db_tasks if defined?(StandaloneMigrations) + end + + private + + def load_db_tasks + StandaloneMigrations::Configurator.prepend(Module.new do + def configuration_file + @path ||= begin + @__tempfile = Tempfile.new + @__tempfile.write <<~YAML + config: + database: config/database.yml + YAML + @__tempfile.close + + @__tempfile.path + end + end + end) + + StandaloneMigrations::Tasks.load_tasks + end + end +end diff --git a/lib/rage/templates/Rakefile b/lib/rage/templates/Rakefile index 046f1fcb..a2824f60 100644 --- a/lib/rage/templates/Rakefile +++ b/lib/rage/templates/Rakefile @@ -1 +1,2 @@ require_relative "config/application" +Rage.load_tasks diff --git a/lib/rage/templates/config-application.rb b/lib/rage/templates/config-application.rb index c57f7486..df7f0178 100644 --- a/lib/rage/templates/config-application.rb +++ b/lib/rage/templates/config-application.rb @@ -2,6 +2,9 @@ require "rage" Bundler.require(*Rage.groups) +<% if @use_database -%> +require "active_record" +<% end -%> require "rage/all" Rage.configure do diff --git a/lib/rage/templates/db-templates/app-models-application_record.rb b/lib/rage/templates/db-templates/app-models-application_record.rb new file mode 100644 index 00000000..10a4cba8 --- /dev/null +++ b/lib/rage/templates/db-templates/app-models-application_record.rb @@ -0,0 +1,3 @@ +class ApplicationRecord < ActiveRecord::Base + self.abstract_class = true +end diff --git a/lib/rage/templates/db-templates/db-seeds.rb b/lib/rage/templates/db-templates/db-seeds.rb new file mode 100644 index 00000000..4fbd6ed9 --- /dev/null +++ b/lib/rage/templates/db-templates/db-seeds.rb @@ -0,0 +1,9 @@ +# This file should ensure the existence of records required to run the application in every environment (production, +# development, test). The code here should be idempotent so that it can be executed at any point in every environment. +# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). +# +# Example: +# +# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name| +# MovieGenre.find_or_create_by!(name: genre_name) +# end diff --git a/lib/rage/templates/db-templates/mysql/config-database.yml b/lib/rage/templates/db-templates/mysql/config-database.yml new file mode 100644 index 00000000..f9626e5f --- /dev/null +++ b/lib/rage/templates/db-templates/mysql/config-database.yml @@ -0,0 +1,24 @@ +## +# MySQL. Versions 5.5.8 and up are supported. +# +default: &default + adapter: mysql2 + encoding: utf8mb4 + pool: <%%= ENV.fetch("DB_MAX_CONNECTIONS") { 5 } %> + username: root + password: + socket: /tmp/mysql.sock + +development: + <<: *default + database: <%= @app_name %>_development + +test: + <<: *default + database: <%= @app_name %>_test + +production: + <<: *default + database: <%= @app_name %>_production + username: <%= @app_name %> + password: <%%= ENV["<%= @app_name.upcase %>_DATABASE_PASSWORD"] %> diff --git a/lib/rage/templates/db-templates/postgresql/config-database.yml b/lib/rage/templates/db-templates/postgresql/config-database.yml new file mode 100644 index 00000000..ddf69450 --- /dev/null +++ b/lib/rage/templates/db-templates/postgresql/config-database.yml @@ -0,0 +1,21 @@ +## +# PostgreSQL. Versions 9.3 and up are supported. +# +default: &default + adapter: postgresql + encoding: unicode + pool: <%%= ENV.fetch("DB_MAX_CONNECTIONS") { 5 } %> + +development: + <<: *default + database: <%= @app_name %>_development + +test: + <<: *default + database: <%= @app_name %>_test + +production: + <<: *default + database: <%= @app_name %>_production + username: <%= @app_name %> + password: <%%= ENV["<%= @app_name.upcase %>_DATABASE_PASSWORD"] %> diff --git a/lib/rage/templates/db-templates/sqlite3/config-database.yml b/lib/rage/templates/db-templates/sqlite3/config-database.yml new file mode 100644 index 00000000..e52124c4 --- /dev/null +++ b/lib/rage/templates/db-templates/sqlite3/config-database.yml @@ -0,0 +1,19 @@ +## +# SQLite. Versions 3.8.0 and up are supported. +# +default: &default + adapter: sqlite3 + pool: <%%= ENV.fetch("DB_MAX_CONNECTIONS") { 5 } %> + timeout: 5000 + +development: + <<: *default + database: storage/development.sqlite3 + +test: + <<: *default + database: storage/test.sqlite3 + +production: + <<: *default + database: storage/production.sqlite3 diff --git a/lib/rage/templates/db-templates/trilogy/config-database.yml b/lib/rage/templates/db-templates/trilogy/config-database.yml new file mode 100644 index 00000000..d4bba43f --- /dev/null +++ b/lib/rage/templates/db-templates/trilogy/config-database.yml @@ -0,0 +1,24 @@ +## +# MySQL. Versions 5.5.8 and up are supported. +# +default: &default + adapter: trilogy + encoding: utf8mb4 + pool: <%%= ENV.fetch("DB_MAX_THREADS") { 5 } %> + username: root + password: + socket: /tmp/mysql.sock + +development: + <<: *default + database: <%= @app_name %>_development + +test: + <<: *default + database: <%= @app_name %>_test + +production: + <<: *default + database: <%= @app_name %>_production + username: <%= @app_name %> + password: <%%= ENV["<%= @app_name.upcase %>_DATABASE_PASSWORD"] %> diff --git a/lib/rage/templates/lib-tasks-.keep b/lib/rage/templates/lib-tasks-.keep new file mode 100644 index 00000000..e69de29b diff --git a/lib/rage/templates/model-template/model.rb b/lib/rage/templates/model-template/model.rb new file mode 100644 index 00000000..37f6de73 --- /dev/null +++ b/lib/rage/templates/model-template/model.rb @@ -0,0 +1,2 @@ +class <%= @model_name %> < ApplicationRecord +end diff --git a/rage.gemspec b/rage.gemspec index fc44c96d..1b445655 100644 --- a/rage.gemspec +++ b/rage.gemspec @@ -32,4 +32,5 @@ Gem::Specification.new do |spec| spec.add_dependency "rage-iodine", "~> 4.0" spec.add_dependency "zeitwerk", "~> 2.6" spec.add_dependency "rack-test", "~> 2.1" + spec.add_dependency "rake", ">= 12.0" end diff --git a/spec/setup_spec.rb b/spec/setup_spec.rb index 1ec5ad95..2b61c253 100644 --- a/spec/setup_spec.rb +++ b/spec/setup_spec.rb @@ -5,7 +5,7 @@ before do allow(Rage).to receive(:env).and_return(env) - allow(Rage).to receive(:root).and_return(File.expand_path("..", __dir__)) + allow(Rage).to receive(:root).and_return(Pathname.new(File.expand_path("..", __dir__))) allow(Rage).to receive_message_chain(:code_loader, :setup).and_return(true) allow(Iodine).to receive(:patch_rack).and_return(true) end