diff --git a/.hound.yml b/.hound.yml
new file mode 100644
index 00000000..cdf10161
--- /dev/null
+++ b/.hound.yml
@@ -0,0 +1,4 @@
+fail_on_violations: true
+
+ruby:
+ config_file: .rubocop.yml
diff --git a/.rubocop.yml b/.rubocop.yml
new file mode 100644
index 00000000..a46eb5e9
--- /dev/null
+++ b/.rubocop.yml
@@ -0,0 +1,27 @@
+AllCops:
+ DisplayCopNames: true
+ DisplayStyleGuide: true
+ ExtraDetails: false
+Style/PredicateName:
+ Enabled: false
+Lint/HandleExceptions:
+ Enabled: false
+Lint/IneffectiveAccessModifier:
+ Enabled: false
+Lint/AssignmentInCondition:
+ Enabled: false
+Style/RaiseArgs:
+ Enabled: false
+Style/MethodMissing:
+ Enabled: false
+Style/RegexpLiteral:
+ Enabled: false
+Style/FileName:
+ Enabled: false
+Metrics/LineLength:
+ Enabled: false
+Style/GuardClause:
+ Enabled: false
+Metrics/BlockLength:
+ Exclude:
+ - "test/**/*.rb" # Minitest syntax generates this false positive
diff --git a/.travis.yml b/.travis.yml
index 5a3f3821..611bfe89 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,13 +1,17 @@
language: ruby
sudo: false
cache: bundler
-script: 'bundle exec rake test:coverage --trace'
+script: 'bundle exec rubocop && bundle exec rake test:coverage --trace'
+after_script: 'echo `env`'
rvm:
- - 2.2.5
- 2.3.1
- - jruby-9.0.5.0
+ - jruby-9.1.5.0
- ruby-head
- jruby-head
+env:
+ - DB=sqlite
+ - DB=postgresql
+ - DB=mysql
addons:
postgresql: '9.4'
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 97edbdec..61c2d885 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,8 +2,39 @@
A persistence layer for Hanami
## v0.7.0 - (unreleased)
+### Added
+- [Luca Guidi] `Hanami::Entity` defines an automatic schema for SQL databases
+– [Luca Guidi] `Hanami::Entity` attributes schema
+- [Luca Guidi] Experimental support for One-To-Many association (aka `has_many`)
+- [Luca Guidi] Native support for PostgreSQL types like UUID, Array, JSON(B) and Money
+- [Luca Guidi] Repositories instances can access all the relations (eg. `BookRepository` can access `users` relation via `#users`)
+- [Luca Guidi] Automapping for SQL databases
+- [Luca Guidi] Added `Hanami::Model::DatabaseError`
+
### Changed
-– [Luca Guidi] Drop support for Ruby 2.0 and 2.1
+- [Luca Guidi] Entities are immutable
+- [Luca Guidi] Removed support for Memory and File System adapters
+- [Luca Guidi] Removed support for _dirty tracking_
+- [Luca Guidi] `Hanami::Entity.attributes` method no longer accepts a list of attributes, but a block to optionally define typed attributes
+- [Luca Guidi] Removed `#fetch`, `#execute` and `#transaction` from repository
+- [Luca Guidi] Removed `mapping` block from `Hanami::Model.configure`
+- [Luca Guidi] Changed `adapter` signature in `Hanami::Model.configure` (use `adapter :sql, ENV['DATABASE_URL']`)
+- [Luca Guidi] Repositories must inherit from `Hanami::Repository` instead of including it
+- [Luca Guidi] Entities must inherit from `Hanami::Entity` instead of including it
+- [Pascal Betz] Repositories use instance level interface (eg. `BookRepository.new.find` instead of `BookRepository.find`)
+- [Luca Guidi] Repositories now work can accept hash for CRUD operations
+- [Luca Guidi] `Hanami::Repository#create` now accepts: and data (or entity)
+- [Luca Guidi] `Hanami::Repository#update` now accepts two arguments: primary key (`id`) and data (or entity)
+- [Luca Guidi] `Hanami::Repository#delete` now accepts: primary key (`id`)
+- [Luca Guidi] Drop `Hanami::Model::NonPersistedEntityError`, `Hanami::Model::InvalidMappingError`, `Hanami::Model::InvalidCommandError`, `Hanami::Model::InvalidQueryError`
+- [Luca Guidi] Official support for Ruby 2.3 and JRuby 9.0.5.0
+- [Luca Guidi] Drop support for Ruby 2.0, 2.1, 2.2, and JRuby 9.0.0.0
+- [Luca Guidi] Drop support for `mysql` gem in favor of `mysql2`
+
+### Fixed
+- [Luca Guidi] Ensure booleans to be correctly dumped in database
+- [Luca Guidi] Ensure to respect default database schema values
+- [Luca Guidi] Ensure SQL UPDATE to not override non-default primary key
## v0.6.2 - 2016-06-01
### Changed
diff --git a/EXAMPLE.md b/EXAMPLE.md
deleted file mode 100644
index d10b801e..00000000
--- a/EXAMPLE.md
+++ /dev/null
@@ -1,213 +0,0 @@
-# Hanami::Model
-
-This is a guide that helps you to get started with [**Hanami::Model**](https://github.com/hanami/model).
-You can find the full code source [here](https://gist.github.com/jodosha/11211048).
-
-## Gems
-
-First of all, we need to setup a `Gemfile`.
-
-```ruby
-source 'https://rubygems.org'
-
-gem 'sqlite3'
-gem 'hanami-model'
-```
-
-Then we can fetch the dependencies with `bundle install`.
-
-## Setup
-
-
-
-**Hanami::Model** doesn't have migrations.
-For this example we will use [Sequel](http://sequel.jeremyevans.net).
-We create the database first.
-Then we create two tables: `authors` and `articles`.
-
-```ruby
-require 'bundler/setup'
-require 'sqlite3'
-require 'hanami/model'
-require 'hanami/model/adapters/sql_adapter'
-
-connection_uri = "sqlite://#{ __dir__ }/test.db"
-
-database = Sequel.connect(connection_uri)
-
-database.create_table! :authors do
- primary_key :id
- String :name
-end
-
-database.create_table! :articles do
- primary_key :id
- Integer :author_id, null: false
- String :title
- Integer :comments_count, default: 0
- Boolean :published, default: false
-end
-```
-
-## Entities
-
-We have two entities in our application: `Author` and `Article`.
-`Author` is a `Struct`, Hanami::Model can persist it.
-`Article` has a small API concerning its publishing process.
-
-```ruby
-Author = Struct.new(:id, :name) do
- def initialize(attributes = {})
- self.id = attributes[:id]
- self.name = attributes[:name]
- end
-end
-
-class Article
- include Hanami::Entity
- attributes :author_id, :title, :comments_count, :published # id is implicit
-
- def published?
- !!published
- end
-
- def publish!
- @published = true
- end
-end
-```
-
-## Repositories
-
-In order to persist and query the entities above, we define two corresponding repositories:
-
-```ruby
-class AuthorRepository
- include Hanami::Repository
-end
-
-class ArticleRepository
- include Hanami::Repository
-
- def self.most_recent_by_author(author, limit = 8)
- query do
- where(author_id: author.id).
- desc(:id).
- limit(limit)
- end
- end
-
- def self.most_recent_published_by_author(author, limit = 8)
- most_recent_by_author(author, limit).published
- end
-
- def self.published
- query do
- where(published: true)
- end
- end
-
- def self.drafts
- exclude published
- end
-
- def self.rank
- published.desc(:comments_count)
- end
-
- def self.best_article_ever
- rank.limit(1).first
- end
-
- def self.comments_average
- query.average(:comments_count)
- end
-end
-```
-
-## Loading
-
-```ruby
-Hanami::Model.configure do
- adapter type: :sql, uri: connection_uri
-
- mapping do
- collection :authors do
- entity Author
- repository AuthorRepository
-
- attribute :id, Integer
- attribute :name, String
- end
-
- collection :articles do
- entity Article
- repository ArticleRepository
-
- attribute :id, Integer
- attribute :author_id, Integer
- attribute :title, String
- attribute :comments_count, Integer
- attribute :published, Boolean
- end
- end
-end.load!
-```
-
-## Persist
-
-We instantiate and persist an `Author` and a few `Articles` for our example:
-
-```ruby
-author = Author.new(name: 'Luca')
-author = AuthorRepository.new.create(author)
-
-articles = [
- Article.new(title: 'Announcing Hanami', author_id: author.id, comments_count: 123, published: true),
- Article.new(title: 'Introducing Hanami::Router', author_id: author.id, comments_count: 63, published: true),
- Article.new(title: 'Introducing Hanami::Controller', author_id: author.id, comments_count: 82, published: true),
- Article.new(title: 'Introducing Hanami::Model', author_id: author.id)
-]
-
-articles.each do |article|
- ArticleRepository.new.create(article)
-end
-```
-
-## Query
-
-We use the repositories to query the database and return the entities we're looking for:
-
-```ruby
-ArticleRepository.new.first # => return the first article
-ArticleRepository.new.last # => return the last article
-
-ArticleRepository.published # => return all the published articles
-ArticleRepository.drafts # => return all the drafts
-
-ArticleRepository.rank # => all the published articles, sorted by popularity
-
-ArticleRepository.best_article_ever # => the most commented article
-
-ArticleRepository.comments_average # => calculates the average of comments across all the published articles.
-
-ArticleRepository.most_recent_by_author(author) # => most recent articles by an author (drafts and published).
-ArticleRepository.most_recent_published_by_author(author) # => most recent published articles by an author
-```
-
-## Business Logic
-
-As we've seen above, `Article` implements an API for publishing.
-We use that logic to alter the state of an article (from draft to published).
-We then use the repository to persist this new state.
-
-```ruby
-article = ArticleRepository.drafts.first
-
-article.published? # => false
-article.publish!
-
-article.published? # => true
-
-ArticleRepository.new.update(article)
-```
diff --git a/Gemfile b/Gemfile
index 08aba871..e0e584a9 100644
--- a/Gemfile
+++ b/Gemfile
@@ -6,14 +6,12 @@ unless ENV['TRAVIS']
gem 'yard', require: false
end
-gem 'hanami-utils', '~> 0.8', require: false, github: 'hanami/utils', branch: '0.8.x'
-gem 'hanami-validations', '~> 0.6', require: false, github: 'hanami/validations', branch: '0.6.x'
+gem 'hanami-utils', '~> 0.8', require: false, github: 'hanami/utils', branch: 'master'
platforms :ruby do
gem 'sqlite3', require: false
- gem 'pg'
- gem 'mysql2'
- gem 'mysql'
+ gem 'pg', require: false
+ gem 'mysql2', require: false
end
platforms :jruby do
@@ -22,5 +20,6 @@ platforms :jruby do
gem 'jdbc-mysql', require: false
end
-gem 'simplecov', require: false
-gem 'coveralls', require: false
+gem 'simplecov', require: false
+gem 'coveralls', require: false
+gem 'rubocop', '~> 0.43', require: false
diff --git a/README.md b/README.md
index 0ee4e91f..47cf0518 100644
--- a/README.md
+++ b/README.md
@@ -7,11 +7,8 @@ The architecture eases keeping the business logic (entities) separated from deta
It implements the following concepts:
- * [Entity](#entities) - An object defined by its identity.
+ * [Entity](#entities) - A model domain object defined by its identity.
* [Repository](#repositories) - An object that mediates between the entities and the persistence layer.
- * [Data Mapper](#data-mapper) - A persistence mapper that keep entities independent from database details.
- * [Adapter](#adapter) – A database adapter.
- * [Query](#query) - An object that represents a database query.
Like all the other Hanami components, it can be used as a standalone framework or within a full Hanami application.
@@ -35,7 +32,7 @@ Like all the other Hanami components, it can be used as a standalone framework o
## Rubies
-__Hanami::Model__ supports Ruby (MRI) 2.2+ and JRuby 9000+
+__Hanami::Model__ supports Ruby (MRI) 2.3+ and JRuby 9.1.5.0+
## Installation
@@ -55,51 +52,40 @@ Or install it yourself as:
## Usage
-This class provides a DSL to configure adapter, mapping and collection.
+This class provides a DSL to configure the connection.
```ruby
require 'hanami/model'
-class User
- include Hanami::Entity
- attributes :name, :age
+class User < Hanami::Entity
end
-class UserRepository
- include Hanami::Repository
+class UserRepository < Hanami::Repository
end
Hanami::Model.configure do
- adapter type: :sql, uri: 'postgres://localhost/database'
+ adapter :sql, 'postgres://localhost/database'
+end.load!
- mapping do
- collection :users do
- entity User
- repository UserRepository
-
- attribute :id, Integer
- attribute :name, String
- attribute :age, Integer
- end
- end
-end
+repository = UserRepository.new
+user = repository.create(name: 'Luca')
-Hanami::Model.load!
+puts user.id # => 1
-user = User.new(name: 'Luca', age: 32)
-user = UserRepository.new.create(user)
+found = repository.find(user.id)
+found == user # => true
-puts user.id # => 1
+updated = repository.update(user.id, age: 34)
+updated.age # => 34
-u = UserRepository.new.find(user.id)
-u == user # => true
+repository.delete(user.id)
```
## Concepts
### Entities
-An object that is defined by its identity.
+A model domain object that is defined by its identity.
See "Domain Driven Design" by Eric Evans.
An entity is the core of an application, where the part of the domain logic is implemented.
@@ -115,59 +101,10 @@ message passing if you will, which is the quintessence of Object Oriented Progra
```ruby
require 'hanami/model'
-class Person
- include Hanami::Entity
- attributes :name, :age
+class Person < Hanami::Entity
end
```
-When a class includes `Hanami::Entity` it receives the following interface:
-
- * `#id`
- * `#id=`
- * `#initialize(attributes = {})`
-
-`Hanami::Entity` also provides the `.attributes` for defining attribute accessors for the given names.
-
-If we expand the code above in **pure Ruby**, it would be:
-
-```ruby
-class Person
- attr_accessor :id, :name, :age
-
- def initialize(attributes = {})
- @id, @name, @age = attributes.values_at(:id, :name, :age)
- end
-end
-```
-
-**Hanami::Model** ships `Hanami::Entity` for developers's convenience.
-
-**Hanami::Model** depends on a narrow and well-defined interface for an Entity - `#id`, `#id=`, `#initialize(attributes={})`.
-If your object implements that interface then that object can be used as an Entity in the **Hanami::Model** framework.
-
-However, we suggest to implement this interface by including `Hanami::Entity`, in case that future versions of the framework will expand it.
-
-See [Dependency Inversion Principle](http://en.wikipedia.org/wiki/Dependency_inversion_principle) for more on interfaces.
-
-When a class extends a `Hanami::Entity` class, it will also *inherit* its mother's attributes.
-
-```ruby
-require 'hanami/model'
-
-class Article
- include Hanami::Entity
- attributes :name
-end
-
-class RareArticle < Article
- attributes :price
-end
-```
-
-That is, `RareArticle`'s attributes carry over `:name` attribute from `Article`,
-thus is `:id, :name, :price`.
-
### Repositories
An object that mediates between entities and the persistence layer.
@@ -192,18 +129,16 @@ This architecture has several advantages:
When a class includes `Hanami::Repository`, it will receive the following interface:
- * `.persist(entity)` – Create or update an entity
- * `.create(entity)` – Create a record for the given entity
- * `.update(entity)` – Update the record corresponding to the given entity
- * `.delete(entity)` – Delete the record corresponding to the given entity
- * `.all` - Fetch all the entities from the collection
- * `.find` - Fetch an entity from the collection by its ID
- * `.first` - Fetch the first entity from the collection
- * `.last` - Fetch the last entity from the collection
- * `.clear` - Delete all the records from the collection
- * `.query` - Fabricates a query object
-
-**A collection is a homogenous set of records.**
+ * `#create(data)` – Create a record for the given data (or entity)
+ * `#update(id, data)` – Update the record corresponding to the given id by setting the given data (or entity)
+ * `#delete(id)` – Delete the record corresponding to the given id
+ * `#all` - Fetch all the entities from the relation
+ * `#find` - Fetch an entity from the relation by primary key
+ * `#first` - Fetch the first entity from the relation
+ * `#last` - Fetch the last entity from the relation
+ * `#clear` - Delete all the records from the relation
+
+**A relation is a homogenous set of records.**
It corresponds to a table for a SQL database or to a MongoDB collection.
**All the queries are private**.
@@ -212,7 +147,7 @@ This decision forces developers to define intention revealing API, instead of le
Look at the following code:
```ruby
-ArticleRepository.where(author_id: 23).order(:published_at).limit(8)
+ArticleRepository.new.where(author_id: 23).order(:published_at).limit(8)
```
This is **bad** for a variety of reasons:
@@ -232,14 +167,11 @@ There is a better way:
```ruby
require 'hanami/model'
-class ArticleRepository
- include Hanami::Repository
-
- def self.most_recent_by_author(author, limit = 8)
- query do
- where(author_id: author.id).
- order(:published_at)
- end.limit(limit)
+class ArticleRepository < Hanami::Repository
+ def most_recent_by_author(author, limit: 8)
+ articles.where(author_id: author.id).
+ order(:published_at).
+ limit(limit)
end
end
```
@@ -256,253 +188,31 @@ This is a **huge improvement**, because:
* If we change the storage, the callers aren't affected.
-Here is an extended example of a repository that uses the SQL adapter.
-
-```ruby
-class ArticleRepository
- include Hanami::Repository
-
- def self.most_recent_by_author(author, limit = 8)
- query do
- where(author_id: author.id).
- desc(:id).
- limit(limit)
- end
- end
-
- def self.most_recent_published_by_author(author, limit = 8)
- most_recent_by_author(author, limit).published
- end
-
- def self.published
- query do
- where(published: true)
- end
- end
-
- def self.drafts
- exclude published
- end
-
- def self.rank
- published.desc(:comments_count)
- end
+### Mapping
- def self.best_article_ever
- rank.limit(1)
- end
+Hanami::Model can **_automap_** columns from relations and entities attributes.
- def self.comments_average
- query.average(:comments_count)
- end
-end
-```
-
-You can also extract the common logic from your repository into a module to reuse it in other repositories. Here is a pagination example:
-
-```ruby
-module RepositoryHelpers
- module Pagination
- def paginate(limit: 10, offset: 0)
- query do
- limit(limit).offset(offset)
- end
- end
- end
-end
-
-class ArticleRepository
- include Hanami::Repository
- extend RepositoryHelpers::Pagination
-
- def self.published
- query do
- where(published: true)
- end
- end
-
- # other repository-specific methods here
-end
-```
-
-That will allow `.paginate` usage on `ArticleRepository`, for example:
-`ArticleRepository.published.paginate(15, 0)`
-
-**Your models and repositories have to be in the same namespace.** Otherwise `Hanami::Model::Mapper#load!`
-will not initialize your repositories correctly.
-
-```ruby
-class MyHanamiApp::Model::User
- include Hanami::Entity
- # your code here
-end
-
-# This repository will work...
-class MyHanamiApp::Model::UserRepository
- include Hanami::Repository
- # your code here
-end
-
-# ...this will not!
-class MyHanamiApp::Repository::UserRepository
- include Hanami::Repository
- # your code here
-end
-```
-
-### Data Mapper
-
-A persistence mapper that keeps entities independent from database details.
-It is database independent, it can work with SQL, document, and even with key/value stores.
-
-The role of a data mapper is to translate database columns into the corresponding attribute of an entity.
+However, there are cases where columns and attribute names do not match (mainly **legacy databases**).
```ruby
require 'hanami/model'
-mapper = Hanami::Model::Mapper.new do
- collection :users do
- entity User
+class UserRepository < Hanami::Repository
+ self.relation = :t_user_archive
- attribute :id, Integer
- attribute :name, String
- attribute :age, Integer
- end
-end
-```
-
-For simplicity's sake, imagine that the mapper above is used with a SQL database.
-We use `#collection` to indicate the name of the table that we want to map, `#entity` to indicate the class that we want to associate.
-In the end, each call to `#attribute` associates the specified column with a corresponding Ruby type.
-
-For advanced mapping and legacy databases, please have a look at the API doc.
-
-**Known limitations**
-
-Note there are limitations with inherited entities:
-
-```ruby
-require 'hanami/model'
-
-class Article
- include Hanami::Entity
- attributes :name
-end
-
-class RareArticle < Article
- attributes :price
-end
-
-mapper = Hanami::Model::Mapper.new do
- collection :articles do
- entity Article
-
- attribute :id, Integer
- attribute :name, String
- attribute :price, Integer
+ mapping do
+ attribute :id, from: :i_user_id
+ attribute :name, from: :s_name
+ attribute :age, from: :i_age
end
end
```
-
-In the example above, there are a few problems:
-
-* `Article` could not be fetched because mapping could not map `price`.
-* Finding a persisted `RareArticle` record, for eg. `ArticleRepository.new.find(123)`,
-the result is an `Article` not `RareArticle`.
-
-### Adapter
-
-An adapter is a concrete implementation of persistence logic for a specific database.
-**Hanami::Model** is shipped with three adapters:
-
- * SqlAdapter
- * MemoryAdapter
- * FileSystemAdapter
-
-An adapter can be associated with one or multiple repositories.
-
-```ruby
-require 'pg'
-require 'hanami/model'
-require 'hanami/model/adapters/sql_adapter'
-
-mapper = Hanami::Model::Mapper.new do
- # ...
-end
-
-adapter = Hanami::Model::Adapters::SqlAdapter.new(mapper, 'postgres://host:port/database')
-
-PersonRepository.adapter = adapter
-ArticleRepository.adapter = adapter
-```
-
-In the example above, we reuse the adapter because the target tables (`people` and `articles`) are defined in the same database.
-**As rule of thumb, one adapter instance per database.**
-
-### Query
-
-An object that implements an interface for querying the database.
-This interface may vary, according to the adapter's specifications.
-
-Here is common interface for existing class:
-
- * `.all` - Resolves the query by fetching records from the database and translating them into entities
- * `.where`, `.and` - Adds a condition that behaves like SQL `WHERE`
- * `.or` - Adds a condition that behaves like SQL `OR`
- * `.exclude`, `.not` - Logical negation of a #where condition
- * `.select` - Selects only the specified columns
- * `.order`, `.asc` - Specify the ascending order of the records, sorted by the given columns
- * `.reverse_order`, `.desc` - Specify the descending order of the records, sorted by the given columns
- * `.limit` - Limit the number of records to return
- * `.offset` - Specify an `OFFSET` clause. Due to SQL syntax restriction, offset MUST be used with `#limit`
- * `.sum` - Returns the sum of the values for the given column
- * `.average`, `.avg` - Returns the average of the values for the given column
- * `.max` - Returns the maximum value for the given column
- * `.min` - Returns the minimum value for the given column
- * `.interval` - Returns the difference between the MAX and MIN for the given column
- * `.range` - Returns a range of values between the MAX and the MIN for the given column
- * `.exist?` - Checks if at least one record exists for the current conditions
- * `.count` - Returns a count of the records for the current conditions
- * `.join` - Adds an inner join with a table (only SQL)
- * `.left_join` - Adds a left join with a table (only SQL)
-
-If you need more information regarding those methods, you can use comments from [memory](https://github.com/hanami/model/blob/master/lib/hanami/model/adapters/memory/query.rb#L29) or [sql](https://github.com/hanami/model/blob/master/lib/hanami/model/adapters/sql/query.rb#L28) adapters interface.
-
-Think of an adapter for Redis, it will probably employ different strategies to filter records than an SQL query object.
-
-### Model Error Coercions
-
-All adapters' errors are encapsulated into Hanami error classes.
-
-Hanami Model may raise the following exceptions:
-
- * `Hanami::Model::UniqueConstraintViolationError`
- * `Hanami::Model::ForeignKeyConstraintViolationError`
- * `Hanami::Model::NotNullConstraintViolationError`
- * `Hanami::Model::CheckConstraintViolationError`
-
-For any other adapter's errors, Hanami will raise the `Hanami::Model::InvalidCommandError` object.
-All errors contains the root cause and the full error message thrown by sql adapter.
+**NOTE:** This feature should be used only when **_automapping_** fails because the naming mismatch.
### Conventions
* A repository must be named after an entity, by appending `"Repository"` to the entity class name (eg. `Article` => `ArticleRepository`).
-### Configurations
-
- * Non-standard repository can be configured for an entity, by setting `repository` on the collection.
-
- ```ruby
- require 'hanami/model'
-
- mapper = Hanami::Model::Mapper.new do
- collection :users do
- entity User
- repository EmployeeRepository
- end
- end
- ```
-
### Thread safety
**Hanami::Model**'s is thread safe during the runtime, but it isn't during the loading process.
@@ -525,105 +235,29 @@ If an entity has the following accessors: `:created_at` and `:updated_at`, they
```ruby
require 'hanami/model'
-class User
- include Hanami::Entity
- attributes :name, :created_at, :updated_at
-end
-
-class UserRepository
- include Hanami::Repository
-end
-
-Hanami::Model.configure do
- adapter type: :memory, uri: 'memory://localhost/timestamps'
-
- mapping do
- collection :users do
- entity User
- repository UserRepository
-
- attribute :id, Integer
- attribute :name, String
- attribute :created_at, DateTime
- attribute :updated_at, DateTime
- end
- end
-end.load!
-
-user = User.new(name: 'L')
-puts user.created_at # => nil
-puts user.updated_at # => nil
-
-user = UserRepository.new.create(user)
-puts user.created_at.to_s # => "2015-05-15T10:12:20+00:00"
-puts user.updated_at.to_s # => "2015-05-15T10:12:20+00:00"
-
-sleep 3
-user.name = "Luca"
-user = UserRepository.new.update(user)
-puts user.created_at.to_s # => "2015-05-15T10:12:20+00:00"
-puts user.updated_at.to_s # => "2015-05-15T10:12:23+00:00"
-```
-
-### Dirty Tracking
-
-Entities are able to track changes of their data, if `Hanami::Entity::DirtyTracking` is included.
-
-```ruby
-require 'hanami/model'
-
-class User
- include Hanami::Entity
- include Hanami::Entity::DirtyTracking
- attributes :name, :age
+class User < Hanami::Entity
end
-class UserRepository
- include Hanami::Repository
+class UserRepository < Hanami::Repository
end
Hanami::Model.configure do
- adapter type: :memory, uri: 'memory://localhost/dirty_tracking'
-
- mapping do
- collection :users do
- entity User
- repository UserRepository
-
- attribute :id, Integer
- attribute :name, String
- attribute :age, String
- end
- end
+ adapter :sql, uri: 'postgresql://localhost/bookshelf'
end.load!
-user = User.new(name: 'L')
-user.changed? # => false
-
-user.age = 33
-user.changed? # => true
-user.changed_attributes # => {:age=>33}
+repository = UserRepository.new
-user = UserRepository.new.create(user)
-user.changed? # => false
+user = repository.create(name: 'Luca')
-user.update(name: 'Luca')
-user.changed? # => true
-user.changed_attributes # => {:name=>"Luca"}
+puts user.created_at.to_s # => "2016-09-19 13:40:13 UTC"
+puts user.updated_at.to_s # => "2016-09-19 13:40:13 UTC"
-user = UserRepository.new.update(user)
-user.changed? # => false
-
-result = UserRepository.new.find(user.id)
-result.changed? # => false
+sleep 3
+user = UserRepository.new.update(user.id, age: 34)
+puts user.created_at.to_s # => "2016-09-19 13:40:13 UTC"
+puts user.updated_at.to_s # => "2016-09-19 13:40:16 UTC"
```
-## Example
-
-For a full working example, have a look at [EXAMPLE.md](https://github.com/hanami/model/blob/master/EXAMPLE.md).
-Please remember that the setup code is only required for the standalone usage of **Hanami::Model**.
-A **Hanami** application will handle that configurations for you.
-
## Versioning
__Hanami::Model__ uses [Semantic Versioning 2.0.0](http://semver.org)
diff --git a/Rakefile b/Rakefile
index 2f74e6a8..89c58d80 100644
--- a/Rakefile
+++ b/Rakefile
@@ -5,20 +5,23 @@ require 'bundler/gem_tasks'
Rake::TestTask.new do |t|
t.pattern = 'test/**/*_test.rb'
t.libs.push 'test'
+ t.warning = false
end
Rake::TestTask.new do |t|
t.name = 'unit'
- t.test_files = Dir['test/**/*_test.rb'].reject do |path|
+ t.test_files = Dir['test/**/*_test.rb'].reject do |path|
path.include?('/integration')
end
t.libs.push 'test'
+ t.warning = false
end
Rake::TestTask.new do |t|
t.name = 'integration'
t.pattern = 'test/integration/**/*_test.rb'
t.libs.push 'test'
+ t.warning = false
end
namespace :test do
diff --git a/hanami-model.gemspec b/hanami-model.gemspec
index 4380b0b5..a1528f28 100644
--- a/hanami-model.gemspec
+++ b/hanami-model.gemspec
@@ -8,8 +8,8 @@ Gem::Specification.new do |spec|
spec.version = Hanami::Model::VERSION
spec.authors = ['Luca Guidi', 'Trung Lê', 'Alfonso Uceda']
spec.email = ['me@lucaguidi.com', 'trung.le@ruby-journal.com', 'uceda73@gmail.com']
- spec.summary = %q{A persistence layer for Hanami}
- spec.description = %q{A persistence framework with entities, repositories, data mapper and query objects}
+ spec.summary = 'A persistence layer for Hanami'
+ spec.description = 'A persistence framework with entities and repositories'
spec.homepage = 'http://hanamirb.org'
spec.license = 'MIT'
@@ -19,10 +19,13 @@ Gem::Specification.new do |spec|
spec.require_paths = ['lib']
spec.required_ruby_version = '>= 2.2.0'
- spec.add_runtime_dependency 'hanami-utils', '~> 0.8'
- spec.add_runtime_dependency 'sequel', '~> 4.9'
+ spec.add_runtime_dependency 'hanami-utils', '~> 0.8'
+ spec.add_runtime_dependency 'rom-sql', '~> 0.9'
+ spec.add_runtime_dependency 'rom-repository', '~> 0.3'
+ spec.add_runtime_dependency 'dry-types', '~> 0.9'
+ spec.add_runtime_dependency 'concurrent-ruby', '~> 1.0'
spec.add_development_dependency 'bundler', '~> 1.6'
spec.add_development_dependency 'minitest', '~> 5'
- spec.add_development_dependency 'rake', '~> 10'
+ spec.add_development_dependency 'rake', '~> 11'
end
diff --git a/lib/hanami/entity.rb b/lib/hanami/entity.rb
index 64b404a6..a7d1d398 100644
--- a/lib/hanami/entity.rb
+++ b/lib/hanami/entity.rb
@@ -1,5 +1,4 @@
-require 'hanami/utils/kernel'
-require 'hanami/utils/attributes'
+require 'hanami/model/types'
module Hanami
# An object that is defined by its identity.
@@ -22,17 +21,8 @@ module Hanami
#
# class Person
# include Hanami::Entity
- # attributes :name, :age
# end
#
- # When a class includes `Hanami::Entity` it receives the following interface:
- #
- # * #id
- # * #id=
- # * #initialize(attributes = {})
- #
- # `Hanami::Entity` also provides the `.attributes=` for defining attribute accessors for the given names.
- #
# If we expand the code above in **pure Ruby**, it would be:
#
# @example Pure Ruby
@@ -60,239 +50,165 @@ module Hanami
# @since 0.1.0
#
# @see Hanami::Repository
- module Entity
- # Inject the public API into the hosting class.
- #
- # @since 0.1.0
- #
- # @example With Object
- # require 'hanami/model'
- #
- # class User
- # include Hanami::Entity
- # end
- #
- # @example With Struct
- # require 'hanami/model'
+ class Entity
+ require 'hanami/entity/schema'
+
+ # Syntactic shortcut to reference types in custom schema DSL
#
- # User = Struct.new(:id, :name) do
- # include Hanami::Entity
- # end
- def self.included(base)
- base.class_eval do
- extend ClassMethods
- attributes :id
- end
+ # @since x.x.x
+ module Types
+ include Hanami::Model::Types
end
+ # Class level interface
+ #
+ # @since x.x.x
+ # @api private
module ClassMethods
- # (Re)defines getters, setters and initialization for the given attributes.
- #
- # These attributes can match the database columns, but this isn't a
- # requirement. The mapper used by the relative repository will translate
- # these names automatically.
- #
- # An entity can work with attributes not configured in the mapper, but
- # of course they will be ignored when the entity will be persisted.
- #
- # Please notice that the required `id` attribute is automatically defined
- # and can be omitted in the arguments.
+ # Define manual entity schema
#
- # @param attrs [Array] a set of arbitrary attribute names
+ # With a SQL database this setup happens automatically and you SHOULD NOT
+ # use this DSL. You should use only when you want to customize the automatic
+ # setup.
#
- # @since 0.2.0
+ # If you're working with an entity that isn't "backed" by a SQL table or
+ # with a schema-less database, you may want to manually setup a set of
+ # attributes via this DSL. If you don't do any setup, the entity accepts all
+ # the given attributes.
#
- # @see Hanami::Repository
- # @see Hanami::Model::Mapper
+ # @param blk [Proc] the block that defines the attributes
#
- # @example
- # require 'hanami/model'
+ # @since x.x.x
#
- # class User
- # include Hanami::Entity
- # attributes :name, :age
- # end
- # User.attributes => #
- #
- # @example Given params is array of attributes
- # require 'hanami/model'
- #
- # class User
- # include Hanami::Entity
- # attributes [:name, :age]
- # end
- # User.attributes => #
- #
- # @example Extend entity
- # require 'hanami/model'
- #
- # class User
- # include Hanami::Entity
- # attributes :name
- # end
- #
- # class DeletedUser < User
- # include Hanami::Entity
- # attributes :deleted_at
- # end
- #
- # User.attributes => #
- # DeletedUser.attributes => #
- #
- def attributes(*attrs)
- return @attributes ||= Set.new unless attrs.any?
-
- Hanami::Utils::Kernel.Array(attrs).each do |attr|
- if allowed_attribute_name?(attr)
- define_attr_accessor(attr)
- self.attributes << attr
- end
- end
+ # @see Hanami::Entity
+ def attributes(&blk)
+ self.schema = Schema.new(&blk)
+ @attributes = true
end
- # Define setter/getter methods for attributes.
+ # Assign a schema
#
- # @param attr [Symbol] an attribute name
+ # @param value [Hanami::Entity::Schema] the schema
#
- # @since 0.3.1
+ # @since x.x.x
# @api private
- def define_attr_accessor(attr)
- attr_accessor(attr)
+ def schema=(value)
+ return if defined?(@attributes)
+ @schema = value
end
- # Check if attr_reader define the given attribute
- #
- # @since 0.5.1
+ # @since x.x.x
# @api private
- def allowed_attribute_name?(name)
- !instance_methods.include?(name)
- end
-
- protected
+ attr_reader :schema
+ end
- # @see Class#inherited
- def inherited(subclass)
- subclass.attributes(*attributes)
- super
+ # @since x.x.x
+ # @api private
+ def self.inherited(klass)
+ klass.class_eval do
+ @schema = Schema.new
+ extend ClassMethods
end
end
- # Defines a generic, inefficient initializer, in case that the attributes
- # weren't explicitly defined with `.attributes=`.
+ # Instantiate a new entity
#
- # @param attributes [Hash] a set of attribute names and values
+ # @param attributes [Hash,#to_h,NilClass] data to initialize the entity
#
- # @raise NoMethodError in case the given attributes are trying to set unknown
- # or private methods.
+ # @return [Hanami::Entity] the new entity instance
#
- # @since 0.1.0
+ # @raise [TypeError] if the given attributes are invalid
#
- # @see .attributes
- def initialize(attributes = {})
- attributes.each do |k, v|
- setter = "#{ k }="
- public_send(setter, v) if respond_to?(setter)
- end
+ # @since 0.1.0
+ def initialize(attributes = nil)
+ @attributes = self.class.schema[attributes]
+ freeze
end
- # Overrides the equality Ruby operator
+ # Entity ID
#
- # Two entities are considered equal if they are instances of the same class
- # and if they have the same #id.
+ # @return [Object,NilClass] the ID, if present
#
- # @since 0.1.0
- def ==(other)
- self.class == other.class &&
- self.id == other.id
+ # @since x.x.x
+ def id
+ attributes.fetch(:id, nil)
end
- # Return the hash of attributes
- #
- # @since 0.2.0
+ # Handle dynamic accessors
#
- # @example
- # require 'hanami/model'
- # class User
- # include Hanami::Entity
- # attributes :name
- # end
+ # If internal attributes set has the requested key, it returns the linked
+ # value, otherwise it raises a NoMethodError
#
- # user = User.new(id: 23, name: 'Luca')
- # user.to_h # => { :id => 23, :name => "Luca" }
- def to_h
- Hash[attribute_names.map { |a| [a, read_attribute(a)] }]
+ # @since x.x.x
+ def method_missing(m, *)
+ attribute?(m) or super # rubocop:disable Style/AndOr
+ attributes.fetch(m, nil)
end
- # Return the set of attribute names
+ # Implement generic equality for entities
#
- # @since 0.5.1
+ # Two entities are equal if they are instances of the same class and they
+ # have the same id.
#
- # @example
- # require 'hanami/model'
- # class User
- # include Hanami::Entity
- # attributes :name
- # end
+ # @param other [Object] the object of comparison
#
- # user = User.new(id: 23, name: 'Luca')
- # user.attribute_names # #
- def attribute_names
- self.class.attributes
+ # @return [FalseClass,TrueClass] the result of the check
+ #
+ # @since 0.1.0
+ def ==(other)
+ self.class == other.class &&
+ id == other.id
end
- # Return the contents of the entity as a nicely formatted string.
+ # Implement predictable hashing for hash equality
#
- # Display all attributes of the entity for inspection (even if they are nil)
+ # @return [Integer] the object hash
#
- # @since 0.5.1
- #
- # @example
- # require 'hanami/model'
- # class User
- # include Hanami::Entity
- # attributes :name, :email
- # end
- #
- # user = User.new(id: 23, name: 'Luca')
- # user.inspect # #
- def inspect
- attr_list = attribute_names.inject([]) do |res, name|
- res << "@#{name}=#{read_attribute(name).inspect}"
- end.join(' ')
-
- "#<#{self.class.name}:0x00#{(__id__ << 1).to_s(16)} #{attr_list}>"
+ # @since x.x.x
+ def hash
+ [self.class, id].hash
end
- alias_method :to_s, :inspect
+ # Freeze the entity
+ #
+ # @since x.x.x
+ def freeze
+ attributes.freeze
+ super
+ end
- # Set attributes for entity
+ # Serialize entity to a Hash
#
- # @since 0.2.0
+ # @return [Hash] the result of serialization
#
- # @example
- # require 'hanami/model'
- # class User
- # include Hanami::Entity
- # attributes :name
- # end
+ # @since 0.1.0
+ def to_h
+ attributes.deep_dup.to_h
+ end
+
+ # @since x.x.x
+ alias to_hash to_h
+
+ protected
+
+ # Check if the attribute is allowed to be read
#
- # user = User.new(name: 'Lucca')
- # user.update(name: 'Luca')
- # user.name # => 'Luca'
- def update(attributes={})
- attributes.each do |attribute, value|
- public_send("#{attribute}=", value)
- end
+ # @since x.x.x
+ # @api private
+ def attribute?(name)
+ self.class.schema.attribute?(name)
end
private
- # Return the value by attribute name
- #
- # @since 0.5.1
+ # @since 0.1.0
+ # @api private
+ attr_reader :attributes
+
+ # @since x.x.x
# @api private
- def read_attribute(attr_name)
- public_send(attr_name)
+ def respond_to_missing?(name, _include_all)
+ attribute?(name)
end
end
end
diff --git a/lib/hanami/entity/dirty_tracking.rb b/lib/hanami/entity/dirty_tracking.rb
deleted file mode 100644
index 564e20ca..00000000
--- a/lib/hanami/entity/dirty_tracking.rb
+++ /dev/null
@@ -1,74 +0,0 @@
-module Hanami
- module Entity
- # Dirty tracking for entities
- #
- # @since 0.3.1
- #
- # @example Dirty tracking
- # require 'hanami/model'
- #
- # class User
- # include Hanami::Entity
- # include Hanami::Entity::DirtyTracking
- #
- # attributes :name
- # end
- #
- # article = Article.new(title: 'Generation P')
- # article.changed? # => false
- #
- # article.title = 'Master and Margarita'
- # article.changed? # => true
- #
- # article.changed_attributes # => {:title => "Generation P"}
- module DirtyTracking
- # Override initialize process.
- #
- # @param attributes [Hash] a set of attribute names and values
- #
- # @since 0.3.1
- #
- # @see Hanami::Entity#initialize
- def initialize(attributes = {})
- super
- @_initial_state = Utils::Hash.new(to_h).deep_dup
- end
-
- # Getter for hash of changed attributes.
- # Return empty hash, if there is no changes
- # Getter for hash of changed attributes. Value in it is the previous one.
- #
- # @return [::Hash] the changed attributes
- #
- # @since 0.3.1
- #
- # @example
- # require 'hanami/model'
- #
- # class Article
- # include Hanami::Entity
- # include Hanami::Entity::DirtyTracking
- #
- # attributes :title
- # end
- #
- # article = Article.new(title: 'The crime and punishment')
- # article.changed_attributes # => {}
- #
- # article.title = 'Master and Margarita'
- # article.changed_attributes # => {:title => "The crime and punishment"}
- def changed_attributes
- Hash[@_initial_state.to_a - to_h.to_a]
- end
-
- # Checks if the attributes were changed
- #
- # @return [TrueClass, FalseClass] the result of the check
- #
- # @since 0.3.1
- def changed?
- changed_attributes.any?
- end
- end
- end
-end
diff --git a/lib/hanami/entity/schema.rb b/lib/hanami/entity/schema.rb
new file mode 100644
index 00000000..f80ccbdf
--- /dev/null
+++ b/lib/hanami/entity/schema.rb
@@ -0,0 +1,236 @@
+require 'hanami/model/types'
+require 'hanami/utils/hash'
+
+module Hanami
+ class Entity
+ # Entity schema is a definition of a set of typed attributes.
+ #
+ # @since x.x.x
+ # @api private
+ #
+ # @example SQL Automatic Setup
+ # require 'hanami/model'
+ #
+ # class Account
+ # include Hanami::Entity
+ # end
+ #
+ # account = Account.new(name: "Acme Inc.")
+ # account.name # => "Hanami"
+ #
+ # account = Account.new(foo: "bar")
+ # account.foo # => NoMethodError
+ #
+ # @example Non-SQL Manual Setup
+ # require 'hanami/model'
+ #
+ # class Account
+ # include Hanami::Entity
+ #
+ # attributes do
+ # attribute :id, Types::Int
+ # attribute :name, Types::String
+ # attribute :codes, Types::Array(Types::Int)
+ # attribute :users, Types::Array(User)
+ # attribute :email, Types::String.constrained(format: /@/)
+ # attribute :created_at, Types::DateTime
+ # end
+ # end
+ #
+ # account = Account.new(name: "Acme Inc.")
+ # account.name # => "Acme Inc."
+ #
+ # account = Account.new(foo: "bar")
+ # account.foo # => NoMethodError
+ #
+ # @example Schemaless Entity
+ # require 'hanami/model'
+ #
+ # class Account
+ # include Hanami::Entity
+ # end
+ #
+ # account = Account.new(name: "Acme Inc.")
+ # account.name # => "Acme Inc."
+ #
+ # account = Account.new(foo: "bar")
+ # account.foo # => "bar"
+ class Schema
+ # Schemaless entities logic
+ #
+ # @since x.x.x
+ # @api private
+ class Schemaless
+ # @since x.x.x
+ # @api private
+ def initialize
+ freeze
+ end
+
+ # @param attributes [#to_hash] the attributes hash
+ #
+ # @return [Hash]
+ #
+ # @since x.x.x
+ # @api private
+ def call(attributes)
+ if attributes.nil?
+ {}
+ else
+ attributes.dup
+ end
+ end
+
+ # @since x.x.x
+ # @api private
+ def attribute?(_name)
+ true
+ end
+ end
+
+ # Schema definition
+ #
+ # @since x.x.x
+ # @api private
+ class Definition
+ # Schema DSL
+ #
+ # @since x.x.x
+ class Dsl
+ # @since x.x.x
+ # @api private
+ def self.build(&blk)
+ attributes = new(&blk).to_h
+ [attributes, Hanami::Model::Types::Coercible::Hash.schema(attributes)]
+ end
+
+ # @since x.x.x
+ # @api private
+ def initialize(&blk)
+ @attributes = {}
+ instance_eval(&blk)
+ end
+
+ # Define an attribute
+ #
+ # @param name [Symbol] the attribute name
+ # @param type [Dry::Types::Definition] the attribute type
+ #
+ # @since x.x.x
+ def attribute(name, type)
+ @attributes[name] = type
+ end
+
+ # @since x.x.x
+ # @api private
+ def to_h
+ @attributes
+ end
+ end
+
+ # Instantiate a new DSL instance for an entity
+ #
+ # @param blk [Proc] the block that defines the attributes
+ #
+ # @return [Hanami::Entity::Schema::Dsl] the DSL
+ #
+ # @since x.x.x
+ # @api private
+ def initialize(&blk)
+ raise LocalJumpError unless block_given?
+ @attributes, @schema = Dsl.build(&blk)
+ @attributes = Hash[@attributes.map { |k, _| [k, true] }]
+ freeze
+ end
+
+ # Process attributes
+ #
+ # @param attributes [#to_hash] the attributes hash
+ #
+ # @raise [TypeError] if the process fails
+ #
+ # @since x.x.x
+ # @api private
+ def call(attributes)
+ schema.call(attributes)
+ rescue Dry::Types::SchemaError => e
+ raise TypeError.new(e.message)
+ end
+
+ # Check if the attribute is known
+ #
+ # @param name [Symbol] the attribute name
+ #
+ # @return [TrueClass,FalseClass] the result of the check
+ #
+ # @since x.x.x
+ # @api private
+ def attribute?(name)
+ attributes.key?(name)
+ end
+
+ private
+
+ # @since x.x.x
+ # @api private
+ attr_reader :schema
+
+ # @since x.x.x
+ # @api private
+ attr_reader :attributes
+ end
+
+ # Build a new instance of Schema with the attributes defined by the given block
+ #
+ # @param blk [Proc] the optional block that defines the attributes
+ #
+ # @return [Hanami::Entity::Schema] the schema
+ #
+ # @since x.x.x
+ # @api private
+ def initialize(&blk)
+ @schema = if block_given?
+ Definition.new(&blk)
+ else
+ Schemaless.new
+ end
+ end
+
+ # Process attributes
+ #
+ # @param attributes [#to_hash] the attributes hash
+ #
+ # @raise [TypeError] if the process fails
+ #
+ # @since x.x.x
+ # @api private
+ def call(attributes)
+ Utils::Hash.new(
+ schema.call(attributes)
+ ).symbolize!
+ end
+
+ # @since x.x.x
+ # @api private
+ alias [] call
+
+ # Check if the attribute is known
+ #
+ # @param name [Symbol] the attribute name
+ #
+ # @return [TrueClass,FalseClass] the result of the check
+ #
+ # @since x.x.x
+ # @api private
+ def attribute?(name)
+ schema.attribute?(name)
+ end
+
+ protected
+
+ # @since x.x.x
+ # @api private
+ attr_reader :schema
+ end
+ end
+end
diff --git a/lib/hanami/model.rb b/lib/hanami/model.rb
index a5fd15db..44bf1338 100644
--- a/lib/hanami/model.rb
+++ b/lib/hanami/model.rb
@@ -1,172 +1,86 @@
-require 'hanami/model/version'
+require 'rom'
+require 'concurrent'
require 'hanami/entity'
-require 'hanami/entity/dirty_tracking'
require 'hanami/repository'
-require 'hanami/model/mapper'
-require 'hanami/model/configuration'
-require 'hanami/model/error'
module Hanami
- # Model
+ # Hanami persistence
#
# @since 0.1.0
module Model
- include Utils::ClassAttribute
+ require 'hanami/model/version'
+ require 'hanami/model/error'
+ require 'hanami/model/configuration'
+ require 'hanami/model/configurator'
+ require 'hanami/model/mapping'
+ require 'hanami/model/plugins'
- # Framework configuration
- #
- # @since 0.2.0
# @api private
- class_attribute :configuration
- self.configuration = Configuration.new
+ # @since x.x.x
+ @__repositories__ = Concurrent::Array.new # rubocop:disable Style/VariableNumber
- # Configure the framework.
- # It yields the given block in the context of the configuration
- #
- # @param blk [Proc] the configuration block
- #
- # @since 0.2.0
+ class << self
+ # @since x.x.x
+ # @api private
+ attr_reader :config
+
+ # @since x.x.x
+ # @api private
+ attr_reader :loaded
+
+ # @since x.x.x
+ # @api private
+ alias loaded? loaded
+ end
+
+ # Configure the framework
#
- # @see Hanami::Model
+ # @since 0.1.0
#
# @example
# require 'hanami/model'
#
# Hanami::Model.configure do
- # adapter type: :sql, uri: 'postgres://localhost/database'
- #
- # mapping do
- # collection :users do
- # entity User
+ # adapter :sql, ENV['DATABASE_URL']
#
- # attribute :id, Integer
- # attribute :name, String
- # end
- # end
+ # migrations 'db/migrations'
+ # schema 'db/schema.sql'
# end
- #
- # Adapter MUST follow the convention in which adapter class is inflection of adapter name
- # The above example has name :sql, thus derived class will be `Hanami::Model::Adapters::SqlAdapter`
- def self.configure(&blk)
- configuration.instance_eval(&blk)
+ def self.configure(&block)
+ @config = Configurator.build(&block)
self
end
- # Load the framework
+ # Current configuration
#
- # @since 0.2.0
- # @api private
- def self.load!
- configuration.load!
+ # @since 0.1.0
+ def self.configuration
+ @configuration ||= Configuration.new(config)
end
- # Unload the framework
- #
- # @since 0.2.0
+ # @since x.x.x
# @api private
- def self.unload!
- configuration.unload!
+ def self.repositories
+ @__repositories__
end
- # Duplicate Hanami::Model in order to create a new separated instance
- # of the framework.
- #
- # The new instance of the framework will be completely decoupled from the
- # original. It will inherit the configuration, but all the changes that
- # happen after the duplication, won't be reflected on the other copies.
- #
- # @return [Module] a copy of Hanami::Model
- #
- # @since 0.2.0
+ # @since x.x.x
# @api private
- #
- # @example Basic usage
- # require 'hanami/model'
- #
- # module MyApp
- # Model = Hanami::Model.dupe
- # end
- #
- # MyApp::Model == Hanami::Model # => false
- #
- # MyApp::Model.configuration ==
- # Hanami::Model.configuration # => false
- #
- # @example Inheriting configuration
- # require 'hanami/model'
- #
- # Hanami::Model.configure do
- # adapter type: :sql, uri: 'sqlite3://uri'
- # end
- #
- # module MyApp
- # Model = Hanami::Model.dupe
- # end
- #
- # module MyApi
- # Model = Hanami::Model.dupe
- # Model.configure do
- # adapter type: :sql, uri: 'postgresql://uri'
- # end
- # end
- #
- # Hanami::Model.configuration.adapter_config.uri # => 'sqlite3://uri'
- # MyApp::Model.configuration.adapter_config.uri # => 'sqlite3://uri'
- # MyApi::Model.configuration.adapter_config.uri # => 'postgresql://uri'
- def self.dupe
- dup.tap do |duplicated|
- duplicated.configuration = Configuration.new
- end
+ def self.container
+ raise 'Not loaded' unless loaded?
+ @container
end
- # Duplicate the framework and generate modules for the target application
- #
- # @param mod [Module] the Ruby namespace of the application
- # @param blk [Proc] an optional block to configure the framework
- #
- # @return [Module] a copy of Hanami::Model
- #
- # @since 0.2.0
- #
- # @see Hanami::Model#dupe
- # @see Hanami::Model::Configuration
- #
- # @example Basic usage
- # require 'hanami/model'
- #
- # module MyApp
- # Model = Hanami::Model.dupe
- # end
- #
- # # It will:
- # #
- # # 1. Generate MyApp::Model
- # # 2. Generate MyApp::Entity
- # # 3. Generate MyApp::Repository
- #
- # MyApp::Model == Hanami::Model # => false
- # MyApp::Repository == Hanami::Repository # => false
- #
- # @example Block usage
- # require 'hanami/model'
- #
- # module MyApp
- # Model = Hanami::Model.duplicate(self) do
- # adapter type: :memory, uri: 'memory://localhost'
- # end
- # end
- #
- # Hanami::Model.configuration.adapter_config # => nil
- # MyApp::Model.configuration.adapter_config # => #
- def self.duplicate(mod, &blk)
- dupe.tap do |duplicated|
- mod.module_eval %{
- Entity = Hanami::Entity.dup
- Repository = Hanami::Repository.dup
- }
+ # @since 0.1.0
+ def self.load!(&blk) # rubocop:disable Metrics/AbcSize
+ configuration.setup.auto_registration(config.directory.to_s) unless config.directory.nil?
+ configuration.instance_eval(&blk) if block_given?
+ repositories.each(&:load!)
+
+ @container = ROM.container(configuration)
+ configuration.define_entities_mappings(@container, repositories)
- duplicated.configure(&blk) if block_given?
- end
+ @loaded = true
end
end
end
diff --git a/lib/hanami/model/adapters/abstract.rb b/lib/hanami/model/adapters/abstract.rb
deleted file mode 100644
index c0c5d51a..00000000
--- a/lib/hanami/model/adapters/abstract.rb
+++ /dev/null
@@ -1,315 +0,0 @@
-require 'hanami/utils/basic_object'
-require 'hanami/utils/string'
-require 'hanami/utils/blank'
-
-module Hanami
- module Model
- module Adapters
- # It's raised when an adapter can't find the underlying database adapter.
- #
- # Example: When we try to use the SqlAdapter with a Postgres database
- # but we didn't loaded the pg gem before.
- #
- # @see Hanami::Model::Adapters::SqlAdapter#initialize
- #
- # @since 0.1.0
- class DatabaseAdapterNotFound < Hanami::Model::Error
- end
-
- # It's raised when an adapter does not support a feature.
- #
- # Example: When we try to get a connection string for the current database
- # but the adapter has not implemented it.
- #
- # @see Hanami::Model::Adapters::Abstract#connection_string
- #
- # @since 0.3.0
- class NotSupportedError < Hanami::Model::Error
- end
-
- # It's raised when a URI is nil or empty
- #
- # @since x.x.x
- class MissingURIError < Hanami::Model::Error
- def initialize(adapter_name)
- super "URI for `#{ adapter_name }' adapter is nil or empty. Please check env variables like `DATABASE_URL'."
- end
- end
-
- # It's raised when an operation is requested to an adapter after it was
- # disconnected.
- #
- # @since 0.5.0
- class DisconnectedAdapterError < Hanami::Model::Error
- def initialize
- super "You have tried to perform an operation on a disconnected adapter"
- end
- end
-
- # Represents a disconnected resource.
- #
- # When we use #disconnect for MemoryAdapter and
- # FileSystemAdapter, we want to free underlying resources such
- # as a mutex or a file descriptor.
- #
- # These adapters use to use anonymous descriptors that are destroyed by
- # Ruby VM after each operation. Sometimes we need to clean the state and
- # start fresh (eg. during a test suite or a deploy).
- #
- # Instead of assign nil to these instance variables, we assign this
- # special type: DisconnectedResource.
- #
- # In case an operation is still performed after the adapter was disconnected,
- # instead of see a generic NoMethodError for nil, a developer
- # will face a specific message relative to the state of the adapter.
- #
- # @api private
- # @since 0.5.0
- #
- # @see Hanami::Model::Adapters::Abstract#disconnect
- # @see Hanami::Model::Adapters::MemoryAdapter#disconnect
- # @see Hanami::Model::Adapters::FileSystemAdapter#disconnect
- class DisconnectedResource < Utils::BasicObject
- def method_missing(method_name, *)
- ::Kernel.raise DisconnectedAdapterError.new
- end
- end
-
- # Abstract adapter.
- #
- # An adapter is a concrete implementation that allows a repository to
- # communicate with a single database.
- #
- # Hanami::Model is shipped with Memory and SQL adapters.
- # Third part adapters MUST implement the interface defined here.
- # For convenience they may inherit from this class.
- #
- # These are low level details, and shouldn't be used directly.
- # Please use a repository for entities persistence.
- #
- # @since 0.1.0
- class Abstract
- # @since x.x.x
- # @api private
- #
- # @see Hanami::Model::Adapters::Abstract#adapter_name
- ADAPTER_NAME_SUFFIX = '_adapter'.freeze
-
- # Initialize the adapter
- #
- # @param mapper [Hanami::Model::Mapper] the object that defines the
- # database to entities mapping
- #
- # @param uri [String] the optional connection string to the database
- #
- # @param options [Hash] a list of non-mandatory adapter options
- #
- # @since 0.1.0
- def initialize(mapper, uri = nil, options = {})
- @mapper = mapper
- @uri = uri
- @options = options
-
- assert_uri_present!
- end
-
- # Creates or updates a record in the database for the given entity.
- #
- # @param collection [Symbol] the target collection (it must be mapped).
- # @param entity [Object] the entity to persist
- #
- # @return [Object] the entity
- #
- # @since 0.1.0
- def persist(collection, entity)
- raise NotImplementedError
- end
-
- # Creates a record in the database for the given entity.
- # It should assign an id (identity) to the entity in case of success.
- #
- # @param collection [Symbol] the target collection (it must be mapped).
- # @param entity [Object] the entity to create
- #
- # @return [Object] the entity
- #
- # @since 0.1.0
- def create(collection, entity)
- raise NotImplementedError
- end
-
- # Updates a record in the database corresponding to the given entity.
- #
- # @param collection [Symbol] the target collection (it must be mapped).
- # @param entity [Object] the entity to update
- #
- # @return [Object] the entity
- #
- # @since 0.1.0
- def update(collection, entity)
- raise NotImplementedError
- end
-
- # Deletes a record in the database corresponding to the given entity.
- #
- # @param collection [Symbol] the target collection (it must be mapped).
- # @param entity [Object] the entity to delete
- #
- # @since 0.1.0
- def delete(collection, entity)
- raise NotImplementedError
- end
-
- # Returns all the records for the given collection
- #
- # @param collection [Symbol] the target collection (it must be mapped).
- #
- # @return [Array] all the records
- #
- # @since 0.1.0
- def all(collection)
- raise NotImplementedError
- end
-
- # Returns a unique record from the given collection, with the given
- # identity.
- #
- # @param collection [Symbol] the target collection (it must be mapped).
- # @param id [Object] the identity of the object.
- #
- # @return [Object] the entity
- #
- # @since 0.1.0
- def find(collection, id)
- raise NotImplementedError
- end
-
- # Returns the first record in the given collection.
- #
- # @param collection [Symbol] the target collection (it must be mapped).
- #
- # @return [Object] the first entity
- #
- # @since 0.1.0
- def first(collection)
- raise NotImplementedError
- end
-
- # Returns the last record in the given collection.
- #
- # @param collection [Symbol] the target collection (it must be mapped).
- #
- # @return [Object] the last entity
- #
- # @since 0.1.0
- def last(collection)
- raise NotImplementedError
- end
-
- # Empties the given collection.
- #
- # @param collection [Symbol] the target collection (it must be mapped).
- #
- # @since 0.1.0
- def clear(collection)
- raise NotImplementedError
- end
-
- # Executes a command for the given query.
- #
- # @param query [Object] the query object to act on.
- #
- # @since 0.1.0
- def command(query)
- raise NotImplementedError
- end
-
- # Returns a query
- #
- # @param collection [Symbol] the target collection (it must be mapped).
- # @param blk [Proc] a block of code to be executed in the context of
- # the query.
- #
- # @return [Object]
- #
- # @since 0.1.0
- def query(collection, &blk)
- raise NotImplementedError
- end
-
- # Wraps the given block in a transaction.
- #
- # For performance reasons the block isn't in the signature of the method,
- # but it's yielded at the lower level.
- #
- # Please note that it's only supported by some databases.
- # For this reason, the options may vary from adapter to adapter.
- #
- # @param options [Hash] options for transaction
- #
- # @see Hanami::Model::Adapters::SqlAdapter#transaction
- # @see Hanami::Model::Adapters::MemoryAdapter#transaction
- #
- # @since 0.2.3
- def transaction(options = {})
- raise NotImplementedError
- end
-
- # Returns a string which can be executed to start a console suitable
- # for the configured database.
- #
- # @return [String] to be executed to start a database console
- #
- # @since 0.3.0
- def connection_string
- raise NotSupportedError
- end
-
- # Executes a raw command
- #
- # @param raw [String] the raw statement to execute on the connection
- #
- # @return [NilClass]
- #
- # @since 0.3.1
- def execute(raw)
- raise NotImplementedError
- end
-
- # Fetches raw records from
- #
- # @param raw [String] the raw query
- # @param blk [Proc] an optional block that is yielded for each record
- #
- # @return [Enumerable, Array]
- #
- # @since 0.5.0
- def fetch(raw, &blk)
- raise NotImplementedError
- end
-
- # Disconnects the connection by freeing low level resources
- #
- # @since 0.5.0
- def disconnect
- raise NotImplementedError
- end
-
- # Adapter name
- #
- # @return [String] adapter name
- #
- # @since x.x.x
- def adapter_name
- Utils::String.new(self.class.name).demodulize.underscore.to_s.sub(ADAPTER_NAME_SUFFIX, '')
- end
-
- private
-
- def assert_uri_present!
- raise MissingURIError.new(adapter_name) if Utils::Blank.blank?(@uri)
- end
- end
- end
- end
-end
diff --git a/lib/hanami/model/adapters/file_system_adapter.rb b/lib/hanami/model/adapters/file_system_adapter.rb
deleted file mode 100644
index f8e3cdcd..00000000
--- a/lib/hanami/model/adapters/file_system_adapter.rb
+++ /dev/null
@@ -1,288 +0,0 @@
-require 'thread'
-require 'pathname'
-require 'hanami/model/adapters/memory_adapter'
-
-module Hanami
- module Model
- module Adapters
- # In memory adapter with file system persistence.
- # It behaves like the SQL adapter, but it doesn't support all the SQL
- # features offered by that kind of databases.
- #
- # This adapter SHOULD be used only for development or testing purposes.
- # Each read/write operation is wrapped by a `Mutex` and persisted to the
- # disk.
- #
- # For those reasons it's really unefficient, but great for quick
- # prototyping as it's schema-less.
- #
- # It works exactly like the `MemoryAdapter`, with the only difference
- # that it persist data to the disk.
- #
- # The persistence policy uses Ruby `Marshal` `dump` and `load` operations.
- # Please be aware of the limitations this model.
- #
- # @see Hanami::Model::Adapters::Implementation
- # @see Hanami::Model::Adapters::MemoryAdapter
- # @see http://www.ruby-doc.org/core/Marshal.html
- #
- # @api private
- # @since 0.2.0
- class FileSystemAdapter < MemoryAdapter
- # Default writing mode
- #
- # Binary, write only, create file if missing or erase if don't.
- #
- # @see http://ruby-doc.org/core/File/Constants.html
- #
- # @since 0.2.0
- # @api private
- WRITING_MODE = File::WRONLY|File::BINARY|File::CREAT
-
- # Default chmod
- #
- # @see http://en.wikipedia.org/wiki/Chmod
- #
- # @since 0.2.0
- # @api private
- CHMOD = 0644
-
- # File scheme
- #
- # @see https://tools.ietf.org/html/rfc3986
- #
- # @since 0.2.0
- # @api private
- FILE_SCHEME = 'file:///'.freeze
-
- # Initialize the adapter.
- #
- # @param mapper [Object] the database mapper
- # @param uri [String] the connection uri
- # @param options [Hash] a hash of non-mandatory adapter options
- #
- # @return [Hanami::Model::Adapters::FileSystemAdapter]
- #
- # @see Hanami::Model::Mapper
- #
- # @api private
- # @since 0.2.0
- def initialize(mapper, uri, options = {})
- super
- prepare(uri)
-
- @_mutex = Mutex.new
- end
-
- # Returns all the records for the given collection
- #
- # @param collection [Symbol] the target collection (it must be mapped).
- #
- # @return [Array] all the records
- #
- # @api private
- # @since 0.2.0
- def all(collection)
- _synchronize do
- read(collection)
- super
- end
- end
-
- # Returns a unique record from the given collection, with the given
- # id.
- #
- # @param collection [Symbol] the target collection (it must be mapped).
- # @param id [Object] the identity of the object.
- #
- # @return [Object] the entity
- #
- # @api private
- # @since 0.2.0
- def find(collection, id)
- _synchronize do
- read(collection)
- super
- end
- end
-
- # Returns the first record in the given collection.
- #
- # @param collection [Symbol] the target collection (it must be mapped).
- #
- # @return [Object] the first entity
- #
- # @api private
- # @since 0.2.0
- def first(collection)
- _synchronize do
- read(collection)
- super
- end
- end
-
- # Returns the last record in the given collection.
- #
- # @param collection [Symbol] the target collection (it must be mapped).
- #
- # @return [Object] the last entity
- #
- # @api private
- # @since 0.2.0
- def last(collection)
- _synchronize do
- read(collection)
- super
- end
- end
-
- # Creates a record in the database for the given entity.
- # It assigns the `id` attribute, in case of success.
- #
- # @param collection [Symbol] the target collection (it must be mapped).
- # @param entity [#id=] the entity to create
- #
- # @return [Object] the entity
- #
- # @api private
- # @since 0.2.0
- def create(collection, entity)
- _synchronize do
- super.tap { write(collection) }
- end
- end
-
- # Updates a record in the database corresponding to the given entity.
- #
- # @param collection [Symbol] the target collection (it must be mapped).
- # @param entity [#id] the entity to update
- #
- # @return [Object] the entity
- #
- # @api private
- # @since 0.2.0
- def update(collection, entity)
- _synchronize do
- super.tap { write(collection) }
- end
- end
-
- # Deletes a record in the database corresponding to the given entity.
- #
- # @param collection [Symbol] the target collection (it must be mapped).
- # @param entity [#id] the entity to delete
- #
- # @api private
- # @since 0.2.0
- def delete(collection, entity)
- _synchronize do
- super
- write(collection)
- end
- end
-
- # Deletes all the records from the given collection and resets the
- # identity counter.
- #
- # @param collection [Symbol] the target collection (it must be mapped).
- #
- # @api private
- # @since 0.2.0
- def clear(collection)
- _synchronize do
- super
- write(collection)
- end
- end
-
- # Fabricates a query
- #
- # @param collection [Symbol] the target collection (it must be mapped).
- # @param blk [Proc] a block of code to be executed in the context of
- # the query.
- #
- # @return [Hanami::Model::Adapters::Memory::Query]
- #
- # @see Hanami::Model::Adapters::Memory::Query
- #
- # @api private
- # @since 0.2.0
- def query(collection, context = nil, &blk)
- # _synchronize do
- read(collection)
- super
- # end
- end
-
- # Database informations
- #
- # @return [Hash] per collection informations
- #
- # @api private
- # @since 0.2.0
- def info
- @collections.each_with_object({}) do |(collection,_), result|
- result[collection] = query(collection).count
- end
- end
-
- # @api private
- # @since 0.5.0
- #
- # @see Hanami::Model::Adapters::Abstract#disconnect
- def disconnect
- super
-
- @_mutex = DisconnectedResource.new
- @root = DisconnectedResource.new
- end
-
- private
- # @api private
- # @since 0.2.0
- def prepare(uri)
- @root = Pathname.new(uri.sub(FILE_SCHEME, ''))
- @root.mkpath
-
- # Eager load previously persisted data.
- @root.each_child do |collection|
- collection = collection.basename.to_s.to_sym
- read(collection)
- end
- end
-
- # @api private
- # @since 0.2.0
- def _synchronize
- @_mutex.synchronize { yield }
- end
-
- # @api private
- # @since 0.2.0
- def write(collection)
- path = @root.join("#{ collection }")
- path.open(WRITING_MODE, CHMOD) {|f| f.write _dump( @collections.fetch(collection) ) }
- end
-
- # @api private
- # @since 0.2.0
- def read(collection)
- path = @root.join("#{ collection }")
- @collections[collection] = _load(path.read) if path.exist?
- end
-
- # @api private
- # @since 0.2.0
- def _dump(contents)
- Marshal.dump(contents)
- end
-
- # @api private
- # @since 0.2.0
- def _load(contents)
- Marshal.load(contents)
- end
- end
- end
- end
-end
diff --git a/lib/hanami/model/adapters/implementation.rb b/lib/hanami/model/adapters/implementation.rb
deleted file mode 100644
index 54a10f93..00000000
--- a/lib/hanami/model/adapters/implementation.rb
+++ /dev/null
@@ -1,118 +0,0 @@
-module Hanami
- module Model
- module Adapters
- # Shared implementation for SqlAdapter and MemoryAdapter
- #
- # @api private
- # @since 0.1.0
- module Implementation
- # Creates or updates a record in the database for the given entity.
- #
- # @param collection [Symbol] the target collection (it must be mapped).
- # @param entity [#id, #id=] the entity to persist
- #
- # @return [Object] the entity
- #
- # @api private
- # @since 0.1.0
- def persist(collection, entity)
- if entity.id
- update(collection, entity)
- else
- create(collection, entity)
- end
- end
-
- # Returns all the records for the given collection
- #
- # @param collection [Symbol] the target collection (it must be mapped).
- #
- # @return [Array] all the records
- #
- # @api private
- # @since 0.1.0
- def all(collection)
- # TODO consider to make this lazy (aka remove #all)
- query(collection).all
- end
-
- # Returns a unique record from the given collection, with the given
- # id.
- #
- # @param collection [Symbol] the target collection (it must be mapped).
- # @param id [Object] the identity of the object.
- #
- # @return [Object] the entity or nil if not found
- #
- # @api private
- # @since 0.1.0
- def find(collection, id)
- _first(
- _find(collection, id)
- )
- rescue TypeError, Hanami::Model::InvalidQueryError => error
- raise error unless _type_mismatch_error?(error)
- nil
- end
-
- # Returns the first record in the given collection.
- #
- # @param collection [Symbol] the target collection (it must be mapped).
- #
- # @return [Object] the first entity
- #
- # @api private
- # @since 0.1.0
- def first(collection)
- _first(
- query(collection).asc(_identity(collection))
- )
- end
-
- # Returns the last record in the given collection.
- #
- # @param collection [Symbol] the target collection (it must be mapped).
- #
- # @return [Object] the last entity
- #
- # @api private
- # @since 0.1.0
- def last(collection)
- _first(
- query(collection).desc(_identity(collection))
- )
- end
-
- private
- def _collection(name)
- raise NotImplementedError
- end
-
- def _mapped_collection(name)
- @mapper.collection(name)
- end
-
- def _type_mismatch_error?(error)
- error.message.match(/InvalidTextRepresentation|incorrect-type|syntax\ for\ uuid/)
- end
-
- def _find(collection, id)
- identity = _identity(collection)
- query(collection).where(identity => _id(collection, identity, id))
- end
-
- def _first(query)
- query.limit(1).first
- end
-
- def _identity(collection)
- _mapped_collection(collection).identity
- end
-
- def _id(collection, column, value)
- _mapped_collection(collection).deserialize_attribute(column, value)
- end
- end
- end
- end
-end
diff --git a/lib/hanami/model/adapters/memory/collection.rb b/lib/hanami/model/adapters/memory/collection.rb
deleted file mode 100644
index 6b5de762..00000000
--- a/lib/hanami/model/adapters/memory/collection.rb
+++ /dev/null
@@ -1,132 +0,0 @@
-module Hanami
- module Model
- module Adapters
- module Memory
- # Acts like a SQL database table.
- #
- # @api private
- # @since 0.1.0
- class Collection
- # A counter that simulates autoincrement primary key of a SQL table.
- #
- # @api private
- # @since 0.1.0
- class PrimaryKey
- # Initialize
- #
- # @return [Hanami::Model::Adapters::Memory::Collection::PrimaryKey]
- #
- # @api private
- # @since 0.1.0
- def initialize
- @current = 0
- end
-
- # Increment the current count by 1 and yields the given block
- #
- # @return [Fixnum] the incremented counter
- #
- # @api private
- # @since 0.1.0
- def increment!
- yield(@current += 1)
- @current
- end
- end
-
- # @attr_reader name [Symbol] the name of the collection (eg. `:users`)
- #
- # @since 0.1.0
- # @api private
- attr_reader :name
-
- # @attr_reader identity [Symbol] the primary key of the collection
- # (eg. `:id`)
- #
- # @since 0.1.0
- # @api private
- attr_reader :identity
-
- # @attr_reader records [Hash] a set of records
- #
- # @since 0.1.0
- # @api private
- attr_reader :records
-
- # Initialize a collection
- #
- # @param name [Symbol] the name of the collection (eg. `:users`).
- # @param identity [Symbol] the primary key of the collection
- # (eg. `:id`).
- #
- # @api private
- # @since 0.1.0
- def initialize(name, identity)
- @name, @identity = name, identity
- clear
- end
-
- # Creates a record for the given entity and assigns an id.
- #
- # @param entity [Object] the entity to persist
- #
- # @see Hanami::Model::Adapters::Memory::Command#create
- #
- # @return the primary key of the created record
- #
- # @api private
- # @since 0.1.0
- def create(entity)
- @primary_key.increment! do |id|
- entity[identity] = id
- records[id] = entity
- end
- end
-
- # Updates the record corresponding to the given entity.
- #
- # @param entity [Object] the entity to persist
- #
- # @see Hanami::Model::Adapters::Memory::Command#update
- #
- # @api private
- # @since 0.1.0
- def update(entity)
- records[entity.fetch(identity)] = entity
- end
-
- # Deletes the record corresponding to the given entity.
- #
- # @param entity [Object] the entity to delete
- #
- # @see Hanami::Model::Adapters::Memory::Command#delete
- #
- # @api private
- # @since 0.1.0
- def delete(entity)
- records.delete(entity.id)
- end
-
- # Returns all the raw records
- #
- # @return [Array]
- #
- # @api private
- # @since 0.1.0
- def all
- records.values
- end
-
- # Deletes all the records and resets the identity counter.
- #
- # @api private
- # @since 0.1.0
- def clear
- @records = {}
- @primary_key = PrimaryKey.new
- end
- end
- end
- end
- end
-end
diff --git a/lib/hanami/model/adapters/memory/command.rb b/lib/hanami/model/adapters/memory/command.rb
deleted file mode 100644
index 2680ccaf..00000000
--- a/lib/hanami/model/adapters/memory/command.rb
+++ /dev/null
@@ -1,113 +0,0 @@
-module Hanami
- module Model
- module Adapters
- module Memory
- # Execute a command for the given collection.
- #
- # @see Hanami::Model::Adapters::Memory::Collection
- # @see Hanami::Model::Mapping::Collection
- #
- # @api private
- # @since 0.1.0
- class Command
- # Initialize a command
- #
- # @param dataset [Hanami::Model::Adapters::Memory::Collection]
- # @param collection [Hanami::Model::Mapping::Collection]
- #
- # @api private
- # @since 0.1.0
- def initialize(dataset, collection)
- @dataset = dataset
- @collection = collection
- end
-
- # Creates a record for the given entity.
- #
- # @param entity [Object] the entity to persist
- #
- # @see Hanami::Model::Adapters::Memory::Collection#insert
- #
- # @return the primary key of the just created record.
- #
- # @api private
- # @since 0.1.0
- def create(entity)
- serialized_entity = _serialize(entity)
- serialized_entity[_identity] = @dataset.create(serialized_entity)
-
- _deserialize(serialized_entity)
- end
-
- # Updates the corresponding record for the given entity.
- #
- # @param entity [Object] the entity to persist
- #
- # @see Hanami::Model::Adapters::Memory::Collection#update
- #
- # @api private
- # @since 0.1.0
- def update(entity)
- serialized_entity = _serialize(entity)
- @dataset.update(serialized_entity)
-
- _deserialize(serialized_entity)
- end
-
- # Deletes the corresponding record for the given entity.
- #
- # @param entity [Object] the entity to delete
- #
- # @see Hanami::Model::Adapters::Memory::Collection#delete
- #
- # @api private
- # @since 0.1.0
- def delete(entity)
- @dataset.delete(entity)
- end
-
- # Deletes all the records from the table.
- #
- # @see Hanami::Model::Adapters::Memory::Collection#clear
- #
- # @api private
- # @since 0.1.0
- def clear
- @dataset.clear
- end
-
- private
- # Serialize the given entity before to persist in the database.
- #
- # @return [Hash] the serialized entity
- #
- # @api private
- # @since 0.1.0
- def _serialize(entity)
- @collection.serialize(entity)
- end
-
- # Deserialize the given entity after it was persisted in the database.
- #
- # @return [Hanami::Entity] the deserialized entity
- #
- # @api private
- # @since 0.2.2
- def _deserialize(entity)
- @collection.deserialize([entity]).first
- end
-
- # Name of the identity column in database
- #
- # @return [Symbol] the identity name
- #
- # @api private
- # @since 0.2.2
- def _identity
- @collection.identity
- end
- end
- end
- end
- end
-end
diff --git a/lib/hanami/model/adapters/memory/query.rb b/lib/hanami/model/adapters/memory/query.rb
deleted file mode 100644
index 81604a0c..00000000
--- a/lib/hanami/model/adapters/memory/query.rb
+++ /dev/null
@@ -1,653 +0,0 @@
-require 'forwardable'
-require 'ostruct'
-require 'hanami/utils/kernel'
-
-module Hanami
- module Model
- module Adapters
- module Memory
- # Query the in-memory database with a powerful API.
- #
- # All the methods are chainable, it allows advanced composition of
- # conditions.
- #
- # This works as a lazy filtering mechanism: the records are fetched from
- # the database only when needed.
- #
- # @example
- #
- # query.where(language: 'ruby')
- # .and(framework: 'hanami')
- # .reverse_order(:users_count).all
- #
- # # the records are fetched only when we invoke #all
- #
- # It implements Ruby's `Enumerable` and borrows some methods from `Array`.
- # Expect a query to act like them.
- #
- # @since 0.1.0
- class Query
- include Enumerable
- extend Forwardable
-
- def_delegators :all, :each, :to_s, :empty?
-
- # @attr_reader conditions [Array] an accumulator for the conditions
- #
- # @since 0.1.0
- # @api private
- attr_reader :conditions
-
- # @attr_reader modifiers [Array] an accumulator for the modifiers
- #
- # @since 0.1.0
- # @api private
- attr_reader :modifiers
-
- # Initialize a query
- #
- # @param dataset [Hanami::Model::Adapters::Memory::Collection]
- # @param collection [Hanami::Model::Mapping::Collection]
- # @param blk [Proc] an optional block that gets yielded in the
- # context of the current query
- #
- # @since 0.1.0
- # @api private
- def initialize(dataset, collection, &blk)
- @dataset = dataset
- @collection = collection
- @conditions = []
- @modifiers = []
- instance_eval(&blk) if block_given?
- end
-
- # Resolves the query by fetching records from the database and
- # translating them into entities.
- #
- # @return [Array] a collection of entities
- #
- # @since 0.1.0
- def all
- @collection.deserialize(run)
- end
-
- # Adds a condition that behaves like SQL `WHERE`.
- #
- # It accepts a `Hash` with only one pair.
- # The key must be the name of the column expressed as a `Symbol`.
- # The value is the one used by the internal filtering logic.
- #
- # @param condition [Hash]
- #
- # @return self
- #
- # @since 0.1.0
- #
- # @example Fixed value
- #
- # query.where(language: 'ruby')
- #
- # @example Array
- #
- # query.where(id: [1, 3])
- #
- # @example Range
- #
- # query.where(year: 1900..1982)
- #
- # @example Using block
- #
- # query.where { age > 31 }
- #
- # @example Multiple conditions
- #
- # query.where(language: 'ruby')
- # .where(framework: 'hanami')
- #
- # @example Multiple conditions with blocks
- #
- # query.where { language == 'ruby' }
- # .where { framework == 'hanami' }
- #
- # @example Mixed hash and block conditions
- #
- # query.where(language: 'ruby')
- # .where { framework == 'hanami' }
- def where(condition = nil, &blk)
- if blk
- _push_evaluated_block_condition(:where, blk, :find_all)
- elsif condition
- _push_to_expanded_condition(:where, condition) do |column, value|
- Proc.new {
- find_all { |r|
- case value
- when Array,Set,Range
- value.include?(r.fetch(column, nil))
- else
- r.fetch(column, nil) == value
- end
- }
- }
- end
- end
-
- self
- end
-
- alias_method :and, :where
-
- # Adds a condition that behaves like SQL `OR`.
- #
- # It accepts a `Hash` with only one pair.
- # The key must be the name of the column expressed as a `Symbol`.
- # The value is the one used by the SQL query
- #
- # This condition will be ignored if not used with WHERE.
- #
- # @param condition [Hash]
- #
- # @return self
- #
- # @since 0.1.0
- #
- # @example Fixed value
- #
- # query.where(language: 'ruby').or(framework: 'hanami')
- #
- # @example Array
- #
- # query.where(id: 1).or(author_id: [15, 23])
- #
- # @example Range
- #
- # query.where(country: 'italy').or(year: 1900..1982)
- #
- # @example Using block
- #
- # query.where { age == 31 }.or { age == 32 }
- #
- # @example Mixed hash and block conditions
- #
- # query.where(language: 'ruby')
- # .or { framework == 'hanami' }
- def or(condition = nil, &blk)
- if blk
- _push_evaluated_block_condition(:or, blk, :find_all)
- elsif condition
- _push_to_expanded_condition(:or, condition) do |column, value|
- Proc.new { find_all { |r| r.fetch(column) == value} }
- end
- end
-
- self
- end
-
- # Logical negation of a #where condition.
- #
- # It accepts a `Hash` with only one pair.
- # The key must be the name of the column expressed as a `Symbol`.
- # The value is the one used by the internal filtering logic.
- #
- # @param condition [Hash]
- #
- # @since 0.1.0
- #
- # @return self
- #
- # @example Fixed value
- #
- # query.exclude(language: 'java')
- #
- # @example Array
- #
- # query.exclude(id: [4, 9])
- #
- # @example Range
- #
- # query.exclude(year: 1900..1982)
- #
- # @example Multiple conditions
- #
- # query.exclude(language: 'java')
- # .exclude(company: 'enterprise')
- #
- # @example Using block
- #
- # query.exclude { age > 31 }
- #
- # @example Multiple conditions with blocks
- #
- # query.exclude { language == 'java' }
- # .exclude { framework == 'spring' }
- #
- # @example Mixed hash and block conditions
- #
- # query.exclude(language: 'java')
- # .exclude { framework == 'spring' }
- def exclude(condition = nil, &blk)
- if blk
- _push_evaluated_block_condition(:where, blk, :reject)
- elsif condition
- _push_to_expanded_condition(:where, condition) do |column, value|
- Proc.new { reject { |r| r.fetch(column) == value} }
- end
- end
-
- self
- end
-
- alias_method :not, :exclude
-
- # Select only the specified columns.
- #
- # By default a query selects all the mapped columns.
- #
- # @param columns [Array]
- #
- # @return self
- #
- # @since 0.1.0
- #
- # @example Single column
- #
- # query.select(:name)
- #
- # @example Multiple columns
- #
- # query.select(:name, :year)
- def select(*columns)
- columns = Hanami::Utils::Kernel.Array(columns)
- modifiers.push(Proc.new{ flatten!; each {|r| r.delete_if {|k,_| !columns.include?(k)} } })
- end
-
- # Specify the ascending order of the records, sorted by the given
- # columns.
- #
- # @param columns [Array] the column names
- #
- # @return self
- #
- # @since 0.1.0
- #
- # @see Hanami::Model::Adapters::Memory::Query#reverse_order
- #
- # @example Single column
- #
- # query.order(:name)
- #
- # @example Multiple columns
- #
- # query.order(:name, :year)
- #
- # @example Multiple invokations
- #
- # query.order(:name).order(:year)
- def order(*columns)
- Hanami::Utils::Kernel.Array(columns).each do |column|
- modifiers.push(Proc.new{ sort_by!{|r| r.fetch(column)} })
- end
-
- self
- end
-
- # Alias for order
- #
- # @since 0.1.0
- #
- # @see Hanami::Model::Adapters::Memory::Query#order
- #
- # @example Single column
- #
- # query.asc(:name)
- #
- # @example Multiple columns
- #
- # query.asc(:name, :year)
- #
- # @example Multiple invokations
- #
- # query.asc(:name).asc(:year)
- alias_method :asc, :order
-
- # Specify the descending order of the records, sorted by the given
- # columns.
- #
- # @param columns [Array] the column names
- #
- # @return self
- #
- # @since 0.3.1
- #
- # @see Hanami::Model::Adapters::Memory::Query#order
- #
- # @example Single column
- #
- # query.reverse_order(:name)
- #
- # @example Multiple columns
- #
- # query.reverse_order(:name, :year)
- #
- # @example Multiple invokations
- #
- # query.reverse_order(:name).reverse_order(:year)
- def reverse_order(*columns)
- Hanami::Utils::Kernel.Array(columns).each do |column|
- modifiers.push(Proc.new{ sort_by!{|r| r.fetch(column)}.reverse! })
- end
-
- self
- end
-
- # Alias for reverse_order
- #
- # @since 0.1.0
- #
- # @see Hanami::Model::Adapters::Memory::Query#reverse_order
- #
- # @example Single column
- #
- # query.desc(:name)
- #
- # @example Multiple columns
- #
- # query.desc(:name, :year)
- #
- # @example Multiple invokations
- #
- # query.desc(:name).desc(:year)
- alias_method :desc, :reverse_order
-
- # Limit the number of records to return.
- #
- # @param number [Fixnum]
- #
- # @return self
- #
- # @since 0.1.0
- #
- # @example
- #
- # query.limit(1)
- def limit(number)
- modifiers.push(Proc.new{ replace(flatten.first(number)) })
- self
- end
-
- # Simulate an `OFFSET` clause, without the need of specify a limit.
- #
- # @param number [Fixnum]
- #
- # @return self
- #
- # @since 0.1.0
- #
- # @example
- #
- # query.offset(10)
- def offset(number)
- modifiers.unshift(Proc.new{ replace(flatten.drop(number)) })
- self
- end
-
- # Returns the sum of the values for the given column.
- #
- # @param column [Symbol] the column name
- #
- # @return [Numeric]
- #
- # @since 0.1.0
- #
- # @example
- #
- # query.sum(:comments_count)
- def sum(column)
- result = all
-
- if result.any?
- result.inject(0.0) do |acc, record|
- if value = record.public_send(column)
- acc += value
- end
-
- acc
- end
- end
- end
-
- # Returns the average of the values for the given column.
- #
- # @param column [Symbol] the column name
- #
- # @return [Numeric]
- #
- # @since 0.1.0
- #
- # @example
- #
- # query.average(:comments_count)
- def average(column)
- if s = sum(column)
- s / _all_with_present_column(column).count.to_f
- end
- end
-
- alias_method :avg, :average
-
- # Returns the maximum value for the given column.
- #
- # @param column [Symbol] the column name
- #
- # @return result
- #
- # @since 0.1.0
- #
- # @example
- #
- # query.max(:comments_count)
- def max(column)
- _all_with_present_column(column).max
- end
-
- # Returns the minimum value for the given column.
- #
- # @param column [Symbol] the column name
- #
- # @return result
- #
- # @since 0.1.0
- #
- # @example
- #
- # query.min(:comments_count)
- def min(column)
- _all_with_present_column(column).min
- end
-
- # Returns the difference between the MAX and MIN for the given column.
- #
- # @param column [Symbol] the column name
- #
- # @return [Numeric]
- #
- # @since 0.1.0
- #
- # @see Hanami::Model::Adapters::Memory::Query#max
- # @see Hanami::Model::Adapters::Memory::Query#min
- #
- # @example
- #
- # query.interval(:comments_count)
- def interval(column)
- max(column) - min(column)
- rescue NoMethodError
- end
-
- # Returns a range of values between the MAX and the MIN for the given
- # column.
- #
- # @param column [Symbol] the column name
- #
- # @return [Range]
- #
- # @since 0.1.0
- #
- # @see Hanami::Model::Adapters::Memory::Query#max
- # @see Hanami::Model::Adapters::Memory::Query#min
- #
- # @example
- #
- # query.range(:comments_count)
- def range(column)
- min(column)..max(column)
- end
-
- # Checks if at least one record exists for the current conditions.
- #
- # @return [TrueClass,FalseClass]
- #
- # @since 0.1.0
- #
- # @example
- #
- # query.where(author_id: 23).exists? # => true
- def exist?
- !count.zero?
- end
-
- # Returns a count of the records for the current conditions.
- #
- # @return [Fixnum]
- #
- # @since 0.1.0
- #
- # @example
- #
- # query.where(author_id: 23).count # => 5
- def count
- run.count
- end
-
- # This method is defined in order to make the interface of
- # `Memory::Query` identical to `Sql::Query`, but this feature is NOT
- # implemented
- #
- # @raise [NotImplementedError]
- #
- # @since 0.1.0
- #
- # @see Hanami::Model::Adapters::Sql::Query#negate!
- def negate!
- raise NotImplementedError
- end
-
- # This method is defined in order to make the interface of
- # `Memory::Query` identical to `Sql::Query`, but this feature is NOT
- # implemented
- #
- # @raise [NotImplementedError]
- #
- # @since 0.5.0
- #
- # @see Hanami::Model::Adapters::Sql::Query#group!
- def group
- raise NotImplementedError
- end
-
- protected
- def method_missing(m, *args, &blk)
- if @context.respond_to?(m)
- apply @context.public_send(m, *args, &blk)
- else
- super
- end
- end
-
- private
- # Apply all the conditions and returns a filtered collection.
- #
- # This operation is idempotent, but the records are actually fetched
- # from the memory store.
- #
- # @return [Array]
- #
- # @api private
- # @since 0.1.0
- def run
- result = @dataset.all.dup
-
- if conditions.any?
- prev_result = nil
- conditions.each do |(type, condition)|
- case type
- when :where
- prev_result = result
- result = prev_result.instance_exec(&condition)
- when :or
- result |= prev_result.instance_exec(&condition)
- end
- end
- end
-
- modifiers.map do |modifier|
- result.instance_exec(&modifier)
- end
-
- Hanami::Utils::Kernel.Array(result)
- end
-
- def _all_with_present_column(column)
- all.map {|record| record.public_send(column) }.compact
- end
-
- # Expands and yields keys and values of a query hash condition and
- # stores the result and condition type in the conditions array.
- #
- # It yields condition's keys and values to allow the caller to create a proc
- # object to be stored and executed later performing the actual query.
- #
- # @param condition_type [Symbol] the condition type. (eg. `:where`, `:or`)
- # @param condition [Hash] the query condition to be expanded.
- #
- # @return [Array] the conditions array itself.
- #
- # @api private
- # @since 0.3.1
- def _push_to_expanded_condition(condition_type, condition)
- proc = yield Array(condition).flatten(1)
- conditions.push([condition_type, proc])
- end
-
- # Evaluates a block condition of a specified type and stores it in the
- # conditions array.
- #
- # @param condition_type [Symbol] the condition type. (eg. `:where`, `:or`)
- # @param condition [Proc] the query condition to be evaluated and stored.
- # @param strategy [Symbol] the iterator method to be executed.
- # (eg. `:find_all`, `:reject`)
- #
- # @return [Array] the conditions array itself.
- #
- # @raise [Hanami::Model::InvalidQueryError] if block raises error when
- # evaluated.
- #
- # @api private
- # @since 0.3.1
- def _push_evaluated_block_condition(condition_type, condition, strategy)
- conditions.push([condition_type, Proc.new {
- send(strategy) { |r|
- begin
- OpenStruct.new(r).instance_eval(&condition)
- rescue NoMethodError
- # TODO improve the error message, informing which
- # attributes are invalid
- raise Hanami::Model::InvalidQueryError.new
- end
- }
- }])
- end
- end
- end
- end
- end
-end
diff --git a/lib/hanami/model/adapters/memory_adapter.rb b/lib/hanami/model/adapters/memory_adapter.rb
deleted file mode 100644
index cf2bd8d5..00000000
--- a/lib/hanami/model/adapters/memory_adapter.rb
+++ /dev/null
@@ -1,179 +0,0 @@
-require 'hanami/model/adapters/abstract'
-require 'hanami/model/adapters/implementation'
-require 'hanami/model/adapters/memory/collection'
-require 'hanami/model/adapters/memory/command'
-require 'hanami/model/adapters/memory/query'
-
-module Hanami
- module Model
- module Adapters
- # In memory adapter that behaves like a SQL database.
- # Not all the features of the SQL adapter are supported.
- #
- # This adapter SHOULD be used only for development or testing purposes,
- # because its computations are inefficient and the data is volatile.
- #
- # @see Hanami::Model::Adapters::Implementation
- #
- # @api private
- # @since 0.1.0
- class MemoryAdapter < Abstract
- include Implementation
-
- # Initialize the adapter.
- #
- # @param mapper [Object] the database mapper
- # @param uri [String] the connection uri (ignored)
- # @param options [Hash] a hash of non mandatory adapter options
- #
- # @return [Hanami::Model::Adapters::MemoryAdapter]
- #
- # @see Hanami::Model::Mapper
- #
- # @api private
- # @since 0.1.0
- def initialize(mapper, uri = nil, options = {})
- super
-
- @mutex = Mutex.new
- @collections = {}
- end
-
- # Creates a record in the database for the given entity.
- # It assigns the `id` attribute, in case of success.
- #
- # @param collection [Symbol] the target collection (it must be mapped).
- # @param entity [#id=] the entity to create
- #
- # @return [Object] the entity
- #
- # @api private
- # @since 0.1.0
- def create(collection, entity)
- synchronize do
- command(collection).create(entity)
- end
- end
-
- # Updates a record in the database corresponding to the given entity.
- #
- # @param collection [Symbol] the target collection (it must be mapped).
- # @param entity [#id] the entity to update
- #
- # @return [Object] the entity
- #
- # @api private
- # @since 0.1.0
- def update(collection, entity)
- synchronize do
- command(collection).update(entity)
- end
- end
-
- # Deletes a record in the database corresponding to the given entity.
- #
- # @param collection [Symbol] the target collection (it must be mapped).
- # @param entity [#id] the entity to delete
- #
- # @api private
- # @since 0.1.0
- def delete(collection, entity)
- synchronize do
- command(collection).delete(entity)
- end
- end
-
- # Deletes all the records from the given collection and resets the
- # identity counter.
- #
- # @param collection [Symbol] the target collection (it must be mapped).
- #
- # @api private
- # @since 0.1.0
- def clear(collection)
- synchronize do
- command(collection).clear
- end
- end
-
- # Fabricates a command for the given query.
- #
- # @param collection [Symbol] the collection name (it must be mapped)
- #
- # @return [Hanami::Model::Adapters::Memory::Command]
- #
- # @see Hanami::Model::Adapters::Memory::Command
- #
- # @api private
- # @since 0.1.0
- def command(collection)
- Memory::Command.new(_collection(collection), _mapped_collection(collection))
- end
-
- # Fabricates a query
- #
- # @param collection [Symbol] the target collection (it must be mapped).
- # @param blk [Proc] a block of code to be executed in the context of
- # the query.
- #
- # @return [Hanami::Model::Adapters::Memory::Query]
- #
- # @see Hanami::Model::Adapters::Memory::Query
- #
- # @api private
- # @since 0.1.0
- def query(collection, context = nil, &blk)
- synchronize do
- Memory::Query.new(_collection(collection), _mapped_collection(collection), &blk)
- end
- end
-
- # WARNING: this is a no-op. For "real" transactions please use
- # `SqlAdapter` or another adapter that supports them
- #
- # @param options [Hash] options for transaction
- #
- # @see Hanami::Model::Adapters::SqlAdapter#transaction
- # @see Hanami::Model::Adapters::Abstract#transaction
- #
- # @since 0.2.3
- def transaction(options = {})
- yield
- end
-
- # @api private
- # @since 0.5.0
- #
- # @see Hanami::Model::Adapters::Abstract#disconnect
- def disconnect
- @collections = DisconnectedResource.new
- @mutex = DisconnectedResource.new
- end
-
- private
-
- # Returns a collection from the given name.
- #
- # @param name [Symbol] a name of the collection (it must be mapped).
- #
- # @return [Hanami::Model::Adapters::Memory::Collection]
- #
- # @see Hanami::Model::Adapters::Memory::Collection
- #
- # @api private
- # @since 0.1.0
- def _collection(name)
- @collections[name] ||= Memory::Collection.new(name, _identity(name))
- end
-
- # Executes the given block within a critical section.
- #
- # @api private
- # @since 0.2.0
- def synchronize
- @mutex.synchronize { yield }
- end
- end
- end
- end
-end
diff --git a/lib/hanami/model/adapters/null_adapter.rb b/lib/hanami/model/adapters/null_adapter.rb
deleted file mode 100644
index 342972ef..00000000
--- a/lib/hanami/model/adapters/null_adapter.rb
+++ /dev/null
@@ -1,24 +0,0 @@
-require 'hanami/model/error'
-
-module Hanami
- module Model
- module Adapters
- # @since 0.2.0
- class NoAdapterError < Hanami::Model::Error
- def initialize(method_name)
- super("Cannot invoke `#{ method_name }' on repository. "\
- "Please check if `adapter' and `mapping' are set, "\
- "and that you call `.load!' on the configuration.")
- end
- end
-
- # @since 0.2.0
- # @api private
- class NullAdapter
- def method_missing(m, *args)
- raise NoAdapterError.new(m)
- end
- end
- end
- end
-end
diff --git a/lib/hanami/model/adapters/sql/collection.rb b/lib/hanami/model/adapters/sql/collection.rb
deleted file mode 100644
index 9630c8c5..00000000
--- a/lib/hanami/model/adapters/sql/collection.rb
+++ /dev/null
@@ -1,287 +0,0 @@
-require 'delegate'
-require 'hanami/utils/kernel' unless RUBY_VERSION >= '2.1'
-
-module Hanami
- module Model
- module Adapters
- module Sql
- # Maps a SQL database table and perfoms manipulations on it.
- #
- # @api private
- # @since 0.1.0
- #
- # @see http://sequel.jeremyevans.net/rdoc/files/doc/dataset_basics_rdoc.html
- # @see http://sequel.jeremyevans.net/rdoc/files/doc/dataset_filtering_rdoc.html
- class Collection < SimpleDelegator
- # Initialize a collection
- #
- # @param dataset [Sequel::Dataset] the dataset that maps a table or a
- # subset of it.
- # @param mapped_collection [Hanami::Model::Mapping::Collection] a
- # mapped collection
- #
- # @return [Hanami::Model::Adapters::Sql::Collection]
- #
- # @api private
- # @since 0.1.0
- def initialize(dataset, mapped_collection)
- super(dataset)
- @mapped_collection = mapped_collection
- end
-
- # Filters the current scope with an `exclude` directive.
- #
- # @param args [Array] the array of arguments
- #
- # @see Hanami::Model::Adapters::Sql::Query#exclude
- #
- # @return [Hanami::Model::Adapters::Sql::Collection] the filtered
- # collection
- #
- # @api private
- # @since 0.1.0
- def exclude(*args)
- Collection.new(super, @mapped_collection)
- end
-
- # Creates a record for the given entity and assigns an id.
- #
- # @param entity [Object] the entity to persist
- #
- # @see Hanami::Model::Adapters::Sql::Command#create
- #
- # @return the primary key of the created record
- #
- # @api private
- # @since 0.1.0
- def insert(entity)
- serialized_entity = _serialize(entity)
- serialized_entity[identity] = super(serialized_entity)
-
- _deserialize(serialized_entity)
- end
-
- # Filters the current scope with a `limit` directive.
- #
- # @param args [Array] the array of arguments
- #
- # @see Hanami::Model::Adapters::Sql::Query#limit
- #
- # @return [Hanami::Model::Adapters::Sql::Collection] the filtered
- # collection
- #
- # @api private
- # @since 0.1.0
- def limit(*args)
- Collection.new(super, @mapped_collection)
- end
-
- # Filters the current scope with an `offset` directive.
- #
- # @param args [Array] the array of arguments
- #
- # @see Hanami::Model::Adapters::Sql::Query#offset
- #
- # @return [Hanami::Model::Adapters::Sql::Collection] the filtered
- # collection
- #
- # @api private
- # @since 0.1.0
- def offset(*args)
- Collection.new(super, @mapped_collection)
- end
-
- # Filters the current scope with an `or` directive.
- #
- # @param args [Array] the array of arguments
- #
- # @see Hanami::Model::Adapters::Sql::Query#or
- #
- # @return [Hanami::Model::Adapters::Sql::Collection] the filtered
- # collection
- #
- # @api private
- # @since 0.1.0
- def or(*args)
- Collection.new(super, @mapped_collection)
- end
-
- # Filters the current scope with an `order` directive.
- #
- # @param args [Array] the array of arguments
- #
- # @see Hanami::Model::Adapters::Sql::Query#order
- #
- # @return [Hanami::Model::Adapters::Sql::Collection] the filtered
- # collection
- #
- # @api private
- # @since 0.1.0
- def order(*args)
- Collection.new(super, @mapped_collection)
- end
-
- # Filters the current scope with an `order` directive.
- #
- # @param args [Array] the array of arguments
- #
- # @see Hanami::Model::Adapters::Sql::Query#order
- #
- # @return [Hanami::Model::Adapters::Sql::Collection] the filtered
- # collection
- #
- # @api private
- # @since 0.1.0
- def order_more(*args)
- Collection.new(super, @mapped_collection)
- end
-
- # Filters the current scope with a `select` directive.
- #
- # @param args [Array] the array of arguments
- #
- # @see Hanami::Model::Adapters::Sql::Query#select
- #
- # @return [Hanami::Model::Adapters::Sql::Collection] the filtered
- # collection
- #
- # @api private
- # @since 0.1.0
- if RUBY_VERSION >= '2.1'
- def select(*args)
- Collection.new(super, @mapped_collection)
- end
- else
- def select(*args)
- Collection.new(__getobj__.select(*Hanami::Utils::Kernel.Array(args)), @mapped_collection)
- end
- end
-
-
- # Filters the current scope with a `group` directive.
- #
- # @param args [Array] the array of arguments
- #
- # @see Hanami::Model::Adapters::Sql::Query#group
- #
- # @return [Hanami::Model::Adapters::Sql::Collection] the filtered
- # collection
- #
- # @api private
- # @since 0.5.0
- def group(*args)
- Collection.new(super, @mapped_collection)
- end
-
- # Filters the current scope with a `where` directive.
- #
- # @param args [Array] the array of arguments
- #
- # @see Hanami::Model::Adapters::Sql::Query#where
- #
- # @return [Hanami::Model::Adapters::Sql::Collection] the filtered
- # collection
- #
- # @api private
- # @since 0.1.0
- def where(*args)
- Collection.new(super, @mapped_collection)
- end
-
- # Updates the record corresponding to the given entity.
- #
- # @param entity [Object] the entity to persist
- #
- # @see Hanami::Model::Adapters::Sql::Command#update
- #
- # @api private
- # @since 0.1.0
- def update(entity)
- serialized_entity = _serialize(entity)
- super(serialized_entity)
-
- _deserialize(serialized_entity)
- end
-
- # Resolves self by fetching the records from the database and
- # translating them into entities.
- #
- # @return [Array] the result of the query
- #
- # @api private
- # @since 0.1.0
- def to_a
- @mapped_collection.deserialize(self)
- end
-
- # Select all attributes for current scope
- #
- # @return [Hanami::Model::Adapters::Sql::Collection] the filtered
- # collection
- #
- # @api private
- # @since 0.5.0
- #
- # @see http://www.rubydoc.info/github/jeremyevans/sequel/Sequel%2FDataset%3Aselect_all
- def select_all
- Collection.new(super(table_name), @mapped_collection)
- end
-
- # Use join table for current scope
- #
- # @return [Hanami::Model::Adapters::Sql::Collection] the filtered
- # collection
- #
- # @api private
- # @since 0.5.0
- #
- # @see http://www.rubydoc.info/github/jeremyevans/sequel/Sequel%2FDataset%3Ajoin_table
- def join_table(*args)
- Collection.new(super, @mapped_collection)
- end
-
- # Return table name mapped collection
- #
- # @return [String] table name
- #
- # @api private
- # @since 0.5.0
- def table_name
- @mapped_collection.name
- end
-
- # Name of the identity column in database
- #
- # @return [Symbol] the identity name
- #
- # @api private
- # @since 0.5.0
- def identity
- @mapped_collection.identity
- end
-
- private
- # Serialize the given entity before to persist in the database.
- #
- # @return [Hash] the serialized entity
- #
- # @api private
- # @since 0.1.0
- def _serialize(entity)
- @mapped_collection.serialize(entity)
- end
-
- # Deserialize the given entity after it was persisted in the database.
- #
- # @return [Hanami::Entity] the deserialized entity
- #
- # @api private
- # @since 0.2.2
- def _deserialize(entity)
- @mapped_collection.deserialize([entity]).first
- end
- end
- end
- end
- end
-end
diff --git a/lib/hanami/model/adapters/sql/command.rb b/lib/hanami/model/adapters/sql/command.rb
deleted file mode 100644
index 9fbad020..00000000
--- a/lib/hanami/model/adapters/sql/command.rb
+++ /dev/null
@@ -1,88 +0,0 @@
-module Hanami
- module Model
- module Adapters
- module Sql
- # Execute a command for the given query.
- #
- # @see Hanami::Model::Adapters::Sql::Query
- #
- # @api private
- # @since 0.1.0
- class Command
- # @api private
- # @since 0.6.1
- SEQUEL_TO_HANAMI_ERROR_MAPPING = {
- 'Sequel::UniqueConstraintViolation' => Hanami::Model::UniqueConstraintViolationError,
- 'Sequel::ForeignKeyConstraintViolation' => Hanami::Model::ForeignKeyConstraintViolationError,
- 'Sequel::NotNullConstraintViolation' => Hanami::Model::NotNullConstraintViolationError,
- 'Sequel::CheckConstraintViolation' => Hanami::Model::CheckConstraintViolationError
- }.freeze
-
- # Initialize a command
- #
- # @param query [Hanami::Model::Adapters::Sql::Query]
- #
- # @api private
- # @since 0.1.0
- def initialize(query)
- @collection = query.scoped
- end
-
- # Creates a record for the given entity.
- #
- # @param entity [Object] the entity to persist
- #
- # @see Hanami::Model::Adapters::Sql::Collection#insert
- #
- # @return the primary key of the just created record.
- #
- # @api private
- # @since 0.1.0
- def create(entity)
- _handle_database_error { @collection.insert(entity) }
- end
-
- # Updates the corresponding record for the given entity.
- #
- # @param entity [Object] the entity to persist
- #
- # @see Hanami::Model::Adapters::Sql::Collection#update
- #
- # @api private
- # @since 0.1.0
- def update(entity)
- _handle_database_error { @collection.update(entity) }
- end
-
- # Deletes all the records for the current query.
- #
- # It's used to delete a single record or an entire database table.
- #
- # @see Hanami::Model::Adapters::SqlAdapter#delete
- # @see Hanami::Model::Adapters::SqlAdapter#clear
- #
- # @api private
- # @since 0.1.0
- def delete
- _handle_database_error { @collection.delete }
- end
-
- alias_method :clear, :delete
-
- private
-
- # Handles any possible Adapter's Database Error
- #
- # @api private
- # @since 0.6.1
- def _handle_database_error
- yield
- rescue Sequel::DatabaseError => e
- error_class = SEQUEL_TO_HANAMI_ERROR_MAPPING.fetch(e.class.name, Hanami::Model::InvalidCommandError)
- raise error_class, e.message
- end
- end
- end
- end
- end
-end
diff --git a/lib/hanami/model/adapters/sql/console.rb b/lib/hanami/model/adapters/sql/console.rb
deleted file mode 100644
index ab403bf7..00000000
--- a/lib/hanami/model/adapters/sql/console.rb
+++ /dev/null
@@ -1,33 +0,0 @@
-module Hanami
- module Model
- module Adapters
- module Sql
- class Console
- extend Forwardable
-
- def_delegator :console, :connection_string
-
- def initialize(uri)
- @uri = URI.parse(uri)
- end
-
- private
-
- def console
- case @uri.scheme
- when 'sqlite'
- require 'hanami/model/adapters/sql/consoles/sqlite'
- Consoles::Sqlite.new(@uri)
- when 'postgres'
- require 'hanami/model/adapters/sql/consoles/postgresql'
- Consoles::Postgresql.new(@uri)
- when 'mysql', 'mysql2'
- require 'hanami/model/adapters/sql/consoles/mysql'
- Consoles::Mysql.new(@uri)
- end
- end
- end
- end
- end
- end
-end
diff --git a/lib/hanami/model/adapters/sql/consoles/mysql.rb b/lib/hanami/model/adapters/sql/consoles/mysql.rb
deleted file mode 100644
index 89c32e3a..00000000
--- a/lib/hanami/model/adapters/sql/consoles/mysql.rb
+++ /dev/null
@@ -1,49 +0,0 @@
-require 'shellwords'
-module Hanami
- module Model
- module Adapters
- module Sql
- module Consoles
- class Mysql
- def initialize(uri)
- @uri = uri
- end
-
- def connection_string
- str = 'mysql'
- str << host
- str << database
- str << port if port
- str << username if username
- str << password if password
- str
- end
-
- private
-
- def host
- " -h #{@uri.host}"
- end
-
- def database
- " -D #{@uri.path.sub(/^\//, '')}"
- end
-
- def port
- " -P #{@uri.port}" if @uri.port
- end
-
- def username
- " -u #{@uri.user}" if @uri.user
- end
-
- def password
- " -p #{@uri.password}" if @uri.password
- end
- end
- end
- end
- end
- end
-end
-
diff --git a/lib/hanami/model/adapters/sql/consoles/postgresql.rb b/lib/hanami/model/adapters/sql/consoles/postgresql.rb
deleted file mode 100644
index bc2f6469..00000000
--- a/lib/hanami/model/adapters/sql/consoles/postgresql.rb
+++ /dev/null
@@ -1,48 +0,0 @@
-require 'shellwords'
-module Hanami
- module Model
- module Adapters
- module Sql
- module Consoles
- class Postgresql
- def initialize(uri)
- @uri = uri
- end
-
- def connection_string
- configure_password
- str = 'psql'
- str << host
- str << database
- str << port if port
- str << username if username
- str
- end
-
- private
-
- def host
- " -h #{@uri.host}"
- end
-
- def database
- " -d #{@uri.path.sub(/^\//, '')}"
- end
-
- def port
- " -p #{@uri.port}" if @uri.port
- end
-
- def username
- " -U #{@uri.user}" if @uri.user
- end
-
- def configure_password
- ENV['PGPASSWORD'] = @uri.password if @uri.password
- end
- end
- end
- end
- end
- end
-end
diff --git a/lib/hanami/model/adapters/sql/consoles/sqlite.rb b/lib/hanami/model/adapters/sql/consoles/sqlite.rb
deleted file mode 100644
index a60a9e85..00000000
--- a/lib/hanami/model/adapters/sql/consoles/sqlite.rb
+++ /dev/null
@@ -1,26 +0,0 @@
-require 'shellwords'
-module Hanami
- module Model
- module Adapters
- module Sql
- module Consoles
- class Sqlite
- def initialize(uri)
- @uri = uri
- end
-
- def connection_string
- "sqlite3 #{@uri.host}#{database}"
- end
-
- private
-
- def database
- Shellwords.escape(@uri.path)
- end
- end
- end
- end
- end
- end
-end
diff --git a/lib/hanami/model/adapters/sql/query.rb b/lib/hanami/model/adapters/sql/query.rb
deleted file mode 100644
index a1d113d5..00000000
--- a/lib/hanami/model/adapters/sql/query.rb
+++ /dev/null
@@ -1,788 +0,0 @@
-require 'forwardable'
-require 'hanami/utils/kernel'
-
-module Hanami
- module Model
- module Adapters
- module Sql
- # Query the database with a powerful API.
- #
- # All the methods are chainable, it allows advanced composition of
- # SQL conditions.
- #
- # This works as a lazy filtering mechanism: the records are fetched from
- # the database only when needed.
- #
- # @example
- #
- # query.where(language: 'ruby')
- # .and(framework: 'hanami')
- # .reverse_order(:users_count).all
- #
- # # the records are fetched only when we invoke #all
- #
- # It implements Ruby's `Enumerable` and borrows some methods from `Array`.
- # Expect a query to act like them.
- #
- # @since 0.1.0
- class Query
- # Define negations for operators.
- #
- # @see Hanami::Model::Adapters::Sql::Query#negate!
- #
- # @api private
- # @since 0.1.0
- OPERATORS_MAPPING = {
- where: :exclude,
- exclude: :where
- }.freeze
-
- include Enumerable
- extend Forwardable
-
- def_delegators :all, :each, :to_s, :empty?
-
- # @attr_reader conditions [Array] an accumulator for the called
- # methods
- #
- # @since 0.1.0
- # @api private
- attr_reader :conditions
-
- # Initialize a query
- #
- # @param collection [Hanami::Model::Adapters::Sql::Collection] the
- # collection to query
- #
- # @param blk [Proc] an optional block that gets yielded in the
- # context of the current query
- #
- # @return [Hanami::Model::Adapters::Sql::Query]
- def initialize(collection, context = nil, &blk)
- @collection, @context = collection, context
- @conditions = []
-
- instance_eval(&blk) if block_given?
- end
-
- # Resolves the query by fetching records from the database and
- # translating them into entities.
- #
- # @return [Array] a collection of entities
- #
- # @raise [Hanami::Model::InvalidQueryError] if there is some issue when
- # hitting the database for fetching records
- #
- # @since 0.1.0
- def all
- run.to_a
- rescue Sequel::DatabaseError => e
- raise Hanami::Model::InvalidQueryError.new(e.message)
- end
-
- # Adds a SQL `WHERE` condition.
- #
- # It accepts a `Hash` with only one pair.
- # The key must be the name of the column expressed as a `Symbol`.
- # The value is the one used by the SQL query
- #
- # @param condition [Hash]
- #
- # @return self
- #
- # @since 0.1.0
- #
- # @example Fixed value
- #
- # query.where(language: 'ruby')
- #
- # # => SELECT * FROM `projects` WHERE (`language` = 'ruby')
- #
- # @example Array
- #
- # query.where(id: [1, 3])
- #
- # # => SELECT * FROM `articles` WHERE (`id` IN (1, 3))
- #
- # @example Range
- #
- # query.where(year: 1900..1982)
- #
- # # => SELECT * FROM `people` WHERE ((`year` >= 1900) AND (`year` <= 1982))
- #
- # @example Multiple conditions
- #
- # query.where(language: 'ruby')
- # .where(framework: 'hanami')
- #
- # # => SELECT * FROM `projects` WHERE (`language` = 'ruby') AND (`framework` = 'hanami')
- #
- # @example Expressions
- #
- # query.where{ age > 10 }
- #
- # # => SELECT * FROM `users` WHERE (`age` > 31)
- def where(condition = nil, &blk)
- _push_to_conditions(:where, condition || blk)
- self
- end
-
- alias_method :and, :where
-
- # Adds a SQL `OR` condition.
- #
- # It accepts a `Hash` with only one pair.
- # The key must be the name of the column expressed as a `Symbol`.
- # The value is the one used by the SQL query
- #
- # This condition will be ignored if not used with WHERE.
- #
- # @param condition [Hash]
- #
- # @return self
- #
- # @since 0.1.0
- #
- # @example Fixed value
- #
- # query.where(language: 'ruby').or(framework: 'hanami')
- #
- # # => SELECT * FROM `projects` WHERE ((`language` = 'ruby') OR (`framework` = 'hanami'))
- #
- # @example Array
- #
- # query.where(id: 1).or(author_id: [15, 23])
- #
- # # => SELECT * FROM `articles` WHERE ((`id` = 1) OR (`author_id` IN (15, 23)))
- #
- # @example Range
- #
- # query.where(country: 'italy').or(year: 1900..1982)
- #
- # # => SELECT * FROM `people` WHERE ((`country` = 'italy') OR ((`year` >= 1900) AND (`year` <= 1982)))
- #
- # @example Expressions
- #
- # query.where(name: 'John').or{ age > 31 }
- #
- # # => SELECT * FROM `users` WHERE ((`name` = 'John') OR (`age` < 32))
- def or(condition = nil, &blk)
- _push_to_conditions(:or, condition || blk)
- self
- end
-
- # Logical negation of a WHERE condition.
- #
- # It accepts a `Hash` with only one pair.
- # The key must be the name of the column expressed as a `Symbol`.
- # The value is the one used by the SQL query
- #
- # @param condition [Hash]
- #
- # @since 0.1.0
- #
- # @return self
- #
- # @example Fixed value
- #
- # query.exclude(language: 'java')
- #
- # # => SELECT * FROM `projects` WHERE (`language` != 'java')
- #
- # @example Array
- #
- # query.exclude(id: [4, 9])
- #
- # # => SELECT * FROM `articles` WHERE (`id` NOT IN (1, 3))
- #
- # @example Range
- #
- # query.exclude(year: 1900..1982)
- #
- # # => SELECT * FROM `people` WHERE ((`year` < 1900) AND (`year` > 1982))
- #
- # @example Multiple conditions
- #
- # query.exclude(language: 'java')
- # .exclude(company: 'enterprise')
- #
- # # => SELECT * FROM `projects` WHERE (`language` != 'java') AND (`company` != 'enterprise')
- #
- # @example Expressions
- #
- # query.exclude{ age > 31 }
- #
- # # => SELECT * FROM `users` WHERE (`age` <= 31)
- def exclude(condition = nil, &blk)
- _push_to_conditions(:exclude, condition || blk)
- self
- end
-
- alias_method :not, :exclude
-
- # Select only the specified columns.
- #
- # By default a query selects all the columns of a table (`SELECT *`).
- #
- # @param columns [Array]
- #
- # @return self
- #
- # @since 0.1.0
- #
- # @example Single column
- #
- # query.select(:name)
- #
- # # => SELECT `name` FROM `people`
- #
- # @example Multiple columns
- #
- # query.select(:name, :year)
- #
- # # => SELECT `name`, `year` FROM `people`
- def select(*columns)
- conditions.push([:select, *columns])
- self
- end
-
- # Limit the number of records to return.
- #
- # This operation is performed at the database level with `LIMIT`.
- #
- # @param number [Fixnum]
- #
- # @return self
- #
- # @since 0.1.0
- #
- # @example
- #
- # query.limit(1)
- #
- # # => SELECT * FROM `people` LIMIT 1
- def limit(number)
- conditions.push([:limit, number])
- self
- end
-
- # Specify an `OFFSET` clause.
- #
- # Due to SQL syntax restriction, offset MUST be used with `#limit`.
- #
- # @param number [Fixnum]
- #
- # @return self
- #
- # @since 0.1.0
- #
- # @see Hanami::Model::Adapters::Sql::Query#limit
- #
- # @example
- #
- # query.limit(1).offset(10)
- #
- # # => SELECT * FROM `people` LIMIT 1 OFFSET 10
- def offset(number)
- conditions.push([:offset, number])
- self
- end
-
- # Specify the ascending order of the records, sorted by the given
- # columns.
- #
- # @param columns [Array] the column names
- #
- # @return self
- #
- # @since 0.1.0
- #
- # @see Hanami::Model::Adapters::Sql::Query#reverse_order
- #
- # @example Single column
- #
- # query.order(:name)
- #
- # # => SELECT * FROM `people` ORDER BY (`name`)
- #
- # @example Multiple columns
- #
- # query.order(:name, :year)
- #
- # # => SELECT * FROM `people` ORDER BY `name`, `year`
- #
- # @example Multiple invokations
- #
- # query.order(:name).order(:year)
- #
- # # => SELECT * FROM `people` ORDER BY `name`, `year`
- def order(*columns)
- conditions.push([_order_operator, *columns])
- self
- end
-
- # Alias for order
- #
- # @since 0.1.0
- #
- # @see Hanami::Model::Adapters::Sql::Query#order
- #
- # @example Single column
- #
- # query.asc(:name)
- #
- # # => SELECT * FROM `people` ORDER BY (`name`)
- #
- # @example Multiple columns
- #
- # query.asc(:name, :year)
- #
- # # => SELECT * FROM `people` ORDER BY `name`, `year`
- #
- # @example Multiple invokations
- #
- # query.asc(:name).asc(:year)
- #
- # # => SELECT * FROM `people` ORDER BY `name`, `year`
- alias_method :asc, :order
-
- # Specify the descending order of the records, sorted by the given
- # columns.
- #
- # @param columns [Array] the column names
- #
- # @return self
- #
- # @since 0.3.1
- #
- # @see Hanami::Model::Adapters::Sql::Query#order
- #
- # @example Single column
- #
- # query.reverse_order(:name)
- #
- # # => SELECT * FROM `people` ORDER BY (`name`) DESC
- #
- # @example Multiple columns
- #
- # query.reverse_order(:name, :year)
- #
- # # => SELECT * FROM `people` ORDER BY `name`, `year` DESC
- #
- # @example Multiple invokations
- #
- # query.reverse_order(:name).reverse_order(:year)
- #
- # # => SELECT * FROM `people` ORDER BY `name`, `year` DESC
- def reverse_order(*columns)
- Array(columns).each do |column|
- conditions.push([_order_operator, Sequel.desc(column)])
- end
-
- self
- end
-
- # Alias for reverse_order
- #
- # @since 0.1.0
- #
- # @see Hanami::Model::Adapters::Sql::Query#reverse_order
- #
- # @example Single column
- #
- # query.desc(:name)
- #
- # @example Multiple columns
- #
- # query.desc(:name, :year)
- #
- # @example Multiple invokations
- #
- # query.desc(:name).desc(:year)
- alias_method :desc, :reverse_order
-
- # Group by the specified columns.
- #
- # @param columns [Array]
- #
- # @return self
- #
- # @since 0.5.0
- #
- # @example Single column
- #
- # query.group(:name)
- #
- # # => SELECT * FROM `people` GROUP BY `name`
- #
- # @example Multiple columns
- #
- # query.group(:name, :year)
- #
- # # => SELECT * FROM `people` GROUP BY `name`, `year`
- def group(*columns)
- conditions.push([:group, *columns])
- self
- end
-
- # Returns the sum of the values for the given column.
- #
- # @param column [Symbol] the column name
- #
- # @return [Numeric]
- #
- # @since 0.1.0
- #
- # @example
- #
- # query.sum(:comments_count)
- #
- # # => SELECT SUM(`comments_count`) FROM articles
- def sum(column)
- run.sum(column)
- end
-
- # Returns the average of the values for the given column.
- #
- # @param column [Symbol] the column name
- #
- # @return [Numeric]
- #
- # @since 0.1.0
- #
- # @example
- #
- # query.average(:comments_count)
- #
- # # => SELECT AVG(`comments_count`) FROM articles
- def average(column)
- run.avg(column)
- end
-
- alias_method :avg, :average
-
- # Returns the maximum value for the given column.
- #
- # @param column [Symbol] the column name
- #
- # @return result
- #
- # @since 0.1.0
- #
- # @example With numeric type
- #
- # query.max(:comments_count)
- #
- # # => SELECT MAX(`comments_count`) FROM articles
- #
- # @example With string type
- #
- # query.max(:title)
- #
- # # => SELECT MAX(`title`) FROM articles
- def max(column)
- run.max(column)
- end
-
- # Returns the minimum value for the given column.
- #
- # @param column [Symbol] the column name
- #
- # @return result
- #
- # @since 0.1.0
- #
- # @example With numeric type
- #
- # query.min(:comments_count)
- #
- # # => SELECT MIN(`comments_count`) FROM articles
- #
- # @example With string type
- #
- # query.min(:title)
- #
- # # => SELECT MIN(`title`) FROM articles
- def min(column)
- run.min(column)
- end
-
- # Returns the difference between the MAX and MIN for the given column.
- #
- # @param column [Symbol] the column name
- #
- # @return [Numeric]
- #
- # @since 0.1.0
- #
- # @see Hanami::Model::Adapters::Sql::Query#max
- # @see Hanami::Model::Adapters::Sql::Query#min
- #
- # @example
- #
- # query.interval(:comments_count)
- #
- # # => SELECT (MAX(`comments_count`) - MIN(`comments_count`)) FROM articles
- def interval(column)
- run.interval(column)
- end
-
- # Returns a range of values between the MAX and the MIN for the given
- # column.
- #
- # @param column [Symbol] the column name
- #
- # @return [Range]
- #
- # @since 0.1.0
- #
- # @see Hanami::Model::Adapters::Sql::Query#max
- # @see Hanami::Model::Adapters::Sql::Query#min
- #
- # @example
- #
- # query.range(:comments_count)
- #
- # # => SELECT MAX(`comments_count`) AS v1, MIN(`comments_count`) AS v2 FROM articles
- def range(column)
- run.range(column)
- end
-
- # Checks if at least one record exists for the current conditions.
- #
- # @return [TrueClass,FalseClass]
- #
- # @since 0.1.0
- #
- # @example
- #
- # query.where(author_id: 23).exists? # => true
- def exist?
- !count.zero?
- end
-
- # Returns a count of the records for the current conditions.
- #
- # @return [Fixnum]
- #
- # @since 0.1.0
- #
- # @example
- #
- # query.where(author_id: 23).count # => 5
- def count
- run.count
- end
-
- # Negates the current where/exclude conditions with the logical
- # opposite operator.
- #
- # All the other conditions will be ignored.
- #
- # @since 0.1.0
- #
- # @see Hanami::Model::Adapters::Sql::Query#where
- # @see Hanami::Model::Adapters::Sql::Query#exclude
- # @see Hanami::Repository#exclude
- #
- # @example
- #
- # query.where(language: 'java').negate!.all
- #
- # # => SELECT * FROM `projects` WHERE (`language` != 'java')
- def negate!
- conditions.map! do |(operator, condition)|
- [OPERATORS_MAPPING.fetch(operator) { operator }, condition]
- end
- end
-
- # Apply all the conditions and returns a filtered collection.
- #
- # This operation is idempotent, and the returned result didn't
- # fetched the records yet.
- #
- # @return [Hanami::Model::Adapters::Sql::Collection]
- #
- # @since 0.1.0
- def scoped
- scope = @collection
-
- conditions.each do |(method,*args)|
- scope = scope.public_send(method, *args)
- end
-
- scope
- end
-
- alias_method :run, :scoped
-
- # Specify an `INNER JOIN` clause.
- #
- # @param collection [String]
- # @param options [Hash]
- # @option key [Symbol] the key
- # @option foreign_key [Symbol] the foreign key
- #
- # @return self
- #
- # @since 0.5.0
- #
- # @example
- #
- # query.join(:users)
- #
- # # => SELECT * FROM `posts` INNER JOIN `users` ON `posts`.`user_id` = `users`.`id`
- def join(collection, options = {})
- _join(collection, options.merge(join: :inner))
- end
-
- alias_method :inner_join, :join
-
- # Specify a `LEFT JOIN` clause.
- #
- # @param collection [String]
- # @param options [Hash]
- # @option key [Symbol] the key
- # @option foreign_key [Symbol] the foreign key
- #
- # @return self
- #
- # @since 0.5.0
- #
- # @example
- #
- # query.left_join(:users)
- #
- # # => SELECT * FROM `posts` LEFT JOIN `users` ON `posts`.`user_id` = `users`.`id`
- def left_join(collection, options = {})
- _join(collection, options.merge(join: :left))
- end
-
- alias_method :left_outer_join, :left_join
-
- protected
- # Handles missing methods for query combinations
- #
- # @api private
- # @since 0.1.0
- #
- # @see Hanami::Model::Adapters:Sql::Query#apply
- def method_missing(m, *args, &blk)
- if @context.respond_to?(m)
- apply @context.public_send(m, *args, &blk)
- else
- super
- end
- end
-
- private
-
- # Specify a JOIN clause. (inner or left)
- #
- # @param collection [String]
- # @param options [Hash]
- # @option key [Symbol] the key
- # @option foreign_key [Symbol] the foreign key
- # @option join [Symbol] the join type
- #
- # @return self
- #
- # @api private
- # @since 0.5.0
- def _join(collection, options = {})
- collection_name = Utils::String.new(collection).singularize
-
- foreign_key = options.fetch(:foreign_key) { "#{ @collection.table_name }__#{ collection_name }_id".to_sym }
- key = options.fetch(:key) { @collection.identity.to_sym }
-
- conditions.push([:select_all])
- conditions.push([:join_table, options.fetch(:join, :inner), collection, key => foreign_key])
-
- self
- end
-
- # Returns a new query that is the result of the merge of the current
- # conditions with the ones of the given query.
- #
- # This is used to combine queries together in a Repository.
- #
- # @param query [Hanami::Model::Adapters::Sql::Query] the query to apply
- #
- # @return [Hanami::Model::Adapters::Sql::Query] a new query with the
- # merged conditions
- #
- # @api private
- # @since 0.1.0
- #
- # @example
- # require 'hanami/model'
- #
- # class ArticleRepository
- # include Hanami::Repository
- #
- # def self.by_author(author)
- # query do
- # where(author_id: author.id)
- # end
- # end
- #
- # def self.rank
- # query.reverse_order(:comments_count)
- # end
- #
- # def self.rank_by_author(author)
- # rank.by_author(author)
- # end
- # end
- #
- # # The code above combines two queries: `rank` and `by_author`.
- # #
- # # The first class method `rank` returns a `Sql::Query` instance
- # # which doesn't respond to `by_author`. How to solve this problem?
- # #
- # # 1. When we use `query` to fabricate a `Sql::Query` we pass the
- # # current context (the repository itself) to the query initializer.
- # #
- # # 2. When that query receives the `by_author` message, it's captured
- # # by `method_missing` and dispatched to the repository.
- # #
- # # 3. The class method `by_author` returns a query too.
- # #
- # # 4. We just return a new query that is the result of the current
- # # query's conditions (`rank`) and of the conditions from `by_author`.
- # #
- # # You're welcome ;)
- def apply(query)
- dup.tap do |result|
- result.conditions.push(*query.conditions)
- end
- end
-
- # Stores a query condition of a specified type in the conditions array.
- #
- # @param condition_type [Symbol] the condition type. (eg. `:where`, `:or`)
- # @param condition [Hash, Proc] the query condition to be stored.
- #
- # @return [Array] the conditions array itself.
- #
- # @raise [ArgumentError] if condition is not specified.
- #
- # @api private
- # @since 0.3.1
- def _push_to_conditions(condition_type, condition)
- raise ArgumentError.new('You need to specify a condition.') if condition.nil?
- conditions.push([condition_type, condition])
- end
-
- def _order_operator
- if conditions.any? {|c, _| c == :order }
- :order_more
- else
- :order
- end
- end
- end
- end
- end
- end
-end
diff --git a/lib/hanami/model/adapters/sql_adapter.rb b/lib/hanami/model/adapters/sql_adapter.rb
deleted file mode 100644
index c761d4b0..00000000
--- a/lib/hanami/model/adapters/sql_adapter.rb
+++ /dev/null
@@ -1,296 +0,0 @@
-require 'hanami/model/adapters/abstract'
-require 'hanami/model/adapters/implementation'
-require 'hanami/model/adapters/sql/collection'
-require 'hanami/model/adapters/sql/command'
-require 'hanami/model/adapters/sql/query'
-require 'hanami/model/adapters/sql/console'
-require 'sequel'
-
-module Hanami
- module Model
- module Adapters
- # Adapter for SQL databases
- #
- # In order to use it with a specific database, you must require the Ruby
- # gem before of loading Hanami::Model.
- #
- # @see Hanami::Model::Adapters::Implementation
- #
- # @api private
- # @since 0.1.0
- class SqlAdapter < Abstract
- include Implementation
-
- # Initialize the adapter.
- #
- # Hanami::Model uses Sequel. For a complete reference of the connection
- # URI, please see: http://sequel.jeremyevans.net/rdoc/files/doc/opening_databases_rdoc.html
- #
- # @param mapper [Object] the database mapper
- # @param uri [String] the connection uri for the database
- # @param options [Hash] a hash of non-mandatory adapter options
- #
- # @return [Hanami::Model::Adapters::SqlAdapter]
- #
- # @raise [Hanami::Model::Adapters::DatabaseAdapterNotFound] if the given
- # URI refers to an unknown or not registered adapter.
- #
- # @raise [URI::InvalidURIError] if the given URI is malformed
- #
- # @see Hanami::Model::Mapper
- # @see http://sequel.jeremyevans.net/rdoc/files/doc/opening_databases_rdoc.html
- #
- # @api private
- # @since 0.1.0
- def initialize(mapper, uri, options = {})
- super
- @connection = Sequel.connect(@uri, @options)
- rescue Sequel::AdapterNotFound => e
- raise DatabaseAdapterNotFound.new(e.message)
- end
-
- # Creates a record in the database for the given entity.
- # It assigns the `id` attribute, in case of success.
- #
- # @param collection [Symbol] the target collection (it must be mapped).
- # @param entity [#id=] the entity to create
- #
- # @return [Object] the entity
- #
- # @api private
- # @since 0.1.0
- def create(collection, entity)
- command(
- query(collection)
- ).create(entity)
- end
-
- # Updates a record in the database corresponding to the given entity.
- #
- # @param collection [Symbol] the target collection (it must be mapped).
- # @param entity [#id] the entity to update
- #
- # @return [Object] the entity
- #
- # @api private
- # @since 0.1.0
- def update(collection, entity)
- command(
- _find(collection, entity.id)
- ).update(entity)
- end
-
- # Deletes a record in the database corresponding to the given entity.
- #
- # @param collection [Symbol] the target collection (it must be mapped).
- # @param entity [#id] the entity to delete
- #
- # @api private
- # @since 0.1.0
- def delete(collection, entity)
- command(
- _find(collection, entity.id)
- ).delete
- end
-
- # Deletes all the records from the given collection.
- #
- # @param collection [Symbol] the target collection (it must be mapped).
- #
- # @api private
- # @since 0.1.0
- def clear(collection)
- command(query(collection)).clear
- end
-
- # Fabricates a command for the given query.
- #
- # @param query [Hanami::Model::Adapters::Sql::Query] the query object to
- # act on.
- #
- # @return [Hanami::Model::Adapters::Sql::Command]
- #
- # @see Hanami::Model::Adapters::Sql::Command
- #
- # @api private
- # @since 0.1.0
- def command(query)
- Sql::Command.new(query)
- end
-
- # Fabricates a query
- #
- # @param collection [Symbol] the target collection (it must be mapped).
- # @param blk [Proc] a block of code to be executed in the context of
- # the query.
- #
- # @return [Hanami::Model::Adapters::Sql::Query]
- #
- # @see Hanami::Model::Adapters::Sql::Query
- #
- # @api private
- # @since 0.1.0
- def query(collection, context = nil, &blk)
- Sql::Query.new(_collection(collection), context, &blk)
- end
-
- # Wraps the given block in a transaction.
- #
- # For performance reasons the block isn't in the signature of the method,
- # but it's yielded at the lower level.
- #
- # @param options [Hash] options for transaction
- # @option rollback [Symbol] the optional rollback policy: `:always` or
- # `:reraise`.
- #
- # @see Hanami::Repository::ClassMethods#transaction
- #
- # @since 0.2.3
- # @api private
- #
- # @example Basic usage
- # require 'hanami/model'
- #
- # class Article
- # include Hanami::Entity
- # attributes :title, :body
- # end
- #
- # class ArticleRepository
- # include Hanami::Repository
- # end
- #
- # article = Article.new(title: 'Introducing transactions',
- # body: 'lorem ipsum')
- #
- # ArticleRepository.new.transaction do
- # ArticleRepository.dangerous_operation!(article) # => RuntimeError
- # # !!! ROLLBACK !!!
- # end
- #
- # @example Policy rollback always
- # require 'hanami/model'
- #
- # class Article
- # include Hanami::Entity
- # attributes :title, :body
- # end
- #
- # class ArticleRepository
- # include Hanami::Repository
- # end
- #
- # article = Article.new(title: 'Introducing transactions',
- # body: 'lorem ipsum')
- #
- # ArticleRepository.new.transaction(rollback: :always) do
- # ArticleRepository.new.create(article)
- # # !!! ROLLBACK !!!
- # end
- #
- # # The operation is rolled back, even in no exceptions were raised.
- #
- # @example Policy rollback reraise
- # require 'hanami/model'
- #
- # class Article
- # include Hanami::Entity
- # attributes :title, :body
- # end
- #
- # class ArticleRepository
- # include Hanami::Repository
- # end
- #
- # article = Article.new(title: 'Introducing transactions',
- # body: 'lorem ipsum')
- #
- # ArticleRepository.new.transaction(rollback: :reraise) do
- # ArticleRepository.dangerous_operation!(article) # => RuntimeError
- # # !!! ROLLBACK !!!
- # end # => RuntimeError
- #
- # # The operation is rolled back, but RuntimeError is re-raised.
- def transaction(options = {})
- @connection.transaction(options) do
- yield
- end
- end
-
- # Returns a string which can be executed to start a console suitable
- # for the configured database, adding the necessary CLI flags, such as
- # url, password, port number etc.
- #
- # @return [String]
- #
- # @since 0.3.0
- def connection_string
- Sql::Console.new(@uri).connection_string
- end
-
- # Executes a raw SQL command
- #
- # @param raw [String] the raw SQL statement to execute on the connection
- #
- # @raise [Hanami::Model::InvalidCommandError] if the raw SQL statement is invalid
- #
- # @return [NilClass]
- #
- # @since 0.3.1
- def execute(raw)
- begin
- @connection.execute(raw)
- nil
- rescue Sequel::DatabaseError => e
- raise Hanami::Model::InvalidCommandError.new(e.message)
- end
- end
-
- # Fetches raw result sets for the given SQL query
- #
- # @param raw [String] the raw SQL query
- # @param blk [Proc] optional block that is yielded for each record
- #
- # @return [Array]
- #
- # @raise [Hanami::Model::InvalidQueryError] if the raw SQL statement is invalid
- #
- # @since 0.5.0
- def fetch(raw, &blk)
- if block_given?
- @connection.fetch(raw, &blk)
- else
- @connection.fetch(raw).to_a
- end
- rescue Sequel::DatabaseError => e
- raise Hanami::Model::InvalidQueryError.new(e.message)
- end
-
- # @api private
- # @since 0.5.0
- #
- # @see Hanami::Model::Adapters::Abstract#disconnect
- def disconnect
- @connection.disconnect
- @connection = DisconnectedResource.new
- end
-
- private
-
- # Returns a collection from the given name.
- #
- # @param name [Symbol] a name of the collection (it must be mapped).
- #
- # @return [Hanami::Model::Adapters::Sql::Collection]
- #
- # @see Hanami::Model::Adapters::Sql::Collection
- #
- # @api private
- # @since 0.1.0
- def _collection(name)
- Sql::Collection.new(@connection[name], _mapped_collection(name))
- end
- end
- end
- end
-end
diff --git a/lib/hanami/model/association.rb b/lib/hanami/model/association.rb
new file mode 100644
index 00000000..01be1da8
--- /dev/null
+++ b/lib/hanami/model/association.rb
@@ -0,0 +1,37 @@
+require 'rom-sql'
+require 'hanami/model/associations/has_many'
+require 'hanami/model/associations/belongs_to'
+
+module Hanami
+ module Model
+ # Association factory
+ #
+ # @since x.x.x
+ # @api private
+ class Association
+ # Instantiate an association
+ #
+ # @since x.x.x
+ # @api private
+ def self.build(repository, target, subject)
+ lookup(repository.root.associations[target])
+ .new(repository, repository.root.name.to_sym, target, subject)
+ end
+
+ # Translate ROM SQL associations into Hanami::Model associations
+ #
+ # @since x.x.x
+ # @api private
+ def self.lookup(association)
+ case association
+ when ROM::SQL::Association::OneToMany
+ Associations::HasMany
+ when ROM::SQL::Association::ManyToOne
+ Associations::BelongsTo
+ else
+ raise "Unsupported association: #{association}"
+ end
+ end
+ end
+ end
+end
diff --git a/lib/hanami/model/associations/belongs_to.rb b/lib/hanami/model/associations/belongs_to.rb
new file mode 100644
index 00000000..803dac25
--- /dev/null
+++ b/lib/hanami/model/associations/belongs_to.rb
@@ -0,0 +1,19 @@
+require 'hanami/model/types'
+
+module Hanami
+ module Model
+ module Associations
+ # Many-To-One association
+ #
+ # @since x.x.x
+ # @api private
+ class BelongsTo
+ # @since x.x.x
+ # @api private
+ def self.schema_type(entity)
+ Sql::Types::Schema::AssociationType.new(entity)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/hanami/model/associations/dsl.rb b/lib/hanami/model/associations/dsl.rb
new file mode 100644
index 00000000..0b564d19
--- /dev/null
+++ b/lib/hanami/model/associations/dsl.rb
@@ -0,0 +1,29 @@
+module Hanami
+ module Model
+ module Associations
+ # Auto-infer relations linked to repository's associations
+ #
+ # @since x.x.x
+ # @api private
+ class Dsl
+ # @since x.x.x
+ # @api private
+ def initialize(repository, &blk)
+ @repository = repository
+ instance_eval(&blk)
+ end
+
+ # @since x.x.x
+ # @api private
+ def has_many(relation, *)
+ @repository.__send__(:relations, relation)
+ end
+
+ # @since x.x.x
+ # @api private
+ def belongs_to(*)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/hanami/model/associations/has_many.rb b/lib/hanami/model/associations/has_many.rb
new file mode 100644
index 00000000..3667fbf1
--- /dev/null
+++ b/lib/hanami/model/associations/has_many.rb
@@ -0,0 +1,179 @@
+require 'hanami/model/types'
+
+module Hanami
+ module Model
+ module Associations
+ # One-To-Many association
+ #
+ # @since x.x.x
+ # @api private
+ class HasMany
+ # @since x.x.x
+ # @api private
+ def self.schema_type(entity)
+ type = Sql::Types::Schema::AssociationType.new(entity)
+ Types::Strict::Array.member(type)
+ end
+
+ # @since x.x.x
+ # @api private
+ attr_reader :repository
+
+ # @since x.x.x
+ # @api private
+ attr_reader :source
+
+ # @since x.x.x
+ # @api private
+ attr_reader :target
+
+ # @since x.x.x
+ # @api private
+ attr_reader :subject
+
+ # @since x.x.x
+ # @api private
+ attr_reader :scope
+
+ # @since x.x.x
+ # @api private
+ def initialize(repository, source, target, subject, scope = nil)
+ @repository = repository
+ @source = source
+ @target = target
+ @subject = subject.to_h
+ @scope = scope || _build_scope
+ freeze
+ end
+
+ # @since x.x.x
+ # @api private
+ def add(data)
+ command(:create, relation(target), use: [:timestamps])
+ .call(associate(data))
+ end
+
+ # @since x.x.x
+ # @api private
+ def remove(id)
+ target_relation = relation(target)
+
+ command(:update, target_relation.where(target_relation.primary_key => id), use: [:timestamps])
+ .call(unassociate)
+ end
+
+ # @since x.x.x
+ # @api private
+ def delete
+ scope.delete
+ end
+
+ # @since x.x.x
+ # @api private
+ def each(&blk)
+ scope.each(&blk)
+ end
+
+ # @since x.x.x
+ # @api private
+ def map(&blk)
+ to_a.map(&blk)
+ end
+
+ # @since x.x.x
+ # @api private
+ def to_a
+ scope.to_a
+ end
+
+ # @since x.x.x
+ # @api private
+ def where(condition)
+ __new__(scope.where(condition))
+ end
+
+ # @since x.x.x
+ # @api private
+ def count
+ scope.count
+ end
+
+ private
+
+ # @since x.x.x
+ # @api private
+ def command(target, relation, options = {})
+ repository.command(target, relation, options)
+ end
+
+ # @since x.x.x
+ # @api private
+ def relation(name)
+ repository.relations[name]
+ end
+
+ # @since x.x.x
+ # @api private
+ def association(name)
+ relation(target).associations[name]
+ end
+
+ # @since x.x.x
+ # @api private
+ def associate(data)
+ relation(source)
+ .associations[target]
+ .associate(container.relations, data, subject)
+ end
+
+ # @since x.x.x
+ # @api private
+ def unassociate
+ { foreign_key => nil }
+ end
+
+ # @since x.x.x
+ # @api private
+ def container
+ repository.container
+ end
+
+ # @since x.x.x
+ # @api private
+ def primary_key
+ association_keys.first
+ end
+
+ # @since x.x.x
+ # @api private
+ def foreign_key
+ association_keys.last
+ end
+
+ # Returns primary key and foreign key
+ #
+ # @since x.x.x
+ # @api private
+ def association_keys
+ relation(source)
+ .associations[target]
+ .__send__(:join_key_map, container.relations)
+ end
+
+ # @since x.x.x
+ # @api private
+ def _build_scope
+ relation(target)
+ .where(foreign_key => subject.fetch(primary_key))
+ .as(Repository::MAPPER_NAME)
+ end
+
+ # @since x.x.x
+ # @api private
+ def __new__(new_scope)
+ self.class.new(repository, source, target, subject, new_scope)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/hanami/model/coercer.rb b/lib/hanami/model/coercer.rb
deleted file mode 100644
index 637ff8f4..00000000
--- a/lib/hanami/model/coercer.rb
+++ /dev/null
@@ -1,74 +0,0 @@
-module Hanami
- module Model
- # Abstract coercer
- #
- # It can be used as super class for custom mapping coercers.
- #
- # @since 0.5.0
- #
- # @see Hanami::Model::Mapper
- #
- # @example Postgres Array
- # require 'hanami/model/coercer'
- # require 'sequel/extensions/pg_array'
- #
- # class PGArray < Hanami::Model::Coercer
- # def self.dump(value)
- # ::Sequel.pg_array(value) rescue nil
- # end
- #
- # def self.load(value)
- # ::Kernel.Array(value) unless value.nil?
- # end
- # end
- #
- # Hanami::Model.configure do
- # mapping do
- # collection :articles do
- # entity Article
- # repository ArticleRepository
- #
- # attribute :id, Integer
- # attribute :title, String
- # attribute :tags, PGArray
- # end
- # end
- # end.load!
- #
- # # When the entity is serialized, it calls `PGArray.dump` to store `tags`
- # # as a Postgres Array.
- # #
- # # When the record is loaded (unserialized) from the database, it calls
- # # `PGArray.load` and returns a Ruby Array.
- class Coercer
- # Deserialize (load) a value coming from the database into a Ruby object.
- #
- # When inheriting from this class, it's a good practice to return nil
- # if the given value it's nil.
- #
- # @abstract
- #
- # @raise [TypeError] if the value can't be coerced
- #
- # @since 0.5.0
- #
- # @see Hanami::Model::Mapping::Coercers
- def self.load(value)
- raise NotImplementedError
- end
-
- # Serialize (dump) a Ruby object into a value that can be store by the database.
- #
- # @abstract
- #
- # @raise [TypeError] if the value can't be coerced
- #
- # @since 0.5.0
- #
- # @see Hanami::Model::Mapping::Coercers
- def self.dump(value)
- self.load(value)
- end
- end
- end
-end
diff --git a/lib/hanami/model/config/adapter.rb b/lib/hanami/model/config/adapter.rb
deleted file mode 100644
index 50ef247f..00000000
--- a/lib/hanami/model/config/adapter.rb
+++ /dev/null
@@ -1,113 +0,0 @@
-require 'hanami/utils/class'
-
-module Hanami
- module Model
- module Config
- # Raised when an adapter class does not exist
- #
- # @since 0.2.0
- class AdapterNotFound < Hanami::Model::Error
- def initialize(adapter_name, message)
- super "Cannot find Hanami::Model adapter `Hanami::Model::Adapters::#{adapter_name}' (#{message})"
- end
- end
-
- # Configuration for the adapter
- #
- # Hanami::Model has its own global configuration that can be manipulated
- # via `Hanami::Model.configure`.
- #
- # New adapter configuration can be registered via `Hanami::Model.adapter`.
- #
- # @see Hanami::Model.adapter
- #
- # @example
- # require 'hanami/model'
- #
- # Hanami::Model.configure do
- # adapter type: :sql, uri: 'postgres://localhost/database'
- # end
- #
- # Hanami::Model.configuration.adapter_config
- # # => Hanami::Model::Config::Adapter(type: :sql, uri: 'postgres://localhost/database')
- #
- # By convention, Hanami inflects type to find the adapter class
- # For example, if type is :sql, derived class will be `Hanami::Model::Adapters::SqlAdapter`
- #
- # @since 0.2.0
- class Adapter
- # @return [Symbol] the adapter name
- #
- # @since 0.2.0
- attr_reader :type
-
- # @return [String] the adapter URI
- #
- # @since 0.2.0
- attr_reader :uri
-
- # @return [Hash] a list of non-mandatory options for the adapter
- #
- attr_reader :options
-
- # @return [String] the adapter class name
- #
- # @since 0.2.0
- attr_reader :class_name
-
- # Initialize an adapter configuration instance
- #
- # @param options [Hash] configuration options
- # @option options [Symbol] :type adapter type name
- # @option options [String] :uri adapter URI
- #
- # @return [Hanami::Model::Config::Adapter] a new apdapter configuration's
- # instance
- #
- # @since 0.2.0
- def initialize(**options)
- opts = options.dup
-
- @type = opts.delete(:type)
- @uri = opts.delete(:uri)
- @options = opts
-
- @class_name ||= Hanami::Utils::String.new("#{@type}_adapter").classify
- end
-
- # Initialize the adapter
- #
- # @param mapper [Hanami::Model::Mapper] the mapper instance
- #
- # @return [Hanami::Model::Adapters::SqlAdapter, Hanami::Model::Adapters::MemoryAdapter] an adapter instance
- #
- # @see Hanami::Model::Adapters
- #
- # @since 0.2.0
- def build(mapper)
- load_adapter
- instantiate_adapter(mapper)
- end
-
- private
-
- def load_adapter
- begin
- require "hanami/model/adapters/#{type}_adapter"
- rescue LoadError => e
- raise AdapterNotFound.new(class_name, e.message)
- end
- end
-
- def instantiate_adapter(mapper)
- begin
- klass = Hanami::Utils::Class.load!(class_name, Hanami::Model::Adapters)
- klass.new(mapper, uri, options)
- rescue => e
- raise Hanami::Model::Error.new(e)
- end
- end
- end
- end
- end
-end
diff --git a/lib/hanami/model/config/mapper.rb b/lib/hanami/model/config/mapper.rb
deleted file mode 100644
index 380d1d6d..00000000
--- a/lib/hanami/model/config/mapper.rb
+++ /dev/null
@@ -1,45 +0,0 @@
-require 'hanami/utils/kernel'
-
-module Hanami
- module Model
- module Config
- # Read mapping file for mapping DSL
- #
- # @since 0.2.0
- # @api private
- class Mapper
- EXTNAME = '.rb'
-
- def initialize(path=nil, &blk)
- if block_given?
- @blk = blk
- elsif path
- @path = root.join(path)
- else
- raise Hanami::Model::InvalidMappingError.new('You must specify a block or a file.')
- end
- end
-
- def to_proc
- unless @blk
- code = realpath.read
- @blk = Proc.new { eval(code) }
- end
-
- @blk
- end
-
- private
- def realpath
- Utils::Kernel.Pathname("#{ @path }#{ EXTNAME }").realpath
- rescue Errno::ENOENT
- raise ArgumentError, 'You must specify a valid filepath.'
- end
-
- def root
- Utils::Kernel.Pathname(Dir.pwd).realpath
- end
- end
- end
- end
-end
diff --git a/lib/hanami/model/configuration.rb b/lib/hanami/model/configuration.rb
index 4bfd9a47..9a8a4589 100644
--- a/lib/hanami/model/configuration.rb
+++ b/lib/hanami/model/configuration.rb
@@ -1,5 +1,4 @@
-require 'hanami/model/config/adapter'
-require 'hanami/model/config/mapper'
+require 'rom/configuration'
module Hanami
module Model
@@ -9,265 +8,94 @@ module Model
# via `Hanami::Model.configure`.
#
# @since 0.2.0
- class Configuration
- # Default migrations path
- #
- # @since 0.4.0
+ class Configuration < ROM::Configuration
+ # @since x.x.x
# @api private
- #
- # @see Hanami::Model::Configuration#migrations
- DEFAULT_MIGRATIONS_PATH = Pathname.new('db/migrations').freeze
+ attr_reader :mappings
- # Default schema path
- #
- # @since 0.4.0
+ # @since x.x.x
# @api private
- #
- # @see Hanami::Model::Configuration#schema
- DEFAULT_SCHEMA_PATH = Pathname.new('db/schema.sql').freeze
+ attr_reader :entities
- # The persistence mapper
- #
- # @return [Hanami::Model::Mapper]
- #
# @since 0.2.0
- attr_reader :mapper
-
- # An adapter configuration template
- #
- # @return [Hanami::Model::Config::Adapter]
- #
- # @since 0.2.0
- attr_reader :adapter_config
-
- # Initialize a configuration instance
- #
- # @return [Hanami::Model::Configuration] a new configuration's
- # instance
- #
- # @since 0.2.0
- def initialize
- reset!
+ # @api private
+ def initialize(configurator)
+ super(configurator.backend, configurator.url)
+ @migrations = configurator._migrations
+ @schema = configurator._schema
+ @mappings = {}
+ @entities = {}
end
- # Reset all the values to the defaults
- #
- # @return void
+ # NOTE: This must be changed when we want to support several adapters at the time
#
- # @since 0.2.0
- def reset!
- @adapter = nil
- @adapter_config = nil
- @mapper = NullMapper.new
- @mapper_config = nil
- @migrations = DEFAULT_MIGRATIONS_PATH
- @schema = DEFAULT_SCHEMA_PATH
+ # @since x.x.x
+ # @api private
+ def url
+ connection.url
end
- alias_method :unload!, :reset!
-
- # Load the configuration for the current framework
+ # NOTE: This must be changed when we want to support several adapters at the time
#
- # @return void
- #
- # @since 0.2.0
- def load!
- _build_mapper
- _build_adapter
-
- mapper.load!(@adapter)
+ # @since x.x.x
+ # @api private
+ def connection
+ gateway.connection
end
- # Register adapter
- #
- # There could only 1 adapter can be registered per application
- #
- # @overload adapter
- # Retrieves the configured adapter
- # @return [Hanami::Model::Config::Adapter,NilClass] the adapter, if
- # present
- #
- # @overload adapter
- # Register the adapter
- # @param @options [Hash] A set of options to register an adapter
- # @option options [Symbol] :type The adapter type. Eg. :sql, :memory
- # (mandatory)
- # @option options [String] :uri The database uri string (mandatory)
- #
- # @return void
- #
- # @raise [ArgumentError] if one of the mandatory options is omitted
- #
- # @see Hanami::Model.configure
- # @see Hanami::Model::Config::Adapter
- #
- # @example Register the adapter
- # require 'hanami/model'
+ # NOTE: This must be changed when we want to support several adapters at the time
#
- # Hanami::Model.configure do
- # adapter type: :sql, uri: 'sqlite3://localhost/database'
- # end
- #
- # Hanami::Model.configuration.adapter_config
- #
- # @since 0.2.0
- def adapter(options = nil)
- if options.nil?
- @adapter_config
- else
- _check_adapter_options!(options)
- @adapter_config ||= Hanami::Model::Config::Adapter.new(options)
- end
+ # @since x.x.x
+ # @api private
+ def gateway
+ environment.gateways[:default]
end
- # Set global persistence mapping
- #
- # @overload mapping(blk)
- # Specify a set of mapping in the given block
- # @param blk [Proc] the mapping definitions
- #
- # @overload mapping(path)
- # Specify a relative path where to find the mapping file
- # @param path [String] the relative path
- #
- # @return void
- #
- # @see Hanami::Model.configure
- # @see Hanami::Model::Mapper
- #
- # @example Set global persistence mapper
- # require 'hanami/model'
- #
- # Hanami::Model.configure do
- # mapping do
- # collection :users do
- # entity User
- #
- # attribute :id, Integer
- # attribute :name, String
- # end
- # end
- # end
+ # Root directory
#
- # @since 0.2.0
- def mapping(path=nil, &blk)
- @mapper_config = Hanami::Model::Config::Mapper.new(path, &blk)
+ # @since 0.4.0
+ # @api private
+ def root
+ Hanami.respond_to?(:root) ? Hanami.root : Pathname.pwd
end
# Migrations directory
#
- # It defaults to db/migrations.
- #
- # @overload migrations
- # Get migrations directory
- # @return [Pathname] migrations directory
- #
- # @overload migrations(path)
- # Set migrations directory
- # @param path [String,Pathname] the path
- # @raise [Errno::ENOENT] if the given path doesn't exist
- #
# @since 0.4.0
- #
- # @see Hanami::Model::Migrations::DEFAULT_MIGRATIONS_PATH
- #
- # @example Set Custom Path
- # require 'hanami/model'
- #
- # Hanami::Model.configure do
- # # ...
- # migrations 'path/to/migrations'
- # end
- def migrations(path = nil)
- if path.nil?
- @migrations
- else
- @migrations = root.join(path).realpath
- end
+ def migrations
+ (@migrations.nil? ? root : root.join(@migrations)).realpath
end
- # Schema
- #
- # It defaults to db/schema.sql.
- #
- # @overload schema
- # Get schema path
- # @return [Pathname] schema path
- #
- # @overload schema(path)
- # Set schema path
- # @param path [String,Pathname] the path
+ # Path for schema dump file
#
# @since 0.4.0
- #
- # @see Hanami::Model::Migrations::DEFAULT_SCHEMA_PATH
- #
- # @example Set Custom Path
- # require 'hanami/model'
- #
- # Hanami::Model.configure do
- # # ...
- # schema 'path/to/schema.sql'
- # end
- def schema(path = nil)
- if path.nil?
- @schema
- else
- @schema = root.join(path)
- end
+ def schema
+ @schema.nil? ? root : root.join(@schema)
end
- # Root directory
- #
- # @since 0.4.0
+ # @since x.x.x
# @api private
- def root
- Hanami.respond_to?(:root) ? Hanami.root : Pathname.pwd
+ def define_mappings(root, &blk)
+ @mappings[root] = Mapping.new(&blk)
end
- # Duplicate by copying the settings in a new instance.
- #
- # @return [Hanami::Model::Configuration] a copy of the configuration
- #
- # @since 0.2.0
+ # @since x.x.x
# @api private
- def duplicate
- Configuration.new.tap do |c|
- c.instance_variable_set(:@adapter_config, @adapter_config)
- c.instance_variable_set(:@mapper, @mapper)
- end
+ def register_entity(plural, singular, klass)
+ @entities[plural] = klass
+ @entities[singular] = klass
end
- private
-
- # Instantiate mapper from mapping block
- #
- # @see Hanami::Model::Configuration#mapping
- #
+ # @since x.x.x
# @api private
- # @since 0.2.0
- def _build_mapper
- @mapper = Hanami::Model::Mapper.new(&@mapper_config) if @mapper_config
- end
+ def define_entities_mappings(container, repositories)
+ return unless defined?(Sql::Entity::Schema)
- # @api private
- # @since 0.1.0
- def _build_adapter
- @adapter = adapter_config.build(mapper)
- end
+ repositories.each do |r|
+ relation = r.relation
+ entity = r.entity
- # @api private
- # @since 0.2.0
- #
- # NOTE Drop this manual check when Ruby 2.0 will not be supported anymore.
- # Use keyword arguments instead.
- def _check_adapter_options!(options)
- # TODO Maybe this is a candidate for Hanami::Utils::Options
- # We already have two similar cases:
- # 1. Hanami::Router :only/:except for RESTful resources
- # 2. Hanami::Validations.validate_options!
- [:type, :uri].each do |keyword|
- raise ArgumentError.new("missing keyword: #{keyword}") if !options.keys.include?(keyword)
+ entity.schema = Sql::Entity::Schema.new(entities, container.relations[relation], mappings.fetch(relation))
end
end
end
diff --git a/lib/hanami/model/configurator.rb b/lib/hanami/model/configurator.rb
new file mode 100644
index 00000000..d188027c
--- /dev/null
+++ b/lib/hanami/model/configurator.rb
@@ -0,0 +1,62 @@
+module Hanami
+ module Model
+ # Configuration DSL
+ #
+ # @since x.x.x
+ # @api private
+ class Configurator
+ # @since x.x.x
+ # @api private
+ attr_reader :backend
+
+ # @since x.x.x
+ # @api private
+ attr_reader :url
+
+ # @since x.x.x
+ # @api private
+ attr_reader :directory
+
+ # @since x.x.x
+ # @api private
+ attr_reader :_migrations
+
+ # @since x.x.x
+ # @api private
+ attr_reader :_schema
+
+ # @since x.x.x
+ # @api private
+ def self.build(&block)
+ new.tap { |config| config.instance_eval(&block) }
+ end
+
+ private
+
+ # @since x.x.x
+ # @api private
+ def adapter(backend, url)
+ @backend = backend
+ @url = url
+ end
+
+ # @since x.x.x
+ # @api private
+ def path(path)
+ @directory = path
+ end
+
+ # @since x.x.x
+ # @api private
+ def migrations(path)
+ @_migrations = path
+ end
+
+ # @since x.x.x
+ # @api private
+ def schema(path)
+ @_schema = path
+ end
+ end
+ end
+end
diff --git a/lib/hanami/model/entity_name.rb b/lib/hanami/model/entity_name.rb
new file mode 100644
index 00000000..26d037a7
--- /dev/null
+++ b/lib/hanami/model/entity_name.rb
@@ -0,0 +1,35 @@
+module Hanami
+ module Model
+ # Conventional name for entities.
+ #
+ # Given a repository named SourceFileRepository, the associated
+ # entity will be SourceFile.
+ #
+ # @since x.x.x
+ # @api private
+ class EntityName
+ # @since x.x.x
+ # @api private
+ SUFFIX = /Repository\z/
+
+ # @param name [Class,String] the class or its name
+ # @return [String] the entity name
+ #
+ # @since x.x.x
+ # @api private
+ def initialize(name)
+ @name = name.sub(SUFFIX, '')
+ end
+
+ # @since x.x.x
+ # @api private
+ def underscore
+ Utils::String.new(@name).underscore.to_sym
+ end
+
+ def to_s
+ @name
+ end
+ end
+ end
+end
diff --git a/lib/hanami/model/error.rb b/lib/hanami/model/error.rb
index 1d026fb1..0a429421 100644
--- a/lib/hanami/model/error.rb
+++ b/lib/hanami/model/error.rb
@@ -1,41 +1,54 @@
+require 'concurrent'
+
module Hanami
module Model
-
# Default Error class
#
# @since 0.5.1
- Error = Class.new(::StandardError)
+ class Error < ::StandardError
+ # @api private
+ # @since x.x.x
+ @__mapping__ = Concurrent::Map.new # rubocop:disable Style/VariableNumber
- # Error for non persisted entity
- # It's raised when we try to update or delete a non persisted entity.
- #
- # @since 0.1.0
- #
- # @see Hanami::Repository.update
- NonPersistedEntityError = Class.new(Error)
+ # @api private
+ # @since x.x.x
+ def self.for(exception)
+ mapping.fetch(exception.class, self).new(exception)
+ end
- # Error for invalid mapper configuration
- # It's raised when mapping is not configured correctly
- #
- # @since 0.2.0
+ # @api private
+ # @since x.x.x
+ def self.register(external, internal)
+ mapping.put_if_absent(external, internal)
+ end
+
+ # @api private
+ # @since x.x.x
+ def self.mapping
+ @__mapping__
+ end
+ end
+
+ # Generic database error
#
- # @see Hanami::Configuration#mapping
- InvalidMappingError = Class.new(Error)
+ # @since x.x.x
+ class DatabaseError < Error
+ end
# Error for invalid raw command syntax
#
# @since 0.5.0
class InvalidCommandError < Error
- def initialize(message = "Invalid command")
+ def initialize(message = 'Invalid command')
super
end
end
- # Error for invalid raw query syntax
+ # Error for Constraint Violation
#
- # @since 0.3.1
- class InvalidQueryError < Error
- def initialize(message = "Invalid query")
+ # @since x.x.x
+ class ConstraintViolationError < Error
+ def initialize(message = 'Constraint has been violated')
super
end
end
@@ -44,7 +57,7 @@ def initialize(message = "Invalid query")
#
# @since 0.6.1
class UniqueConstraintViolationError < Error
- def initialize(message = "Unique constraint has been violated")
+ def initialize(message = 'Unique constraint has been violated')
super
end
end
@@ -53,7 +66,7 @@ def initialize(message = "Unique constraint has been violated")
#
# @since 0.6.1
class ForeignKeyConstraintViolationError < Error
- def initialize(message = "Foreign key constraint has been violated")
+ def initialize(message = 'Foreign key constraint has been violated')
super
end
end
@@ -62,7 +75,7 @@ def initialize(message = "Foreign key constraint has been violated")
#
# @since 0.6.1
class NotNullConstraintViolationError < Error
- def initialize(message = "NOT NULL constraint has been violated")
+ def initialize(message = 'NOT NULL constraint has been violated')
super
end
end
@@ -71,7 +84,7 @@ def initialize(message = "NOT NULL constraint has been violated")
#
# @since 0.6.1
class CheckConstraintViolationError < Error
- def initialize(message = "Check constraint has been violated")
+ def initialize(message = 'Check constraint has been violated')
super
end
end
diff --git a/lib/hanami/model/mapper.rb b/lib/hanami/model/mapper.rb
deleted file mode 100644
index d73f7488..00000000
--- a/lib/hanami/model/mapper.rb
+++ /dev/null
@@ -1,124 +0,0 @@
-require 'hanami/model/mapping'
-
-module Hanami
- module Model
- # Error for missing mapper
- # It's raised when loading model without mapper
- #
- # @since 0.2.0
- #
- # @see Hanami::Model::Configuration#mapping
- class NoMappingError < Hanami::Model::Error
- def initialize
- super("Mapping is missing. Please check your framework configuration.")
- end
- end
-
- # @since 0.2.0
- # @api private
- class NullMapper
- def method_missing(m, *args)
- raise NoMappingError.new
- end
- end
-
- # A persistence mapper that keeps entities independent from database details.
- #
- # This is database independent. It can work with SQL, document, and even
- # with key/value stores.
- #
- # @since 0.1.0
- #
- # @see http://martinfowler.com/eaaCatalog/dataMapper.html
- #
- # @example
- # require 'hanami/model'
- #
- # mapper = Hanami::Model::Mapper.new do
- # collection :users do
- # entity User
- #
- # attribute :id, Integer
- # attribute :name, String
- # end
- # end
- #
- # # This guarantees thread-safety and should happen as last thing before
- # # to start the app code.
- # mapper.load!
- class Mapper
- # @attr_reader collections [Hash] all the mapped collections
- #
- # @since 0.1.0
- # @api private
- attr_reader :collections
-
- # Instantiate a mapper.
- #
- # It accepts an optional argument (`coercer`) a class that defines the
- # policies for entities translations from/to the database.
- #
- # If provided, this class must implement the following interface:
- #
- # * #initialize(collection) # Hanami::Model::Mapping::Collection
- # * #to_record(entity) # translates an entity to the database type
- # * #from_record(record) # translates a record into an entity
- # * #deserialize_*(value) # a set of methods, one for each database column.
- #
- # If not given, it uses `Hanami::Model::Mapping::CollectionCoercer`, by default.
- #
- #
- #
- # @param coercer [Class] an optional class that defines the policies for
- # entity translations from/to the database.
- #
- # @param blk [Proc] an optional block of code that gets evaluated in the
- # context of the current instance
- #
- # @return [Hanami::Model::Mapper]
- #
- # @since 0.1.0
- def initialize(coercer = nil, &blk)
- @coercer = coercer || Mapping::CollectionCoercer
- @collections = {}
-
- instance_eval(&blk) if block_given?
- end
-
- # Maps a collection.
- #
- # A collection is a set of homogeneous records. Think of a table of a SQL
- # database or about collection of MongoDB.
- #
- # @param name [Symbol] the name of the mapped collection. If used with a
- # SQL database it's the table name.
- #
- # @param blk [Proc] the block that maps the attributes of that collection.
- #
- # @since 0.1.0
- #
- # @see Hanami::Model::Mapping::Collection
- def collection(name, &blk)
- if block_given?
- @collections[name] = Mapping::Collection.new(name, @coercer, &blk)
- else
- @collections[name] or raise Mapping::UnmappedCollectionError.new(name)
- end
- end
-
- # Loads the internals of the mapper, in order to guarantee thread safety.
- #
- # This method MUST be invoked as the last thing before of start using the
- # application.
- #
- # @since 0.1.0
- def load!(adapter = nil)
- @collections.each_value do |collection|
- collection.adapter = adapter
- collection.load!
- end
- self
- end
- end
- end
-end
diff --git a/lib/hanami/model/mapping.rb b/lib/hanami/model/mapping.rb
index 272ba33c..918296a4 100644
--- a/lib/hanami/model/mapping.rb
+++ b/lib/hanami/model/mapping.rb
@@ -1,47 +1,41 @@
-require 'hanami/model/mapping/collection'
-require 'hanami/model/mapping/collection_coercer'
-require 'hanami/model/mapping/coercers'
+require 'transproc'
module Hanami
module Model
- # Mapping internal utilities
+ # Mapping
#
# @since 0.1.0
- module Mapping
- # Unmapped collection error.
- #
- # It gets raised when the application tries to access to a non-mapped
- # collection.
- #
- # @since 0.1.0
- class UnmappedCollectionError < Hanami::Model::Error
- def initialize(name)
- super("Cannot find collection: #{ name }")
- end
+ class Mapping
+ def initialize(&blk)
+ @attributes = {}
+ @r_attributes = {}
+ instance_eval(&blk)
+ @processor = @attributes.empty? ? ::Hash : Transproc(:rename_keys, @attributes)
end
- # Invalid entity error.
- #
- # It gets raised when the application tries to access to a existing
- # entity.
- #
- # @since 0.2.0
- class EntityNotFound < Hanami::Model::Error
- def initialize(name)
- super("Cannot find class for entity: #{ name }")
- end
+ def model(entity)
end
- # Invalid repository error.
- #
- # It gets raised when the application tries to access to a existing
- # repository.
- #
- # @since 0.2.0
- class RepositoryNotFound < Hanami::Model::Error
- def initialize(name)
- super("Cannot find class for repository: #{ name }")
- end
+ def register_as(name)
+ end
+
+ def attribute(name, options)
+ from = options.fetch(:from, name)
+
+ @attributes[name] = from
+ @r_attributes[from] = name
+ end
+
+ def process(input)
+ @processor[input]
+ end
+
+ def reverse?
+ @r_attributes.any?
+ end
+
+ def translate(attribute)
+ @r_attributes.fetch(attribute)
end
end
end
diff --git a/lib/hanami/model/mapping/attribute.rb b/lib/hanami/model/mapping/attribute.rb
deleted file mode 100644
index 109ad043..00000000
--- a/lib/hanami/model/mapping/attribute.rb
+++ /dev/null
@@ -1,85 +0,0 @@
-require 'hanami/utils/class'
-
-module Hanami
- module Model
- module Mapping
- # Mapping attribute
- #
- # @api private
- # @since 0.5.0
- class Attribute
- # @api private
- # @since 0.5.0
- COERCERS_NAMESPACE = "Hanami::Model::Mapping::Coercers".freeze
-
- # Initialize a new attribute
- #
- # @param name [#to_sym] attribute name
- # @param coercer [.load, .dump] a coercer
- # @param options [Hash] a set of options
- #
- # @option options [#to_sym] :as Resolve mismatch between database column
- # name and entity attribute name
- #
- # @return [Hanami::Model::Mapping::Attribute]
- #
- # @api private
- # @since 0.5.0
- #
- # @see Hanami::Model::Coercer
- # @see Hanami::Model::Mapping::Coercers
- # @see Hanami::Model::Mapping::Collection#attribute
- def initialize(name, coercer, options)
- @name = name.to_sym
- @coercer = coercer
- @options = options
- end
-
- # Returns the mapped name
- #
- # @return [Symbol] the mapped name
- #
- # @api private
- # @since 0.5.0
- #
- # @see Hanami::Model::Mapping::Collection#attribute
- def mapped
- (@options.fetch(:as) { name }).to_sym
- end
-
- # @api private
- # @since 0.5.0
- def load_coercer
- "#{ coercer }.load"
- end
-
- # @api private
- # @since 0.5.0
- def dump_coercer
- "#{ coercer }.dump"
- end
-
- # @api private
- # @since 0.5.0
- def ==(other)
- self.class == other.class &&
- self.name == other.name &&
- self.mapped == other.mapped &&
- self.coercer == other.coercer
- end
-
- protected
-
- # @api private
- # @since 0.5.0
- attr_reader :name
-
- # @api private
- # @since 0.5.0
- def coercer
- Utils::Class.load_from_pattern!("(#{ COERCERS_NAMESPACE }::#{ @coercer }|#{ @coercer })")
- end
- end
- end
- end
-end
diff --git a/lib/hanami/model/mapping/coercers.rb b/lib/hanami/model/mapping/coercers.rb
deleted file mode 100644
index 523b4ab6..00000000
--- a/lib/hanami/model/mapping/coercers.rb
+++ /dev/null
@@ -1,314 +0,0 @@
-require 'hanami/model/coercer'
-require 'hanami/utils/kernel'
-
-module Hanami
- module Model
- module Mapping
- # Default coercers
- #
- # @since 0.5.0
- # @api private
- module Coercers
- # Array coercer
- #
- # @since 0.5.0
- # @api private
- #
- # @see Hanami::Model::Coercer
- class Array < Coercer
- # Transform a value from the database into a Ruby Array, unless nil
- #
- # @param value [Object] the object to coerce
- #
- # @return [Array] the result of the coercion
- #
- # @raise [TypeError] if the value can't be coerced
- #
- # @since 0.5.0
- # @api private
- #
- # @see Hanami::Model::Coercer.load
- # @see http://ruby-doc.org/core/Kernel.html#method-i-Array
- def self.load(value)
- ::Kernel.Array(value) unless value.nil?
- end
- end
-
- # Boolean coercer
- #
- # @since 0.5.0
- # @api private
- #
- # @see Hanami::Model::Coercer
- class Boolean < Coercer
- # Transform a value from the database into a Boolean, unless nil
- #
- # @param value [Object] the object to coerce
- #
- # @return [Boolean] the result of the coercion
- #
- # @raise [TypeError] if the value can't be coerced
- #
- # @since 0.5.0
- # @api private
- #
- # @see Hanami::Model::Coercer.load
- # @see http://rdoc.info/gems/hanami-utils/Hanami/Utils/Kernel#Boolean-class_method
- def self.load(value)
- Utils::Kernel.Boolean(value) unless value.nil?
- end
- end
-
- # Date coercer
- #
- # @since 0.5.0
- # @api private
- #
- # @see Hanami::Model::Coercer
- class Date < Coercer
- # Transform a value from the database into a Date, unless nil
- #
- # @param value [Object] the object to coerce
- #
- # @return [Date] the result of the coercion
- #
- # @raise [TypeError] if the value can't be coerced
- #
- # @since 0.5.0
- # @api private
- #
- # @see Hanami::Model::Coercer.load
- # @see http://rdoc.info/gems/hanami-utils/Hanami/Utils/Kernel#Date-class_method
- def self.load(value)
- Utils::Kernel.Date(value) unless value.nil?
- end
- end
-
- # DateTime coercer
- #
- # @since 0.5.0
- # @api private
- #
- # @see Hanami::Model::Coercer
- class DateTime < Coercer
- # Transform a value from the database into a DateTime, unless nil
- #
- # @param value [Object] the object to coerce
- #
- # @return [DateTime] the result of the coercion
- #
- # @raise [TypeError] if the value can't be coerced
- #
- # @since 0.5.0
- # @api private
- #
- # @see Hanami::Model::Coercer.load
- # @see http://rdoc.info/gems/hanami-utils/Hanami/Utils/Kernel#DateTime-class_method
- def self.load(value)
- Utils::Kernel.DateTime(value) unless value.nil?
- end
- end
-
- # Float coercer
- #
- # @since 0.5.0
- # @api private
- #
- # @see Hanami::Model::Coercer
- class Float < Coercer
- # Transform a value from the database into a Float, unless nil
- #
- # @param value [Object] the object to coerce
- #
- # @return [Float] the result of the coercion
- #
- # @raise [TypeError] if the value can't be coerced
- #
- # @since 0.5.0
- # @api private
- #
- # @see Hanami::Model::Coercer.load
- # @see http://rdoc.info/gems/hanami-utils/Hanami/Utils/Kernel#Float-class_method
- def self.load(value)
- Utils::Kernel.Float(value) unless value.nil?
- end
- end
-
- # Hash coercer
- #
- # @since 0.5.0
- # @api private
- #
- # @see Hanami::Model::Coercer
- class Hash < Coercer
- # Transform a value from the database into a Hash, unless nil
- #
- # @param value [Object] the object to coerce
- #
- # @return [Hash] the result of the coercion
- #
- # @raise [TypeError] if the value can't be coerced
- #
- # @since 0.5.0
- # @api private
- #
- # @see Hanami::Model::Coercer.load
- # @see http://rdoc.info/gems/hanami-utils/Hanami/Utils/Kernel#Hash-class_method
- def self.load(value)
- Utils::Kernel.Hash(value) unless value.nil?
- end
- end
-
- # Integer coercer
- #
- # @since 0.5.0
- # @api private
- #
- # @see Hanami::Model::Coercer
- class Integer < Coercer
- # Transform a value from the database into a Integer, unless nil
- #
- # @param value [Object] the object to coerce
- #
- # @return [Integer] the result of the coercion
- #
- # @raise [TypeError] if the value can't be coerced
- #
- # @since 0.5.0
- # @api private
- #
- # @see Hanami::Model::Coercer.load
- # @see http://rdoc.info/gems/hanami-utils/Hanami/Utils/Kernel#Integer-class_method
- def self.load(value)
- Utils::Kernel.Integer(value) unless value.nil?
- end
- end
-
- # BigDecimal coercer
- #
- # @since 0.5.0
- # @api private
- #
- # @see Hanami::Model::Coercer
- class BigDecimal < Coercer
- # Transform a value from the database into a BigDecimal, unless nil
- #
- # @param value [Object] the object to coerce
- #
- # @return [BigDecimal] the result of the coercion
- #
- # @raise [TypeError] if the value can't be coerced
- #
- # @since 0.5.0
- # @api private
- #
- # @see Hanami::Model::Coercer.load
- # @see http://rdoc.info/gems/hanami-utils/Hanami/Utils/Kernel#BigDecimal-class_method
- def self.load(value)
- Utils::Kernel.BigDecimal(value) unless value.nil?
- end
- end
-
- # Set coercer
- #
- # @since 0.5.0
- # @api private
- #
- # @see Hanami::Model::Coercer
- class Set < Coercer
- # Transform a value from the database into a Set, unless nil
- #
- # @param value [Object] the object to coerce
- #
- # @return [Set] the result of the coercion
- #
- # @raise [TypeError] if the value can't be coerced
- #
- # @since 0.5.0
- # @api private
- #
- # @see Hanami::Model::Coercer.load
- # @see http://rdoc.info/gems/hanami-utils/Hanami/Utils/Kernel#Set-class_method
- def self.load(value)
- Utils::Kernel.Set(value) unless value.nil?
- end
- end
-
- # String coercer
- #
- # @since 0.5.0
- # @api private
- #
- # @see Hanami::Model::Coercer
- class String < Coercer
- # Transform a value from the database into a String, unless nil
- #
- # @param value [Object] the object to coerce
- #
- # @return [String] the result of the coercion
- #
- # @raise [TypeError] if the value can't be coerced
- #
- # @since 0.5.0
- # @api private
- #
- # @see Hanami::Model::Coercer.load
- # @see http://rdoc.info/gems/hanami-utils/Hanami/Utils/Kernel#String-class_method
- def self.load(value)
- Utils::Kernel.String(value) unless value.nil?
- end
- end
-
- # Symbol coercer
- #
- # @since 0.5.0
- # @api private
- #
- # @see Hanami::Model::Coercer
- class Symbol < Coercer
- # Transform a value from the database into a Symbol, unless nil
- #
- # @param value [Object] the object to coerce
- #
- # @return [Symbol] the result of the coercion
- #
- # @raise [TypeError] if the value can't be coerced
- #
- # @since 0.5.0
- # @api private
- #
- # @see Hanami::Model::Coercer.load
- # @see http://rdoc.info/gems/hanami-utils/Hanami/Utils/Kernel#Symbol-class_method
- def self.load(value)
- Utils::Kernel.Symbol(value) unless value.nil?
- end
- end
-
- # Time coercer
- #
- # @since 0.5.0
- # @api private
- #
- # @see Hanami::Model::Coercer
- class Time < Coercer
- # Transform a value from the database into a Time, unless nil
- #
- # @param value [Object] the object to coerce
- #
- # @return [Time] the result of the coercion
- #
- # @raise [TypeError] if the value can't be coerced
- #
- # @since 0.5.0
- # @api private
- #
- # @see Hanami::Model::Coercer.load
- # @see http://rdoc.info/gems/hanami-utils/Hanami/Utils/Kernel#Time-class_method
- def self.load(value)
- Utils::Kernel.Time(value) unless value.nil?
- end
- end
- end
- end
- end
-end
diff --git a/lib/hanami/model/mapping/collection.rb b/lib/hanami/model/mapping/collection.rb
deleted file mode 100644
index af7c1ab3..00000000
--- a/lib/hanami/model/mapping/collection.rb
+++ /dev/null
@@ -1,490 +0,0 @@
-require 'hanami/utils/class'
-require 'hanami/model/mapping/attribute'
-
-module Hanami
- module Model
- module Mapping
- # Maps a collection and its attributes.
- #
- # A collection is a set of homogeneous records. Think of a table of a SQL
- # database or about collection of MongoDB.
- #
- # This is database independent. It can work with SQL, document, and even
- # with key/value stores.
- #
- # @since 0.1.0
- #
- # @see Hanami::Model::Mapper
- #
- # @example
- # require 'hanami/model'
- #
- # mapper = Hanami::Model::Mapper.new do
- # collection :users do
- # entity User
- #
- # attribute :id, Integer
- # attribute :name, String
- # end
- # end
- class Collection
- # Repository name suffix
- #
- # @api private
- # @since 0.1.0
- #
- # @see Hanami::Repository
- REPOSITORY_SUFFIX = 'Repository'.freeze
-
- # @attr_reader name [Symbol] the name of the collection
- #
- # @since 0.1.0
- # @api private
- attr_reader :name
-
- # @attr_reader coercer_class [Class] the coercer class
- #
- # @since 0.1.0
- # @api private
- attr_reader :coercer_class
-
- # @attr_reader attributes [Hash] the set of attributes
- #
- # @since 0.1.0
- # @api private
- attr_reader :attributes
-
- # @attr_reader adapter [Hanami::Model::Adapters] the instance of adapter
- #
- # @since 0.1.0
- # @api private
- attr_accessor :adapter
-
- # Instantiate a new collection
- #
- # @param name [Symbol] the name of the mapped collection. If used with a
- # SQL database it's the table name.
- #
- # @param coercer_class [Class] the coercer class
- # @param blk [Proc] the block that maps the attributes of that collection.
- #
- # @since 0.1.0
- #
- # @see Hanami::Model::Mapper#collection
- def initialize(name, coercer_class, &blk)
- @name = name
- @coercer_class = coercer_class
- @attributes = {}
- instance_eval(&blk) if block_given?
- end
-
- # Defines the entity that is persisted with this collection.
- #
- # The entity can be any kind of object as long as it implements the
- # following interface: `#initialize(attributes = {})`.
- #
- # @param klass [Class, String] the entity persisted with this collection.
- #
- # @since 0.1.0
- #
- # @see Hanami::Entity
- #
- # @example Set entity with class name
- # require 'hanami/model'
- #
- # mapper = Hanami::Model::Mapper.new do
- # collection :articles do
- # entity Article
- # end
- # end
- #
- # mapper.entity #=> Article
- #
- # @example Set entity with class name string
- # require 'hanami/model'
- #
- # mapper = Hanami::Model::Mapper.new do
- # collection :articles do
- # entity 'Article'
- # end
- # end
- #
- # mapper.entity #=> Article
- #
- def entity(klass = nil)
- if klass
- @entity = klass
- else
- @entity
- end
- end
-
- # Defines the repository that interacts with this collection.
- #
- # @param klass [Class, String] the repository that interacts with this collection.
- #
- # @since 0.2.0
- #
- # @see Hanami::Repository
- #
- # @example Set repository with class name
- # require 'hanami/model'
- #
- # mapper = Hanami::Model::Mapper.new do
- # collection :articles do
- # entity Article
- #
- # repository RemoteArticleRepository
- # end
- # end
- #
- # mapper.repository #=> RemoteArticleRepository
- #
- # @example Set repository with class name string
- # require 'hanami/model'
- #
- # mapper = Hanami::Model::Mapper.new do
- # collection :articles do
- # entity Article
- #
- # repository 'RemoteArticleRepository'
- # end
- # end
- #
- # mapper.repository #=> RemoteArticleRepository
- def repository(klass = nil)
- if klass
- @repository = klass
- else
- @repository ||= default_repository_klass
- end
- end
-
- # Defines the identity for a collection.
- #
- # An identity is a unique value that identifies a record.
- # If used with an SQL table it corresponds to the primary key.
- #
- # This is an optional feature.
- # By default the system assumes that your identity is `:id`.
- # If this is the case, you can omit the value, otherwise you have to
- # specify it.
- #
- # @param name [Symbol] the name of the identity
- #
- # @since 0.1.0
- #
- # @example Default
- # require 'hanami/model'
- #
- # # We have an SQL table `users` with a primary key `id`.
- # #
- # # This this is compliant to the mapper default, we can omit
- # # `#identity`.
- #
- # mapper = Hanami::Model::Mapper.new do
- # collection :users do
- # entity User
- #
- # # attribute definitions..
- # end
- # end
- #
- # @example Custom identity
- # require 'hanami/model'
- #
- # # We have an SQL table `articles` with a primary key `i_id`.
- # #
- # # This schema diverges from the expected default: `id`, that's why
- # # we need to use #identity to let the mapper to recognize the
- # # primary key.
- #
- # mapper = Hanami::Model::Mapper.new do
- # collection :articles do
- # entity Article
- #
- # # attribute definitions..
- #
- # identity :i_id
- # end
- # end
- def identity(name = nil)
- if name
- @identity = name
- else
- @identity || :id
- end
- end
-
- # Map an attribute.
- #
- # An attribute defines a property of an object.
- # This is storage independent. For instance, it can map an SQL column,
- # a MongoDB attribute or everything that makes sense for your database.
- #
- # Each attribute defines a Ruby type, to coerce that value from the
- # database. This fixes a huge problem, because database types don't
- # match Ruby types.
- # Think of Redis, where everything is stored as a string or integer,
- # the mapper translates values from/to the database.
- #
- # It supports the following types (coercers):
- #
- # * Array
- # * Boolean
- # * Date
- # * DateTime
- # * Float
- # * Hash
- # * Integer
- # * BigDecimal
- # * Set
- # * String
- # * Symbol
- # * Time
- #
- # @param name [Symbol] the name of the attribute, as we want it to be
- # mapped in the object
- #
- # @param coercer [.load, .dump] a class that implements coercer interface
- #
- # @param options [Hash] a set of options to customize the mapping
- # @option options [Symbol] :as the name of the original column
- #
- # @raise [NameError] if coercer cannot be found
- #
- # @since 0.1.0
- #
- # @see Hanami::Model::Coercer
- #
- # @example Default schema
- # require 'hanami/model'
- #
- # # Given the following schema:
- # #
- # # CREATE TABLE users (
- # # id integer NOT NULL,
- # # name varchar(64),
- # # );
- # #
- # # And the following entity:
- # #
- # # class User
- # # include Hanami::Entity
- # # attributes :name
- # # end
- #
- # mapper = Hanami::Model::Mapper.new do
- # collection :users do
- # entity User
- #
- # attribute :id, Integer
- # attribute :name, String
- # end
- # end
- #
- # # The first argument (`:name`) always corresponds to the `User`
- # # attribute.
- #
- # # The second one (`:coercer`) is the Ruby type coercer that we want
- # # for our attribute.
- #
- # # We don't need to use `:as` because the database columns match the
- # # `User` attributes.
- #
- # @example Customized schema
- # require 'hanami/model'
- #
- # # Given the following schema:
- # #
- # # CREATE TABLE articles (
- # # i_id integer NOT NULL,
- # # i_user_id integer NOT NULL,
- # # s_title varchar(64),
- # # comments_count varchar(8) # Not an error: it's for String => Integer coercion
- # # );
- # #
- # # And the following entity:
- # #
- # # class Article
- # # include Hanami::Entity
- # # attributes :user_id, :title, :comments_count
- # # end
- #
- # mapper = Hanami::Model::Mapper.new do
- # collection :articles do
- # entity Article
- #
- # attribute :id, Integer, as: :i_id
- # attribute :user_id, Integer, as: :i_user_id
- # attribute :title, String, as: :s_title
- # attribute :comments_count, Integer
- #
- # identity :i_id
- # end
- # end
- #
- # # The first argument (`:name`) always corresponds to the `Article`
- # # attribute.
- #
- # # The second one (`:coercer`) is the Ruby type that we want for our
- # # attribute.
- #
- # # The third option (`:as`) is mandatory only when the database
- # # column doesn't match the name of the mapped attribute.
- # #
- # # For instance: we need to use it for translate `:s_title` to
- # # `:title`, but not for `:comments_count`.
- #
- # @example Custom coercer
- # require 'hanami/model'
- #
- # # Given the following schema:
- # #
- # # CREATE TABLE articles (
- # # id integer NOT NULL,
- # # title varchar(128),
- # # tags text[],
- # # );
- # #
- # # The following entity:
- # #
- # # class Article
- # # include Hanami::Entity
- # # attributes :title, :tags
- # # end
- # #
- # # And the following custom coercer:
- # #
- # # require 'hanami/model/coercer'
- # # require 'sequel/extensions/pg_array'
- # #
- # # class PGArray < Hanami::Model::Coercer
- # # def self.dump(value)
- # # ::Sequel.pg_array(value) rescue nil
- # # end
- # #
- # # def self.load(value)
- # # ::Kernel.Array(value) unless value.nil?
- # # end
- # # end
- #
- # mapper = Hanami::Model::Mapper.new do
- # collection :articles do
- # entity Article
- #
- # attribute :id, Integer
- # attribute :title, String
- # attribute :tags, PGArray
- # end
- # end
- #
- # # When an entity is persisted as record into the database,
- # # `PGArray.dump` is invoked.
- #
- # # When an entity is retrieved from the database, it will be
- # # deserialized as an Array via `PGArray.load`.
- def attribute(name, coercer, options = {})
- @attributes[name] = Attribute.new(name, coercer, options)
- end
-
- # Serializes an entity to be persisted in the database.
- #
- # @param entity [Object] an entity
- #
- # @api private
- # @since 0.1.0
- def serialize(entity)
- @coercer.to_record(entity)
- end
-
- # Deserialize a set of records fetched from the database.
- #
- # @param records [Array] a set of raw records
- #
- # @api private
- # @since 0.1.0
- def deserialize(records)
- records.map do |record|
- @coercer.from_record(record)
- end
- end
-
- # Deserialize only one attribute from a raw value.
- #
- # @param attribute [Symbol] the attribute name
- # @param value [Object,nil] the value to be coerced
- #
- # @api private
- # @since 0.1.0
- def deserialize_attribute(attribute, value)
- @coercer.public_send(:"deserialize_#{ attribute }", value)
- end
-
- # Loads the internals of the mapper, in order to guarantee thread safety.
- #
- # @api private
- # @since 0.1.0
- def load!
- _load_entity!
- _load_repository!
- _load_coercer!
-
- _configure_repository!
- end
-
- private
-
- # Assigns a repository to an entity
- #
- # @see Hanami::Repository
- #
- # @api private
- # @since 0.1.0
- def _configure_repository!
- repository.collection = name
- repository.adapter = adapter if adapter
- end
-
- # Convert repository string to repository class
- #
- # @api private
- # @since 0.2.0
- def _load_repository!
- @repository = Utils::Class.load!(repository)
- rescue NameError
- raise Hanami::Model::Mapping::RepositoryNotFound.new(repository.to_s)
- end
-
- # Convert entity string to entity class
- #
- # @api private
- # @since 0.2.0
- def _load_entity!
- @entity = Utils::Class.load!(entity)
- rescue NameError
- raise Hanami::Model::Mapping::EntityNotFound.new(entity.to_s)
- end
-
- # Load coercer
- #
- # @api private
- # @since 0.1.0
- def _load_coercer!
- @coercer = coercer_class.new(self)
- end
-
- # Retrieves the default repository class
- #
- # @see Hanami::Repository
- #
- # @api private
- # @since 0.2.0
- def default_repository_klass
- "#{ entity }#{ REPOSITORY_SUFFIX }"
- end
-
- end
- end
- end
-end
diff --git a/lib/hanami/model/mapping/collection_coercer.rb b/lib/hanami/model/mapping/collection_coercer.rb
deleted file mode 100644
index 1a69d851..00000000
--- a/lib/hanami/model/mapping/collection_coercer.rb
+++ /dev/null
@@ -1,79 +0,0 @@
-module Hanami
- module Model
- module Mapping
- # Translates values from/to the database with the corresponding Ruby type.
- #
- # @api private
- # @since 0.1.0
- class CollectionCoercer
- # Initialize a coercer for the given collection.
- #
- # @param collection [Hanami::Model::Mapping::Collection] the collection
- #
- # @api private
- # @since 0.1.0
- def initialize(collection)
- @collection = collection
- _compile!
- end
-
- # Translates the given entity into a format compatible with the database.
- #
- # @param entity [Object] the entity
- #
- # @return [Hash]
- #
- # @api private
- # @since 0.1.0
- def to_record(entity)
- end
-
- # Translates the given record into a Ruby object.
- #
- # @param record [Hash]
- #
- # @return [Object]
- #
- # @api private
- # @since 0.1.0
- def from_record(record)
- end
-
- private
- # Compile itself for performance boost.
- #
- # @api private
- # @since 0.1.0
- def _compile!
- code = @collection.attributes.map do |_,attr|
- %{
- def deserialize_#{ attr.mapped }(value)
- #{ attr.load_coercer }(value)
- end
- }
- end.join("\n")
-
- instance_eval <<-EVAL, __FILE__, __LINE__
- def to_record(entity)
- if entity.id
- Hash[#{ @collection.attributes.map{|name,attr| ":#{ attr.mapped },#{ attr.dump_coercer }(entity.#{name})"}.join(',') }]
- else
- Hash[].tap do |record|
- #{ @collection.attributes.reject{|name,_| name == @collection.identity }.map{|name,attr| "value = #{ attr.dump_coercer }(entity.#{name}); record[:#{attr.mapped}] = value unless value.nil?"}.join('; ') }
- end
- end
- end
-
- def from_record(record)
- ::#{ @collection.entity }.new(
- Hash[#{ @collection.attributes.map{|name,attr| ":#{name},#{attr.load_coercer}(record[:#{attr.mapped}])"}.join(',') }]
- )
- end
-
- #{ code }
- EVAL
- end
- end
- end
- end
-end
diff --git a/lib/hanami/model/migration.rb b/lib/hanami/model/migration.rb
new file mode 100644
index 00000000..efcbb0af
--- /dev/null
+++ b/lib/hanami/model/migration.rb
@@ -0,0 +1,31 @@
+module Hanami
+ module Model
+ # Database migration
+ #
+ # @since x.x.x
+ # @api private
+ class Migration
+ # @since x.x.x
+ # @api private
+ attr_reader :gateway
+
+ # @since x.x.x
+ # @api private
+ attr_reader :migration
+
+ # @since x.x.x
+ # @api private
+ def initialize(gateway, &block)
+ @gateway = gateway
+ @migration = gateway.migration(&block)
+ freeze
+ end
+
+ # @since x.x.x
+ # @api private
+ def run(direction = :up)
+ migration.apply(gateway.connection, direction)
+ end
+ end
+ end
+end
diff --git a/lib/hanami/model/migrator.rb b/lib/hanami/model/migrator.rb
index 0ec9530c..e921d64a 100644
--- a/lib/hanami/model/migrator.rb
+++ b/lib/hanami/model/migrator.rb
@@ -1,7 +1,5 @@
require 'sequel'
require 'sequel/extensions/migration'
-require 'hanami/model/migrator/connection'
-require 'hanami/model/migrator/adapter'
module Hanami
module Model
@@ -11,51 +9,13 @@ module Model
class MigrationError < Hanami::Model::Error
end
- # Define a migration
- #
- # It must define an up/down strategy to write schema changes (up) and to
- # rollback them (down).
- #
- # We can use up and down blocks for custom strategies, or
- # only one change block that automatically implements "down" strategy.
- #
- # @param blk [Proc] a block that defines up/down or change database migration
- #
- # @since 0.4.0
- #
- # @example Use up/down blocks
- # Hanami::Model.migration do
- # up do
- # create_table :books do
- # primary_key :id
- # column :book, String
- # end
- # end
- #
- # down do
- # drop_table :books
- # end
- # end
- #
- # @example Use change block
- # Hanami::Model.migration do
- # change do
- # create_table :books do
- # primary_key :id
- # column :book, String
- # end
- # end
- #
- # # DOWN strategy is automatically generated
- # end
- def self.migration(&blk)
- Sequel.migration(&blk)
- end
-
# Database schema migrator
#
# @since 0.4.0
- module Migrator
+ class Migrator
+ require 'hanami/model/migrator/connection'
+ require 'hanami/model/migrator/adapter'
+
# Create database defined by current configuration.
#
# It's only implemented for the following databases:
@@ -76,12 +36,14 @@ module Migrator
#
# Hanami::Model.configure do
# # ...
- # adapter type: :sql, uri: 'postgres://localhost/foo'
+ # adapter :sql, 'postgres://localhost/foo'
# end
#
# Hanami::Model::Migrator.create # Creates `foo' database
+ #
+ # NOTE: Class level interface SHOULD be removed in Hanami 2.0
def self.create
- adapter(connection).create
+ new.create
end
# Drop database defined by current configuration.
@@ -104,12 +66,14 @@ def self.create
#
# Hanami::Model.configure do
# # ...
- # adapter type: :sql, uri: 'postgres://localhost/foo'
+ # adapter :sql, 'postgres://localhost/foo'
# end
#
# Hanami::Model::Migrator.drop # Drops `foo' database
+ #
+ # NOTE: Class level interface SHOULD be removed in Hanami 2.0
def self.drop
- adapter(connection).drop
+ new.drop
end
# Migrate database schema
@@ -132,7 +96,7 @@ def self.drop
#
# Hanami::Model.configure do
# # ...
- # adapter type: :sql, uri: 'postgres://localhost/foo'
+ # adapter :sql, 'postgres://localhost/foo'
# migrations 'db/migrations'
# end
#
@@ -145,7 +109,7 @@ def self.drop
#
# Hanami::Model.configure do
# # ...
- # adapter type: :sql, uri: 'postgres://localhost/foo'
+ # adapter :sql, 'postgres://localhost/foo'
# migrations 'db/migrations'
# end
#
@@ -154,12 +118,10 @@ def self.drop
#
# # Migrate to a specifiy version
# Hanami::Model::Migrator.migrate(version: "20150610133853")
+ #
+ # NOTE: Class level interface SHOULD be removed in Hanami 2.0
def self.migrate(version: nil)
- version = Integer(version) unless version.nil?
-
- Sequel::Migrator.run(connection, migrations, target: version, allow_missing_migration_files: true) if migrations?
- rescue Sequel::Migrator::Error => e
- raise MigrationError.new(e.message)
+ new.migrate(version: version)
end
# Migrate, dump schema, delete migrations.
@@ -192,7 +154,7 @@ def self.migrate(version: nil)
#
# Hanami::Model.configure do
# # ...
- # adapter type: :sql, uri: 'postgres://localhost/foo'
+ # adapter :sql, 'postgres://localhost/foo'
# migrations 'db/migrations'
# schema 'db/schema.sql'
# end
@@ -200,10 +162,10 @@ def self.migrate(version: nil)
# # Reads all files from "db/migrations" and apply and delete them.
# # It generates an updated version of "db/schema.sql"
# Hanami::Model::Migrator.apply
+ #
+ # NOTE: Class level interface SHOULD be removed in Hanami 2.0
def self.apply
- migrate
- adapter(connection).dump
- delete_migrations
+ new.apply
end
# Prepare database: drop, create, load schema (if any), migrate.
@@ -223,7 +185,7 @@ def self.apply
#
# Hanami::Model.configure do
# # ...
- # adapter type: :sql, uri: 'postgres://localhost/foo'
+ # adapter :sql, 'postgres://localhost/foo'
# migrations 'db/migrations'
# end
#
@@ -235,18 +197,17 @@ def self.apply
#
# Hanami::Model.configure do
# # ...
- # adapter type: :sql, uri: 'postgres://localhost/foo'
+ # adapter :sql, 'postgres://localhost/foo'
# migrations 'db/migrations'
# schema 'db/schema.sql'
# end
#
# Hanami::Model::Migrator.apply # => updates schema dump
# Hanami::Model::Migrator.prepare # => creates `foo', load schema and run pending migrations (if any)
+ #
+ # NOTE: Class level interface SHOULD be removed in Hanami 2.0
def self.prepare
- drop rescue nil
- create
- adapter(connection).load
- migrate
+ new.prepare
end
# Return current database version timestamp
@@ -262,40 +223,82 @@ def self.prepare
# # 20150610133853_create_books.rb
#
# Hanami::Model::Migrator.version # => "20150610133853"
+ #
+ # NOTE: Class level interface SHOULD be removed in Hanami 2.0
def self.version
- adapter(connection).version
+ new.version
end
- private
+ # Instantiate a new migrator
+ #
+ # @param configuration [Hanami::Model::Configuration] framework configuration
+ #
+ # @return [Hanami::Model::Migrator] a new instance
+ #
+ # @since x.x.x
+ # @api private
+ def initialize(configuration: self.class.configuration)
+ @configuration = configuration
+ @adapter = Adapter.for(configuration)
+ end
- # Loads an adapter for the given connection
+ # @since x.x.x
+ # @api private
#
- # @since 0.4.0
+ # @see Hanami::Model::Migrator.create
+ def create
+ adapter.create
+ end
+
+ # @since x.x.x
# @api private
- def self.adapter(connection)
- Adapter.for(connection)
+ #
+ # @see Hanami::Model::Migrator.drop
+ def drop
+ adapter.drop
end
- # Delete all the migrations
+ # @since x.x.x
+ # @api private
#
- # @since 0.4.0
+ # @see Hanami::Model::Migrator.migrate
+ def migrate(version: nil)
+ adapter.migrate(migrations, version) if migrations?
+ end
+
+ # @since x.x.x
# @api private
- def self.delete_migrations
- migrations.each_child(&:delete)
+ #
+ # @see Hanami::Model::Migrator.apply
+ def apply
+ migrate
+ adapter.dump
+ delete_migrations
end
- # Database connection
+ # @since x.x.x
+ # @api private
#
- # @since 0.4.0
+ # @see Hanami::Model::Migrator.prepare
+ def prepare
+ drop
+ rescue
+ ensure
+ create
+ adapter.load
+ migrate
+ end
+
+ # @since x.x.x
# @api private
- def self.connection
- Sequel.connect(
- configuration.adapter.uri
- )
- rescue Sequel::AdapterNotFound
- raise MigrationError.new("Current adapter (#{ configuration.adapter.type }) doesn't support SQL database operations.")
+ #
+ # @see Hanami::Model::Migrator.version
+ def version
+ adapter.version
end
+ private
+
# Hanami::Model configuration
#
# @since 0.4.0
@@ -304,20 +307,40 @@ def self.configuration
Model.configuration
end
+ # @since x.x.x
+ # @api private
+ attr_reader :configuration
+
+ # @since x.x.x
+ # @api private
+ attr_reader :connection
+
+ # @since x.x.x
+ # @api private
+ attr_reader :adapter
+
# Migrations directory
#
- # @since 0.4.0
+ # @since x.x.x
# @api private
- def self.migrations
+ def migrations
configuration.migrations
end
# Check if there are migrations
#
- # @since 0.4.0
+ # @since x.x.x
+ # @api private
+ def migrations?
+ Dir["#{migrations}/*.rb"].any?
+ end
+
+ # Delete all the migrations
+ #
+ # @since x.x.x
# @api private
- def self.migrations?
- Dir["#{ migrations }/*.rb"].any?
+ def delete_migrations
+ migrations.each_child(&:delete)
end
end
end
diff --git a/lib/hanami/model/migrator/adapter.rb b/lib/hanami/model/migrator/adapter.rb
index a703bb54..c2a16c01 100644
--- a/lib/hanami/model/migrator/adapter.rb
+++ b/lib/hanami/model/migrator/adapter.rb
@@ -3,7 +3,7 @@
module Hanami
module Model
- module Migrator
+ class Migrator
# Migrator base adapter
#
# @since 0.4.0
@@ -25,7 +25,9 @@ class Adapter
#
# @since 0.4.0
# @api private
- def self.for(connection)
+ def self.for(configuration) # rubocop:disable Metrics/MethodLength
+ connection = connection_for(configuration)
+
case connection.database_type
when :sqlite
require 'hanami/model/migrator/sqlite_adapter'
@@ -38,15 +40,30 @@ def self.for(connection)
MySQLAdapter
else
self
- end.new(connection)
+ end.new(connection, configuration)
+ end
+
+ class << self
+ private
+
+ # @since x.x.x
+ # @api private
+ def connection_for(configuration)
+ Sequel.connect(
+ configuration.url
+ )
+ rescue Sequel::AdapterNotFound
+ raise MigrationError.new("Current adapter (#{configuration.adapter.type}) doesn't support SQL database operations.")
+ end
end
# Initialize an adapter
#
# @since 0.4.0
# @api private
- def initialize(connection)
+ def initialize(connection, configuration)
@connection = Connection.new(connection)
+ @schema = configuration.schema
end
# Create database.
@@ -57,7 +74,7 @@ def initialize(connection)
#
# @see Hanami::Model::Migrator.create
def create
- raise MigrationError.new("Current adapter (#{ connection.database_type }) doesn't support create.")
+ raise MigrationError.new("Current adapter (#{connection.database_type}) doesn't support create.")
end
# Drop database.
@@ -68,7 +85,15 @@ def create
#
# @see Hanami::Model::Migrator.drop
def drop
- raise MigrationError.new("Current adapter (#{ connection.database_type }) doesn't support drop.")
+ raise MigrationError.new("Current adapter (#{connection.database_type}) doesn't support drop.")
+ end
+
+ def migrate(migrations, version)
+ version = Integer(version) unless version.nil?
+
+ Sequel::Migrator.run(connection.raw, migrations, target: version, allow_missing_migration_files: true)
+ rescue Sequel::Migrator::Error => e
+ raise MigrationError.new(e.message)
end
# Load database schema.
@@ -79,7 +104,7 @@ def drop
#
# @see Hanami::Model::Migrator.prepare
def load
- raise MigrationError.new("Current adapter (#{ connection.database_type }) doesn't support load.")
+ raise MigrationError.new("Current adapter (#{connection.database_type}) doesn't support load.")
end
# Database version.
@@ -87,9 +112,10 @@ def load
# @since 0.4.0
# @api private
def version
- return unless connection.adapter_connection.tables.include?(MIGRATIONS_TABLE)
+ table = connection.table(MIGRATIONS_TABLE)
+ return if table.nil?
- if record = connection.adapter_connection[MIGRATIONS_TABLE].order(MIGRATIONS_TABLE_VERSION_COLUMN).last
+ if record = table.order(MIGRATIONS_TABLE_VERSION_COLUMN).last
record.fetch(MIGRATIONS_TABLE_VERSION_COLUMN).scan(/\A[\d]{14}/).first.to_s
end
end
@@ -100,6 +126,10 @@ def version
# @api private
attr_reader :connection
+ # @since 0.4.0
+ # @api private
+ attr_reader :schema
+
# Returns a database connection
#
# Given a DB connection URI we can connect to a specific database or not, we need this when creating
@@ -110,7 +140,6 @@ def version
#
# @since 0.5.0
# @api private
- #
def new_connection(global: false)
uri = global ? connection.global_uri : connection.uri
@@ -147,12 +176,6 @@ def password
escape connection.password
end
- # @since 0.4.0
- # @api private
- def schema
- Model.configuration.schema
- end
-
# @since 0.4.0
# @api private
def migrations_table
diff --git a/lib/hanami/model/migrator/connection.rb b/lib/hanami/model/migrator/connection.rb
index ba7a150e..54ec17b7 100644
--- a/lib/hanami/model/migrator/connection.rb
+++ b/lib/hanami/model/migrator/connection.rb
@@ -1,6 +1,6 @@
module Hanami
module Model
- module Migrator
+ class Migrator
# Sequel connection wrapper
#
# Normalize external adapters interfaces
@@ -8,10 +8,14 @@ module Migrator
# @since 0.5.0
# @api private
class Connection
- attr_reader :adapter_connection
+ # @since x.x.x
+ # @api private
+ attr_reader :raw
- def initialize(adapter_connection)
- @adapter_connection = adapter_connection
+ # @since 0.5.0
+ # @api private
+ def initialize(raw)
+ @raw = raw
end
# Returns DB connection host
@@ -48,12 +52,12 @@ def database
#
# @example
# connection.database_type
- # # => 'postgres'
+ # # => 'postgres'
#
# @since 0.5.0
# @api private
def database_type
- adapter_connection.database_type
+ raw.database_type
end
# Returns user from DB connection
@@ -81,7 +85,7 @@ def password
# @since 0.5.0
# @api private
def uri
- adapter_connection.uri
+ raw.uri
end
# Returns DB connection wihout specifying database name
@@ -89,7 +93,7 @@ def uri
# @since 0.5.0
# @api private
def global_uri
- adapter_connection.uri.sub(parsed_uri.select(:path).first, '')
+ raw.uri.sub(parsed_uri.select(:path).first, '')
end
# Returns a boolean telling if a DB connection is from JDBC or not
@@ -97,7 +101,7 @@ def global_uri
# @since 0.5.0
# @api private
def jdbc?
- !adapter_connection.uri.scan('jdbc:').empty?
+ !raw.uri.scan('jdbc:').empty?
end
# Returns database connection URI instance without JDBC namespace
@@ -105,7 +109,15 @@ def jdbc?
# @since 0.5.0
# @api private
def parsed_uri
- @uri ||= URI.parse(adapter_connection.uri.sub('jdbc:', ''))
+ @uri ||= URI.parse(raw.uri.sub('jdbc:', ''))
+ end
+
+ # Return the database table for the given name
+ #
+ # @since x.x.x
+ # @api private
+ def table(name)
+ raw[name] if raw.tables.include?(name)
end
private
@@ -125,7 +137,7 @@ def parsed_opt(option)
# @since 0.5.0
# @api private
def opts
- adapter_connection.opts
+ raw.opts
end
end
end
diff --git a/lib/hanami/model/migrator/mysql_adapter.rb b/lib/hanami/model/migrator/mysql_adapter.rb
index f25e8861..edef20e6 100644
--- a/lib/hanami/model/migrator/mysql_adapter.rb
+++ b/lib/hanami/model/migrator/mysql_adapter.rb
@@ -1,21 +1,28 @@
module Hanami
module Model
- module Migrator
+ class Migrator
# MySQL adapter
#
# @since 0.4.0
# @api private
class MySQLAdapter < Adapter
+ # @since x.x.x
+ # @api private
+ PASSWORD = 'MYSQL_PWD'.freeze
+
# @since 0.4.0
# @api private
- def create
- new_connection(global: true).run %(CREATE DATABASE #{ database };)
+ def create # rubocop:disable Metrics/MethodLength
+ new_connection(global: true).run %(CREATE DATABASE #{database};)
rescue Sequel::DatabaseError => e
- message = if e.message.match(/database exists/)
- "Database creation failed. There is 1 other session using the database"
- else
- e.message
- end
+ message = if e.message.match(/database exists/) # rubocop:disable Performance/RedundantMatch
+ "Database creation failed. If the database exists, \
+ then its console may be open. See this issue for more details:\
+ https://github.com/hanami/model/issues/250\
+ "
+ else
+ e.message
+ end
raise MigrationError.new(message)
end
@@ -23,13 +30,13 @@ def create
# @since 0.4.0
# @api private
def drop
- new_connection(global: true).run %(DROP DATABASE #{ database };)
+ new_connection(global: true).run %(DROP DATABASE #{database};)
rescue Sequel::DatabaseError => e
- message = if e.message.match(/doesn\'t exist/)
- "Cannot find database: #{ database }"
- else
- e.message
- end
+ message = if e.message.match(/doesn\'t exist/) # rubocop:disable Performance/RedundantMatch
+ "Cannot find database: #{database}"
+ else
+ e.message
+ end
raise MigrationError.new(message)
end
@@ -37,6 +44,7 @@ def drop
# @since 0.4.0
# @api private
def dump
+ set_environment_variables
dump_structure
dump_migrations_data
end
@@ -44,27 +52,40 @@ def dump
# @since 0.4.0
# @api private
def load
+ set_environment_variables
load_structure
end
private
+ # @since x.x.x
+ # @api private
+ def set_environment_variables
+ ENV[PASSWORD] = password unless password.nil?
+ end
+
+ # @since x.x.x
+ # @api private
+ def password
+ connection.password
+ end
+
# @since 0.4.0
# @api private
def dump_structure
- system "mysqldump --user=#{ username } --password=#{ password } --no-data --skip-comments --ignore-table=#{ database }.#{ migrations_table } #{ database } > #{ schema }"
+ system "mysqldump --user=#{username} --no-data --skip-comments --ignore-table=#{database}.#{migrations_table} #{database} > #{schema}"
end
# @since 0.4.0
# @api private
def load_structure
- system "mysql --user=#{ username } --password=#{ password } #{ database } < #{ escape(schema) }" if schema.exist?
+ system "mysql --user=#{username} #{database} < #{escape(schema)}" if schema.exist?
end
# @since 0.4.0
# @api private
def dump_migrations_data
- system "mysqldump --user=#{ username } --password=#{ password } --skip-comments #{ database } #{ migrations_table } >> #{ schema }"
+ system "mysqldump --user=#{username} --skip-comments #{database} #{migrations_table} >> #{schema}"
end
end
end
diff --git a/lib/hanami/model/migrator/postgres_adapter.rb b/lib/hanami/model/migrator/postgres_adapter.rb
index c7e58059..ce8829fc 100644
--- a/lib/hanami/model/migrator/postgres_adapter.rb
+++ b/lib/hanami/model/migrator/postgres_adapter.rb
@@ -1,6 +1,6 @@
module Hanami
module Model
- module Migrator
+ class Migrator
# PostgreSQL adapter
#
# @since 0.4.0
@@ -24,18 +24,18 @@ class PostgresAdapter < Adapter
# @since 0.4.0
# @api private
- def create
+ def create # rubocop:disable Metrics/MethodLength
set_environment_variables
call_db_command('createdb') do |error_message|
- message = if error_message.match(/already exists/)
- "createdb: database creation failed. If the database exists, \
- then its console may be open. See this issue for more details:\
- https://github.com/hanami/model/issues/250\
- "
- else
- error_message
- end
+ message = if error_message.match(/already exists/) # rubocop:disable Performance/RedundantMatch
+ "createdb: database creation failed. If the database exists, \
+ then its console may be open. See this issue for more details:\
+ https://github.com/hanami/model/issues/250\
+ "
+ else
+ error_message
+ end
raise MigrationError.new(message)
end
@@ -47,11 +47,11 @@ def drop
set_environment_variables
call_db_command('dropdb') do |error_message|
- message = if error_message.match(/does not exist/)
- "Cannot find database: #{ database }"
- else
- error_message
- end
+ message = if error_message.match(/does not exist/) # rubocop:disable Performance/RedundantMatch
+ "Cannot find database: #{database}"
+ else
+ error_message
+ end
raise MigrationError.new(message)
end
@@ -86,19 +86,19 @@ def set_environment_variables
# @since 0.4.0
# @api private
def dump_structure
- system "pg_dump -s -x -O -T #{ migrations_table } -f #{ escape(schema) } #{ database }"
+ system "pg_dump -s -x -O -T #{migrations_table} -f #{escape(schema)} #{database}"
end
# @since 0.4.0
# @api private
def load_structure
- system "psql -X -q -f #{ escape(schema) } #{ database }" if schema.exist?
+ system "psql -X -q -f #{escape(schema)} #{database}" if schema.exist?
end
# @since 0.4.0
# @api private
def dump_migrations_data
- system "pg_dump -t #{ migrations_table } #{ database } >> #{ escape(schema) }"
+ system "pg_dump -t #{migrations_table} #{database} >> #{escape(schema)}"
end
# @since 0.5.1
@@ -107,10 +107,8 @@ def call_db_command(command)
require 'open3'
begin
- Open3.popen3(command, database) do |stdin, stdout, stderr, wait_thr|
- unless wait_thr.value.success? # wait_thr.value is the exit status
- yield stderr.read
- end
+ Open3.popen3(command, database) do |_stdin, _stdout, stderr, wait_thr|
+ yield stderr.read unless wait_thr.value.success? # wait_thr.value is the exit status
end
rescue SystemCallError => e
yield e.message
diff --git a/lib/hanami/model/migrator/sqlite_adapter.rb b/lib/hanami/model/migrator/sqlite_adapter.rb
index bd49b04a..f8652672 100644
--- a/lib/hanami/model/migrator/sqlite_adapter.rb
+++ b/lib/hanami/model/migrator/sqlite_adapter.rb
@@ -1,8 +1,9 @@
require 'pathname'
+require 'hanami/utils'
module Hanami
module Model
- module Migrator
+ class Migrator
# SQLite3 Migrator
#
# @since 0.4.0
@@ -28,7 +29,7 @@ def drop
#
# @since 0.4.0
# @api private
- def initialize(connection)
+ def initialize(connection, configuration)
super
extend Memory if memory?
end
@@ -39,7 +40,7 @@ def create
path.dirname.mkpath
FileUtils.touch(path)
rescue Errno::EACCES, Errno::EPERM
- raise MigrationError.new("Permission denied: #{ path.sub(/\A\/\//, '') }")
+ raise MigrationError.new("Permission denied: #{path.sub(/\A\/\//, '')}")
end
# @since 0.4.0
@@ -47,7 +48,7 @@ def create
def drop
path.delete
rescue Errno::ENOENT
- raise MigrationError.new("Cannot find database: #{ path.sub(/\A\/\//, '') }")
+ raise MigrationError.new("Cannot find database: #{path.sub(/\A\/\//, '')}")
end
# @since 0.4.0
@@ -69,7 +70,7 @@ def load
# @api private
def path
root.join(
- @connection.uri.sub(/(jdbc\:|)sqlite\:\/\//, '')
+ @connection.uri.sub(/\A(jdbc:sqlite:|sqlite:\/\/)/, '')
)
end
@@ -90,19 +91,19 @@ def memory?
# @since 0.4.0
# @api private
def dump_structure
- system "sqlite3 #{ escape(path) } .schema > #{ escape(schema) }"
+ system "sqlite3 #{escape(path)} .schema > #{escape(schema)}"
end
# @since 0.4.0
# @api private
def load_structure
- system "sqlite3 #{ escape(path) } < #{ escape(schema) }" if schema.exist?
+ system "sqlite3 #{escape(path)} < #{escape(schema)}" if schema.exist?
end
# @since 0.4.0
# @api private
def dump_migrations_data
- system %(sqlite3 #{ escape(path) } .dump | grep '^INSERT INTO "#{ migrations_table }"' >> #{ escape(schema) })
+ system %(sqlite3 #{escape(path)} .dump | grep '^INSERT INTO "#{migrations_table}"' >> #{escape(schema)})
end
end
end
diff --git a/lib/hanami/model/plugins.rb b/lib/hanami/model/plugins.rb
new file mode 100644
index 00000000..9db7f066
--- /dev/null
+++ b/lib/hanami/model/plugins.rb
@@ -0,0 +1,25 @@
+module Hanami
+ module Model
+ # Plugins to extend read/write operations from/to the database
+ #
+ # @since x.x.x
+ # @api private
+ module Plugins
+ # Wrapping input
+ #
+ # @since x.x.x
+ # @api private
+ class WrappingInput
+ # @since x.x.x
+ # @api private
+ def initialize(_relation, input)
+ @input = input || Hash
+ end
+ end
+
+ require 'hanami/model/plugins/mapping'
+ require 'hanami/model/plugins/schema'
+ require 'hanami/model/plugins/timestamps'
+ end
+ end
+end
diff --git a/lib/hanami/model/plugins/mapping.rb b/lib/hanami/model/plugins/mapping.rb
new file mode 100644
index 00000000..b820f6fc
--- /dev/null
+++ b/lib/hanami/model/plugins/mapping.rb
@@ -0,0 +1,55 @@
+module Hanami
+ module Model
+ module Plugins
+ # Transform output into model domain types (entities).
+ #
+ # @since x.x.x
+ # @api private
+ module Mapping
+ # Takes the output and applies the transformations
+ #
+ # @since x.x.x
+ # @api private
+ class InputWithMapping < WrappingInput
+ # @since x.x.x
+ # @api private
+ def initialize(relation, input)
+ super
+ @mapping = Hanami::Model.configuration.mappings[relation.name.to_sym]
+ end
+
+ # Processes the output
+ #
+ # @since x.x.x
+ # @api private
+ def [](value)
+ @mapping.process(@input[value])
+ end
+ end
+
+ # Class interface
+ #
+ # @since x.x.x
+ # @api private
+ module ClassMethods
+ # Builds the output processor
+ #
+ # @since x.x.x
+ # @api private
+ def build(relation, options = {})
+ input(InputWithMapping.new(relation, input))
+ super(relation, options.merge(input: input))
+ end
+ end
+
+ # @since x.x.x
+ # @api private
+ def self.included(klass)
+ super
+
+ klass.extend ClassMethods
+ end
+ end
+ end
+ end
+end
diff --git a/lib/hanami/model/plugins/schema.rb b/lib/hanami/model/plugins/schema.rb
new file mode 100644
index 00000000..5ea855af
--- /dev/null
+++ b/lib/hanami/model/plugins/schema.rb
@@ -0,0 +1,55 @@
+module Hanami
+ module Model
+ module Plugins
+ # Transform input values into database specific types (primitives).
+ #
+ # @since x.x.x
+ # @api private
+ module Schema
+ # Takes the input and applies the values transformations.
+ #
+ # @since x.x.x
+ # @api private
+ class InputWithSchema < WrappingInput
+ # @since x.x.x
+ # @api private
+ def initialize(relation, input)
+ super
+ @schema = relation.schema_hash
+ end
+
+ # Processes the input
+ #
+ # @since x.x.x
+ # @api private
+ def [](value)
+ @schema[value]
+ end
+ end
+
+ # Class interface
+ #
+ # @since x.x.x
+ # @api private
+ module ClassMethods
+ # Builds the input processor
+ #
+ # @since x.x.x
+ # @api private
+ def build(relation, options = {})
+ input(InputWithSchema.new(relation, input))
+ super(relation, options.merge(input: input))
+ end
+ end
+
+ # @since x.x.x
+ # @api private
+ def self.included(klass)
+ super
+
+ klass.extend ClassMethods
+ end
+ end
+ end
+ end
+end
diff --git a/lib/hanami/model/plugins/timestamps.rb b/lib/hanami/model/plugins/timestamps.rb
new file mode 100644
index 00000000..83fecc36
--- /dev/null
+++ b/lib/hanami/model/plugins/timestamps.rb
@@ -0,0 +1,118 @@
+module Hanami
+ module Model
+ module Plugins
+ # Automatically set/update timestamp columns for create/update commands
+ #
+ # @since x.x.x
+ # @api private
+ module Timestamps
+ # Takes the input and applies the timestamp transformation.
+ # This is an "abstract class", please look at the subclasses for
+ # specific behaviors.
+ #
+ # @since x.x.x
+ # @api private
+ class InputWithTimestamp < WrappingInput
+ # Conventional timestamp names
+ #
+ # @since x.x.x
+ # @api private
+ TIMESTAMPS = [:created_at, :updated_at].freeze
+
+ # @since x.x.x
+ # @api private
+ def initialize(relation, input)
+ super
+ columns = relation.columns.sort
+ @timestamps = (columns & TIMESTAMPS) == TIMESTAMPS
+ end
+
+ # Processes the input
+ #
+ # @since x.x.x
+ # @api private
+ def [](value)
+ return value unless timestamps?
+ _touch(@input[value], Time.now)
+ end
+
+ protected
+
+ # @since x.x.x
+ # @api private
+ def _touch(_value)
+ raise NotImplementedError
+ end
+
+ private
+
+ # @since x.x.x
+ # @api private
+ def timestamps?
+ @timestamps
+ end
+ end
+
+ # Updates updated_at timestamp for update commands
+ #
+ # @since x.x.x
+ # @api private
+ class InputWithUpdateTimestamp < InputWithTimestamp
+ protected
+
+ # @since x.x.x
+ # @api private
+ def _touch(value, now)
+ value[:updated_at] = now
+ value
+ end
+ end
+
+ # Sets created_at and updated_at timestamps for create commands
+ #
+ # @since x.x.x
+ # @api private
+ class InputWithCreateTimestamp < InputWithUpdateTimestamp
+ protected
+
+ # @since x.x.x
+ # @api private
+ def _touch(value, now)
+ super
+ value[:created_at] = now
+ value
+ end
+ end
+
+ # Class interface
+ #
+ # @since x.x.x
+ # @api private
+ module ClassMethods
+ # Build an input processor according to the current command (create or update).
+ #
+ # @since x.x.x
+ # @api private
+ def build(relation, options = {})
+ plugin = if self < ROM::Commands::Create
+ InputWithCreateTimestamp
+ else
+ InputWithUpdateTimestamp
+ end
+
+ input(plugin.new(relation, input))
+ super(relation, options.merge(input: input))
+ end
+ end
+
+ # @since x.x.x
+ # @api private
+ def self.included(klass)
+ super
+
+ klass.extend ClassMethods
+ end
+ end
+ end
+ end
+end
diff --git a/lib/hanami/model/relation_name.rb b/lib/hanami/model/relation_name.rb
new file mode 100644
index 00000000..fc5a19fe
--- /dev/null
+++ b/lib/hanami/model/relation_name.rb
@@ -0,0 +1,24 @@
+require_relative 'entity_name'
+require 'hanami/utils/string'
+
+module Hanami
+ module Model
+ # Conventional name for relations.
+ #
+ # Given a repository named SourceFileRepository, the associated
+ # relation will be :source_files.
+ #
+ # @since x.x.x
+ # @api private
+ class RelationName < EntityName
+ # @param name [Class,String] the class or its name
+ # @return [Symbol] the relation name
+ #
+ # @since x.x.x
+ # @api private
+ def self.new(name)
+ Utils::String.new(super).underscore.pluralize.to_sym
+ end
+ end
+ end
+end
diff --git a/lib/hanami/model/sql.rb b/lib/hanami/model/sql.rb
new file mode 100644
index 00000000..906f6db8
--- /dev/null
+++ b/lib/hanami/model/sql.rb
@@ -0,0 +1,161 @@
+require 'rom-sql'
+require 'hanami/utils'
+
+module Hanami
+ # Hanami::Model migrations
+ module Model
+ require 'hanami/model/error'
+ require 'hanami/model/association'
+ require 'hanami/model/migration'
+
+ # Define a migration
+ #
+ # It must define an up/down strategy to write schema changes (up) and to
+ # rollback them (down).
+ #
+ # We can use up and down blocks for custom strategies, or
+ # only one change block that automatically implements "down" strategy.
+ #
+ # @param blk [Proc] a block that defines up/down or change database migration
+ #
+ # @since 0.4.0
+ #
+ # @example Use up/down blocks
+ # Hanami::Model.migration do
+ # up do
+ # create_table :books do
+ # primary_key :id
+ # column :book, String
+ # end
+ # end
+ #
+ # down do
+ # drop_table :books
+ # end
+ # end
+ #
+ # @example Use change block
+ # Hanami::Model.migration do
+ # change do
+ # create_table :books do
+ # primary_key :id
+ # column :book, String
+ # end
+ # end
+ #
+ # # DOWN strategy is automatically generated
+ # end
+ def self.migration(&blk)
+ Migration.new(configuration.gateways[:default], &blk)
+ end
+
+ # SQL adapter
+ #
+ # @since x.x.x
+ module Sql
+ require 'hanami/model/sql/types'
+ require 'hanami/model/sql/entity/schema'
+
+ # Returns a SQL fragment that references a database function by the given name
+ # This is useful for database migrations
+ #
+ # @param name [String,Symbol] the function name
+ # @return [String] the SQL fragment
+ #
+ # @since x.x.x
+ #
+ # @example
+ # Hanami::Model.migration do
+ # up do
+ # execute 'CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'
+ #
+ # create_table :source_files do
+ # column :id, 'uuid', primary_key: true, default: Hanami::Model::Sql.function(:uuid_generate_v4)
+ # # ...
+ # end
+ # end
+ #
+ # down do
+ # drop_table :source_files
+ # execute 'DROP EXTENSION "uuid-ossp"'
+ # end
+ # end
+ def self.function(name)
+ Sequel.function(name)
+ end
+
+ # Returns a literal SQL fragment for the given SQL fragment.
+ # This is useful for database migrations
+ #
+ # @param string [String] the SQL fragment
+ # @return [String] the literal SQL fragment
+ #
+ # @since x.x.x
+ #
+ # @example
+ # Hanami::Model.migration do
+ # up do
+ # execute %{
+ # CREATE TYPE inventory_item AS (
+ # name text,
+ # supplier_id integer,
+ # price numeric
+ # );
+ # }
+ #
+ # create_table :items do
+ # column :item, 'inventory_item', default: Hanami::Model::Sql.literal("ROW('fuzzy dice', 42, 1.99)")
+ # # ...
+ # end
+ # end
+ #
+ # down do
+ # drop_table :itmes
+ # execute 'DROP TYPE inventory_item'
+ # end
+ # end
+ def self.literal(string)
+ Sequel.lit(string)
+ end
+
+ # Returns SQL fragment for ascending order for the given column
+ #
+ # @param column [Symbol] the column name
+ # @return [String] the SQL fragment
+ #
+ # @since x.x.x
+ def self.asc(column)
+ Sequel.asc(column)
+ end
+
+ # Returns SQL fragment for descending order for the given column
+ #
+ # @param column [Symbol] the column name
+ # @return [String] the SQL fragment
+ #
+ # @since x.x.x
+ def self.desc(column)
+ Sequel.desc(column)
+ end
+ end
+
+ Error.register(ROM::SQL::DatabaseError, DatabaseError)
+ Error.register(ROM::SQL::ConstraintError, ConstraintViolationError)
+ Error.register(ROM::SQL::NotNullConstraintError, NotNullConstraintViolationError)
+ Error.register(ROM::SQL::UniqueConstraintError, UniqueConstraintViolationError)
+ Error.register(ROM::SQL::CheckConstraintError, CheckConstraintViolationError)
+ Error.register(ROM::SQL::ForeignKeyConstraintError, ForeignKeyConstraintViolationError)
+
+ Error.register(Java::JavaSql::SQLException, DatabaseError) if Utils.jruby?
+ end
+end
+
+Sequel.default_timezone = :utc
+
+ROM.plugins do
+ adapter :sql do
+ register :mapping, Hanami::Model::Plugins::Mapping, type: :command
+ register :schema, Hanami::Model::Plugins::Schema, type: :command
+ register :timestamps, Hanami::Model::Plugins::Timestamps, type: :command
+ end
+end
diff --git a/lib/hanami/model/sql/console.rb b/lib/hanami/model/sql/console.rb
new file mode 100644
index 00000000..8dfd49bf
--- /dev/null
+++ b/lib/hanami/model/sql/console.rb
@@ -0,0 +1,41 @@
+require 'uri'
+
+module Hanami
+ module Model
+ module Sql
+ # SQL console
+ #
+ # @since x.x.x
+ # @api private
+ class Console
+ extend Forwardable
+
+ def_delegator :console, :connection_string
+
+ # @since x.x.x
+ # @api private
+ def initialize(uri)
+ @uri = URI.parse(uri)
+ end
+
+ private
+
+ # @since x.x.x
+ # @api private
+ def console # rubocop:disable Metrics/MethodLength
+ case @uri.scheme
+ when 'sqlite'
+ require 'hanami/model/sql/consoles/sqlite'
+ Sql::Consoles::Sqlite.new(@uri)
+ when 'postgres'
+ require 'hanami/model/sql/consoles/postgresql'
+ Sql::Consoles::Postgresql.new(@uri)
+ when 'mysql', 'mysql2'
+ require 'hanami/model/sql/consoles/mysql'
+ Sql::Consoles::Mysql.new(@uri)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/hanami/model/sql/consoles/abstract.rb b/lib/hanami/model/sql/consoles/abstract.rb
new file mode 100644
index 00000000..fd0e83d2
--- /dev/null
+++ b/lib/hanami/model/sql/consoles/abstract.rb
@@ -0,0 +1,33 @@
+module Hanami
+ module Model
+ module Sql
+ module Consoles
+ # Abstract adapter
+ #
+ # @since x.x.x
+ # @api private
+ class Abstract
+ # @since x.x.x
+ # @api private
+ def initialize(uri)
+ @uri = uri
+ end
+
+ private
+
+ # @since x.x.x
+ # @api private
+ def database_name
+ @uri.path.sub(/^\//, '')
+ end
+
+ # @since x.x.x
+ # @api private
+ def concat(*tokens)
+ tokens.join
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/hanami/model/sql/consoles/mysql.rb b/lib/hanami/model/sql/consoles/mysql.rb
new file mode 100644
index 00000000..bfc90ae2
--- /dev/null
+++ b/lib/hanami/model/sql/consoles/mysql.rb
@@ -0,0 +1,63 @@
+require_relative 'abstract'
+
+module Hanami
+ module Model
+ module Sql
+ module Consoles
+ # MySQL adapter
+ #
+ # @since x.x.x
+ # @api private
+ class Mysql < Abstract
+ # @since x.x.x
+ # @api private
+ COMMAND = 'mysql'.freeze
+
+ # @since x.x.x
+ # @api private
+ def connection_string
+ concat(command, host, database, port, username, password)
+ end
+
+ private
+
+ # @since x.x.x
+ # @api private
+ def command
+ COMMAND
+ end
+
+ # @since x.x.x
+ # @api private
+ def host
+ " -h #{@uri.host}"
+ end
+
+ # @since x.x.x
+ # @api private
+ def database
+ " -D #{database_name}"
+ end
+
+ # @since x.x.x
+ # @api private
+ def port
+ " -P #{@uri.port}" unless @uri.port.nil?
+ end
+
+ # @since x.x.x
+ # @api private
+ def username
+ " -u #{@uri.user}" unless @uri.user.nil?
+ end
+
+ # @since x.x.x
+ # @api private
+ def password
+ " -p #{@uri.password}" unless @uri.password.nil?
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/hanami/model/sql/consoles/postgresql.rb b/lib/hanami/model/sql/consoles/postgresql.rb
new file mode 100644
index 00000000..72d079ef
--- /dev/null
+++ b/lib/hanami/model/sql/consoles/postgresql.rb
@@ -0,0 +1,68 @@
+require_relative 'abstract'
+
+module Hanami
+ module Model
+ module Sql
+ module Consoles
+ # PostgreSQL adapter
+ #
+ # @since x.x.x
+ # @api private
+ class Postgresql < Abstract
+ # @since x.x.x
+ # @api private
+ COMMAND = 'psql'.freeze
+
+ # @since x.x.x
+ # @api private
+ PASSWORD = 'PGPASSWORD'.freeze
+
+ # @since x.x.x
+ # @api private
+ def connection_string
+ configure_password
+ concat(command, host, database, port, username)
+ end
+
+ private
+
+ # @since x.x.x
+ # @api private
+ def command
+ COMMAND
+ end
+
+ # @since x.x.x
+ # @api private
+ def host
+ " -h #{@uri.host}"
+ end
+
+ # @since x.x.x
+ # @api private
+ def database
+ " -d #{database_name}"
+ end
+
+ # @since x.x.x
+ # @api private
+ def port
+ " -p #{@uri.port}" unless @uri.port.nil?
+ end
+
+ # @since x.x.x
+ # @api private
+ def username
+ " -U #{@uri.user}" unless @uri.user.nil?
+ end
+
+ # @since x.x.x
+ # @api private
+ def configure_password
+ ENV[PASSWORD] = @uri.password unless @uri.password.nil?
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/hanami/model/sql/consoles/sqlite.rb b/lib/hanami/model/sql/consoles/sqlite.rb
new file mode 100644
index 00000000..1a9fae37
--- /dev/null
+++ b/lib/hanami/model/sql/consoles/sqlite.rb
@@ -0,0 +1,46 @@
+require_relative 'abstract'
+require 'shellwords'
+
+module Hanami
+ module Model
+ module Sql
+ module Consoles
+ # SQLite adapter
+ #
+ # @since x.x.x
+ # @api private
+ class Sqlite < Abstract
+ # @since x.x.x
+ # @api private
+ COMMAND = 'sqlite3'.freeze
+
+ # @since x.x.x
+ # @api private
+ def connection_string
+ concat(command, ' ', host, database)
+ end
+
+ private
+
+ # @since x.x.x
+ # @api private
+ def command
+ COMMAND
+ end
+
+ # @since x.x.x
+ # @api private
+ def host
+ @uri.host unless @uri.host.nil?
+ end
+
+ # @since x.x.x
+ # @api private
+ def database
+ Shellwords.escape(@uri.path)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/hanami/model/sql/entity/schema.rb b/lib/hanami/model/sql/entity/schema.rb
new file mode 100644
index 00000000..071000a0
--- /dev/null
+++ b/lib/hanami/model/sql/entity/schema.rb
@@ -0,0 +1,125 @@
+require 'hanami/entity/schema'
+require 'hanami/model/types'
+require 'hanami/model/association'
+
+module Hanami
+ module Model
+ module Sql
+ module Entity
+ # SQL Entity schema
+ #
+ # This schema setup is automatic.
+ #
+ # Hanami looks at the database columns, associations and potentially to
+ # the mapping in the repository (optional, only for legacy databases).
+ #
+ # @since x.x.x
+ # @api private
+ #
+ # @see Hanami::Entity::Schema
+ class Schema < Hanami::Entity::Schema
+ # Build a new instance of Schema according to database columns,
+ # associations and potentially to mapping defined by the repository.
+ #
+ # @param registry [Hash] a registry that keeps reference between
+ # entities klass and their underscored names
+ # @param relation [ROM::Relation] the database relation
+ # @param mapping [Hanami::Model::Mapping] the optional repository
+ # mapping
+ #
+ # @return [Hanami::Model::Sql::Entity::Schema] the schema
+ #
+ # @since x.x.x
+ # @api private
+ def initialize(registry, relation, mapping)
+ attributes = build(registry, relation, mapping)
+ @schema = Types::Coercible::Hash.schema(attributes)
+ @attributes = Hash[attributes.map { |k, _| [k, true] }]
+ freeze
+ end
+
+ # Check if the attribute is known
+ #
+ # @param name [Symbol] the attribute name
+ #
+ # @return [TrueClass,FalseClass] the result of the check
+ #
+ # @since x.x.x
+ # @api private
+ def attribute?(name)
+ attributes.key?(name)
+ end
+
+ private
+
+ # @since x.x.x
+ # @api private
+ attr_reader :attributes
+
+ # Build the schema
+ #
+ # @param registry [Hash] a registry that keeps reference between
+ # entities klass and their underscored names
+ # @param relation [ROM::Relation] the database relation
+ # @param mapping [Hanami::Model::Mapping] the optional repository
+ # mapping
+ #
+ # @return [Dry::Types::Constructor] the inner schema
+ #
+ # @since x.x.x
+ # @api private
+ def build(registry, relation, mapping)
+ build_attributes(relation, mapping).merge(
+ build_associations(registry, relation.associations)
+ )
+ end
+
+ # Extract a set of attributes from the database table or from the
+ # optional repository mapping.
+ #
+ # @param relation [ROM::Relation] the database relation
+ # @param mapping [Hanami::Model::Mapping] the optional repository
+ # mapping
+ #
+ # @return [Hash] a set of attributes
+ #
+ # @since x.x.x
+ # @api private
+ def build_attributes(relation, mapping)
+ schema = relation.schema.to_h
+ schema.each_with_object({}) do |(attribute, type), result|
+ attribute = mapping.translate(attribute) if mapping.reverse?
+ result[attribute] = coercible(type)
+ end
+ end
+
+ # Merge attributes and associations
+ #
+ # @param registry [Hash] a registry that keeps reference between
+ # entities klass and their underscored names
+ # @param associations [ROM::AssociationSet] a set of associations for
+ # the current relation
+ #
+ # @return [Hash] attributes with associations
+ #
+ # @since x.x.x
+ # @api private
+ def build_associations(registry, associations)
+ associations.each_with_object({}) do |(name, association), result|
+ target = registry.fetch(name)
+ result[name] = Association.lookup(association).schema_type(target)
+ end
+ end
+
+ # Converts given ROM type into coercible type for entity attribute
+ #
+ # @since x.x.x
+ # @api private
+ def coercible(type)
+ Types::Schema.coercible(type)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/hanami/model/sql/types.rb b/lib/hanami/model/sql/types.rb
new file mode 100644
index 00000000..35654f45
--- /dev/null
+++ b/lib/hanami/model/sql/types.rb
@@ -0,0 +1,96 @@
+require 'hanami/model/types'
+require 'rom/types'
+
+module Hanami
+ module Model
+ module Sql
+ # Types definitions for SQL databases
+ #
+ # @since x.x.x
+ module Types
+ # include ROM::SQL::Types
+ include Dry::Types.module
+
+ # Types for schema definitions
+ #
+ # @since x.x.x
+ module Schema
+ require 'hanami/model/sql/types/schema/coercions'
+
+ String = Types::Optional::Coercible::String
+
+ Int = Types::Strict::Nil | Types::Int.constructor(Coercions.method(:int))
+ Float = Types::Strict::Nil | Types::Float.constructor(Coercions.method(:float))
+ Decimal = Types::Strict::Nil | Types::Float.constructor(Coercions.method(:decimal))
+
+ Bool = Types::Strict::Nil | Types::Strict::Bool
+
+ Date = Types::Strict::Nil | Types::Date.constructor(Coercions.method(:date))
+ DateTime = Types::Strict::Nil | Types::DateTime.constructor(Coercions.method(:datetime))
+ Time = Types::Strict::Nil | Types::Time.constructor(Coercions.method(:time))
+
+ Array = Types::Strict::Nil | Types::Array.constructor(Coercions.method(:array))
+ Hash = Types::Strict::Nil | Types::Array.constructor(Coercions.method(:hash))
+
+ # @since x.x.x
+ # @api private
+ MAPPING = {
+ Types::String.with(meta: {}) => Schema::String,
+ Types::Int.with(meta: {}) => Schema::Int,
+ Types::Float.with(meta: {}) => Schema::Float,
+ Types::Decimal.with(meta: {}) => Schema::Decimal,
+ Types::Bool.with(meta: {}) => Schema::Bool,
+ Types::Date.with(meta: {}) => Schema::Date,
+ Types::DateTime.with(meta: {}) => Schema::DateTime,
+ Types::Time.with(meta: {}) => Schema::Time,
+ Types::Array.with(meta: {}) => Schema::Array,
+ Types::Hash.with(meta: {}) => Schema::Hash,
+ Types::String.optional.with(meta: {}) => Schema::String,
+ Types::Int.optional.with(meta: {}) => Schema::Int,
+ Types::Float.optional.with(meta: {}) => Schema::Float,
+ Types::Decimal.optional.with(meta: {}) => Schema::Decimal,
+ Types::Bool.optional.with(meta: {}) => Schema::Bool,
+ Types::Date.optional.with(meta: {}) => Schema::Date,
+ Types::DateTime.optional.with(meta: {}) => Schema::DateTime,
+ Types::Time.optional.with(meta: {}) => Schema::Time,
+ Types::Array.optional.with(meta: {}) => Schema::Array,
+ Types::Hash.optional.with(meta: {}) => Schema::Hash
+ }.freeze
+
+ # Convert given type into coercible
+ #
+ # @since x.x.x
+ # @api private
+ def self.coercible(type)
+ return type if type.constrained?
+ MAPPING.fetch(type.with(meta: {}), type)
+ end
+
+ # Coercer for SQL associations target
+ #
+ # @since x.x.x
+ # @api private
+ class AssociationType < Hanami::Model::Types::Schema::CoercibleType
+ # Check if value can be coerced
+ #
+ # @param value [Object] the value
+ #
+ # @return [TrueClass,FalseClass] the result of the check
+ #
+ # @since x.x.x
+ # @api private
+ def valid?(value)
+ value.inspect =~ /\[#{primitive}\]/ || super
+ end
+
+ # @since x.x.x
+ # @api private
+ def success(*args)
+ result(Dry::Types::Result::Success, primitive.new(args.first.to_h))
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/hanami/model/sql/types/schema/coercions.rb b/lib/hanami/model/sql/types/schema/coercions.rb
new file mode 100644
index 00000000..41c6d6f8
--- /dev/null
+++ b/lib/hanami/model/sql/types/schema/coercions.rb
@@ -0,0 +1,198 @@
+require 'hanami/utils/string'
+
+module Hanami
+ module Model
+ module Sql
+ module Types
+ module Schema
+ # Coercions for schema types
+ #
+ # @since x.x.x
+ # @api private
+ #
+ # rubocop:disable Metrics/MethodLength
+ module Coercions
+ # Coerces given argument into Integer
+ #
+ # @param arg [#to_i,#to_int] the argument to coerce
+ #
+ # @return [Integer] the result of the coercion
+ #
+ # @raise [ArgumentError] if the coercion fails
+ #
+ # @since x.x.x
+ # @api private
+ def self.int(arg)
+ case arg
+ when ::Integer
+ arg
+ when ::Float, ::BigDecimal, ::String, ::Hanami::Utils::String, ->(a) { a.respond_to?(:to_int) }
+ ::Kernel.Integer(arg)
+ else
+ raise ArgumentError.new("invalid value for Integer(): #{arg.inspect}")
+ end
+ end
+
+ # Coerces given argument into Float
+ #
+ # @param arg [#to_f] the argument to coerce
+ #
+ # @return [Float] the result of the coercion
+ #
+ # @raise [ArgumentError] if the coercion fails
+ #
+ # @since x.x.x
+ # @api private
+ def self.float(arg)
+ case arg
+ when ::Float
+ arg
+ when ::Integer, ::BigDecimal, ::String, ::Hanami::Utils::String, ->(a) { a.respond_to?(:to_f) && !a.is_a?(::Time) }
+ ::Kernel.Float(arg)
+ else
+ raise ArgumentError.new("invalid value for Float(): #{arg.inspect}")
+ end
+ end
+
+ # Coerces given argument into BigDecimal
+ #
+ # @param arg [#to_d] the argument to coerce
+ #
+ # @return [BigDecimal] the result of the coercion
+ #
+ # @raise [ArgumentError] if the coercion fails
+ #
+ # @since x.x.x
+ # @api private
+ def self.decimal(arg)
+ case arg
+ when ::BigDecimal
+ arg
+ when ::Integer, ::Float, ::String, ::Hanami::Utils::String
+ ::BigDecimal.new(arg, ::Float::DIG)
+ when ->(a) { a.respond_to?(:to_d) }
+ arg.to_d
+ else
+ raise ArgumentError.new("invalid value for BigDecimal(): #{arg.inspect}")
+ end
+ end
+
+ # Coerces given argument into Date
+ #
+ # @param arg [#to_date,String] the argument to coerce
+ #
+ # @return [Date] the result of the coercion
+ #
+ # @raise [ArgumentError] if the coercion fails
+ #
+ # @since x.x.x
+ # @api private
+ def self.date(arg)
+ case arg
+ when ::Date
+ arg
+ when ::String, ::Hanami::Utils::String
+ ::Date.parse(arg)
+ when ::Time, ::DateTime, ->(a) { a.respond_to?(:to_date) }
+ arg.to_date
+ else
+ raise ArgumentError.new("invalid value for Date(): #{arg.inspect}")
+ end
+ end
+
+ # Coerces given argument into DateTime
+ #
+ # @param arg [#to_datetime,String] the argument to coerce
+ #
+ # @return [DateTime] the result of the coercion
+ #
+ # @raise [ArgumentError] if the coercion fails
+ #
+ # @since x.x.x
+ # @api private
+ def self.datetime(arg)
+ case arg
+ when ::DateTime
+ arg
+ when ::String, ::Hanami::Utils::String
+ ::DateTime.parse(arg)
+ when ::Date, ::Time, ->(a) { a.respond_to?(:to_datetime) }
+ arg.to_datetime
+ else
+ raise ArgumentError.new("invalid value for DateTime(): #{arg.inspect}")
+ end
+ end
+
+ # Coerces given argument into Time
+ #
+ # @param arg [#to_time,String] the argument to coerce
+ #
+ # @return [Time] the result of the coercion
+ #
+ # @raise [ArgumentError] if the coercion fails
+ #
+ # @since x.x.x
+ # @api private
+ def self.time(arg)
+ case arg
+ when ::Time
+ arg
+ when ::String, ::Hanami::Utils::String
+ ::Time.parse(arg)
+ when ::Date, ::DateTime, ->(a) { a.respond_to?(:to_time) }
+ arg.to_time
+ when ::Integer
+ ::Time.at(arg)
+ else
+ raise ArgumentError.new("invalid value for Time(): #{arg.inspect}")
+ end
+ end
+
+ # Coerces given argument into Array
+ #
+ # @param arg [#to_ary] the argument to coerce
+ #
+ # @return [Array] the result of the coercion
+ #
+ # @raise [ArgumentError] if the coercion fails
+ #
+ # @since x.x.x
+ # @api private
+ def self.array(arg)
+ case arg
+ when ::Array
+ arg
+ when ->(a) { a.respond_to?(:to_ary) }
+ ::Kernel.Array(arg)
+ else
+ raise ArgumentError.new("invalid value for Array(): #{arg.inspect}")
+ end
+ end
+
+ # Coerces given argument into Hash
+ #
+ # @param arg [#to_hash] the argument to coerce
+ #
+ # @return [Hash] the result of the coercion
+ #
+ # @raise [ArgumentError] if the coercion fails
+ #
+ # @since x.x.x
+ # @api private
+ def self.hash(arg)
+ case arg
+ when ::Hash
+ arg
+ when ->(a) { a.respond_to?(:to_hash) }
+ ::Kernel.Hash(arg)
+ else
+ raise ArgumentError.new("invalid value for Hash(): #{arg.inspect}")
+ end
+ end
+ end
+ # rubocop:enable Metrics/MethodLength
+ end
+ end
+ end
+ end
+end
diff --git a/lib/hanami/model/types.rb b/lib/hanami/model/types.rb
new file mode 100644
index 00000000..bff944ff
--- /dev/null
+++ b/lib/hanami/model/types.rb
@@ -0,0 +1,99 @@
+require 'rom/types'
+
+module Hanami
+ module Model
+ # Types definitions
+ #
+ # @since x.x.x
+ module Types
+ include ROM::Types
+
+ # @since x.x.x
+ # @api private
+ def self.included(mod)
+ mod.extend(ClassMethods)
+ end
+
+ # Class level interface
+ #
+ # @since x.x.x
+ module ClassMethods
+ # Define an array of given type
+ #
+ # @since x.x.x
+ def Collection(type) # rubocop:disable Style/MethodName
+ type = Schema::CoercibleType.new(type) unless type.is_a?(Dry::Types::Definition)
+ Types::Array.member(type)
+ end
+ end
+
+ # Types for schema definitions
+ #
+ # @since x.x.x
+ module Schema
+ # Coercer for objects within custom schema definition
+ #
+ # @since x.x.x
+ # @api private
+ class CoercibleType < Dry::Types::Definition
+ # Coerce given value into the wrapped object type
+ #
+ # @param value [Object] the value
+ #
+ # @return [Object] the coerced value of `object` type
+ #
+ # @raise [TypeError] if value can't be coerced
+ #
+ # @since x.x.x
+ # @api private
+ def call(value)
+ if valid?(value)
+ coerce(value)
+ else
+ raise TypeError.new("#{value.inspect} must be coercible into #{object}")
+ end
+ end
+
+ # Check if value can be coerced
+ #
+ # It is true if value is an instance of `object` type or if value
+ # respond to `#to_hash`.
+ #
+ # @param value [Object] the value
+ #
+ # @return [TrueClass,FalseClass] the result of the check
+ #
+ # @since x.x.x
+ # @api private
+ def valid?(value)
+ value.is_a?(object) ||
+ value.respond_to?(:to_hash)
+ end
+
+ # Coerce given value into an instance of `object` type
+ #
+ # @param value [Object] the value
+ #
+ # @return [Object] the coerced value of `object` type
+ def coerce(value)
+ case value
+ when object
+ value
+ else
+ object.new(value.to_hash)
+ end
+ end
+
+ # @since x.x.x
+ # @api private
+ def object
+ result = primitive
+ return result unless result.respond_to?(:primitive)
+
+ result.primitive
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/hanami/repository.rb b/lib/hanami/repository.rb
index d9e4024b..1ab9b085 100644
--- a/lib/hanami/repository.rb
+++ b/lib/hanami/repository.rb
@@ -1,5 +1,10 @@
+require 'rom-repository'
+require 'hanami/model/entity_name'
+require 'hanami/model/relation_name'
+require 'hanami/model/associations/dsl'
+require 'hanami/model/association'
+require 'hanami/utils/class'
require 'hanami/utils/class_attribute'
-require 'hanami/model/adapters/null_adapter'
module Hanami
# Mediates between the entities and the persistence layer, by offering an API
@@ -18,25 +23,11 @@ module Hanami
# end
#
# # valid
- # class ArticleRepository
- # include Hanami::Repository
+ # class ArticleRepository < Hanami::Repository
# end
#
# # not valid for Article
- # class PostRepository
- # include Hanami::Repository
- # end
- #
- # Repository for an entity can be configured by setting # the `#repository`
- # on the mapper.
- #
- # @example
- # # PostRepository is repository for Article
- # mapper = Hanami::Model::Mapper.new do
- # collection :articles do
- # entity Article
- # repository PostRepository
- # end
+ # class PostRepository < Hanami::Repository
# end
#
# A repository is storage independent.
@@ -54,10 +45,9 @@ module Hanami
#
# * Isolates the persistence logic at a low level
#
- # Hanami::Model is shipped with two adapters:
+ # Hanami::Model is shipped with one adapter:
#
# * SqlAdapter
- # * MemoryAdapter
#
#
#
@@ -82,7 +72,7 @@ module Hanami
# # * If we change the storage, we are forced to change the code of the
# # caller(s).
#
- # ArticleRepository.where(author_id: 23).order(:published_at).limit(8)
+ # ArticleRepository.new.where(author_id: 23).order(:published_at).limit(8)
#
#
#
@@ -100,16 +90,14 @@ module Hanami
# #
# # * If we change the storage, the callers aren't affected.
#
- # ArticleRepository.most_recent_by_author(author)
+ # ArticleRepository.new.most_recent_by_author(author)
#
- # class ArticleRepository
- # include Hanami::Repository
- #
- # def self.most_recent_by_author(author, limit = 8)
- # query do
+ # class ArticleRepository < Hanami::Repository
+ # def most_recent_by_author(author, limit = 8)
+ # articles.
# where(author_id: author.id).
- # order(:published_at)
- # end.limit(limit)
+ # order(:published_at).
+ # limit(limit)
# end
# end
#
@@ -118,769 +106,331 @@ module Hanami
# @see Hanami::Entity
# @see http://martinfowler.com/eaaCatalog/repository.html
# @see http://en.wikipedia.org/wiki/Dependency_inversion_principle
- module Repository
- # Inject the public API into the hosting class.
+ class Repository < ROM::Repository::Root
+ # Mapper name.
#
- # @since 0.1.0
+ # With ROM mapping there is a link between the entity class and a generic
+ # reference for it. Example: BookRepository references Book
+ # as :entity.
#
- # @example
- # require 'hanami/model'
+ # @since x.x.x
+ # @api private
#
- # class UserRepository
- # include Hanami::Repository
- # end
- def self.included(base)
-
- base.class_eval do
- include InstanceMethods
- extend ClassMethods
- include Hanami::Utils::ClassAttribute
- class_attribute :collection
- self.adapter = Hanami::Model::Adapters::NullAdapter.new
- end
- end
-
- module ClassMethods
- # Assigns an adapter.
- #
- # Hanami::Model is shipped with two adapters:
- #
- # * SqlAdapter
- # * MemoryAdapter
- #
- # @param adapter [Object] an object that implements
- # `Hanami::Model::Adapters::Abstract` interface
- #
- # @since 0.1.0
- #
- # @see Hanami::Model::Adapters::SqlAdapter
- # @see Hanami::Model::Adapters::MemoryAdapter
- #
- # @example Memory adapter
- # require 'hanami/model'
- # require 'hanami/model/adapters/memory_adapter'
- #
- # mapper = Hanami::Model::Mapper.new do
- # # ...
- # end
- #
- # adapter = Hanami::Model::Adapters::MemoryAdapter.new(mapper)
- #
- # class UserRepository
- # include Hanami::Repository
- # end
- #
- # UserRepository.adapter = adapter
- #
- #
- #
- # @example SQL adapter with a Sqlite database
- # require 'sqlite3'
- # require 'hanami/model'
- # require 'hanami/model/adapters/sql_adapter'
- #
- # mapper = Hanami::Model::Mapper.new do
- # # ...
- # end
- #
- # adapter = Hanami::Model::Adapters::SqlAdapter.new(mapper, 'sqlite://path/to/database.db')
- #
- # class UserRepository
- # include Hanami::Repository
- # end
- #
- # UserRepository.adapter = adapter
- #
- #
- #
- # @example SQL adapter with a Postgres database
- # require 'pg'
- # require 'hanami/model'
- # require 'hanami/model/adapters/sql_adapter'
- #
- # mapper = Hanami::Model::Mapper.new do
- # # ...
- # end
- #
- # adapter = Hanami::Model::Adapters::SqlAdapter.new(mapper, 'postgres://host:port/database')
- #
- # class UserRepository
- # include Hanami::Repository
- # end
- #
- # UserRepository.adapter = adapter
- # @since 0.5.0
- # @api private
- def adapter=(adapter)
- @adapter = adapter
- end
+ # @see Hanami::Repository.inherited
+ # @see Hanami::Repository.define_mapping
+ MAPPER_NAME = :entity
- def adapter
- @adapter
- end
+ # Plugins for database commands
+ #
+ # @since x.x.x
+ # @api private
+ #
+ # @see Hanami::Model::Plugins
+ COMMAND_PLUGINS = [:mapping, :timestamps, :schema].freeze
+ # Configuration
+ #
+ # @since x.x.x
+ # @api private
+ def self.configuration
+ Hanami::Model.configuration
end
- module InstanceMethods
- # Get the currently configured adapter
- def adapter
- self.class.adapter
- end
-
- # Get the currently configured collection name
- def collection
- self.class.collection
- end
+ # Container
+ #
+ # @since x.x.x
+ # @api private
+ def self.container
+ Hanami::Model.container
+ end
- # Creates or updates a record in the database for the given entity.
- #
- # @param entity [#id, #id=] the entity to persist
- #
- # @return [Object] a copy of the entity with `id` assigned
- #
- # @since 0.1.0
- #
- # @see Hanami::Repository#create
- # @see Hanami::Repository#update
- #
- # @example With a non persisted entity
- # require 'hanami/model'
- #
- # class ArticleRepository
- # include Hanami::Repository
- # end
- #
- # article = Article.new(title: 'Introducing Hanami::Model')
- # article.id # => nil
- #
- # persisted_article = ArticleRepository.new.persist(article) # creates a record
- # article.id # => nil
- # persisted_article.id # => 23
- #
- # @example With a persisted entity
- # require 'hanami/model'
- #
- # class ArticleRepository
- # include Hanami::Repository
- # end
- #
- # article = ArticleRepository.new.find(23)
- # article.id # => 23
- #
- # article.title = 'Launching Hanami::Model'
- # ArticleRepository.new.persist(article) # updates the record
- #
- # article = ArticleRepository.new.find(23)
- # article.title # => "Launching Hanami::Model"
- def persist(entity)
- _touch(entity)
- adapter.persist(collection, entity)
- end
+ # Define a database relation, which describes how data is fetched from the
+ # database.
+ #
+ # It auto-infers the underlying database table.
+ #
+ # @since x.x.x
+ # @api private
+ def self.define_relation # rubocop:disable Metrics/MethodLength
+ a = @associations
- # Creates a record in the database for the given entity.
- # It returns a copy of the entity with `id` assigned.
- #
- # If already persisted (`id` present) it does nothing.
- #
- # @param entity [#id,#id=] the entity to create
- #
- # @return [Object] a copy of the entity with `id` assigned
- #
- # @since 0.1.0
- #
- # @see Hanami::Repository#persist
- #
- # @example
- # require 'hanami/model'
- #
- # class ArticleRepository
- # include Hanami::Repository
- # end
- #
- # article = Article.new(title: 'Introducing Hanami::Model')
- # article.id # => nil
- #
- # created_article = ArticleRepository.new.create(article) # creates a record
- # article.id # => nil
- # created_article.id # => 23
- #
- # created_article = ArticleRepository.new.create(article)
- # created_article.id # => 24
- #
- # created_article = ArticleRepository.new.create(existing_article) # => no-op
- # created_article # => nil
- #
- def create(entity)
- unless _persisted?(entity)
- _touch(entity)
- adapter.create(collection, entity)
+ configuration.relation(relation) do
+ schema(infer: true) do
+ associations(&a) unless a.nil?
end
- end
- # Updates a record in the database corresponding to the given entity.
- #
- # If not already persisted (`id` present) it raises an exception.
- #
- # @param entity [#id] the entity to update
- #
- # @return [Object] the entity
- #
- # @raise [Hanami::Model::NonPersistedEntityError] if the given entity
- # wasn't already persisted.
- #
- # @since 0.1.0
- #
- # @see Hanami::Repository#persist
- # @see Hanami::Model::NonPersistedEntityError
- #
- # @example With a persisted entity
- # require 'hanami/model'
- #
- # class ArticleRepository
- # include Hanami::Repository
- # end
- #
- # article = ArticleRepository.new.find(23)
- # article.id # => 23
- # article.title = 'Launching Hanami::Model'
- #
- # ArticleRepository.new.update(article) # updates the record
- #
- #
- #
- # @example With a non persisted entity
- # require 'hanami/model'
- #
- # class ArticleRepository
- # include Hanami::Repository
- # end
- #
- # article = Article.new(title: 'Introducing Hanami::Model')
- # article.id # => nil
- #
- # ArticleRepository.new.update(article) # raises Hanami::Model::NonPersistedEntityError
- def update(entity)
- if _persisted?(entity)
- _touch(entity)
- adapter.update(collection, entity)
- else
- raise Hanami::Model::NonPersistedEntityError
+ # rubocop:disable Lint/NestedMethodDefinition
+ def by_primary_key(id)
+ where(primary_key => id)
end
+ # rubocop:enable Lint/NestedMethodDefinition
end
- # Deletes a record in the database corresponding to the given entity.
- #
- # If not already persisted (`id` present) it raises an exception.
- #
- # @param entity [#id] the entity to delete
- #
- # @return [Object] the entity
- #
- # @raise [Hanami::Model::NonPersistedEntityError] if the given entity
- # wasn't already persisted.
- #
- # @since 0.1.0
- #
- # @see Hanami::Model::NonPersistedEntityError
- #
- # @example With a persisted entity
- # require 'hanami/model'
- #
- # class ArticleRepository
- # include Hanami::Repository
- # end
- #
- # article = ArticleRepository.new.find(23)
- # article.id # => 23
- #
- # ArticleRepository.new.delete(article) # deletes the record
- #
- #
- #
- # @example With a non persisted entity
- # require 'hanami/model'
- #
- # class ArticleRepository
- # include Hanami::Repository
- # end
- #
- # article = Article.new(title: 'Introducing Hanami::Model')
- # article.id # => nil
- #
- # ArticleRepository.new.delete(article) # raises Hanami::Model::NonPersistedEntityError
- def delete(entity)
- if _persisted?(entity)
- adapter.delete(collection, entity)
- else
- raise Hanami::Model::NonPersistedEntityError
- end
+ relations(relation)
+ root(relation)
+ end
- entity
- end
+ # Defines the ampping between a database table and an entity.
+ #
+ # It's also responsible to associate table columns to entity attributes.
+ #
+ # @since x.x.x
+ # @api private
+ #
+ # rubocop:disable Metrics/MethodLength
+ # rubocop:disable Metrics/AbcSize
+ def self.define_mapping
+ self.entity = Utils::Class.load!(entity_name)
+ e = entity
+ m = @mapping
- # Returns all the persisted entities.
- #
- # @return [Array