hanami 2.2.0.beta1 → 2.2.0.beta2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,31 +2,37 @@
2
2
 
3
3
  require "dry/configurable"
4
4
  require "dry/core"
5
+ require "uri"
6
+ require_relative "../constants"
5
7
 
6
8
  module Hanami
7
9
  module Providers
8
10
  # @api private
9
11
  # @since 2.2.0
10
- class DB < Dry::System::Provider::Source
12
+ class DB < Hanami::Provider::Source
11
13
  extend Dry::Core::Cache
12
14
 
13
15
  include Dry::Configurable(config_class: Providers::DB::Config)
14
16
 
15
- setting :database_url
16
- setting :adapter, default: :sql
17
17
  setting :adapters, mutable: true, default: Adapters.new
18
- setting :relations_path, default: "relations"
18
+ setting :gateways, default: {}
19
19
 
20
20
  def initialize(...)
21
21
  super(...)
22
22
 
23
- @configured_for_database = false
23
+ @config_finalized = false
24
24
  end
25
25
 
26
26
  def finalize_config
27
- apply_parent_config and return if apply_parent_config?
27
+ return if @config_finalized
28
28
 
29
- configure_for_database
29
+ apply_parent_config if apply_parent_config?
30
+
31
+ configure_gateways
32
+
33
+ @config_finalized = true
34
+
35
+ self
30
36
  end
31
37
 
32
38
  def prepare
@@ -38,59 +44,43 @@ module Hanami
38
44
 
39
45
  require "hanami-db"
40
46
 
41
- unless database_url
42
- raise Hanami::ComponentLoadError, "A database_url is required to start :db."
43
- end
47
+ gateways = prepare_gateways
44
48
 
45
- # Avoid making spurious connections by reusing identically configured gateways across slices
46
- gateway = fetch_or_store(database_url, config.gateway_cache_keys) {
47
- ROM::Gateway.setup(
48
- config.adapter,
49
- database_url,
50
- **config.gateway_options
51
- )
52
- }
49
+ if gateways[:default]
50
+ register "gateway", gateways[:default]
51
+ elsif gateways.length == 1
52
+ register "gateway", gateways.values.first
53
+ end
54
+ gateways.each do |key, gateway|
55
+ register "gateways.#{key}", gateway
56
+ end
53
57
 
54
- @rom_config = ROM::Configuration.new(gateway)
58
+ @rom_config = ROM::Configuration.new(gateways)
55
59
 
56
- config.each_plugin do |plugin_spec, config_block|
60
+ config.each_plugin do |adapter_name, plugin_spec, config_block|
57
61
  if config_block
58
- @rom_config.plugin(config.adapter, plugin_spec) do |plugin_config|
62
+ @rom_config.plugin(adapter_name, plugin_spec) do |plugin_config|
59
63
  instance_exec(plugin_config, &config_block)
60
64
  end
61
65
  else
62
- @rom_config.plugin(config.adapter, plugin_spec)
66
+ @rom_config.plugin(adapter_name, plugin_spec)
63
67
  end
64
68
  end
65
69
 
66
70
  register "config", @rom_config
67
- register "gateway", gateway
68
71
  end
69
72
 
70
- # @api private
71
73
  def start
72
74
  start_and_import_parent_db and return if import_from_parent?
73
75
 
74
76
  # Set up DB logging for the whole app. We share the app's notifications bus across all
75
77
  # slices, so we only need to configure the subsciprtion for DB logging just once.
76
- target.app.start :db_logging
77
-
78
- # Find and register relations
79
- relations_path = target.source_path.join(config.relations_path)
80
- relations_path.glob("*.rb").each do |relation_file|
81
- relation_name = relation_file
82
- .relative_path_from(relations_path)
83
- .basename(relation_file.extname)
84
- .to_s
85
-
86
- relation_class = target.namespace
87
- .const_get(:Relations) # TODO don't hardcode
88
- .const_get(target.inflector.camelize(relation_name))
89
-
90
- @rom_config.register_relation(relation_class)
91
- end
78
+ slice.app.start :db_logging
92
79
 
93
- # TODO: register mappers & commands
80
+ # Register ROM components
81
+ register_rom_components :relation, "relations"
82
+ register_rom_components :command, File.join("db", "commands")
83
+ register_rom_components :mapper, File.join("db", "mappers")
94
84
 
95
85
  rom = ROM.container(@rom_config)
96
86
 
@@ -98,19 +88,14 @@ module Hanami
98
88
  end
99
89
 
100
90
  def stop
101
- target["db.rom"].disconnect
91
+ slice["db.rom"].disconnect
102
92
  end
103
93
 
104
94
  # @api private
105
- def database_url
106
- return @database_url if instance_variable_defined?(:@database_url)
107
-
108
- # For "main" slice, expect MAIN__DATABASE_URL
109
- slice_url_var = "#{target.slice_name.name.gsub("/", "__").upcase}__DATABASE_URL"
110
- chosen_url = config.database_url || ENV[slice_url_var] || ENV["DATABASE_URL"]
111
- chosen_url &&= Hanami::DB::Testing.database_url(chosen_url) if Hanami.env?(:test)
112
-
113
- @database_url = chosen_url
95
+ # @since 2.2.0
96
+ def database_urls
97
+ finalize_config
98
+ config.gateways.transform_values { _1.database_url }
114
99
  end
115
100
 
116
101
  private
@@ -118,7 +103,7 @@ module Hanami
118
103
  def parent_db_provider
119
104
  return @parent_db_provider if instance_variable_defined?(:@parent_db_provider)
120
105
 
121
- @parent_db_provider = target.parent && target.parent.container.providers[:db]
106
+ @parent_db_provider = slice.parent && slice.parent.container.providers[:db]
122
107
  end
123
108
 
124
109
  def apply_parent_config
@@ -128,6 +113,9 @@ module Hanami
128
113
  # Preserve settings already configured locally
129
114
  next if config.configured?(key)
130
115
 
116
+ # Do not copy gateways, these are always configured per slice
117
+ next if key == :gateways
118
+
131
119
  # Skip adapter config, we handle this below
132
120
  next if key == :adapters
133
121
 
@@ -146,36 +134,29 @@ module Hanami
146
134
  end
147
135
 
148
136
  def apply_parent_config?
149
- target.config.db.configure_from_parent && parent_db_provider
150
- end
151
-
152
- def configure_for_database
153
- return if @configured_for_database
154
-
155
- config.adapter(config.adapter_name).configure_for_database(database_url)
156
- @configured_for_database = true
137
+ slice.config.db.configure_from_parent && parent_db_provider
157
138
  end
158
139
 
159
140
  def import_from_parent?
160
- target.config.db.import_from_parent && target.parent
141
+ slice.config.db.import_from_parent && slice.parent
161
142
  end
162
143
 
163
144
  def prepare_and_import_parent_db
164
145
  return unless parent_db_provider
165
146
 
166
- target.parent.prepare :db
167
- @rom_config = target.parent["db.config"]
147
+ slice.parent.prepare :db
148
+ @rom_config = slice.parent["db.config"]
168
149
 
169
- register "config", (@rom_config = target.parent["db.config"])
170
- register "gateway", target.parent["db.gateway"]
150
+ register "config", (@rom_config = slice.parent["db.config"])
151
+ register "gateway", slice.parent["db.gateway"]
171
152
  end
172
153
 
173
154
  def start_and_import_parent_db
174
155
  return unless parent_db_provider
175
156
 
176
- target.parent.start :db
157
+ slice.parent.start :db
177
158
 
178
- register "rom", target.parent["db.rom"]
159
+ register "rom", slice.parent["db.rom"]
179
160
  end
180
161
 
181
162
  # ROM 5.3 doesn't have a configurable inflector.
@@ -191,6 +172,120 @@ module Hanami
191
172
  const_set :Inflector, Hanami.app["inflector"]
192
173
  }
193
174
  end
175
+
176
+ def configure_gateways
177
+ # Create gateway configs for gateways detected from database_url ENV vars
178
+ database_urls_from_env = detect_database_urls_from_env
179
+ database_urls_from_env.keys.each do |key|
180
+ config.gateways[key] ||= Gateway.new
181
+ end
182
+
183
+ # Create a single default gateway if none is configured or detected from database URLs
184
+ config.gateways[:default] = Gateway.new if config.gateways.empty?
185
+
186
+ # Leave gateways in a stable order: :default first, followed by others in sort order
187
+ if config.gateways.length > 1
188
+ gateways = config.gateways
189
+ config.gateways = {default: gateways[:default], **gateways.sort.to_h}.compact
190
+ end
191
+
192
+ config.gateways.each do |key, gw_config|
193
+ gw_config.database_url ||= database_urls_from_env.fetch(key) {
194
+ raise Hanami::ComponentLoadError, "A database_url for gateway #{key} is required to start :db."
195
+ }
196
+
197
+ ensure_database_gem(gw_config.database_url)
198
+
199
+ gw_config.configure_adapter(config.adapters)
200
+ end
201
+ end
202
+
203
+ def prepare_gateways
204
+ config.gateways.transform_values { |gw_config|
205
+ # Avoid spurious connections by reusing identically configured gateways across slices
206
+ gateway = fetch_or_store(gw_config.cache_keys) {
207
+ ROM::Gateway.setup(
208
+ gw_config.adapter_name,
209
+ gw_config.database_url,
210
+ **gw_config.adapter.gateway_options
211
+ )
212
+ }
213
+ }
214
+ end
215
+
216
+ def detect_database_urls_from_env
217
+ database_urls = {}
218
+
219
+ env_var_prefix = slice.slice_name.name.gsub("/", "__").upcase + "__" unless slice.app?
220
+
221
+ # Build gateway URLs from ENV vars with specific gateway named suffixes
222
+ gateway_prefix = "#{env_var_prefix}DATABASE_URL__"
223
+ ENV.select { |(k, _)| k.start_with?(gateway_prefix) }
224
+ .each do |(var, _)|
225
+ gateway_name = var.split(gateway_prefix).last.downcase
226
+
227
+ database_urls[gateway_name.to_sym] = ENV[var]
228
+ end
229
+
230
+ # Set the default gateway from ENV var without suffix
231
+ if !database_urls.key?(:default)
232
+ fallback_vars = ["#{env_var_prefix}DATABASE_URL", "DATABASE_URL"].uniq
233
+
234
+ fallback_vars.each do |var|
235
+ url = ENV[var]
236
+ database_urls[:default] = url and break if url
237
+ end
238
+ end
239
+
240
+ if Hanami.env?(:test)
241
+ database_urls.transform_values! { Hanami::DB::Testing.database_url(_1) }
242
+ end
243
+
244
+ database_urls
245
+ end
246
+
247
+ # @api private
248
+ # @since 2.2.0
249
+ DATABASE_GEMS = {
250
+ "mysql2" => "mysql2",
251
+ "postgres" => "pg",
252
+ "sqlite" => "sqlite3"
253
+ }.freeze
254
+ private_constant :DATABASE_GEMS
255
+
256
+ # Raises an error if the relevant database gem for the configured database_url is not
257
+ # installed.
258
+ #
259
+ # Takes a conservative approach to raising errors. It only does so for the database_url
260
+ # schemes generated by the `hanami new` CLI command. Uknown schemes are ignored and no errors
261
+ # are raised.
262
+ def ensure_database_gem(database_url)
263
+ scheme = URI(database_url).scheme
264
+ return unless scheme
265
+
266
+ database_gem = DATABASE_GEMS[scheme]
267
+ return unless database_gem
268
+
269
+ return if Hanami.bundled?(database_gem)
270
+
271
+ raise Hanami::ComponentLoadError, %(The "#{database_gem}" gem is required to connect to #{database_url}. Please add it to your Gemfile.)
272
+ end
273
+
274
+ def register_rom_components(component_type, path)
275
+ components_path = target.source_path.join(path)
276
+ components_path.glob("**/*.rb").each do |component_file|
277
+ component_name = component_file
278
+ .relative_path_from(components_path)
279
+ .sub(RB_EXT_REGEXP, "")
280
+ .to_s
281
+
282
+ component_class = target.inflector.camelize(
283
+ "#{target.slice_name.name}/#{path}/#{component_name}"
284
+ ).then { target.inflector.constantize(_1) }
285
+
286
+ @rom_config.public_send(:"register_#{component_type}", component_class)
287
+ end
288
+ end
194
289
  end
195
290
 
196
291
  Dry::System.register_provider_source(
@@ -4,18 +4,18 @@ module Hanami
4
4
  module Providers
5
5
  # @api private
6
6
  # @since 2.2.0
7
- class DBLogging < Dry::System::Provider::Source
7
+ class DBLogging < Hanami::Provider::Source
8
8
  # @api private
9
9
  # @since 2.2.0
10
10
  def prepare
11
11
  require "dry/monitor/sql/logger"
12
- target["notifications"].register_event :sql
12
+ slice["notifications"].register_event :sql
13
13
  end
14
14
 
15
15
  # @api private
16
16
  # @since 2.2.0
17
17
  def start
18
- Dry::Monitor::SQL::Logger.new(target["logger"]).subscribe(target["notifications"])
18
+ Dry::Monitor::SQL::Logger.new(slice["logger"]).subscribe(slice["notifications"])
19
19
  end
20
20
  end
21
21
  end
@@ -7,7 +7,7 @@ module Hanami
7
7
  #
8
8
  # @api private
9
9
  # @since 2.0.0
10
- class Inflector < Dry::System::Provider::Source
10
+ class Inflector < Hanami::Provider::Source
11
11
  # @api private
12
12
  def start
13
13
  register :inflector, Hanami.app.inflector
@@ -9,7 +9,7 @@ module Hanami
9
9
  #
10
10
  # @api private
11
11
  # @since 2.0.0
12
- class Logger < Dry::System::Provider::Source
12
+ class Logger < Hanami::Provider::Source
13
13
  # @api private
14
14
  def start
15
15
  register :logger, Hanami.app.config.logger_instance
@@ -12,7 +12,7 @@ module Hanami
12
12
  #
13
13
  # @api private
14
14
  # @since 2.0.0
15
- class Rack < Dry::System::Provider::Source
15
+ class Rack < Hanami::Provider::Source
16
16
  # @api private
17
17
  def prepare
18
18
  Dry::Monitor.load_extensions(:rack)
@@ -30,14 +30,14 @@ module Hanami
30
30
 
31
31
  # @api private
32
32
  def start
33
- target.start :logger
33
+ slice.start :logger
34
34
 
35
35
  monitor_middleware = Dry::Monitor::Rack::Middleware.new(
36
36
  target["notifications"],
37
37
  clock: Dry::Monitor::Clock.new(unit: :microsecond)
38
38
  )
39
39
 
40
- rack_logger = Hanami::Web::RackLogger.new(target[:logger], env: target.container.env)
40
+ rack_logger = Hanami::Web::RackLogger.new(target[:logger], env: slice.container.env)
41
41
  rack_logger.attach(monitor_middleware)
42
42
 
43
43
  register "monitor", monitor_middleware
@@ -4,11 +4,11 @@ module Hanami
4
4
  module Providers
5
5
  # @api private
6
6
  # @since 2.2.0
7
- class Relations < Dry::System::Provider::Source
7
+ class Relations < Hanami::Provider::Source
8
8
  def start
9
- start_and_import_parent_relations and return if target.parent && target.config.db.import_from_parent
9
+ start_and_import_parent_relations and return if slice.parent && slice.config.db.import_from_parent
10
10
 
11
- target.start :db
11
+ slice.start :db
12
12
 
13
13
  register_relations target["db.rom"]
14
14
  end
@@ -22,9 +22,9 @@ module Hanami
22
22
  end
23
23
 
24
24
  def start_and_import_parent_relations
25
- target.parent.start :relations
25
+ slice.parent.start :relations
26
26
 
27
- register_relations target.parent["db.rom"]
27
+ register_relations slice.parent["db.rom"]
28
28
  end
29
29
  end
30
30
  end
@@ -9,7 +9,7 @@ module Hanami
9
9
  #
10
10
  # @api private
11
11
  # @since 2.0.0
12
- class Routes < Dry::System::Provider::Source
12
+ class Routes < Hanami::Provider::Source
13
13
  # @api private
14
14
  def prepare
15
15
  require "hanami/slice/routes_helper"
@@ -21,7 +21,7 @@ module Hanami
21
21
  # router during the process of booting. This ensures the router's resolver can run strict
22
22
  # action key checks once when it runs on a fully booted slice.
23
23
  register :routes do
24
- Hanami::Slice::RoutesHelper.new(target.router)
24
+ Hanami::Slice::RoutesHelper.new(slice.router)
25
25
  end
26
26
  end
27
27
  end
@@ -7,7 +7,7 @@ module Hanami
7
7
  # @api private
8
8
  module Version
9
9
  # @api public
10
- VERSION = "2.2.0.beta1"
10
+ VERSION = "2.2.0.beta2"
11
11
 
12
12
  # @since 0.9.0
13
13
  # @api private
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe "DB / Commands", :app_integration do
4
+ before do
5
+ @env = ENV.to_h
6
+ allow(Hanami::Env).to receive(:loaded?).and_return(false)
7
+ end
8
+
9
+ after do
10
+ ENV.replace(@env)
11
+ end
12
+
13
+ it "registers custom commands" do
14
+ with_tmp_directory(@dir = Dir.mktmpdir) do
15
+ write "config/app.rb", <<~RUBY
16
+ require "hanami"
17
+
18
+ module TestApp
19
+ class App < Hanami::App
20
+ config.logger.stream = File::NULL
21
+ end
22
+ end
23
+ RUBY
24
+
25
+ write "app/relations/posts.rb", <<~RUBY
26
+ module TestApp
27
+ module Relations
28
+ class Posts < Hanami::DB::Relation
29
+ schema :posts, infer: true
30
+ end
31
+ end
32
+ end
33
+ RUBY
34
+
35
+ write "app/db/commands/nested/create_post_with_default_title.rb", <<~RUBY
36
+ module TestApp
37
+ module DB
38
+ module Commands
39
+ module Nested
40
+ class CreatePostWithDefaultTitle < ROM::SQL::Commands::Create
41
+ relation :posts
42
+ register_as :create_with_default_title
43
+ result :one
44
+
45
+ before :set_title
46
+
47
+ def set_title(tuple, *)
48
+ tuple[:title] ||= "Default title from command"
49
+ tuple
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ RUBY
57
+
58
+ ENV["DATABASE_URL"] = "sqlite::memory"
59
+
60
+ require "hanami/prepare"
61
+
62
+ Hanami.app.prepare :db
63
+
64
+ # Manually run a migration and add a test record
65
+ gateway = TestApp::App["db.gateway"]
66
+ migration = gateway.migration do
67
+ change do
68
+ create_table :posts do
69
+ primary_key :id
70
+ column :title, :text, null: false
71
+ end
72
+ end
73
+ end
74
+ migration.apply(gateway, :up)
75
+
76
+ post = TestApp::App["relations.posts"].command(:create_with_default_title).call({})
77
+ expect(post[:title]).to eq "Default title from command"
78
+ end
79
+ end
80
+ end
@@ -84,13 +84,9 @@ RSpec.describe "DB / Slices", :app_integration do
84
84
  end
85
85
  RUBY
86
86
 
87
- write "config/db/.keep", ""
88
-
89
87
  write "config/providers/db.rb", <<~RUBY
90
88
  Hanami.app.configure_provider :db do
91
89
  config.adapter :sql do |a|
92
- # a.skip_defaults
93
- # a.plugin relation: :auto_restrictions
94
90
  a.extension :exclude_or_null
95
91
  end
96
92
  end
@@ -119,6 +115,7 @@ RSpec.describe "DB / Slices", :app_integration do
119
115
  write "slices/main/config/providers/db.rb", <<~RUBY
120
116
  Main::Slice.configure_provider :db do
121
117
  config.adapter :sql do |a|
118
+ a.skip_defaults :extensions
122
119
  a.extensions.clear
123
120
  end
124
121
  end
@@ -136,6 +133,9 @@ RSpec.describe "DB / Slices", :app_integration do
136
133
 
137
134
  ENV["DATABASE_URL"] = "sqlite://" + Pathname(@dir).realpath.join("database.db").to_s
138
135
 
136
+ # Extra gateway for app only. Unlike other config, not copied to child slices.
137
+ ENV["DATABASE_URL__EXTRA"] = "sqlite://" + Pathname(@dir).realpath.join("extra.db").to_s
138
+
139
139
  require "hanami/prepare"
140
140
 
141
141
  Main::Slice.prepare :db
@@ -167,6 +167,11 @@ RSpec.describe "DB / Slices", :app_integration do
167
167
  migration.apply(gateway, :up)
168
168
  gateway.connection.execute("INSERT INTO posts (title) VALUES ('Together breakfast')")
169
169
 
170
+ # Gateways on app are not passed down to child slices
171
+ expect(Hanami.app["db.rom"].gateways.keys).to eq [:default, :extra]
172
+ expect(Main::Slice["db.rom"].gateways.keys).to eq [:default]
173
+ expect(Admin::Slice["db.rom"].gateways.keys).to eq [:default]
174
+
170
175
  # Admin slice has appropriate relations registered, and can access data
171
176
  expect(Admin::Slice["db.rom"].relations[:posts].to_a).to eq [{id: 1, title: "Together breakfast"}]
172
177
  expect(Admin::Slice["relations.posts"]).to be Admin::Slice["db.rom"].relations[:posts]
@@ -39,6 +39,7 @@ RSpec.describe "DB", :app_integration do
39
39
 
40
40
  expect(Hanami.app["db.config"]).to be_an_instance_of ROM::Configuration
41
41
  expect(Hanami.app["db.gateway"]).to be_an_instance_of ROM::SQL::Gateway
42
+ expect(Hanami.app["db.gateways.default"]).to be Hanami.app["db.gateway"]
42
43
 
43
44
  # Manually run a migration and add a test record
44
45
  gateway = Hanami.app["db.gateway"]
@@ -90,6 +91,7 @@ RSpec.describe "DB", :app_integration do
90
91
 
91
92
  expect(Hanami.app["db.config"]).to be_an_instance_of ROM::Configuration
92
93
  expect(Hanami.app["db.gateway"]).to be_an_instance_of ROM::SQL::Gateway
94
+ expect(Hanami.app["db.gateways.default"]).to be Hanami.app["db.gateway"]
93
95
 
94
96
  # Manually run a migration and add a test record
95
97
  gateway = Hanami.app["db.gateway"]
@@ -117,8 +119,6 @@ RSpec.describe "DB", :app_integration do
117
119
 
118
120
  module TestApp
119
121
  class App < Hanami::App
120
- config.inflections do |inflections|
121
- end
122
122
  end
123
123
  end
124
124
  RUBY
@@ -131,6 +131,32 @@ RSpec.describe "DB", :app_integration do
131
131
  end
132
132
  end
133
133
 
134
+ it "raises an error when the database driver gem is missing" do
135
+ allow(Hanami).to receive(:bundled?).and_call_original
136
+ expect(Hanami).to receive(:bundled?).with("pg").and_return false
137
+
138
+ with_tmp_directory(Dir.mktmpdir) do
139
+ write "config/app.rb", <<~RUBY
140
+ require "hanami"
141
+
142
+ module TestApp
143
+ class App < Hanami::App
144
+ end
145
+ end
146
+ RUBY
147
+
148
+ write "app/relations/.keep", ""
149
+
150
+ ENV["DATABASE_URL"] = "postgres://127.0.0.0"
151
+
152
+ require "hanami/prepare"
153
+
154
+ expect { Hanami.app.prepare :db }.to raise_error(Hanami::ComponentLoadError) { |error|
155
+ expect(error.message).to include %(The "pg" gem is required)
156
+ }
157
+ end
158
+ end
159
+
134
160
  it "allows the user to configure the provider" do
135
161
  with_tmp_directory(Dir.mktmpdir) do
136
162
  write "config/app.rb", <<~RUBY
@@ -154,11 +180,10 @@ RSpec.describe "DB", :app_integration do
154
180
 
155
181
  write "config/providers/db.rb", <<~RUBY
156
182
  Hanami.app.configure_provider :db do
157
- configure do |config|
158
- # In this test, we're not setting an ENV["DATABASE_URL"], and instead configuring
159
- # it via the provider source config, to prove that this works
160
-
161
- config.database_url = "sqlite::memory"
183
+ # In this test, we're not setting an ENV["DATABASE_URL"], and instead configuring
184
+ # it via the provider source config, to prove that this works
185
+ config.gateway :default do |gw|
186
+ gw.database_url = "sqlite::memory"
162
187
  end
163
188
  end
164
189
  RUBY