lotus-model 0.4.1 → 0.5.0

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.
@@ -1,4 +1,5 @@
1
1
  require 'lotus/utils/class'
2
+ require 'lotus/model/mapping/attribute'
2
3
 
3
4
  module Lotus
4
5
  module Model
@@ -227,7 +228,7 @@ module Lotus
227
228
  # Think of Redis, where everything is stored as a string or integer,
228
229
  # the mapper translates values from/to the database.
229
230
  #
230
- # It supports the following types:
231
+ # It supports the following types (coercers):
231
232
  #
232
233
  # * Array
233
234
  # * Boolean
@@ -245,13 +246,17 @@ module Lotus
245
246
  # @param name [Symbol] the name of the attribute, as we want it to be
246
247
  # mapped in the object
247
248
  #
248
- # @param klass [Class] the Ruby type that we want to assign as value
249
+ # @param coercer [.load, .dump] a class that implements coercer interface
249
250
  #
250
251
  # @param options [Hash] a set of options to customize the mapping
251
252
  # @option options [Symbol] :as the name of the original column
252
253
  #
254
+ # @raise [NameError] if coercer cannot be found
255
+ #
253
256
  # @since 0.1.0
254
257
  #
258
+ # @see Lotus::Model::Coercer
259
+ #
255
260
  # @example Default schema
256
261
  # require 'lotus/model'
257
262
  #
@@ -281,8 +286,8 @@ module Lotus
281
286
  # # The first argument (`:name`) always corresponds to the `User`
282
287
  # # attribute.
283
288
  #
284
- # # The second one (`:klass`) is the Ruby type that we want for our
285
- # # attribute.
289
+ # # The second one (`:coercer`) is the Ruby type coercer that we want
290
+ # # for our attribute.
286
291
  #
287
292
  # # We don't need to use `:as` because the database columns match the
288
293
  # # `User` attributes.
@@ -322,7 +327,7 @@ module Lotus
322
327
  # # The first argument (`:name`) always corresponds to the `Article`
323
328
  # # attribute.
324
329
  #
325
- # # The second one (`:klass`) is the Ruby type that we want for our
330
+ # # The second one (`:coercer`) is the Ruby type that we want for our
326
331
  # # attribute.
327
332
  #
328
333
  # # The third option (`:as`) is mandatory only when the database
@@ -330,8 +335,57 @@ module Lotus
330
335
  # #
331
336
  # # For instance: we need to use it for translate `:s_title` to
332
337
  # # `:title`, but not for `:comments_count`.
333
- def attribute(name, klass, options = {})
334
- @attributes[name] = [klass, (options.fetch(:as) { name }).to_sym]
338
+ #
339
+ # @example Custom coercer
340
+ # require 'lotus/model'
341
+ #
342
+ # # Given the following schema:
343
+ # #
344
+ # # CREATE TABLE articles (
345
+ # # id integer NOT NULL,
346
+ # # title varchar(128),
347
+ # # tags text[],
348
+ # # );
349
+ # #
350
+ # # The following entity:
351
+ # #
352
+ # # class Article
353
+ # # include Lotus::Entity
354
+ # # attributes :title, :tags
355
+ # # end
356
+ # #
357
+ # # And the following custom coercer:
358
+ # #
359
+ # # require 'lotus/model/coercer'
360
+ # # require 'sequel/extensions/pg_array'
361
+ # #
362
+ # # class PGArray < Lotus::Model::Coercer
363
+ # # def self.dump(value)
364
+ # # ::Sequel.pg_array(value) rescue nil
365
+ # # end
366
+ # #
367
+ # # def self.load(value)
368
+ # # ::Kernel.Array(value) unless value.nil?
369
+ # # end
370
+ # # end
371
+ #
372
+ # mapper = Lotus::Model::Mapper.new do
373
+ # collection :articles do
374
+ # entity Article
375
+ #
376
+ # attribute :id, Integer
377
+ # attribute :title, String
378
+ # attribute :tags, PGArray
379
+ # end
380
+ # end
381
+ #
382
+ # # When an entity is persisted as record into the database,
383
+ # # `PGArray.dump` is invoked.
384
+ #
385
+ # # When an entity is retrieved from the database, it will be
386
+ # # deserialized as an Array via `PGArray.load`.
387
+ def attribute(name, coercer, options = {})
388
+ @attributes[name] = Attribute.new(name, coercer, options)
335
389
  end
336
390
 
337
391
  # Serializes an entity to be persisted in the database.
@@ -1,5 +1,3 @@
1
- require 'lotus/model/mapping/coercer'
2
-
3
1
  module Lotus
4
2
  module Model
5
3
  module Mapping
@@ -7,7 +5,7 @@ module Lotus
7
5
  #
8
6
  # @api private
9
7
  # @since 0.1.0
10
- class Coercer
8
+ class CollectionCoercer
11
9
  # Initialize a coercer for the given collection.
12
10
  #
13
11
  # @param collection [Lotus::Model::Mapping::Collection] the collection
@@ -47,10 +45,10 @@ module Lotus
47
45
  # @api private
48
46
  # @since 0.1.0
49
47
  def _compile!
50
- code = @collection.attributes.map do |_,(klass,mapped)|
48
+ code = @collection.attributes.map do |_,attr|
51
49
  %{
52
- def deserialize_#{ mapped }(value)
53
- Lotus::Model::Mapping::Coercions.#{klass}(value)
50
+ def deserialize_#{ attr.mapped }(value)
51
+ #{ attr.load_coercer }(value)
54
52
  end
55
53
  }
56
54
  end.join("\n")
@@ -58,17 +56,17 @@ module Lotus
58
56
  instance_eval <<-EVAL, __FILE__, __LINE__
59
57
  def to_record(entity)
60
58
  if entity.id
61
- Hash[#{ @collection.attributes.map{|name,(klass,mapped)| ":#{mapped},Lotus::Model::Mapping::Coercions.#{klass}(entity.#{name})"}.join(',') }]
59
+ Hash[#{ @collection.attributes.map{|name,attr| ":#{ attr.mapped },#{ attr.dump_coercer }(entity.#{name})"}.join(',') }]
62
60
  else
63
61
  Hash[].tap do |record|
64
- #{ @collection.attributes.reject{|name,_| name == @collection.identity }.map{|name,(klass,mapped)| "value = Lotus::Model::Mapping::Coercions.#{klass}(entity.#{name}); record[:#{mapped}] = value unless value.nil?"}.join('; ') }
62
+ #{ @collection.attributes.reject{|name,_| name == @collection.identity }.map{|name,attr| "value = #{ attr.dump_coercer }(entity.#{name}); record[:#{attr.mapped}] = value unless value.nil?"}.join('; ') }
65
63
  end
66
64
  end
67
65
  end
68
66
 
69
67
  def from_record(record)
70
68
  ::#{ @collection.entity }.new(
71
- Hash[#{ @collection.attributes.map{|name,(klass,mapped)| ":#{name},Lotus::Model::Mapping::Coercions.#{klass}(record[:#{mapped}])"}.join(',') }]
69
+ Hash[#{ @collection.attributes.map{|name,attr| ":#{name},#{attr.load_coercer}(record[:#{attr.mapped}])"}.join(',') }]
72
70
  )
73
71
  end
74
72
 
@@ -1,5 +1,6 @@
1
1
  require 'sequel'
2
2
  require 'sequel/extensions/migration'
3
+ require 'lotus/model/migrator/connection'
3
4
  require 'lotus/model/migrator/adapter'
4
5
 
5
6
  module Lotus
@@ -314,7 +315,7 @@ module Lotus
314
315
  # @since 0.4.0
315
316
  # @api private
316
317
  def self.migrations?
317
- migrations.children.any?
318
+ Dir["#{ migrations }/*.rb"].any?
318
319
  end
319
320
  end
320
321
  end
@@ -46,7 +46,7 @@ module Lotus
46
46
  # @since 0.4.0
47
47
  # @api private
48
48
  def initialize(connection)
49
- @connection = connection
49
+ @connection = Connection.new(connection)
50
50
  end
51
51
 
52
52
  # Create database.
@@ -57,7 +57,7 @@ module Lotus
57
57
  #
58
58
  # @see Lotus::Model::Migrator.create
59
59
  def create
60
- raise MigrationError.new("Current adapter (#{ @connection.database_type }) doesn't support create.")
60
+ raise MigrationError.new("Current adapter (#{ connection.database_type }) doesn't support create.")
61
61
  end
62
62
 
63
63
  # Drop database.
@@ -68,7 +68,7 @@ module Lotus
68
68
  #
69
69
  # @see Lotus::Model::Migrator.drop
70
70
  def drop
71
- raise MigrationError.new("Current adapter (#{ @connection.database_type }) doesn't support drop.")
71
+ raise MigrationError.new("Current adapter (#{ connection.database_type }) doesn't support drop.")
72
72
  end
73
73
 
74
74
  # Load database schema.
@@ -79,7 +79,7 @@ module Lotus
79
79
  #
80
80
  # @see Lotus::Model::Migrator.prepare
81
81
  def load
82
- raise MigrationError.new("Current adapter (#{ @connection.database_type }) doesn't support load.")
82
+ raise MigrationError.new("Current adapter (#{ connection.database_type }) doesn't support load.")
83
83
  end
84
84
 
85
85
  # Database version.
@@ -87,25 +87,32 @@ module Lotus
87
87
  # @since 0.4.0
88
88
  # @api private
89
89
  def version
90
- return unless @connection.tables.include?(MIGRATIONS_TABLE)
90
+ return unless connection.adapter_connection.tables.include?(MIGRATIONS_TABLE)
91
91
 
92
- if record = @connection[MIGRATIONS_TABLE].order(MIGRATIONS_TABLE_VERSION_COLUMN).last
92
+ if record = connection.adapter_connection[MIGRATIONS_TABLE].order(MIGRATIONS_TABLE_VERSION_COLUMN).last
93
93
  record.fetch(MIGRATIONS_TABLE_VERSION_COLUMN).scan(/\A[\d]{14}/).first.to_s
94
94
  end
95
95
  end
96
96
 
97
97
  private
98
98
 
99
- # @since 0.4.0
99
+ # @since 0.5.0
100
100
  # @api private
101
- def new_connection
102
- uri = URI.parse(@connection.uri)
103
- scheme, userinfo, host, port = uri.select(:scheme, :userinfo, :host, :port)
101
+ attr_reader :connection
104
102
 
105
- uri = "#{ scheme }://"
106
- uri += "#{ userinfo }@" unless userinfo.nil?
107
- uri += host
108
- uri += ":#{ port }" unless port.nil?
103
+ # Returns a database connection
104
+ #
105
+ # Given a DB connection URI we can connect to a specific database or not, we need this when creating
106
+ # or droping a database. Important to notice that we can't always open a _global_ DB connection,
107
+ # because most of the times application's DB user has no rights to do so.
108
+ #
109
+ # @param global [Boolean] determine whether or not a connection should specify an database.
110
+ #
111
+ # @since 0.5.0
112
+ # @api private
113
+ #
114
+ def new_connection(global: false)
115
+ uri = global ? connection.global_uri : connection.uri
109
116
 
110
117
  Sequel.connect(uri)
111
118
  end
@@ -113,31 +120,31 @@ module Lotus
113
120
  # @since 0.4.0
114
121
  # @api private
115
122
  def database
116
- escape options.fetch(:database)
123
+ escape connection.database
117
124
  end
118
125
 
119
126
  # @since 0.4.0
120
127
  # @api private
121
- def host
122
- escape options.fetch(:host)
128
+ def port
129
+ escape connection.port
123
130
  end
124
131
 
125
132
  # @since 0.4.0
126
133
  # @api private
127
- def port
128
- escape options.fetch(:port)
134
+ def host
135
+ escape connection.host
129
136
  end
130
137
 
131
138
  # @since 0.4.0
132
139
  # @api private
133
140
  def username
134
- escape options.fetch(:user)
141
+ escape connection.user
135
142
  end
136
143
 
137
144
  # @since 0.4.0
138
145
  # @api private
139
146
  def password
140
- escape options.fetch(:password)
147
+ escape connection.password
141
148
  end
142
149
 
143
150
  # @since 0.4.0
@@ -152,12 +159,6 @@ module Lotus
152
159
  escape MIGRATIONS_TABLE
153
160
  end
154
161
 
155
- # @since 0.4.0
156
- # @api private
157
- def options
158
- @connection.opts
159
- end
160
-
161
162
  # @since 0.4.0
162
163
  # @api private
163
164
  def escape(string)
@@ -0,0 +1,133 @@
1
+ module Lotus
2
+ module Model
3
+ module Migrator
4
+ # Sequel connection wrapper
5
+ #
6
+ # Normalize external adapters interfaces
7
+ #
8
+ # @since 0.5.0
9
+ # @api private
10
+ class Connection
11
+ attr_reader :adapter_connection
12
+
13
+ def initialize(adapter_connection)
14
+ @adapter_connection = adapter_connection
15
+ end
16
+
17
+ # Returns DB connection host
18
+ #
19
+ # Even when adapter doesn't provide it explicitly it tries to parse
20
+ #
21
+ # @since 0.5.0
22
+ # @api private
23
+ def host
24
+ @host ||= opts.fetch(:host, parsed_uri.host)
25
+ end
26
+
27
+ # Returns DB connection port
28
+ #
29
+ # Even when adapter doesn't provide it explicitly it tries to parse
30
+ #
31
+ # @since 0.5.0
32
+ # @api private
33
+ def port
34
+ @port ||= opts.fetch(:port, parsed_uri.port)
35
+ end
36
+
37
+ # Returns DB name from conenction
38
+ #
39
+ # Even when adapter doesn't provide it explicitly it tries to parse
40
+ #
41
+ # @since 0.5.0
42
+ # @api private
43
+ def database
44
+ @database ||= opts.fetch(:database, parsed_uri.path[1..-1])
45
+ end
46
+
47
+ # Returns DB type
48
+ #
49
+ # @example
50
+ # connection.database_type
51
+ # # => 'postgres'
52
+ #
53
+ # @since 0.5.0
54
+ # @api private
55
+ def database_type
56
+ adapter_connection.database_type
57
+ end
58
+
59
+ # Returns user from DB connection
60
+ #
61
+ # Even when adapter doesn't provide it explicitly it tries to parse
62
+ #
63
+ # @since 0.5.0
64
+ # @api private
65
+ def user
66
+ @user ||= opts.fetch(:user, parsed_opt('user'))
67
+ end
68
+
69
+ # Returns user from DB connection
70
+ #
71
+ # Even when adapter doesn't provide it explicitly it tries to parse
72
+ #
73
+ # @since 0.5.0
74
+ # @api private
75
+ def password
76
+ @password ||= opts.fetch(:password, parsed_opt('password'))
77
+ end
78
+
79
+ # Returns DB connection URI directly from adapter
80
+ #
81
+ # @since 0.5.0
82
+ # @api private
83
+ def uri
84
+ adapter_connection.uri
85
+ end
86
+
87
+ # Returns DB connection wihout specifying database name
88
+ #
89
+ # @since 0.5.0
90
+ # @api private
91
+ def global_uri
92
+ adapter_connection.uri.sub(parsed_uri.select(:path).first, '')
93
+ end
94
+
95
+ # Returns a boolean telling if a DB connection is from JDBC or not
96
+ #
97
+ # @since 0.5.0
98
+ # @api private
99
+ def jdbc?
100
+ !adapter_connection.uri.scan('jdbc:').empty?
101
+ end
102
+
103
+ # Returns database connection URI instance without JDBC namespace
104
+ #
105
+ # @since 0.5.0
106
+ # @api private
107
+ def parsed_uri
108
+ @uri ||= URI.parse(adapter_connection.uri.sub('jdbc:', ''))
109
+ end
110
+
111
+ private
112
+
113
+ # Returns a value of a given query string param
114
+ #
115
+ # @param option [String] which option from database connection will be extracted from URI
116
+ #
117
+ # @since 0.5.0
118
+ # @api private
119
+ def parsed_opt(option)
120
+ parsed_uri.to_s.match(/[\?|\&]#{ option }=(\w+)\&?/).to_a.last
121
+ end
122
+
123
+ # Fetch connection options from adapter
124
+ #
125
+ # @since 0.5.0
126
+ # @api private
127
+ def opts
128
+ adapter_connection.opts
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -9,13 +9,13 @@ module Lotus
9
9
  # @since 0.4.0
10
10
  # @api private
11
11
  def create
12
- new_connection.run %(CREATE DATABASE #{ database };)
12
+ new_connection(global: true).run %(CREATE DATABASE #{ database };)
13
13
  end
14
14
 
15
15
  # @since 0.4.0
16
16
  # @api private
17
17
  def drop
18
- new_connection.run %(DROP DATABASE #{ database };)
18
+ new_connection(global: true).run %(DROP DATABASE #{ database };)
19
19
  rescue Sequel::DatabaseError => e
20
20
  message = if e.message.match(/doesn\'t exist/)
21
21
  "Cannot find database: #{ database }"