hanami-model 0.0.0 → 0.6.0

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