hanami-model 0.0.0 → 0.6.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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +145 -0
  3. data/EXAMPLE.md +212 -0
  4. data/LICENSE.md +22 -0
  5. data/README.md +600 -7
  6. data/hanami-model.gemspec +17 -12
  7. data/lib/hanami-model.rb +1 -0
  8. data/lib/hanami/entity.rb +298 -0
  9. data/lib/hanami/entity/dirty_tracking.rb +74 -0
  10. data/lib/hanami/model.rb +204 -2
  11. data/lib/hanami/model/adapters/abstract.rb +281 -0
  12. data/lib/hanami/model/adapters/file_system_adapter.rb +288 -0
  13. data/lib/hanami/model/adapters/implementation.rb +111 -0
  14. data/lib/hanami/model/adapters/memory/collection.rb +132 -0
  15. data/lib/hanami/model/adapters/memory/command.rb +113 -0
  16. data/lib/hanami/model/adapters/memory/query.rb +653 -0
  17. data/lib/hanami/model/adapters/memory_adapter.rb +179 -0
  18. data/lib/hanami/model/adapters/null_adapter.rb +24 -0
  19. data/lib/hanami/model/adapters/sql/collection.rb +287 -0
  20. data/lib/hanami/model/adapters/sql/command.rb +73 -0
  21. data/lib/hanami/model/adapters/sql/console.rb +33 -0
  22. data/lib/hanami/model/adapters/sql/consoles/mysql.rb +49 -0
  23. data/lib/hanami/model/adapters/sql/consoles/postgresql.rb +48 -0
  24. data/lib/hanami/model/adapters/sql/consoles/sqlite.rb +26 -0
  25. data/lib/hanami/model/adapters/sql/query.rb +788 -0
  26. data/lib/hanami/model/adapters/sql_adapter.rb +296 -0
  27. data/lib/hanami/model/coercer.rb +74 -0
  28. data/lib/hanami/model/config/adapter.rb +116 -0
  29. data/lib/hanami/model/config/mapper.rb +45 -0
  30. data/lib/hanami/model/configuration.rb +275 -0
  31. data/lib/hanami/model/error.rb +7 -0
  32. data/lib/hanami/model/mapper.rb +124 -0
  33. data/lib/hanami/model/mapping.rb +48 -0
  34. data/lib/hanami/model/mapping/attribute.rb +85 -0
  35. data/lib/hanami/model/mapping/coercers.rb +314 -0
  36. data/lib/hanami/model/mapping/collection.rb +490 -0
  37. data/lib/hanami/model/mapping/collection_coercer.rb +79 -0
  38. data/lib/hanami/model/migrator.rb +324 -0
  39. data/lib/hanami/model/migrator/adapter.rb +170 -0
  40. data/lib/hanami/model/migrator/connection.rb +133 -0
  41. data/lib/hanami/model/migrator/mysql_adapter.rb +72 -0
  42. data/lib/hanami/model/migrator/postgres_adapter.rb +119 -0
  43. data/lib/hanami/model/migrator/sqlite_adapter.rb +110 -0
  44. data/lib/hanami/model/version.rb +4 -1
  45. data/lib/hanami/repository.rb +872 -0
  46. metadata +100 -16
  47. data/.gitignore +0 -9
  48. data/Gemfile +0 -4
  49. data/Rakefile +0 -2
  50. data/bin/console +0 -14
  51. data/bin/setup +0 -8
@@ -0,0 +1,133 @@
1
+ module Hanami
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
@@ -0,0 +1,72 @@
1
+ module Hanami
2
+ module Model
3
+ module Migrator
4
+ # MySQL adapter
5
+ #
6
+ # @since 0.4.0
7
+ # @api private
8
+ class MySQLAdapter < Adapter
9
+ # @since 0.4.0
10
+ # @api private
11
+ def create
12
+ new_connection(global: true).run %(CREATE DATABASE #{ database };)
13
+ rescue Sequel::DatabaseError => e
14
+ message = if e.message.match(/database exists/)
15
+ "Database creation failed. There is 1 other session using the database"
16
+ else
17
+ e.message
18
+ end
19
+
20
+ raise MigrationError.new(message)
21
+ end
22
+
23
+ # @since 0.4.0
24
+ # @api private
25
+ def drop
26
+ new_connection(global: true).run %(DROP DATABASE #{ database };)
27
+ rescue Sequel::DatabaseError => e
28
+ message = if e.message.match(/doesn\'t exist/)
29
+ "Cannot find database: #{ database }"
30
+ else
31
+ e.message
32
+ end
33
+
34
+ raise MigrationError.new(message)
35
+ end
36
+
37
+ # @since 0.4.0
38
+ # @api private
39
+ def dump
40
+ dump_structure
41
+ dump_migrations_data
42
+ end
43
+
44
+ # @since 0.4.0
45
+ # @api private
46
+ def load
47
+ load_structure
48
+ end
49
+
50
+ private
51
+
52
+ # @since 0.4.0
53
+ # @api private
54
+ def dump_structure
55
+ system "mysqldump --user=#{ username } --password=#{ password } --no-data --skip-comments --ignore-table=#{ database }.#{ migrations_table } #{ database } > #{ schema }"
56
+ end
57
+
58
+ # @since 0.4.0
59
+ # @api private
60
+ def load_structure
61
+ system "mysql --user=#{ username } --password=#{ password } #{ database } < #{ escape(schema) }" if schema.exist?
62
+ end
63
+
64
+ # @since 0.4.0
65
+ # @api private
66
+ def dump_migrations_data
67
+ system "mysqldump --user=#{ username } --password=#{ password } --skip-comments #{ database } #{ migrations_table } >> #{ schema }"
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,119 @@
1
+ module Hanami
2
+ module Model
3
+ module Migrator
4
+ # PostgreSQL adapter
5
+ #
6
+ # @since 0.4.0
7
+ # @api private
8
+ class PostgresAdapter < Adapter
9
+ # @since 0.4.0
10
+ # @api private
11
+ HOST = 'PGHOST'.freeze
12
+
13
+ # @since 0.4.0
14
+ # @api private
15
+ PORT = 'PGPORT'.freeze
16
+
17
+ # @since 0.4.0
18
+ # @api private
19
+ USER = 'PGUSER'.freeze
20
+
21
+ # @since 0.4.0
22
+ # @api private
23
+ PASSWORD = 'PGPASSWORD'.freeze
24
+
25
+ # @since 0.4.0
26
+ # @api private
27
+ def create
28
+ set_environment_variables
29
+
30
+ call_db_command('createdb') do |error_message|
31
+ message = if error_message.match(/already exists/)
32
+ "createdb: database creation failed. There is 1 other session using the database."
33
+ else
34
+ error_message
35
+ end
36
+
37
+ raise MigrationError.new(message)
38
+ end
39
+ end
40
+
41
+ # @since 0.4.0
42
+ # @api private
43
+ def drop
44
+ set_environment_variables
45
+
46
+ call_db_command('dropdb') do |error_message|
47
+ message = if error_message.match(/does not exist/)
48
+ "Cannot find database: #{ database }"
49
+ else
50
+ error_message
51
+ end
52
+
53
+ raise MigrationError.new(message)
54
+ end
55
+ end
56
+
57
+ # @since 0.4.0
58
+ # @api private
59
+ def dump
60
+ set_environment_variables
61
+ dump_structure
62
+ dump_migrations_data
63
+ end
64
+
65
+ # @since 0.4.0
66
+ # @api private
67
+ def load
68
+ set_environment_variables
69
+ load_structure
70
+ end
71
+
72
+ private
73
+
74
+ # @since 0.4.0
75
+ # @api private
76
+ def set_environment_variables
77
+ ENV[HOST] = host unless host.nil?
78
+ ENV[PORT] = port.to_s unless port.nil?
79
+ ENV[PASSWORD] = password unless password.nil?
80
+ ENV[USER] = username unless username.nil?
81
+ end
82
+
83
+ # @since 0.4.0
84
+ # @api private
85
+ def dump_structure
86
+ system "pg_dump -i -s -x -O -T #{ migrations_table } -f #{ escape(schema) } #{ database }"
87
+ end
88
+
89
+ # @since 0.4.0
90
+ # @api private
91
+ def load_structure
92
+ system "psql -X -q -f #{ escape(schema) } #{ database }" if schema.exist?
93
+ end
94
+
95
+ # @since 0.4.0
96
+ # @api private
97
+ def dump_migrations_data
98
+ system "pg_dump -t #{ migrations_table } #{ database } >> #{ escape(schema) }"
99
+ end
100
+
101
+ # @since 0.5.1
102
+ # @api private
103
+ def call_db_command(command)
104
+ require 'open3'
105
+
106
+ begin
107
+ Open3.popen3(command, database) do |stdin, stdout, stderr, wait_thr|
108
+ unless wait_thr.value.success? # wait_thr.value is the exit status
109
+ yield stderr.read
110
+ end
111
+ end
112
+ rescue SystemCallError => e
113
+ yield e.message
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,110 @@
1
+ require 'pathname'
2
+
3
+ module Hanami
4
+ module Model
5
+ module Migrator
6
+ # SQLite3 Migrator
7
+ #
8
+ # @since 0.4.0
9
+ # @api private
10
+ class SQLiteAdapter < Adapter
11
+ # No-op for in-memory databases
12
+ #
13
+ # @since 0.4.0
14
+ # @api private
15
+ module Memory
16
+ # @since 0.4.0
17
+ # @api private
18
+ def create
19
+ end
20
+
21
+ # @since 0.4.0
22
+ # @api private
23
+ def drop
24
+ end
25
+ end
26
+
27
+ # Initialize adapter
28
+ #
29
+ # @since 0.4.0
30
+ # @api private
31
+ def initialize(connection)
32
+ super
33
+ extend Memory if memory?
34
+ end
35
+
36
+ # @since 0.4.0
37
+ # @api private
38
+ def create
39
+ path.dirname.mkpath
40
+ FileUtils.touch(path)
41
+ rescue Errno::EACCES, Errno::EPERM
42
+ raise MigrationError.new("Permission denied: #{ path.sub(/\A\/\//, '') }")
43
+ end
44
+
45
+ # @since 0.4.0
46
+ # @api private
47
+ def drop
48
+ path.delete
49
+ rescue Errno::ENOENT
50
+ raise MigrationError.new("Cannot find database: #{ path.sub(/\A\/\//, '') }")
51
+ end
52
+
53
+ # @since 0.4.0
54
+ # @api private
55
+ def dump
56
+ dump_structure
57
+ dump_migrations_data
58
+ end
59
+
60
+ # @since 0.4.0
61
+ # @api private
62
+ def load
63
+ load_structure
64
+ end
65
+
66
+ private
67
+
68
+ # @since 0.4.0
69
+ # @api private
70
+ def path
71
+ root.join(
72
+ @connection.uri.sub(/(jdbc\:|)sqlite\:\/\//, '')
73
+ )
74
+ end
75
+
76
+ # @since 0.4.0
77
+ # @api private
78
+ def root
79
+ Hanami::Model.configuration.root
80
+ end
81
+
82
+ # @since 0.4.0
83
+ # @api private
84
+ def memory?
85
+ uri = path.to_s
86
+ uri.match(/sqlite\:\/\z/) ||
87
+ uri.match(/\:memory\:/)
88
+ end
89
+
90
+ # @since 0.4.0
91
+ # @api private
92
+ def dump_structure
93
+ system "sqlite3 #{ escape(path) } .schema > #{ escape(schema) }"
94
+ end
95
+
96
+ # @since 0.4.0
97
+ # @api private
98
+ def load_structure
99
+ system "sqlite3 #{ escape(path) } < #{ escape(schema) }" if schema.exist?
100
+ end
101
+
102
+ # @since 0.4.0
103
+ # @api private
104
+ def dump_migrations_data
105
+ system %(sqlite3 #{ escape(path) } .dump | grep '^INSERT INTO "#{ migrations_table }"' >> #{ escape(schema) })
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -1,5 +1,8 @@
1
1
  module Hanami
2
2
  module Model
3
- VERSION = "0.0.0"
3
+ # Defines the version
4
+ #
5
+ # @since 0.1.0
6
+ VERSION = '0.6.0'.freeze
4
7
  end
5
8
  end
@@ -0,0 +1,872 @@
1
+ require 'hanami/utils/class_attribute'
2
+ require 'hanami/model/adapters/null_adapter'
3
+
4
+ module Hanami
5
+ # Mediates between the entities and the persistence layer, by offering an API
6
+ # to query and execute commands on a database.
7
+ #
8
+ #
9
+ #
10
+ # By default, a repository is named after an entity, by appending the
11
+ # `Repository` suffix to the entity class name.
12
+ #
13
+ # @example
14
+ # require 'hanami/model'
15
+ #
16
+ # class Article
17
+ # include Hanami::Entity
18
+ # end
19
+ #
20
+ # # valid
21
+ # class ArticleRepository
22
+ # include Hanami::Repository
23
+ # end
24
+ #
25
+ # # not valid for Article
26
+ # class PostRepository
27
+ # include Hanami::Repository
28
+ # end
29
+ #
30
+ # Repository for an entity can be configured by setting # the `#repository`
31
+ # on the mapper.
32
+ #
33
+ # @example
34
+ # # PostRepository is repository for Article
35
+ # mapper = Hanami::Model::Mapper.new do
36
+ # collection :articles do
37
+ # entity Article
38
+ # repository PostRepository
39
+ # end
40
+ # end
41
+ #
42
+ # A repository is storage independent.
43
+ # All the queries and commands are delegated to the current adapter.
44
+ #
45
+ # This architecture has several advantages:
46
+ #
47
+ # * Applications depend on an abstract API, instead of low level details
48
+ # (Dependency Inversion principle)
49
+ #
50
+ # * Applications depend on a stable API, that doesn't change if the
51
+ # storage changes
52
+ #
53
+ # * Developers can postpone storage decisions
54
+ #
55
+ # * Isolates the persistence logic at a low level
56
+ #
57
+ # Hanami::Model is shipped with two adapters:
58
+ #
59
+ # * SqlAdapter
60
+ # * MemoryAdapter
61
+ #
62
+ #
63
+ #
64
+ # All the queries and commands are private.
65
+ # This decision forces developers to define intention revealing API, instead
66
+ # leak storage API details outside of a repository.
67
+ #
68
+ # @example
69
+ # require 'hanami/model'
70
+ #
71
+ # # This is bad for several reasons:
72
+ # #
73
+ # # * The caller has an intimate knowledge of the internal mechanisms
74
+ # # of the Repository.
75
+ # #
76
+ # # * The caller works on several levels of abstraction.
77
+ # #
78
+ # # * It doesn't express a clear intent, it's just a chain of methods.
79
+ # #
80
+ # # * The caller can't be easily tested in isolation.
81
+ # #
82
+ # # * If we change the storage, we are forced to change the code of the
83
+ # # caller(s).
84
+ #
85
+ # ArticleRepository.where(author_id: 23).order(:published_at).limit(8)
86
+ #
87
+ #
88
+ #
89
+ # # This is a huge improvement:
90
+ # #
91
+ # # * The caller doesn't know how the repository fetches the entities.
92
+ # #
93
+ # # * The caller works on a single level of abstraction.
94
+ # # It doesn't even know about records, only works with entities.
95
+ # #
96
+ # # * It expresses a clear intent.
97
+ # #
98
+ # # * The caller can be easily tested in isolation.
99
+ # # It's just a matter of stub this method.
100
+ # #
101
+ # # * If we change the storage, the callers aren't affected.
102
+ #
103
+ # ArticleRepository.most_recent_by_author(author)
104
+ #
105
+ # class ArticleRepository
106
+ # include Hanami::Repository
107
+ #
108
+ # def self.most_recent_by_author(author, limit = 8)
109
+ # query do
110
+ # where(author_id: author.id).
111
+ # order(:published_at)
112
+ # end.limit(limit)
113
+ # end
114
+ # end
115
+ #
116
+ # @since 0.1.0
117
+ #
118
+ # @see Hanami::Entity
119
+ # @see http://martinfowler.com/eaaCatalog/repository.html
120
+ # @see http://en.wikipedia.org/wiki/Dependency_inversion_principle
121
+ module Repository
122
+ # Inject the public API into the hosting class.
123
+ #
124
+ # @since 0.1.0
125
+ #
126
+ # @example
127
+ # require 'hanami/model'
128
+ #
129
+ # class UserRepository
130
+ # include Hanami::Repository
131
+ # end
132
+ def self.included(base)
133
+ base.class_eval do
134
+ extend ClassMethods
135
+ include Hanami::Utils::ClassAttribute
136
+
137
+ class_attribute :collection
138
+ self.adapter = Hanami::Model::Adapters::NullAdapter.new
139
+ end
140
+ end
141
+
142
+ module ClassMethods
143
+ # Assigns an adapter.
144
+ #
145
+ # Hanami::Model is shipped with two adapters:
146
+ #
147
+ # * SqlAdapter
148
+ # * MemoryAdapter
149
+ #
150
+ # @param adapter [Object] an object that implements
151
+ # `Hanami::Model::Adapters::Abstract` interface
152
+ #
153
+ # @since 0.1.0
154
+ #
155
+ # @see Hanami::Model::Adapters::SqlAdapter
156
+ # @see Hanami::Model::Adapters::MemoryAdapter
157
+ #
158
+ # @example Memory adapter
159
+ # require 'hanami/model'
160
+ # require 'hanami/model/adapters/memory_adapter'
161
+ #
162
+ # mapper = Hanami::Model::Mapper.new do
163
+ # # ...
164
+ # end
165
+ #
166
+ # adapter = Hanami::Model::Adapters::MemoryAdapter.new(mapper)
167
+ #
168
+ # class UserRepository
169
+ # include Hanami::Repository
170
+ # end
171
+ #
172
+ # UserRepository.adapter = adapter
173
+ #
174
+ #
175
+ #
176
+ # @example SQL adapter with a Sqlite database
177
+ # require 'sqlite3'
178
+ # require 'hanami/model'
179
+ # require 'hanami/model/adapters/sql_adapter'
180
+ #
181
+ # mapper = Hanami::Model::Mapper.new do
182
+ # # ...
183
+ # end
184
+ #
185
+ # adapter = Hanami::Model::Adapters::SqlAdapter.new(mapper, 'sqlite://path/to/database.db')
186
+ #
187
+ # class UserRepository
188
+ # include Hanami::Repository
189
+ # end
190
+ #
191
+ # UserRepository.adapter = adapter
192
+ #
193
+ #
194
+ #
195
+ # @example SQL adapter with a Postgres database
196
+ # require 'pg'
197
+ # require 'hanami/model'
198
+ # require 'hanami/model/adapters/sql_adapter'
199
+ #
200
+ # mapper = Hanami::Model::Mapper.new do
201
+ # # ...
202
+ # end
203
+ #
204
+ # adapter = Hanami::Model::Adapters::SqlAdapter.new(mapper, 'postgres://host:port/database')
205
+ #
206
+ # class UserRepository
207
+ # include Hanami::Repository
208
+ # end
209
+ #
210
+ # UserRepository.adapter = adapter
211
+ def adapter=(adapter)
212
+ @adapter = adapter
213
+ end
214
+
215
+ # @since 0.5.0
216
+ # @api private
217
+ def adapter
218
+ @adapter
219
+ end
220
+
221
+ # Creates or updates a record in the database for the given entity.
222
+ #
223
+ # @param entity [#id, #id=] the entity to persist
224
+ #
225
+ # @return [Object] a copy of the entity with `id` assigned
226
+ #
227
+ # @since 0.1.0
228
+ #
229
+ # @see Hanami::Repository#create
230
+ # @see Hanami::Repository#update
231
+ #
232
+ # @example With a non persisted entity
233
+ # require 'hanami/model'
234
+ #
235
+ # class ArticleRepository
236
+ # include Hanami::Repository
237
+ # end
238
+ #
239
+ # article = Article.new(title: 'Introducing Hanami::Model')
240
+ # article.id # => nil
241
+ #
242
+ # persisted_article = ArticleRepository.persist(article) # creates a record
243
+ # article.id # => nil
244
+ # persisted_article.id # => 23
245
+ #
246
+ # @example With a persisted entity
247
+ # require 'hanami/model'
248
+ #
249
+ # class ArticleRepository
250
+ # include Hanami::Repository
251
+ # end
252
+ #
253
+ # article = ArticleRepository.find(23)
254
+ # article.id # => 23
255
+ #
256
+ # article.title = 'Launching Hanami::Model'
257
+ # ArticleRepository.persist(article) # updates the record
258
+ #
259
+ # article = ArticleRepository.find(23)
260
+ # article.title # => "Launching Hanami::Model"
261
+ def persist(entity)
262
+ _touch(entity)
263
+ @adapter.persist(collection, entity)
264
+ end
265
+
266
+ # Creates a record in the database for the given entity.
267
+ # It returns a copy of the entity with `id` assigned.
268
+ #
269
+ # If already persisted (`id` present) it does nothing.
270
+ #
271
+ # @param entity [#id,#id=] the entity to create
272
+ #
273
+ # @return [Object] a copy of the entity with `id` assigned
274
+ #
275
+ # @since 0.1.0
276
+ #
277
+ # @see Hanami::Repository#persist
278
+ #
279
+ # @example
280
+ # require 'hanami/model'
281
+ #
282
+ # class ArticleRepository
283
+ # include Hanami::Repository
284
+ # end
285
+ #
286
+ # article = Article.new(title: 'Introducing Hanami::Model')
287
+ # article.id # => nil
288
+ #
289
+ # created_article = ArticleRepository.create(article) # creates a record
290
+ # article.id # => nil
291
+ # created_article.id # => 23
292
+ #
293
+ # created_article = ArticleRepository.create(article)
294
+ # created_article.id # => 24
295
+ #
296
+ # created_article = ArticleRepository.create(existing_article) # => no-op
297
+ # created_article # => nil
298
+ #
299
+ def create(entity)
300
+ unless _persisted?(entity)
301
+ _touch(entity)
302
+ @adapter.create(collection, entity)
303
+ end
304
+ end
305
+
306
+ # Updates a record in the database corresponding to the given entity.
307
+ #
308
+ # If not already persisted (`id` present) it raises an exception.
309
+ #
310
+ # @param entity [#id] the entity to update
311
+ #
312
+ # @return [Object] the entity
313
+ #
314
+ # @raise [Hanami::Model::NonPersistedEntityError] if the given entity
315
+ # wasn't already persisted.
316
+ #
317
+ # @since 0.1.0
318
+ #
319
+ # @see Hanami::Repository#persist
320
+ # @see Hanami::Model::NonPersistedEntityError
321
+ #
322
+ # @example With a persisted entity
323
+ # require 'hanami/model'
324
+ #
325
+ # class ArticleRepository
326
+ # include Hanami::Repository
327
+ # end
328
+ #
329
+ # article = ArticleRepository.find(23)
330
+ # article.id # => 23
331
+ # article.title = 'Launching Hanami::Model'
332
+ #
333
+ # ArticleRepository.update(article) # updates the record
334
+ #
335
+ #
336
+ #
337
+ # @example With a non persisted entity
338
+ # require 'hanami/model'
339
+ #
340
+ # class ArticleRepository
341
+ # include Hanami::Repository
342
+ # end
343
+ #
344
+ # article = Article.new(title: 'Introducing Hanami::Model')
345
+ # article.id # => nil
346
+ #
347
+ # ArticleRepository.update(article) # raises Hanami::Model::NonPersistedEntityError
348
+ def update(entity)
349
+ if _persisted?(entity)
350
+ _touch(entity)
351
+ @adapter.update(collection, entity)
352
+ else
353
+ raise Hanami::Model::NonPersistedEntityError
354
+ end
355
+ end
356
+
357
+ # Deletes a record in the database corresponding to the given entity.
358
+ #
359
+ # If not already persisted (`id` present) it raises an exception.
360
+ #
361
+ # @param entity [#id] the entity to delete
362
+ #
363
+ # @return [Object] the entity
364
+ #
365
+ # @raise [Hanami::Model::NonPersistedEntityError] if the given entity
366
+ # wasn't already persisted.
367
+ #
368
+ # @since 0.1.0
369
+ #
370
+ # @see Hanami::Model::NonPersistedEntityError
371
+ #
372
+ # @example With a persisted entity
373
+ # require 'hanami/model'
374
+ #
375
+ # class ArticleRepository
376
+ # include Hanami::Repository
377
+ # end
378
+ #
379
+ # article = ArticleRepository.find(23)
380
+ # article.id # => 23
381
+ #
382
+ # ArticleRepository.delete(article) # deletes the record
383
+ #
384
+ #
385
+ #
386
+ # @example With a non persisted entity
387
+ # require 'hanami/model'
388
+ #
389
+ # class ArticleRepository
390
+ # include Hanami::Repository
391
+ # end
392
+ #
393
+ # article = Article.new(title: 'Introducing Hanami::Model')
394
+ # article.id # => nil
395
+ #
396
+ # ArticleRepository.delete(article) # raises Hanami::Model::NonPersistedEntityError
397
+ def delete(entity)
398
+ if _persisted?(entity)
399
+ @adapter.delete(collection, entity)
400
+ else
401
+ raise Hanami::Model::NonPersistedEntityError
402
+ end
403
+
404
+ entity
405
+ end
406
+
407
+ # Returns all the persisted entities.
408
+ #
409
+ # @return [Array<Object>] the result of the query
410
+ #
411
+ # @since 0.1.0
412
+ #
413
+ # @example
414
+ # require 'hanami/model'
415
+ #
416
+ # class ArticleRepository
417
+ # include Hanami::Repository
418
+ # end
419
+ #
420
+ # ArticleRepository.all # => [ #<Article:0x007f9b19a60098> ]
421
+ def all
422
+ @adapter.all(collection)
423
+ end
424
+
425
+ # Finds an entity by its identity.
426
+ #
427
+ # If used with a SQL database, it corresponds to the primary key.
428
+ #
429
+ # @param id [Object] the identity of the entity
430
+ #
431
+ # @return [Object,NilClass] the result of the query, if present
432
+ #
433
+ # @since 0.1.0
434
+ #
435
+ # @example
436
+ # require 'hanami/model'
437
+ #
438
+ # class ArticleRepository
439
+ # include Hanami::Repository
440
+ # end
441
+ #
442
+ # ArticleRepository.find(23) # => #<Article:0x007f9b19a60098>
443
+ # ArticleRepository.find(9999) # => nil
444
+ def find(id)
445
+ @adapter.find(collection, id)
446
+ end
447
+
448
+ # Returns the first entity in the database.
449
+ #
450
+ # @return [Object,nil] the result of the query
451
+ #
452
+ # @since 0.1.0
453
+ #
454
+ # @see Hanami::Repository#last
455
+ #
456
+ # @example With at least one persisted entity
457
+ # require 'hanami/model'
458
+ #
459
+ # class ArticleRepository
460
+ # include Hanami::Repository
461
+ # end
462
+ #
463
+ # ArticleRepository.first # => #<Article:0x007f8c71d98a28>
464
+ #
465
+ # @example With an empty collection
466
+ # require 'hanami/model'
467
+ #
468
+ # class ArticleRepository
469
+ # include Hanami::Repository
470
+ # end
471
+ #
472
+ # ArticleRepository.first # => nil
473
+ def first
474
+ @adapter.first(collection)
475
+ end
476
+
477
+ # Returns the last entity in the database.
478
+ #
479
+ # @return [Object,nil] the result of the query
480
+ #
481
+ # @since 0.1.0
482
+ #
483
+ # @see Hanami::Repository#last
484
+ #
485
+ # @example With at least one persisted entity
486
+ # require 'hanami/model'
487
+ #
488
+ # class ArticleRepository
489
+ # include Hanami::Repository
490
+ # end
491
+ #
492
+ # ArticleRepository.last # => #<Article:0x007f8c71d98a28>
493
+ #
494
+ # @example With an empty collection
495
+ # require 'hanami/model'
496
+ #
497
+ # class ArticleRepository
498
+ # include Hanami::Repository
499
+ # end
500
+ #
501
+ # ArticleRepository.last # => nil
502
+ def last
503
+ @adapter.last(collection)
504
+ end
505
+
506
+ # Deletes all the records from the current collection.
507
+ #
508
+ # If used with a SQL database it executes a `DELETE FROM <table>`.
509
+ #
510
+ # @since 0.1.0
511
+ #
512
+ # @example
513
+ # require 'hanami/model'
514
+ #
515
+ # class ArticleRepository
516
+ # include Hanami::Repository
517
+ # end
518
+ #
519
+ # ArticleRepository.clear # deletes all the records
520
+ def clear
521
+ @adapter.clear(collection)
522
+ end
523
+
524
+ # Wraps the given block in a transaction.
525
+ #
526
+ # For performance reasons the block isn't in the signature of the method,
527
+ # but it's yielded at the lower level.
528
+ #
529
+ # Please note that it's only supported by some databases.
530
+ # For this reason, the accepted options may be different from adapter to
531
+ # adapter.
532
+ #
533
+ # For advanced scenarios, please check the documentation of each adapter.
534
+ #
535
+ # @param options [Hash] options for transaction
536
+ #
537
+ # @see Hanami::Model::Adapters::SqlAdapter#transaction
538
+ # @see Hanami::Model::Adapters::MemoryAdapter#transaction
539
+ #
540
+ # @since 0.2.3
541
+ #
542
+ # @example Basic usage with SQL adapter
543
+ # require 'hanami/model'
544
+ #
545
+ # class Article
546
+ # include Hanami::Entity
547
+ # attributes :title, :body
548
+ # end
549
+ #
550
+ # class ArticleRepository
551
+ # include Hanami::Repository
552
+ # end
553
+ #
554
+ # article = Article.new(title: 'Introducing transactions',
555
+ # body: 'lorem ipsum')
556
+ #
557
+ # ArticleRepository.transaction do
558
+ # ArticleRepository.dangerous_operation!(article) # => RuntimeError
559
+ # # !!! ROLLBACK !!!
560
+ # end
561
+ def transaction(options = {})
562
+ @adapter.transaction(options) do
563
+ yield
564
+ end
565
+ end
566
+
567
+ private
568
+
569
+ # Executes the given raw statement on the adapter.
570
+ #
571
+ # Please note that it's only supported by some databases,
572
+ # a `NotImplementedError` will be raised when the adapter does not
573
+ # responds to the `execute` method.
574
+ #
575
+ # For advanced scenarios, please check the documentation of each adapter.
576
+ #
577
+ # @param raw [String] the raw statement to execute on the connection
578
+ #
579
+ # @return [NilClass]
580
+ #
581
+ # @raise [NotImplementedError] if current Hanami::Model adapter doesn't
582
+ # implement `execute`.
583
+ #
584
+ # @raise [Hanami::Model::InvalidCommandError] if the raw statement is invalid
585
+ #
586
+ # @see Hanami::Model::Adapters::Abstract#execute
587
+ # @see Hanami::Model::Adapters::SqlAdapter#execute
588
+ #
589
+ # @since 0.3.1
590
+ #
591
+ # @example Basic usage with SQL adapter
592
+ # require 'hanami/model'
593
+ #
594
+ # class Article
595
+ # include Hanami::Entity
596
+ # attributes :title, :body
597
+ # end
598
+ #
599
+ # class ArticleRepository
600
+ # include Hanami::Repository
601
+ #
602
+ # def self.reset_comments_count
603
+ # execute "UPDATE articles SET comments_count = 0"
604
+ # end
605
+ # end
606
+ #
607
+ # ArticleRepository.reset_comments_count
608
+ def execute(raw)
609
+ @adapter.execute(raw)
610
+ end
611
+
612
+ # Fetch raw result sets for the the given statement.
613
+ #
614
+ # PLEASE NOTE: The returned result set contains an array of hashes.
615
+ # The columns are returned as they are from the database,
616
+ # the mapper is bypassed here.
617
+ #
618
+ # @param raw [String] the raw statement used to fetch records
619
+ # @param blk [Proc] an optional block that is yielded for each record
620
+ #
621
+ # @return [Enumerable<Hash>,Array<Hash>] the collection of raw records
622
+ #
623
+ # @raise [NotImplementedError] if current Hanami::Model adapter doesn't
624
+ # implement `fetch`.
625
+ #
626
+ # @raise [Hanami::Model::InvalidQueryError] if the raw statement is invalid
627
+ #
628
+ # @since 0.5.0
629
+ #
630
+ # @example Basic Usage
631
+ # require 'hanami/model'
632
+ #
633
+ # mapping do
634
+ # collection :articles do
635
+ # attribute :id, Integer, as: :s_id
636
+ # attribute :title, String, as: :s_title
637
+ # end
638
+ # end
639
+ #
640
+ # class Article
641
+ # include Hanami::Entity
642
+ # attributes :title, :body
643
+ # end
644
+ #
645
+ # class ArticleRepository
646
+ # include Hanami::Repository
647
+ #
648
+ # def self.all_raw
649
+ # fetch("SELECT * FROM articles")
650
+ # end
651
+ # end
652
+ #
653
+ # ArticleRepository.all_raw
654
+ # # => [{:_id=>1, :user_id=>nil, :s_title=>"Art 1", :comments_count=>nil, :umapped_column=>nil}]
655
+ #
656
+ # @example Map A Value From Result Set
657
+ # require 'hanami/model'
658
+ #
659
+ # mapping do
660
+ # collection :articles do
661
+ # attribute :id, Integer, as: :s_id
662
+ # attribute :title, String, as: :s_title
663
+ # end
664
+ # end
665
+ #
666
+ # class Article
667
+ # include Hanami::Entity
668
+ # attributes :title, :body
669
+ # end
670
+ #
671
+ # class ArticleRepository
672
+ # include Hanami::Repository
673
+ #
674
+ # def self.titles
675
+ # fetch("SELECT s_title FROM articles").map do |article|
676
+ # article[:s_title]
677
+ # end
678
+ # end
679
+ # end
680
+ #
681
+ # ArticleRepository.titles # => ["Announcing Hanami v0.5.0"]
682
+ #
683
+ # @example Passing A Block
684
+ # require 'hanami/model'
685
+ #
686
+ # mapping do
687
+ # collection :articles do
688
+ # attribute :id, Integer, as: :s_id
689
+ # attribute :title, String, as: :s_title
690
+ # end
691
+ # end
692
+ #
693
+ # class Article
694
+ # include Hanami::Entity
695
+ # attributes :title, :body
696
+ # end
697
+ #
698
+ # class ArticleRepository
699
+ # include Hanami::Repository
700
+ #
701
+ # def self.titles
702
+ # result = []
703
+ #
704
+ # fetch("SELECT s_title FROM articles") do |article|
705
+ # result << article[:s_title]
706
+ # end
707
+ #
708
+ # result
709
+ # end
710
+ # end
711
+ #
712
+ # ArticleRepository.titles # => ["Announcing Hanami v0.5.0"]
713
+ def fetch(raw, &blk)
714
+ @adapter.fetch(raw, &blk)
715
+ end
716
+
717
+ # Fabricates a query and yields the given block to access the low level
718
+ # APIs exposed by the query itself.
719
+ #
720
+ # This is a Ruby private method, because we wanted to prevent outside
721
+ # objects to query directly the database. However, this is a public API
722
+ # method, and this is the only way to filter entities.
723
+ #
724
+ # The returned query SHOULD be lazy: the entities should be fetched by
725
+ # the database only when needed.
726
+ #
727
+ # The returned query SHOULD refer to the entire collection by default.
728
+ #
729
+ # Queries can be reused and combined together. See the example below.
730
+ # IMPORTANT: This feature works only with the Sql adapter.
731
+ #
732
+ # A repository is storage independent.
733
+ # All the queries are delegated to the current adapter, which is
734
+ # responsible to implement a querying API.
735
+ #
736
+ # Hanami::Model is shipped with two adapters:
737
+ #
738
+ # * SqlAdapter, which yields a Hanami::Model::Adapters::Sql::Query
739
+ # * MemoryAdapter, which yields a Hanami::Model::Adapters::Memory::Query
740
+ #
741
+ # @param blk [Proc] a block of code that is executed in the context of a
742
+ # query
743
+ #
744
+ # @return a query, the type depends on the current adapter
745
+ #
746
+ # @api public
747
+ # @since 0.1.0
748
+ #
749
+ # @see Hanami::Model::Adapters::Sql::Query
750
+ # @see Hanami::Model::Adapters::Memory::Query
751
+ #
752
+ # @example
753
+ # require 'hanami/model'
754
+ #
755
+ # class ArticleRepository
756
+ # include Hanami::Repository
757
+ #
758
+ # def self.most_recent_by_author(author, limit = 8)
759
+ # query do
760
+ # where(author_id: author.id).
761
+ # desc(:published_at).
762
+ # limit(limit)
763
+ # end
764
+ # end
765
+ #
766
+ # def self.most_recent_published_by_author(author, limit = 8)
767
+ # # combine .most_recent_published_by_author and .published queries
768
+ # most_recent_by_author(author, limit).published
769
+ # end
770
+ #
771
+ # def self.published
772
+ # query do
773
+ # where(published: true)
774
+ # end
775
+ # end
776
+ #
777
+ # def self.rank
778
+ # # reuse .published, which returns a query that respond to #desc
779
+ # published.desc(:comments_count)
780
+ # end
781
+ #
782
+ # def self.best_article_ever
783
+ # # reuse .published, which returns a query that respond to #limit
784
+ # rank.limit(1)
785
+ # end
786
+ #
787
+ # def self.comments_average
788
+ # query.average(:comments_count)
789
+ # end
790
+ # end
791
+ def query(&blk)
792
+ @adapter.query(collection, self, &blk)
793
+ end
794
+
795
+ # Negates the filtering conditions of a given query with the logical
796
+ # opposite operator.
797
+ #
798
+ # This is only supported by the SqlAdapter.
799
+ #
800
+ # @param query [Object] a query
801
+ #
802
+ # @return a negated query, the type depends on the current adapter
803
+ #
804
+ # @api public
805
+ # @since 0.1.0
806
+ #
807
+ # @see Hanami::Model::Adapters::Sql::Query#negate!
808
+ #
809
+ # @example
810
+ # require 'hanami/model'
811
+ #
812
+ # class ProjectRepository
813
+ # include Hanami::Repository
814
+ #
815
+ # def self.cool
816
+ # query do
817
+ # where(language: 'ruby')
818
+ # end
819
+ # end
820
+ #
821
+ # def self.not_cool
822
+ # exclude cool
823
+ # end
824
+ # end
825
+ def exclude(query)
826
+ query.negate!
827
+ query
828
+ end
829
+
830
+ # This is a method to check entity persited or not
831
+ #
832
+ # @param entity
833
+ # @return a boolean value
834
+ # @since 0.3.1
835
+ def _persisted?(entity)
836
+ !!entity.id
837
+ end
838
+
839
+ # Update timestamps
840
+ #
841
+ # @param entity [Object, Hanami::Entity] the entity
842
+ #
843
+ # @api private
844
+ # @since 0.3.1
845
+ def _touch(entity)
846
+ now = Time.now.utc
847
+
848
+ if _has_timestamp?(entity, :created_at)
849
+ entity.created_at ||= now
850
+ end
851
+
852
+ if _has_timestamp?(entity, :updated_at)
853
+ entity.updated_at = now
854
+ end
855
+ end
856
+
857
+ # Check if the given entity has the given timestamp
858
+ #
859
+ # @param entity [Object, Hanami::Entity] the entity
860
+ # @param timestamp [Symbol] the timestamp name
861
+ #
862
+ # @return [TrueClass,FalseClass]
863
+ #
864
+ # @api private
865
+ # @since 0.3.1
866
+ def _has_timestamp?(entity, timestamp)
867
+ entity.respond_to?(timestamp) &&
868
+ entity.respond_to?("#{ timestamp }=")
869
+ end
870
+ end
871
+ end
872
+ end