diff --git a/lib/hanami/cli/commands/app.rb b/lib/hanami/cli/commands/app.rb index ea3ad300..2a583a35 100644 --- a/lib/hanami/cli/commands/app.rb +++ b/lib/hanami/cli/commands/app.rb @@ -43,9 +43,9 @@ def self.extended(base) register "generate", aliases: ["g"] do |prefix| prefix.register "action", Generate::Action prefix.register "component", Generate::Component - prefix.register "slice", Generate::Slice prefix.register "operation", Generate::Operation prefix.register "part", Generate::Part + prefix.register "slice", Generate::Slice prefix.register "view", Generate::View end end diff --git a/lib/hanami/cli/commands/gem/new.rb b/lib/hanami/cli/commands/gem/new.rb index bf7b60c5..72bb124c 100644 --- a/lib/hanami/cli/commands/gem/new.rb +++ b/lib/hanami/cli/commands/gem/new.rb @@ -25,6 +25,27 @@ class New < Command SKIP_ASSETS_DEFAULT = false private_constant :SKIP_ASSETS_DEFAULT + # @since 2.2.0 + # @api private + SKIP_DB_DEFAULT = false + private_constant :SKIP_DB_DEFAULT + + # @since 2.2.0 + # @api private + DATABASE_SQLITE = "sqlite" + + # @since 2.2.0 + # @api private + DATABASE_POSTGRES = "postgres" + + # @since 2.2.0 + # @api private + DATABASE_MYSQL = "mysql" + + # @since 2.2.0 + # @api private + SUPPORTED_DATABASES = [DATABASE_SQLITE, DATABASE_POSTGRES, DATABASE_MYSQL].freeze + desc "Generate a new Hanami app" # @since 2.0.0 @@ -47,14 +68,28 @@ class New < Command # @api private option :skip_assets, type: :boolean, required: false, default: SKIP_ASSETS_DEFAULT, - desc: "Skip assets" + desc: "Skip including hanami-assets" + + # @since 2.2.0 + # @api private + option :skip_db, type: :boolean, required: false, + default: SKIP_DB_DEFAULT, + desc: "Skip including hanami-db" + + # @since 2.2.0 + # @api private + option :database, type: :string, required: false, + default: DATABASE_SQLITE, + desc: "Database adapter (supported: sqlite, mysql, postgres)" # rubocop:disable Layout/LineLength example [ - "bookshelf # Generate a new Hanami app in `bookshelf/' directory, using `Bookshelf' namespace", - "bookshelf --head # Generate a new Hanami app, using Hanami HEAD version from GitHub `main' branches", - "bookshelf --skip-install # Generate a new Hanami app, but it skips Hanami installation", - "bookshelf --skip-assets # Generate a new Hanami app without assets" + "bookshelf # Generate a new Hanami app in `bookshelf/' directory, using `Bookshelf' namespace", + "bookshelf --head # Generate a new Hanami app, using Hanami HEAD version from GitHub `main' branches", + "bookshelf --skip-install # Generate a new Hanami app, but it skips Hanami installation", + "bookshelf --skip-assets # Generate a new Hanami app without hanmai-assets", + "bookshelf --skip-db # Generate a new Hanami app without hanami-db", + "bookshelf --database={sqlite|postgres|mysql} # Generate a new Hanami app with a specified database (default: sqlite)", ] # rubocop:enable Layout/LineLength @@ -75,20 +110,36 @@ def initialize( @system_call = system_call end - # rubocop:enable Metrics/ParameterLists - - # rubocop:disable Metrics/AbcSize + # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity # @since 2.0.0 # @api private - def call(app:, head: HEAD_DEFAULT, skip_install: SKIP_INSTALL_DEFAULT, skip_assets: SKIP_ASSETS_DEFAULT, **) + def call( + app:, + head: HEAD_DEFAULT, + skip_install: SKIP_INSTALL_DEFAULT, + skip_assets: SKIP_ASSETS_DEFAULT, + skip_db: SKIP_DB_DEFAULT, + database: nil + ) + # rubocop:enable Metrics/ParameterLists app = inflector.underscore(app) raise PathAlreadyExistsError.new(app) if fs.exist?(app) + raise ConflictingOptionsError.new("--skip-db", "--database") if skip_db && database + + normalized_database ||= normalize_database(database) fs.mkdir(app) fs.chdir(app) do - context = Generators::Context.new(inflector, app, head: head, skip_assets: skip_assets) + context = Generators::Context.new( + inflector, + app, + head: head, + skip_assets: skip_assets, + skip_db: skip_db, + database: normalized_database + ) generator.call(app, context: context) do if skip_install out.puts "Skipping installation, please enter `#{app}' directory and run `bundle exec hanami install'" @@ -112,7 +163,7 @@ def call(app:, head: HEAD_DEFAULT, skip_install: SKIP_INSTALL_DEFAULT, skip_asse end end end - # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/AbcSize, Metrics/PerceivedComplexity private @@ -120,6 +171,19 @@ def call(app:, head: HEAD_DEFAULT, skip_install: SKIP_INSTALL_DEFAULT, skip_asse attr_reader :generator attr_reader :system_call + def normalize_database(database) + case database + when nil, "sqlite", "sqlite3" + DATABASE_SQLITE + when "mysql", "mysql2" + DATABASE_MYSQL + when "postgres", "postgresql", "pg" + DATABASE_POSTGRES + else + raise DatabaseNotSupportedError.new(database, SUPPORTED_DATABASES) + end + end + def run_install_command!(head:) head_flag = head ? " --head" : "" bundler.exec("hanami install#{head_flag}").tap do |result| diff --git a/lib/hanami/cli/errors.rb b/lib/hanami/cli/errors.rb index 235ad771..56888bf8 100644 --- a/lib/hanami/cli/errors.rb +++ b/lib/hanami/cli/errors.rb @@ -91,5 +91,21 @@ def initialize(scheme) super("`#{scheme}' is not a supported db scheme") end end + + # @since 2.2.0 + # @api public + class DatabaseNotSupportedError < Error + def initialize(invalid_database, supported_databases) + super("`#{invalid_database}' is not a supported database. Supported databases are: #{supported_databases.join(', ')}") + end + end + + # @since 2.2.0 + # @api public + class ConflictingOptionsError < Error + def initialize(option1, option2) + super("`#{option1}' and `#{option2}' cannot be used together") + end + end end end diff --git a/lib/hanami/cli/generators/context.rb b/lib/hanami/cli/generators/context.rb index fed0e925..624034e6 100644 --- a/lib/hanami/cli/generators/context.rb +++ b/lib/hanami/cli/generators/context.rb @@ -80,6 +80,44 @@ def generate_assets? !options.fetch(:skip_assets, false) end + # @since 2.2.0 + # @api private + def generate_db? + !options.fetch(:skip_db, false) + end + + # @since 2.2.0 + # @api private + def generate_sqlite? + database_option == Commands::Gem::New::DATABASE_SQLITE + end + + # @since 2.2.0 + # @api private + def generate_postgres? + database_option == Commands::Gem::New::DATABASE_POSTGRES + end + + # @since 2.2.0 + # @api private + def generate_mysql? + database_option == Commands::Gem::New::DATABASE_MYSQL + end + + # @since 2.2.0 + # @api private + def database_url + if generate_sqlite? + "sqlite://db/#{app}.sqlite" + elsif generate_postgres? + "postgres://localhost/#{app}" + elsif generate_mysql? + "mysql://localhost/#{app}" + else + raise "Unknown database option: #{database_option}" + end + end + # @since 2.1.0 # @api private def bundled_views? @@ -108,6 +146,10 @@ def ruby_omit_hash_values? private + def database_option + options.fetch(:database, Commands::Gem::New::DATABASE_SQLITE) + end + # @since 2.0.0 # @api private attr_reader :inflector diff --git a/lib/hanami/cli/generators/gem/app.rb b/lib/hanami/cli/generators/gem/app.rb index 440de26b..1388c1de 100644 --- a/lib/hanami/cli/generators/gem/app.rb +++ b/lib/hanami/cli/generators/gem/app.rb @@ -68,6 +68,15 @@ def generate_app(app, context) # rubocop:disable Metrics/AbcSize fs.write("app/assets/images/favicon.ico", file("favicon.ico")) end + if context.generate_db? + fs.write("app/db/repo.rb", t("repo.erb", context)) + fs.write("app/db/relation.rb", t("relation.erb", context)) + fs.write("app/db/struct.rb", t("struct.erb", context)) + fs.touch("app/structs/.keep") + fs.touch("app/repos/.keep") + fs.touch("config/db/migrate/.keep") + end + fs.write("app/operation.rb", t("operation.erb", context)) fs.write("public/404.html", file("404.html")) diff --git a/lib/hanami/cli/generators/gem/app/env.erb b/lib/hanami/cli/generators/gem/app/env.erb index e69de29b..510ab057 100644 --- a/lib/hanami/cli/generators/gem/app/env.erb +++ b/lib/hanami/cli/generators/gem/app/env.erb @@ -0,0 +1,4 @@ +# This is checked into source control, so put sensitive values into `.env.local` +<%- if generate_db? -%> +DATABASE_URL=<%= database_url %> +<%- end -%> diff --git a/lib/hanami/cli/generators/gem/app/gemfile.erb b/lib/hanami/cli/generators/gem/app/gemfile.erb index 771359f4..1bb81ba7 100644 --- a/lib/hanami/cli/generators/gem/app/gemfile.erb +++ b/lib/hanami/cli/generators/gem/app/gemfile.erb @@ -7,6 +7,9 @@ source "https://rubygems.org" <%= hanami_gem("assets") %> <%- end -%> <%= hanami_gem("controller") %> +<%- if generate_db? -%> +<%= hanami_gem("db") %> +<%- end -%> <%= hanami_gem("router") %> <%= hanami_gem("validations") %> <%= hanami_gem("view") %> @@ -15,6 +18,13 @@ gem "dry-types", "~> 1.0", ">= 1.6.1" gem "dry-operation", github: "dry-rb/dry-operation" gem "puma" gem "rake" +<%- if generate_sqlite? -%> +gem "sqlite3" +<%- elsif generate_postgres? -%> +gem "pg" +<%- elsif generate_mysql? -%> +gem "mysql2" +<%- end -%> group :development do <%= hanami_gem("webconsole") %> diff --git a/lib/hanami/cli/generators/gem/app/gitignore.erb b/lib/hanami/cli/generators/gem/app/gitignore.erb index 0a8c8c8e..59c423c2 100644 --- a/lib/hanami/cli/generators/gem/app/gitignore.erb +++ b/lib/hanami/cli/generators/gem/app/gitignore.erb @@ -1,4 +1,4 @@ -.env +.env*.local log/* <%- if generate_assets? -%> public/ diff --git a/lib/hanami/cli/generators/gem/app/operation.erb b/lib/hanami/cli/generators/gem/app/operation.erb index d0449f2c..0872f507 100644 --- a/lib/hanami/cli/generators/gem/app/operation.erb +++ b/lib/hanami/cli/generators/gem/app/operation.erb @@ -5,5 +5,9 @@ require "dry/operation" module <%= camelized_app_name %> class Operation < Dry::Operation + <%- if generate_db? -%> + # Provide `transaction do ... end` method for database transactions + include Dry::Operation::Extensions::ROM + <%- end -%> end end diff --git a/lib/hanami/cli/generators/gem/app/relation.erb b/lib/hanami/cli/generators/gem/app/relation.erb new file mode 100644 index 00000000..1c97db55 --- /dev/null +++ b/lib/hanami/cli/generators/gem/app/relation.erb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require "hanami/db/relation" + +module <%= camelized_app_name %> + module DB + class Relation < Hanami::DB::Relation + end + end +end diff --git a/lib/hanami/cli/generators/gem/app/repo.erb b/lib/hanami/cli/generators/gem/app/repo.erb new file mode 100644 index 00000000..f491deef --- /dev/null +++ b/lib/hanami/cli/generators/gem/app/repo.erb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require "hanami/db/repo" + +module <%= camelized_app_name %> + module DB + class Repo < Hanami::DB::Repo + end + end +end diff --git a/lib/hanami/cli/generators/gem/app/struct.erb b/lib/hanami/cli/generators/gem/app/struct.erb new file mode 100644 index 00000000..cade488a --- /dev/null +++ b/lib/hanami/cli/generators/gem/app/struct.erb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require "hanami/db/struct" + +module <%= camelized_app_name %> + module DB + class Struct < Hanami::DB::Struct + end + end +end diff --git a/spec/unit/hanami/cli/commands/gem/new_spec.rb b/spec/unit/hanami/cli/commands/gem/new_spec.rb index f6c86fe8..f04b5352 100644 --- a/spec/unit/hanami/cli/commands/gem/new_spec.rb +++ b/spec/unit/hanami/cli/commands/gem/new_spec.rb @@ -11,10 +11,12 @@ let(:inflector) { Dry::Inflector.new } let(:system_call) { instance_double(Hanami::CLI::SystemCall, call: successful_system_call_result) } let(:app) { "bookshelf" } - let(:kwargs) { {head: hanami_head, skip_assets: skip_assets} } + let(:kwargs) { {head: hanami_head, skip_assets: skip_assets, skip_db: skip_db, database: database} } let(:hanami_head) { false } let(:skip_assets) { false } + let(:skip_db) { false } + let(:database) { nil } let(:output) { out.rewind && out.read.chomp } @@ -92,7 +94,7 @@ fs.chdir(app) do # .gitignore gitignore = <<~EXPECTED - .env + .env*.local log/* public/ node_modules/ @@ -102,6 +104,8 @@ # .env env = <<~EXPECTED + # This is checked into source control, so put sensitive values into `.env.local` + DATABASE_URL=sqlite://db/#{app}.sqlite EXPECTED expect(fs.read(".env")).to eq(env) expect(output).to include("Created .env") @@ -123,6 +127,7 @@ gem "hanami", "#{hanami_version}" gem "hanami-assets", "#{hanami_version}" gem "hanami-controller", "#{hanami_version}" + gem "hanami-db", "#{hanami_version}" gem "hanami-router", "#{hanami_version}" gem "hanami-validations", "#{hanami_version}" gem "hanami-view", "#{hanami_version}" @@ -131,6 +136,7 @@ gem "dry-operation", github: "dry-rb/dry-operation" gem "puma" gem "rake" + gem "sqlite3" group :development do gem "hanami-webconsole", "#{hanami_version}" @@ -448,6 +454,8 @@ module Types module #{inflector.camelize(app)} class Operation < Dry::Operation + # Provide `transaction do ... end` method for database transactions + include Dry::Operation::Extensions::ROM end end EXPECTED @@ -490,6 +498,7 @@ class Operation < Dry::Operation gem "hanami", github: "hanami/hanami", branch: "main" gem "hanami-assets", github: "hanami/assets", branch: "main" gem "hanami-controller", github: "hanami/controller", branch: "main" + gem "hanami-db", github: "hanami/db", branch: "main" gem "hanami-router", github: "hanami/router", branch: "main" gem "hanami-validations", github: "hanami/validations", branch: "main" gem "hanami-view", github: "hanami/view", branch: "main" @@ -498,6 +507,7 @@ class Operation < Dry::Operation gem "dry-operation", github: "dry-rb/dry-operation" gem "puma" gem "rake" + gem "sqlite3" group :development do gem "hanami-webconsole", github: "hanami/webconsole", branch: "main" @@ -521,6 +531,475 @@ class Operation < Dry::Operation end end + context "default configuration" do + it "generates an app with hanami-assets and hanami-db" do + expect(bundler).to receive(:install!) + .and_return(true) + + expect(bundler).to receive(:exec) + .with("hanami install") + .and_return(successful_system_call_result) + + expect(bundler).to receive(:exec) + .with("check") + .at_least(1) + .and_return(successful_system_call_result) + + expect(system_call).to receive(:call).with("npm", ["install"]) + + subject.call(app: app, **kwargs) + + expect(fs.directory?(app)).to be(true) + expect(output).to include("Created #{app}/") + expect(output).to include("-> Within #{app}/") + expect(output).to include("Running Bundler install...") + expect(output).to include("Running Hanami install...") + + fs.chdir(app) do + # .gitignore + gitignore = <<~EXPECTED + .env*.local + log/* + public/ + node_modules/ + EXPECTED + expect(fs.read(".gitignore")).to eq(gitignore) + expect(output).to include("Created .gitignore") + + # .env + env = <<~EXPECTED + # This is checked into source control, so put sensitive values into `.env.local` + DATABASE_URL=sqlite://db/#{app}.sqlite + EXPECTED + expect(fs.read(".env")).to eq(env) + expect(output).to include("Created .env") + + # README.md + readme = <<~EXPECTED + # #{inflector.camelize(app)} + EXPECTED + expect(fs.read("README.md")).to eq(readme) + expect(output).to include("Created README.md") + + # Gemfile + hanami_version = Hanami::CLI::Generators::Version.gem_requirement + gemfile = <<~EXPECTED + # frozen_string_literal: true + + source "https://rubygems.org" + + gem "hanami", "#{hanami_version}" + gem "hanami-assets", "#{hanami_version}" + gem "hanami-controller", "#{hanami_version}" + gem "hanami-db", "#{hanami_version}" + gem "hanami-router", "#{hanami_version}" + gem "hanami-validations", "#{hanami_version}" + gem "hanami-view", "#{hanami_version}" + + gem "dry-types", "~> 1.0", ">= 1.6.1" + gem "dry-operation", github: "dry-rb/dry-operation" + gem "puma" + gem "rake" + gem "sqlite3" + + group :development do + gem "hanami-webconsole", "#{hanami_version}" + end + + group :development, :test do + gem "dotenv" + end + + group :cli, :development do + gem "hanami-reloader", "#{hanami_version}" + end + + group :cli, :development, :test do + gem "hanami-rspec", "#{hanami_version}" + end + EXPECTED + expect(fs.read("Gemfile")).to eq(gemfile) + expect(output).to include("Created Gemfile") + + # package.json + hanami_npm_version = Hanami::CLI::Generators::Version.npm_package_requirement + package_json = <<~EXPECTED + { + "name": "#{app}", + "private": true, + "type": "module", + "dependencies": { + "hanami-assets": "#{hanami_npm_version}" + } + } + EXPECTED + expect(fs.read("package.json")).to eq(package_json) + expect(output).to include("Created package.json") + + # Procfile.dev + procfile = <<~EXPECTED + web: bundle exec hanami server + assets: bundle exec hanami assets watch + EXPECTED + expect(fs.read("Procfile.dev")).to eq(procfile) + expect(output).to include("Created Procfile.dev") + + # Rakefile + rakefile = <<~EXPECTED + # frozen_string_literal: true + + require "hanami/rake_tasks" + EXPECTED + expect(fs.read("Rakefile")).to eq(rakefile) + expect(output).to include("Created Rakefile") + + # config.ru + config_ru = <<~EXPECTED + # frozen_string_literal: true + + require "hanami/boot" + + run Hanami.app + EXPECTED + expect(fs.read("config.ru")).to eq(config_ru) + expect(output).to include("Created config.ru") + + # bin/dev + bin_dev = <<~EXPECTED + #!/usr/bin/env sh + + if ! gem list foreman -i --silent; then + echo "Installing foreman..." + gem install foreman + fi + + exec foreman start -f Procfile.dev "$@" + EXPECTED + expect(fs.read("bin/dev")).to eq(bin_dev) + expect(fs.executable?("bin/dev")).to be(true) + expect(output).to include("Created bin/dev") + + # config/app.rb + hanami_app = <<~EXPECTED + # frozen_string_literal: true + + require "hanami" + + module Bookshelf + class App < Hanami::App + end + end + EXPECTED + expect(fs.read("config/app.rb")).to eq(hanami_app) + expect(output).to include("Created config/app.rb") + + # config/assets.js + assets = <<~EXPECTED + import * as assets from "hanami-assets"; + + await assets.run(); + + // To provide additional esbuild (https://esbuild.github.io) options, use the following: + // + // Read more at: https://guides.hanamirb.org/assets/customization/ + // + // await assets.run({ + // esbuildOptionsFn: (args, esbuildOptions) => { + // // Add to esbuildOptions here. Use `args.watch` as a condition for different options for + // // compile vs watch. + // + // return esbuildOptions; + // } + // }); + EXPECTED + expect(fs.read("config/assets.js")).to eq(assets) + expect(output).to include("Created config/assets.js") + + # config/settings.rb + settings = <<~EXPECTED + # frozen_string_literal: true + + module Bookshelf + class Settings < Hanami::Settings + # Define your app settings here, for example: + # + # setting :my_flag, default: false, constructor: Types::Params::Bool + end + end + EXPECTED + expect(fs.read("config/settings.rb")).to eq(settings) + expect(output).to include("Created config/settings.rb") + + # config/routes.rb + routes = <<~EXPECTED + # frozen_string_literal: true + + module Bookshelf + class Routes < Hanami::Routes + # Add your routes here. See https://guides.hanamirb.org/routing/overview/ for details. + end + end + EXPECTED + expect(fs.read("config/routes.rb")).to eq(routes) + expect(output).to include("Created config/routes.rb") + + # config/puma.rb + puma = <<~EXPECTED + # frozen_string_literal: true + + # + # Environment and port + # + port ENV.fetch("HANAMI_PORT", 2300) + environment ENV.fetch("HANAMI_ENV", "development") + + # + # Threads within each Puma/Ruby process (aka worker) + # + + # Configure the minimum and maximum number of threads to use to answer requests. + max_threads_count = ENV.fetch("HANAMI_MAX_THREADS", 5) + min_threads_count = ENV.fetch("HANAMI_MIN_THREADS") { max_threads_count } + + threads min_threads_count, max_threads_count + + # + # Workers (aka Puma/Ruby processes) + # + + puma_concurrency = Integer(ENV.fetch("HANAMI_WEB_CONCURRENCY", 0)) + puma_cluster_mode = puma_concurrency > 1 + + # How many worker (Puma/Ruby) processes to run. + # Typically this is set to the number of available cores. + workers puma_concurrency + + # + # Cluster mode (aka multiple workers) + # + + if puma_cluster_mode + # Preload the application before starting the workers. Only in cluster mode. + preload_app! + + # Code to run immediately before master process forks workers (once on boot). + # + # These hooks can block if necessary to wait for background operations unknown + # to puma to finish before the process terminates. This can be used to close + # any connections to remote servers (database, redis, …) that were opened when + # preloading the code. + before_fork do + Hanami.shutdown + end + end + EXPECTED + expect(fs.read("config/puma.rb")).to eq(puma) + expect(output).to include("Created config/puma.rb") + + # lib/tasks/.keep + tasks_keep = <<~EXPECTED + EXPECTED + expect(fs.read("lib/tasks/.keep")).to eq(tasks_keep) + expect(output).to include("Created lib/tasks/.keep") + + # app/action.rb + action = <<~EXPECTED + # auto_register: false + # frozen_string_literal: true + + require "hanami/action" + require "dry/monads" + + module #{inflector.camelize(app)} + class Action < Hanami::Action + # Provide `Success` and `Failure` for pattern matching on operation results + include Dry::Monads[:result] + end + end + EXPECTED + expect(fs.read("app/action.rb")).to eq(action) + expect(output).to include("Created app/action.rb") + + # app/repo.rb + repo = <<~EXPECTED + # frozen_string_literal: true + + require "hanami/db/repo" + + module #{inflector.camelize(app)} + module DB + class Repo < Hanami::DB::Repo + end + end + end + EXPECTED + expect(fs.read("app/db/repo.rb")).to eq(repo) + expect(output).to include("Created app/db/repo.rb") + + # app/view.rb + view = <<~RUBY + # auto_register: false + # frozen_string_literal: true + + require "hanami/view" + + module #{inflector.camelize(app)} + class View < Hanami::View + end + end + RUBY + expect(fs.read("app/view.rb")).to eq(view) + expect(output).to include("Created app/view.rb") + + # app/views/helpers.rb + helpers = <<~RUBY + # auto_register: false + # frozen_string_literal: true + + module #{inflector.camelize(app)} + module Views + module Helpers + # Add your view helpers here + end + end + end + RUBY + expect(fs.read("app/views/helpers.rb")).to eq(helpers) + expect(output).to include("Created app/views/helpers.rb") + + # app/templates/layouts/app.html.erb + layout = <<~ERB + + +
+ + +