hanami 2.2.0.beta1 → 2.2.0.beta2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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